/** * Builds the committed 2º-turno artifact (`bahia-second-round-2026.json`): * Lula × Flávio in Bahia, 2022 → 1º turno 2026, cut by the same * `municipalityCatalog` geography the campaign uses (Salvador split by ZE). * * Why an artifact: the campaign DB has Solla 2014/2018/2022 and the 2026 * estimates, and the official presidential 1º turno 2026 lives in the committed * `public/dados/potencial-secao-2026/BA.json` (per section). Solla's official * 2026 federal result and the 2022 adversary slice come from the internal * analysis repo (analise-eleicoes-2026 / nota-solla-2turno). This builder joins * the two, validates each 2026 number against an independent source, cross-checks * 2022 against `bahia-federal-baseline.json` and pins the BA totals. * * Inputs: * - public/dados/potencial-secao-2026/BA.json TSE 2026 presidente T1 (por seção) * - src/lib/electionAggregates/bahia-federal-baseline.json Solla/Lula 2022 (cross-check) * - --analysis-dir= outputs of analise-eleicoes-2026 (default ../analise-eleicoes-2026/bahia): * solla_municipios.csv, ba_ausentes_bn.csv, salvador_ze_v3.csv, solla_ze_salvador.csv * * Output: src/lib/electionAggregates/bahia-second-round-2026.json * Uso: pnpm build:second-round -- [--analysis-dir=] [--check] * * Never runs at build/deploy time — the artifact is committed (same rule as * `pnpm build:election-aggregates`). */ import { readFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parse as parseCsvSync } from 'csv-parse/sync' import { dieWithLabel, parseEqualsFlags, writeRepoFile } from './lib/cli.mjs' const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') const LABEL = 'build-second-round-ba' const die = dieWithLabel(LABEL) const ARTIFACT_PATH = 'src/lib/electionAggregates/bahia-second-round-2026.json' const SHARD_PATH = 'public/dados/potencial-secao-2026/BA.json' const BASELINE_PATH = 'src/lib/electionAggregates/bahia-federal-baseline.json' const DEFAULT_ANALYSIS_DIR = resolve(ROOT, '..', 'analise-eleicoes-2026', 'bahia') /** * Audited Bahia totals (TSE, 1º turno 2026, 100% apurado) — sum over the 435 * catalog units (Salvador once, through its 19 ZE). Any divergence aborts the * build: a re-published bulk or a changed analysis must be a deliberate PR. */ const BA_PINS = { aptos26: 11_312_752, comparecimento26: 9_053_601, lula26: 5_664_771, adversary26: 2_442_585, validos26: 8_560_542, lula22: 5_873_081, adversary22: 2_047_599, solla22: 128_968, solla26: 152_049, } const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts') const { classifySecondRoundRole } = await import('../src/lib/secondRoundRole.ts') const { flags } = parseEqualsFlags(process.argv.slice(2)) const analysisDir = resolve( ROOT, typeof flags['analysis-dir'] === 'string' ? flags['analysis-dir'] : DEFAULT_ANALYSIS_DIR, ) const checkOnly = flags.check === true const int = (value) => { const parsed = Number(value) if (!Number.isFinite(parsed)) die(`Número inválido: ${JSON.stringify(value)}`) return Math.round(parsed) } const readJson = async (relativePath) => { try { return JSON.parse(await readFile(resolve(ROOT, relativePath), 'utf8')) } catch (error) { die(`Falha ao ler ${relativePath}: ${error instanceof Error ? error.message : error}`) } } const readCsv = async (fileName) => { const path = join(analysisDir, fileName) let text try { text = await readFile(path, 'utf8') } catch (error) { die( `Falha ao ler ${path}: ${error instanceof Error ? error.message : error}\n` + ' Passe --analysis-dir=.', ) } return parseCsvSync(text, { columns: true, delimiter: ';', skip_empty_lines: true, trim: true, bom: true, relax_quotes: true, relax_column_count: true, }) } const indexBy = (rows, key) => { const map = new Map() for (const row of rows) map.set(String(row[key]), row) return map } /** * 2026 president aggregates from the committed per-section shard: one pass over * the 417 municipalities, accumulating `aptos, comparecimento, lula, flávio, * válidos` per município and — for Salvador — per zone (the campaign unit). */ const aggregateShard = (shard) => { const byMunicipality = new Map() const salVadorByZone = new Map() for (const municipality of shard.municipalities) { const code = String(municipality.code) const total = { aptos: 0, comparecimento: 0, lula: 0, adversary: 0, validos: 0 } for (const [zone, , aptos, comparecimento, lula, adversary, validos] of municipality.sections) { total.aptos += aptos total.comparecimento += comparecimento total.lula += lula total.adversary += adversary total.validos += validos if (code === '38490') { const zoneTotal = salVadorByZone.get(zone) ?? { aptos: 0, comparecimento: 0, lula: 0, adversary: 0, validos: 0, } zoneTotal.aptos += aptos zoneTotal.comparecimento += comparecimento zoneTotal.lula += lula zoneTotal.adversary += adversary zoneTotal.validos += validos salVadorByZone.set(zone, zoneTotal) } } byMunicipality.set(code, total) } return { byMunicipality, salVadorByZone } } const failCheck = (failures) => { if (!failures.length) return die( `Divergência de validação (${failures.length}):\n ` + failures .slice(0, 20) .map((line) => `- ${line}`) .join('\n ') + (failures.length > 20 ? `\n … e mais ${failures.length - 20}.` : ''), ) } const main = async () => { const [shard, baseline] = await Promise.all([readJson(SHARD_PATH), readJson(BASELINE_PATH)]) const [sollaMunicipios, ausentesBn, salvadorZe, sollaSalvadorZe] = await Promise.all([ readCsv('solla_municipios.csv'), readCsv('ba_ausentes_bn.csv'), readCsv('salvador_ze_v3.csv'), readCsv('solla_ze_salvador.csv'), ]) const sollaByCity = indexBy(sollaMunicipios, 'cd_tse') const ausByCity = indexBy(ausentesBn, 'cd') const salZeByZone = indexBy(salvadorZe, 'zona') const sollaSalZeByZone = indexBy(sollaSalvadorZe, 'zona') const { byMunicipality, salVadorByZone } = aggregateShard(shard) const failures = [] const units = {} const totals = { aptos22: 0, aptos26: 0, comparecimento22: 0, comparecimento26: 0, abstencao22: 0, abstencao26: 0, brancosNulos22: 0, brancosNulos26: 0, validos22: 0, validos26: 0, lula22: 0, lula26: 0, adversary22: 0, adversary26: 0, solla22: 0, solla26: 0, } for (const entry of municipalityCatalog) { const isZone = entry.kind === 'zona' const zoneNumber = entry.zoneNumber ?? null const source = isZone ? sollaSalZeByZone.get(String(zoneNumber).padStart(4, '0')) : null const analysis = isZone ? salZeByZone.get(String(zoneNumber).padStart(4, '0')) : null const cityRow = isZone ? null : sollaByCity.get(entry.tseCityCode) const ausRow = isZone ? null : ausByCity.get(entry.tseCityCode) const shardRow = isZone ? salVadorByZone.get(zoneNumber) : byMunicipality.get(entry.tseCityCode) if (!shardRow) { failures.push(`${entry.slug}: sem agregado de 2026 no shard oficial`) continue } if (!isZone && (!cityRow || !ausRow)) { failures.push(`${entry.slug}: sem linha na análise 2º turno (${entry.tseCityCode})`) continue } if (isZone && (!source || !analysis)) { failures.push(`${entry.slug}: sem linha da análise para a ZE ${zoneNumber}`) continue } // 2022: análise (primary) — one table per kind, validated below against the // committed TSE baseline (Lula #13 and Solla 1313). const lula22 = int(isZone ? analysis.lula22 : cityRow.lula22) const adversary22 = int(isZone ? analysis.adv22 : cityRow.bolso22) const solla22 = int(isZone ? source.solla22 : cityRow.solla22) const aptos22 = int(isZone ? analysis.te22 : ausRow.aptos22) const abstencao22 = int(isZone ? analysis.abst22 : ausRow.abst22) const comparecimento22 = aptos22 - abstencao22 const validos22 = int(isZone ? analysis.val22 : cityRow.validos22) const brancosNulos22 = comparecimento22 - validos22 // 2026: shard oficial (per section) — the analysis numbers are the check. const lula26 = shardRow.lula const adversary26 = shardRow.adversary const aptos26 = shardRow.aptos const comparecimento26 = shardRow.comparecimento const validos26 = shardRow.validos const abstencao26 = aptos26 - comparecimento26 const brancosNulos26 = comparecimento26 - validos26 const solla26 = int(isZone ? source.solla26 : cityRow.solla26) if (!isZone) { if (lula26 !== int(cityRow.lula26)) failures.push(`${entry.slug}: Lula26 shard ${lula26} ≠ análise ${cityRow.lula26}`) if (adversary26 !== int(cityRow.flavio26)) failures.push(`${entry.slug}: Flávio26 shard ${adversary26} ≠ análise ${cityRow.flavio26}`) if (aptos26 !== int(ausRow.aptos26)) failures.push(`${entry.slug}: aptos26 shard ${aptos26} ≠ análise ${ausRow.aptos26}`) if (comparecimento26 !== int(ausRow.aptos26) - int(ausRow.abst26)) failures.push( `${entry.slug}: comp26 shard ${comparecimento26} ≠ análise ${int(ausRow.aptos26) - int(ausRow.abst26)}`, ) } else { if (lula26 !== int(analysis.lula26)) failures.push(`${entry.slug}: Lula26 shard ${lula26} ≠ análise ${analysis.lula26}`) if (adversary26 !== int(analysis.adv26)) failures.push(`${entry.slug}: Flávio26 shard ${adversary26} ≠ análise ${analysis.adv26}`) if (aptos26 !== int(analysis.te26)) failures.push(`${entry.slug}: aptos26 shard ${aptos26} ≠ análise ${analysis.te26}`) if (comparecimento26 !== int(analysis.comp26)) failures.push( `${entry.slug}: comp26 shard ${comparecimento26} ≠ análise ${analysis.comp26}`, ) } // 2022 cross-check against the committed Teqo baseline (independent source). const baselineUnit = baseline.municipalities?.[entry.slug] if (!baselineUnit) { failures.push(`${entry.slug}: fora do baseline TSE 2014/2018/2022`) } else { const baselineSolla22 = baselineUnit.votesByYear?.['2022'] ?? null if (baselineSolla22 !== solla22) failures.push(`${entry.slug}: Solla22 baseline ${baselineSolla22} ≠ análise ${solla22}`) const baselineLula22 = baselineUnit.majoritarian2022?.president?.votes ?? null if (baselineLula22 !== lula22) failures.push(`${entry.slug}: Lula22 baseline ${baselineLula22} ≠ análise ${lula22}`) } const dLula = lula26 - lula22 const dAdversary = adversary26 - adversary22 const dSolla = solla26 - solla22 const role = classifySecondRoundRole({ dLula, dSolla, solla26 }) units[entry.slug] = { name: entry.name, kind: entry.kind, tseCityCode: entry.tseCityCode, ibgeCode: entry.ibgeCode, zone: zoneNumber, role, lula22, lula26, dLula, adversary22, adversary26, dAdversary, solla22, solla26, dSolla, aptos22, aptos26, comparecimento22, comparecimento26, abstencao22, abstencao26, brancosNulos22, brancosNulos26, validos22, validos26, } for (const key of Object.keys(totals)) totals[key] += units[entry.slug][key] } for (const [key, expected] of Object.entries(BA_PINS)) { if (totals[key] !== expected) failures.push(`total BA ${key}: esperado ${expected}, encontrado ${totals[key]}`) } failCheck(failures) const artifact = { version: 1, generatedAt: new Date().toISOString(), scope: 'BA — 2º turno Lula × Flávio', provenance: 'TSE 2026 presidente 1º turno por seção (public/dados/potencial-secao-2026/BA.json, oficial) + análise 2º turno da campanha (analise-eleicoes-2026/nota-solla-2turno) para Solla 2026 e a fatia de 2022; 2022 validado contra bahia-federal-baseline.json.', sources: { shard: SHARD_PATH, baseline: BASELINE_PATH, analysisDir: 'analise-eleicoes-2026/bahia (solla_municipios, ba_ausentes_bn, salvador_ze_v3, solla_ze_salvador)', }, pins: BA_PINS, totals, units, } if (checkOnly) { console.log(`[${LABEL}] OK — ${Object.keys(units).length} unidades, totais batem com os pins.`) return } await writeRepoFile({ label: LABEL, root: ROOT, relativePath: ARTIFACT_PATH, body: `${JSON.stringify(artifact, null, 2)}\n`, }) console.log( `[${LABEL}] ${Object.keys(units).length} unidades · Lula ${totals.lula26} · Flávio ${totals.adversary26} · Solla ${totals.solla26}`, ) } await main()