Add approximatePercentile aggregated value function - #1327
Add approximatePercentile aggregated value function#1327nikhilkumarjadhav-toast wants to merge 3 commits into
Conversation
Adds an approximate-percentile aggregation to the aggregatedValues API for Float, Int, JsonSafeLong, LongString, Date, DateTime, and LocalTime fields. Callers request a specific percentile rank via a `percentile` argument (e.g. `percentile: 50` for the median), and can request multiple ranks in a single query by aliasing the field selection. The aliasing-based shape (rather than a list-return shape accepting an array of percentiles) keeps query validity statically verifiable: each aliased selection independently validates its own rank and always returns exactly one value, with no ambiguity around duplicate or out-of-range requests the way a list-argument API would have.
The tight 0.1 absolute tolerance assumed the t-digest algorithm would land within noise of the true p99 on a 4-document dataset, but CI caught real Elasticsearch backends returning ~491 instead of 500 at that extreme tail, where the algorithm has very little data to interpolate from. Switch to a percentage-based tolerance sized to tolerate this legitimate cross-version/cross-backend variance.
myronmarston
left a comment
There was a problem hiding this comment.
Great work @nikhilkumarjadhav-toast! As noted in my comments below, I'm working on #1331 and #1332 which should make this much easier. You'll want to rebase on top of that and rework your solution after those land.
| def define_approximate_percentile_on_aggregated_values(aggregated_values_type, scalar_type) | ||
| aggregated_values_type.field names.approximate_percentile, scalar_type, graphql_only: true do |f| | ||
| f.argument names.percentile, "Float!" do |a| | ||
| a.documentation "The percentile rank to compute, from `0` to `100` (e.g. `50` for the median, `99` for the 99th percentile)." |
There was a problem hiding this comment.
| a.documentation "The percentile rank to compute, from `0` to `100` (e.g. `50` for the median, `99` for the 99th percentile)." | |
| a.documentation "The percentile rank to compute, from `0` to `100` (e.g. `0` for the min, `100` for the max, `50` for the median, `99` for the 99th percentile)." |
/nit I was initially unsure if "from 0 to 100" was an inclusive or exclusive range until I thought about it a bit and realized that 0 = min and 100 = max. Seems useful to say that explicitly.
| # | ||
| # @private | ||
| class ComputationDetail < ::Data.define(:empty_bucket_value, :function) | ||
| class ComputationDetail < ::Data.define(:empty_bucket_value, :function, :function_arg_name) |
| end | ||
| end | ||
|
|
||
| def define_exact_min_max_and_approx_avg_on_aggregated_values(aggregated_values_type, scalar_type, &block) |
There was a problem hiding this comment.
| def define_temporal_aggregated_values(aggregated_values_type, scalar_type, &block) |
...now that we include percentile in addition to exact_min_max_and_approx_avg. (And be sure to update the callers).
|
|
||
| {% include copyable_code_snippet.html language="graphql" data="music_queries.aggregations.BluegrassArtistLifetimeSales" %} | ||
|
|
||
| This example query aggregates the values of the `Artist.lifetimeSales` field using all 4 of the standard numeric |
There was a problem hiding this comment.
| This example query aggregates the values of the `Artist.lifetimeSales` field using all 4 of the basic numeric |
(I think "percentile" is a pretty standard numeric aggregation function, but it's useful to split off below, and is a bit more "advanced" than these "basic" ones).
|
|
||
| `approximatePercentile` | ||
| : An approximate percentile of the field values within this grouping. The `percentile` argument specifies | ||
| the desired percentile rank, from `0` to `100` (e.g. `50` for the median, `90` for the 90th percentile). |
There was a problem hiding this comment.
I made a suggestion about the SDL docs. Be sure to apply that here as well.
| # `approximate_percentile` returns `Float` even for these integral types, since the percentile | ||
| # computation interpolates between adjacent values and can produce a non-integer result. | ||
| expect(value_at_path(aggregations.first, aggregated_values, "weight_in_ng", "approximate_percentile")).to be_a(::Float) | ||
| expect(value_at_path(aggregations.first, aggregated_values, "weight_in_ng_str", "approximate_percentile")).to be_a(::Float) |
There was a problem hiding this comment.
Can these use .and be_approximately(...) to also assert on the value like the others--not just the type?
BTW feel free to use the 0th or 100th percentile for this example so you can just do .max or .min in the value part of the assertion--that's easier than writing an arithmetic expression to compute the median.
| }) | ||
| end | ||
|
|
||
| it "supports requesting multiple percentiles via aliased `approximate_percentile` selections" do |
There was a problem hiding this comment.
Let's fold these two new examples into the existing ones rather than adding standalone examples.
Acceptance tests here are by far our most expensive layer — each example boots a real datastore
round-trip and re-indexes its own fixtures. The established pattern in this file is to index once
and then assert a lot against that one indexed state (see it "returns aggregates (terms, date histogram) and nested aggregates", which indexes 5 records and then runs ~40 distinct grouping
scenarios against them). Two new examples that each index 4 fresh widgets to check one function is a
meaningful cost increase for coverage we can get for free.
Concretely:
1. Treat approximatePercentile like every other aggregated value function
Add it to all_amount_aggregations (line 1842) alongside approximate_sum / exact_sum /
approximate_avg / exact_min / exact_max, and add the expected entries to
expected_aggregated_amounts_of (line 856):
# in all_amount_aggregations (and the nested `cost { amount_cents { ... } }` block)
p50: approximate_percentile(percentile: 50)
p99: approximate_percentile(percentile: 99)# in expected_aggregated_amounts_of
"p50" => float_of(...),
"p99" => float_of(...)This gets us, at zero additional indexing cost, everything the two new examples were checking plus a
lot more:
- Multiple aliased selections of the same function under one parent field — the collision-avoidance
behavior that motivated foldingfunction_arg_valueinto the aggregation key. This is the single most
important thing to cover, andall_amount_aggregationsexercises it in every scenario rather than once. - Grouped and ungrouped — makes the second new example (
"supports grouping alongside an aliased approximate_percentile selection") redundant outright;amount_aggregation/all_amount_aggregations
are already run against ~40 differentgrouped_byshapes. - Nested under a sub-object (
cost { amount_cents { ... } }) — verifies the field path is encoded into
the key correctly alongside the arg value. Not covered today. - Empty buckets — the pre-index assertion at line 52 checks
expected_aggregated_amounts_of([]), so we'd
getempty_bucket_value: nilcoverage for free. Also not covered today.
Two things to watch when doing this:
float_ofuses a 0.1%-relative tolerance, which is tighter than thebe_within(5).percent_ofthe new
p99 example needed. Rather than looseningfloat_ofglobally, I'd suggest picking percentile ranks whose
expected values are consistent for the fixture sizes involved (e.g. p75 or p90). Worth confirming empirically what the datastore returns for the small (1–3 doc)
groupings — t-digest interpolation on tiny sets isn't obvious, so please pin the expected values from an
actual run rather than assuming a plain linear median.percentile: 0andpercentile: 100are exactlyminandmaxregardless of interpolation, so a
p0/p100pair is a cheap way to get a tight, deterministic assertion. Could be a nice complement to a
looserp50.
2. Cover the calendar types
approximatePercentile is defined on Date, DateTime, and LocalTime aggregated values too (via
define_exact_min_max_and_approx_avg_on_aggregated_values), and returns the formatted string type
(): Date in the schema, not Float). That path goes through the value_as_string fallback in
Resolvers::AggregatedValues, and it does so through the percentiles-specific
result.fetch("values").first unwrapping — i.e. a combination nothing currently exercises. If the
datastore ever stopped returning value_as_string inside the keyed: false array entries, we'd
silently start returning raw epoch millis and no test would catch it.
Please add it to it "supports using aggregations for calendar types (Date, DateTime, LocalTime)"
(line 1266), which already indexes exactly the right fixtures:
created_at { exact_min, exact_max, approximate_avg, approximate_distinct_value_count, p50: approximate_percentile(percentile: 50) }
created_on { ... }
created_at_time_of_day { ... }with the expected formatted-string values added to the contain_exactly hash below.
3. Delete both new examples
Once 1 and 2 are in place, both new examples are strictly subsumed, and per our "avoid duplicate
tests" guidance they should go.
| "#{field.name_in_index}(#{args.fetch(arg_name)})" | ||
| else | ||
| field.name_in_index | ||
| end |
There was a problem hiding this comment.
Encoding the args in the aggregation key feels a bit messy and brittle. I think there's a better way: we can use the field alias. I'm working on a prep refactoring PR (#1331) that should obviate the need for this.
| # returns `"values" => [{"key" => ..., "value" => ..., "value_as_string" => ...}]` (an array | ||
| # with exactly one entry) rather than the `"value"`/`"value_as_string"` pair that every other | ||
| # aggregation function (avg, sum, min, max, cardinality) returns directly on the result hash. | ||
| result = result.fetch("values").first if computation_detail.function == :percentiles |
There was a problem hiding this comment.
Having conditional logic based on the aggregation function here (and in elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/resolvers/relay_connection_builder.rb, elasticgraph-graphql/lib/elastic_graph/graphql/aggregation/computation.rb, etc) is a big of a smell. These abstractions are designed to be agnostic to the specific aggregation function.
Unfortunately, the internal abstractions EG currently has aren't currently good enough for this, so you're forced to add if computation_detail.function == :percentiles conditionals. I'm working on a prep refactoring in #1332 that should solve this.
computed_index_field_name only used name_in_index for the aggregated value function's leaf, while every parent path segment already used alias-aware name_in_graphql_query. That's what forced an argument- bearing function (upcoming approximatePercentile) to invent a synthetic leaf name built independently on the query-building and resolver sides, which then had to agree byte-for-byte. Computation now carries a `leaf` PathSegment built via the same PathSegment.for factory on both sides, so the leaf key derives from the alias like every other segment. This removes the need for synthetic key naming for argument-bearing functions entirely. Behavior change (not a pure refactor): two aliases of the same function under one field used to collapse into a single computation (equal value objects, same key). Now their leaf segments differ, so two identical datastore aggregations are sent -- correct since each field resolves via its own alias. Deduping by clause content would require threading an alias-to-canonical-key map from query building into the resolver, reintroducing the coupling this change removes. Prep refactor #1 of 2 for PR #1327; the percentile function itself is not added here.
Aggregated value functions were treated uniformly only because every one
of them takes no arguments and returns a flat {"value" => ...} response.
A function needing a request argument and returning a nested response has
nowhere for that knowledge to live, so it would leak as `if function ==
:percentiles` conditionals across the clause builder, the resolver, and
the empty-bucket builder.
Each function now routes through an adapter owning all of its
datastore-specific behavior: datastore aggregation name, GraphQL argument
extraction, extra clause options, reading the value out of the response,
and fabricating a response for a bucket the datastore omitted. Adding a
function becomes a registry entry plus one adapter.
Decoupling the metadata name from the datastore aggregation name is what
lets the two diverge. Fields resolve the name to an adapter once at boot
(fields are built once and cached), so an unregistered name fails at boot
rather than mid-query, and the registry has one query-time reader.
Arguments flow in two phases because argument names are customizable via
schema element names, which the computation value object cannot see when
building a clause: query building calls extract_args at the boundary,
storing a canonically-keyed hash; clause building later calls the pure
clause_options. Threading element names onto the computation instead
would pollute its identity, which is used to dedupe computations in a Set.
The empty-bucket value moves from per-field metadata into the registry.
It is fully derivable from the function (verified across all 45
aggregated value fields), so per-field storage was redundant and let a
schema-definition author pair a function with a wrong empty value. The
adapter returns the entire fabricated response rather than a bare value
the builder must wrap, removing a read-path/write-path asymmetry.
The argument plumbing ships now even though no function uses it yet: the
point of this prep work is that the framework is ready, and deferring it
would leave the follow-up changing the adapter interface itself.
`computes :sum` reads as part of the field DSL, which is otherwise
unprefixed. It validates against the registry at dump time, mirroring how
elasticgraph-schema_definition already depends on elasticgraph-graphql to
validate scalar coercion adapters; a mistyped name is a plausible slip.
Prep refactor #2 of 2 for PR #1327; the percentile function itself is not
added here. Closes #1330.
myronmarston
left a comment
There was a problem hiding this comment.
Another thing that I thought of after submitting my review above: we should make sure that out-of-range percentiles are handled properly. I checked out your branch and ran bundle exec rake boot_locally to try it out and found that percentile values out side the 0 to 100 range cause OpenSearch/Elasticsearch to throw exceptions:
We should return a GraphQL validation error instead. (Would be good to cover that in the acceptance test).
Aggregated value functions were treated uniformly only because every one
of them takes no arguments and returns a flat {"value" => ...} response.
A function needing a request argument and returning a nested response has
nowhere for that knowledge to live, so it would leak as `if function ==
:percentiles` conditionals across the clause builder, the resolver, and
the empty-bucket builder.
Each function now routes through an adapter owning all of its
datastore-specific behavior: datastore aggregation name, GraphQL argument
extraction, extra clause options, reading the value out of the response,
and fabricating a response for a bucket the datastore omitted. Adding a
function becomes a registry entry plus one adapter.
Decoupling the metadata name from the datastore aggregation name is what
lets the two diverge. Fields resolve the name to an adapter once at boot
(fields are built once and cached), so an unregistered name fails at boot
rather than mid-query, and the registry has one query-time reader.
Arguments flow in two phases because argument names are customizable via
schema element names, which the computation value object cannot see when
building a clause: query building calls extract_args at the boundary,
storing a canonically-keyed hash; clause building later calls the pure
clause_options. Threading element names onto the computation instead
would pollute its identity, which is used to dedupe computations in a Set.
The empty-bucket value moves from per-field metadata into the registry.
It is fully derivable from the function (verified across all 45
aggregated value fields), so per-field storage was redundant and let a
schema-definition author pair a function with a wrong empty value. The
adapter returns the entire fabricated response rather than a bare value
the builder must wrap, removing a read-path/write-path asymmetry.
The argument plumbing ships now even though no function uses it yet: the
point of this prep work is that the framework is ready, and deferring it
would leave the follow-up changing the adapter interface itself.
`computes :sum` reads as part of the field DSL, which is otherwise
unprefixed. It validates against the registry at dump time, mirroring how
elasticgraph-schema_definition already depends on elasticgraph-graphql to
validate scalar coercion adapters; a mistyped name is a plausible slip.
Prep refactor #2 of 2 for PR #1327; the percentile function itself is not
added here. Closes #1330.
## Summary Prep refactor #1 of 2 for PR #1327 (`approximatePercentile`). The percentile function itself is not added here. An aggregated value function field that is aliased in a GraphQL query now resolves through a datastore aggregation key derived from that alias, rather than from the field's `name_in_index`. Previously, the leaf segment of an aggregated value key was the only path segment keyed off `name_in_index` -- every parent segment already used the alias-aware `name_in_graphql_query`. That inconsistency would have forced an argument-bearing function (like the upcoming `approximatePercentile`) to invent a synthetic leaf name (e.g. `approximate_percentile(50.0)`), built independently in the query-building code and the resolver, which would then have to agree byte-for-byte. Making the leaf alias-derived removes the need for synthetic key naming entirely. - `Computation` replaces its `computed_index_field_name` string attribute with a `leaf` attribute holding a `PathSegment`. Its `name_in_index` is intentionally unused -- the function name isn't part of the datastore index path, which `clause` derives entirely from `source_field_path`. - Both the query-building side (`QueryAdapter`) and the resolver side (`Resolvers::AggregatedValues`) now derive the leaf name through the same `PathSegment.for` factory, so there's one rule instead of two implementations that must agree. - `computed_index_field_name` is deleted (not left unused), since the datastore clause already derives its index path from `source_field_path`. ## Behavior change This is **not** a pure refactor. Two aliases of the same function under one field used to collapse into a single computation (the value objects were equal, held in a `Set`, with an identical key). After this change their leaf segments differ, so two identical datastore aggregations are sent: ```graphql aggregatedValues { amount { exactMin, myMin: exactMin } } # before: ONE agg clause; both fields read it # after: TWO identical agg clauses, keyed by `exactMin` and `myMin` ``` Correct in both cases -- each field resolves via its own alias. Accepted deliberately: - The redundancy only occurs when a client asks for the same value twice under two names, which is pathological, and the cost is a duplicate metric aggregation on an already-loaded shard. - Deduplicating by clause content would require threading an alias-to-canonical-key map from query building into the resolver, reintroducing the coupling this ticket exists to delete. - Rejecting aliases outright is a non-starter: the percentile function requires aliases to request multiple ranks. ## Test plan - [x] Unit test: an aliased aggregated value function field produces a key built from the alias - [x] Unit test: two aliases of the same function under one field produce two distinct keys - [x] Unit test: the datastore clause's index field path is unaffected by leaf aliasing - [x] Query-building unit test covering an aliased function field - [x] Acceptance test issuing a GraphQL query with an aliased aggregated value function and asserting the resolved value - [x] RBS signatures updated; `script/type_check` passes - [x] `script/run_gem_specs elasticgraph-graphql` passes (100% line/branch coverage maintained) - [x] `script/quick_build` passes Closes #1329 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Aggregated value functions were treated uniformly only because every one
of them takes no arguments and returns a flat {"value" => ...} response.
A function needing a request argument and returning a nested response has
nowhere for that knowledge to live, so it would leak as `if function ==
:percentiles` conditionals across the clause builder, the resolver, and
the empty-bucket builder.
Each function now routes through an adapter owning all of its
datastore-specific behavior: datastore aggregation name, GraphQL argument
extraction, extra clause options, reading the value out of the response,
and fabricating a response for a bucket the datastore omitted. Adding a
function becomes a registry entry plus one adapter.
Decoupling the metadata name from the datastore aggregation name is what
lets the two diverge. Fields resolve the name to an adapter once at boot
(fields are built once and cached), so an unregistered name fails at boot
rather than mid-query, and the registry has one query-time reader.
Arguments flow in two phases because argument names are customizable via
schema element names, which the computation value object cannot see when
building a clause: query building calls extract_args at the boundary,
storing a canonically-keyed hash; clause building later calls the pure
clause_options. Threading element names onto the computation instead
would pollute its identity, which is used to dedupe computations in a Set.
The empty-bucket value moves from per-field metadata into the registry.
It is fully derivable from the function (verified across all 45
aggregated value fields), so per-field storage was redundant and let a
schema-definition author pair a function with a wrong empty value. The
adapter returns the entire fabricated response rather than a bare value
the builder must wrap, removing a read-path/write-path asymmetry.
The argument plumbing ships now even though no function uses it yet: the
point of this prep work is that the framework is ready, and deferring it
would leave the follow-up changing the adapter interface itself.
`computes :sum` reads as part of the field DSL, which is otherwise
unprefixed. It validates against the registry at dump time, mirroring how
elasticgraph-schema_definition already depends on elasticgraph-graphql to
validate scalar coercion adapters; a mistyped name is a plausible slip.
Prep refactor #2 of 2 for PR #1327; the percentile function itself is not
added here. Closes #1330.
Prep refactor #2 of 2 paving the way for #1327 (`approximatePercentile`). This does **not** add the percentile function — it makes the framework ready for it. Closes #1330. Stacked on #1331 — review that one first. ## Why ElasticGraph treats aggregated value functions uniformly, expressing differences via runtime metadata rather than special casing. That works only while every function takes no arguments and returns a flat `{"value" => ...}` response. A function that needs a request argument and returns a nested response has nowhere for that knowledge to live, so it leaks as `if function == :percentiles` conditionals across three files: the clause builder, the resolver, and the empty-bucket builder. After this change, adding a function is a registry entry plus one adapter — no changes to any of those three. ## The adapter interface | Method | Responsibility | |---|---| | `datastore_function_name` | The datastore's aggregation type (may differ from the ElasticGraph function name) | | `extract_args(args, element_names)` | GraphQL args → canonically-keyed args hash | | `clause_options(function_args)` | Extra keys merged into the aggregation clause alongside `field` | | `extract_result(raw)` | Locate the value hash within the datastore's response | | `empty_bucket_result` | The complete fabricated response for a bucket the datastore omitted | All five existing functions differ only in datastore name and empty-bucket value, so one `SimpleMetric` data class parameterized on those two covers all of them; its other three methods are no-ops. A function whose behavior isn't a parameterization of anything would register as a singleton module instead. ## Key decisions - **Metadata names the adapter; the adapter owns the datastore aggregation name.** Breaking that coupling lets the two diverge, which the percentile function needs. For all five existing functions the metadata string is unchanged. - **Fields resolve the name to an adapter once, at boot.** Fields are built once and cached, so resolution is per field rather than per query, the registry has one query-time reader, and an unregistered name fails at boot rather than mid-query. - **Arguments flow in two phases: extract, then apply.** Argument names are customizable via schema element names, which the computation value object can't see when building a clause. Query building calls `extract_args` at the boundary; clause building later calls the pure `clause_options`. Threading element names onto the computation would pollute its identity, which is used to dedupe computations in a `Set`. - **The empty-bucket value moves into the registry.** It's fully derivable from the function (verified across all 45 aggregated value fields: sum/cardinality → `0`; avg/min/max → `nil`), so per-field storage was redundant and let an author pair a function with a wrong empty value. The adapter returns the entire fabricated response rather than a bare value the builder must wrap. - **Argument plumbing ships now**, though no function uses it yet. The point of these prep tickets is that the framework is ready; deferring would leave the follow-up changing the adapter interface itself. ## Renames | Before | After | |---|---| | `ComputationDetail` | deleted (class, spec, RBS, requires) | | `GraphQLField#computation_detail` | `computation_function` (a bare `Symbol`) | | `GraphQLField#with_computation_detail` | deleted | | `Field#runtime_metadata_computation_detail` | `computes(function)` | | `Schema::Field#computation_detail` | `function_adapter` | | `Computation#detail` | `function_adapter` + `function_args` | Runtime metadata per field collapses from three keys to one: ```yaml approximate_sum: computation_function: sum ``` `computes :sum` reads as part of the field DSL, which is otherwise unprefixed (`documentation`, `mapping`, `json_schema`). It validates the name against the registry at dump time, mirroring how `elasticgraph-schema_definition` already depends on `elasticgraph-graphql` to validate scalar coercion adapters — no new gem dependency. ## Verification - `script/type_check` — clean - `script/lint` — 891 files, no offenses - Full suite — 5227 examples, 0 failures - Mutation-checked the empty-bucket path: breaking `empty_bucket_result` fails 2 tests Note: `script/quick_build` exits non-zero on SimpleCov's 100%-coverage gate (79 uncovered lines across 16 files, none touched here). Confirmed pre-existing — the base commit fails identically with the same 79 lines. ## Follow-up With both prep changes landed, #1327 reduces to a percentile adapter, one registry entry, the field definition, two schema element names, docs, and tests. No framework changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Summary
Adds an
approximatePercentilefield to theaggregatedValuesAPI forFloat,Int,JsonSafeLong,LongString,Date,DateTime, andLocalTimefields, backed by the datastore'spercentilesaggregation.Callers request a specific percentile rank via the
percentileargument (e.g.percentile: 50for the median), and can request multiple ranks in a single query by aliasing the field selection:{ amountCents { p50: approximatePercentile(percentile: 50) p99: approximatePercentile(percentile: 99) } }This shape (as opposed to a list-return shape accepting an array of percentiles) keeps query validity statically verifiable: each aliased selection independently validates its own rank and always returns exactly one value, avoiding the ambiguity a list-argument API would have around duplicate or out-of-range requests. Percentiles are computed using an approximate algorithm (t-digest) regardless of field type, so there's no
exactPercentilecounterpart the way there is formin/max/sum.@myronmarston and I discussed and agreed on this API shape beforehand.
Implementation notes
ComputationDetailgains an optionalfunction_arg_name, andComputationgains afunction_arg_value, so a function that needs a query-time argument (likepercentile) can fold the resolved value into both the emitted datastore clause and the aggregation key. This avoids key collisions between multiple aliased selections of the same field with different argument values.percentilesaggregation is requested withkeyed: falseand a single-elementpercentsarray perComputation, so the response is a small array ("values" => [{"key" => ..., "value" => ...}]) rather than a string-keyed hash — this avoids reconstructing the datastore's float-formatted string key to look up the single requested value.Float/Intfield and aDateTimefield, confirmingvalue_as_stringformatting carries through the same as the other aggregated value functions) before writing the automated tests.Test plan
Computation#key/#clausefor thepercentilesfunction;QueryAdapterbuilds distinct computations for aliased selections with different percentile args; resolver tests including empty-bucket (null) andDateTimeformattinggroupedBybuilt_in_types_spec.rb/for_built_in_types_spec.rbSDL and runtime-metadata assertions for the new field on all 7 supported typesscript/quick_buildpasses (spellcheck, lint, type_check, schema_artifacts:check, full spec suite at 100% line/branch coverage, site validation)config/site/examples/music) and doc-site prose inquery-api/aggregations/aggregated-values.md