· 7 min de leitura

Rinha de Backend 2026: vetorizando o payload em 14 dimensões

Terceiro artigo da série. A função pura que transforma a transação em um vetor [f32; 14], com parser ISO manual, tabela de MCC hardcoded e os dois exemplos do regulamento como gabarito de teste.

Capa do post 3 da série Rinha de Backend 2026: 'Cada transação vira 14 perguntas' à esquerda e um grid de 14 quadradinhos rotulados à direita (valor, parcelas, hora, dia, tempo*, distância*, ...), com tempo e distância destacados em amarelo

Começando

No post anterior prometi que o próximo seria sobre vetorização. Cumpri o prometido — Slice 2a fechado, PR aberto, 12 testes verdes. Esse artigo é a história de como transformar o JSON da transação em uma lista de 14 números entre 0 e 1, sem inventar nada além do regulamento.

Em termos de algoritmo, é a parte mais simples do projeto. Em termos de risco, a mais ingrata: se eu errar a hora UTC, o dia da semana ou o tratamento do last_transaction: null, os 3 milhões de vetores de referência ficam num sistema de coordenadas e os meus em outro. A busca devolve lixo e eu nunca descubro porque o p99 continua bonito.

Por isso o slice é puxado por testes contra os exemplos do REGRAS_DE_DETECCAO.md. Se bate byte a byte com o doc, bate com o dataset oficial.

As 14 dimensões

Grid de 14 quadradinhos numerados 0 a 13 representando o vetor f32; 14, com os índices 5 e 6 destacados em amarelo e marcados com -1. Embaixo, o vetor real do exemplo legítimo do regulamento
Os 14 índices na ordem do regulamento. 5 e 6 são a única exceção legal a [0,1]-1 quando last_transaction: null.

A ordem é fixa. A normalização também. Tudo está no REGRAS_DE_DETECCAO.md:

idimensãofórmula
0amountclamp(amount / 10000)
1installmentsclamp(installments / 12)
2amount_vs_avgclamp((amount / customer.avg_amount) / 10)
3hour_of_dayhora_utc / 23
4day_of_weekdow / 6 (seg=0 … dom=6)
5minutes_since_lastclamp(min / 1440) ou -1 se last_transaction: null
6km_from_lastclamp(km_from_current / 1000) ou -1 se last_transaction: null
7km_from_homeclamp(km_from_home / 1000)
8tx_count_24hclamp(tx_count_24h / 20)
9is_onlinebool → 1
10card_presentbool → 1
11unknown_merchantmerchant.id ∉ known_merchants → 1
12mcc_risktabela[mcc] ou 0.5
13merchant_avg_amountclamp(merchant.avg_amount / 10000)

clamp(x, 0, 1) aparece em quase todas. As dimensões 5 e 6 são a única exceção legal a [0,1] — o sentinela -1 representa “transação anterior inexistente”.

As armadilhas que o regulamento esconde

Lendo o doc rápido você passa por três pegadinhas sem perceber:

Hora dividida por 23, não 24. O exemplo legítimo do doc tem requested_at = 2026-03-11T18:45:53Z e a dimensão 3 sai como 0.7826. Conferindo: 18 / 23 = 0.7826. A interpretação intuitiva (24 buckets de hora) daria 18/24 = 0.75. Errado. O regulamento divide pelo valor máximo da hora (23, porque hora vai de 0 a 23), não pela quantidade de horas no dia.

Convenção segunda=0. A dimensão 4 do exemplo legítimo é 0.3333, e 11/03/2026 é uma quarta. Convenção: seg=0, ter=1, qua=2 → 2/6 = 0.3333. ISO 8601, não a americana com domingo=0. Errar a convenção joga o vetor longe do gabarito sem barulho nenhum.

