Skip to content

feat(verifier): dashboard fidelity verification framework - #212

Merged
shmsr merged 19 commits into
mainfrom
feat/dashboard-verification-framework
Jun 24, 2026
Merged

feat(verifier): dashboard fidelity verification framework#212
shmsr merged 19 commits into
mainfrom
feat/dashboard-verification-framework

Conversation

@shmsr

@shmsr shmsr commented Jun 23, 2026

Copy link
Copy Markdown
Member

Summary

Adds a layered, oracle-backed dashboard fidelity verification framework on top of the existing 5-tier panel verifier. The 5-tier verifier proves a panel's ES|QL text does not mutate across the pipeline; this proves the Lens visualization wiring is internally consistent - the defect class behind multi-label legend collapse (#189), which text-drift, smoke.py, and comparators.py all miss.

Guiding principle: borrow real engines as oracles instead of re-implementing correctness.

What's added

  • Layer 9 - deterministic invariant linter (parity-rig/verifier/invariants.py): offline checks over migration_report.json (intended query_ir vs emitted visual_ir):
    • ACCESSOR_BROKEN - a dimension/metric/breakdown references a column the query never produces.
    • VISUAL_SEMANTIC_DRIFT - >1 grouping dimension collapsed onto a single XY breakdown; ERROR if silent, WARNING if disclosed.
    • BREAKDOWN_LEGEND_MISMATCH - breakdown bound to legend with no EVAL legend = CONCAT(...).
    • PLACEHOLDER_DROPPED - a chart silently became a markdown placeholder.
    • Column truth from an embedded ES|QL parser, upgradeable to Elasticsearch's authoritative POST /_query columns (Oracle 1) via --live-oracle.
  • Layer 7 (test_promql_property.py) - Hypothesis no-crash / determinism / label-conservation over generated PromQL.
  • Layer 8 (test_promql_metamorphic.py) - semantics-preserving mutations (whitespace, by() / matcher reordering) must not change translation structure.
  • Layer 11 (test_panel_matrix.py) - combinatorial matrix (300 cells) through the real translate_panel pipeline + invariant lint.
  • Layer 12 (test_verifier_invariants.py) - self-test: corrupt an artifact, assert the exact category fires.
  • Layer 13 (scorecard.py) - fidelity scorecard + one-way regression ratchet for CI.

Wiring

  • obs-migrate verify-panels now emits invariant_findings / invariant_summary and gains --no-invariants, --live-oracle, --fail-on-invariant.
  • Standalone python -m verifier.scorecard for the ratchet gate.
  • The verifier package keeps its zero-dependency-on-observability_migration contract (embedded parser; package parser used only when importable; live result adapter is duck-typed).

What the tooling found

Run across the 300-cell matrix and the property/metamorphic suites: 0 silent fidelity errors. The matrix surfaced 24 disclosed merged-series warnings - all the documented XY single-breakdown degradation, consistently disclosed (never silent), so no new bug to file. The metamorphic relations found no ordering bugs.

Test plan

  • pytest tests/test_verifier_invariants.py tests/test_promql_property.py tests/test_promql_metamorphic.py tests/test_panel_matrix.py tests/test_verifier_scorecard.py (validated against a clean origin/main worktree)
  • Existing tests/test_verifier*.py still pass (no regressions)
  • ruff + source-header checks clean
  • End-to-end verify-panels --fail-on-invariant gates correctly on a broken-accessor fixture

Out of scope (follow-ups, tracked in #211)

  • Layer 10 - adversarial/chaos telemetry against a live cluster.
  • Layer 14 - browser-render differential with stale-state guard.

Closes #211

shmsr added 2 commits June 24, 2026 02:43
The 5-tier verifier proves a panel's ES|QL text does not mutate across the
pipeline, but not that the Lens visualization wiring is internally consistent -
the defect class behind multi-label legend collapse (#189). This adds a layered,
oracle-backed fidelity framework that catches those defects deterministically.

- Layer 9 (invariants.py): offline linter over migration_report.json that flags
  broken accessors, silently merged XY series, legend/breakdown mismatches, and
  silent markdown placeholders. Column truth from an embedded ES|QL parser,
  upgradeable to the live ES POST /_query columns oracle (Oracle 1).
- Layer 7 (test_promql_property.py): Hypothesis no-crash, determinism, and
  label-conservation properties over generated PromQL.
- Layer 8 (test_promql_metamorphic.py): semantics-preserving mutations must not
  change translation structure.
- Layer 11 (test_panel_matrix.py): combinatorial panel matrix through the real
  translate_panel pipeline + invariant lint (300 cells, 0 silent errors).
- Layer 12 (test_verifier_invariants.py): self-test that corrupts artifacts and
  asserts the exact category fires.
- Layer 13 (scorecard.py): fidelity scorecard + one-way regression ratchet.

Wired into obs-migrate verify-panels (--no-invariants / --live-oracle /
--fail-on-invariant) and a standalone verifier.scorecard CLI. The verifier
package keeps its zero-dependency-on-observability_migration contract.

Refs #211
…ositives

Linting node-exporter-full / prometheus-all and a real user corpus end-to-end
surfaced three false-positive classes in the Layer-9 linter (the product
pipeline itself produced no silent fidelity errors):

- ES|QL escapes dotted fields with backticks (`service.instance.id`); the
  breakdown references the unescaped name. Normalize identifiers on both sides
  so escaped columns are not reported as ACCESSOR_BROKEN.
- The emitter reorders/expands panels, so an externally-zipped yaml panel can
  belong to a different panel. report_panel_from_translation now uses the
  pipeline-associated panel_result.visual_ir, falling back to the yaml panel
  only when visual_ir is absent.
- Grafana text/row/dashlist/etc. panels legitimately migrate to markdown;
  exclude these non-data source types from the PLACEHOLDER_DROPPED check.

Added regressions covering backtick columns, text/row->markdown, and
visual_ir-preferred association. Re-running the linter across all bundled +
corpus dashboards now reports 0 ERROR findings (only disclosed merged-series
warnings, the known XY single-breakdown limitation).

Refs #211
@shmsr

shmsr commented Jun 23, 2026

Copy link
Copy Markdown
Member Author

Deep-testing pass: ran the framework against real dashboards end-to-end

Pointed the Layer-9 linter at every bundled fixture (node-exporter-full, prometheus-all, k8s-views-global, diverse-panels-test, home) plus a real user corpus (6 community/exported dashboards), translating each through the actual translate_panel/translate_dashboard pipeline.

Outcome: 0 silent fidelity errors from the product pipeline. All 82 findings are disclosed merged-series warnings - the known XY single-breakdown limitation, which the translator always discloses (composite legend = CONCAT(...) where applicable, or an explicit "visually merged" warning otherwise). No silent series merges, no broken accessors, no silent placeholders.

The pass did surface three false-positive bugs in the linter itself, now fixed in 89ea8c6:

  1. Backtick-escaped fields - ES|QL escapes dotted fields (`service.instance.id`); the breakdown references the unescaped name. Identifiers are now normalized on both sides.
  2. Panel association - the emitter reorders/expands panels, so an externally-zipped yaml panel can belong to a different panel. The live adapter now uses the pipeline-associated panel_result.visual_ir, falling back to the yaml panel only when absent.
  3. Non-data source panels - Grafana text/row/dashlist/etc. legitimately become markdown; these are excluded from PLACEHOLDER_DROPPED.

Regressions added for all three. Net: the linter is now accurate on real-world ES|QL output, and confirms the pipeline degrades honestly.

shmsr added 11 commits June 24, 2026 03:53
Elasticsearch is the real ES|QL parser; the compiler/lint are heuristic. This
module executes each emitted panel query against a live cluster and classifies
the response so genuine translator bugs are separated from unseeded-data noise:

- real_bug: parsing / type / argument / function errors (the emitted ES|QL is
  invalid) - e.g. one-arg PERCENTILE (#213), the ^ power operator (#214).
- data_gap: unknown column / index (well-formed query, telemetry absent).
- ok / other.

The executor is injectable, so the classifier and driver are fully unit-tested
with no cluster (error strings are real captured 9.5 responses). A CLI runs over
a migration_report.json and can gate CI with --fail-on-bug.

This is the oracle that found the quantile and power-operator bugs fixed in the
engine PR; structural compile/lint checks cannot catch runtime-only defects.

Refs #211
The live ES|QL oracle treated any error body containing "Found N problem" as a
data gap. Elasticsearch also wraps real verification/type failures in that
phrase (for example RATE() on a non-counter field), so the oracle could hide
invalid emitted ES|QL from --fail-on-bug.

Classify data gaps only on explicit unknown-column/index/no-index signals, and
add a regression for a captured "Found 1 problem ... first argument ... must be
[counter]" response so it stays a real_bug.

Refs #211
…racles

Expands the dashboard verification framework with four trust-building layers:

- Typed Dashboards API conformance oracle: maps emitted migration visual_ir into
  Kibana's typed /api/dashboards payload for common ES|QL xy/metric/markdown
  panels, validates via a live API client (fake-client tested), and reports
  server-side schema rejection as UI-contract gaps.
- Frozen corpus gate: evaluates one or more obs-migrate compare reports against
  explicit FAIL/ERROR/SHAPE_PASS budgets for repeatable semantic CI.
- Mutation harness: deliberately corrupts otherwise-good reports (broken
  accessors, broken composite legends, silent placeholders) and asserts the
  invariant linter catches the expected category.
- Lens fixture oracle scaffold: defines and validates the consumer-side contract
  for Kibana LensConfigBuilder-generated fixtures, plus coverage checks for
  chart families we claim to support.

These are deliberately independent oracles: schema/UI contract, semantic corpus
budgeting, verifier self-falsification, and authoritative Lens fixture coverage.

Refs #211
…rtifacts

Exercising the new oracles against a real migrated artifact and live Kibana
surfaced two verifier issues:

- The typed Dashboards API ES|QL xy schema uses `breakdown_by`, not `breakdown`.
  Update the conformance converter and regression tests; the oracle now passes
  live against a real migrated dashboard.
- The mutation harness mutated the first panel in a report. Native PROMQL
  passthrough panels have statically-unknown output columns, so accessor/legend
  mutations were skipped on reports that only use native PROMQL panels. Select a
  statically-inferable ES|QL panel, or append a tiny sentinel panel when none
  exists, so mutation self-tests remain meaningful on any artifact set.

Refs #211
Adds a gate for the exact success metrics tracked by the benchmark UI:

- dashboards migrated %
- dashboards clean %
- panels migrated %
- panels clean %
- panels verified %
- optional duration increase

The gate loads benchmark_history.json-style data, picks the most recent
compatible baseline (same Grafana/Datadog config and same schema-discovery
class), and fails when the latest run drops beyond the configured percentage
point budget. It also aggregates grafana/datadog leg metrics when `overall` is
missing, matching the tools UI formulas.

This turns the trend chart into a pre-merge/CI guard instead of an after-merge
surprise.

Refs #211
Reviewing the PM benchmark gate against the dashboard UI/server formulas exposed
two ways a gate could miss or misclassify regressions:

- Comparing a run against an earlier run of the same CLI hash can turn rerun
  noise into a baseline. The gate now skips same-hash baselines by default and
  compares the latest run to the most recent compatible different hash, with an
  explicit --allow-same-hash-baseline override.
- Percentage metrics can stay flat while the corpus denominator drops (for
  example fewer panels/dashboards verified). The gate now also tracks count
  regressions for dashboards, panels_total, and verification_total via
  --max-count-drop.

Adds regressions for same-hash reruns, denominator drops, verification coverage
drops, count tolerances, and current-index bounds.

Refs #211
Add screenshot-like benchmark-history cases for the observed 9864c90 -> c658c9a
regression: the gate fails on the material dashboard migrated/clean and panel
verified drops even with a 0.5pp percentage tolerance and small count-drop
allowance. Also cover the adjacent stable 14c0a94 -> 9864c90 transition, where a
2-panel verification denominator drift should pass with a small count tolerance.

Refs #211
Add a deterministic corpus-manifest builder so benchmark size can grow without
turning into a noisy "top N today" sample. The manifest combines:

- top Grafana dashboards by downloads
- deterministic long-tail rank slices
- datasource-stratified quotas using the tools repo datasource map
- pinned bug-seed dashboard IDs

The output is plain JSON with de-duplicated all_ids, suitable for PR/nightly
benchmark runners. Tests cover deterministic long-tail sampling, datasource
quotas, bug seed preservation, catalog loading, and CLI shape.

Refs #211
The PM benchmark UI can filter history by source (Grafana/Datadog) and by
Grafana datasource. Overall metrics alone do not protect those filtered slices.

Add source and Grafana datasource filtering to benchmark_gate, mirroring the UI:

- --source grafana/datadog selects benchmark legs.
- --grafana-datasource plus --grafana-datasource-map recomputes Grafana metrics
  from per-dashboard results for the selected datasource slice.
- legacy grafana_esql_* rows and Datadog rows are excluded from Grafana
  datasource slices, matching the UI.

Adds tests for source filtering, datasource-slice recomputation, no-match skips,
legacy row exclusion, and a datasource-specific regression.

Refs #211
Add command-contract documentation for the layered verifier tools: live ES|QL
validation, typed Dashboards API conformance, semantic corpus gates, PM
benchmark-history gates, mutation checks, Lens fixtures, and stratified corpus
manifest generation.

Also update agent guidance so dashboard regression work uses these gates instead
of relying on one migrated/clean percentage. Call out denominator drops,
datasource-filtered slices, and pinned stratified corpora as required practice.

Refs #211
The typed Dashboards API oracle could pass while mapping only a small subset of
panels and reporting the rest as informational unsupported findings. Add explicit
coverage budgets so callers can gate on the amount of the dashboard that was
actually checked:

- --max-unsupported limits unsupported_by_api_oracle findings.
- --min-mapped-panels requires a minimum mapped panel count.
- summary now reports mapped_panels and unsupported counts.

Validated live against a migrated dashboard with --min-mapped-panels 3 and
--max-unsupported 0.

Refs #211

@stefans-elastic stefans-elastic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Findings:

  1. High: invalid ES|QL can be classified as data_gap.

In parity-rig/verifier/live_validate.py, _DATA_GAP matches verification_exception.*Unknown and classify_error() checks it before _REAL_BUG. A normal Elasticsearch error like verification_exception ... Unknown function [FOO] matches both, but returns data_gap, so --fail-on-bug would pass despite invalid emitted ES|QL. Please add a wrapped unknown-function regression and make the data-gap regex specific to unknown columns/indices.

  1. High: Datadog reports are effectively skipped by the new oracles.

queries_from_report() in parity-rig/verifier/live_validate.py only reads visual_ir.presentation.config.query or top-level esql, while Datadog reports write translated queries as esql_query. Similarly, lint_report_panel() in parity-rig/verifier/invariants.py returns early without visual_ir.presentation. A Datadog migration_report.json can therefore produce 0 queries / 0 findings and pass. If these tools are Grafana-only, the CLI/docs should say so; otherwise add esql_query support and Datadog-shaped tests.

  1. Medium: datasource-slice benchmark gates can pass without evaluating anything.

In parity-rig/verifier/benchmark_gate.py, using --grafana-datasource without a datasource map makes every dashboard miss, _filtered_metrics() returns None, and evaluate_history() returns ok=True with a skipped reason. That is risky for CI because a misconfigured slice gate looks green. Please treat “filter requested but no current/baseline metrics matched” as a failure unless an explicit skip flag is set.

  1. Medium: corpus gate treats zero evaluated panels as success.

parity-rig/verifier/corpus_gate.py leaves ok=True when reports contain no panels or no panel verdicts. A broken/empty compare artifact can pass the frozen corpus gate. Please add a minimum evaluated panel check or fail zero-total reports by default.

I did not run the full PR test suite; this was a remote diff review. I did locally confirm the live classifier regex overlap for the wrapped Unknown function case.

shmsr added 4 commits June 24, 2026 13:59
Whole-dashboard typed API validation proves that Kibana accepts the mapped
payload, but a 400 response only identifies paths like panels.0 and can be hard
to triage on large dashboards. Add --per-panel mode to validate each mapped
panel in its own scratch dashboard, preserving delete-on-success cleanup and
attributing rejection findings to the exact panel title/index.

This trades speed for diagnosability and is intended for debugging suspicious
Dashboards API failures or high-value corpus runs.

Refs #211
The public LensConfigBuilder fixture generator writes raw Lens attributes, not
only the wrapper shape originally assumed by the fixture oracle. Teach
lens_fixtures to accept both forms:

- wrapper fixtures with explicit chart_type/data_source/attributes metadata
- raw Lens attributes generated by Kibana, inferred from visualizationType,
  datasourceStates, visualization layers, and filenames

The oracle now reports source-format and data-source counts and has regressions
using real raw fixture shapes (metric and xy ES|QL). Also smoke-tested against
actual v9.2.2 generated fixtures from strawgate/kb-yaml-to-lens-fixtures.

Refs #211
Strengthen the LensConfigBuilder fixture oracle beyond presence/coverage checks:
for raw textBased Lens attributes, collect generated column IDs and ensure
visualization accessors (metricAccessor, xAccessor, accessors, yConfig,
split/breakdown accessors) reference existing columns.

Testing against real generated fixtures also found two oracle issues:
- xy fixtures can have preferredSeriesType=line while the actual layer is bar;
  coverage should use layer.seriesType.
- rerunning the CLI in the fixture directory should not consume its own report.json
  output as a fixture.

Adds regressions for both and smoke-tests actual metric/xy fixtures from the
LensConfigBuilder fixture generator.

Refs #211
Reviewer feedback exposed verifier paths that could pass without evaluating Datadog reports or filtered corpus slices. Tighten those gates so CI failures represent real missing coverage instead of silent success.
@shmsr

shmsr commented Jun 24, 2026

Copy link
Copy Markdown
Member Author

Addressed the review findings in 49533ea.

  • Tightened live_validate so wrapped Unknown function errors classify as real_bug, while unknown columns/indices remain data_gap.
  • Added Datadog-shaped esql_query support to live_validate, invariant linting, and the typed Dashboards API oracle.
  • Made datasource-slice benchmark gates fail closed by default when filters evaluate no current/baseline metrics, with explicit --allow-filter-skip opt-out.
  • Made corpus gates require at least one evaluated panel by default, with --min-panels 0 as the explicit opt-out.

Verification on the PR branch:
pytest tests/test_verifier_live_validate.py tests/test_verifier_invariants.py tests/test_verifier_benchmark_gate.py tests/test_verifier_corpus_gate.py tests/test_verifier_dashboards_api.py -> 95 passed.
Pre-commit hooks also passed on commit.

@stefans-elastic stefans-elastic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Findings:

  1. High: obs-migrate verify-panels cannot run the new invariant gate flags.

parity-rig/verifier/cli.py defines --no-invariants, --live-oracle, and --fail-on-invariant, but the package-level wrapper in observability_migration/app/cli.py never registers those options on verify-panels and _run_verify_panels() never forwards them. That means the user-facing command advertised by the PR cannot actually enable the new CI gate; obs-migrate verify-panels --fail-on-invariant is rejected/unsupported while only python -m verifier.cli can use it. Please add the three flags to the umbrella CLI and cover the forwarding behavior in the app CLI tests.

  1. High: explicit markdown panels can be reinterpreted as ES|QL when stale query fields are present.

invariants.lint_report_panel(), dashboards_api._visual_presentation(), and live_validate.queries_from_report() all fall back to panel["esql"] / panel["esql_query"] whenever the presentation is not ES|QL or has no config. That includes panels whose visual_ir.presentation.kind is explicitly markdown. If a placeholder/manual-review markdown panel retains a top-level translated query for audit purposes, the new oracles lint/API-validate/live-execute the stale query instead of the emitted markdown, hiding placeholder regressions and validating the wrong artifact. The fallback should only run when presentation is absent/unknown, not when it is an explicit non-query kind, and this needs a markdown+retained-query regression.

  1. High: Datadog report-only invariant linting still does not verify Lens accessor wiring.

Datadog migration_report.json panels persist esql_query and query_ir, but not visual_ir (observability_migration/adapters/source/datadog/report.py). The new _fallback_esql_config() reconstructs only {query, type} from that shape, with no dimension, metrics, breakdown, or breakdowns; then referenced_fields() returns no field references, so _check_accessor_fields() cannot catch broken Datadog Lens/YAML accessors. This makes Layer 9 look enabled for Datadog while the core accessor invariant is skipped. Please either persist Datadog visual_ir in the report or merge the emitted YAML panel config into the linter, plus add a Datadog-shaped bad-accessor test.

  1. Medium: verifier.benchmark_gate still exits success when no comparison happened.

evaluate_history() returns ok=True for empty history and for no compatible baseline, and main() converts that to exit code 0. The docs correctly say “no compatible baseline” is not a quality pass, but a CI gate that only checks exit status will still go green if the history file is empty, reset, or keyed differently. Please fail closed for skipped comparisons unless an explicit opt-out such as --allow-skip is provided, and document the exit-code contract.

I did not rerun the full PR suite locally. The GitHub checks were green at review time.

Follow-up review found that the user-facing CLI and report-only fallbacks could still skip the new gates. Forward invariant flags, preserve emitted Datadog panel config, and fail skipped benchmark comparisons unless explicitly allowed.
@shmsr

shmsr commented Jun 24, 2026

Copy link
Copy Markdown
Member Author

Addressed the follow-up review findings in 920b401.

  • Added obs-migrate verify-panels flags for --no-invariants, --live-oracle, and --fail-on-invariant, and forward them to verifier.cli.
  • Changed report fallbacks so explicit non-query presentations like markdown are not reinterpreted as stale ES|QL.
  • Persist Datadog yaml_panel in migration_report.json and use that emitted config for report-only invariant/API checks, so accessor wiring is linted.
  • Made benchmark_gate fail closed for empty history / no compatible baseline unless --allow-skip is explicit, and documented the exit-code contract.

Verification on the PR branch:
pytest tests/test_app_cli.py tests/test_verifier_live_validate.py tests/test_verifier_invariants.py tests/test_verifier_dashboards_api.py tests/test_verifier_benchmark_gate.py tests/test_datadog_tls.py -> 161 passed.
Pre-commit hooks passed on commit.

Bugbot review found that --limit capped the five-tier loop but invariant linting still ran over the whole report, so --fail-on-invariant could trip on out-of-sample panels and the report mixed limited panels with whole-dashboard findings. Scope linting to the sampled panels when --limit is set.
@shmsr

shmsr commented Jun 24, 2026

Copy link
Copy Markdown
Member Author

Fixed in 70eb8be.

--limit capped the five-tier panel loop but invariant linting still ran over the whole migration_report.json, so --fail-on-invariant could exit non-zero on panels outside the limited sample, and the written report mixed limited panels with whole-dashboard invariant_findings. Invariant linting is now scoped to the same (dashboard, panel) pairs processed by the limited loop, keeping panels and invariant_findings consistent.

Added regressions for the scoping helper (_scope_report_to_panels).

pytest tests/test_verifier.py tests/test_app_cli.py tests/test_verifier_invariants.py -> passed. Pre-commit hooks passed.

@shmsr
shmsr merged commit 5657ae2 into main Jun 24, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dashboard fidelity verification framework (deterministic invariant linter + property/metamorphic/combinatorial tests)

2 participants