Skip to content

feat(reports): compare two metrics.json payloads field by field - #250

Merged
clickmatos merged 8 commits into
mainfrom
feat/metrics-parity-comparator
Sep 16, 2026
Merged

clickmatos merged 8 commits into
mainfrom
feat/metrics-parity-comparator

Conversation

@cpenaforte-clickbus

@cpenaforte-clickbus cpenaforte-clickbus commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Contexto e Motivação

O engine tem de provar que uma mudança interna não alterou o que ele emite. Duas frentes dependem disso e nenhuma tinha mecanismo: a otimização da busca de PR, cujo critério de aceite é metrics.json idêntico antes e depois no mesmo commit e na mesma janela; e a medição de paridade entre uma execução centralizada e uma local, que é critério de bloqueio do rollout.

Sem isso, "idêntico campo a campo" era afirmação que ninguém conseguia verificar sem abrir dois arquivos lado a lado e confiar no olho.

Closes #214

Mudanças

  • iris/reports/metrics_diff.py — achata um payload em caminhos pontuados (grupo.subgrupo.chave, [i] para itens de lista) e reporta cada chave que difere, com os dois lados. A comparação é estrita em tipo: em Python True == 1 e 1 == 1.0, mas uma troca de tipo é mudança real no JSON emitido, e num payload de verdade 8 das 48 chaves float carregam valor integral (cascade_rate = 0.0, churn_couplings[0].coupling_rate = 1.0) — exatamente as que colidiriam com int. Container vazio achata para si mesmo, para que uma seção que sumiu não se confunda com chave que nunca existiu; a sentinela de ausência é objeto próprio porque None é valor legítimo no payload.
  • scripts/compare_metrics.py — CLI no padrão do check_analysis_chain.py, com saídas 0 idênticos, 1 divergentes, 2 entrada inutilizável. Os quatro casos de entrada inutilizável (arquivo ausente, argumento ausente, caminho que é diretório, bytes não-UTF-8) saem 2; antes, diretório e não-UTF-8 escapavam como traceback e saíam 1, que no contrato do próprio script significa "divergente".
  • tests/test_metrics_determinism.py — constrói um repositório real, roda o engine duas vezes e exige payloads iguais. Um segundo teste garante que a durabilidade rodou de fato, para o primeiro não passar por vacuidade. O repositório temporário fixa commit.gpgsign e core.hooksPath no próprio repo, para não depender da config global de git de quem roda: uma exigência global de assinatura, ou hooks herdados de um init.templateDir, quebrariam um teste cuja razão de existir é ser confiável na máquina de outra pessoa.
  • A lista de ignorados nasce vazia, por medição: duas execuções no mesmo commit não divergiram em 103 nem em 190 chaves achatadas. Os dois candidatos derivados de tempo ficam documentados no código, com o motivo pelo qual seguem não provados, para entrarem só com prova de deriva num run real.

Plano de Teste

  • pytest tests/test_metrics_diff.py -v — 23 testes cobrindo achatamento, divergência de valor e de tipo, ausência de chave em cada lado, ordenação, ignorados exatos e por curinga de um segmento, e as cinco saídas do CLI
  • pytest tests/test_metrics_determinism.py -v — 2 testes: duas execuções do engine no mesmo commit batem, e a durabilidade rodou de fato
  • pytest tests/ -q — a suíte inteira, sem regressão
  • Manual: python scripts/compare_metrics.py a.json b.json com dois arquivos que diferem em uma chave imprime o caminho e sai 1

A verificação roda fora da máquina de quem construiu: o job cli do CI executa pytest tests/ -q a cada push.

Impacto e Risco

Risco baixo. Nada existente muda de comportamento: dois arquivos novos, um módulo novo, nenhuma assinatura pública alterada, nenhuma dependência nova — só stdlib. Nenhuma migração, nenhuma rota, nenhum contrato de dados. Nenhum arquivo sob .github/ foi tocado: o portão entra no pytest tests/ -q que o job cli já roda.

O único efeito no que já roda é o tempo de CI: o teste de determinismo executa o engine três vezes, contra repositórios temporários de 6 commits.

Sobre o tamanho (RFC 0028 §2.4): 494 linhas alteradas, faixa 251–500, que pede justificativa de por que não foi dividido. A justificativa é que o comparador e seu único consumidor não deveriam entrar separados — o módulo chegaria à main sem chamador, e o portão de determinismo chegaria sem o comparador de que depende. São oito commits atômicos dentro de uma mudança coerente, que é o que a RFC pede de um PR.

Plano de Rollback

git revert dos commits deste PR. Não há estado a desfazer: sem migração, sem feature flag, sem configuração. Reverter devolve o repositório ao que era, e o único efeito é o CI voltar a não ter o portão de determinismo.

Referências

