Skip to content

LLM reference segmenter and citation engine - #702

Draft
de-code wants to merge 12 commits into
mainfrom
llm-reference-segmenter-engine
Draft

LLM reference segmenter and citation engine#702
de-code wants to merge 12 commits into
mainfrom
llm-reference-segmenter-engine

Conversation

@de-code

@de-code de-code commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

ScienceBeam Parser Evaluation

Overall (55 docs across 6 corpora)

grobid 0.9.0-crf (default): 60 docs | sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf): 60 docs | sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation): 55 docs

⚠️ Unequal document sets (grobid 0.9.0-crf (default) 60, sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf) 60, sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation) 55). Deltas below reflect which documents each run covered as well as how it performed.

Field (method) Type grobid 0.9.0-crf (default) sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf) sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation) Δ grobid 0.9.0-crf (default) Δ sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf)
title (exact) string 0.582 0.572 0.540 -0.042 -0.032
title (levenshtein) string 0.657 0.657 0.608 -0.049 -0.049
title (edit_sim) string 0.701 0.701 0.678 -0.023 -0.023
abstract (levenshtein) string 0.709 0.709 0.724 +0.015 +0.015
abstract (edit_sim) string 0.721 0.716 0.737 +0.016 +0.021
author_full_names (levenshtein) partial_ulist 0.720 0.706 0.705 -0.015 -0.001
author_full_names (edit_sim) partial_ulist 0.746 0.735 0.729 -0.017 -0.006
affiliation_text (levenshtein) partial_ulist 0.471 0.446 0.440 -0.031 -0.006
affiliation_text (edit_sim) partial_ulist 0.582 0.544 0.542 -0.040 -0.001
keywords (levenshtein) partial_ulist 0.766 0.000 0.000 -0.766 +0.000
keywords (edit_sim) partial_ulist 0.695 0.000 0.000 -0.695 +0.000
body_section_titles (levenshtein) partial_list 0.346 0.328 0.328 -0.017 +0.000
body_section_titles (edit_sim) partial_list 0.353 0.345 0.350 -0.004 +0.005
acknowledgement (levenshtein) string 0.474 0.361 0.389 -0.084 +0.028
acknowledgement (edit_sim) string 0.473 0.356 0.383 -0.091 +0.026
first_reference_text (levenshtein) string 0.552 0.552 0.554 +0.002 +0.002
first_reference_text (edit_sim) string 0.667 0.666 0.665 -0.002 -0.001
reference_title (levenshtein) partial_list 0.455 0.466 0.533 +0.077 +0.066
reference_title (edit_sim) partial_list 0.462 0.480 0.532 +0.071 +0.052
reference_doi (levenshtein) partial_ulist 0.711 0.567 0.588 -0.123 +0.021
reference_doi (edit_sim) partial_ulist 0.668 0.532 0.563 -0.105 +0.031
biorxiv (9 docs)

grobid 0.9.0-crf (default): 10 docs | sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf): 10 docs | sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation): 9 docs

⚠️ Unequal document sets (grobid 0.9.0-crf (default) 10, sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf) 10, sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation) 9). Deltas below reflect which documents each run covered as well as how it performed.

