D-4 — Priorização e Ranking
Parte do Ciclo de Demandas — Fase 1
Propósito
Seção intitulada “Propósito”A D-4 aplica a fórmula de priorização sobre as demandas já categorizadas e com contexto territorial resolvido. Transforma dados em fila de trabalho: para cada unidade cívica, produz um ranking ordenado por score, com breakdown completo dos fatores que contribuíram para a posição de cada demanda.
A fórmula combina três fatores: peso_nacional do nível de precedência (estrutural), peso_situacional da unidade cívica (dinâmico, reflete onde aquela unidade ainda está deficiente) e score_horizontal da categoria (desempate dentro do mesmo nível). O resultado é um score normalizado e auditável.
Consome demanda.categorizada da D-3, na versão 1.1.0 do schema, que inclui unidade_cívica_id e nivel_minimo_resolvido. Com isso, a D-4 obtém todos os dados necessários ao cálculo em um único evento: categoria (categoria_id, nivel_precedencia, score_horizontal), confiança da categorização e contexto territorial. Produz demanda.ranqueada para cada nova demanda que entra no ranking de sua UC e ranking.atualizado quando mudanças significativas ocorrem (demanda entra no top 10 ou primeira posição se altera).
Não executa agenda. Não aloca conselheiros. Não define política. Aplica parâmetros definidos externamente. No MVP, os pesos são fixos em configuração estática. Na Fase 2, a D-4 passa a consumir parâmetros.atualizados da D-19 (Governança de Parâmetros) para recalculação dinâmica.
A auditabilidade é estrutural: o breakdown persiste os fatores e a versão dos parâmetros usados no cálculo. Qualquer pessoa pode reproduzir o score manualmente.
É uma colônia pura de eventos. Não tem BFF acoplado, não expõe endpoints REST e não faz chamadas síncronas a outras colônias.
Referência
Seção intitulada “Referência”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “D-4 — Priorização e Ranking”
- Fórmula de priorização e distribuição por decaimento: contexto_IA.md, seção 7 (A Gestão)
- A D-4 é o nono elo na ordem de implementação do MVP (Bloco 2, após D-3).
1. Estrutura do Módulo NestJS
Seção intitulada “1. Estrutura do Módulo NestJS”A D-4 é um módulo NestJS com encapsulamento próprio dentro do monolito modular do MVP. Consome eventos do barramento via EventBusService (N-0a) e publica eventos ao final do processamento. Não expõe controllers REST.
1.1 Árvore de diretórios
Seção intitulada “1.1 Árvore de diretórios”src/demanda/d-4-priorizacao-ranking/├── d4.constants.ts # Constantes: pesos nacionais, situacionais, versão, top N e tipos consumidos├── d4.module.ts # Module definition├── d4.repository.ts # Acesso a d4.rankings e d4.demandas_agregadas├── d4-schema.spec.ts # Testes de schema e migration da agregação├── d4.service.spec.ts # Testes unitários do service├── d4.service.ts # Lógica de negócio: calcular score, ranking em memória (Map), publicar├── config/│ └── formula-params.config.ts # Config estática: pesos nacionais, situacionais e versão (o score horizontal vem do payload)└── repositories/ └── consumer-offset.repository.ts # Acesso a d4.consumer_offsetO ranking vive em Map em memória dentro do D4Service (rankingPorUc, top1Cache), reconstruído de d4.rankings no boot. As posições são persistidas no banco e reordenadas a cada inserção (reordenarPosicoes em transação). Não existe RankingStoreService no MVP; Redis (ioredis) entra na Fase 2 com múltiplas instâncias.
1.2 Module definition
Seção intitulada “1.2 Module definition”@Module({ imports: [], controllers: [], // Colônia pura de eventos: sem REST providers: [ D4Service, D4Repository, ConsumerOffsetRepository, ], exports: [],})export class D4Module implements OnModuleInit { constructor(private readonly d4Service: D4Service) {}
async onModuleInit() { await this.d4Service.iniciar(); }}1.3 Pontos de atenção
Seção intitulada “1.3 Pontos de atenção”- O módulo não é
@Global(). A D-4 não é dependência de nenhuma outra colônia. Outras colônias consomem seus eventos (demanda.ranqueada,ranking.atualizado), não seu código. - O módulo não importa
EventBusModuleexplicitamente.EventBusModuleé@Global(), e oEventBusServiceé injetável sem import. - O módulo não importa
RegistryModule. A validação de schema dos eventos publicados é feita pelo próprio Event Bus (N-0a) no momento da publicação. - O
OnModuleInitdispara o protocolo de inicialização: seed do cursor, replay de eventos perdidos e registro de handlers. - O módulo não registra
ThrottlerModule. Rate limiting é responsabilidade exclusiva do BFF (D-1a), na borda HTTP. - Os pesos da fórmula são carregados de
config/formula-params.config.ts, arquivo de configuração estática no MVP. Na Fase 2, a D-4 passa a consumir o eventoparâmetros.atualizadosda D-19 (Governança de Parâmetros). - O ranking vive em
Mapem memória noD4Service(rankingPorUcpor UC,top1Cache), reconstruído ded4.rankings(PostgreSQL) no boot. Sem Redis no MVP. Os sorted sets (d4:ranking:{uc_id}) são referência para a Fase 2, quando houver múltiplas instâncias do monolito. - O módulo registra o consumidor de
demanda.categorizada, o consumidor deduplicidade.agregadae o stub deparâmetros.atualizados, que apenas registra log e avança o cursor. Não há consumidores dedemanda.removida_por_votaçãoepeso_situacional.atualizadono MVP.
1.4 Serviço — responsabilidades e contrato
Seção intitulada “1.4 Serviço — responsabilidades e contrato”// Métodos públicos do D4Serviceiniciar(): Promise<void>processarDemandaCategorizada(evento: EventoRecebido): Promise<void>processarDuplicidadeAgregada(evento: EventoRecebido): Promise<void>registrarConsumidores(): voidcalcularScore(payload): ScoreResultadorankingDeUc(ucId): Array<{ demanda_id, score_final, posicao }>topN(ucId, n): Array<{ posicao, demanda_id, score_final }>onParametrosAtualizados(evento: EventoRecebido): Promise<void> // stub da Fase 2
// D4Service consome 'demanda.categorizada' e 'duplicidade.agregada',// registra o stub de 'parâmetros.atualizados'// e publica 'demanda.ranqueada' e 'ranking.atualizado'O serviço é interno ao módulo. Nenhuma outra colônia injeta D4Service. A comunicação com o exterior é via barramento.
1.5 Colônia pura de eventos
Seção intitulada “1.5 Colônia pura de eventos”A D-4 não tem BFF acoplado. É uma colônia de processamento puro: escuta, calcula, atualiza ranking, publica. O input é sempre via barramento: demanda.categorizada da D-3, duplicidade.agregada da D-12 e o stub de parâmetros.atualizados.
1.6 Ranking em memória, com PostgreSQL como fonte da verdade
Seção intitulada “1.6 Ranking em memória, com PostgreSQL como fonte da verdade”A D-4 mantém duas camadas de estado do ranking:
- PostgreSQL (
d4.rankings): write model. Fonte da verdade do score e da posição. Toda operação de cálculo e atualização de posição é persistida aqui.d4.demandas_agregadasregistra os membros de agregados de duplicidade. - Memória (
MapnoD4Service): ranking corrente por UC (rankingPorUc) e cache do último top 1 (top1Cache), sincronizados após cada persistência em PostgreSQL. Reconstruídos ded4.rankingsnoiniciar(). As consultas de ranking no MVP derivam dessa estrutura. A D-5 e a D-7 não leem o estado interno da D-4; consomem os eventosdemanda.ranqueadaeranking.atualizado.
Sem Redis no MVP. Na Fase 2, com múltiplas instâncias, o ranking migra para Redis sorted sets (d4:ranking:{uc_id}) com isolamento por prefixo de chave (d2:geo:*, l2:geo:*, l2:osm:*, d4:ranking:*). A lógica é a mesma do isolamento de schemas no PostgreSQL.
2. Banco de Dados — Schema e Entidades
Seção intitulada “2. Banco de Dados — Schema e Entidades”2.1 Schema d4
Seção intitulada “2.1 Schema d4”Todas as tabelas da D-4 residem no schema d4 do PostgreSQL. Este schema é de uso exclusivo do módulo D-4. Nenhuma outra colônia lê ou escreve nestas tabelas.
2.2 Tabela d4.rankings
Seção intitulada “2.2 Tabela d4.rankings”Write model do ranking. Registra o score calculado para cada demanda, o breakdown completo dos fatores e a posição corrente na unidade cívica. Uma linha por demanda_id. O inserir() faz upsert por demanda_id: um novo cálculo da mesma demanda atualiza a linha existente. Nenhuma linha é apagada; membros de agregado de duplicidade permanecem com ativo = false.
CREATE SCHEMA IF NOT EXISTS d4;
CREATE TABLE d4.rankings ( demanda_id UUID NOT NULL, unidade_civica_id UUID NOT NULL, score_final NUMERIC(12,2) NOT NULL, peso_nacional NUMERIC(5,2) NOT NULL, peso_situacional NUMERIC(5,4) NOT NULL, score_horizontal INTEGER NOT NULL, nivel_precedencia INTEGER NOT NULL, categoria_id VARCHAR(10) NOT NULL, posicao INTEGER, versao_parametros VARCHAR(20) NOT NULL, event_id UUID NOT NULL, correlacao_id UUID NOT NULL, calculado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP, ativo BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT rankings_pkey PRIMARY KEY (demanda_id));
CREATE INDEX rankings_unidade_civica_id_idx ON d4.rankings (unidade_civica_id);CREATE INDEX rankings_event_id_idx ON d4.rankings (event_id);CREATE INDEX rankings_unidade_civica_id_posicao_idx ON d4.rankings (unidade_civica_id, posicao);Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
demanda_id |
UUID PK | Identificador da demanda. Referência lógica ao registro da D-1a, sem constraint formal. Vale a regra de isolamento. |
unidade_civica_id |
UUID | UC de menor nível resolvida. Extraído do payload de demanda.categorizada. |
score_final |
NUMERIC(12,2) | Produto da fórmula: peso_nacional × peso_situacional × score_horizontal, arredondado em 2 casas. |
peso_nacional |
NUMERIC(5,2) | Peso estrutural por nível de precedência vigente no cálculo. |
peso_situacional |
NUMERIC(5,4) | Peso dinâmico da UC para o nível de precedência vigente no cálculo (0-1). |
score_horizontal |
INTEGER | Score horizontal da categoria. Extraído do payload de demanda.categorizada. |
nivel_precedencia |
INTEGER | Nível de precedência da categoria (1 a 5). Determina peso_nacional. |
categoria_id |
VARCHAR(10) | Identificador da categoria atribuída. Formato N.M. |
posicao |
INTEGER | Posição corrente da demanda no ranking da UC. 1 = topo. |
versao_parametros |
VARCHAR(20) | Versão dos parâmetros usados no cálculo. No MVP: string fixa "mvp-v1". Na Fase 2: versão do evento parâmetros.atualizados. |
event_id |
UUID | event_id do evento que originou o cálculo. Indexado; a idempotência consulta por ele. |
correlacao_id |
UUID | correlacao_id do evento de origem. Para trace distribuído. |
calculado_em |
TIMESTAMPTZ(2) | Timestamp do cálculo. Default CURRENT_TIMESTAMP. |
ativo |
BOOLEAN | true por padrão. Vira false quando a demanda é membro de um agregado de duplicidade; a linha permanece para auditoria. |
O banco não tem CHECKs de faixa. Os limites de nivel_precedencia, score_horizontal e peso_situacional são garantidos pelo código e pelo Registry.
2.3 Tabela d4.demandas_agregadas
Seção intitulada “2.3 Tabela d4.demandas_agregadas”Registra os membros de cada agregado de duplicidade publicado pela D-12. Uma linha por demanda_id membro. Serve às guardas de idempotência e de evento tardio: uma demanda já agregada não volta ao ranking ativo quando um demanda.categorizada atrasado chega.
CREATE TABLE d4.demandas_agregadas ( demanda_id UUID NOT NULL, agregado_id UUID NOT NULL, representante_demanda_id UUID NOT NULL, evento_id UUID NOT NULL, agregado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT demandas_agregadas_pkey PRIMARY KEY (demanda_id));
CREATE INDEX demandas_agregadas_evento_id_idx ON d4.demandas_agregadas (evento_id);CREATE INDEX demandas_agregadas_representante_demanda_id_idx ON d4.demandas_agregadas (representante_demanda_id);2.4 Tabela d4.consumer_offset
Seção intitulada “2.4 Tabela d4.consumer_offset”Controla o cursor de processamento para replay seletivo após falha ou reinicialização. Segue o padrão definido pela N-0a (Event Bus).
CREATE TABLE d4.consumer_offset ( tipo_evento VARCHAR(255) PRIMARY KEY, last_sequence BIGINT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());No MVP, a tabela tem três linhas: demanda.categorizada, parâmetros.atualizados e duplicidade.agregada, seedadas com obterMaiorSequence() no boot. O cursor de cada tipo avança em todo caminho terminal do handler.
2.5 Migrations esperadas
Seção intitulada “2.5 Migrations esperadas”Três migrations:
20260809212816_add_d4_rankings— Cria o schemad4e a tabelad4.rankingscom PK emdemanda_id, colunaposicaoanulável e os índicesrankings_unidade_civica_id_idx,rankings_event_id_idxerankings_unidade_civica_id_posicao_idx.20260813112406_add_consumer_offset_d1b_d2_d4— Criad4.consumer_offsetcom PK emtipo_evento, na mesma migration que cria as tabelas equivalentes de D-1b e D-2.20260820140000_d4_agregacao— Adiciona a colunaativocom defaulttruee criad4.demandas_agregadascom PK emdemanda_ide os índicesdemandas_agregadas_evento_id_idxedemandas_agregadas_representante_demanda_id_idx.
Migrations futuras (Fase 2): adição de coluna fatores_ajuste (JSONB) para fatores de ajuste dinâmicos (urgência da D-13, remoção por votação da D-10) e índices compostos para queries de recalculação em lote.
2.6 Relações internas
Seção intitulada “2.6 Relações internas”Não há foreign keys entre as tabelas do schema d4. d4.rankings.demanda_id referencia o identificador de demanda em schemas de outras colônias (D-1a, D-3) e d4.demandas_agregadas.agregado_id referencia o agregado da D-12, sem FK formal. Vale a regra de isolamento.
2.7 Decisões de schema
Seção intitulada “2.7 Decisões de schema”Score e posição na mesma linha (d4.rankings), uma por demanda_id.
O inserir() faz upsert por demanda_id; a posição é recalculada e reescrita em lote pela reordenarPosicoes() a cada inserção ou desativação de membro. Uma linha por demanda evita joins entre score e posição e concentra o breakdown auditável. As linhas de membros agregados permanecem com ativo = false.
peso_situacional como NUMERIC(5,4), com precisão de 4 casas decimais.
O peso situacional é um fator entre 0 e 1. Quatro casas decimais capturam granularidade suficiente (ex: 0.8750). O tipo NUMERIC evita erros de arredondamento de ponto flutuante que poderiam causar divergência entre o ranking persistido e o ranking em memória.
Sem coluna fatores_ajuste no MVP.
Na Fase 2, fatores como urgência (D-13) e remoção por votação (D-10) serão adicionados como multiplicadores adicionais na fórmula. No MVP, a fórmula é puramente Pn × Ps × Sh. A coluna será adicionada por migration na Fase 2.
versao_parametros como VARCHAR(20), não como FK para tabela de versões.
No MVP, o valor é a string fixa "mvp-v1". Na Fase 2, será o identificador de versão do evento parâmetros.atualizados (ex: "2026-Q3-v2"). Uma FK implicaria tabela no schema d4 ou dependência do schema da D-19. Ambos são inadequados. O campo é rótulo para rastreabilidade, não constraint relacional.
3. Eventos — Contratos Detalhados
Seção intitulada “3. Eventos — Contratos Detalhados”A D-4 consome demanda.categorizada e duplicidade.agregada como gatilhos de processamento, registra o stub de parâmetros.atualizados e produz dois eventos. Os schemas completos (JSON Schema draft-2020-12) estão definidos no Registry (N-0b). Esta seção descreve os contratos do ponto de vista da D-4.
3.1 Evento consumido: demanda.categorizada (gatilho)
Seção intitulada “3.1 Evento consumido: demanda.categorizada (gatilho)”| Propriedade | Valor |
|---|---|
| Tipo | demanda.categorizada |
| Schema version | 1.1.0 (mínimo) |
| Produtor | D-3 (Categorização) |
| Consumidor | D-4 (esta colônia) |
| Descrição | Demanda classificada com categoria, nível de precedência, score horizontal, confiança, contexto territorial e sugestões alternativas. |
Payload esperado (conforme Registry N-0b, v1.1.0):
interface DemandaCategorizadaPayload { demanda_id: string; categoria_id: string; // Formato "N.M" nivel_precedencia: number; // 1-5 score_horizontal: number; // 0-100 confianca_categorizacao: number; // 0-1 metodo: string; // 'automatico' | 'manual' | 'revisao_pendente' sugestoes_alternativas: Array<{ categoria_id: string; score_confianca: number; }>; unidade_civica_id: string; // v1.1.0: UC de menor nível nivel_minimo_resolvido: number; // v1.1.0: 1-7}3.2 Evento produzido: demanda.ranqueada
Seção intitulada “3.2 Evento produzido: demanda.ranqueada”| Propriedade | Valor |
|---|---|
| Tipo | demanda.ranqueada |
| Schema version | 1.1.0 |
| Produtor | D-4 (esta colônia) |
| Consumidores | D-5 (Agenda), D-7 (Transparência), L-4 (Análise de Cobertura e Gaps, Fase 2), D-14 (Agregação Multinível, Fase 2) |
| Descrição | Demanda com score final calculado, breakdown dos fatores e posição no ranking da UC. Publicado individualmente para cada demanda que entra no ranking. |
A versão 1.1.0 acrescenta categoria_id e nivel_precedencia, exigidos pela D-5 na projeção do backlog.
Payload publicado:
interface DemandaRanqueadaPayload { demanda_id: string; unidade_civica_id: string; categoria_id: string; nivel_precedencia: number; // 1-5 score_final: number; breakdown: { peso_nacional: number; // Peso estrutural por nível de precedência peso_situacional: number; // Peso dinâmico da UC para o nível (0-1) score_horizontal: number; // Score da categoria (0-100) }; posicao_no_ranking: number; // Posição no ranking da UC (1 = topo) total_demandas_na_uc: number; // Total de demandas no ranking da UC versao_parametros: string; // Versão dos parâmetros usados timestamp_calculo: string; // ISO-8601}3.3 Evento produzido: ranking.atualizado
Seção intitulada “3.3 Evento produzido: ranking.atualizado”| Propriedade | Valor |
|---|---|
| Tipo | ranking.atualizado |
| Schema version | 1.0.0 |
| Produtor | D-4 (esta colônia) |
| Consumidores | D-5 (Agenda), D-7 (Transparência), D-14 (Agregação Multinível, Fase 2), D-15 (Pesquisa e Exportação, Fase 2), E-4 (Impacto Territorial, Fase 2) |
| Descrição | Publicado quando o ranking de uma unidade cívica sofre mudança significativa: nova demanda entra no top 10, a primeira posição se altera ou um membro de agregado sai da ordenação. Sinaliza para a D-5 que vale a pena reconstruir o backlog. |
Payload publicado:
interface RankingAtualizadoPayload { unidade_civica_id: string; motivo: string; // 'demanda_top10' | 'primeira_posicao_alterada' demanda_id_gatilho: string; // Demanda que causou a mudança significativa top_10: Array<{ // Top 10 atual do ranking da UC posicao: number; demanda_id: string; score_final: number; }>; total_demandas: number; // Total de demandas no ranking da UC versao_parametros: string; timestamp: string; // ISO-8601}3.4 Evento consumido: duplicidade.agregada
Seção intitulada “3.4 Evento consumido: duplicidade.agregada”| Propriedade | Valor |
|---|---|
| Tipo | duplicidade.agregada |
| Schema version | 1.0.0 |
| Produtor | D-12 (Detecção de Duplicidade) |
| Consumidor | D-4 (esta colônia) |
| Descrição | Agregado de demandas consolidadas por confirmação coletiva: um representante e uma lista de membros. |
Payload esperado:
interface DuplicidadeAgregadaPayload { agregado_id: string; representante_demanda_id: string; membros: Array<{ demanda_id: string }>; mecanismo: 'confirmacao_coletiva'; confirmacoes_gatilho: number; criterios: Record<string, unknown>;}Ordem de operações no handler (processarDuplicidadeAgregada):
1. Validar payload (agregado_id, representante_demanda_id e membros não vazio)2. Para cada membro diferente do representante: → registrar em d4.demandas_agregadas → buscar a linha em d4.rankings → se a linha estiver ativa: marcar ativo = false e remover do ranking em memória3. Recalcular as posições das linhas ativas de cada UC afetada4. Publicar ranking.atualizado por UC afetada → motivo 'primeira_posicao_alterada' quando o primeiro lugar muda; caso contrário 'demanda_top10'5. Avançar cursor em todo caminho terminalGuarda de evento tardio: um demanda.categorizada de demanda já agregada é descartado antes do cálculo, porque a consulta a d4.demandas_agregadas antecede a inserção. No replay idempotente, a linha com ativo = false também é descartada. O representante mantém sua posição.
3.5 Ordem de operações no handler (processarDemandaCategorizada)
Seção intitulada “3.5 Ordem de operações no handler (processarDemandaCategorizada)”O fluxo em D4Service.processarDemandaCategorizada() segue esta ordem:
1. Verificar idempotência por event_id → buscarPorEventId() em d4.rankings → se encontrado com ativo = false: log, avançar o cursor e retornar (a demanda é membro de agregado de duplicidade) → se encontrado ativo: republicar demanda.ranqueada; se a posição for significativa, publicar ranking.atualizado com o snapshot corrente; avançar o cursor e retornar
2. Validar payload mínimo → demanda_id, categoria_id, nivel_precedencia, score_horizontal e unidade_civica_id obrigatórios → se ausente: log.error, avançar o cursor e retornar
3. Guarda de evento tardio → buscarAgregacaoPorDemandaId() em d4.demandas_agregadas → se a demanda já é membro de agregado: log, avançar o cursor e retornar
4. Calcular score_final → recuperar peso_nacional do nível de precedência (config estática) → recuperar peso_situacional da UC para o nível (config estática; fallback 1.0 para UC sem configuração) → score_final = round(peso_nacional × peso_situacional × score_horizontal, 2)
5. Atualizar o ranking da UC em memória → upsert da demanda no array da UC → ordenação por score decrescente, desempate por demanda_id → posicao = índice no array + 1 → total = tamanho do array da UC
6. Persistir a linha em d4.rankings → upsert por demanda_id com os fatores, a posição, a versão dos parâmetros, event_id e correlacao_id
7. Reordenar as posições persistidas da UC → reordenarPosicoes() com a ordem do ranking em memória
8. Publicar demanda.ranqueada → eventBus.publicar com breakdown, posição, total e timestamp
9. Verificar se a mudança é significativa → verificarSignificancia(uc_id, demanda_id, posicao) → se sim: publicar ranking.atualizado com motivo, top 10 e total
10. Avançar o cursor → upsert do offset de demanda.categorizada3.6 Tratamento de erro e idempotência
Seção intitulada “3.6 Tratamento de erro e idempotência”| Cenário | Comportamento |
|---|---|
Evento demanda.categorizada reentregue (replay DLQ) |
Detectado por event_id em d4.rankings. O registro existente é republicado como demanda.ranqueada. A verificação de significância roda de novo: se a demanda está no top 10 ou se a primeira posição mudou, ranking.atualizado é publicado com o snapshot corrente. Membro de agregado (ativo = false) é descartado com log. Cursor avançado em todos os casos. |
Payload incompleto (ausência de demanda_id, categoria_id, nivel_precedencia, score_horizontal ou unidade_civica_id) |
Log.error. Cursor avançado. Evento descartado. Se ocorrer, é bug upstream (D-3). |
unidade_civica_id ou nível sem peso situacional configurado |
Log.warn. Usa o peso situacional padrão 1.0 como fallback (UC sem gap conhecido; todos os níveis tratados como igualmente prioritários). |
nivel_precedencia fora do intervalo 1-5 |
Log.error e exceção. O evento vai para a DLQ e o cursor não avança. |
Falha ao persistir em d4.rankings |
Exceção propagada. O evento vai para a DLQ e o cursor não avança. O ranking em memória pode ficar à frente do banco até o próximo boot, quando é reconstruído de d4.rankings. |
Publish de demanda.ranqueada falha |
Log.error e exceção propagada. A linha existe em d4.rankings. O evento vai para a DLQ e o cursor não avança. O replay seguinte encontra o registro e republica a saída. |
Publish de ranking.atualizado falha |
A exceção propaga após as tentativas do publicarComRetry. demanda.ranqueada já foi publicado. O evento vai para a DLQ e o cursor não avança. |
| Reinicialização do processo | O ranking em memória é perdido, mas reconstruído a partir de d4.rankings (PostgreSQL) no iniciar() (carregarRankingEmMemoria()). Nenhuma ação necessária. |
3.7 Decisões de design com justificativa
Seção intitulada “3.7 Decisões de design com justificativa”Consumir apenas demanda.categorizada, não também demanda.georreferenciada.
Com o schema v1.1.0 da D-3, o unidade_civica_id viaja no evento de categorização. A D-4 obtém categoria e UC em um único evento. Isso elimina a necessidade de projeção local de geo (como a D-3 faz) e simplifica o pipeline. Se a D-3 operar com schema antigo (v1.0.0, sem UC id), a D-4 detecta a ausência e descarta com log.error. O upgrade da D-3 é pré-requisito para a D-4.
Atualizar o ranking em memória e persistir a linha na sequência.
A persistência em d4.rankings é a fonte da verdade; o Map é índice derivado, reconstruído no boot. Se o processo falhar entre a atualização da memória e a persistência, a exceção deixa o evento na DLQ com o cursor parado, e o boot seguinte reconstrói o estado a partir de d4.rankings.
Publicar demanda.ranqueada sempre; ranking.atualizado apenas em mudanças significativas.
demanda.ranqueada é o evento de registro individual: toda demanda que entra no ranking publica o seu. A D-7 usa isso para a timeline da demanda. ranking.atualizado é o sinal de reconstrução do backlog. A D-5 só precisa reagir quando o topo da fila muda de forma relevante (top 10 ou primeira posição). Publicar ranking.atualizado a cada inserção geraria ruído desproporcional.
ranking.atualizado no replay usa o snapshot corrente.
No replay de um evento já processado, a D-4 republica demanda.ranqueada e roda de novo a verificação de significância. Quando a demanda está no top 10 ou a primeira posição mudou, o ranking.atualizado publicado carrega o top 10 corrente, não um snapshot antigo. A D-5 trata o evento como gatilho de rebuild e é idempotente por event_id.
Peso situacional 1.0 como fallback para UC desconhecida.
Se uma UC não tem peso situacional configurado (ex: UC recém-criada, ainda sem dados de cobertura), assumir peso_situacional = 1.0 significa tratar todos os níveis como se nenhum estivesse vencido. A prioridade é determinada puramente por peso_nacional × score_horizontal. É um fallback conservador que não distorce o ranking artificialmente.
4. Lógica de Negócio — Algoritmos e Fluxos
Seção intitulada “4. Lógica de Negócio — Algoritmos e Fluxos”4.1 Fórmula de priorização
Seção intitulada “4.1 Fórmula de priorização”score_final = peso_nacional(nivel_precedencia) × peso_situacional(unidade_cívica_id, nivel_precedencia) × score_horizontal(categoria_id)4.2 Parâmetros estáticos do MVP
Seção intitulada “4.2 Parâmetros estáticos do MVP”Arquivo config/formula-params.config.ts, carregado por carregarFormulaParams() com cache em variável de módulo:
interface FormulaParams { pesosNacionais: Record<number, number>; // nivel_precedencia -> peso pesosSituacionais: Record<string, Record<number, number>>; // uc_id -> nivel -> peso (0-1) pesoSituacionalDefault: number; // 1.0 versao: string; // 'mvp-v1'}Os valores vêm de d4.constants.ts. resetarFormulaParams() limpa o cache nos testes.
Pesos nacionais (valores de referência do Apêndice B):
pesosNacionais: { 1: 100, // Sobrevivência básica 2: 80, // Segurança e saúde 3: 60, // Infraestrutura e serviços 4: 40, // Qualidade de vida 5: 20, // Autorrealização}Scores horizontais: a D-4 não mantém cópia dos valores. O score_horizontal chega no payload de demanda.categorizada, calculado pela D-3 a partir da taxonomia. A fonte canônica é D-3 - Taxonomia.md (tabela consolidada no final do documento).
Pesos situacionais (configuração do desenvolvimento local):
pesosSituacionais: { 'f0000001-0000-4000-8000-000000000001': { // UC de teste do seed de desenvolvimento 1: 0.85, // Saneamento e água ainda deficientes 2: 0.60, // Saúde parcialmente coberta 3: 0.75, // Infraestrutura com gaps 4: 0.40, 5: 0.20, }, // UC não configurada: todos os níveis = 1.0}A chave é o UUID da UC de teste do seed de desenvolvimento (prisma/seed-dev.ts), o mesmo usado no payload real em que unidade_civica_id é UUID. No MVP, a configuração é estática. Na Fase 2, os valores passam a chegar pelo evento parâmetros.atualizados da D-19 e a ser armazenados em tabela versionada.
4.3 D4Service.calcularScore() — pseudocódigo
Seção intitulada “4.3 D4Service.calcularScore() — pseudocódigo”função calcularScore(payload) -> ScoreResultado:
formulaParams = carregarFormulaParams()
// 1. Recuperar peso nacional do nível de precedência pesoNacional = formulaParams.pesosNacionais[payload.nivel_precedencia] se pesoNacional é undefined: logger.error("Nível de precedência sem peso nacional configurado", { demanda_id: payload.demanda_id, nivel_precedencia: payload.nivel_precedencia, }) lançar Error
// 2. Recuperar peso situacional da UC para o nível pesosUC = formulaParams.pesosSituacionais[payload.unidade_civica_id] se pesosUC é undefined: // UC sem configuração específica logger.warn("UC sem pesos situacionais configurados, usando fallback 1.0", { demanda_id: payload.demanda_id, unidade_civica_id: payload.unidade_civica_id, }) pesoSituacional = formulaParams.pesoSituacionalDefault senão: pesoSituacional = pesosUC[payload.nivel_precedencia] ?? formulaParams.pesoSituacionalDefault
// 3. Calcular o score final com arredondamento em 2 casas scoreFinal = Math.round( pesoNacional × pesoSituacional × payload.score_horizontal × 100 ) / 100
return { demanda_id, unidade_civica_id, categoria_id, nivel_precedencia, score_final, peso_nacional, peso_situacional, score_horizontal, versao_parametros: formulaParams.versao, }4.4 Ranking em memória no D4Service — sem Redis no MVP
Seção intitulada “4.4 Ranking em memória no D4Service — sem Redis no MVP”O ranking é mantido em memória dentro do próprio D4Service. Não existe serviço de cache separado no MVP. Os sorted sets do Redis são referência para a Fase 2.
classe D4Service:
rankingPorUc: Map<string, EntradaRanking[]> // uc_id -> array ordenado por score decrescente top1Cache: Map<string, {demanda_id, score_final}> // último top 1 conhecido por UC
função carregarRankingEmMemoria(): // chamada no iniciar() registros = repo.listarAtivos() // d4.rankings WHERE ativo = true para cada registro em registros: rankingPorUc[registro.unidade_civica_id].push({demanda_id, score_final}) para cada [ucId, ranking] em rankingPorUc: ranking.sort(por score desc, desempate por demanda_id) top1Cache.set(ucId, ranking[0])
função atualizarRanking(score: ScoreResultado) -> EntradaRanking[]: ranking = rankingPorUc.get(score.unidade_civica_id) ?? [] // upsert da demanda no array (substitui o score se a demanda já existir) ranking.sort(por score desc, desempate por demanda_id) return ranking
função removerDoRankingEmMemoria(ucId, demandaId): // usado na agregação remove a entrada do array; se o array esvaziar, remove a UC dos dois Maps
posição da demanda = índice no array + 1 (1-based) total da UC = tamanho do array
função rankingDeUc(ucId) -> Array<{demanda_id, score_final, posicao}> função topN(ucId, n) -> Array<{posicao, demanda_id, score_final}>4.5 verificarSignificancia() — gatilho de ranking.atualizado
Seção intitulada “4.5 verificarSignificancia() — gatilho de ranking.atualizado”Método privado do D4Service (não existe classe RankingSignificance no MVP):
função verificarSignificancia(ucId: string, demandaId: string, posicao: number) -> { significativa: boolean, motivo: string | null}:
// Critério 1: demanda entrou no top 10 se posicao <= TOP_N_SIGNIFICATIVO (10): return { significativa: true, motivo: 'demanda_top10' }
// Critério 2: primeira posição foi alterada ranking = this.rankingPorUc.get(ucId) se !ranking ou ranking.length == 0: return { significativa: false, motivo: null } top1Atual = ranking[0] top1Anterior = this.top1Cache.get(ucId)
se !top1Anterior ou top1Anterior.demanda_id != top1Atual.demanda_id: this.top1Cache.set(ucId, { demanda_id: top1Atual.demanda_id, score_final: top1Atual.score_final, }) return { significativa: true, motivo: 'primeira_posicao_alterada' }
return { significativa: false, motivo: null }A primeira inserção de uma UC tem posição 1 e, como 1 ≤ 10, publica ranking.atualizado com motivo demanda_top10. O top1Cache é inicializado no boot com o top 1 de cada UC.
4.6 D4Service.iniciar() — protocolo de inicialização
Seção intitulada “4.6 D4Service.iniciar() — protocolo de inicialização”função iniciar():
se já iniciado: retornar
// 1. Carregar parâmetros da fórmula carregarFormulaParams()
// 2. Reconstruir ranking em memória e cache de top 1 // (ambos de d4.rankings; não há Redis no MVP) await carregarRankingEmMemoria()
// 3. Semear o cursor dos tipos consumidos, sem sobrescrever maiorSequence = await eventBus.obterMaiorSequence() await offsetRepo.seed(TIPOS_EVENTO_CONSUMIDOS, maiorSequence)
// 4. Replay de eventos perdidos durante inatividade await reprocessarEventosPerdidos()
// 5. Registrar consumidores para eventos futuros registrarConsumidores()
logger.log('D-4 Priorização e Ranking inicializada')O reprocessarEventosPerdidos() percorre os tipos de TIPOS_EVENTO_CONSUMIDOS (demanda.categorizada, parâmetros.atualizados e duplicidade.agregada), lê o cursor de cada um, chama replayDeSequence(cursor, [tipo]) e despacha cada evento pelo handler correspondente. Falha no despacho é logada e o evento permanece pendente, com o cursor parado.
O registrarConsumidores() inscreve os handlers de demanda.categorizada e duplicidade.agregada e o stub de parâmetros.atualizados.
4.7 Handler stub de parâmetros.atualizados
Seção intitulada “4.7 Handler stub de parâmetros.atualizados”O único stub registrado no MVP. Loga o evento e avança o cursor. A implementação real entra na Fase 2 com a D-19 (Governança de Parâmetros).
async onParametrosAtualizados(evento: EventoRecebido): Promise<void> { this.logger.log('Evento parâmetros.atualizados ignorado (MVP — pesos estáticos)', { event_id: evento.event_id, }); await this.offsetRepo.upsert('parâmetros.atualizados', BigInt(evento.sequence_number)); // Fase 2: recarregar formulaParams, recalcular ranking global, // publicar demanda.ranqueada para cada demanda com score alterado}4.8 Auditoria e explicação do ranking — como ler o breakdown
Seção intitulada “4.8 Auditoria e explicação do ranking — como ler o breakdown”O breakdown é o mecanismo de explicabilidade. Para qualquer demanda, qualquer pessoa pode ver:
| Fator | O que é | Como verificar |
|---|---|---|
peso_nacional |
Posição da categoria na tabela de precedência material. Nível 1 (sobrevivência) sempre tem peso maior que nível 3 (infraestrutura). | Consultar formula-params.config.ts ou a versão publicada dos parâmetros. |
peso_situacional |
O gap daquela UC naquele nível. Se saneamento (nível 1) já está resolvido na UC, peso_situacional(UC, 1) é baixo. |
Fórmula: 1 - (cobertura_atingida / cobertura_meta). No MVP, valor estático em config. Na Fase 2, calculado com dados reais. |
score_horizontal |
Posição relativa da categoria dentro do nível. Água (95) > Esgoto (85) > Energia (80) dentro do nível 1. | Definido na taxonomia (D-3 - Taxonomia.md) e revisado por comitês técnicos. |
score_final |
Produto dos três fatores acima. | Multiplicar: peso_nacional × peso_situacional × score_horizontal. Arredondar para 2 casas. |
Reproduzir o score manualmente, dado o breakdown, é possível com uma calculadora.
4.9 Casos de borda
Seção intitulada “4.9 Casos de borda”| Caso | Comportamento |
|---|---|
Duas demandas com mesmo score_final na mesma UC |
Desempate determinístico por demanda_id (ordem lexicográfica UUID) no sort do array em memória. |
| UC sem nenhuma demanda no ranking ainda | Primeira inserção: posição 1. ranking.atualizado publicado com motivo demanda_top10 (posição 1 ≤ 10). |
| UC tem 100+ demandas no ranking e uma nova entra na posição 55 | demanda.ranqueada publicado com posição 55. ranking.atualizado NÃO publicado (não entrou no top 10, não alterou primeira posição). |
| UC tem 8 demandas no ranking e uma nova entra (total passa a 9) | demanda.ranqueada publicado. Se posição ≤ 10, ranking.atualizado publicado (critério top 10). |
| Peso situacional = 0 para todos os níveis da UC (UC hipoteticamente perfeita, com todos os gaps resolvidos) | score_final = 0 para todas as demandas da UC. Ranking se torna ordenação puramente por demanda_id. Cenário teórico; na prática, sempre há gaps. |
Categoria com score_horizontal = 0 |
score_final = 0, independente dos pesos. Demanda vai para o fim do ranking. A taxonomia não deve ter scores horizontais zero. O mínimo é 10. |
demanda.categorizada com metodo = 'revisao_pendente' |
A D-3 NÃO publica demanda.categorizada para demandas com revisao_pendente. Este caso não deveria chegar à D-4. Se chegar (bug na D-3), a D-4 processa normalmente e ranqueia a demanda com a categoria sugerida. |
4.10 Decisões de design com justificativa
Seção intitulada “4.10 Decisões de design com justificativa”Pesos nacionais como valores absolutos (100, 80, 60, 40, 20), não relativos (1.0, 0.8, 0.6, 0.4, 0.2).
A escolha é cosmética para legibilidade do breakdown. O produto 100 × 0.85 × 95 = 8075.00 é tão correto quanto 1.0 × 0.85 × 95 = 80.75. A diferença é fator de escala. Usar 100 como base torna explícito que o nível 1 é o teto de referência. O score final absoluto não é comparável entre níveis diferentes diretamente. A ordenação do ranking resolve isso.
A distribuição por decaimento geométrico é aplicada pela D-5 (Agenda), não pela D-4. A D-4 produz o ranking ordenado por score puro. A D-5 consome esse ranking e aplica a distribuição de capacidade (decaimento geométrico com fator f). Essa separação de responsabilidades é intencional: a D-4 calcula prioridade; a D-5 decide capacidade. Se a distribuição por decaimento fosse aplicada na D-4, o ranking seria distorcido por um fator que não é intrínseco à prioridade da demanda, mas à capacidade de agenda. São conceitos distintos.
Sem deduplicação ou verificação de demandas similares dentro da D-4.
A detecção de duplicidade é da D-12 (Detecção de Duplicidade). A D-4 ranqueia o que recebe. Se duas demandas idênticas chegarem, ambas ocupam posições até o agregado se formar. Quando a D-12 publica duplicidade.agregada, a D-4 marca os membros como inativos no ranking, recalcula as posições das linhas ativas e republica ranking.atualizado. O representante permanece ranqueado; os membros saem da ordenação sem perder a linha.
5. Integração com o Barramento e Outras Colônias
Seção intitulada “5. Integração com o Barramento e Outras Colônias”5.1 Consumo de eventos via EventBusService
Seção intitulada “5.1 Consumo de eventos via EventBusService”A D-4 injeta EventBusService (do módulo @Global() N-0a) para quatro operações: inscrever() (registrar handlers), publicar() (publicar demanda.ranqueada e ranking.atualizado), replayDeSequence() (replay na inicialização) e obterMaiorSequence() (seed do cursor).
@Injectable()export class D4Service { constructor( private readonly eventBus: EventBusService, private readonly repo: D4Repository, private readonly offsetRepo: ConsumerOffsetRepository, ) {}}A D-4 injeta apenas o núcleo (EventBus) e seus repositórios próprios. Sem Redis e sem dependências de outras colônias.
5.2 Fluxo de eventos — cadeia completa
Seção intitulada “5.2 Fluxo de eventos — cadeia completa”Cidadão (app) → POST /demandas (BFF D-1a) → demanda.recebida (barramento) → [D-1b] → demanda.normalizada → [D-2] → demanda.georreferenciada → [D-3] → demanda.categorizada (v1.1.0, inclui UC id) → [D-12] → duplicidade.candidata_detectada → [D-4] (esta colônia) → demanda.ranqueada → ranking.atualizado → [D-5] → agenda.gerada / agenda.item_disponível → [D-7] → timeline + dashboard → [L-4] → cobertura.atualizada (Fase 2) → [D-14] → agregado.* (Fase 2)
[D-12] → duplicidade.agregada (3 confirmações de cidadãos distintos) → [D-4] desativa membros, recalcula posições, republica ranking.atualizado → [D-5] marca membros como agregado, regenera backlog5.3 Publicação de demanda.ranqueada
Seção intitulada “5.3 Publicação de demanda.ranqueada”private async publicarDemandaRanqueada( registro: RankingRegistro, correlacaoId: string,): Promise<void> { try { const ranking = this.rankingPorUc.get(registro.unidade_civica_id); const total = ranking !== undefined ? ranking.length : 0;
await publicarComRetry(this.eventBus, { tipo: 'demanda.ranqueada', origem: 'D-4', versao_schema: '1.1.0', event_id: uuidv4(), correlacao_id: correlacaoId, payload: { demanda_id: registro.demanda_id, unidade_civica_id: registro.unidade_civica_id, categoria_id: registro.categoria_id, nivel_precedencia: registro.nivel_precedencia, score_final: registro.score_final, breakdown: { peso_nacional: registro.peso_nacional, peso_situacional: registro.peso_situacional, score_horizontal: registro.score_horizontal, }, posicao_no_ranking: registro.posicao, total_demandas_na_uc: total, versao_parametros: registro.versao_parametros, timestamp_calculo: new Date().toISOString(), }, }); } catch (erro) { this.logger.error('Falha ao publicar demanda.ranqueada', { demanda_id: registro.demanda_id, erro: erro instanceof Error ? erro.message : String(erro), }); throw erro; }}A publicação usa publicarComRetry (4 tentativas, backoff de 500 ms a 2000 ms) e a versão 1.1.0, com categoria_id e nivel_precedencia no payload. A falha definitiva propaga e o cursor não avança.
5.4 Publicação de ranking.atualizado
Seção intitulada “5.4 Publicação de ranking.atualizado”private async publicarRankingAtualizado( ucId: string, motivo: string, demandaIdGatilho: string, total: number, versaoParametros: string, correlacaoId: string,): Promise<void> { const top10 = this.topN(ucId, TOP_N_SIGNIFICATIVO);
await publicarComRetry(this.eventBus, { tipo: 'ranking.atualizado', origem: 'D-4', versao_schema: '1.0.0', event_id: uuidv4(), correlacao_id: correlacaoId, payload: { unidade_civica_id: ucId, motivo, demanda_id_gatilho: demandaIdGatilho, top_10: top10, total_demandas: total, versao_parametros: versaoParametros, timestamp: new Date().toISOString(), }, });}A publicação usa publicarComRetry (4 tentativas, backoff de 500 ms a 2000 ms). A falha definitiva propaga e o cursor não avança.
5.5 Chamadas síncronas via BFF
Seção intitulada “5.5 Chamadas síncronas via BFF”A D-4 não faz chamadas síncronas a outras colônias. Não expõe endpoints REST. Não atua como proxy. Toda comunicação é via barramento.
5.6 Dependências de projeções de leitura
Seção intitulada “5.6 Dependências de projeções de leitura”A D-4 não consome projeções de leitura de outras colônias. Os dados externos que acessa são:
core.event_logviaEventBusService.replayDeSequence(), dependência do núcleo permitida.
5.7 Estado em memória (sem Redis no MVP)
Seção intitulada “5.7 Estado em memória (sem Redis no MVP)”A D-4 mantém o ranking corrente em Map dentro do próprio processo:
| Estrutura | Conteúdo | Operações |
|---|---|---|
rankingPorUc |
Map<uc_id, EntradaRanking[]>, array ordenado por score decrescente |
upsert + Array.sort(), findIndex() (posição), length (tamanho), slice(0, n) (top N) |
top1Cache |
Map<uc_id, {demanda_id, score_final}> |
detectar mudança de primeira posição |
Ambos são reconstruídos a partir de d4.rankings (PostgreSQL) no iniciar(). A perda de estado em reinicialização é aceitável: o PostgreSQL é a fonte da verdade e o custo de reconstrução é uma query.
5.8 Prefixos Redis de referência (Fase 2)
Seção intitulada “5.8 Prefixos Redis de referência (Fase 2)”Sem Redis no MVP. Quando a Fase 2 introduzir múltiplas instâncias do monolito, os prefixos planejados são:
| Colônia | Prefixo | Uso |
|---|---|---|
| D-2 | d2:geo:{lat}:{lng} |
Cache de point-in-polygon |
| L-2 | l2:geo:{lat}:{lng} |
Cache de point-in-polygon (lugares) |
| L-2 | l2:osm:{lat}:{lng} |
Cache de respostas OSM |
| D-4 | d4:ranking:{uc_id} |
Sorted sets de ranking por UC |
O isolamento é por prefixo de chave. Nenhuma colônia lê chaves de outra.
6. Performance e Limites
Seção intitulada “6. Performance e Limites”6.1 Rate limiting
Seção intitulada “6.1 Rate limiting”A D-4 não aplica rate limiting. O volume de eventos é limitado indiretamente pelo rate limiting do BFF (D-1a).
6.2 Cotas e limites de tamanho
Seção intitulada “6.2 Cotas e limites de tamanho”| Limite | Valor | Justificativa |
|---|---|---|
| Tamanho máximo do ranking por UC em memória | 100.000 demandas | Valor teórico para Fase 2 (município grande com anos de dados). MVP: < 1.000. |
| Tamanho do Map fallback em memória | Sem limite explícito | No MVP monolito, volume baixo. Monitorar uso de heap. |
Precisão de score_final |
2 casas decimais | Suficiente para distinguir prioridades. 3+ casas seria over-engineering para o MVP. |
Arredondamento de peso_situacional |
4 casas decimais | Evita que diferenças de micro-centésimos distorçam o ranking. |
6.3 Índices e padrões de query
Seção intitulada “6.3 Índices e padrões de query”| Índice | Query atendida |
|---|---|
rankings_event_id_idx (event_id) |
Idempotência: buscarPorEventId(). |
rankings_unidade_civica_id_idx (unidade_civica_id) |
Linhas por UC. |
rankings_unidade_civica_id_posicao_idx (unidade_civica_id, posicao) |
Posições por UC. |
demandas_agregadas_evento_id_idx (evento_id) |
Registros de agregação por evento. |
demandas_agregadas_representante_demanda_id_idx (representante_demanda_id) |
Membros de um representante. |
As buscas por demanda_id usam a primary key das duas tabelas.
6.4 Padrões de query esperados
Seção intitulada “6.4 Padrões de query esperados”buscarPorEventId(): 1 query por eventodemanda.categorizadarecebido (idempotência).buscarAgregacaoPorDemandaId(): 1 query por eventodemanda.categorizadanão processado (guarda de evento tardio).inserir(): 1 upsert emd4.rankingspor evento.reordenarPosicoes(): N updates emd4.rankingspor evento (N = demandas da UC; pior caso, todas as posições).listarAtivos(): 1 query na inicialização (reconstrução do cache).registrarDemandaAgregada(),buscarPorDemandaId()emarcarInativa(): por membro emduplicidade.agregada.
Volume esperado no MVP: < 100 demandas/dia. Tempo médio de processamento: < 3ms com ranking em memória.
6.5 Estratégia de cache
Seção intitulada “6.5 Estratégia de cache”- Ranking em memória:
Map<uc_id, EntradaRanking[]>com ordenação viaArray.sort(). Consulta de posição O(N), irrelevante para o volume do MVP (< 1.000 demandas/UC). Reconstruído ded4.rankingsna inicialização. - Cache de top 1:
Map<uc_id, {demanda_id, score_final}>em memória. Inicializado na partida. Usado porverificarSignificancia()para detectar mudança de primeira posição.
Justificativa para PostgreSQL + memória (sem Redis no MVP):
O ranking em memória é reconstruído do write model no boot. O PostgreSQL é a única fonte da verdade. As colônias a jusante (D-5, D-7) não leem o estado interno da D-4: consomem demanda.ranqueada e ranking.atualizado e mantêm suas próprias projeções. Não existe consulta externa que exija um cache compartilhado. Com o monolito em processo único, o Map é mais rápido que Redis (zero latência de rede) e elimina uma dependência de infraestrutura. Redis sorted sets entram na Fase 2 quando houver múltiplas instâncias da API.
6.6 Projeção de volume
Seção intitulada “6.6 Projeção de volume”| Cenário | Demandas/dia | Rankings ativos (total) | UCs com ranking | Tamanho médio/UC |
|---|---|---|---|---|
| PoC (1 bairro) | ~20 | ~20 | 1 | 20 |
| MVP (1 município) | ~100 | ~500 | ~10 | 50 |
| Fase 2 (regional) | ~10.000 | ~50.000 | ~200 | 250 |
7. Testabilidade
Seção intitulada “7. Testabilidade”7.1 Como testar o módulo isolado
Seção intitulada “7.1 Como testar o módulo isolado”Teste unitário do D4Service:
beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ D4Service, { provide: EventBusService, useValue: mockEventBus }, { provide: D4Repository, useValue: mockRepo }, { provide: ConsumerOffsetRepository, useValue: mockOffsetRepo }, ], }).compile();
service = module.get(D4Service);
mockRepo.buscarPorEventId.mockResolvedValue(null); mockRepo.buscarPorDemandaId.mockResolvedValue(null); mockRepo.buscarAgregacaoPorDemandaId.mockResolvedValue(null); mockRepo.listarAtivos.mockResolvedValue([]); mockRepo.inserir.mockImplementation((dados) => Promise.resolve({ ...dados, calculado_em: new Date().toISOString(), ativo: true }), ); mockRepo.reordenarPosicoes.mockResolvedValue(undefined);
mockOffsetRepo.seed.mockResolvedValue(undefined); mockOffsetRepo.buscarTodos.mockResolvedValue([]); mockOffsetRepo.upsert.mockResolvedValue(undefined);
mockEventBus.obterMaiorSequence.mockResolvedValue(100); mockEventBus.replayDeSequence.mockResolvedValue([]); mockEventBus.publicar.mockResolvedValue({ sequence_number: 10n, event_id: 'pub-evt-id', tipo: 'demanda.ranqueada', }); mockEventBus.inscrever.mockImplementation(() => {});});O d4-schema.spec.ts verifica a coluna ativo, a tabela d4.demandas_agregadas e a migration 20260820140000_d4_agregacao.
7.2 Cenários de teste críticos
Seção intitulada “7.2 Cenários de teste críticos”Happy path:
| # | Cenário | Verificação |
|---|---|---|
| T1 | processarDemandaCategorizada() com payload completo, confiança alta |
calcularScore() com payload correto. Upsert em d4.rankings. atualizarRanking() em memória. publicar('demanda.ranqueada') chamado com breakdown. Cursor atualizado. |
| T2 | iniciar() sem eventos perdidos |
replayDeSequence() chamado. inscrever() registrado para demanda.categorizada, parâmetros.atualizados e duplicidade.agregada. Ranking reconstruído de d4.rankings. |
| T3 | iniciar() com eventos perdidos |
replayDeSequence() retorna 3 eventos. processarDemandaCategorizada() chamado 3 vezes. |
| T4 | Demanda entra no top 10, ranking.atualizado publicado |
verificarSignificancia() retorna significativa: true, motivo: 'demanda_top10'. publicar('ranking.atualizado') chamado com top 10. |
| T5 | Demanda entra na posição 15 (fora do top 10) | verificarSignificancia() retorna significativa: false. ranking.atualizado NÃO publicado. Apenas demanda.ranqueada. |
| T6 | Primeira posição alterada (nova demanda com score maior que o top 1 atual) | verificarSignificancia() retorna significativa: true, motivo: 'primeira_posicao_alterada'. publicar('ranking.atualizado') chamado. |
Falhas e bordas:
| # | Cenário | Verificação |
|---|---|---|
| T7 | Evento já processado (mesmo event_id), com republicação |
buscarPorEventId() retorna registro. demanda.ranqueada republicado; ranking.atualizado republicado quando a posição é significativa. |
| T8 | Payload sem unidade_cívica_id (schema D-3 v1.0.0 antigo) |
Log.error. Cursor avançado. Evento descartado. |
| T9 | Payload sem nivel_precedencia |
Log.error. Cursor avançado. Evento descartado. |
| T10 | UC sem pesos situacionais configurados | Peso situacional padrão 1.0 (fallback). Log.warn. Cálculo prossegue normalmente. |
| T11 | publicar('demanda.ranqueada') falha |
Registro persiste em d4.rankings. Exceção propagada. Cursor NÃO atualizado. |
| T12 | publicar('ranking.atualizado') falha |
demanda.ranqueada já foi publicado. Exceção propagada após as tentativas do publicarComRetry. Cursor NÃO atualizado. |
| T13 | Reinicialização com ranking em memória vazio | carregarRankingEmMemoria() reconstrói de d4.rankings. Funcionalidade preservada. |
| T14 | Duas demandas com mesmo score_final na mesma UC |
Desempate por demanda_id (UUID) no sort do array em memória. Posição determinística. |
| T15 | peso_situacional = 0 para o nível da demanda (UC com gap zero naquele nível) |
score_final = 0. Demanda vai para o fim do ranking. Cálculo não quebra. |
| T16 | score_final com valor muito alto (ex: 100 × 1.0 × 100 = 10000.00) |
NUMERIC(12,2) suporta até 9999999999.99. Sem risco de overflow no MVP. |
| T17 | Stub onParametrosAtualizados() chamado |
Log.info de evento ignorado. Cursor de parâmetros.atualizados avançado. |
Teste de integração:
| # | Cenário | Verificação |
|---|---|---|
| T18 | Ciclo completo: demanda.categorizada → demanda.ranqueada + ranking.atualizado |
1 linha em d4.rankings (upsert por demanda_id). 1 evento demanda.ranqueada. Se top 10: 1 evento ranking.atualizado. correlacao_id propagado. |
| T19 | Ranking com 3 demandas, nova entra e altera primeira posição | ranking.atualizado publicado com motivo: 'primeira_posicao_alterada'. Top 10 reflete nova ordem. |
| T20 | Replay após reinício: 5 eventos, 2 já processados | 3 novos scores inseridos. 2 demanda.ranqueada republicados. ranking.atualizado republicado nos casos significativos. |
| T21 | Processo reinicia durante operação | Ranking em memória perdido. iniciar() reconstrói de d4.rankings. Nenhum evento duplicado (idempotência por event_id). |
| T22 | Boot com d4.rankings populado |
carregarRankingEmMemoria() reconstrói ranking e top1Cache. Ranking funcional em memória. |
7.3 Dados de seed para desenvolvimento local
Seção intitulada “7.3 Dados de seed para desenvolvimento local”-- Scores de exemplo para uma UC de desenvolvimento, com o peso situacional padrão 1.0INSERT INTO d4.rankings ( demanda_id, unidade_civica_id, categoria_id, nivel_precedencia, score_final, peso_nacional, peso_situacional, score_horizontal, versao_parametros, posicao, event_id, correlacao_id)VALUES ( 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', '00000000-0000-0000-0000-000000000001', '1.1', 1, 9500.00, 100, 1.0, 95, 'mvp-v1', 1, '20000000-0000-0000-0000-000000000001', '20000000-0000-0000-0000-000000000001' ), ( 'b2c3d4e5-f6a7-8901-bcde-f12345678901', '00000000-0000-0000-0000-000000000001', '3.1', 3, 5100.00, 60, 1.0, 85, 'mvp-v1', 2, '20000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000002' ), ( 'c3d4e5f6-a7b8-9012-cdef-123456789012', '00000000-0000-0000-0000-000000000001', '3.4', 3, 4680.00, 60, 1.0, 78, 'mvp-v1', 3, '20000000-0000-0000-0000-000000000003', '20000000-0000-0000-0000-000000000003' );O cursor de d4.consumer_offset é seedado no boot por iniciar() com obterMaiorSequence(); não há passo manual de seed. Sem Redis no MVP, também não há passo de seed de cache: o ranking em memória é reconstruído de d4.rankings no boot.
8. Alinhamento com o MVP
Seção intitulada “8. Alinhamento com o MVP”8.1 O que é MVP obrigatório
Seção intitulada “8.1 O que é MVP obrigatório”| Funcionalidade | Status |
|---|---|
inscrever('demanda.categorizada') com handler idempotente por event_id |
MVP obrigatório |
Consumo de duplicidade.agregada com desativação de membros e recálculo das posições |
MVP obrigatório |
Validação de payload mínimo (demanda_id, categoria_id, nivel_precedencia, score_horizontal, unidade_cívica_id) |
MVP obrigatório |
Cálculo da fórmula score_final = Pn × Ps × Sh, com arredondamento em 2 casas |
MVP obrigatório |
| Pesos nacionais e situacionais carregados de config estática; o score horizontal vem no payload | MVP obrigatório |
Breakdown completo persistido em d4.rankings com versão dos parâmetros |
MVP obrigatório |
Ranking por UC em memória (Map no D4Service), reconstruído de d4.rankings no boot |
MVP obrigatório |
Write model do ranking em d4.rankings (PostgreSQL) |
MVP obrigatório |
Publicação de demanda.ranqueada com breakdown e posição |
MVP obrigatório |
Publicação de ranking.atualizado em mudanças significativas (top 10, 1ª posição) |
MVP obrigatório |
Republicação de demanda.ranqueada no replay, com ranking.atualizado quando a posição é significativa |
MVP obrigatório |
Stub de parâmetros.atualizados (log e avanço de cursor) |
MVP obrigatório |
Consumer offset para demanda.categorizada e duplicidade.agregada |
MVP obrigatório |
Propagação de correlacao_id |
MVP obrigatório |
Logs estruturados com demanda_id, unidade_civica_id, score_final, posicao e event_id |
MVP obrigatório |
8.2 Simplificações válidas no MVP
Seção intitulada “8.2 Simplificações válidas no MVP”| Simplificação | Justificativa | Quando remover |
|---|---|---|
Pesos fixos em configuração estática (formula-params.config.ts) |
Sem sistema de parametrização dinâmica em produção no MVP. Os valores de referência do Apêndice B são suficientes para fechar o ciclo. | Substituir por consumo do evento parâmetros.atualizados da D-19 na Fase 2. |
peso_situacional estático por UC e nível |
O cálculo real 1 - (cobertura_atingida / cobertura_meta) depende de dados da L-4 (Análise de Cobertura, Fase 2) e D-19. No MVP, valores manuais em config simulam UCs com diferentes níveis de cobertura. |
Migrar para cálculo dinâmico com dados reais da L-4 e parâmetros da D-19. |
| Sem recalculação em lote por mudança de parâmetros | Inexistente no MVP; os parâmetros não mudam. Na Fase 2, parâmetros.atualizados dispara recalculação do ranking para todas as UCs afetadas. |
Implementar onParametrosAtualizados() com recalculação em lote. |
Sem consumo de demanda.removida_por_votação e peso_situacional.atualizado |
Votação (D-10) e ajuste de peso situacional (D-19) são Fase 2. A D-4 não registra consumidores para esses tipos no MVP. | Implementar handlers com remoção de ranking e recalculação por UC. |
| Sem distribuição por decaimento na D-4 | Aplicada pela D-5 (Agenda). Separação de responsabilidades: D-4 prioriza, D-5 distribui capacidade. | Mantido como está; não é simplificação, é design. |
| Ranking em memória (sem Redis) | Monolito de processo único; o Map é mais rápido que Redis (zero latência de rede) e elimina dependência de infraestrutura. Reconstruído do PostgreSQL no boot. |
Migrar para Redis sorted sets quando houver múltiplas instâncias da API (Fase 2). |
| Sem endpoint REST para consulta de ranking | A consulta pública é feita pela D-7 (Transparência) na sua projeção de leitura própria; a D-5 (Agenda) mantém projeção em d5.backlog_scores. Nenhuma colônia lê o estado interno da D-4. |
Adicionar endpoint administrativo se necessário na Fase 2. |
| Sem fatores de ajuste além dos três principais | Urgência (D-13) e remoção por votação (D-10) são Fase 2. A fórmula MVP é Pn × Ps × Sh. |
Adicionar coluna fatores_ajuste (JSONB) e multiplicadores na fórmula. |
8.3 O que vai para a Fase 2
Seção intitulada “8.3 O que vai para a Fase 2”- Consumo de
parâmetros.atualizadoscom recalculação dinâmica de ranking para todas as UCs - Consumo de
demanda.removida_por_votaçãocom remoção da demanda do ranking - Consumo de
peso_situacional.atualizadocom recalculação do ranking da UC específica - Integração com a D-13 (Classificação de Risco): fator de urgência como multiplicador adicional no score
- Integração com a D-10 (Votação): re-ranqueamento por resultado de votação de priorização
versao_parametrosdinâmico (versão do evento da D-19)- Recalculação em lote com estratégia de chunking para UCs com muitas demandas
- Métricas Prometheus:
d4_demandas_ranqueadas_total,d4_score_final_distribution,d4_ranking_size_by_uc,d4_recalculacao_duration_ms - Tabela
d4.param_versionscom histórico de versões de parâmetros usadas - Coluna
fatores_ajuste(JSONB) emd4.rankingspara multiplicadores adicionais
8.4 Verificação de conflitos com outras colônias
Seção intitulada “8.4 Verificação de conflitos com outras colônias”Conflito potencial: a D-4 depende de unidade_cívica_id no evento demanda.categorizada (v1.1.0). E se a D-3 ainda estiver na v1.0.0?
Avaliação: a D-3 publica v1.1.0. A D-4 valida a presença do campo e descarta eventos sem ele. O deploy deve ser ordenado: D-3 primeiro, depois D-4. Se a ordem for invertida, a D-4 opera em modo degradado (descarta eventos até a D-3 ser atualizada). O teste T8 cobre este cenário.
Conflito potencial: a D-5 (Agenda) consome ranking.atualizado para reconstruir backlog, mas ranking.atualizado só é publicado em mudanças significativas. A D-5 pode perder atualizações?
Avaliação: a D-5 também consome demanda.ranqueada individualmente. O ranking.atualizado é sinalização adicional de “vale a pena reconstruir o backlog desta UC”. A D-5 deve manter seu próprio controle de quando reconstruir. O ranking.atualizado é gatilho, não fonte única. Sem conflito: a D-5 pode decidir reconstruir o backlog por outros critérios (timer, polling, primeiro demanda.ranqueada do dia).
Conflito potencial: sem Redis no MVP, não há infraestrutura compartilhada entre colônias. Quando a Fase 2 introduzir Redis (D-2, L-2 e D-4), há risco de interferência?
Avaliação: nenhum. Os prefixos planejados são disjuntos (d2:geo:*, l2:geo:*, l2:osm:*, d4:ranking:*). Estruturas de dados diferentes (sorted set vs. string) não colidem. Cada colônia gerencia seu próprio TTL e ciclo de vida. O Redis será infraestrutura compartilhada com isolamento lógico por prefixo, o mesmo padrão de schemas no PostgreSQL.
Conflito potencial: a D-4 usa demanda.categorizada com metodo = 'revisao_pendente'? A D-3 publica esse evento?
Avaliação: a D-3 NÃO publica demanda.categorizada para demandas com metodo = 'revisao_pendente', porque elas aguardam decisão do moderador. O campo metodo existe no payload para distinguir automatico de manual quando o evento é publicado. A D-4 não precisa tratar revisao_pendente porque esse caso nunca chega ao barramento. Se chegar (bug na D-3), a D-4 processa normalmente e ranqueia com a categoria sugerida.
Conflito potencial: a D-4 armazena a posição em d4.rankings, mas a posição muda quando novas demandas entram. O valor fica desatualizado?
Avaliação: a coluna posicao guarda a posição corrente. Cada inserção ou desativação de membro de agregado recalcula as posições da UC afetada (reordenarPosicoes()) e o ranking em memória acompanha. A posição de entrada no momento do cálculo fica registrada no evento demanda.ranqueada publicado (posicao_no_ranking).
Referências
Seção intitulada “Referências”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “D-4 — Priorização e Ranking”
- Fórmula de priorização e gestão: contexto_IA.md, seção 7 (A Gestão)
- Taxonomia e scores horizontais: D-3 - Taxonomia.md
- Schemas de eventos: N-0b - Registry.md
- Barramento de eventos: N-0a - Event Bus.md
- Observabilidade: N-0c - Observabilidade.md
- Colônias a montante: D-3 - Categorização.md
- Colônias a jusante: D-5 - Agenda.md, D-7 - Transparência.md
- Stack de referência e arquitetura do MVP: Apêndice B - Colônias.md, seção “Arquitetura do MVP — Monolito Modular”
- Mapa de dependências de eventos: Apêndice B - Colônias.md, seção “Mapa de Dependências de Eventos entre Colônias”
- Princípios do Formigueiro: Apêndice B - Colônias.md, seção “Princípios herdados do Formigueiro”
Documento de especificação técnica de implementação.