Depois de anunciar o PrisML, eu quis registrar com clareza como isso foi construído na prática.

Este post é esse bastidor: a arquitetura que escolhi, a ordem de implementação, o que quebrou no caminho e o que eu faria diferente hoje.

E, principalmente: o que está implementado de fato no repositório hoje.


O problema que eu estava tentando resolver

Eu não comecei tentando “fazer mais uma lib de ML”.

Comecei tentando resolver uma dor recorrente de produto:

  • o modelo funciona no notebook;
  • vira um serviço separado;
  • o schema muda no app;
  • o modelo continua rodando com premissas antigas;
  • ninguém percebe até a métrica cair.

A ideia do PrisML nasceu daqui: tratar treinamento como compilação e inferência como chamada local e type-safe.


O impacto do Prisma no meu modelo mental

Uma parte importante da origem do PrisML veio do meu contato com o Prisma ORM.

O que eu gosto no Prisma é o modelo mental dele:

  • você descreve intenção num schema;
  • gera código type-safe;
  • e, quando algo quebra o contrato, o problema aparece cedo no ciclo de desenvolvimento.

Esse fluxo influenciou diretamente como pensei ML no projeto.

Em vez de tratar modelo como um serviço “solto”, eu quis trazer a mesma disciplina de contrato para predição:

  • definição em TypeScript;
  • artefato compilado com metadata explícita;
  • validação de compatibilidade com o schema antes de inferir.

No fundo, foi uma transferência de mentalidade: aplicar no ML a previsibilidade que eu já tinha no fluxo ORM.


Arquitetura que eu escolhi

Eu optei por um monorepo com responsabilidades separadas:

  • @vncsleal/prisml-core: tipos, defineModel, hashing de schema, encoding e validações;
  • @vncsleal/prisml-cli: prisml train e prisml check;
  • @vncsleal/prisml-runtime: sessão de inferência ONNX;
  • @vncsleal/prisml: pacote de entrada para consumo.

No começo (v0.1.0), o núcleo era esses quatro pacotes. Depois, no v0.2.0, eu adicionei o quinto pacote:

  • @vncsleal/prisml-generator: integração com gerador Prisma para annotations de schema.

Essa separação foi crítica por dois motivos:

  1. manter o runtime limpo (sem dependências de treino);
  2. isolar a complexidade de compilação no CLI.

O fluxo final ficou assim:

TypeScript model definition -> prisml train -> ONNX + metadata.json -> PredictionSession

E, a partir do v0.2.0, o fluxo também passou a incluir o gerador como camada opcional de DX para schema annotations.


Fase 1: definir o contrato antes de treinar qualquer coisa

Antes de mexer no Python, eu defini o contrato de artefato (metadata.json) e os erros tipados.

Por quê?

Porque se o contrato é frouxo, todo o resto vira improviso.

Eu congelei cedo:

  • formato de features e ordem vetorial;
  • regras de imputação;
  • encoding categórico;
  • hash SHA256 do schema Prisma;
  • métricas e quality gates.

Só depois disso fui para pipeline de treino.


Fase 2: pipeline de compilação (prisml train)

O comando de treino virou um pipeline explícito:

  1. carregar config e schema;
  2. validar definições de modelo;
  3. materializar dataset via Prisma;
  4. extrair features com resolvers TS;
  5. normalizar para vetores numéricos determinísticos;
  6. treinar no backend Python (scikit-learn);
  7. exportar para ONNX + metadata;
  8. validar quality gates e falhar build se necessário.

Alguns detalhes concretos da implementação atual:

  • split de dataset com seed fixa (42) e divisão 80/20;
  • serialização de X_train, X_test, y_train, y_test em .dataset.json por modelo;
  • backend Python local com numpy, scikit-learn, skl2onnx, onnx;
  • algoritmos suportados hoje: linear, tree, forest, gbm (regressão e classificação).
  • imputação no fluxo atual é majoritariamente constant (média para numérico e modo para booleano), com fallback explícito para strings por modo.

A regra principal foi: tudo que impacta inferência precisa estar serializado no metadata.

Nada de “comportamento implícito” só no código.


Por que Python fica no CLI (e não no runtime)

Essa foi uma decisão bem intencional de arquitetura.

No PrisML, Python existe para treinar e exportar modelo (scikit-learn + skl2onnx). Quem faz isso é o prisml train, que roda na camada de CLI/build.

No runtime da aplicação, a ideia é outra:

  • só carregar artefatos já compilados (.onnx + .metadata.json);
  • validar contrato (incluindo hash de schema);
  • inferir em-process com ONNX Runtime no Node.

Os ganhos práticos dessa separação:

  1. Deploy mais simples

    • app Node não precisa de Python em produção.
  2. Dependências isoladas

    • stack de treino fica no toolchain de build, não no caminho crítico de runtime.
  3. Fronteira clara entre compile-time e runtime

    • treino gera artefato imutável; runtime só executa.
  4. Menor acoplamento operacional

    • dá para evoluir pipeline de treino sem transformar inferência num serviço separado.

