Repository navigation
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
Description
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.
forgelm/safety/_generate.py:26forgelm/safety/_generate.py:73forgelm/judge.py:389forgelm/judge.py:420-426forgelm/safety/_orchestrator.py:254-256forgelm/safety/_orchestrator.py:275-276forgelm/safety/_score_classification.py:44forgelm/safety/_score_generation.py:139-148forgelm/safety/_types.py:61-89forgelm/judge.py:721-734
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.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTodo
Activity
cemililik commentedon Oct 15, 2026
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 withtruncation=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_tokensinstead 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 markedjudge_failedwith 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:
Run these guards/checks as applicable to the changed files:
Tests must not need a GPU, network access or model downloads. Add a
### Fixedentry under## [Unreleased]inCHANGELOG.mdnaming 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
developmentwithFixes #125once every finding is resolved or explicitly closed with a reason; useRefs #125for partial work and list the resolved finding IDs (Resolves: CR-A06-011).cemililik commentedon Oct 17, 2026
Moved from
good first issuetohelp wanted. Three things need a maintainer's decisionbefore 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 nolength, 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.
krisnaparahita commentedon Oct 20, 2026
Proposal for the three open questions:
max_position_embeddings(ormodel_max_lengthif that's what's actually available at the safety/judge entry points) minusmax_new_tokens, rather than threadingmodel.max_lengththrough every call site — this keeps the budget co-located with the tokenizer that's already in scope.judge_failedreason 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.
cemililik commentedon Oct 23, 2026
@krisnaparahita thank you for the careful proposal, and for asking before starting. We checked it against the current code (
developmentatcbac6b9). 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.
model.config:max_position_embeddings,n_positions,max_sequence_lengthorseq_length.tokenizer.model_max_length, but only when it is a real value (below 1e6; HF uses a huge sentinel when it is unset).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.
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:passed=Falseandevaluation_completed=False, so there is no auto-revert, and exit 2;judge_failedwith a distinct reason, for exampleprompt exceeds 1536 tokens (2061). It counts againstmin_valid_fractionlike a cut-off reply._cut_off_notedoes.failure_reason, like the existing early returns inrun_judge_evaluation.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_responseand_generate_batch_with_oom_retryinforgelm/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.pyandtests/test_judge_functions.py:generateunchanged.Docs: state the limit and what happens in
docs/usermanuals/en/evaluation/safety.mdandjudge.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### Fixedline under## [Unreleased].Open the PR against
developmentwithFixes #125andResolves: CR-A06-011. Thanks again; we look forward to it.