Field (method) Type grobid 0.9.0-crf (default) sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf) sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation) Δ grobid 0.9.0-crf (default) Δ sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf)
title (exact) string 0.947 0.889 0.875 -0.072 -0.014
title (levenshtein) string 1.000 1.000 1.000 +0.000 +0.000
title (edit_sim) string 1.000 1.000 1.000 +0.000 +0.000
abstract (levenshtein) string 0.750 0.750 0.800 +0.050 +0.050
abstract (edit_sim) string 0.754 0.755 0.817 +0.062 +0.061
author_full_names (levenshtein) partial_ulist 0.994 0.994 0.994 -0.000 -0.000
author_full_names (edit_sim) partial_ulist 0.972 0.972 0.970 -0.002 -0.002
affiliation_text (levenshtein) partial_ulist 0.873 0.842 0.835 -0.038 -0.007
affiliation_text (edit_sim) partial_ulist 0.846 0.817 0.810 -0.036 -0.007
keywords (levenshtein) partial_ulist 0.966 0.000 0.000 -0.966 +0.000
keywords (edit_sim) partial_ulist 0.951 0.000 0.000 -0.951 +0.000
body_section_titles (levenshtein) partial_list 0.275 0.277 0.272 -0.003 -0.005
body_section_titles (edit_sim) partial_list 0.294 0.302 0.302 +0.007 +0.000
acknowledgement (levenshtein) string 0.875 0.500 0.545 -0.330 +0.045
acknowledgement (edit_sim) string 0.836 0.502 0.536 -0.300 +0.034
first_reference_text (levenshtein) string 0.947 0.947 0.941 -0.006 -0.006
first_reference_text (edit_sim) string 0.913 0.913 0.904 -0.009 -0.009
reference_title (levenshtein) partial_list 0.702 0.804 0.843 +0.141 +0.039
reference_title (edit_sim) partial_list 0.660 0.764 0.801 +0.141 +0.037
reference_doi (levenshtein) partial_ulist 0.964 0.942 0.981 +0.018 +0.039
reference_doi (edit_sim) partial_ulist 0.932 0.931 0.957 +0.025 +0.026
ore (9 docs)

grobid 0.9.0-crf (default): 10 docs | sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf): 10 docs | sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation): 9 docs

⚠️ Unequal document sets (grobid 0.9.0-crf (default) 10, sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf) 10, sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation) 9). Deltas below reflect which documents each run covered as well as how it performed.

Field (method) Type grobid 0.9.0-crf (default) sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf) sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation) Δ grobid 0.9.0-crf (default) Δ sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf)
title (exact) string 0.667 0.667 0.714 +0.048 +0.048
title (levenshtein) string 0.750 0.750 0.800 +0.050 +0.050
title (edit_sim) string 0.840 0.840 0.866 +0.026 +0.026
abstract (levenshtein) string 0.824 0.824 0.800 -0.024 -0.024
abstract (edit_sim) string 0.805 0.807 0.786 -0.019 -0.021
author_full_names (levenshtein) partial_ulist 0.783 0.777 0.779 -0.003 +0.002
author_full_names (edit_sim) partial_ulist 0.775 0.770 0.772 -0.003 +0.002
affiliation_text (levenshtein) partial_ulist 0.565 0.517 0.526 -0.039 +0.009
affiliation_text (edit_sim) partial_ulist 0.574 0.522 0.531 -0.043 +0.009
keywords (levenshtein) partial_ulist 0.500 0.000 0.000 -0.500 +0.000
keywords (edit_sim) partial_ulist 0.446 0.000 0.000 -0.446 +0.000
body_section_titles (levenshtein) partial_list 0.279 0.323 0.350 +0.071 +0.027
body_section_titles (edit_sim) partial_list 0.298 0.343 0.363 +0.065 +0.020
acknowledgement (levenshtein) string 0.833 0.833 0.833 +0.000 +0.000
acknowledgement (edit_sim) string 0.870 0.802 0.802 -0.068 +0.000
first_reference_text (levenshtein) string 0.333 0.333 0.364 +0.030 +0.030
first_reference_text (edit_sim) string 0.528 0.528 0.550 +0.022 +0.022
reference_title (levenshtein) partial_list 0.228 0.284 0.344 +0.115 +0.059
reference_title (edit_sim) partial_list 0.265 0.315 0.360 +0.095 +0.044
reference_doi (levenshtein) partial_ulist 0.704 0.012 0.015 -0.689 +0.002
reference_doi (edit_sim) partial_ulist 0.558 0.003 0.003 -0.555 +0.001
pkp (10 docs)

