---
title: nexyron.temporal_bucket_group_aggregate
url: https://nexyron.tech/docs/cypher/procedures/temporal-and-streaming/nexyron-temporal-bucket-group-aggregate
description: Aggregate one declared temporal property into calendar or fixed width time buckets, split by a categorical path inside the record, without first materializing every temporal point
---

# nexyron.temporal_bucket_group_aggregate

## Generic Description

Aggregate one declared temporal property into calendar or fixed-width time buckets, split by a categorical path inside the record, without first materializing every temporal point as a Cypher row.

Simple example:

```cypher
CALL nexyron.temporal_bucket_group_aggregate(
  'Member',
  'payment_events',
  'month',
  'sum',
  'invoice_total',
  'processed_by',
  null,
  null,
  null,
  20
)
YIELD bucket_start, bucket_end, group_key, value, sample_count, subject_count
RETURN bucket_start, group_key, value, sample_count, subject_count
ORDER BY bucket_start, group_key
```

Arguments:

-   `label`: owning node label for the declared temporal property.
-   `property`: declared temporal property name.
-   `bucket`: `year`, `month`, `week`, `day`, or a positive fixed bucket width in milliseconds.
-   `aggregate`: `count`, `sum`, `min`, `max`, `avg`, `first`, or `last`.
-   `value_path`: optional dot path to the numeric value inside object records, such as `invoice_total`; use `null` for scalar temporal values.
-   `group_path`: dot path to the categorical value the buckets are split by, such as `processed_by` or `payment_type`. Required.
-   `from_ts`: optional inclusive lower bound in epoch milliseconds.
-   `to_ts`: optional inclusive upper bound in epoch milliseconds.
-   `owners`: optional list of internal owner node IDs; use `null` to aggregate every subject that has the declared label/property stream.
-   `max_groups`: how many groups to return before the remainder is folded into a single tail row. At least 1, and reduced further when the accumulator budget cannot afford that many at the observed bucket count — the result reports `max_groups_reduced_to` when that happens.

Returned columns:

-   `bucket_start`: epoch-millisecond bucket start.
-   `bucket_end`: exclusive epoch-millisecond bucket end.
-   `group_key`: the categorical value this row is for, or `__other__` for the folded tail.
-   `value`: aggregate result for this group in this bucket.
-   `sample_count`: number of samples included.
-   `subject_count`: number of distinct owner nodes that contributed.

## Consumer-Level Explanation

Use this when the business question is "which part of the business moved the number", not just "did the number move".

`nexyron.temporal_bucket_aggregate` answers the second question. It gives one series: monthly revenue, weekly volume, daily events. What it cannot do is split that series, because it aggregates one value path and has no notion of a grouping. So a question like *"revenue by payment processor by month"* or *"volume by region by week"* has no shape it can be asked in — and every decomposition question is that shape.

That is the gap this procedure closes. It produces one row per populated bucket **per group**, so the caller can see a total fall and immediately ask which segment fell with it.

For business users this means:

-   "monthly invoiced value split by processor" is a single call, not one call per processor.
-   a segment that stopped contributing shows up as a group whose rows stop, rather than as an absence you have to notice.
-   the tail is folded rather than dropped: everything beyond `max_groups` arrives as one `__other__` row per bucket, so shares still add up.

## Conceptual Explanation

The row grain is explicit:

```text
one returned row = one populated time bucket for one group of one declared temporal property
```

Calendar buckets are UTC calendar buckets, identical to the ungrouped procedure: `month` means real calendar months, and `week` uses ISO-style Monday-start weeks.

The grouping value is read from inside the temporal record by `group_path`, using the same dot-path resolution as `value_path`. On an object-valued stream the useful group paths are usually among the declared `index_paths`, which is where the categorical fields of the payload live.

Group keys are rendered as strings so that grouping is categorical regardless of the stored type. Whole floats render as integers, so `2.0` and `2` do not split one business group across two rows.

Records whose `group_path` is absent or null are not silently mixed into another group. They are excluded and counted, so a partially-populated grouping field is visible rather than distorting the groups that are populated.

## More Detailed Explanation

The procedure scans the temporal-property store directly and keeps one compact accumulator per group per output bucket. It does not build a list of matching history rows and it does not keep per-subject row buffers.

The memory shape is:

```text
(group_id, bucket_start) -> aggregate_state
```

Two things keep it cheap:

**The window is narrowed before any value is read.** Each subject's history is sorted by timestamp, so `from_ts` and `to_ts` become binary searches (`partition_point`) that pick a slice once, and both passes reuse that slice. Records outside the window are never touched.

**Distinct subjects are counted without a set.** The scan is subject-major — one subject's records are consumed before the next subject is reached — so an accumulator only needs to remember the last subject that touched it in order to count distinct subjects exactly. Per-accumulator memory is therefore constant. This matters more here than in the ungrouped case: the ungrouped procedure holds one subject set per bucket, and grouping would have multiplied that cost by the group cardinality.

The scan runs twice, and that is what makes the result both bounded and exact.

The first pass weighs every group. It holds one small entry per distinct key — a string and a float — and nothing per bucket, so it can afford to see the whole cardinality. It also learns how many buckets the window actually spans.

