Skip to content

feat(audio-io): make IO stages agent-ready (contract + serialization-safe sink)#2171

Open
shubhamNvidia wants to merge 2 commits into
NVIDIA-NeMo:mainfrom
shubhamNvidia:agent_pr/io
Open

feat(audio-io): make IO stages agent-ready (contract + serialization-safe sink)#2171
shubhamNvidia wants to merge 2 commits into
NVIDIA-NeMo:mainfrom
shubhamNvidia:agent_pr/io

Conversation

@shubhamNvidia

Copy link
Copy Markdown
Contributor

Summary

Makes the audio IO stages agent-ready (they now implement describe()) and hardens the document sink.

io/convert.py (AudioToDocumentStage) — N:1 sink, AudioTask → DocumentBatch

  • Now configurable via constructor: keep_keys (whitelist of columns to emit; None = all, prior behavior), drop_keys (blacklist), serialize_segments + segments_key.
  • segments (a list of per-segment dicts that may embed waveform tensors) was previously dropped wholesale. With serialize_segments=True it is kept but recursively cleaned by the new _sanitize_nested helper, which walks nested dicts/lists and strips torch.Tensor / audio-blob values (using a _DROP_VALUE sentinel so a legitimate None is preserved).
  • describe() sets Gates(sanitizes_output=True) to mark this as the tensor-safe boundary before a JSON/parquet writer.

io/extract_segments.py (SegmentExtractionStage)

  • Adds describe() with OR-shaped reads covering the extraction combos (timestamp-based vs diarization-based). Marked BATCH_ONLY: extraction runs per batch and appends to a shared metadata file, so process() raises and process_batch() does the work.

Notes for reviewers

  • Backward compatible: keep_keys=None emits all keys as before; segments still dropped unless serialize_segments=True.
  • Worth a look: _sanitize / _sanitize_nested in convert.py (recursive tensor stripping) and the combo auto-detection in extract_segments.py.

…idency resolver

Shared base imported by the audio stage modules to make them agent-ready:
- _agent_ready.py: AgentReady mixin, StageContract/IOSpec/Gates/Role
- _residency.py: input residency resolver (file/waveform/auto)
- common.py: agent-ready helpers (ensure_mono/ensure_waveform_2d)

Excludes the agent orchestration layer (registry/catalog/conformance/planning/agent).
@shubhamNvidia shubhamNvidia requested a review from a team as a code owner July 7, 2026 15:43
@shubhamNvidia shubhamNvidia requested review from meatybobby and removed request for a team July 7, 2026 15:43
@copy-pr-bot

copy-pr-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes audio IO stages agent-ready by adding AgentReady mixin inheritance and describe() implementations that emit StageContract objects, and hardens AudioToDocumentStage with configurable key filtering (keep_keys, drop_keys) and optional sanitized segment serialization (serialize_segments).

  • _agent_ready.py / _residency.py — Two new infrastructure files: the first defines the full contract dataclass hierarchy (StageContract, IOSpec, Gates, ParamSpec) plus the AgentReady mixin; the second provides residency helpers (resolve_audio, resolve_audio_path) for waveform-vs-file dispatch with temp-file cleanup support.
  • common.py — Five existing stages gain AgentReady and accurate describe() implementations, including the requires_serializable_input gate on ManifestWriterStage.
  • io/convert.pyAudioToDocumentStage gains constructor-level key-filter knobs and a new _sanitize_nested recursive helper that strips tensors/audio blobs from segment dicts using a _DROP_VALUE sentinel; describe() marks the stage as sanitizes_output=True.
  • io/extract_segments.pySegmentExtractionStage gains output_key configurability, collects all written paths into a list per entry, and exposes a describe() with OR-shaped reads_one_of covering timestamp and diarization combos.

Confidence Score: 4/5

Safe to merge for the common pipeline paths; the changes are additive and backward-compatible, but the _sanitize path has a small correctness gap when serialize_segments=True.

The _sanitize method stores the _DROP_VALUE sentinel directly into the cleaned dict without checking it, so if a segment value is unexpectedly a raw tensor, the DataFrame will contain an unserializable object — breaking the sanitizes_output=True guarantee that the stage advertises. The reads_one_of contract for SegmentExtractionStage also omits Combo 4, so agents planning around it may misjudge valid inputs. Both are correctable in a follow-up, and neither fires in the typical list-of-dicts segments case.

nemo_curator/stages/audio/io/convert.py — the _sanitize / _sanitize_nested interaction; nemo_curator/stages/audio/io/extract_segments.py — the reads_one_of contract completeness.

Important Files Changed

