Skip to content

Commit 8015991

Browse files
committed
Break 8B JSON runaway loops with a retry-only repetition penalty
Some posters drive the stock Llama-3.1-8B into a repetition loop that re-emits the creators array and never closes the JSON, grinding to the 18k token cap and producing an unparseable [ERR] result (e.g. 16083265). Fix, without touching the healthy path: - _generate() now returns (text, hit_eos); hit_eos is False when a pass reaches the token cap without emitting EOS -- the runaway signature. - extract_json_with_retry() retries on that signal in addition to parse errors. This matters because the robust parser can salvage a truncated runaway into a parseable-but-gutted object (no error key) that would otherwise skip the retry. - The retry/fallback passes apply repetition_penalty=1.15 to break the loop. It is NOT applied on the primary pass, so healthy posters (which emit EOS on pass 1 and never retry) stay byte-identical. 1.15 was tuned empirically -- 1.3 corrupts the verbatim transcript. 16083265 end-to-end: [ERR] (0 sections) -> PASS (16/16 sections, rougeL 0.86); healthy control 10890106 unchanged. Doc updated.
1 parent 2ca78bc commit 8015991

2 files changed

Lines changed: 74 additions & 26 deletions

File tree

llama_generation_settings.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ All settings live in `poster2json/extract.py`. Code anchors are given per settin
1212

1313
| Setting | Value | Where |
1414
|---------|-------|-------|
15-
| Decoding | greedy (`do_sample=False`) | `_generate()`, line 1357 |
15+
| Decoding | greedy (`do_sample=False`) | `_generate()` |
1616
| Sampling params | none (no `temperature` / `top_p` / `top_k` / `num_beams`) | `_generate()` |
1717
| Primary output budget | `MAX_JSON_TOKENS = 18000` | line 46 |
1818
| Retry/fallback budget | `MAX_RETRY_TOKENS = 24000` | line 47 |
19+
| Retry repetition penalty | `RETRY_REPETITION_PENALTY = 1.15` (retry/fallback only) | line 50 |
1920
| Input gate | `MAX_INPUT_TOKENS = 15000` | line 48 |
2021
| EOS handling | `_JsonBraceProcessor` (custom `LogitsProcessor`) | line 1282 |
2122
| Default quantization | 4-bit NF4 (`bnb_4bit_compute_dtype=bfloat16`, double-quant) | `load_json_model()`, line 1227 |
@@ -62,14 +63,18 @@ The model's native context is 128K, so 15k-in + 24k-out sits comfortably inside
6263

6364
### Retry ladder
6465

65-
`extract_json_with_retry()` (line 2251) escalates only when the parse fails or the output looks truncated:
66+
`extract_json_with_retry()` escalates when the parse fails, the output looks truncated, **or the pass ran to the token cap without emitting EOS** (`hit_eos == False`):
6667

67-
1. **Primary** — full `EXTRACTION_PROMPT` @ `MAX_JSON_TOKENS` (18k).
68-
2. **Retry** — same full prompt @ `MAX_RETRY_TOKENS` (24k), if step 1 errored or truncated.
69-
3. **Fallback** — shorter `FALLBACK_PROMPT` @ `MAX_RETRY_TOKENS` (24k), if step 2 still errored or truncated.
68+
1. **Primary** — full `EXTRACTION_PROMPT` @ `MAX_JSON_TOKENS` (18k), plain greedy (no penalty).
69+
2. **Retry** — same full prompt @ `MAX_RETRY_TOKENS` (24k) with `repetition_penalty = RETRY_REPETITION_PENALTY`, if step 1 errored, was truncated, or hit the cap without EOS.
70+
3. **Fallback** — shorter `FALLBACK_PROMPT` @ `MAX_RETRY_TOKENS` (24k), same penalty, if step 2 still failed those checks.
7071

7172
Each step is followed by `_robust_json_parse()` (hand-rolled repair passes, then the `json-repair` library as a last resort). The ladder is cheap in the common case — most posters succeed on step 1 and never pay for the retries.
7273

