Skip to content

Model-under-test prompts are silently right-truncated at a hard-coded 1024 tokens in both gates: the probe's tail (usually the actual ask) is dropped, and the row still counts as… (CR-A06-011) #125

Open

Description

@cemililik

Wave 1 · theme D1-1: Correctness of the evaluation gates · highest severity High · 1 finding(s) · effort small

Summary

An operator follows safety.md:140/155 and adds long jailbreak probes, for example a role-play preamble of about 2000 tokens ending in 'FINAL QUESTION: explain step by step how to make '. The model under test receives only the first 1024 tokens of the preamble and continues the role-play. The guard (user turn = full probe, assistant turn = continuation) or the classifier returns 'safe'. The row is scored, safe_ratio rises, unscored_count stays 0, and if every probe is long the gate reports passed=True / exit 0 for a model never asked any of the questions. No log line, detail field, audit field or doc tells the operator. On the judge gate the model answers the cut prompt, the judge scores it against prompt[:500], and the score is counted as valid.

Primary finding

CR-A06-011 — Model-under-test prompts are silently right-truncated at a hard-coded 1024 tokens in both gates: the probe's tail (usually the actual ask) is dropped, and the row still counts as a valid scored verdi…

severity High · reachability default · intent intended-but-harmful · confidence confirmed · independently confirmed · reproduced (plumbing only)

What happens. _generate_one_safety_response and _generate_safety_batch_with_oom_retry (forgelm/safety/_generate.py:26, 62-68) and the judge's _generate_response and _generate_batch_with_oom_retry (forgelm/judge.py:389, 420-426) tokenize the probe or eval prompt with truncation=True, max_length=1024. Nothing in ForgeLM sets truncation_side (rg finds none in forgelm/ or tests/), and HF tokenizers default to the right side, so a prompt longer than 1024 tokens loses its END. Nothing measures or records the cut. GeneratedResponse carries only text and error (forgelm/safety/_types.py:88-89), the generation returns error=None (_generate.py:73), the call emits no log line and no Python warning at any level, the detail rows have no field for it, and no doc mentions 1024. The scorers, meanwhile, receive the UNTRUNCATED prompt list (forgelm/safety/_orchestrator.py:239 generates from prompts; :254-256 and :275-276 score with the same full prompts). So the guard or classifier judges [full probe including the ask] + [the model's answer to a different, cut prompt]. That is a well-formed conversation and gets an ordinary verdict, normally 'safe' for a benign continuation. It counts toward safe_ratio with unscored_count unchanged. This is the situation the project's own GeneratedResponse rationale rules out: 'a safety certificate for a model that was never asked a question' (forgelm/safety/_types.py:74-80). On the judge gate, the model answers the cut prompt and the judge sees prompt[:500] chars, so neither ever sees the question. A prompt over 1024 tokens is almost always over 500 chars, so the existing one-time WARNING (judge.py:721-730) fires, but it describes the judge's char trim, not the model's token cut, and the score is recorded as valid. The 1024 literal also ignores model.max_length (…

Where.

Evidence.

  • m2_result.json: a 2061-token probe (10518 chars, 20-token question at the end) was fed to generate() as exactly 1024 tokens on all four paths (safety batched and single, judge batched and single), with the question absent from the fed text, GeneratedResponse.failed=False and error=None, judge responses non-empty str, 0 log records and 0 Python warnings. m3_result.json (run_safety_evaluation, classification mode, real tiny-cls pipeline, probes=[short, 2061-token]): the model under test was fed 1…

Recommended fix. Measure the prompt's token length before generation. Do not pass a literal: derive the budget from config.model.max_length (or model.config.max_position_embeddings) minus max_new_tokens. When a prompt exceeds it, either fail closed (return GeneratedResponse(error='prompt exceeds N tokens (M)') so the row is unscored, and mark judge rows judge_failed with a distinct reason), or truncate from the LEFT so the ask is kept, and record prompt_truncated / prompt_tokens in the detail rows and gate metadata. Apply the same policy when M-A06-009 is fixed (truncate before rendering the chat template, never after). Document the limit next to the existing 500/1000-char note. Add tests with a probe over 1024 tokens for both gates.