Filename Overview
nemo_curator/stages/audio/_agent_ready.py New file: defines StageContract, IOSpec, Gates, AgentReady mixin, and JSON-schema helpers for agent-facing stage discovery. Well-structured with frozen dataclasses and sensible defaults.
nemo_curator/stages/audio/_residency.py New file: provides resolve_audio / resolve_audio_path helpers for waveform-vs-file residency; correctly uses a temporary file for in-memory waveforms and has cleanup support.
nemo_curator/stages/audio/common.py Adds AgentReady mixin + describe() to four existing stages; GetAudioDurationStage and ManifestWriterStage describe() look accurate; PreserveByValueStage correctly marks cardinality as 'filter'.
nemo_curator/stages/audio/io/convert.py AudioToDocumentStage gains keep_keys/drop_keys/serialize_segments config and _sanitize_nested; _sanitize does not guard against _DROP_VALUE being stored in cleaned dict, and keep_keys list membership test is O(n) per key.
nemo_curator/stages/audio/io/extract_segments.py SegmentExtractionStage adds output_key, describe(), and switches extracted_path collection to a list per entry; reads_one_of in describe() omits Combo 4 (speaker + timestamps without diar_segments).

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Agent
    participant Stage
    participant AgentReady
    participant StageContract

    Agent->>Stage: stage.describe()
    Stage->>AgentReady: (inherits describe())
    Stage-->>Agent: StageContract(reads, writes, gates, cardinality)

    Note over Agent,StageContract: AudioToDocumentStage (N:1 sink)
    Agent->>Stage: process_batch(tasks)
    Stage->>Stage: _sanitize(task.data) per task
    Stage->>Stage: _sanitize_nested(segments) if serialize_segments
    Stage-->>Agent: DocumentBatch (tensor-free DataFrame)

    Note over Agent,StageContract: SegmentExtractionStage (BATCH_ONLY)
    Agent->>Stage: process_batch(tasks)
    Stage->>Stage: detect_combo(entries) to 2/3/4
    Stage->>Stage: _extract_file_segments(entries, ...)
    Stage->>Stage: entry[output_key].append(output_path)
    Stage-->>Agent: tasks (with output_key list updated in-place)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Agent
    participant Stage
    participant AgentReady
    participant StageContract

    Agent->>Stage: stage.describe()
    Stage->>AgentReady: (inherits describe())
    Stage-->>Agent: StageContract(reads, writes, gates, cardinality)

    Note over Agent,StageContract: AudioToDocumentStage (N:1 sink)
    Agent->>Stage: process_batch(tasks)
    Stage->>Stage: _sanitize(task.data) per task
    Stage->>Stage: _sanitize_nested(segments) if serialize_segments
    Stage-->>Agent: DocumentBatch (tensor-free DataFrame)

    Note over Agent,StageContract: SegmentExtractionStage (BATCH_ONLY)
    Agent->>Stage: process_batch(tasks)
    Stage->>Stage: detect_combo(entries) to 2/3/4
    Stage->>Stage: _extract_file_segments(entries, ...)
    Stage->>Stage: entry[output_key].append(output_path)
    Stage-->>Agent: tasks (with output_key list updated in-place)
Loading

Reviews (1): Last reviewed commit: "feat(audio-io): make io stages agent-rea..." | Re-trigger Greptile

Comment on lines +57 to +69
def __init__(
self,
batch_size: int = 64,
keep_keys: list[str] | None = None,
drop_keys: tuple[str, ...] = (),
serialize_segments: bool = False,
segments_key: str = "segments",
) -> None:
self.batch_size = batch_size
self.keep_keys = keep_keys
self.drop_keys = drop_keys
self.serialize_segments = serialize_segments
self.segments_key = segments_key

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.

P2 self.keep_keys is a list[str], so k not in keys is an O(n) scan on every iteration through data.items(). For a data dict with many keys and a non-trivial keep_keys whitelist, this becomes O(data_keys × keep_keys) per call to _sanitize. Since this runs for every task in every batch, converting to a frozenset at construction time is worth the one-time cost.

Suggested change
def __init__(
self,
batch_size: int = 64,
keep_keys: list[str] | None = None,
drop_keys: tuple[str, ...] = (),
serialize_segments: bool = False,
segments_key: str = "segments",
) -> None:
self.batch_size = batch_size
self.keep_keys = keep_keys
self.drop_keys = drop_keys
self.serialize_segments = serialize_segments
self.segments_key = segments_key
def __init__(
self,
batch_size: int = 64,
keep_keys: list[str] | None = None,
drop_keys: tuple[str, ...] = (),
serialize_segments: bool = False,
segments_key: str = "segments",
) -> None:
self.batch_size = batch_size
self.keep_keys = frozenset(keep_keys) if keep_keys is not None else None
self.drop_keys = frozenset(drop_keys)
self.serialize_segments = serialize_segments
self.segments_key = segments_key

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!

