Skip to content

Commit f1c79cc

Browse files
authored
Merge branch 'main' into codex/docs-nemotron-ocr-pipeline
2 parents 2497d6d + 73e37a1 commit f1c79cc

35 files changed

Lines changed: 4000 additions & 78 deletions

.github/workflows/claude-review.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ permissions:
1313
jobs:
1414
claude-review:
1515
if: github.event.issue.pull_request != null && contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
16-
uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_claude_review.yml@v0.88.0
16+
uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_claude_review.yml@v1.7.0
1717
with:
18+
model: ${{ vars.CLAUDE_MODEL }}
1819
prompt: |
1920
You are doing a light code review. Keep it concise and actionable.
2021
@@ -36,4 +37,5 @@ jobs:
3637
It's perfectly acceptable to not have anything to comment on.
3738
If you do not have anything to comment on, post "LGTM".
3839
secrets:
39-
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
40+
NVIDIA_INFERENCE_URL: ${{ secrets.NVIDIA_INFERENCE_URL }}
41+
NVIDIA_INFERENCE_KEY: ${{ secrets.NVIDIA_INFERENCE_KEY }}

nemo_curator/backends/base.py

Lines changed: 159 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
22
#
33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.
@@ -17,14 +17,32 @@
1717
from dataclasses import dataclass
1818
from typing import TYPE_CHECKING, Any
1919

20+
from loguru import logger
21+
22+
from nemo_curator.backends.failed_task_markers import record_failed_tasks
23+
from nemo_curator.backends.slurm_array import (
24+
filter_slurm_array_source_tasks,
25+
resolve_slurm_array_config,
26+
)
2027
from nemo_curator.core.utils import ignore_ray_head_node
2128
from nemo_curator.tasks import Task
29+
from nemo_curator.tasks.sentinels import FailedTask, NoneTask
2230
from nemo_curator.utils.performance_utils import StageTimer
31+
from nemo_curator.utils.resumability_client import (
32+
completed_resumability_sources,
33+
flush_resumability_deltas,
34+
is_resumability_actor_active,
35+
)
2336

2437
if TYPE_CHECKING:
2538
from nemo_curator.stages.base import ProcessingStage
2639

2740

