Adding a Grouper
Embedding Pulse, not contributing to it? This recipe adds a built-in in
internal/processing/, which embedders cannot import. To add your own operator, implement the matching contract in the publicextendpackage instead — see Extension Points.
Audience: Pulse internals contributors adding a new GROUP_*
operator — a bucketing function that maps each record to a group key
the orchestrator uses to partition the aggregation.
The recipe mirrors the aggregator recipe; the grouper-specific moving
parts are the per-bucket key contract, the grouper registry, and the
v0.20.0 ComponentSchema / MetaGrouper emission path.
1. Declare the type constant
Add the new constant to types/types.go and the slice returned by
types.AllGroupTypes():
const (
// ... existing constants ...
GROUP_DECILE GroupType = "GROUP_DECILE"
)
func AllGroupTypes() []GroupType {
return []GroupType{
// ... existing entries, alphabetised ...
GROUP_DECILE,
}
}
2. Implement and register
Implement the grouper in internal/processing/. Register the factory in
grouperRegistry (internal/processing/registry.go). The interface choice
depends on whether the grouper can run in the streaming path:
Grouper— buffered-only.Group(rows)returns a slice of bucket assignments. Used by quantile-class groupers that need the full sorted view.StreamingGrouper/StreamableGrouper— streaming. The per-recordKeyFor(record)/KeyForRow(record)returns a bucket key as the iterator walks rows; the orchestrator accumulates per key.MultiKeyStreamingGrouper— multi-emit streaming.KeysForRowreturns N keys per row; used by set-typed groupers (GROUP_SET_PER_ELEMENT).
3. Tests
Add tests in internal/processing/grouper_test.go (or the per-grouper test
file) before the implementation. Cover empty input, single-value
input, null-bearing input, the Include filter slot if your grouper
honours it, and the streaming-vs-buffered parity assertions where
applicable.
4. Declare the capability metadata
Add a row to internal/descriptor/capabilities_groupers.go with the grouper’s
params, accepted field types, and the
ComponentSchema
that follows. TestManifestOperatorsComplete enforces a capability
row per registered grouper.
5. Declare the ComponentSchema (Response.Components contract)
Every registered grouper MUST declare a ComponentSchema on its
capability row in internal/descriptor/capabilities_groupers.go, tagged with
one of three mergeability classes:
| Class | Wire value | When to use |
|---|---|---|
Mergeable | "mergeable" | Per-bucket counts and bucket-key state fold associatively across chunks and shards. Used by GROUP_CATEGORY, GROUP_RANGE, GROUP_ROUNDED, GROUP_DATE. |
Partial | "partial" | Counts fold trivially but per-bucket key state is dict-bearing (e.g. growing label sets). The orchestrator may stage the merge at terminal flush. |
None | "none" | Bucket definitions cannot be known until the full input is seen — quantile-class groupers (GROUP_QUANTILE) need the full sorted view. Streaming chunks omit components; emission lands on terminal buffered flush only. |
The groupSchema helper in capabilities_groupers.go prepends the
universal floor ({"total_n", "n_null"}) automatically — do not list
those keys in your extra slice:
{
Name: string(types.GROUP_DECILE),
Category: "grouper",
Description: "Partition the numeric field into ten equally-sized quantile buckets.",
AcceptsTypes: numericFieldTypesAnalyticsNoDecimal,
ComponentSchema: groupSchema(None,
ComponentKey{Name: "edges", Type: "[]float64", Description: "Decile boundary values (length 9)."},
ComponentKey{Name: "buckets", Type: "map[string]int", Description: "Per-decile record counts keyed by label."},
),
},
6. Emit per-operator component values at runtime
Implement MetaGrouper (internal/processing) on the grouper type and add a
compile-time assertion in the grouper implementation file:
type decileGrouper struct {
edges []float64
buckets map[string]int
// ...
}
func (g *decileGrouper) Components() (map[string]any, error) {
return map[string]any{
"edges": g.edges,
"buckets": g.buckets,
}, nil
}
var _ MetaGrouper = (*decileGrouper)(nil)
The orchestrator owns the universal floor {total_n, n_null} — the
post-filter record walker fills it unconditionally. Your Components()
MUST NOT re-emit those keys.
total_n is the number of (record, bucket) assignments: the sum of
your buckets[].count when Components() returns a buckets list of
[]map[string]any with int counts (as GROUP_CATEGORY and GROUP_SET_PER_ELEMENT do), otherwise
the orchestrator’s own assignment count. For single-key groupers that
is the number of keyed records; for multi-key streaming groupers
(GROUP_SET_PER_ELEMENT) a record contributes once per selected label,
so total_n can exceed the record count.
Merging under the parallel reducers
The parallel reducers (ShardWorkers over a shard archive,
DecodeWorkers over a large single file) build one grouper per
partition and fold the per-key aggregator buckets themselves. Your
grouper’s own components state (the counters behind Components())
is folded through MergeableGrouper.MergeGrouperState(other)
(internal/processing/grouper_merge.go); other is a fresh instance
of the same concrete type from the same spec. Implement it and return
true from GroupType.Mergeable() (types/streamability.go) only when
the fold is associative — a sum, or the same first/last-write rule the
serial tracker applies — so the merged Components() equals a serial
instance’s. The reducers sum the assignment count across partitions
and hand it to processing.FinalizeGroupedStream, so total_n /
n_null match the serial path even for a bucket-less grouper. A
grouper not Mergeable() runs those requests serially
(processing.CanMergeRequestWithExtensions).
An extension grouper takes the same paths through the public
contract: declare Streamable: true and Mergeable: true on its
pulse.GrouperRegistration and, if it emits components, implement
extend.MergeableGrouper.MergeState(other); the adapter hands
MergeState your own value. A grouper that emits no components needs
no method (the adapter folds nothing). Probe-validation refuses a
declaration the value cannot honour
(PULSE_EXTENSION_MERGEABLE_MISMATCH). See
Extension points — Grouper.
The full Response.Components contract for groupers — streaming
behaviour by mergeability class, the orchestrator-owned floor, the
extension-side parity contract — lives in the response-components
skill.
Embedder extensions implement the same MetaGrouper interface via
ComponentsFunc or directly; see
Extension Points.
7. The Include inclusion-list slot
If your grouper honours an Include whitelist of allowed bucket
labels, update internal/processing/grouper.go + internal/processing/grouper_set.go
so the include filter is applied at key-emission time, not on the
output table after the fact. The contract is enforced by the
following gates:
TestGroupCategory_IncludeFiltersLabelsTestGroupSetValue_IncludeFiltersCompositeKeyTestGroupSetPerElement_IncludeFilters
8. Update the grouper-design skill
Add a section in skills/grouper-design.md covering the new
grouper’s bucket-naming convention, the params it accepts, the
streamability classification, and any cardinality bound.
9. Update CLAUDE.md
There is no registered-grouper count or list in CLAUDE.md to update —
CLAUDE.md never hardcodes registered counts; the manifest is the source of
truth. The Update Demand operator row (.claude/reference/update-demand.md)
requires instead the atomic skill skills/op-group-<kebab>.md, the capability declaration in
internal/descriptor/capabilities_groupers.go — and an internal/examples/<dir>/*.json example whose _meta.operators
names the operator (TestEveryOperatorHasAnExampleTag). Edit CLAUDE.md only if the
operator introduces a contract it states directly, and mind
TestClaudeMdSizeBudget — long-form prose belongs in .claude/reference/.
10. Run the gates
go test ./internal/skills/ -run TestSkillsCoverAllComponents
go test ./internal/descriptor/ -run TestManifestOperatorsComplete
go test ./internal/processing/ -run TestGroup
The Update Demand row for groupers (and the Group.Include slot) covers
all of these in one PR; see The Update Demand.