The second pass accumulates only the groups worth returning, plus the folded tail. Accumulator memory is therefore `(kept + 1) × buckets`, whatever the path's cardinality.

The bound that matters is the product, not the group count. Groups alone is the wrong thing to cap: ten thousand groups over hourly buckets is hundreds of millions of accumulators. So the accumulator budget decides how many groups the result can afford at the observed bucket count, and when that is fewer than `max_groups` the result reports `max_groups_reduced_to` rather than quietly narrowing — a narrower decomposition changes what the tail means.

Two further ceilings exist for inputs that are not really categorical or not really a grain:

-   more than a million distinct keys sets `group_key_ceiling_reached`; a path with that many values is an identifier, and the extra keys fold into the tail.
-   more than a hundred thousand distinct buckets sets `bucket_ceiling_reached` and returns no rows. Only populated buckets exist, so the count is bounded by distinct timestamps rather than by the width of the window — a one-millisecond grain over sparse data is fine, and only genuinely dense data reaches this. Choosing a coarser grain on the caller's behalf would answer a question they did not ask.

Ranking is by absolute contribution over complete masses, with the group key as a tie-break. Because the first pass weighs every key before any group is chosen, selection does not depend on which keys the scan happened to reach first: two runs over unchanged data return the same groups, in the same order. Above the key ceiling that guarantee narrows to the keys that were weighed.

## Advanced Example

Split monthly invoiced value by processor, and compute each group's share of its own month:

```cypher
CALL nexyron.temporal_bucket_group_aggregate(
  'Member', 'payment_events', 'month', 'sum', 'invoice_total', 'processed_by',
  null, null, null, 10
)
YIELD bucket_start, group_key, value, sample_count
WITH bucket_start, collect({group_key: group_key, value: value}) AS groups,
     sum(value) AS bucket_total
UNWIND groups AS group
RETURN bucket_start,
       group.group_key AS processor,
       group.value AS invoiced,
       bucket_total,
       group.value / bucket_total AS share_of_month
ORDER BY bucket_start, invoiced DESC
```

Restrict the grouping to a selected population, by passing owner IDs:

```cypher
MATCH (:Club {id: $club_id})<-[:MEMBER_OF]-(m:Member)
WITH collect(id(m)) AS owners
CALL nexyron.temporal_bucket_group_aggregate(
  'Member', 'payment_events', 'week', 'count', null, 'payment_status',
  null, null, owners, 8
)
YIELD bucket_start, group_key, value
RETURN bucket_start, group_key AS status, value AS events
ORDER BY bucket_start, status
```

Find which group is responsible for a period-over-period fall:

```cypher
CALL nexyron.temporal_bucket_group_aggregate(
  'Member', 'payment_events', 'month', 'sum', 'invoice_total', 'payment_type',
  null, null, null, 20
)
YIELD bucket_start, group_key, value
WITH group_key, bucket_start, value
ORDER BY group_key, bucket_start
WITH group_key, collect(value) AS series
WHERE size(series) >= 2
RETURN group_key,
       series[-2] AS previous_month,
       series[-1] AS latest_month,
       series[-1] - series[-2] AS change
ORDER BY change ASC
```

## Real Use Cases

-   Revenue decomposition: split monthly invoiced value by processor, payment type, or plan, and see which segment carries a movement.
-   Operational triage: split event counts by status or failure reason per week, so a rise in volume can be told apart from a rise in failures.
-   Channel and mix analysis: compare how the composition of a total changed over time, not just the total.
-   Concentration checks: rank groups by contribution and read `__other__` to see how much of the population sits outside the leaders.
-   Preparing evidence for a claim about cause: establishing that a decline is concentrated in one segment, and absent from comparable ones, is the observation a causal argument has to start from.

## Real Limitations And Tradeoffs

-   Like the ungrouped procedure, this targets declared temporal properties. It does not group arbitrary relationship timestamp properties or graph-visible event nodes unless those facts are stored as temporal properties.
-   For `sum`, `min`, `max`, `avg`, `first`, and `last`, `value_path` must point to a numeric scalar. Non-numeric values are skipped, so an incorrect path can return empty or undercounted buckets. For `count`, the path only needs to exist.
-   `group_path` must point at a value that is meaningfully categorical. Grouping by a near-unique path such as an identifier is safe — it folds into `__other__` and reports it — but the answer will not be useful.
-   A fixed-width `bucket` fine enough to exceed a hundred thousand buckets returns no rows and sets `bucket_ceiling_reached`. An empty result therefore has three possible causes: no records in the window, a `group_path` the records do not carry, or a grain too fine to decompose.
-   `__other__` is a summary row, not a population. Its `subject_count` is the sum of its merged groups' contributions and can double-count a subject that appeared in more than one folded group.
-   Ranking is by absolute contribution across the whole window. A group that dominates one bucket but is small overall can therefore be folded into the tail; raise `max_groups` when that matters.
-   Calendar buckets use UTC, as elsewhere in the temporal surface. Region-specific wall-clock calendars would need to be added explicitly rather than silently applying local machine time.
-   Records missing the group path are excluded and reported in the procedure result rather than being assigned to a group. A caller that ignores that count can overstate how completely the groups cover the population.
