Skip to content

feat(dataset): pluggable conversation renderers, pretokenized dataset loading, and tool-call preservation - #163

Merged
torchspec-bot merged 5 commits into
mainfrom
export/dataset-renderers
Aug 9, 2026
Merged

feat(dataset): pluggable conversation renderers, pretokenized dataset loading, and tool-call preservation#163
torchspec-bot merged 5 commits into
mainfrom
export/dataset-renderers

Conversation

@torchspec-bot

Copy link
Copy Markdown
Collaborator

Summary

Three independent dataset-path changes, one commit each. Together they let a model whose conversation format a ChatTemplate cannot express be trained without a bespoke fork of dataset.py, and let a corpus that was tokenized offline be trained on as-is.

1. fix(data) — preserve extended message fields when normalizing ShareGPT conversations

_normalize_conversation rebuilt each message as a fresh {"role", "content"} dict, dropping every other key. Tool-calling corpora lost tool_calls, tool_call_id, and name, so an assistant turn that only emitted a tool call normalized to empty content with no recoverable call. Unrecognized keys now pass through, with role/content written last so a stale duplicate in the source row cannot shadow them.

Existing chat templates only read role and content, so the extra keys are inert on the template path.

2. feat(dataset) — pluggable conversation renderers

ChatTemplate describes a conversation as a flat header/content/end-of-turn string triple, and derives the loss mask by string-matching the header it defines. That cannot express nested message structures, tool-call serialization, or a checkpoint whose own apply_chat_template is the only correct source of truth.

This adds a ConversationRenderer protocol and a RENDERER_REGISTRY alongside TEMPLATE_REGISTRY. A renderer is constructed with the target model's tokenizer and returns (input_ids, loss_mask) directly, so supervision comes from the token stream it just built rather than being re-discovered from text. Selection is dataset.renderer, which takes precedence over dataset.chat_template; the template path is untouched when unset.

Supporting plumbing: per-row tools and generation_config are forwarded to render (generation_config is opaque to the framework — whatever per-sample options a renderer needs); multimodal_inputs stay unflattened, since a renderer's tokenizer consumes structured content directly; CACHE_VERSION is folded into the tokenization cache key; train_with_decode is rejected, because appending a generation prompt is a template-path concept.

Renderers also force dynamic_loss_mask on outside offline replay, because a renderer emits its mask before the engine expands media placeholders. Stale pre-expansion masks are dropped for multimodal rows — on the fresh path and when loading an older cache — so the training-time matcher recomputes them against the engine's real token IDs. get_assistant_token_ids grows a renderer branch to supply that matcher.

No renderer is registered here; the registry ships empty. A concrete implementation follows in a separate PR.

3. feat(dataset) — load pretokenized input_ids/loss-mask datasets without re-rendering

Rendering a large corpus at training time is wasted work when the tokens are already fixed, and it makes supervision non-reproducible: a per-row mask policy chosen by an offline pipeline cannot be recovered from one global last_turn_loss_only flag.

A dataset carrying input_ids plus a loss mask (packed segment string or explicit binary) is now loaded straight through — no tokenizer, no template, no renderer, no tokenization cache. The loader validates rather than repairs: an over-length row raises instead of truncating, both mask encodings must agree position-by-position, every row needs a supervised token, and data_ids must be unique. Columns the loader does not consume are carried through to metadata untouched, so a producer's provenance bookkeeping survives without this code knowing its schema.

Behavior changes to be aware of

  • A multimodal row tokenized with defer_tokenization=False and no dynamic mask used to receive a mask rendered against unexpanded placeholders, which cannot align with what the engine feeds the model. That now fails closed with an explanation.
  • load_hf_dataset no longer strips unknown columns from a Hub repo that exposes input_ids; that filter would have dropped the token columns and silently demoted a pretokenized repo to raw text.
  • dataset.chat_template is now Optional[str] so it can be unset in favor of dataset.renderer. Its default is unchanged.

Test plan

  • tests/test_conversation_normalization.py (new, 4 tests) — field preservation, no input mutation, reasoning-alias collapsing, identity on already-normalized input.
  • tests/test_renderer_registry.py (new, 17 tests) — registry semantics and CACHE_VERSION fallback; tools/generation_config/last_turn_only forwarding; train_with_decode and unknown-renderer rejection; end-to-end ShareGPT load through a stub renderer; multimodal mask deferral and the fail-closed path; dynamic_loss_mask derivation including the offline-replay exemption; the renderer-supplied assistant matcher.
  • tests/test_pretokenized_dataset.py (new, 17 tests) — round-trip load, generic metadata passthrough, both mask encodings, every validation branch, and an end-to-end load of a real Parquet file asserting no tokenizer is loaded and no cache is written.
  • ruff check . and ruff format --check . clean.
  • Full suite compared against an unmodified upstream/main checkout in the same sandbox: identical failure set (93 pre-existing failures both before and after, zero new). Those are CUDA/mooncake/ray.dag/flash_attn sandbox limitations.
  • Re-run the full suite on a GPU box with mooncake installed before merging — this sandbox is CPU-only, so trainer and inference-engine tests could not execute.

