Skip to content

7.7 Aggregations — reduce a whole GROUP to one cell

The primitives above judge rows one at a time. An aggregation goes the other way: it reduces the group a traversal produced to a single cell, as the model sibling of count() / collect(). Neo4j’s implicit GROUP-BY supplies the grouping for free — RETURN topic.name, summarize(n.description) yields one digest per topic.

MATCH (t:ResearchTopic {name:'retrieval augmented generation'})-[:HAS_NEWS]->(n:NewsItem)
RETURN summarize(n.description, 'what is newest and most important') AS digest
Function Returns Reduces a group to…
summarize(text [, instruction]) prose a neutral overview
render(text [, instruction]) prose the same items as prose — all of them, in order, hedges kept
synthesize(text, goal) prose a goal-directed answer (argues toward goal)
classify(item, labels [, rubric]) one label exactly one label from the closed set 'a,b,c'
extract(text, what) list the distinct things asked for (deduped)
themes(text [, focus] [, count]) list the recurring cross-item topics (labels only)
cluster(text [, k]) list of maps semantic groups, each with a COUNTED size and examples
score(item, rubric) number 0–1 one ordinal judgment of the whole group
holds(item, question) boolean or null a three-valued verdict on a claim
relevant(item, criterion) list only the items matching a subjective criterion — over nodes, the group’s own nodes
argmax(key, text, criterion) winner payload the best candidate under a comparative rubric
correlate(x, y) map the Pearson correlation of two numeric expressions: {r, n, dropped, ci, detectable}
regress([x1, …], y, label) map a least-squares fit of y on the predictors: fit quality, per-predictor strengths, and the rows most above and most below their prediction, one list per direction

render keeps what summarize compresses. Both write prose from a group; they differ in what they promise. summarize gives a neutral overview and will drop, merge and reword items to get there — right for a group of items nobody has read. render promises the opposite: every item appears, in the order given, with its figures as written and its qualifying clauses intact. It is for rows a realm has already composed for a reader — a sentence per row, a status, a caveat — where only the joins between them are missing.

The distinction matters most where it is least visible. A row that ends “stated, not verified as current”, “a name match to check”, or “no record was found” is carrying the reader’s warrant to doubt it; compressed out by a digest, the same row reads as established fact, with its figures still correct. Ask for render whenever losing such a clause would change what the prose means.

Neither function may ADD. Both are held to what the items literally state — no description of what a subject is or does from outside the rows — and both report an honest miss rather than write from nothing. render additionally never sums or combines figures across items: a total it calculated would appear in none of them, and could not be checked against any row.

render COSTS A MODEL CALL AND VARIES BETWEEN RUNS, like every prose reduction. Where the same words are required every time, build the sentence in Cypher from the row’s own columns and return it; render buys prose that reads well, never reproducibility.

correlate and regress are arithmetic: no model call, and the same rows always yield the same result. They reduce a group’s NUMERIC columns, not its text, and both report ASSOCIATION across the group — never causation, and never a claim about any individual row.

MATCH (d:District)
RETURN regress([d.medianPay, d.avgPrice, d.density], d.crimeRatePer1000, d.name) AS model
  • correlate(x, y) returns {r, n, dropped, ci, detectable} — Pearson’s r over the rows where BOTH operands were numeric, n counting those rows and dropped the rows that were not. ci is the 95% confidence interval for r (null when the group is too small to bound one), and detectable says whether that interval excludes zero — when it is false, the sample cannot distinguish the relationship from none, and the honest report is “no detectable relationship”, never the bare r. It returns null rather than a map when fewer than three usable pairs remain or when either operand never varies.

  • regress([x1, x2, …], y, label) fits y against the predictor LIST by ordinary least squares. The first argument is a Cypher list of numeric expressions (any number of them), the second the numeric outcome, the third an expression naming each row. It returns {n, dropped, r2, adjustedR2, coefficients, abovePrediction, belowPrediction}: coefficients carries one entry per predictor with a STANDARDISED beta — magnitudes are comparable across predictors of different units, the list is ordered strongest first, and each is named READABLY from the query (a coercion wrapper is unwrapped and a plain variable.property drops its variable, unless that would give two predictors the same name). The two residual lists name the rows (by the label expression) whose actual outcome sits furthest ABOVE and furthest BELOW the fit’s prediction, in the outcome’s own units — one list per direction, so a divergence that runs only one way is visible as an empty other side. It returns null rather than an unreliable fit when the group is too small for its predictor count, when a predictor is constant or a copy of another, or when the outcome never varies.

  • Rows where any needed operand is null or non-numeric are dropped from the fit and counted in dropped — a thin join cannot masquerade as a strong signal.

  • Grouping is the ordinary implicit GROUP-BY: RETURN d.region, regress(…) fits one model per region; drop the key for one fit over the whole group.

  • They belong in the RETURN, where an aggregation is finalized. Written into a WITH they type-check, compute nothing, and hand back null — which reads as “no association” when it means “not computed”. Two forms of the same mistake, both silent:

    WITH pt, collect(…) AS seats, correlate(pay, sigs) AS r // null: a WITH does not finalize
    RETURN correlate([x IN seats | x.pay], [x IN seats | x.sigs]) // null: these are AGGREGATES
    // over rows, not list functions

    Both are fixed the same way — accumulate the rows and let the RETURN reduce them:

    WITH pt, toFloat(cp.annualPay) AS pay, toFloat(cs.signatures) AS sigs
    RETURN pt.action AS petition, correlate(pay, sigs) AS payVsSignatures

    If a view needs both a collected list and a coefficient, that is two views, not one clause.

