Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
34d2f34
feat(interleaved): foundation IO improvements — schema utilities, bas…
VibhuJawa Mar 24, 2026
7702bfd
simplify(interleaved): move resolve_schema to shared utils, remove de…
VibhuJawa Mar 24, 2026
a1eeccc
Merge remote-tracking branch 'origin/main' into feat/interleaved-io-f…
VibhuJawa Mar 24, 2026
33088b1
chore: remove unrelated nixl-cu12 from override-dependencies
VibhuJawa Mar 24, 2026
6f0e655
chore: restore nixl-cu12 override-dependency
VibhuJawa Mar 24, 2026
57263df
Remove source_id_field from WebDataset reader
VibhuJawa Mar 24, 2026
27cc69a
simplify: fix align_table, type hints, and add dict-type comment
VibhuJawa Mar 24, 2026
987f5d4
fix(tutorial): update quickstart notebook and set source_files in rea…
VibhuJawa Mar 24, 2026
575e505
Apply suggestion from @claude[bot]
VibhuJawa Mar 24, 2026
7df12f3
ci: update secrets baseline for notebook image outputs (false positives)
VibhuJawa Mar 24, 2026
1a4dce4
fix: make source_files unique per split in webdataset reader
VibhuJawa Mar 24, 2026
472b21c
fix: source_files per split lists only contributing tars in webdatase…
VibhuJawa Mar 24, 2026
be9e7c5
fix: source_files per split lists only contributing tars in webdatase…
VibhuJawa Mar 24, 2026
86de41b
test: add schema_utils tests and expand interleaved coverage
VibhuJawa Mar 31, 2026
9c35387
Merge remote-tracking branch 'origin/main' into feat/interleaved-io-f…
VibhuJawa Apr 1, 2026
802ab23
Merge branch 'main' into feat/interleaved-io-foundation
VibhuJawa Apr 1, 2026
5a82f0e
feat: add interleaved_cpu to all extras in pyproject.toml
VibhuJawa Apr 1, 2026
54d1378
Merge branch 'main' into feat/interleaved-io-foundation
VibhuJawa Apr 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 14 additions & 18 deletions .github/workflows/config/.secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,4 @@ data/
.DS_Store
AGENTS.md
alm_output/
benchmark_results/
4 changes: 2 additions & 2 deletions nemo_curator/stages/interleaved/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ WebDataset tar shards
|
v
┌─────────────────────────┐
│ WebdatasetReader │ CompositeStage: FilePartitioning + WebdatasetReaderStage
│ WebdatasetReader │ CompositeStage: FilePartitioning + InterleavedWebdatasetReaderStage
│ (io/reader.py) │ Parses tar members -> normalized interleaved rows
└────────┬────────────────┘
| InterleavedBatch (Arrow/Pandas)
Expand Down Expand Up @@ -141,7 +141,7 @@ stages/interleaved/
│ ├── reader.py # WebdatasetReader (CompositeStage)
│ ├── readers/
│ │ ├── base.py # BaseInterleavedReader
│ │ └── webdataset.py # WebdatasetReaderStage (ProcessingStage)
│ │ └── webdataset.py # InterleavedWebdatasetReaderStage (ProcessingStage)
│ └── writers/
│ ├── base.py # BaseInterleavedWriter (filesystem + materialization + process)
│ └── tabular.py # InterleavedParquetWriterStage
Expand Down
5 changes: 4 additions & 1 deletion nemo_curator/stages/interleaved/io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@
from nemo_curator.stages.interleaved.io.reader import WebdatasetReader
from nemo_curator.stages.interleaved.io.writers.tabular import InterleavedParquetWriterStage

__all__ = ["InterleavedParquetWriterStage", "WebdatasetReader"]
__all__ = [
"InterleavedParquetWriterStage",
"WebdatasetReader",
]
10 changes: 5 additions & 5 deletions nemo_curator/stages/interleaved/io/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,18 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any

from nemo_curator.stages.base import CompositeStage
from nemo_curator.stages.file_partitioning import FilePartitioningStage
from nemo_curator.stages.interleaved.io.readers.webdataset import WebdatasetReaderStage
from nemo_curator.stages.interleaved.io.readers.webdataset import InterleavedWebdatasetReaderStage
from nemo_curator.stages.interleaved.utils import (
DEFAULT_IMAGE_EXTENSIONS,
DEFAULT_JSON_EXTENSIONS,
DEFAULT_WEBDATASET_EXTENSIONS,
require_source_id_field,
resolve_storage_options,
)
from nemo_curator.tasks import InterleavedBatch, _EmptyTask
Expand All @@ -41,7 +42,7 @@ class WebdatasetReader(CompositeStage[_EmptyTask, InterleavedBatch]):
file_extensions: list[str] = field(default_factory=lambda: list(DEFAULT_WEBDATASET_EXTENSIONS))
json_extensions: list[str] = field(default_factory=lambda: list(DEFAULT_JSON_EXTENSIONS))
image_extensions: list[str] = field(default_factory=lambda: list(DEFAULT_IMAGE_EXTENSIONS))
source_id_field: str = ""
source_id_field: str | None = None
sample_id_field: str | None = None
texts_field: str = "texts"
images_field: str = "images"
Expand All @@ -53,7 +54,6 @@ class WebdatasetReader(CompositeStage[_EmptyTask, InterleavedBatch]):