41+
def _is_sentinel(task: Task) -> bool:
42+
"""A payload-less marker (NoneTask/FailedTask), stripped before the next stage."""
43+
return isinstance(task, (NoneTask, FailedTask))
44+
45+
2846
@dataclass
2947
class NodeInfo:
3048
"""Generic node information for setup_on_node calls across backends.
@@ -85,9 +103,47 @@ def process_batch(self, tasks: list[Task]) -> list[Task]:
85103
# Use the batch processing logic
86104
results = self.stage.process_batch(tasks)
87105

106+
# A returned ``None`` ("filter this slot") becomes a NoneTask so every
107+
# output is a real Task that gets a task_id. Sentinels (NoneTask /
108+
# FailedTask) carry no identity and are stripped again before this
109+
# method returns.
110+
results = [NoneTask() if r is None else r for r in results]
111+
88112
# Guarantee every emitted task has a task_id (derived id, or uuid fallback).
89113
results = self._post_process_task_ids(tasks, results)
90114

115+
# Failed tasks on the source stage are not supported.
116+
is_source_stage = getattr(self.stage, "is_source_stage", False)
117+
failed_tasks = [r for r in results if isinstance(r, FailedTask)]
118+
if failed_tasks and is_source_stage:
119+
msg = (
120+
f"Source stage {self.stage.name} emitted FailedTask, which is not supported."
121+
)
122+
raise ValueError(msg)
123+
124+
# Record failed tasks for later inspection or retry bookkeeping.
125+
if failed_tasks:
126+
record_failed_tasks()
127+
128+
# Source-stage sentinels (NoneTask only; FailedTask already raised above) are
129+
# not real partitions and must not influence shard assignment or resumability
130+
# counters. Non-source stages keep sentinels here so _apply_resumability_counters
131+
# can fire the correct -1 delta for filtered (NoneTask) slots in the 1:1 path.
132+
if is_source_stage:
133+
results = [r for r in results if not _is_sentinel(r)]
134+
135+
# Filter tasks based on the Slurm array configuration.
136+
slurm_array = resolve_slurm_array_config(is_source_stage=is_source_stage)
137+
if slurm_array is not None and is_source_stage:
138+
results = filter_slurm_array_source_tasks(results, slurm_array, self.stage.name)
139+
140+
# Opt-in resumability: fire per-source deltas (no-op when no actor registered).
141+
if is_resumability_actor_active():
142+
results = self._apply_resumability_counters(tasks, results)
143+
144+
# Sentinels never propagate to the next stage.
145+
results = [r for r in results if not _is_sentinel(r)]
146+
91147
# Log performance stats and add to result tasks
92148
_, stage_perf_stats = self._timer.log_stats()
93149
# Consume and attach any custom metrics recorded by the stage during this call
@@ -99,62 +155,41 @@ def process_batch(self, tasks: list[Task]) -> list[Task]:
99155

100156
return results
101157

102-
def _post_process_task_ids(self, input_tasks: list[Task], output_tasks: list[Task | None]) -> list[Task]:
103-
"""Assign a deterministic ``task_id`` to every emitted task.
104-
105-
This is the single place task ids are assigned — it runs for every
106-
stage on every backend (all backend adapters subclass this), so it
107-
makes no difference whether a stage defines ``process`` or overrides
108-
``process_batch``. ``task_id`` is the task's id path (parents + own segment); ids are
109-
re-derived at each stage boundary so the same object passing through
110-
N stages gets N ids.
111-
112-
The input→output mapping decides each output's PARENT; whether the
113-
stage is a source decides each output's SEGMENT (content id vs index)
114-
— the two are independent. ``None`` outputs (Curator's "return None to
115-
filter") are NOT removed before the length check — keeping them in
116-
place preserves positional alignment for filter stages — and are then
117-
dropped from the returned list.
118-
119-
- single input → every output is its child (fan-out): ``parent_<seg>``
120-
- ``len(output) == len(input)`` → positional 1:1: each ``parent_i_<seg>``;
121-
a ``None`` slot just means input ``i`` was filtered.
122-
- any other (ambiguous) cardinality across a batch → a random ``uuid``
123-
prefixed with ``"r"`` (e.g. ``"r3f9a…"``), so ``task_id`` is never
124-
empty even when a derived id is not possible. The ``"r"`` prefix flags
125-
the id as non-deterministic / ancestry-not-tracked (see
126-
``Task.task_id`` docstring).
127-
128-
``seg`` is the output's content id (``Task.get_deterministic_id()``)
129-
for a source stage when available, else the positional index — so a
130-
source partition keeps a stable id across reorderings regardless of
131-
whether the source is 1→N or N→N.
132-
133-
Note: a stage that BOTH filters and fans out within a single batch
134-
(returning a flat list rather than a per-input slot) cannot be mapped
135-
positionally; if its length happens to equal the input length the 1:1
136-
assumption may misattribute parents. That combination is unsupported
137-
until per-slot sentinels (NoneTask/FailedTask) land in a later PR.
158+
def _post_process_task_ids(self, input_tasks: list[Task], output_tasks: list[Task]) -> list[Task]:
159+
"""Assign a deterministic ``task_id`` (parent id + own segment) to every
160+
emitted task. Runs once per stage on every backend, so ``process`` vs
161+
``process_batch`` makes no difference; ids are re-derived at each stage
162+
boundary, so one object passing through N stages gets N ids.
163+
164+
- single input → fan-out: each output is ``parent_<seg>``
165+
- ``len(output) == len(input)`` → positional 1:1: ``parent_i_<seg>``; a
166+
``NoneTask`` slot means input ``i`` was filtered (kept for alignment, then
167+
dropped from the result)
168+
- any other cardinality → a random ``"r"``-prefixed uuid (non-deterministic,
169+
ancestry-not-tracked; see ``Task.task_id``)
170+
171+
``seg`` is the content id (``get_deterministic_id()``) for a source stage,
172+
else the positional index. A stage that both filters and fans out in one
173+
batch can't be mapped positionally and falls to the ``"r"`` case — return
174+
one value (or ``None``) per input to stay positional.
138175
"""
139176
is_source = getattr(self.stage, "is_source_stage", False)
140177

141178
if len(input_tasks) == 1:
142-
# Fan-out (incl. a source reading from EmptyTask): every non-None
179+
# Fan-out (including a source reading from EmptyTask): every
143180
# output is a child of the single input.
144181
parent_id = input_tasks[0].task_id
145-
out: list[Task] = [t for t in output_tasks if t is not None]
182+
out = list(output_tasks)
146183
for i, task in enumerate(out):
147184
suffix = (task.get_deterministic_id() or i) if is_source else i
148185
task._set_task_id(parent_id, suffix)
149186
return out
150187

151188
if len(output_tasks) == len(input_tasks):
152-
# Positional 1:1. None is kept above so a filtered slot still lines
153-
# up with its own parent; drop the None slots from the result.
189+
# Positional 1:1. A NoneTask sentinel remains aligned with the
190+
# parent whose output was filtered.
154191
out = []
155192
for parent, task in zip(input_tasks, output_tasks, strict=True):
156-
if task is None:
157-
continue
158193
suffix = (task.get_deterministic_id() or 0) if is_source else 0
159194
task._set_task_id(parent.task_id, suffix)
160195
out.append(task)
@@ -163,11 +198,91 @@ def _post_process_task_ids(self, input_tasks: list[Task], output_tasks: list[Tas
163198
# Ambiguous cardinality across a batch: a derived id is not possible. Use a
164199
# random "r"-prefixed uuid so task_id is non-empty but clearly flagged
165200
# non-deterministic.
166-
out = [t for t in output_tasks if t is not None]
201+
out = list(output_tasks)
167202
for task in out:
168203
task.task_id = "r" + uuid.uuid4().hex
169204
return out
170205

206+
# Resumability (opt-in): stamp _source_id, fire per-source deltas, drop
207+
# completed sources. task_ids are already assigned; sentinels stripped by caller.
208+
def _apply_resumability_counters(self, input_tasks: list[Task], output_tasks: list[Task]) -> list[Task]: # noqa: C901
209+
# Dedup key is always an OUTPUT task_id, never the input's: the source
210+
# already keyed its +1 on that id, and an output id is one level deeper,
211+
# so it's unique to the (task, stage) that produced it.
212+
stage = self.stage
213+
if getattr(stage, "is_source_stage", False):
214+
return self._source_counters(output_tasks)
215+
216+
# No outputs (e.g. a batch entirely filtered, or an end-of-pipeline
217+
# no-op): nothing to attribute a delta to, so skip.
218+
if not output_tasks:
219+
return output_tasks
220+
221+
# Pre-source: inputs have no _source_id yet; nothing to track.
222+
if all(not t._source_id for t in input_tasks):
223+
return output_tasks
224+
225+
is_sink = stage.is_sink_stage
226+
per_task: list[tuple[str, str, int]] = []
227+
228+
if len(input_tasks) == 1 and len(output_tasks) > 1:
229+
# Fan-out (1->N): parent consumed (-1); each real child continues
230+
# (+1, or 0 at a sink); each FailedTask keeps the source open (+1);
231+
# NoneTask contributes 0.
232+
parent = input_tasks[0]
233+
real = [t for t in output_tasks if not _is_sentinel(t)]
234+
n_failed = sum(1 for t in output_tasks if isinstance(t, FailedTask))
235+
continuing = 0 if is_sink else len(real)
236+
delta = continuing + n_failed - 1
237+
# Key on output[0].task_id (not parent.task_id, which collides with the
238+
# source's +1). Non-source children are indexed positionally, so
239+
# output[0] is always "<parent>_0".
240+
per_task.append((output_tasks[0].task_id, parent._source_id, delta))
241+
for c in real:
242+
if not c._source_id:
243+
c._source_id = parent._source_id
244+
elif len(output_tasks) == len(input_tasks):
245+
# Positional 1:1; each delta keys on the output id (r.task_id).
246+
for parent, r in zip(input_tasks, output_tasks, strict=True):
247+
sid = parent._source_id
248+
if isinstance(r, NoneTask): # filtered -> consumed
249+
per_task.append((r.task_id, sid, -1))
250+
continue
251+
if isinstance(r, FailedTask): # failed -> source stays open (no sink test)
252+
per_task.append((r.task_id, sid, 0))
253+
continue
254+
per_task.append((r.task_id, sid, -1 if is_sink else 0)) # real: sink -1, else 0
255+
if not r._source_id:
256+
r._source_id = sid
257+
else:
258+
# M->K (M!=K): can't attribute parents; skip (source stays pending -> reprocessed).
259+
logger.warning(
260+
f"resumability: {type(stage).__name__} produced {len(output_tasks)} outputs "
261+
f"for {len(input_tasks)} inputs; can't attribute sources, skipping counter "
262+
f"update for this batch."
263+
)
264+
return output_tasks
265+
266+
flush_resumability_deltas(per_task)
267+
return output_tasks
268+
269+
def _source_counters(self, output_tasks: list[Task]) -> list[Task]:
270+
"""Source stage: each output is a source partition; its ``_source_id`` is
271+
``Task.get_source_id()``. Drop already-completed sources; each survivor fires ``+1``."""
272+
sources = [t for t in output_tasks if not _is_sentinel(t)]
273+
for t in sources:
274+
t._source_id = t.get_source_id()
275+
completed = completed_resumability_sources([t._source_id for t in sources])
276+
per_task: list[tuple[str, str, int]] = []
277+
survivors: list[Task] = []
278+
for t in sources:
279+
if t._source_id in completed:
280+
continue
281+
per_task.append((t.task_id, t._source_id, +1))
282+
survivors.append(t)
283+
flush_resumability_deltas(per_task)
284+
return survivors
285+
171286
def setup_on_node(self, node_info: NodeInfo | None = None, worker_metadata: WorkerMetadata | None = None) -> None:
172287
"""Setup the stage on a node.
173288
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import os
16+
import uuid
17+
from pathlib import Path
18+
19+
from nemo_curator.utils.retry_manifest import METADATA_DIRNAME
20+
21+
FAILED_TASKS_DIR_ENV_VAR = "NEMO_CURATOR_FAILED_TASKS_DIR"
22+
FAILED_TASK_MANIFEST_FILENAME = "failed_tasks.json"
23+
24+
25+
def _configure_failed_task_manifest_dir(default_dir: Path) -> Path:
26+
existing = os.environ.get(FAILED_TASKS_DIR_ENV_VAR)
27+
if existing:
28+
return Path(existing)
29+
30+
manifest_dir = default_dir.absolute()
31+
os.environ[FAILED_TASKS_DIR_ENV_VAR] = str(manifest_dir)
32+
return manifest_dir
33+
34+
35+
def configure_failed_task_manifest_dir(checkpoint_path: str | Path) -> Path:
36+
"""Configure a local attempt-scoped FailedTask manifest directory unless overridden."""
37+
manifest_dir = Path(
38+
checkpoint_path,
39+
METADATA_DIRNAME,
40+
".failed_tasks",
41+
f"local_attempt_{uuid.uuid4().hex}",
42+
)
43+
return _configure_failed_task_manifest_dir(manifest_dir)
44+
45+
46+
def configure_slurm_array_failed_task_manifest_dir(checkpoint_path: str | Path, shard_index: int) -> Path:
47+
"""Configure an attempt-scoped FailedTask manifest directory unless overridden."""
48+
job_id = os.environ.get("SLURM_JOB_ID", f"local_{os.getpid()}")
49+
array_task_id = os.environ.get("SLURM_ARRAY_TASK_ID", "local")
50+
restart_count = os.environ.get("SLURM_RESTART_COUNT", "0")
51+
manifest_dir = Path(
52+
checkpoint_path,
53+
METADATA_DIRNAME,
54+
".failed_tasks",
55+
f"slurm_job_{job_id}",
56+
f"array_task_{array_task_id}",
57+
f"restart_{restart_count}",
58+
f"shard_{shard_index}",
59+
)
60+
return _configure_failed_task_manifest_dir(manifest_dir)
61+
62+
63+
def record_failed_tasks() -> None:
64+
"""Write one attempt-scoped manifest after any FailedTask is detected."""
65+
manifest_dir = os.environ.get(FAILED_TASKS_DIR_ENV_VAR)
66+
if not manifest_dir:
67+
return
68+
69+
manifest_path = Path(manifest_dir, FAILED_TASK_MANIFEST_FILENAME)
70+
if manifest_path.is_file():
71+
return
72+
73+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
74+
manifest_path.touch(exist_ok=True)
75+
76+
77+
def failed_task_manifest_exists(manifest_dir: str | Path | None = None) -> bool:
78+
"""Return whether the current attempt has recorded any FailedTask."""
79+
resolved_manifest_dir = manifest_dir if manifest_dir is not None else os.environ.get(FAILED_TASKS_DIR_ENV_VAR)
80+
if not resolved_manifest_dir:
81+
return False
82+
return Path(resolved_manifest_dir, FAILED_TASK_MANIFEST_FILENAME).is_file()

0 commit comments

Comments
 (0)