Skip to content

Commit fe4f8f6

Browse files
committed
feat: expose terminal failure locations
Add opt-in terminal failure capture for create and preview results, including zero-row generation errors. Surface early shutdown so callers can distinguish cancelled rows from attributable terminal failures. Closes #860 Signed-off-by: Andre Manoel <amanoel@nvidia.com>
1 parent 27acf14 commit fe4f8f6

14 files changed

Lines changed: 479 additions & 28 deletions

File tree

packages/data-designer-config/src/data_designer/config/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
LocalFileSeedSource,
108108
)
109109
from data_designer.config.seed_source_dataframe import DataFrameSeedSource # noqa: F401
110+
from data_designer.config.terminal_failure import TerminalTaskFailure # noqa: F401
110111
from data_designer.config.utils.code_lang import CodeLang # noqa: F401
111112
from data_designer.config.utils.info import InfoType # noqa: F401
112113
from data_designer.config.utils.media_helpers import AudioFormat, ImageFormat, VideoFormat # noqa: F401
@@ -194,6 +195,7 @@
194195
"ResumeMode": (f"{_MOD_BASE}.run_config", "ResumeMode"),
195196
"RunConfig": (f"{_MOD_BASE}.run_config", "RunConfig"),
196197
"ThrottleConfig": (f"{_MOD_BASE}.run_config", "ThrottleConfig"),
198+
"TerminalTaskFailure": (f"{_MOD_BASE}.terminal_failure", "TerminalTaskFailure"),
197199
# script_params
198200
"DataDesignerScriptParams": (f"{_MOD_BASE}.script_params", "DataDesignerScriptParams"),
199201
# scheduling metadata

packages/data-designer-config/src/data_designer/config/interface.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def create(
3333
config_builder: DataDesignerConfigBuilder,
3434
*,
3535
num_records: int = DEFAULT_NUM_RECORDS,
36+
capture_terminal_failures: bool = False,
3637
) -> ResultsT: ...
3738

3839
@abstractmethod
@@ -41,6 +42,7 @@ def preview(
4142
config_builder: DataDesignerConfigBuilder,
4243
*,
4344
num_records: int = DEFAULT_NUM_RECORDS,
45+
capture_terminal_failures: bool = False,
4446
) -> PreviewResults: ...
4547

4648
@abstractmethod

packages/data-designer-config/src/data_designer/config/preview_results.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from data_designer.config.config_builder import DataDesignerConfigBuilder
1010
from data_designer.config.dataset_metadata import DatasetMetadata
1111
from data_designer.config.seed_source_dataframe import DataFrameSeedSource
12+
from data_designer.config.terminal_failure import TerminalTaskFailure
1213
from data_designer.config.utils.visualization import WithRecordSamplerMixin
1314

1415
if TYPE_CHECKING:
@@ -25,6 +26,8 @@ def __init__(
2526
analysis: DatasetProfilerResults | None = None,
2627
processor_artifacts: dict[str, list[dict]] | None = None,
2728
task_traces: list[Any] | None = None,
29+
terminal_failures: list[TerminalTaskFailure] | None = None,
30+
early_shutdown: bool = False,
2831
):
2932
"""Creates a new instance with results from a Data Designer preview run.
3033
@@ -35,12 +38,18 @@ def __init__(
3538
analysis: Analysis of the preview run.
3639
processor_artifacts: Artifacts generated by the processors.
3740
task_traces: Async scheduler task traces (when DATA_DESIGNER_ASYNC_TRACE=1).
41+
terminal_failures: Terminal column failures captured for omitted seed rows.
42+
Check ``early_shutdown`` before treating this list as complete.
43+
early_shutdown: Whether generation stopped at the global error-rate threshold.
44+
Cancelled rows are not included in ``terminal_failures``.
3845
"""
3946
self.dataset: pd.DataFrame | None = dataset
4047
self.analysis: DatasetProfilerResults | None = analysis
4148
self.processor_artifacts: dict[str, list[dict]] | None = processor_artifacts
4249
self.dataset_metadata: DatasetMetadata | None = dataset_metadata
4350
self.task_traces: list[Any] | None = task_traces
51+
self.terminal_failures: list[TerminalTaskFailure] = list(terminal_failures or [])
52+
self.early_shutdown = early_shutdown
4453
self._config_builder = config_builder
4554

