Skip to content

Fix preference max length enforcement across TRL wrappers - #1196

Open
ousamabenyounes wants to merge 8 commits into
MakazhanAlpamys:mainfrom
ousamabenyounes:fix/issue-1185
Open

ousamabenyounes wants to merge 8 commits into
MakazhanAlpamys:mainfrom
ousamabenyounes:fix/issue-1185

Conversation

@ousamabenyounes

@ousamabenyounes ousamabenyounes commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • restore post-tokenization data.max_length enforcement for SimPO and KTO when TRL no longer accepts max_prompt_length
  • extend the shared TRL compatibility helper to cover IPO, BCO, and KTO prepared dataset layouts
  • rebuild KTO/BCO completion and KTO KL rows from untruncated answer_input_ids when TRL already sliced the completion away
  • cut BCO, IPO, KTO, and SimPO prompts only when the prepared row exceeds max_length; DPO/ORPO stay unchanged for Preference prompt cap: should DPO and ORPO cut the prompt only when the row does not fit? #1209
  • expand preference trainer contract tests to pin train/eval rows that fit, EOS preservation, prompt-tail retention, KTO KL answer rotation, and the KL fallback cap path
  • document preference truncation behavior and update the changelog fragment

Closes #1185.

Test verification (RED -> GREEN)

  • RED: the reviewer's batch-4 KL and long-completion tests against 8874d7c's production code: python -m pytest tests/test_trl_preference_config_contract.py -o addopts="" -q -> 1 failed (test_kto_kl_rows_borrow_the_predecessor_answer_at_batch_four[overlong]: KL rows borrowed the successor's answer, trl uses the predecessor), 43 passed.
  • GREEN on 4c9e8d02527466c62cf2c14842cc6e71d49a4ae5: same command -> 44 passed; dropping the - 1 in answer_limit fails test_an_overlong_prompt_with_a_long_completion_fits_and_ends_in_eos[bco|kto].
  • Local CI replay on 4c9e8d0: ruff clean on the CI paths, 15 related test files 519 passed, Python diff coverage 100% (87/87 changed production lines); 569 passed on a local merge with current main.
  • Remote checks on 4c9e8d0: lint, plan, gate, and test (ubuntu-latest, 3.12) succeeded; the remaining jobs were skipped by workflow conditions.

@MakazhanAlpamys MakazhanAlpamys left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Review of 244b43fe.

Thanks for picking up #1185, and for covering all four uncapped wrappers instead of only the two I measured. I ran everything below on your head merged onto current main, CPU-only unless stated.

What holds, verified by running it

  • Your RED/GREEN evidence reproduces exactly. On clean main your new effective-batch test fails for bco, ipo, kto and simpo (4 failed, 2 passed): bco completion_input_ids (1, 163), ipo input_ids (2, 164), kto completion_input_ids (2, 163), simpo chosen_input_ids (1, 165). On your branch the contract file passes 21/21, and your five-file command passes 163.
  • Wider run: 842 passed, 14 skipped over 20 files that drive these wrappers. With the GPU visible, test_v07204.py, test_issue1049_untied_streamed_preference.py and the contract file pass 124/124, so the streamed simpo/kto path is fine.
  • Each call site is pinned. Disabling the new block in simpo.py:227, kto.py:219, ipo.py:187 and bco.py:239 fails exactly one case, the one named for that wrapper. That meets the acceptance criterion in the issue. Leaving KL_prompt_* or KL_completion_* uncapped fails the kto case too.
  • No test was weakened. The ORPO helper extraction preserves behaviour: ORPO rows still carry good answer </s>.

Blocking

  1. The fragment fails the repository check, which will turn all nine test cells red. tests/test_issue487_changelog_fragments.py::test_repository_fragments_match_the_newest_release fails on your branch (1 failed, 20 passed; 21 passed on main). The validator wants the text to start with - , and once that is fixed it also wants the text to reference the file's number. Fragments are also named after the PR. Please run git mv changelog.d/0.75.1/1185.fixed.md changelog.d/0.75.1/1196.fixed.md, start the text with - , and end it with (#1185 by @ousamabenyounes in #1196).

  2. Every over-long KTO and BCO row trains on </s> alone. The length is now bounded, but the completion is already gone. I decoded the prepared datasets from your own fixture (161-word prompt, completion good answer) after setup(). In all 8 KTO rows and all 16 BCO rows, the only label that is not -100 is EOS, and the KL completion looks the same. DPO, IPO, ORPO and SimPO keep good answer </s>. The cause is upstream of your helper: trl slices the answer with a negative bound before the helper sees the row. That is answer[:max_length - prompt_length] in trl/experimental/kto/kto_trainer.py:205-208, and the same slice in trl/experimental/bco/bco_trainer.py:288-290. Both files are byte-identical in trl 0.29.0 and 0.29.1, so this covers the whole supported range. On main those rows are 163 tokens and have no completion. This PR fixes the length but not the missing completion. The prompt-longer-than-max_length case is the acceptance scenario in #1185, and the project's rule is to refuse or report, never to drop content silently (RAFT drops such rows with a warning, docs/training.md:1523). A fix is available. The prepared dataset still holds the untruncated answer in answer_input_ids (I checked: ['good', 'answer'], ['bad'], and all 80 ids for an 80-token answer) and the untruncated prompt in prompt_input_ids. So cap_unpaired can rebuild completion_* from those two columns (capped prompt, then answer up to the remaining budget, then EOS the way trl appends it, with -100 over the prompt) instead of re-slicing trl's completion_*. KL_* has no answer column to rebuild from, so either rebuild it the same way or raise an error that names the row count and max_length.

  3. The test cannot see the content of a row, and that is why item 2 passes. It asserts only shape[-1] <= 64 and the row count. Each of these mutations still passes all 21 tests:

    • completion_limit = 0 at _trl_compat.py:171, which deletes every completion;
    • completion_limit = 10**9, which never caps a completion;
    • leaving the prompt tokens trainable in *_labels at :178;
    • flipping the prompt/completion boundary test at :162;
    • passing max_prompt_length = max_length in kto.py.

    The 10**9 mutation survives because every fixture completion is one or two words, so the completion cap on the new unpaired path never runs. Please assert per prepared row that the tokens whose label is not -100 equal the expected truncated completion and include at least one non-EOS token, and add a long-completion row for each layout. One trap when you do: key.endswith("input_ids") (test file line 256) also matches answer_input_ids. That is an unused trl column that stays untruncated. With a long completion, a correct KTO row has completion_input_ids (2, 64) but answer_input_ids (2, 80), so the assertion as written would fail it. Limit the check to the model inputs.

  4. KTO truncates the prompt at the wrong end. kto.py:223 passes DEFAULT_PROMPT_TRUNCATION_MODE = "keep_start", which drops the end of the prompt. When I checked, the prompt's last word was gone. With a chat template, the end of the prompt is the generation header (<|im_start|>assistant\n), so the completion gets trained after a cut-off user turn. trl 0.29's KTOConfig has no truncation_mode field. Its default before the removal was keep_end (trl 0.26.2 experimental/kto/kto_config.py:195-196), and BCOConfig, CPOConfig and ORPOConfig still default to keep_end in 0.29.1. As it stands, KTO and BCO share a layout but cut opposite ends. Please use keep_end for KTO and add a test that pins the direction. Flipping it today leaves all 21 tests passing.

A decision I owed you, made: cap only when the row does not fit. As written, the cap is unconditional, so any prompt longer than max_length // 2 gets cut even when the whole row fits. On a 45-of-64-token row, main keeps the 42-token prompt and this branch cuts it to 32, for all four wrappers. You followed the issue, which said to mirror DPO and ORPO, and those two are unconditional on main; that wording was mine. But trl itself cut KTO, BCO and CPO prompts only when prompt plus response overran max_length, and cutting a row that fits throws away training data for nothing. So for the four wrappers here, please apply the cap only when the row exceeds max_length, and add one case with a row that fits and must reach the trainer unchanged. Whether DPO and ORPO should become conditional too is a separate question, and I will open an issue for it rather than widen this PR.

Non-blocking

  • A related problem is already on main, and this PR doesn't touch it; I'll file it separately. When chosen is longer than max_length, trl's CPO slice answer[: max_length - longer_response_length] (cpo_trainer.py:513-515) leaves a short rejected with no tokens. SimPO's length-normalised log-prob then becomes 0/0. After two steps all 8 LoRA tensors were NaN on both main and this branch, and the logged loss read 0.0, because transformers filters NaN out of the log by default (with the filter off, the losses read [nan, nan]). Both control fixtures trained normally. If your fix for item 2 moves truncation ahead of trl's tokenizer, this may come along for free. It doesn't have to.
  • IPO inherits DPO's keep_start prompt cap. That matches DPO on main, so any direction change belongs in a DPO follow-up, not here.
  • Docs: no page says how the preference tasks truncate prompts. One line in the preference section of docs/training.md would cover all six tasks: the cap is max_length // 2, name which end is kept, and the completion gets the rest.

Nits

  • DEFAULT_PROMPT_TRUNCATION_MODE reads like a module-wide default, but only KTO uses it. Name it for KTO, or inline it once item 4 is done.
  • The blank line after if orpo_columns <= columns: was dropped; the other branches keep theirs.
  • A one-line docstring on _cap_combined_preference_sequence would help: it treats the first label that is not -100 as the prompt/completion boundary, and the boundary mutation above shows that line matters.

@MakazhanAlpamys

Copy link
Copy Markdown
Owner

As promised in my review above: the SimPO NaN on main is #1208, and the DPO/ORPO conditional-cap question is #1209. Neither blocks this PR.

@MakazhanAlpamys

Copy link
Copy Markdown
Owner

@ousamabenyounes, following up on my 09-24 review of 244b43fe. Four things are still open:

  • the changelog fragment fails the repo's validator — git mv changelog.d/0.75.1/1185.fixed.md changelog.d/0.75.1/1196.fixed.md, start the line with "- ", end with "(SimPO and KTO rows run past max_length on trl 0.29: the two wrappers never call enforce_preference_sequence_limit #1185 by @ousamabenyounes in Fix preference max length enforcement across TRL wrappers #1196)";
  • every over-length KTO/BCO row is capped to length, but the completion is already gone — trl slices it upstream of your helper, so completion_* needs rebuilding from the still-present answer_input_ids/prompt_input_ids in _trl_compat.py's cap_unpaired, not re-derived from trl's own truncated output;
  • the tests only check row shape, not content, which is why the gap above passes 21/21 today — add a per-row assertion that the unmasked tokens equal the expected truncated completion (including at least one non-EOS token) plus a long-completion fixture row, and keep the check off answer_input_ids (an unused trl column the generic *input_ids check would otherwise catch);
  • KTO truncates the prompt from the wrong end (keep_start instead of trl's own historical keep_end for KTO/BCO/CPO/ORPO), which can cut off a chat template's generation header — flip DEFAULT_PROMPT_TRUNCATION_MODE for KTO and pin the direction with a test.

The one open design question is already answered on my side: cap only when a row overflows max_length, not unconditionally (I'll raise the DPO/ORPO version of that question separately rather than widen this PR). #1208 (SimPO NaN) and #1209 (the DPO/ORPO conditional-cap question) are filed and don't block this.

Through 2026-10-01 it stays yours, and a short "still on it" keeps it with nothing else owed. If there's no word by then, I'll close this without prejudice and mark #1185 as available again, so the next person can see it is free. That's not a judgement on the work already here — the RED/GREEN evidence and covering all four wrappers instead of two were both right.

ousamabenyounes added a commit to ousamabenyounes/Soup that referenced this pull request Sep 27, 2026
Addresses the review on MakazhanAlpamys#1196:

- cap_unpaired rebuilds completion_* from the untruncated answer_input_ids /
  prompt_input_ids. TRL's KTO/BCO tokenizer slices the answer to fit max_length
  before Soup's cap runs, so an over-length prompt left completion_input_ids with
  only the trailing EOS; re-deriving from TRL's own truncated column could never
  recover the answer.
- DEFAULT_PROMPT_TRUNCATION_MODE switches to keep_end. KTOConfig has no
  truncation_mode field, so KTO caps its prompt through this default; keep_end
  matches TRL's historical KTO/BCO/CPO/ORPO direction and preserves a chat
  template's generation header at the prompt tail.
- Tests: per-row assertion that the unmasked completion tokens equal the
  truncated answer (including a non-EOS token), a long-completion fixture, and a
  keep_end direction pin. The content check reads completion_labels, never the
  unused answer_input_ids column.
- Rename the changelog fragment to 1196.fixed.md and give it the required list
  item and attribution.
@ousamabenyounes

Copy link
Copy Markdown
Contributor Author

Thanks — all four are addressed in b1ca232.

  • Changelog: renamed to 1196.fixed.md, reformatted as - … (#1185 by @ousamabenyounes in #1196); the validator passes.
  • The real bug: cap_unpaired now rebuilds completion_* from the still-present answer_input_ids/prompt_input_ids instead of re-deriving it from trl's already-truncated output. RED first: on the old helper the new content assertion fails with "kto row 0: the capped completion is EOS-only … trains on nothing" — exactly the gap you described.
  • Tests: per-row assertion that the unmasked tokens equal the expected truncated completion (with at least one non-EOS token), plus a long-completion fixture row; the check stays off answer_input_ids. 21 → 24 passing.
  • KTO truncation side: DEFAULT_PROMPT_TRUNCATION_MODE flipped to keep_end for KTO, pinned by a test.

Conditional capping kept as you settled it — only when a row overflows max_length. #1208 and #1209 left to you.

@MakazhanAlpamys MakazhanAlpamys left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

@ousamabenyounes, thanks for turning this around so quickly. Review of b1ca232e. I ran everything below on it merged onto current main (d7a4f7a7), CPU only, with trl 0.29.1.

What this push fixed, verified by running it

  • The KTO/BCO completion is back. On an over-long prompt (160 × hello + world, completion good answer), main has 8 of 8 KTO rows and 16 of 16 BCO rows whose only trainable token is </s>. On your branch that is 0 of 8 and 0 of 16, and the trainable tokens decode to good answer </s>.
  • The new content test has real teeth. The five changes that passed all 21 tests last time now fail it: deleting every completion, never capping the completion, leaving the prompt trainable, inverting the prompt/completion boundary, and max_prompt_length = max_length in kto.py. It also catches disabling your rebuild (your RED), unmasking the prompt inside the rebuilt row, and dropping the answer. Across the 19 changes I made to the code, 16 fail your contract file.
  • KTO keeps the end of the prompt now, and setting DEFAULT_PROMPT_TRUNCATION_MODE back to keep_start fails your new unit test.
  • The fragment passes the validator. The contract file plus the fragment tests pass 45 of 45. A wider run passes 756 with 14 skipped; it covers 23 related files, including the six preference trainers, the dispatcher, the streaming suite and the recent GRPO tests. ruff is clean. On main, your contract file fails 7 of 24, so every new test is red without the fix.
  • DPO and ORPO come out identical to main on all three fixtures I used, which is right while #1209 is still open.

Still blocking

  1. The cap is still unconditional. Your reply says conditional capping is in place, but a row that fits is still cut. The fixture: a 41-word prompt ending in world plus a 2-word completion, 45 of 64 tokens. main hands KTO, BCO and SimPO a 42-token prompt and IPO a 41-token one; this branch cuts all four to 32. IPO loses world, and the end of the prompt is where a chat template puts the assistant header. KTO, BCO and SimPO lose the leading <s>. The condition in _truncate_tokens (_trl_compat.py:155) asks whether the prompt is longer than max_length // 2. The review asked whether the row is longer than max_length, plus one case with a row that fits.

    One constraint on the fix: IPO shares DPO's prompt_ids layout, and all six wrappers call the same helper. Please make "only when the row overflows" an argument that the four call sites pass (simpo.py:227, kto.py:219, ipo.py:187, bco.py:239). Leave dpo.py and orpo.py exactly as they are, so that this PR does not decide #1209 as a side effect.

  2. KTO/BCO rows that trl had already fit now lose their EOS (new in this push). Take a short prompt with a 100-token completion. trl truncates the answer and then appends EOS (trl/experimental/kto/kto_trainer.py:202-208, then :232-238), so on main the row is 64 tokens ending in </s>. On this branch, _rebuild_unpaired_completion_row rebuilds the full prompt, the full answer and the EOS, and the cap (_trl_compat.py:183-186) then slices the EOS off. The row is 64 tokens ending in answer, and that happens to every long-completion KTO and BCO row. That contradicts the helper's own docstring ("never drops the stop signal"). KTO's KL rows still end in </s>, so the matched and KL completions now disagree. Rebuilding only when the original row overflows fixes this together with item 1. When you do rebuild, truncate the answer to the budget minus one and then append the EOS, as trl does.

  3. KTO's KL rows are still EOS-only when the prompt overflows. This is the second half of item 2 in the 09-24 review ("KL_* has no answer column to rebuild from, so either rebuild it the same way or raise an error that names the row count and max_length"). My 09-27 summary left it out, so that part is on me.

    • With _long_kto_rows, 8 of 8 KL_completion rows train on </s> only: 33 tokens, one trainable.
    • _mixed_kto_rows hides this. Each long prompt's KL row borrows its neighbour's 100-token completion, and trl's negative slice leaves 1 of those 100 tokens, so a non-EOS check passes there.
    • Fix path: trl builds the KL rows by rotating answer_input_ids by one inside each per_device_train_batch_size chunk (kto_trainer.py:85-93, :597-603), and answer_input_ids survives untruncated. So you can rebuild the KL completion the same way you rebuilt the matched one. Pin it with a test showing that your reconstruction equals trl's own KL rows exactly on a fixture that fits.
    • Or raise an error that names the row count and max_length.

Tests that pin all three. These are ready to paste at the end of tests/test_trl_preference_config_contract.py; they use only that file's own helpers.

#: 41 words: longer than max_length // 2, yet prompt + completion is 45 of 64 tokens.
_FITTING_PROMPT = " ".join(["hi"] * 40 + ["world"])
#: An over-long prompt whose END is visible, so the kept side of the cut can be asserted.
_OVERLONG_PROMPT_WITH_TAIL = " ".join(["hello"] * (_OVERLONG_PROMPT_WORDS - 1) + ["world"])


def _setup_rows(tmp_path, monkeypatch, task, rows):
    import importlib

    module, cls_name, _ = _WRAPPERS[task]
    weights = _tiny_llama_dir(tmp_path)
    _write_tiny_tokenizer(weights)
    monkeypatch.chdir(tmp_path)
    cfg = _cfg(weights, tmp_path / "out", task)
    wrapper = getattr(importlib.import_module(module), cls_name)(cfg, device="cpu")
    wrapper.setup({"train": rows})
    return wrapper


def _rows_with(task, prompt, completion, n=8):
    if task == "kto":
        return [
            {"prompt": prompt, "completion": completion, "label": i % 2 == 0} for i in range(n)
        ]
    return [{"prompt": prompt, "chosen": completion, "rejected": " bad"} for _ in range(n)]


class TestTheCapTouchesOnlyRowsThatOverflow:
    @pytest.mark.parametrize("task", ("bco", "ipo", "kto", "simpo"))
    def test_a_row_that_fits_reaches_the_trainer_uncut(self, tmp_path, monkeypatch, task):
        rows = _rows_with(task, _FITTING_PROMPT, " good answer")
        wrapper = _setup_rows(tmp_path, monkeypatch, task, rows)
        row = wrapper.trainer.train_dataset[0]
        prompt = list(row["prompt_ids"] if "prompt_ids" in row else row["prompt_input_ids"])
        words = wrapper.tokenizer.convert_ids_to_tokens(prompt)
        assert len(prompt) > _MAX_PROMPT_LENGTH, (task, len(prompt), words[:2], words[-2:])
        assert words[-1] == "world", (task, words[-3:])

    @pytest.mark.parametrize("task", ("bco", "kto"))
    def test_a_row_trl_already_fit_keeps_its_eos(self, tmp_path, monkeypatch, task):
        rows = _rows_with(task, "hi", _LONG_COMPLETION)
        wrapper = _setup_rows(tmp_path, monkeypatch, task, rows)
        eos_id = wrapper.tokenizer.eos_token_id
        for index, row in enumerate(wrapper.trainer.train_dataset):
            if task == "bco" and len(row["answer_input_ids"]) < 10:
                continue  # BCO's rejected half (" bad") is not the long row
            ids = list(row["completion_input_ids"])
            assert len(ids) <= _MAX_SEQUENCE_LENGTH, (task, index, len(ids))
            assert ids[-1] == eos_id, (task, index, "EOS dropped from a row trl already fit")

    @pytest.mark.parametrize("task", ("bco", "kto"))
    def test_an_overlong_prompt_keeps_its_end_and_the_completion_its_eos(
        self, tmp_path, monkeypatch, task
    ):
        rows = _rows_with(task, _OVERLONG_PROMPT_WITH_TAIL, " good answer")
        wrapper = _setup_rows(tmp_path, monkeypatch, task, rows)
        tok = wrapper.tokenizer
        for index, row in enumerate(wrapper.trainer.train_dataset):
            ids = list(row["completion_input_ids"])
            labels = list(row["completion_labels"])
            assert len(ids) <= _MAX_SEQUENCE_LENGTH, (task, index, len(ids))
            masked = [t for t, lab in zip(ids, labels) if lab == _IGNORE_LABEL]
            assert tok.convert_ids_to_tokens(masked[-1:]) == ["world"], (task, index)
            assert ids[-1] == tok.eos_token_id, (task, index, "the completion lost its EOS")

    def test_the_kto_kl_completion_keeps_its_answer(self, tmp_path, monkeypatch):
        wrapper = _setup_rows(tmp_path, monkeypatch, "kto", _long_kto_rows(8))
        eos_id = wrapper.tokenizer.eos_token_id
        for index, row in enumerate(wrapper.trainer.train_dataset):
            ids = list(row["KL_completion_input_ids"])
            labels = list(row["KL_completion_labels"])
            assert len(ids) <= _MAX_SEQUENCE_LENGTH, (index, len(ids))
            non_eos = [t for t, lab in zip(ids, labels) if lab != _IGNORE_LABEL and t != eos_id]
            assert non_eos, f"kto row {index}: the KL completion is EOS-only"

I ran this exact block appended to your file, and it is ruff-clean.

  • On this branch, 7 of the 9 new cases fail (4 fits, 2 EOS, 1 KL) and 26 tests pass.
  • On main, the 4 fits and 2 EOS cases pass, because they pin what main already does; the rest fail.
  • A fix for items 1-3 should pass all 33.

The third test also catches two changes your file currently lets through. One is kto.py:223 passing "keep_start" literally instead of the constant. The other is the rebuild never appending the EOS. Each of those leaves all 24 contract tests green, and 228 tests across six files.

Non-blocking

  • keep_end now drops the leading <s> from every capped KTO row. BCO and SimPO on this branch, and ORPO on main, do the same. trl adds BOS after its own truncation (kto_trainer.py:218-230), so its cut never removed it. Keeping a leading BOS and taking the tail of the rest would match trl. Since ORPO behaves this way already, this can be a follow-up.
  • Docs, still open from 09-24: one line in the preference section of docs/training.md saying when the prompt is cut and which end is kept.
  • Nits:
    • The blank line after if orpo_columns <= columns: (_trl_compat.py:279) is still missing.
    • _cap_combined_preference_sequence (:162) still has no docstring.
    • The fragment has no final period, while most fragments in changelog.d/0.75.1/ end with one. Once item 1 lands, it can also say that prompts are cut only when a row exceeds max_length.

Your update keeps #1185 with you, as the 09-27 note said. CI on b1ca232e has all 13 required checks queued, and none has started yet.

@MakazhanAlpamys MakazhanAlpamys left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

@ousamabenyounes, a status check on b1ca232e, and a first answer to a question the red board will have raised: the CI failures on this PR are not yours.

  • Every one of the 16 jobs on this head is cancelled, none is failed. Each annotation reads "The run was canceled by @MakazhanAlpamys", at 2026-09-28 07:56 UTC, and no job ever got a runner (no steps, no log). That was a clean-up of queued runs whose head already carried a changes-requested review, to drain a CI backlog; it says nothing about your code.
  • The only real CI failure this PR ever had was on the previous head: the fragment check (fragment must start with a Markdown list item). You fixed it in b1ca232e.
  • A re-run of the cancelled jobs would reuse a merge ref frozen on 09-27, well over a hundred commits behind main, so the next push is what will give you a meaningful run.

I also re-ran your head myself, merged with current main (89dc498a, clean merge), CPU only, trl 0.29.1:

  • Your contract file plus the fragment tests: 45 passed. 43 test files that import the six wrappers, _trl_compat, the dispatch, layer streaming and the MoE/seed/kwarg-drift scans: 1764 passed, 26 skipped, 0 failed. ruff is clean.
  • The claim in #1185 reproduces on unmodified main: with max_length: 64 and your 161-token prompt, the real batch is [1, 165] for SimPO, [2, 163] for KTO, [2, 164] for IPO and [1, 163] for BCO. Your contract file fails 7 of 24 on main and passes 24 of 24 here.
  • Seven mutations of your fix each fail your file: the rebuild branch off (2 failed), any one of the four call sites off (1, 2, 1 and 2), keep_start for KTO (1), and completion_limit = 0 (2). The tests do pin what they claim to pin.

The three items from my 09-28 review are still open, and I checked each by running it. I appended the nine cases from that review to your file on this head: 7 fail, 26 pass, the same as on the 28th.

  1. The cap is unconditional: a row that fits (41-word prompt, 45 of 64 tokens) is still cut to a 32-token prompt for all four wrappers.
  2. KTO and BCO rows trl had already fit lose their trailing </s> in the rebuild.
  3. KTO's KL completions are still </s> only when the prompt overflows.

I went one step further than last time so that the next round is short. I wrote a patch for all three and ran it: only_when_overflow passed from the four call sites (dpo.py and orpo.py untouched, so #1209 stays open), a rebuild that cuts the answer to max_length - len(prompt) - 1 and appends the EOS after the cut, and a KL rebuild that follows trl's rotation of answer_input_ids inside each per_device_train_batch_size chunk. Results on this head merged with main (d2b0d153; the diff also applies cleanly on 89dc498a):

  • your contract file with the nine cases from that review and two more of mine (KL rows equal trl's own rotated rows exactly on rows that fit, and an over-long row's KL completion is its prompt plus the neighbour's answer): 35 passed;
  • the same 43 wrapper files: 1775 passed, 26 skipped, 0 failed; ruff clean;
  • six mutations of the patch, each caught: the gate off (2 failed), the EOS not appended (4), the rotation replaced by the identity (1), the SimPO call site (1), kl_chunk_size dropped in kto.py (1), keep_start (2).

The diff is attached below. It is a proposal, not something to paste blindly: git apply takes it on your head, and you are welcome to restructure it. I have not run the GPU or streamed paths, only CPU.

Still small, for the same push: one line in the preference section of docs/training.md saying when the prompt is cut and which end is kept; the fragment can say prompts are cut only when a row exceeds max_length, and end with a period; and the two nits from last time (the blank line after if orpo_columns <= columns:, a docstring on _cap_combined_preference_sequence).

This stays yours through 2026-10-01, as noted on the 27th; a short "still on it" keeps it with nothing else owed.

Patch (applies to this head merged with current main)
diff --git a/src/soup_cli/trainer/_trl_compat.py b/src/soup_cli/trainer/_trl_compat.py
index cddeb9819..d0460132d 100644
--- a/src/soup_cli/trainer/_trl_compat.py
+++ b/src/soup_cli/trainer/_trl_compat.py
@@ -191,44 +191,61 @@ def _cap_combined_preference_sequence(
     }
 
 
-def _rebuild_unpaired_completion_row(row: dict[str, Any]) -> dict[str, list[int]]:
-    """Reassemble the KTO/BCO completion from the untruncated answer tokens.
-
-    TRL's KTO/BCO tokenizer caps the answer to ``max_length - len(prompt)``
-    while ignoring ``max_prompt_length`` (``_process_tokens`` truncates
-    ``answer_input_ids`` before concatenating). An over-length prompt therefore
-    fills the whole budget and leaves ``completion_input_ids`` with the answer
-    already sliced away, so capping that column can only recover an empty
-    completion. The matched answer survives verbatim in ``answer_input_ids`` /
-    ``answer_attention_mask`` (columns the loss never reads) and the prompt in
-    ``prompt_input_ids``, so rebuild the combined sequence from those before the
-    real cap runs. TRL guarantees ``completion_input_ids`` ends with EOS; reuse
-    that token so a prompt-driven cap never drops the stop signal.
+def _rebuild_unpaired_sequence(
+    prompt_ids: list[int],
+    prompt_mask: list[int],
+    answer_ids: list[int],
+    answer_mask: list[int],
+    eos_id: int,
+    *,
+    max_length: int,
+    max_prompt_length: int,
+    truncation_mode: str,
+) -> dict[str, list[int]]:
+    """One KTO/BCO sequence rebuilt from the untruncated prompt and answer.
+
+    TRL slices the answer with a negative bound once the prompt alone reaches
+    ``max_length`` (``answer[:max_length - len(prompt)]``), so the ``*completion_*``
+    columns of an over-long row hold an EOS and nothing else. The prompt and the
+    answer survive untruncated in ``prompt_input_ids`` / ``answer_input_ids``, so
+    cut the prompt to ``max_prompt_length``, give the answer what is left of
+    ``max_length`` minus one, and append the EOS AFTER that cut, as TRL does.
     """
-    prompt_ids = list(row["prompt_input_ids"])
-    prompt_mask = list(row["prompt_attention_mask"])
-    answer_ids = list(row["answer_input_ids"])
-    answer_mask = list(row["answer_attention_mask"])
-    eos_id = list(row["completion_input_ids"])[-1]
+    prompt_ids = _truncate_tokens(list(prompt_ids), max_prompt_length, truncation_mode)
+    prompt_mask = _truncate_tokens(list(prompt_mask), max_prompt_length, truncation_mode)
+    answer_ids = list(answer_ids)
+    answer_mask = list(answer_mask)
     if answer_ids and answer_ids[-1] == eos_id:
-        eos_ids: list[int] = []
-        eos_mask: list[int] = []
-    else:
-        eos_ids = [eos_id]
-        eos_mask = [1]  # EOS is a real, attended token
+        answer_ids, answer_mask = answer_ids[:-1], answer_mask[:-1]
+    budget = max(0, max_length - len(prompt_ids) - 1)
+    body, body_mask = answer_ids[:budget], answer_mask[:budget]
     return {
-        "completion_input_ids": prompt_ids + answer_ids + eos_ids,
-        "completion_attention_mask": prompt_mask + answer_mask + eos_mask,
-        "completion_labels": [_IGNORE_INDEX] * len(prompt_ids) + answer_ids + eos_ids,
+        "input_ids": prompt_ids + body + [eos_id],
+        "attention_mask": prompt_mask + body_mask + [1],
+        "labels": [_IGNORE_INDEX] * len(prompt_ids) + body + [eos_id],
     }
 
 
+def _rotated_answer_index(index: int, size: int, chunk: int) -> int:
+    """Row whose answer TRL's ``_get_kl_dataset`` lends to row ``index``.
+
+    TRL rotates ``answer_input_ids`` by one inside each ``per_device_train_batch_size``
+    chunk of the dataset (the last chunk may be shorter), so a row borrows its
+    predecessor's answer and the first row of a chunk borrows the last one's.
+    """
+    start = (index // chunk) * chunk
+    end = min(start + chunk, size)
+    return index - 1 if index > start else end - 1
+
+
 def enforce_preference_sequence_limit(
     dataset: Any,
     *,
     max_length: int,
     max_prompt_length: int,
     truncation_mode: str,
+    only_when_overflow: bool = False,
+    kl_chunk_size: int | None = None,
 ) -> Any:
     """Restore TRL's removed prompt cap on an already-tokenized dataset.
 
@@ -240,6 +257,13 @@ def enforce_preference_sequence_limit(
     keeps text and conversational inputs on the exact same chat-template path
     while guaranteeing that the tensors reaching the model obey
     ``data.max_length``.
+
+    ``only_when_overflow`` leaves every row whose TRL-built sequences already
+    fit in ``max_length`` untouched (TRL itself cut KTO/BCO/CPO prompts only
+    when a row overran), so a prompt longer than ``max_prompt_length`` is cut
+    only when the row does not fit. DPO and ORPO keep the unconditional cap.
+    ``kl_chunk_size`` is the per-device train batch size TRL rotated KTO's KL
+    answers within; it is needed to rebuild an over-long KL row.
     """
     columns = set(getattr(dataset, "column_names", ()))
     dpo_columns = {"prompt_ids", "chosen_ids", "rejected_ids"}
@@ -264,6 +288,10 @@ def enforce_preference_sequence_limit(
     if dpo_columns <= columns:
 
         def cap_dpo(row: dict[str, Any]) -> dict[str, Any]:
+            if only_when_overflow and len(row["prompt_ids"]) + max(
+                len(row["chosen_ids"]), len(row["rejected_ids"])
+            ) <= max_length:
+                return {}
             prompt = _truncate_tokens(
                 list(row["prompt_ids"]), max_prompt_length, truncation_mode
             )
@@ -277,7 +305,13 @@ def enforce_preference_sequence_limit(
         return dataset.map(cap_dpo)
 
     if orpo_columns <= columns:
+
         def cap_orpo(row: dict[str, Any]) -> dict[str, Any]:
+            if only_when_overflow and (
+                len(row["chosen_input_ids"]) <= max_length
+                and len(row["rejected_input_ids"]) <= max_length
+            ):
+                return {}
             prompt_ids = _truncate_tokens(
                 list(row["prompt_input_ids"]), max_prompt_length, truncation_mode
             )
@@ -306,61 +340,94 @@ def enforce_preference_sequence_limit(
         return dataset.map(cap_orpo)
 
     if unpaired_columns <= columns:
-
-        def cap_unpaired(row: dict[str, Any]) -> dict[str, Any]:
+        has_answer = {"answer_input_ids", "answer_attention_mask"} <= columns
+        has_kl = {
+            "KL_prompt_input_ids",
+            "KL_prompt_attention_mask",
+            "KL_completion_input_ids",
+            "KL_completion_attention_mask",
+            "KL_completion_labels",
+        } <= columns
+        answers = list(dataset["answer_input_ids"]) if has_answer and has_kl else []
+        answer_masks = list(dataset["answer_attention_mask"]) if answers else []
+        chunk = max(1, int(kl_chunk_size or 1))
+
+        def cap_unpaired(row: dict[str, Any], index: int) -> dict[str, Any]:
+            fits = len(row["completion_input_ids"]) <= max_length and (
+                not has_kl or len(row["KL_completion_input_ids"]) <= max_length
+            )
+            if only_when_overflow and fits:
+                return {}
+            eos_id = list(row["completion_input_ids"])[-1]
             prompt_ids = _truncate_tokens(
                 list(row["prompt_input_ids"]), max_prompt_length, truncation_mode
             )
             prompt_mask = _truncate_tokens(
                 list(row["prompt_attention_mask"]), max_prompt_length, truncation_mode
             )
-            # TRL already sliced the answer out of ``completion_input_ids`` when
-            # the prompt overflowed; rebuild it from the untruncated answer
-            # columns so the cap keeps real completion tokens, not just the EOS.
-            completion_source = (
-                _rebuild_unpaired_completion_row(row)
-                if {"answer_input_ids", "answer_attention_mask"} <= columns
-                else row
-            )
-            capped = {
+            capped: dict[str, Any] = {
                 "prompt_input_ids": prompt_ids,
                 "prompt_attention_mask": prompt_mask,
-                **_cap_combined_preference_sequence(
-                    completion_source,
-                    "completion",
+            }
+            if has_answer:
+                completion = _rebuild_unpaired_sequence(
+                    row["prompt_input_ids"],
+                    row["prompt_attention_mask"],
+                    row["answer_input_ids"],
+                    row["answer_attention_mask"],
+                    eos_id,
                     max_length=max_length,
                     max_prompt_length=max_prompt_length,
                     truncation_mode=truncation_mode,
-                ),
-            }
-            if {"KL_prompt_input_ids", "KL_prompt_attention_mask"} <= columns:
-                capped["KL_prompt_input_ids"] = _truncate_tokens(
-                    list(row["KL_prompt_input_ids"]),
-                    max_prompt_length,
-                    truncation_mode,
                 )
-                capped["KL_prompt_attention_mask"] = _truncate_tokens(
-                    list(row["KL_prompt_attention_mask"]),
-                    max_prompt_length,
-                    truncation_mode,
-                )
-            if {
-                "KL_completion_input_ids",
-                "KL_completion_attention_mask",
-                "KL_completion_labels",
-            } <= columns:
+                capped["completion_input_ids"] = completion["input_ids"]
+                capped["completion_attention_mask"] = completion["attention_mask"]
+                capped["completion_labels"] = completion["labels"]
+            else:
                 capped.update(
                     _cap_combined_preference_sequence(
                         row,
-                        "KL_completion",
+                        "completion",
                         max_length=max_length,
                         max_prompt_length=max_prompt_length,
                         truncation_mode=truncation_mode,
                     )
                 )
+            if has_kl:
+                capped["KL_prompt_input_ids"] = _truncate_tokens(
+                    list(row["KL_prompt_input_ids"]), max_prompt_length, truncation_mode
+                )
+                capped["KL_prompt_attention_mask"] = _truncate_tokens(
+                    list(row["KL_prompt_attention_mask"]), max_prompt_length, truncation_mode
+                )
+                if answers:
+                    lender = _rotated_answer_index(index, len(answers), chunk)
+                    kl = _rebuild_unpaired_sequence(
+                        row["KL_prompt_input_ids"],
+                        row["KL_prompt_attention_mask"],
+                        answers[lender],
+                        answer_masks[lender],
+                        eos_id,
+                        max_length=max_length,
+                        max_prompt_length=max_prompt_length,
+                        truncation_mode=truncation_mode,
+                    )
+                    capped["KL_completion_input_ids"] = kl["input_ids"]
+                    capped["KL_completion_attention_mask"] = kl["attention_mask"]
+                    capped["KL_completion_labels"] = kl["labels"]
+                else:
+                    capped.update(
+                        _cap_combined_preference_sequence(
+                            row,
+                            "KL_completion",
+                            max_length=max_length,
+                            max_prompt_length=max_prompt_length,
+                            truncation_mode=truncation_mode,
+                        )
+                    )
             return capped
 
-        return dataset.map(cap_unpaired)
+        return dataset.map(cap_unpaired, with_indices=True)
 
     raise ValueError(
         "TRL prepared a preference dataset with an unknown token layout; "
diff --git a/src/soup_cli/trainer/bco.py b/src/soup_cli/trainer/bco.py
index 4e9b913f8..33d0d796e 100644
--- a/src/soup_cli/trainer/bco.py
+++ b/src/soup_cli/trainer/bco.py
@@ -243,6 +243,7 @@ class BCOTrainerWrapper:
                 "max_length": cfg.data.max_length,
                 "max_prompt_length": cfg.data.max_length // 2,
                 "truncation_mode": bco_config.truncation_mode,
+                "only_when_overflow": True,
             }
             self.trainer.train_dataset = enforce_preference_sequence_limit(
                 self.trainer.train_dataset, **cap_kwargs
diff --git a/src/soup_cli/trainer/ipo.py b/src/soup_cli/trainer/ipo.py
index 665436c68..b3f7f9614 100644
--- a/src/soup_cli/trainer/ipo.py
+++ b/src/soup_cli/trainer/ipo.py
@@ -191,6 +191,7 @@ class IPOTrainerWrapper:
                 "max_length": cfg.data.max_length,
                 "max_prompt_length": cfg.data.max_length // 2,
                 "truncation_mode": dpo_config.truncation_mode,
+                "only_when_overflow": True,
             }
             self.trainer.train_dataset = enforce_preference_sequence_limit(
                 self.trainer.train_dataset, **cap_kwargs
diff --git a/src/soup_cli/trainer/kto.py b/src/soup_cli/trainer/kto.py
index 9e1e69dcf..2b9868102 100644
--- a/src/soup_cli/trainer/kto.py
+++ b/src/soup_cli/trainer/kto.py
@@ -226,6 +226,8 @@ class KTOTrainerWrapper(StreamingSetupMixin):
                 "max_length": cfg.data.max_length,
                 "max_prompt_length": cfg.data.max_length // 2,
                 "truncation_mode": DEFAULT_PROMPT_TRUNCATION_MODE,
+                "only_when_overflow": True,
+                "kl_chunk_size": batch_size,
             }
             self.trainer.train_dataset = enforce_preference_sequence_limit(
                 self.trainer.train_dataset, **cap_kwargs
diff --git a/src/soup_cli/trainer/simpo.py b/src/soup_cli/trainer/simpo.py
index e42a6b8ae..2469c3982 100644
--- a/src/soup_cli/trainer/simpo.py
+++ b/src/soup_cli/trainer/simpo.py
@@ -231,6 +231,7 @@ class SimPOTrainerWrapper(StreamingSetupMixin):
                 "max_length": cfg.data.max_length,
                 "max_prompt_length": cfg.data.max_length // 2,
                 "truncation_mode": cpo_config.truncation_mode,
+                "only_when_overflow": True,
             }
             self.trainer.train_dataset = enforce_preference_sequence_limit(
                 self.trainer.train_dataset, **cap_kwargs
diff --git a/tests/test_trl_preference_config_contract.py b/tests/test_trl_preference_config_contract.py
index 8af120acd..fdc5d363b 100644
--- a/tests/test_trl_preference_config_contract.py
+++ b/tests/test_trl_preference_config_contract.py
@@ -481,3 +481,151 @@ class TestKtoTruncatesThePromptFromTheEnd:
             if label == _IGNORE_LABEL
         ]
         assert masked == tail
+
+
+#: 41 words: longer than max_length // 2, yet prompt + completion is 45 of 64 tokens.
+_FITTING_PROMPT = " ".join(["hi"] * 40 + ["world"])
+#: An over-long prompt whose END is visible, so the kept side of the cut can be asserted.
+_OVERLONG_PROMPT_WITH_TAIL = " ".join(["hello"] * (_OVERLONG_PROMPT_WORDS - 1) + ["world"])
+
+
+def _setup_rows(tmp_path, monkeypatch, task, rows):
+    import importlib
+
+    module, cls_name, _ = _WRAPPERS[task]
+    weights = _tiny_llama_dir(tmp_path)
+    _write_tiny_tokenizer(weights)
+    monkeypatch.chdir(tmp_path)
+    cfg = _cfg(weights, tmp_path / "out", task)
+    wrapper = getattr(importlib.import_module(module), cls_name)(cfg, device="cpu")
+    wrapper.setup({"train": rows})
+    return wrapper
+
+
+def _rows_with(task, prompt, completion, n=8):
+    if task == "kto":
+        return [
+            {"prompt": prompt, "completion": completion, "label": i % 2 == 0} for i in range(n)
+        ]
+    return [{"prompt": prompt, "chosen": completion, "rejected": " bad"} for _ in range(n)]
+
+
+class TestTheCapTouchesOnlyRowsThatOverflow:
+    @pytest.mark.parametrize("task", ("bco", "ipo", "kto", "simpo"))
+    def test_a_row_that_fits_reaches_the_trainer_uncut(self, tmp_path, monkeypatch, task):
+        rows = _rows_with(task, _FITTING_PROMPT, " good answer")
+        wrapper = _setup_rows(tmp_path, monkeypatch, task, rows)
+        row = wrapper.trainer.train_dataset[0]
+        prompt = list(row["prompt_ids"] if "prompt_ids" in row else row["prompt_input_ids"])
+        words = wrapper.tokenizer.convert_ids_to_tokens(prompt)
+        assert len(prompt) > _MAX_PROMPT_LENGTH, (task, len(prompt), words[:2], words[-2:])
+        assert words[-1] == "world", (task, words[-3:])
+
+    @pytest.mark.parametrize("task", ("bco", "kto"))
+    def test_a_row_trl_already_fit_keeps_its_eos(self, tmp_path, monkeypatch, task):
+        rows = _rows_with(task, "hi", _LONG_COMPLETION)
+        wrapper = _setup_rows(tmp_path, monkeypatch, task, rows)
+        eos_id = wrapper.tokenizer.eos_token_id
+        for index, row in enumerate(wrapper.trainer.train_dataset):
+            if task == "bco" and len(row["answer_input_ids"]) < 10:
+                continue  # BCO's rejected half (" bad") is not the long row
+            ids = list(row["completion_input_ids"])
+            assert len(ids) <= _MAX_SEQUENCE_LENGTH, (task, index, len(ids))
+            assert ids[-1] == eos_id, (task, index, "EOS dropped from a row trl already fit")
+
+    @pytest.mark.parametrize("task", ("bco", "kto"))
+    def test_an_overlong_prompt_keeps_its_end_and_the_completion_its_eos(
+        self, tmp_path, monkeypatch, task
+    ):
+        rows = _rows_with(task, _OVERLONG_PROMPT_WITH_TAIL, " good answer")
+        wrapper = _setup_rows(tmp_path, monkeypatch, task, rows)
+        tok = wrapper.tokenizer
+        for index, row in enumerate(wrapper.trainer.train_dataset):
+            ids = list(row["completion_input_ids"])
+            labels = list(row["completion_labels"])
+            assert len(ids) <= _MAX_SEQUENCE_LENGTH, (task, index, len(ids))
+            masked = [t for t, lab in zip(ids, labels) if lab == _IGNORE_LABEL]
+            assert tok.convert_ids_to_tokens(masked[-1:]) == ["world"], (task, index)
+            assert ids[-1] == tok.eos_token_id, (task, index, "the completion lost its EOS")
+
+    def test_the_kto_kl_completion_keeps_its_answer(self, tmp_path, monkeypatch):
+        wrapper = _setup_rows(tmp_path, monkeypatch, "kto", _long_kto_rows(8))
+        eos_id = wrapper.tokenizer.eos_token_id
+        for index, row in enumerate(wrapper.trainer.train_dataset):
+            ids = list(row["KL_completion_input_ids"])
+            labels = list(row["KL_completion_labels"])
+            assert len(ids) <= _MAX_SEQUENCE_LENGTH, (index, len(ids))
+            non_eos = [t for t, lab in zip(ids, labels) if lab != _IGNORE_LABEL and t != eos_id]
+            assert non_eos, f"kto row {index}: the KL completion is EOS-only"
+
+    def test_the_kto_kl_rebuild_matches_trl_on_rows_that_fit(self, tmp_path, monkeypatch):
+        """The KL reconstruction reproduces TRL's own rotated KL rows exactly.
+
+        Rows that fit reach the trainer untouched, so they are TRL's own output;
+        rebuilding the KL completion from answer_input_ids with the same
+        per-device-batch rotation must give the identical ids, mask and labels.
+        """
+        from soup_cli.trainer._trl_compat import (
+            _rebuild_unpaired_sequence,
+            _rotated_answer_index,
+        )
+
+        completions = [" good answer", " fine", " a much longer answer with words", " no"]
+        rows = [
+            {"prompt": "hi there", "completion": completions[i % 4], "label": i % 2 == 0}
+            for i in range(9)
+        ]
+        wrapper = _setup_rows(tmp_path, monkeypatch, "kto", rows)
+        dataset = wrapper.trainer.train_dataset
+        answers = list(dataset["answer_input_ids"])
+        masks = list(dataset["answer_attention_mask"])
+        chunk = wrapper.trainer.args.per_device_train_batch_size
+        eos_id = wrapper.tokenizer.eos_token_id
+        for index, row in enumerate(dataset):
+            lender = _rotated_answer_index(index, len(answers), chunk)
+            rebuilt = _rebuild_unpaired_sequence(
+                row["KL_prompt_input_ids"],
+                row["KL_prompt_attention_mask"],
+                answers[lender],
+                masks[lender],
+                eos_id,
+                max_length=_MAX_SEQUENCE_LENGTH,
+                max_prompt_length=_MAX_SEQUENCE_LENGTH,
+                truncation_mode="keep_end",
+            )
+            assert rebuilt["input_ids"] == list(row["KL_completion_input_ids"]), index
+            assert rebuilt["labels"] == list(row["KL_completion_labels"]), index
+            assert rebuilt["attention_mask"] == list(row["KL_completion_attention_mask"]), index
+
+    def test_the_kto_kl_completion_of_an_overlong_prompt_borrows_the_rotated_answer(
+        self, tmp_path, monkeypatch
+    ):
+        """An over-long row's KL completion is its prompt plus the neighbour's answer.
+
+        TRL pairs each row with another row's answer inside every
+        per_device_train_batch_size chunk; the rebuild has to follow the same
+        rotation, or the KL estimate compares the wrong text.
+        """
+        from soup_cli.trainer._trl_compat import _rotated_answer_index
+
+        completions = [" alpha beta", " gamma", " delta epsilon zeta", " eta"]
+        rows = [
+            {
+                "prompt": _OVERLONG_PROMPT_WITH_TAIL,
+                "completion": completions[i % 4],
+                "label": i % 2 == 0,
+            }
+            for i in range(8)
+        ]
+        wrapper = _setup_rows(tmp_path, monkeypatch, "kto", rows)
+        eos_id = wrapper.tokenizer.eos_token_id
+        dataset = wrapper.trainer.train_dataset
+        answers = list(dataset["answer_input_ids"])
+        chunk = wrapper.trainer.args.per_device_train_batch_size
+        for index, row in enumerate(dataset):
+            lender = _rotated_answer_index(index, len(answers), chunk)
+            ids = list(row["KL_completion_input_ids"])
+            labels = list(row["KL_completion_labels"])
+            trained = [tok for tok, lab in zip(ids, labels) if lab != _IGNORE_LABEL]
+            assert len(ids) <= _MAX_SEQUENCE_LENGTH, (index, len(ids))
+            assert trained == list(answers[lender]) + [eos_id], index

@MakazhanAlpamys

Copy link
Copy Markdown
Owner

@ousamabenyounes, thank you for the quick rounds on #1196: the KTO/BCO completion is back and the content tests pin it, which I verified by running them. Three items and a few small ones are still open on b1ca232e (all in my last review, which also has a tested patch for 1-3: git apply takes it on your head, 35 contract tests and the 43 wrapper test files pass with it):

  1. src/soup_cli/trainer/_trl_compat.py and the four call sites (simpo.py:227, kto.py:219, ipo.py:187, bco.py:239): cut only rows that overflow max_length (only_when_overflow=True from those four; dpo.py / orpo.py stay as they are).
  2. _rebuild_unpaired_completion_row: cut the answer to max_length - len(prompt) - 1, append the EOS after the cut, and rebuild only overflowing rows (KTO/BCO rows that trl already fit lose their </s> today).
  3. cap_unpaired, KL branch: rebuild KL_completion_* from the rotated answer_input_ids, as trl does.
  4. Small: one line in docs/training.md (when the prompt is cut, which end is kept); the fragment should say prompts are cut only when a row exceeds max_length and end with a period; a blank line after if orpo_columns <= columns:; a docstring on _cap_combined_preference_sequence.
    The red CI is not yours: all 16 jobs were cancelled during a queue clean-up, none failed; your next push gives a real run. It merges once those are in and the required checks are green. If you are short on time, a short "still on it" is fine, or say so and a maintainer or another contributor can pick it up with your work credited.

Because your checks were cancelled on our side during a queue clean-up, the window I set earlier for this PR is extended: it stays yours through 2026-10-04.

@MakazhanAlpamys

Copy link
Copy Markdown
Owner

@ousamabenyounes, a short check-in on #1196. b1ca232e is unchanged since my comment on the 1st, so this only lists what is still open.

What is already solid: rebuilding the KTO/BCO completion from answer_input_ids brought the trainable good answer </s> back on over-long prompts, and your per-row content assertions are what now catch the changes that used to pass unnoticed.

Still open (details in my review of 28 September; the review of 30 September has a tested patch for 1-3 that git apply takes on your head):

  1. Cut only rows that overflow max_length. Pass an only_when_overflow=True argument from the four call sites (simpo.py:227, kto.py:219, ipo.py:187, bco.py:239). Leave dpo.py and orpo.py exactly as they are, so Preference prompt cap: should DPO and ORPO cut the prompt only when the row does not fit? #1209 stays open.
  2. _rebuild_unpaired_completion_row. Rebuild only the rows that overflow. Cut the answer to max_length - len(prompt) - 1 and append the EOS after the cut, so KTO/BCO rows that trl already fit keep their </s>.
  3. cap_unpaired, KL branch. Rebuild KL_completion_* from the rotated answer_input_ids, the way trl builds its KL rows.
  4. Small items:
    • one line in docs/training.md on when the prompt is cut and which end is kept;
    • the fragment saying that prompts are cut only when a row exceeds max_length, and ending with a period;
    • the blank line after if orpo_columns <= columns:;
    • a docstring on _cap_combined_preference_sequence.

The nine ready-to-paste cases in the 28 September review pin items 1-3. A fix for all three should pass the whole contract file.

The CI jobs on this head were cancelled during our queue clean-up; your code did not cause it. Re-running them would only re-test the old merge from 27 September. Your next push starts a fresh run. The branch still merges cleanly with current main, so you do not need to rebase first.

If you are still on it, a one-line note is enough. If you would rather hand it on, just say so, and a maintainer or another contributor can pick it up with your work credited.

@ousamabenyounes

Copy link
Copy Markdown
Contributor Author

Pushed 7ee51ad with the remaining review items addressed.

What changed:

Validation:

  • RED: the new review-counterexample tests failed on b1ca232e before the source fix.
  • GREEN: ./run-ci.sh passes locally with 202 tests, ruff clean, and Python diff coverage 65/65 changed production lines covered.
  • Remote checks on 7ee51ad are terminal: lint, plan, gate, and test (ubuntu-latest, 3.12) succeeded; the remaining jobs were skipped by workflow conditions.

@ousamabenyounes

Copy link
Copy Markdown
Contributor Author

Current head is 4a3edc94c0cd5572537ded7b69000dd0fa64f240.

I reran the focused review/changelog command in a clean CPU validation venv: 36 passed. I also reran ./run-ci.sh: 203 passed, ruff clean, and Python diff coverage is 90/90 changed production lines covered. Remote checks on this exact SHA are terminal: lint, plan, gate, and test (ubuntu-latest, 3.12) succeeded; the remaining workflow jobs were skipped by conditions.

@ousamabenyounes

Copy link
Copy Markdown
Contributor Author

Pushed and verified current head 4a3edc94.

What changed since the last review round:

  • BCO, IPO, KTO, and SimPO keep rows untouched when the prepared row already fits data.max_length; DPO/ORPO remain unchanged for Preference prompt cap: should DPO and ORPO cut the prompt only when the row does not fit? #1209.
  • KTO/BCO completion rebuild preserves EOS, and KTO KL completion rebuild follows TRL's rotated answer_input_ids.
  • Added train/eval fitting-row coverage, EOS/prompt-tail/KL rotation coverage, and the no-answer_input_ids KL fallback cap test.
  • Updated docs/training.md and changelog.d/0.75.1/1196.fixed.md.

Validation:

  • ./run-ci.sh -> 203 passed, ruff clean, Python diff coverage 90/90.
  • Remote checks on 4a3edc94: lint, plan, gate, and test (ubuntu-latest, 3.12) succeeded; the remaining visible jobs were skipped by workflow conditions.

@MakazhanAlpamys MakazhanAlpamys left a comment •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks, @ousamabenyounes. The three previous requests are addressed on 4c9e8d0: predecessor rotation, IPO's documented keep_start default, and the EOS budget regression. All 44 amended contract cases pass in a native integration with main 3b28d69.

[P2] Preserve an answer's existing terminal EOS during rebuilding.
File: src/soup_cli/trainer/_trl_compat.py:213-215

An overlong prompt plus the valid short completion good</s> reaches _rebuild_unpaired_sequence with raw answer IDs [good, EOS]. The helper retains those IDs and appends another EOS, so BCO's matched target, KTO's matched target and KTO's KL target train on [good, EOS, EOS]. The original TRL path appends EOS only when absent; a fitting prompt preserves the single EOS. This changes the target for inputs that already include their stop token.

Please preserve an already-terminal EOS while enforcing the answer budget, for both matched and KL reconstruction, and add caller-level regressions that compare exact unmasked tokens against a known vocabulary oracle. Keep the existing fitting-row, no-explicit-EOS and long-answer controls; no broader tokenizer change is needed.

Independent verification used real BCO/KTO wrapper setup and the existing tiny local tokenizer/checkpoint: the new external file gives exactly 3 failures and 9 passing controls. A temporary narrow production-source control that removes the existing terminal EOS before the current cap/append makes the unchanged 12 cases pass. That control was removed, every byte restored, full Ruff passed, and the native merge aborted cleanly. The authored 44-case file remains unchanged.

Evidence is CPU-only on Torch 2.14.0, Transformers 5.17.0 and TRL 0.29.1. Available physical RAM exceeded 6 GiB before each child. Each child verified its owned source and ran offline with one CPU thread, disabled CUDA visibility and a 90-second bound. No model download, provider, GPU test or training step was performed. This review does not claim a fresh full matrix or repeat the historical rotation/budget mutations.

@MakazhanAlpamys

Copy link
Copy Markdown
Owner

Heads-up for the merge, @ousamabenyounes: v0.76.0 is out, so changelog fragments now go under changelog.d/0.76.0/, and a fragment left under changelog.d/0.75.1/ fails test_repository_fragments_match_the_newest_release once this branch contains main. At your next push (you will need to rebase on current main anyway), please move it: git mv changelog.d/0.75.1/1196.fixed.md changelog.d/0.76.0/1196.fixed.md. No other change is needed for this. We are not asking you to push before you are ready.

@ousamabenyounes

Copy link
Copy Markdown
Contributor Author

Done, thanks for the heads-up — pushed 8874d7c.

  1. Rebased the four commits onto current main (5349282), no conflicts.
  2. git mv changelog.d/0.75.1/1196.fixed.md changelog.d/0.76.0/1196.fixed.md; fragment text unchanged, and changelog.d/0.75.1/ is gone from the branch.
  3. Reproduced the failure you predicted before the move — ChangelogError: fragment baseline is 0.75.1, but the latest release is 0.76.0 — then confirmed tests/test_issue487_changelog_fragments.py is 21 passed after it.
  4. Also reran this PR's own tests/test_trl_preference_config_contract.py: 40 passed, and ruff check is clean on the files this PR touches. No production code changed in this push.

I did not replay the whole suite locally (37,853 collected tests is several hours on my 4-core box), so remote CI on 8874d7c is the full-suite gate here.

ousamabenyounes added a commit to ousamabenyounes/Soup that referenced this pull request Oct 12, 2026
Addresses the review on MakazhanAlpamys#1196:

- cap_unpaired rebuilds completion_* from the untruncated answer_input_ids /
  prompt_input_ids. TRL's KTO/BCO tokenizer slices the answer to fit max_length
  before Soup's cap runs, so an over-length prompt left completion_input_ids with
  only the trailing EOS; re-deriving from TRL's own truncated column could never
  recover the answer.
- DEFAULT_PROMPT_TRUNCATION_MODE switches to keep_end. KTOConfig has no
  truncation_mode field, so KTO caps its prompt through this default; keep_end
  matches TRL's historical KTO/BCO/CPO/ORPO direction and preserves a chat
  template's generation header at the prompt tail.
- Tests: per-row assertion that the unmasked completion tokens equal the
  truncated answer (including a non-EOS token), a long-completion fixture, and a
  keep_end direction pin. The content check reads completion_labels, never the
  unused answer_input_ids column.
- Rename the changelog fragment to 1196.fixed.md and give it the required list
  item and attribution.
@MakazhanAlpamys

Copy link
Copy Markdown
Owner

@ousamabenyounes, thanks for the rebase and the fragment move. I checked 8874d7cb against my review of 4a3edc94: the net patch is identical apart from the fragment, which is now correctly changelog.d/0.76.0/1196.fixed.md, and the contract file plus the fragment test pass (61). The three items from that review are still open, because no source or test line changed:

  1. _trl_compat.py:227: trl rotates the KL answers as [a[-1]] + a[:-1], so row i borrows row i-1, but _rotated_answer_index returns i+1. The fix is return index - 1 if index > chunk_start else chunk_end - 1; the batch-4 test in my review fails on [overlong] today and passes with it.
  2. docs/training.md:1309-1312: IPO follows DPO's truncation_mode (keep_start by default), so "keeping the prompt end" is wrong for IPO (BCO, KTO and SimPO are keep_end).
  3. Dropping the - 1 in answer_limit (_trl_compat.py:209) still passes all 40 tests; the long-completion test in my review pins it.

Both tests are in my review of 5 October, ready to paste at the end of the contract file. With these three in and the required checks green, the maintainers will queue it.

@ousamabenyounes

Copy link
Copy Markdown
Contributor Author

@MakazhanAlpamys, thanks for the precise review. All three items are addressed. Current head: 4c9e8d02527466c62cf2c14842cc6e71d49a4ae5.

  1. KL rotation (_trl_compat.py): _rotated_answer_index now returns index - 1 if index > chunk_start else chunk_end - 1. This matches trl's [a[-1]] + a[:-1] (kto_trainer.py:91). The chunk_width guard is gone, because a one-row chunk already resolves to itself. The two existing KL tests now use your independent _trl_kl_lender oracle in place of the function under test.
  2. Docs (docs/training.md): BCO, KTO and SimPO keep the prompt end. IPO follows DPO's truncation_mode (keep_start by default).
  3. Tests: I appended both of your tests. The only change is that the batch size 4 is now a named constant (_KL_ROTATION_BATCH).

Validation (CPU, trl 0.29.1):

  • RED: your tests against 8874d7c's production code: only test_kto_kl_rows_borrow_the_predecessor_answer_at_batch_four[overlong] fails (43 passed).
  • GREEN: the contract file passes 44/44. Dropping the - 1 in answer_limit now fails test_an_overlong_prompt_with_a_long_completion_fits_and_ends_in_eos[bco|kto].
  • Local CI replay (ruff on the CI paths plus 15 related test files): 519 passed, with 100% diff coverage on changed production lines. On a local merge with current main (8323ba9), the same set passes 569.
  • Remote CI on 4c9e8d0: lint, plan, gate and test (ubuntu-latest, 3.12) all passed. merge-freshness is informational only (the live merge is clean), so I did not rebase.

@MakazhanAlpamys

Copy link
Copy Markdown
Owner

@ousamabenyounes, thank you for the fast round. The three items from my 13 October comment are closed, and I checked each one by running it rather than by reading your reply:

  • KL rotation. I compared _rotated_answer_index against TRL 0.29.1's own _get_kl_dataset (datasets.map(..., batched=True, batch_size=chunk)) on 54 shapes (9 row counts times 6 batch sizes, including a short last chunk): 0 mismatches. Against 8874d7c's production code only test_kto_kl_rows_borrow_the_predecessor_answer_at_batch_four[overlong] fails; on your head it passes.
  • IPO wording. DPOConfig.truncation_mode defaults to keep_start, and BCOConfig / CPOConfig (SimPO) default to keep_end, so the new sentence in docs/training.md is exact.
  • The - 1. Dropping it at _trl_compat.py:209 now fails both test_an_overlong_prompt_with_a_long_completion_fits_and_ends_in_eos cases.

What I ran (CPU only, head 4c9e8d02): tests/test_trl_preference_config_contract.py gives 44 passed, plain and with FORCE_COLOR=1 COLUMNS=80 LINES=24; ruff check is clean. Merged with current main (125 commits ahead of your base) there are no conflicts and the same file gives 44 passed again, as do test_v07204.py (71 passed, 14 skipped), test_kto.py, test_simpo.py, test_bco.py, test_ipo.py and test_issue1208_simpo_zero_token_completion.py. Copied onto main without your change, 16 of the 44 fail. I removed or reverted each changed behaviour line of _trl_compat.py and the four wrappers: 52 mutations, 38 caught, 14 not.

1. An answer that already ends in EOS gets a second one (this is the [P2] in the review of 5 October, which was edited this morning on this same head, so it may not have reached you). With a completion like " good</s>" and an over-long prompt, _rebuild_unpaired_sequence (_trl_compat.py:209-215) keeps the answer's own EOS and appends another. I ran it: BCO's completion, KTO's completion and KTO's KL completion train on token ids [7, 2, 2] (good, EOS, EOS). With a fitting prompt, which TRL builds, the same row trains on [7, 2], because _process_tokens appends an EOS only when the answer's last token is not one. A fix that I tried (the contract file stays at 44 passed, and the test further down goes 4 of 4): strip one trailing EOS from the answer before the cap, then append exactly one:

    capped_prompt_mask = _truncate_tokens(list(prompt_mask), max_prompt_length, truncation_mode)
    answer_ids, answer_mask = list(answer_ids), list(answer_mask)
    if answer_ids and answer_ids[-1] == eos_id:
        # TRL appends EOS only when it is absent: keep exactly one
        answer_ids, answer_mask = answer_ids[:-1], answer_mask[:-1]
    answer_limit = max(0, max_length - len(capped_prompt_ids) - 1)

2. Fourteen of the 52 mutations still pass the file, 12 of them real gaps. Each line below passes all 44 tests when broken:

  • _trl_compat.py:222-223, the KL rotation you just fixed: chunk_end = min(chunk_start + chunk, row_count) replaced by chunk_start + chunk (short last chunk), and chunk_start = 0 (rotating over the whole dataset instead of per batch). Every fixture has 8 rows, and its answers repeat with period 4, which is the batch size, so a lender taken from the wrong chunk looks identical.
  • The validation set is never tested with an over-long row, so deleting the eval cap passes: bco.py:253, ipo.py:205, kto.py:236, simpo.py:237.
  • ipo.py:198 (max_length // 2 replaced by max_length), ipo.py:199 (IPO's keep_start turned into keep_end) and simpo.py:231 (keep_end turned into keep_start): the documented prompt budget and direction are not pinned.
  • _trl_compat.py:421: KL_prompt_attention_mask is not capped, and nothing checks that it stays as long as KL_prompt_input_ids.
  • _trl_compat.py:315 and :380: <= max_length turned into <, so a row that exactly fits (40-token prompt, total 64) would be cut.

The two others (:333, and turned into or, and :381, the KL length ignored in fits) I could not reach through the wrappers: SimPO refuses lopsided pairs first (#1208), and the KL row shares the prompt while TRL slices the lender's answer to fit. I am not asking for tests for those two.

This block, appended to tests/test_trl_preference_config_contract.py (it only uses helpers that are already there), kills the 12. On your current head it gives 2 failed (the two over-long cases of the EOS test) and 61 passed; with the fix above, 63 passed:

def _world_id(wrapper):
    return wrapper.tokenizer("world", add_special_tokens=False)["input_ids"][0]


@pytest.mark.parametrize("task", ("bco", "ipo", "kto", "simpo"))
def test_overlong_validation_rows_are_capped_too(tmp_path, monkeypatch, task):
    rows = _rows_with(task, "hi", " good answer")
    val_rows = _rows_with(task, _OVERLONG_PROMPT_WITH_TAIL, " good answer", n=4)
    wrapper = _setup_rows(tmp_path, monkeypatch, task, rows, val_rows=val_rows)
    eval_dataset = wrapper.trainer.eval_dataset
    assert eval_dataset is not None and len(eval_dataset) > 0
    for row in eval_dataset:
        if task == "ipo":
            assert len(row["prompt_ids"]) + len(row["chosen_ids"]) <= _MAX_SEQUENCE_LENGTH
            assert len(row["prompt_ids"]) + len(row["rejected_ids"]) <= _MAX_SEQUENCE_LENGTH
        elif task == "simpo":
            assert len(row["chosen_input_ids"]) <= _MAX_SEQUENCE_LENGTH
            assert len(row["rejected_input_ids"]) <= _MAX_SEQUENCE_LENGTH
        else:
            assert len(row["completion_input_ids"]) <= _MAX_SEQUENCE_LENGTH
            if task == "kto":
                assert len(row["KL_completion_input_ids"]) <= _MAX_SEQUENCE_LENGTH


@pytest.mark.parametrize(("size", "chunk"), ((8, 4), (10, 4), (7, 3), (5, 2), (9, 2), (3, 8)))
def test_the_kl_rotation_stays_inside_its_chunk_and_handles_a_short_last_chunk(size, chunk):
    from soup_cli.trainer._trl_compat import _rotated_answer_index

    for index in range(size):
        assert _rotated_answer_index(index, size, chunk) == _trl_kl_lender(index, size, chunk)


def test_ipo_keeps_the_prompt_head_within_half_the_budget(tmp_path, monkeypatch):
    rows = _rows_with("ipo", _OVERLONG_PROMPT_WITH_TAIL, " good answer")
    wrapper = _setup_rows(tmp_path, monkeypatch, "ipo", rows)
    world = _world_id(wrapper)
    for row in wrapper.trainer.train_dataset:
        assert len(row["prompt_ids"]) == _MAX_PROMPT_LENGTH
        assert world not in list(row["prompt_ids"])  # keep_start drops the tail


def test_simpo_keeps_the_prompt_tail_within_half_the_budget(tmp_path, monkeypatch):
    rows = _rows_with("simpo", _OVERLONG_PROMPT_WITH_TAIL, " good answer")
    wrapper = _setup_rows(tmp_path, monkeypatch, "simpo", rows)
    world = _world_id(wrapper)
    for row in wrapper.trainer.train_dataset:
        assert len(row["prompt_input_ids"]) == _MAX_PROMPT_LENGTH
        assert list(row["prompt_input_ids"])[-1] == world  # keep_end keeps the tail


def test_the_kto_prompt_masks_stay_as_long_as_their_ids(tmp_path, monkeypatch):
    rows = _rows_with("kto", _OVERLONG_PROMPT_WITH_TAIL, " good answer")
    wrapper = _setup_rows(tmp_path, monkeypatch, "kto", rows)
    for row in wrapper.trainer.train_dataset:
        assert len(row["prompt_attention_mask"]) == len(row["prompt_input_ids"])
        assert len(row["KL_prompt_attention_mask"]) == len(row["KL_prompt_input_ids"])


def test_an_unpaired_row_that_exactly_fits_is_not_cut():
    from datasets import Dataset

    from soup_cli.trainer._trl_compat import enforce_preference_sequence_limit

    eos_id = 999
    prompt = list(range(100, 140))  # 40 tokens: longer than max_prompt_length 32
    answer = list(range(200, 223))  # 23 tokens: 40 + 23 + EOS == 64 == max_length
    dataset = Dataset.from_dict(
        {
            "prompt_input_ids": [prompt],
            "prompt_attention_mask": [[1] * len(prompt)],
            "answer_input_ids": [answer],
            "answer_attention_mask": [[1] * len(answer)],
            "completion_input_ids": [prompt + answer + [eos_id]],
            "completion_attention_mask": [[1] * 64],
            "completion_labels": [[_IGNORE_LABEL] * len(prompt) + answer + [eos_id]],
        }
    )
    capped = enforce_preference_sequence_limit(
        dataset,
        max_length=64,
        max_prompt_length=32,
        truncation_mode="keep_end",
        only_when_overflow=True,
    )[0]
    assert list(capped["completion_input_ids"]) == prompt + answer + [eos_id]


def test_a_dpo_layout_row_that_exactly_fits_is_not_cut():
    from datasets import Dataset

    from soup_cli.trainer._trl_compat import enforce_preference_sequence_limit

    prompt = list(range(100, 140))  # 40 tokens: longer than max_prompt_length 32
    chosen = list(range(200, 224))  # 40 + 24 == 64 == max_length
    dataset = Dataset.from_dict(
        {"prompt_ids": [prompt], "chosen_ids": [chosen], "rejected_ids": [list(range(300, 310))]}
    )
    capped = enforce_preference_sequence_limit(
        dataset,
        max_length=64,
        max_prompt_length=32,
        truncation_mode="keep_start",
        only_when_overflow=True,
    )[0]
    assert list(capped["prompt_ids"]) == prompt
    assert list(capped["chosen_ids"]) == chosen


@pytest.mark.parametrize("task", ("bco", "kto"))
@pytest.mark.parametrize("prompt_kind", ("fits", "overlong"))
def test_an_answer_that_already_ends_in_eos_keeps_exactly_one(
    tmp_path, monkeypatch, task, prompt_kind
):
    prompt = "hi" if prompt_kind == "fits" else _OVERLONG_PROMPT_WITH_TAIL
    rows = _rows_with(task, prompt, " good</s>")
    wrapper = _setup_rows(tmp_path, monkeypatch, task, rows)
    eos_id = wrapper.tokenizer.eos_token_id
    prefixes = ["completion"] + (["KL_completion"] if task == "kto" else [])
    for row in wrapper.trainer.train_dataset:
        for prefix in prefixes:
            ids = list(row[f"{prefix}_input_ids"])
            labels = list(row[f"{prefix}_labels"])
            trained = [tok for tok, lab in zip(ids, labels) if lab != _IGNORE_LABEL]
            assert trained[-1] == eos_id, (task, prompt_kind, prefix, trained)
            assert trained.count(eos_id) == 1, (task, prompt_kind, prefix, trained)

After both of these I found nothing else open: the merge with main is clean, and the fragment (changelog.d/0.76.0/1196.fixed.md) and the docs paragraph are right. If something in the block does not fit how you want to structure the file, say so here and we can adjust it.

ousamabenyounes added a commit to ousamabenyounes/Soup that referenced this pull request Oct 26, 2026
Addresses the review on MakazhanAlpamys#1196:

- cap_unpaired rebuilds completion_* from the untruncated answer_input_ids /
  prompt_input_ids. TRL's KTO/BCO tokenizer slices the answer to fit max_length
  before Soup's cap runs, so an over-length prompt left completion_input_ids with
  only the trailing EOS; re-deriving from TRL's own truncated column could never
  recover the answer.
- DEFAULT_PROMPT_TRUNCATION_MODE switches to keep_end. KTOConfig has no
  truncation_mode field, so KTO caps its prompt through this default; keep_end
  matches TRL's historical KTO/BCO/CPO/ORPO direction and preserves a chat
  template's generation header at the prompt tail.
- Tests: per-row assertion that the unmasked completion tokens equal the
  truncated answer (including a non-EOS token), a long-completion fixture, and a
  keep_end direction pin. The content check reads completion_labels, never the
  unused answer_input_ids column.
- Rename the changelog fragment to 1196.fixed.md and give it the required list
  item and attribution.
@Srinivasan8888

Copy link
Copy Markdown
Contributor

The red test (ubuntu-latest, 3.12) job on 8208aa06 is two tests that reached main after the 10-19 review and pin the behaviour this PR changes; I reproduced both locally on the same head, and they are the only two failures in that job (2 failed, 45419 passed):

  • tests/test_simpo_prompt_cap.py::TestThePromptBudgetBoundary::test_one_token_over_the_budget_is_cut_although_the_row_fits (from fix(simpo): cap the prompt on the transformers backend, as dpo and orpo do (#2112) #2116, merged 10-23) asserts a SimPO prompt one token over max_length // 2 is cut although the row fits. This PR's only_when_overflow=True in simpo.py keeps that row whole, which is what the review asked for, so the test now encodes the old rule.
  • tests/test_mlx_kto.py::TestTrlFacts::test_the_transformers_kto_path_passes_beta_and_max_length_only (from feat(mlx): native KTO on Apple Silicon (#2000) #2111, merged 10-24) asserts "enforce_preference_sequence_limit" not in the source of kto.py ("no Soup cap touches the rows"). This PR adds that call to kto.py.

Both look like test updates on this branch rather than code changes, but that is yours and the maintainer's call. For what it is worth, #1209 is stacked on 8208aa06 with only_when_overflow=True passed from dpo.py and orpo.py, and it opens once this lands.

@MakazhanAlpamys

Copy link
Copy Markdown
Owner

@ousamabenyounes, this is a review of 8208aa06, the head after your rebase on 26 October. Thank you for that rebase, for moving the fragment to changelog.d/0.76.0/, and for the terminal-EOS commit: _rebuild_unpaired_sequence now splits one trailing EOS off the answer before the budget and appends exactly one, which is what TRL does. We have read that change and it looks right to us; we have not run it yet.

The CHANGES_REQUESTED review on this pull request is from 5 October and is about 4a3edc94. Its single item is the one your last commit fixes, so that review no longer describes the branch. What is open on 8208aa06 is the list below. Once the checks are green we replace it with a review of this head, and the maintainers will queue the pull request, as we said on 13 October.

1. Two red tests. They are the only two failures in test (ubuntu-latest, 3.12) (2 failed, 45419 passed; thank you, @Srinivasan8888, for naming them). Both came in with your rebase (they reached main on 23 and 24 October) and both pin the rule this pull request changes, so the code is not at fault; the tests have to move with it.

  • tests/test_simpo_prompt_cap.py::TestThePromptBudgetBoundary::test_one_token_over_the_budget_is_cut_although_the_row_fits (from fix(simpo): cap the prompt on the transformers backend, as dpo and orpo do (#2112) #2116, merged 23 October). It builds a SimPO row with a 33-token prompt that fits in max_length: 64 and expects the prompt cut to its last 32 tokens. With only_when_overflow set in simpo.py, the row now reaches the trainer whole, so the kept prompt still starts with <s> and the comparison fails. Please turn it into the new rule: the same row is unchanged (compare with _before(wrapper, raw), as its neighbour test_a_prompt_of_exactly_the_budget_is_unchanged does), and add its twin, a 33-token prompt whose row does not fit (assert on before that it overflows, as this test asserts that it fits), which is cut to its last 32 tokens. Update the class docstring ("the cap cuts by the prompt alone") too. The twin matters: fix(simpo): cap the prompt on the transformers backend, as dpo and orpo do (#2112) #2116 had boundary tests that kill max_length // 2 plus or minus one, and a rewrite that keeps only the "unchanged" half would lose that.
  • tests/test_mlx_kto.py::TestTrlFacts::test_the_transformers_kto_path_passes_beta_and_max_length_only (from feat(mlx): native KTO on Apple Silicon (#2000) #2111, merged 24 October), line 114: assert "enforce_preference_sequence_limit" not in source. kto.py now calls it, which is what this pull request adds. The test's real subjects (the KTOConfig keywords and prompt_length_kwargs(KTOConfig, 64) == {}) are not touched by your change. Please drop that line and the words "and no Soup cap touches the rows" from the docstring; the cap itself is pinned through the real wrapper by your test_a_row_that_fits_reaches_the_trainer_uncut[kto].

On the rule itself: for SimPO, KTO, IPO and BCO a prompt is cut only when the row does not fit in data.max_length, as in our review of 24 September; DPO and ORPO follow in #1209. #2116 added an unconditional SimPO cap, and our note on it said that was right because it matches DPO and ORPO. That note did not take this thread's ruling into account. It is the reason the first test is red, and the conflict is ours, not yours.

2. Text that now says the opposite. Please change:

  • docs/training.md, the SimPO paragraph "Long prompts are capped as in DPO and ORPO" (lines 1932-1939 on main). It says the cut "goes by the prompt alone" and happens "even when prompt and answers would fit", which also contradicts the paragraph your change adds near line 1363. For example: "A SimPO prompt is cut only when the row does not fit in data.max_length: it is then cut to data.max_length // 2 tokens, keeping its end, and the answers get the room left. A row that fits is unchanged, whatever its prompt length."
  • changelog.d/0.76.0/2116.fixed.md: its last sentence says a long prompt is cut "even when the whole row would fit". Two fragments that contradict each other would land in the same release notes.
  • docs/backends-and-ops.md, lines 278-283 (the SimPO bullet says "The transformers SimPO path applies no prompt cap", which has been out of date since fix(simpo): cap the prompt on the transformers backend, as dpo and orpo do (#2112) #2116, and "rows that fit ... train on the same tokens on either backend") and lines 376-378 (KTO: "the transformers path adds none"). MLX SimPO, DPO and ORPO still cut by the prompt alone (mlx_orpo.py:162); one sentence saying so is enough, and we will deal with the MLX side separately.

3. The test block from 19 October. None of the cases in that block is in 8208aa06 yet (we checked by name; only the EOS test is new), so the 12 mutations listed there are still unpinned as far as we can tell; we have not re-run them on this head. By our reading the block pastes in unchanged, because the helpers it uses are all in the file now (_setup_rows(..., val_rows=), _rows_with, _trl_kl_lender, _OVERLONG_PROMPT_WITH_TAIL, _IGNORE_LABEL). Its test_an_answer_that_already_ends_in_eos_keeps_exactly_one is still worth having next to your own EOS test: yours builds the row by hand, so it cannot see a wrapper that never reaches the rebuild.

A realistic path. Everything above is a small edit or a paste. A suggested order: paste the 19 October block, rewrite the SimPO boundary test, drop line 114, fix the wording in the four places, then run tests/test_simpo_prompt_cap.py tests/test_mlx_kto.py tests/test_trl_preference_config_contract.py tests/test_issue487_changelog_fragments.py and push. The required cell takes about 12 minutes. If a release is cut before this merges, the fragment has to move to the new release directory once more, as on 12 October; we will say so here.

Timing, kindly. Nothing here is urgent and nothing you wrote is lost. Your last push was on 26 October and this list is new, so we count from today. Our queue rule is that a pull request waiting on its author for 14 days gets a ping in the thread, and if another 7 days pass we close it with a note that it can be reopened at any time. Counted from today that is 9 November and 16 November. A short "still on it" is enough to tell us you are here; if your time is short, say so and a maintainer or another contributor can finish the last steps with your work credited. Two other pieces wait behind this pull request: #1209 (the same rule for DPO and ORPO) and the CUDA run for #840.

@ousamabenyounes

Copy link
Copy Markdown
Contributor Author

still on it - thank for your review will send you update soon

@MakazhanAlpamys

Copy link
Copy Markdown
Owner

Thank you for the word, @ousamabenyounes, take the time you need. One small update to the review above, because v0.77.0 was released today: the 0.76.0 fragments were folded into CHANGELOG.md, so changelog.d/0.76.0/2116.fixed.md no longer exists and cannot be edited, and your own fragment has to move to changelog.d/0.77.0/ (a fragment in an old directory fails the fragment test). For the changelog, please put the corrected wording in your new fragment: for SimPO, KTO, IPO and BCO a prompt is cut only when the row does not fit in data.max_length, which replaces the unconditional cut that #2116 introduced. We are noting the interim behaviour in the release notes ourselves. The two tests and the docs/training.md and docs/backends-and-ops.md edits listed above are unchanged.

ousamabenyounes and others added 8 commits October 27, 2026 01:22
Addresses the review on MakazhanAlpamys#1196:

- cap_unpaired rebuilds completion_* from the untruncated answer_input_ids /
  prompt_input_ids. TRL's KTO/BCO tokenizer slices the answer to fit max_length
  before Soup's cap runs, so an over-length prompt left completion_input_ids with
  only the trailing EOS; re-deriving from TRL's own truncated column could never
  recover the answer.
- DEFAULT_PROMPT_TRUNCATION_MODE switches to keep_end. KTOConfig has no
  truncation_mode field, so KTO caps its prompt through this default; keep_end
  matches TRL's historical KTO/BCO/CPO/ORPO direction and preserves a chat
  template's generation header at the prompt tail.
- Tests: per-row assertion that the unmasked completion tokens equal the
  truncated answer (including a non-EOS token), a long-completion fixture, and a
  keep_end direction pin. The content check reads completion_labels, never the
  unused answer_input_ids column.
- Rename the changelog fragment to 1196.fixed.md and give it the required list
  item and attribution.
v0.76.0 is released, so the fragment belongs under changelog.d/0.76.0/.
Keeping it under 0.75.1 fails test_repository_fragments_match_the_newest_release
once this branch contains main.
TRL builds KL rows with [a[-1]] + a[:-1] inside each batch chunk, so row i
borrows row i-1's answer; _rotated_answer_index returned i+1. Both directions
coincide at batch 2, which hid the bug from the existing tests.

- follow TRL's predecessor rotation in _rotated_answer_index
- KL tests use an independent oracle and a batch-4 case that separates
  the two directions
- pin the EOS slot in answer_limit with an over-long prompt plus long
  completion case
- docs: IPO follows DPO's truncation_mode (keep_start by default)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aired rows

_rebuild_unpaired_sequence appended EOS unconditionally, so a valid short
completion whose answer_input_ids already end in the stop token ([good, </s>])
was rebuilt as [good, </s>, </s>] on the matched and KL targets whenever an
overlong prompt forced the rebuild. TRL appends EOS only when absent; split the
terminal EOS off the answer body before enforcing the budget and re-appending.

Adds a per-row content regression that compares the unmasked completion and KL
tokens against the exact answer oracle and asserts a single EOS.
The cap now runs only when a prepared row overflows data.max_length for
SimPO/KTO/IPO/BCO, so the two boundary tests that reached main (MakazhanAlpamys#2116 SimPO
prompt-budget, MakazhanAlpamys#2111 KTO source check) encoded the old unconditional rule and
turned the test job red. Rewrite the SimPO boundary case to assert an over-
budget prompt is left whole when the row fits and cut to max_length // 2 only
when it overflows, drop the stale KTO source assertion, and add the review's
coverage block pinning the eval-split cap, KL rotation chunk bounds, prompt
budget/direction, mask lengths, exact-fit no-op, and single-EOS rebuild.

Move the changelog fragment to 0.77.0 and sync the SimPO/KTO prose in
docs/training.md and docs/backends-and-ops.md to the conditional rule.
@ousamabenyounes

Copy link
Copy Markdown
Contributor Author

Rebased onto main; fixed conditional trimming, answer preservation and EOS/KL handling, added the requested coverage tests, and updated the docs and 0.77.0 changelog. RED/GREEN verified; 308 tests passed.

This branch has not been deployed

No deployments
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.

SimPO and KTO rows run past max_length on trl 0.29: the two wrappers never call enforce_preference_sequence_limit

3 participants