1/* global ET */ 2/** 3 * Per-race forecast simulation calibration (Option B + fundamentals blend). 4 * Load after assets/forecast-model.js (uses ET.forecastModelMarginToWinProb, ET.forecastModelNormalInv). 5 */ 6(function () { 7 if (!window.ET) window.ET = {}; 8 9 /** Senate / governor simulation calibration curve. */ 10 var ODDS_TO_MARGIN_ANCHORS = [ 11 { p: 0.50, margin: 0 }, 12 { p: 0.55, margin: 1 }, 13 { p: 0.65, margin: 2.5 }, 14 { p: 0.75, margin: 5 }, 15 { p: 0.85, margin: 7.5 }, 16 { p: 0.90, margin: 10 }, 17 { p: 0.95, margin: 12.5 }, 18 { p: 0.99, margin: 25 }, 19 { p: 0.999, margin: 40 }, 20 { p: 0.9999, margin: 50 } 21 ]; 22 23 /** 24 * House margin â favored-party win % (exact engineering curve). 25 * 0.5â57.5%, 1â65%, 3â72.5%, 5â80%, 10â87.5%, 15â95%, 20â98%, 30â99.9%. 26 */ 27 var HOUSE_ODDS_TO_MARGIN_ANCHORS = [ 28 { p: 0.50, margin: 0 }, 29 { p: 0.575, margin: 0.5 }, 30 { p: 0.65, margin: 1 }, 31 { p: 0.725, margin: 3 }, 32 { p: 0.80, margin: 5 }, 33 { p: 0.875, margin: 10 }, 34 { p: 0.95, margin: 15 }, 35 { p: 0.98, margin: 20 }, 36 { p: 0.999, margin: 30 }, 37 { p: 0.9999, margin: 40 } 38 ]; 39 40 ET.FORECAST_SIM_BLEND_WEIGHTS = { 41 senate: { calibrated: 0.65, fundamentals: 0.35 }, 42 house: { calibrated: 1, fundamentals: 0 }, 43 gov: { calibrated: 0.60, fundamentals: 0.40 } 44 }; 45 46 ET.FORECAST_SIM_SIGMA_LIMITS = { 47 senate: { min: 2, max: 20, tossup: 7 }, 48 house: { min: 2, max: 22, tossup: 7 }, 49 gov: { min: 2, max: 20, tossup: 7.5 } 50 }; 51 52 /** Legacy anchor extrapolation cap â not applied to per-draw histogram margins. */ 53 ET.FORECAST_SIM_MARGIN_SANITY = null; 54 55 ET.FORECAST_MARGIN_CALIBRATION_ANCHORS = ODDS_TO_MARGIN_ANCHORS; 56 ET.FORECAST_HOUSE_MARGIN_CALIBRATION_ANCHORS = HOUSE_ODDS_TO_MARGIN_ANCHORS; 57 58 function anchorsForChamber(chamber) { 59 return normalizeChamber(chamber) === 'house' 60 ? HOUSE_ODDS_TO_MARGIN_ANCHORS 61 : ODDS_TO_MARGIN_ANCHORS; 62 } 63 64 if (typeof ET.forecastClampProb !== 'function') { 65 ET.forecastClampProb = function (p) { 66 if (p == null || !isFinite(p)) return 0.5; 67 return Math.min(Math.max(p, 0.001), 0.999); 68 }; 69 } 70 71 ET.forecastClampProbForMargin = function (p) { 72 if (p == null || !isFinite(p)) return 0.5; 73 return Math.min(Math.max(p, 0.0001), 0.9999); 74 }; 75 76 function smoothstep(t) { 77 return t * t * (3 - 2 * t); 78 } 79 80 function normalizeChamber(chamber) { 81 if (chamber === 'senate' || chamber === 'gov' || chamber === 'governor') { 82 return chamber === 'governor' ? 'gov' : chamber; 83 } 84 return 'house'; 85 } 86 87 function sigmaLimitsForChamber(chamber) { 88 var ch = normalizeChamber(chamber); 89 return ET.FORECAST_SIM_SIGMA_LIMITS[ch] || ET.FORECAST_SIM_SIGMA_LIMITS.house; 90 } 91 92 function blendWeightsForChamber(chamber) { 93 var ch = normalizeChamber(chamber); 94 return ET.FORECAST_SIM_BLEND_WEIGHTS[ch] || ET.FORECAST_SIM_BLEND_WEIGHTS.house; 95 } 96 97 function extrapolateMarginBeyondAnchors(favoredProb, sign, anchors) { 98 anchors = anchors || ODDS_TO_MARGIN_ANCHORS; 99 var last = anchors[anchors.length - 1]; 100 var prev = anchors[anchors.length - 2]; 101 var dp = last.p - prev.p; 102 var dm = last.margin - prev.margin; 103 var slope = dp > 0 ? dm / dp : 50;
104 var m = last.margin + (favoredProb - last.p) * slope; 105 return sign * m; 106 } 107 108 ET.forecastProbabilityToTargetMargin = function (demProb, chamber) { 109 if (demProb == null || !isFinite(demProb)) return 0; 110 var anchors = anchorsForChamber(chamber); 111 var clampedProb = ET.forecastClampProbForMargin(demProb); 112 var sign = clampedProb >= 0.5 ? 1 : -1; 113 var favoredProb = clampedProb >= 0.5 ? clampedProb : 1 - clampedProb; 114 if (favoredProb <= anchors[0].p + 1e-12) return 0; 115 var i; 116 var a; 117 var b; 118 for (i = 0; i < anchors.length - 1; i++) { 119 a = anchors[i]; 120 b = anchors[i + 1]; 121 if (favoredProb >= a.p && favoredProb <= b.p) { 122 var t = smoothstep((favoredProb - a.p) / (b.p - a.p)); 123 return sign * (a.margin + t * (b.margin - a.margin)); 124 } 125 } 126 return extrapolateMarginBeyondAnchors(favoredProb, sign, anchors); 127 }; 128 129 /** Inverse of forecastProbabilityToTargetMargin â used for House composite win-odds. */ 130 ET.forecastMarginToTargetWinProb = function (margin, chamber) { 131 if (margin == null || !isFinite(margin)) return 0.5; 132 var anchors = anchorsForChamber(chamber); 133 var absM = Math.abs(margin); 134 if (absM <= anchors[0].margin + 1e-12) return 0.5; 135 var sign = margin > 0 ? 1 : -1; 136 var i; 137 var a; 138 var b; 139 for (i = 0; i < anchors.length - 1; i++) { 140 a = anchors[i]; 141 b = anchors[i + 1]; 142 if (absM >= a.margin && absM <= b.margin + 1e-12) { 143 var dm = b.margin - a.margin; 144 var t = dm > 1e-12 ? (absM - a.margin) / dm : 0; 145 var favoredProb = a.p + t * (b.p - a.p); 146 return sign > 0 ? favoredProb : 1 - favoredProb; 147 } 148 } 149 var last = anchors[anchors.length - 1]; 150 var prev = anchors[anchors.length - 2]; 151 var dp = last.p - prev.p; 152 var dmLast = last.margin - prev.margin; 153 var slope = dmLast > 0 ? dp / dmLast : 0.0001; 154 var favoredBeyond = last.p + (absM - last.margin) * slope; 155 favoredBeyond = Math.min(0.9999, Math.max(last.p, favoredBeyond)); 156 return sign > 0 ? favoredBeyond : 1 - favoredBeyond; 157 }; 158 159 ET.forecastSimVolatilityMultiplier = function (det, chamber, pollCount) { 160 if (normalizeChamber(chamber) === 'house') return 1; 161 var mult = 1; 162 var n = pollCount != null ? pollCount : 0; 163 if (n >= 8) mult *= 0.88; 164 else if (n >= 4) mult *= 0.95; 165 else if (n <= 1) mult *= 1.18; 166 else mult *= 1.06; 167 var ch = normalizeChamber(chamber); 168 if (ch === 'gov') mult *= 1.08; 169 if (ch === 'house') mult *= 1.0; 170 if (det && det.hasIncumbency === false) mult *= 1.1; 171 return mult; 172 }; 173 174 ET.forecastSimSigmaFromFinalMargin = function (targetWinD, finalTargetMargin, chamber, volatilityMultiplier) { 175 var limits = sigmaLimitsForChamber(chamber); 176 var p = ET.forecastClampProb(targetWinD); 177 var favoredProb = p >= 0.5 ? p : 1 - p; 178 if (favoredProb <= 0.5 + 1e-9) return limits.tossup; 179 var absM = Math.abs(finalTargetMargin != null && isFinite(finalTargetMargin) ? finalTargetMargin : 0); 180 if (absM < 1e-6) return limits.tossup; 181 var inv = typeof ET.forecastModelNormalInv === 'function' ? ET.forecastModelNormalInv : null; 182 if (!inv) return limits.tossup; 183 var z = inv(favoredProb); 184 if (!z || !isFinite(z) || Math.abs(z) < 1e-6) return limits.tossup; 185 var sigma = (absM / Math.abs(z)) * (volatilityMultiplier != null ? volatilityMultiplier : 1); 186 return Math.max(limits.min, Math.min(limits.max, sigma)); 187 }; 188 189 ET.forecastSimSigmaFromTargetWinD = function (targetWinD, finalTargetMargin, chamber, volatilityMultiplier) { 190 return ET.forecastSimSigmaFromFinalMargin(targetWinD, finalTargetMargin, chamber, volatilityMultiplier); 191 }; 192 193 ET.forecastSimParamsFromDetail = function (det, chamber, opts) { 194 opts = opts || {}; 195 var ch = normalizeChamber(chamber || (det && det.forecastKind) || 'house'); 196 var compositeM = det && det.margin != null && isFinite(det.margin) ? det.margin : 0; 197 var targetWinD = opts.targetWinD != null && isFinite(opts.targetWinD) 198 ? ET.forecastClampProb(opts.targetWinD) 199 : (ch === 'house' && typeof ET.forecastMarginToTargetWinProb === 'function' 200 ? ET.forecastMarginToTargetWinProb(compositeM, ch) 201 : (typeof ET.forecastModelMarginToWinProb === 'function' 202 ? ET.forecastModelMarginToWinProb(compositeM) 203 : 0.5)); 204 var calibratedFromOdds = ET.forecastProbabilityToTargetMargin(targetWinD, ch); 205 var blend = blendWeightsForChamber(ch); 206 if (opts.pureCalibration) blend = { calibrated: 1, fundamentals: 0 }; 207 if (ch === 'house') blend = { calibrated: 1, fundamentals: 0 }; 208 var finalTarget = blend.calibrated * calibratedFromOdds + blend.fundamentals * compositeM; 209 var pollCount = opts.pollCount != null ? opts.pollCount : 0; 210 var volMult = ET.forecastSimVolatilityMultiplier(det, ch, pollCount); 211 var sigma = ET.forecastSimSigmaFromFinalMargin(targetWinD, finalTarget, ch, volMult); 212 return { 213 chamber: ch, 214 targetWinD: ET.forecastClampProb(targetWinD), 215 originalCompositeMargin: compositeM, 216 originalCompositeMarginAdjusted: compositeM, 217 calibratedMarginFromOdds: calibratedFromOdds, 218 finalTargetMargin: finalTarget, 219 centerMargin: finalTarget, 220 sigma: sigma, 221 volatilityMultiplier: volMult, 222 favoredProb: targetWinD >= 0.5 ? targetWinD : 1 - targetWinD 223 }; 224 }; 225 226 ET.forecastSimParamsFromTargetWinD = function (targetWinD, chamber, opts) { 227 opts = opts || {}; 228 var det = { margin: 0, forecastKind: normalizeChamber(chamber) }; 229 opts.targetWinD = targetWinD; 230 opts.pureCalibration = opts.pureCalibration !== false; 231 return ET.forecastSimParamsFromDetail(det, chamber, opts); 232 }; 233 234 ET.forecastSimParamsFromCompositeMargin = function (compositeMargin, chamber) { 235 return ET.forecastSimParamsFromDetail({ margin: compositeMargin }, chamber || 'house', {}); 236 }; 237})();
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.