Skip to content

fix: composite key negative values, select generator exhaustion, cross join validation - #28

Merged
dsfulf merged 7 commits into
mainfrom
bugfix/join-correctness-2
Mar 28, 2026
Merged

dsfulf merged 7 commits into
mainfrom
bugfix/join-correctness-2

Conversation

@dsfulf

@dsfulf dsfulf commented Mar 28, 2026

Copy link
Copy Markdown
Member

Summary

  • Composite key negative values: integer join/groupby keys with negative values produced incorrect cardinality (max+1 instead of max-min+1), causing key collisions in multi-column positional encoding. Shift to non-negative in _encode_columns (GroupBy) and _encode_columns_paired (joins) using combined min as shared baseline.
  • Select generator exhaustion: self.select typed as Iterable[str] — passing a generator caused silent column dropping on second use. Added Join.__post_init__ to materialize on and select to lists.
  • CrossJoin validation: added select column existence check, fixed docstring (how='outer' → how='cross').
  • Encoding kind-match: _encode_columns_paired now validates left/right column kinds are compatible before concatenation.
  • Minor perf: astype(int64, copy=False) avoids unnecessary allocation when array is already int64.

Test plan

  • pytest — 182 tests pass
  • ruff format + ruff check — clean
  • mypy --strict tafra — no new errors
  • Verified: negative int key joins produce correct matches
  • Verified: negative int key groupby produces correct groups
  • 3 rounds of pre-push-audit — clean on third pass

🤖 Generated with Claude Code

dsfulf and others added 2 commits March 28, 2026 12:31
…s join validation

- _build_composite_key: shift columns to non-negative range before computing
  cardinality — fixes key collisions when integer join keys are negative
- _resolve_join_cols: materialize self.select to frozenset — prevents
  generator exhaustion silently dropping columns
- _encode_columns_paired: add kind-match check before concatenating left/right
  columns — produces clear TypeError instead of silent object upcast
- CrossJoin: validate select columns exist, fix docstring (outer → cross)
- Move _STRING_KINDS to module-level constant (was per-call frozenset)
- Use astype(int64, copy=False) to avoid unnecessary allocation

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

The shift-by-min in _build_composite_key was applied independently to
left and right sides, producing non-comparable keys when mins differed.
Moved the shift into _encode_columns_paired where both sides are visible,
using a combined min as the shared baseline. Only applies to signed
integer columns (kind 'i'), not datetime or other types.

Also added the same shift to _encode_columns (GroupBy path) so negative
integer group-by columns work correctly with _direct_labels_firstseen.

Added Join.__post_init__ to materialize self.on and self.select
from any Iterable into lists, preventing generator exhaustion on reuse.

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

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

This PR addresses correctness and robustness issues in Tafra’s grouping and join implementations, focusing on composite-key encoding, join input handling, and CrossJoin validation.

Changes:

  • Fix composite-key positional encoding for signed integer keys containing negative values by shifting to non-negative baselines (GroupBy and equi-joins).
  • Prevent generator exhaustion in join configuration by materializing on and select in Join.__post_init__.
  • Add CrossJoin select existence validation and tighten dtype-kind compatibility checks during paired encoding; minor perf tweaks to avoid unnecessary copies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tafra/group.py
Comment on lines +1691 to 1692
Analogy to SQL CROSS JOIN, or `pandas.merge(left, right, how='cross')`.

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

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

This docstring is now corrected, but the public helper Tafra.cross_join() docstring in tafra/base.py still describes the old how='outer'/temporary-column approach. To avoid conflicting documentation, update the helper docstring to match the new how='cross' wording.

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py
Comment on lines +968 to +971
def __post_init__(self) -> None:
# Materialize iterables to prevent generator exhaustion on reuse.
self.on = list(self.on)
self.select = list(self.select)

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

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

