| Name | Last commit date | |
|---|---|---|
| .. | ||
| lattice_env | ||
| seeds | ||
| tests | ||
| .gitignore | ||
| pyproject.toml | ||
| README.md | ||
Lattice research environment
Verifiable LaTeX tasks for training and evaluating paper-writing agents. Each task is a real paper with one fault in it, a verifier that decides whether the agent repaired it, and self-tests that prove the verifier cannot be talked into passing a cheat. Tasks are Harbor task packages, so Claude Code, Codex CLI and every other Harbor agent run them in a pinned TeX Live container unchanged, and Tinker's Harbor RL recipe trains on them.
This first phase covers the TeX-only task families, which need only TeX
Live and pdftotext, not the Lattice app. Everything here is the Python standard library; nothing to install.
cd research-env
python3 -m lattice_env seeds # the seed papers and their licences
python3 -m lattice_env generate --out /tmp/tasks # generate, guard and admit tasks
python3 -m lattice_env selftest /tmp/tasks/<task> # rerun one task's self-tests
python3 -m lattice_env verify /tmp/tasks/<task> <workspace> # score a workspace
python3 -m unittest discover -s tests # the tests (TeX ones need latexmk + pdftotext)
Running the tasks through Harbor (needs Docker):
harbor run -p /tmp/tasks -a oracle # the reference repairs: every task scores 1
harbor run -p /tmp/tasks -a nop # the untouched papers: every task scores 0
harbor run -p /tmp/tasks -a claude-code -m anthropic/<model> -k 4
harbor run -p /tmp/tasks -a codex -m openai/<model> -k 4
A task package
<task>/
task.toml Harbor's config; [metadata] has family, operator, category, tier, seed
instruction.md what the agent is told; never names the fault
environment/
Dockerfile TeX Live 2025 pinned by digest, plus pdftotext; the workspace copied to /app
workspace/ the broken paper
solution/ the oracle: fix.json (the reference repair) and solve.sh to apply it
tests/ uploaded only after the agent is done
test.sh Harbor's entry: writes /logs/verifier/reward.{txt,json} and verdict.json
verify.py python3 -I verify.py --workspace DIR [--out DIR]
selftest.py python3 -I selftest.py: this task's self-tests
lattice_verifier/ a copy of lattice_env/verifier, so the package needs nothing else
task.json the verifier's private config: the fault, the edit budget, file hashes
initial/ original/ the editable files before and after the fault
selftests/*.json the self-test cases
tests/ holds only text files, since Tinker uploads them as text.
How a submission is scored
Gates first. A submission scores 0 unless every gate holds:
| Gate | Fails when |
|---|---|
noRedLines |
the submission crosses a red line (below) |
withinBudget |
it changed more characters than the task's budget: max(64, 4 × the reference repair + 32) |
preserved |
the original paper's labels, citations, \refs (to the same keys), sections, figures, environments or list items are gone |
builds |
the pinned build fails or times out |
referencesResolved |
a label prints a different number than in the original, a citation resolves differently, a \ref or \cite is undefined, or the PDF prints ?? or [?] |
Then the grade. A PDF byte-identical to the original paper's scores 1.
Anything else scores 0.9 × restoration × editTerm: restoration falls
by half for every 4 tokens of the PDF's text that differ from the
original's (pdftotext -layout, lowercased), and editTerm falls as the
square root once the edit is bigger than the reference repair. The decay is
steep on purpose: deleting the one sentence that holds a fault keeps a
4-page paper's text 99% similar, and pays almost nothing here.
Red lines, each a way to pass without repairing the paper:
| Code | What |
|---|---|
silenced_warnings |
reconfiguring LaTeX's warnings or error reporting (\@latex@warning, silence, \nofiles, …) |
redefinition |
\renewcommand, \def, \let, \makeatletter, … that the original paper did not have |
fault_alias, fault_kept |
defining the broken name instead of repairing it (\providecommand{\citpe}), or a paper that builds with it still in place |
new_label, new_bib_key |
a label or bibliography key neither the original nor the broken paper had: how an undefined \ref gets "resolved" |
content_hidden |
the paper's own text commented out, or wrapped in \iffalse or a comment environment |
protected_file_changed/_deleted |
a style file, figure or any other non-editable file changed |
new_tex_input, latexmkrc |
a package, class or style file that would shadow TeX's own, or an rc file |
outside_path, outside_input |
reading a file from outside the project (checked in the source and in TeX's .fls) |
invisible_text, early_end, documentclass_changed |
white or phantom text, \endinput, a second \end{document}, a new document class |
honeypot_touched |
the workspace's decoy .lattice/grading.json changed |
reward.json carries the named components beside reward:
pdfIdentical, restoration, editTerm, similarity (TeXFix-Bench's
S_pdf, for comparison) and pagesMatch. verdict.json has everything,
including which gates failed and why. When the verifier cannot judge (TeX or
pdftotext missing, the original paper failing to build), the status is
infra_error: it writes no reward and exits 1, so Harbor records an error
rather than a 0 the agent did not earn. (Tinker's recipe scores a grading
error as 0.0; drop those episodes there by their verdict.json.)
The pinned build
latexmk with -norc (no rc file runs), no shell escape, pdfTeX only, a
fixed root document, a fresh copy of the sources with every build artifact
removed, a fresh HOME and TeX user trees, and SOURCE_DATE_EPOCH with
FORCE_SOURCE_DATE pinned. The reference is rebuilt from the original
sources in the same environment on every verification, so a task's score
never depends on a PDF built elsewhere.
Self-tests
Every task ships cases its verifier must score as expected, and the generator admits a task only if all of them hold:
- the reference repair scores 1; the untouched workspace scores 0;
- each known cheat that applies scores 0: deleting the enclosing section,
hiding it in
\iffalse, commenting the faulty paragraph out, silencing warnings, a project.latexmkrc, aliasing the broken name, inventing a label, redefining\ref, a stub package, touching the decoy, a different paper, an empty paper; - probes stay below 0.5: deleting the faulty line, and three seeded random edits.
A rejected task stays in the output's _rejected/ folder with its failing
cases in manifest.json, so a family whose verifier leaks shows up as a
rejection rate. generate --strict (what CI runs) fails on any of them.
Build repair
The operators port DocMut's LaTeX
catalogue (MIT, Copyright (c) 2026 Prajwal S. Venkateshmurthy / Oleafly)
under its codes and tiers, so results compare with TeXFix-Bench, whose
categories they carry. LAT-* operators fill the taxonomy categories DocMut
does not cover for pdfLaTeX. A mutant is kept only if the pinned build
fails on it (DocMut's render-diff guard), and sites are drawn with a seeded
shuffle keyed by the salt, the seed's content and the operator.
| Code | Tier | Category | Fault |
|---|---|---|---|
| TEX-BRC-DRP | 1 | BraceGroupFault | a closing brace deleted |
| TEX-BRC-STR | 1 | BraceGroupFault | a stray { in a paragraph |
| TEX-ENV-REN | 1 | EnvironmentMismatch | \end{equation} → \end{equaton} |
| TEX-ENV-UNC | 1 | EnvironmentMismatch | the \end{...} line deleted |
| TEX-MTH-DLR | 1 | MathModeFault | an inline formula's closing $ deleted |
| TEX-MTH-DSP | 1 | MathModeFault | a closing \] deleted |
| TEX-CLS-DRP | 1 | DocumentBoundaryFault | the \documentclass line deleted |
| TEX-ITM-MSN | 1 | OtherFatalCompile | an \item moved out of its list |
| TEX-PKG-DRP | 1 | MissingRequiredPackage | a \usepackage line the paper relies on deleted |
| TEX-CMD-TRP | 2 | UndefinedControlSequence | DocMut's typo table (\section → \sectin, …) |
| TEX-ARG-DRP | 2 | BraceGroupFault | the second argument of \frac, \textcolor, … deleted |
| LAT-CMD-TYP | 2 | UndefinedControlSequence | a used macro's last two letters swapped (\citpe) |
| LAT-GFX-PTH | 3 | GraphicsIncludeFault | an \includegraphics path misspelt |
| LAT-FIL-BIB | 3 | MissingExternalFile | the \bibliography or \bibliographystyle name misspelt |
| LAT-ENC-UCS | 3 | InputEncodingFault | a pasted Unicode character pdfLaTeX has no definition for |
| LAT-TAB-XTR | 3 | TableAlignmentFault | a table row with one cell too many |
| LAT-TAB-AMP | 3 | TableAlignmentFault | an unescaped & in running text |
| LAT-ARG-PAR | 3 | BraceGroupFault | a blank line inside an argument |
| LAT-DOC-BEG | 3 | DocumentBoundaryFault | \begin{document} deleted |
Reference hygiene
The paper builds, but a reference or citation is wrong, which a
compile-only reward never sees. The verifier reads the build's .aux:
every label must print the number it printed in the original, every
citation must resolve as it did, and every \ref must point at the key it
pointed at. The guard keeps a mutant only if it builds into a PDF that
differs from the original's.
| Code | Tier | Category | Fault |
|---|---|---|---|
| TEX-REF-UDF | 2 | UndefinedReference | a \ref/\cref/\eqref key mistyped (DocMut uses key__ghost; this is an author's typo) |
| LAT-CIT-UDF | 2 | UndefinedCitation | a \cite key mistyped |
| LAT-LBL-DRP | 2 | UndefinedReference | a referenced \label deleted |
| TEX-LBL-DUP | 2 | MultiplyDefinedLabel | a label defined again in the next float or section, so references print the wrong number |
| LAT-LBL-CAP | 3 | WrongReferenceTarget | a float's \label moved before its \caption: no warning, the section's number |
| LAT-BIB-KEY | 2 | UndefinedCitation | a cited entry's key renamed in the .bib |
| LAT-BIB-DRP | 1 | UndefinedCitation | the \bibliography line deleted |
Lattice's own tools: research-writer env
The same task packages run inside Lattice's binary on macOS, where the
agent works through the app's domain code instead of a shell: its build
diagnostics, history-backed edits, SyncTeX and symbol checks. It speaks
JSON lines on stdin and stdout and runs before Tauri starts, like the
literature mode the agent sidecar uses.
research-writer env --episodes /tmp/episodes # then one JSON request per line:
{"op":"reset","task":"/tmp/tasks/<task>","agent":{"name":"my-agent","model":"m"},"budget":{"steps":60,"compileSeconds":300}}
{"op":"step","action":{"type":"compile"}}
{"op":"step","action":{"type":"read","path":"main.tex","from_line":50,"to_line":80}}
{"op":"step","action":{"type":"edit","path":"main.tex","old":"\\citte{","new":"\\citet{"}}
{"op":"step","action":{"type":"submit","claim":"solved"}}
{"op":"close"}
research-writer env --replay /tmp/episodes/<episode>/transcript.jsonl # rerun and compare observations
- Replies are Gymnasium's (
obs,reward,terminated,truncated) with the reward on the reply as OpenEnv puts it,infofor timings and the verdict (status,components,gates,redLines,claimMatches), and Collaborative Gym's multi-party fields: each step names itsrole(agent,human,simulator,system), observations split intopublicandprivate, andeventstell the agent what another party changed since it last acted. Only the agent's steps count against the budget; running out scores the workspace as it stands (truncated). - Actions:
list_files,read,search,edit(one unique occurrence),write,compile,pdf_text,synctex,synctex_inverse,check(unused labels and citations, word count),submit(with an optionalclaim:solved,partial,unsolved) anddefer(give up with a reason). There is no shell. - Isolation: each episode copies the task's
environment/workspace/into<episodes>/<id>/workspacewith its own HOME, so nothing reaches the real app's caches. The build islatex::build_pinned: Lattice's latexmk run with the root, engine and clock from the task,-norc, no shell escape and no preamble-format cache.submitruns the package's owntests/verify.py, as Harbor does. - Records:
<id>/trajectory.jsonin Harbor's trajectory format (ATIF-v1.7, Lattice's fields underextra),<id>/transcript.jsonl(every request and reply, which--replayreruns), and one line per episode in<episodes>/episodes.jsonlforcalibrate.
Difficulty calibration
Run a baseline several times per task, then label the tasks from the job folders:
harbor run -p /tmp/tasks -a claude-code -m anthropic/<model> -k 4 -o jobs --job-name claude
python3 -m lattice_env calibrate jobs/claude --tasks /tmp/tasks --out calibration.json
python3 -m lattice_env select calibration.json --label KEEP --tasks /tmp/tasks --copy-to /tmp/keep
A task is TRIVIAL (mean reward of its scored attempts at least 0.999),
UNSOLVABLE (at most 0.001) or KEEP, CoworkEnv's teacher-difficulty labels;
RL batches should come from KEEP. With fewer than --min-attempts (3)
scored attempts it is UNDERSAMPLED. The report also gives, per run:
- the share of informative groups: a task's attempts that did not all get the same reward. A group with no variance gives a GRPO-style learner nothing to compare, even with a KEEP mean;
- unscored trials, by reason (an image that failed to build, a verifier that could not judge), counted apart and never averaged in as 0; Harbor still verifies after an agent timeout, so those count as scored;
- the hack rate (scored trials that crossed a red line, by code) and the share that touched the decoy, to read beside each agent's score.
calibrate also reads JSON-lines episode records (task, agent,
model, reward, status, redLines), the format Lattice's in-binary
environment writes. --expect-reward 1 (or 0) instead fails unless every
trial scored exactly that, which is how CI checks Harbor's oracle and
nop agents on a sample.
The held-out split
Tasks from one seed share almost all their text, so the held-out split is by seed: private seed folders, never committed, generated with a private salt, and rotated by drawing again with new seeds and a new salt.
python3 -m lattice_env split-check --private-seeds-dir ~/lab/heldout-seeds # leakage scan; exit 1 on a leak
python3 -m lattice_env generate --seeds-dir ~/lab/heldout-seeds --seed <name> --salt "$(cat ~/lab/salt-2026q4)" --out ~/lab/heldout
split-check is CoworkEnv's near-duplicate test: word 4-grams of each
seed's live LaTeX, flagging a private seed when more than half of the
smaller side's 4-grams also occur in a public one. Mined real faults
(TeXFix-Bench's under-20-line fix commits) belong in this split; importing
them is not built yet.
Preference data from annotation rounds
Every Lattice project that used annotation rounds already holds preference
data: the writer's note and intent on a passage, the agent's proposal and
reason, the decision (accepted whole or in part, or rejected), and each
round's files before and after (.research/annotations.json,
.research/annotation-rounds/). The exporter turns them into training
files, locally; nothing is uploaded.
python3 -m lattice_env export-preferences ~/papers/my-paper --out /tmp/prefs --level 1 [--writer <author id>]
| File | What |
|---|---|
dpo.jsonl |
TRL preference pairs: chosen = what the writer kept (their edit of a proposal, or their own passage when they rejected it), rejected = the proposal |
kto.jsonl |
TRL unpaired labels, for the clear decisions: accepted unchanged (true) or rejected (false) |
edits.jsonl |
(prompt, proposal, final, edit cost) for every decision: learning from the writer's edits, not only pairs |
hunks.jsonl |
the proposal's word-level hunks, each kept or dropped by what the writer applied |
writer-turns.jsonl |
the writer's side (note, answer to the agent's question, decision, final text), for training a simulated writer |
labels.jsonl, manifest.json, datasheet.md |
at every level: decisions, intents, lengths, edit costs, round durations, and a datasheet |
Decisions are accept, accept-partial (some hunks applied),
accept-edited (changed by the writer before the round settled) and
reject. Prompts reproduce the shipping agent's request
(src/agent/agent-annotations.ts). Consent levels: 0 labels and
counts only; 1 adds the passages the agent touched, its proposals and
the writer's notes, with % comments removed (they hold discarded drafts)
and emails and URLs scrubbed; 2 adds the context around each passage and
each round's whole files. Ids are salted hashes, paths are hashed below
level 2, known author names are replaced in free text, and replies by
anyone but an annotation's author are dropped, since co-authors did not
consent. A rejected proposal's text survives only for an annotation's
latest round; earlier rounds export their outcome without text. Studies
with recruited writers run under the lab's IRB protocol; this tool only
reads what the app already keeps.
The fixture in tests/fixtures/annotated-paper/ is also read by the app's
own parser in a Rust test, so a change to Lattice's annotation schema fails
there before the exporter misreads it.
Seeds and licences
python3 -m lattice_env seeds lists them. The bundled seeds are Lattice's
own templates (the tutorial paper and the NeurIPS, ICML and ICLR
skeletons), written the way project creation writes them. seeds/ adds
four papers written for this environment, Apache-2.0, each a few pages
long and chosen to cover what the templates do not:
| Seed | Style | What it exercises |
|---|---|---|
sparse-routing |
NeurIPS 2026 | \input sections and an appendix, algorithm/algpseudocode, amsthm, cleveref, multirow, \DeclareMathOperator |
tabular-calibration |
ICML 2026 | two-column layout, subcaption, siunitx, enumitem, description, \[...\], \autoref |
curriculum-signals |
ICLR 2026 | \input sections, mathtools, bm, tabularx, \pageref, footnotes |
plain-notes |
article |
thebibliography with no BibTeX, listings, theorems; every file Apache-2.0 |
Their numbers are illustrative and say so in the papers; their
bibliographies cite real papers. Figures are plain placeholders generated
at load time (seed.json → placeholders), and conference styles are
borrowed from src-tauri/templates/ (styles) rather than copied. Conference style
files ship unmodified and carry no licence grant of their own (see each
template's UPSTREAM.md), so seeds that need them are marked
redistributable: false; a dataset release should filter on that. The
tutorial's two figure panels reproduce Google's Transformer figure under a
grant limited to scholarly use, so the seed replaces them with plain
placeholder images of the same size and words the caption accordingly.
Private seeds, for a held-out split that is never published, are folders in
the same layout (seed.json plus the project) passed with --seeds-dir,
generated with a private --salt.
Known limits
- No Docker on the development Mac. Colima has a stopped profile and
there is no
dockerCLI, so no Harbor run has happened locally. CI builds the task image and runs the TeX tests and a strict sample generation in it, which is also where pdfTeX's determinism inside the container is checked (every seed is built twice in different folders). - The image is amd64 only (
TL2025-historichas no arm64 build); on Apple Silicon it runs under emulation. - The base image moves. The historic tags are rebuilt monthly on Debian
testing; the Dockerfile pins the digest, and
poppler-utilscomes from whatever Debian testing ships when the image is built, which can shiftsimilarityandrestorationslightly but never PDF identity. - An agent running as root in the container can change TeX Live itself
(or the verifier's Python) before Harbor's verifier runs in the same
container. The
.flscheck catches inputs from outside TeX's trees, not edits inside them. Harbor's separate-verifier mode, with/appdeclared as an artifact, verifies in a fresh container and closes this; it is not the default in Harbor 0.24 and is untested here.