fix: drop invalid expression rows#757
Conversation
Handle per-row expression render and cast failures by dropping only affected rows, preserving row identity in sync and async full-column paths, and failing when no valid rows remain. Fixes #749
PR #757 Review —
|
Greptile SummaryThis PR changes expression column generation from a fail-whole-column strategy to a drop-invalid-rows strategy: each row that produces an empty render, a row-specific Jinja error, or a cast failure is silently dropped with structured warning counts, while expressions that drop every row still abort loudly via
|
| Filename | Overview |
|---|---|
| packages/data-designer-engine/src/data_designer/engine/column_generators/generators/expression.py | Adds per-row drop logic with Counter-based breakdown, preserves row indexes in the result DataFrame, and raises UserTemplateError only when all rows are dropped. |
| packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py | Adds _run_full_column_from_scratch_with_row_drops for root expression columns, replaces sequential result_idx with index-keyed lookup in _batch_result_by_row_index, and promotes UserTemplateError to a fatal scheduler error via _is_fatal_expression_template_error. |
| packages/data-designer-engine/src/data_designer/engine/dataset_builders/dataset_builder.py | Adds _merge_row_dropped_generated_records for skip-aware merging of dropped-row results, passes allow_resize=True to both replace_buffer call sites when the generator supports row drops. |
| packages/data-designer-engine/tests/engine/column_generators/generators/test_expression.py | Adds unit tests for empty-render drops, template-error drops, type-cast drops, and all-rows-dropped fatal case; index preservation is verified explicitly. |
| packages/data-designer-engine/tests/engine/dataset_builders/test_async_builder_integration.py | Adds four async integration tests covering partial row-group shrink, all-dropped fatal abort, root-expression all-dropped fatal, and skip+row-drop interaction. |
| packages/data-designer-engine/tests/engine/dataset_builders/test_dataset_builder.py | Adds sync builder tests for full-column row-drop shrink and skip-aware row-drop shrink. |
Reviews (3): Last reviewed commit: "Merge branch 'main' into codex/issue-749..." | Re-trigger Greptile
| result_records = result_df.to_dict(orient="records") | ||
| if supports_row_drops: | ||
| return dict(zip(result_df.index.to_list(), result_records, strict=True)) | ||
| return dict(zip(active_row_indices, result_records, strict=True)) | ||
|
|
||
| def _get_rg_size(self, row_group: int) -> int: | ||
| try: | ||
| return self._row_groups.row_group_size(row_group) |
There was a problem hiding this comment.
Third guard in
_require_expression_row_drop_result is unreachable
Given that the two preceding checks already pass — (1) no duplicate result indexes, and (2) every result index is a member of active_index_set — the result set is a subset of active_row_indices with no duplicates. Because active_row_indices is itself duplicate-free (built from range(rg_size) minus pre-dropped rows), len(active_index_set) == len(active_row_indices), so len(result_df) <= len(active_row_indices) is guaranteed and the third if can never fire. This is dead code, not a runtime bug, but it may create a false sense of coverage for this guard.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py
Line: 2063-2070
Comment:
**Third guard in `_require_expression_row_drop_result` is unreachable**
Given that the two preceding checks already pass — (1) no duplicate result indexes, and (2) every result index is a member of `active_index_set` — the result set is a subset of `active_row_indices` with no duplicates. Because `active_row_indices` is itself duplicate-free (built from `range(rg_size)` minus pre-dropped rows), `len(active_index_set) == len(active_row_indices)`, so `len(result_df) <= len(active_row_indices)` is guaranteed and the third `if` can never fire. This is dead code, not a runtime bug, but it may create a false sense of coverage for this guard.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| ) | ||
| return result | ||
|
|
||
| def _task_supports_row_drops(self, task: Task, generator: ColumnGenerator) -> bool: |
There was a problem hiding this comment.
I think root expression columns fall through this check. Root full-column columns are scheduled as from_scratch, so an expression with no upstream refs, like ExpressionColumnConfig(name="empty_root", expr="{{ \"\" }}"), does not hit the new fatal expression path and the scheduler drops the whole row group instead of raising. It also reaches the zero-column DataFrame case in expression.py before rendering. Maybe route root expressions through the batch path, or treat expression from_scratch tasks as row-drop-capable too?
There was a problem hiding this comment.
Good catch — fixed in cdf9ffd by taking option 2 (treat expression from_scratch tasks as row-drop-capable).
_task_supports_row_dropsnow accepts bothbatchandfrom_scratchfor expression configs, so the fatal-error path fires for root expressions too.- New helper
_run_full_column_from_scratch_with_row_dropsmirrors_run_batch's expression handling (active-row-index tracking,_require_expression_row_drop_resultvalidation, per-row_drop_row+update_cell) but skips thepre_skippedevaluation since root expressions have no upstream refs. - Fixed the zero-column case you flagged too:
DataFrame.to_dict(orient="records")returns[]for any 0-column DataFrame regardless of row count, so a root expression dispatched against a fresh row group buffer would otherwise skip rendering entirely and fail thestrict=Truezip.ExpressionColumnGenerator.generatenow synthesizes empty per-row dicts in that case so the loop still fires once per row.
Covered by the new test_root_expression_all_dropped_async_fails_loudly regression test.
| assert tracker.is_row_group_complete(1, 2, all_cols) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio(loop_scope="session") |
There was a problem hiding this comment.
suggestion: could add the async equivalent of the sync skip+drop regression here. The temp smoke I ran passes, but a committed test would pin the active_row_indices mapping when a skipped row appears before a dropped expression row.
There was a problem hiding this comment.
Added in cdf9ffd as test_expression_row_drops_with_skip_async_row_group. It mirrors the sync test_expression_column_row_drops_shrink_sync_skip_aware_batch setup: a seed producing ["skip-me", "", "keep"] feeds an expression with SkipConfig(when="{{ seed == 'skip-me' }}", value="SKIPPED") and expr="{{ seed }}". The test asserts:
- row 0 (skipped) keeps
seed="skip-me"withcopy="SKIPPED" - row 1 (empty render) is dropped via
tracker.is_dropped(0, 1) - row 2 is preserved with
copy="keep" - the warning log shows
dropped 1/2(the skipped row never reachesgenerate)
This pins the active_row_indices mapping when a skipped row precedes a dropped expression row.
Address PR #757 review feedback from @andreatgretel: - Root expression columns are scheduled as ``from_scratch`` rather than ``batch``, so the prior ``_task_supports_row_drops`` check (which gated on ``task_type == "batch"``) silently dropped the whole row group on a user template error instead of raising. Accept both task types and route expression ``from_scratch`` tasks through a shared row-drop helper that preserves indices and honors the fatal-when-all-dropped contract. - ``DataFrame.to_dict(orient='records')`` returns ``[]`` for any 0-column DataFrame regardless of row count, so a root expression dispatched against a fresh row group buffer would otherwise skip rendering entirely. Synthesize empty per-row dicts in ``ExpressionColumnGenerator`` so the loop fires once per row. - Add async coverage for the root-expression fatal path and for the skip-then-drop interaction (async equivalent of the existing sync skip-aware regression).
|
Pushed cdf9ffd addressing @andreatgretel's review feedback. Changes
Tests
|
📋 Summary
Expression columns currently fail the whole column when a single row renders to empty text or hits a row-specific Jinja/cast error. This PR makes those failures behave like other row-level generation failures: invalid rows are dropped with structured warning counts, while fully broken expressions still fail loudly.
🔗 Related Issue
Fixes #749
🔄 Changes
ExpressionColumnGeneratorso shrunken expression batches can be merged back safely.🔍 Attention Areas
async_scheduler.py— this is the core async merge/error-boundary change, including the distinction between partial row drops and all-dropped expression failures.dataset_builder.py— sync full-column and skip-aware paths now allow expression generators to shrink batches while preserving skip metadata.🧪 Testing
make testpasses (not run; focused engine checks were run instead)uv run --group dev pytest packages/data-designer-engine/tests/engine/column_generators/generators/test_expression.py packages/data-designer-engine/tests/engine/dataset_builders/test_dataset_builder.py::test_expression_column_row_drops_shrink_sync_batch packages/data-designer-engine/tests/engine/dataset_builders/test_dataset_builder.py::test_expression_column_row_drops_shrink_sync_skip_aware_batch packages/data-designer-engine/tests/engine/dataset_builders/test_async_builder_integration.py::test_expression_row_drops_shrink_async_row_group packages/data-designer-engine/tests/engine/dataset_builders/test_async_builder_integration.py::test_expression_all_dropped_async_row_group_fails_loudly -quv run --group dev pytest packages/data-designer-engine/tests/engine/dataset_builders/test_async_builder_integration.py -q.venv/bin/ruff check <changed files>.venv/bin/ruff format --check <changed files>✅ Checklist
Signed-off-bytrailers