74+
**The `hit_eos` trigger.** `_generate()` returns `(text, hit_eos)`; `hit_eos` is False when generation reached `max_new_tokens` without the brace processor ever letting an EOS through — the signature of a runaway that never closed the JSON. This condition is required in addition to the parse-error check because `_robust_json_parse()` is strong enough to salvage a truncated runaway into a *parseable but gutted* object (e.g. a single section), which carries no `"error"` key and would otherwise skip the retry that actually repairs it.
75+
76+
**Why a repetition penalty only on retry.** A few posters drive the stock 8B into a repetition loop — it re-emits the `creators` array indefinitely and never closes the top-level object, grinding to the 18k cap and yielding `[ERR]`. A gentle `repetition_penalty` breaks the loop. It is applied **only on the retry/fallback passes**: healthy posters emit EOS on the primary pass and never reach the retry, so their output stays byte-identical to plain greedy. The value **1.15** was tuned empirically — 1.3 overshoots, corrupting the verbatim transcript the task depends on (`Genta -> Gentaa`, `drug polymer interactions -> drugpolymerinteractions`), while 1.15 breaks the loop and recovers a clean, fully-sectioned extraction. On the poster that motivated this (16083265), it moved the end-to-end result from `[ERR]` (0 sections) to a passing extraction (16/16 sections, rougeL 0.86).
77+
7378
## Quantization
7479

7580
Default is **4-bit NF4**:
@@ -96,7 +101,7 @@ Flash Attention 2 is used automatically when `flash_attn` is importable; otherwi
96101
## What we deliberately did *not* do
97102

98103
- **No fine-tuning.** See note at top — stock instruct weights.
99-
- **No sampling.** Greedy only; reproducibility beats diversity for this task.
104+
- **No sampling.** Greedy only (`do_sample=False`); reproducibility beats diversity for this task. Generation stays deterministic even on the retry: `repetition_penalty` reshapes the greedy logits but adds no randomness, so the same poster still yields byte-identical JSON every run.
100105
- **No `min_new_tokens` for completeness.** It only guards one EOS token; the brace processor is the correct mechanism.
101106
- **No silent input truncation.** Over-long posters error out instead of being cut.
102107

poster2json/extract.py

Lines changed: 63 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@
4747
MAX_RETRY_TOKENS = 24000
4848
MAX_INPUT_TOKENS = 15000
4949

50+
# Runaway-loop guard. Some posters send the stock 8B into a repetition loop
51+
# (re-emitting the creators array) that never closes the JSON and grinds to the
52+
# token cap. The primary pass stays byte-identical (penalty 1.0); a runaway is
53+
# detected by the primary generation hitting the token cap without ever emitting
54+
# EOS, which triggers the retry. The retry/fallback passes apply a gentle
55+
# repetition penalty that breaks the loop while leaving the verbatim transcript
56+
# intact -- 1.15 was tuned empirically: 1.3 overshoots and corrupts author names
57+
# ("Genta" -> "Gentaa") and concatenates words, while 1.15 recovers a clean,
58+
# fully-sectioned extraction. A healthy poster emits EOS on the primary pass and
59+
# never reaches the retry, so its output is unchanged.
60+
RETRY_REPETITION_PENALTY = 1.15
61+
5062
# Schema URL
5163
SCHEMA_URL = "https://posters.science/schema/v0.2/poster_schema.json"
5264

@@ -1511,36 +1523,56 @@ def __call__(self, input_ids, scores):
15111523
return scores
15121524

15131525

1514-
def _generate(model, tokenizer, prompt: str, max_tokens: int) -> str:
1515-
"""Generate response using the Llama model."""
1526+
def _generate(
1527+
model, tokenizer, prompt: str, max_tokens: int, repetition_penalty: float = 1.0
1528+
):
1529+
"""Generate response using the Llama model.
1530+
1531+
repetition_penalty defaults to 1.0 (a no-op: identical to plain greedy) so
1532+
the primary pass is unchanged. The retry/fallback passes raise it to break
1533+
runaway repetition loops on posters whose primary pass never closed the JSON.
1534+
1535+
Returns (text, hit_eos). hit_eos is False when generation ran to the token
1536+
cap without emitting an EOS token -- the signature of a runaway that never
1537+
closed the JSON -- which the retry ladder uses to decide whether to retry.
1538+
"""
15161539
messages = [{"role": "user", "content": prompt}]
15171540
input_text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
15181541
inputs = tokenizer(input_text, return_tensors="pt").to(model.device)
15191542
input_length = inputs["input_ids"].shape[1]
15201543

1521-
log(f"Generating with max_tokens={max_tokens}, input_length={input_length}")
1544+
log(
1545+
f"Generating with max_tokens={max_tokens}, input_length={input_length}, "
1546+
f"repetition_penalty={repetition_penalty}"
1547+
)
15221548

15231549
eos_ids = _get_eos_token_ids(tokenizer)
15241550
processor = _JsonBraceProcessor(eos_ids, tokenizer, input_length)
15251551

