Skip to content

feat(operators): add GroupBy for many→one reduction, with logical-type and provenance fixes (NPIPE-204) - #250

Merged
eywalker merged 21 commits into
mainfrom
arnoldb/npipe-204-orcapod-add-tag-based-grouping-to-batch-manyone-reduction
Aug 27, 2026
Merged

feat(operators): add GroupBy for many→one reduction, with logical-type and provenance fixes (NPIPE-204)#250
eywalker merged 21 commits into
mainfrom
arnoldb/npipe-204-orcapod-add-tag-based-grouping-to-batch-manyone-reduction

Conversation

@brian-arnold

@brian-arnold brian-arnold commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds GroupBy, orcapod's first many→one operator, and fixes two bugs found along the way —
one already shipped, one that made the new operator unusable on the column type it was built
for.

GroupBy(by=["subject", "date"]) collapses N rows sharing a tag tuple into one packet: group
keys stay scalar tags, every other column becomes list-valued. This is what lets a downstream
pod receive a whole recording session at once — every existing pipeline fans out, and nothing
reduced.

grouped = op.operators.GroupBy(by=["subject", "date"]).process(sync_out)
common_clock_op.pod(grouped)

⚠️ API deviates from the issue

NPIPE-204 specifies Batch(group_by=[...]). This PR adds a separate GroupBy(by=[...]).

Batch exists for throughput and pipelining — its own async_execute docstring says batching
lets "downstream consumers start processing before all input is consumed" — and its git history
contains only refactors. Folding reduction into it would give one class two output contracts
selected by which kwarg you passed. Splitting keeps one contract per class, leaves Batch's
streaming path untouched, and makes GroupBy's barrier structural rather than a conditional
inside a streaming operator.

The Linear issue still shows the old API. Full reasoning in
superpowers/specs/2026-08-07-npipe-204-batch-group-by-design.md.

Two bugs fixed

1. Data could not represent list-valued provenance — already shipped and silently broken.
_ensure_source_info_table hard-coded large_string for every _source_* column, so any
operator emitting a list-valued token crashed:

pyarrow.lib.ArrowTypeError: Expected bytes, got a 'list' object
  core/datagrams/tag_data.py:342  _ensure_source_info_table

MergeJoin already does this (merge_join.py:262) and was failing inside job.run() on
966d759a. Reproduced independently of Batch; now covered by a job-level regression test.
Resolves the tag_data.py half of DESIGN_ISSUES §U1.

