The workflow, drawn eleven ways¶
Status: reference. Written 2026-08-06.
Every diagram here describes the same pipeline. They come in three groups:
- The ladder -- six views of the whole workflow, ordered by how much you already know, from one that assumes nothing to one that assumes you are about to change the worker pool.
- By genre -- three views of how the writing skills differ in what they ask of the pipeline. The five genre skills are not variations on one template; they disagree about how much of it is worth running.
- Appendix -- the same workflow in time order, and the ledger's state machine for one citekey.
Read the one that matches your question and ignore the rest.
One property holds in all eleven: a genre skill loops on the citation
gate until it exits 0, and shows you nothing before that. All five
SKILL.md files in .claude/skills/ carry that instruction, four of them
in the same words: "Fix and re-run until OK. Never present a draft
that hasn't passed." A gate failure is normally something you never
see.
The fenced mermaid blocks below are the source of truth, and GitHub
renders them inline, so a change to the pipeline and a change to its
diagram land in the same diff. docs/diagrams/ carries the same eleven as
standalone .mmd sources and .svg exports, for slides, a paper, or a
viewer that doesn't render Mermaid -- see Editing these
at the end.
The ladder¶
| # | Diagram | Written for | Answers |
|---|---|---|---|
| 1 | One glance | Someone who has never heard of this | what are the steps, and who does each one? |
| 2 | Your first run | Someone installing it today | what do I type, in what order, and what does each step tell me? |
| 3 | The full workflow | Someone reading the source | what actually runs, what does it write, and where does the module I'm looking at fit? |
| 4 | Everything on disk | Someone deciding what to back up, or debugging a wrong file | what are all these files, who wrote them, and what is safe to delete? |
| 5 | Gates and exit codes | Someone whose run just failed, or who is scripting it | why did this exit 1, and what is safe to retry automatically? |
| 6 | Inside one parse | Someone tuning [parser].workers, or changing the pool |
what does a worker pool actually do here, and what happens when part of it dies? |
1. One glance¶
Written for: Someone who has never heard of this. Answers: what are the steps, and who does each one?
Deliberately the least detailed diagram here. Two properties do all the work: phase 1 is the only entrance -- citekeys come from your BibTeX export and nowhere else -- and phase 4 is the only exit, with no arrow around it. This is the version in the README.
The DISCARD DRAFT box loops back to drafting, not to you. A failing
gate is normally invisible: the skill rewrites the claim and runs the gate
again. You only get involved in the rarer case where the paper genuinely
isn't in the corpus yet.
flowchart LR
P0["<b>1 · CURATE</b><br/><i>you, in Zotero</i><br/><br/>Add papers, export<br/>BibTeX + Export Files<br/><br/><b>papers/bibliography.bib</b><br/><small>nothing else may invent a citekey</small>"]
P1["<b>2 · SYNC</b><br/><i>the corpus layer — deterministic, no LLM</i><br/><br/><code>python -m src.corpus sync</code><br/>read bib → update ledger<br/>→ extract PDF text<br/><br/><b>content/ledger.sqlite</b><br/><b>content/parsed/*.txt</b><br/><small>idempotent · re-runs cost almost nothing</small>"]
P2["<b>3 · DRAFT</b><br/><i>the drafting layer — generative, you review</i><br/><br/>Ask a genre skill:<br/><i>“write a survey section on …”</i><br/>it retrieves only from<br/>the parsed corpus<br/><br/><b>content/drafts/<slug>.md</b>"]
P3{{"<b>4 · VERIFY</b><br/><i>machine-enforced</i><br/><br/><code>src.draft gate</code><br/>Is every citekey<br/>in the ledger?"}}
P4["<b>5 · PUBLISH</b><br/><i>stdlib + Pandoc / TeX Live</i><br/><br/><code>src.draft references</code><br/>IEEE list from exactly<br/>the citekeys cited<br/><br/><code>src.draft render --format pdf</code><br/><b>content/rendered/<slug>.pdf</b>"]
FIX["<b>DISCARD DRAFT</b><br/><br/>The skill throws the bad claim away<br/>and drafts again — you never see this.<br/><br/><small>“Fix and re-run until <code>OK</code>.”<br/>A FAIL is treated like a failing test,<br/>not a lint warning.</small>"]
P0 ==> P1 ==> P2 ==> P3
P3 == "PASS · exit 0" ==> P4
P3 -- "FAIL · exit 1" --> FIX
FIX == "re-draft · <b>loop until it passes</b>" ==> P2
FIX -. "or: the paper really is missing —<br/>add it in Zotero and re-sync" .-> P0
classDef you fill:#fff7ed,stroke:#c2410c,stroke-width:1.5px,color:#431407
classDef det fill:#eef2ff,stroke:#4f46e5,stroke-width:1.5px,color:#1e1b4b
classDef gen fill:#f0fdf4,stroke:#16a34a,stroke-width:1.5px,color:#052e16
classDef gate fill:#fef2f2,stroke:#dc2626,stroke-width:3px,color:#450a0a
classDef out fill:#faf5ff,stroke:#9333ea,stroke-width:1.5px,color:#3b0764
classDef bad fill:#fee2e2,stroke:#dc2626,stroke-width:1.5px,color:#450a0a
class P0 you
class P1 det
class P2 gen
class P3 gate
class P4 out
class FIX bad
2. Your first run¶
Written for: Someone installing it today. Answers: what do I type, in what order, and what does each step tell me?
The same path as the Quickstart, drawn with
the two checkpoints that actually catch people. The first is the Export
Files tick box, which silently produces a bibliography with no PDFs
attached. The second is the first python -m src.corpus ledger after a
sync, which is where you find out whether anything became citable.
Exit codes are on the diagram because at this stage they are the only feedback you have.
flowchart TB
A["<b>1 · Export from Zotero</b><br/><code>papers/bibliography.bib</code><br/><small>Format: BibTeX · tick <b>Export Files</b></small>"]
A1{{"Did you tick<br/><b>Export Files</b>?"}}
ATRAP["<b>The trap.</b> Without it you get metadata and no PDFs.<br/>Every entry then reports <i>no PDF attachment</i> — not an error,<br/>just an empty corpus. Same if you rename the companion folder:<br/><code>bib_reader</code> resolves <code>file</code> fields relative to the .bib.<br/><small>docs/ZOTERO.md</small>"]
B["<b>2 · Make a config</b><br/><code>cp config.toml.example config.toml</code><br/><small>every key is optional — but the file must exist,<br/>or <code>src.config</code> refuses to import</small>"]
C["<b>3 · Install</b><br/><code>pipx install poetry</code><br/><code>bash scripts/install_full_pipeline.sh all</code><br/><small><b>all</b> = OS packages (pdftotext, TeX Live, Pandoc) + Python.<br/>No stage argument = Python only.</small>"]
D["<b>4 · Sync the corpus</b><br/><code>source .venv-full/bin/activate</code><br/><code>python -m src.corpus sync</code><br/><small>needs the venv — this is the one command that does</small>"]
D1{{"exit code?"}}
DFAIL["<b>1</b> — something didn't parse.<br/><small>The summary names each one.<br/>Fix or remove it; re-running is cheap.</small>"]
DBUSY["<b>2</b> — another run holds the lock.<br/><small>Nothing was lost. Try again later.</small>"]
E["<b>5 · Look at what it found</b><br/><code>python -m src.corpus ledger</code><br/><small>read-only, takes no lock, needs no venv —<br/>safe to run <i>while</i> a sync is going</small>"]
E1{{"Rows with status<br/><code>parsed</code>?"}}
EEMPTY["Nothing is citable yet.<br/><small>Almost always the Export Files trap above.<br/><code>--status no_pdf</code> tells you which entries and why.</small>"]
F["<b>6 · Ask for a draft</b><br/><i>“write a survey section on digital twin composability”</i><br/><small>in Claude Code. The matching skill in <code>.claude/skills/</code><br/>picks it up and runs its own gate → references → render chain,<br/><b>looping on the gate until it exits 0</b> before showing you anything.</small>"]
G["<b>7 · Or drive that chain by hand</b><br/><code>python -m src.draft gate <draft></code> <i>← fix and repeat until OK</i><br/><code>python -m src.draft references <draft></code><br/><code>python -m src.draft render <draft> --format pdf</code><br/><small>bare <code>python</code> — none of these need the venv</small>"]
DONE(["<b>content/rendered/<slug>.pdf</b>"])
A --> A1
A1 -- no --> ATRAP
ATRAP -. "re-export" .-> A
A1 -- yes --> B --> C --> D --> D1
D1 -- "1" --> DFAIL
D1 -- "2" --> DBUSY
DBUSY -. "re-run" .-> D
DFAIL -. "re-run" .-> D
D1 -- "<b>0</b> — corpus in sync" --> E --> E1
E1 -- no --> EEMPTY
EEMPTY -. "fix the export" .-> A
E1 -- yes --> F --> DONE
F -. "same chain, manually" .-> G
G --> DONE
classDef step fill:#eef2ff,stroke:#4f46e5,stroke-width:1.5px,color:#1e1b4b
classDef check fill:#fefce8,stroke:#a16207,stroke-width:1.5px,color:#422006
classDef trap fill:#fef2f2,stroke:#dc2626,stroke-width:1.5px,color:#450a0a
classDef done fill:#f0fdf4,stroke:#16a34a,stroke-width:2px,color:#052e16
class A,B,C,D,E,F,G step
class A1,D1,E1 check
class ATRAP,EEMPTY,DFAIL,DBUSY trap
class DONE done
3. The full workflow¶
Written for: Someone reading the source. Answers: what actually runs, what does it write, and where does the module I'm looking at fit?
The reference diagram. Everything in src/ appears here exactly once.
ARCHITECTURE.md is the prose companion to it -- the
same system in words, plus what each part needs to run.
Three things it is drawn to make unmissable. The thick edge from
content/ledger.sqlite to the gate is the entire safety argument -- the
gate consults the ledger and nothing else. The gate sits on the only path
from a draft to content/rendered/. And the FAIL edge does not leave
the system: it runs back into the skill, which re-drafts and re-runs the
gate until it exits 0.
Note that the enrichment layer's docling stage reads the PDF itself,
not content/parsed/. It is a second, independent extraction of the same
source, not a refinement of the first one -- which is why it can produce
figures and layout-aware passages that pdftotext cannot.
Dotted edges are optional or conditional: the enrichment layer is opt-in, and each consumer checks the stack exists before using it and degrades to the lightweight default when it doesn't.
flowchart TB
%% ─────────────── 1 · CURATE ───────────────
subgraph S0["<b>1 · CURATE</b> — you, in Zotero. The pipeline never fetches a paper."]
direction TB
ZOT[("Zotero library")]
BIB["<b>papers/bibliography.bib</b><br/><i>the source of truth for citekeys</i>"]
ATT[/"<b>papers/bibliography/files/<id>/*.pdf</b><br/><small>the companion folder Zotero writes beside the .bib —<br/>never rename it, the <code>file</code> fields are relative paths</small>"/]
ZOT -- "Export · BibTeX + Export Files" --> BIB
ZOT --> ATT
end
%% ─────────────── 2 · SYNC ───────────────
subgraph S1["<b>2 · SYNC</b> — the corpus layer · deterministic, no LLM, safe to run unattended · <code>python -m src.corpus sync</code> · holds the run lock"]
direction TB
BR["<b>src/bib_reader.py</b><br/><small>the only module that reads the .bib</small>"]
LED[("<b>content/ledger.sqlite</b><br/><small>one row per citekey: status, bib_fields,<br/>PDF fingerprint — re-parse only what moved</small>")]
PT["<b>src/pdf_text.py</b> — extract text<br/><small>backend <code>pdftotext</code> · or <code>docling</code><br/>serial by default; opt-in worker pool</small>"]
TXT[/"<b>content/parsed/<citekey>.txt</b>"/]
ADV["<i>reported, never fatal:</i><br/>near-duplicates · parse-quality warning · stale citekeys<br/><small>deletion needs <code>--remove-stale</code></small>"]
BR --> LED --> PT --> TXT --> ADV
end
%% ─────────────── 3 · RETRIEVE ───────────────
subgraph S2["<b>3 · RETRIEVE</b> — the only evidence a writer is given · <b>one ranker or the other, never both</b>"]
direction LR
XOR{{"which ranker?<br/><small>the genre skill picks</small>"}}
BM25["<b>src/retrieval.py</b> · BM25<br/><small>stdlib keyword overlap, cached term-frequency index.<br/><b>the default every skill starts from</b></small>"]
EMB["<b>src/enrich/embed_index.py</b> · semantic<br/><small>same <code>search(q, k)</code> signature, so it is a drop-in.<br/>Named as the alternative by <code>survey-writer</code><br/>and <code>deep-research</code> — and only by those two.</small>"]
PASS["<b>src/passages.py</b> · evidence ladder<br/><small>docling sidecar → page → pdftotext</small>"]
XOR -- "default" --> BM25
XOR -. "only if content/chroma/ was built" .-> EMB
BM25 --> PASS
EMB --> PASS
end
%% ─────────────── 4 · DRAFT ───────────────
subgraph S3["<b>4 · DRAFT</b> — the drafting layer · generative, on demand, reviewed by you"]
direction TB
SKILLS["<b>.claude/skills/</b> — five genre skills<br/>survey-writer · thesis-chapter-writer · textbook-chapter-writer<br/>tutorial-writer · deep-research"]
DRAFT[/"<b>content/drafts/<slug>.md | .tex</b>"/]
SKILLS --> DRAFT
end
%% ─────────────── 5 · GATE ───────────────
GATE{{"<b>THE CITATION GATE</b> · <code>python -m src.draft gate</code><br/>Is every citekey in this draft present in the ledger?<br/><small>run twice: by the PostToolUse hook on every write under content/drafts/,<br/>and by the skill itself before it shows you anything</small>"}}
BLOCK["<b>REFUSED</b> · exit 1<br/><small>the write is blocked and the chain stops</small>"]
ITER["<b>the skill fixes it and re-runs — itself</b><br/><small>“Fix and re-run until <code>OK</code>. Never present a draft that hasn't<br/>passed.” — every SKILL.md carries this instruction.<br/>It swaps the bad key for one retrieval actually returned, or drops<br/>the claim. You are shown nothing until the gate is green.</small>"]
%% ─────────────── 6 · PUBLISH ───────────────
subgraph S5["<b>6 · PUBLISH</b> — stdlib only, no venv needed"]
direction TB
REFS["<b>python -m src.draft references</b><br/><small>IEEE ## References, numbered by first appearance,<br/>built only from citekeys the draft actually cites.<br/>Skipped for thesis .tex fragments, where the<br/>surrounding LaTeX owns the bibliography.</small>"]
REND["<b>python -m src.draft render</b><br/><small>pandoc --citeproc + assets/csl/ieee.csl</small>"]
OUT[/"<b>content/rendered/<slug>.pdf | .tex | .docx | .md</b>"/]
REFS --> REND --> OUT
end
%% ─────────────── ENRICHMENT (side branch) ───────────────
subgraph SH["<b>OPTIONAL · ENRICHMENT LAYER</b><br/><code>python -m src.enrich --stages …</code> · same run lock"]
direction TB
H1["<b>docling</b> — <i>reads the PDF itself, not content/parsed/</i><br/><small><b>content/docling/<doc>.md</b> — layout-aware text<br/><b><doc>.passages.json</b> — the quotable-passage sidecar<br/><b><doc>_artifacts/</b> — figure bitmaps, written by Docling<br/><b><doc>.figures.json</b> — page, caption, cite string per figure<br/>the last two only when <code>[enrich].docling_images</code> is on</small>"]
H2["<b>embed</b><br/><small>content/chroma/ — drop-in search(q,k)</small>"]
H3["<b>bertopic</b><br/><small>content/topics.json</small>"]
H1 --> H2 --> H3
end
%% ─────────────── AIDS (side branch) ───────────────
subgraph SA["<b>REVIEW AIDS</b> — you run these; none of them is a gate"]
direction TB
A1["<b>src.review provenance</b><br/><small>what in the source supports this claim?</small>"]
A2["<b>src.review verbatim</b><br/><small>verbatim overlap · locate a phrase by page</small>"]
A3["<b>src.review coverage</b><br/><small>retrieval surfaced it — did the draft cite it?</small>"]
end
%% ─────────────── SPINE ───────────────
BIB --> BR
ATT --> PT
TXT --> XOR
PASS --> SKILLS
DRAFT --> GATE
GATE -- "FAIL" --> BLOCK
BLOCK --> ITER
ITER == "re-draft · <b>loop until it passes</b>" ==> SKILLS
GATE == "PASS · exit 0" ==> REFS
LED == "the ledger is the<br/>only authority the<br/>gate consults" ==> GATE
LED --> REFS
ATT -. "the PDF, direct" .-> H1
H1 -.-> PASS
H2 -.-> EMB
DRAFT --> SA
%% ─────────────── STYLE ───────────────
classDef src fill:#eef2ff,stroke:#4f46e5,stroke-width:1.5px,color:#1e1b4b
classDef store fill:#fff7ed,stroke:#c2410c,stroke-width:1.5px,color:#431407
classDef gate fill:#fef2f2,stroke:#dc2626,stroke-width:2.5px,color:#450a0a
classDef bad fill:#dc2626,stroke:#7f1d1d,color:#ffffff
classDef loop fill:#fefce8,stroke:#a16207,stroke-width:2px,color:#422006
classDef gen fill:#f0fdf4,stroke:#16a34a,stroke-width:1.5px,color:#052e16
classDef aid fill:#f8fafc,stroke:#94a3b8,stroke-dasharray:4 3,color:#0f172a
classDef heavy fill:#faf5ff,stroke:#9333ea,stroke-width:1.5px,color:#3b0764
class BR,PT,BM25,PASS,REFS,REND src
class ZOT,BIB,ATT,LED,TXT,DRAFT,OUT store
class GATE,XOR gate
class BLOCK bad
class ITER loop
class SKILLS gen
class A1,A2,A3,ADV aid
class H1,H2,H3,EMB heavy
4. Everything on disk¶
Written for: Someone deciding what to back up, or debugging a wrong file. Answers: what are all these files, who wrote them, and what is safe to delete?
Same pipeline, but the files are the nodes and the modules are the edge labels -- the inverse of the full workflow diagram.
The split that matters: everything under content/ is disposable. Delete
the directory and one sync plus one enrichment run rebuilds all of it.
Nothing under papers/ is disposable, and neither is config.toml; both
are gitignored and per-host, so they are also the only things a backup
needs to contain.
With [enrich].docling_images on, docling also writes each document's
figure bitmaps into <doc>_artifacts/ and an index of them in
<doc>.figures.json -- page, caption, and the string to cite each figure
by. Those are a reading aid for checking a draft against its sources.
Having a paper in your library grants no right to reproduce its figures;
see DEVELOPER.md's "Figures and copyright".
flowchart TB
subgraph IN["<b>YOURS</b> — hand-curated, gitignored, per host"]
direction LR
BIB[/"papers/bibliography.bib"/]
PDF[/"<b>papers/bibliography/files/<id>/*.pdf</b><br/><small>Zotero's companion folder, written beside the .bib<br/>by <b>Export Files</b> — renaming it breaks every<br/><code>file</code> field silently</small>"/]
CFG[/"config.toml"/]
end
subgraph GEN["<b>GENERATED</b> — <code>content/</code>, all of it disposable: delete it and re-run"]
direction TB
subgraph L1["written by <code>src.corpus sync</code> — the corpus layer"]
direction LR
LED[("content/ledger.sqlite")]
TXT[/"content/parsed/<citekey>.txt<br/><small>form feeds between pages, either backend</small>"/]
CPS[/"content/parsed/<citekey>.passages.json<br/><small>only with <code>[parser].backend = docling</code>;<br/>cleared before every re-parse</small>"/]
end
subgraph L2["written by the enrichment layer — opt-in"]
direction LR
DOC[/"<b>content/docling/<doc>.md</b><br/>+ <doc>.passages.json<br/><b>+ <doc>_artifacts/*.png</b> — figure bitmaps<br/><b>+ <doc>.figures.json</b> — page, caption, cite string<br/><small>the last two only when <code>[enrich].docling_images</code><br/>is on; Docling reads the <b>PDF</b>, never content/parsed/</small>"/]
CHR[("content/chroma/")]
TOP[/"content/topics.json"/]
end
subgraph L3["caches — rebuilt on demand, safe to delete, cost only time"]
direction LR
RIX[/"content/retrieval_index.json"/]
DCA[/"content/docling_cache.json"/]
TCA[/"content/topic_embed_cache.json"/]
end
subgraph L4["written by you, through a skill"]
direction LR
DRF[/"content/drafts/<slug>.md | .tex"/]
REN[/"content/rendered/<slug>.pdf | .tex | .docx | .md"/]
RVW[/"content/review/<slug>.{provenance,verbatim,coverage}.md<br/><small>the draft's path under drafts/, mirrored · renders land beside them</small>"/]
end
LCK[/"content/pipeline.lock.db<br/><small>held by whichever writer is running</small>"/]
end
CFG -. "read once at import,<br/>env vars override" .-> GEN
BIB -- "src/bib_reader.py" --> LED
PDF -- "src/pdf_text.py" --> TXT
PDF -- "src/pdf_text.py<br/><small>docling backend only</small>" --> CPS
LED -- "which PDFs need a parse" --> TXT
LED -- "src/enrich/corpus.py<br/><small>every row, keyed by <code>citekey</code></small>" --> DOC
TXT -- "src/enrich/embed_index.py" --> CHR
PDF -- "src/enrich/docling_parse.py" --> DOC
CPS -. "<b>src/enrich/docling_parse.py</b><br/><small>adopts the corpus layer's parse<br/>instead of repeating it</small>" .-> DOC
DOC --> CHR
TXT -- "src/enrich/topic_model.py<br/><small>whole-doc embeddings — its own cache,<br/>not the Chroma collection</small>" --> TOP
TXT -- "src/retrieval.py" --> RIX
RIX -- "<b>src/retrieval.py</b> · BM25 hits<br/><small>the default path</small>" --> DRF
CHR -. "<b>src/enrich/embed_index.py</b> · semantic hits<br/><small><b>either this or BM25 — never both.</b> Only<br/>survey-writer and deep-research name it.</small>" .-> DRF
DOC -- "src/passages.py<br/>quotable passages — rung 1" --> DRF
CPS -- "src/passages.py<br/>quotable passages — rung 2" --> DRF
LED -- "src/references.py<br/>bib_fields → IEEE entries" --> DRF
DRF == "<b>src.draft gate</b> — FAIL rewrites the draft in place,<br/>and the skill re-runs it until it exits 0" ==> DRF
DRF -- "src.draft render<br/><small>only after the gate passes</small>" --> REN
DRF -- "the review layer<br/><small>src.review provenance · verbatim scan --write<br/>src.review coverage --write</small>" --> RVW
classDef mine fill:#fff7ed,stroke:#c2410c,color:#431407
classDef corpus fill:#eef2ff,stroke:#4f46e5,color:#1e1b4b
classDef heavy fill:#faf5ff,stroke:#9333ea,color:#3b0764
classDef cache fill:#f8fafc,stroke:#94a3b8,stroke-dasharray:4 3,color:#0f172a
classDef draft fill:#f0fdf4,stroke:#16a34a,color:#052e16
classDef lock fill:#fefce8,stroke:#a16207,color:#422006
class BIB,PDF,CFG mine
class LED,TXT,CPS corpus
class DOC,CHR,TOP heavy
class RIX,DCA,TCA cache
class DRF,REN,RVW draft
class LCK lock
5. Gates and exit codes¶
Written for: Someone whose run just failed, or who is scripting it. Answers: why did this exit 1, and what is safe to retry automatically?
Exit codes are the API for unattended callers: 0 corpus in sync, 1
corpus not in sync and a human is needed, 2 cycle skipped and no work
was lost.
The distinction worth reading twice is in the failure branch. A document the backend genuinely cannot read is never retried -- re-reading it every run would spend the same minutes to reach the same answer -- but it never goes quiet either, failing the run until someone deals with it. A failure caused by the run rather than the document -- a dead worker, a timeout, a CUDA OOM -- retries itself next time without being asked.
The gate's exit 1 is the one failure on this diagram that usually
reaches nobody, because the skill loops on it itself.
flowchart TB
START(["<code>python -m src.corpus sync</code>"]) --> Q2
Q2{"<b>config.toml present?</b><br/><small>read once at import,<br/>before anything else happens</small>"}
Q2 -- no --> EC["<b>refuses to import</b><br/><small>cp config.toml.example config.toml</small>"]
Q2 -- yes --> Q1
Q1{"<b>run lock free?</b><br/><small>content/pipeline.lock.db</small>"}
Q1 -- "no · another writer holds it" --> E2["<b>exit 2</b><br/>cycle skipped, no work lost<br/><small>readers were never blocked</small>"]
Q1 -- yes --> Q3
Q3{"<b>bibliography.bib readable?</b>"}
Q3 -- no --> EF["<b>FileNotFoundError</b><br/><small>export from Zotero first</small>"]
Q3 -- yes --> LOOP
LOOP["<b>for each citekey in the bib</b><br/><small>upsert the ledger row</small>"] --> Q4
Q4{"<b>has a resolvable PDF?</b>"}
Q4 -- no --> SN["status <code>no_pdf</code><br/><small>reported with a reason;<br/>the run still succeeds</small>"]
Q4 -- yes --> Q5
Q5{"<b>bytes changed since<br/>the last parse?</b><br/><small>size+mtime, then sha256</small>"}
Q5 -- no --> SK["<b>skipped</b><br/><small>this is why a re-run costs<br/>close to nothing</small>"]
Q5 -- yes --> PARSE["<b>parse the PDF</b><br/><small>pdftotext or docling ·<br/>serial, or a clamped worker pool</small>"]
PARSE --> Q6
Q6{"<b>parse result</b>"}
Q6 -- "complete" --> OK["status <code>parsed</code><br/><small>content/parsed/<citekey>.txt written</small>"]
Q6 -- "partial success" --> PF
Q6 -- "backend can't read it" --> PF["status <code>parse_failed</code> · <i>deterministic</i><br/><small>never retried — same minutes, same answer —<br/>but fails every run until fixed</small>"]
Q6 -- "worker died · timeout · CUDA OOM" --> PT2["status <code>parse_failed</code> · <i>transient</i><br/><small>retried automatically next run</small>"]
OK --> AGG
SN --> AGG
SK --> AGG
PF --> AGG
PT2 --> AGG
AGG{"<b>anything unresolved?</b>"} -- no --> E0["<b>exit 0</b><br/>corpus in sync"]
AGG -- yes --> E1["<b>exit 1</b><br/>corpus not in sync,<br/>human attention needed"]
E0 --> GATEIN(["a skill drafts against this corpus"])
GATEIN --> G{"<b>src.draft gate</b><br/>every citekey resolvable?"}
G -- "yes · <b>exit 0</b>" --> GOOD["references → render<br/><small>the only path to a rendered draft</small>"]
G -- "no · <b>exit 1</b>" --> GBAD["<b>the write is blocked.</b><br/><small>Treated like a failing test,<br/>not a lint warning.</small>"]
GBAD --> GLOOP["<b>the skill re-drafts and re-runs the gate</b><br/><small>“Fix and re-run until <code>OK</code>. Never present a draft<br/>that hasn't passed.” — all five SKILL.md files.<br/>This exit 1 is the only one nobody has to see:<br/>the loop is inside the skill, not in front of you.</small>"]
GLOOP == "<b>loop until exit 0</b>" ==> G
GLOOP -. "unless the paper genuinely isn't in the corpus —<br/>then it stops and tells you to sync" .-> E1
classDef q fill:#eef2ff,stroke:#4f46e5,color:#1e1b4b
classDef ok fill:#f0fdf4,stroke:#16a34a,color:#052e16
classDef warn fill:#fefce8,stroke:#a16207,color:#422006
classDef bad fill:#fef2f2,stroke:#dc2626,stroke-width:2px,color:#450a0a
classDef hard fill:#dc2626,stroke:#7f1d1d,color:#ffffff
class Q1,Q2,Q3,Q4,Q5,Q6,AGG,LOOP,PARSE,G q
class E0,OK,SK,GOOD ok
class GLOOP warn
class E2,SN,PT2 warn
class E1,PF,EC,EF bad
class GBAD hard
6. Inside one parse¶
Written for: Someone tuning [parser].workers, or changing the pool.
Answers: what does a worker pool actually do here, and what happens when
part of it dies?
The deepest view, and the only part of the repository that runs work in parallel. Everything else -- retrieval, gating, rendering -- is deliberately serial.
The through-line: the pool is clamped to the host, not to the
request. The ceiling counts the CPUs this process may run on
(os.sched_getaffinity), not the machine's. An over-large request is
clamped and said out loud: silently obeying thrashes, and silently
ignoring leaves someone believing they configured something they did
not.
Five failure modes hang off the pool, each handled where it can be, and the parent process keeps everything only it can do: sqlite has a single writer, and the parent is the only place that can order results deterministically. Full component-by-component write-up in docs/PARALLELISM.md; the reasoning behind the lock is in docs/DESIGN.md.
flowchart TB
subgraph PRE["<b>before the pool exists</b>"]
direction TB
P1["<b>prestart_pool()</b><br/><small>forkserver begins importing torch + docling<br/>in the background — and declines outright when<br/>no pool is coming (pdftotext, or workers = 1)</small>"]
P2["<b>read bibliography.bib</b> — ~2.5s<br/><small>deliberately started <i>after</i> the pre-start, so the<br/>import overlaps the read: a fixed ~1.5-2s saved</small>"]
P3["<b>ask the ledger</b><br/><small>which documents actually need a parse</small>"]
P1 -.-> P2 --> P3
end
W{{"<b>resolve_workers(n_docs)</b><br/><code>max(1, min(requested, ceiling, n_docs))</code>"}}
subgraph CEIL["<b>the ceiling is the host's, not the request's</b>"]
direction TB
C1["<b>allowed_cpus()</b> = <code>len(os.sched_getaffinity(0))</code><br/><small>the CPUs <i>this process</i> may run on — not <code>os.cpu_count()</code>.<br/>On the dev machine those are 48 and 96.</small>"]
C2["<b>÷ 4 for docling</b><br/><small>one worker was measured holding ~300% CPU.<br/>A later full-corpus sweep found the divisor<br/>too conservative by ~1.41x — not yet changed.</small>"]
C3["<b>clamped <i>and</i> reported</b><br/><small>silently obeying thrashes; silently ignoring leaves<br/>someone believing they configured something they didn't</small>"]
C1 --> C2 --> C3
end
SER["<b>workers = 1 — the default</b><br/><small>a genuinely different code path: no executor,<br/>no pickling, no subprocess. Incremental skipping<br/>means a routine run has almost nothing to do,<br/>so pool setup would cost more than it saves.</small>"]
subgraph POOL["<b>the pool</b> — each backend gets the concurrency it can use"]
direction TB
EXE{{"backend?"}}
TP["<b>ThreadPoolExecutor</b> · <code>pdftotext</code><br/><small>external subprocess — releases the GIL.<br/>Processes would add pickling and spawn cost<br/>to buy the same OS-level concurrency.</small>"]
PP["<b>ProcessPoolExecutor</b> · <code>docling</code><br/><small>runs in-process and holds the GIL.<br/><b>forkserver</b> or <b>spawn</b>, never plain fork —<br/>a forked child inherits a broken CUDA context.</small>"]
LPT["<b>submit biggest-file-first</b><br/><small>one 675-page document is 5% of the corpus's pages.<br/>Picked up last it would define the wall clock by itself.<br/>File size, not page count — counting pages needs a<br/>PDF library the corpus layer refuses to depend on.</small>"]
DEV["<b>init_worker</b> — one CUDA card each<br/><small>a shared counter under a lock, round-robin over<br/><code>usable_devices()</code> (≥ 2.5 GiB free). Without this,<br/>docling's <code>AcceleratorDevice.AUTO</code> resolves to<br/><code>cuda:0</code> in <i>every</i> process.</small>"]
EXE -- pdftotext --> TP
EXE -- docling --> PP
TP --> LPT
PP --> LPT --> DEV
end
RET["<b>workers return</b> <code>(citekey, out_path, exception)</code><br/><small>the exception is <i>returned</i>, not raised, so both the value<br/>and its type survive pickling — <code>sync</code> reports<br/><code>ExtractionError</code> and <code>BackendUnavailable</code> differently</small>"]
subgraph FAIL["<b>five failure modes, each handled where it can be</b>"]
direction TB
F1["<b>a dead worker</b> → <code>BrokenProcessPool</code><br/><small>results collected with <code>as_completed</code>, not <code>map</code>,<br/>so documents that already finished are kept</small>"]
F2["<b>a hung pool</b> → stall watchdog<br/><small>fires when <i>no</i> document completes for <code>stall_timeout</code>;<br/>warns at half. Not a per-document deadline — no threshold<br/>separates a hang from the legitimate 246s document.</small>"]
F3["<b>a slow document</b> → <code>document_timeout</code><br/><small>a real kill for pdftotext; a cooperative between-stages<br/>check for docling. Not equally strong, and said so.</small>"]
F4["<b>CUDA OOM</b> → demote that worker to CPU<br/><small>for the rest of the run, and retry immediately.<br/>Surviving the CPU retry marks it <i>transient</i>.</small>"]
F5["<b>Ctrl+C</b> → explicit SIGINT handler<br/><small><code>except KeyboardInterrupt</code> around <code>as_completed()</code><br/>never fires. Terminate, grace, <code>kill()</code>, <code>os._exit</code> —<br/>safe only because the ledger commits incrementally.</small>"]
end
PARENT["<b>the parent keeps what only the parent can do</b><br/>every ledger and cache write · results replayed <b>in bib order</b><br/><small>sqlite has a single writer, and the parent is the only place<br/>that can order results deterministically</small>"]
PARTIAL["<b>partial success is a failure</b><br/><small><code>convert(raises_on_error=True)</code> raises only on FAILURE.<br/>A PARTIAL_SUCCESS would hand the citation gate a source that<br/>silently ends at page k of n — so both call sites check the<br/>status and raise <i>before</i> anything is written.</small>"]
LOCK["<b>and around all of it: one writer at a time</b><br/><code>content/pipeline.lock.db</code> · a dedicated sqlite file under <code>BEGIN IMMEDIATE</code><br/><small>a RESERVED lock does not block readers, so the gate, retrieval and the<br/>drafting skills keep working during a run. A second writer gets SQLITE_BUSY<br/>and exits 2. A killed holder releases it immediately — no staleness heuristic.</small>"]
P3 --> W
CEIL -.-> W
W -- "= 1" --> SER
W -- "> 1" --> EXE
DEV --> RET
SER --> RET
RET --> PARTIAL --> PARENT
FAIL -.-> RET
LOCK -.-> PRE
classDef pre fill:#eef2ff,stroke:#4f46e5,stroke-width:1.5px,color:#1e1b4b
classDef dec fill:#fefce8,stroke:#a16207,stroke-width:1.5px,color:#422006
classDef pool fill:#faf5ff,stroke:#9333ea,stroke-width:1.5px,color:#3b0764
classDef fail fill:#fef2f2,stroke:#dc2626,stroke-width:1.5px,color:#450a0a
classDef parent fill:#f0fdf4,stroke:#16a34a,stroke-width:2px,color:#052e16
classDef lock fill:#fff7ed,stroke:#c2410c,stroke-width:1.5px,color:#431407
class P1,P2,P3,C1,C2,C3,SER pre
class W,EXE dec
class TP,PP,LPT,DEV pool
class F1,F2,F3,F4,F5,PARTIAL fail
class RET,PARENT parent
class LOCK lock
By genre¶
The ladder above draws the pipeline as one thing. It isn't, quite -- the
five genre skills in .claude/skills/ use very different amounts of it.
The enrichment layer in particular is worth building for two of them,
largely wasted on two others, and reduced to a preview step for the fifth.
| Genre | Skills | Retrieval | Enrichment stages | src.draft references |
|---|---|---|---|---|
| Genre A: corpus-led | survey-writer, deep-research |
BM25 or embed_index |
docling + embed, both worth it |
yes |
| Genre B: teaching | tutorial-writer, textbook-chapter-writer |
BM25 only | none | yes (custom heading) |
| Genre C: LaTeX-native | thesis-chapter-writer |
BM25 only | none | no -- skipped |
All five run the same gate, in the same loop, with the same wording.
Genre A: corpus-led¶
Skills: survey-writer, deep-research
The corpus is the content. Nearly every sentence is a cited claim, so
these are the two skills that pay off the enrichment layer. docling
gives passages good enough to survive review; embed gives semantic
recall, finding the paper that makes your point in words you did not
search for. They are also the only two skills whose SKILL.md names
src.enrich.embed_index.search() as an alternative to BM25.
Both read the same corpus the rest of the pipeline does, and that corpus is the bibliography and nothing else -- so every document either skill can reach carries a citekey the gate will accept, and there is no class of source that has to be discussed by title because it may not be cited.
bertopic sits off to one side because no skill calls it. It is for
you, deciding what the survey should be about before anything is drafted.
Same reason, other direction: src.review's coverage aid ("retrieval surfaced
this paper -- did the draft actually cite it?") is only a meaningful
question in this genre.
flowchart LR
P0["<b>1 · CURATE</b><br/><i>you, in Zotero</i><br/><br/>Breadth is the whole job here.<br/>A thin corpus shows up<br/>immediately as a thin survey.<br/><br/><b>papers/bibliography.bib</b><br/><small>the only source either skill can reach —<br/>everything retrieval returns is citable</small>"]
P1["<b>2 · SYNC</b><br/><i>deterministic</i><br/><br/><code>python -m src.corpus sync</code><br/><br/><b>content/ledger.sqlite</b><br/><b>content/parsed/*.txt</b>"]
HEAVY["<b>ENRICHMENT — worth it for this genre</b><br/><code>-m src.enrich --stages docling,embed</code><br/><br/><b>docling</b> → layout-aware .md + .passages.json<br/><small>better quotable passages, so claims survive review</small><br/><br/><b>embed</b> → content/chroma/<br/><small>semantic recall: finds the paper that argues the<br/>point in different words. <b>Both skills name<br/><code>embed_index.search()</code> by hand.</b></small>"]
P2["<b>3 · DRAFT</b><br/><i>every sentence is a cited claim</i><br/><br/><b>survey-writer</b> — over-fetch <code>k=15</code>,<br/>hand-cluster into 2-4 sub-themes,<br/>comparison table + gap analysis<br/><br/><b>deep-research</b> — perspectives,<br/>parallel interviews, contradiction map,<br/>then cited sections<br/><br/><b>content/drafts/<slug>.md</b>"]
P3{{"<b>4 · VERIFY</b><br/><code>src.draft gate</code><br/><br/>Highest citation density<br/>in the repo, so this<br/>is the genre where<br/>the gate earns its keep"}}
FIX["<b>DISCARD DRAFT</b><br/><small>swap the key for one retrieval<br/>actually returned, or drop the claim.<br/>Never invent one.</small>"]
P4["<b>5 · PUBLISH</b><br/><br/><code>src.draft references</code> → IEEE list<br/><code>src.draft render --format tex</code><br/><code>--format pdf</code> · <code>--format md</code><br/><br/><b>content/rendered/<slug>.pdf</b>"]
AID["<b>LAYER 4 · REVIEW — afterwards, by you, never a gate</b><br/><code>src.review provenance</code> · <code>src.review verbatim</code><br/><code>src.review coverage</code><br/><small>“retrieval surfaced it — did the draft cite it?”<br/>only meaningful when the corpus <i>is</i> the argument</small>"]
BERT["<b>bertopic</b> → content/topics.json<br/><small>no skill calls this. It is for <i>you</i>, deciding what<br/>the survey should even be about.</small>"]
P0 ==> P1 ==> P2 ==> P3
P1 ==> HEAVY ==> P2
P3 == "PASS · exit 0" ==> P4
P3 -- "FAIL · exit 1" --> FIX
FIX == "re-draft · <b>loop until it passes</b>" ==> P2
P4 -.-> AID
P1 -.-> BERT -.-> P0
classDef you fill:#fff7ed,stroke:#c2410c,stroke-width:1.5px,color:#431407
classDef det fill:#eef2ff,stroke:#4f46e5,stroke-width:1.5px,color:#1e1b4b
classDef gen fill:#f0fdf4,stroke:#16a34a,stroke-width:1.5px,color:#052e16
classDef gate fill:#fef2f2,stroke:#dc2626,stroke-width:3px,color:#450a0a
classDef out fill:#faf5ff,stroke:#9333ea,stroke-width:1.5px,color:#3b0764
classDef bad fill:#fee2e2,stroke:#dc2626,stroke-width:1.5px,color:#450a0a
classDef heavy fill:#faf5ff,stroke:#9333ea,stroke-width:2.5px,color:#3b0764
classDef aid fill:#f8fafc,stroke:#94a3b8,stroke-dasharray:4 3,color:#0f172a
class P0 you
class P1 det
class P2 gen
class P3 gate
class P4 out
class FIX bad
class HEAVY heavy
class AID,BERT aid
Genre B: teaching¶
Skills: tutorial-writer, textbook-chapter-writer
The corpus is a garnish. Most of the content is original -- worked
examples, exercises, a lesson that has to actually run. Citations are
deliberately confined: tutorial-writer bans them mid-lesson and allows
them only in a closing "Where to go next", and textbook-chapter-writer
uses them for motivation and background.
So the enrichment layer is mostly wasted here. Neither SKILL.md mentions
embed_index; both use src.retrieval.search(), which is stdlib BM25.
Building a semantic index to place four citations is effort in the wrong
place. The rendering they do want at the end is not an enrichment stage
at all -- render_output is the drafting layer's own publish step, and
needs no package from the enrich group.
This is also the one genre where the gate can legitimately pass with zero citations, and both SKILL.md files say so. An empty reference list is a correct outcome for a tutorial.
Which points at the real risk: the failure mode in this genre is not a bad citekey, it is writing the wrong genre -- a tutorial that explains instead of instructing. Both skills open by warning about exactly that, and no gate in this repository can catch it.
flowchart LR
P0["<b>1 · CURATE</b><br/><i>you, in Zotero</i><br/><br/>A handful of grounding papers is<br/>enough. Breadth buys you<br/>very little in this genre.<br/><br/><b>papers/bibliography.bib</b>"]
P1["<b>2 · SYNC</b><br/><i>deterministic</i><br/><br/><code>python -m src.corpus sync</code><br/><br/><b>content/ledger.sqlite</b><br/><b>content/parsed/*.txt</b>"]
HEAVY["<b>ENRICHMENT — not worth it here</b><br/><br/><s>docling</s> · <s>embed</s> · <s>bertopic</s><br/><small>Neither SKILL.md mentions <code>embed_index</code>.<br/>Both use <code>src.retrieval.search()</code> — <b>BM25, stdlib</b> —<br/>and building a semantic index to place four<br/>citations is effort spent in the wrong place.</small><br/><br/><b>render</b> is the drafting layer's own publish step<br/><small><code>python -m src.draft render</code> — it was never<br/>enrichment work, and is deliberately not a stage</small>"]
P2["<b>3 · DRAFT</b><br/><i>mostly original content</i><br/><br/><b>tutorial-writer</b> — one path, keyboard-first,<br/>verified to actually run. Citations are<br/><b>banned mid-lesson</b>; they live only in<br/>a closing “Where to go next”.<br/><br/><b>textbook-chapter-writer</b> — objectives,<br/>worked examples, exercises. Cites for<br/><b>motivation and background only</b>.<br/><br/><b>content/drafts/<slug>.md</b>"]
P3{{"<b>4 · VERIFY</b><br/><code>src.draft gate</code><br/><br/><small><b>May legitimately pass with<br/>zero citations.</b> Both SKILL.md<br/>files say so explicitly — this is<br/>the one genre where an empty<br/>reference list is correct.</small>"}}
FIX["<b>DISCARD DRAFT</b><br/><small>drop the claim; the lesson<br/>rarely needed it in the first place</small>"]
P4["<b>5 · PUBLISH</b><br/><br/><code>src.draft references</code><br/><small><b>tutorial-writer</b> passes<br/><code>--heading "Further reading"</code>,<br/>which then survives into the render<br/>instead of being stripped</small><br/><br/><code>src.draft render --format pdf</code>"]
RISK["<b>the real failure mode here isn't a bad citekey</b><br/><small>It is writing the wrong genre: a tutorial that explains<br/>instead of instructing, or a chapter that instructs instead<br/>of explaining. Both SKILL.md files open by warning about<br/>exactly that — and no gate in this repository can catch it.<br/><b>You are the check.</b></small>"]
P0 ==> P1 ==> P2 ==> P3
P1 -. "publish step only" .-> HEAVY
HEAVY -.-> P4
P3 == "PASS · exit 0<br/>(often trivially)" ==> P4
P3 -- "FAIL · exit 1" --> FIX
FIX == "re-draft · <b>loop until it passes</b>" ==> P2
P2 -.-> RISK
classDef you fill:#fff7ed,stroke:#c2410c,stroke-width:1.5px,color:#431407
classDef det fill:#eef2ff,stroke:#4f46e5,stroke-width:1.5px,color:#1e1b4b
classDef gen fill:#f0fdf4,stroke:#16a34a,stroke-width:1.5px,color:#052e16
classDef gate fill:#fef2f2,stroke:#dc2626,stroke-width:3px,color:#450a0a
classDef out fill:#faf5ff,stroke:#9333ea,stroke-width:1.5px,color:#3b0764
classDef bad fill:#fee2e2,stroke:#dc2626,stroke-width:1.5px,color:#450a0a
classDef off fill:#f1f5f9,stroke:#94a3b8,stroke-dasharray:5 4,color:#334155
classDef risk fill:#fefce8,stroke:#a16207,stroke-width:2px,color:#422006
class P0 you
class P1 det
class P2 gen
class P3 gate
class P4 out
class FIX bad
class HEAVY off
class RISK risk
Genre C: LaTeX-native¶
Skills: thesis-chapter-writer
The output isn't the deliverable. This skill emits a standalone .tex
fragment with \citep/\citet and no preamble, meant to be \input by
your own thesis document -- so rendering produces a preview, not the
artifact that matters. A rendering failure
never blocks presenting the draft.
It is also the only skill that deliberately skips src.draft references.
Your thesis owns its own bibliography; a second reference list inside the
fragment would collide with it. That exception is easy to miss when
reading the README's gate -> references -> render chain, which is why
it gets its own diagram.
The gate is not relaxed for LaTeX: src.draft gate reads
\citep/\citet as readily as Markdown [@key], and the loop-until-OK
rule is worded identically to the other four skills.
flowchart LR
P0["<b>1 · CURATE</b><br/><i>you, in Zotero</i><br/><br/>Depth over breadth: everything<br/>bearing on one research question.<br/><br/><b>papers/bibliography.bib</b><br/><small>this same .bib is very likely already<br/>your thesis's <code>\\bibliography</code> — which is<br/>why the two stay consistent for free</small>"]
P1["<b>2 · SYNC</b><br/><i>deterministic</i><br/><br/><code>python -m src.corpus sync</code><br/><br/><b>content/ledger.sqlite</b><br/><b>content/parsed/*.txt</b>"]
HEAVY["<b>ENRICHMENT — not worth it here</b><br/><br/><s>docling</s> · <s>embed</s> · <s>bertopic</s><br/><small>SKILL.md names <code>src/retrieval.py</code> alone —<br/><b>BM25, <code>k=15</code>, then filter by hand</b></small><br/><br/><b>render</b> is <i>not</i> an enrichment stage<br/><small>it is the drafting layer's own publish step, and here<br/>even that is disposable: <code>--format md</code>/<code>--format pdf</code><br/>to <i>look</i> at the chapter. The artifact that matters<br/>is the .tex you <code>\\input</code>. “A rendering failure<br/>never blocks presenting the draft.”</small>"]
P2["<b>3 · DRAFT</b><br/><i>RQ-driven narrative</i><br/><br/>A standalone <b>.tex fragment</b> —<br/><code>\\citep</code> / <code>\\citet</code>, <b>no preamble</b>,<br/>meant to be <code>\\input</code> by your own<br/>thesis document.<br/><br/><b>content/drafts/<slug>.tex</b>"]
P3{{"<b>4 · VERIFY</b><br/><code>src.draft gate</code><br/><br/><small>Reads <code>\\citep</code>/<code>\\citet</code> as<br/>readily as Markdown <code>[@key]</code>,<br/>so the .tex path is gated<br/>exactly as hard</small>"}}
FIX["<b>DISCARD DRAFT</b><br/><small>“Fix and re-run until <code>OK</code>.<br/>Never present a draft<br/>that hasn't passed.”</small>"]
P4["<b>5 · PUBLISH</b> — <i>the odd one out</i><br/><br/><b>❌ <s>src.draft references</s> — deliberately skipped</b><br/><small>the only genre that skips it. Your thesis's own<br/>bibliography owns the reference list; a second<br/>one inside the fragment would collide with it.</small><br/><br/><code>src.draft render --format md | pdf</code><br/><small>a preview for you, not the deliverable</small>"]
OUT["<b>the actual output</b><br/><code>\\input{chapter-4}</code><br/><small>into your own thesis, compiled by your own<br/>LaTeX toolchain against your own .bib</small>"]
P0 ==> P1 ==> P2 ==> P3
P1 -. "publish step only" .-> HEAVY
HEAVY -.-> P4
P3 == "PASS · exit 0" ==> P4
P3 -- "FAIL · exit 1" --> FIX
FIX == "re-draft · <b>loop until it passes</b>" ==> P2
P4 ==> OUT
classDef you fill:#fff7ed,stroke:#c2410c,stroke-width:1.5px,color:#431407
classDef det fill:#eef2ff,stroke:#4f46e5,stroke-width:1.5px,color:#1e1b4b
classDef gen fill:#f0fdf4,stroke:#16a34a,stroke-width:1.5px,color:#052e16
classDef gate fill:#fef2f2,stroke:#dc2626,stroke-width:3px,color:#450a0a
classDef out fill:#faf5ff,stroke:#9333ea,stroke-width:1.5px,color:#3b0764
classDef bad fill:#fee2e2,stroke:#dc2626,stroke-width:1.5px,color:#450a0a
classDef off fill:#f1f5f9,stroke:#94a3b8,stroke-dasharray:5 4,color:#334155
classDef final fill:#ecfdf5,stroke:#16a34a,stroke-width:2.5px,color:#052e16
class P0 you
class P1 det
class P2 gen
class P3 gate
class P4 out
class FIX bad
class HEAVY off
class OUT final
Appendix¶
These two answer narrower questions than the groups above, and are kept separate for that reason.
One draft, in time order¶
The full workflow with time on the vertical axis instead of dependency.
Useful for two things in particular. The first is seeing that the gate
runs twice: the PostToolUse hook fires on every write under
content/drafts/, so a bad citekey cannot reach disk even if a skill
forgets, and the skill then runs the gate itself, because the hook fires
only on the tool call that wrote the file. The second is seeing the
loop that wraps both of them.
sequenceDiagram
autonumber
actor You as You
participant Z as Zotero
participant Sync as src.corpus sync<br/>(corpus layer)
participant Led as content/<br/>ledger.sqlite
participant Ret as src.retrieval<br/>+ src.passages
participant Skill as genre skill<br/>(.claude/skills/)
participant Hook as PostToolUse hook
participant Gate as src.draft gate
participant Ref as src.draft references
participant Ren as src.draft render<br/>(pandoc / TeX Live)
rect rgba(255,247,237,0.6)
Note over You,Z: CURATE — the only step that adds a paper
You->>Z: add papers, then Export BibTeX + files
Z-->>You: papers/bibliography.bib + attachment tree
end
rect rgba(238,242,255,0.6)
Note over Sync,Led: SYNC — deterministic, incremental, holds the run lock
You->>Sync: python -m src.corpus sync
Sync->>Sync: take content/pipeline.lock.db
Sync->>Led: upsert one row per citekey
Led-->>Sync: which PDFs actually changed
Sync->>Sync: extract text → content/parsed/*.txt
Sync->>Led: mark parsed / parse_failed / no_pdf
Sync-->>You: summary + stale list + exit 0 | 1 | 2
end
rect rgba(240,253,244,0.6)
Note over You,Skill: DRAFT — the generative half
You->>Skill: "write a survey section on digital twin composability"
Skill->>Led: is the ledger populated?
Led-->>Skill: N citekeys parsed
Skill->>Ret: search(query, k)
Ret-->>Skill: ranked snippets, each tied to a real citekey
Skill->>Skill: write content/drafts/<slug>.md
end
rect rgba(254,242,242,0.7)
Note over Hook,Gate: VERIFY — enforced mechanically, twice
Hook->>Gate: every Write/Edit under content/drafts/
Gate->>Led: are these citekeys in the ledger?
loop until the gate exits 0 — you are shown nothing before that
alt any citekey unresolved
Led-->>Gate: no
Gate-->>Hook: FAIL (exit 1)
Hook-->>Skill: the write is blocked
Skill->>Skill: swap the key for one retrieval returned,<br/>or drop the claim — never invent one
else all resolvable
Led-->>Gate: yes
Gate-->>Skill: PASS (exit 0)
end
end
Skill->>Gate: runs the gate again itself, before showing you anything
Note right of Skill: If the paper genuinely isn't in the corpus,<br/>the loop stops and the skill tells you to<br/>add it in Zotero and re-run sync.
end
rect rgba(250,245,255,0.6)
Note over Ref,Ren: PUBLISH
Skill->>Ref: python -m src.draft references <draft>
Ref->>Led: bib_fields for exactly the cited citekeys
Ref-->>Skill: a ## References section, numbered by first appearance
Skill->>Ren: src.draft render --format pdf
Ren-->>You: content/rendered/<slug>.pdf
end
Note over You,Ren: Layer 4, the review layer — optional afterwards, never a gate:<br/>src.review provenance · verbatim · coverage<br/>reports land in content/review/, mirroring the draft
The life of a single citekey¶
The ledger's own state machine, for when the question is "why can't I
cite this paper?". parsed is the only state that makes a citekey
citable, and the self-loop on it is the incremental path that makes a
routine re-run nearly free.
Note that stale deletes nothing. A bib export that comes back short a
citekey is far more often a botched re-export or a BIB_FILE pointing at
the wrong path than an intentional deletion, so the row survives until you
re-run with --remove-stale.
stateDiagram-v2
direction TB
[*] --> discovered : appears in bibliography.bib<br/>(src.bib_reader)
discovered --> no_pdf : no resolvable<br/>file field
discovered --> parsing : PDF found and<br/>its bytes changed
no_pdf --> parsing : you attach the PDF in<br/>Zotero and re-export
parsing --> parsed : text extracted in full
parsing --> failed_deterministic : backend cannot read this<br/>PDF, or parsed it only partly
parsing --> failed_transient : worker died, timeout,<br/>CUDA OOM, broken pool
failed_transient --> parsing : retried automatically<br/>on the next sync
failed_deterministic --> parsing : only on<br/>sync --reparse
parsed --> parsing : PDF bytes changed<br/>(size+mtime, then sha256)
parsed --> parsed : bytes unchanged, skipped —<br/>the incremental path
parsed --> stale : dropped out of<br/>bibliography.bib
no_pdf --> stale : dropped out of<br/>bibliography.bib
stale --> parsed : it was a botched re-export —<br/>fix the export and re-sync
stale --> [*] : sync --remove-stale<br/>opt-in, after you read the list
note right of parsed
Only this state makes the citekey citable.
src.draft gate resolves a citekey against
the ledger, so a row that is not here
cannot appear in a draft that renders.
end note
note left of failed_deterministic
Never silent, never pointlessly expensive.
Not re-parsed — the same minutes would
reach the same answer — but every run
exits 1 until a human deals with it.
end note
Editing these¶
Each diagram is plain Mermaid in a fenced block above. GitHub renders them, and so does any Mermaid-aware editor. That block is the source of truth.
The same eleven are also checked in as standalone files, so you can drop one into a slide deck or a paper without copying it out of this document:
| Path | What it is |
|---|---|
docs/diagrams/<name>.mmd |
the Mermaid source, with a title line |
docs/diagrams/svg/<name>.svg |
the rendered export, ~1900px wide on a white background |
| Diagram | <name> |
|---|---|
| 1. One glance | v1-overview |
| 2. Your first run | v2-first-run |
| 3. The full workflow | 00-main-workflow |
| 4. Everything on disk | v3-artifacts |
| 5. Gates and exit codes | v4-gates-and-failure |
| 6. Inside one parse | v5-parallelism |
| Genre A: corpus-led | g1-corpus-led |
| Genre B: teaching | g2-teaching |
| Genre C: LaTeX-native | g3-thesis |
| Appendix: one draft, in time order | extra-sequence |
| Appendix: the life of a single citekey | extra-ledger-state |
Those are exports, not a second source. Edit the fenced block first, then re-render, or the two drift apart:
1 2 | |
Keep them honest the way the rest of this repository stays honest: if a diagram and the code disagree, the diagram is the bug.