… conversations

`_normalize_conversation` rebuilt each ShareGPT message as a fresh
`{"role", "content"}` dict plus an optional `reasoning_content`, silently
dropping every other key. Tool-calling corpora lost `tool_calls`,
`tool_call_id`, and `name`, so an assistant turn that only emitted a tool
call normalized to an empty-content message with no recoverable call, and
`tool` responses lost the id that binds them to their request.

Carry all unrecognized keys through instead, with `role`/`content` written
last so a stale duplicate in the source row cannot shadow the normalized
values. The reasoning aliases still collapse into `reasoning_content` so
callers keep a single field to read.

Existing chat templates only read `role` and `content`, so the extra keys
are inert on the template path and become available to tokenizers whose
`apply_chat_template` understands them. Also widen the `Conversation` alias
to `List[Dict[str, Any]]`, which was already inaccurate for multimodal
content lists and is now inaccurate for `tool_calls` too.

Signed-off-by: torchspec-bot <262938024+torchspec-bot@users.noreply.github.com>
…late cannot express

`ChatTemplate` describes a conversation as a flat header/content/end-of-turn
string triple. That is enough for most chat models but cannot express nested
message structures, tool-call serialization, or a checkpoint whose own
`apply_chat_template` is the only correct source of truth — and it derives the
loss mask by string matching on a header the template itself defines.

Add a `ConversationRenderer` protocol plus a `RENDERER_REGISTRY` alongside the
existing `TEMPLATE_REGISTRY`. A renderer is constructed with the target model's
tokenizer and owns both halves of the job: it returns `(input_ids, loss_mask)`
directly from the structured messages, so supervision is derived from the token
stream it just built rather than re-discovered from text. Selection is
`dataset.renderer`, which takes precedence over `dataset.chat_template`; the
template path is untouched when it is unset.

Renderer-specific plumbing this adds to the dataset path:

- Per-row `tools` and `generation_config` are read from the source dataset and
  forwarded to `render`. `generation_config` is opaque to the framework — it
  carries whatever per-sample rendering options a renderer needs (a
  reasoning-effort setting, a serving-time sampling config) and renderers that
  do not need it ignore it. Both columns are added to `load_hf_dataset`'s
  keep-list, which previously dropped them for Hub datasets, and `data_id` is
  accepted as an alias for `id`.
- `multimodal_inputs` are left unflattened for renderers, since a renderer's
  tokenizer consumes structured content directly instead of a placeholder
  string.
- `CACHE_VERSION` is folded into the tokenization cache key so a renderer whose
  semantics change cannot silently reuse a stale cache.
- `train_with_decode` is rejected: appending a generation prompt is a
  template-path concept, and a renderer emitting one would supervise tokens the
  model never produces.
- A renderer owns its supervision, so a row with zero supervised tokens is
  dropped rather than trained on, independent of `min_loss_tokens`.

Renderers also force `dynamic_loss_mask` on outside offline replay, because a
renderer emits its mask *before* the inference engine expands media
placeholders. Any multimodal row's pre-expansion mask is therefore stale and is
dropped — both on the fresh path and when loading a cache written by an earlier
run — so the training-time matcher recomputes it against the engine's real
token IDs. `get_assistant_token_ids` grows a renderer branch to supply that
matcher, and the controller now accepts `input_ids` without a
`packed_loss_mask` when dynamic masking is on.

The one behavior change on the existing template path: a multimodal row
tokenized with `defer_tokenization=False` and no dynamic mask used to be given
a mask rendered against unexpanded placeholders, which cannot align with what
the engine feeds the model. That now fails closed with an explanation instead
of training on a silently misaligned mask.

No renderer is registered here; the registry ships empty.

Signed-off-by: torchspec-bot <262938024+torchspec-bot@users.noreply.github.com>
… re-rendering

Rendering a large corpus at training time is wasted work when the tokens are
already fixed, and it makes supervision non-reproducible: a per-row mask policy
decided by an offline pipeline cannot be recovered from a single global
`last_turn_loss_only` flag, and re-rendering under a different tokenizer or
template version silently shifts the mask.

Detect a dataset that already carries `input_ids` plus a loss mask and load it
straight through — no tokenizer, no template, no renderer, and no tokenization
cache, since there is nothing to cache. Either mask encoding is accepted: a
packed segment-length string, or an explicit binary mask that gets packed on
load.

The loader validates rather than repairs, because a bad row here trains on the
wrong tokens with no downstream symptom:

- `input_ids` must be a non-empty list of ints, and `seq_len`/`loss_tokens`, if
  present, must agree with them.