Ingested document content is aggregable. When the accumulated row expression is an ingested document’s content (or text) — holds(d.content, '…'), summarize(d.text, '…') on a matched Document — the aggregation reads the document’s FULL ingested text, not a stored node property. The same document always yields the same text; a document whose text was never ingested contributes nothing. Cost scales with document length (the reduction is chunked internally), so prefer a narrower match when the question targets one section. Cached document summaries remain available and cheaper, but can omit specific findings; content is the exhaustive surface.

A reduction with nothing to reduce says so. When the accumulated expression is empty for a group — no rows matched, or the property carries no values — a PROSE reduction (summarize, synthesize, render) answers an UNAVAILABLE: sentence saying there was nothing to aggregate, and every TYPED reduction (a score, a verdict, a label, a list, a pick) answers null. A sentence in a score column would sort among real scores, and no comparison could tell it from an answer; null is what an empty group means. A typed reduction whose model fails is also null, flagged REDUCTION_FAILED (§9), so it is never bare. Neither is ever a fabricated verdict or digest, and ask surfaces report the result as an honest miss rather than an answer. An empty LIST accumulates as empty: collect() of zero rows never manufactures evidence.

A verdict about a NAMED thing requires the name in the corpus. When a holds verdict is anchored through document relevance, the seed’s words must appear together somewhere in the corpus text. A seed that appears nowhere (an address, a name, an identifier the documents never mention) yields an honest not-in-graph miss that names the seed — never a verdict judged against whatever document happened to be semantically nearest. Topical reductions (summaries, filters, themes) keep full semantic reach: aboutness without the literal words remains their contract.

holds judges the claim as written. Every specific the claim itself states (a year, a figure, a name) must be supported by the evidence, or the verdict is null (UNKNOWN) — qualifiers the claim does not state are assumed enforced by the query’s own filters. Risk or possibility language in the evidence (“may be present”, “high risk of”) never supports TRUE. A verdict of false requires evidence that addresses the claim and answers no; material that does not bear on the claim cannot veto such a grounded no, and if NOTHING bears on it the verdict is null, never a confident no.

An aggregation’s result can be filtered, ordered and grouped by. Write it as you would any other value:

MATCH (e:Electorate)
WITH e, classify(e.member, 'female,male,unknown') AS gender
WHERE gender = 'female'
RETURN count(e) AS count, gender

The count is the database’s, over real labels. The same holds for ORDER BY score(...), for grouping by an aggregated label in a later WITH, and for every aggregation in §5 — each reduces a group to one cell, and a cell can be filtered on.

