Skip to content

refactor: split the MinMax aggregation family into Min and Max - #716

Merged
zzylol merged 4 commits into
mainfrom
refactor/split-min-max
Sep 14, 2026
Merged

refactor: split the MinMax aggregation family into Min and Max#716
zzylol merged 4 commits into
mainfrom
refactor/split-min-max

Conversation

@zzylol

@zzylol zzylol commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Why

A summary's family said MinMax and its direction rode alongside it as a string.

  • build_backend_aggregation_json emitted aggregationType: MinMax and recovered "max" from an if matches! wedged inside the json! literal.
  • accumulator_factory re-derived the direction with config.aggregation_sub_type.eq_ignore_ascii_case("max").
  • MinMaxAccumulator carried sub_type: String and panicked on anything that was not "min" or "max"; the direction also occupied a ninth byte in its serialized form.
  • catalog_resolver::supports cross-checked aggregation_sub_type against the family before admitting a candidate.

Every one of those is a place where the direction can disagree with the state it labels, and the failure is silent — a minimum answered as a maximum, not an error.

Worse, capability_for mapped both AggIntent::Min and AggIntent::Max onto ExactAgg(MinMax), so a deployed minimum summary was a legal candidate for a max_over_time read.

What

Direction is the family now, end to end.

Layer Before After
asap_types::AggregationType MinMax, MultipleMinMax Min, Max, MultipleMin, MultipleMax
asap_types::ExactStateKind MinMax Min, Max
asap_types::ExactReadout Max only Min, Max
wire aggregationType: MinMax + aggregationSubType: max aggregationType: Min/Max, sub-type empty
accumulators MinMaxAccumulator { sub_type: String } MinAccumulator / MaxAccumulator
keyed accumulators MultipleMinMaxAccumulator { sub_type: String } MultipleMinAccumulator / MultipleMaxAccumulator
rollup index RollupReduction::Max RollupReduction::{Min, Max}
  • AggregationType::from_str rejects the retired names (MinMax, MultipleMinMax, min_max, …) rather than guessing a direction. There is no safe guess, and a wrong one is silently wrong.
  • Each accumulator answers exactly one Statistic, refuses to merge with the opposite direction, and serializes without a direction byte.
  • min_over_time gets the derived-rollup fast path max_over_time already had. Which rollup a pane feeds follows from the accumulator's type instead of from a sub_type string that had to agree with it.
  • capability_for maps AggIntent::MinExactAgg(Min) and AggIntent::MaxExactAgg(Max); a new test pins that neither satisfies the other.

Planner pin

Needs ASAPPlanner's own split (ExactKind::MinMaxMax alongside the existing Min), so the four ASAPPlanner deps move ca7546d029ff2fProjectASAP/ASAPPlanner#407.

Two unrelated upstream changes ride in with that pin and are handled here:

  • BinaryOperator gained checked_relative_division / checked_finite_division, and avg_over_time now realizes as a guarded Sum/Count division instead of staying archive-only. avg_over_time_is_not_yet_realizable_matching_capability_for_today asserted the old outcome; it is rewritten as avg_over_time_realizes_as_a_checked_sum_over_count_division.
  • PromQL count(v) is a row count upstream now (correct PromQL), and the distinct-count idiom is count(distinct_over_time(v[w])). The four HLL tests that spelled it count(v) are retargeted. Restoring a by-less cross-source merged cardinality is the second commit of feat(control_plane): adopt asap_plan::bind::implement_tree (Step A) #407.

Deliberately not in scope

summary_family_matches_exact still excludes Min/Max from the legacy family-discovery path. It excluded them because direction was unrecoverable once a summary reached AggKind::ExactAgg — a reason this PR removes — but admitting them widens candidate discovery beyond the family split. The doc comment now records that, and the test is renamed to say it is the discovery path, not the ambiguity, that keeps them out.

Compatibility

PolicyFingerprint hashes aggregation_type and aggregation_sub_type, so extremum summaries content-address differently after this change. Both sides of that wire move together in this PR; persisted streaming-configs and on-disk parts written as MinMaxAccumulator by an older build are not readable by the new byte factory.

Testing