2. Aggregating operators dropped logical element types. Batch and GroupBy built their
schema with pa.list_(field.type), which raises ArrowNotImplementedError on any
extension-typed column — Arrow cannot embed an extension type inside a list value field
(§ET1/ET2). A pod annotated -> Path emits extension<orcapod.path>, so grouping the column
the motivating consumer actually needs was impossible. ITL-173 (#251) fixed the
type-declaration layer with ListLogicalType but never touched the operator layer, so
declaring list[Path] worked while grouping it still failed.

Both operators now build through arrow_utils.build_aggregated_table, which constructs the
list over the element's storage type and wraps it in the outer list[<element>] extension
type. Verified end to end:

grouped col type:   extension<list[orcapod.path]>
grouped py schema:  {'result_path': list[pathlib.Path]}
pod received:       [['PosixPath','PosixPath'], ['PosixPath']]

Downstream pods can take list[Path] directly — no Pathstr conversion pod needed.

Design decisions

Decision Rationale
Source-info types derived from the value Fixes MergeJoin for free; keeps per-member provenance readable. No pipeline-DB schema bump — a node's source-column type is fixed by its own output schema.
System tags fold to a scalar digest _build_record_id_preimage hashes them directly, so they must stay scalar. Folding rather than dropping preserves the Merkle link, so an upstream recompute yielding identical data still invalidates.
uuid5 + combine_hashes, never hash() The digest is a cache key. A per-process value looks correct in a single-process test and misses the cache on every new driver run. Pinned by a subprocess test.
Members sorted by non-key tags, record_id as tiebreaker Tags are unique within a stream, so this is a total order needing no assumption about which column holds a path. Upstream emission order is not stable (Ray scheduling, DB fetch order).
Groups emitted in key order Output is byte-identical under input reordering.
One shared list-wrapping helper Batch and GroupBy had the same defect; fixing it once keeps them from drifting.

Testing

tests/: 4742 passed, 93 skipped, 2 xfailed, 0 failures
test-objective/: 567 passed, 7 xfailed — note this is a separate suite with its own
workflow and is not covered by pytest tests/.

New job-level coverage in tests/test_pipeline/test_aggregation_job.py closes the gap that let
bug 1 ship — every prior aggregating-operator test stopped at op.process() + as_table(),
which never reaches _materialize_to_stream. Against a real DeltaTableDatabase:

  • Two identical runs → zero pod invocations on the second, both rows still returned
  • One member changed → only that group recomputes; the untouched group stays cached
  • A control test points the second run at a fresh store, proving the cache-hit assertion can fail
  • MergeJoin regression test for bug 1

Two test-objective provenance tests were updated: they asserted the old rule that Batch
list-wraps system tags. That changed deliberately, so the tests were wrong, not the code. They
now assert both halves — user columns do list-wrap, system tags stay scalar and gain a
name-extension block.

⚠️ Reviewer attention: DESIGN_ISSUES §O5

A reduction can persist a result computed from an incomplete member set. After an upstream
change, the first job.run() emits only the recomputed rows. For a per-row pod that is
complete; for a reduction it is partial — the group executes and its result is persisted with
nothing marking it incomplete, then self-corrects on the next run.

CONTROL (per-row)  changed run1  pod(sync_99)          <- converged in one run
GROUPED            changed run1  GROUP ['sync_99']     <- ONE member, persisted
                   changed run2  GROUP ['sync_99','sync_1']

Batch behaves identically, so this is pre-existing pipeline semantics surfaced by reduction —
but it matters more here, because GroupBy exists so a pod can reason over a complete group.
For common_clock_op an intermediate run can write an alignment.json built from one of a
session's probes.

This is not fixable inside the operator — an operator sees whatever its upstream emits and
cannot know whether a group is complete. A fix needs a completeness signal at the node level.

I'd value a reviewer's judgment on whether this gates the downstream rev bump; that depends on
how common_clock_op's output is consumed, which the sync-and-qc side knows better than I do.

Other known limitations, logged not fixed

  • §O3_materialize_to_stream applies row 0's source_info() to every row, so rows 1+
    are mis-attributed. Pre-existing and equally wrong for scalars, but this PR makes it more
    visible: the list case previously crashed loudly and now completes with wrong tokens.
  • §O4GroupBy member order is undefined when the input has duplicate tag tuples. Root
    cause is that DuplicateTagError is defined but never raised, so duplicates are never
    prevented. Does not affect the common_clock_op wiring, which groups on a strict subset.
    Now tracked in ITL-630 (below).
  • §O2 — operators uniformly discard a non-default _context_key. Pre-existing, layer-wide.
  • §U1's arrow_utils.add_source_info_to_table() half remains open. The dead
    polars_data_utils.add_source_info (a third site, with a latent shadowing bug) was deleted.

Follow-up tracked: ITL-630

Two design questions raised in review are deferred rather than settled here, and are tracked in
ITL-630
(Tools / Orcapod Python v0.2 Feature Sprint, alongside ITL-173 and ITL-627):

  • Member and group ordering. That the order is deterministic is a requirement — the
    emitted lists are hashed into the memoization key, and upstream emission order is not stable
    across runs. Which order, and whether the operator promises one publicly, is open.
  • Empty-input behaviour. "Zero rows in, zero groups out" is the current default but was
    never designed; emit-nothing, emit-empty-group, and raise were not weighed.

ITL-630 also absorbs §O4, since duplicate-tag ordering is the same "what do we promise"
question. §O5 is noted there as related but out of scope — its cause is pipeline propagation,
not operator semantics.

TODO(ITL-630) comments mark both sites in tests/test_core/operators/test_group_by.py. The
tests pin current behaviour so a change is deliberate rather than accidental.

The third review note — the source-info type-derivation shortcut in core/datagrams/tag_data.py
— carries its own TODO(NPIPE-204) and is not part of ITL-630.

Usability gotcha worth knowing

A pod function nested inside another function, in a module using
from __future__ import annotations, cannot resolve non-builtin annotations — Path arrives as
the string 'Path' with no resolvable scope. Module-level pods are fine. This bit the new test
fixture and would bite any pipeline module written the same way.

Follow-up

Rebased onto current main, so ListLogicalType (#251) and GroupBy are in one tree. Merging
requires a coordinated rev bump in both orcapod-sync-and-qc and orcapod-spikesorting — they
are deliberately kept on the same rev because they target the same Ray cluster.

Relates to NPIPE-204.

🤖 Generated with Claude Code

@brian-arnold
brian-arnold force-pushed the arnoldb/npipe-204-orcapod-add-tag-based-grouping-to-batch-manyone-reduction branch from f73f539 to b93ab32 Compare August 21, 2026 18:54
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.69281% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/orcapod/core/datagrams/tag_data.py 94.73% 1 Missing ⚠️
src/orcapod/core/operators/batch.py 96.15% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@brian-arnold brian-arnold changed the title feat(operators): add GroupBy for many→one reduction + fix list-valued provenance (NPIPE-204) feat(operators): add GroupBy for many→one reduction, with logical-type and provenance fixes (NPIPE-204) Aug 21, 2026
@brian-arnold
brian-arnold marked this pull request as ready for review August 21, 2026 23:29
@brian-arnold
brian-arnold requested review from eywalker and a lite review from Copilot August 25, 2026 23:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a first-class many→one reduction operator (GroupBy) to the operator layer, along with underlying provenance + logical-type aggregation fixes needed for job.run() correctness and cache stability.

Changes:

  • Introduces GroupBy(by=[...]) and registers it for serialization and the stream fluent API.
  • Fixes list-valued provenance handling by widening SourceInfoValue and deriving _source_* Arrow/Python types from stored values.
  • Adds shared aggregation helpers (fold_system_tag_values, build_aggregated_table) and updates Batch to fold system tags to scalar digests while preserving list-valued member/source columns; adds job-level regression coverage.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/test_utils/test_arrow_utils.py Adds pinned + cross-process tests for system-tag folding digests.
tests/test_pipeline/test_aggregation_job.py Adds job-level Delta-backed aggregation tests covering memoization + MergeJoin regression.
tests/test_core/operators/test_operators.py Extends Batch tests to assert scalar system tags and list-valued source columns.
tests/test_core/operators/test_group_by.py Adds comprehensive operator-level tests for GroupBy shape, ordering, validation, schema prediction, and logical types.
tests/test_core/datagrams/test_data_source_info_types.py Adds unit tests for list-valued _source_* provenance typing/round-tripping in Data.
test-objective/integration/test_provenance.py Updates objective provenance assertions for “reducing” ops (system tags scalar; user cols list-wrapped).
superpowers/specs/2026-08-07-npipe-204-batch-group-by-design.md Adds design spec documenting API deviation and implementation details.
superpowers/plans/2026-08-07-npipe-204-groupby-operator.md Adds implementation plan / checklist for the work.
src/orcapod/utils/polars_data_utils.py Removes dead add_source_info implementation.
src/orcapod/utils/arrow_utils.py Adds fold_system_tag_values and build_aggregated_table to support many→one ops + logical-type-safe list aggregation.
src/orcapod/types.py Introduces SourceInfoValue type alias for recursive list-valued provenance tokens.
src/orcapod/protocols/core_protocols/datagrams.py Updates DataProtocol source-info typing to SourceInfoValue.
src/orcapod/pipeline/serialization.py Registers GroupBy in the operator registry for (de)serialization.
src/orcapod/core/streams/base.py Adds .group_by(...) fluent method to StreamBase.
src/orcapod/core/streams/arrow_table_stream.py Widens source_info typing to SourceInfoValue.
src/orcapod/core/operators/group_by.py Implements the new GroupBy operator (partition, deterministic ordering, system-tag folding, schema prediction).
src/orcapod/core/operators/batch.py Updates Batch aggregation semantics to fold system tags scalar + use shared aggregated-table builder.
src/orcapod/core/operators/init.py Exports GroupBy from the operators package.
src/orcapod/core/datagrams/tag_data.py Makes Data derive _source_* Arrow/Python types from stored provenance values (including recursive lists).
DESIGN_ISSUES.md Logs/updates operator-layer design issues and marks the tag_data.py half of U1 as resolved.
CLAUDE.md Updates architecture guidance for reducing ops, GroupBy, and aggregated logical types.
.zed/rules Mirrors the same documentation updates for Zed AI rules.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +870 to +885
import subprocess
import sys

script = (
"from orcapod.utils.arrow_utils import fold_system_tag_values\n"
"rids = [bytes.fromhex('0102030405060708090a0b0c0d0e0f10'),\n"
" bytes.fromhex('1112131415161718191a1b1c1d1e1f20')]\n"
"print(fold_system_tag_values('_tag_record_id::abc123', rids).hex())\n"
"print(fold_system_tag_values('_tag_source_id::abc123', ['src_a','src_b']))\n"
)
out = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
check=True,
).stdout.split()

@brian-arnold brian-arnold Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3ce1be6 — explicit PYTHONPATH, prepended so an existing value survives.

On severity: it was not failing. The venv has an editable install whose .pth points at src, so sys.executable resolves orcapod regardless of cwd — verified from /tmp with PYTHONPATH unset, and CI was green on 3.11/3.12. Still right in principle, since the test depended on install mode rather than the suite path config.

Brian Arnold and others added 16 commits August 26, 2026 02:58
…IPE-204)