grobid 0.9.0-crf (default): 10 docs | sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf): 10 docs | sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation): 10 docs

Field (method) Type grobid 0.9.0-crf (default) sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf) sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation) Δ grobid 0.9.0-crf (default) Δ sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf)
title (exact) string 0.750 0.750 0.750 +0.000 +0.000
title (levenshtein) string 0.824 0.824 0.824 +0.000 +0.000
title (edit_sim) string 0.805 0.805 0.805 +0.000 +0.000
abstract (levenshtein) string 0.933 0.933 0.933 +0.000 +0.000
abstract (edit_sim) string 0.914 0.915 0.915 +0.001 +0.000
author_full_names (levenshtein) partial_ulist 0.905 0.905 0.905 +0.000 +0.000
author_full_names (edit_sim) partial_ulist 0.917 0.917 0.917 +0.000 +0.000
affiliation_text (levenshtein) partial_ulist 0.667 0.600 0.600 -0.067 +0.000
affiliation_text (edit_sim) partial_ulist 0.743 0.666 0.666 -0.077 +0.000
keywords (levenshtein) partial_ulist 1.000 0.000 0.000 -1.000 +0.000
keywords (edit_sim) partial_ulist 1.000 0.000 0.000 -1.000 +0.000
body_section_titles (levenshtein) partial_list 0.678 0.714 0.714 +0.036 +0.000
body_section_titles (edit_sim) partial_list 0.650 0.685 0.685 +0.035 +0.000
acknowledgement (levenshtein) string 0.800 0.500 0.500 -0.300 +0.000
acknowledgement (edit_sim) string 0.800 0.500 0.500 -0.300 +0.000
first_reference_text (levenshtein) string 1.000 1.000 1.000 +0.000 +0.000
first_reference_text (edit_sim) string 0.995 0.995 0.995 +0.000 +0.000
reference_title (levenshtein) partial_list 0.799 0.770 0.862 +0.063 +0.093
reference_title (edit_sim) partial_list 0.770 0.740 0.832 +0.061 +0.091
reference_doi (levenshtein) partial_ulist 0.889 0.889 0.857 -0.032 -0.032
reference_doi (edit_sim) partial_ulist 0.889 0.889 0.857 -0.032 -0.032
scielo_br (10 docs)

grobid 0.9.0-crf (default): 10 docs | sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf): 10 docs | sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation): 10 docs

Field (method) Type grobid 0.9.0-crf (default) sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf) sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation) Δ grobid 0.9.0-crf (default) Δ sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf)
title (exact) string 0.462 0.462 0.462 +0.000 +0.000
title (levenshtein) string 0.571 0.571 0.571 +0.000 +0.000
title (edit_sim) string 0.628 0.628 0.628 +0.000 +0.000
abstract (levenshtein) string 0.750 0.750 0.750 +0.000 +0.000
abstract (edit_sim) string 0.697 0.692 0.692 -0.005 +0.000
author_full_names (levenshtein) partial_ulist 0.582 0.500 0.500 -0.082 +0.000
author_full_names (edit_sim) partial_ulist 0.635 0.566 0.566 -0.069 +0.000
affiliation_text (levenshtein) partial_ulist 0.000 0.000 0.000 +0.000 +0.000
affiliation_text (edit_sim) partial_ulist 0.357 0.299 0.299 -0.059 +0.000
keywords (levenshtein) partial_ulist 0.614 0.000 0.000 -0.614 +0.000
keywords (edit_sim) partial_ulist 0.544 0.000 0.000 -0.544 +0.000
body_section_titles (levenshtein) partial_list 0.449 0.271 0.271 -0.178 +0.000
body_section_titles (edit_sim) partial_list 0.485 0.354 0.354 -0.131 +0.000
acknowledgement (levenshtein) string 0.000 0.000 0.000 +0.000 +0.000
acknowledgement (edit_sim) string 0.000 0.000 0.000 +0.000 +0.000
first_reference_text (levenshtein) string 0.000 0.000 0.000 +0.000 +0.000
first_reference_text (edit_sim) string 0.391 0.369 0.391 +0.000 +0.022
reference_title (levenshtein) partial_list 0.443 0.457 0.482 +0.039 +0.026
reference_title (edit_sim) partial_list 0.451 0.468 0.484 +0.033 +0.016
reference_doi (levenshtein) partial_ulist 0.965 0.824 0.890 -0.075 +0.067
reference_doi (edit_sim) partial_ulist 0.960 0.718 0.833 -0.127 +0.115
scielo_mx (9 docs)

