Engineering

Construindo um Engine de Avaliação de Regras

Engine que roda em um passo opaco é engine que você não consegue debugar. Deixa os estágios explícitos e o bug não tem onde se esconder.

A escalada chegou às 23:14: a regra de markup disparou no cliente errado.

Não tinha disparado. Os três da gente passaram os próximos quarenta e cinco minutos provando isso. O fact set tava correto. A regra tava correta. A ação rodou com os inputs que a gente esperava. O resultado foi o que a regra dizia que devia ser. O cliente era o que a gente esperava cobrar.

O que tinha acontecido de verdade foi que a regra disparou, a ação executou, e o consumidor lá embaixo mapeou o resultado num campo diferente do que o time tinha acordado. O bug tava três camadas acima do engine. A gente gastou quarenta e cinco minutos provando que o engine não tava mentindo porque o engine não tinha como provar. Execute entrou; um número saiu; o meio era uma caixa preta.

Na manhã seguinte a gente abriu o engine em estágios. Dali pra frente, todo Execute produzia um registro que nomeava as regras que casaram, as condições avaliadas, as ações executadas, e o resultado composto. A próxima vez que alguém escalou, a resposta levou dois minutos.

É esse o resto desse post. Engine que roda em um passo opaco é engine que você não consegue debugar. Engine com estágios explícitos é engine que se explica sozinho.

O pipeline

Rule engine é pipeline. Lê de cabeça pra rabo a partir de Execute(ctx, facts) → Result:

Facts entram
┌──────────────────────────────────────────┐
│ Load        (coberto no post 3)          │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Validate    (coberto no post 3)          │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Receber facts                            │
│   normalizar, conferir tipo, completar   │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Match                                    │
│   candidato ← matcher.Match(facts)       │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Evaluate                                 │
│   pra cada candidato: condition.Eval     │
│   acumular resultado pós-filtro          │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Execute                                  │
│   action(facts) pra toda regra que disparou │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Compose                                  │
│   combinar output das ações por política │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Explain                                  │
│   emitir registro de cada fronteira      │
└──────────────────────────────────────────┘
Result + Explanation saem

Cada estágio é uma função. Cada estágio tem input e output. Cada estágio se testa sem os outros. Cada estágio falha com erro tipado que nomeia qual estágio falhou, não só que algo falhou.

Essa é a regra de design pro resto do post: toda fronteira do pipeline é observável. O time pergunta pro engine “o que aconteceu nesse ponto?” e o engine tem resposta.

Receber facts: o contrato na borda

Facts são o que o engine usa pra avaliar. A assinatura do Execute define o que o engine aceita:

// engine.Engine.Execute é o ponto de entrada do hot-path público.
func (e *Engine) Execute(ctx context.Context, in Request) (Result, error)

No bre-go, Request carrega Input como interface{} e callbacks opcionais pra ConditionContext e ActionContext. O wrapper exec.Executor[In, Out] adiciona input e output tipados. O formato é o mesmo, a ponte é mais larga.

Antes do matching começar, três coisas têm que ser verdade sobre os facts:

Têm que ser completos o bastante. Uma condição que referencia days_to_departure quebra em silêncio se a request não trouxe esse campo. O matcher não sabe se o campo “tá ausente” ou “tá em nil” — e a resposta muda a semântica. O estágio de recepção normaliza campo ausente como não-restringido (que o matcher trata como sem constraint) ou rejeita a request (quando o campo é obrigatório pro rule set que o engine tá servindo).

Têm que ser do tipo certo. market é string. days_to_departure é número. enabled é bool. O estágio de recepção é onde isso é convertido, validado, e carimbado no formato canônico que o engine indexed quer. O engine indexed do bre-go trabalha contra map[string]string, então esse estágio marshalla todo campo tipado pra representação string.

Têm que ser baratos de ler. O matcher vai ler o mesmo campo várias vezes em várias regras. O estágio de recepção converte a request crua num mapa de facts uma vez. Leituras subsequentes são lookup de hash O(1).

Um formato útil:

type Facts struct {
    raw      map[string]string  // em string, pronto pro matcher
    original interface{}        // pros callbacks de ActionContext
    received time.Time          // quando essa request entrou no engine
}

func receive(in Request) (Facts, error) {
    raw, err := marshalFacts(in.Input)
    if err != nil {
        return Facts{}, fmt.Errorf("receive: %w", err)
    }
    return Facts{
        raw:      raw,
        original: in.Input,
        received: time.Now(),
    }, nil
}