Adds the design for NPIPE-204: `group_by` on the `Batch` operator for
many->one reduction, plus the type-aware source-info fix that unblocks it.

Also expands DESIGN_ISSUES U1 to record the `tag_data.py` half of the
hard-coded `large_string` source-info type, which is the actual cause of
the `ArrowTypeError` that breaks both `Batch` and `MergeJoin` inside
`job.run()`.

NPIPE-204

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Splits the reduction semantics out of `Batch` into a new `GroupBy`
operator, so each class keeps one output contract and `Batch`'s streaming
async path stays untouched. This deviates from NPIPE-204's stated
`Batch(group_by=...)` API; the issue and the downstream wiring both need
updating.

Also from review:
- Pin the system-tag fold to `combine_hashes` and `uuid.uuid5`, reusing
  the convention in `stream_builder._make_record_id`, and add a subprocess
  test. Nothing previously guaranteed cross-process digest stability.
- Drop the `_context_key` row from the column table. Verified that
  `as_table(columns={"source", "system_tags"})` excludes it, so no
  operator ever sees a context key -- logged as DESIGN_ISSUES O2 instead.
- Record the dead `polars_data_utils.add_source_info` as a third
  hard-coded site under U1; delete it rather than fix it.
- Require >=2 groups in the invalidation fixture, and cover empty input.