- An over-length row raises instead of truncating. Truncation would drop
  supervised tokens the producer counted, so it is a corpus/config mismatch to
  fix at the source.
- A row with both mask encodings must have them agree position-by-position, not
  merely in supervised-token count.
- Every row needs at least one supervised token, and `data_id`s must be unique.

Columns the loader does not consume are carried through to `metadata`
untouched, so a producer's own provenance bookkeeping survives into training
without this code having to know its schema. Non-scalar values are skipped so a
stray array column is not pinned in memory for the dataset's lifetime.

Detection needs a typed schema, so this covers Parquet/Arrow files and Hub
repos; a JSONL file reports no columns when streamed and is still treated as
raw conversations. `load_hf_dataset` also no longer strips unknown columns from
a Hub repo that exposes `input_ids` — that filter would have dropped the token
columns and silently demoted a pretokenized repo to raw text.

Signed-off-by: torchspec-bot <262938024+torchspec-bot@users.noreply.github.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c8501fef67

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread torchspec/data/dataset.py Outdated
Comment thread torchspec/data/dataset.py Outdated
Comment on lines +335 to +336
if not renderer_name and not chat_template_name:
raise ValueError("Either renderer or chat_template must be set for dataset tokenization")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bypass template requirements for pretokenized datasets

This validation runs before checking whether the dataset already has input_ids plus a loss mask, so a Parquet/Arrow pretokenized corpus fails when the config explicitly sets dataset.chat_template=null and no renderer, even though the pretokenized branch below never renders and does not need either setting. Load the schema and short-circuit pretokenized datasets before enforcing raw-conversation renderer/template requirements.

Useful? React with 👍 / 👎.

… masks

With the default `last_turn_loss_only: "auto"`, only the deferred-formatting
worker recorded its per-sample decision. `_tokenize_single` — the path a
renderer takes — resolved the decision, baked it into the mask, and discarded
it. A multimodal row's mask is then dropped as stale, so the controller had no
`has_thinking` to forward and the training fetcher fell back to the global
`"auto"` string, which `compute_assistant_loss_mask` reads as truthy. Every
non-thinking multi-turn multimodal sample was silently trained on its final
assistant turn alone.

Record the resolved decision as `has_thinking` metadata whenever the flag is
`"auto"`, on both the renderer and the template branch, so it survives the
mask drop and reaches the training-time matcher. `_format_single` now shares
the same helper rather than repeating the logic.

Also stop `resolve_loss_mask` from reading an unresolved `"auto"` sentinel as
last-turn-only: it is a per-sample question that token IDs cannot answer, so
supervise every assistant turn instead. And fail closed when a cache written
before this change would lose a multimodal mask with no recorded decision —
the source messages are gone, so it cannot be recomputed.

Signed-off-by: torchspec-bot <262938024+torchspec-bot@users.noreply.github.com>
…corpora

The "either renderer or chat_template must be set" check ran before the
pretokenized branch, so a Parquet/Arrow corpus carrying input_ids plus a loss
mask failed whenever the config explicitly set `dataset.chat_template=null`
with no renderer — even though that branch never renders and needs neither
setting.

Load the schema and short-circuit pretokenized detection before enforcing it.
The two checks that remain above the load are decidable from the config alone
and stay there: `renderer` with `defer_tokenization=True` is a contradiction,
and a renderer name absent from the registry is wrong whatever the corpus
turns out to be.

`test_dataset_loading_requires_a_renderer_or_a_chat_template` now writes the
raw JSONL file it points at, since the requirement it asserts can no longer be
decided before the schema is known.

Signed-off-by: torchspec-bot <262938024+torchspec-bot@users.noreply.github.com>
@torchspec-bot
torchspec-bot merged commit 2ab0ada into main Aug 9, 2026
2 checks passed
@torchspec-bot
torchspec-bot deleted the export/dataset-renderers branch August 9, 2026 12:53

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 601a44cffd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread torchspec/data/dataset.py
Comment on lines +365 to +367
if renderer_name and renderer_name not in RENDERER_REGISTRY.get_all_renderer_names():
available = ", ".join(RENDERER_REGISTRY.get_all_renderer_names()) or "<none>"
raise ValueError(f"Unknown dataset renderer {renderer_name!r}; available: {available}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer renderer validation until after pretokenized detection

When train_data_path points at a pretokenized Parquet/Arrow/Hub dataset but the config still has dataset.renderer set to a renderer that is not registered in this process, this check raises before the schema is inspected. The remaining pre-schema check is the renderer registry lookup; the pretokenized branch below never calls a renderer, so reusing a renderer-based config for offline-tokenized data is blocked even though input_ids plus a loss mask are already present. Move the unknown-renderer validation until after _is_pretokenized_dataset short-circuits, or skip it for pretokenized inputs.

Useful? React with 👍 / 👎.

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