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:
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, orlast.value_path: optional dot path to the numeric value inside object records, such asinvoice_total; usenullfor scalar temporal values.group_path: dot path to the categorical value the buckets are split by, such asprocessed_byorpayment_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; usenullto 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 reportsmax_groups_reduced_towhen 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_groupsarrives as one__other__row per bucket, so shares still add up.
Conceptual Explanation
The row grain is explicit:
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:
(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_reachedand 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:
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:
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:
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, andlast,value_pathmust point to a numeric scalar. Non-numeric values are skipped, so an incorrect path can return empty or undercounted buckets. Forcount, the path only needs to exist. group_pathmust 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
bucketfine enough to exceed a hundred thousand buckets returns no rows and setsbucket_ceiling_reached. An empty result therefore has three possible causes: no records in the window, agroup_paththe records do not carry, or a grain too fine to decompose. __other__is a summary row, not a population. Itssubject_countis 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_groupswhen 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.