# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Shared conversation rendering, masking, and multimodal normalization.""" from __future__ import annotations import copy import inspect import json import re import warnings from collections.abc import Mapping, MutableMapping, Sequence from dataclasses import dataclass, field from typing import Any, Literal import torch from megatron.bridge.data.datasets.utils import GENERATION_REGEX, IGNORE_INDEX from megatron.bridge.training.utils.visual_inputs import GenericVisualInputs CHATML_ASSISTANT_ROLE = "assistant" _LEGACY_CHAT_ROLE_ALIASES = {"human": "user", "gpt": CHATML_ASSISTANT_ROLE} # Upper bound on the rendered form of a non-tokenizable payload that the boundary-token scan # will tokenize. Sized to admit payloads that print themselves in full — a few hundred frame # reprs, or a float array below numpy's summarization threshold — and reject the rest. _MAX_SCANNED_REPR_CHARS = 65536 _PIPELINE_CONTROLLED_CHAT_TEMPLATE_KWARGS = frozenset( { "add_generation_prompt", "chat_template", "continue_final_message", "max_length", "padding", "return_assistant_tokens_mask", "return_dict", "return_tensors", "tokenize", "tools", "truncation", } ) @dataclass(frozen=True) class AssistantMaskBoundaryConfig: """Token boundary fallback configuration for assistant loss masks. Expected input format: ``role_start_tokens`` maps each chat role to the token-id sequence that immediately precedes that role's content in already-rendered ``input_ids``. ``role_end_tokens`` maps each chat role to the token-id sequence that terminates that role's turn. ``loss_roles`` names top-level chat roles that should contribute to loss. This follows the common SFT convention used by chat-template based pipelines: once a role contributes to loss, the role's full rendered content contributes, including formatted tool-call or reasoning spans inside that role. ``include_*_tokens_for_roles`` optionally include boundary tokens in the loss. By default, assistant turn end tokens are included because they are generated by the assistant and teach the model when to stop. ``trim_leading_token_ids`` removes repeated single-token delimiters from the start of loss-role content. ``trim_leading_token_sequences`` removes exact leading token sequences, for example an empty ```` wrapper without masking a non-empty ``reasoning`` span. ``role_end_token_variants`` provides ordered fallback end sequences for formats whose final turn may omit a trailing delimiter such as a newline. The primary ``role_end_tokens`` sequence is always preferred. ``rendered_turn_end_roles`` covers formats such as GLM-5.2 whose assistant turn has no explicit end token. For those roles, the conversation-prefix render determines the exclusive content boundary. Example:: AssistantMaskBoundaryConfig( role_start_tokens={"user": [10], "assistant": [11]}, role_end_tokens={"user": [12], "assistant": [12]}, loss_roles=("assistant",), include_end_tokens_for_roles=("assistant",), trim_leading_token_ids=(198,), trim_leading_token_sequences=([55, 56],), ) Output format: When passed to :func:`build_assistant_loss_mask`, the fallback scans rendered ``input_ids`` for role boundaries and returns an unshifted float mask with ``1.0`` only on role spans whose role appears in ``loss_roles``. Boundary marker tokens are included only when requested by the corresponding ``include_*`` fields. """ role_start_tokens: Mapping[str, Sequence[int]] role_end_tokens: Mapping[str, Sequence[int]] loss_roles: Sequence[str] = (CHATML_ASSISTANT_ROLE,) include_start_tokens_for_roles: Sequence[str] = () include_end_tokens_for_roles: Sequence[str] = (CHATML_ASSISTANT_ROLE,) trim_leading_token_ids: Sequence[int] = () trim_leading_token_sequences: Sequence[Sequence[int]] = () role_end_token_variants: Mapping[str, Sequence[Sequence[int]]] = field(default_factory=dict) rendered_turn_end_roles: Sequence[str] = () @dataclass(frozen=True) class NormalizedVLMSample: """Source-normalized VLM sample consumed by shared processing. Expected input format: Instances are produced by source adapters such as :func:`normalize_energon_vlm_sample` and :func:`normalize_hf_vlm_example`. ``conversation`` must be a structured list of chat turns in HF processor format, for example:: [ {"role": "user", "content": "describe "}, {"role": "assistant", "content": "a red car"}, ] ``content`` may also be a multimodal content list accepted by ``processor.apply_chat_template``, for example:: [{"type": "image", "image": image_obj}, {"type": "text", "text": "describe"}] ``images`` and ``videos`` are optional processor-ready modality payloads. ``tools`` contains optional top-level tool definitions passed to the HF chat template. Message-level ``tool_calls`` remain in ``conversation``. Energon adapters convert WDS tensors to PIL objects before populating these fields; HF adapters may leave them ``None`` when media already lives inline in ``conversation`` content. Output format: Shared processing treats this as the single boundary contract before model-specific tokenization and vision preprocessing. It does not contain batched tensors; model-specific collators convert it into model input tensors. """ conversation: list[dict[str, Any]] images: list[Any] | None = None videos: list[Any] | None = None audio: Any | None = None tools: list[dict[str, Any]] | None = None @dataclass(frozen=True) class TokenizedConversation: """Token ids and an unshifted assistant-only loss mask for one chat row.""" input_ids: torch.Tensor assistant_mask: torch.Tensor conversation: list[dict[str, Any]] final_assistant_start: int | None = None def normalize_energon_vlm_sample(sample: Any) -> NormalizedVLMSample: """Normalize an Energon ``ChatMLSample`` into the shared VLM sample contract. Expected input format: ``sample`` is expected to expose the Energon ``ChatMLSample`` fields: - ``conversation``: JSON string accepted by ``cook_chatml_sample``. The JSON may use either ``{"role": ..., "content": ...}`` turns or ``{"from": ..., "value": ...}`` turns. - ``imgs``: optional WDS decoded image tensor/list payload. - ``videos``: optional WDS decoded video tensor/list payload. - ``audio``: optional audio payload, passed through unchanged. - ``tools``: optional top-level tool-definition list. Wrapped ``conversation`` JSON may also carry this field. Output format: Returns ``NormalizedVLMSample`` where ``conversation`` is a list of ``{"role": str, "content": str | list[dict]}`` turns, ``images`` are PIL/list processor inputs or ``None``, ``videos`` are nested PIL/list processor inputs or ``None``, and ``audio`` is copied from the source sample when present. """ from megatron.bridge.data.energon.task_encoder_utils import ( _images_to_pil, _videos_to_pil, cook_chatml_sample, extract_chatml_tools, ) imgs = getattr(sample, "imgs", None) videos = getattr(sample, "videos", None) tools = getattr(sample, "tools", None) if tools is None: tools = extract_chatml_tools(sample.conversation) if isinstance(tools, (str, bytes)): tools = json.loads(tools) if tools is not None and (not isinstance(tools, list) or not all(isinstance(tool, Mapping) for tool in tools)): raise ValueError("Energon ChatML tools must be a list of tool-definition dictionaries.") if isinstance(videos, (bytes, bytearray)): normalized_videos = [videos] else: normalized_videos = _videos_to_pil(videos) if videos is not None and len(videos) > 0 else None return NormalizedVLMSample( conversation=cook_chatml_sample(sample.conversation), tools=copy.deepcopy(tools), images=_images_to_pil(imgs) if imgs is not None and len(imgs) > 0 else None, videos=normalized_videos, audio=getattr(sample, "audio", None), ) def normalize_hf_vlm_example(example: Mapping[str, Any]) -> NormalizedVLMSample: """Normalize a HF-style VLM dataset example into the shared sample contract. Expected input format: ``example`` must contain ``"conversation"`` as a structured list of chat turns already produced by an HF schema adapter, for example:: { "conversation": [ {"role": "user", "content": [{"type": "image", "image": img}, {"type": "text", "text": "Q"}]}, {"role": "assistant", "content": [{"type": "text", "text": "A"}]}, ], "audio": optional_audio, } Optional top-level ``images``/``image`` and ``videos``/``video`` fields are accepted for adapter variants that do not embed media inline in the conversation. Optional top-level ``tools`` are copied for chat-template rendering. Output format: Returns ``NormalizedVLMSample`` with a deep-copied structured ``conversation`` list, optional list-valued ``images`` and ``videos`` payloads, and optional ``audio``. The adapter does not call ``cook_chatml_sample`` because HF adapters have already normalized the chat schema. Raises: ValueError: If ``example["conversation"]`` is missing or is not a list. """ conversation = example.get("conversation") if not isinstance(conversation, list): raise ValueError("HF VLM examples must contain a list-valued 'conversation' field.") images = example.get("images") if images is None and "image" in example: images = [example["image"]] videos = example.get("videos") if videos is None and "video" in example: videos = [example["video"]] return NormalizedVLMSample( conversation=copy.deepcopy(conversation), tools=copy.deepcopy(example.get("tools")), images=images, videos=videos, audio=example.get("audio"), ) def normalized_vlm_sample_to_hf_example( sample: NormalizedVLMSample, *, media_first: bool = False, ) -> dict[str, Any]: """Convert a normalized VLM sample into the HF-style collate example schema. Expected input format: ``sample`` follows ``NormalizedVLMSample``: ``conversation`` is a list of chat turns, and optional ``images``/``videos`` contain processor-ready media payloads such as PIL images or decoded video frame lists. Text turns may contain literal ```` / ``