Thesis linters¶
Command-line linters that check MSc thesis manuscripts (PDF or LaTeX sources) against the writing instructions of ml-theses.org — the thesis guide for students supervised by Alex Jung at Aalto University.
All scripts are run with python3 <script> ... and print a findings
report; exit status is 0 when clean, 1 when findings exist, and 2 on
usage errors, so they can be scripted. The heuristic linters need at most
pdftotext (poppler) or PyMuPDF; the *_llm linters call the
Aalto AI API by
default ($AALTO_API_KEY — sign up for a key on the
Aalto API developer portal; Aalto
network/VPN only — see aalto_llm.py; --base-url switches to the
Aalto LLM Gateway or any OpenAI-style endpoint such as OpenRouter).
Run everything at once:
python3 run_all_linters.py thesis.pdf # fast heuristic suite
python3 run_all_linters.py thesis.pdf --llm --bib # + LLM + bibliography
Coverage: ml-theses.org instruction → linter¶
Manuscript preparation¶
| Instruction | Linter | How |
|---|---|---|
| Problem formulation: data points, features, labels defined |
thesis_checklist_llm.py |
verdict problem-formulationwith quoted evidence |
| Research scope/questions well-posed (clear, focused, specific, complex, feasible, relevant) |
rq_quality_lint_llm.py |
per-question criteria verdicts + scope checks (gap, delimitations, alignment) |
| Identify data sources and evaluation criteria | thesis_checklist_llm.py |
verdict data-sources-eval |
| Training loss and validation/test loss explicitly stated |
thesis_checklist_llm.py |
verdict loss-functions |
| Numerical results answer the research questions |
research_questions_lint_llm.pythesis_checklist_llm.py |
per-question tracing global verdict results-discussed |
| Use appropriate baselines or benchmarks | thesis_checklist_llm.py |
verdict baselines |
| Chapter/section introductions | section_intro_lint_llm.pythesis_checklist_llm.pyprose_lint_llm.py |
intro maps its subsections verdict section-introsunmotivated-section |
Reference all numbered equations using \eqref{} |
math_typeset_lint.py |
REF-NOT-EQREF (LaTeX) |
| All numbered equations, tables, figures referenced in the text |
unreferenced_entity_linter.py |
UNREFERENCED, UNLABELED-EQ,UNLABELED-FLOAT (LaTeX + PDF) |
| Present new methods as pseudocode | thesis_checklist_llm.py |
verdict pseudocode |
| Model diagnosis via numerical experiments and mathematical analysis | thesis_checklist_llm.py |
verdict model-diagnosis |
| Figures clear, labelled, informative captions |
figure_lint_llm.pycaption_lint.pythesis_checklist_llm.py |
rendered figures: fonts, whitespace, contrast, axes (pixels + vision LLM) SHORT-CAPTION, NO-CAPTIONverdict captions-informative |
| References formatted per IEEE guidelines | citation_style_lint.py |
style/entry/citation checks (LaTeX + PDF) |
| Terms from the Aalto Dictionary of ML |
terminology_lint.py |
NON-DICTIONARY, TERM-MIX(dictionary term first per cluster) |
Typesetting mathematical texts¶
| Instruction | Linter | How |
|---|---|---|
| Inline math for short expressions; display math for central/referenced equations | math_typeset_lint.py |
LONG-INLINE (+ EQNARRAY hygiene) |
| Punctuate displayed equations as part of the sentence | math_typeset_lint.py |
EQ-NO-PUNCT, EQ-PUNCT-CHECK |
Self-editing pass (prose linter)¶
| Instruction | Linter | How |
|---|---|---|
| Excessive forward referencing | prose_lint.pycrossref_forward_lint.pyforward_ref_lint.pyforward_ref_lint_llm.py |
FORWARD-CUE phrasesfloats defined pages later concepts used before defined (regex and LLM variants) |
| Undefined or re-defined acronyms | acronym_lint.py |
USED-BEFORE-EXPANSION, NEVER-EXPANDED, RE-EXPANDED |
| Inconsistent terminology / synonym switching |
terminology_lint.pyprose_lint_llm.py |
TERM-MIXsynonym-switch |
| Vague quantifiers without a number | prose_lint.pyprose_lint_llm.py |
VAGUE-QUANTIFIERvague-quantifier |
| Jargon / undefined evaluative claims ("smoothest convergence") |
prose_lint.pyprose_lint_llm.py |
JARGONjargon |
| Uncited claims | prose_lint_llm.pybibliography_linter.py |
uncited-claimverifies the cited references |
| Dangling references ("this shows" without antecedent) |
prose_lint.pyprose_lint_llm.py |
DANGLING-REFERENCEdangling-reference |
| Unmotivated sections | prose_lint_llm.pythesis_checklist_llm.py |
unmotivated-sectionverdict section-intros |
| Tense drift | prose_lint_llm.py (tense-drift) |
LLM |
| Section openers stand alone; no narrative jumps between paragraphs |
flow_lint_llm.py |
OPAQUE-OPENER (judged withoutthe preceding text), FLOW-BREAK |
Responsible use of AI, references quality¶
| Instruction | Linter | How |
|---|---|---|
| Disclose AI use in a dedicated statement (not in Methods) | ai_disclosure_lint.py |
NO-AI-STATEMENT, IN-METHODS |
| Record the tool, version, and settings | ai_disclosure_lint.py |
NO-TOOL-NAMED, NO-VERSION |
| Citations verified; high-quality references |
bibliography_linter.py |
existence + author/title/venue vs Crossref/arXiv/DBLP; PREPRINT,WEB-SOURCE, NOT-FOUND |
Not machine-checkable (process instructions)¶
Write literature review/methodology first, defer results chapters, write the abstract last, keep a lab notebook, budget revision rounds, don't upload confidential data to public AI services, accountability for content — these concern how you work, not the manuscript text, so no linter can check them. The closest proxy: run the suite before every revision round.
The linters¶
| Script | Checks | Input | Needs |
|---|---|---|---|
bibliography_linter.py |
cited references exist; author/title/ venue/year match Crossref/arXiv/DBLP |
.bib, .pdf |
network |
unreferenced_entity_linter.py |
numbered equations/tables/figures never referenced | .tex, .pdf |
— |
crossref_forward_lint.py |
references to floats defined much later | .pdf |
PyMuPDF |
forward_ref_lint.py |
concepts used before defined (regex) | .pdf |
PyMuPDF |
forward_ref_lint_llm.py |
concepts used before defined (LLM) | .pdf |
Aalto AI API |
acronym_lint.py |
acronym expanded at first use, no re-expansion | .tex, .pdf |
— |
prose_lint.py |
vague quantifiers, dangling refs, forward cues | .tex, .pdf |
— |
terminology_lint.py |
synonym mixing vs Aalto Dictionary terms | .tex, .pdf |
— |
math_typeset_lint.py |
display-math punctuation, \eqref, long inline math |
.tex |
— |
citation_style_lint.py |
IEEE reference/citation format | .tex, .pdf |
— |
caption_lint.py |
missing/too-short figure & table captions | .tex, .pdf |
— |
ai_disclosure_lint.py |
dedicated AI-use statement with tool + version | .tex, .pdf |
— |
thesis_checklist_llm.py |
9-item manuscript checklist, PASS/FAIL + evidence |
.pdf |
Aalto AI API |
research_questions_lint_llm.py |
each stated research question: answered? where? on what evidence? |
.pdf |
Aalto AI API |
rq_quality_lint_llm.py |
how well-posed are research questions and scope (university criteria)? |
.pdf |
Aalto AI API |
figure_lint_llm.py |
figure quality: fonts vs body text, whitespace, contrast, axes, resolution |
.pdf |
PyMuPDF (+ Aalto AI API unless --no-llm) |
section_intro_lint_llm.py |
does each chapter/section intro map its subsections and tie them together? |
.pdf |
PyMuPDF + Aalto AI API |
flow_lint_llm.py |
narrative flow: section openers that stand alone, no paragraph-to-paragraph discontinuities |
.pdf |
PyMuPDF + Aalto AI API |
prose_lint_llm.py |
LLM self-editing pass (uncited claims, tense drift, jargon, …) |
.tex, .pdf |
Aalto AI API |
run_all_linters.py |
runs everything above | either | — |
Shared modules: lintutil.py (text extraction, report format),
aalto_llm.py (Aalto AI API client; also used by the *_llm linters).
Maintainer note: the suite is mirrored to the ml-theses.org repo
(assets/linters/); after editing, run ./sync_to_site.sh from the
source directory (--check reports drift without writing), then commit
and push the site repo to deploy.
Notes on individual linters¶
bibliography_linter.py — verifies that cited references exist and
match a real record. Findings include NOT-FOUND (possibly hallucinated),
AUTHOR-MISMATCH, TITLE-DRIFT, VENUE-MISMATCH, PREPRINT,
YEAR-MISMATCH. Results cached in .bibcheck_cache.json.
unreferenced_entity_linter.py — LaTeX mode flags \labels no
\ref/\eqref/\cref points to, numbered math without \label, captioned
floats without \label; PDF mode works from captions and right-aligned
equation numbers.
crossref_forward_lint.py — flags "as depicted in Figure 7" on page 3
when Figure 7 appears on page 21 (threshold: > 1 page forward by default).
forward_ref_lint.py / forward_ref_lint_llm.py — paragraph-level
conceptual forward references; the LLM version maintains a running
introduced-concept set, caches per paragraph
(<input>.fwdref_cache.jsonl, --resume).
terminology_lint.py — each synonym cluster lists the Aalto Dictionary
of ML term first; TERM-MIX fires only when two or more variants each occur
--min-count times.
thesis_checklist_llm.py — one LLM call over the full extracted text
(page markers included) returns PASS/FAIL/UNCLEAR per checklist item with a
quoted, page-referenced evidence snippet and a concrete fix for each FAIL.
prose_lint_llm.py — chunked LLM pass (--chunk-chars,
--concurrency, --checks to select categories, --pages for a partial
run).
figure_lint_llm.py — locates every figure via its caption, renders
the figure region at 144 dpi, and checks it two ways. Pixel heuristics
(work offline, --no-llm): EXCESS-WHITESPACE (blank-background fraction
above --max-white) and LOW-RESOLUTION (embedded raster below
--min-dpi at printed size). Vision LLM judgement (the rendered figure is
sent with the body-text font size as yardstick): FONT-TOO-SMALL,
AXES-UNLABELED, OVERLAPPING-TEXT, LOW-CONTRAST, EXCESS-WHITESPACE,
ILLEGIBLE. --save-crops DIR writes the judged renderings for
inspection; --figures 3,7 restricts the run. On the Aalto LLM Gateway the
Qwen3-VL vision model is used automatically.
section_intro_lint_llm.py — for every chapter/section that has
subsections (outline from the PDF's embedded TOC), extracts the text
between the heading and the first subsection heading and judges it:
GOOD only if each subsection is framed as an upcoming part (explicit
section reference, ordinal enumeration, or forward-pointing phrasing) with
its content indicated, plus a connective thread. A thematic categorization
that never says which subsection treats which theme is judged WEAK — the
alignment exists only in hindsight. Uses the full GPT-5 model by default
(the mini tier lets near-miss intros pass). --match 2.4 restricts to one
unit; --levels 1,2 selects outline depths.
flow_lint_llm.py — two checks on the manuscript as a narrative.
opener: the first sentence(s) after every chapter/section heading are
judged WITHOUT the preceding text — exactly like a reader entering at
the heading — and flagged OPAQUE-OPENER when they hang on an
antecedent across the heading (a bare pronoun, or a definite noun
phrase like "A difference of almost an order of magnitude
illustrates ..."); named references ("as shown in Section 2.3") and
deixis to the unit itself are fine. Judged with the full GPT-5 model
(one call per heading, --levels 1,2,3). flow: overlapping windows
of consecutive paragraphs are scanned for non-sequitur transitions and
paragraphs that presuppose not-yet-introduced material (FLOW-BREAK);
headings legitimately reset the narrative and float furniture is
skipped. Complements section_intro_lint_llm.py (which judges only
units WITH subsections) and the dangling-reference checks (which only
see pronoun anaphora, and judge resolvability in-chunk rather than
from the heading).
rq_quality_lint_llm.py — judges how well-posed the research
questions and scope are, against criteria compiled from authoritative
university guidance (the Monash University Library research-question
checklist — focused, researchable, feasible, specific, complex, relevant;
the George Mason University Writing Center criteria — clear, focused,
concise, complex, arguable; and the FINER framework). Per question:
STRONG/ADEQUATE/WEAK with the violated criteria named and a concrete
reformulation (a yes/no-formed engineering question with obvious
quantitative intent is treated as a minor form issue, not a defect).
Scope-level checks: gap identified, delimitations stated, question–
objective alignment, aim coverage. Uses the full GPT-5 model by default.
research_questions_lint_llm.py — extracts every explicitly stated
research question (RQ lists, hypotheses, numbered objectives) and judges
each one: ANSWERED / PARTIALLY-ANSWERED / UNANSWERED, where the answer is
developed, whether the presented results actually support it, and whether
the conclusions chapter revisits it. Warns on NO-RQS (none stated) and
NOT-REVISITED. Exit 1 unless every question is answered and revisited.
Typical workflow for a new thesis PDF¶
python3 run_all_linters.py thesis.pdf # fast pass, fix mechanics
python3 thesis_checklist_llm.py thesis.pdf # content checklist
python3 research_questions_lint_llm.py thesis.pdf # RQs answered?
python3 prose_lint_llm.py thesis.pdf # deep prose pass
python3 bibliography_linter.py thesis.pdf # verify references