Proposal: a GROBID stage for a corpus-internal citation graph¶
Status: a proposal, not a plan. Written 2026-08-07.
Nothing described here is built, and [grobid] is not a setting that
exists. This document argues a case and
records what it would cost; the decision has not been taken.
Written for someone weighing whether to add the stage. It assumes ARCHITECTURE.md for the four layers and PDF-PARSER.md for why GROBID was removed from this repository on 2026-08-01 -- which this proposal has to answer before it can argue anything else.
Originally drafted 2026-08-07, against an earlier layout of this repository, and updated here for the current one.
Table of contents¶
- Answering the removal first
- Where the repository stands today
- Why GROBID and docling, not GROBID instead of docling
- Proposed design
- Respecting the citekey invariant
- What it would cost
- What this does not change
- Open questions
Answering the removal first¶
GROBID was in this repository and was removed on 2026-08-01. Any proposal to bring it back has to say what is different, so:
What GROBID uniquely offered -- parsing a paper's own reference list into structured author/title/year/DOI records, via the
/api/processFulltextDocumentendpoint this repo never called -- serves corpus discovery [...] If corpus-growth-by-snowballing later becomes a real workflow, the case to revisit is for/api/processFulltextDocumentspecifically -- not the header endpoint that was here.
This proposal is exactly that revisit. The removed integration called
/api/processHeaderDocument for title, authors and abstract -- metadata
papers/bibliography.bib already supplies for every document, which is
why it earned nothing. What is proposed here calls
/api/processFulltextDocument for reference lists, which the bib file
does not supply and nothing else in the pipeline can derive.
That is a genuine difference in capability, not a re-litigation. It does not dispose of the operational objection -- a pinned JDK 21, a multi-GB Gradle build, and a long-running service -- which is unchanged and is the main cost weighed in What it would cost.
Where the repository stands today¶
Two layers touch PDFs, and neither produces bibliographic structure:
- The corpus layer (
src/pdf_text.py, viapython -m src.corpus sync) extracts plain text per citekey tocontent/parsed/<citekey>.txt, feeding BM25 retrieval insrc/retrieval.py. It dispatches through_EXTRACTORStopdftotext(default) ordocling. One file in, one text file out. - The enrichment layer (
src/enrich/docling_parse.py, viapython -m src.enrich --stages docling) always uses docling regardless of[parser].backend, producingcontent/docling/<citekey>.mdplus a.passages.jsonsidecar of reading-ordered, quotable passages. This feeds embeddings and BERTopic.
What neither produces is structured bibliographic metadata and a
citation graph. The only metadata source is papers/bibliography.bib
itself, parsed by src/bib_reader.py -- whatever your reference manager
exported. There are no in-text citation contexts, no resolved reference
list per paper, and no way to ask "does paper A cite paper B" within
this corpus.
_EXTRACTORS looks like the seam for a new backend, and it is the wrong
one. It exists to swap what the corpus layer's plain text is made of:
one PDF in, one .txt out. GROBID's TEI is structurally different data
-- nested XML with header, body and back sections -- and forcing it
through that table would mean either discarding everything but body text
(defeating the point) or making pdf_text.py non-uniform in its return
type. The right precedent is docling_parse.py: a corpus-wide stage
under src/enrich/, run from src/enrich/__main__.py, independent of
[parser].backend.
Why GROBID and docling, not GROBID instead of docling¶
Each is purpose-built for something the other is not:
| GROBID | docling | |
|---|---|---|
| Header metadata | Purpose-built (title, authors, affiliations, abstract) | General layout detection, not citation-schema-aware |
| References | Structured TEI records, ~0.87-0.90 F1 | No dedicated citation parser |
| Body text and sections | Adequate; sometimes misses full section isolation | Strong -- this repo depends on it for reading order |
| Quotable passages | Not its job | The .passages.json sidecar a claim is quoted from |
| Output | XML/TEI | JSON, Markdown |
GROBID answers "what is this paper and what does it cite"; docling answers "what does this paper actually say."
The cost argument is favourable. A GROBID call does sequence labelling, not layout or OCR inference, so it is orders of magnitude cheaper per document than a docling parse -- which PERFORMANCE.md measures at 55m 30s for a serial 501-PDF pass with OCR off, CPU-bound even on a GPU host (~7% SM utilisation on an A40). GROBID writes to a separate artefact, so it does not compete for the same CPU-bound budget.
Proposed design¶
Where the stage sits¶
flowchart TB
BIB[/"<b>papers/bibliography.bib</b>"/]
READER["<code>src/bib_reader.py</code>"]
LEDGER[("<b>content/ledger.sqlite</b><br/><small>one row per citekey</small>")]
CORPUS["<code>src/enrich/corpus.py</code><br/><small>one CorpusDoc per bib item</small>"]
BIB --> READER --> LEDGER --> CORPUS
subgraph EX["<b>ENRICHMENT LAYER</b> — <code>python -m src.enrich --stages …</code> · one write lock"]
direction LR
DOC["<code>docling_parse.py</code><br/><small>body text, reading order</small>"]
GRO["<code>grobid_parse.py</code> <b>(NEW)</b><br/><small>header + reference lists</small>"]
end
CORPUS --> DOC
CORPUS --> GRO
MD[/"content/docling/<citekey>.md<br/>+ .passages.json"/]
TEI[/"content/grobid/<citekey>.tei.xml"/]
DOC --> MD
GRO --> TEI
EMB["<code>embed_index.py</code> → Chroma<br/><code>topic_model.py</code> → topics.json"]
CG["<code>citation_graph.py</code> <b>(NEW)</b><br/><small>resolve refs against the ledger</small>"]
MD --> EMB
TEI --> CG
LEDGER -. "the only names an edge may use" .-> CG
CG --> GRAPH[/"content/citation_graph.json"/]
classDef new fill:#fef3c7,stroke:#b45309,stroke-width:2px,color:#431407
classDef store fill:#fff7ed,stroke:#c2410c,color:#431407
classDef det fill:#eef2ff,stroke:#4f46e5,color:#1e1b4b
class GRO,CG new
class MD,TEI,GRAPH,BIB store
class LEDGER,READER,CORPUS,DOC,EMB det
The two new modules are shaded. Note that both read
src/enrich/corpus.py, which sources the corpus from the ledger and
nothing else -- so every document GROBID sees is one a draft is allowed
to cite. That is a constraint, not an accident; see
AGENTS.md.
src/enrich/grobid_parse.py¶
Modelled on docling_parse.py: corpus-wide, incremental, self-probing.
It calls GROBID's /api/processFulltextDocument per PDF and writes the
TEI to content/grobid/<citekey>.tei.xml.
It must follow the same three conventions every stage here follows:
- Report, don't assume. Probe
GET {url}/api/isalivebefore running and reportservice-unavailablerather than failing hard -- the client/server equivalent of the existingmissing-binary. - Be incremental. Fingerprint each PDF by
(size, mtime_ns)asdocling_parse.pydoes, so a second run over an unchanged corpus is free. - Report per document. It holds the write lock, so it must be
observably making progress --
[done/total] <citekey>, per DESIGN.md's concurrency policy.
Status vocabulary, matching src/enrich/__main__.py:
| Condition | Report |
|---|---|
[grobid].enabled = false |
skipped |
Service unreachable at [grobid].url |
service-unavailable |
| TEI written | ok |
| GROBID returns malformed or empty TEI for one PDF | partial, with a warning naming the citekey -- one bad document does not fail the corpus |
src/enrich/citation_graph.py¶
Parses each TEI's <listBibl>, extracts each referenced work's
title/DOI, and resolves it against the ledger's existing citekeys.
flowchart LR
TEI[/"<citekey>.tei.xml<br/><small><listBibl></small>"/] --> REF["one reference<br/><small>title · DOI · year</small>"]
REF --> Q{"resolves to a<br/><b>citekey already<br/>in the ledger?</b>"}
Q -- yes --> IN["<b>internal edge</b><br/><code>A → B</code><br/><small>both citable</small>"]
Q -- no --> OUT["<b>external node</b><br/><small>recorded by DOI/title<br/><b>never minted as a citekey</b></small>"]
IN --> J[/"content/citation_graph.json"/]
OUT --> J
OUT -.-> NOTE["to become citable it must go<br/>through the reference manager:<br/>catalogue → re-export → <code>sync</code>"]
classDef ok fill:#f0fdf4,stroke:#16a34a,color:#052e16
classDef warn fill:#fef2f2,stroke:#dc2626,color:#450a0a
classDef note fill:#f8fafc,stroke:#94a3b8,stroke-dasharray:4 3,color:#0f172a
class IN ok
class OUT warn
class NOTE note
Output is an edge list of citekey -> [citekeys it cites, restricted to
this corpus].
This is the gap nothing else can fill. BM25 in src/retrieval.py and
the embedding index in src/enrich/embed_index.py both rank on body-text
similarity. Neither can answer "what does this corpus treat as
foundational" or "cluster these papers by citation structure rather than
by topic model" -- there is no citation-structure data to answer from.
Configuration¶
A new section, following the existing [parser]/[enrich] pattern, each
key overridable by an env var of the same name:
1 2 3 4 5 6 7 8 | |
consolidate_citations deserves a flag in the requirements table:
GROBID's consolidation calls CrossRef to resolve incomplete reference
strings, so this stage may need network access per document at parse
time -- a different network profile from anything else here, where the
network is needed once for model downloads and never again.
Runtime¶
GROBID runs as a long-lived service, not an in-process import:
1 | |
That fits the existing container story (DOCKER.md) as a
sidecar. Invocation would extend --stages:
1 | |
Respecting the citekey invariant¶
The hard rule is unchanged and this stage does not get an exception.
GROBID's consolidated references routinely resolve to DOIs and titles not
in papers/bibliography.bib. Those become edges to external nodes,
never new citable claims. citation_graph.py may only ever resolve a
reference against an existing ledger citekey; it must never mint one.
Two consequences worth stating, because both are new since the original draft:
- There is no longer a second, uncitable document namespace to appeal
to. The original draft justified this by analogy with
papers/pdfs/, a directory of raw PDFs that were indexed but never citable. That directory, its config key and itsdoc:<stem>id namespace were all removed precisely because a permanently non-citable case was a cost every downstream stage paid. Reintroducing one here would undo that, so external nodes must live incitation_graph.jsonalone -- never in the ledger, never in Chroma, never anywhere a retrieval call can return them. - A citekey is also a filename, enforced by
bib_reader.citekey_problem(). Anything writingcontent/grobid/<citekey>.tei.xmlinherits that guarantee, and must not construct a path from a GROBID-derived string, which carries no such guarantee.
What it would cost¶
Honest accounting, since the operational objection that removed GROBID still stands:
| Cost | Detail |
|---|---|
| A pinned JDK 21 | Its bundled Kotlin compiler cannot parse a JDK 25 version string. Unchanged since the removal |
| A multi-GB, multi-minute build | Or accepting the prebuilt Docker image and its footprint |
| A long-running service on port 8070 | The only component here that is not a batch job -- a genuinely new operational shape |
| Per-document network | With consolidate_citations > 0. New for this pipeline |
| A new artefact in the reproducibility contract | ARCHITECTURE.md is artifact-by-artifact; .tei.xml and citation_graph.json each need a row, and consolidation makes the graph depend on an external service's state, so it is unlikely to be reproducible at all |
The case turns on one question: is snowballing a real workflow here? If corpus growth stays "notice a paper, catalogue it in Zotero, re-export", the graph is interesting but unused, and this is a service and a JDK for a feature nobody runs. If it becomes routine, this is the only design that supports it without touching the citekey invariant.
What this does not change¶
- The corpus layer --
sync,pdf_text.py, BM25 retrieval: untouched. [parser].backend-- untouched. GROBID is not apdf_text.pybackend and does not appear in_EXTRACTORS.- docling, embeddings, BERTopic -- untouched; they run independently and write to separate artefacts.
- The gate chain --
citation_gate→references→render_output: untouched. The citation graph is an optional additional input a genre skill may consult, never a replacement for the gate. - Who runs it -- a human, like every other enrichment stage. No skill builds it.
Open questions¶
- Does the graph have a consumer?
content/topics.jsonis already an artefact nothing reads (see DEVELOPER.md). Adding a second unread artefact, at the cost of a JDK and a service, would be a worse version of the same mistake. A concrete consumer -- asurvey-writerstep, a retrieval signal -- should be named before this is built. - How well does title/DOI resolution actually work against a real bib file, where the same paper appears as a preprint in one entry and a published version in another? The edge count is meaningless if resolution is unreliable, and this is measurable before any of the above is built: run GROBID over 20 PDFs by hand and check the resolution rate.
- Is
consolidate_citations = 0enough? It avoids the per-document network entirely. Whether unconsolidated reference strings resolve well enough against the ledger is exactly what the pilot above would answer.