PageSourceSearch

https://www.pollsmax.com/assets/forecast-model.js?v=forecast-cache-gov-rebuild-20260620

js pollsmax.com collected 2026-09-28 06:39:14 UTC 165,084 bytes, 3,882 lines download raw bytes

1/* global ET */
2/**
3 * Pollsmax composite forecast. Senate/Governor use standardized fixed weights:
4 * Senate (8+ polls): polling 45%, generic 2.5%, Trump 2.5%, incumbency 7.5%,
5 * incumbent's last Senate win 20%, pres avg (2016/20/24) 7.5%, Cook/Pollsmax PVI 7.5%, rating 7.5%.
6 * Governor (8+ polls): polling 45%, generic 2.5%, Trump 2.5%, incumbency 12.5%,
7 * incumbent's last governor win 22.5%, pres avg 2.5%, Cook/Pollsmax PVI 2.5%, rating 10%.
8 * Poll weight drops 5 points for each poll below 8, with the remaining factors rescaled proportionally.
9 * Simulation certainty is fixed across Senate and Governor regardless of poll count.
10 * House @ 6+ polls: polling 30%; Cook 40%; PVI 10%; prior House 10%; incumbency 5%; generic/Trump 2.5% each.
11 * House forecast math uses the same composite + simulation pipeline as the other forecast pages.
12 */
13(function () {
14    if (!window.ET) window.ET = {};
15
16    var POLL_STEP = 0.05;
17    var POLL_FULL_COUNT = 8;
18
19    /** Senate @ 8+ polls (non-poll sum = 55%). */
20    var SENATE_POLL_BASE_AT_8 = 0.45;
21    var SENATE_BASE_NON_POLL = {
22        generic: 0.025,
23        trump: 0.025,
24        incumbency: 0.075,
25        priorSenate: 0.20,
26        presAvg: 0.075,
27        pvi: 0.075,
28        cook: 0.075
29    };
30
31    /** Governor @ 8+ polls (non-poll sum = 55%). */
32    var GOV_POLL_BASE_AT_8 = 0.45;
33    var GOV_BASE_NON_POLL = {
34        generic: 0.025,
35        trump: 0.025,
36        incumbency: 0.125,
37        priorGov: 0.225,
38        presAvg: 0.025,
39        pvi: 0.025,
40        cook: 0.10
41    };
42
43    /** House @ 6+ polls (non-poll sum = 70%). */
44    var HOUSE_POLL_FULL_COUNT = 6;
45    var HOUSE_POLL_BASE_AT_6 = 0.30;
46    var HOUSE_BASE_NON_POLL = {
47        generic: 0.025,
48        trump: 0.025,
49        incumbency: 0.05,
50        pvi: 0.10,
51        priorHouse: 0.10,
52        cook: 0.40
53    };
54    /** Bump when HOUSE_BASE_NON_POLL or GOP boost changes (invalidates cached House sim snapshots). */
55    ET.HOUSE_COOK_BLEND_REVISION = 22;
56
57    /** House-only: shift D-margin down (favors R). Applied after blend; tossups get extra. Disabled (0). */
58    ET.HOUSE_FORECAST_GOP_MARGIN_BOOST = 0;
59    ET.HOUSE_FORECAST_GOP_TOSSUP_EXTRA_BOOST = 0;
60
61    /** Incumbent score on D-margin scale (× weight = contribution). */
62    var INCUMBENCY_SCORE = 5;
63
64    /**
65     * Win odds steepen slightly away from 50%: signed |margin|^gamma, then logistic.
66     * gamma > 1 => modest acceleration (keep close to legacy linear @ 4.5).
67     */
68    var WIN_PROB_MARGIN_GAMMA = 1.1;
69    var WIN_PROB_LOGISTIC_SCALE = 4.15;
70
71    function forecastModelEffectiveMargin(margin) {
72        if (margin == null || !isFinite(margin) || margin === 0) return 0;
73        var sign = margin > 0 ? 1 : -1;
74        return sign * Math.pow(Math.abs(margin), WIN_PROB_MARGIN_GAMMA);
75    }
76
77    /**
78     * Input/editorial rating → signed D margin for forecast blend (cook component only).
79     * Moderate Solid / Super Solid — not display buckets or map colors.
80     */
81    var INPUT_RATING_TO_MARGIN = {
82        megaSolidD: 75,
83        ultraSolidD: 50,
84        supersolidD: 30,
85        solidD: 18,
86        likelyD: 8.5,
87        leanD: 4,
88        tiltD: 1.25,
89        tossup: 0,
90        tiltR: -1.25,
91        leanR: -4,
92        likelyR: -8.5,
93        solidR: -18,
94        supersolidR: -30,
95        ultraSolidR: -50,
96        megaSolidR: -75
97    };
98
99    var RATING_MARGIN = INPUT_RATING_TO_MARGIN;
100
101    ET.INPUT_RATING_TO_MARGIN = INPUT_RATING_TO_MARGIN;
102    ET.RATING_MARGIN = RATING_MARGIN;
103
104    ET.forecastModelRatingToMargin = function (rating) {
105        if (!rating) return 0;
106        if (RATING_MARGIN.hasOwnProperty(rating)) return RATING_MARGIN[rating];
107        return 0;
108    };
109
110    /** House-only cook-component tier margins (Senate/Gov use INPUT_RATING_TO_MARGIN). */
111    var HOUSE_RATING_TO_MARGIN = Object.assign({}, INPUT_RATING_TO_MARGIN, {
112        likelyD: 15,
113        likelyR: -15,
114        solidD: 25,
115        solidR: -25,
116        supersolidD: 35,
117        supersolidR: -35
118    });
119
120    ET.HOUSE_INPUT_RATING_TO_MARGIN = HOUSE_RATING_TO_MARGIN;
121
122    ET.forecastModelHouseRatingToMargin = function (rating) {
123        if (!rating) return 0;
124        if (HOUSE_RATING_TO_MARGIN.hasOwnProperty(rating)) return HOUSE_RATING_TO_MARGIN[rating];
125        return 0;
126    };
127
128    /** D or R if that party won the state by 20+ (signed D margin) in 2016, 2020, and 2024. */
129    ET.forecastPresSuperParty = function (stateKey) {
130        if (!stateKey || !ET.FORECAST_PRES_SUPER) return null;
131        var p = ET.FORECAST_PRES_SUPER[stateKey];
132        return p === 'D' || p === 'R' ? p : null;
133    };
134
135    function houseStateKeyFromCode(code) {
136        if (!code) return null;
137        var dash = code.indexOf('-');
138        return dash > 0 ? code.slice(0, dash) : null;
139    }
140
141    /** States where 2024 district House result is excluded from the blend (redrawn / unreliable lines). */
142    var HOUSE_PRIOR_RESULT_EXEMPT_STATES = {
143        AL: true,
144        CA: true,
145        FL: true,
146        LA: true,
147        NC: true,
148        OH: true,
149        TN: true,
150        TX: true,
151        UT: true
152    };
153
154    /** Signed 2024 House result in D-margin points, keyed like AL-1 / AK-AL. */
155    var HOUSE_PREVIOUS_RESULT_BY_CODE = {
156        'AK-AL': -8,
157        'AL-1': -28,
158        'AL-2': 4,
159        'AL-3': -23,
160        'AL-4': -33,
161        'AL-5': -17,
162        'AL-6': -22,
163        'AL-7': 12,
164        'AR-1': -22,
165        'AR-2': -9,
166        'AR-3': -15,
167        'AR-4': -20,
168        'AZ-1': -2,
169        'AZ-2': -6,
170        'AZ-3': 24,
171        'AZ-4': 2,
172        'AZ-5': -11,
173        'AZ-6': -3,
174        'AZ-7': 15,
175        'AZ-8': -10,
176        'AZ-9': -16,
177        'CA-1': -12,
178        'CA-10': 18,
179        'CA-11': 37,
180        'CA-12': 40,
181        'CA-13': 4,
182        'CA-14': 22,
183        'CA-15': 28,
184        'CA-16': 26,
185        'CA-17': 23,
186        'CA-18': 21,
187        'CA-19': 18,
188        'CA-2': 23,
189        'CA-20': -16,
190        'CA-21': 9,
191        'CA-22': 5,
192        'CA-23': -8,
193        'CA-24': 13,
194        'CA-25': 6,
195        'CA-26': 8,
196        'CA-27': 4,
197        'CA-28': 16,
198        'CA-29': 26,
199        'CA-3': -4,
200        'CA-30': 23,
201        'CA-31': 15,
202        'CA-32': 20,
203        'CA-33': 12,
204        'CA-34': 32,
205        'CA-35': 13,
206        'CA-36': 21,
207        'CA-37': 37,
208        'CA-38': 14,
209        'CA-39': 12,
210        'CA-4': 17,
211        'CA-40': -2,
212        'CA-41': -3,
213        'CA-42': 22,
214        'CA-43': 32,
215        'CA-44': 24,
216        'CA-45': 2,
217        'CA-46': 15,
218        'CA-47': 3,
219        'CA-48': -9,
220        'CA-49': 3,
221        'CA-5': -9,
222        'CA-50': 14,
223        'CA-51': 12,
224        'CA-52': 18,
225        'CA-6': 7,
226        'CA-7': 17,
227        'CA-8': 26,
228        'CA-9': 5,
229        'CO-1': 29,
230        'CO-2': 17,
231        'CO-3': -7,
232        'CO-4': -13,
233        'CO-5': -9,
234        'CO-6': 9,
235        'CO-7': 4,
236        'CO-8': 0,
237        'CT-1': 12,
238        'CT-2': 3,
239        'CT-3': 7,
240        'CT-4': 13,
241        'CT-5': 3,
242        'DE-AL': 7,
243        'FL-1': -19,
244        'FL-10': 14,
245        'FL-11': -8,
246        'FL-12': -17,
247        'FL-13': -6,
248        'FL-14': 8,
249        'FL-15': -4,
250        'FL-16': -7,
251        'FL-17': -10,
252        'FL-18': -13,
253        'FL-19': -13,
254        'FL-2': -8,
255        'FL-20': 25,
256        'FL-21': -7,
257        'FL-22': 7,
258        'FL-23': 5,
259        'FL-24': 25,
260        'FL-25': 9,
261        'FL-26': -8,
262        'FL-27': 0,
263        'FL-28': -2,
264        'FL-3': -9,
265        'FL-4': -6,
266        'FL-5': -11,
267        'FL-6': -14,
268        'FL-7': -5,
269        'FL-8': -11,
270        'FL-9': 8,
271        'GA-1': -9,
272        'GA-10': -14,
273        'GA-11': -14,
274        'GA-12': -8,
275        'GA-13': 17,
276        'GA-14': -22,
277        'GA-2': 3,
278        'GA-3': -18,
279        'GA-4': 27,
280        'GA-5': 35,
281        'GA-6': 22,
282        'GA-7': -13,
283        'GA-8': -16,
284        'GA-9': -20,
285        'HI-1': 14,
286        'HI-2': 14,
287        'IA-1': -3,
288        'IA-2': -4,
289        'IA-3': -3,
290        'IA-4': -16,
291        'ID-1': -22,
292        'ID-2': -14,
293        'IL-1': 20,
294        'IL-10': 11,
295        'IL-11': 5,
296        'IL-12': -24,
297        'IL-13': 3,
298        'IL-14': 4,
299        'IL-15': -22,
300        'IL-16': -13,
301        'IL-17': 2,
302        'IL-2': 19,
303        'IL-3': 20,
304        'IL-4': 22,
305        'IL-5': 18,
306        'IL-6': 3,
307        'IL-7': 36,
308        'IL-8': 6,
309        'IL-9': 19,
310        'IN-1': 3,
311        'IN-2': -14,
312        'IN-3': -18,
313        'IN-4': -18,
314        'IN-5': -11,
315        'IN-6': -19,
316        'IN-7': 19,
317        'IN-8': -19,
318        'IN-9': -16,
319        'KS-1': -18,
320        'KS-2': -11,
321        'KS-3': -1,
322        'KS-4': -14,
323        'KY-1': -24,
324        'KY-2': -21,
325        'KY-3': 9,
326        'KY-4': -19,
327        'KY-5': -32,
328        'KY-6': -9,
329        'LA-1': -22,
330        'LA-2': 16,
331        'LA-3': -22,
332        'LA-4': -26,
333        'LA-5': -19,
334        'LA-6': 8,
335        'MA-1': 9,
336        'MA-2': 13,
337        'MA-3': 11,
338        'MA-4': 12,
339        'MA-5': 23,
340        'MA-6': 11,
341        'MA-7': 35,
342        'MA-8': 15,
343        'MA-9': 6,
344        'MD-1': -11,
345        'MD-2': 7,
346        'MD-3': 10,
347        'MD-4': 40,
348        'MD-5': 15,
349        'MD-6': 2,
350        'MD-7': 30,
351        'MD-8': 29,
352        'ME-1': 9,
353        'ME-2': -6,
354        'MI-1': -13,
355        'MI-10': -3,
356        'MI-11': 7,
357        'MI-12': 23,
358        'MI-13': 23,
359        'MI-2': -16,
360        'MI-3': 1,
361        'MI-4': -5,
362        'MI-5': -15,
363        'MI-6': 11,
364        'MI-7': -2,
365        'MI-8': -1,
366        'MI-9': -18,
367        'MN-1': -7,
368        'MN-2': 1,
369        'MN-3': 8,
370        'MN-4': 17,
371        'MN-5': 30,
372        'MN-6': -12,
373        'MN-7': -19,
374        'MN-8': -8,
375        'MO-1': 27,
376        'MO-2': -7,
377        'MO-3': -16,
378        'MO-4': -23,
379        'MO-5': 11,
380        'MO-6': -21,
381        'MO-7': -24,
382        'MO-8': -28,
383        'MS-1': -18,
384        'MS-2': 11,
385        'MS-3': -15,
386        'MS-4': -22,
387        'MT-1': -6,
388        'MT-2': -16,
389        'NC-1': -1,
390        'NC-10': -10,
391        'NC-11': -8,
392        'NC-12': 23,
393        'NC-13': -11,
394        'NC-14': -11,
395        'NC-2': 15,
396        'NC-3': -11,
397        'NC-4': 21,
398        'NC-5': -10,
399        'NC-6': -11,
400        'NC-7': -8,
401        'NC-8': -11,
402        'NC-9': -9,
403        'ND-AL': -20,
404        'NE-1': -9,
405        'NE-2': 0,
406        'NE-3': -29,
407        'NH-1': 0,
408        'NH-2': 2,
409        'NJ-1': 10,
410        'NJ-10': 30,
411        'NJ-11': 6,
412        'NJ-12': 12,
413        'NJ-2': -5,
414        'NJ-3': 5,
415        'NJ-4': -14,
416        'NJ-5': 4,
417        'NJ-6': 8,
418        'NJ-7': -1,
419        'NJ-8': 22,
420        'NJ-9': 8,
421        'NM-1': 5,
422        'NM-2': 1,
423        'NM-3': 4,
424        'NV-1': 3,
425        'NV-2': -8,
426        'NV-3': 1,
427        'NV-4': 3,
428        'NY-1': -4,
429        'NY-10': 34,
430        'NY-11': -6,
431        'NY-12': 34,
432        'NY-13': 38,
433        'NY-14': 27,
434        'NY-15': 35,
435        'NY-16': 21,
436        'NY-17': 3,
437        'NY-18': 2,
438        'NY-19': -1,
439        'NY-2': -4,
440        'NY-20': 7,
441        'NY-21': -10,
442        'NY-22': 3,
443        'NY-23': -12,
444        'NY-24': -13,
445        'NY-25': 8,
446        'NY-26': 10,
447        'NY-3': 3,
448        'NY-4': 5,
449        'NY-5': 30,
450        'NY-6': 14,
451        'NY-7': 30,
452        'NY-8': 27,
453        'NY-9': 25,
454        'OH-1': 2,
455        'OH-10': -4,
456        'OH-11': 28,
457        'OH-12': -18,
458        'OH-13': -1,
459        'OH-14': -9,
460        'OH-15': -6,
461        'OH-2': -25,
462        'OH-3': 20,
463        'OH-4': -20,
464        'OH-5': -15,
465        'OH-6': -16,
466        'OH-7': -7,
467        'OH-8': -14,
468        'OH-9': -3,
469        'OK-1': -14,
470        'OK-2': -29,
471        'OK-3': -24,
472        'OK-4': -19,
473        'OK-5': -12,
474        'OR-1': 18,
475        'OR-2': -15,
476        'OR-3': 22,
477        'OR-4': 4,
478        'OR-5': 2,
479        'OR-6': 4,
480        'PA-1': 0,
481        'PA-10': -5,
482        'PA-11': -13,
483        'PA-12': 8,
484        'PA-13': -25,
485        'PA-14': -18,
486        'PA-15': -21,
487        'PA-16': -13,
488        'PA-17': 0,
489        'PA-2': 20,
490        'PA-3': 39,
491        'PA-4': 7,
492        'PA-5': 14,
493        'PA-6': 5,
494        'PA-7': -2,
495        'PA-8': -4,
496        'PA-9': -21,
497        'RI-1': 12,
498        'RI-2': 4,
499        'SC-1': -7,
500        'SC-2': -8,
501        'SC-3': -21,
502        'SC-4': -12,
503        'SC-5': -12,
504        'SC-6': 14,
505        'SC-7': -11,
506        'SD-AL': -16,
507        'TN-1': -30,
508        'TN-2': -18,
509        'TN-3': -19,
510        'TN-4': -22,
511        'TN-5': -9,
512        'TN-6': -17,
513        'TN-7': -10,
514        'TN-8': -21,
515        'TN-9': 22,
516        'TX-1': -26,
517        'TX-10': -13,
518        'TX-11': -23,
519        'TX-12': -12,
520        'TX-13': -26,
521        'TX-14': -17,
522        'TX-15': -1,
523        'TX-16': 17,
524        'TX-17': -14,
525        'TX-18': 23,
526        'TX-19': -26,
527        'TX-2': -15,
528        'TX-20': 15,
529        'TX-21': -13,
530        'TX-22': -11,
531        'TX-23': -5,
532        'TX-24': -10,
533        'TX-25': -19,
534        'TX-26': -13,
535        'TX-27': -13,
536        'TX-28': 3,
537        'TX-29': 18,
538        'TX-3': -11,
539        'TX-30': 27,
540        'TX-31': -14,
541        'TX-32': 14,
542        'TX-33': 24,
543        'TX-34': 9,
544        'TX-35': 21,
545        'TX-36': -18,
546        'TX-37': 24,
547        'TX-38': -12,
548        'TX-4': -16,
549        'TX-5': -14,
550        'TX-6': -15,
551        'TX-7': 13,
552        'TX-8': -16,
553        'TX-9': 26,
554        'UT-1': -12,
555        'UT-2': -11,
556        'UT-3': -13,
557        'UT-4': -16,
558        'VA-1': -6,
559        'VA-10': 6,
560        'VA-11': 18,
561        'VA-2': -2,
562        'VA-3': 17,
563        'VA-4': 16,
564        'VA-5': -7,
565        'VA-6': -14,
566        'VA-7': 1,
567        'VA-8': 26,
568        'VA-9': -23,
569        'VT-AL': 16,
570        'WA-1': 13,
571        'WA-10': 7,
572        'WA-2': 9,
573        'WA-3': -5,
574        'WA-4': -11,
575        'WA-5': -8,
576        'WA-6': 6,
577        'WA-7': 36,
578        'WA-8': 1,
579        'WA-9': 21,
580        'WI-1': -3,
581        'WI-2': 19,
582        'WI-3': -4,
583        'WI-4': 25,
584        'WI-5': -14,
585        'WI-6': -10,
586        'WI-7': -12,
587        'WI-8': -10,
588        'WV-1': -23,
589        'WV-2': -22,
590        'WY-AL': -25
591    };
592
593    function forecastPresAvgSignedMargin(stateKey) {
594        if (!stateKey) return null;
595        var v;
596        if (ET.SENATE_FORECAST_PRES_AVG && ET.SENATE_FORECAST_PRES_AVG[stateKey] != null) {
597            v = ET.SENATE_FORECAST_PRES_AVG[stateKey];
598        } else if (ET.GOV_FORECAST_PRES_AVG && ET.GOV_FORECAST_PRES_AVG[stateKey] != null) {
599            v = ET.GOV_FORECAST_PRES_AVG[stateKey];
600        } else {
601            return null;
602        }
603        return v != null && isFinite(v) ? v : null;
604    }
605
606    function senateEditorialRatingForModel(key, asOfIso) {
607        if (key && asOfIso && ET.SENATE_EDITORIAL_BLEND_EFFECTIVE_FROM &&
608            ET.SENATE_EDITORIAL_BLEND_EFFECTIVE_FROM[key] &&
609            asOfIso < ET.SENATE_EDITORIAL_BLEND_EFFECTIVE_FROM[key]) {
610            if (ET.SENATE_EDITORIAL_BLEND_PRIOR && ET.SENATE_EDITORIAL_BLEND_PRIOR[key]) {
611                return ET.SENATE_EDITORIAL_BLEND_PRIOR[key];
612            }
613            return ET.SENATE_EDITORIAL_RATINGS && ET.SENATE_EDITORIAL_RATINGS[key];
614        }
615        if (ET.SENATE_EDITORIAL_RATINGS_BASE && ET.SENATE_EDITORIAL_RATINGS_BASE[key]) {
616            return ET.SENATE_EDITORIAL_RATINGS_BASE[key];
617        }
618        return ET.SENATE_EDITORIAL_RATINGS && ET.SENATE_EDITORIAL_RATINGS[key];
619    }
620
621    function govEditorialRatingForModel(key) {
622        if (ET.GOV_EDITORIAL_RATINGS_BASE && ET.GOV_EDITORIAL_RATINGS_BASE[key]) {
623            return ET.GOV_EDITORIAL_RATINGS_BASE[key];
624        }
625        if (ET.COOK_GOV_RATINGS && ET.COOK_GOV_RATINGS[key]) {
626            return ET.COOK_GOV_RATINGS[key];
627        }
628        return ET.GOV_EDITORIAL_RATINGS && ET.GOV_EDITORIAL_RATINGS[key];
629    }
630
631    /** Senate cook component: editorial rating tier (INPUT_RATING_TO_MARGIN), optional per-state override. */
632    ET.forecastModelSenateEditorialBlendMargin = function (stateKey, rating) {
633        if (stateKey && ET.SENATE_EDITORIAL_BLEND_MARGINS &&
634            ET.SENATE_EDITORIAL_BLEND_MARGINS[stateKey] != null &&
635            isFinite(ET.SENATE_EDITORIAL_BLEND_MARGINS[stateKey])) {
636            return ET.SENATE_EDITORIAL_BLEND_MARGINS[stateKey];
637        }
638        return ET.forecastModelRatingToMargin(rating);
639    };
640
641    /** Governor cook component: editorial rating tier, optional per-state override. */
642    ET.forecastModelGovEditorialBlendMargin = function (stateKey, rating) {
643        if (stateKey && ET.GOV_EDITORIAL_BLEND_MARGINS &&
644            ET.GOV_EDITORIAL_BLEND_MARGINS[stateKey] != null &&
645            isFinite(ET.GOV_EDITORIAL_BLEND_MARGINS[stateKey])) {
646            return ET.GOV_EDITORIAL_BLEND_MARGINS[stateKey];
647        }
648        return ET.forecastModelRatingToMargin(rating);
649    };
650
651    /** Cook/editorial rating → signed D margin (House/Gov; Senate uses forecastModelSenateEditorialBlendMargin). */
652    ET.forecastModelCookMargin = function (cookRating, stateKey) {
653        return ET.forecastModelRatingToMargin(cookRating);
654    };
655
656    /**
657     * House cook component: COOK_HOUSE_RATINGS tier unless a 2026 incumbent qualifies for
658     * Ultra Solid (2024 margin ≥25) or Mega Solid (≥35) — internal blend only.
659     */
660    ET.forecastModelHouseCookBlendMargin = function (code, cookRating) {
661        var key = houseDistrictLookupKey(code);
662        if (key && ET.HOUSE_INCUMBENT_2024_BLEND_MARGINS &&
663            ET.HOUSE_INCUMBENT_2024_BLEND_MARGINS[key] &&
664            ET.HOUSE_INCUMBENT_2024_BLEND_MARGINS[key].margin != null &&
665            isFinite(ET.HOUSE_INCUMBENT_2024_BLEND_MARGINS[key].margin)) {
666            return ET.HOUSE_INCUMBENT_2024_BLEND_MARGINS[key].margin;
667        }
668        return ET.forecastModelHouseRatingToMargin(cookRating);
669    };
670
671    ET.forecastModelPollWeightAtCount = function (pollCount, hasPollMargin, pollBaseAtFull, fullCount) {
672        pollBaseAtFull = pollBaseAtFull == null ? 0.45 : pollBaseAtFull;
673        fullCount = fullCount == null ? POLL_FULL_COUNT : fullCount;
674        var n = Math.max(0, Math.floor(pollCount || 0));
675        if (!hasPollMargin || n <= 0) return 0;
676        if (n >= fullCount) return pollBaseAtFull;
677        return Math.max(0, pollBaseAtFull - (fullCount - n) * POLL_STEP);
678    };
679
680    function scaleNonPollWeights(pollW, baseNonPoll) {
681        var sum = 0;
682        var k;
683        for (k in baseNonPoll) {
684            if (baseNonPoll.hasOwnProperty(k)) sum += baseNonPoll[k];
685        }
686        if (sum <= 0) return { poll: pollW };
687        var scale = pollW >= 1 ? 0 : (1 - pollW) / sum;
688        var w = { poll: pollW };
689        for (k in baseNonPoll) {
690            if (baseNonPoll.hasOwnProperty(k)) w[k] = baseNonPoll[k] * scale;
691        }
692        return w;
693    }
694
695    /** Senate weights; drops priorSenate / incumbency when not applicable and rescales. */
696    ET.forecastModelSenateBlendWeights = function (pollCount, hasPollMargin, hasPriorSenate, hasIncumbency) {
697        var pollW = ET.forecastModelPollWeightAtCount(pollCount, hasPollMargin, SENATE_POLL_BASE_AT_8);
698        var base = {
699            generic: SENATE_BASE_NON_POLL.generic,
700            trump: SENATE_BASE_NON_POLL.trump,
701            presAvg: SENATE_BASE_NON_POLL.presAvg,
702            pvi: SENATE_BASE_NON_POLL.pvi,
703            cook: SENATE_BASE_NON_POLL.cook
704        };
705        if (hasPriorSenate) base.priorSenate = SENATE_BASE_NON_POLL.priorSenate;
706        if (hasIncumbency) base.incumbency = SENATE_BASE_NON_POLL.incumbency;
707        return scaleNonPollWeights(pollW, base);
708    };
709
710    /** Governor weights; drops priorGov / incumbency when not applicable and rescales. */
711    ET.forecastModelGovBlendWeights = function (pollCount, hasPollMargin, hasPriorGov, hasIncumbency) {
712        var pollW = ET.forecastModelPollWeightAtCount(pollCount, hasPollMargin, GOV_POLL_BASE_AT_8);
713        var base = {
714            generic: GOV_BASE_NON_POLL.generic,
715            trump: GOV_BASE_NON_POLL.trump,
716            presAvg: GOV_BASE_NON_POLL.presAvg,
717            pvi: GOV_BASE_NON_POLL.pvi,
718            cook: GOV_BASE_NON_POLL.cook
719        };
720        if (hasPriorGov) base.priorGov = GOV_BASE_NON_POLL.priorGov;
721        if (hasIncumbency) base.incumbency = GOV_BASE_NON_POLL.incumbency;
722        return scaleNonPollWeights(pollW, base);
723    };
724
725    /**
726     * House weights; drops unavailable incumbency / previous-result inputs and rescales.
727     * @param {{ solidRace?: boolean, closeRace?: boolean }} [opts]
728     */
729    ET.forecastModelHouseBlendWeights = function (pollCount, hasPollMargin, hasIncumbency, hasPriorHouse, opts) {
730        opts = opts || {};
731        var pollW = ET.forecastModelPollWeightAtCount(
732            pollCount, hasPollMargin, HOUSE_POLL_BASE_AT_6, HOUSE_POLL_FULL_COUNT);
733        var base = {
734            generic: HOUSE_BASE_NON_POLL.generic,
735            trump: HOUSE_BASE_NON_POLL.trump,
736            pvi: HOUSE_BASE_NON_POLL.pvi,
737            cook: HOUSE_BASE_NON_POLL.cook
738        };
739        if (hasIncumbency) base.incumbency = HOUSE_BASE_NON_POLL.incumbency;
740        if (hasPriorHouse) base.priorHouse = HOUSE_BASE_NON_POLL.priorHouse;
741        return scaleNonPollWeights(pollW, base);
742    };
743
744    function recencyWeightedMean(rows, valueKey) {
745        if (!rows || !rows.length) return null;
746        var sorted = rows.slice().sort(function (a, b) {
747            return a.date < b.date ? 1 : (a.date > b.date ? -1 : 0);
748        });
749        var wSum = 0;
750        var vSum = 0;
751        for (var i = 0; i < sorted.length; i++) {
752            var v = sorted[i][valueKey];
753            if (v == null || !isFinite(v)) continue;
754            var w = 1 / (1 + i * 0.35);
755            wSum += w;
756            vSum += v * w;
757        }
758        return wSum ? vSum / wSum : null;
759    }
760
761    /** Match generic-ballot / Trump approval charts: ~28-day effective window. */
762    var NATIONAL_ENV_HALFLIFE_DAYS = 20;
763    var NATIONAL_ENV_ROLLING_DAYS = 28;
764    /** Faster decay for daily forecast trends so generic / Trump drift visibly day-to-day. */
765    var NATIONAL_ENV_TREND_HALFLIFE_DAYS = 12;
766    var NATIONAL_ENV_TREND_ROLLING_DAYS = 28;
767
768    function isoToMs(iso) {
769        if (!iso || iso.length < 10) return NaN;
770        return new Date(iso.slice(0, 10) + 'T12:00:00Z').getTime();
771    }
772
773    function nationalPollSampleWt(poll) {
774        if (!poll) return 1;
775        var s = poll.sample;
776        if (s == null || s === '') return 1;
777        if (typeof s === 'number' && isFinite(s)) return s;
778        var cleaned = String(s).replace(/,/g, '');
779        var m = cleaned.match(/^\s*(\d+)/);
780        return m ? +m[1] : 1;
781    }
782
783    /**
784     * Mean as of a calendar day: sample-weighted with exponential decay by days since fieldwork
785     * so the value drifts slightly every day even between new polls.
786     */
787    function calendarDecayWeightedMean(rows, valueKey, asOfIso, halfLifeDays) {
788        if (!rows || !rows.length || !asOfIso) return null;
789        halfLifeDays = halfLifeDays == null ? NATIONAL_ENV_HALFLIFE_DAYS : halfLifeDays;
790        var asOfMs = isoToMs(asOfIso);
791        if (!isFinite(asOfMs)) return null;
792        var ln2 = Math.LN2;
793        var wSum = 0;
794        var vSum = 0;
795        var i;
796        for (i = 0; i < rows.length; i++) {
797            var p = rows[i];
798            if (!p.date || p.date > asOfIso) continue;
799            var v = p[valueKey];
800            if (v == null || !isFinite(v)) continue;
801            var daysAgo = (asOfMs - isoToMs(p.date)) / 86400000;
802            if (daysAgo < 0 || !isFinite(daysAgo)) continue;
803            var w = nationalPollSampleWt(p) * Math.exp(-daysAgo * ln2 / halfLifeDays);
804            wSum += w;
805            vSum += v * w;
806        }
807        return wSum ? vSum / wSum : null;
808    }
809
810    /** 28-day inclusive rolling window (approval-page style); shifts daily as polls age out. */
811    function rollingMeanInWindow(rows, valueKey, asOfIso, windowDays) {
812        if (!rows || !rows.length || !asOfIso) return null;
813        windowDays = windowDays == null ? NATIONAL_ENV_ROLLING_DAYS : windowDays;
814        var endMs = isoToMs(asOfIso);
815        if (!isFinite(endMs)) return null;
816        var startMs = endMs - (windowDays - 1) * 86400000;
817        var wSum = 0;
818        var vSum = 0;
819        var i;
820        for (i = 0; i < rows.length; i++) {
821            var p = rows[i];
822            if (!p.date || p.date > asOfIso) continue;
823            var pm = isoToMs(p.date);
824            if (!isFinite(pm) || pm < startMs || pm > endMs) continue;
825            var v = p[valueKey];
826            if (v == null || !isFinite(v)) continue;
827            var w = nationalPollSampleWt(p);
828            wSum += w;
829            vSum += v * w;
830        }
831        return wSum ? vSum / wSum : null;
832    }
833
834    function nationalMeanAsOf(rows, valueKey, asOfIso) {
835        var v = calendarDecayWeightedMean(rows, valueKey, asOfIso, NATIONAL_ENV_HALFLIFE_DAYS);
836        if (v != null) return v;
837        v = rollingMeanInWindow(rows, valueKey, asOfIso, NATIONAL_ENV_ROLLING_DAYS);
838        if (v != null) return v;
839        return recencyWeightedMean(rows, valueKey);
840    }
841
842    /** Trend series: faster daily drift; anchor to first reading before national polls exist. */
843    function nationalMeanAsOfForTrend(rows, valueKey, asOfIso) {
844        var v = calendarDecayWeightedMean(rows, valueKey, asOfIso, NATIONAL_ENV_TREND_HALFLIFE_DAYS);
845        if (v != null) return v;
846        v = rollingMeanInWindow(rows, valueKey, asOfIso, NATIONAL_ENV_TREND_ROLLING_DAYS);
847        if (v != null) return v;
848        var eligible = [];
849        var i;
850        var p;
851        for (i = 0; i < (rows || []).length; i++) {
852            p = rows[i];
853            if (p.date && p.date <= asOfIso && p[valueKey] != null && isFinite(p[valueKey])) {
854                eligible.push(p);
855            }
856        }
857        if (eligible.length) return recencyWeightedMean(eligible, valueKey);
858        for (i = 0; i < (rows || []).length; i++) {
859            p = rows[i];
860            if (p[valueKey] != null && isFinite(p[valueKey])) return p[valueKey];
861        }
862        return recencyWeightedMean(rows, valueKey);
863    }
864
865    function rollingRaceDrAtDate(pollRows, asOfIso) {
866        if (!pollRows || !pollRows.length || !asOfIso) return null;
867        var endMs = isoToMs(asOfIso);
868        if (!isFinite(endMs)) return null;
869        var ln2 = Math.LN2;
870        var wSum = 0;
871        var dSum = 0;
872        var rSum = 0;
873        var i;
874        var p;
875        var d;
876        var r;
877        var daysAgo;
878        var w;
879        for (i = 0; i < pollRows.length; i++) {
880            p = pollRows[i];
881            if (p.avgExclude) continue;
882            var iso = racePollIso(p) || p.date;
883            if (!iso || iso > asOfIso) continue;
884            d = p.D;
885            r = p.R;
886            if (typeof d !== 'number' || typeof r !== 'number' || !isFinite(d) || !isFinite(r)) continue;
887            daysAgo = (endMs - isoToMs(iso)) / 86400000;
888            if (daysAgo < 0 || !isFinite(daysAgo)) continue;
889            w = nationalPollSampleWt(p) * Math.exp(-daysAgo * ln2 / NATIONAL_ENV_HALFLIFE_DAYS);
890            dSum += d * w;
891            rSum += r * w;
892            wSum += w;
893        }
894        if (!wSum) return null;
895        var dPct = dSum / wSum;
896        var rPct = rSum / wSum;
897        return { margin: dPct - rPct, dPct: dPct, rPct: rPct };
898    }
899
900    var FORECAST_TREND_START = '2026-01-01';
901    ET.FORECAST_TREND_START_ISO = FORECAST_TREND_START;
902
903    function pollsOnOrBeforeDate(rows, asOfIso) {
904        if (!rows || !rows.length || !asOfIso) return [];
905        return rows.filter(function (p) {
906            return p.date && p.date <= asOfIso;
907        });
908    }
909
910    function racePollIso(p) {
911        if (!p) return null;
912        if (typeof ET.pollFieldworkIso === 'function') {
913            var fw = ET.pollFieldworkIso(p);
914            if (fw) return fw;
915        }
916        if (typeof ET.pollSiteListedIso === 'function') {
917            var listed = ET.pollSiteListedIso(p);
918            if (listed) return listed;
919        }
920        return p.date || null;
921    }
922
923    function racePollsOnOrBeforeDate(rows, asOfIso) {
924        if (!rows || !rows.length || !asOfIso) return [];
925        return rows.filter(function (p) {
926            var iso = racePollIso(p);
927            return iso && iso <= asOfIso;
928        });
929    }
930
931    /** Senate hub control-odds backtest: only polls whose fieldwork/list date is strictly before asOfIso. */
932    function racePollsStrictlyBeforeDate(rows, asOfIso) {
933        if (!rows || !rows.length || !asOfIso) return [];
934        return rows.filter(function (p) {
935            var iso = racePollIso(p);
936            return iso && iso < asOfIso;
937        });
938    }
939
940    function pollsStrictlyBeforeDate(rows, asOfIso) {
941        if (!rows || !rows.length || !asOfIso) return [];
942        return rows.filter(function (p) {
943            return p.date && p.date < asOfIso;
944        });
945    }
946
947    function dailyLabelsEndingAt(startIso, endIso) {
948        if (!endIso) {
949            endIso = typeof ET.etTodayISO === 'function' ? ET.etTodayISO() : FORECAST_TREND_START;
950        }
951        var start = startIso || FORECAST_TREND_START;
952        if (!ET.nextISO) return [endIso];
953        var out = [start];
954        var cur = start;
955        while (cur < endIso) {
956            cur = ET.nextISO(cur);
957            out.push(cur);
958        }
959        return out;
960    }
961
962    function marginFromAvgsAtDate(dAvg, rAvg, labels, asOfIso) {
963        var idx = -1;
964        var i;
965        for (i = labels.length - 1; i >= 0; i--) {
966            if (labels[i] <= asOfIso) {
967                idx = i;
968                break;
969            }
970        }
971        if (idx < 0) return null;
972        if (dAvg[idx] != null && rAvg[idx] != null) {
973            return { margin: dAvg[idx] - rAvg[idx], dPct: dAvg[idx], rPct: rAvg[idx] };
974        }
975        return null;
976    }
977
978    function nationalEnvironmentFromPolls(gb, ta, asOfIso, meanFn) {
979        meanFn = meanFn || nationalMeanAsOf;
980        var genericD = meanFn(gb, 'approve', asOfIso);
981        var genericR = meanFn(gb, 'disapprove', asOfIso);
982        var trumpAp = meanFn(ta, 'approve', asOfIso);
983        var trumpDis = meanFn(ta, 'disapprove', asOfIso);
984        var genericMargin = (genericD != null && genericR != null) ? genericD - genericR : 0;
985        var trumpNet = (trumpAp != null && trumpDis != null) ? trumpAp - trumpDis : 0;
986        /** Pro-D environment when Trump is underwater (disapprove − approve). */
987        var trumpProD = (trumpAp != null && trumpDis != null) ? trumpDis - trumpAp : 0;
988        return {
989            genericMargin: genericMargin,
990            trumpNet: trumpNet,
991            trumpProD: trumpProD
992        };
993    }
994
995    ET.forecastModelNationalEnvironmentAsOf = function (asOfIso) {
996        var gbAll = typeof window !== 'undefined' ? window.GENERIC_BALLOT_POLLS : null;
997        var taAll = typeof window !== 'undefined' ? window.TRUMP_APPROVAL_POLLS : null;
998        var gb = asOfIso ? pollsOnOrBeforeDate(gbAll, asOfIso) : gbAll;
999        var ta = asOfIso ? pollsOnOrBeforeDate(taAll, asOfIso) : taAll;
1000        return nationalEnvironmentFromPolls(gb, ta, asOfIso, nationalMeanAsOf);
1001    };
1002
1003    /** Daily trend / simulation history — generic ballot + Trump approval move every calendar day. */
1004    ET.forecastModelNationalEnvironmentAsOfForTrend = function (asOfIso) {
1005        var gbAll = typeof window !== 'undefined' ? window.GENERIC_BALLOT_POLLS : null;
1006        var taAll = typeof window !== 'undefined' ? window.TRUMP_APPROVAL_POLLS : null;
1007        var gb = asOfIso ? pollsOnOrBeforeDate(gbAll, asOfIso) : gbAll;
1008        var ta = asOfIso ? pollsOnOrBeforeDate(taAll, asOfIso) : taAll;
1009        return nationalEnvironmentFromPolls(gb, ta, asOfIso, nationalMeanAsOfForTrend);
1010    };
1011
1012    ET.forecastModelNationalEnvironment = function () {
1013        var today = typeof ET.etTodayISO === 'function' ? ET.etTodayISO() : null;
1014        return ET.forecastModelNationalEnvironmentAsOf(today || '9999-12-31');
1015    };
1016
1017    ET.forecastModelDailyTrendLabels = function () {
1018        var end = typeof ET.etTodayISO === 'function' ? ET.etTodayISO() : FORECAST_TREND_START;
1019        return dailyLabelsEndingAt(FORECAST_TREND_START, end);
1020    };
1021
1022    ET.forecastModelMarginToWinProb = function (margin, chamber) {
1023        if (chamber === 'house' && typeof ET.forecastMarginToTargetWinProb === 'function') {
1024            return ET.forecastMarginToTargetWinProb(margin, 'house');
1025        }
1026        var eff = forecastModelEffectiveMargin(margin == null || !isFinite(margin) ? 0 : margin);
1027        return 1 / (1 + Math.exp(-eff / WIN_PROB_LOGISTIC_SCALE));
1028    };
1029
1030    /**
1031     * Win-probability tiers from favored party odds (no toss-up):
1032     * Solid ≥95%, Likely 80–95%, Lean 65–80%, Tilt <65% (includes 50–55% coin-flip band).
1033     * @param {number} pD — probability Democrats win (0–1)
1034     */
1035    ET.forecastModelWinProbToRating = function (pD, stateKey) {
1036        var p = pD == null || !isFinite(pD) ? 0.5 : Math.max(0, Math.min(1, pD));
1037        var lead = p >= 0.5 ? 'D' : 'R';
1038        var winP = lead === 'D' ? p : 1 - p;
1039        if (winP >= 0.95) return lead === 'D' ? 'solidD' : 'solidR';
1040        if (winP >= 0.80) return lead === 'D' ? 'likelyD' : 'likelyR';
1041        if (winP >= 0.65) return lead === 'D' ? 'leanD' : 'leanR';
1042        return lead === 'D' ? 'tiltD' : 'tiltR';
1043    };
1044
1045    /** Favored party from simulated win probabilities (always D or R). */
1046    ET.forecastModelFavoredPartyFromWinProb = function (pD) {
1047        var p = pD == null || !isFinite(pD) ? 0.5 : Math.max(0, Math.min(1, pD));
1048        return p >= 0.5 ? 'D' : 'R';
1049    };
1050
1051    /** When rounded projected vote is 50/50, break margin label/chip ties using win odds. */
1052    ET.forecastModelEvenVoteLead = function (winProbD) {
1053        if (typeof ET.forecastEvenVoteLeadFromWinProb === 'function') {
1054            return ET.forecastEvenVoteLeadFromWinProb(winProbD);
1055        }
1056        if (winProbD == null || !isFinite(winProbD)) return 'Tie';
1057        if (winProbD > 0.5) return 'D';
1058        if (winProbD < 0.5) return 'R';
1059        return 'Tie';
1060    };
1061
1062    /** True when margin rounds to an even two-party vote share (50.0% / 50.0%). */
1063    ET.forecastModelMarginIsEvenVote = function (margin) {
1064        if (typeof ET.forecastMarginIsEvenVote === 'function') {
1065            return ET.forecastMarginIsEvenVote(margin);
1066        }
1067        if (margin == null || !isFinite(margin)) return false;
1068        return Math.abs(margin) < 0.05;
1069    };
1070
1071    /** Display margin label, e.g. D+3.2 or R+8.7; even vote + win-odds lead → D+0.0 / R+0.0. */
1072    ET.forecastModelMarginDisplayLabel = function (margin, winProbD) {
1073        if (margin == null || !isFinite(margin)) return '';
1074        if (ET.forecastModelMarginIsEvenVote(margin)) {
1075            var evenLead = ET.forecastModelEvenVoteLead(winProbD);
1076            if (evenLead === 'D') return 'D+0.0';
1077            if (evenLead === 'R') return 'R+0.0';
1078            return 'Even';
1079        }
1080        var party = margin > 0 ? 'D' : 'R';
1081        var absM = Math.abs(margin);
1082        var dec = typeof ET.oneDec === 'function' ? ET.oneDec(absM) : absM.toFixed(1);
1083        return party + '+' + dec;
1084    };
1085
1086    /** solid | likely | lean | tilt — matches win-prob rating tiers (for race-page copy). */
1087    ET.forecastModelMarginTierFromWinProb = function (pD) {
1088        var rating = ET.forecastModelWinProbToRating(pD);
1089        return ET.editorialRatingToMarginTier
1090            ? ET.editorialRatingToMarginTier(rating)
1091            : 'tilt';
1092    };
1093
1094    /** @deprecated Use win-probability path; kept for callers expecting a margin tier. */
1095    ET.forecastModelMarginToRating = function (margin) {
1096        return ET.forecastModelWinProbToRating(ET.forecastModelMarginToWinProb(margin));
1097    };
1098
1099    function runBlend(components, pollMargin, pollCount, weightFn, stateKey) {
1100        var hasPoll = pollMargin != null && isFinite(pollMargin);
1101        var w = weightFn(pollCount, hasPoll);
1102        var m = 0;
1103        if (hasPoll && w.poll) m += w.poll * pollMargin;
1104        if (w.generic) m += w.generic * (components.generic || 0);
1105        if (w.trump) m += w.trump * (components.trump || 0);
1106        if (w.incumbency) m += w.incumbency * (components.incumbency || 0);
1107        if (w.priorHouse) m += w.priorHouse * (components.priorHouse || 0);
1108        if (w.priorSenate) m += w.priorSenate * (components.priorSenate || 0);
1109        if (w.priorGov) m += w.priorGov * (components.priorGov || 0);
1110        if (w.presAvg) m += w.presAvg * (components.presAvg || 0);
1111        if (w.pvi) m += w.pvi * (components.pvi || 0);
1112        if (w.cook) m += w.cook * (components.cook || 0);
1113        var pD = ET.forecastModelMarginToWinProb(m);
1114        return {
1115            margin: m,
1116            winProbD: pD,
1117            winProbR: 1 - pD,
1118            rating: ET.forecastModelWinProbToRating(pD, stateKey),
1119            weights: w,
1120            components: components
1121        };
1122    }
1123
1124    function runHouseBlend(components, pollMargin, pollCount, weightFn, stateKey) {
1125        var hasPoll = pollMargin != null && isFinite(pollMargin);
1126        var w = weightFn(pollCount, hasPoll);
1127        var m = 0;
1128        if (hasPoll && w.poll) m += w.poll * pollMargin;
1129        if (w.generic) m += w.generic * (components.generic || 0);
1130        if (w.trump) m += w.trump * (components.trump || 0);
1131        if (w.incumbency) m += w.incumbency * (components.incumbency || 0);
1132        if (w.priorHouse) m += w.priorHouse * (components.priorHouse || 0);
1133        if (w.pvi) m += w.pvi * (components.pvi || 0);
1134        if (w.cook) m += w.cook * (components.cook || 0);
1135        var pD = typeof ET.forecastMarginToTargetWinProb === 'function'
1136            ? ET.forecastMarginToTargetWinProb(m, 'house')
1137            : ET.forecastModelMarginToWinProb(m, 'house');
1138        return {
1139            margin: m,
1140            winProbD: pD,
1141            winProbR: 1 - pD,
1142            rating: ET.forecastModelWinProbToRating(pD, stateKey),
1143            weights: w,
1144            components: components
1145        };
1146    }
1147
1148    ET.forecastModelPollMarginFromDetail = function (det) {
1149        if (!det) return null;
1150        if (det.pollDPct != null && det.pollRPct != null &&
1151            isFinite(det.pollDPct) && isFinite(det.pollRPct)) {
1152            return det.pollDPct - det.pollRPct;
1153        }
1154        return null;
1155    };
1156
1157    /**
1158     * Composite D margin from blend weights (optional noise on generic / Trump components for simulation).
1159     * @param {{ genericNoise?: number, trumpNoise?: number }} [opts]
1160     */
1161    ET.forecastModelBlendMargin = function (components, weights, pollMargin, opts) {
1162        opts = opts || {};
1163        var w = weights || {};
1164        var c = components || {};
1165        var m = 0;
1166        var hasPoll = pollMargin != null && isFinite(pollMargin);
1167        if (hasPoll && w.poll) m += w.poll * pollMargin;
1168        if (w.generic) m += w.generic * ((c.generic || 0) + (opts.genericNoise || 0));
1169        if (w.trump) m += w.trump * ((c.trump || 0) + (opts.trumpNoise || 0));
1170        if (w.incumbency) m += w.incumbency * (c.incumbency || 0);
1171        if (w.priorHouse) m += w.priorHouse * (c.priorHouse || 0);
1172        if (w.priorSenate) m += w.priorSenate * (c.priorSenate || 0
1172);
1173        if (w.priorGov) m += w.priorGov * (c.priorGov || 0);
1174        if (w.presAvg) m += w.presAvg * (c.presAvg || 0);
1175        if (w.pvi) m += w.pvi * (c.pvi || 0);
1176        if (w.cook) m += w.cook * (c.cook || 0);
1177        return m;
1178    };
1179
1180    ET.forecastModelSenateBlend = function (components, pollMargin, pollCount, blendOpts) {
1181        blendOpts = blendOpts || {};
1182        var hasPrior = components.priorSenate != null && isFinite(components.priorSenate);
1183        var hasInc = components.incumbency != null && isFinite(components.incumbency);
1184        var detail = runBlend(components, pollMargin, pollCount, function (n, hasPoll) {
1185            return ET.forecastModelSenateBlendWeights(n, hasPoll, hasPrior, hasInc);
1186        }, blendOpts.stateKey);
1187        detail.forecastKind = 'senate';
1188        return detail;
1189    };
1190
1191    ET.forecastModelGovBlend = function (components, pollMargin, pollCount, blendOpts) {
1192        blendOpts = blendOpts || {};
1193        var hasPrior = components.priorGov != null && isFinite(components.priorGov);
1194        var hasInc = components.incumbency != null && isFinite(components.incumbency);
1195        var detail = runBlend(components, pollMargin, pollCount, function (n, hasPoll) {
1196            return ET.forecastModelGovBlendWeights(n, hasPoll, hasPrior, hasInc);
1197        }, blendOpts.stateKey);
1198        detail.forecastKind = 'gov';
1199        return detail;
1200    };
1201
1202    ET.forecastModelHouseBlend = function (components, pollMargin, pollCount, blendOpts) {
1203        blendOpts = blendOpts || {};
1204        var hasInc = components.incumbency != null && isFinite(components.incumbency);
1205        var hasPrior = components.priorHouse != null && isFinite(components.priorHouse);
1206        var detail = runBlend(components, pollMargin, pollCount, function (n, hasPoll) {
1207            return ET.forecastModelHouseBlendWeights(n, hasPoll, hasInc, hasPrior, blendOpts);
1208        }, blendOpts.stateKey);
1209        detail.forecastKind = 'house';
1210        return forecastModelApplyHouseGopBoost(detail, blendOpts.cookRating);
1211    };
1212
1213    function forecastModelHouseTossupish(detail, cookRating) {
1214        if (!detail) return false;
1215        if (cookRating === 'tossup' || detail.rating === 'tossup') return true;
1216        var pD = detail.winProbD;
1217        if (pD == null || !isFinite(pD)) {
1218            pD = ET.forecastModelMarginToWinProb(detail.margin != null && isFinite(detail.margin) ? detail.margin : 0);
1219        }
1220        var fav = pD >= 0.5 ? pD : 1 - pD;
1221        return fav < 0.575;
1222    }
1223
1224    function forecastModelHouseGopBoostShift(detail, cookRating) {
1225        if (!detail || detail.margin == null || !isFinite(detail.margin)) return 0;
1226        var base = ET.HOUSE_FORECAST_GOP_MARGIN_BOOST != null && isFinite(ET.HOUSE_FORECAST_GOP_MARGIN_BOOST)
1227            ? ET.HOUSE_FORECAST_GOP_MARGIN_BOOST
1228            : 0;
1229        var extra = forecastModelHouseTossupish(detail, cookRating)
1230            ? (ET.HOUSE_FORECAST_GOP_TOSSUP_EXTRA_BOOST != null && isFinite(ET.HOUSE_FORECAST_GOP_TOSSUP_EXTRA_BOOST)
1231                ? ET.HOUSE_FORECAST_GOP_TOSSUP_EXTRA_BOOST
1232                : 0)
1233            : 0;
1234        return base + extra;
1235    }
1236
1237    function forecastModelApplyHouseGopBoost(detail, cookRating) {
1238        if (!detail) return detail;
1239        var shift = forecastModelHouseGopBoostShift(detail, cookRating);
1240        if (!shift) return detail;
1241        detail.margin = detail.margin - shift;
1242        detail.winProbD = ET.forecastModelMarginToWinProb(detail.margin);
1243        detail.winProbR = 1 - detail.winProbD;
1244        detail.rating = ET.forecastModelWinProbToRating(detail.winProbD, detail.code);
1245        detail._houseGopBoostPts = shift;
1246        return detail;
1247    }
1248
1249    ET.forecastModelApplyHouseGopBoost = forecastModelApplyHouseGopBoost;
1250
1251    /** Name-matched margin from the incumbent's previous Senate election. */
1252    function senateIncumbentSameRacePrior(key) {
1253        var v = ET.SENATE_FORECAST_PRIOR_SENATE && ET.SENATE_FORECAST_PRIOR_SENATE[key];
1254        return v != null && isFinite(v) ? v : null;
1255    }
1256
1257    function forecastModelStripStar(name) {
1258        return String(name || '').replace(/\*+$/, '').trim();
1259    }
1260
1261    function forecastModelNormalizeCandName(name) {
1262        var s = forecastModelStripStar(name);
1263        if (!s) return '';
1264        return typeof ET.normalizeCandNamePeriods === 'function' ? ET.normalizeCandNamePeriods(s) : s;
1265    }
1266
1267    function forecastModelCandMatchesIncumbent(name, incName) {
1268        var a = forecastModelNormalizeCandName(name).toLowerCase();
1269        var b = forecastModelNormalizeCandName(incName).toLowerCase();
1270        return !!a && !!b && a === b;
1271    }
1272
1273    function senateRunningIncumbentParty(key) {
1274        if (!key) return null;
1275        if (ET.SENATE_NO_INCUMBENT_2026 && ET.SENATE_NO_INCUMBENT_2026[key]) return null;
1276        var inc = ET.SENATE_INCUMBENT_FULL_2026 && ET.SENATE_INCUMBENT_FULL_2026[key];
1277        if (inc && (inc.party === 'D' || inc.party === 'R')) {
1278            var incCand = typeof ET.candName === 'function' ? ET.candName(key, inc.party) : '';
1279            return forecastModelCandMatchesIncumbent(incCand, inc.name) ? inc.party : null;
1280        }
1281        if (typeof ET.candName === 'function') {
1282            var dFull = ET.candName(key, 'D');
1283            var rFull = ET.candName(key, 'R');
1284            if (ET.candFullNameIsIncumbent && ET.candFullNameIsIncumbent(dFull)) return 'D';
1285            if (ET.candFullNameIsIncumbent && ET.candFullNameIsIncumbent(rFull)) return 'R';
1286        }
1287        return null;
1288    }
1289
1290    /** Incumbent's most recent win in this Senate seat only; only when the incumbent is on the ballot. */
1291    function senatePriorElectionScore(key) {
1292        if (!senateRunningIncumbentParty(key)) return null;
1293        return senateIncumbentSameRacePrior(key);
1294    }
1295
1296    function senatePresAvgScore(key) {
1297        if (!ET.SENATE_FORECAST_PRES_AVG || ET.SENATE_FORECAST_PRES_AVG[key] == null) return 0;
1298        return ET.SENATE_FORECAST_PRES_AVG[key];
1299    }
1300
1301    function senateIncumbencyScore(key) {
1302        var inc = senateRunningIncumbentParty(key);
1303        if (!inc) return null;
1304        if (key === 'NE' || key === 'MT') return 0;
1305        return inc === 'R' ? -INCUMBENCY_SCORE : INCUMBENCY_SCORE;
1306    }
1307
1308    function govRunningIncumbentParty(key) {
1309        if (!key) return null;
1310        if (ET.GOV_NO_INCUMBENT_2026 && ET.GOV_NO_INCUMBENT_2026[key]) return null;
1311        var inc = ET.GOV_INCUMBENT_FULL_2026 && ET.GOV_INCUMBENT_FULL_2026[key];
1312        if (inc && (inc.party === 'D' || inc.party === 'R')) {
1313            var incCand = typeof ET.govCandName === 'function' ? ET.govCandName(key, inc.party) : '';
1314            return forecastModelCandMatchesIncumbent(incCand, inc.name) ? inc.party : null;
1315        }
1316        if (typeof ET.govCandName === 'function') {
1317            var dFull = ET.govCandName(key, 'D');
1318            var rFull = ET.govCandName(key, 'R');
1319            if (ET.candFullNameIsIncumbent && ET.candFullNameIsIncumbent(dFull)) return 'D';
1320            if (ET.candFullNameIsIncumbent && ET.candFullNameIsIncumbent(rFull)) return 'R';
1321        }
1322        return null;
1323    }
1324
1325    function govIncumbencyScore(key) {
1326        var inc = govRunningIncumbentParty(key);
1327        if (!inc) return null;
1328        return inc === 'R' ? -INCUMBENCY_SCORE : INCUMBENCY_SCORE;
1329    }
1330
1331    function govIncumbentSameRacePrior(key) {
1332        var v = ET.GOV_FORECAST_PRIOR_GOV && ET.GOV_FORECAST_PRIOR_GOV[key];
1333        return v != null && isFinite(v) ? v : null;
1334    }
1335
1336    /** Incumbent's most recent gov win in this state only; only when the incumbent is on the ballot. */
1337    function govPriorElectionScore(key) {
1338        if (!govRunningIncumbentParty(key)) return null;
1339        return govIncumbentSameRacePrior(key);
1340    }
1341
1342    function govPresAvgScore(key) {
1343        if (!ET.GOV_FORECAST_PRES_AVG || ET.GOV_FORECAST_PRES_AVG[key] == null) return 0;
1344        return ET.GOV_FORECAST_PRES_AVG[key];
1345    }
1346
1347    /** Normalize KS-01 → KS-1 to match Cook PVI / incumbent lookup keys. */
1348    function houseDistrictLookupKey(code) {
1349        if (typeof ET.houseDistrictCodeForIncumbentLookup === 'function') {
1350            return ET.houseDistrictCodeForIncumbentLookup(code);
1351        }
1352        var m = /^([A-Z]{2})-(\d+|AL)$/i.exec(String(code || '').trim());
1353        if (!m) return code;
1354        var st = m[1].toUpperCase();
1355        var dist = m[2].toUpperCase();
1356        if (dist === 'AL') return st + '-AL';
1357        return st + '-' + parseInt(dist, 10);
1358    }
1359
1360    function houseIncumbencyScore(code) {
1361        var key = houseDistrictLookupKey(code);
1362        var row = ET.HOUSE_RATING_INCUMBENT_BY_CODE && ET.HOUSE_RATING_INCUMBENT_BY_CODE[key];
1363        if (!row || row.open || !row.party) return null;
1364        if (row.party === 'R') return -INCUMBENCY_SCORE;
1365        if (row.party === 'D') return INCUMBENCY_SCORE;
1366        return null;
1367    }
1368
1369    function housePreviousElectionScore(code) {
1370        var st = houseStateKeyFromCode(code);
1371        if (st && HOUSE_PRIOR_RESULT_EXEMPT_STATES[st]) return null;
1372        var key = houseDistrictLookupKey(code);
1373        if (key && HOUSE_PREVIOUS_RESULT_BY_CODE[key] != null && isFinite(HOUSE_PREVIOUS_RESULT_BY_CODE[key])) {
1374            return HOUSE_PREVIOUS_RESULT_BY_CODE[key];
1375        }
1376        return null;
1377    }
1378
1379    function sortPollsChrono(series) {
1380        return series.slice().sort(function (a, b) {
1381            if (a.date < b.date) return -1;
1382            if (a.date > b.date) return 1;
1383            return 0;
1384        });
1385    }
1386
1387    function marginFromPollRows(rows) {
1388        if (!rows || !rows.length) return null;
1389        var d = 0;
1390        var r = 0;
1391        var n = 0;
1392        var i;
1393        for (i = 0; i < rows.length; i++) {
1394            if (rows[i].avgExclude) continue;
1395            if (typeof rows[i].D === 'number' && typeof rows[i].R === 'number') {
1396                d += rows[i].D;
1397                r += rows[i].R;
1398                n++;
1399            }
1400        }
1401        if (!n) return null;
1402        return d / n - r / n;
1403    }
1404
1405    function latestDrMarginFromAvgs(dAvg, rAvg) {
1406        var i;
1407        for (i = dAvg.length - 1; i >= 0; i--) {
1408            if (dAvg[i] != null && rAvg[i] != null) {
1409                return { margin: dAvg[i] - rAvg[i], dPct: dAvg[i], rPct: rAvg[i] };
1410            }
1411        }
1412        return null;
1413    }
1414
1415    function senatePollInfoFromRows(key, pollRows, asOfIso) {
1416        if (typeof ET.senateIsForecastOnlyGe === 'function' && ET.senateIsForecastOnlyGe(key)) {
1417            return { count: 0, margin: null, dPct: null, rPct: null };
1418        }
1419        if (!pollRows || !pollRows.length) return { count: 0, margin: null, dPct: null, rPct: null };
1420        /* As-of path uses the exponential roll, which skips runningAvgSenateOnLabels.
1421           Apply the same state cheats (GOP boosts) the polling page adds to the modeled average. */
1422        function withSenateCheat(info) {
1423            if (!info || info.dPct == null || info.rPct == null) return info;
1424            if (typeof ET.senateStatePollingAvgCheat !== 'function') return info;
1425            var cheat = ET.senateStatePollingAvgCheat(key);
1426            if (!cheat) return info;
1427            var dAdd = cheat.D != null && isFinite(cheat.D) ? cheat.D : 0;
1428            var rAdd = cheat.R != null && isFinite(cheat.R) ? cheat.R : 0;
1429            if (!dAdd && !rAdd) return info;
1430            var d = info.dPct + dAdd;
1431            var r = info.rPct + rAdd;
1432            return { count: info.count, margin: d - r, dPct: d, rPct: r };
1433        }
1434        if (asOfIso) {
1435            var rollS = rollingRaceDrAtDate(pollRows, asOfIso);
1436            if (rollS) {
1437                return withSenateCheat({
1438                    count: pollRows.length,
1439                    margin: rollS.margin,
1440                    dPct: rollS.dPct,
1441                    rPct: rollS.rPct
1442                });
1443            }
1444        }
1445        if (ET.runningAvgSenateOnLabels && (ET.dailyRangeToTodayET || asOfIso)) {
1446            var seriesForAvg = ET.senateSeriesForAverage ? ET.senateSeriesForAverage(pollRows, key) : pollRows;
1447            var labelsPoll;
1448            if (asOfIso) {
1449                labelsPoll = dailyLabelsEndingAt(FORECAST_TREND_START, asOfIso);
1450            } else {
1451                labelsPoll = ET.dailyRangeToTodayET(pollRows);
1452                if (ET.extendLabelsTo) labelsPoll = ET.extendLabelsTo(labelsPoll, '2025-10-31');
1453            }
1454            var dAvg = ET.runningAvgSenateOnLabels(seriesForAvg, 'D', labelsPoll, { statePageAdaptiveRecency: true, stateKey: key });
1455            var rAvg = ET.runningAvgSenateOnLabels(seriesForAvg, 'R', labelsPoll, { statePageAdaptiveRecency: true, stateKey: key });
1456            var pair = asOfIso
1457                ? marginFromAvgsAtDate(dAvg, rAvg, labelsPoll, asOfIso)
1458                : latestDrMarginFromAvgs(dAvg, rAvg);
1459            if (pair) return { count: pollRows.length, margin: pair.margin, dPct: pair.dPct, rPct: pair.rPct };
1460        }
1461        var m = marginFromPollRows(pollRows);
1462        if (m != null && typeof ET.senateStatePollingAvgCheat === 'function') {
1463            var cheatM = ET.senateStatePollingAvgCheat(key);
1464            if (cheatM) m += (cheatM.D || 0) - (cheatM.R || 0);
1465        }
1466        return { count: pollRows.length, margin: m, dPct: null, rPct: null };
1467    }
1468
1469    function govPollInfoFromRows(key, pollRows, asOfIso) {
1470        if (!pollRows || !pollRows.length) return { count: 0, margin: null, dPct: null, rPct: null };
1471        function withGovCheat(info) {
1472            if (!info || info.dPct == null || info.rPct == null) return info;
1473            if (typeof ET.applyGovStatePollingAvgCheatToDr !== 'function') return info;
1474            var cheated = ET.applyGovStatePollingAvgCheatToDr(key, info.dPct, info.rPct);
1475            if (!cheated) return info;
1476            return {
1477                count: info.count,
1478                margin: cheated.margin,
1479                dPct: cheated.D,
1480                rPct: cheated.R
1481            };
1482        }
1483        /* Prefer the published modeled avg (same path as hub/polling pages) so bake
1484           matches the on-page avg — including GOV_STATE_POLLING_AVG_CHEATS. */
1485        if (ET.runningAvgOnLabels && (ET.dailyRangeToTodayET || asOfIso)) {
1486            var seriesForAvg = ET.govSeriesForGovernorPollingModel
1487                ? ET.govSeriesForGovernorPollingModel(pollRows, key)
1488                : (ET.govSeriesForAverage ? ET.govSeriesForAverage(pollRows) : pollRows);
1489            var labelsPoll;
1490            if (asOfIso) {
1491                labelsPoll = dailyLabelsEndingAt(FORECAST_TREND_START, asOfIso);
1492            } else {
1493                labelsPoll = ET.dailyRangeToTodayET(pollRows);
1494                if (ET.extendLabelsTo) labelsPoll = ET.extendLabelsTo(labelsPoll, '2025-10-31');
1495            }
1496            /* skipStateAvgCheat: apply once via withGovCheat so as-of + live match. */
1497            var govAvgOpts = { adaptiveRecencyPowerFromSeries: true, stateKey: key, skipStateAvgCheat: true };
1498            var dAvg = ET.runningAvgOnLabels(seriesForAvg, 'D', labelsPoll, govAvgOpts);
1499            var rAvg = ET.runningAvgOnLabels(seriesForAvg, 'R', labelsPoll, govAvgOpts);
1500            var pair = asOfIso
1501                ? marginFromAvgsAtDate(dAvg, rAvg, labelsPoll, asOfIso)
1502                : latestDrMarginFromAvgs(dAvg, rAvg);
1503            if (pair) {
1504                return withGovCheat({
1505                    count: pollRows.length,
1506                    margin: pair.margin,
1507                    dPct: pair.dPct,
1508                    rPct: pair.rPct
1509                });
1510            }
1511        }
1512        if (asOfIso) {
1513            var rollG = rollingRaceDrAtDate(pollRows, asOfIso);
1514            if (rollG) {
1515                return withGovCheat({
1516                    count: pollRows.length,
1517                    margin: rollG.margin,
1518                    dPct: rollG.dPct,
1519                    rPct: rollG.rPct
1520                });
1521            }
1522        }
1523        var m = marginFromPollRows(pollRows);
1524        if (m != null && typeof ET.govStatePollingAvgCheat === 'function') {
1525            var cheatM = ET.govStatePollingAvgCheat(key);
1526            if (cheatM) {
1527                m += (cheatM.D || 0) - (cheatM.R || 0);
1528            }
1529        }
1530        return { count: pollRows.length, margin: m, dPct: null, rPct: null };
1531    }
1532
1533    function housePollInfoFromRows(pollRows, pollOpts) {
1534        pollOpts = pollOpts || {};
1535        if (!pollRows || !pollRows.length) return { count: 0, margin: null, dPct: null, rPct: null };
1536        var asOfIso = pollOpts.asOfIso;
1537        var raceMeta = pollOpts.raceMeta;
1538        if (asOfIso) {
1539            var rollH = rollingRaceDrAtDate(pollRows, asOfIso);
1540            if (rollH) {
1541                return { count: pollRows.length, margin: rollH.margin, dPct: rollH.dPct, rPct: rollH.rPct };
1542            }
1543        }
1544        if (asOfIso && ET.runningAvgOnLabels && ET.nextISO) {
1545            var series = pollRows.slice().sort(function (a, b) {
1546                return a.date < b.date ? -1 : (a.date > b.date ? 1 : 0);
1547            });
1548            var race = raceMeta || { series: pollRows };
1549            var usePartyTotals = !!(race.caOpenPrimary && ET.pollPartyTotalsDR);
1550            var modelSeries = usePartyTotals && ET.houseCaOpenPartyTotalSeries
1551                ? ET.houseCaOpenPartyTotalSeries(series)
1552                : (ET.houseSeriesForAverage ? ET.houseSeriesForAverage(series) : series);
1553            var labels = dailyLabelsEndingAt(FORECAST_TREND_START, asOfIso);
1554            var avgOpts = { statePageAdaptiveRecency: true };
1555            var lineD = ET.runningAvgOnLabels(modelSeries, 'D', labels, avgOpts);
1556            var lineR = ET.runningAvgOnLabels(modelSeries, 'R', labels, avgOpts);
1557            var pair = marginFromAvgsAtDate(lineD, lineR, labels, asOfIso);
1558            if (pair) return { count: pollRows.length, margin: pair.margin, dPct: pair.dPct, rPct: pair.rPct };
1559        }
1560        if (!asOfIso && ET.houseLatestAvg && pollRows.length) {
1561            var tracked = raceMeta || { series: pollRows };
1562            var avg = ET.houseLatestAvg(tracked);
1563            if (avg && avg.margin != null) {
1564                var dPct = avg.D != null ? avg.D : (avg.margin >= 0 ? 50 + avg.margin / 2 : 50 - Math.abs(avg.margin) / 2);
1565                var rPct = avg.R != null ? avg.R : 100 - dPct;
1566                return { count: pollRows.length, margin: avg.margin, dPct: dPct, rPct: rPct };
1567            }
1568        }
1569        var m = marginFromPollRows(pollRows);
1570        return { count: pollRows.length, margin: m, dPct: null, rPct: null };
1571    }
1572
1573    ET.forecastModelSenatePollInfoAtCount = function (key, pollCount) {
1574        if (!ET.SENATE || !key || !ET.SENATE[key] || !ET.SENATE[key].length) return { count: 0, margin: null };
1575        var sorted = sortPollsChrono(ET.SENATE[key]);
1576        var n = typeof pollCount === 'number' && pollCount >= 0
1577            ? Math.min(pollCount, sorted.length)
1578            : sorted.length;
1579        var subset = sorted.slice(0, n);
1580        return { count: n, margin: marginFromPollRows(subset) };
1581    };
1582
1583    function senatePollInfo(key, name) {
1584        if (typeof ET.senateIsForecastOnlyGe === 'function' && ET.senateIsForecastOnlyGe(key)) {
1585            return { count: 0, margin: null, dPct: null, rPct: null };
1586        }
1587        if (!ET.SENATE || !key || !ET.SENATE[key] || !ET.SENATE[key].length) return { count: 0, margin: null };
1588        var at = ET.forecastModelSenatePollInfoAtCount(key, ET.SENATE[key].length);
1589        if (at.count && at.margin != null) return at;
1590        var avg = ET.latestAvgByName && name ? ET.latestAvgByName(name) : null;
1591        return { count: ET.SENATE[key].length, margin: avg ? avg.margin : null };
1592    }
1593
1594    ET.forecastModelGovPollInfoAtCount = function (key, pollCount) {
1595        var series = ET.govSeriesWithLocalFallback ? ET.govSeriesWithLocalFallback(key) : (ET.govSeries ? ET.govSeries(key) : []);
1596        if (!series || !series.length) return { count: 0, margin: null };
1597        var sorted = sortPollsChrono(series);
1598        var n = typeof pollCount === 'number' && pollCount >= 0
1599            ? Math.min(pollCount, sorted.length)
1600            : sorted.length;
1601        if (typeof ET.isForecastRacePageView === 'function' && ET.isForecastRacePageView() &&
1602            typeof ET.latestGovAvgForForecast === 'function') {
1603            var fc = ET.latestGovAvgForForecast(key);
1604            if (fc && fc.D != null && fc.R != null && fc.margin != null) {
1605                return { count: n, margin: fc.margin, dPct: fc.D, rPct: fc.R };
1606            }
1607        }
1608        return { count: n, margin: marginFromPollRows(sorted.slice(0, n)) };
1609    };
1610
1611    function govPollInfo(key) {
1612        var at = ET.forecastModelGovPollInfoAtCount(key, null);
1613        if (at.count) return at;
1614        var series = ET.govSeriesWithLocalFallback ? ET.govSeriesWithLocalFallback(key) : (ET.govSeries ? ET.govSeries(key) : []);
1615        if (!series || !series.length) return { count: 0, margin: null };
1616        var avg = ET.latestGovAvgForHub ? ET.latestGovAvgForHub(key) : (ET.latestGovAvgByKey ? ET.latestGovAvgByKey(key) : null);
1617        var margin = null;
1618        if (avg) {
1619            if (key === 'MI' && avg.leader && avg.margin != null) {
1620                margin = avg.leader === 'D' ? Math.abs(avg.margin) : -Math.abs(avg.margin);
1621            } else if (avg.D != null && avg.R != null) margin = avg.D - avg.R;
1622        }
1623        return { count: series.length, margin: margin };
1624    }
1625
1626    ET.forecastModelGovDetailWithPollRows = function (key, pollRows, opts) {
1627        opts = opts || {};
1628        var env = opts.env || ET.forecastModelNationalEnvironment();
1629        var pvi = ET.COOK_STATE_PVI && ET.COOK_STATE_PVI[key] != null ? ET.COOK_STATE_PVI[key] : 0;
1630        var cookRating = govEditorialRatingForModel(key);
1631        var cookM = ET.forecastModelGovEditorialBlendMargin(key, cookRating);
1632        var poll = govPollInfoFromRows(key, pollRows || [], opts.asOfIso);
1633        var inc = govIncumbencyScore(key);
1634        var prior = govPriorElectionScore(key);
1635        var components = {
1636            pvi: pvi,
1637            generic: env.genericMargin,
1638            trump: env.trumpProD,
1639            incumbency: inc,
1640            priorGov: prior,
1641            presAvg: govPresAvgScore(key),
1642            cook: cookM
1643        };
1644        var detail = ET.forecastModelGovBlend(components, poll.margin, poll.count, {
1645            cookRating: cookRating,
1646            stateKey: key
1647        });
1648        detail.hasPriorGov = prior != null;
1649        detail.hasIncumbency = inc != null;
1650        detail.pollDPct = poll.dPct;
1651        detail.pollRPct = poll.rPct;
1652        return detail;
1653    };
1654
1655    ET.forecastModelGovDetailAtPollCount = function (key, pollCount) {
1656        var env = ET.forecastModelNationalEnvironment();
1657        var pvi = ET.COOK_STATE_PVI && ET.COOK_STATE_PVI[key] != null ? ET.COOK_STATE_PVI[key] : 0;
1658        var cookRating = govEditorialRatingForModel(key);
1659        var cookM = ET.forecastModelGovEditorialBlendMargin(key, cookRating);
1660        var poll = ET.forecastModelGovPollInfoAtCount(key, pollCount);
1661        var inc = govIncumbencyScore(key);
1662        var prior = govPriorElectionScore(key);
1663        var components = {
1664            pvi: pvi,
1665            generic: env.genericMargin,
1666            trump: env.trumpProD,
1667            incumbency: inc,
1668            priorGov: prior,
1669            presAvg: govPresAvgScore(key),
1670            cook: cookM
1671        };
1672        var detail = ET.forecastModelGovBlend(components, poll.margin, poll.count, {
1673            cookRating: cookRating,
1674            stateKey: key
1675        });
1676        detail.hasPriorGov = prior != null;
1677        detail.hasIncumbency = inc != null;
1678        return detail;
1679    };
1680
1681    ET.forecastModelHousePollInfoAtCount = function (code, pollCount) {
1682        var row = ET.HOUSE_RATING_BY_CODE && ET.HOUSE_RATING_BY_CODE[code];
1683        var tracked = row && row.geoid && ET.HOUSE_BY_GEOID ? ET.HOUSE_BY_GEOID[row.geoid] : null;
1684        if (!tracked || !tracked.series || !tracked.series.length) return { count: 0, margin: null };
1685        var sorted = sortPollsChrono(tracked.series);
1686        var n = typeof pollCount === 'number' && pollCount >= 0
1687            ? Math.min(pollCount, sorted.length)
1688            : sorted.length;
1689        return { count: n, margin: marginFromPollRows(sorted.slice(0, n)) };
1690    };
1691
1692    function housePollInfo(code) {
1693        var at = ET.forecastModelHousePollInfoAtCount(code, null);
1694        if (at.count) return at;
1695        var row = ET.HOUSE_RATING_BY_CODE && ET.HOUSE_RATING_BY_CODE[code];
1696        var tracked = row && row.geoid && ET.HOUSE_BY_GEOID ? ET.HOUSE_BY_GEOID[row.geoid] : null;
1697        if (!tracked || !tracked.series || !tracked.series.length) return { count: 0, margin: null };
1698        var avg = ET.houseLatestAvg ? ET.houseLatestAvg(tracked) : null;
1699        return { count: tracked.series.length, margin: avg && avg.margin != null ? avg.margin : null };
1700    }
1701
1702    ET.forecastModelHouseDetailWithPollRows = function (code, pollRows, opts) {
1703        opts = opts || {};
1704        var env = opts.env || ET.forecastModelNationalEnvironment();
1705        var houseSt = houseStateKeyFromCode(code);
1706        var pvi = ET.forecastModelHouseDistrictPvi(code);
1707        var cookRating = ET.COOK_HOUSE_RATINGS && ET.COOK_HOUSE_RATINGS[code];
1708        var cookM = ET.forecastModelHouseCookBlendMargin(code, cookRating);
1709        var poll = housePollInfoFromRows(pollRows || [], {
1710            asOfIso: opts.asOfIso,
1711            raceMeta: opts.raceMeta
1712        });
1713        var inc = houseIncumbencyScore(code);
1714        var prior = housePreviousElectionScore(code);
1715        var components = {
1716            pvi: pvi,
1717            generic: env.genericMargin,
1718            trump: env.trumpProD,
1719            incumbency: inc,
1720            priorHouse: prior,
1721            cook: cookM
1722        };
1723        var detail = ET.forecastModelHouseBlend(components, poll.margin, poll.count, {
1724            cookRating: cookRating,
1725            stateKey: houseSt
1726        });
1727        detail.hasIncumbency = inc != null;
1728        detail.hasPriorHouse = prior != null;
1729        detail.pollDPct = poll.dPct;
1730        detail.pollRPct = poll.rPct;
1731        return detail;
1732    };
1733
1734    ET.forecastModelHouseDetailAtPollCount = function (code, pollCount) {
1735        var env = ET.forecastModelNationalEnvironment();
1736        var houseSt = houseStateKeyFromCode(code);
1737        var pvi = ET.forecastModelHouseDistrictPvi(code);
1738        var cookRating = ET.COOK_HOUSE_RATINGS && ET.COOK_HOUSE_RATINGS[code];
1739        var cookM = ET.forecastModelHouseCookBlendMargin(code, cookRating);
1740        var poll = ET.forecastModelHousePollInfoAtCount(code, pollCount);
1741        var inc = houseIncumbencyScore(code);
1742        var prior = housePreviousElectionScore(code);
1743        var components = {
1744            pvi: pvi,
1745            generic: env.genericMargin,
1746            trump: env.trumpProD,
1747            incumbency: inc,
1748            priorHouse: prior,
1749            cook: cookM
1750        };
1751        var detail = ET.forecastModelHouseBlend(components, poll.margin, poll.count, {
1752            cookRating: cookRating,
1753            stateKey: houseSt
1754        });
1755        detail.hasIncumbency = inc != null;
1756        detail.hasPriorHouse = prior != null;
1757        return detail;
1758    };
1759
1760    ET.forecastModelSenateDetailWithPollRows = function (key, name, pollRows, opts) {
1761        opts = opts || {};
1762        if (typeof ET.senateIsForecastOnlyGe === 'function' && ET.senateIsForecastOnlyGe(key)) {
1763            pollRows = [];
1764        }
1765        var env = opts.env || ET.forecastModelNationalEnvironment();
1766        var pvi = ET.COOK_STATE_PVI && ET.COOK_STATE_PVI[key] != null ? ET.COOK_STATE_PVI[key] : 0;
1767        var cookRating = senateEditorialRatingForModel(key, opts.asOfIso);
1768        var cookM = ET.forecastModelSenateEditorialBlendMargin(key, cookRating);
1769        var poll = senatePollInfoFromRows(key, pollRows || [], opts.asOfIso);
1770        var inc = senateIncumbencyScore(key);
1771        var prior = senatePriorElectionScore(key);
1772        var components = {
1773            pvi: pvi,
1774            generic: env.genericMargin,
1775            trump: env.trumpProD,
1776            incumbency: inc,
1777            priorSenate: prior,
1778            presAvg: senatePresAvgScore(key),
1779            cook: cookM
1780        };
1781        var detail = ET.forecastModelSenateBlend(components, poll.margin, poll.count, {
1782            cookRating: cookRating,
1783            stateKey: key
1784        });
1785        detail.hasPriorSenate = prior != null;
1786        detail.hasIncumbency = inc != null;
1787        detail.pollDPct = poll.dPct;
1788        detail.pollRPct = poll.rPct;
1789        return detail;
1790    };
1791
1792    ET.forecastModelSenateDetailAtPollCount = function (key, name, pollCount) {
1793        var env = ET.forecastModelNationalEnvironment();
1794        var pvi = ET.COOK_STATE_PVI && ET.COOK_STATE_PVI[key] != null ? ET.COOK_STATE_PVI[key] : 0;
1795        var cookRating = senateEditorialRatingForModel(key);
1796        var cookM = ET.forecastModelSenateEditorialBlendMargin(key, cookRating);
1797        var poll = ET.forecastModelSenatePollInfoAtCount(key, pollCount);
1798        var inc = senateIncumbencyScore(key);
1799        var prior = senatePriorElectionScore(key);
1800        var components = {
1801            pvi: pvi,
1802            generic: env.genericMargin,
1803            trump: env.trumpProD,
1804            incumbency: inc,
1805            priorSenate: prior,
1806            presAvg: senatePresAvgScore(key),
1807            cook: cookM
1808        };
1809        var detail = ET.forecastModelSenateBlend(components, poll.margin, poll.count, {
1810            cookRating: cookRating,
1811            stateKey: key
1812        });
1813        detail.hasPriorSenate = prior != null;
1814        detail.hasIncumbency = inc != null;
1815        return detail;
1816    };
1817
1818    ET.forecastModelSenateDetail = function (key, name) {
1819        if (typeof ET.senateIsForecastOnlyGe === 'function' && ET.senateIsForecastOnlyGe(key)) {
1820            return ET.forecastModelSenateDetailAtPollCount(key, name, 0);
1821        }
1822        var env = ET.forecastModelNationalEnvironment();
1823        var pvi = ET.COOK_STATE_PVI && ET.COOK_STATE_PVI[key] != null ? ET.COOK_STATE_PVI[key] : 0;
1824        var cookRating = senateEditorialRatingForModel(key);
1825        var cookM = ET.forecastModelSenateEditorialBlendMargin(key, cookRating);
1826        var poll = senatePollInfo(key, name);
1827        var inc = senateIncumbencyScore(key);
1828        var prior = senatePriorElectionScore(key);
1829        var components = {
1830            pvi: pvi,
1831            generic: env.genericMargin,
1832            trump: env.trumpProD,
1833            incumbency: inc,
1834            priorSenate: prior,
1835            presAvg: senatePresAvgScore(key),
1836            cook: cookM
1837        };
1838        var detail = ET.forecastModelSenateBlend(components, poll.margin, poll.count, {
1839            cookRating: cookRating,
1840            stateKey: key
1841        });
1842        detail.hasPriorSenate = prior != null;
1843        detail.hasIncumbency = inc != null;
1844        return detail;
1845    };
1846
1847    ET.forecastModelSenateRating = function (key, name) {
1848        return ET.forecastModelSenateDetail(key, name).rating;
1849    };
1850
1851    ET.forecastModelGovDetail = function (key) {
1852        var env = ET.forecastModelNationalEnvironment();
1853        var pvi = ET.COOK_STATE_PVI && ET.COOK_STATE_PVI[key] != null ? ET.COOK_STATE_PVI[key] : 0;
1854        var cookRating = govEditorialRatingForModel(key);
1855        var cookM = ET.forecastModelGovEditorialBlendMargin(key, cookRating);
1856        var poll = govPollInfo(key);
1857        var inc = govIncumbencyScore(key);
1858        var prior = govPriorElectionScore(key);
1859        var components = {
1860            pvi: pvi,
1861            generic: env.genericMargin,
1862            trump: env.trumpProD,
1863            incumbency: inc,
1864            priorGov: prior,
1865            presAvg: govPresAvgScore(key),
1866            cook: cookM
1867        };
1868        var detail = ET.forecastModelGovBlend(components, poll.margin, poll.count, {
1869            cookRating: cookRating,
1870            stateKey: key
1871        });
1872        detail.hasPriorGov = prior != null;
1873        detail.hasIncumbency = inc != null;
1874        return detail;
1875    };
1876
1877    ET.forecastModelGovRating = function (key) {
1878        return ET.forecastModelGovDetail(key).rating;
1879    };
1880
1881    /** House forecast scales raw Cook district PVI before blend (Senate/state PVI unchanged). */
1882    ET.HOUSE_FORECAST_PVI_SCALE = 1.5;
1883
1884    /**
1885     * District PVI for House forecast. Tries padded code (KS-01), unpadded Cook key (KS-1),
1886     * then statewide PVI for -AL seats. Raw Cook values are scaled by HOUSE_FORECAST_PVI_SCALE.
1887     */
1888    ET.forecastModelHouseDistrictPvi = function (code) {
1889        var d = ET.COOK_DISTRICT_PVI;
1890        if (!d) return 0;
1891        var raw = null;
1892        if (d[code] != null) raw = d[code];
1893        else {
1894            var key = houseDistrictLookupKey(code);
1895            if (key && d[key] != null) raw = d[key];
1896            else if (code && /-AL$/i.test(code) && ET.COOK_STATE_PVI) {
1897                var st = code.slice(0, 2).toUpperCase();
1898                if (ET.COOK_STATE_PVI[st] != null) raw = ET.COOK_STATE_PVI[st];
1899            }
1900        }
1901        if (raw == null || !isFinite(raw)) return 0;
1902        var scale = ET.HOUSE_FORECAST_PVI_SCALE != null && isFinite(ET.HOUSE_FORECAST_PVI_SCALE)
1903            ? ET.HOUSE_FORECAST_PVI_SCALE
1904            : 1;
1905        return raw * scale;
1906    };
1907
1908    ET.forecastModelHouseDetail = function (code) {
1909        var env = ET.forecastModelNationalEnvironment();
1910        var houseSt = houseStateKeyFromCode(code);
1911        var pvi = ET.forecastModelHouseDistrictPvi(code);
1912        var cookRating = ET.COOK_HOUSE_RATINGS && ET.COOK_HOUSE_RATINGS[code];
1913        var cookM = ET.forecastModelHouseCookBlendMargin(code, cookRating);
1914        var poll = housePollInfo(code);
1915        var inc = houseIncumbencyScore(code);
1916        var prior = housePreviousElectionScore(code);
1917        var components = {
1918            pvi: pvi,
1919            generic: env.genericMargin,
1920            trump: env.trumpProD,
1921            incumbency: inc,
1922            priorHouse: prior,
1923            cook: cookM
1924        };
1925        var detail = ET.forecastModelHouseBlend(components, poll.margin, poll.count, {
1926            cookRating: cookRating,
1927            stateKey: houseSt
1928        });
1929        detail.hasIncumbency = inc != null;
1930        detail.hasPriorHouse = prior != null;
1931        detail.code = code;
1932        return detail;
1933    };
1934
1935    ET.forecastModelHouseRating = function (code) {
1936        return ET.forecastModelHouseDetail(code).rating;
1937    };
1938
1939    ET.forecastModelNationalEnvironmentStrictBeforeForTrend = function (asOfIso) {
1940        var gbAll = typeof window !== 'undefined' ? window.GENERIC_BALLOT_POLLS : null;
1941        var taAll = typeof window !== 'undefined' ? window.TRUMP_APPROVAL_POLLS : null;
1942        var gb = asOfIso ? pollsStrictlyBeforeDate(gbAll, asOfIso) : gbAll;
1943        var ta = asOfIso ? pollsStrictlyBeforeDate(taAll, asOfIso) : taAll;
1944        return nationalEnvironmentFromPolls(gb, ta, asOfIso, nationalMeanAsOfForTrend);
1945    };
1946
1947    /**
1948     * @param {string} key
1949     * @param {string} name
1950     * @param {string} asOfIso
1951     * @param {{ pollsBeforeDateOnly?: boolean }} [detailOpts]
1952     */
1953    ET.forecastModelSenateDetailAsOfDate = function (key, name, asOfIso, detailOpts) {
1954        detailOpts = detailOpts || {};
1955        var env = detailOpts.pollsBeforeDateOnly
1956            ? ET.forecastModelNationalEnvironmentStrictBeforeForTrend(asOfIso)
1957            : ET.forecastModelNationalEnvironmentAsOfForTrend(asOfIso);
1958        if (typeof ET.senateIsForecastOnlyGe === 'function' && ET.senateIsForecastOnlyGe(key)) {
1959            return ET.forecastModelSenateDetailWithPollRows(key, name, [], { env: env, asOfIso: asOfIso });
1960        }
1961        var all = ET.SENATE && key && ET.SENATE[key] ? ET.SENATE[key] : [];
1962        var available = detailOpts.pollsBeforeDateOnly
1963            ? racePollsStrictlyBeforeDate(all, asOfIso)
1964            : racePollsOnOrBeforeDate(all, asOfIso);
1965        return ET.forecastModelSenateDetailWithPollRows(key, name, available, { env: env, asOfIso: asOfIso });
1966    };
1967
1968    ET.forecastModelGovDetailAsOfDate = function (key, asOfIso, detailOpts) {
1969        detailOpts = detailOpts || {};
1970        var series = ET.govSeriesWithLocalFallback
1971            ? ET.govSeriesWithLocalFallback(key)
1972            : (ET.govSeries ? ET.govSeries(key) : []);
1973        var available = detailOpts.pollsBeforeDateOnly
1974            ? racePollsStrictlyBeforeDate(series || [], asOfIso)
1975            : racePollsOnOrBeforeDate(series || [], asOfIso);
1976        var env = detailOpts.pollsBeforeDateOnly
1977            ? ET.forecastModelNationalEnvironmentStrictBeforeForTrend(asOfIso)
1978            : ET.forecastModelNationalEnvironmentAsOfForTrend(asOfIso);
1979        return ET.forecastModelGovDetailWithPollRows(key, available, { env: env, asOfIso: asOfIso });
1980    };
1981
1982    ET.forecastModelHouseDetailAsOfDate = function (code, asOfIso) {
1983        var lockParty = typeof ET.housePartyLockoutParty === 'function' ? ET.housePartyLockoutParty(code) : null;
1984        if (lockParty && typeof ET.housePartyLockoutForecastDetail === 'function') {
1985            return ET.housePartyLockoutForecastDetail(code, lockParty);
1986        }
1987        var row = ET.HOUSE_RATING_BY_CODE && ET.HOUSE_RATING_BY_CODE[code];
1988        var tracked = row && row.geoid && ET.HOUSE_BY_GEOID ? ET.HOUSE_BY_GEOID[row.geoid] : null;
1989        var all = tracked && tracked.series ? tracked.series : [];
1990        var available = racePollsOnOrBeforeDate(all, asOfIso);
1991        var env = ET.forecastModelNationalEnvironmentAsOfForTrend(asOfIso);
1992        return ET.forecastModelHouseDetailWithPollRows(code, available, {
1993            env: env,
1994            asOfIso: asOfIso,
1995            raceMeta: tracked
1996        });
1997    };
1998
1999    /**
2000     * Full forecast blend for a race page context on a calendar day (ET).
2001     * ctx: { kind: 'senate'|'gov'|'house', stateKey, houseRace }
2002     */
2003    ET.forecastModelRaceDetailAsOf = function (ctx, asOfIso) {
2004        if (!ctx || !asOfIso) return null;
2005        if (ctx.kind === 'senate') {
2006            var nm = ET.NAME_BY_KEY && ET.NAME_BY_KEY[ctx.stateKey];
2007            if (!nm) return null;
2008            return ET.forecastModelSenateDetailAsOfDate(ctx.stateKey, nm, asOfIso);
2009        }
2010        if (ctx.kind === 'gov') {
2011            return ET.forecastModelGovDetailAsOfDate(ctx.stateKey, asOfIso);
2012        }
2013        if (ctx.kind === 'house' && ctx.houseRace) {
2014            var row = typeof ET.houseRatingRowForRace === 'function'
2015                ? ET.houseRatingRowForRace(ctx.houseRace)
2016                : null;
2017            var codeH = row && row.code ? row.code : ctx.houseRace.code;
2018            if (codeH && typeof ET.houseDistrictCodeForIncumbentLookup === 'function') {
2019                codeH = ET.houseDistrictCodeForIncumbentLookup(codeH) || codeH;
2020            }
2021            if (codeH) return ET.forecastModelHouseDetailAsOfDate(codeH, asOfIso);
2022        }
2023        return null;
2024    };
2025
2026    /** Daily trend series — same composite as headline forecast and simulation histogram. */
2027    ET.forecastModelRaceDetailAsOfForTrend = function (ctx, asOfIso) {
2028        return ET.forecastModelRaceDetailAsOf(ctx, asOfIso);
2029    };
2030
2031    var FORECAST_SIM_DRAW_COUNT = 100000;
2032    var FORECAST_SIM_HOUSE_DRAW_COUNT = 10000;
2033    ET.FORECAST_SIM_HOUSE_DRAW_COUNT = FORECAST_SIM_HOUSE_DRAW_COUNT;
2034    var _forecastModelSimCache = {};
2035
2036    /**
2037     * Per-race margin σ from calibrated distribution (used for trend bands when sim has run).
2038     */
2039    function forecastSimulationSigma(pollCount, det) {
2040        if (det && det.simSigma != null && isFinite(det.simSigma)) return det.simSigma;
2041        if (typeof ET.forecastSimParamsFromDetail === 'function') {
2042            var p = ET.forecastSimParamsFromDetail(det, det.forecastKind || 'house', { pollCount: pollCount });
2043            if (p && p.sigma != null) return p.sigma;
2044        }
2045        return 7;
2046    }
2047
2048    function forecastSimulationNationalNoiseScale(winProbD, pollCount) {
2049        return 1;
2050    }
2051
2052    ET.forecastSimulationSigma = forecastSimulationSigma;
2053    ET.forecastModelNormalInv = null;
2054
2055    function forecastModelNormalCdf(z) {
2056        if (z < -8) return 0;
2057        if (z > 8) return 1;
2058        var t = 1 / (1 + 0.2316419 * Math.abs(z));
2059        var d = 0.3989423 * Math.exp(-z * z / 2);
2060        var p = d * t * (0.3193815 + t * (-0.3565638 + t * (1.7814779 + t * (-1.8212560 + t * 1.3302744))));
2061        return z > 0 ? 1 - p : p;
2062    }
2063
2064    /** Acklam rational approximation for Φ⁻¹(p), p ∈ (0, 1). */
2065    function forecastModelNormalInv(p) {
2066        p = Math.max(1e-12, Math.min(1 - 1e-12, p));
2067        var a = [
2068            -3.969683028665376e+01, 2.209460984245205e+02,
2069            -2.759285104469138e+02, 1.383577518672690e+02,
2070            -3.066479806614716e+01, 2.506628277459239e+00
2071        ];
2072        var b = [
2073            -5.447738719110352e+01, 1.615858368580409e+02,
2074            -1.556989962850169e+02, 6.680131188771972e+01,
2075            -1.328068155288572e+01
2076        ];
2077        var c = [
2078            -7.784894002430293e-03, -3.224671290700398e-01,
2079            -2.445806658489466e+00, -2.272964305932520e+00,
2080            4.584458827487364e+00, 2.938163982698053e+00
2081        ];
2082        var d = [
2083            7.784695709041462e-03, 3.224671290700398e-01,
2084            2.445806658489466e+00, 2.272964305932520e+00
2085        ];
2086        var q;
2087        var r;
2088        var i;
2089        if (p < 0.02425) {
2090            q = Math.sqrt(-2 * Math.log(p));
2091            r = (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
2092                ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
2093            return r;
2094        }
2095        if (p > 1 - 0.02425) {
2096            q = Math.sqrt(-2 * Math.log(1 - p));
2097            r = -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
2098                ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
2099            return r;
2100        }
2101        q = p - 0.5;
2102        r = q * q;
2103        var num = (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q;
2104        var den = (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
2105        return num / den;
2106    }
2107
2108    ET.forecastModelNormalInv = forecastModelNormalInv;
2109
2110    (function ensureForecastCalibrationFallback() {
2111        if (typeof ET.forecastProbabilityToTargetMargin === 'function' &&
2112            typeof ET.forecastSimParamsFromDetail === 'function') {
2113            return;
2114        }
2115    })();
2116
2117    function forecastModelHash32(str) {
2118        var h = 2166136261;
2119        var i;
2120        for (i = 0; i < str.length; i++) {
2121            h ^= str.charCodeAt(i);
2122            h = Math.imul(h, 16777619);
2123        }
2124        return h >>> 0;
2125    }
2126
2127    function forecastModelMulberry32(seed) {
2128        return function () {
2129            seed |= 0;
2130            seed = (seed + 0x6d2b79f5) | 0;
2131            var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
2132            t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
2133            return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
2134        };
2135    }
2136
2137    function forecastModelSeededRandn(rnd) {
2138        var u = rnd();
2139        while (u === 0) u = rnd();
2140        var v = rnd();
2141        while (v === 0) v = rnd();
2142        return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
2143    }
2144
2145    function forecastModelSimDrawCenter(det, pollMargin, rnd, nationalScale) {
2146        var nat = nationalScale == null ? 1 : nationalScale;
2147        var genNoise = forecastModelSeededRandn(rnd) * NATIONAL_SIM_GENERIC_SIGMA * nat;
2148        var trumpNoise = forecastModelSeededRandn(rnd) * NATIONAL_SIM_TRUMP_SIGMA * nat;
2149        if (det && det.components && det.weights) {
2150            return ET.forecastModelBlendMargin(det.components, det.weights, pollMargin, {
2151                genericNoise: genNoise,
2152                trumpNoise: trumpNoise
2153            });
2154        }
2155        return det && det.margin != null && isFinite(det.margin) ? det.margin : 0;
2156    }
2157
2158    function forecastModelSimCacheKey(det, seedKey, pollCount, drawCount) {
2159        var c = det && det.components ? det.components : {};
2160        var pollM = ET.forecastModelPollMarginFromDetail(det);
2161        var pm = pollM != null && isFinite(pollM) ? pollM : 'np';
2162        var cm = det && det.margin != null && isFinite(det.margin) ? det.margin.toFixed(4) : '0';
2163        return seedKey + '|' + pollCount + '|' + drawCount + '|simMarginCal:v3|' + cm + '|' +
2164            ((c.generic || 0).toFixed(4)) + '|' + ((c.trump || 0).toFixed(4)) + '|' +
2165            ((c.cook != null && isFinite(c.cook)) ? c.cook.toFixed(4) : '
2165nc') + '|' + pm;
2166    }
2167
2168    function forecastModelMedianOfSorted(sorted) {
2169        var n = sorted.length;
2170        if (!n) return 0;
2171        var mid = Math.floor(n / 2);
2172        if (n % 2) return sorted[mid];
2173        return (sorted[mid - 1] + sorted[mid]) / 2;
2174    }
2175
2176    function forecastModelPercentileSorted(sorted, q) {
2177        var n = sorted.length;
2178        if (!n) return 0;
2179        var idx = (n - 1) * q;
2180        var lo = Math.floor(idx);
2181        var hi = Math.ceil(idx);
2182        if (lo === hi) return sorted[lo];
2183        return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
2184    }
2185
2186    /**
2187     * Per-race MC: margin ~ Normal(calibratedCenter, σ); D wins iff margin > 0.
2188     * Win % and projected margin come from the same simulatedMargin array.
2189     * @param {{ seedKey: string, pollCount?: number, drawCount?: number, keepMargins?: boolean }} opts
2190     */
2191    ET.forecastModelSimAggregateForDetail = function (det, opts) {
2192        opts = opts || {};
2193        if (!det) {
2194            return {
2195                margin: 0,
2196                marginMedian: 0,
2197                winProbD: 0.5,
2198                margins: null,
2199                dWon: null,
2200                compositeMargin: 0,
2201                pollCount: 0,
2202                targetWinD: 0.5,
2203                centerMargin: 0,
2204                sigma: 7
2205            };
2206        }
2207        var drawCount = opts.drawCount == null ? FORECAST_SIM_DRAW_COUNT : opts.drawCount;
2208        var pollCount = opts.pollCount == null ? 0 : opts.pollCount;
2209        var seedKey = opts.seedKey || 'forecast|sim';
2210        var cacheKey = forecastModelSimCacheKey(det, seedKey, pollCount, drawCount);
2211        var cached = _forecastModelSimCache[cacheKey];
2212        if (cached && (!opts.keepMargins || (cached.margins && cached.margins.length))) return cached;
2213
2214        var chamber = opts.chamber || det.forecastKind || 'house';
2215        var simParams = typeof ET.forecastSimParamsFromDetail === 'function'
2216            ? ET.forecastSimParamsFromDetail(det, chamber, {
2217                pollCount: pollCount,
2218                pureCalibration: !!opts.pureCalibration,
2219                targetWinD: opts.targetWinD
2220            })
2221            : (function () {
2222                var cm = det && det.margin != null && isFinite(det.margin) ? det.margin : 0;
2223                var tw = ET.forecastModelMarginToWinProb(cm);
2224                return {
2225                    targetWinD: tw,
2226                    centerMargin: cm,
2227                    finalTargetMargin: cm,
2228                    sigma: 7,
2229                    favoredProb: tw >= 0.5 ? tw : 1 - tw,
2230                    calibratedMarginFromOdds: cm,
2231                    originalCompositeMargin: cm,
2232                    volatilityMultiplier: 1
2233                };
2234            })();
2235        var center = simParams.finalTargetMargin != null ? simParams.finalTargetMargin : simParams.centerMargin;
2236        var sigma = simParams.sigma;
2237        var targetWinD = simParams.targetWinD;
2238        var rnd = forecastModelMulberry32(forecastModelHash32(seedKey));
2239        var sumM = 0;
2240        var sumWin = 0;
2241        var marginBuf = [];
2242        var i;
2243        var draw;
2244        for (i = 0; i < drawCount; i++) {
2245            draw = center + forecastModelSeededRandn(rnd) * sigma;
2246            marginBuf.push(draw);
2247            sumM += draw;
2248            if (draw > 0) sumWin += 1;
2249        }
2250        var sorted = marginBuf.slice().sort(function (a, b) { return a - b; });
2251        var marginMean = sumM / drawCount;
2252        var marginMedian = forecastModelMedianOfSorted(sorted);
2253        var margins = opts.keepMargins ? marginBuf : null;
2254        var dWon = opts.keepMargins ? marginBuf.map(function (m) { return m > 0 ? 1 : 0; }) : null;
2255        var absMed = Math.abs(marginMedian);
2256        var out = {
2257            margin: marginMean,
2258            marginMedian: marginMedian,
2259            winProbD: sumWin / drawCount,
2260            margins: margins,
2261            dWon: dWon,
2262            compositeMargin: simParams.originalCompositeMargin,
2263            targetWinD: targetWinD,
2264            centerMargin: center,
2265            finalTargetMargin: center,
2266            calibratedMarginFromOdds: simParams.calibratedMarginFromOdds,
2267            originalCompositeMargin: simParams.originalCompositeMargin,
2268            originalCompositeMarginAdjusted: simParams.originalCompositeMarginAdjusted,
2269            sigma: sigma,
2270            volatilityMultiplier: simParams.volatilityMultiplier,
2271            chamber: chamber,
2272            marginExceeded25: absMed > 25.05 || Math.max.apply(null, marginBuf.map(Math.abs)) > 25.05,
2273            appearsCappedAt25: absMed > 24.95 && absMed < 25.05 && Math.abs(marginMean - marginMedian) < 0.15,
2274            pollCount: pollCount,
2275            p05: sorted && sorted.length ? forecastModelPercentileSorted(sorted, 0.05) : null,
2276            p25: sorted && sorted.length ? forecastModelPercentileSorted(sorted, 0.25) : null,
2277            p75: sorted && sorted.length ? forecastModelPercentileSorted(sorted, 0.75) : null,
2278            p95: sorted.length ? forecastModelPercentileSorted(sorted, 0.95) : null
2279        };
2280        _forecastModelSimCache[cacheKey] = out;
2281        return out;
2282    };
2283
2284    /** @deprecated Alias — production uses margin-first calibrated draws (same as aggregate). */
2285    ET.forecastModelSimAggregateUnconditionalForDetail = function (det, opts) {
2286        return ET.forecastModelSimAggregateForDetail(det, opts);
2287    };
2288
2289    ET.FORECAST_SIM_METHODOLOGY = {
2290        production: {
2291            description: 'Normal(finalTargetMargin, σ): D wins if margin > 0; finalTarget blends odds-calibrated margin with composite fundamentals',
2292            senateDrawCount: FORECAST_SIM_DRAW_COUNT,
2293            governorDrawCount: FORECAST_SIM_DRAW_COUNT,
2294            houseRacePageDrawCount: FORECAST_SIM_HOUSE_DRAW_COUNT,
2295            houseHubDailyBundleDrawCount: FORECAST_SIM_HOUSE_DRAW_COUNT,
2296            winProbFrom: 'share of simulated margins with margin > 0',
2297            projectedMarginFrom: 'median (fallback mean) of same simulated margins',
2298            compositeWinProbFrom: 'logistic(composite blend margin) — target for σ',
2299            calibration: 'forecast-calibration.js anchors + blend weights + sigma = |finalTarget| / Φ⁻¹(favoredProb) × volatilityMultiplier',
2300            inputRatingMargins: 'House: ET.HOUSE_INPUT_RATING_TO_MARGIN / forecastModelHouseRatingToMargin; Senate/Gov: ET.INPUT_RATING_TO_MARGIN'
2301        }
2302    };
2303
2304    /** Win % for headlines / map — simulated average when available, else composite logistic. */
2305    ET.forecastModelDisplayWinProbD = function (det) {
2306        if (!det) return null;
2307        if (det.displayWinProbD != null && isFinite(det.displayWinProbD)) return det.displayWinProbD;
2308        if (det.winProbDSim != null && isFinite(det.winProbDSim)) return det.winProbDSim;
2309        if (det.winProbD != null && isFinite(det.winProbD)) return det.winProbD;
2310        return null;
2311    };
2312
2313    /** Projected D margin — median of calibrated margin draws (mean in marginSimMean). */
2314    ET.forecastModelDisplayMargin = function (det) {
2315        if (!det) return null;
2316        if (det.displayMargin != null && isFinite(det.displayMargin)) return det.displayMargin;
2317        if (det.marginSim != null && isFinite(det.marginSim)) return det.marginSim;
2318        if (det.marginSimMedian != null && isFinite(det.marginSimMedian)) return det.marginSimMedian;
2319        if (det.marginSimMean != null && isFinite(det.marginSimMean)) return det.marginSimMean;
2320        if (det.margin != null && isFinite(det.margin)) return det.margin;
2321        return null;
2322    };
2323
2324    /** Shared display snapshot so house hub + house forecast pages use identical current values. */
2325    ET.forecastModelDisplaySnapshot = function (det) {
2326        if (!det) return null;
2327        var margin = ET.forecastModelDisplayMargin(det);
2328        var winProbD = ET.forecastModelDisplayWinProbD(det);
2329        if ((margin == null || !isFinite(margin)) && (winProbD == null || !isFinite(winProbD))) return null;
2330        var marginDisplay = null;
2331        var voteShareDDisplay = null;
2332        var voteShareRDisplay = null;
2333        if (margin != null && isFinite(margin) && typeof ET.forecastVoteSharesFromDisplayMargin === 'function') {
2334            var sharePack = ET.forecastVoteSharesFromDisplayMargin(margin);
2335            if (sharePack) {
2336                marginDisplay = sharePack.marginDisplay;
2337                voteShareDDisplay = sharePack.voteShareD;
2338                voteShareRDisplay = sharePack.voteShareR;
2339            }
2340        } else if (margin != null && isFinite(margin)) {
2341            marginDisplay = parseFloat(typeof ET.oneDec === 'function' ? ET.oneDec(margin) : Number(margin).toFixed(1));
2342            voteShareDDisplay = marginDisplay != null ? (50 + marginDisplay / 2) : null;
2343            voteShareRDisplay = marginDisplay != null ? (50 - marginDisplay / 2) : null;
2344        }
2345        var voteShareDRaw = (margin != null && isFinite(margin)) ? (50 + margin / 2) : null;
2346        var voteShareRRaw = (margin != null && isFinite(margin)) ? (50 - margin / 2) : null;
2347        var winPctRaw = (winProbD != null && isFinite(winProbD))
2348            ? Math.max(0, Math.min(100, winProbD * 100))
2349            : null;
2350        var winPctCapped = (winPctRaw != null && typeof ET.floorWinProbDisplayPct === 'function')
2351            ? ET.floorWinProbDisplayPct(winPctRaw)
2352            : winPctRaw;
2353        var winPctDDisplay = winPctCapped != null
2354            ? parseFloat(typeof ET.oneDec === 'function' ? ET.oneDec(winPctCapped) : Number(winPctCapped).toFixed(1))
2355            : null;
2356        if (det.partyLockout === 'D' || det.partyLockout === 'R') {
2357            winPctDDisplay = det.partyLockout === 'D' ? 100 : 0;
2358            voteShareDDisplay = det.partyLockout === 'D' ? 100 : 0;
2359            voteShareRDisplay = det.partyLockout === 'R' ? 100 : 0;
2360            marginDisplay = det.partyLockout === 'D' ? 100 : -100;
2361        }
2362        var winPctRDisplay = winPctDDisplay != null
2363            ? parseFloat(typeof ET.oneDec === 'function' ? ET.oneDec(100 - winPctDDisplay) : Number(100 - winPctDDisplay).toFixed(1))
2364            : null;
2365        return {
2366            detail: det,
2367            margin: margin,
2368            marginDisplay: marginDisplay,
2369            voteShareDRaw: voteShareDRaw,
2370            voteShareRRaw: voteShareRRaw,
2371            voteShareDDisplay: voteShareDDisplay,
2372            voteShareRDisplay: voteShareRDisplay,
2373            winProbD: winProbD,
2374            winPctDDisplay: winPctDDisplay,
2375            winPctRDisplay: winPctRDisplay,
2376            rating: (winProbD != null && isFinite(winProbD) && typeof ET.forecastModelWinProbToRating === 'function')
2377                ? ET.forecastModelWinProbToRating(winProbD)
2378                : (det.rating || null)
2379        };
2380    };
2381
2382    function houseForecastSnapshotPayload() {
2383        return ET.HOUSE_FORECAST_SNAPSHOT && ET.HOUSE_FORECAST_SNAPSHOT.districts
2384            ? ET.HOUSE_FORECAST_SNAPSHOT
2385            : null;
2386    }
2387
2388    function applyHouseSnapshotDistrictToDetail(detail, districtSnap, asOfIso) {
2389        if (!detail || !districtSnap) return false;
2390        if (districtSnap.demWinProb != null && isFinite(districtSnap.demWinProb)) {
2391            detail.winProbDSim = districtSnap.demWinProb;
2392            detail.displayWinProbD = districtSnap.demWinProb;
2393            detail.winProbD = districtSnap.demWinProb;
2394        }
2395        if (districtSnap.repWinProb != null && isFinite(districtSnap.repWinProb)) {
2396            detail.winProbR = districtSnap.repWinProb;
2397        }
2398        if (districtSnap.projectedMargin != null && isFinite(districtSnap.projectedMargin)) {
2399            var snapM = districtSnap.projectedMargin;
2400            /*
2401             * Soft-cap / incumbent snap can disagree with the composite blend used on prior
2402             * trend days. Prefer composite so hub + forecast pages stay continuous.
2403             */
2404            var composite = detail.margin;
2405            if (composite != null && isFinite(composite) && Math.abs(snapM - composite) >= 4) {
2406                var softCapRail = Math.abs(Math.abs(snapM) - 40) <= 1.25;
2407                var moreExtreme = Math.abs(snapM) > Math.abs(composite) + 3.5;
2408                if (softCapRail || moreExtreme) {
2409                    snapM = composite;
2410                    detail._houseTerminalJumpRepaired = true;
2411                }
2412            }
2413            detail.marginSim = snapM;
2414            detail.marginSimMedian = snapM;
2415            detail.displayMargin = snapM;
2416            if (detail._houseTerminalJumpRepaired && typeof ET.forecastModelMarginDisplayLabel === 'function') {
2417                detail.marginLabel = ET.forecastModelMarginDisplayLabel(
2418                    snapM,
2419                    detail.winProbDSim != null ? detail.winProbDSim : detail.winProbD
2420                );
2421            }
2422        }
2423        if (districtSnap.rating) detail.rating = districtSnap.rating;
2424        if (districtSnap.favoredParty) detail.favoredParty = districtSnap.favoredParty;
2425        if (districtSnap.marginLabel && !detail._houseTerminalJumpRepaired) {
2426            detail.marginLabel = districtSnap.marginLabel;
2427        }
2428        if (districtSnap.pollDPct != null) detail.pollDPct = districtSnap.pollDPct;
2429        if (districtSnap.pollRPct != null) detail.pollRPct = districtSnap.pollRPct;
2430        detail._fromHouseForecastSnapshot = true;
2431        detail._forecastSimSig = 'houseSnapshot|' + (asOfIso || districtSnap.asOfIso || '') + '|' +
2432            (districtSnap.code || '');
2433        return true;
2434    }
2435
2436    ET.houseForecastSnapshotDistrict = function (code, asOfIso) {
2437        if (!code) return null;
2438        var payload = houseForecastSnapshotPayload();
2439        if (!payload || !payload.districts) return null;
2440        if (asOfIso && payload.asOfIso && payload.asOfIso !== asOfIso) {
2441            var today = typeof ET.etTodayISO === 'function' ? ET.etTodayISO() : null;
2442            /*
2443             * Daily snapshot not rebuilt yet for "today" — use the latest published
2444             * snapshot so hub maps and district /forecast/ pages stay aligned.
2445             */
2446            if (!(today && asOfIso === today && payload.asOfIso <= today)) {
2447                return null;
2448            }
2449        }
2450        return payload.districts[code] || null;
2451    };
2452
2453    ET.houseForecastSnapshotSig = function () {
2454        var parts = ['houseSnapshotSigV1', String(FORECAST_DAILY_BUNDLE_VERSION)];
2455        if (typeof ET.editorialRatingsBakeFingerprint === 'function') {
2456            parts.push(ET.editorialRatingsBakeFingerprint());
2457        }
2458        if (ET.HOUSE_COOK_BLEND_REVISION != null) {
2459            parts.push('houseCookBlend' + ET.HOUSE_COOK_BLEND_REVISION);
2460        }
2461        return parts.join('|');
2462    };
2463
2464    ET.applyHouseForecastSnapshot = function () {
2465        if (typeof ET.houseHubUsesBakedForecastOnly === 'function' && ET.houseHubUsesBakedForecastOnly()) {
2466            return false;
2467        }
2468        var payload = houseForecastSnapshotPayload();
2469        if (!payload || !payload.districts || ET._houseForecastSnapshotApplied) return false;
2470        var districts = payload.districts;
2471        var asOfIso = payload.asOfIso || null;
2472        ET.FORECAST_MODEL_HOUSE_DETAIL = ET.FORECAST_MODEL_HOUSE_DETAIL || {};
2473        Object.keys(districts).forEach(function (code) {
2474            var d = districts[code];
2475            if (!d) return;
2476            var det = ET.FORECAST_MODEL_HOUSE_DETAIL[code] || {};
2477            applyHouseSnapshotDistrictToDetail(det, d, asOfIso);
2478            ET.FORECAST_MODEL_HOUSE_DETAIL[code] = det;
2479        });
2480        ET._forecastDailyBundleIso = asOfIso;
2481        ET._forecastDailyBundleVersion = FORECAST_DAILY_BUNDLE_VERSION;
2482        ET._houseForecastSnapshotApplied = true;
2483        if (typeof document !== 'undefined') {
2484            try {
2485                document.dispatchEvent(new CustomEvent('pollsmax-house-forecast-snapshot-applied'));
2486            } catch (eSnap) { /* ignore */ }
2487        }
2488        return true;
2489    };
2490
2491    if (typeof ET.HOUSE_FORECAST_SNAPSHOT !== 'undefined' && ET.HOUSE_FORECAST_SNAPSHOT) {
2492        ET.applyHouseForecastSnapshot();
2493    }
2494
2495    ET.houseForecastDetail = function (code, asOfIso) {
2496        if (!code) return null;
2497        /*
2498         * Canonicalize the district code before ANY lookup/sim. "AZ-1" and "AZ-01"
2499         * must resolve to the same result: the published snapshot (payload.districts)
2500         * and the rating rows are keyed by the unpadded form ("AZ-1"), so a padded
2501         * caller (hub map via geoid, race.code = "AZ-01") would miss the snapshot and
2502         * fall back to a fresh MC draw whose seed string ("house:AZ-01|…") differs
2503         * from the unpadded seed — flipping a knife-edge race D↔R vs the hub list /
2504         * forecast hero. Normalize so every surface shows identical odds/margin/rating.
2505         */
2506        if (typeof ET.houseDistrictCodeForIncumbentLookup === 'function') {
2507            var canonCode = ET.houseDistrictCodeForIncumbentLookup(code);
2508            if (canonCode) code = canonCode;
2509        }
2510        var lockParty = typeof ET.housePartyLockoutParty === 'function' ? ET.housePartyLockoutParty(code) : null;
2511        if (lockParty && typeof ET.housePartyLockoutForecastDetail === 'function') {
2512            var lockDet = ET.housePartyLockoutForecastDetail(code, lockParty);
2513            if (lockDet) {
2514                if (ET.FORECAST_MODEL_HOUSE_DETAIL) ET.FORECAST_MODEL_HOUSE_DETAIL[code] = lockDet;
2515                return lockDet;
2516            }
2517        }
2518        var iso = asOfIso || (typeof ET.etTodayISO === 'function' ? ET.etTodayISO() : null);
2519        var det = null;
2520        if (iso && typeof ET.forecastModelHouseDetailAsOfDate === 'function') {
2521            det = ET.forecastModelHouseDetailAsOfDate(code, iso);
2522        }
2523        if (!det) det = ET.FORECAST_MODEL_HOUSE_DETAIL && ET.FORECAST_MODEL_HOUSE_DETAIL[code];
2524        if (!det && typeof ET.forecastModelHouseDetail === 'function') {
2525            det = ET.forecastModelHouseDetail(code);
2526        }
2527        if (det && typeof ET.forecastModelEnsureDetailSim === 'function') {
2528            ET.forecastModelEnsureDetailSim(det, 'house', code, null, iso || asOfIso);
2529        }
2530        if (det && typeof ET.forecastModelDisplayWinProbD === 'function' &&
2531            typeof ET.forecastModelWinProbToRating === 'function') {
2532            var pD = ET.forecastModelDisplayWinProbD(det);
2533            if (pD != null && isFinite(pD)) det.rating = ET.forecastModelWinProbToRating(pD, code);
2534        }
2535        if (det && ET.FORECAST_MODEL_HOUSE_DETAIL) ET.FORECAST_MODEL_HOUSE_DETAIL[code] = det;
2536        return det || null;
2537    };
2538
2539    ET.houseForecastDisplaySnapshot = function (code, asOfIso) {
2540        var det = ET.houseForecastDetail(code, asOfIso);
2541        var snap = ET.forecastModelDisplaySnapshot(det);
2542        if (!snap) return det ? { detail: det, rating: det.rating || null } : null;
2543        snap.detail = det;
2544        snap.rating = det && det.rating ? det.rating : (snap.rating || null);
2545        return snap;
2546    };
2547
2548    function attachSimWinProbToDetail(detail, seedKey, pollCount, drawCount, kind) {
2549        if (!detail) return detail;
2550        if (kind) detail.forecastKind = kind;
2551        var sim = ET.forecastModelSimAggregateForDetail(detail, {
2552            seedKey: seedKey,
2553            pollCount: pollCount,
2554            drawCount: drawCount,
2555            chamber: kind || detail.forecastKind
2556        });
2557        detail.winProbDSim = sim.winProbD;
2558        detail.marginSimMean = sim.margin;
2559        detail.marginSimMedian = sim.marginMedian != null ? sim.marginMedian : sim.margin;
2560        detail.marginSim = detail.marginSimMedian;
2561        detail.targetWinD = sim.targetWinD;
2562        detail.simTargetMargin = sim.finalTargetMargin != null ? sim.finalTargetMargin : sim.centerMargin;
2563        detail.simCalibratedMargin = sim.calibratedMarginFromOdds;
2564        detail.simSigma = sim.sigma;
2565        detail.simVolatilityMultiplier = sim.volatilityMultiplier;
2566        detail.displayWinProbD = detail.winProbDSim;
2567        detail.displayMargin = detail.marginSim;
2568        return detail;
2569    }
2570
2571    function forecastModelPollCountForKind(kind, key, asOfIso) {
2572        var iso = asOfIso || (typeof ET.etTodayISO === 'function' ? ET.etTodayISO() : '9999-12-31');
2573        if (kind === 'senate' && ET.SENATE && ET.SENATE[key]) {
2574            return racePollsOnOrBeforeDate(ET.SENATE[key], iso).length;
2575        }
2576        if (kind === 'gov' && typeof ET.govSeriesWithLocalFallback === 'function') {
2577            return racePollsOnOrBeforeDate(ET.govSeriesWithLocalFallback(key) || [], iso).length;
2578        }
2579        if (kind === 'house' && ET.HOUSE_FORECAST_RATINGS) {
2580            var row = ET.HOUSE_RATING_BY_CODE && ET.HOUSE_RATING_BY_CODE[key];
2581            var tracked = row && row.geoid && ET.HOUSE_BY_GEOID ? ET.HOUSE_BY_GEOID[row.geoid] : null;
2582            if (tracked && tracked.series) return racePollsOnOrBeforeDate(tracked.series, iso).length;
2583        }
2584        return 0;
2585    }
2586
2587    ET.forecastModelPollCountForKind = forecastModelPollCountForKind;
2588
2589    function forecastModelDetailSimSignature(kind, key, det, pollCount) {
2590        var pm = ET.forecastModelPollMarginFromDetail(det);
2591        var pmStr = pm != null && isFinite(pm) ? pm.toFixed(4) : '
2591np';
2592        var cm = det && det.margin != null && isFinite(det.margin) ? det.margin.toFixed(4) : '0';
2593        var blendRev = '0';
2594        var houseRev = kind === 'house' && ET.HOUSE_COOK_BLEND_REVISION != null
2595            ? '|houseCookBlend' + ET.HOUSE_COOK_BLEND_REVISION
2596            : '';
2597        var houseCalRev = kind === 'house' ? '|houseMarginCal2' : '';
2598        if (kind === 'senate' && ET.SENATE_EDITORIAL_BLEND_REVISION != null) {
2599            blendRev = String(ET.SENATE_EDITORIAL_BLEND_REVISION);
2600        } else if (kind === 'gov' && ET.GOV_EDITORIAL_BLEND_REVISION != null) {
2601            blendRev = String(ET.GOV_EDITORIAL_BLEND_REVISION);
2602        }
2603        return 'simSig:v15|marginCal9|cal4|' + kind + '|' + key + '|' + pollCount + '|br' + blendRev + '|' + cm + '|' + pmStr + houseRev + houseCalRev;
2604    }
2605
2606    function forecastModelDetailSimSeedKey(kind, key, asOfIso) {
2607        var iso = asOfIso || (typeof ET.etTodayISO === 'function' ? ET.etTodayISO() : '9999-12-31');
2608        if (kind === 'senate') return 'senate:' + key + '|' + iso + '|trend100k';
2609        if (kind === 'gov') return 'gov:' + key + '|' + iso + '|trend100k';
2610        return 'house:' + key + '|' + iso + '|trend10k';
2611    }
2612
2613    /** Stamp cache sig after a fresh 100k run (state forecast page sync). */
2614    ET.forecastModelStampDetailSimCache = function (detail, kind, key, pollCount) {
2615        if (!detail || !key) return detail;
2616        detail._forecastSimSig = forecastModelDetailSimSignature(
2617            kind, key, detail, pollCount == null ? forecastModelPollCountForKind(kind, key) : pollCount);
2618        return detail;
2619    };
2620
2621    /**
2622     * Re-run 100k sim when composite/polls changed so hub map % matches state forecast pages.
2623     */
2624    ET.forecastModelEnsureDetailSim = function (detail, kind, key, name, asOfIso) {
2625        if (!detail || !key) return detail;
2626        var iso = asOfIso || (typeof ET.etTodayISO === 'function' ? ET.etTodayISO() : '9999-12-31');
2627        var pollCount = forecastModelPollCountForKind(kind, key, iso);
2628        var sig = forecastModelDetailSimSignature(kind, key, detail, pollCount);
2629        if (detail._forecastSimSig === sig &&
2630            detail.winProbDSim != null && isFinite(detail.winProbDSim) &&
2631            forecastSimSnapshotConsistentWithDetail(detail, {
2632                ws: detail.winProbDSim,
2633                ms: detail.marginSim != null ? detail.marginSim : detail.marginSimMedian
2634            })) {
2635            return detail;
2636        }
2637        if (kind === 'house') {
2638            var districtSnap = ET.houseForecastSnapshotDistrict && ET.houseForecastSnapshotDistrict(key, iso);
2639            if (districtSnap && applyHouseSnapshotDistrictToDetail(detail, districtSnap, iso)) {
2640                if (typeof ET.forecastModelWinProbToRating === 'function' &&
2641                    detail.displayWinProbD != null && isFinite(detail.displayWinProbD)) {
2642                    detail.rating = ET.forecastModelWinProbToRating(detail.displayWinProbD, key);
2643                }
2644                detail._forecastSimSig = sig;
2645                return detail;
2646            }
2647            /* Keep hub bake / last published snapshot — do not re-sim past them. */
2648            if (detail._fromHouseForecastSnapshot &&
2649                detail.winProbDSim != null && isFinite(detail.winProbDSim)) {
2650                return detail;
2651            }
2652        }
2653        var drawCount = kind === 'house' ? FORECAST_SIM_HOUSE_DRAW_COUNT : FORECAST_SIM_DRAW_COUNT;
2654        attachSimWinProbToDetail(
2655            detail,
2656            forecastModelDetailSimSeedKey(kind, key, iso),
2657            pollCount,
2658            drawCount,
2659            kind);
2660        detail._forecastSimSig = sig;
2661        if (typeof ET.forecastModelWinProbToRating === 'function') {
2662            var probForRating = (kind === 'house' && detail.displayWinProbD != null && isFinite(detail.displayWinProbD))
2663                ? detail.displayWinProbD
2664                : detail.winProbDSim;
2665            if (probForRating != null && isFinite(probForRating)) {
2666                detail.rating = ET.forecastModelWinProbToRating(probForRating, key);
2667            }
2668        }
2669        return detail;
2670    };
2671
2672    /** Hub + state pages: one 100k sim batch per calendar day (localStorage), not every page load. */
2673    var FORECAST_DAILY_BUNDLE_PREFIX = 'pollsmax|forecastDaily|v9|';
2674    var FORECAST_DAILY_BUNDLE_VERSION = 43;
2675    ET.FORECAST_DAILY_BUNDLE_VERSION = FORECAST_DAILY_BUNDLE_VERSION;
2676    var FORECAST_DAILY_MIN_SENATE = 35;
2677    var FORECAST_DAILY_MIN_GOV = 10;
2678    var FORECAST_DAILY_MIN_HOUSE = 100;
2679
2680    function readForecastDailyStorage(key) {
2681        try {
2682            var raw = localStorage.getItem(key);
2683            if (!raw) return null;
2684            return JSON.parse(raw);
2685        } catch (e) {
2686            return null;
2687        }
2688    }
2689
2690    function writeForecastDailyStorage(key, payload) {
2691        try {
2692            localStorage.setItem(key, JSON.stringify(payload));
2693        } catch (e) { /* quota / private mode */ }
2694    }
2695
2696    function forecastDailyBundleKey(iso) {
2697        return FORECAST_DAILY_BUNDLE_PREFIX + iso;
2698    }
2699
2700    function forecastDailyBundleNeedsKind(kind) {
2701        var path = '';
2702        try { path = location && location.pathname ? location.pathname : ''; } catch (e) { path = ''; }
2703        var ctx = typeof ET.parseRacePageContext === 'function' ? ET.parseRacePageContext() : null;
2704        if (kind === 'house') {
2705            return !!(
2706                document.getElementById('houseCdMap') ||
2707                /^\/2026-us-house(?:\/index\.html)?\/?$/.test(path)
2708            );
2709        }
2710        if (kind === 'senate') {
2711            return !!document.getElementById('usMap');
2712        }
2713        if (kind === 'gov') {
2714            return !!document.getElementById('govMap');
2715        }
2716        return false;
2717    }
2718
2719    function forecastDailyBundleRequirements() {
2720        return {
2721            sen: !!(ET.COOK_STATE_PVI && ET.SENATE_EDITORIAL_RATINGS && ET.SENATE_FORECAST_PRES_AVG &&
2722                ET.ELECTION_2026 && ET.NAME_BY_FIPS && ET.KEY_BY_NAME &&
2723                forecastDailyBundleNeedsKind('senate')),
2724            gov: !!(ET.GOV_EDITORIAL_RATINGS && ET.GOV_FORECAST_PRES_AVG && ET.COOK_GOV_RATINGS &&
2725                forecastDailyBundleNeedsKind('gov')),
2726            house: !!(ET.HOUSE_FORECAST_RATINGS && ET.HOUSE_RATING_BY_CODE && ET.HOUSE_BY_GEOID &&
2727                ET.COOK_HOUSE_RATINGS && ET.COOK_DISTRICT_PVI &&
2728                forecastDailyBundleNeedsKind('house'))
2729        };
2730    }
2731
2732    function forecastDailySectionReady(section, minKeys) {
2733        return section && typeof section === 'object' && Object.keys(section).length >= minKeys;
2734    }
2735
2736    function forecastDailyBundleMeetsRequirements(bundle, iso, req) {
2737        if (!bundle || bundle.v !== FORECAST_DAILY_BUNDLE_VERSION || bundle.iso !== iso) return false;
2738        if (req.sen && !forecastDailySectionReady(bundle.sen, FORECAST_DAILY_MIN_SENATE)) return false;
2739        if (req.gov && !forecastDailySectionReady(bundle.gov, FORECAST_DAILY_MIN_GOV)) return false;
2740        if (req.house && !forecastDailySectionReady(bundle.house, FORECAST_DAILY_MIN_HOUSE)) return false;
2741        return true;
2742    }
2743
2744    function loadForecastDailyBundle(iso) {
2745        return readForecastDailyStorage(forecastDailyBundleKey(iso));
2746    }
2747
2748    function mergeForecastDailyBundle(iso, partial) {
2749        var bundle = loadForecastDailyBundle(iso) || {
2750            v: FORECAST_DAILY_BUNDLE_VERSION,
2751            iso: iso,
2752            sen: null,
2753            gov: null,
2754            house: null
2755        };
2756        bundle.v = FORECAST_DAILY_BUNDLE_VERSION;
2757        bundle.iso = iso;
2758        if (partial.sen) bundle.sen = partial.sen;
2759        if (partial.gov) bundle.gov = partial.gov;
2760        if (partial.house) bundle.house = partial.house;
2761        writeForecastDailyStorage(forecastDailyBundleKey(iso), bundle);
2762        return bundle;
2763    }
2764
2765    function forecastSimSnapshotConsistentWithDetail(det, snap) {
2766        if (!det || !snap) return false;
2767        var cm = det.margin != null && isFinite(det.margin) ? det.margin : null;
2768        var sm = snap.ms != null && isFinite(snap.ms) ? snap.ms : null;
2769        var sw = snap.ws != null && isFinite(snap.ws) ? snap.ws : null;
2770        if (cm == null || !isFinite(cm)) return true;
2771        if (sm != null && Math.abs(cm) >= 2 && Math.abs(sm) < 0.75) return false;
2772        if (sm != null && cm * sm < 0 && Math.abs(cm) >= 1 && Math.abs(sm) >= 1) return false;
2773        if (sw != null && Math.abs(cm) >= 2 && Math.abs(sw - 0.5) < 0.02) return false;
2774        return true;
2775    }
2776
2777    function applySimSnapshotToDetail(det, snap, currentSig) {
2778        if (!det || !snap) return det;
2779        if (!snap.sig) return det;
2780        if (currentSig && snap.sig !== currentSig) return det;
2781        if (!forecastSimSnapshotConsistentWithDetail(det, snap)) return det;
2782        if (snap.ws != null && isFinite(snap.ws)) {
2783            det.winProbDSim = snap.ws;
2784            det.displayWinProbD = snap.ws;
2785        }
2786        if (snap.ms != null && isFinite(snap.ms)) {
2787            det.marginSim = snap.ms;
2788            det.marginSimMedian = snap.ms;
2789            det.displayMargin = snap.ms;
2790        }
2791        if (snap.mm != null && isFinite(snap.mm)) det.marginSimMean = snap.mm;
2792        if (snap.sig) det._forecastSimSig = snap.sig;
2793        return det;
2794    }
2795
2796    function simSnapshotFromDetail(det, kind, key, pollCount) {
2797        if (!det) return null;
2798        return {
2799            ws: det.winProbDSim,
2800            ms: det.marginSim,
2801            mm: det.marginSimMean,
2802            sig: forecastModelDetailSimSignature(kind, key, det, pollCount)
2803        };
2804    }
2805
2806    function restoreSenateFromDailyBundle(bundleSen, todayIso) {
2807        ET.FORECAST_MODEL_SENATE_DETAIL = {};
2808        for (var f in ET.ELECTION_2026) {
2809            if (!ET.ELECTION_2026.hasOwnProperty(f)) continue;
2810            var nm = ET.NAME_BY_FIPS[f];
2811            var key = ET.KEY_BY_NAME[nm];
2812            if (!key) continue;
2813            var detailSen = ET.forecastModelSenateDetailAsOfDate(key, nm, todayIso);
2814            var senPollN = ET.SENATE && ET.SENATE[key]
2815                ? racePollsOnOrBeforeDate(ET.SENATE[key], todayIso).length
2816                : 0;
2817            var senSig = forecastModelDetailSimSignature('senate', key, detailSen, senPollN);
2818            applySimSnapshotToDetail(detailSen, bundleSen[key], senSig);
2819            ET.FORECAST_MODEL_SENATE_DETAIL[key] = detailSen;
2820        }
2821    }
2822
2823    function restoreGovFromDailyBundle(bundleGov, todayIso) {
2824        ET.FORECAST_MODEL_GOV_DETAIL = {};
2825        Object.keys(ET.GOV_EDITORIAL_RATINGS).forEach(function (key) {
2826            if (ET.govEditorialNoElectionKeys && ET.govEditorialNoElectionKeys().has(key)) return;
2827            var detailGov = ET.forecastModelGovDetailAsOfDate(key, todayIso);
2828            var govPollN0 = typeof ET.govSeriesWithLocalFallback === 'function'
2829                ? racePollsOnOrBeforeDate(ET.govSeriesWithLocalFallback(key) || [], todayIso).length
2830                : 0;
2831            var govSig = forecastModelDetailSimSignature('gov', key, detailGov, govPollN0);
2832            applySimSnapshotToDetail(detailGov, bundleGov[key], govSig);
2833            ET.FORECAST_MODEL_GOV_DETAIL[key] = detailGov;
2834            ET.GOV_EDITORIAL_RATINGS[key] = detailGov.rating;
2835        });
2836    }
2837
2838    function restoreHouseFromDailyBundle(bundleHouse, todayIso) {
2839        ET.FORECAST_MODEL_HOUSE_DETAIL = {};
2840        ET.HOUSE_FORECAST_RATINGS.forEach(function (row) {
2841            if (!row || !row.code) return;
2842            var detailHouse = ET.forecastModelHouseDetailAsOfDate(row.code, todayIso);
2843            var houseRow = ET.HOUSE_RATING_BY_CODE && ET.HOUSE_RATING_BY_CODE[row.code];
2844            var houseTracked = houseRow && houseRow.geoid && ET.HOUSE_BY_GEOID
2845                ? ET.HOUSE_BY_GEOID[houseRow.geoid]
2846                : null;
2847            var housePollN = houseTracked && houseTracked.series
2848                ? racePollsOnOrBeforeDate(houseTracked.series, todayIso).length
2849                : 0;
2850            var houseSig = forecastModelDetailSimSignature('house', row.code, detailHouse, housePollN);
2851            applySimSnapshotToDetail(detailHouse, bundleHouse[row.code], houseSig);
2852            ET.FORECAST_MODEL_HOUSE_DETAIL[row.code] = detailHouse;
2853            row.rating = detailHouse.rating;
2854        });
2855    }
2856
2857    function restoreForecastFromDailyBundle(bundle, todayIso, req) {
2858        if (req.sen && bundle.sen) restoreSenateFromDailyBundle(bundle.sen, todayIso);
2859        if (req.gov && bundle.gov) restoreGovFromDailyBundle(bundle.gov, todayIso);
2860        if (req.house && bundle.house) restoreHouseFromDailyBundle(bundle.house, todayIso);
2861    }
2862
2863    function computeSenateDailySim(todayIso) {
2864        var snaps = {};
2865        for (var f in ET.ELECTION_2026) {
2866            if (!ET.ELECTION_2026.hasOwnProperty(f)) continue;
2867            var nm = ET.NAME_BY_FIPS[f];
2868            var key = ET.KEY_BY_NAME[nm];
2869            if (!key) continue;
2870            var detailSen = ET.forecastModelSenateDetailAsOfDate(key, nm, todayIso);
2871            var senPollN = ET.SENATE && ET.SENATE[key]
2872                ? racePollsOnOrBeforeDate(ET.SENATE[key], todayIso).length
2873                : 0;
2874            attachSimWinProbToDetail(
2875                detailSen, 'senate:' + key + '|' + todayIso + '|trend100k', senPollN, FORECAST_SIM_DRAW_COUNT, 'senate');
2876            detailSen._forecastSimSig = forecastModelDetailSimSignature('senate', key, detailSen, senPollN);
2877            ET.FORECAST_MODEL_SENATE_DETAIL[key] = detailSen;
2878            snaps[key] = simSnapshotFromDetail(detailSen, 'senate', key, senPollN);
2879        }
2880        return snaps;
2881    }
2882
2883    function computeGovDailySim(todayIso) {
2884        var snaps = {};
2885        Object.keys(ET.GOV_EDITORIAL_RATINGS).forEach(function (key) {
2886            if (ET.govEditorialNoElectionKeys && ET.govEditorialNoElectionKeys().has(key)) return;
2887            var detailGov = ET.forecastModelGovDetailAsOfDate(key, todayIso);
2888            var govSeries = ET.govSeriesWithLocalFallback
2889                ? ET.govSeriesWithLocalFallback(key)
2890                : (ET.govSeries ? ET.govSeries(key) : []);
2891            var govPollN = racePollsOnOrBeforeDate(govSeries || [], todayIso).length;
2892            attachSimWinProbToDetail(
2893                detailGov, 'gov:' + key + '|' + todayIso + '|trend100k', govPollN, FORECAST_SIM_DRAW_COUNT, 'gov');
2894            detailGov._forecastSimSig = forecastModelDetailSimSignature('gov', key, detailGov, govPollN);
2895            ET.FORECAST_MODEL_GOV_DETAIL[key] = detailGov;
2896            ET.GOV_EDITORIAL_RATINGS[key] = detailGov.rating;
2897            snaps[key] = simSnapshotFromDetail(detailGov, 'gov', key, govPollN);
2898        });
2899        return snaps;
2900    }
2901
2902    function computeHouseDailySim(todayIso) {
2903        var snaps = {};
2904        ET.HOUSE_FORECAST_RATINGS.forEach(function (row) {
2905            if (!row || !row.code) return;
2906            var detailHouse = ET.forecastModelHouseDetailAsOfDate(row.code, todayIso);
2907            var houseRow = ET.HOUSE_RATING_BY_CODE && ET.HOUSE_RATING_BY_CODE[row.code];
2908            var houseTracked = houseRow && houseRow.geoid && ET.HOUSE_BY_GEOID
2909                ? ET.HOUSE_BY_GEOID[houseRow.geoid]
2910                : null;
2911            var housePollN = houseTracked && houseTracked.series
2912                ? racePollsOnOrBeforeDate(houseTracked.series, todayIso).length
2913                : 0;
2914            attachSimWinProbToDetail(
2915                detailHouse,
2916                'house:' + row.code + '|' + todayIso + '|trend100k',
2917                housePollN,
2918                FORECAST_SIM_HOUSE_DRAW_COUNT,
2919                'house');
2920            ET.FORECAST_MODEL_HOUSE_DETAIL[row.code] = detailHouse;
2921            row.rating = detailHouse.rating;
2922            detailHouse._forecastSimSig = forecastModelDetailSimSignature('house', row.code, detailHouse, housePollN);
2923            snaps[row.code] = simSnapshotFromDetail(detailHouse, 'house', row.code, housePollN);
2924        });
2925        return snaps;
2926    }
2927
2928    function inMemoryForecastDailyComplete(todayIso, req) {
2929        if (ET._forecastDailyBundleIso !== todayIso) return false;
2930        if (ET._forecastDailyBundleVersion !== FORECAST_DAILY_BUNDLE_VERSION) return false;
2931        if (req.sen) {
2932            if (!ET.FORECAST_MODEL_SENATE_DETAIL ||
2933                Object.keys(ET.FORECAST_MODEL_SENATE_DETAIL).length < FORECAST_DAILY_MIN_SENATE) {
2934                return false;
2935            }
2936        }
2937        if (req.gov) {
2938            if (!ET.FORECAST_MODEL_GOV_DETAIL ||
2939                Object.keys(ET.FORECAST_MODEL_GOV_DETAIL).length < FORECAST_DAILY_MIN_GOV) {
2940                return false;
2941            }
2942        }
2943        if (req.house) {
2944            if (!ET.FORECAST_MODEL_HOUSE_DETAIL ||
2945                Object.keys(ET.FORECAST_MODEL_HOUSE_DETAIL).length < FORECAST_DAILY_MIN_HOUSE) {
2946                return false;
2947            }
2948        }
2949        return true;
2950    }
2951
2952    function refreshForecastUiAfterApply() {
2953        if (typeof ET.refreshStateRacePageHeadFromForecast === 'function' &&
2954            typeof ET.racePageViewMode === 'function' &&
2955            ET.racePageViewMode() === 'forecast') {
2956            ET.refreshStateRacePageHeadFromForecast();
2957        }
2958        if (typeof ET.scheduleRaceOverviewLiveDataRefresh === 'function') {
2959            ET.scheduleRaceOverviewLiveDataRefresh();
2960        }
2961        if (typeof ET.refreshRaceForecastPageIfMounted === 'function') {
2962            ET.refreshRaceForecastPageIfMounted();
2963        } else if (typeof ET.initRaceForecastPage === 'function' &&
2964            typeof ET.racePageViewMode === 'function' &&
2965            ET.racePageViewMode() === 'forecast' &&
2966            !ET._raceForecastPageInitInFlight) {
2967            ET.initRaceForecastPage();
2968        }
2969        if (typeof ET.refreshForecastHubAsideRaces === 'function') {
2970            ET.refreshForecastHubAsideRaces('usMap');
2971            ET.refreshForecastHubAsideRaces('govMap');
2972        }
2973        if (typeof ET.refreshHouseCdMap === 'function') {
2974            try { ET.refreshHouseCdMap('houseCdMap'); } catch (eHouseMap) { /* prerender join / fill */ }
2975        }
2976        if (typeof ET.refreshHouseHubOddsCharts === 'function' && document.getElementById('houseHubControlOddsChart')) {
2977            ET.refreshHouseHubOddsCharts({ clearMemos: true });
2978        }
2979        if (typeof ET.updateSeatBar === 'function') {
2980            var senIds = { r: 'rSeats', d: 'dSeats', tie: 'tieSeats', nr: 'rCount', nd: 'dCount' };
2981            if (document.getElementById('rSeats')) ET.updateSeatBar(senIds);
2982        }
2983        if (typeof ET.updateGovSeatBar === 'function' && document.getElementById('govHubSeatBar')) {
2984            ET.updateGovSeatBar();
2985        }
2986        if (typeof ET.refreshForecastHubSeatBar === 'function') {
2987            var uiSen = ET.getForecastHubMapUi && ET.getForecastHubMapUi('usMap');
2988            if (uiSen && uiSen.mapSource === 'ratings') {
2989                ET.refreshForecastHubSeatBar({ r: 'rSeats', d: 'dSeats', tie: 'tieSeats', nr: 'rCount', nd: 'dCount' }, 'senate', 'usMap');
2990            }
2991            var uiGov = ET.getForecastHubMapUi && ET.getForecastHubMapUi('govMap');
2992            if (uiGov && uiGov.mapSource === 'ratings') {
2993                ET.refreshForecastHubSeatBar({ r: 'govRSeats', d: 'govDSeats', tie: 'govTieSeats', nr: 'govRCount', nd: 'govDCount' }, 'gov', 'govMap');
2994            }
2995        }
2996            if (typeof ET.homeMapUsesForecastMode === 'function' && ET.homeMapUsesForecastMode()) {
2997            if (typeof ET.ensureHomeForecastMapUi === 'function') {
2998                ET.ensureHomeForecastMapUi('usMap');
2999                ET.ensureHomeForecastMapUi('govMap');
3000                ET.ensureHomeForecastMapUi('houseCdMap');
3001            }
3002            if (typeof ET.refreshForecastHubSenateMap === 'function' && document.getElementById('usMap')) {
3003                ET.refreshForecastHubSenateMap('usMap');
3004            }
3005            if (typeof ET.refreshForecastHubGovMap === 'function' && document.getElementById('govMap')) {
3006                ET.refreshForecastHubGovMap('govMap');
3007            }
3008            if (typeof ET.refreshHouseCdMap === 'function' && document.getElementById('houseCdMap')) {
3009                try { ET.refreshHouseCdMap('houseCdMap'); } catch (eHouseMap2) { /* prerender join / fill */ }
3010            }
3011            /* Home: keep 50/50 preload bars until senate+gov maps reveal together. */
3012            if (!ET.homeForecastDeferSeatBars) {
3013                if (typeof ET.updateSeatBar === 'function' && document.getElementById('barR') &&
3014                    !(typeof ET.isHouseHubSeatBarPage === 'function' && ET.isHouseHubSeatBarPage())) {
3015                    ET.updateSeatBar({ r: 'barR', d: 'barD', tie: 'barTie', nr: 'numR', nd: 'numD', rFlip: 'barRFlip', dFlip: 'barDFlip' });
3016                }
3017                if (typeof ET.updateGovSeatBar === 'function' && document.getElementById('govBarR')) {
3018                    ET.updateGovSeatBar({ r: 'govBarR', d: 'govBarD', nr: 'govNumR', nd: 'govNumD', rFlip: 'govBarRFlip', dFlip: 'govBarDFlip' });
3019                }
3020                if (typeof ET.refreshHouseHubSeatBar === 'function' && document.getElementById('houseBarR')) {
3021                    ET.refreshHouseHubSeatBar({
3022                        r: 'houseBarR',
3023                        d: 'houseBarD',
3024                        tie: 'houseBarTie',
3025                        nr: 'houseNumR',
3026                        nd: 'houseNumD',
3027                        rFlip: 'houseBarRFlip',
3028                        dFlip: 'houseBarDFlip'
3029                    }, 'houseCdMap');
3030                }
3031            }
3032            notifyHomeForecastReady();
3033        }
3034        try {
3035            ET.forecastModelApplied = true;
3036            document.dispatchEvent(new CustomEvent('pollsmax-forecast-model-applied'));
3037        } catch (e) { /* ignore */ }
3038    }
3039
3040    function applySenateHubLiteForecast(todayIso, req) {
3041        if (chamberHubBakedDetailsIntact('senate')) {
3042            ET._forecastDailyBundleIso = todayIso;
3043            ET._forecastDailyBundleVersion = FORECAST_DAILY_BUNDLE_VERSION;
3044            refreshForecastUiAfterApply();
3045            return 'baked';
3046        }
3047        if (ET.SENATE_HUB_BAKED_FORECAST && typeof ET.applySenateHubBakedForecast === 'function') {
3048            ET.SENATE_HUB_BAKED_FORECAST._appliedKey = null;
3049            if (ET.applySenateHubBakedForecast() && chamberHubBakedDetailsIntact('senate')) {
3050                ET._forecastDailyBundleIso = todayIso;
3051                ET._forecastDailyBundleVersion = FORECAST_DAILY_BUNDLE_VERSION;
3052                refreshForecastUiAfterApply();
3053                return 'baked';
3054            }
3055        }
3056        ET.FORECAST_MODEL_ENV = ET.forecastModelNationalEnvironment();
3057        if (req.sen && ET.ELECTION_2026) {
3058            ET.FORECAST_MODEL_SENATE_DETAIL = {};
3059            for (var f in ET.ELECTION_2026) {
3060                if (!ET.ELECTION_2026.hasOwnProperty(f)) continue;
3061                var nm = ET.NAME_BY_FIPS[f];
3062                var key = ET.KEY_BY_NAME[nm];
3063                if (!key) continue;
3064                ET.FORECAST_MODEL_SENATE_DETAIL[key] =
3065                    ET.forecastModelSenateDetailAsOfDate(key, nm, todayIso);
3066            }
3067        }
3068        ET._forecastDailyBundleIso = todayIso;
3069        ET._forecastDailyBundleVersion = FORECAST_DAILY_BUNDLE_VERSION;
3070        refreshForecastUiAfterApply();
3071        return 'live';
3072    }
3073
3074    function applyGovHubLiteForecast(todayIso, req) {
3075        if (chamberHubBakedDetailsIntact('gov')) {
3076            ET._forecastDailyBundleIso = todayIso;
3077            ET._forecastDailyBundleVersion = FORECAST_DAILY_BUNDLE_VERSION;
3078            refreshForecastUiAfterApply();
3079            return 'baked';
3080        }
3081        if (ET.GOV_HUB_BAKED_FORECAST && typeof ET.applyGovHubBakedForecast === 'function') {
3082            ET.GOV_HUB_BAKED_FORECAST._appliedKey = null;
3083            if (ET.applyGovHubBakedForecast() && chamberHubBakedDetailsIntact('gov')) {
3084                ET._forecastDailyBundleIso = todayIso;
3085                ET._forecastDailyBundleVersion = FORECAST_DAILY_BUNDLE_VERSION;
3086                refreshForecastUiAfterApply();
3087                return 'baked';
3088            }
3089        }
3090        ET.FORECAST_MODEL_ENV = ET.forecastModelNationalEnvironment();
3091        if (req.gov && ET.GOV_EDITORIAL_RATINGS) {
3092            ET.FORECAST_MODEL_GOV_DETAIL = {};
3093            var govKeys = ET.GOV_PAGE_KEYS && ET.GOV_PAGE_KEYS.length
3094                ? ET.GOV_PAGE_KEYS
3095                : Object.keys(ET.GOV_EDITORIAL_RATINGS);
3096            for (var gi = 0; gi < govKeys.length; gi++) {
3097                var gk = govKeys[gi];
3098                ET.FORECAST_MODEL_GOV_DETAIL[gk] = ET.forecastModelGovDetailAsOfDate(gk, todayIso);
3099            }
3100        }
3101        ET._forecastDailyBundleIso = todayIso;
3102        ET._forecastDailyBundleVersion = FORECAST_DAILY_BUNDLE_VERSION;
3103        refreshForecastUiAfterApply();
3104        return 'live';
3105    }
3106
3107    function applyHouseHubLiteForecast(todayIso, req) {
3108        /* Home House map: keep hub bake numbers (same as Senate/Gov home maps). */
3109        if (typeof ET.homeMapUsesForecastMode === 'function' && ET.homeMapUsesForecastMode() &&
3110            typeof ET.applyHouseHubBakedForecast === 'function' && ET.HOUSE_HUB_BAKED_FORECAST) {
3111            ET._houseHubBakedForecastApplied = false;
3112            if (ET.applyHouseHubBakedForecast()) {
3113                ET._forecastDailyBundleIso = todayIso;
3114                ET._forecastDailyBundleVersion = FORECAST_DAILY_BUNDLE_VERSION;
3115                refreshForecastUiAfterApply();
3116                notifyHouseHubForecastReady();
3117                if (typeof notifyHomeForecastReady === 'function') notifyHomeForecastReady();
3118                return 'baked';
3119            }
3120        }
3121        if (typeof ET.houseHubUsesBakedForecastOnly === 'function' && ET.houseHubUsesBakedForecastOnly()) {
3122            notifyHouseHubForecastReady();
3123            return;
3124        }
3125        ET.FORECAST_MODEL_ENV = ET.forecastModelNationalEnvironment();
3126        if (req.house && ET.HOUSE_FORECAST_RATINGS) {
3127            ET.FORECAST_MODEL_HOUSE_DETAIL = {};
3128            ET.HOUSE_FORECAST_RATINGS.forEach(function (row) {
3129                if (!row || !row.code) return;
3130                var detailHouse = ET.forecastModelHouseDetailAsOfDate(row.code, todayIso);
3131                ET.FORECAST_MODEL_HOUSE_DETAIL[row.code] = detailHouse;
3132                row.rating = detailHouse.rating;
3133            });
3134        }
3135        ET._forecastDailyBundleIso = todayIso;
3136        ET._forecastDailyBundleVersion = FORECAST_DAILY_BUNDLE_VERSION;
3137        refreshForecastUiAfterApply();
3138        notifyHouseHubForecastReady();
3139    }
3140
3141    function isHouseForecastHubPage() {
3142        try {
3143            var path = location && location.pathname ? location.pathname : '';
3144            return /^\/2026-us-house(?:\/index\.html)?\/?$/.test(path) && !!document.getElementById('houseCdMap');
3145        } catch (e) {
3146            return false;
3147        }
3148    }
3149
3150    function notifyHomeForecastReady() {
3151        if (typeof document === 'undefined') return;
3152        if (typeof ET.homeMapUsesForecastMode !== 'function' || !ET.homeMapUsesForecastMode()) return;
3153        setTimeout(function () {
3154            try {
3155                document.dispatchEvent(new CustomEvent('pollsmax-home-forecast-ready'));
3156            } catch (eHome) { /* IE */ }
3157        }, 0);
3158    }
3159
3160    /** Home maps use the same senate/gov/house hub bakes as hub pages — do not wait on today's live MC. */
3161    function homeChamberHubBakeReady() {
3162        if (typeof ET.homeMapUsesForecastMode !== 'function' || !ET.homeMapUsesForecastMode()) return false;
3163        if (!ET.SENATE_HUB_BAKED_FORECAST || !ET.GOV_HUB_BAKED_FORECAST) return false;
3164        var sen = ET.FORECAST_MODEL_SENATE_DETAIL;
3165        var gov = ET.FORECAST_MODEL_GOV_DETAIL;
3166        if (!sen || !gov) return false;
3167        if (Object.keys(sen).length < FORECAST_DAILY_MIN_SENATE ||
3168            Object.keys(gov).length < FORECAST_DAILY_MIN_GOV) {
3169            return false;
3170        }
3171        if (typeof document !== 'undefined' && document.getElementById('houseCdMap')) {
3172            if (typeof ET.applyHouseHubBakedForecast === 'function' && !ET._houseHubBakedForecastApplied) {
3173                try { ET.applyHouseHubBakedForecast(); } catch (eHouseBake) { /* ignore */ }
3174            }
3175            if (!ET.HOUSE_HUB_BAKED_FORECAST) return false;
3176            var house = ET.FORECAST_MODEL_HOUSE_DETAIL;
3177            if (!house || Object.keys(house).length < FORECAST_DAILY_MIN_HOUSE) return false;
3178        }
3179        return true;
3180    }
3181
3182    /** True when chamber hub bake is already applied into FORECAST_MODEL_*_DETAIL. */
3183    function chamberHubBakedDetailsIntact(chamber) {
3184        if (chamber === 'house') {
3185            if (typeof ET.applyHouseHubBakedForecast === 'function' && !ET._houseHubBakedForecastApplied) {
3186                try { ET.applyHouseHubBakedForecast(); } catch (eHouseBake2) { /* ignore */ }
3187            }
3188            var houseMap = ET.FORECAST_MODEL_HOUSE_DETAIL;
3189            var houseSrc = ET.HOUSE_HUB_BAKED_FORECAST;
3190            if (!houseSrc || !houseMap) return false;
3191            var houseKeys = Object.keys(houseMap);
3192            if (houseKeys.length < FORECAST_DAILY_MIN_HOUSE) return false;
3193            var houseBaked = 0;
3194            var hi;
3195            for (hi = 0; hi < houseKeys.length; hi++) {
3196                if (houseMap[houseKeys[hi]] && houseMap[houseKeys[hi]]._fromHouseForecastSnapshot) houseBaked++;
3197            }
3198            return houseBaked >= FORECAST_DAILY_MIN_HOUSE;
3199        }
3200        var map = chamber === 'gov' ? ET.FORECAST_MODEL_GOV_DETAIL : ET.FORECAST_MODEL_SENATE_DETAIL;
3201        var minN = chamber === 'gov' ? FORECAST_DAILY_MIN_GOV : FORECAST_DAILY_MIN_SENATE;
3202        var bakedSrc = chamber === 'gov' ? ET.GOV_HUB_BAKED_FORECAST : ET.SENATE_HUB_BAKED_FORECAST;
3203        if (!bakedSrc || !map) return false;
3204        var keys = Object.keys(map);
3205        if (keys.length < minN) return false;
3206        var baked = 0;
3207        var i;
3208        for (i = 0; i < keys.length; i++) {
3209            if (map[keys[i]] && map[keys[i]]._fromChamberHubBaked) baked++;
3210        }
3211        return baked >= minN;
3212    }
3213
3214    /**
3215     * Senate/gov/house hub (and home): keep race-forecast bake numbers so map tips, race lists,
3216     * and forecast pages stay aligned — do not overwrite with a fresh live MC draw.
3217     */
3218    function preferChamberHubBakedForecast(req) {
3219        req = req || forecastDailyBundleRequirements();
3220        var onHome = typeof ET.homeMapUsesForecastMode === 'function' && ET.homeMapUsesForecastMode();
3221        var onSenHub = typeof document !== 'undefined' && document.body &&
3222            document.body.classList.contains('senate-hub-page');
3223        var onGovHub = typeof document !== 'undefined' && document.body &&
3224            document.body.classList.contains('gov-hub-page');
3225        /* House hub page uses its own lite/bake path; home House map uses hub bake like Senate/Gov. */
3226        if (req.house && !onHome) return false;
3227        if (!onHome && !onSenHub && !onGovHub) return false;
3228        if (req.sen && !chamberHubBakedDetailsIntact('senate')) return false;
3229        if (req.gov && !chamberHubBakedDetailsIntact('gov')) return false;
3230        if (req.house && !chamberHubBakedDetailsIntact('house')) return false;
3231        if (!req.sen && !req.gov && !req.house) return false;
3232        return true;
3233    }
3234
3235    /** Re-apply chamber hub bakes on home so Senate / Gov / House maps share hub numbers. */
3236    ET.reapplyHomeChamberHubBakes = function () {
3237        if (typeof ET.homeMapUsesForecastMode !== 'function' || !ET.homeMapUsesForecastMode()) return;
3238        try {
3239            if (ET.SENATE_HUB_BAKED_FORECAST && typeof ET.applySenateHubBakedForecast === 'function') {
3240                ET.SENATE_HUB_BAKED_FORECAST._appliedKey = null;
3241                ET.applySenateHubBakedForecast();
3242            }
3243            if (ET.GOV_HUB_BAKED_FORECAST && typeof ET.applyGovHubBakedForecast === 'function') {
3244                ET.GOV_HUB_BAKED_FORECAST._appliedKey = null;
3245                ET.applyGovHubBakedForecast();
3246            }
3247            if (ET.HOUSE_HUB_BAKED_FORECAST && typeof ET.applyHouseHubBakedForecast === 'function') {
3248                ET._houseHubBakedForecastApplied = false;
3249                ET.applyHouseHubBakedForecast();
3250            }
3251        } catch (eReapply) { /* ignore */ }
3252    };
3253
3254    ET.isHomeForecastReady = function () {
3255        if (typeof ET.homeMapUsesForecastMode !== 'function' || !ET.homeMapUsesForecastMode()) return true;
3256        if (homeChamberHubBakeReady()) return true;
3257        var req = forecastDailyBundleRequirements();
3258        if (!req.sen && !req.gov) return true;
3259        var todayIso = typeof ET.etTodayISO === 'function' ? ET.etTodayISO() : '9999-12-31';
3260        return inMemoryForecastDailyComplete(todayIso, req);
3261    };
3262
3263    ET.whenHomeForecastReady = function (fn) {
3264        if (typeof fn !== 'function') return;
3265        if (ET.isHomeForecastReady()) {
3266            fn();
3267            return;
3268        }
3269        var done = false;
3270        function finish() {
3271            if (done || !ET.isHomeForecastReady()) return;
3272            done = true;
3273            fn();
3274        }
3275        document.addEventListener('pollsmax-home-forecast-ready', finish, { once: true });
3276        var tries = 0;
3277        (function tick() {
3278            if (done) return;
3279            try {
3280                if (typeof ET.applyForecastModelRatings === 'function') ET.applyForecastModelRatings();
3281            } catch (eApply) { /* map refresh must not stall home ready */ }
3282            if (ET.isHomeForecastReady()) {
3283                finish();
3284                return;
3285            }
3286            if (++tries < 120) setTimeout(tick, 50);
3287        })();
3288    };
3289
3290    function notifyHouseHubForecastReady() {
3291        if (typeof document === 'undefined') return;
3292        setTimeout(function () {
3293            try {
3294                document.dispatchEvent(new CustomEvent('pollsmax-house-forecast-ready'));
3295            } catch (e) { /* IE */ }
3296        }, 0);
3297    }
3298
3299    ET.isHouseHubForecastReady = function () {
3300        var req = forecastDailyBundleRequirements();
3301        if (!req.house) return true;
3302        var todayIso = typeof ET.etTodayISO === 'function' ? ET.etTodayISO() : '9999-12-31';
3303        return inMemoryForecastDailyComplete(todayIso, req);
3304    };
3305
3306    ET.whenHouseHubForecastReady = function (fn) {
3307        if (typeof fn !== 'function') return;
3308        if (ET.isHouseHubForecastReady()) {
3309            fn();
3310            return;
3311        }
3312        document.addEventListener('pollsmax-house-forecast-ready', function onReady() {
3313            fn();
3314        }, { once: true });
3315        setTimeout(function () {
3316            if (ET.isHouseHubForecastReady()) fn();
3317        }, 0);
3318    };
3319
3320    function scheduleFullForecastDailySimDeferred() {
3321        if (typeof window !== 'undefined' && window.POLLSMAX_HOUSE_HUB_LITE && isHouseForecastHubPage()) {
3322            return;
3323        }
3324        if (ET._forecastFullDailySimQueued) return;
3325        ET._forecastFullDailySimQueued = true;
3326        function runFull() {
3327            if (typeof window !== 'undefined') {
3328                window.POLLSMAX_SENATE_HUB_LITE = false;
3329                window.POLLSMAX_GOV_HUB_LITE = false;
3330                window.POLLSMAX_HOUSE_HUB_LITE = false;
3331            }
3332            ET._forecastFullDailySimQueued = false;
3333            ET.applyForecastModelRatings();
3334        }
3335        if (typeof requestIdleCallback === 'function') {
3336            requestIdleCallback(runFull, { timeout: 12000 });
3337        } else {
3338            setTimeout(runFull, 2500);
3339        }
3340    }
3341
3342    ET.applyForecastModelRatings = function () {
3343        if (!ET.SENATE_EDITORIAL_RATINGS_BASE && ET.SENATE_EDITORIAL_RATINGS) {
3344            ET.SENATE_EDITORIAL_RATINGS_BASE = Object.assign({}, ET.SENATE_EDITORIAL_RATINGS);
3345        }
3346        if (!ET.GOV_EDITORIAL_RATINGS_BASE && ET.GOV_EDITORIAL_RATINGS) {
3347            ET.GOV_EDITORIAL_RATINGS_BASE = Object.assign({}, ET.GOV_EDITORIAL_RATINGS);
3348        }
3349
3350        var req = forecastDailyBundleRequirements();
3351        if (!req.sen && !req.gov && !req.house) return true;
3352
3353        var todayIso = typeof ET.etTodayISO === 'function' ? ET.etTodayISO() : '9999-12-31';
3354        /* Hub + home: keep chamber-hub bake so map tips, race lists, and forecast pages match. */
3355        if (preferChamberHubBakedForecast(req)) {
3356            if (req.house && typeof ET.applyHouseHubBakedForecast === 'function') {
3357                try { ET.applyHouseHubBakedForecast(); } catch (eHousePref) { /* ignore */ }
3358            }
3359            ET._forecastDailyBundleIso = todayIso;
3360            ET._forecastDailyBundleVersion = FORECAST_DAILY_BUNDLE_VERSION;
3361            refreshForecastUiAfterApply();
3362            if (typeof ET.homeMapUsesForecastMode === 'function' && ET.homeMapUsesForecastMode()) {
3363                notifyHomeForecastReady();
3364            }
3365            return true;
3366        }
3367        /* Home: keep chamber-hub bake (matches hub maps); skip live daily overwrite. */
3368        if (homeChamberHubBakeReady()) {
3369            if (req.house && typeof ET.applyHouseHubBakedForecast === 'function') {
3370                try { ET.applyHouseHubBakedForecast(); } catch (eHouseHome) { /* ignore */ }
3371            }
3372            refreshForecastUiAfterApply();
3373            notifyHomeForecastReady();
3374            return true;
3375        }
3376        if (inMemoryForecastDailyComplete(todayIso, req)) {
3377            if (req.house && isHouseForecastHubPage()) notifyHouseHubForecastReady();
3378            if (typeof ET.homeMapUsesForecastMode === 'function' && ET.homeMapUsesForecastMode()) {
3379                refreshForecastUiAfterApply();
3380                notifyHomeForecastReady();
3381            }
3382            return true;
3383        }
3384
3385        if (typeof window !== 'undefined' && window.POLLSMAX_SENATE_HUB_LITE && req.sen) {
3386            var senLite = applySenateHubLiteForecast(todayIso, req);
3387            if (senLite !== 'baked') scheduleFullForecastDailySimDeferred();
3388            return true;
3389        }
3390
3391        if (typeof window !== 'undefined' && window.POLLSMAX_GOV_HUB_LITE && req.gov) {
3392            var govLite = applyGovHubLiteForecast(todayIso, req);
3393            if (govLite !== 'baked') scheduleFullForecastDailySimDeferred();
3394            return true;
3395        }
3396
3397        if (typeof window !== 'undefined' && window.POLLSMAX_HOUSE_HUB_LITE && req.house) {
3398            applyHouseHubLiteForecast(todayIso, req);
3399            scheduleFullForecastDailySimDeferred();
3400            return true;
3401        }
3402
3403        if (ET._forecastModelApplyInFlight) {
3404            return false;
3405        }
3406        ET._forecastModelApplyInFlight = true;
3407        try {
3408            ET.FORECAST_MODEL_ENV = ET.forecastModelNationalEnvironment();
3409            var bundle = loadForecastDailyBundle(todayIso);
3410            var partialSave = {};
3411
3412            if (!forecastDailyBundleMeetsRequirements(bundle, todayIso, req)) {
3413                ET._forecastDailyBundleIso = null;
3414                ET._forecastDailyBundleVersion = null;
3415            }
3416
3417            if (forecastDailyBundleMeetsRequirements(bundle, todayIso, req)) {
3418                restoreForecastFromDailyBundle(bundle, todayIso, req);
3419                ET._forecastDailyBundleIso = todayIso;
3420            } else {
3421                ET.FORECAST_MODEL_SENATE_DETAIL = ET.FORECAST_MODEL_SENATE_DETAIL || {};
3422                ET.FORECAST_MODEL_GOV_DETAIL = ET.FORECAST_MODEL_GOV_DETAIL || {};
3423                ET.FORECAST_MODEL_HOUSE_DETAIL = ET.FORECAST_MODEL_HOUSE_DETAIL || {};
3424
3425                if (req.sen) {
3426                    if (bundle && forecastDailySectionReady(bundle.sen, FORECAST_DAILY_MIN_SENATE)) {
3427                        restoreSenateFromDailyBundle(bundle.sen, todayIso);
3428                    } else {
3429                        partialSave.sen = computeSenateDailySim(todayIso);
3430                    }
3431                }
3432                if (req.gov) {
3433                    if (bundle && forecastDailySectionReady(bundle.gov, FORECAST_DAILY_MIN_GOV)) {
3434                        restoreGovFromDailyBundle(bundle.gov, todayIso);
3435                    } else {
3436                        partialSave.gov = computeGovDailySim(todayIso);
3437                    }
3438                }
3439                if (req.house) {
3440                    if (bundle && forecastDailySectionReady(bundle.house, FORECAST_DAILY_MIN_HOUSE)) {
3441                        restoreHouseFromDailyBundle(bundle.house, todayIso);
3442                    } else {
3443                        partialSave.house = computeHouseDailySim(todayIso);
3444                    }
3445                }
3446
3447                if (partialSave.sen || partialSave.gov || partialSave.house) {
3448                    bundle = mergeForecastDailyBundle(todayIso, partialSave);
3449                }
3450                ET._forecastDailyBundleIso = todayIso;
3451            }
3452
3453            ET._forecastDailyBundleVersion = FORECAST_DAILY_BUNDLE_VERSION;
3454            if (ET.SENATE_EDITORIAL_BLEND_REVISION != null) {
3455                ET._forecastModelAppliedBlendRevision = ET.SENATE_EDITORIAL_BLEND_REVISION;
3456                ET._forecastModelAppliedSenateBlendRevision = ET.SENATE_EDITORIAL_BLEND_REVISION;
3457            }
3458            if (ET.GOV_EDITORIAL_BLEND_REVISION != null) {
3459                ET._forecastModelAppliedGovBlendRevision = ET.GOV_EDITORIAL_BLEND_REVISION;
3460            }
3461            refreshForecastUiAfterApply();
3462            if (req.house && isHouseForecastHubPage()) notifyHouseHubForecastReady();
3463            return true;
3464        } finally {
3465            ET._forecastModelApplyInFlight = false;
3466        }
3467    };
3468
3469    /** Hub map / seat bar slot from daily 100k sim + composite (not static editorial ratings). */
3470    ET.forecastHubRaceSlotFromModel = function (kind, key, name) {
3471        if (!key) return null;
3472        var det = typeof ET.hubAsideForecastDetail === 'function'
3473            ? ET.hubAsideForecastDetail(kind, key, name)
3474            : null;
3475        if (!det) return null;
3476        var pD = ET.forecastModelDisplayWinProbD(det);
3477        if (pD == null || !isFinite(pD)) return null;
3478        var margin = ET.forecastModelDisplayMargin(det);
3479        if (margin == null || !isFinite(margin)) margin = 0;
3480        // Color maps/bars as the slight favorite (tilt), not gray TIE — e.g. IA gov ~50/50 → Lahn tiltR.
3481        // Governors: never emit TIE/gray. Any edge in win prob or margin → that party; perfect
3482        // 50/50 → party currently holding the governorship (open seats included).
3483        var winner = 'R';
3484        var perfectTie = false;
3485        if (pD > 0.5) winner = 'D';
3486        else if (pD < 0.5) winner = 'R';
3487        else if (margin > 0) winner = 'D';
3488        else if (margin < 0) winner = 'R';
3489        else {
3490            perfectTie = true;
3491            if (kind === 'gov') {
3492                var heldGov = (typeof ET.govFlipBaselineParty === 'function' && ET.govFlipBaselineParty(key))
3493                    || (ET.GOV_INCUMBENT_PARTY && ET.GOV_INCUMBENT_PARTY[key])
3494                    || null;
3495                winner = (heldGov === 'D' || heldGov === 'R') ? heldGov : 'R';
3496            }
3497        }
3498        if (kind === 'senate' && ET.isSenateIndGeState && ET.isSenateIndGeState(key) && margin > 0) {
3499            winner = 'I';
3500        }
3501        var rating = typeof ET.forecastModelWinProbToRating === 'function'
3502            ? ET.forecastModelWinProbToRating(pD, key)
3503            : (det.rating || null);
3504        if (kind === 'gov' && perfectTie && (winner === 'D' || winner === 'R')) {
3505            rating = winner === 'D' ? 'tiltD' : 'tiltR';
3506        }
3507        var tier = rating && typeof ET.editorialRatingToMarginTier === 'function'
3508            ? ET.editorialRatingToMarginTier(rating)
3509            : (typeof ET.forecastHubMarginTier === 'function'
3510                ? ET.forecastHubMarginTier(true, Math.abs(margin))
3511                : 'tilt');
3512        if (kind === 'gov' && perfectTie) tier = 'tilt';
3513        var flip = false;
3514        if (winner !== 'TIE' && winner !== 'I') {
3515            if (kind === 'senate' && typeof ET.senateHubFlipLead === 'function') {
3516                var flSen = ET.senateHubFlipLead(name, key, null, { mapSource: 'ratings', showFlips: true });
3517                flip = flSen === 'R' || flSen === 'D';
3518            } else if (rating && typeof ET.editorialRatingFlipLead === 'function') {
3519                flip = !!(ET.editorialRatingFlipLead(kind, key, rating, name));
3520            }
3521        }
3522        var hasPolls = false;
3523        if (kind === 'senate' && ET.SENATE && ET.SENATE[key] && ET.SENATE[key].length) hasPolls = true;
3524        if (kind === 'gov' && typeof ET.govSeriesWithLocalFallback === 'function') {
3525            var gs = ET.govSeriesWithLocalFallback(key);
3526            hasPolls = !!(gs && gs.length);
3527        }
3528        return {
3529            winner: winner,
3530            tier: tier,
3531            flip: flip,
3532            hasPolls: hasPolls,
3533            key: key,
3534            name: name,
3535            marginAbs: Math.abs(margin),
3536            forecastRating: rating,
3537            winProbD: pD
3538        };
3539    };
3540
3541    ET.senateForecastHubRaceSlotFromForecast = function (name, key) {
3542        return ET.forecastHubRaceSlotFromModel('senate', key, name);
3543    };
3544
3545    ET.govForecastHubRaceSlotFromForecast = function (key) {
3546        var nm = ET.NAME_BY_KEY && ET.NAME_BY_KEY[key];
3547        return ET.forecastHubRaceSlotFromModel('gov', key, nm || null);
3548    };
3549
3550    ET.forecastHubModelStateFill = function (kind, key, name, ui) {
3551        ui = ui || { showFlips: true, showMargins: false };
3552        if (kind === 'senate' && ui.showFlips &&
3553            typeof ET.senateHubMapFlipPatternFill === 'function') {
3554            var senateFlipFill = ET.senateHubMapFlipPatternFill(name, key, ui);
3555            if (senateFlipFill) return senateFlipFill;
3556        }
3557        var slot = ET.forecastHubRaceSlotFromModel(kind, key, name);
3558        if (!slot) return null;
3559        if (slot.winner === 'TIE') {
3560            // Governors must never paint gray — break remaining ties to held party tilt.
3561            if (kind === 'gov') {
3562                var heldFill = (typeof ET.govFlipBaselineParty === 'function' && ET.govFlipBaselineParty(key))
3563                    || (ET.GOV_INCUMBENT_PARTY && ET.GOV_INCUMBENT_PARTY[key])
3564                    || 'R';
3565                if (heldFill !== 'D' && heldFill !== 'R') heldFill = 'R';
3566                slot = {
3567                    winner: heldFill,
3568                    tier: 'tilt',
3569                    flip: false,
3570                    hasPolls: !!slot.hasPolls,
3571                    key: slot.key,
3572                    name: slot.name,
3573                    marginAbs: slot.marginAbs || 0,
3574                    forecastRating: heldFill === 'D' ? 'tiltD' : 'tiltR',
3575                    winProbD: slot.winProbD
3576                };
3577            } else {
3578                return '#64748b';
3579            }
3580        }
3581        if (slot.winner === 'I') return ET.COLORS.IND_GOLD || '#a5b2c2';
3582        var useTierColors = typeof ET.forecastHubMapUsesTierColors === 'function'
3583            ? ET.forecastHubMapUsesTierColors(ui)
3584            : !!ui.showMargins;
3585        if (useTierColors) {
3586            if (ui.showFlips && slot.flip && (slot.winner === 'R' || slot.winner === 'D') &&
3587                typeof ET.forecastHubMarginFlipPatternId === 'function') {
3588                return 'url(#' + ET.forecastHubMarginFlipPatternId(kind, slot.winner, slot.tier) + ')';
3589            }
3590            if (typeof ET.forecastHubMarginFill === 'function') {
3591                return ET.forecastHubMarginFill(slot.winner, slot.tier);
3592            }
3593        }
3594        if (typeof ET.forecastHubSimpleMapPartyFill === 'function') {
3595            return ET.forecastHubSimpleMapPartyFill(
3596                kind,
3597                slot.winner,
3598                slot.flip ? slot.winner : null,
3599                ui
3600            );
3601        }
3602        return slot.winner === 'D' ? ET.COLORS.BLUE : ET.COLORS.RED;
3603    };
3604
3605    function countElectionSeatsFromForecastSlots(kind) {
3606        var r = ET.NOT_UP_BASE_R || 0;
3607        var d = ET.NOT_UP_BASE_D || 0;
3608        var toss = 0;
3609        var hasModel = kind === 'gov'
3610            ? (ET.FORECAST_MODEL_GOV_DETAIL && Object.keys(ET.FORECAST_MODEL_GOV_DETAIL).length > 0)
3611            : (ET.FORECAST_MODEL_SENATE_DETAIL && Object.keys(ET.FORECAST_MODEL_SENATE_DETAIL).length > 0);
3612        if (!hasModel) return null;
3613        if (kind === 'senate' && ET.ELECTION_2026) {
3614            for (var f in ET.ELECTION_2026) {
3615                if (!ET.ELECTION_2026.hasOwnProperty(f)) continue;
3616                var nm = ET.NAME_BY_FIPS[f];
3617                var key = ET.KEY_BY_NAME[nm];
3618                var slot = ET.senateForecastHubRaceSlotFromForecast(nm, key);
3619                if (!slot) continue;
3620                if (slot.winner === 'TIE') toss++;
3621                else if (slot.winner === 'R') r++;
3622                else d++;
3623            }
3624            return { r: r, d: d, tossup: toss };
3625        }
3626        if (kind === 'gov' && ET.GOV_EDITORIAL_RATINGS) {
3627            var govNotUp = typeof ET.forecastHubNotUpCounts === 'function'
3628                ? ET.forecastHubNotUpCounts('gov')
3629                : { r: ET.GOV_NOT_UP_BASE_R || 8, d: ET.GOV_NOT_UP_BASE_D || 6 };
3630            r = govNotUp.r;
3631            d = govNotUp.d;
3632            Object.keys(ET.GOV_EDITORIAL_RATINGS).forEach(function (key) {
3633                if (ET.govEditorialNoElectionKeys && ET.govEditorialNoElectionKeys().has(key)) return;
3634                var slot = ET.govForecastHubRaceSlotFromForecast(key);
3635                if (!slot) return;
3636                if (slot.winner === 'TIE') {
3637                    var heldSim = (typeof ET.govFlipBaselineParty === 'function' && ET.govFlipBaselineParty(key))
3638                        || (ET.GOV_INCUMBENT_PARTY && ET.GOV_INCUMBENT_PARTY[key])
3639                        || 'R';
3640                    if (heldSim === 'D') d++;
3641                    else r++;
3642                } else if (slot.winner === 'R') r++;
3643                else d++;
3644            });
3645            return { r: r, d: d, tossup: 0 };
3646        }
3647        return null;
3648    }
3649
3650    ET.countSenateSeatsFromForecastSim = function () {
3651        return countElectionSeatsFromForecastSlots('senate');
3652    };
3653
3654    ET.countGovSeatsFromForecastSim = function () {
3655        return countElectionSeatsFromForecastSlots('gov');
3656    };
3657
3658    var SENATE_HUB_STATE_SIM_DRAWS = 100000;
3659    var _senateHubStateWinProbMemo = null;
3660
3661    ET.senateHubClearStateWinProbMemo = function () {
3662        _senateHubStateWinProbMemo = {};
3663    };
3664
3665    /**
3666     * Per-state D win probability for Senate hub chamber MC (same 100k sim as race forecast pages).
3667     * @param {string[]} electionKeys
3668     * @param {string} cutoffIso
3669     * @param {{ drawCount?: number }} [opts]
3670     * @returns {{ key: string, pD: number, incumbent: string|null }[]}
3671     */
3672    ET.senateHubStateWinProbsForCutoff = function (electionKeys, cutoffIso, opts) {
3673        opts = opts || {};
3674        if ((typeof ET.forecastMathDisabledForKind === 'function' && ET.forecastMathDisabledForKind('senate')) ||
3675            ET.FORECAST_MATH_DISABLED) return [];
3676        if (!electionKeys || !electionKeys.length || !cutoffIso) return [];
3677        var isBacktest = !!opts.backtest;
3678        /** Hub history uses strict as-of-day inputs; live uses the current day including same-day updates. */
3679        var detailOpts = isBacktest ? { pollsBeforeDateOnly: true } : {};
3680        var memoTag = 'hubWinProb|asOf' + (isBacktest ? '|hist' : '|live');
3681        if (!_senateHubStateWinProbMemo) _senateHubStateWinProbMemo = {};
3682        var out = [];
3683        var i;
3684        for (i = 0; i < electionKeys.length; i++) {
3685            var key = electionKeys[i];
3686            var memoKey = key + '|' + cutoffIso + '|' + memoTag;
3687            if (_senateHubStateWinProbMemo[memoKey]) {
3688                out.push(_senateHubStateWinProbMemo[memoKey]);
3689                continue;
3690            }
3691            var nm = ET.NAME_BY_KEY && ET.NAME_BY_KEY[key];
3692            var incumbent = null;
3693            if (nm && ET.INCUMBENT_2026) incumbent = ET.INCUMBENT_2026[nm];
3694            if (!incumbent && nm && typeof ET.senateEditorialIncumbentParty === 'function') {
3695                incumbent = ET.senateEditorialIncumbentParty(nm, key);
3696            }
3697            var pD = 0.5;
3698            var det = null;
3699            if (nm && typeof ET.forecastModelSenateDetailAsOfDate === 'function') {
3700                det = ET.forecastModelSenateDetailAsOfDate(key, nm, cutoffIso, detailOpts);
3701                if (det && typeof ET.forecastModelDisplayWinProbD === 'function') {
3702                    var displayPD = ET.forecastModelDisplayWinProbD(det);
3703                    if (displayPD != null && isFinite(displayPD)) pD = displayPD;
3704                } else if (det) {
3705                    if (det.winProbD != null && isFinite(det.winProbD)) {
3706                        pD = det.winProbD;
3707                    } else if (det.margin != null && isFinite(det.margin) &&
3708                        typeof ET.forecastModelMarginToWinProb === 'function') {
3709                        pD = ET.forecastModelMarginToWinProb(det.margin);
3710                    }
3711                }
3712            }
3713            var margin = det && det.margin != null && isFinite(det.margin) ? det.margin : null;
3714            var sigma = null;
3715            if (margin != null && typeof ET.forecastSimulationSigma === 'function') {
3716                var pollN = 0;
3717                if (ET.SENATE && ET.SENATE[key]) {
3718                    pollN = racePollsOnOrBeforeDate(ET.SENATE[key], cutoffIso).length;
3719                }
3720                sigma = ET.forecastSimulationSigma(pollN, det);
3721            }
3722            var row = { key: key, pD: pD, margin: margin, sigma: sigma, incumbent: incumbent };
3723            _senateHubStateWinProbMemo[memoKey] = row;
3724            out.push(row);
3725        }
3726        return out;
3727    };
3728
3729    ET.FORECAST_MATH_DISABLED = false;
3730    ET.forecastMathDisabledForKind = function () {
3731        return !!ET.FORECAST_MATH_DISABLED;
3732    };
3733
3734    function forecastMathDisabledDetail(kind, id, extras) {
3735        var detail = {
3736            kind: kind,
3737            stateKey: kind === 'house' ? null : (id || null),
3738            code: kind === 'house' ? (id || null) : null,
3739            margin: null,
3740            marginSim: null,
3741            winProbD: null,
3742            winProbDSim: null,
3743            rating: null,
3744            disabled: true,
3745            forecastDisabled: true,
3746            forecastMathDisabled: true
3747        };
3748        if (extras && typeof extras === 'object') {
3749            for (var k in extras) {
3750                if (Object.prototype.hasOwnProperty.call(extras, k)) detail[k] = extras[k];
3751            }
3752        }
3753        return detail;
3754    }
3755
3756    var forecastModelRaceDetailAsOfLive = ET.forecastModelRaceDetailAsOf;
3757    var forecastModelSimAggregateForDetailLive = ET.forecastModelSimAggregateForDetail;
3758    var forecastModelEnsureDetailSimLive = ET.forecastModelEnsureDetailSim;
3759
3760    ET.forecastModelRaceDetailAsOf = function (ctx, asOfIso) {
3761        if (!ctx) return null;
3762        if (ET.forecastMathDisabledForKind && ET.forecastMathDisabledForKind(ctx.kind)) {
3763            var id = ctx.kind === 'house'
3764                ? ((ctx.houseRace && ctx.houseRace.code) || ctx.houseCode || null)
3765                : (ctx.stateKey || null);
3766            return forecastMathDisabledDetail(ctx.kind || null, id, { asOfIso: asOfIso || null });
3767        }
3768        return forecastModelRaceDetailAsOfLive(ctx, asOfIso);
3769    };
3770
3771    ET.forecastModelRaceDetailAsOfForTrend = function (ctx, asOfIso) {
3772        return ET.forecastModelRaceDetailAsOf(ctx, asOfIso);
3773    };
3774
3775    ET.forecastModelSimAggregateForDetail = function (det, opts) {
3776        opts = opts || {};
3777        if (det && det.forecastDisabled) {
3778            return {
3779                disabled: true,
3780                forecastDisabled: true,
3781                margin: null,
3782                winProbD: null,
3783                margins: opts.keepMargins ? [] : null,
3784                dWon: opts.keepMargins ? [] : null,
3785                compositeMargin: det && det.margin != null ? det.margin : null,
3786                pollCount: opts.pollCount == null ? null : opts.pollCount
3787            };
3788        }
3789        return forecastModelSimAggregateForDetailLive(det, opts);
3790    };
3791
3792    /**
3793     * Stamp published /forecast/ bake onto a detail — hub maps and race pages must not
3794     * live-resimulate past these numbers.
3795     */
3796    function applyRaceForecastPageBakeToDetail(detail, kind, key) {
3797        if (!detail || !kind || !key) return false;
3798        if (kind !== 'gov' && kind !== 'senate') return false;
3799        var entry = ET.RACE_FORECAST_PAGE_BAKED;
3800        if (!entry || !entry.histogram) return false;
3801        if (entry._raceKey !== (kind + ':' + key)) return false;
3802        var today = typeof ET.etTodayISO === 'function' ? ET.etTodayISO() : null;
3803        if (entry.todayIso && today && entry.todayIso !== today && entry.todayIso > today) {
3804            return false;
3805        }
3806        var h = entry.histogram;
3807        var med = h.marginMedian != null ? h.marginMedian : h.margin;
3808        if (med != null && isFinite(med)) {
3809            detail.marginSim = med;
3810            detail.marginSimMedian = med;
3811            detail.displayMargin = med;
3812        }
3813        if (h.winProbD != null && isFinite(h.winProbD)) {
3814            detail.winProbDSim = h.winProbD;
3815            detail.displayWinProbD = h.winProbD;
3816        }
3817        if (detail.winProbDSim == null || !isFinite(detail.winProbDSim)) return false;
3818        detail._fromRaceForecastBaked = true;
3819        if (typeof ET.forecastModelWinProbToRating === 'function') {
3820            detail.rating = ET.forecastModelWinProbToRating(detail.winProbDSim, key);
3821        }
3822        return true;
3823    }
3824
3825    ET.forecastModelEnsureDetailSim = function (detail, kind, key, name, asOfIso) {
3826        if (detail && detail.partyLockout) {
3827            return detail;
3828        }
3829        if (detail && (detail.forecastDisabled || (kind && ET.forecastMathDisabledForKind && ET.forecastMathDisabledForKind(kind)))) {
3830            detail.winProbDSim = null;
3831            detail.marginSim = null;
3832            detail._forecastSimSig = 'disabled';
3833            detail.disabled = true;
3834            detail.forecastDisabled = true;
3835            detail.forecastMathDisabled = true;
3836            return detail;
3837        }
3838        /* Hub bake is aggregated from race-forecast-baked — preserve those odds/margins. */
3839        if (detail && detail._fromChamberHubBaked &&
3840            detail.winProbDSim != null && isFinite(detail.winProbDSim)) {
3841            return detail;
3842        }
3843        /* Race forecast page bake — keep page aligned with hub map / published histogram. */
3844        if (detail && detail._fromRaceForecastBaked &&
3845            detail.winProbDSim != null && isFinite(detail.winProbDSim)) {
3846            return detail;
3847        }
3848        /* Prefer published race bake over a fresh live MC draw (same source as hub tips). */
3849        if (applyRaceForecastPageBakeToDetail(detail, kind, key)) {
3850            if (kind === 'gov') {
3851                ET.FORECAST_MODEL_GOV_DETAIL = ET.FORECAST_MODEL_GOV_DETAIL || {};
3852                ET.FORECAST_MODEL_GOV_DETAIL[key] = detail;
3853            } else if (kind === 'senate') {
3854                ET.FORECAST_MODEL_SENATE_DETAIL = ET.FORECAST_MODEL_SENATE_DETAIL || {};
3855                ET.FORECAST_MODEL_SENATE_DETAIL[key] = detail;
3856            }
3857            return detail;
3858        }
3859        return forecastModelEnsureDetailSimLive(detail, kind, key, name, asOfIso);
3860    };
3861
3862    function scheduleApply() {
3863        if (typeof ET !== 'undefined' && ET._houseHubBakedForecastApplied && isHouseForecastHubPage()) {
3864            return;
3865        }
3866        var tries = 0;
3867        function tick() {
3868            tries++;
3869            if (ET.applyForecastModelRatings()) return;
3870            if (tries < 120) setTimeout(tick, 50);
3871        }
3872        tick();
3873    }
3874
3875    if (typeof document !== 'undefined') {
3876        if (document.readyState === 'loading') {
3877            document.addEventListener('DOMContentLoaded', scheduleApply);
3878        } else {
3879            scheduleApply();
3880        }
3881    }
3882})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.