NPIPE-204

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ten TDD tasks: type-aware source info in Data, delete the dead polars
add_source_info, the shared system-tag fold helper, Batch's provenance fix,
the GroupBy operator, schema-mirroring and registration, job-level tests,
and docs.

NPIPE-204

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_ensure_source_info_table and Data.schema hard-coded large_string / str for
every _source_* field, so any operator producing a list-valued provenance
token crashed with ArrowTypeError inside job.run(). MergeJoin already does
this (merge_join.py:262) and was silently broken.

Types are now derived from the stored value. None still maps to
large_string, so unknown-provenance columns are unchanged. No pipeline-DB
schema bump: a node's source-column type is fixed by its own output schema.

Refs DESIGN_ISSUES U1.
NPIPE-204

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tions (NPIPE-204)

Review follow-up to 1b570e6.

- Move SourceInfoValue from core/datagrams/tag_data.py to types.py, next to
  TagValue/DataValue, so protocols/ can reference it without inverting the
  protocols -> core layering. Not exported from core/datagrams/__init__.py;
  types.py is the visibility surface.
- Cover the recursive descent in _source_info_arrow_type /
  _source_info_python_type with a nested-token case. The previous tests all
  passed against a non-recursive implementation.
- Widen DataProtocol.source_info / with_source_info and
  ArrowTableStream.__init__ from str | None to SourceInfoValue. The narrow
  annotation is already false at runtime: _materialize_to_stream and
  sync_orchestrator pass a MergeJoin's list-valued tokens straight into that
  parameter. EmptyData.empty_source_info stays str | None -- nothing in src/
  constructs it, so it cannot reach _ensure_source_info_table.
- Annotate _source_info_python_type as -> DataType; list[str] is a
  GenericAlias, not a type, and the value flows into Schema.
- Narrow both isinstance checks to list, matching the alias; no code path
  produces a tuple.

NPIPE-204

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zero callers in src/, no test coverage, and a latent shadowing bug where
source_column is rebound to a pl.Series inside the per-column loop. It was
also a third site hard-coding a scalar string type for source info.

Refs DESIGN_ISSUES U1.
NPIPE-204
…rs (NPIPE-204)

System tags must stay scalar because _build_record_id_preimage hashes them
directly. Each column folds independently over its ordered member values:
record_id via uuid5 (matching stream_builder._make_record_id), source_id via
combine_hashes. Both SHA-based, so the digest is stable across processes --
pinned by a subprocess test, since a hash()-based fold would look correct
within one process and miss the cache on every new driver run.

NPIPE-204

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…E-204)

Batch list-wrapped every column including the system tags, but record
identity hashes those columns directly, so they must stay scalar. They now
fold via fold_system_tag_values and gain a ::{pipeline_hash} name suffix,
mirroring the name-extending rule joins already use.

Partitioning, list-valued tag columns, and the streaming async_execute path
are unchanged.

NPIPE-204

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…PE-204)

Every other operator preserves one row per tag; GroupBy collapses N rows
sharing a tag tuple into one packet with list-valued members, which is what
lets a downstream pod receive a whole recording session at once.

Group keys stay scalar tags; non-key tags are promoted to list-valued data
columns rather than dropped, so consumers can tell which member each element
came from. Members sort by non-key tag values so the hashed lists are stable
across runs.

NPIPE-204
- unary_output_schema no longer predicts _source_* columns for promoted tags.
  ArrowTableStream.output_schema returns the data schema unconditionally, so
  columns.source is a no-op at the schema level; the old prediction was also
  internally inconsistent, declaring _source_probe but not _source_path.
- record_id is now an unconditional final tiebreaker in the member sort rather
  than an elif fallback. The elif branch was dead whenever any non-key tag
  existed, leaving duplicate tag tuples ordered by emission order, which Ray
  scheduling and DB fetch order make nondeterministic.
- Reject list-valued group keys in validate_unary_input. Batch list-wraps its
  tag columns, so Batch -> GroupBy previously died with a bare
  TypeError: unhashable type: 'list' naming neither operator nor column.