unknown_merchant é set membership. O campo customer.known_merchants é um array que pode ter duplicatas (["MERC-009", "MERC-009", "MERC-001"] aparece nos exemplos). A dimensão 11 só pergunta se o merchant.id atual está na lista, sem se importar com quantas vezes. Tratei como conjunto.

A função pura

A vectorize é determinística, sem alocação por requisição, e sem Result. Entradas inválidas são barradas pelo serde no handler antes de chegar aqui:

pub fn vectorize(p: &Payload) -> [f32; 14] {
    let ts = parse_iso_utc(&p.transaction.requested_at);

    let (minutes_since, km_from_last) = match &p.last_transaction {
        None => (SENTINEL_NULL, SENTINEL_NULL),
        Some(last) => {
            let prev = parse_iso_utc(&last.timestamp);
            let diff_min = (ts.epoch_seconds - prev.epoch_seconds) / 60;
            (
                clamp01(diff_min as f32 / MAX_MINUTES),
                clamp01(last.km_from_current / MAX_KM),
            )
        }
    };

    [
        clamp01(p.transaction.amount / MAX_AMOUNT),
        clamp01(p.transaction.installments as f32 / MAX_INSTALLMENTS),
        clamp01((p.transaction.amount / p.customer.avg_amount) / AMOUNT_VS_AVG_RATIO),
        ts.hour as f32 / 23.0,
        ts.weekday_mon0 as f32 / 6.0,
        minutes_since,
        km_from_last,
        clamp01(p.terminal.km_from_home / MAX_KM),
        clamp01(p.customer.tx_count_24h as f32 / MAX_TX_COUNT_24H),
        bool_to_f32(p.terminal.is_online),
        bool_to_f32(p.terminal.card_present),
        if is_known(&p.merchant.id, &p.customer.known_merchants) { 0.0 } else { 1.0 },
        mcc_risk(&p.merchant.mcc),
        clamp01(p.merchant.avg_amount / MAX_MERCHANT_AVG_AMOUNT),
    ]
}

Sem JSON I/O em runtime, sem regex, sem chamadas pra chrono. Tudo aritmética em f32 sobre &str e &[u8]. As 14 dimensões saem na ordem do regulamento, posição por posição, na cara.

Decisões pequenas que vão pagar lá na frente

Constantes hardcoded, não lidas de normalization.json. O DATASET.md é explícito: “esses arquivos não mudam durante o teste”. Carregar JSON em runtime só pra ler max_amount = 10000 é incoerente com tudo que decidi no post 2 sobre evitar I/O no caminho quente. Se o organizador alterar, recompila.

Parser ISO manual em vez de chrono. O formato é fixo: YYYY-MM-DDTHH:MM:SSZ, 20 bytes. Escrever parse_u32(&bytes[0..4]) deixa o binário menor, evita uma dependência transitiva grande e não tem risco de o parser aceitar um formato que o organizador não enviou.

fn parse_iso_utc(s: &str) -> Timestamp {
    let b = s.as_bytes();
    debug_assert!(b.len() == 20 && b[19] == b'Z');
    let year   = parse_u32(&b[0..4]);
    let month  = parse_u32(&b[5..7]);
    let day    = parse_u32(&b[8..10]);
    let hour   = parse_u32(&b[11..13]);
    let minute = parse_u32(&b[14..16]);
    let second = parse_u32(&b[17..19]);
    Timestamp {
        hour,
        weekday_mon0: weekday_mon0(year, month, day),
        epoch_seconds: epoch_seconds(year, month, day, hour, minute, second),
    }
}

Sakamoto pro dia da semana. Algoritmo clássico, tabela de 12 inteiros, retorna o dia (sun=0). Converto pra mon=0 com (sun + 6) % 7. Validei contra quatro datas conhecidas, incluindo borda (segunda e domingo).

Hinnant days_from_civil pra diferença de minutos. Quando há last_transaction, preciso da diferença em minutos entre dois timestamps. Em vez de implementar uma calendar library, computo “dias desde 1970-01-01” pelos dois timestamps e subtraio. Algoritmo de Howard Hinnant, sem branch por ano bissexto:

fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
    let y = if m <= 2 { y - 1 } else { y };
    let era = y.div_euclid(400);
    let yoe = y - era * 400;
    let m_adj = if m > 2 { m - 3 } else { m + 9 } as i64;
    let doy = (153 * m_adj + 2) / 5 + d as i64 - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    era * 146_097 + doe - 719_468
}

mcc_risk como const &[(&str, f32)] com busca linear. São 10 entradas. Busca linear em 10 elementos com strings curtas vive na L1, é mais rápido que HashMap por causa do hashing + heap. O default 0.5 quando o MCC não consta entra no fallback do loop.

Pausa pra quem não manja de Rust

Se você caiu aqui sem manjar muito de Rust, vou traduzir.

A Rinha 2026 manda transações de cartão pra minha API e pergunta: isso é fraude ou não? O regulamento diz que a forma “oficial” de decidir é:

  1. Pegar a transação que chegou.
  2. Transformar ela num vetor — uma lista de 14 números entre 0 e 1 que resumem características (valor, hora, distância de casa, dia da semana, etc.).
  3. Comparar esse vetor com 3 milhões de vetores de referência guardados.
  4. Pegar os 5 mais parecidos. Se a maioria deles é fraude, devolvo “fraude”.

O Slice 2a cuidou só do passo 2. Pegar o JSON e transformar nos 14 números.

Cada posição tem um significado:

  • Posição 0: o valor é alto? (R$ 41 → 0.0041, R$ 9.505 → 0.95)
  • Posição 1: parcelou muito? (2x → 0.16, 10x → 0.83)
  • Posição 3: que horas era? (5h da manhã → 0.21, 18h → 0.78)
  • Posição 4: dia da semana (segunda → 0, domingo → 1)
  • Posições 5 e 6: tempo e distância desde a última compra. Se é a primeira compra do cliente, marca -1 (“não tem dado”).
  • Posição 11: o cliente já comprou nesse lugar antes? (1 → lugar desconhecido, mais suspeito)
  • Posição 12: o tipo de loja é arriscado? (mercado → 0.15, casa de aposta → 0.85)

A ideia é simples: transações fraudulentas tendem a ter um perfil parecido — valor alto, longe de casa, loja arriscada, hora estranha. Se eu represento cada transação como um ponto num espaço de 14 dimensões, fraudes ficam aglomeradas perto de outras fraudes.

A analogia que uso pra explicar pra não-programadores: imagina que você contratou um estagiário pra fazer só uma coisa — ler o ticket de venda e preencher um formulário de 14 campos com checkboxes e percentuais. Ele não decide nada, só preenche. Depois outra pessoa pega o formulário e compara com milhões de outros pra decidir.

Slice 2a foi ensinar a máquina a preencher esse formulário corretamente, com testes que garantem que ela preenche igualzinho ao gabarito do regulamento.

Os testes são o contrato

O regulamento traz dois exemplos completos: uma transação legítima (tx-1329056812) e uma fraudulenta (tx-3330991687). Cada um vem com o vetor esperado, com 4 casas decimais. Esses dois exemplos viraram fixture inline:

const LEGIT_PAYLOAD: &str = r#"{
    "id": "tx-1329056812",
    "transaction": { "amount": 41.12, "installments": 2, "requested_at": "2026-03-11T18:45:53Z" },
    ...
}"#;

#[test]
fn vectorize_legitimate_example_matches_doc() {
    let p: Payload = serde_json::from_str(LEGIT_PAYLOAD).unwrap();
    let v = vectorize(&p);
    let expected = [
        0.0041, 0.1667, 0.05, 0.7826, 0.3333, -1.0, -1.0,
        0.0292, 0.15, 0.0, 1.0, 0.0, 0.15, 0.006,
    ];
    approx_eq_4dp(&v, &expected);
}