Recepção é o primeiro lugar onde o Execute pode falhar. Falha de recepção não é falha de runtime — é falha de cliente. A Request não trouxe o que o engine precisava. O engine retorna erro; nenhuma regra é avaliada; nenhum listener dispara como se uma regra tivesse casado.

Match: dos facts pro conjunto candidato

Match foi o assunto do Post 4. Aqui ele se acomoda no pipeline.

type Matcher interface {
    Match(facts Facts) []RuleRef
}

O matcher devolve um conjunto candidato: as regras cujas condições indexáveis são consistentes com os facts. O conjunto candidato é pequeno (sub-linear na contagem de regras, num matcher indexed) e ainda não leva em conta as condições pós-filtro.

O contrato aqui é estreito. O matcher não avalia condição completa; não roda ação; não compõe nada. Entrega pro próximo estágio uma lista de regras pra considerar.

No adaptador indexed do bre-go:

// Pseudocódigo do indexed.Engine.Execute, simplificado.
candidates := e.index.Lookup(facts.raw)
// candidates é o conjunto das regras cujos termos indexáveis
// hashearam nos buckets em que essa request também hasheou.

O conjunto candidato é a primeira fronteira observável do pipeline. O tamanho do conjunto candidato é uma métrica que vale acompanhar — mostra se o índice tá fazendo o trabalho. Conjunto candidato consistentemente grande quer dizer que as regras não estão bem indexadas e o matcher tá degradando pra linear. Conjunto candidato consistentemente vazio pra tráfego vivo quer dizer que as regras estão estreitas demais, e muito esforço tá sendo desperdiçado.

Evaluate: do conjunto candidato pras regras que casaram

O evaluator caminha cada regra candidata e avalia a árvore Condition inteira contra os facts. Aqui é onde condições pós-filtro (negação, faixa, condição tipada custom registrada pelo hook de pós-filtro) são avaliadas.

type Evaluator interface {
    Evaluate(candidates []RuleRef, facts Facts) []RuleRef
}

O output é o subconjunto do conjunto candidato que de fato dispara. O evaluator é o primeiro estágio que sabe se a condição completa de uma regra é verdade pra esses facts.

É também onde todo “quase casou” é registrado. Uma regra cujos termos indexáveis bateram mas a faixa não, é interessante. Diz pro time que a regra foi quase um match — e quase um match é exatamente o tipo de coisa que o log de explicação precisa.

type EvaluationRecord struct {
    Rule      RuleRef
    Outcome   Outcome  // OutcomeFired, OutcomeFailedCondition, OutcomeDisabled
    FailedAt  string   // "when.days_to_departure.lt" se uma cláusula falhou
}

Um matcher simples só retorna as regras que dispararam. Um matcher útil retorna as regras que dispararam e os registros de avaliação das que não dispararam. O segundo custa um pouquinho mais de memória; paga de volta na primeira vez que alguém pergunta “por que a regra X não disparou?”

No bre-go, é pra isso que serve a interface de listener. OnRuleMatched dispara por match; o structured telemetry listener pode ser hookado pra gravar outcome de condição; a abordagem listener-driven mantém o hot-path enxuto e a história de observabilidade rica pros callers que adotam.

Execute: rodar as ações

Depois que o conjunto de regras é conhecido, a ação roda. A ação pega os facts (e opcionalmente um ActionContext carregando o correlation ID, o handle do listener, e o que mais o caller pediu) e devolve o que a assinatura de tipo da ação disser que devolve.

type ActionResult struct {
    Rule    RuleRef
    Output  interface{} // o que essa ação devolveu
    Err     error       // tipado se a ação deu panic
    Latency time.Duration
}

func (e *Engine) executeActions(ctx context.Context,
    fired []RuleRef, facts Facts) []ActionResult {
    results := make([]ActionResult, 0, len(fired))
    for _, r := range fired {
        start := time.Now()
        out, err := r.Action(facts.original) // com recover de panic
        results = append(results, ActionResult{
            Rule:    r,
            Output:  out,
            Err:     err,
            Latency: time.Since(start),
        })
    }
    return results
}

Duas escolhas de design nesse loop merecem o destaque.