grobid 0.9.0-crf (default): 10 docs | sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf): 10 docs | sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation): 9 docs

⚠️ Unequal document sets (grobid 0.9.0-crf (default) 10, sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf) 10, sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation) 9). Deltas below reflect which documents each run covered as well as how it performed.

Field (method) Type grobid 0.9.0-crf (default) sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf) sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation) Δ grobid 0.9.0-crf (default) Δ sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf)
title (exact) string 0.333 0.333 0.364 +0.030 +0.030
title (levenshtein) string 0.462 0.462 0.364 -0.098 -0.098
title (edit_sim) string 0.520 0.520 0.461 -0.060 -0.060
abstract (levenshtein) string 0.333 0.333 0.364 +0.030 +0.030
abstract (edit_sim) string 0.550 0.522 0.579 +0.029 +0.057
author_full_names (levenshtein) partial_ulist 0.627 0.627 0.603 -0.024 -0.024
author_full_names (edit_sim) partial_ulist 0.659 0.659 0.640 -0.018 -0.018
affiliation_text (levenshtein) partial_ulist 0.100 0.095 0.105 +0.005 +0.010
affiliation_text (edit_sim) partial_ulist 0.287 0.296 0.301 +0.015 +0.005
keywords (levenshtein) partial_ulist 0.814 0.000 0.000 -0.814 +0.000
keywords (edit_sim) partial_ulist 0.692 0.000 0.000 -0.692 +0.000
body_section_titles (levenshtein) partial_list 0.000 0.000 0.000 +0.000 +0.000
body_section_titles (edit_sim) partial_list 0.000 0.000 0.000 +0.000 +0.000
acknowledgement (levenshtein) string 0.000 0.000 0.000 +0.000 +0.000
acknowledgement (edit_sim) string 0.000 0.000 0.000 +0.000 +0.000
first_reference_text (levenshtein) string 0.571 0.571 0.615 +0.044 +0.044
first_reference_text (edit_sim) string 0.547 0.557 0.559 +0.012 +0.002
reference_title (levenshtein) partial_list 0.264 0.249 0.343 +0.079 +0.094
reference_title (edit_sim) partial_list 0.304 0.302 0.364 +0.061 +0.062
reference_doi (levenshtein) partial_ulist 0.000 0.000 0.000 +0.000 +0.000
reference_doi (edit_sim) partial_ulist 0.000 0.000 0.000 +0.000 +0.000
scielo_preprints-jats (8 docs)

grobid 0.9.0-crf (default): 10 docs | sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf): 10 docs | sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation): 8 docs

⚠️ Unequal document sets (grobid 0.9.0-crf (default) 10, sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf) 10, sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation) 8). Deltas below reflect which documents each run covered as well as how it performed.