Autoria Assistida

  • Agente: Claude Code — claude-sonnet-5 nos commits, claude-opus-5 na orquestração e nas revisões
  • Escopo da assistência: medição do determinismo do engine que fundamenta a lista de ignorados vazia, desenho do módulo e do CLI, implementação guiada por testes, e as duas correções vindas da revisão de branch inteira (comparação estrita em tipo e códigos de saída do CLI)
  • Revisão humana: pendente — o autor revisa antes do merge

cpenaforte-clickbus and others added 8 commits September 14, 2026 18:31
The acceptance criterion for the PR-fetch optimization is that metrics.json
comes out identical before and after, on the same commit and window - and the
pilot measures the same thing between the worker and a local run. Neither was
verifiable by reading two files side by side.

Flattens each payload into dotted paths and reports every key that differs,
with both sides. Empty containers flatten to themselves so a section that
vanished is not mistaken for a key that was never there, and the missing
sentinel is its own object because None is a legitimate value in the payload.

The ignore list ships empty, by measurement: two runs of the engine on the
same commit produced identical output across 103 and 190 flattened keys. The
two time-derived candidates are documented in place, to be added only when a
real run proves the drift.

Assisted-by: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The comparison is only useful if it says where the two payloads part ways.
Each divergence prints the dotted key path and both sides, so a failing
parity check points at the field instead of at the file.

Exit codes follow the check_analysis_chain.py precedent: 0 identical,
1 divergent, 2 unusable input. That is what lets the pilot compare the
worker's output against a local run without a human reading either file.

Assisted-by: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Parity between a centralized run and a local one only means something if the
engine is deterministic to begin with. Nothing checked that, so the claim
rested on an assumption.

Builds a real repository whose files are touched across several commits -
enough for durability to run, which is the path that reads git blame and the
current clock, and therefore the likeliest to drift - then runs the engine
twice and requires the payloads to match. A second test asserts durability
actually ran, so the first cannot pass vacuously.

Assisted-by: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Python treats True == 1 and 1 == 1.0 as equal, so compare_metrics missed a
bool-vs-int or int-vs-float flip between two payloads - and the payload does
carry booleans alongside numerics (commit_metrics_reliable is one). A type
change between two runs is a real non-determinism in the emitted JSON: json
round-trips a 0 and a 0.0 as different types. The comparator's whole value is
that its verdict can be trusted, so it now diverges on type or value, not
value alone.

Also documents flatten_metrics's dotted-path assumption: a key containing
"." or "[" would collide with a nested path. Latent today - no current
metrics key contains either - but the module is what the pilot will reuse,
so the assumption belongs where the next reader meets it.

Assisted-by: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_load only caught FileNotFoundError and json.JSONDecodeError, so a directory
passed by mistake or a non-UTF-8 file fell through to an uncaught traceback,
which exits 1 - the code this script's own docstring reserves for
"divergent". An operator or a scripted gate reading that exit code goes
looking for a divergence that was never there, when the real problem is that
the input could not be read at all. Failing closed was already correct; only
the reported reason was wrong.

Broadens the first arm to OSError, which covers FileNotFoundError,
IsADirectoryError and PermissionError alike, and adds UnicodeDecodeError to
the second arm alongside JSONDecodeError. Both still exit 2 and name the
path; the messages are reworded so they read correctly across every case
each arm now catches.

Assisted-by: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extract _run_cli next to _write so each of the five CLI tests invokes
the comparator in one line instead of repeating the same four-line
subprocess.run block verbatim. Also move the mid-file json/subprocess
imports, the format_divergences import, and the SCRIPT constant up to
the top of the file with the rest of the module's imports, clearing an
E402 a strict linter would flag.

No behavioural change: every assertion is untouched.

Assisted-by: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Compress restated or re-derived reasoning down to the sentence that
carries it: the metrics_diff module docstring's two-reason preamble,
the IGNORED_PATHS block (drops the 103/190-key and rounding arithmetic,
which the commit history already carries and test_ignore_list_ships_empty
enforces), the empty-root guard down to one line, and the flatten_metrics
/ path_is_ignored / format_divergences docstrings. compare_metrics.py's
module docstring drops the design-doc preamble duplicated from the
library module and keeps its own Usage/Exit codes contract untouched.
Both test modules lose their redundant "runnable as pytest ..." line,
and test_metrics_determinism.py's _build_repo docstring and console-script
comment are reworded to the same point in fewer words.

Every comment that explains a decision the code can't state itself —
why MISSING isn't None, why empty containers flatten to themselves,
why the empty root is a separate case, why keys can't contain "." or
"[", why the comparison is type-strict, why "*" matches one segment,
both unproven IGNORED_PATHS candidates, why the vacuity guard test
exists, why the console script and not `python -m iris`, why the repo
is hermetic against global git config — is kept, just shorter.

Assisted-by: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous comment pass trimmed the median_age_days and open_pr_aging
candidates down to how each value is computed, dropping why each is
still unproven. Restore both: the rounding that absorbs ~2.4h of drift
(why the median_age_days pair has never actually been seen diverging),
and that open_pr_aging.* was never exercised because the measurements
behind the empty list came from repositories with no PRs. Both tell a
future reader what to check before adding an entry, which is the point
of the list. The 103/190-key arithmetic stays out — it lives in the
commit history and test_ignore_list_ships_empty enforces it directly.