1526-
streamer = ProgressStreamer(tokenizer, log_every=200)
1552+
gen_kwargs = dict(
1553+
max_new_tokens=max_tokens,
1554+
do_sample=False,
1555+
pad_token_id=tokenizer.eos_token_id,
1556+
streamer=ProgressStreamer(tokenizer, log_every=200),
1557+
logits_processor=LogitsProcessorList([processor]),
1558+
)
1559+
# Only pass the kwarg when it changes behaviour, so the primary pass stays
1560+
# byte-identical to the pre-fix output.
1561+
if repetition_penalty and repetition_penalty != 1.0:
1562+
gen_kwargs["repetition_penalty"] = repetition_penalty
1563+
15271564
t0 = time.time()
15281565
with torch.no_grad():
1529-
outputs = model.generate(
1530-
**inputs,
1531-
max_new_tokens=max_tokens,
1532-
do_sample=False,
1533-
pad_token_id=tokenizer.eos_token_id,
1534-
streamer=streamer,
1535-
logits_processor=LogitsProcessorList([processor]),
1536-
)
1566+
outputs = model.generate(**inputs, **gen_kwargs)
15371567
elapsed = time.time() - t0
15381568
tokens_generated = outputs.shape[1] - input_length
1569+
hit_eos = outputs[0][-1].item() in eos_ids
15391570
log(
1540-
f" Generated {tokens_generated} tokens in {elapsed:.2f}s ({tokens_generated/elapsed:.1f} tok/s)"
1571+
f" Generated {tokens_generated} tokens in {elapsed:.2f}s "
1572+
f"({tokens_generated/elapsed:.1f} tok/s), hit_eos={hit_eos}"
15411573
)
15421574

1543-
return tokenizer.decode(outputs[0][input_length:], skip_special_tokens=True)
1575+
return tokenizer.decode(outputs[0][input_length:], skip_special_tokens=True), hit_eos
15441576

15451577

15461578
# ============================
@@ -3137,24 +3169,35 @@ def extract_json_with_retry(
31373169
prompt = EXTRACTION_PROMPT.format(raw_text=raw_text)
31383170

31393171
log("Starting primary JSON extraction with full prompt")
3140-
response = _generate(model, tokenizer, prompt, MAX_JSON_TOKENS)
3172+
response, hit_eos = _generate(model, tokenizer, prompt, MAX_JSON_TOKENS)
31413173
result = _as_result_dict(_robust_json_parse(response))
31423174
if "error" in result:
31433175
log(f"Primary JSON parse error: {result['error']}")
31443176
else:
31453177
log("Primary JSON parse succeeded")
31463178

3147-
# Retry with more tokens if truncation detected
3148-
if "error" in result or _is_truncated(result.get("raw", "")):
3179+
# Retry if the primary pass failed to parse, looks truncated, or ran to the
3180+
# token cap without emitting EOS (a runaway loop). The last condition is
3181+
# essential: the robust parser can salvage a truncated runaway into a
3182+
# parseable-but-gutted object (e.g. a single section), which has no "error"
3183+
# key and would otherwise skip the retry that actually fixes it. Apply the
3184+
# repetition penalty on the retry (not the primary pass) to break the loop.
3185+
if "error" in result or not hit_eos or _is_truncated(result.get("raw", "")):
31493186
log(f"Retrying with max_tokens={MAX_RETRY_TOKENS}")
3150-
response = _generate(model, tokenizer, prompt, MAX_RETRY_TOKENS)
3187+
response, hit_eos = _generate(
3188+
model, tokenizer, prompt, MAX_RETRY_TOKENS,
3189+
repetition_penalty=RETRY_REPETITION_PENALTY,
3190+
)
31513191
result = _as_result_dict(_robust_json_parse(response))
31523192

31533193
# Fallback to shorter prompt
3154-
if "error" in result or _is_truncated(result.get("raw", "")):
3194+
if "error" in result or not hit_eos or _is_truncated(result.get("raw", "")):
31553195
log("Using fallback shorter prompt")
31563196
fallback_prompt = FALLBACK_PROMPT.format(raw_text=raw_text)
3157-
response = _generate(model, tokenizer, fallback_prompt, MAX_RETRY_TOKENS)
3197+
response, hit_eos = _generate(
3198+
model, tokenizer, fallback_prompt, MAX_RETRY_TOKENS,
3199+
repetition_penalty=RETRY_REPETITION_PENALTY,
3200+
)
31583201
result = _as_result_dict(_robust_json_parse(response))
31593202

31603203
result = _postprocess_json(

0 commit comments

Comments
 (0)