New behavior: Join.__post_init__ materializes on/select to prevent generator exhaustion. There should be a unit test that passes a generator for select (and/or on) and verifies joins still include all requested columns (and don't drop columns on the second internal iteration).

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py Outdated
Comment on lines +1720 to +1726
# Validate that select columns actually exist in at least one table
select_set = frozenset(self.select)
if select_set:
valid_cols = set(left_t._data.keys()) | set(right_t._data.keys())
invalid = select_set - valid_cols
if invalid:
raise KeyError(f"Column(s) {invalid} in `select` not found in either table.")

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

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

Since CrossJoin now validates select column existence and raises, add a focused test that asserts the exception type/message for an invalid column in select. This will lock in the intended behavior and prevent regressions (especially given other join types currently ignore unknown select entries).

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py Outdated
Comment on lines +544 to +545
# Columns should be non-negative at this point — _encode_columns_paired
# shifts negative integer columns using a combined min across both sides.

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

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

The comment here says columns should be non-negative because _encode_columns_paired shifts negative ints, but _build_composite_key is also used for GroupBy/Transform keys built via _encode_columns. Consider updating the comment to reflect both call sites (or to state the general precondition: inputs must already be non-negative integer codes).

Suggested change
# Columns should be non-negative at this point — _encode_columns_paired
# shifts negative integer columns using a combined min across both sides.
# Precondition: columns are already non-negative integer codes.
# Callers such as _encode_columns (GroupBy/Transform) and
# _encode_columns_paired (joins) ensure this by encoding/shifting values
# as needed.

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py Outdated
Comment on lines +1720 to +1727
# Validate that select columns actually exist in at least one table
select_set = frozenset(self.select)
if select_set:
valid_cols = set(left_t._data.keys()) | set(right_t._data.keys())
invalid = select_set - valid_cols
if invalid:
raise KeyError(f"Column(s) {invalid} in `select` not found in either table.")

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

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

CrossJoin now raises on unknown select columns, but InnerJoin/LeftJoin currently ignore unknown select entries (see test_inner_join_select_nonexistent in the test suite). This is an API behavior inconsistency across join types; consider either (a) keeping the previous behavior (silently ignore) or (b) validating select consistently in the shared Join logic for all join implementations and updating docs/tests accordingly.

Suggested change
# Validate that select columns actually exist in at least one table
select_set = frozenset(self.select)
if select_set:
valid_cols = set(left_t._data.keys()) | set(right_t._data.keys())
invalid = select_set - valid_cols
if invalid:
raise KeyError(f"Column(s) {invalid} in `select` not found in either table.")

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py Outdated
valid_cols = set(left_t._data.keys()) | set(right_t._data.keys())
invalid = select_set - valid_cols
if invalid:
raise KeyError(f"Column(s) {invalid} in `select` not found in either table.")

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

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

The error message interpolates a Python set (invalid), which can produce nondeterministic ordering in the exception string. Sorting the missing column names (and formatting as a stable list/tuple) will make the message predictable and easier to test/debug.

Suggested change
raise KeyError(f"Column(s) {invalid} in `select` not found in either table.")
raise KeyError(
f"Column(s) {tuple(sorted(invalid))} in `select` not found in either table."
)

Copilot uses AI. Check for mistakes.
dsfulf and others added 2 commits March 28, 2026 15:04
- Move select validation to _resolve_join_cols (consistent across all
  join types — InnerJoin, LeftJoin, CrossJoin all raise KeyError)
- Fix base.py cross_join docstring (how='outer' → how='cross')
- Fix _build_composite_key comment to list both callers
- Sort set in KeyError message for deterministic output
- Add tests: generator select/on, invalid select for all join types

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

Targets uncovered code paths:
- LeftJoin empty-right shortcut: datetime, timedelta, string, int null-fill
- LeftJoin/InnerJoin non-equi paths with NaN keys
- _build_composite_key overflow ValueError
Coverage: 87% → 94% on group.py

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

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

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tafra/group.py
# COMBINED min so both sides share the same baseline.
if l_kind == "i" and len(lc) > 0 and len(rc) > 0:
combined_min = min(int(lc.min()), int(rc.min()))
if combined_min < 0:

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

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

The combined-min shift for signed integers can also overflow/wrap when combined_min is very negative (notably np.iinfo(np.int64).min), producing incorrect non-negative codes and potential composite-key collisions. Add the same int64-range/overflow guard here (raise ValueError when the shift cannot be represented safely).

Suggested change
if combined_min < 0:
if combined_min < 0:
# Guard against int64 overflow in the shifted codes.
# We must ensure that (max_value - combined_min) fits in int64,
# and that combined_min is not the minimal int64 value.
info = np.iinfo(np.int64)
combined_max = max(int(lc.max()), int(rc.max()))
if combined_min == info.min:
raise ValueError(
"Integer encoding overflow: combined minimum equals "
"int64 minimum; cannot safely shift to non-negative "
"codes for join key encoding"
)
shift_range = combined_max - combined_min
if shift_range > info.max:
raise ValueError(
"Integer encoding overflow: combined value range too "
"large for int64 shift in join key encoding"
)

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py Outdated
Comment on lines +1106 to +1107
# Materialize select to a set — protects against generator exhaustion
# and provides O(1) membership testing.

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

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

The comment about "protects against generator exhaustion" is now misleading because Join.__post_init__ already materializes select to a list. Consider rewording to focus on O(1) membership testing (or drop the exhaustion mention) to keep the comment accurate.

Suggested change
# Materialize select to a set — protects against generator exhaustion
# and provides O(1) membership testing.
# Use a set for select columns to provide O(1) membership testing.
# `self.select` is already materialized earlier; this just re-encodes it.

Copilot uses AI. Check for mistakes.
Comment thread tafra/base.py Outdated
def __len__(self) -> int:
assert self._data is not None, \
'Interal error: Cannot construct a Tafra with no data.'
assert self._data is not None, "Interal error: Cannot construct a Tafra with no data."

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

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

Typo in the assertion message: "Interal" should be "Internal".

Suggested change
assert self._data is not None, "Interal error: Cannot construct a Tafra with no data."
assert self._data is not None, "Internal error: Cannot construct a Tafra with no data."

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py Outdated
Comment on lines +480 to +482
c_min = int(c.min())
if c_min < 0:
encoded.append(c.astype(np.int64, copy=False) - c_min)

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

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

Shifting negative int keys via c.astype(np.int64, copy=False) - c_min can silently overflow/wrap for extreme values (e.g., c_min == np.iinfo(np.int64).min), which would reintroduce negative codes and break composite-key uniqueness. Add an explicit overflow check before shifting (and raise ValueError) to guarantee the result stays within int64 and non-negative.

Suggested change
c_min = int(c.min())
if c_min < 0:
encoded.append(c.astype(np.int64, copy=False) - c_min)
c64 = c.astype(np.int64, copy=False)
c_min = int(c64.min())
if c_min < 0:
c_max = int(c64.max())
# Ensure that shifting by -c_min does not overflow int64.
span = c_max - c_min
if span > np.iinfo(np.int64).max:
raise ValueError(
"Shifting integer column to non-negative codes "
"would overflow int64 range"
)
encoded.append(c64 - c_min)

Copilot uses AI. Check for mistakes.
- Add overflow check in _encode_columns and _encode_columns_paired:
  raise ValueError if max - min exceeds int64 range after shift
- Fix stale comment in _resolve_join_cols (generator exhaustion
  already handled by __post_init__, comment now says O(1) testing)

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

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

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tafra/base.py Outdated
) -> "Tafra":
"""
Apply a function to the `Tafra` and return the resulting `Tafra`. Primarily
used to build a tranformer pipeline.

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

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

Spelling typo in the docstring: "tranformer" should be "transformer".

Suggested change
used to build a tranformer pipeline.
used to build a transformer pipeline.

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py
Comment on lines +478 to +482
# Shift signed integer columns to non-negative for positional encoding.
if c.dtype.kind == "i" and len(c) > 0:
c64 = c.astype(np.int64, copy=False)
c_min = int(c64.min())
if c_min < 0:

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

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

This adds logic to shift negative integer groupby keys to avoid composite-key collisions, but there doesn’t appear to be a regression test covering negative integer keys (especially multi-column group_by). Please add a focused test to lock in the expected grouping behavior for negative values.

Copilot uses AI. Check for mistakes.
Comment thread tafra/group.py
Comment on lines +524 to +531
# For signed integer columns, shift to non-negative using
# COMBINED min so both sides share the same baseline.
if l_kind == "i" and len(lc) > 0 and len(rc) > 0:
combined_min = min(int(lc.min()), int(rc.min()))
if combined_min < 0:
combined_max = max(int(lc.max()), int(rc.max()))
if combined_max - combined_min > np.iinfo(np.int64).max:
raise ValueError(

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

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

This adds logic to shift negative integer join keys to avoid composite-key collisions, but there doesn’t appear to be a regression test covering multi-column joins with negative integer keys. Please add a test that fails on the old implementation (collision) and passes with the shift logic.

Copilot uses AI. Check for mistakes.
Comment thread tafra/base.py Outdated

assert value.ndim >= 1, \
'Interal error: `Tafra` only supports assigning ndim == 1.'
assert value.ndim >= 1, "Interal error: `Tafra` only supports assigning ndim == 1."

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

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

Spelling typo in the assertion message: "Interal" should be "Internal".

Suggested change
assert value.ndim >= 1, "Interal error: `Tafra` only supports assigning ndim == 1."
assert value.ndim >= 1, "Internal error: `Tafra` only supports assigning ndim == 1."

Copilot uses AI. Check for mistakes.
dsfulf and others added 2 commits March 28, 2026 17:12
Applies ruff formatting to files that were reformatted during the
session but never staged: __init__.py, formatter.py, protocol.py,
bench_tafra.py, bench_vs_pandas_vs_polars.py.

Also includes regression tests for negative int groupby and join keys
requested by Copilot review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- "Interal" → "Internal" in base.py:383 and base.py:806
- "tranformer" → "transformer" in base.py:1389

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

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

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@dsfulf
dsfulf merged commit 320979d into main Mar 28, 2026
11 checks passed
@dsfulf
dsfulf deleted the bugfix/join-correctness-2 branch March 28, 2026 22:40
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.

2 participants