teqo/src/utilities/municipalityTerritorialClass.ts
Francisco Ignácio Fontoura Solla 66feecd66b simplify(B13): corrige classe agregada, bolhas em pane proprio e registra debitos
O /simplify da escala relativa do mapa achou um bug semantico real em
computeAggregateTerritorialClass — somava o headroom dos slugs e comparava
contra a mediana de UMA unidade, classificando um grupo grande como expansao
quando ele e marginal. A mediana agora escala por slugs.length, com teste.

As bolhas sairam do centro do bounding box para o centroide real e ganharam
pane proprio (z-index 450), porque o bringToFront do hover as escondia atras
do poligono. Legenda e pintura passaram a compartilhar o mesmo predicado,
a copy dos quantis parou de prometer faixas de igual largura, e o subtitulo
do painel parou de dizer "em 2026" com 2022 selecionado (defeito do B11).

Fonte unica onde havia duas: NO_DATA_FILL, o raio das bolhas e
formatPlacementOrdinal (ex-formatMunicipalityVoteRank, agora client-safe em
electionFormat.ts).

Os quatro debitos maiores que o cleanup viraram o fill-in B13+
(docs/plans/escala-dry-pos-b13.md); a flakiness dos e2e, reproduzida em main
limpo, foi para o ledger com a causa afinada.
2026-07-26 05:12:37 -03:00

279 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
federalBaselineMunicipalitySlugs,
getMunicipalityFederalBaseline,
getStatewideFederalTotals,
type MunicipalityFederalBaseline,
} from '@/lib/bahiaElectionAggregates'
import { ELECTION_YEAR_2022 } from '@/lib/electionResults'
import { computeVoteRankByYear } from '@/lib/municipalityVoteRank'
import { TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors'
import {
captureRate,
fieldCeiling,
ownVotes2022,
projectedFieldCeiling,
} from '@/utilities/municipalityPotential'
/**
* E10 "classificação territorial relativa" — the operational class of one
* município, read from the committed `bahia-federal-baseline.json` artifact
* alone (pure functions, no DB, no `campaignGoals`, no user).
*
* Why relative anchors: the class the campaign inherited from the majoritarian
* era used absolute shares of local valid votes (35/20/10). In a federal-deputy
* race the candidate's local share is typically 1–5%, so every município in the
* state collapsed into the worst class. The research report (§6, "limiares
* 35/20/10 degeneram em DF") prescribes anchoring on multiples of the
* candidate's OWN statewide share (a location quotient) and reading more than
* one axis: dominance + how much of his own vote lives here + how much field
* vote is still uncaptured.
*/
/** Reference year: the field ceiling is 2022-only in the artifact by design. */
const TERRITORIAL_CLASS_YEAR = ELECTION_YEAR_2022
export const TERRITORIAL_CLASSES = [
'reduto',
'expansao',
'manutencao',
'marginal',
'sem_base',
] as const
export type MunicipalityTerritorialClass = (typeof TERRITORIAL_CLASSES)[number]
/** The axes behind a class — the UI always shows the "por quê", never the label alone. */
type TerritorialFactorId = 'dominance' | 'ownShare' | 'field' | 'capture'
export type TerritorialFactor = {
id: TerritorialFactorId
/**
* `dominance` = LQ (ratio); `ownShare` = share of his statewide vote (0..1);
* `field` = uncaptured field votes (absolute); `capture` = capture rate (0..1).
*/
value: number
}
export type MunicipalityTerritorialClassification = {
class: MunicipalityTerritorialClass
/** Ordered by relevance to the class — surfaces show the first two. */
factors: TerritorialFactor[]
/** Location quotient vs. his own statewide share; null without a local electorate. */
lq: number | null
/** Share of his statewide vote that comes from here (the mesa's own anchor, A11). */
ownShare: number
/** True when the município belongs to the block holding `coreCumulativeShare` of his vote. */
inCoreBlock: boolean
}
export type TerritorialClassInput = {
ownVotes: number
validVotes: number
stateOwnVotes: number
stateValidVotes: number
/** Projected field ceiling minus own votes (E8), floored at 0. */
fieldHeadroom: number
/**
* The "big field" cut `fieldHeadroom` is measured against: the catalog
* median for one município, and that median times the group size for an
* aggregate — headroom is a level, not a ratio, so a summed one has to be
* compared against a reference summed the same way.
*/
fieldHeadroomCut: number
captureRate: number | null
inCoreBlock: boolean
}
const UNCLASSIFIED: MunicipalityTerritorialClassification = {
class: 'sem_base',
factors: [],
lq: null,
ownShare: 0,
inCoreBlock: false,
}
/**
* Pure classifier over explicit inputs (the artifact-backed wrapper below
* fills them in). Order of the rules is the reading order of the mesa:
* dominance first, then — only for the weak half — whether there is field
* left to win.
*/
export const classifyMunicipalityTerritory = (
input: TerritorialClassInput,
): MunicipalityTerritorialClassification => {
const { ownVotes, validVotes, stateOwnVotes, stateValidVotes } = input
if (validVotes <= 0 || stateValidVotes <= 0 || stateOwnVotes <= 0) return UNCLASSIFIED
const stateShare = stateOwnVotes / stateValidVotes
const lq = ownVotes / validVotes / stateShare
const ownShare = ownVotes / stateOwnVotes
const shared = { lq, ownShare, inCoreBlock: input.inCoreBlock }
const dominance: TerritorialFactor = { id: 'dominance', value: lq }
const ownShareFactor: TerritorialFactor = { id: 'ownShare', value: ownShare }
const fieldFactor: TerritorialFactor = { id: 'field', value: input.fieldHeadroom }
const captureFactor: TerritorialFactor[] =
input.captureRate === null ? [] : [{ id: 'capture', value: input.captureRate }]
if (lq >= TERRITORIAL_CLASS_ANCHORS.strongLq) {
// Reduto de conta vs. reduto simbólico is the `ownShare` factor's job
// (P10: the trophy município must not eat the agenda of the ones that
// close the quotient), not a fifth class.
return { ...shared, class: 'reduto', factors: [dominance, ownShareFactor, ...captureFactor] }
}
if (lq < TERRITORIAL_CLASS_ANCHORS.weakLq) {
// A município inside the core block carries real votes even while
// under-performing, so it can never read as "marginal" — the honest label
// there is expansão (big base, low penetration).
const hasFieldLeft = input.fieldHeadroom >= input.fieldHeadroomCut || input.inCoreBlock
return hasFieldLeft
? { ...shared, class: 'expansao', factors: [fieldFactor, dominance, ...captureFactor] }
: { ...shared, class: 'marginal', factors: [dominance, fieldFactor] }
}
return { ...shared, class: 'manutencao', factors: [dominance, ownShareFactor, ...captureFactor] }
}
/** Uncaptured field votes: projected 2022 field ceiling minus his own vote. */
const fieldHeadroomOf = (baseline: MunicipalityFederalBaseline): number =>
Math.max(0, projectedFieldCeiling(baseline) - ownVotes2022(baseline))
const median = (values: number[]): number => {
if (values.length === 0) return 0
const sorted = [...values].sort((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
type CatalogContext = {
medianFieldHeadroom: number
coreBlockSlugs: ReadonlySet<string>
}
let catalogContext: CatalogContext | null = null
/**
* Catalog-wide reference values, computed once per process (the artifact is
* immutable): the median field headroom — a relative cut, so no magic vote
* count ages — and the set of municípios that, sorted by his own vote,
* accumulate `coreCumulativeShare` of his statewide total.
*/
const getCatalogContext = (): CatalogContext => {
if (catalogContext) return catalogContext
// Slugs whose majoritarian cell is missing would drag the median to zero and
// make every weak município read as "expansão".
const medianFieldHeadroom = median(
federalBaselineMunicipalitySlugs()
.map(getMunicipalityFederalBaseline)
.filter((baseline) => projectedFieldCeiling(baseline) > 0)
.map(fieldHeadroomOf),
)
// A11 already ranks every slug by own vote, descending, and memoizes it —
// walking that map is the same order this used to re-sort for itself.
const coreVoteTarget =
getStatewideFederalTotals(TERRITORIAL_CLASS_YEAR).ownVotes *
TERRITORIAL_CLASS_ANCHORS.coreCumulativeShare
const coreBlockSlugs = new Set<string>()
let accumulated = 0
for (const [slug, entry] of computeVoteRankByYear(TERRITORIAL_CLASS_YEAR)) {
if (accumulated >= coreVoteTarget) break
coreBlockSlugs.add(slug)
accumulated += entry.votes
}
catalogContext = { medianFieldHeadroom, coreBlockSlugs }
return catalogContext
}
const classificationBySlug = new Map<string, MunicipalityTerritorialClassification>()
/** Classification for one catalog slug — memoized, pure over the artifact. */
export const computeMunicipalityTerritorialClass = (
slug: string,
): MunicipalityTerritorialClassification => {
const cached = classificationBySlug.get(slug)
if (cached) return cached
const baseline = getMunicipalityFederalBaseline(slug)
const totals = getStatewideFederalTotals(TERRITORIAL_CLASS_YEAR)
const { medianFieldHeadroom, coreBlockSlugs } = getCatalogContext()
const classification = classifyMunicipalityTerritory({
ownVotes: ownVotes2022(baseline),
validVotes: baseline.validVotesByYear[String(TERRITORIAL_CLASS_YEAR)] ?? 0,
stateOwnVotes: totals.ownVotes,
stateValidVotes: totals.validVotes,
fieldHeadroom: fieldHeadroomOf(baseline),
fieldHeadroomCut: medianFieldHeadroom,
captureRate: captureRate(baseline),
inCoreBlock: coreBlockSlugs.has(slug),
})
classificationBySlug.set(slug, classification)
return classification
}
/**
* The class of a GROUP of catalog slugs read as one territory — the map paints
* one polygon per IBGE municipality, and Salvador is 19 catalog slugs.
*
* It sums the inputs and runs the SAME `classifyMunicipalityTerritory`; it
* never averages or votes on the per-slug classes, because LQ is a ratio and
* the average of ratios is not the ratio of the sums. The ratios (LQ, capture)
* are compared against exactly the statewide standard everyone else gets; the
* one LEVEL among the inputs, field headroom, needs its cut scaled by the
* group size or a 19-slug Salvador would clear a per-município median by an
* order of magnitude and never read as "marginal".
*
* E12's TI rollup inherits this helper rather than growing a second one.
*/
export const computeAggregateTerritorialClass = (
slugs: ReadonlyArray<string>,
): MunicipalityTerritorialClassification => {
if (slugs.length === 1) return computeMunicipalityTerritorialClass(slugs[0])
if (slugs.length === 0) return UNCLASSIFIED
const totals = getStatewideFederalTotals(TERRITORIAL_CLASS_YEAR)
const { medianFieldHeadroom, coreBlockSlugs } = getCatalogContext()
let ownVotes = 0
let validVotes = 0
let fieldHeadroom = 0
let fieldCeiling2022 = 0
let inCoreBlock = false
for (const slug of slugs) {
const baseline = getMunicipalityFederalBaseline(slug)
ownVotes += ownVotes2022(baseline)
validVotes += baseline.validVotesByYear[String(TERRITORIAL_CLASS_YEAR)] ?? 0
fieldHeadroom += fieldHeadroomOf(baseline)
fieldCeiling2022 += fieldCeiling(baseline)
inCoreBlock ||= coreBlockSlugs.has(slug)
}
return classifyMunicipalityTerritory({
ownVotes,
validVotes,
stateOwnVotes: totals.ownVotes,
stateValidVotes: totals.validVotes,
fieldHeadroom,
fieldHeadroomCut: medianFieldHeadroom * slugs.length,
captureRate: fieldCeiling2022 > 0 ? ownVotes / fieldCeiling2022 : null,
inCoreBlock,
})
}
/**
* Sort weight for the list's `classe` ordering: descending puts the
* municípios that carry the campaign (reduto) first and the ones without a
* baseline last, in either direction.
*/
export const territorialClassSortWeight: Record<MunicipalityTerritorialClass, number | null> = {
reduto: 4,
expansao: 3,
manutencao: 2,
marginal: 1,
sem_base: null,
}