- Reject duplicate names in by, which produced a duplicated output tag column.
- Add output-schema parity tests across the four {source, system_tags} configs,
  comparing against the materialized stream from unary_static_process rather
  than process(), whose DynamicPodStream delegates back to the pod and would
  make the comparison circular.
- Tighten the record_id ordering test to assert the exact emitted list; it
  previously sorted the output first, making it blind to ordering.
- Drop unused logging import and function-local ArrowTableStream import.

NPIPE-204
…IPE-204)

Without the registry entry a pipeline containing a GroupBy cannot be
deserialized.

NPIPE-204

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…PE-204)

Every existing aggregating-operator test stopped at op.process() + as_table(),
which never reaches _materialize_to_stream -- the reason the list-valued
provenance crash shipped. These run the full job.run() path against a Delta
Lake store and assert memoization holds across identical runs and invalidates
only the group whose member changed.

NPIPE-204

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
System tag evolution rule 3 described Batch as purely type-evolving
(str -> list[str]), which was only ever true of user tag and data columns --
system tags fold to a scalar and extend their name. Renames the rule to
"reducing" and covers both Batch and GroupBy, including why the fold must be
SHA-based rather than hash().

Also records two schema-verification traps found during implementation: a
prediction must be checked against unary_static_process(...).output_schema(),
not against process() (circular) or as_table() (legitimately differs).

DESIGN_ISSUES:
- U1 tag_data.py half resolved; arrow_utils.py half stays open.
- O3 (new): _materialize_to_stream applies row 0's source_info to every row,
  so rows 1+ are mis-attributed. Pre-existing and equally wrong for scalars,
  but the U1 fix turns what was a loud crash into a quiet wrong answer.
- O4 (new): GroupBy member order is undefined when the input has duplicate
  tag tuples. Not fixable inside the operator -- the root cause is that
  DuplicateTagError is defined but never raised, so duplicates are never
  prevented.

NPIPE-204

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…IPE-204)

Batch and GroupBy built their output schema with pa.list_(field.type), which
raises ArrowNotImplementedError on any extension-typed column: Arrow cannot
embed an extension type inside a list value field (DESIGN_ISSUES ET1/ET2).
A pod annotated -> Path emits extension<orcapod.path>, so grouping the column
the common-clock pipeline actually needs was impossible.