O approx_eq_4dp arredonda o f32 pra 4 casas e compara com tolerância 1e-4. Como o doc imprime 4dp, essa é a precisão máxima reportável — tolerância menor seria comparar contra ruído de display, não contra o gabarito.

10 testes ao todo no vector.rs:

  1. vectorize_legitimate_example_matches_doc: fixture legit do doc, byte-equal a 4dp.
  2. vectorize_fraud_example_matches_doc: fixture fraude do doc, byte-equal a 4dp.
  3. vectorize_last_transaction_null_writes_minus_one_at_5_and_6: sentinela explícita.
  4. vectorize_with_last_transaction_computes_minutes_and_km: payload com last_transaction preenchido, valida 325 minutos entre dois timestamps reais.
  5. clamp_caps_amount_above_max: todas as dimensões com clamp saturam em 1.0.
  6. mcc_risk_unknown_returns_default: 0.5 pra “9999” e "".
  7. mcc_risk_known_returns_table_value: 0.85 pra 7995, 0.15 pra 5411.
  8. unknown_merchant_uses_set_membership_with_duplicates: ["MERC-001", "MERC-001"] ainda reconhece “MERC-001”.
  9. weekday_matches_known_dates: quatro dias conhecidos cobrindo bordas seg/dom.
  10. parse_u32_handles_zero_padded_fields: 00, 05, 23, 2026.

Todos verdes na primeira execução depois que o fixture do legit passou. Cada teste novo me forçou a perguntar “se isso quebrar, qual é o pior caso?”. Set membership com duplicata foi o que eu escrevi rezando porque o doc não é explícito sobre isso. Passou.

Resultado em runtime

http_req_duration: avg=109µs  med=76µs  p95=220µs  max=252µs
checks_succeeded:  100% (20/20)
http_req_failed:   0%

Slice 1 marcava p95 218µs. Slice 2a com vetorização real marca p95 220µs. O custo da vetorização + parser ISO + Sakamoto + Hinnant some no ruído de medição. Era esperado: payload pequeno, contas em f32, zero alocação. Mas é satisfatório ver o número.

Edge case: payload mal-formado ({"id": "bad"} sem o resto) volta HTTP 422. O serde corta na entrada do handler. Não preciso validar nada à mão dentro de vectorize.

O que ficou de fora (de propósito)

  • Score real (approved, fraud_score). Continua placeholder. O score só faz sentido depois da busca dos 5 vizinhos, e isso é Slice 3.
  • Carregar normalization.json em runtime. Decisão consciente. Constantes não mudam.
  • Otimização SIMD da vetorização. Não é gargalo. O foco do projeto é a busca contra 3M vetores — lá SIMD vale o esforço, aqui não.
  • Testes de integração com os 50 payloads de example-payloads.json. Os payloads de exemplo não vêm com vetor esperado anexado (o example-references.json é dataset separado, não é mapeamento 1:1). Os dois fixtures do regulamento já cobrem o contrato, e expandir pra 50 sem gabarito seria escrever testes que comparam código contra ele mesmo.

Próximo

Slice 2b: o binário preprocess. Vai ler o references.json.gz (16 MB comprimido, 284 MB descomprimido), parsear o stream sem carregar tudo em memória, quantizar cada vetor de f32 pra i16 (decisão fechada no post 2), e escrever três arquivos binários: data/refs.i16.bin, data/labels.bin e data/metadata.json.

Esse binário roda no estágio de build do Dockerfile. A imagem de runtime já recebe os arquivos binários prontos, sem descompactar JSON nem parsear no boot. É lá que a quantização vira artefato.

A vetorização é a parte fácil. A parte difícil é descobrir que ela está errada quando o p99 continua bonito. Por isso o gabarito do regulamento virou suite de teste antes da função existir.

Código completo em obrunogonzaga/rinha-backend-2026-rust. PR do Slice 2a é o #2.