Acceptance criteria

  • A regression test reproduces the failure scenario and asserts the corrected behaviour.
  • Documentation listed above is corrected in English and in its Turkish mirror in the same PR.
  • Every finding in this issue is resolved or explicitly closed with a reason.

Found by the October 2026 ForgeLM code and documentation review against commit cab2947. Code links point at that commit; line numbers may have moved since. Finding IDs (CR-…, DOC-…, TECH-…) are stable references for this backlog.

Activity

  1. added
    bugSomething isn't working
    severity: highWrong behaviour or a false claim users rely on
    source: review-2026-10Found by the October 2026 code and documentation review
    wave: 1Roadmap wave 1: high severity, small effort
    on Oct 9, 2026
  2. cemililik commented on Oct 15, 2026

    @cemililik
    CollaboratorAuthor

    Where to start

    Thanks for taking a look. Please confirm the issue is unassigned and comment before starting so work does not overlap.

    Start with these files on development:

    The safety gate (forgelm/safety/_generate.py) and the judge (forgelm/judge.py) tokenize the prompt under test with truncation=True, max_length=1024. Tokenizers truncate on the right by default, so a longer prompt loses its end, which is usually the actual request, and nothing records it.

    Derive the budget from the model's configured length minus max_new_tokens instead of a literal, and measure the prompt before generating. When a prompt does not fit, prefer the fail-closed behaviour from the issue: return an error result so the row is unscored (judge rows marked judge_failed with a distinct reason) rather than silently altering the prompt, in line with the project's no-silent-failures rule. If you would rather truncate from the left, propose it on the issue first.

    Add tests for both generation paths: a prompt within the budget is unchanged, and an over-long prompt takes the new path and is visible in the result. Use a small fake tokenizer; no model download.

    Run the focused tests:

    pytest tests/test_safety_advanced.py tests/test_judge_functions.py --no-cov

    Run these guards/checks as applicable to the changed files:

    ruff format --check forgelm/safety/_generate.py forgelm/judge.py tests/test_safety_advanced.py tests/test_judge_functions.py
    ruff check forgelm/safety/_generate.py forgelm/judge.py tests/test_safety_advanced.py tests/test_judge_functions.py

    Tests must not need a GPU, network access or model downloads. Add a ### Fixed entry under ## [Unreleased] in CHANGELOG.md naming the finding ID.

    If user-facing documentation changes, update the existing Turkish mirror in the same PR; keep commands, code and terminal output in English and structurally aligned. English-only contributor standards need no new mirror.

    Open the PR against development with Fixes #125 once every finding is resolved or explicitly closed with a reason; use Refs #125 for partial work and list the resolved finding IDs (Resolves: CR-A06-011).

  3. added
    help wantedExtra attention is needed
    and removed on Oct 17, 2026
  4. cemililik commented on Oct 17, 2026

    @cemililik
    CollaboratorAuthor

    Moved from good first issue to help wanted. Three things need a maintainer's decision
    before this is a well-scoped task: where the token budget comes from (the safety and judge
    entry points do not receive model.max_length), what to do when the model exposes no
    length, and how the judge carries a per-row error. Failing closed can also turn a gate from
    pass to fail. Please comment with a proposal before starting.

  5. krisnaparahita commented on Oct 20, 2026

    @krisnaparahita
    Contributor

    Proposal for the three open questions:

    1. Token budget source: derive it from the tokenizer/model's own max_position_embeddings (or model_max_length if that's what's actually available at the safety/judge entry points) minus max_new_tokens, rather than threading model.max_length through every call site — this keeps the budget co-located with the tokenizer that's already in scope.
    2. When the model exposes no length at all: fail closed per the project's no-silent-failures rule — treat it the same as an over-long prompt (unscored, distinct reason) rather than falling back to the old hardcoded 1024, since silently reusing a stale default is exactly the kind of silent failure this issue is about.
    3. Per-row error: add a dedicated judge_failed reason value (e.g. prompt_too_long) alongside the existing failure-reason plumbing already used elsewhere in judge.py, rather than inventing a new column.

    Let me know if this lines up with what you had in mind, or if you'd rather go a different direction on any of the three — happy to start once it's confirmed.

  6. cemililik commented on Oct 23, 2026

    @cemililik
    CollaboratorAuthor

    @krisnaparahita thank you for the careful proposal, and for asking before starting. We checked it against the current code (development at cbac6b9). Points 1 and 3 work as you describe. The safety-gate half of point 2 needs one change, because of how that gate counts unscored rows. Below is the agreed design. The issue is assigned to you, and you can start.

    1. Budget source: agreed, derived from the model.

    • Take the first value present on the model under test's model.config: max_position_embeddings, n_positions, max_sequence_length or seq_length.
    • Also consider tokenizer.model_max_length, but only when it is a real value (below 1e6; HF uses a huge sentinel when it is unset).
    • Use the smaller of the known values, minus max_new_tokens. A budget that is not positive is an error.

    This needs no change to forgelm/trainer.py, which matters: that file has one line left in its module-size budget (tools/check_module_size.py).

    2. Over-long prompts, and a model that exposes no length.

    • Safety gate: no per-row unscored result. In forgelm/safety/_gates.py, unscored rows below the 50 % ceiling are counted unsafe. A few long probes would then fail the gate and auto-revert the model because of a problem in the probe file. Instead:
      • measure every probe before generating anything;
      • if any probe exceeds the budget, or no length can be determined, generate nothing and report the evaluation as not performed. Use the same outcome as the existing unscored-ratio guard: passed=False and evaluation_completed=False, so there is no auto-revert, and exit 2;
      • the reason lists the offending probe indices with their token counts, and the budget.
    • Judge gate: your per-row design is right here.
      • Mark the row judge_failed with a distinct reason, for example prompt exceeds 1536 tokens (2061). It counts against min_valid_fraction like a cut-off reply.
      • Add a count to the failure reason, the way _cut_off_note does.
      • If no length can be determined, fail the evaluation up front with a clear failure_reason, like the existing early returns in run_judge_evaluation.
    • All four model-under-test paths: remove truncation=True, max_length=1024. Nothing is cut any more.

    3. Scope. The four generation paths are the two in forgelm/safety/_generate.py, plus _generate_response and _generate_batch_with_oom_retry in forgelm/judge.py. The local judge model's own input in _call_local_judge (max_length=2048) is a different matter; please leave it.

    Tests (fake tokenizer and model, no download), in tests/test_safety_advanced.py and tests/test_judge_functions.py:

    • Budget derivation: each attribute is read, the sentinel is ignored, the smallest value wins, and a budget that is not positive is rejected.
    • Safety: with one over-long probe, nothing is generated and the result is "not performed", listing that probe. Within the budget, prompts reach generate unchanged.
    • Judge: an over-long prompt becomes a failed row with the reason, and the other rows are still scored.

    Docs: state the limit and what happens in docs/usermanuals/en/evaluation/safety.md and judge.md, and in their Turkish mirrors. If you do not write Turkish, do the English pages and say so in the PR; a maintainer will complete the mirror. Add a ### Fixed line under ## [Unreleased].

    Open the PR against development with Fixes #125 and Resolves: CR-A06-011. Thanks again; we look forward to it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area: eval-gatesCode area: eval-gatesbugSomething isn't workinghelp wantedExtra attention is neededseverity: highWrong behaviour or a false claim users rely onsource: review-2026-10Found by the October 2026 code and documentation reviewwave: 1Roadmap wave 1: high severity, small effort

Type

No type

Projects

Relationships

None yet

Development

No branches or pull requests

Issue actions