ITL-173 (#251) fixed the type-declaration layer with ListLogicalType but never
touched the operator layer, so declaring list[Path] worked while grouping it
still failed.

Both operators now build through arrow_utils.build_aggregated_table, which
constructs the list over the element's storage type and wraps it in the outer
list[<element>] extension type. Verified end to end: sync pod -> Path, GroupBy,
downstream pod annotated list[Path] receives real Path objects through
job.run(). The Path -> str conversion pod callers were using as a workaround is
no longer needed.

Adds a path-typed regression fixture. Every previous fixture used plain
large_string/int64, which is why this shipped uncaught. The new fixture is
defined at module level on purpose: this file uses `from __future__ import
annotations`, and a pod nested in a test method has unresolvable stringified
annotations.

Also updates the ET2 section's stale src/orcapod/extension_types/ path after
main's rename to logical_types/.

NPIPE-204

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rule (NPIPE-204)

Two tests in test-objective/ encoded the old rule that Batch list-wraps system
tag columns. That behaviour changed deliberately: system tags must stay scalar
because _build_record_id_preimage hashes them directly to derive record
identity. They now fold to a digest and gain a ::{pipeline_hash} name block.

The tests are the thing that was wrong, not the code. Updated to assert both
halves of the rule, which makes them stronger than before:
- user tag and data columns DO become lists
- system tag columns stay scalar AND gain a name-extension block

Renames TestTypeEvolving -> TestReducing to match the rule's new name in
CLAUDE.md, and splits the single batch test so a failure says which half broke.

These were missed locally because test-objective/ is a separate suite with its
own workflow (run-objective-tests.yml) and is not covered by `pytest tests/`.

NPIPE-204

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… (NPIPE-204)

After an upstream pod's output changes, the first job.run() emits only the rows
recomputed in that pass. For a per-row pod that emission is complete; for a
many->one operator it is partial — the group is reduced over only the members
present, the reducing pod executes, and its result is persisted with nothing
marking it incomplete. It self-corrects on the next run.

Measured with two upstream pod stages: the per-row control converges in one run
while the grouped path needs two, executing on a one-member group first. So the
behaviour is NOT identical with and without GroupBy, which is how it was
previously characterised.

Batch behaves the same way, so this is pre-existing pipeline semantics surfaced
by reduction rather than something GroupBy introduced — but it matters more for
GroupBy, which exists precisely so a pod can reason over a complete group.

Not fixable inside the operator: an operator sees whatever its upstream emits
and cannot know whether a group is complete. Logged for reviewer judgment
rather than fixed here.

NPIPE-204

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brian-arnold
brian-arnold force-pushed the arnoldb/npipe-204-orcapod-add-tag-based-grouping-to-batch-manyone-reduction branch from 72c88b0 to f069a98 Compare August 26, 2026 03:03
…lumn (NPIPE-204)

A joined stream carries one record_id system tag per canonical input
position. The broadcast side of a fan-out repeats its id on every row it
was joined into, so it cannot separate those rows -- only the fanned-out
side's record_id can. The tiebreaker took the first record_id column in
table order, so whenever the broadcast input sorted to canonical position
0 the sort key tied on exactly the rows the tiebreaker exists for, and
members fell back to emission order. DB fetch order and Ray scheduling
make that nondeterministic, so a reducing pod's input hash could change
between runs and trigger a spurious recompute.

Append every record_id column, sorted by name so the key order does not
depend on column order in the input table.

Reachable only when a group has two or more members with identical
non-key tags -- otherwise a non-key tag already breaks the tie -- and only
when the broadcast input sorts first canonically. The new test pins both
conditions; its literals are load-bearing, and it fails with
[10, 20] != [20, 10] without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brian-arnold

Copy link
Copy Markdown
Collaborator Author

Review + one fix pushed (7baafcf6)

Reviewed the full diff against origin/main. Unit suite passes (4767 / 93 skipped / 2 xfailed), predicted-vs-materialized schema equality checked for both aggregating operators across {}, source, system_tags, all_info, and the extension-type paths (Path, np.ndarray, nested list[list[Path]]), empty input, drop_partial_batch, join-then-group, group-then-group, group-then-batch, and the job/Delta path all exercised. The DESIGN_ISSUES notes (O1–O5, U1) accurately capture most of the known gaps.

One finding fixed in 7baafcf6; the rest are left for the author to judge.

Fixed — group_by.py: the member-order tiebreaker only consulted the first record_id column

A joined stream carries one record_id per canonical input position, and the broadcast side of a fan-out repeats its id on every row it was joined into — so it cannot separate those rows; only the fanned-out side's record_id can. next((c for c in system_tag_columns if c.startswith(SYSTEM_TAG_RECORD_ID_PREFIX)), None) took one column, so whenever the broadcast input sorted to canonical position 0 the sort key tied on exactly the rows the tiebreaker exists for, and members.sort fell back to emission order. DB fetch order and Ray scheduling make that nondeterministic, so a reducing pod's input hash can change between runs and trigger a spurious recompute.

Now appends every record_id column, sorted by name so the key order does not itself depend on column order in the input table.

Worth noting how narrow the reachable case is:

  • It needs a group with ≥2 members sharing identical non-key tags — i.e. duplicate tag tuples in the stream (§O4 territory). With unique tags a non-key tag column already breaks the tie and record_id is never reached.
  • It also needs the broadcast input at canonical position 0. When the fanned-out input sorts first, ordering was already deterministic. Two of my test attempts landed on that benign arrangement, so the literals in the new test are load-bearing.
  • The exposure is on materialized / DB-backed streams, where record_ids are fixed and fetch order varies. Permuting an in-memory source is not a valid repro: record_id there is derived per row position, not from row content, so permuting the source permutes the ids too — which the class docstring already declares undefined.

So it is not a merge blocker on its own, but it makes the tiebreaker actually function for joined streams instead of silently tying, and it makes the determinism claim in the class docstring (lines 53–58) and in the §O4 note true rather than false. New test test_join_fan_out_ties_broken_by_every_record_id_column fails with [10, 20] != [20, 10] without the fix.

Not fixed — for your call

arrow_utils.py:1279build_aggregated_table builds 32-bit pa.list_ while the rest of the PR uses large_list. _source_info_arrow_type (tag_data.py:263) emits large_list, ListLogicalType's storage is large_list, and add_system_tag_columns uses large_string; only the non-extension member branch uses pa.list_(field.type). Observed: GroupBy(...).unary_static_process(src).as_table() gives path: list<item: string> while the same node through PipelineJob.run() gives path: large_list<item: large_string>. Any pa.concat_tables/union across the two, or any schema comparison / arrow hash, sees a type mismatch. Suggest pa.large_list(field.type).

group_by.py:68 — a bare string by is silently expanded per character. tuple(by) with str satisfying Collection[str] means GroupBy(by="subject") type-checks and produces ('s','u','b','j','e','c','t'), failing later with InputValidationError: ['s','u','b','j','e','c','t'] are not tag columns. Other APIs here accept str | Collection[str] (e.g. PythonDataFunction(output_keys="n")), so this is a likely user mistake — normalize or reject explicitly.

arrow_utils.py:1222 — the non-record_id fold concatenates members with no delimiter and maps None to "". combine_hashes is "".join, so ["ab","c"] and ["a","bc"] fold identically, and [None,"abcd"], ["abcd",None], ["","abcd"], ["abcd"] are indistinguishable. Latent today since source_id values are fixed-length hex — but join.sort_system_tag_values substitutes [None] * num_rows for absent paired columns, so nulls do reach the fold. Interpose constants.BLOCK_SEPARATOR, as the record_id branch already does.

tag_data.py:263 — list element type inferred from value[0] alone. [None, ["tok"]] returns large_list(large_string) and _ensure_source_info_table then raises a confusing ArrowTypeError. Not constructible from current operators, so robustness rather than a live defect; scanning for the first non-None element removes the sharp edge.

types.py:50 — the SourceInfoValue docstring calls MergeJoin a many-to-one operator. It is a row-preserving binary join that merges colliding columns into list[T]; CLAUDE.md and GroupBy's own docstring both state GroupBy is the only many→one operator. Suggest "aggregating operators such as GroupBy and Batch, and MergeJoin's merged columns".

Notes

  • ruff format --check would reformat group_by.py, arrow_utils.py, and three new test files, but no workflow gates on ruff and arrow_utils.py was already unformatted on main — not a CI break.
  • No regressions in the highest-risk areas: Batch's rewritten batching loop is behaviourally identical to the old index-counter version (including batch_size <= 0 and drop_partial_batch), the extension re-wrap in build_aggregated_table handles the zero-chunk empty-table case, and the new top-level from orcapod.hashing.hash_utils import combine_hashes introduces no import cycle.
  • On §O5 — I agree it is not fixable inside the operator; a completeness signal has to live at the node level.

🤖 Generated with Claude Code

…NPIPE-204)

