feat(reports): compare two metrics.json payloads field by field - #250
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Code Review Completed! 🔥The code review was successfully completed based on your current configurations. Kody Guide: Usage and ConfigurationInteracting with Kody
Current Kody ConfigurationReview OptionsThe following review options are enabled or disabled:
|
| 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 |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
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.jsonidê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 PythonTrue == 1e1 == 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 comint. 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 porqueNoneé valor legítimo no payload.scripts/compare_metrics.py— CLI no padrão docheck_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 fixacommit.gpgsignecore.hooksPathno 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 uminit.templateDir, quebrariam um teste cuja razão de existir é ser confiável na máquina de outra pessoa.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 CLIpytest tests/test_metrics_determinism.py -v— 2 testes: duas execuções do engine no mesmo commit batem, e a durabilidade rodou de fatopytest tests/ -q— a suíte inteira, sem regressãopython scripts/compare_metrics.py a.json b.jsoncom dois arquivos que diferem em uma chave imprime o caminho e sai 1A verificação roda fora da máquina de quem construiu: o job
clido CI executapytest tests/ -qa 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 nopytest tests/ -qque o jobclijá 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 à
mainsem 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 revertdos 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
metrics.jsonidêntico campo a camposcripts/check_analysis_chain.pyAutoria Assistida
claude-sonnet-5nos commits,claude-opus-5na orquestração e nas revisões