Field (method) Type grobid 0.9.0-crf (default) sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf) sciencebeam-parser:pr-702-bee85626-20260820.1336 (llm_citation) Δ grobid 0.9.0-crf (default) Δ sciencebeam-parser:main-41940126-20260820.0246 (grobid_crf)
title (exact) string 0.333 0.333 0.000 -0.333 -0.333
title (levenshtein) string 0.333 0.333 0.000 -0.333 -0.333
title (edit_sim) string 0.411 0.411 0.255 -0.157 -0.157
abstract (levenshtein) string 0.667 0.667 0.667 +0.000 +0.000
abstract (edit_sim) string 0.606 0.605 0.606 +0.001 +0.001
author_full_names (levenshtein) partial_ulist 0.431 0.435 0.419 -0.012 -0.016
author_full_names (edit_sim) partial_ulist 0.521 0.525 0.479 -0.042 -0.046
affiliation_text (levenshtein) partial_ulist 0.620 0.621 0.623 +0.003 +0.002
affiliation_text (edit_sim) partial_ulist 0.688 0.662 0.676 -0.012 +0.014
keywords (levenshtein) partial_ulist 0.704 0.000 0.000 -0.704 +0.000
keywords (edit_sim) partial_ulist 0.535 0.000 0.000 -0.535 +0.000
body_section_titles (levenshtein) partial_list 0.393 0.383 0.325 -0.068 -0.058
body_section_titles (edit_sim) partial_list 0.393 0.385 0.357 -0.035 -0.028
acknowledgement (levenshtein) string 0.333 0.333 0.500 +0.167 +0.167
acknowledgement (edit_sim) string 0.333 0.333 0.500 +0.167 +0.167
first_reference_text (levenshtein) string 0.462 0.462 0.400 -0.062 -0.062
first_reference_text (edit_sim) string 0.627 0.632 0.572 -0.055 -0.060
reference_title (levenshtein) partial_list 0.297 0.235 0.260 -0.037 +0.025
reference_title (edit_sim) partial_list 0.319 0.291 0.300 -0.020 +0.009
reference_doi (levenshtein) partial_ulist 0.744 0.735 0.739 -0.004 +0.004
reference_doi (edit_sim) partial_ulist 0.670 0.653 0.678 +0.008 +0.025

de-code added 11 commits August 20, 2026 14:32
A third sequence-model engine alongside wapiti and delft, reachable only through
one of three opt-in profiles: llm_reference_segmenter, llm_citation, or
llm_references for both. One profile per model so that a failing run says which
model produced it. The shipped default stays grobid_crf, and a default install
acquires no network dependency, credential requirement or new failure mode —
asserted by test rather than intended, including that every other model in each
profile stays on wapiti.

No text reaches a document that was not in the source. The segmenter returns line
numbers into numbered input and never text at all. The citation model returns
field values, which the engine locates back in the token sequence: a value that
cannot be found, or whose tokens an earlier field already claimed, raises. Either
way the existing seam in Model independently rejects any result whose tokens are
not the input tokens.

The two shapes are opposite on purpose. Whatever the model must emit verbatim is
what breaks at scale, so the shape follows the ratio of output to input: a
citation response is shorter than its input, a segmentation response quoting
reference text is as long as it. response_shape is therefore configuration, so
comparing shapes is defining a second profile rather than building a second
evaluation route.

An invalid response raises rather than falling back to a CRF engine, since a
score is only meaningful if every label came from the model under test. A
response cut off at the output limit raises its own error naming finish_reason
and the completion token count, because "truncated" and "malformed" want
different fixes and reporting the first as the second sends you looking for a
parsing bug. Every request pins zero data retention and fails closed, and a :free
model id is refused at load because that tier requires allowing training on
prompts.

Three details that came from measurement rather than taste. The line_status
column is resolved by name through the task's own data generator, because the
feature layout differs per model and a wrong column produces plausible output
with no exception. Predicted boundaries snap onto a preceding bare-label line,
because models point at the line where reference text starts while the training
convention puts the boundary on the line holding the number — and stating that in
the prompt did not fix it. Values are matched on word tokens at the earliest
unclaimed occurrence rather than in document order, because models write
"Treble-Barna" where the tokeniser emits three tokens, and emit date out of
position often enough that requiring order rejects values the source contains.

The citation label vocabulary is read from TRAINING_XML_ELEMENT_PATH_BY_LABEL
rather than restated, so it cannot drift from what the extractor understands.