def __post_init__(self):
super().__init__()
self.source_id_field = require_source_id_field(self.source_id_field)
self.storage_options = resolve_storage_options(io_kwargs=self.read_kwargs)

def decompose(self) -> list:
Expand All @@ -65,7 +65,7 @@ def decompose(self) -> list:
file_extensions=self.file_extensions,
storage_options=self.storage_options,
),
WebdatasetReaderStage(
InterleavedWebdatasetReaderStage(
read_kwargs=self.read_kwargs,
materialize_on_read=self.materialize_on_read,
max_batch_bytes=self.max_batch_bytes,
Expand Down
4 changes: 2 additions & 2 deletions nemo_curator/stages/interleaved/io/readers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from nemo_curator.stages.interleaved.io.readers.webdataset import WebdatasetReaderStage
from nemo_curator.stages.interleaved.io.readers.webdataset import InterleavedWebdatasetReaderStage

__all__ = ["WebdatasetReaderStage"]
__all__ = ["InterleavedWebdatasetReaderStage"]
61 changes: 60 additions & 1 deletion nemo_curator/stages/interleaved/io/readers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,78 @@
from dataclasses import dataclass, field
from typing import Any

import pyarrow as pa
from loguru import logger

from nemo_curator.stages.base import ProcessingStage
from nemo_curator.stages.interleaved.utils.schema import align_table, reconcile_schema
from nemo_curator.tasks import FileGroupTask, InterleavedBatch
from nemo_curator.tasks.interleaved import INTERLEAVED_SCHEMA


@dataclass
class BaseInterleavedReader(ProcessingStage[FileGroupTask, InterleavedBatch]):
"""Base contract for interleaved readers."""
"""Base contract for interleaved readers.

By default (``schema=None``) user-added passthrough columns are preserved
and only reserved-column types are reconciled via ``reconcile_schema``.

If *schema* is set explicitly, every output table is strictly aligned to it
(missing columns become typed nulls, extra columns are dropped).

Use *schema_overrides* to add or override individual field types relative to
``INTERLEAVED_SCHEMA`` while keeping strict alignment:

.. code-block:: python

reader = InterleavedParquetReader(
"data.parquet",
schema_overrides={"url": pa.string(), "timestamp": pa.int64()},
)
"""

read_kwargs: dict[str, Any] = field(default_factory=dict)
schema: pa.Schema | None = None
schema_overrides: dict[str, pa.DataType] | None = None
name: str = "base_interleaved_reader"

def __post_init__(self) -> None:
if self.schema_overrides is not None:
self.schema = _resolve_schema(self.schema, self.schema_overrides)
Comment thread
VibhuJawa marked this conversation as resolved.
Resolved

def inputs(self) -> tuple[list[str], list[str]]:
return ["data"], []

def outputs(self) -> tuple[list[str], list[str]]:
return ["data"], ["sample_id", "position", "modality"]

def _align_output(self, table: pa.Table) -> pa.Table:
"""Reconcile or align *table* to the declared schema."""
if self.schema is not None:
return align_table(table, self.schema)
return table.cast(reconcile_schema(table.schema))


def _resolve_schema(
schema: pa.Schema | None,
overrides: dict[str, pa.DataType] | None,
) -> pa.Schema | None:
"""Return the effective schema from user-supplied *schema* or *overrides*.

Priority: *schema* > *schema_overrides* merged on top of ``INTERLEAVED_SCHEMA``.
Raises ``ValueError`` if both inputs are ``None``.
"""
if schema is not None:
if overrides:
logger.warning("schema_overrides ignored because schema= is already set; use one or the other, not both")
return schema
if overrides:
fields = {f.name: f for f in INTERLEAVED_SCHEMA}
for name, dtype in overrides.items():
orig = fields.get(name)
nullable = orig.nullable if orig is not None else True
metadata = orig.metadata if orig is not None else None
fields[name] = pa.field(name, dtype, nullable=nullable, metadata=metadata)
return pa.schema(list(fields.values()))
msg = "At least one of schema= or schema_overrides= must be provided"
raise ValueError(msg)
43 changes: 18 additions & 25 deletions nemo_curator/stages/interleaved/io/readers/webdataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
from nemo_curator.stages.interleaved.utils import (
DEFAULT_IMAGE_EXTENSIONS,
DEFAULT_JSON_EXTENSIONS,
require_source_id_field,
resolve_storage_options,
validate_and_project_source_fields,
)
Expand Down Expand Up @@ -67,14 +66,14 @@ class _SampleContext:


@dataclass
class WebdatasetReaderStage(BaseInterleavedReader):
class InterleavedWebdatasetReaderStage(BaseInterleavedReader):
"""Read MINT1T-style WebDataset shards into a row-wise multimodal task."""

materialize_on_read: bool = False
max_batch_bytes: int | None = None
json_extensions: tuple[str, ...] = DEFAULT_JSON_EXTENSIONS
image_extensions: tuple[str, ...] = field(default_factory=lambda: DEFAULT_IMAGE_EXTENSIONS)
source_id_field: str = ""
source_id_field: str | None = None
sample_id_field: str | None = None
texts_field: str = "texts"
images_field: str = "images"
Expand All @@ -85,7 +84,7 @@ class WebdatasetReaderStage(BaseInterleavedReader):
name: str = "webdataset_reader"

def __post_init__(self) -> None:
self.source_id_field = require_source_id_field(self.source_id_field)
super().__post_init__()

# -- source_ref construction --

Expand Down Expand Up @@ -254,7 +253,7 @@ def _rows_from_sample(self, ctx: _SampleContext) -> list[dict[str, Any]]:

def _build_passthrough_row(self, sample: dict[str, Any]) -> dict[str, Any]:
excluded = RESERVED_COLUMNS | {
self.source_id_field,
*([self.source_id_field] if self.source_id_field else []),
*([self.sample_id_field] if self.sample_id_field else []),
self.texts_field,
self.images_field,
Expand Down Expand Up @@ -283,30 +282,24 @@ def _extract_per_modality_fields(
return result

def _empty_output_schema(self) -> pa.Schema:
schema = INTERLEAVED_SCHEMA
# Use explicit schema if set; otherwise fall back to INTERLEAVED_SCHEMA as base
base = self.schema if self.schema is not None else INTERLEAVED_SCHEMA
seen = set(self.fields or ())
all_extra = list(self.fields or ())
for f in (*self.per_image_fields, *self.per_text_fields):
if f not in seen:
all_extra.append(f)
seen.add(f)
if not all_extra:
return schema
existing = set(schema.names)
extra_fields = [pa.field(name, pa.null()) for name in all_extra if name not in existing]
return pa.schema([*schema, *extra_fields]) if extra_fields else schema
return base
existing = set(base.names)
extra_fields = []
for name in all_extra:
if name not in existing:
extra_fields.append(pa.field(name, pa.null()))
return pa.schema([*base, *extra_fields]) if extra_fields else base

@staticmethod
def _reconcile_schema(inferred: pa.Schema) -> pa.Schema:
"""Build a schema with canonical types for reserved columns and inferred types for passthrough."""
canonical = {f.name: f for f in INTERLEAVED_SCHEMA}
fields = []
for f in inferred:
if f.name in canonical:
fields.append(canonical[f.name])
else:
fields.append(f)
return pa.schema(fields)
# _align_output is inherited from BaseInterleavedReader

# -- image member resolution --

Expand Down Expand Up @@ -398,11 +391,11 @@ def _rows_from_member(
else:
frame_index = parsed_ref.get("frame_index")
if frame_index is not None:
extracted = _extract_tiff_frame(raw_bytes, frame_index)
if extracted is None:
tiff_frame = _extract_tiff_frame(raw_bytes, frame_index)
if tiff_frame is None:
row["materialize_error"] = f"failed to extract frame {frame_index} from '{content_key}'"
else:
raw_bytes = extracted
raw_bytes = tiff_frame
row["binary_content"] = raw_bytes
read_ctx.byte_cache.clear()
return sample_rows
Expand Down Expand Up @@ -434,7 +427,7 @@ def process(self, task: FileGroupTask) -> InterleavedBatch | list[InterleavedBat

if rows:
table = pa.Table.from_pylist(rows)
table = table.cast(self._reconcile_schema(table.schema))
table = self._align_output(table)
else:
# Empty tables use _empty_output_schema(); passthrough columns get
# pa.null() type which is intentional (no data to infer from).
Expand Down
4 changes: 3 additions & 1 deletion nemo_curator/stages/interleaved/io/writers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@

from nemo_curator.stages.interleaved.io.writers.tabular import InterleavedParquetWriterStage

__all__ = ["InterleavedParquetWriterStage"]
__all__ = [
"InterleavedParquetWriterStage",
]
Loading
Loading