EngineAggregatorNode
Overview
- Executes the
ExecutionDAGover persisted artifacts using the aggregation engine. - Exists to combine scan results deterministically into final result sets.
- Sits after
layer_routerand beforeAnswerSynthesizerNode. - Class:
EngineAggregatorNode - Source:
packages/nl2sql/src/nl2sql/pipeline/nodes/aggregator/node.py
Responsibilities
- Load scan artifacts from
artifact_refs, lazily, as polars scans. - Execute combine and post‑operation nodes in DAG order, as one lazy plan.
- Save each terminal result as its own artifact, and produce
AggregatorResponsewith those artifacts and a preview of each.
Position in Execution Graph
Upstream:
- layer_router (after all scan artifacts are available)
Downstream:
- AnswerSynthesizerNode, when aggregation succeeded
- End of run, when it failed (aggregator_route in pipeline/routes.py)
Trigger conditions: - Router dispatches to aggregator when no pending scan nodes remain.
flowchart LR
Router[layer_router] --> Aggregator[EngineAggregatorNode] --> Synth[AnswerSynthesizerNode]
Inputs
From GraphState:
execution_dag(required)artifact_refs(required)
Validation performed:
- None in node; aggregation service raises on missing artifacts.
Outputs
Mutations to GraphState:
aggregator_response(AggregatorResponse)reasoninganderrors
Side effects:
- Reads artifacts from the artifact store.
- Writes one artifact per terminal result, in the run's folder:
<tenant_id>/<request_id>/result_<node_id>.parquetwith the default template (see Artifact Store). - Writes the run's result manifest beside them,
<tenant_id>/<request_id>/manifest.json(see Artifact Store).
Internal Flow (Step-by-Step)
- Read
execution_dagandartifact_refs. - Check every combine input against its row cap (see Row cap before a combine below). A capped input to a
join/compareends the run here withRESULT_TRUNCATED; a capped input to aunionadds aRESULT_TRUNCATEDwarning and aggregation goes on. - Invoke
AggregationService.execute(dag, artifact_refs). - A
join/comparecombine casts both sides of each key to one type before the join, in polars, non-strictly (Wave A, D30). The types are the ones the decomposer declared (JoinKeyPair.left_dtype,right_dtype): each side is cast first to its own, then both to the type they meet as (nl2sql/aggregation/join_keys.py: text is parsed as the other side's type, two numbers meet as a whole number, a date and a datetime as a datetime, a number and a boolean as a boolean by 0 and 1). A number goes straight to the number type, and two keys declaredfloatwhose frames hold integers join as integers: a float cannot tell ids above 2^53 apart. A cast never changes a value, it nulls it: 7.9 or a decimal 7.50 cast to a whole number is null, not 7 or 8. Text is stripped of surrounding spaces, on both sides of a text key too. Timestamps of any unit and zone become naive UTC microseconds; text becomes a date or timestamp only by the ISO formats (2024-01-02,2024-01-02 13:00[:00[.fff]], the same withT, a trailingZor a+02:00offset), never by inference, and aTIMEkey declared a date or timestamp is null. Text parsed as a number loses its padding, so"007"and"7"both match7: a key of zero-padded codes should be joined as text, or it may fan out. A value that does not convert becomes null and matches nothing;AggregationService.executecounts them per side (PolarsDuckdbEngine.unconverted_join_keys) and the node adds aJOIN_KEY_UNCONVERTEDwarning for each, carried toQueryResult.warnings: "3 of 412 support.customer_ref values are not numbers and joined nothing." No dialect is involved: this is compute on result frames. - A
join/comparecombine resolves each join key against its frame's columns. A key written with a side or sub-query prefix (right.customer,sq_2.customer) resolves to the bare column when only that exists; an unknown key is left for polars to report. Ajoin/comparewith fewer than two inputs raises (AGGREGATOR_FAILED) instead of returning the one frame it has; the decomposer refuses such a group before anything runs, so this is the last line of defence. - A post-combine op (
PolarsDuckdbEngine.post_op) applies every field it carries, in SQL's order: the reshaping itsoperationnames (aggregategroups with polars'group_by,projectselectsexpected_schema), then itsfilters(after an aggregate they filter the aggregated rows, as HAVING does), itsorder_by, and itslimit. Afilterop withorder_byandlimit, as the decomposer's prompt used to show, keeps all three. - Before polars is asked for any column, a post-combine op's attributes (
group_by,metrics,project'sexpected_schema,filters,order_by) are checked against the combined frame. An attribute the frame does not have is refused with the columns it does have, and a side-qualified one (right.customer) with the reason — see No anti-join below. The decomposer runs the same check on the decomposition before anything executes (nl2sql/aggregation/columns.py, shared with this engine) and asks the model again once, so here it is the last line of defence. AggregationService.executereturns each terminal node as a polarsLazyFrame: the scans areArtifactStore.scan_parquet, and the combines and post-combine ops only add to the plan, so no sub-query result is loaded whole before it is written.- Save each terminal result with
ArtifactStore.write_lazy, which streams the plan into a parquet artifact, and buildAggregatorResponse: computed_artifacts[node_id]: the saved result'sArtifactRef, with the whole result'srow_countandcolumns(sub_query_idunset).results: oneResultSummaryper saved result (node_id,columns,row_count, andtruncated: a sub-query upstream of the node in the DAG was cut at its row cap), also written as the run'smanifest.json.QueryResult.result_manifestcarries it, and the facade's paging and CSV export read the result by it.terminal_results[node_id]: the firstsettings.results.preview_rowsrows, read back from that artifact. This is what the answer model is shown; it is not the whole result.- Return success reasoning.
- On exception, emit
AGGREGATOR_FAILED, on the state'serrorsand onAggregatorResponse.errors. The graph'saggregator_routethen ends the run: the answer synthesizer is not called on the empty result, so the aggregator's error is the run's outcome and no model call is spent explaining{}.
Row cap before a combine
The generator caps every sub-query at its datasource's row_limit, bounded by
the settings.results.row_limit ceiling (100,000, also the cap when none is set), unless the plan asks for fewer rows itself, and records that cap as
row_cap on its response and on the sub-query's SubgraphOutput. The cap is
applied in each sub-query's SQL, before the combine: a join input with
2240 rows under a row_limit of 1000 reaches the aggregator as 1000, and every match the other 1240 held
is silently lost. The wrapper marks such a sub-query's SubgraphOutput.truncated,
which QueryResult and the answer carry (see AnswerSynthesizerNode).
So before combining, the aggregator looks at each combine input whose result
has row_cap rows or more:
join/compare: refused. The run ends withRESULT_TRUNCATED(throughaggregator_route, so no answer is synthesized), naming the sub-query and the cap. Missing rows mean missing matches, so the combined answer could be wrong, and a wrong answer is worse than none.union: a warning. The rows a union returns are still right, only possibly incomplete, so it runs and aRESULT_TRUNCATEDwarning with the sub-query's id is carried toQueryResult.warnings.
A result of exactly row_cap rows may be complete; telling the two apart would
mean fetching one row more, so it is treated as cut short. A plan's own top-N
(LIMIT 5) is the answer, not truncation, and is never flagged. To answer such
a question, ask something narrower (aggregate in the sub-query) or raise the
datasource's row_limit.
No anti-join: what "bought X but never Y" does here
CombineGroup.operation is one of standalone, compare, join and union.
join and compare are both inner joins (how="inner" in
PolarsDuckdbEngine.combine), and FilterSpec has no null or existence
operator, so there is no way to express "present on the left and absent on
the right". This is a capability gap in the plan language, not a bug in the
engine.
Asked "Which customers bought jazz tracks but never rock?", the model
decomposes it into the two sets and then reaches for a right-hand column to
negate against — right.customer. That column does not exist: an inner join
keyed on the shared column drops the right-hand copy, and the other right-hand
columns are suffixed _right. Every tier 2 run has failed this question, and
what reached the caller was polars' own unable to find column
"right.customer"; valid columns: ["customer"].
Resolving the prefix away would be worse. right.customer would become
customer, the filter would run against the intersection, and the answer
would be the customers who bought both genres — wrong, and silently so. So
the attribute check refuses the question and says why. The decomposer now
makes this check first and asks the model once more with the same message;
a second answer with the same problem ends the run in the decomposer with it,
before any scan runs. A recording run also met the made-up variant, a filter on
bought_rock, which the same check catches. The message reads:
Aggregator failed: Post-combine operation references 'right.customer', which
the combined result does not have. Available columns: customer. A column
qualified by side does not survive the combine: ... it cannot be expressed:
the plan language has no anti-join or set-difference operation ... so the
question is refused rather than answered with the intersection.
The same gap has a second shape: the decomposer sometimes emits the two
sub-queries uncombined, and AggregationService.execute then returns two
terminal result sets, which the run reports as "expected one result set, got 2"
(2026-09-22_27cad30). Both are the same missing operation.
Closing it properly means adding an anti-join (or except) to
CombineGroup.operation, the decomposer prompt, the DAG and the engine. That
is a feature, not a fix, and is deliberately not done here.
Contracts & Interfaces
Implements a LangGraph node callable:
Key contracts:
AggregatorResponseExecutionDAG
Determinism Guarantees
- Deterministic for a fixed DAG and artifact inputs.
- Aggregation order follows DAG layers and ordered inputs.
Error Handling
Emits PipelineError with:
AGGREGATOR_FAILEDJOIN_KEY_UNCONVERTED, a warning: join-key values that did not convert to the key's typeRESULT_TRUNCATED, an error when ajoin/compareinput hit its row cap, a warning when aunioninput did
Logs failures via logger.error.
Retry + Idempotency
- No internal retry logic.
- Idempotent for a fixed DAG and artifacts.
Performance Characteristics
- Scans Parquet artifacts lazily and executes joins/aggregations in polars; the combined result is streamed to its artifact (
sink_parquet), and only the preview rows are collected into Python. - Cost grows with artifact size and DAG complexity. A join, sort or group-by may still need its inputs in memory as polars columns, never as Python rows.
Observability
- Logger:
aggregator - Adds reasoning entries for success and failure.
Configuration
- Uses
PolarsDuckdbEngine. settings.results.preview_rows: rows of each terminal result kept interminal_results.- The combined result is written to the runtime's results store (
ctx.artifact_store,<storage.path>/results/), as the sub-query artifacts are.
Extension Points
- Replace aggregation engine by modifying node to use a different
AggregationService. - Replace node in
build_graph()for alternate aggregation behavior.
Known Limitations
- Fails if required artifacts are missing.
- Several post-combine ops on one combine group are siblings, not a chain: each reads the combine's output and is its own terminal result.
Related Code
packages/nl2sql/src/nl2sql/pipeline/nodes/aggregator/node.pypackages/nl2sql/src/nl2sql/aggregation/aggregator.py