Checked end to end against qwen3.5-9b: 33 references against a gold of 33 over
1502 tokens for the segmenter, and 0.865 token accuracy over 223 tokens for
citation, with tokens returned unchanged in both.
Spans follow OpenTelemetry's GenAI semantic conventions, so they are meaningful
in any OTLP backend: gen_ai.operation.name, gen_ai.request.model, temperature and
max_tokens, gen_ai.usage.input_tokens and output_tokens, response model and
finish reasons, and — configurably — gen_ai.prompt and gen_ai.completion.
OpenInference names are emitted alongside them so a backend reading those instead
still renders the span as an LLM call. Nothing in the engine names a backend;
Phoenix is only what listens in development, and the collector is configured with
plain OTEL_EXPORTER_OTLP_ENDPOINT.

This answers the question nothing else did. The response body says what a run
cost; it never said what the model actually said, and every question of that kind
so far has cost a re-run. The response body is attached to the span even when it
fails to decode, which is the point: a truncated or malformed response becomes
visible rather than inferred from an exception.

PHOENIX_COLLECTOR_ENDPOINT is deliberately not read. An earlier version accepted
it, which put one backend's configuration in the engine and, worse, disagreed
with the exporter: the exporter reads only the OTEL_ variables and otherwise
defaults to localhost:4318, so tracing switched on and then exported to a closed
port.

Phoenix is a compose service rather than a bare docker run, matching the
convention that dev- targets use the venv and docker- targets use compose. It
sits behind the telemetry compose profile so a plain docker-start does not bring
an observability server up with it, is pinned rather than tracking latest, and
keeps its sqlite db in a named volume so traces survive a restart — without one,
comparing two runs would mean never restarting between them.

dev-start-with-telemetry runs the host parser with the OTLP endpoint set, and
nothing else: which profile is active is set the usual way with
SCIENCEBEAM_PARSER__PROFILE, which is a general mechanism rather than anything to
do with tracing. dev-install now includes the telemetry extra, because a dev
environment that cannot see the responses is the situation this is meant to end.

Optional in both directions. Without the extra installed, or without an endpoint
in the environment, the engine emits nothing and behaves identically — a default
install acquires no tracing dependency, and emission stays gated on the endpoint
rather than following the extra. An existing tracer provider is left alone rather
than replaced. The optional imports go through importlib rather than guarded
static imports, so neither mypy nor pylint needs telling to ignore a module that
is absent by design.

A span carrying prompt text is a copy of manuscript text. Sending it to a
collector on localhost is not a new disclosure when the same text already goes to
the model, but sending it elsewhere is, so record_trace_content switches the text
off while keeping the metrics.
processor.py hands the engine every reference of a document at once, and the
engine was making one HTTP call per reference. It now sends
max_references_per_request (default 10) per call, each numbered in the prompt,
with the response nesting one entry per reference so a batch stays a transport
decision rather than growing the index space the model has to track.

Requiring an answer for every reference sent is what makes this safer than a flat
list, not just faster: a merged or omitted reference fails validation rather than
scoring, as does a duplicated or out-of-range index. Values are located within
their own reference's tokens only, so a value belonging to reference 3 cannot be
matched against reference 5.

Measured on 10 real references: one batched call took 22s against 48s for ten
single calls, at 0.906 token accuracy against 0.904. A first attempt measured 320s
for the batched case, which turned out to be a provider timeout returned as HTTP
200 with an error body — the retry only inspected the status code, so it never
fired and the failure surfaced as "response has no choices". Error bodies are now
inspected for a retryable code, and the 320s was the bug rather than the batching.
The tail remains: a long generation draws that timeout occasionally, and the retry
costs more than the batching saves, so the bound is the lever on a flaky provider.