O que aprendi estudando ONNX para o PrisML

ONNX, pra mim, começou como “formato de exportação”.

No projeto, ele virou algo mais importante: fronteira de execução entre treino e runtime.

Alguns aprendizados práticos:

  1. Portabilidade é útil, mas contrato é indispensável

    • exportar para ONNX resolve a execução no Node, mas não resolve semântica de features.
    • por isso o .metadata.json virou parte obrigatória do artefato.
  2. Inferência é simples quando a preparação é rigorosa

    • ONNX Runtime funciona bem in-process.
    • a parte difícil é garantir que o vetor de entrada no runtime seja idêntico ao vetor usado no treino.
  3. ML em produto é menos sobre modelo e mais sobre consistência

    • com ONNX, o caminho de execução fica previsível;
    • com schema hash + encoding/imputação serializados, a predição fica auditável.

No contexto do PrisML, ONNX não é “a solução inteira”.

Ele é a peça que permite empacotar o modelo de forma executável, enquanto TypeScript + metadata + validações garantem que ele rode com o contrato certo.


Fase 3: runtime mínimo e previsível

No runtime, eu queria uma API pequena:

  • inicializar modelo (metadata + onnx + schema hash atual);
  • prever (predict / predictBatch);
  • falhar com erro claro quando contrato é quebrado.

Resultado: o runtime não “adivinha” nada.

Se aparecer schema divergente, feature inválida ou valor incompatível com o contrato serializado, ele quebra cedo com erro tipado (SchemaDriftError, FeatureExtractionError, EncodingError, etc.).

O runtime também já implementa predictBatch com preflight atômico: se uma entidade falha na validação de features, o lote inteiro é abortado antes da inferência ONNX.


A decisão mais importante: bloquear drift por hash de schema

A proteção contra drift foi o coração do projeto.

Durante o treino, eu calculo e salvo hash do schema Prisma. Durante a inicialização do runtime, comparo com o hash atual.

Se não bater, não roda inferência.

Isso elimina um dos bugs mais caros desse tipo de stack: modelo “funcionando” com semântica antiga.


O que o MVP já cobre (e o que ainda não cobre)

O que já está sólido no código:

  • contrato de artefato (.onnx + .metadata.json) com versionamento;
  • hash SHA256 de schema Prisma no treino e validação no runtime;
  • prisml check para validação de contrato sem retreinar;
  • quality gates com falha explícita no build;
  • runtime Node com ONNX in-process.

Importante: no prisml check atual, divergência de hash de schema gera warning; erro fatal fica para incompatibilidades de campo/tipo/nullability.

O que ainda está em escopo MVP (e não “completo”):

  • a análise AST avançada de resolvers ainda está como stub de MVP;
  • hoje a validação estática depende mais de contratos serializados + checagens de schema/feature do que de inferência profunda do resolver;
  • para casos dinâmicos, o runtime é quem faz a validação mais rígida.

O que mais deu trabalho

Três pontos foram mais chatos do que pareciam:

  1. Determinismo de features

    • ordem de coluna, encoding e imputação precisam ser idênticos entre treino e inferência.
  2. Contratos de feature vs. realidade do schema

    • nem toda feature nasce de um campo direto do Prisma.
    • precisei separar bem o que é validável estaticamente no check e o que só é confiável no runtime.
  3. DX no CLI

    • erro de ML costuma vir “críptico”.
    • investi bastante em mensagens acionáveis com contexto (modelo, feature, threshold, etc.).

Erros que cometi no caminho

Alguns aprendizados práticos:

  • eu inicialmente subestimei o peso de contrato de metadata;
  • tentei ser permissivo demais com inferência e isso escondia problema;
  • deixei lint/CI frouxo em etapas iniciais e paguei com falhas evitáveis.

No fim, o padrão que funcionou foi:

ser rígido no contrato + explícito nos erros + simples na API pública.


O que eu faria diferente hoje

Se eu começasse de novo:

  • investiria mais cedo em prisml check para CI sem treino completo;
  • criaria fixtures de “schema drift” desde o primeiro dia;
  • adicionaria benchmark de regressão de performance mais cedo no runtime.

Conclusão

O PrisML não nasceu para competir com plataformas de ML em larga escala.

Ele nasceu para um cenário específico: times de produto que querem usar predição no app TypeScript com menos fricção, menos infraestrutura e menos espaço para erro silencioso.

Se esse for seu contexto, o feedback de quem vai usar em produção é o que mais me ajuda agora.

Se quiser o post de posicionamento (mais “por que”), ele está aqui também: Eu construí uma biblioteca de ML compiler-first pra quem usa TypeScript