cargo test --workspace -- --test-threads=1 in CI's configuration (ASAP_TEST_TIMEOUT_SCALE=4, ASAP_E2E_CONTROL_PLANE_BIN set), toolchain 1.98.0. cargo clippy --workspace --all-targets and cargo fmt --check clean.

Everything green except six asapquery_compatibility_process_e2e tests. Those six fail identically on unmodified origin/main (b1a58ca, pin ca7546d) in the same configuration — verified against a pristine baseline worktree, not assumed:

collector_free_profile_serves_complete_matrix_and_falls_back_exactly
durable_summary_process::persisted_summary_restarts_without_live_reregistration
registered_temporal_topk_cms_heap
registered_temporal_topk_count_sketch_heap
repeated_dashboard_executes_multiple_selected_panes
univmon_erp_process::measured_readout_evidence_selects_and_executes_univmon

They are pre-existing on main in this environment and are not touched by this change.

🤖 Generated with Claude Code

A summary's family said `MinMax` and its direction rode alongside it as
a string. `build_backend_aggregation_json` emitted `aggregationType:
MinMax` and recovered "max" from the family in an `if matches!` wedged
inside the `json!` literal; `accumulator_factory` re-derived the
direction with `aggregation_sub_type.eq_ignore_ascii_case("max")`;
`MinMaxAccumulator` carried `sub_type: String` and panicked on anything
that was not "min" or "max". Every one of those is a place where the
direction can disagree with the state it labels, and the failure is
silent: a minimum answered as a maximum.

Worse, `capability_for` mapped both `AggIntent::Min` and
`AggIntent::Max` onto `ExactAgg(MinMax)`, so a deployed minimum summary
was a legal candidate for a `max_over_time` read.

Direction is the family now, end to end:

* `AggregationType::{Min, Max, MultipleMin, MultipleMax}` replace
  `MinMax`/`MultipleMinMax`. `FromStr` rejects the retired names rather
  than guessing a direction -- there is no safe guess.
* `ExactStateKind::{Min, Max}` and `ExactReadout::Min` give the state
  contract and the readout contract the same two-sided shape.
* The wire is `aggregationType: Min` / `Max` with an empty
  `aggregationSubType`; nothing reads that field for a direction.
* `MinAccumulator`/`MaxAccumulator` and `MultipleMinAccumulator`/
  `MultipleMaxAccumulator` replace the `sub_type`-carrying pair. Each
  answers exactly one `Statistic`, refuses to merge with the other
  direction, and serializes without a direction byte.
* `RollupReduction::Min` joins `Max`, so `min_over_time` gets the same
  derived-rollup fast path `max_over_time` already had; which rollup a
  pane feeds follows from the accumulator's type.

This needs ASAPPlanner's own split (`ExactKind::MinMax` -> `Max`
alongside the existing `Min`), so the planner pin moves to
029ff2fe. Two other changes ride in with that pin:

* `BinaryOperator` gained `checked_relative_division` /
  `checked_finite_division`, and `avg_over_time` now realizes as a
  guarded Sum/Count division instead of staying archive-only --
  `avg_over_time_is_not_yet_realizable_matching_capability_for_today`
  asserted the old outcome and is rewritten to assert the new one.
* PromQL `count(v)` is a row count upstream now, and the distinct-count
  idiom is `count(distinct_over_time(v[w]))`. The four HLL tests that
  spelled it `count(v)` are retargeted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@milindsrivastava1997

Copy link
Copy Markdown
Contributor

Does this take care of min_over_time compilation from #701 ?

@zzylol

zzylol commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

min_over_time was taken care by a previous pr

Before: Planner division guards were discarded when lowering average rewrites and relative divisions into installed binary nodes. After: use a verified original exact subtree where available, or request exact fallback. Remove query placements pruned by exact subtree execution.

Regression reproduced before the fix. Validation: 787 control-plane, 1200 data-plane, and 107 shared-type unit tests passed; formatting and clippy for both planes with all targets and warnings denied passed.
Keep the typed minimum and checked division runtime from main, including both compiler paths. Preserve full-window rollup admission and port the min/max wire split to the renamed backend emitter.
@zzylol
zzylol merged commit 8cf1890 into main Sep 14, 2026
1 check 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.

2 participants