The prompt is a new version rather than an edit, since its shape changed;
values-v1 stays on disk so an earlier number can still be reproduced.
The model returns a line number per reference and the first words on that line.
The number is still the answer; the words are only a check on it, so a wrong
quote costs a check rather than the reference — which is the failure mode the
anchor shape had, where the quoted text was the locator and a mismatch lost the
reference entirely.

Not the default. The pilot behind this measured evidence at about 0.19
partial_list above lines on a 12B and 0.003 above it on the 9B shipped here, at
roughly four times the output tokens. On one real reference list both find 33
references against a gold of 33, in 5.3s and 6.9s. It earns its cost on a weaker
model, so it is available rather than assumed.

A quote is accepted against the line it names or the one below it, because models
name the line holding the reference number while quoting the words underneath —
which is the training convention rather than an error. Anything else increments a
span attribute and logs; evidence_mismatch_raises makes it load-bearing. Off by
default because a mismatch is a well-formed answer whose evidence disagrees, not
a protocol violation, and in the pilot a 12B mismatched on 158 of 210 references
against the 9B's 1 of 118.

Word splitting moves into decode.py and the value shapes import it, rather than
each shape carrying its own: two implementations of "does this text match these
tokens" drifting apart is how a match silently stops matching, which a test
caught here before it could.
The batched prompt introduced each reference with `[n]`, which is what a numbered
reference list looks like, and paired `[0]` with `note: "1"` in its worked
example. The model duly returned the marker as a `note` — on an unnumbered list,
or on a mislabelled region with no bibliographic content, that value then appears
nowhere in the reference and the request fails. Measured over 12 documents: 4
failed this way, all with `note` equal to the index.

values-v3 introduces each reference with `REFERENCE n` on its own line, says the
marker is not part of the reference and must never be returned, and uses an
example whose indices differ from its notes with a second reference that is
unnumbered and so has none. 11 of the same 12 documents now pass, and token
accuracy over those documents rises from 0.877 to 0.930.

Values are matched on characters rather than word sequences, because models join
and split words differently from the tokeniser: `Moreno - San Segundo` comes back
as `Moreno SanSegundo`, which no word comparison can match. A match must still
begin at a token boundary, so a value cannot be located mid-word.

The one remaining failure in those 12 is the model attributing reference 5's
author to another reference in the batch. That is caught rather than silent
precisely because values are located within their own reference's tokens — a flat
field list would have found it elsewhere in the document and labelled it. The
error now names the reference index, the label and the reference text, which is
what made this diagnosable at all.

The segmenter prompts gain a v2 telling them to report only bibliographic
references and to return an empty list if the region contains none, since the
region is sometimes a table and deciding what is a reference is the segmenter's
job. An empty answer is now accepted rather than rejected.
Two fields claiming the same span was raising, which cost every reference in the
document over one confused field. It also contradicted the rule this engine is
built on: protocol violations raise, well-formed but implausible labellings are
scored. A value absent from the source violates the guarantee and still raises. A
second field over the same text does not — the text is in the source either way —
so the later claim is dropped with a warning naming the reference, the label and
the text.

Found on a region the segmentation model mislabelled: the model was handed table
rows, labelled a whole row as a booktitle and then again as a title, and the
document failed. wapiti given the same input produces poor output rather than
failing, so raising there also biased any comparison towards the engine that
gives up.
Four reported failures in a row were the same class: the region handed to the
citation model was not a reference list, the model invented a plausible field
value, and one such field destroyed every reference in the document. Each was
patched at the symptom — the batch marker, character-level matching, overlapping
spans — and the class stayed.

The mistake was conflating decision 4's guarantee with raising. The guarantee is
that no text reaches a document that was not in the source, and discarding an
unlocatable claim satisfies it completely: the value never becomes a label.
Raising is stronger than the guarantee needs, and it fails precisely the documents
where the CRF path degrades instead of failing, which biases any comparison
towards the engine that gives up.