The operator reference pages listed every operator except the one this branch
adds, and the hashing reference still described the pre-change Batch behaviour.

- docs/api/operators.md: add the GroupBy mkdocstrings entry.
- docs/concepts/operators.md: add a GroupBy section (verified against a real
  run), note the many-to-one exception under the operator/function pod
  boundary, and mention grouping in the UnaryOperator category.
- docs/reference/hashing.md: site 6's "Batch list-wraps system tags" note was
  made stale by this branch; replace it and add site 6b for the aggregating
  operator fold in arrow_utils.fold_system_tag_values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brian-arnold

Copy link
Copy Markdown
Collaborator Author

Review: consistency with the rest of the codebase

Reviewed specifically for whether this matches the conventions and rigor of the surrounding code. It largely does; one real gap, which I've now fixed on the branch, plus a few small items left for you to judge.

Conforms

  • Operator boilerplate is complete. to_config, identity_structure, registration in pipeline/serialization.py, export from core/operators/__init__.py, and a fluent StreamBase.group_by(). Nothing in that set was skipped, which is more than some existing operators manage.
  • Barrier semantics come from the base class. GroupBy correctly does not override async_execute, inheriting UnaryOperator's collect-then-process path — and test_does_not_override_async_execute pins that, so a future streaming override can't land silently.
  • Schema prediction is verified the right way. TestGroupByOutputSchema compares against unary_static_process(stream).output_schema(...), not process(...) (circular) and not as_table() (legitimately different) — exactly the trap CLAUDE.md warns about.
  • Google docstrings with no ReST roles, no backward-compat shims, and DESIGN_ISSUES.md / CLAUDE.md / .zed/rules all updated per the project rules.
  • Test structure (class-grouped, one behavior per test) matches test_operators.py. The touched test files pass locally: 226 passed.

Gap — fixed in 6f26a20

