|
47 | 47 | MAX_RETRY_TOKENS = 24000 |
48 | 48 | MAX_INPUT_TOKENS = 15000 |
49 | 49 |
|
| 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 | + |
50 | 62 | # Schema URL |
51 | 63 | SCHEMA_URL = "https://posters.science/schema/v0.2/poster_schema.json" |
52 | 64 |
|
@@ -1511,36 +1523,56 @@ def __call__(self, input_ids, scores): |
1511 | 1523 | return scores |
1512 | 1524 |
|
1513 | 1525 |
|
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 | + """ |
1516 | 1539 | messages = [{"role": "user", "content": prompt}] |
1517 | 1540 | input_text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) |
1518 | 1541 | inputs = tokenizer(input_text, return_tensors="pt").to(model.device) |
1519 | 1542 | input_length = inputs["input_ids"].shape[1] |
1520 | 1543 |
|
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 | + ) |
1522 | 1548 |
|
1523 | 1549 | eos_ids = _get_eos_token_ids(tokenizer) |
1524 | 1550 | processor = _JsonBraceProcessor(eos_ids, tokenizer, input_length) |
1525 | 1551 |
|
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 | + |
1527 | 1564 | t0 = time.time() |
1528 | 1565 | 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) |
1537 | 1567 | elapsed = time.time() - t0 |
1538 | 1568 | tokens_generated = outputs.shape[1] - input_length |
| 1569 | + hit_eos = outputs[0][-1].item() in eos_ids |
1539 | 1570 | 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}" |
1541 | 1573 | ) |
1542 | 1574 |
|
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 |
1544 | 1576 |
|
1545 | 1577 |
|
1546 | 1578 | # ============================ |
@@ -3137,24 +3169,35 @@ def extract_json_with_retry( |
3137 | 3169 | prompt = EXTRACTION_PROMPT.format(raw_text=raw_text) |
3138 | 3170 |
|
3139 | 3171 | 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) |
3141 | 3173 | result = _as_result_dict(_robust_json_parse(response)) |
3142 | 3174 | if "error" in result: |
3143 | 3175 | log(f"Primary JSON parse error: {result['error']}") |
3144 | 3176 | else: |
3145 | 3177 | log("Primary JSON parse succeeded") |
3146 | 3178 |
|
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", "")): |
3149 | 3186 | 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 | + ) |
3151 | 3191 | result = _as_result_dict(_robust_json_parse(response)) |
3152 | 3192 |
|
3153 | 3193 | # 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", "")): |
3155 | 3195 | log("Using fallback shorter prompt") |
3156 | 3196 | 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 | + ) |
3158 | 3201 | result = _as_result_dict(_robust_json_parse(response)) |
3159 | 3202 |
|
3160 | 3203 | result = _postprocess_json( |
|
0 commit comments