4655
def to_config_builder(self, columns: list[str] | None = None) -> DataDesignerConfigBuilder:
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
from dataclasses import dataclass
7+
8+
9+
@dataclass(frozen=True, order=True, slots=True)
10+
class TerminalTaskFailure:
11+
"""Terminal column failure for an omitted seed row.
12+
13+
``seed_row_index`` is the zero-based position in the requested generation
14+
sequence. It is not necessarily the raw source index for shuffled, selected,
15+
or cycled seed datasets.
16+
"""
17+
18+
seed_row_index: int
19+
column: str

packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import data_designer.lazy_heavy_imports as lazy
1919
from data_designer.config.column_configs import ExpressionColumnConfig, GenerationStrategy
20+
from data_designer.config.terminal_failure import TerminalTaskFailure
2021
from data_designer.engine.capacity import (
2122
AsyncCapacityConfigured,
2223
AsyncCapacityObservedMaxima,
@@ -214,6 +215,7 @@ def __init__(
214215
adaptive_row_group_initial_target: int = 1,
215216
request_pressure_provider: RequestPressureSnapshotProvider | None = None,
216217
request_pressure_advisory: bool = False,
218+
capture_terminal_failures: bool = False,
217219
) -> None:
218220
self._generators = generators
219221
self._graph = graph
@@ -339,6 +341,7 @@ def __init__(
339341
# context naturally because the from_scratch task raised; the async
340342
# engine drops rows and continues, losing the cause unless we capture it.
341343
self._first_non_retryable_error: Exception | None = None
344+
self._terminal_failures: list[TerminalTaskFailure] | None = [] if capture_terminal_failures else None
342345
self._fatal_worker_error: BaseException | None = None
343346
self._cancel_requested = Event()
344347
self._run_loop: asyncio.AbstractEventLoop | None = None
@@ -446,6 +449,11 @@ def first_non_retryable_error(self) -> Exception | None:
446449
"""
447450
return self._first_non_retryable_error
448451

452+
@property
453+
def terminal_failures(self) -> list[TerminalTaskFailure]:
454+
"""Terminal column failures captured for omitted seed rows."""
455+
return sorted(self._terminal_failures or [])
456+
449457
@property
450458
def retryable_outcome_metrics(self) -> dict[str, object]:
451459
"""Return sanitized rolling and cumulative model-task outcome counts."""
@@ -1542,6 +1550,7 @@ async def _salvage_stalled_row_groups(
15421550
already_dropped = task.row_index is not None and self._tracker.is_dropped(task.row_group, task.row_index)
15431551
if not already_dropped and self._reporter:
15441552
self._reporter.record_failure(task.column)
1553+
self._record_terminal_failure(task)
15451554
if task.row_index is not None:
15461555
self._drop_row(task.row_group, task.row_index, exclude_columns={task.column})
15471556
else:
@@ -1767,6 +1776,22 @@ def _drop_row(self, row_group: int, row_index: int, *, exclude_columns: set[str]
17671776
if self._buffer_manager:
17681777
self._buffer_manager.drop_row(row_group, row_index)
17691778

1779+
def _record_terminal_failure(self, task: Task) -> None:
1780+
if self._terminal_failures is None:
1781+
return
1782+
1783+
start_offset = self._get_rg_start_offset(task.row_group)
1784+
if start_offset is None:
1785+
return
1786+
row_indices = (task.row_index,) if task.row_index is not None else range(self._get_rg_size(task.row_group))
1787+
for row_index in row_indices:
1788+
# Preserve the failure that actually caused the row to be omitted.
1789+
if self._tracker.is_dropped(task.row_group, row_index):
1790+
continue
1791+
self._terminal_failures.append(
1792+
TerminalTaskFailure(seed_row_index=start_offset + row_index, column=task.column)
1793+
)
1794+
17701795
def _drop_row_group(self, row_group: int, row_group_size: int, *, exclude_columns: set[str] | None = None) -> None:
17711796
for row_index in range(row_group_size):
17721797
self._drop_row(row_group, row_index, exclude_columns=exclude_columns)
@@ -2091,6 +2116,7 @@ async def _execute_task_inner_impl(self, task: Task, lease: TaskAdmissionLease,
20912116
logger.error("Unexpected %s", log_message, exc_info=True)
20922117
# Non-retryable data/user/provider failures drop the affected row(s);
20932118
# internal bug-shaped failures above abort the run instead.
2119+
self._record_terminal_failure(task)
20942120
if task.row_index is not None:
20952121
self._drop_row(task.row_group, task.row_index, exclude_columns={task.column})
20962122
else:

packages/data-designer-engine/src/data_designer/engine/dataset_builders/dataset_builder.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
ProcessorConfig,
2828
ProcessorType,
2929
)
30+
from data_designer.config.terminal_failure import TerminalTaskFailure
3031
from data_designer.config.utils.type_helpers import StrEnum
3132
from data_designer.config.version import get_library_version
3233
from data_designer.engine.column_generators.generators.base import (
@@ -193,6 +194,7 @@ def __init__(
193194
# async run, if any. Used by the interface to surface the original cause
194195
# when a run produces 0 records due to deterministic failures.
195196
self._first_non_retryable_error: Exception | None = None
197+
self._terminal_failures: list[TerminalTaskFailure] = []
196198

197199
self._data_designer_config = compile_data_designer_config(data_designer_config, resource_provider)
198200
self._column_configs = compile_dataset_builder_column_configs(self._data_designer_config)
@@ -239,6 +241,11 @@ def first_non_retryable_error(self) -> Exception | None:
239241
"""First non-retryable error captured by the scheduler in the most recent run."""
240242
return self._first_non_retryable_error
241243

244+
@property
245+
def terminal_failures(self) -> list[TerminalTaskFailure]:
246+
"""Terminal column failures captured during the most recent run."""
247+
return list(self._terminal_failures)
248+
242249
@functools.cached_property
243250
def single_column_configs(self) -> list[ColumnConfigT]:
244251
configs = []
@@ -256,6 +263,7 @@ def build(
256263
on_batch_complete: Callable[[Path], None] | None = None,
257264
save_multimedia_to_disk: bool = True,
258265
resume: ResumeMode = ResumeMode.NEVER,
266+
capture_terminal_failures: bool = False,
259267
) -> Path:
260268
"""Build the dataset.
261269
@@ -279,6 +287,7 @@ def build(
279287
280288
In all resume modes, in-flight partial results from the interrupted run are
281289
discarded before generation continues.
290+
capture_terminal_failures: Capture the terminal column for omitted seed rows.
282291
283292
Returns:
284293
Path to the generated dataset directory.
@@ -351,7 +360,14 @@ def build(
351360
resume = ResumeMode.NEVER
352361
self.artifact_storage.resume = ResumeMode.NEVER
353362

354-
self._build_async(generators, num_records, buffer_size, on_batch_complete, resume=resume)
363+
self._build_async(
364+
generators,
365+
num_records,
366+
buffer_size,
367+
on_batch_complete,
368+
resume=resume,
369+
capture_terminal_failures=capture_terminal_failures,
370+
)
355371

356372
# After-generation processors run unconditionally on the on-disk dataset
357373
# (not gated on ``generated``). When resume sees every row group already
@@ -537,7 +553,7 @@ def _load_resume_state(self, num_records: int, buffer_size: int) -> _ResumeState
537553
completed_row_groups=completed_row_groups,
538554
)
539555

540-
def build_preview(self, *, num_records: int) -> pd.DataFrame:
556+
def build_preview(self, *, num_records: int, capture_terminal_failures: bool = False) -> pd.DataFrame:
541557
self._reset_run_state()
542558
run_readiness_check(
543559
self.single_column_configs,
@@ -551,7 +567,11 @@ def build_preview(self, *, num_records: int) -> pd.DataFrame:
551567
generators, self._graph = self._initialize_generators_and_graph()
552568
start_time = time.perf_counter()
553569

554-
dataset = self._build_async_preview(generators, num_records)
570+
dataset = self._build_async_preview(
571+
generators,
572+
num_records,
573+
capture_terminal_failures=capture_terminal_failures,
574+
)
555575

556576
self._resource_provider.model_registry.log_model_usage(time.perf_counter() - start_time)
557577

@@ -564,8 +584,15 @@ def _reset_run_state(self) -> None:
564584
self._actual_num_records = -1
565585
self._first_non_retryable_error = None
566586
self._task_traces = []
587+
self._terminal_failures = []
567588

568-
def _build_async_preview(self, generators: list[ColumnGenerator], num_records: int) -> pd.DataFrame:
589+
def _build_async_preview(
590+
self,
591+
generators: list[ColumnGenerator],
592+
num_records: int,
593+
*,
594+
capture_terminal_failures: bool = False,
595+
) -> pd.DataFrame:
569596
"""Async preview path - single row group, no disk writes, returns in-memory DataFrame."""
570597
logger.info("⚡ Using async task-queue preview")
571598

@@ -578,6 +605,7 @@ def _build_async_preview(self, generators: list[ColumnGenerator], num_records: i
578605
buffer_size=num_records,
579606
run_post_batch_in_scheduler=False,
580607
trace=trace_enabled,
608+
capture_terminal_failures=capture_terminal_failures,
581609
)
582610

583611
loop = ensure_async_engine_loop()
@@ -590,6 +618,7 @@ def _build_async_preview(self, generators: list[ColumnGenerator], num_records: i
590618
self._partial_row_groups = scheduler.partial_row_groups
591619
self._actual_num_records = buffer_manager.actual_num_records
592620
self._first_non_retryable_error = scheduler.first_non_retryable_error
621+
self._terminal_failures = scheduler.terminal_failures
593622

594623
if not buffer_manager.has_row_group(0):
595624
return lazy.pd.DataFrame()
@@ -746,6 +775,7 @@ def _build_async(
746775
on_batch_complete: Callable[[Path], None] | None = None,
747776
*,
748777
resume: ResumeMode = ResumeMode.NEVER,
778+
capture_terminal_failures: bool = False,
749779
) -> bool:
750780
"""Async task-queue builder path - dispatches tasks based on dependency readiness.
751781
@@ -851,6 +881,7 @@ def on_complete(final_path: Path | str | None) -> None:
851881
initial_actual_num_records=initial_actual_num_records,
852882
initial_total_num_batches=initial_total_num_batches,
853883
scheduler_event_sink=scheduler_event_sink,
884+
capture_terminal_failures=capture_terminal_failures,
854885
)
855886

856887
# Run on background event loop. Capture scheduler state in `finally`
@@ -867,6 +898,7 @@ def on_complete(final_path: Path | str | None) -> None:
867898
self._partial_row_groups = scheduler.partial_row_groups
868899
self._actual_num_records = buffer_manager.actual_num_records
869900
self._first_non_retryable_error = scheduler.first_non_retryable_error
901+
self._terminal_failures = scheduler.terminal_failures
870902

871903
# Emit telemetry
872904
try:
@@ -917,6 +949,7 @@ def _prepare_async_run(
917949
initial_actual_num_records: int = 0,
918950
initial_total_num_batches: int = 0,
919951
scheduler_event_sink: SchedulerAdmissionEventSink | None = None,
952+
capture_terminal_failures: bool = False,
920953
) -> tuple[AsyncTaskScheduler, RowGroupBufferManager]:
921954
"""Build a fully-wired scheduler and buffer manager for async generation.
922955
@@ -1008,6 +1041,7 @@ def on_before_checkpoint(rg_id: int, rg_size: int) -> None:
10081041
),
10091042
request_pressure_provider=self._resource_provider.model_registry.request_admission,
10101043
request_pressure_advisory=True,
1044+
capture_terminal_failures=capture_terminal_failures,
10111045
)
10121046
return scheduler, buffer_manager
10131047

0 commit comments

Comments
 (0)