Assisted-by: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cpenaforte-clickbus cpenaforte-clickbus added type: feature Nova funcionalidade ai-assisted PR gerado total ou parcialmente com auxílio de agente de IA (RFC 0028 §2.2.7) labels Sep 15, 2026
@vercel

vercel Bot commented Sep 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
clickbus-iris Ready Ready Preview Sep 15, 2026 5:25pm UTC

Request Review

@kody-ai

kody-ai Bot commented Sep 15, 2026

Copy link
Copy Markdown

Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Validate Business Logic: Ask Kody to validate your code against business rules by adding a comment with the @kody -v business-logic command.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug
Performance
Security
Business Logic

Access your configuration settings here.

Comment on lines +67 to +72
if isinstance(payload, dict) and payload:
flat: dict[str, Any] = {}
for key, value in payload.items():
child = f"{prefix}.{key}" if prefix else str(key)
flat.update(flatten_metrics(value, child))
return flat

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

flatten_metrics merges children with flat.update(), so two distinct keys that concatenate to the same dotted path (e.g., {"a": {"b.c": 1}} and {"a": {"b": {"c": 1}}}, or sibling keys {"a.b": 1, "a": {"b": 2}}) silently overwrite each other and collapse into one leaf. This causes compare_metrics to report [] — 'metrics.json identical field by field' — despite a payload structure change, producing the exact false negative this PR-fetch-gate exists to catch. Detect the collision during flatten and raise a ValueError naming the colliding raw keys (or return a collision list for the caller to surface) instead of silently keeping the last write.

for key, value in payload.items():
    child = f"{prefix}.{key}" if prefix else str(key)
    child_flat = flatten_metrics(value, child)
    for p in child_flat:
        if p in flat:
            raise ValueError(f"key collision after flattening: {p!r}")
    flat.update(child_flat)
Prompt for LLM

File iris/reports/metrics_diff.py:

Line 67 to 72:

flatten_metrics merges children with flat.update(), so two distinct keys that concatenate to the same dotted path (e.g., {"a": {"b.c": 1}} and {"a": {"b": {"c": 1}}}, or sibling keys {"a.b": 1, "a": {"b": 2}}) silently overwrite each other and collapse into one leaf. This causes compare_metrics to report [] — 'metrics.json identical field by field' — despite a payload structure change, producing the exact false negative this PR-fetch-gate exists to catch. Detect the collision during flatten and raise a ValueError naming the colliding raw keys (or return a collision list for the caller to surface) instead of silently keeping the last write.

Suggested Code:

    for key, value in payload.items():
        child = f"{prefix}.{key}" if prefix else str(key)
        child_flat = flatten_metrics(value, child)
        for p in child_flat:
            if p in flat:
                raise ValueError(f"key collision after flattening: {p!r}")
        flat.update(child_flat)

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

return False


def compare_metrics(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

The new parsing/comparison functions (flatten_metrics, path_is_ignored, compare_metrics, format_divergences) lack unit tests, yet they serve as the acceptance criterion for the PR-fetch optimization and rollout parity gate. A regression in recursive flattening, wildcard path matching, or type-strict comparison would silently break field-by-field equality checks. Add a test module (e.g., iris/reports/test_metrics_diff.py) covering flatten_metrics with nested dicts/lists/empty containers and the empty-root edge case; path_is_ignored with single-segment wildcards; compare_metrics type strictness (True vs 1, int vs float), missing keys on each side, and ignored paths; and format_divergences for both empty and non-empty output.

Kody rule violation: Unit test complex pure parsing functions

Prompt for LLM

File iris/reports/metrics_diff.py:

Line 100:

The new parsing/comparison functions (flatten_metrics, path_is_ignored, compare_metrics, format_divergences) lack unit tests, yet they serve as the acceptance criterion for the PR-fetch optimization and rollout parity gate. A regression in recursive flattening, wildcard path matching, or type-strict comparison would silently break field-by-field equality checks. Add a test module (e.g., iris/reports/test_metrics_diff.py) covering flatten_metrics with nested dicts/lists/empty containers and the empty-root edge case; path_is_ignored with single-segment wildcards; compare_metrics type strictness (True vs 1, int vs float), missing keys on each side, and ignored paths; and format_divergences for both empty and non-empty output.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

@clickmatos
clickmatos merged commit 996ef97 into main Sep 16, 2026
5 checks passed
@clickmatos
clickmatos deleted the feat/metrics-parity-comparator branch September 16, 2026 20:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-assisted PR gerado total ou parcialmente com auxílio de agente de IA (RFC 0028 §2.2.7) type: feature Nova funcionalidade

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] Comparador de paridade do metrics.json, campo a campo

2 participants