Pular para o conteúdo

D-4 — Priorização e Ranking

Parte do Ciclo de Demandas — Fase 1


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.


  • 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).

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.

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_offset

O 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.

@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();
}
}
  • 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 EventBusModule explicitamente. EventBusModule é @Global(), e o EventBusService é 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 OnModuleInit dispara 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 evento parâmetros.atualizados da D-19 (Governança de Parâmetros).
  • O ranking vive em Map em memória no D4Service (rankingPorUc por UC, top1Cache), reconstruído de d4.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 de duplicidade.agregada e o stub de parâmetros.atualizados, que apenas registra log e avança o cursor. Não há consumidores de demanda.removida_por_votação e peso_situacional.atualizado no MVP.
// Métodos públicos do D4Service
iniciar(): Promise<void>
processarDemandaCategorizada(evento: EventoRecebido): Promise<void>
processarDuplicidadeAgregada(evento: EventoRecebido): Promise<void>
registrarConsumidores(): void
calcularScore(payload): ScoreResultado
rankingDeUc(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.

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_agregadas registra os membros de agregados de duplicidade.
  • Memória (Map no D4Service): ranking corrente por UC (rankingPorUc) e cache do último top 1 (top1Cache), sincronizados após cada persistência em PostgreSQL. Reconstruídos de d4.rankings no iniciar(). 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 eventos demanda.ranqueada e ranking.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.


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.

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);
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.

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);

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.

Três migrations:

  1. 20260809212816_add_d4_rankings — Cria o schema d4 e a tabela d4.rankings com PK em demanda_id, coluna posicao anulável e os índices rankings_unidade_civica_id_idx, rankings_event_id_idx e rankings_unidade_civica_id_posicao_idx.
  2. 20260813112406_add_consumer_offset_d1b_d2_d4 — Cria d4.consumer_offset com PK em tipo_evento, na mesma migration que cria as tabelas equivalentes de D-1b e D-2.
  3. 20260820140000_d4_agregacao — Adiciona a coluna ativo com default true e cria d4.demandas_agregadas com PK em demanda_id e os índices demandas_agregadas_evento_id_idx e demandas_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.

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.

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.


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
}
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
}
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
}
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ória
3. Recalcular as posições das linhas ativas de cada UC afetada
4. 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 terminal

Guarda 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.categorizada
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.

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.


score_final = peso_nacional(nivel_precedencia)
× peso_situacional(unidade_cívica_id, nivel_precedencia)
× score_horizontal(categoria_id)

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.

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.

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.

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.

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”

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.

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 backlog
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.

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.

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.

A D-4 não consome projeções de leitura de outras colônias. Os dados externos que acessa são:

  • core.event_log via EventBusService.replayDeSequence(), dependência do núcleo permitida.

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.

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.


A D-4 não aplica rate limiting. O volume de eventos é limitado indiretamente pelo rate limiting do BFF (D-1a).

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.
Í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.

  • buscarPorEventId(): 1 query por evento demanda.categorizada recebido (idempotência).
  • buscarAgregacaoPorDemandaId(): 1 query por evento demanda.categorizada não processado (guarda de evento tardio).
  • inserir(): 1 upsert em d4.rankings por evento.
  • reordenarPosicoes(): N updates em d4.rankings por evento (N = demandas da UC; pior caso, todas as posições).
  • listarAtivos(): 1 query na inicialização (reconstrução do cache).
  • registrarDemandaAgregada(), buscarPorDemandaId() e marcarInativa(): por membro em duplicidade.agregada.

Volume esperado no MVP: < 100 demandas/dia. Tempo médio de processamento: < 3ms com ranking em memória.

  • Ranking em memória: Map<uc_id, EntradaRanking[]> com ordenação via Array.sort(). Consulta de posição O(N), irrelevante para o volume do MVP (< 1.000 demandas/UC). Reconstruído de d4.rankings na inicialização.
  • Cache de top 1: Map<uc_id, {demanda_id, score_final}> em memória. Inicializado na partida. Usado por verificarSignificancia() 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.

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

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.

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.categorizadademanda.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.
-- Scores de exemplo para uma UC de desenvolvimento, com o peso situacional padrão 1.0
INSERT 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.


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
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.
  • Consumo de parâmetros.atualizados com recalculação dinâmica de ranking para todas as UCs
  • Consumo de demanda.removida_por_votação com remoção da demanda do ranking
  • Consumo de peso_situacional.atualizado com 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_parametros dinâ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_versions com histórico de versões de parâmetros usadas
  • Coluna fatores_ajuste (JSONB) em d4.rankings para 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).



Documento de especificação técnica de implementação.