What this costs, and the one rule it imposes:

  • The value is computed BEFORE the query runs, and only for the groups whose value can still reach the answer. Every part of the query that does not read the aggregation narrows that set first — a WHERE before the aggregating WITH, and equally a WHERE after it on any other column, a later MATCH that drops rows, a later WITH … WHERE. In

    MATCH (l:Lead)
    WITH l, classify(l.notes, 'strategic,standard,at_risk') AS triage
    WHERE triage = 'at_risk' AND l.probability < 50
    MATCH (l)-[:OWNED_BY]->(u:User {active: true})
    RETURN l.name

    only the leads under 50% with an active owner are judged; the others are excluded whatever the judgement would have been, and are never sent to the model. The guarantee is one-directional: the set judged is never SMALLER than the set that can reach the answer, so the answer is exactly what judging every group would give. Where the aggregation flows into something other than a filter or a bare pass-through — an expression (toUpper(triage)), a pattern, an UNWIND, a CALL, a UNION — the query cannot be narrowed by it and every group is judged, as before.

  • A LIMIT stops the judging when the answer is full. Under ORDER BY … LIMIT n, the groups are judged toward the top of the answer a few at a time, best rows first, and judging stops the moment the first n rows (or SKIP s LIMIT n: the first s + n) are all judged — every group below them is never sent to the model. “The ten biggest at-risk deals” costs judging the biggest deals until ten of them are at risk, not judging every deal. The answer is exactly what judging every group would give. This holds when the judgement only FILTERS rows; where its value shapes them — an ORDER BY on the judgement, a RETURN that is DISTINCT or aggregates, a later clause grouped by it, a LIMIT before the final RETURN — every candidate is judged as before. One residue is the store’s own: rows that tie exactly at the boundary of the window are seated arbitrarily, as they are under any LIMIT; such a row can carry no label, never a wrong one. A query whose filter would still need more than a few hundred model calls is REFUSED with the count, rather than sampled quietly. The refusal names the cap it hit, and a query that MEANS to spend that much says so: {ai: {maxGroups: 600}} raises it (up to 2000 — past that, compute the value once and persist it), and a smaller number LOWERS it, which is how a shipped view holds its own spending line. This is a cost guard, so it is the author’s to set; the completeness gate on a truncated fetch is a correctness guard and has no such override, by design.

  • The clause must carry the node the value belongs to. WITH e, classify(e.member, …) AS gender works; WITH e.name AS name, classify(e.member, …) AS gender is refused, because an aggregate value belongs to a group and a group needs an identity to attach it to. The refusal says so and names the fix. Project the node itself and read its properties later.

  • The value is attached to the group for the duration of the query only. It is never written to the graph: ask twice and it is computed twice, and nothing in the graph carries a stale label from a model run last week. To keep a classification, write it deliberately (§9 annotation writes) rather than relying on a query having filtered on it.

  • Merely RETURNING an aggregation is unchanged and stays cheaper — RETURN t.name, summarize(…) AS digest reduces after the read, with no pre-pass.

Grouping follows Cypher’s own rules: the non-aggregated keys of the clause define the groups, so WITH t, summarize(n.description, …) is one digest per t over all its ns — not one per n.

relevant, classify, holds and score accept a NODE variable as their item, not only a property:

MATCH (i:Issue)<-[:RAISED]-(:Attendee)-[:WORKS_FOR]->(:CustomerAccount)-[:HAS_CASE]->(c:SupportCase)
WHERE c.status IN ['open', 'pending']
WITH i, relevant(c, 'the same problem as: ' + i.summary) AS tracked
UNWIND tracked AS tc
MATCH (tc)-[:ASSIGNED_TO]->(e:Engineer)
RETURN i.summary, tc.subject, e.name
  • The model reads each node whole: its labels, its properties, and what the world’s types declare they mean — the description of the type and of each property (§ types). A status: pending is shown with its declared meaning, so a description written for query authors is read by the judge too; an undeclared type is shown with its labels and properties alone. Who may see a node (its scoping properties) and embeddings are never shown.
  • relevant over nodes keeps the group’s own nodes. The result is a subset of the nodes the group matched — the model can only keep what it was shown, by its position in what it was shown — so it can be UNWINDed, traversed from and re-matched like the nodes it came from, and it is scoped as they were. A group whose candidates were all rejected keeps an empty list. A group that could not be judged is never an empty list: its cell is null with the reason in the result, or — where a later clause reads the kept nodes — the query fails and says so, since an answer built on a silently missing group would look complete.
  • Projected in the final RETURN, the kept nodes come back as nodes.
  • A node is longer than a title, and the cost check prices what is shown: a reduction over nodes is quoted on the size of the nodes as rendered, and a query over the inline limit is refused before it runs, never cut down to fit.
  • What does NOT flow on: kept nodes from a variable that no node pattern bound (an UNWINDed list, a comprehension), and any structured result (correlate, regress, cluster, argmax). Re-binding the matched name itself (UNWIND tracked AS c after matching c) is refused with a request to use a new name.
  • Inside a subquery. A judgement may sit inside a unit CALL { … }, OPTIONAL CALL { … } or CALL () { … }, nested at any depth — the groups are read over the rest of that body. So a plan whose steps are unit subqueries can judge in one step what an earlier step projected. Refused, each for a reason and before any model call: an importing body (CALL { WITH x … }, CALL (x) { … } — a node grouped under two outer rows could collect different values and keep only one answer); inside EXISTS {}, COUNT {} or COLLECT {} (no clause boundary to read the groups up to); inside CALL {} IN TRANSACTIONS or any other braces; and a judging body that writes and runs more than once (later runs would form groups the judgement never saw).

Every signature above splits its arguments the same way, and getting the split wrong is the most common authoring error:

  • The leading text is a ROW EXPRESSION, accumulated — n.description, n.title + ' — ' + n.body, coalesce(n.a, n.b): anything Cypher can evaluate per row. It is gathered across the group exactly as collect() gathers, so ONE cell is produced per group, not one per row. A quoted literal in this position is rejected: it would reduce the same constant N times.
  • argmax accumulates TWO row expressions — argmax(key, text, criterion) collects key (what identifies the winner: a filename, a title, a name) and text (what is judged) together, so the answer can say WHICH item won and not merely what the winning text said. Every other function in the table accumulates exactly one.
  • Every argument after the accumulated ones is a LITERAL string — the instruction, goal, labels, what, focus, rubric, criterion, question — with one exception, below. A row expression there is not evaluated per row (there is nothing to evaluate it against once the group is reduced), so write the words themselves.
  • The exception: a judgement’s criterion may read the GROUPING KEY. relevant’s criterion, classify‘s rubric, holds’ question and score’s rubric may be an expression over the clause’s grouping keys — WITH i, relevant(c, 'the same problem as: ' + i.summary) AS tracked asks one question per issue. It is evaluated once per group, from that group’s keys only. An expression that reads a variable the clause aggregates over (relevant(c, 'like ' + c.title) grouped by i) is REFUSED before the query runs: it has a different value on every row of the group, and none of them is the group’s. A group whose criterion evaluates to null is not judged — its cell is null and the result says why — because no criterion would keep everything. classify’s label set is always a literal: it is the closed set every group’s answer is checked against.
  • Optional arguments are POSITIONAL but type-disambiguated. themes(text [, focus] [, count]) reads a NUMERIC argument as the topic count and a string as the focus, in whichever order they arrive — themes(n.title, 5) and themes(n.title, 'risks', 5) both mean what they look like. cluster(text [, k]) takes a number only.
  • classify labels are a single comma-separated string — classify(n.body, 'positive,neutral,negative'), not three arguments and not a list. The set is CLOSED: the answer is always one of the labels given.
  • An honest miss is a value, not an error. When the accumulated text does not contain what the instruction asks about — a group of document TITLES asked for a profit figure — a grounded reduction returns the exact sentence “The provided items do not contain this information.” rather than inventing one. Treat that sentence as “no answer”, never as the answer.

Steering ONE call — the trailing {ai: {…}} map

Section titled “Steering ONE call — the trailing {ai: {…}} map”

An aggregation may carry the same {ai: {…}} map as a virtual edge (§7.2), as its LAST argument. It is steering, not data: stripped before the query runs, never part of the aggregated text.

MATCH (t:ResearchTopic {name:'retrieval augmented generation'})-[:HAS_NEWS]->(n:NewsItem)
RETURN summarize(n.description, 'what is newest', {ai: {model:'chat_cheap', voice:'brisk', wordcount: 40}}) AS digest

Which keys are honoured depends on what the function returns — a list, a label, a number or a boolean has no voice and no word count:

Key Applies to Effect
model EVERY aggregation the world ROLE the reduction runs on; an unknown role falls back to the default, with a warning, never a failure
hint the PROSE reductions (summarize, synthesize) a soft steer added alongside the instruction — never a filter, and never able to override grounding
voice the PROSE reductions register/style (“second person, warm”)
wordcount the PROSE reductions a target length — a prompt-level target, never a mid-sentence cut
punctuation the PROSE reductions 'plain' (the only value): the result never contains a semicolon — the ban is enforced after the model writes, not merely requested, so it holds on every run
sample the HOLISTIC judgments (score, classify) {size, subset} — how much of the group reaches the model, and which part
text EVERY reduction {chars, keep, clean} — how much of each ITEM reaches the model, which part, and what is stripped first
maxGroups a FILTERABLE aggregation the group cap this query intends to spend, up to 2000