So: a response the engine cannot parse raises — bad JSON, a missing reference
entry, an out-of-range index. A claim it cannot honour is dropped, counted on
sciencebeam.dropped_fields, and logged with the reference, label and text.
dropped_field_raises restores strictness for a run that wants it.

The remaining problem is upstream and unchanged: these documents are results
tables that segmentation labelled as references. The engine now degrades to a
countable number instead of an exception, which is what makes the scale of that
visible.
Batches are independent, so the wall-clock should be the slowest batch rather
than their sum. On 33 references in 4 batches, 4 concurrent calls took 111s down
to 58s with accuracy unchanged. Order is preserved by mapping rather than
completion order, and the first exception propagates.

max_references_per_request and max_concurrent_requests are now in config.yml
rather than only in the dataclass, with the reasoning next to them: raising the
batch size is the wrong lever, because decode cost is linear in output tokens
however they are grouped, so a bigger batch generates the same amount in one long
stream that cannot be overlapped — and long generations are what draw the provider
timeouts seen earlier. More, smaller batches running concurrently is the direction
that helps.

Recorded next to the setting: lower the concurrency if the provider answers with
429s, since the client retries with backoff but a rate-limited provider makes
concurrency a net loss.
Measured on 33 references in 4 batches: 12s against 39s, at the same fp8
quantisation and the same price, with 100% reported uptime against 96% over the
last 30 minutes. On the reference segmenter the same document took 2.1s against
3.9s with identical output.

It costs about 0.007 token accuracy — 0.860 across three runs against 0.867 —
which sits inside the run-to-run spread of most things measured for this engine,
while latency has been the binding constraint throughout. Venice was also the
steadiest thing measured today, at 11.9/12.1/11.9 seconds and 0.859/0.859/0.862.

Records that concurrency beyond 4 stops helping on either provider, so the bound
was provider throughput rather than parallelism, and that not every provider is
reachable under the zero-retention pin: Parasail answered 429 immediately, so a
candidate has to be tried rather than looked up.
The parser runs in a container in CI, so the key has to reach the container. It
is passed as a bare `-e OPENROUTER_API_KEY` with the secret in the step
environment: the value never appears in a command line, and an unset secret
leaves the variable unset in the container rather than empty. Every non-LLM
profile ignores it, so it is passed unconditionally and the engine is selected
the usual way, with a profile: label or the workflow_dispatch input.

PRELOAD_ON_STARTUP is already set there, so an llm profile validates its endpoint
and model id while the container starts. A missing key fails at "Wait for parser"
with the reason in the logs rather than part-way through a run, and that step now
says so.

benchmarks/run.py refuses an llm profile together with plos-manuscripts, naming
both. This is the case that matters: on main the workflow adds
--include-corpus plos-manuscripts automatically, so without the guard an llm
profile on main would send non-redistributable manuscripts to a third party.
Provider zero-retention does not cover the intermediary, which is why the
exclusion cannot be satisfied by routing flags. The check is in run.py rather
than only the workflow so it also protects a run from a laptop.
The native-test job runs pytest over tests/ alone with only the dev group
installed, so a test there cannot import benchmarks.run: that pulls in
benchmarks.fetch, benchmarks.corpus_source and then pyarrow, which the benchmark
group provides and that job does not install. benchmarks/tests is collected
separately and already imports benchmarks.run freely, which is why run_test.py
was fine and this was not.
`bool(x) and any(... x.values())` does not narrow `Any | None`, so the bake
build's lint-mypy stage rejected it. Two isinstance checks with early returns
read better anyway.

It escaped local checking because I ran mypy over sciencebeam_parser and tests
while every lint target — dev-flake8, dev-pylint, dev-mypy and the Dockerfile
stages — also covers benchmarks.
@de-code

de-code commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Next steps:

  • Run benchmark locally over train
  • Investigate why the results do not quite match the original pilot
  • Investigate why some documents failed
  • Investigate why API requests seem to rate limit at two concurrent requests

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant