From aeee6c7de11b2a79a68cab276e859273ba1fb360 Mon Sep 17 00:00:00 2001 From: Marcos Lohmann Date: Wed, 7 Oct 2026 11:14:29 -0300 Subject: [PATCH] setup environment --- .gitignore | 27 +++ README.md | 60 ++++++ adapters/gliner/.gitkeep | 0 adapters/laya/.gitkeep | 0 adapters/rizzo_flow/.gitkeep | 0 adapters/semif/.gitkeep | 0 analysis/.gitkeep | 0 config/benchmark.yaml | 101 +++++++++ contracts/jev/fixtures/.gitkeep | 0 contracts/jev/schemas/request.schema.json | 79 +++++++ contracts/jev/schemas/response.schema.json | 74 +++++++ data/annotated/.gitkeep | 0 data/raw/.gitkeep | 0 data/splits/.gitkeep | 0 data/taxonomy/.gitkeep | 0 docs/01-contexto-arquitetural.md | 74 +++++++ docs/02-desenho-experimental.md | 131 ++++++++++++ docs/03-plano-de-tarefas.md | 175 ++++++++++++++++ docs/04-questoes-em-aberto.md | 167 +++++++++++++++ env/.gitkeep | 0 harness/.gitkeep | 0 infra/oci/nat.py | 93 +++++++++ infra/oci/provision.py | 231 +++++++++++++++++++++ infra/oci/session.py | 25 +++ reports/.gitkeep | 0 25 files changed, 1237 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 adapters/gliner/.gitkeep create mode 100644 adapters/laya/.gitkeep create mode 100644 adapters/rizzo_flow/.gitkeep create mode 100644 adapters/semif/.gitkeep create mode 100644 analysis/.gitkeep create mode 100644 config/benchmark.yaml create mode 100644 contracts/jev/fixtures/.gitkeep create mode 100644 contracts/jev/schemas/request.schema.json create mode 100644 contracts/jev/schemas/response.schema.json create mode 100644 data/annotated/.gitkeep create mode 100644 data/raw/.gitkeep create mode 100644 data/splits/.gitkeep create mode 100644 data/taxonomy/.gitkeep create mode 100644 docs/01-contexto-arquitetural.md create mode 100644 docs/02-desenho-experimental.md create mode 100644 docs/03-plano-de-tarefas.md create mode 100644 docs/04-questoes-em-aberto.md create mode 100644 env/.gitkeep create mode 100644 harness/.gitkeep create mode 100644 infra/oci/nat.py create mode 100644 infra/oci/provision.py create mode 100644 infra/oci/session.py create mode 100644 reports/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d71d244 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +.pytest_cache/ + +# Pesos e caches de modelos (ficam no bucket `artefatos`) +models/ +.cache/ +*.safetensors +*.bin +*.gguf + +# Dados brutos e anotados não são versionados (ficam nos buckets OCI) +data/raw/* +data/annotated/* +!data/**/.gitkeep + +# Resultados de execução (ficam no bucket `resultados`) +runs/ +*.log + +# Credenciais +.secrets/ +.oci/ +*.pem +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..99ef668 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Benchmark de modelos de decisão em PT-BR para roteamento em orquestradores multiagente + +Benchmark reprodutível de modelos de decisão *open source*, do tipo "System 1" e semelhantes ao JEV (`POST /v1/systemone`), aplicados ao **roteamento e à orquestração de enunciados de usuário** (*user utterances*) num sistema multiagente com **A2A Gateway** e **MCP Gateway**. + +O modelo de decisão é avaliado como um componente do **Agente Orquestrador**. A cada turno, ele toma decisões rápidas e estruturadas: identifica a intenção, escolhe o agente de destino, decide entre continuar, alternar ou retomar uma sessão e decide quando é preciso desambiguar. Essas decisões substituem ou complementam chamadas a LLMs generalistas. + +## Objetivo + +Comparar os candidatos em duas dimensões, nas mesmas condições: + +- **Acurácia e calibração** das decisões de roteamento em PT-BR, incluindo casos ambíguos, adversariais e com várias intenções. +- **Desempenho operacional**: latência por turno (p50/p95/p99), vazão, memória, tempo de carga e custo por mil decisões, em CPU e GPU na OCI. + +O resultado é um relatório técnico com uma recomendação. Todos os resultados podem ser reproduzidos a partir dos artefatos versionados. + +## Participantes + +| Candidato | Artefato | +|-------------|----------------------------------------------| +| Laya | `convaiinnovations/laya-multilingual` | +| SemIf | `SemIf-OpenJev` com `Qwen/Qwen3.5-4B` (modo `direct`) | +| GLiNER | `fastino/GLiNER2.5-multi-Decide` (`classify_text`) | +| Rizzo Flow | `rizzoaiacademy/rizzo-flow` (4B) | + +Cada candidato é exposto por um adaptador local com o mesmo contrato JEV (`POST /v1/systemone`). Assim, o *harness* não sabe qual modelo está respondendo. + +## Documentação + +| Documento | Conteúdo | +|-----------|----------| +| [docs/01-contexto-arquitetural.md](docs/01-contexto-arquitetural.md) | Arquitetura de referência do orquestrador e onde o modelo de decisão atua | +| [docs/02-desenho-experimental.md](docs/02-desenho-experimental.md) | Tarefas de decisão, formato dos dados, partições, métricas e regras de isolamento | +| [docs/03-plano-de-tarefas.md](docs/03-plano-de-tarefas.md) | As 78 microtarefas organizadas em fases, com dependências e adaptações ao contexto de roteamento | +| [docs/04-questoes-em-aberto.md](docs/04-questoes-em-aberto.md) | Decisões de projeto (Q1–Q13) e os mecanismos automatizados que as aplicam | +| [config/benchmark.yaml](config/benchmark.yaml) | Parâmetros e critérios pré-registrados, congelados na tarefa 62 | + +## Estrutura do repositório + +``` +config/ Configuração pré-registrada (benchmark.yaml) +infra/oci/ Compartment, tags, orçamento, IAM, VCN, buckets, Data Science (tarefas 1–10, 17, 78) +env/ Imagem de contêiner e manifesto de versões (11–12) +contracts/jev/ Contrato JEV jev-compat-v1: esquemas, mapeamento de tipos, fixtures de conformidade (36–41) +data/taxonomy/ Taxonomia de intenções/agentes e definição das tarefas de decisão (20–23) +data/raw/ Enunciados coletados e anonimizados — não versionar dados sensíveis (24–25) +data/annotated/ Anotações, adjudicação e referência (29–32) +data/splits/ Partições dev / calibração / teste congeladas (33–35) +adapters// Adaptadores JEV locais por candidato (42–51) +harness/ Execução, controle de seeds/lotes/truncamento, coleta de medições (52–66) +analysis/ Métricas, cortes, análise de erros e gráficos (67–74) +reports/ Relatório técnico e anexos de reprodução (75–76) +``` + +## Princípios + +1. **Isolamento de rede.** Na inferência, os modelos usam só artefatos locais com hash conhecido. Qualquer tentativa de download faz o job falhar. +2. **Contrato único.** Todos os candidatos recebem a mesma requisição JEV e devolvem o mesmo formato de resposta. +3. **Teste final congelado.** Prompts, opções, pesos e limiares não mudam depois que o conjunto de teste final é aberto. +4. **Partição por conversa.** Turnos de uma mesma conversa ou caso nunca aparecem em mais de uma partição. +5. **Reprodutibilidade.** Versões, seeds, hashes, configurações e comandos ficam registrados em manifestos anexados ao relatório. diff --git a/adapters/gliner/.gitkeep b/adapters/gliner/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/adapters/laya/.gitkeep b/adapters/laya/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/adapters/rizzo_flow/.gitkeep b/adapters/rizzo_flow/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/adapters/semif/.gitkeep b/adapters/semif/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/analysis/.gitkeep b/analysis/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config/benchmark.yaml b/config/benchmark.yaml new file mode 100644 index 0000000..eb3e287 --- /dev/null +++ b/config/benchmark.yaml @@ -0,0 +1,101 @@ +# Configuração pré-registrada do benchmark. +# Decisões e justificativas: docs/04-questoes-em-aberto.md +# Este arquivo é congelado com hash no marco da tarefa 62. + +contract: + version: jev-compat-v1 # Q1 + endpoint: /v1/systemone + max_options: 20 # Q2: inclui sem_correspondencia + fallback_option: sem_correspondencia + require_zero_output_tokens: true + +candidates: + laya: + model: convaiinnovations/laya-multilingual + revision: null # preenchido pela aquisição (tarefa 13) + declared_context: 1024 + semif: + model: Qwen/Qwen3.5-4B + runtime: SemIf-OpenJev + mode: direct + revision: null + declared_context: 8192 + gliner: + model: fastino/GLiNER2.5-multi-Decide + interface: classify_text + revision: null + declared_context: null # medido (Q4) + rizzo_flow: + model: rizzoaiacademy/rizzo-flow + prompt_version: spark-decisions-v3 + revision: null + declared_context: 2048 + +baseline: # Q9: fora do ranking principal + enabled: true + model: null # fixado no manifesto + network: gateway_only + +licenses: # Q4 + allowlist: [apache-2.0, mit] + +network: # Q3 + egress_probe_hosts: [huggingface.co, pypi.org] + require_probe_failure: true + +input: # Q8 + state_turns_candidates: [0, 1, 2, 4] + fit_ratio_min: 0.95 + token_counter: most_verbose_tokenizer + truncation: drop_oldest_turns_then_tail_truncate_utterance + +execution: + seed: 20261007 + batch_sweep: [1, 2, 4, 8, 16, 32] # Q6 + production_batch: auto # maior lote com p95 <= slo + repetitions: 3 + cpu: # Q11 + timeout_ms: 10000 + max_timeout_rate: 0.05 + gpu: + timeout_ms: 2000 + unavailable_retries: 3 + +calibration: # Q10 + method: temperature_scaling + per: [candidate, task] + cv_folds: 5 + accept_if: ece_decreases + +criteria: # Q5 + latency_gpu_batch1: + p95_ms_per_decision: 150 + p95_ms_per_turn: 300 + accuracy: + tasks: [D1, D2] + non_inferiority_margin_pp: 3.0 + ci: 0.95 + bootstrap_unit: group_id + fallback_macro_f1_min: 0.80 + risk: + max_confident_wrong_route_rate: 0.02 + min_coverage: 0.80 + +sample_size: # Q12 + task: D1 + mde_pp: 3.0 + alpha: 0.05 + power: 0.80 + expected_discordance: 0.15 + prior_icc: 0.05 + min_test_decisions: 1300 + +data: # Q7 + sources: [real, synthetic] + human_audit_rate: 0.05 + dedup: minhash_lsh + +budget: # Q13: em aberto (cota interna); não provisionado + enabled: false + contingency_factor: 1.3 + alerts_pct: [50, 80, 100] diff --git a/contracts/jev/fixtures/.gitkeep b/contracts/jev/fixtures/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/contracts/jev/schemas/request.schema.json b/contracts/jev/schemas/request.schema.json new file mode 100644 index 0000000..6146f50 --- /dev/null +++ b/contracts/jev/schemas/request.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "jev-compat-v1/request.schema.json", + "title": "JEV systemone request (jev-compat-v1)", + "type": "object", + "required": ["model", "state", "questions"], + "additionalProperties": false, + "properties": { + "model": {"type": "string", "minLength": 1}, + "state": { + "oneOf": [ + {"type": "string", "minLength": 1}, + {"type": "object", "minProperties": 1}, + {"type": "array", "minItems": 1} + ] + }, + "questions": { + "type": "object", + "minProperties": 1, + "additionalProperties": {"$ref": "#/$defs/question"} + } + }, + "$defs": { + "question": { + "type": "object", + "required": ["type", "instructions", "criteria"], + "additionalProperties": false, + "properties": { + "type": {"enum": ["choice", "noul", "score"]}, + "instructions": {"type": "string", "minLength": 1}, + "criteria": {} + }, + "allOf": [ + { + "if": {"properties": {"type": {"const": "choice"}}}, + "then": { + "properties": { + "criteria": { + "type": "object", + "minProperties": 2, + "maxProperties": 20, + "additionalProperties": {"type": "string", "minLength": 1} + } + } + } + }, + { + "if": {"properties": {"type": {"const": "noul"}}}, + "then": { + "properties": { + "criteria": { + "type": "object", + "required": ["true", "false"], + "additionalProperties": false, + "properties": { + "true": {"type": "string", "minLength": 1}, + "false": {"type": "string", "minLength": 1} + } + } + } + } + }, + { + "if": {"properties": {"type": {"const": "score"}}}, + "then": { + "properties": { + "criteria": { + "type": "array", + "minItems": 2, + "maxItems": 20, + "items": {"type": "string", "minLength": 1} + } + } + } + } + ] + } + } +} diff --git a/contracts/jev/schemas/response.schema.json b/contracts/jev/schemas/response.schema.json new file mode 100644 index 0000000..ef09b31 --- /dev/null +++ b/contracts/jev/schemas/response.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "jev-compat-v1/response.schema.json", + "title": "JEV systemone response (jev-compat-v1)", + "type": "object", + "required": ["model", "answers", "usage"], + "properties": { + "model": {"type": "string", "minLength": 1}, + "answers": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "oneOf": [ + {"$ref": "#/$defs/choice"}, + {"$ref": "#/$defs/noul"}, + {"$ref": "#/$defs/score"} + ] + } + }, + "usage": { + "type": "object", + "required": ["input_tokens", "output_tokens"], + "properties": { + "input_tokens": {"type": "integer", "minimum": 0}, + "output_tokens": {"const": 0} + } + }, + "metadata": { + "type": "object", + "description": "Extensão do benchmark (tarefa 48); ignorada por clientes JEV.", + "properties": { + "latency_ms": {"type": "number", "minimum": 0}, + "model_id": {"type": "string"}, + "run_id": {"type": "string"}, + "adapter_version": {"type": "string"}, + "probabilities_source": {"enum": ["native", "normalized_scores", "logprobs", "none"]}, + "truncated": {"type": "boolean"} + } + } + }, + "$defs": { + "prob": {"type": "number", "minimum": 0, "maximum": 1}, + "probs": {"type": "object", "minProperties": 1, "additionalProperties": {"$ref": "#/$defs/prob"}}, + "choice": { + "type": "object", + "required": ["type", "choice", "probabilities"], + "properties": { + "type": {"const": "choice"}, + "choice": {"type": "string"}, + "confidence": {"$ref": "#/$defs/prob"}, + "probabilities": {"$ref": "#/$defs/probs"} + } + }, + "noul": { + "type": "object", + "required": ["type", "noul"], + "properties": { + "type": {"const": "noul"}, + "noul": {"$ref": "#/$defs/prob"} + } + }, + "score": { + "type": "object", + "required": ["type", "score", "probabilities", "legend"], + "properties": { + "type": {"const": "score"}, + "score": {"type": "number", "minimum": 0}, + "confidence": {"$ref": "#/$defs/prob"}, + "probabilities": {"$ref": "#/$defs/probs"}, + "legend": {"type": "object", "additionalProperties": {"type": "string"}} + } + } + } +} diff --git a/data/annotated/.gitkeep b/data/annotated/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/raw/.gitkeep b/data/raw/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/splits/.gitkeep b/data/splits/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/taxonomy/.gitkeep b/data/taxonomy/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/01-contexto-arquitetural.md b/docs/01-contexto-arquitetural.md new file mode 100644 index 0000000..2b445f1 --- /dev/null +++ b/docs/01-contexto-arquitetural.md @@ -0,0 +1,74 @@ +# 01 — Contexto arquitetural + +Este documento descreve a arquitetura de referência em termos genéricos e só com os conceitos necessários ao experimento. O objetivo é deixar claro **onde** o modelo de decisão atua e **quais informações de contexto** ele recebe. + +## Componentes da arquitetura de referência + +| Componente | Papel | Relevância para o benchmark | +|------------|-------|-----------------------------| +| **Adaptador de canal** | Integra canais de interação (chat, mensageria, voz) e normaliza a entrada | Define o formato do enunciado que chega ao orquestrador | +| **Agente Orquestrador** | Supervisiona cada turno: identifica a intenção, aplica políticas, controla sessões e seleciona o agente | **Hospeda o modelo de decisão avaliado** | +| **A2A Gateway** | Registra agentes (cards) e faz autenticação, mediação de tarefas A2A, ciclo de vida de tarefas (IDs, estado, retomada, cancelamento), além de erros e timeouts | Fonte do catálogo de agentes, que vira o conjunto de opções de destino | +| **A2A Adapter** | Permite que agentes sem A2A nativo conversem com o orquestrador de forma padronizada | Fora de escopo, exceto como opção de destino | +| **Agentes especializados** | Agentes de domínio, um agente de propósito geral (por exemplo, com busca web) e outros | São as **classes de destino** do roteamento | +| **MCP Gateway / Server** | Expõe ferramentas e fontes de dados com contrato padronizado, *discovery*, autenticação e limites | Fonte de dados para pré-requisitos e para o **serviço de elegibilidade de intenções** | +| **Serviço de elegibilidade de intenções** | A partir das intenções mapeadas, identifica as que são elegíveis para um dado usuário | **Restringe as opções** apresentadas ao modelo de decisão | +| **LLM Gateway** | Centraliza o acesso a modelos (LLM, busca e modelos System 1), além de políticas, limites e telemetria | Ponto de integração natural do modelo de decisão em produção | +| **Memória curta** | Estado operacional da conversa e de cada sessão de agente (`conversation_id`, `session_id`/`task_id`, `agent_id`) | Fornece o **estado** (`state`) da requisição de decisão | +| **Memória longa** | Fatos e preferências duradouros entre sessões | Contexto opcional; avaliado só se fizer parte do `state` | +| **Base de personalização** | Atributos declarados e inferidos do usuário, com acesso governado | Fora de escopo no primeiro ciclo | +| **Observabilidade** | OpenTelemetry Collector, Pub/Sub, armazenamento de traces e logs, dashboards, avaliação de LLM e data lake | Modelo de telemetria usado pelo harness (latência, `run_id`, `model_id`) | + +```mermaid +flowchart LR + U[Usuário] --> CA[Adaptador de canal] + CA --> ORQ + subgraph ORQ[Agente Orquestrador] + DM[[Modelo de decisão
POST /v1/systemone]] + end + MC[(Memória curta)] --> ORQ + ML[(Memória longa)] --> ORQ + ORQ <--> MCP[MCP Gateway] --> EL[Serviço de elegibilidade] + ORQ <--> LLMG[LLM Gateway] + ORQ <--> A2A[A2A Gateway] + A2A <--> AG1[Agente especializado A] + A2A <--> AG2[Agente especializado B] + A2A <--> AGG[Agente de propósito geral] + ORQ -.-> OT[OTel Collector] +``` + +## Fluxo de um turno no orquestrador + +1. Registrar a mensagem recebida, carregar o estado da conversa e carregar os atributos de contexto. +2. Aplicar os **guardrails de entrada**. +3. **Classificar e analisar a intenção.** +4. Seguir um destes ramos: + - **rotear para o agente ativo** via A2A; + - **executar os pré-requisitos** e **iniciar uma sessão com um novo agente** via A2A; + - **preparar perguntas de pré-requisito**; + - **preparar uma pergunta de desambiguação de intenção**. +5. Validar o retorno do agente. +6. Preparar a resposta e aplicar os **guardrails de saída**. +7. Registrar a mensagem e os estados. + +## Pontos de decisão avaliados + +O benchmark avalia os pontos de decisão em que um modelo System 1 substitui ou antecede uma chamada a um LLM generalista. Cada ponto vira uma **tarefa de decisão** com pergunta, opções e regra de decisão próprias. A definição completa está em [02-desenho-experimental.md](02-desenho-experimental.md). + +| ID | Ponto de decisão | Etapa do fluxo | Tipo JEV previsto | +|----|------------------|----------------|-------------------| +| D1 | Classificação de intenção entre as intenções **elegíveis** | Passo 3 | `choice` | +| D2 | Continuidade de sessão: manter o agente ativo, retomar uma sessão suspensa, iniciar um novo agente ou desambiguar | Passo 4 | `choice` | +| D3 | Necessidade de desambiguação: a intenção está clara o suficiente para rotear? | Passos 3–4 | `noul` ou `score`, a confirmar (ver questões em aberto) | +| D4 | Várias intenções no mesmo enunciado: quais intenções estão presentes | Passo 3 | várias decisões sobre o mesmo texto (tarefa 65) | +| D5 | *(opcional)* Guardrail de entrada: dentro do escopo, fora do escopo ou bloqueado | Passo 2 | `choice` | + +Ficam **fora de escopo**: geração de resposta, validação do retorno dos agentes, guardrails de saída, extração de atributos de personalização e o próprio protocolo A2A/MCP. A elegibilidade de intenções é **simulada**: o conjunto de opções elegíveis já vem pronto em cada exemplo. + +## Requisitos não funcionais que derivam da arquitetura + +- **Latência por turno.** O modelo roda em todos os turnos, no caminho síncrono do usuário. Por isso, p95 e p99 com lote 1 são a métrica operacional principal. +- **Contexto curto.** A decisão usa o enunciado e um resumo do estado (agente ativo, sessões abertas, últimos turnos), não o histórico completo. +- **Opções dinâmicas.** O conjunto de opções muda a cada usuário e a cada turno, por causa da elegibilidade e das sessões abertas. Os modelos precisam aceitar opções descritas em tempo de inferência, sem retreino. +- **Abstenção útil.** Quando a confiança é baixa, o melhor caminho é desambiguar, não errar a rota. A calibração e a acurácia seletiva são métricas de primeira classe. +- **Telemetria.** Cada decisão gera um registro compatível com OpenTelemetry (`run_id`, `model_id`, `latency_ms`, versão do adaptador). diff --git a/docs/02-desenho-experimental.md b/docs/02-desenho-experimental.md new file mode 100644 index 0000000..5dad2cf --- /dev/null +++ b/docs/02-desenho-experimental.md @@ -0,0 +1,131 @@ +# 02 — Desenho experimental + +## 1. Hipóteses + +- **H1 (acurácia).** Pelo menos um candidato atinge, em PT-BR, uma acurácia de roteamento (D1/D2) compatível com o uso no orquestrador. O critério é a não inferioridade em relação ao baseline LLM (margem de 3 p.p.); ver [04, Q5](04-questoes-em-aberto.md#q5--critérios-de-sucesso-pré-registrados). +- **H2 (calibração).** As probabilidades e a confiança devolvidas permitem uma política de abstenção (encaminhar para desambiguação) que melhora a acurácia seletiva sem derrubar demais a cobertura. +- **H3 (desempenho).** O candidato cabe no orçamento de latência por turno (p95 com lote 1) com um custo por mil decisões menor que o de uma chamada a um LLM generalista. + +## 2. Tarefas de decisão + +Cada tarefa tem **pergunta**, **opções com descrição** e **regra de decisão** (tarefa 21). No primeiro ciclo, cada questão tem **no máximo 20 opções**, incluindo `sem_correspondencia` (tarefas 22–23). + +### D1 — Classificação de intenção + +- **Pergunta:** "Qual intenção do usuário este enunciado expressa?" +- **Opções:** as intenções elegíveis do exemplo, de 2 a 19, mais `sem_correspondencia`. Cada opção tem uma descrição curta, do mesmo jeito que viria do catálogo de agentes ou intenções. +- **Regra:** escolher a intenção principal que o próximo agente precisa atender. Se nenhuma se aplica, escolher `sem_correspondencia`. + +### D2 — Continuidade de sessão + +- **Pergunta:** "Como o orquestrador deve tratar este turno, considerando o estado da conversa?" +- **Opções:** `continuar_agente_ativo`, `retomar_sessao:` (uma opção para cada sessão suspensa), `iniciar_novo_agente`, `desambiguar`. +- **Regra:** `continuar_agente_ativo` quando o enunciado dá sequência à tarefa em curso, mesmo que seja curto ("sim", "o segundo", "pode ser"). `retomar_sessao` quando o usuário volta a um assunto anterior. `iniciar_novo_agente` quando aparece uma intenção nova. `desambiguar` quando não dá para decidir com segurança. + +### D3 — Necessidade de desambiguação + +- **Pergunta:** "O enunciado tem informação suficiente para rotear sem perguntar ao usuário?" +- **Tipo:** `noul` (P(sim) = "dá para rotear sem perguntar"). +- **Uso:** comparar a abstenção explícita (D3) com a abstenção derivada da confiança de D1 (tarefa 60). + +### D4 — Várias intenções + +- Aplica-se só a enunciados anotados com mais de uma intenção. +- Executa várias perguntas `noul` sobre o mesmo texto, uma por intenção candidata ("O enunciado pede X?"), na mesma requisição JEV (tarefa 65). +- Métrica: F1 por conjunto de intenções e custo total, que soma as latências. + +### D5 — Guardrail de entrada *(opcional)* + +- Opções: `no_escopo`, `fora_do_escopo`, `requer_bloqueio`. Só entra se houver dados anotados suficientes. + +## 3. Formato canônico de entrada (tarefa 36) + +```json +{ + "id": "conv-000123-t04-D2", + "group_id": "conv-000123", + "task": "D2", + "state": { + "active_agent": "agente_a", + "suspended_sessions": ["agente_b"], + "recent_turns": [ + {"role": "user", "text": "..."}, + {"role": "agent", "agent_id": "agente_a", "text": "..."} + ], + "eligible_intents": ["intencao_1", "intencao_2"] + }, + "utterance": "...", + "question": "Como o orquestrador deve tratar este turno?", + "question_type": "choice", + "options": { + "continuar_agente_ativo": "...", + "retomar_sessao:agente_b": "...", + "iniciar_novo_agente": "...", + "desambiguar": "..." + }, + "gold_label": "retomar_sessao:agente_b", + "metadata": { + "taxonomy_version": "v0.1", + "domain": "...", + "length_bucket": "curto", + "ambiguity": "media", + "turn_index": 4, + "adversarial": ["abreviacao"], + "n_options": 4, + "n_tokens": {"laya": 0, "semif": 0, "gliner": 0, "rizzo_flow": 0} + } +} +``` + +**Conversão para JEV.** `question` vira `questions..instructions` e `options` vira `criteria` (contrato `jev-compat-v1`, em [contracts/jev/schemas/](../contracts/jev/schemas/)). O `state` enviado é sempre o **texto serializado** descrito abaixo, nunca o objeto estruturado. + +**Serialização do estado.** Alguns candidatos aceitam só texto, e não `state` estruturado. Por isso, uma **única regra de serialização** de `state` + `utterance` em texto vale para todos os adaptadores (um bloco "Contexto:" com o agente ativo, as sessões suspensas e os N últimos turnos, seguido de "Mensagem:"; N calculado como em [04, Q8](04-questoes-em-aberto.md#q8--turnos-no-state-e-orçamento-de-tokens)). A regra é versionada junto com a política de truncamento (tarefa 53). Um adaptador não pode usar informação de `state` que não esteja nessa serialização. + +## 4. Dados + +- **Unidade de amostragem:** conversa (`group_id`). Cada turno anotado gera um ou mais exemplos (um por tarefa de decisão). +- **Fontes:** enunciados representativos do domínio de destino, anonimizados (tarefas 24–25). Se houver dados sintéticos, eles ficam identificados em `metadata` e são reportados em separado. +- **Estratificação (tarefa 26):** domínio, comprimento, ambiguidade, classe esperada, posição do turno e presença de agente ativo ou de sessões suspensas. +- **Adversariais (tarefa 27):** abreviações, erros de ortografia, regionalismos, enunciados muito curtos e dependentes de contexto ("sim", "o outro"), várias intenções, troca de assunto no meio da conversa, retorno a um assunto anterior e pedidos fora de escopo. +- **Anotação (tarefas 29–32):** dois anotadores independentes e um terceiro para adjudicação. Para cada exemplo se registram o rótulo, a justificativa e a versão da taxonomia. A concordância (kappa de Cohen ou alfa de Krippendorff) vai para o relatório. +- **Partições (tarefas 33–35):** desenvolvimento, calibração e teste final, separadas por `group_id`. O teste final é congelado (hash registrado) antes de qualquer ajuste. + +## 5. Protocolo de execução + +| Etapa | Partição | O que pode mudar | Tarefas | +|-------|----------|------------------|---------| +| Conformidade e sanidade | fixtures | adaptadores | 41, 49–51 | +| Piloto CPU/GPU (50 ex.) | dev | só parâmetros operacionais | 55–58 | +| Desenvolvimento | dev | nada além do operacional; sem calibração | 59 | +| Calibração | calibração | limiares de confiança, regra de abstenção, calibradores por candidato | 60–61 | +| **Congelamento** | — | **nada** daqui em diante | 62 | +| Teste final | teste | — | 63–66 | + +- Seeds, ordem dos exemplos, lote, precisão numérica (fp32, bf16 ou fp16 por candidato, registrada) e limite de tokens fixos por execução (tarefa 52). +- As medições de desempenho são feitas depois do aquecimento, com no mínimo três execuções independentes (tarefa 66). +- Cenários de execução: CPU com lote 1, GPU com lote 1 e GPU com lote de produção (escolhido automaticamente, ver Q6). O lote 1 representa o caminho síncrono do orquestrador; o lote maior mede a vazão. + +## 6. Métricas + +**Qualidade (tarefas 67–68)** +- Accuracy, macro F1, precision e recall por classe, matriz de confusão. +- Taxa de **rota errada com confiança alta** (erro que não seria desambiguado): é a métrica de maior impacto operacional. +- Brier score, ECE, curva cobertura × acurácia seletiva e AURC, calculados sobre `max(probabilities)` calibrado. A `confidence` nativa do JEV não é calibrada e só é registrada. +- D4: F1 por conjunto de intenções. + +**Desempenho (tarefas 69–70)** +- Latência p50, p95 e p99 por decisão e por turno (soma das decisões do turno). +- Vazão, uso de GPU, RAM, VRAM, tempo de carga, tempo de aquecimento e armazenamento dos artefatos. +- Custo estimado por mil decisões em cada cenário, a partir do preço do *shape* OCI e da vazão medida. + +**Cortes (tarefa 71):** tarefa de decisão, número de opções, comprimento, ambiguidade, posição do turno, com ou sem agente ativo e categoria adversarial. + +**Análise de erros (tarefas 72–73):** taxonomia, contexto insuficiente, ambiguidade, linguagem, truncamento, instrução ou desempenho do modelo. + +## 7. Ameaças à validade + +- **Vantagem de formato.** Cada modelo pode ter sido treinado com um estilo de prompt diferente. Por isso há um contrato único e uma serialização única, e os desvios ficam documentados por adaptador. +- **Vazamento entre partições.** É controlado pela partição por `group_id` e pelo congelamento com hash. +- **Truncamento desigual.** Todos usam a mesma política, baseada no menor contexto efetivo, e a contagem de tokens de cada entrada é registrada. +- **Representatividade.** Dados sintéticos e reais são reportados em separado. +- **Elegibilidade simulada.** Em produção, o conjunto de opções vem de um serviço. Aqui ele é fixado por exemplo. diff --git a/docs/03-plano-de-tarefas.md b/docs/03-plano-de-tarefas.md new file mode 100644 index 0000000..c05eb9f --- /dev/null +++ b/docs/03-plano-de-tarefas.md @@ -0,0 +1,175 @@ +# 03 — Plano de tarefas + +As 78 microtarefas mantêm a numeração original. As linhas **↳** registram a adaptação de cada tarefa ao contexto de roteamento e orquestração ou uma dependência importante. As referências *Qn* apontam para as decisões de projeto em [04-questoes-em-aberto.md](04-questoes-em-aberto.md). + +```mermaid +flowchart LR + F0[F0 Infra OCI] --> F1[F1 Ambiente e artefatos] + F2[F2 Tarefas de decisão] --> F3[F3 Dados e anotação] + F2 --> F4[F4 Contrato e adaptadores] + F1 --> F4 + F3 --> F5[F5 Controle de execução] + F4 --> F5 + F5 --> F6[F6 Piloto] + F6 --> F7[F7 Dev e calibração] + F7 --> F8[F8 Teste final] + F8 --> F9[F9 Métricas e análise] + F9 --> F10[F10 Relatório e encerramento] +``` + +As fases F0–F1 (infra) e F2–F3 (dados) podem andar em paralelo. + +--- + +## F0 — Infraestrutura OCI + +- [x] **1.** Criar um compartment OCI dedicado ao benchmark. + - ↳ `root//decision-models`. Recursos em `us-chicago-1`. OCIDs em `.secrets/artefatos-OCI.md` (fora do Git). +- [ ] **2.** Aplicar as tags `projeto=benchmark-decisoes-ptbr`, `ambiente=benchmark` e `centro_custo=` aos recursos do projeto. *(Q13)* + - ↳ Parcial: `projeto` e `ambiente` aplicadas como tags livres por `infra/oci/provision.py`. Falta `centro_custo`. +- [ ] **3.** Criar um orçamento para o compartment. + - ↳ **Em aberto por decisão (2026-10-07):** o uso está coberto por cota interna de engenharia. Não será criado no primeiro ciclo. +- [ ] **4.** Configurar alertas de orçamento em 50%, 80% e 100% do valor aprovado. + - ↳ Em aberto, porque depende da tarefa 3. +- [x] **5.** Criar um projeto no OCI Data Science. + - ↳ `benchmark-decisoes-ptbr`. +- [ ] **6.** Criar buckets no Object Storage para `entrada`, `artefatos`, `resultados` e `logs-imutaveis`. + - ↳ Criados com o prefixo `dmb-` (nomes de bucket são únicos no namespace), privados e com versionamento. + - ↳ Pendente: a regra de retenção (WORM) de `dmb-logs-imutaveis`, que depende do prazo de retenção (tarefa 77). +- [x] **7.** Definir políticas IAM de menor privilégio para leitura dos dados, escrita dos resultados e execução dos jobs. + - ↳ Sem acesso de administrador à tenancy, as políticas ficam no próprio compartment e não usam grupos nem dynamic groups. + - ↳ `dmb-usuario-manager`: o usuário (filtrado por `request.user.id`) é *manager* das famílias data-science, object, virtual-network, logging, repos e generative-ai. + - ↳ `dmb-jobs-menor-privilegio`: os jobs (`request.principal.type='datasciencejob'`) leem `dmb-entrada`/`dmb-artefatos`, escrevem em `dmb-resultados`/`dmb-logs-imutaveis`, usam logs e repos. Inclui a política de serviço do Data Science para a VCN. +- [x] **8.** Definir uma VCN privada para os jobs, com acesso ao Object Storage por Service Gateway. + - ↳ `dmb-vcn` 10.20.0.0/16, sub-rede privada `dmb-subnet-jobs`. A rota e a security list só permitem tráfego para serviços OCI pelo Service Gateway. +- [ ] **9.** Criar uma rota temporária de saída por NAT Gateway, só para a aquisição inicial de dependências e pesos. + - ↳ Remover a rota logo após a tarefa 16 e passar pelo gate de egresso antes de qualquer execução. *(Q3)* + - ↳ Criar e ativar **só quando necessário**, com `infra/oci/nat.py on`. Para desligar: `nat.py off`, que fecha a rota e bloqueia o NAT. Na tarefa 78: `nat.py off --delete`. +- [ ] **10.** Validar que os jobs acessam os buckets sem internet pública. + - ↳ Adiado para quando o primeiro job existir (depois da tarefa 11). + +## F1 — Ambiente e artefatos + +- [ ] **11.** Criar uma imagem de contêiner base com Python, CUDA, PyTorch, Transformers, Hugging Face Hub e ferramentas de medição. + - ↳ Incluir um exportador OpenTelemetry, para que a telemetria do harness tenha o mesmo formato da observabilidade do orquestrador. +- [ ] **12.** Fixar num manifesto as versões do sistema, dos drivers CUDA, do Python, das bibliotecas e da imagem de contêiner. +- [ ] **13.** Baixar os pesos, tokenizadores, revisões e licenças aprovados de cada candidato. *(Q4)* + - ↳ A aquisição falha se a licença estiver fora da allowlist de `config/benchmark.yaml`. O contexto efetivo de cada candidato é medido e registrado. + - ↳ Incluir as dependências específicas de cada candidato (pacote GLiNER, runtime SemIf, interface Rizzo Flow) e a revisão fixada de `Qwen/Qwen3.5-4B`. +- [ ] **14.** Calcular o SHA-256 de cada artefato baixado. +- [ ] **15.** Copiar os pesos e dependências aprovados para o bucket `artefatos`. +- [ ] **16.** Registrar no manifesto a origem, a revisão, a licença, o hash, a data de aquisição e o tamanho de cada artefato. +- [ ] **17.** Desabilitar a saída pública dos jobs de benchmark. +- [ ] **18.** Carregar cada candidato só a partir dos artefatos locais. + - ↳ Usar `HF_HUB_OFFLINE=1`, `TRANSFORMERS_OFFLINE=1` e um cache apontado para os artefatos verificados. +- [ ] **19.** Fazer o job falhar se algum candidato tentar baixar arquivos durante a inferência. + - ↳ Duas camadas: as variáveis de ambiente offline e o bloqueio de rede da VCN. Um teste negativo prova que a falha ocorre. + +## F2 — Tarefas de decisão e taxonomia + +- [ ] **20.** Definir a taxonomia de decisões a avaliar em PT-BR. + - ↳ Taxonomia de **intenções** (D1), ligadas aos **agentes de destino** do catálogo A2A, mais as decisões de **continuidade de sessão** (D2), **desambiguação** (D3) e **várias intenções** (D4). D5 é opcional. Ver [02 §2](02-desenho-experimental.md#2-tarefas-de-decisão). +- [ ] **21.** Escrever a pergunta, as opções, as descrições das opções e a regra de decisão de cada tarefa. + - ↳ As descrições das opções seguem o estilo de um *agent card*: curtas e orientadas à capacidade do agente. +- [ ] **22.** Limitar o primeiro ciclo a no máximo 20 opções por questão, para garantir compatibilidade com o Laya. *(Q2)* + - ↳ Os subconjuntos de opções por exemplo simulam o serviço de elegibilidade de intenções. +- [ ] **23.** Incluir uma opção explícita de `outro` ou `sem correspondência` quando a taxonomia não for exaustiva. + +## F3 — Dados e anotação + +- [ ] **24.** Coletar textos PT-BR representativos do domínio de destino. *(Q7)* + - ↳ A unidade é a **conversa com vários turnos**, não o enunciado isolado. Cada turno guarda o estado (agente ativo e sessões suspensas). +- [ ] **25.** Remover ou mascarar dados pessoais, segredos e identificadores que o benchmark não precisa. +- [ ] **26.** Separar os textos por domínio, comprimento, grau de ambiguidade e classe esperada. + - ↳ Acrescentar a posição do turno e a presença de agente ativo ou de sessões suspensas. +- [ ] **27.** Criar exemplos adversariais com abreviações, erros de ortografia, regionalismos, textos curtos e várias intenções. + - ↳ Acrescentar respostas curtas que dependem do contexto ("sim", "o segundo"), troca de assunto, retorno a um assunto anterior e pedidos fora de escopo. +- [ ] **28.** Definir os critérios de inclusão e exclusão dos exemplos. +- [ ] **29.** Produzir o guia de anotação humana. + - ↳ O guia cobre D1–D4 com exemplos-limite (por exemplo, quando um enunciado curto continua a tarefa do agente ativo e quando inicia um novo agente). +- [ ] **30.** Anotar cada exemplo com dois avaliadores independentes. +- [ ] **31.** Resolver as discordâncias com a revisão de um terceiro avaliador. +- [ ] **32.** Registrar, para cada exemplo, a resposta de referência, a justificativa e a versão da taxonomia. + - ↳ Reportar também a concordância entre anotadores. +- [ ] **33.** Separar os dados em desenvolvimento, calibração e teste final por grupo de origem. *(Q12)* + - ↳ O grupo é `group_id`, ou seja, a conversa. +- [ ] **34.** Impedir que textos derivados do mesmo caso apareçam em mais de uma partição. +- [ ] **35.** Congelar o conjunto de teste final antes de qualquer ajuste de parâmetros. + - ↳ Registrar o hash do arquivo congelado e guardar uma cópia em `logs-imutaveis`. + +## F4 — Contrato JEV e adaptadores + +- [ ] **36.** Criar um formato canônico de entrada com `id`, `state`, `question`, `options`, `gold_label` e metadados. + - ↳ Ver [02 §3](02-desenho-experimental.md#3-formato-canônico-de-entrada-tarefa-36), que inclui a **regra única de serialização** de `state`. +- [ ] **37.** Definir a versão do contrato JEV compatível a usar no benchmark: `POST /v1/systemone`, esquema de requisição, esquema de resposta e códigos de erro. *(Q1)* + - ↳ Versão `jev-compat-v1`. Os esquemas já estão em `contracts/jev/schemas/`; falta revisar os códigos de erro no código dos adaptadores. +- [ ] **38.** Definir o mapeamento obrigatório entre o contrato JEV e os tipos internos `choice`, `noul` e `score`. +- [ ] **39.** Definir a regra de conversão de probabilidades, confiança, níveis ordinais, opções descritas e campos de uso. +- [ ] **40.** Definir o comportamento padronizado para entradas inválidas, limite de contexto, timeout, opção desconhecida e indisponibilidade do modelo. + - ↳ Na prática, uma falha do modelo de decisão vira desambiguação ou fallback no orquestrador. No benchmark, essas falhas contam como erro e são reportadas à parte. +- [ ] **41.** Criar testes de conformidade com requisições e respostas JEV congeladas. +- [ ] **42.** Criar um adaptador JEV local para o Laya com o checkpoint multilíngue fixado. +- [ ] **43.** Criar um adaptador JEV local para o SemIf em modo `direct`, com a revisão do Qwen fixada. +- [ ] **44.** Criar um adaptador JEV local para o GLiNER Decide usando `classify_text`. +- [ ] **45.** Criar um adaptador JEV local para o Rizzo Flow usando sua interface de decisões. *(Q4)* +- [ ] **46.** Expor cada adaptador no mesmo caminho local `POST /v1/systemone`, sem dependência de rede externa. +- [ ] **47.** Garantir que cada adaptador devolva a resposta JEV completa, incluindo escolha, distribuição de probabilidades, confiança e metadados de uso, quando disponíveis. +- [ ] **48.** Normalizar as saídas adicionais de execução num registro interno com `latency_ms`, `model_id`, `run_id` e versão do adaptador. + - ↳ O registro é emitido como span OpenTelemetry e também gravado em arquivo de resultados. +- [ ] **49.** Validar que os adaptadores preservam a mesma ordem e a mesma descrição das opções. +- [ ] **50.** Validar que trocar de candidato não altera a requisição JEV nem o formato da resposta JEV. +- [ ] **51.** Implementar testes de sanidade com decisões triviais, opções permutadas e os três tipos de pergunta. + - ↳ Incluir um teste de invariância à permutação das opções em D1 e D2 (a mesma escolha com outra ordem). + +## F5 — Controle de execução + +- [ ] **52.** Fixar seeds, ordem dos exemplos, tamanho de lote, precisão numérica e limites de tokens de cada execução. +- [ ] **53.** Definir uma política de truncamento comum para textos maiores que o menor contexto efetivamente suportado. *(Q8)* + - ↳ Truncar primeiro os turnos mais antigos do `state`. O enunciado atual nunca é truncado. +- [ ] **54.** Registrar o número de tokens de cada entrada antes de executar os modelos. + +## F6 — Piloto + +- [ ] **55.** Executar um piloto de 50 exemplos por candidato em CPU. *(Q11)* +- [ ] **56.** Executar um piloto de 50 exemplos por candidato em GPU. +- [ ] **57.** Medir memória de GPU, memória RAM, tempo de carga, tempo de aquecimento e falhas do piloto. +- [ ] **58.** Ajustar apenas os parâmetros operacionais necessários para eliminar falhas de execução. + +## F7 — Desenvolvimento e calibração + +- [ ] **59.** Executar os candidatos no conjunto de desenvolvimento, sem calibração adicional. +- [ ] **60.** Escolher os limites de confiança e as regras de abstenção só com a partição de calibração. + - ↳ Abster-se significa encaminhar para desambiguação. A regra é escolhida por meta de cobertura ou de risco, definida antes. *(Q5)* +- [ ] **61.** Calibrar as probabilidades separadamente por candidato, quando aplicável. *(Q10)* +- [ ] **62.** Não alterar prompts, opções, pesos ou limiares depois da abertura do conjunto de teste final. + - ↳ Marco de congelamento: registrar os hashes da configuração, dos calibradores e dos adaptadores. + +## F8 — Teste final + +- [ ] **63.** Executar uma rodada de acurácia no teste final com lote 1. +- [ ] **64.** Executar uma rodada de acurácia no teste final com o lote de produção definido. *(Q6)* +- [ ] **65.** Executar uma rodada de várias decisões sobre o mesmo texto, quando esse padrão existir no caso de uso. + - ↳ Corresponde a D4 e a combinações por turno (por exemplo, D2 + D1). Medir a latência somada por turno. +- [ ] **66.** Repetir cada medição de desempenho depois do aquecimento, em pelo menos três execuções independentes. + +## F9 — Métricas e análise + +- [ ] **67.** Coletar accuracy, macro F1, precision, recall e matriz de confusão. + - ↳ Acrescentar a taxa de rota errada com confiança alta. +- [ ] **68.** Coletar Brier score, ECE, cobertura e acurácia seletiva quando houver probabilidades ou abstenção. +- [ ] **69.** Coletar latência p50, p95 e p99, vazão, uso de GPU, uso de RAM e consumo de armazenamento. + - ↳ Reportar a latência por decisão e por turno. +- [ ] **70.** Calcular o custo estimado por mil decisões em cada cenário de execução. +- [ ] **71.** Criar cortes dos resultados por domínio, número de opções, comprimento e dificuldade do texto. + - ↳ Acrescentar os cortes por tarefa de decisão, posição do turno, com ou sem agente ativo e categoria adversarial. +- [ ] **72.** Inspecionar manualmente os erros de alto impacto e as discordâncias entre os modelos. +- [ ] **73.** Classificar os erros em: taxonomia, contexto insuficiente, ambiguidade, linguagem, truncamento, instrução ou desempenho do modelo. +- [ ] **74.** Gerar tabelas comparativas e gráficos a partir dos arquivos de resultados versionados. *(Q9)* + - ↳ Incluir intervalos de confiança (bootstrap) e testes pareados entre candidatos. + +## F10 — Relatório e encerramento + +- [ ] **75.** Elaborar o relatório técnico com configuração, limitações, resultados e recomendação. +- [ ] **76.** Anexar ao relatório os manifestos de artefatos, hashes, configurações, logs, testes de conformidade e comandos de reprodução. +- [ ] **77.** Arquivar os conjuntos congelados e os resultados no Object Storage com retenção definida. +- [ ] **78.** Desligar notebooks, jobs, endpoints temporários e o NAT Gateway que não sejam mais necessários. diff --git a/docs/04-questoes-em-aberto.md b/docs/04-questoes-em-aberto.md new file mode 100644 index 0000000..73a597b --- /dev/null +++ b/docs/04-questoes-em-aberto.md @@ -0,0 +1,167 @@ +# 04 — Decisões de projeto (ex-questões em aberto) + +Todas as questões foram resolvidas em 2026-10-07. O critério foi preferir a **abordagem automatizada e verificável**: regras determinísticas em código, gates que fazem o pipeline falhar, valores calculados a partir de medições e parâmetros pré-registrados em [config/benchmark.yaml](../config/benchmark.yaml). Ninguém precisa decidir nada caso a caso durante a execução. Os valores numéricos pré-registrados ficam congelados com hash no marco da tarefa 62. + +## Resumo + +| # | Questão | Decisão | Mecanismo automatizado | +|---|---------|---------|------------------------| +| Q1 | Contrato JEV | Esquema público do `POST /v1/systemone` (`state` + `questions` tipadas → `answers` + `usage`), congelado como `jev-compat-v1` | JSON Schema em `contracts/jev/schemas/` e testes de conformidade | +| Q2 | 20 opções incluem o fallback? | Sim: no máximo 19 opções de domínio + `sem_correspondencia` | Validação de esquema (`maxProperties: 20`) e lint do dataset | +| Q3 | NAT × isolamento | Desassociar a rota NAT logo após a tarefa 16 | Gate de egresso: o pipeline só segue se a sonda de saída **falhar** | +| Q4 | Interfaces e licenças | Interfaces mapeadas por candidato (abaixo). Só licenças da allowlist | O script de aquisição lê a licença nos metadados do Hub e falha fora da allowlist | +| Q5 | Critérios de sucesso | SLO de latência fixo + não inferioridade relativa ao baseline | Avaliação automática dos critérios em `config/benchmark.yaml` | +| Q6 | Lote de produção | Escolhido por candidato: o maior lote com p95 dentro do SLO | Varredura de lotes no piloto | +| Q7 | Fonte dos dados | Reais anonimizados + sintéticos, com origem marcada | Geração sintética por template e LLM, anonimização automática, deduplicação | +| Q8 | Turnos no `state` | O maior N ∈ {0,1,2,4} que cabe no orçamento comum de tokens | Cálculo sobre a partição de desenvolvimento | +| Q9 | Baseline LLM | Incluído, fora do ranking principal | Adaptador JEV sobre o LLM Gateway | +| Q10 | Calibração | Temperature scaling por candidato × tarefa, só na partição de calibração | Ajuste por NLL e congelamento com hash | +| Q11 | Piloto em CPU | Mantido, com timeout. Inviabilidade declarada automaticamente | Regra p95 > timeout → "inviável em CPU" | +| Q12 | Tamanho do teste | ≥ 1.300 decisões D1 no teste final (análise de poder) | `analysis/sample_size.py`, com recálculo do MDE | +| Q13 | Centro de custo e orçamento | **Orçamento em aberto**: o uso está coberto por cota interna de engenharia. A estimativa de custo continua no relatório | A estimativa é calculada por script (tarefa 70) | + +--- + +## Q1 — Contrato JEV `jev-compat-v1` + +Base: a forma pública do `POST /v1/systemone`, a mesma reproduzida pelas implementações abertas (OpenJev/SemIf). + +**Requisição** + +```json +{ + "model": "", + "state": " | ", + "questions": { + "": {"type": "choice", "instructions": "...", "criteria": {"": ""}}, + "": {"type": "noul", "instructions": "...", "criteria": {"true": "...", "false": "..."}}, + "": {"type": "score", "instructions": "...", "criteria": ["", "", "..."]} + } +} +``` + +**Resposta** + +```json +{ + "model": "", + "answers": { + "": {"type": "choice", "choice": "", "confidence": 0.0, "probabilities": {"": 0.0}}, + "": {"type": "noul", "noul": 0.0}, + "": {"type": "score", "score": 0.0, "confidence": 0.0, + "probabilities": {"0": 0.0}, "legend": {"0": ""}} + }, + "usage": {"input_tokens": 0, "output_tokens": 0} +} +``` + +**Erros:** `422` para requisição malformada, modelo desconhecido ou estado acima do contexto. `401` para autenticação, que não se aplica aos adaptadores locais. Comportamentos padronizados do benchmark (tarefa 40): + +| Situação | Código | Contabilização | +|----------|--------|----------------| +| Requisição inválida | `422` | Erro do harness: falha o job | +| Estado acima do contexto, depois do truncamento comum | `422` | Erro de decisão (`error_type=context`) | +| Timeout (`timeout_ms` do cenário) | `504` | Erro de decisão (`error_type=timeout`) | +| Opção devolvida fora de `criteria` | `502` | Erro de decisão (`error_type=unknown_option`) | +| Modelo indisponível | `503` | Erro de execução: refaz até 3 vezes e depois falha o job | + +**Mapeamento para os tipos internos (tarefas 38–39)** + +- `choice` → D1, D2 e D5. A predição é `choice`; a distribuição é `probabilities`. +- `noul` → D3 e as perguntas por intenção de D4. P(sim) = `noul`. +- `score` → reservado. Não é usado no primeiro ciclo. +- **`confidence` não é calibrada** em nenhuma implementação. Por isso, todas as métricas de calibração e abstenção usam `max(probabilities)` (ou `max(noul, 1−noul)`) **depois** da calibração da tarefa 61. A `confidence` nativa é só registrada. +- Quando um candidato não expõe uma distribuição normalizada, o adaptador aplica softmax sobre os escores das opções e marca `metadata.probabilities_source = "normalized_scores"`. +- `output_tokens` deve ser sempre 0, porque não há geração. Um valor diferente de 0 reprova o teste de conformidade. + +## Q2 — Limite de opções + +O limite de 20 opções inclui `sem_correspondencia`: no máximo 19 opções de domínio + 1. O dataset é validado por esquema. Um exemplo com mais de 20 opções reprova o lint e impede o congelamento (tarefa 35). + +## Q3 — NAT e isolamento de rede + +Sequência automatizada, em que cada passo falha o pipeline se não for cumprido: + +1. **9 → 16:** a rota NAT existe só durante a aquisição. +2. **Fim da tarefa 16:** `infra/oci/nat.py off` remove a rota e o egresso `0.0.0.0/0` e bloqueia o NAT. O NAT só é criado ou ativado com `nat.py on`, quando for necessário. +3. **Gate de egresso (tarefas 17 e 19):** um job de sonda tenta resolver e abrir conexão com hosts públicos (Hugging Face Hub, PyPI). O gate passa só se **todas** as tentativas falharem e se o acesso ao Object Storage pelo Service Gateway funcionar. +4. **Toda execução de benchmark** roda o gate antes da inferência e grava o resultado no manifesto da execução. +5. **Tarefa 78:** o NAT Gateway é destruído, e o gate confirma que nenhum recurso do compartment tem rota de saída. + +## Q4 — Interfaces e licenças dos candidatos + +| Candidato | Licença | Interface usada pelo adaptador | Contexto declarado | Observação | +|-----------|---------|--------------------------------|--------------------|------------| +| Laya multilingual | Apache-2.0 | `laya.load(...).predict(state, questions)`, nativamente no formato JEV | 1.024 tokens (default), até 8.192 | Recomenda até ~20 opções. Vem descalibrado: a calibração da tarefa 61 é obrigatória | +| SemIf + Qwen3.5-4B | MIT (SemIf); Qwen: verificar no Hub | Modo `direct`: um forward pass lê os logits das opções declaradas | ~8k tokens | Revisão do Qwen fixada no manifesto | +| GLiNER2.5-multi-Decide | Apache-2.0 | `classify_text(text, {"": {"labels": {opcao: descrição}}})` | Não declarado: medir | Não tem `noul`/`score` nativos. `noul` vira o rótulo binário `{true,false}`. As probabilidades são normalizadas a partir dos escores | +| Rizzo Flow 4B | Apache-2.0 (base); dados de treino com componentes CC-BY-SA/CC-BY | Prompt `spark-decisions-v3`, com leitura dos logits das letras de resposta | 2.048 tokens | **O formato de prompt é obrigatório**: o adaptador monta `spark-decisions-v3` a partir da serialização comum. Documentação só em inglês: o risco de idioma vai para o relatório | + +Regras automatizadas: +- A **allowlist de licenças** (`Apache-2.0`, `MIT`) fica em `config/benchmark.yaml`. O script da tarefa 13 lê `cardData.license` de cada repositório no Hub. Licença ausente ou fora da lista falha a aquisição, até haver aprovação explícita registrada no manifesto. +- O **contexto efetivo** de cada candidato é medido por código: o mínimo entre `tokenizer.model_max_length`, `config.max_position_embeddings` e o limite declarado no adaptador. O resultado vai para o manifesto. Esse valor alimenta Q8. + +## Q5 — Critérios de sucesso pré-registrados + +Avaliados automaticamente pelo `analysis/` a partir de `config/benchmark.yaml`: + +- **Desempenho (H3):** em GPU com lote 1, p95 ≤ **150 ms por decisão** e ≤ **300 ms por turno**. Em CPU os valores são só reportados. +- **Acurácia (H1):** em D1 e D2, **não inferioridade em relação ao baseline LLM** com margem de **3 p.p.**: o limite inferior do IC 95% (bootstrap pareado por conversa) da diferença precisa ser > −3 p.p. Sem baseline disponível, vale o critério absoluto: macro F1 ≥ 0,80. +- **Risco (H2):** no limiar escolhido na calibração, a taxa de **rota errada com confiança alta** precisa ser ≤ **2%**, com cobertura ≥ **80%**. + +O critério relativo ao baseline dispensa um limiar absoluto arbitrário de acurácia, que dependeria da dificuldade do dataset. + +## Q6 — Lote de produção + +O piloto em GPU (tarefa 56) varre os lotes {1, 2, 4, 8, 16, 32}. O lote de produção de cada candidato é **o maior lote com p95 por decisão ≤ SLO**. O valor escolhido entra no manifesto e é congelado na tarefa 62. Lote 1 continua sendo o cenário principal. O lote de produção serve para reportar vazão e custo. + +## Q7 — Fonte e preparação dos dados + +- **Reais:** enunciados de conversas anonimizadas, se houver. **Sintéticos:** gerados a partir da taxonomia e dos templates adversariais (tarefa 27), com um LLM via gateway que **não** seja nenhum dos candidatos nem o baseline. +- **Anonimização automática (tarefa 25):** regras para CPF, CNPJ, telefone, e-mail, CEP, números de cartão e protocolo, além de NER de pessoas e lugares, substituindo os achados por marcadores tipados (``, ``). Uma amostra aleatória de 5% passa por verificação humana. Qualquer vazamento encontrado obriga a reprocessar o lote inteiro. +- **Deduplicação:** MinHash/LSH para quase-duplicatas, dentro de cada fonte e entre fontes. Antes da partição, duplicatas recebem o mesmo `group_id`. +- O campo `metadata.source` ∈ {`real`, `synthetic`} existe em todos os exemplos. As métricas saem por fonte e no agregado. +- A anotação humana (tarefas 30–31) se mantém para todos os exemplos, inclusive os sintéticos. O rótulo de geração serve só como sugestão e é escondido dos anotadores. + +## Q8 — Turnos no `state` e orçamento de tokens + +1. **Orçamento comum** = o menor contexto efetivo medido entre os candidatos (Q4) − a reserva para a pergunta e as opções do maior exemplo. +2. **N** = o maior valor em {0, 1, 2, 4} em que ≥ 95% dos exemplos de desenvolvimento cabem no orçamento sem truncar o enunciado atual. A contagem usa o tokenizador **mais verboso** entre os candidatos. +3. A regra de truncamento (tarefa 53) remove primeiro os turnos mais antigos e nunca o enunciado atual. Se o enunciado sozinho estoura o orçamento, ele é truncado no fim e o exemplo recebe `truncated=true`. +4. Uma ablação com N ∈ {0, 1, 2, 4} roda **só no desenvolvimento** e serve só para o relatório. Ela não altera a configuração congelada. + +## Q9 — Baseline LLM + +- Um LLM generalista disponível via LLM Gateway ou serviço gerenciado na OCI, com a versão fixada no manifesto. +- Adaptador JEV próprio com saída restrita às opções. Quando houver logprobs, as probabilidades vêm deles. Quando não houver, a distribuição é one-hot com `probabilities_source="none"`, e o baseline fica fora das métricas de calibração. +- Tem a mesma serialização, o mesmo truncamento e os mesmos dados. Fica **fora do ranking principal** e serve de referência para H1 e para o custo. +- Por usar rede, é a **única exceção** ao isolamento. Roda num job separado, com egresso só para o endpoint do gateway, sem acesso aos artefatos dos candidatos. + +## Q10 — Calibração + +- **Temperature scaling** por candidato × tarefa de decisão: um único parâmetro, ajustado por minimização de NLL na partição de calibração. Para `noul`, o mesmo método é aplicado ao logit de P(sim). +- O calibrador só é aceito se reduzir o ECE na própria partição de calibração (validação cruzada em 5 dobras por `group_id`). Se não reduzir, fica a identidade (T = 1). +- Em seguida, os limiares de abstenção (tarefa 60) são escolhidos sobre as probabilidades calibradas, pelo critério de Q5: maximizar a cobertura com taxa de rota errada com confiança alta ≤ 2%. +- Os parâmetros T e os limiares são versionados e congelados com hash na tarefa 62. + +## Q11 — Piloto em CPU + +- Timeout de **10 s por decisão** em CPU. +- Regra automática: candidato com p95 > timeout ou com taxa de timeout > 5% no piloto recebe o status `inviavel_cpu` no manifesto e é excluído das rodadas finais em CPU. Os resultados em CPU dos demais são reportados como viabilidade, não como ranking. + +## Q12 — Tamanho do conjunto de teste final + +Análise de poder para a comparação pareada (teste de McNemar) entre dois candidatos em D1: + +- Diferença mínima detectável d = 3 p.p., α = 0,05 (bilateral), poder = 0,80, proporção de discordância esperada p_d = 0,15: + n ≈ (z₀.₉₇₅·√p_d + z₀.₈·√(p_d − d²))² / d² ≈ **1.300 decisões**. +- Correção de agrupamento: n_final = n × (1 + (m − 1)·ICC), em que m é o número médio de turnos por conversa e o ICC é estimado na partição de desenvolvimento. Antes de medir, usar ICC = 0,05. +- `analysis/sample_size.py` (a implementar) recalcula n com os valores observados. Se o teste congelado ficar abaixo do n necessário, o relatório troca a afirmação de significância pelo **MDE efetivo** do conjunto disponível. +- D2–D4 usam o que estiver disponível, com IC por bootstrap por conversa. + +## Q13 — Centro de custo e orçamento + +> **Revisão (2026-10-07):** o orçamento e os alertas (tarefas 3–4) ficam **em aberto**, porque o uso está coberto por cota interna de engenharia. A tag `centro_custo` não é aplicada por enquanto. O texto abaixo vale só se for necessário criar um orçamento depois. + +- `centro_custo` é variável Terraform **obrigatória e sem default**. O `terraform plan` falha enquanto ela não for informada, o que impede criar recursos sem a tag. +- O valor do orçamento é calculado pelo script de estimativa, que multiplica as horas previstas por *shape* (piloto, dev, calibração, teste e repetições) pelo preço público da OCI e aplica um fator de contingência de 1,3. O resultado vai para `infra/oci/` como `budget_amount`. Os alertas de 50%, 80% e 100% (tarefa 4) são derivados desse valor. diff --git a/env/.gitkeep b/env/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/harness/.gitkeep b/harness/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/infra/oci/nat.py b/infra/oci/nat.py new file mode 100644 index 0000000..a3fecab --- /dev/null +++ b/infra/oci/nat.py @@ -0,0 +1,93 @@ +"""Liga e desliga a saída temporária por NAT Gateway (tarefas 9, 17 e 78; decisão Q3). + +Uso: + PYTHONPATH=infra/oci .venv/bin/python infra/oci/nat.py on # cria/desbloqueia o NAT e abre a rota + PYTHONPATH=infra/oci .venv/bin/python infra/oci/nat.py off # fecha a rota e bloqueia o NAT + PYTHONPATH=infra/oci .venv/bin/python infra/oci/nat.py off --delete # fecha a rota e apaga o NAT (tarefa 78) + +O NAT só deve ficar ativo durante a aquisição de dependências e pesos. +Ao final, regrava .secrets/artefatos-OCI.md. +""" +import sys + +import oci + +import provision +from session import load_config + +NAT_NAME = "dmb-nat-temporario" +ANYWHERE = "0.0.0.0/0" + + +def lookup(vn, cid): + vcn = next(v for v in vn.list_vcns(cid, display_name="dmb-vcn").data if v.lifecycle_state == "AVAILABLE") + rt = next(r for r in vn.list_route_tables(cid, vcn_id=vcn.id, display_name="dmb-rt-privada").data + if r.lifecycle_state == "AVAILABLE") + sl = next(s for s in vn.list_security_lists(cid, vcn_id=vcn.id, display_name="dmb-sl-privada").data + if s.lifecycle_state == "AVAILABLE") + nat = next((n for n in vn.list_nat_gateways(cid, vcn_id=vcn.id, display_name=NAT_NAME).data + if n.lifecycle_state == "AVAILABLE"), None) + return vcn, rt, sl, nat + + +def nat_on(vn, cid): + vcn, rt, sl, nat = lookup(vn, cid) + if nat is None: + nat = vn.create_nat_gateway(oci.core.models.CreateNatGatewayDetails( + compartment_id=cid, vcn_id=vcn.id, display_name=NAT_NAME, + freeform_tags=provision.TAGS)).data + nat = oci.wait_until(vn, vn.get_nat_gateway(nat.id), "lifecycle_state", "AVAILABLE").data + elif nat.block_traffic: + vn.update_nat_gateway(nat.id, oci.core.models.UpdateNatGatewayDetails(block_traffic=False)) + + if not any(r.destination == ANYWHERE for r in rt.route_rules): + vn.update_route_table(rt.id, oci.core.models.UpdateRouteTableDetails(route_rules=rt.route_rules + [ + oci.core.models.RouteRule(destination=ANYWHERE, destination_type="CIDR_BLOCK", + network_entity_id=nat.id, description="TEMPORÁRIO: aquisição de artefatos")])) + if not any(r.destination == ANYWHERE for r in sl.egress_security_rules): + vn.update_security_list(sl.id, oci.core.models.UpdateSecurityListDetails( + ingress_security_rules=sl.ingress_security_rules, + egress_security_rules=sl.egress_security_rules + [oci.core.models.EgressSecurityRule( + destination=ANYWHERE, protocol="6", description="TEMPORÁRIO: HTTPS para aquisição", + tcp_options=oci.core.models.TcpOptions( + destination_port_range=oci.core.models.PortRange(min=443, max=443)))])) + print(f"NAT ativo: {nat.id}") + + +def nat_off(vn, cid, delete): + _, rt, sl, nat = lookup(vn, cid) + rules = [r for r in rt.route_rules if r.destination != ANYWHERE] + if len(rules) != len(rt.route_rules): + vn.update_route_table(rt.id, oci.core.models.UpdateRouteTableDetails(route_rules=rules)) + egress = [r for r in sl.egress_security_rules if r.destination != ANYWHERE] + if len(egress) != len(sl.egress_security_rules): + vn.update_security_list(sl.id, oci.core.models.UpdateSecurityListDetails( + ingress_security_rules=sl.ingress_security_rules, egress_security_rules=egress)) + if nat is None: + print("NAT inexistente; rota e egresso fechados") + return + if delete: + vn.delete_nat_gateway(nat.id) + print(f"NAT apagado: {nat.id}") + else: + vn.update_nat_gateway(nat.id, oci.core.models.UpdateNatGatewayDetails(block_traffic=True)) + print(f"NAT bloqueado: {nat.id}") + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] not in ("on", "off"): + sys.exit(__doc__) + cfg = load_config() + home = load_config(region="us-ashburn-1") + cid = provision.find_compartment(oci.identity.IdentityClient(home), home["tenancy"]) + vn = oci.core.VirtualNetworkClient(cfg) + if sys.argv[1] == "on": + nat_on(vn, cid) + else: + nat_off(vn, cid, delete="--delete" in sys.argv) + provision.artifacts.clear() + provision.main() + + +if __name__ == "__main__": + main() diff --git a/infra/oci/provision.py b/infra/oci/provision.py new file mode 100644 index 0000000..59c6ced --- /dev/null +++ b/infra/oci/provision.py @@ -0,0 +1,231 @@ +"""Provisionamento idempotente da fase F0 (tarefas 1, 2, 5, 6, 7, 8). + +Uso: PYTHONPATH=infra/oci .venv/bin/python infra/oci/provision.py +Registra os artefatos criados em .secrets/artefatos-OCI.md. +""" +import time +from datetime import datetime, timezone + +import oci + +from session import CRED_DIR, REGION, load_config + +PARENT_PATH = "root/" +COMPARTMENT_NAME = "decision-models" +TAGS = {"projeto": "benchmark-decisoes-ptbr", "ambiente": "benchmark"} # centro_custo pendente (Q13) +BUCKETS = ["entrada", "artefatos", "resultados", "logs-imutaveis"] +VCN_CIDR = "10.20.0.0/16" +SUBNET_CIDR = "10.20.1.0/24" +ARTIFACTS_FILE = CRED_DIR.parent / "artefatos-OCI.md" + +artifacts = [] + + +def record(kind, name, ocid, region): + artifacts.append((kind, name, ocid, region)) + + +def find_compartment(idn, tenancy): + allc = oci.pagination.list_call_get_all_results( + idn.list_compartments, tenancy, compartment_id_in_subtree=True, + access_level="ACCESSIBLE", lifecycle_state="ACTIVE").data + byid = {c.id: c for c in allc} + + def path(c): + parts = [] + while c: + parts.append(c.name) + c = byid.get(c.compartment_id) + return "root/" + "/".join(reversed(parts)) + + parent = next(c for c in allc if path(c) == PARENT_PATH) + comp = next((c for c in allc if c.compartment_id == parent.id and c.name == COMPARTMENT_NAME), None) + if comp is None: + comp = idn.create_compartment(oci.identity.models.CreateCompartmentDetails( + compartment_id=parent.id, name=COMPARTMENT_NAME, freeform_tags=TAGS, + description="Benchmark de modelos de decisão PT-BR para roteamento em orquestrador multiagente")).data + for _ in range(30): + try: + if idn.get_compartment(comp.id).data.lifecycle_state == "ACTIVE": + break + except oci.exceptions.ServiceError as e: + if e.status != 404: + raise + time.sleep(10) + record("Compartment", f"{PARENT_PATH}/{COMPARTMENT_NAME}", comp.id, "global (home us-ashburn-1)") + return comp.id + + +def ensure_ds_project(cfg, cid): + ds = oci.data_science.DataScienceClient(cfg) + name = "benchmark-decisoes-ptbr" + proj = next((p for p in ds.list_projects(cid, lifecycle_state="ACTIVE").data if p.display_name == name), None) + if proj is None: + proj = ds.create_project(oci.data_science.models.CreateProjectDetails( + compartment_id=cid, display_name=name, freeform_tags=TAGS, + description="Benchmark de modelos de decisão PT-BR")).data + record("Data Science Project", name, proj.id, REGION) + + +def ensure_buckets(cfg, cid): + os_ = oci.object_storage.ObjectStorageClient(cfg) + ns = os_.get_namespace().data + existing = {b.name for b in oci.pagination.list_call_get_all_results(os_.list_buckets, ns, cid).data} + for short in BUCKETS: + name = f"dmb-{short}" + if name not in existing: + os_.create_bucket(ns, oci.object_storage.models.CreateBucketDetails( + name=name, compartment_id=cid, public_access_type="NoPublicAccess", + storage_tier="Standard", versioning="Enabled", freeform_tags=TAGS)) + b = os_.get_bucket(ns, name).data + record("Object Storage Bucket", f"{name} (namespace {ns})", b.id, REGION) + + +def ensure_network(cfg, cid): + vn = oci.core.VirtualNetworkClient(cfg) + comp = oci.wait_until # alias curto + + vcn = next((v for v in vn.list_vcns(cid, display_name="dmb-vcn").data + if v.lifecycle_state == "AVAILABLE"), None) + if vcn is None: + vcn = vn.create_vcn(oci.core.models.CreateVcnDetails( + compartment_id=cid, display_name="dmb-vcn", cidr_blocks=[VCN_CIDR], + dns_label="dmbvcn", freeform_tags=TAGS)).data + vcn = comp(vn, vn.get_vcn(vcn.id), "lifecycle_state", "AVAILABLE").data + record("VCN", f"dmb-vcn ({VCN_CIDR})", vcn.id, REGION) + + svc = next(s for s in vn.list_services().data if s.cidr_block.startswith("all-") and "services-in-oracle-services-network" in s.cidr_block) + sgw = next((g for g in vn.list_service_gateways(cid, vcn_id=vcn.id).data + if g.lifecycle_state == "AVAILABLE"), None) + if sgw is None: + sgw = vn.create_service_gateway(oci.core.models.CreateServiceGatewayDetails( + compartment_id=cid, vcn_id=vcn.id, display_name="dmb-sgw", freeform_tags=TAGS, + services=[oci.core.models.ServiceIdRequestDetails(service_id=svc.id)])).data + sgw = comp(vn, vn.get_service_gateway(sgw.id), "lifecycle_state", "AVAILABLE").data + record("Service Gateway", f"dmb-sgw ({svc.cidr_block})", sgw.id, REGION) + + rt = next((r for r in vn.list_route_tables(cid, vcn_id=vcn.id, display_name="dmb-rt-privada").data + if r.lifecycle_state == "AVAILABLE"), None) + if rt is None: + rt = vn.create_route_table(oci.core.models.CreateRouteTableDetails( + compartment_id=cid, vcn_id=vcn.id, display_name="dmb-rt-privada", freeform_tags=TAGS, + route_rules=[oci.core.models.RouteRule( + destination=svc.cidr_block, destination_type="SERVICE_CIDR_BLOCK", + network_entity_id=sgw.id, description="Object Storage e serviços OCI via Service Gateway")])).data + rt = comp(vn, vn.get_route_table(rt.id), "lifecycle_state", "AVAILABLE").data + record("Route Table", "dmb-rt-privada (somente Service Gateway)", rt.id, REGION) + + sl = next((s for s in vn.list_security_lists(cid, vcn_id=vcn.id, display_name="dmb-sl-privada").data + if s.lifecycle_state == "AVAILABLE"), None) + if sl is None: + sl = vn.create_security_list(oci.core.models.CreateSecurityListDetails( + compartment_id=cid, vcn_id=vcn.id, display_name="dmb-sl-privada", freeform_tags=TAGS, + egress_security_rules=[ + oci.core.models.EgressSecurityRule( + destination=svc.cidr_block, destination_type="SERVICE_CIDR_BLOCK", protocol="6", + tcp_options=oci.core.models.TcpOptions(destination_port_range=oci.core.models.PortRange(min=443, max=443)), + description="HTTPS para serviços OCI"), + oci.core.models.EgressSecurityRule(destination=VCN_CIDR, protocol="all", description="Tráfego interno da VCN"), + ], + ingress_security_rules=[ + oci.core.models.IngressSecurityRule(source=VCN_CIDR, protocol="all", description="Tráfego interno da VCN"), + ])).data + sl = comp(vn, vn.get_security_list(sl.id), "lifecycle_state", "AVAILABLE").data + record("Security List", "dmb-sl-privada (egresso só para serviços OCI e VCN)", sl.id, REGION) + + sub = next((s for s in vn.list_subnets(cid, vcn_id=vcn.id, display_name="dmb-subnet-jobs").data + if s.lifecycle_state == "AVAILABLE"), None) + if sub is None: + sub = vn.create_subnet(oci.core.models.CreateSubnetDetails( + compartment_id=cid, vcn_id=vcn.id, display_name="dmb-subnet-jobs", cidr_block=SUBNET_CIDR, + dns_label="jobs", prohibit_public_ip_on_vnic=True, route_table_id=rt.id, + security_list_ids=[sl.id], freeform_tags=TAGS)).data + sub = comp(vn, vn.get_subnet(sub.id), "lifecycle_state", "AVAILABLE").data + record("Subnet (privada)", f"dmb-subnet-jobs ({SUBNET_CIDR})", sub.id, REGION) + + # O NAT é criado só por nat.py; aqui ele só é registrado, se existir. + for nat in vn.list_nat_gateways(cid, vcn_id=vcn.id).data: + if nat.lifecycle_state == "AVAILABLE": + status = "bloqueado" if nat.block_traffic else "ATIVO" + record("NAT Gateway (temporário)", f"{nat.display_name} ({status})", nat.id, REGION) + + +USER_FAMILIES = [ + "data-science-family", + "object-family", + "virtual-network-family", + "logging-family", + "repos", + "generative-ai-family", +] + + +def ensure_policy(idn, cid, name, description, statements): + pol = next((p for p in idn.list_policies(cid).data if p.name == name), None) + if pol is None: + pol = idn.create_policy(oci.identity.models.CreatePolicyDetails( + compartment_id=cid, name=name, description=description, + statements=statements, freeform_tags=TAGS)).data + elif sorted(pol.statements) != sorted(statements): + pol = idn.update_policy(pol.id, oci.identity.models.UpdatePolicyDetails( + description=description, statements=statements)).data + record("IAM Policy", f"{name} ({len(statements)} declarações)", pol.id, "global (home us-ashburn-1)") + + +def ensure_policies(idn, cid, user_id): + # Sem acesso de administrador à tenancy: nada de grupos nem dynamic groups. + # O usuário é identificado pelo OCID e os jobs pelo tipo de principal. + comp = f"compartment id {cid}" + user_stmts = [f"Allow any-user to manage {fam} in {comp} where request.user.id = '{user_id}'" + for fam in USER_FAMILIES] + ensure_policy(idn, cid, "dmb-usuario-manager", + "Usuário do benchmark como manager das famílias usadas", user_stmts) + + job = f"request.principal.type = 'datasciencejob', request.principal.compartment.id = '{cid}'" + read_buckets = "any {target.bucket.name = 'dmb-entrada', target.bucket.name = 'dmb-artefatos'}" + write_buckets = "any {target.bucket.name = 'dmb-resultados', target.bucket.name = 'dmb-logs-imutaveis'}" + job_stmts = [ + f"Allow any-user to read buckets in {comp} where all {{{job}}}", + f"Allow any-user to read objects in {comp} where all {{{job}, {read_buckets}}}", + f"Allow any-user to manage objects in {comp} where all {{{job}, {write_buckets}, " + "any {request.permission = 'OBJECT_CREATE', request.permission = 'OBJECT_INSPECT', request.permission = 'OBJECT_READ'}}", + f"Allow any-user to use log-content in {comp} where all {{{job}}}", + f"Allow any-user to read repos in {comp} where all {{{job}}}", + f"Allow service datascience to use virtual-network-family in {comp}", + ] + ensure_policy(idn, cid, "dmb-jobs-menor-privilegio", + "Jobs de benchmark: leitura de entrada/artefatos e escrita de resultados/logs (tarefa 7)", + job_stmts) + + +def write_artifacts(): + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + lines = [ + "# Artefatos OCI — benchmark de modelos de decisão", + "", + f"Atualizado em {now} por `infra/oci/provision.py`. Região dos recursos: `{REGION}`.", + f"Tags livres aplicadas: {', '.join(f'`{k}={v}`' for k, v in TAGS.items())} (`centro_custo` pendente, Q13).", + "", + "| Tipo | Nome | OCID | Região |", + "|------|------|------|--------|", + ] + lines += [f"| {k} | {n} | `{o}` | {r} |" for k, n, o, r in artifacts] + ARTIFACTS_FILE.write_text("\n".join(lines) + "\n") + + +def main(): + home = load_config(region="us-ashburn-1") + cfg = load_config() + idn = oci.identity.IdentityClient(home) + cid = find_compartment(idn, home["tenancy"]) + ensure_policies(idn, cid, home["user"]) + ensure_ds_project(cfg, cid) + ensure_buckets(cfg, cid) + ensure_network(cfg, cid) + write_artifacts() + for a in artifacts: + print(" | ".join(a)) + + +if __name__ == "__main__": + main() diff --git a/infra/oci/session.py b/infra/oci/session.py new file mode 100644 index 0000000..028ca78 --- /dev/null +++ b/infra/oci/session.py @@ -0,0 +1,25 @@ +"""Sessão OCI do benchmark a partir das credenciais em .secrets/oci-credentials/.""" +import configparser +from pathlib import Path + +import oci + +ROOT = Path(__file__).resolve().parents[2] +CRED_DIR = ROOT / ".secrets" / "oci-credentials" +REGION = "us-chicago-1" + + +def load_config(region: str = REGION, profile: str = "DEFAULT") -> dict: + parser = configparser.ConfigParser() + parser.read(CRED_DIR / "profile.config") + cfg = dict(parser[profile]) + key_file = cfg.get("key_file", "").split("#")[0].strip() + if not key_file or not Path(key_file).expanduser().is_file(): + keys = [p for p in CRED_DIR.glob("*.pem") if not p.name.endswith("_public.pem")] + if len(keys) != 1: + raise RuntimeError(f"esperada 1 chave privada em {CRED_DIR}, encontradas {len(keys)}") + key_file = str(keys[0]) + cfg["key_file"] = key_file + cfg["region"] = region + oci.config.validate_config(cfg) + return cfg diff --git a/reports/.gitkeep b/reports/.gitkeep new file mode 100644 index 0000000..e69de29