confidence, fresh and materialize are EDGE keys (a generative floor, a producer’s cache) and an aggregation has neither, so they are rejected here rather than accepted and ignored. A prose key on a non-prose function, an unknown key, and a {realm: {…}} map are all rejected the same way: warned about, never silently inert. Because a map is a value the map cannot be confused with an instruction — summarize(n.body, {ai: {voice:'noir'}}) steers, it does not summarize toward the word “noir”.

The map NESTS: sample groups two keys that are one decision, and the interior is ordinary Cypher (quoted or back-ticked keys, strings containing braces, any depth) because the real parser reads it, not a scanner. A trailing map carrying NEITHER ai nor realm is not steering at all — it stays the caller’s own positional argument.

sample — how much of a group a holistic judgment reads

Section titled “sample — how much of a group a holistic judgment reads”

score and classify reduce a group to ONE number or ONE label, so their evidence must fit a single model call — unlike summarize, themes, relevant or extract, which fold a whole group in batches. They therefore read a bounded sample of the group, and say what they left out: a group larger than the sample returns a PARTIAL_RESULT note naming the counts and the strategy, so a number over 40 of 500 items never renders as a number over all 500.

Settle what you already know before the model is asked. Wrap any reduction in a two-argument coalesce whose first argument is a plain expression over the row: where that expression is not null it IS the group’s value and no model call is made; where it is null the reduction runs as usual.

MATCH (a:CustomerAccount)
WITH a, a.caseSubjects AS problems, coalesce(a.crmNotes, '') AS written
RETURN a.name,
coalesce(CASE WHEN written = '' THEN 'unaware'
WHEN any(p IN problems WHERE toLower(written) CONTAINS toLower(p)) THEN 'aware' END,
classify('OPEN: ' + problems + ' || CRM: ' + written, 'aware,unaware',
'aware: a note touches the same topic as a support item …')) AS crmAwareness

An account sales never wrote about is unaware by definition, and one whose case subject appears verbatim in a note is aware by the same word test an app would apply — neither needs a model, and neither is sent one. The guard settles a GROUP only when every row of it carries the same non-null guard value; a group with one guarded row and one unguarded, or with guards that disagree, is judged whole by the model over its values (the guards are never part of the evidence). The guard must be a plain expression — one that aggregates is refused — and a reduction that collects two expressions (argmax, correlate, regress) cannot be guarded. The guarded form can be filtered, ordered and grouped by like any other reduction (§6.4), and there too the settled groups cost nothing.

Many groups share a call. A query that classifies per row is a query of many small groups, and classify judges up to twenty of them in one model call — RETURN c.id, classify(c.body, 'blocked,degraded,asking') over 150 cases costs about eight calls, not 150. Nothing about the answer changes: every group is still judged on its own evidence, its label still comes from the closed set, and a group the model’s reply does not settle unambiguously (a number missing, given twice, or given a label off the set) is judged again on its own rather than guessed. llmCalls in the result counts the calls actually made. score shares a call the same way — ORDER BY score(…) over many rows is batched, not one call per row — and keeps its contract: a batched score is the same 0–1 number, clamped, and a group whose evidence is too long for one call is scored on its own. holds is not batched: a verdict read from a numbered list was measured to differ from the verdict the same evidence gets on its own (a small judge answered FALSE in a batch where it answers TRUE alone), and a confidently wrong negative is a row silently missing from WHERE holds(…) = true — a holds over many rows still costs one call per group. The calls a reduction makes for independent groups (or batches of them) overlap, a few at a time, so a query that judges many groups waits for the slowest few round trips rather than the sum of all of them; the order of the rows and the count of the calls are unchanged by this.

A classify judgement asks the same question every run. The same evidence reaches the model cleaned the same way and in the same order, and the answer is snapped onto the declared label set, so a label never changes because the question did. It is not guaranteed to be identical run to run: the host sets no sampling temperature, which current models ignore or reject. A materialised view refreshed on its ttl keeps its labels between refreshes.

MATCH (t:ResearchTopic)-[:HAS_NEWS]->(n:NewsItem)
RETURN t.name,
score(n.description, 'relevance to enterprise AI',
{ai: {sample: {size: 120, subset: 'longest'}}}) AS fit
Key Effect
size items that reach the model. Capped at 200 — the evidence has to fit one call — and clamped with a warning rather than refused. Omit it to keep the function’s own default
subset which items: first (the default), last, longest, random. Omit it to keep first