Comment on lines +75 to +85
def describe(self) -> StageContract:
return StageContract(
reads=IOSpec(data_keys=[]),
writes=IOSpec(data_keys=[]),
cardinality="N:1",
# Strips tensors/audio blobs while building the DataFrame, so its
# output is serialization-safe — the sanctioned sink to place before
# a JSON writer when a resident tensor may be present.
gates=Gates(sanitizes_output=True),
description="Aggregate AudioTasks into a DocumentBatch, stripping tensors/audio blobs (JSON/disk-safe).",
)

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.

P2 batch_only not reflected in describe() output

BATCH_ONLY = True is set on the class, but the StageContract returned by describe() keeps batch_only=False (the default). The doc comment on StageContract.batch_only says it is "auto-derived at discovery time", so the registry path is correct — but any caller that invokes stage.describe() directly (e.g. a test, a planner that bypasses the registry, or a stage that calls describe() on a sub-stage) receives a contract that says process() is safe to call when it actually raises NotImplementedError. Consider passing batch_only=True here explicitly; the same gap exists in SegmentExtractionStage.describe() and PreserveByValueStage.describe().

Comment on lines +116 to 119
if k in _NON_SERIALIZABLE_KEYS or k == self.segments_key:
if k == self.segments_key and self.serialize_segments:
cleaned[k] = self._sanitize_nested(v)
continue

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.

P2 The _DROP_VALUE sentinel returned by _sanitize_nested is stored into cleaned without a guard check. If segments_key's value happens to be a raw torch.Tensor (rather than the expected list-of-dicts), _sanitize_nested returns _DROP_VALUE and cleaned[k] becomes an unserializable Python sentinel object, which pandas will store as an object-dtype cell. Any downstream JSON or Parquet writer will then fail — silently violating the sanitizes_output=True contract declared in describe().

Suggested change
if k in _NON_SERIALIZABLE_KEYS or k == self.segments_key:
if k == self.segments_key and self.serialize_segments:
cleaned[k] = self._sanitize_nested(v)
continue
if k in _NON_SERIALIZABLE_KEYS or k == self.segments_key:
if k == self.segments_key and self.serialize_segments:
nested = self._sanitize_nested(v)
if nested is not _DROP_VALUE:
cleaned[k] = nested
continue

Comment on lines +332 to +340
def describe(self) -> StageContract:
return StageContract(
reads_one_of=[
IOSpec(data_keys=["original_file", "original_start_ms", "original_end_ms"], accepts=["file"]),
IOSpec(data_keys=["original_file", "diar_segments", "speaker_id"], accepts=["file"]),
],
writes=IOSpec(data_keys=[self.output_key], produces=["disk"]),
gates=Gates(writes_to_disk=True),
)

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.

P2 The reads_one_of contract only covers Combo 2 (timestamp-based) and Combo 3 (diarization with diar_segments + speaker_id), but misses Combo 4 (speaker + timestamps: speaker_id present, diar_segments absent). An agent planning around this contract would incorrectly infer that no speaker_id input is needed for the timestamp combo, or that diar_segments is always required alongside speaker_id.

Suggested change
def describe(self) -> StageContract:
return StageContract(
reads_one_of=[
IOSpec(data_keys=["original_file", "original_start_ms", "original_end_ms"], accepts=["file"]),
IOSpec(data_keys=["original_file", "diar_segments", "speaker_id"], accepts=["file"]),
],
writes=IOSpec(data_keys=[self.output_key], produces=["disk"]),
gates=Gates(writes_to_disk=True),
)
def describe(self) -> StageContract:
return StageContract(
reads_one_of=[
# Combo 2: timestamp-based extraction (no speaker info)
IOSpec(data_keys=["original_file", "original_start_ms", "original_end_ms"], accepts=["file"]),
# Combo 3: diarization-based extraction (diar_segments + speaker_id)
IOSpec(data_keys=["original_file", "diar_segments", "speaker_id"], accepts=["file"]),
# Combo 4: speaker + timestamp extraction (speaker_id present, no diar_segments)
IOSpec(data_keys=["original_file", "original_start_ms", "original_end_ms", "speaker_id"], accepts=["file"]),
],
writes=IOSpec(data_keys=[self.output_key], produces=["disk"]),
gates=Gates(writes_to_disk=True),
)

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.

1 participant