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:

Returned columns:

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:

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:

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

Real Limitations And Tradeoffs