Choosing a subset is not a formality. first and last are only as meaningful as the group’s ORDER, which is the engine’s unless the query put an ORDER BY before the clause that groups — so longest is the order-independent choice, and usually the better one: the longest items carry the most evidence per call. random is SEEDED from the call’s own criterion, so the same question over the same group always reads the same items; an unseeded spread would make identical queries disagree with nothing in the result to explain why.

sample on a folding reduction is rejected like any inapplicable key — capping summarize would quietly throw away evidence the author expected folded.

text — how much of each ITEM is read, and which part

Section titled “text — how much of each ITEM is read, and which part”

Where sample chooses the ITEMS, text chooses what each of them contributes. Every reduction cuts a long item to a per-item budget; this makes the budget, the part kept, and the cleaning explicit.

RETURN score(n.body, 'relevance to my Series A funding round',
{ai: {text: {chars: 2000, keep: 'around'}}}) AS fit
Key Effect
chars characters of each item that reach the model (capped at 8000 — a per-item budget multiplies by the sample). Omit to keep the reduction’s own default
keep which part survives: head (the default), tail, ends (both, with a visible elision), around
clean a CLEANER NAME, or auto (the default — every cleaner that detects its own noise) or none. Several compose: 'html,quoted-reply'

around reads the passages nearest your criterion, and it is the one to reach for on documents. Every reduction that cuts text is holding the criterion it is cutting it for — a rubric, a label set, a relevance criterion — so “the first N characters” can be “the N characters nearest the question” at no extra cost. On ai.relevant, where the judgment DROPS rows, that is the difference between a degraded answer and a row wrongly excluded because its deciding sentence sat in paragraph three.

Markup is stripped by default. A field carrying HTML spends most of a character budget on tags, so the sentence that decides the answer is the one that does not fit; clean: 'none' keeps the markup for the rare item whose markup IS the subject.

Cleaning is a named strategy, not a fixed list. html is the one the engine ships; a world adds another by registering a cleaner — an email’s quoted reply chain, a CMS field’s shortcodes, a log’s escape codes — and it is then available to every reduction and every per-row judgment by name, with nothing in the query language to change. auto runs whichever of them recognise their own noise in the item; naming one runs it whether or not it recognised anything, which is the escape hatch for a source the detector is deliberately too conservative for. A name that is registered nowhere cleans with NOTHING and says so — a typo must neither silently clean with something else nor fail the query.

An item cut to its budget returns a PARTIAL_RESULT note naming the part read and how many items were cut — cleaning alone does not, because removing markup loses nothing the model could have used.

The same {ai: {text: {…}}} map applies on a virtual EDGE, where it bounds what the per-row judgments read: WHERE ai.relevant(n, '…'), ORDER BY ai.score(n, '…'), RETURN ai.classify(n, '…') (§7.3–7.5).

cluster — groups with sizes you can trust

Section titled “cluster — groups with sizes you can trust”

cluster returns one entry per group — {label, size, share, examples} — where examples are the most typical members of that group.

Its cost does not grow with the size of the group the way the others do: every other function in the table is priced per batch of rows, so a few hundred items can exceed a lens’s time budget, while cluster stays flat. Reach for it on large groups.

MATCH (scope:DiseaseScope {registryQuery:'insomnia'})-[:HAS_TRIAL_SEARCH]->(run:TrialSearchRun)
MATCH (run)-[:RETURNED]->(trial:ClinicalTrial)
RETURN cluster(trial.title, 6) AS clusters

Three properties follow, and they are the reason to prefer it when membership matters:

  • The sizes are counted, not estimated. size is arithmetic over the rows the traversal returned. Every other function can describe a group but cannot say how big it is — a model asked for a count guesses. This is the difference between a claim and a number.
  • It is deterministic. The same rows always yield the same clusters and the same sizes, run after run — so a page built on it does not move underfoot when re-read.
  • It degrades rather than fails. If a group cannot be named, it still returns with its size and examples: the grouping is true whether or not anything named it.

Being a list of maps, clusters[0].size is addressable — which is what lets a surface make a cluster filter the rows it came from. Prefer themes when only the recurring topics matter and membership does not; prefer cluster for “what groups are in these, and how big is each”.