Ação roda depois do matching, nunca durante. O estágio de match e o estágio de ação são separados por uma lista explícita. O engine conhece o conjunto completo de regras que dispararam antes de qualquer ação rodar. Isso é o que deixa a detecção de conflito acontecer nas colisões de campo que o Post 4 abordou: o engine vê que R7 e R12 querem setar markup_percentage ao mesmo tempo, e a política de resolução decide o que fazer — antes de qualquer ação rodar.

Panic de ação é capturado e tipado. O listener OnExecutionErrored do bre-go recebe um ActionPanicError carregando o nome da regra e o valor do panic. Panic numa ação não derruba o engine; produz um resultado tipado em volta do qual o próximo estágio compõe. O engine ainda devolve um Result; o Result reporta que R7 deu panic e foi excluída.

É onde o instinto de engenharia briga com pragmatismo. O instinto diz: se alguma coisa deu errado, falha o Execute inteiro. O pragmatismo diz: se R7 deu panic mas R12 e R18 dispararam limpas, o caller provavelmente ainda consegue tomar decisão. O meio-termo em que o bre-go chega é deixar o caller ver o que aconteceu e escolher: a regra falha aparece na explicação; o Result carrega o conjunto parcial; o caller pode rebaixar pra um default seguro se não gostar do parcial.

Compose: combinar output das ações por política

Compose é o estágio que transforma N outputs de ação num Result. É onde a política de resolução do Post 4 de fato roda.

type Composer interface {
    Compose(actions []ActionResult) Result
}

// Um composer concreto pra um engine de pricing:
type pricingComposer struct {
    policy ResolutionPolicy // sum, last, fail por campo
}

func (c *pricingComposer) Compose(actions []ActionResult) Result {
    var r Result
    for _, a := range actions {
        if a.Err != nil {
            r.Failed = append(r.Failed, a.Rule)
            continue
        }
        r = c.policy.Apply(r, a.Output, a.Rule)
    }
    return r
}

O composer é onde markup aditivo empilha, onde last-write-wins resolve, onde conflito que o loader não pegou é levantado como erro em tempo de Execute. O composer é também a camada que a maioria dos engenheiros embute dentro do próprio engine no começo — e a camada que mais paga quando é puxada pra fora.

Composer que é seu próprio estágio se testa com resultados de ação sintéticos. O teste não precisa subir o matcher nem o evaluator. O teste dá pro composer uma lista de ActionResult{} e checa o Result composto. É isso que torna mudança de política de resolução auditável.

Explain: a fronteira que paga pelas outras

A explicação é o artefato que todo outro estágio alimenta.

type Explanation struct {
    CorrelationID string
    ReceivedAt    time.Time
    Facts         Facts
    CandidateSet  []RuleRef
    Evaluations   []EvaluationRecord
    Actions       []ActionResult
    Result        Result
    Composer      string  // qual política
    Snapshot      string  // ID do snapshot do engine no momento do Execute
}

A explicação é o que faz a próxima escalada levar dois minutos em vez de quarenta e cinco. Registra toda fronteira do pipeline. O conjunto candidato diz pro time se o índice ajudou. As avaliações dizem pro time quais regras quase casaram. As ações dizem quais regras dispararam e o que devolveram. O Result diz o que o composer produziu.

A parte cara é que a explicação tem que ser barata de produzir. No bre-go, o modelo listener-driven significa que a explicação é opt-in: hot-path que não precisa não paga nada; investigação que precisa pode ser reproduzida contra o snapshot do engine que o Execute original usou, produzindo a mesma explicação de forma determinística. Foi disso que o trabalho de ExportSnapshot / LoadSnapshot em v0.15 / v0.16 tratou — explicabilidade não é só sobre emitir registro agora; é sobre conseguir reproduzir o estado do engine depois.

O pipeline como fronteiras observáveis — cada estágio emite um registro que a explicação lê

🔄 Rendering PlantUML diagram...

A função Execute num único frame

Juntando tudo, o engine fica assim:

// engine.Engine.Execute, com o pipeline explícito.
func (e *Engine) Execute(ctx context.Context, in Request) (Result, error) {
    facts, err := e.receive(in)
    if err != nil {
        return Result{}, fmt.Errorf("receive: %w", err)
    }

    // Fronteira 1: conjunto candidato
    candidates := e.matcher.Match(facts)
    e.listeners.OnCandidates(ctx, candidates)

    // Fronteira 2: conjunto que disparou
    fired, evals := e.evaluator.Evaluate(candidates, facts)
    for _, ev := range evals {
        e.listeners.OnEvaluation(ctx, ev)
    }

    // Fronteira 3: output das ações
    actions := e.executeActions(ctx, fired, facts)
    for _, a := range actions {
        e.listeners.OnAction(ctx, a)
    }

    // Fronteira 4: result
    result := e.composer.Compose(actions)
    e.listeners.OnFinished(ctx, result)

    return result, nil
}