User-facing docs did not include the operator. CLAUDE.md and .zed/rules were updated, but docs/ — the actual library documentation — was not:

  • docs/api/operators.md carries one ::: orcapod.core.operators.X block per operator; GroupBy was absent.
  • docs/concepts/operators.md carries a prose section per operator (### Batch at line 90); GroupBy was absent.
  • docs/reference/hashing.md was made stale by this branch. Site 6's "Known exclusions" cell still read "Batch changes the column type from str to list[str] but preserves the column name" — which this PR deliberately reverses. The branch also introduces a genuinely new hash site (arrow_utils.fold_system_tag_values) that the doc's exhaustive site index did not list.

I pushed 6f26a20 covering all three: the mkdocstrings entry, a ### GroupBy concepts section (both code examples verified against a real run rather than written from the docstring), the many-to-one caveat under the operator/function-pod boundary table, and a new Site 6b — Aggregating operator system tag fold entry replacing the stale Site 6 note. mkdocs build is clean — no new warnings, and the 11 remaining ones are pre-existing in files this branch doesn't touch.

Minor, left to your judgment

  1. Not ruff-formated. .pre-commit-config.yaml runs ruff-format pinned at v0.14.4; under that exact version group_by.py, arrow_utils.py, test_group_by.py, and test_aggregation_job.py all fail --check. Caveat that makes this low-stakes: roughly 106 of 185 files under src/ fail repo-wide, so the hook plainly isn't enforced today. Trivial to fix if you want the new files clean.
  2. Dead import block, group_by.py:17-20 — the TYPE_CHECKING / LazyModule("pyarrow") pair is present but pa is never referenced anywhere in the file. Ruff won't flag it, since it's an assignment rather than an import.
  3. build_aggregated_table(type_converter: Any) (arrow_utils.py:1231) — the same module already types this parameter as TypeConverterProtocol in make_empty_table (line 20).
  4. The two branches of fold_system_tag_values disagree on separators. The record_id branch joins member hex with BLOCK_SEPARATOR; the other delegates to combine_hashes, which concatenates with "" and is therefore length-ambiguous. Not a live bug — the values are fixed-width digests — but it's an asymmetry in a function whose docstring makes a point of digest stability.

Not a regression

pyright reports 2 errors in group_by.py (stream.data_context is not on StreamProtocol). batch.py has the identical two at lines 99 and 110, so this is an inherited protocol gap, not one this branch introduces.

On the §O5 caveat in the description: I agree it's a node-level problem rather than an operator-level one. An operator genuinely cannot see whether its upstream handed it a complete group, so there's nothing to fix inside GroupBy.

🤖 Generated with Claude Code

Comment thread src/orcapod/core/datagrams/tag_data.py
Comment thread tests/test_core/operators/test_group_by.py
Comment thread tests/test_core/operators/test_group_by.py
@eywalker

Copy link
Copy Markdown
Contributor

The PR looks amazing and implemented extremely well and aligned to the rest of the codebase --- thank you! I just had a few notes mostly asking to put TODO and/or create follow up issues

Brian Arnold and others added 3 commits August 27, 2026 14:29
Review asked for TODOs and deferred design questions to be marked rather than
resolved in this PR.

- tag_data.py: TODO on _source_info_arrow_type / _source_info_python_type.
  Both only understand scalars and list[T]; structured source-info values need
  their own branches, and the recursion should dispatch on a logical type rather
  than isinstance(value, list). Deriving the element type from value[0] is also
  a shortcut -- correct for the homogeneous lists many->one operators produce,
  not in general.

- test_group_by.py: document why member/group ordering is load-bearing (the
  emitted lists are hashed into the memoization key, so an unstable order causes
  spurious recomputes, and upstream emission order is not stable across runs),
  while marking the open question -- which order to use and whether to promise
  one publicly -- as deferred. Same for empty-group handling: "zero rows in,
  zero groups out" is the current behaviour but was never designed. Both tests
  now state that they pin the status quo so a change is deliberate.

- test_arrow_utils.py: pass an explicit PYTHONPATH to the cross-process digest
  subprocess. It does not inherit pytest.ini's `pythonpath = src`, so it was
  relying on orcapod being installed in the active environment. That holds here
  (editable install) and in CI, but the test should not depend on install mode.

No behaviour changes.

NPIPE-204

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two deferred questions from PR #250 review -- GroupBy member/group ordering
guarantees and empty-input behaviour -- now have a tracking issue, ITL-630
(Tools / Orcapod Python v0.2 Feature Sprint). It also absorbs DESIGN_ISSUES O4,
since duplicate-tag ordering is the same "what do we promise" question.

Retargets the notes from NPIPE-204 to ITL-630 and adds real `# TODO(ITL-630)`
comment lines alongside the class docstrings, so they are visible to anyone
grepping for TODO comments rather than reading docstrings.

No behaviour changes.

NPIPE-204
…204)

ITL-627 Defect 2 reported that list-wrapping a File column lost content
hashing: build_aggregated_table produced a list[orcapod.file] hashed by its
storage values (JSON path strings), so editing a file silently failed to
invalidate. Verified against 8968018, before this branch was rebased.

It does not reproduce on the current base -- #257 fixed it in
SemanticHashingVisitor.visit_extension, which previously short-circuited for
generic aliases like list[File]. That fix added thorough unit coverage in
test_extension_type_hashing.py::TestListExtensionHashing, but nothing exercises
the job level, which is where the failure was observed and where it fails
silently rather than raising.

Adds a parametrized per-row/grouped test: cold run executes, identical re-run
hits the cache, and a content edit at the same path re-executes. Mutation-checked
by disabling the list/set unwrapping branch in visitors.py -- the grouped case
fails while per_row still passes, so the test isolates the list-wrapping
regression rather than scalar File hashing.

NPIPE-204
@brian-arnold
brian-arnold requested a review from eywalker August 27, 2026 15:39
@eywalker
eywalker merged commit 73fd239 into main Aug 27, 2026
11 checks passed
@eywalker
eywalker deleted the arnoldb/npipe-204-orcapod-add-tag-based-grouping-to-batch-manyone-reduction branch August 27, 2026 16:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants