From 321982d7e4f70d7a8afbd383a7e6e6686ce6cfe3 Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 13 May 2026 09:30:59 -0600 Subject: [PATCH] =?UTF-8?q?test(analyzer):=20=CE=B1=20=E2=80=94=2018-query?= =?UTF-8?q?=20parity=20catalog=20pinning=20control=20plane=20vs=20engine?= =?UTF-8?q?=20analyzers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR α of the analyzer-unification chain (α→β→γ→δ→ε). Pure additive baseline that freezes what each PromQL → asap-tier analyzer answers today, so β/γ have a verifiable parity contract before they start collapsing the two surfaces. Rebased onto origin/main (post-PR #211): doc moved to control_plane/docs/ and the controller→control_plane / warm_tier→ asap_tier renames swept through. Golden master re-verified byte-for-byte against the new base. Co-Authored-By: Claude Opus 4.7 (1M context) --- control_plane/docs/analyzer-parity-matrix.md | 170 ++++++++++++ .../query_engines/asap_query_engine/engine.rs | 256 ++++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 control_plane/docs/analyzer-parity-matrix.md diff --git a/control_plane/docs/analyzer-parity-matrix.md b/control_plane/docs/analyzer-parity-matrix.md new file mode 100644 index 00000000..c7f5c2ea --- /dev/null +++ b/control_plane/docs/analyzer-parity-matrix.md @@ -0,0 +1,170 @@ +# Analyzer parity matrix — α catalog + +**Companion to** `data_plane/src/query_engines/asap_query_engine/engine.rs::analyzer_parity_tests`. + +Two PromQL → asap-tier analyzers live in this workspace today and the +analyzer-unification chain (α→β→γ→δ→ε) is going to collapse them. This +document is the **human-readable freeze** of how each one answers an +18-query corpus; the inline golden-master test pins the same data +byte-for-byte. β/γ MUST preserve every `engine` row in the table below +— that is the parity contract. δ may change them **only** when the +matching `ctrl` row already matches. + +## The two analyzers + +| | **Control plane (canonical)** | **Engine (duplicate)** | +|---|---|---| +| Entry | `control_plane::asap_tier_analysis::analyze_promql_for_asap_tier(metricsql: &str) -> ASAPTierAnalysis` | `ASAPQueryEngine::parse_and_match_promql` + `build_query_requirements_promql` | +| Pipeline | `query_parser::parse_query` → `intent_algebra::lower::lower_parsed_query` → walk `QueryExpr` → `sketch_algebra::capability::capability_for(&AggIntent)` | `promql_parser::parser::parse` → match against `controller_patterns: HashMap>` built in `new_with_hot_reload` | +| Output value | `ASAPTierAnalysis { candidates: Vec, unsupported: Option }` | `(QueryPatternType, PromQLMatchResult)` + `QueryRequirements { metric, statistics, data_range_ms, grouping_labels, spatial_filter_normalized }` | +| Vocabulary | `Capability` + `AggIntent` (L3 — semantic) | `Statistic` + `QueryPatternType` (physical — sketch-storage table) | +| File | `control_plane/src/asap_tier_analysis.rs` | `data_plane/src/query_engines/asap_query_engine/engine.rs` (~1100 LOC of analyzer-shaped code spread across the file) | + +## How to read each row + +``` +─── q01: + ctrl {OK [cand,...] | MISS(reason)} + engine {OK pattern=… stats=[…] metric=… fn=… agg_op=… range_s=… range_ms=… spatial=… grouping=… | MISS(NoPattern)} +``` + +- `ctrl` rows speak `Capability` + `AggIntent`. `MISS(UnsupportedAggIntent("X"))` means `capability_for` returned `None` for `AggIntent::X` at the default accuracy; `MISS(NoCallNodeFound)` means the lowerer produced no call shape (bare selector); `MISS(UnparseableMetricsql(...))` means `query_parser::parse_query` rejected the input. +- `engine` rows speak `Statistic` + `QueryPatternType`. `MISS(NoPattern)` means none of the entries in `controller_patterns` matched the AST. + +## The 18-query corpus + +| id | query | ctrl | engine | parity? | +|-----|-------|------|--------|--------| +| q01 | `quantile_over_time(0.99, http_latency_ms[5m])` | OK · `QuantileApprox(Any)` · range=300s | OK · `only_temporal/quantile` · range=300s | ✅ | +| q02 | `quantile_over_time(0.5, m[30s])` | OK · range=30s | OK · range=30s | ✅ | +| q03 | `quantile_over_time(0.99, m[2h])` | OK · range=7200s | OK · range=7200s | ✅ | +| q04 | `sum by (zone) (http_requests_total)` | MISS · `UnsupportedAggIntent("sum")` | OK · `only_spatial/sum` · grouping=`[zone]` | **D1** | +| q05 | `sum by (zone, region) (http_requests_total)` | MISS · same | OK · grouping=`[region, zone]` (sorted) | **D1** | +| q06 | `topk(5, http_requests_total)` | MISS · `UnsupportedAggIntent("topk")` | OK · `only_spatial/topk` | **D2** | +| q07 | `topk(10, sum by (svc) (m))` | MISS · `UnsupportedAggIntent("topk")` | MISS · `NoPattern` | ✅ | +| q08 | `count_over_time(http_requests_total[5m])` | MISS · `UnsupportedAggIntent("count_over_time")` | OK · `only_temporal/count` · range=300s | **D3** | +| q09 | `count by (zone) (count_over_time(http_requests_total[5m]))` | OK · `CardinalityApprox` · gbk=`[zone]` · range=300s | OK · `one_temporal_one_spatial/count` · grouping=`[zone]` · range=300s | ✅ | +| q10 | `histogram_quantile(0.99, sum by (le) (rate(http_latency_bucket[5m])))` | MISS · `UnparseableMetricsql` (γ5 substitution rejects non-matrix arg) | MISS · `NoPattern` (no `histogram_quantile` pattern) | ✅ | +| q11 | `histogram_quantile(0.99, http_latency_bucket)` | MISS · `UnparseableMetricsql` (same) | MISS · `NoPattern` | ✅ | +| q12 | `http_requests_total` | MISS · `NoCallNodeFound` | MISS · `NoPattern` | ✅ | +| q13 | `http_requests_total{zone="z0"}` | MISS · `NoCallNodeFound` | MISS · `NoPattern` | ✅ | +| q14 | `rate(http_requests_total[5m])` | MISS · `UnsupportedAggIntent("rate")` | OK · `only_temporal/rate` | **D4** | +| q15 | `irate(http_requests_total[5m])` | MISS · `UnsupportedAggIntent("irate")` | MISS · `NoPattern` (no `irate` in engine pattern list) | ✅ | +| q16 | `increase(http_requests_total[5m])` | MISS · `UnsupportedAggIntent("increase")` | OK · `only_temporal/increase` | **D4** | +| q17 | `sum(rate(http_requests_total[5m]))` | MISS · `UnsupportedAggIntent("sum")` | OK · `one_temporal_one_spatial/rate` · agg_op=sum | **D5** | +| q18 | `@@@ not promql @@@` | MISS · `UnparseableMetricsql("PromQL parse error: invalid promql query")` | MISS · `NoPattern` | ✅ | + +## Divergences + +Each divergence has a code; γ implementations refer to it. + +### D1 — `sum by (...)(m)` control-plane-reject vs engine-accept + +`sum`, `min`, `max`, `count` over a bare metric never become an +`AggIntent::Sum`-with-capability — `capability_for(Sum)` returns +`None` because sum is exact (no sketch needed), the ASAP tier doesn't +materialise the raw counter values that a `sum` would reduce. The +engine, by contrast, registers `Statistic::Sum` in `OnlySpatial` and +routes to a stored aggregation that exposes the sum. + +This is **semantically correct on both sides**: the control plane +refuses because the ASAP-tier sketch can't satisfy the query *as +written*; engine accepts because the ASAP tier might still own a +*precomputed* sum-by-zone aggregation that does. γ's adapter must keep +the engine path answerable when a precompute exists — see +`find_compatible_aggregation_with_miss_notify`. + +### D2 — `topk(k, m)` control-plane-reject vs engine-accept + +Same shape as D1: the control plane treats `topk` as a non-sketchable +exact AggIntent (`UnsupportedAggIntent("topk")`); engine accepts as +`Statistic::Topk` and answers from a precomputed CMS-with-heap when +the indexed sketch carries `Capability::FrequencyTopk(...)`. γ must +preserve the engine path's `topk(k, bare_metric)` shape. + +### D3 — `count_over_time(m[r])` control-plane-reject vs engine-accept + +`count_over_time` lowers to `AggIntent::Count` in the control plane, +and `capability_for(Count)` at the default accuracy is `None` (count +is exact). Engine emits `Statistic::Count` and routes to a counter +agg. This is the textbook example of "the engine has a precompute +table the control plane doesn't model" — γ must preserve the engine +row. + +### D4 — `rate(m[r])` and `increase(m[r])` control-plane-reject vs engine-accept + +The control plane's analyzer rejects rate/increase as +`UnsupportedAggIntent("rate" / "increase")`. The engine has them in +the temporal generic pattern set and emits `Statistic::Rate` or +`Statistic::Increase`. Note that **`irate` is NOT in the engine +pattern list** (q15), so `irate` is one of the few queries both +analyzers reject. + +### D5 — `sum(rate(m[r]))` rejected by the control plane, accepted by engine as `OneTemporalOneSpatial/rate` + `agg_op=sum` + +The composition of D1 (sum) and D4 (rate). Engine has the +`spatial_of_temporal_pattern` cross-product wired in +`new_with_hot_reload` (the engine's `OneTemporalOneSpatial` table); +the control plane's lowerer hits the outer `sum` first and bails. γ +must preserve the composed engine output. + +## Implementation notes + +1. **`histogram_quantile` parser-level substitution (γ5 / PR #144)** — + the control plane substitutes `histogram_quantile(phi, m)` to + `AggIntent::Quantile { q: phi }` at the parser site. In the + current corpus (q10, q11) this substitution is rejecting both + shapes with `UnparseableMetricsql("expected MatrixSelector, got + Discriminant(...)")` — the substitution path requires + `histogram_quantile(phi, MatrixSelector)` directly, and the + composed `histogram_quantile(phi, sum_by(le, rate(m[r])))` is not + accepted. This may be an intentional restriction (γ5 only handles + the canonical Prometheus shape), but is worth re-examining when + ε expands the corpus. **Do NOT reintroduce + `AggIntent::HistogramQuantile`** — this is the hard cross-cutting + invariant from `controller_todo_may_12.md` §6. + +2. **`KeyByLabelNames::new` sorts** — q05's grouping comes back as + `[region, zone]` not `[zone, region]`. The engine summary already + reflects this; the control plane's `BTreeSet` ordering + aligns. + +3. **`QuantileApprox(Any)` vs `QuantileApprox(DDSketch)`** — the + TODO doc references `QuantileApprox(DDSketch)`, but post-Step 2a + (PR #129) `capability_for` returns the family-wildcard `Any` form. + Don't be surprised by the diff — `Capability::is_satisfied_by` + does the family match at the indexed-side. + +4. **Why no `Capability::is_satisfied_by` tests in α** — those live + in `control_plane/src/sketch_algebra/capability.rs::tests` and the + data plane's `index::classify` tests. α is strictly about *what + each analyzer says it requires*; satisfaction is downstream. + +## Where to look for each pipeline + +### Control plane path +- Parse: `control_plane/src/query_parser/promql.rs` (γ5 lives here) +- Lower: `control_plane/src/intent_algebra/lower.rs` +- Capability map: `control_plane/src/sketch_algebra/capability.rs::capability_for` +- Public entry: `control_plane/src/asap_tier_analysis.rs::analyze_promql_for_asap_tier` + +### Engine path +- Pattern set: `data_plane/src/query_engines/asap_query_engine/engine.rs:316–460` (inside `new_with_hot_reload`) +- AST match: `parse_and_match_promql` (`:2219`) +- Requirements build: `build_query_requirements_promql` (`:1690`) +- Free helpers (also to be deleted in δ): `crates/promql_utilities/src/query_logics/parsing.rs::{get_metric_and_spatial_filter, get_statistics_to_compute, get_spatial_aggregation_output_labels}` + +## When to update this file + +- **β** (add `physical_projection` + `analyze_promql_full`) — extend + the control plane column when it newly covers a previously-MISS row + (D1/D2/D3/D4/D5). The engine column must NOT move. +- **γ** (rewrite engine analyzer as a shim) — the engine column MUST + match the embedded golden exactly. If it doesn't, γ has dropped + parity. +- **δ** (delete the engine analyzer) — engine column becomes + derivable from the control plane column; merge the two into a single + `unified` column and re-run α. +- **ε** (expand corpus to 35 queries) — append new rows; never edit + q01–q18 without an accompanying β/γ semantic change explaining + the drift. diff --git a/data_plane/src/query_engines/asap_query_engine/engine.rs b/data_plane/src/query_engines/asap_query_engine/engine.rs index 4c1d805d..680aa209 100644 --- a/data_plane/src/query_engines/asap_query_engine/engine.rs +++ b/data_plane/src/query_engines/asap_query_engine/engine.rs @@ -6127,3 +6127,259 @@ mod hybrid_stitch_tests { assert_eq!(b.samples.len(), 2); } } + +#[cfg(test)] +mod analyzer_parity_tests { + //! PR-α parity tests — capture both PromQL → asap-tier analyzers + //! side by side and pin the output. + //! + //! Two analyzers exist today and the analyzer-unification chain + //! (α→β→γ→δ→ε) is going to collapse them. To make that collapse + //! verifiable, α (this test) freezes how each analyzer answers + //! every shape in an 18-query corpus. γ rewrites the engine path + //! as a shim over the control plane path; δ deletes the engine + //! analyzer. Both stages must preserve the *engine column* of + //! this table — that's the parity contract. + //! + //! The two analyzers: + //! + //! 1. **Control plane** — + //! `control_plane::asap_tier_analysis::analyze_promql_for_asap_tier`. + //! Pipeline: `query_parser::parse_query` → + //! `intent_algebra::lower::lower_parsed_query` → + //! `capability_for(&AggIntent)`. Output: + //! `ASAPTierAnalysis { candidates, unsupported }` — speaks + //! `Capability` + `AggIntent` (L3). + //! + //! 2. **Engine** — `ASAPQueryEngine::parse_and_match_promql` + + //! `build_query_requirements_promql`. Pipeline: + //! `promql_parser::parser::parse` → match against + //! `controller_patterns: HashMap>` + //! built at `new_with_hot_reload`. Output: + //! `(QueryPatternType, PromQLMatchResult)` + `QueryRequirements` + //! — speaks `Statistic` + `QueryPatternType` (physical + //! sketch-storage table). + //! + //! Known divergences pinned by this corpus (see + //! `control_plane/docs/analyzer-parity-matrix.md` for the + //! per-query explanation): + //! + //! - `count_over_time(m[r])` without an outer `count by`: + //! control plane → `MISS(UnsupportedAggIntent("count"))`, + //! engine → `OK pattern=only_temporal stats=[count]`. + //! - `histogram_quantile(phi, m[…])`: control plane substitutes to + //! `Quantile` at the parser site (γ5 / PR #144); engine has no + //! `histogram_quantile` pattern and falls through to `MISS`. + //! - `irate(m[r])`: engine pattern list omits `irate` so it + //! misses; control plane rejects it as `UnsupportedAggIntent("rate")`. + //! - `topk(k, sum_by(…))` (topk wrapping a spatial agg, no + //! metric leaf at the call site): engine's `topk` pattern only + //! accepts a bare metric; control plane accepts via the topk + //! bridge. + //! - Bare selectors (`m`, `m{l=v}`): control plane → `NoCallNodeFound`; + //! engine → `MISS(NoPattern)` (no aggregation / function node). + + use super::*; + use crate::storage_engines::types::{HotReloadStreamingConfig, StreamingConfig}; + + /// Build a parity-test `ASAPQueryEngine`. The engine analyzer's + /// `parse_and_match_promql` depends only on `self.controller_patterns` + /// (built inside `new_with_hot_reload` from a static table), so an + /// empty `StreamingConfig` is sufficient. `build_query_requirements_promql` + /// calls `resolve_metric_labels(&metric)` which returns `None` against + /// an empty config and falls back to `KeyByLabelNames::empty()` — we + /// want exactly that fallback so the parity output is deterministic + /// and independent of any schema registry state. + fn make_engine() -> ASAPQueryEngine { + let sc = Arc::new(StreamingConfig::new(HashMap::new())); + let hr = HotReloadStreamingConfig::from_arc(sc); + ASAPQueryEngine::new_with_hot_reload(hr, 60) + } + + /// The 18-query parity corpus. Each row is `(id, promql)`. The id + /// is the row anchor in `control_plane/docs/analyzer-parity-matrix.md`; + /// keep them aligned when adding queries. + const CORPUS: &[(&str, &str)] = &[ + ("q01", "quantile_over_time(0.99, http_latency_ms[5m])"), + ("q02", "quantile_over_time(0.5, m[30s])"), + ("q03", "quantile_over_time(0.99, m[2h])"), + ("q04", "sum by (zone) (http_requests_total)"), + ("q05", "sum by (zone, region) (http_requests_total)"), + ("q06", "topk(5, http_requests_total)"), + ("q07", "topk(10, sum by (svc) (m))"), + ("q08", "count_over_time(http_requests_total[5m])"), + ("q09", "count by (zone) (count_over_time(http_requests_total[5m]))"), + ("q10", "histogram_quantile(0.99, sum by (le) (rate(http_latency_bucket[5m])))"), + ("q11", "histogram_quantile(0.99, http_latency_bucket)"), + ("q12", "http_requests_total"), + ("q13", "http_requests_total{zone=\"z0\"}"), + ("q14", "rate(http_requests_total[5m])"), + ("q15", "irate(http_requests_total[5m])"), + ("q16", "increase(http_requests_total[5m])"), + ("q17", "sum(rate(http_requests_total[5m]))"), + ("q18", "@@@ not promql @@@"), + ]; + + /// One-line stable summary of `ASAPTierAnalysis`. `MISS(reason)` on + /// the unsupported path; `OK [cand, ...]` on the supported path + /// with the full candidate shape so γ can be checked against this + /// without ambiguity. + fn summarize_controller(q: &str) -> String { + let a = control_plane::asap_tier_analysis::analyze_promql_for_asap_tier(q); + if let Some(reason) = &a.unsupported { + return format!("MISS({:?})", reason); + } + if a.candidates.is_empty() { + return "MISS(NoCandidates)".to_string(); + } + let cands: Vec = a + .candidates + .iter() + .map(|c| { + let gbk: Vec<&str> = c.group_by_keys.iter().map(|s| s.as_str()).collect(); + format!( + "metric={} gbk={:?} cap={:?} fn={} args={:?} range_s={}", + c.metric_name, + gbk, + c.required_capability, + c.function, + c.function_args, + c.range_seconds, + ) + }) + .collect(); + format!("OK [{}]", cands.join(" | ")) + } + + /// One-line stable summary of the engine analyzer's + /// `(QueryPatternType, PromQLMatchResult)` + `QueryRequirements`. + /// `MISS(NoPattern)` when no pattern in `controller_patterns` + /// matches the AST; `OK pattern=… stats=[…] …` otherwise. + fn summarize_engine(eng: &ASAPQueryEngine, q: &str) -> String { + match eng.parse_and_match_promql(q) { + None => "MISS(NoPattern)".to_string(), + Some((pt, mr)) => { + let req = eng.build_query_requirements_promql(&mr, pt); + let stats: Vec = + req.statistics.iter().map(|s| s.to_string()).collect(); + let fn_name = mr.get_function_name().unwrap_or_default(); + let agg_op = mr.get_aggregation_op().unwrap_or_default(); + let range_s = mr + .get_range_duration() + .map(|d| d.num_seconds().to_string()) + .unwrap_or_else(|| "-".to_string()); + format!( + "OK pattern={pattern} stats=[{stats}] metric={metric} fn={fn_name} \ + agg_op={agg_op} range_s={range_s} range_ms={range_ms:?} \ + spatial={spatial:?} grouping={grouping:?}", + pattern = pt, + stats = stats.join(","), + metric = req.metric, + fn_name = fn_name, + agg_op = agg_op, + range_s = range_s, + range_ms = req.data_range_ms, + spatial = req.spatial_filter_normalized, + grouping = req.grouping_labels.labels, + ) + } + } + } + + fn build_parity_table() -> String { + let eng = make_engine(); + let mut out = String::new(); + for (id, q) in CORPUS { + out.push_str(&format!("─── {id}: {q}\n")); + out.push_str(&format!(" ctrl {}\n", summarize_controller(q))); + out.push_str(&format!(" engine {}\n", summarize_engine(&eng, q))); + } + out + } + + /// Embedded golden master — captured against `origin/main` at + /// commit `6557fb8` (post-PR #187), re-verified byte-for-byte on + /// the rebase onto `origin/main` post-PR #211. Replace whenever an + /// analyzer output changes intentionally: re-run the test, copy the + /// printed `=== ACTUAL ===` block, and update + /// `control_plane/docs/analyzer-parity-matrix.md` in the same PR. + /// + /// Each row records what the analyzer **today** answers. β/γ MUST + /// preserve every `engine ...` row (the parity contract); δ MAY + /// change them only if the matching `ctrl ...` row already matches + /// the new behavior. The two paths must converge, not drift apart. + const GOLDEN: &str = "\ +─── q01: quantile_over_time(0.99, http_latency_ms[5m]) + ctrl OK [metric=http_latency_ms gbk=[] cap=QuantileApprox(Any) fn=quantile_over_time args=[0.99] range_s=300] + engine OK pattern=only_temporal stats=[quantile] metric=http_latency_ms fn=quantile_over_time agg_op= range_s=300 range_ms=Some(300000) spatial=\"\" grouping=[] +─── q02: quantile_over_time(0.5, m[30s]) + ctrl OK [metric=m gbk=[] cap=QuantileApprox(Any) fn=quantile_over_time args=[0.5] range_s=30] + engine OK pattern=only_temporal stats=[quantile] metric=m fn=quantile_over_time agg_op= range_s=30 range_ms=Some(30000) spatial=\"\" grouping=[] +─── q03: quantile_over_time(0.99, m[2h]) + ctrl OK [metric=m gbk=[] cap=QuantileApprox(Any) fn=quantile_over_time args=[0.99] range_s=7200] + engine OK pattern=only_temporal stats=[quantile] metric=m fn=quantile_over_time agg_op= range_s=7200 range_ms=Some(7200000) spatial=\"\" grouping=[] +─── q04: sum by (zone) (http_requests_total) + ctrl MISS(UnsupportedAggIntent(\"sum\")) + engine OK pattern=only_spatial stats=[sum] metric=http_requests_total fn= agg_op=sum range_s=- range_ms=None spatial=\"\" grouping=[\"zone\"] +─── q05: sum by (zone, region) (http_requests_total) + ctrl MISS(UnsupportedAggIntent(\"sum\")) + engine OK pattern=only_spatial stats=[sum] metric=http_requests_total fn= agg_op=sum range_s=- range_ms=None spatial=\"\" grouping=[\"region\", \"zone\"] +─── q06: topk(5, http_requests_total) + ctrl MISS(UnsupportedAggIntent(\"topk\")) + engine OK pattern=only_spatial stats=[topk] metric=http_requests_total fn= agg_op=topk range_s=- range_ms=None spatial=\"\" grouping=[] +─── q07: topk(10, sum by (svc) (m)) + ctrl MISS(UnsupportedAggIntent(\"topk\")) + engine MISS(NoPattern) +─── q08: count_over_time(http_requests_total[5m]) + ctrl MISS(UnsupportedAggIntent(\"count_over_time\")) + engine OK pattern=only_temporal stats=[count] metric=http_requests_total fn=count_over_time agg_op= range_s=300 range_ms=Some(300000) spatial=\"\" grouping=[] +─── q09: count by (zone) (count_over_time(http_requests_total[5m])) + ctrl OK [metric=http_requests_total gbk=[\"zone\"] cap=CardinalityApprox fn=count args=[] range_s=300] + engine OK pattern=one_temporal_one_spatial stats=[count] metric=http_requests_total fn=count_over_time agg_op=count range_s=300 range_ms=Some(300000) spatial=\"\" grouping=[\"zone\"] +─── q10: histogram_quantile(0.99, sum by (le) (rate(http_latency_bucket[5m]))) + ctrl MISS(UnparseableMetricsql(\"expected MatrixSelector, got Discriminant(0)\")) + engine MISS(NoPattern) +─── q11: histogram_quantile(0.99, http_latency_bucket) + ctrl MISS(UnparseableMetricsql(\"expected MatrixSelector, got Discriminant(7)\")) + engine MISS(NoPattern) +─── q12: http_requests_total + ctrl MISS(NoCallNodeFound) + engine MISS(NoPattern) +─── q13: http_requests_total{zone=\"z0\"} + ctrl MISS(NoCallNodeFound) + engine MISS(NoPattern) +─── q14: rate(http_requests_total[5m]) + ctrl MISS(UnsupportedAggIntent(\"rate\")) + engine OK pattern=only_temporal stats=[rate] metric=http_requests_total fn=rate agg_op= range_s=300 range_ms=Some(300000) spatial=\"\" grouping=[] +─── q15: irate(http_requests_total[5m]) + ctrl MISS(UnsupportedAggIntent(\"irate\")) + engine MISS(NoPattern) +─── q16: increase(http_requests_total[5m]) + ctrl MISS(UnsupportedAggIntent(\"increase\")) + engine OK pattern=only_temporal stats=[increase] metric=http_requests_total fn=increase agg_op= range_s=300 range_ms=Some(300000) spatial=\"\" grouping=[] +─── q17: sum(rate(http_requests_total[5m])) + ctrl MISS(UnsupportedAggIntent(\"sum\")) + engine OK pattern=one_temporal_one_spatial stats=[rate] metric=http_requests_total fn=rate agg_op=sum range_s=300 range_ms=Some(300000) spatial=\"\" grouping=[] +─── q18: @@@ not promql @@@ + ctrl MISS(UnparseableMetricsql(\"PromQL parse error: invalid promql query\")) + engine MISS(NoPattern) +"; + + /// Run all 18 queries through both analyzers, format as a parity + /// table, and pin against the embedded golden. A mismatch here is + /// the parity-violation signal β/γ must not trip — and is what + /// PR #144 (`histogram_quantile` parser substitution) regression- + /// guards against. + #[test] + fn analyzer_parity_18_query_corpus() { + let actual = build_parity_table(); + if actual != GOLDEN { + eprintln!("=== ACTUAL ===\n{actual}=== END ACTUAL ==="); + } + assert_eq!( + actual, GOLDEN, + "analyzer parity drifted — update control_plane/docs/analyzer-parity-matrix.md \ + and replace GOLDEN with the new ACTUAL block above" + ); + } +}