O que esse código deixa explícito é que todo momento interessante no engine é uma chamada de função com output nomeado. Os listeners são a superfície de observabilidade; as chamadas de função são as costuras de unit-test. O pipeline parou de ser um fluxograma no quadro e virou uma sequência de fronteiras tipadas.

Testar o pipeline

Aqui é onde o Post 6 começa a fazer sentido. Com estágio tão explícito, teste se encaixa em quatro baldes.

Unit test por estágio. O matcher recebe um mapa de facts e a gente checa o conjunto candidato. O evaluator recebe um conjunto candidato e a gente checa o conjunto que disparou. O composer recebe uma lista de resultados de ação e a gente checa o Result. Cada teste é pequeno. Cada teste roda em microssegundos. Cada teste nomeia o estágio no nome do arquivo.

Teste de integração pro pipeline. Conecta os estágios reais contra um rule set conhecido. Passa facts realistas. Confere o Result. Esses pegam o bug entre estágios que unit test não pega: um matcher que produz conjunto candidato que o evaluator não consegue caminhar; um evaluator que dispara regra cuja ação o composer não tem política.

Golden test pra comportamento ponta-a-ponta. Dado um snapshot do rule set e um fixture de facts, a gente confere o Result e a Explanation. Esses testes prendem o sistema num comportamento que o time concordou. Quando o teste falha, o diff contra o arquivo golden é a história do que mudou.

Teste de propriedade pra matcher e composer. Pra qualquer fact set, o conjunto candidato é superset do conjunto que disparou. Pra qualquer resultado de ação, o Result composto obedece a política de resolução. Esses testes não prendem comportamento específico; prendem invariante. Teste de propriedade é o tipo de teste que acha bug que ninguém escreveu teste pra pegar.

O formato que torna esses quatro baldes baratos é o próprio pipeline. Estágio com input e output tipado se testa. Engine opaco não.

O que o pipeline explícito te compra

Três coisas, principalmente.

Tempo de debug cai. A escalada de quarenta e cinco minutos do começo vira lookup de dois minutos. A explicação diz pro time onde no pipeline a coisa surpreendente aconteceu.

Trabalho de performance vira focado. Quando o p99 sobe, a latência por estágio emitida pelo listener diz pro time se o gargalo é match, evaluate ou execute. Sem fronteira de estágio, o único dado é “Execute tá lento”.

Mudança arquitetural vira segura. Trocar o matcher de linear pra indexed é troca numa fronteira. Trocar o composer de last-write-wins pra aditivo é troca numa fronteira. Cada troca é code review de uma interface, não rearquitetura do engine.

O custo são as interfaces. O engine sobe com um tipo Engine e quatro interfaces internas — Matcher, Evaluator, Executor, Composer — cada uma implementada por um tipo concreto. As interfaces parecem cerimônia até o dia em que alguém quer testar o composer sem matcher de verdade na frente.

O que vem a seguir

O próximo post é teste — não no abstrato, mas no formato específico que esses estágios habilitam. Teste table-driven pro matcher. Golden test pra ponta-a-ponta. Teste de propriedade pra invariante. O vocabulário de testar um rule engine, depois que o engine foi aberto em peças nomeadas.

O post seguinte é explicabilidade — o artefato que todo estágio desse post alimentou. Explicabilidade é o que vira o engine de uma caixa preta num sistema que o time consegue operar. É também onde as fronteiras registradas desse post viram o diff que um postmortem lê.

Por enquanto, a lição é o pipeline. Rule engine que roda em um passo é engine que não consegue se defender quando alguma coisa dá errado. Rule engine feito de estágios é engine que o time consegue interrogar. O primeiro tipo de engine você consegue shippar numa semana. O segundo tipo de engine você consegue manter rodando por cinco anos.

A escalada que começou esse post nunca se repetiu. Não porque o bug fosse raro. Porque na próxima vez que alguém escalou, o engine produziu uma explicação e a conversa acabou antes do segundo café.