diff --git a/.gitignore b/.gitignore
index f68865e..d685d85 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,16 @@
work/
+.venv/
+__pycache__/
+*.pyc
*.aux
*.log
*.out
*.fls
*.fdb_latexmk
*.synctex.gz
+*.toc
.DS_Store
+claims.md
+**/notes/notes.pdf
+**/notes/sections/symbols.tex
+**/out/
diff --git a/README.md b/README.md
index 069cc73..2998867 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,13 @@ skills/supertensor -> superpaper/supertensor
## Status
-The family contract lives in [`DESIGN.md`](DESIGN.md). Figure children
-are usable now. The notes scaffold (`scripts/`, ledger, template) is
-the SuperPaper v1 PR chain in that document — not in this commit.
+v1 notes scaffold is in this repo: ledger schema, router, lint, ingest,
+and three `--work` examples (`excerpt-toy`, `pipeline-delegate`,
+`tensor-delegate`). `superderive` is still phase 2.
+
+```bash
+python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
+./scripts/preflight.sh
+./scripts/test.sh
+./scripts/build.sh --work examples/excerpt-toy
+```
diff --git a/SKILL.md b/SKILL.md
index 3015761..685f3cc 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -17,8 +17,8 @@ description: >-
`ledger.yaml` + 结构化中文笔记。图一律委派给子仓库,不要在笔记里
`\usepackage{superfig}` / `supertensor`。
-本目录是家族母仓库。子仓库在 `superfig/` 与 `supertensor/`(git submodule)。
-完整合同见 `DESIGN.md`。笔记脚手架(`scripts/`、模板、schema)按 DESIGN 的 PR 1 落地;在此之前按下面路由手工委派。
+本目录是家族母仓库。子仓库在 `superfig/` 与 `supertensor/`。
+契约细节按需加载 `references/`;schema 在 `assets/ledger.schema.yaml`。
## When to use
@@ -43,12 +43,15 @@ video.
## Workflow
-1. 读 `DESIGN.md` 的输入契约与 ledger schema。
-2. 先写 `ledger.yaml`(claims / symbols / derivations / figure plan),再写笔记。
-3. 公式三拍:中文动机 → `\[` / `align` → 扁平符号表。
-4. 每张计划图写一份自包含 `F*.request.md`,开一个 figure agent,**只**把对应子仓库的 `SKILL.md` 给它。
-5. 笔记用 PDF `\includegraphics` 嵌入子仓库产物。禁止 `\input` standalone 源。
-6. 改术语或改图:先改 ledger,再改那一处。
+1. `./scripts/preflight.sh`。0 = 全路径,1 = 降级(说出口),2 = 无 LaTeX。
+2. `./scripts/ingest.sh --work
…`(`references/input.md`)。
+3. 从 `assets/ledger.example.yaml` 写 `ledger.yaml`,再写 `outline.md`,再写正文。
+4. 公式三拍:中文动机 → `\[` / `align` → 扁平符号表(`references/pedagogy.md`)。
+5. 按 `references/router.md` 填 figure plan。每张重绘图一份 `F*.request.md`,figure agent 只读它和对应子仓库 `SKILL.md`。
+6. `./scripts/lint.py --work ` 然后 `./scripts/build.sh --work `。
+7. 改术语或改图:先改 ledger,再改那一处。
+
+Worked trees: `examples/excerpt-toy/`(`align` 推导)、`examples/pipeline-delegate/`、`examples/tensor-delegate/`。
## Spawn recipe
diff --git a/assets/ledger.example.yaml b/assets/ledger.example.yaml
new file mode 100644
index 0000000..63c00d7
--- /dev/null
+++ b/assets/ledger.example.yaml
@@ -0,0 +1,24 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: "example"
+ title: ""
+ authors: []
+ notes_language: zh
+ source:
+ kind: excerpt
+coverage:
+ mode: excerpt
+ sections_in: []
+ sections_skipped: []
+questions: []
+claims: []
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures: []
+evidence: []
+terms: []
+source_assets: []
diff --git a/assets/ledger.schema.yaml b/assets/ledger.schema.yaml
new file mode 100644
index 0000000..22ada30
--- /dev/null
+++ b/assets/ledger.schema.yaml
@@ -0,0 +1,210 @@
+$schema: "http://json-schema.org/draft-07/schema#"
+$id: "https://local/superpaper.ledger/v1"
+type: object
+required: [schema, paper, coverage]
+additionalProperties: true
+properties:
+ schema: {const: superpaper.ledger/v1}
+ retired_ids:
+ type: array
+ items: {type: string, pattern: "^(C|Q|D|A|L|E|DER|F|SA)[1-9][0-9]*$"}
+ paper:
+ type: object
+ required: [id, title, source]
+ additionalProperties: true
+ properties:
+ id: {type: string, minLength: 1}
+ title: {type: string}
+ authors: {type: array, items: {type: string}}
+ year: {type: integer}
+ venue: {type: string}
+ notes_language: {type: string, enum: [zh, en]}
+ degraded:
+ type: array
+ items: {type: string, enum: [scanned, no-eprint, no-pdftotext]}
+ source:
+ type: object
+ required: [kind]
+ additionalProperties: true
+ properties:
+ kind: {enum: [arxiv, pdf, tex, excerpt, markdown]}
+ arxiv: {type: string}
+ local_pdf: {type: string}
+ pages: {type: integer, minimum: 1}
+ language: {type: string}
+ coverage:
+ type: object
+ required: [mode]
+ additionalProperties: true
+ properties:
+ mode: {enum: [full, excerpt, body-only]}
+ sections_in: {type: array, items: {type: string}}
+ sections_skipped: {type: array, items: {type: string}}
+ skip_reasons:
+ type: array
+ items:
+ type: object
+ required: [section, reason]
+ properties:
+ section: {type: string}
+ reason: {type: string}
+ questions:
+ type: array
+ items:
+ type: object
+ required: [id, text]
+ properties:
+ id: {type: string, pattern: "^Q[1-9][0-9]*$"}
+ text: {type: string}
+ source: {type: string}
+ claims:
+ type: array
+ items:
+ type: object
+ required: [id, text, kind, status]
+ properties:
+ id: {type: string, pattern: "^C[1-9][0-9]*$"}
+ text: {type: string}
+ kind: {enum: [contribution, theoretical, empirical, methodological]}
+ status: {enum: [core, supporting, dropped]}
+ supports: {type: array, items: {type: string}}
+ depends_on: {type: array, items: {type: string}}
+ evidence: {type: array, items: {type: string}}
+ source: {type: string}
+ definitions:
+ type: array
+ items:
+ type: object
+ required: [id, name, text]
+ properties:
+ id: {type: string, pattern: "^D[1-9][0-9]*$"}
+ name: {type: string}
+ text: {type: string}
+ source: {type: string}
+ assumptions:
+ type: array
+ items:
+ type: object
+ required: [id, text]
+ properties:
+ id: {type: string, pattern: "^A[1-9][0-9]*$"}
+ text: {type: string}
+ source: {type: string}
+ used_by: {type: array, items: {type: string}}
+ lemmas:
+ type: array
+ items:
+ type: object
+ required: [id, text]
+ properties:
+ id: {type: string, pattern: "^L[1-9][0-9]*$"}
+ text: {type: string}
+ depends_on: {type: array, items: {type: string}}
+ used_by: {type: array, items: {type: string}}
+ source: {type: string}
+ symbols:
+ type: array
+ items:
+ type: object
+ required: [name, latex, meaning, kind]
+ properties:
+ name: {type: string}
+ latex: {type: string}
+ meaning: {type: string}
+ domain: {type: string}
+ kind:
+ enum: [value, score, probability, index, rank, count, id, mask,
+ permutation, "shape parameter", scalar, set]
+ introduced: {type: string}
+ used: {type: array, items: {type: string}}
+ aliases: {type: array, items: {type: string}}
+ derivations:
+ type: array
+ items:
+ type: object
+ required: [id, claim, title, expand]
+ properties:
+ id: {type: string, pattern: "^DER[1-9][0-9]*$"}
+ claim: {type: string}
+ title: {type: string}
+ source: {type: string}
+ expand: {type: boolean}
+ figure: {type: ["string", "null"]}
+ steps:
+ type: array
+ items:
+ type: object
+ required: [id, from, to, rule]
+ properties:
+ id: {type: string}
+ from: {type: string}
+ to: {type: string}
+ rule:
+ enum: [definition, substitute, cancel, factor, scale,
+ take-limit, approx, cite, rearrange, introduce]
+ cite: {type: string}
+ justify: {type: string}
+ figures:
+ type: array
+ items:
+ type: object
+ required: [id, claim, title, grammar, toolkit, signals, status]
+ properties:
+ id: {type: string, pattern: "^F[1-9][0-9]*$"}
+ claim: {type: string}
+ title: {type: string}
+ grammar:
+ enum: [architecture, pipeline, data-flow, state, time, dependency,
+ argument-map, tensor-face, derivation, screenshot, plot,
+ notation, none]
+ toolkit:
+ enum: [superfig, supertensor, superderive, screenshot,
+ matplotlib, none, align]
+ signals: {type: array, items: {type: string}}
+ source_fig: {type: string}
+ source_pages: {type: array, items: {type: integer, minimum: 1}}
+ crop_bbox:
+ type: ["array", "null"]
+ minItems: 4
+ maxItems: 4
+ items: {type: number}
+ request: {type: ["string", "null"]}
+ include: {type: ["string", "null"]}
+ status: {enum: [planned, delegated, built, included, dropped]}
+ drop_reason: {type: ["string", "null"]}
+ evidence:
+ type: array
+ items:
+ type: object
+ required: [id, kind, source, supports, handling]
+ properties:
+ id: {type: string, pattern: "^E[1-9][0-9]*$"}
+ kind: {enum: [table, plot, ablation, theorem, example]}
+ source: {type: string}
+ supports: {type: array, items: {type: string}}
+ handling:
+ enum: [redraw-superfig, redraw-supertensor, redraw-superderive,
+ screenshot, matplotlib, omit]
+ terms:
+ type: array
+ items:
+ type: object
+ required: [canonical]
+ properties:
+ canonical: {type: string}
+ aliases: {type: array, items: {type: string}}
+ first_defined: {type: string}
+ source_assets:
+ type: array
+ items:
+ type: object
+ required: [id, kind, handling]
+ properties:
+ id: {type: string, pattern: "^SA[1-9][0-9]*$"}
+ kind: {enum: [figure, table]}
+ paper_ref: {type: string}
+ handling:
+ enum: [redraw-superfig, redraw-supertensor, redraw-superderive,
+ screenshot, matplotlib, omit]
+ pages: {type: array, items: {type: integer}}
+ note: {type: string}
diff --git a/assets/notes-macros.tex b/assets/notes-macros.tex
new file mode 100644
index 0000000..093d046
--- /dev/null
+++ b/assets/notes-macros.tex
@@ -0,0 +1,54 @@
+% Shared notes preamble. TEXINPUTS must include superpaper/assets/.
+\usepackage[fontset=fandol]{ctex}
+\usepackage{amsmath,amssymb}
+\usepackage{graphicx}
+\usepackage[margin=2.5cm]{geometry}
+\usepackage[most]{tcolorbox}
+\usepackage{etoolbox}
+\usepackage{listings}
+\usepackage{booktabs}
+\usepackage{subcaption}
+\usepackage{float}
+\usepackage{tikz}
+\usepackage{hyperref}
+
+\newtcolorbox{knowledgebox}[1]{
+ enhanced, colback=blue!5!white, colframe=blue!75!black, colbacktitle=blue!75!black,
+ coltitle=white, fonttitle=\bfseries, title=#1,
+ attach boxed title to top left={yshift=-2mm, xshift=2mm},
+ boxrule=1pt, sharp corners
+}
+\newtcolorbox{importantbox}[1]{
+ enhanced, colback=yellow!10!white, colframe=yellow!80!black, colbacktitle=yellow!80!black,
+ coltitle=black, fonttitle=\bfseries, title=#1, sharp corners
+}
+\newtcolorbox{warningbox}[1]{
+ enhanced, colback=red!5!white, colframe=red!75!black, colbacktitle=red!75!black,
+ coltitle=white, fonttitle=\bfseries, title=#1, sharp corners
+}
+\newtcolorbox{quotebox}[1]{
+ enhanced, breakable,
+ colback=black!3!white, colframe=black!55, colbacktitle=black!55,
+ coltitle=white, fonttitle=\bfseries, title=#1, sharp corners
+}
+
+\newcommand{\splabel}[1]{\hypertarget{sp:#1}{}\label{sp:#1}}
+\newcommand{\spref}[1]{\hyperlink{sp:#1}{\texttt{#1}}}
+\newcommand{\spsource}[1]{\footnote{来源:#1}}
+\newcommand{\spfig}[4][0.92\textwidth]{%
+ \begin{figure}[H]\centering
+ \includegraphics[width=#1]{figures/#2/build/#2.pdf}%
+ \caption{#3\protect\footnotemark}\end{figure}
+ \footnotetext{#4}}
+\newcommand{\spscreenshot}[4][0.92\textwidth]{%
+ \begin{figure}[H]\centering
+ \includegraphics[width=#1]{figures/#2/orig.png}%
+ \caption{#3\protect\footnotemark}\end{figure}
+ \footnotetext{#4}}
+
+\newcommand{\notetitle}{论文笔记}
+\newcommand{\noteauthors}{}
+\newcommand{\notedate}{\today}
+\newcommand{\notepaper}{}
+\newcommand{\notevenue}{}
+\newcommand{\notearxiv}{}
diff --git a/assets/notes-template.tex b/assets/notes-template.tex
new file mode 100644
index 0000000..1ba4928
--- /dev/null
+++ b/assets/notes-template.tex
@@ -0,0 +1,43 @@
+\documentclass[a4paper]{article}
+\input{notes-macros}
+
+% Locked skeleton (see references/pedagogy.md):
+% \section{这篇论文在问什么}
+% \section{主张与贡献} % \splabel{C*}
+% \section{预备:定义、假设、符号}
+% % --- outline-chosen mechanism sections ---
+% \section{实验与证据} % optional
+% \section{总结与延伸}
+% \appendix
+% \section{符号表} % \input{sections/symbols.tex}
+% \section{推导链一览}
+% \section{图表清单}
+%
+% Formula: Chinese motive, then \[ / align, then a flat symbol list.
+% Keep figures outside knowledgebox / importantbox / warningbox / quotebox.
+% Do not \usepackage{superfig|supertensor|superderive}.
+
+\begin{document}
+
+\begin{titlepage}
+\centering
+\vspace{1.2cm}
+{\huge\bfseries \notetitle\par}
+\vspace{0.8cm}
+{\large \noteauthors\par}
+\vspace{0.3cm}
+{\large \notedate\par}
+\vspace{1.2cm}
+\begin{tcolorbox}[width=0.9\textwidth, colback=black!2!white, colframe=black!60, sharp corners]
+\textbf{论文}:\notepaper\par
+\textbf{venue}:\notevenue\par
+\textbf{arXiv}:\notearxiv\par
+\end{tcolorbox}
+\end{titlepage}
+
+\tableofcontents
+\newpage
+
+\input{sections/sec-01.tex}
+
+\end{document}
diff --git a/examples/excerpt-toy/ledger.yaml b/examples/excerpt-toy/ledger.yaml
new file mode 100644
index 0000000..542c47b
--- /dev/null
+++ b/examples/excerpt-toy/ledger.yaml
@@ -0,0 +1,46 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: excerpt-toy
+ title: A One-Step Predictor
+ authors: ["Fixture"]
+ year: 2026
+ venue: Superpaper examples
+ notes_language: zh
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+ sections_in: ["1"]
+questions:
+ - {id: Q1, text: "一次前向如何得到预测,损失该放在哪?", source: "excerpt"}
+claims:
+ - id: C1
+ text: "一次前向是 x → f_θ → ŷ;损失在预测之后单独计算"
+ kind: contribution
+ status: core
+ supports: [Q1]
+ source: "excerpt"
+definitions:
+ - {id: D1, name: "one-step predictor", text: "ŷ = f_θ(x)", source: "excerpt"}
+assumptions: []
+lemmas: []
+symbols:
+ - {name: x, latex: "x", meaning: "输入", kind: value, introduced: "excerpt"}
+ - {name: yhat, latex: "\\hat y", meaning: "预测", kind: value}
+ - {name: L, latex: "L", meaning: "损失", kind: scalar}
+ - {name: d, latex: "d", meaning: "特征维", kind: "shape parameter"}
+derivations:
+ - id: DER1
+ claim: C1
+ title: "缩放来自方差"
+ source: "excerpt"
+ expand: true
+ figure: null
+ steps:
+ - {id: S1, from: "u^\\top v", to: "u^\\top v / \\sqrt{d}", rule: scale,
+ justify: "点积方差随 d 增长"}
+figures: []
+evidence: []
+terms:
+ - {canonical: "one-step predictor", aliases: ["一次前向"]}
+source_assets: []
diff --git a/examples/excerpt-toy/notes/notes.tex b/examples/excerpt-toy/notes/notes.tex
new file mode 100644
index 0000000..b528fc6
--- /dev/null
+++ b/examples/excerpt-toy/notes/notes.tex
@@ -0,0 +1,29 @@
+\documentclass[a4paper]{article}
+\input{notes-macros}
+\renewcommand{\notetitle}{一次前向预测器}
+\renewcommand{\noteauthors}{Superpaper fixture}
+\renewcommand{\notepaper}{A One-Step Predictor}
+\renewcommand{\notevenue}{examples/excerpt-toy}
+\begin{document}
+\begin{titlepage}
+\centering
+\vspace{2cm}
+{\huge\bfseries \notetitle\par}
+\vspace{1cm}
+{\large \notepaper\par}
+\vspace{0.5cm}
+{\large \noteauthors\par}
+\end{titlepage}
+\tableofcontents
+\newpage
+\input{sections/sec-01.tex}
+\input{sections/sec-02.tex}
+\input{sections/sec-03.tex}
+\input{sections/sec-04.tex}
+\input{sections/sec-05.tex}
+\appendix
+\section{符号表}
+\input{sections/symbols.tex}
+\section{推导链一览}
+DER1:缩放来自方差,见 \spref{C1}。
+\end{document}
diff --git a/examples/excerpt-toy/notes/sections/sec-01.tex b/examples/excerpt-toy/notes/sections/sec-01.tex
new file mode 100644
index 0000000..06abff0
--- /dev/null
+++ b/examples/excerpt-toy/notes/sections/sec-01.tex
@@ -0,0 +1,3 @@
+\section{这篇论文在问什么}
+要把输入变成预测,最简单的机制是什么?损失要不要走在前向主路上?
+这是摘录 fixture,不假装读完全文。
diff --git a/examples/excerpt-toy/notes/sections/sec-02.tex b/examples/excerpt-toy/notes/sections/sec-02.tex
new file mode 100644
index 0000000..7170234
--- /dev/null
+++ b/examples/excerpt-toy/notes/sections/sec-02.tex
@@ -0,0 +1,3 @@
+\section{主张与贡献}
+\splabel{C1}
+一次前向是 $x \to f_\theta \to \hat y$。损失 $L(\hat y,y)$ 在预测之后单独计算,不是主路上的一站。
diff --git a/examples/excerpt-toy/notes/sections/sec-03.tex b/examples/excerpt-toy/notes/sections/sec-03.tex
new file mode 100644
index 0000000..af57f2d
--- /dev/null
+++ b/examples/excerpt-toy/notes/sections/sec-03.tex
@@ -0,0 +1,2 @@
+\section{预备:定义、假设、符号}
+预测器定义为 $\hat y = f_\theta(x)$。符号见附录。
diff --git a/examples/excerpt-toy/notes/sections/sec-04.tex b/examples/excerpt-toy/notes/sections/sec-04.tex
new file mode 100644
index 0000000..21127ed
--- /dev/null
+++ b/examples/excerpt-toy/notes/sections/sec-04.tex
@@ -0,0 +1,18 @@
+\section{一次前向与损失}
+内积的方差会随维数 $d$ 涨。为了不让后续非线性饱和,要把点积除掉 $\sqrt{d}$。
+
+\begin{align}
+u^\top v &\longrightarrow \frac{u^\top v}{\sqrt{d}}.
+\end{align}
+
+\begin{itemize}
+\item $u,v$ — 两个 $d$ 维向量
+\item $d$ — 特征维(shape parameter)
+\end{itemize}
+
+\begin{importantbox}{主路与损失}
+损失比较 $\hat y$ 与 $y$,梯度再回到 $\theta$。不要把 $L$ 画成前向的一站。
+\end{importantbox}
+
+\subsection{本章小结}
+前向只负责预测;缩放是改写,不是新算子。
diff --git a/examples/excerpt-toy/notes/sections/sec-05.tex b/examples/excerpt-toy/notes/sections/sec-05.tex
new file mode 100644
index 0000000..522cc36
--- /dev/null
+++ b/examples/excerpt-toy/notes/sections/sec-05.tex
@@ -0,0 +1,2 @@
+\section{总结与延伸}
+摘录只保留一条机制:一次前向加侧路损失。更长的论文用 ledger 把 claim 钉住,再按路由出图。
diff --git a/examples/excerpt-toy/outline.md b/examples/excerpt-toy/outline.md
new file mode 100644
index 0000000..f31e76c
--- /dev/null
+++ b/examples/excerpt-toy/outline.md
@@ -0,0 +1,17 @@
+# Outline: A One-Step Predictor
+
+## Lecture map
+
+| file | lecture_title | paper_sections | ledger_ids |
+|---|---|---|---|
+| sec-01.tex | 这篇论文在问什么 | 1 | Q1 |
+| sec-02.tex | 主张与贡献 | 1 | C1 |
+| sec-03.tex | 预备:定义、假设、符号 | 1 | D1 |
+| sec-04.tex | 一次前向与损失 | 1 | C1, DER1 |
+| sec-05.tex | 总结与延伸 | 1 | C1 |
+| sec-app-a.tex | 符号表 | — | |
+| sec-app-b.tex | 推导链一览 | — | DER1 |
+
+## Locked
+- 首节标题必须是「这篇论文在问什么」
+- 末节(appendix 前)必须是「总结与延伸」
diff --git a/examples/excerpt-toy/source/excerpt.md b/examples/excerpt-toy/source/excerpt.md
new file mode 100644
index 0000000..0249128
--- /dev/null
+++ b/examples/excerpt-toy/source/excerpt.md
@@ -0,0 +1,8 @@
+# A One-Step Predictor (fixture)
+
+We predict $\hat y = f_\theta(x)$ in one forward pass. The loss
+$L(\hat y, y)$ is computed after the prediction; it is not a station
+on the forward path.
+
+The scale $1/\sqrt{d}$ is introduced so that the variance of the
+inner product does not grow with $d$.
diff --git a/examples/pipeline-delegate/ledger.yaml b/examples/pipeline-delegate/ledger.yaml
new file mode 100644
index 0000000..06ad7a0
--- /dev/null
+++ b/examples/pipeline-delegate/ledger.yaml
@@ -0,0 +1,47 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: pipeline-delegate
+ title: A One-Step Predictor
+ authors: ["Fixture"]
+ notes_language: zh
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+ sections_in: ["1"]
+questions:
+ - {id: Q1, text: "前向主路和损失如何分开?", source: "excerpt"}
+claims:
+ - id: C1
+ text: "一次前向是 x → f_θ → ŷ;损失不在主路上"
+ kind: contribution
+ status: core
+ supports: [Q1]
+definitions: []
+assumptions: []
+lemmas: []
+symbols:
+ - {name: x, latex: "x", meaning: "输入", kind: value}
+ - {name: yhat, latex: "\\hat y", meaning: "预测", kind: value}
+ - {name: L, latex: "L", meaning: "损失", kind: scalar}
+derivations:
+ - id: DER1
+ claim: C1
+ title: "前向定义"
+ expand: true
+ figure: null
+ steps:
+ - {id: S1, from: "x", to: "f_\\theta(x)=\\hat y", rule: definition}
+figures:
+ - id: F1
+ claim: C1
+ title: "一次前向与侧路损失"
+ grammar: pipeline
+ toolkit: superfig
+ signals: [pipeline, what-eats-what]
+ request: figures/F1/F1.request.md
+ include: figures/F1/build/F1.pdf
+ status: included
+evidence: []
+terms: []
+source_assets: []
diff --git a/examples/pipeline-delegate/notes/figures/F1/F1-mechanism.md b/examples/pipeline-delegate/notes/figures/F1/F1-mechanism.md
new file mode 100644
index 0000000..929b88e
--- /dev/null
+++ b/examples/pipeline-delegate/notes/figures/F1/F1-mechanism.md
@@ -0,0 +1 @@
+一次前向 $x\to f_\theta\to\hat y$。损失挂在预测下方,不是主路车站。
diff --git a/examples/pipeline-delegate/notes/figures/F1/F1.request.md b/examples/pipeline-delegate/notes/figures/F1/F1.request.md
new file mode 100644
index 0000000..2e38e98
--- /dev/null
+++ b/examples/pipeline-delegate/notes/figures/F1/F1.request.md
@@ -0,0 +1,27 @@
+# Figure request F1
+toolkit: superfig
+language: cjk
+claim: 一次前向是 x → f_θ → ŷ;损失不在主路上。
+grammar: pipeline
+work_rel_dir: figures/F1
+
+## Roles
+- {role: input, color: sfTeal}
+- {role: model, color: sfOrange}
+- {role: loss, color: sfCoral}
+- {role: output, color: sfViolet}
+
+## Flow (cursor). \sfconn 没有 endpoints。
+stage: {name: SA, text: "推理流程:一次前向"}
+row: {name: R1, height: 16mm}
+in_row:
+ - {macro: sfnode, name: x, role: input, label: "输入 $x$", w: 16mm, h: 12mm}
+ - {macro: sfconn, name: e1, label: 预处理}
+ - {macro: sfnode, name: f, role: model, label: "模型 $f_\\theta$", w: 18mm, h: 12mm}
+ - {macro: sfconn, name: e2, label: logits}
+ - {macro: sfnode, name: y, role: output, label: "预测 $\\hat y$", w: 16mm, h: 12mm}
+
+## Fixed topology
+- {macro: sfnode, name: s, role: loss, label: "损失 $L$", w: 14mm, h: 12mm,
+ at: "($(y.south)+(0,-22mm)$)"}
+- {macro: sfarrowlabel, from: "y.south", to: "s.north", label: "$L(\\hat y, y)$"}
diff --git a/examples/pipeline-delegate/notes/figures/F1/F1.tex b/examples/pipeline-delegate/notes/figures/F1/F1.tex
new file mode 100644
index 0000000..955b096
--- /dev/null
+++ b/examples/pipeline-delegate/notes/figures/F1/F1.tex
@@ -0,0 +1,47 @@
+% superfig golden example 1 -- one horizontal paper-figure pipeline.
+% Main path is input -> model -> prediction. Loss is a side object, not a
+% station on the forward path.
+% ../scripts/build.sh pipeline.tex
+\documentclass[border=10pt]{standalone}
+\usepackage[cjk]{superfig}
+
+\sfsetrole{input}{sfTeal}
+\sfsetrole{model}{sfOrange}
+\sfsetrole{loss}{sfCoral}
+\sfsetrole{output}{sfViolet}
+
+\begin{document}
+\begin{tikzpicture}
+
+\sfstage{SA}{推理流程:一次前向}
+\sfrow{R1}{16mm}
+ \sfnode[role=input]{x}{输入 $x$}{16mm}{12mm}
+ \sfconn{e1}{预处理}
+ \sfnode[role=model]{f}{模型 $f_\theta$}{18mm}{12mm}
+ \sfconn{e2}{logits}
+ \sfnode[role=output]{y}{预测 $\hat y$}{16mm}{12mm}
+\sfrowend
+
+% Loss compares the prediction with the target; it is not on the main path.
+% Hang it below ŷ with enough shaft that no caption sits on the arrow.
+\sfnode[role=loss, at={($(y.south)+(0,-22mm)$)}]{s}{损失 $L$}{14mm}{12mm}
+\sfarrowlabel{y.south}{s.north}{$L(\hat y,y)$}
+
+\sflane{R1}
+\sfcaption{x}{$x$}{原始输入}
+\sfcaption{f}{$f_\theta$}{可学习参数}
+\sfnolane
+\sfcaption{s}{$L$}{与真值比较}
+
+\sfbbox{all}
+\sftopformula{F}{%
+ $x \;\xrightarrow{\;f_\theta\;}\; \hat y,\qquad
+ \min_\theta\; L\bigl(f_\theta(x),\,y\bigr)$}
+\sfmeaningbox{mb}{96mm}{all}
+ {一次从输入到预测的前向;损失在预测之后单独计算}
+ {数据、模型参数、预测、损失}
+ {预处理后送入模型;模型产生 logits 得到预测;损失比较预测与真值,梯度再回到参数}
+\sfsignature{推理流程示意}{mb}
+
+\end{tikzpicture}
+\end{document}
diff --git a/examples/pipeline-delegate/notes/figures/F1/build/F1-alpha.png b/examples/pipeline-delegate/notes/figures/F1/build/F1-alpha.png
new file mode 100644
index 0000000..a6596e3
Binary files /dev/null and b/examples/pipeline-delegate/notes/figures/F1/build/F1-alpha.png differ
diff --git a/examples/pipeline-delegate/notes/figures/F1/build/F1-thumb.png b/examples/pipeline-delegate/notes/figures/F1/build/F1-thumb.png
new file mode 100644
index 0000000..cb3580b
Binary files /dev/null and b/examples/pipeline-delegate/notes/figures/F1/build/F1-thumb.png differ
diff --git a/examples/pipeline-delegate/notes/figures/F1/build/F1.pdf b/examples/pipeline-delegate/notes/figures/F1/build/F1.pdf
new file mode 100644
index 0000000..0845129
Binary files /dev/null and b/examples/pipeline-delegate/notes/figures/F1/build/F1.pdf differ
diff --git a/examples/pipeline-delegate/notes/figures/F1/build/F1.png b/examples/pipeline-delegate/notes/figures/F1/build/F1.png
new file mode 100644
index 0000000..496171a
Binary files /dev/null and b/examples/pipeline-delegate/notes/figures/F1/build/F1.png differ
diff --git a/examples/pipeline-delegate/notes/figures/F1/build/F1.svg b/examples/pipeline-delegate/notes/figures/F1/build/F1.svg
new file mode 100644
index 0000000..04a3663
--- /dev/null
+++ b/examples/pipeline-delegate/notes/figures/F1/build/F1.svg
@@ -0,0 +1,674 @@
+
+
diff --git a/examples/pipeline-delegate/notes/notes.tex b/examples/pipeline-delegate/notes/notes.tex
new file mode 100644
index 0000000..2daaf4e
--- /dev/null
+++ b/examples/pipeline-delegate/notes/notes.tex
@@ -0,0 +1,14 @@
+\documentclass[a4paper]{article}
+\input{notes-macros}
+\renewcommand{\notetitle}{一次前向与侧路损失}
+\renewcommand{\notepaper}{A One-Step Predictor}
+\begin{document}
+\tableofcontents
+\newpage
+\input{sections/sec-01.tex}
+\input{sections/sec-02.tex}
+\input{sections/sec-03.tex}
+\appendix
+\section{符号表}
+\input{sections/symbols.tex}
+\end{document}
diff --git a/examples/pipeline-delegate/notes/sections/sec-01.tex b/examples/pipeline-delegate/notes/sections/sec-01.tex
new file mode 100644
index 0000000..e6be679
--- /dev/null
+++ b/examples/pipeline-delegate/notes/sections/sec-01.tex
@@ -0,0 +1,2 @@
+\section{这篇论文在问什么}
+预测怎么从输入算出来,损失该不该站在前向主路上?
diff --git a/examples/pipeline-delegate/notes/sections/sec-02.tex b/examples/pipeline-delegate/notes/sections/sec-02.tex
new file mode 100644
index 0000000..825a213
--- /dev/null
+++ b/examples/pipeline-delegate/notes/sections/sec-02.tex
@@ -0,0 +1,8 @@
+\section{主张与贡献}
+\splabel{C1}
+一次前向是 $x\to f_\theta\to\hat y$。损失在预测之后单独比较。
+
+\spfig{F1}{一次前向与侧路损失。}{重绘自 fixture Figure~1;toolkit: \texttt{superfig};ledger id: F1。}
+
+\subsection{本章小结}
+损失不是前向的一站。
diff --git a/examples/pipeline-delegate/notes/sections/sec-03.tex b/examples/pipeline-delegate/notes/sections/sec-03.tex
new file mode 100644
index 0000000..c8fd1b8
--- /dev/null
+++ b/examples/pipeline-delegate/notes/sections/sec-03.tex
@@ -0,0 +1,2 @@
+\section{总结与延伸}
+这张图走 superfig,因为要画的是谁吃谁,不是轴长。
diff --git a/examples/pipeline-delegate/outline.md b/examples/pipeline-delegate/outline.md
new file mode 100644
index 0000000..6c7df88
--- /dev/null
+++ b/examples/pipeline-delegate/outline.md
@@ -0,0 +1,14 @@
+# Outline: A One-Step Predictor
+
+## Lecture map
+
+| file | lecture_title | paper_sections | ledger_ids |
+|---|---|---|---|
+| sec-01.tex | 这篇论文在问什么 | 1 | Q1 |
+| sec-02.tex | 主张与贡献 | 1 | C1, F1 |
+| sec-03.tex | 总结与延伸 | 1 | C1 |
+| sec-app-a.tex | 符号表 | — | |
+
+## Locked
+- 首节标题必须是「这篇论文在问什么」
+- 末节(appendix 前)必须是「总结与延伸」
diff --git a/examples/tensor-delegate/ledger.yaml b/examples/tensor-delegate/ledger.yaml
new file mode 100644
index 0000000..1c15b45
--- /dev/null
+++ b/examples/tensor-delegate/ledger.yaml
@@ -0,0 +1,47 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: tensor-delegate
+ title: Head scores
+ authors: ["Fixture"]
+ notes_language: zh
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions:
+ - {id: Q1, text: "每头打分沿哪一维收缩?", source: "excerpt"}
+claims:
+ - id: C1
+ text: "每头打分沿 d_h 收缩;K^T 必须物理换面"
+ kind: theoretical
+ status: core
+ supports: [Q1]
+definitions: []
+assumptions: []
+lemmas: []
+symbols:
+ - {name: Q, latex: "Q", meaning: "query", kind: value}
+ - {name: K, latex: "K", meaning: "key", kind: value}
+ - {name: S, latex: "S", meaning: "score", kind: score}
+ - {name: dh, latex: "d_h", meaning: "头维", kind: "shape parameter"}
+derivations:
+ - id: DER1
+ claim: C1
+ title: "打分"
+ expand: true
+ figure: null
+ steps:
+ - {id: S1, from: "Q K", to: "Q K^{\\top}", rule: rearrange}
+figures:
+ - id: F2
+ claim: C1
+ title: "每头打分"
+ grammar: tensor-face
+ toolkit: supertensor
+ signals: [axis, shape, transpose, contraction, face]
+ request: figures/F2/F2.request.md
+ include: figures/F2/build/F2.pdf
+ status: included
+evidence: []
+terms: []
+source_assets: []
diff --git a/examples/tensor-delegate/notes/figures/F2/F2-mechanism.md b/examples/tensor-delegate/notes/figures/F2/F2-mechanism.md
new file mode 100644
index 0000000..751aa97
--- /dev/null
+++ b/examples/tensor-delegate/notes/figures/F2/F2-mechanism.md
@@ -0,0 +1 @@
+$S^{(i)}=Q^{(i)}K^{(i)\top}$。收缩维 $d_h$ 在两个操作数上同一边长;$K^\top$ 换面。
diff --git a/examples/tensor-delegate/notes/figures/F2/F2.request.md b/examples/tensor-delegate/notes/figures/F2/F2.request.md
new file mode 100644
index 0000000..c9dc50e
--- /dev/null
+++ b/examples/tensor-delegate/notes/figures/F2/F2.request.md
@@ -0,0 +1,25 @@
+# Figure request F2
+toolkit: supertensor
+language: cjk
+claim: 每头打分沿 d_h 收缩;K^T 必须物理换面。
+grammar: tensor-face
+work_rel_dir: figures/F2
+
+## Roles
+- {role: q, color: stTeal}
+- {role: k, color: stOrange}
+- {role: s, color: stCoral}
+
+## Geometry
+- {axis: T, cells: 6}
+- {axis: dh, cells: 3}
+
+## Flow
+stage: {name: SA, text: "每头打分:沿 $d_h$ 收缩"}
+row: {name: rowA, height: T}
+in_row:
+ - {macro: ststack, name: Q, role: q, coord: "", rows: T, cols: dh, sheets: 3, bracket: true}
+ - {macro: stglyph, name: mA, coord: "", glyph: "$\\times$"}
+ - {macro: ststack, name: KT, role: k, coord: "", rows: dh, cols: T, sheets: 3, bracket: true}
+ - {macro: stglyph, name: eA, coord: "", glyph: "$=$"}
+ - {macro: ststack, name: S, role: s, coord: "", rows: T, cols: T, sheets: 3}
diff --git a/examples/tensor-delegate/notes/figures/F2/F2.tex b/examples/tensor-delegate/notes/figures/F2/F2.tex
new file mode 100644
index 0000000..00605c4
--- /dev/null
+++ b/examples/tensor-delegate/notes/figures/F2/F2.tex
@@ -0,0 +1,31 @@
+\documentclass[border=10pt]{standalone}
+\usepackage[cjk]{supertensor}
+
+\stsetrole{q}{stTeal}
+\stsetrole{k}{stOrange}
+\stsetrole{s}{stCoral}
+
+\stdim{T}{6}
+\stdim{dh}{3}
+
+\begin{document}
+\begin{tikzpicture}
+\ststage{SA}{每头打分:沿 $d_h$ 收缩}
+\strow{rowA}{T}
+ \ststack[role=q, bracket=true]{Q}{}{T}{dh}{3}
+ \stglyph{mA}{$\times$}
+ \ststack[role=k, bracket=true]{KT}{}{dh}{T}{3}
+ \stglyph{eA}{$=$}
+ \ststack[role=s]{S}{}{T}{T}{3}
+\strowend
+\stcaption{Q}{$\mathbf Q^{(i)}$}{$h\times T\times d_h$}
+\stcaption{KT}{$\mathbf K^{(i)\top}$}{$h\times d_h\times T$}
+\stcaption{S}{$\mathbf S^{(i)}$}{$h\times T\times T$}
+\sttopformula{F}{$S^{(i)}=Q^{(i)}K^{(i)\top}$}
+\stbbox{all}
+\stmeaningbox{mb}{120mm}{all}
+ {$T$ 时间;$d_h$ 头维;$h$ 头数画成 stack 深度}
+ {$Q/K$ 是 value;$S$ 是 score,不是 mask}
+ {沿 $d_h$ 收缩;$K^\top$ 换面,收缩边等长}
+\end{tikzpicture}
+\end{document}
diff --git a/examples/tensor-delegate/notes/figures/F2/build/F2-alpha.png b/examples/tensor-delegate/notes/figures/F2/build/F2-alpha.png
new file mode 100644
index 0000000..b634795
Binary files /dev/null and b/examples/tensor-delegate/notes/figures/F2/build/F2-alpha.png differ
diff --git a/examples/tensor-delegate/notes/figures/F2/build/F2-thumb.png b/examples/tensor-delegate/notes/figures/F2/build/F2-thumb.png
new file mode 100644
index 0000000..ab2efaf
Binary files /dev/null and b/examples/tensor-delegate/notes/figures/F2/build/F2-thumb.png differ
diff --git a/examples/tensor-delegate/notes/figures/F2/build/F2.pdf b/examples/tensor-delegate/notes/figures/F2/build/F2.pdf
new file mode 100644
index 0000000..3a80f94
Binary files /dev/null and b/examples/tensor-delegate/notes/figures/F2/build/F2.pdf differ
diff --git a/examples/tensor-delegate/notes/figures/F2/build/F2.png b/examples/tensor-delegate/notes/figures/F2/build/F2.png
new file mode 100644
index 0000000..a63ea1a
Binary files /dev/null and b/examples/tensor-delegate/notes/figures/F2/build/F2.png differ
diff --git a/examples/tensor-delegate/notes/figures/F2/build/F2.svg b/examples/tensor-delegate/notes/figures/F2/build/F2.svg
new file mode 100644
index 0000000..da7d30f
--- /dev/null
+++ b/examples/tensor-delegate/notes/figures/F2/build/F2.svg
@@ -0,0 +1,607 @@
+
+
diff --git a/examples/tensor-delegate/notes/notes.tex b/examples/tensor-delegate/notes/notes.tex
new file mode 100644
index 0000000..15df6b4
--- /dev/null
+++ b/examples/tensor-delegate/notes/notes.tex
@@ -0,0 +1,14 @@
+\documentclass[a4paper]{article}
+\input{notes-macros}
+\renewcommand{\notetitle}{每头打分沿 $d_h$ 收缩}
+\renewcommand{\notepaper}{Head scores}
+\begin{document}
+\tableofcontents
+\newpage
+\input{sections/sec-01.tex}
+\input{sections/sec-02.tex}
+\input{sections/sec-03.tex}
+\appendix
+\section{符号表}
+\input{sections/symbols.tex}
+\end{document}
diff --git a/examples/tensor-delegate/notes/sections/sec-01.tex b/examples/tensor-delegate/notes/sections/sec-01.tex
new file mode 100644
index 0000000..1ded60b
--- /dev/null
+++ b/examples/tensor-delegate/notes/sections/sec-01.tex
@@ -0,0 +1,2 @@
+\section{这篇论文在问什么}
+每头的 $Q$ 和 $K$ 沿哪一条边收缩,$K^\top$ 要不要真的换面?
diff --git a/examples/tensor-delegate/notes/sections/sec-02.tex b/examples/tensor-delegate/notes/sections/sec-02.tex
new file mode 100644
index 0000000..b316723
--- /dev/null
+++ b/examples/tensor-delegate/notes/sections/sec-02.tex
@@ -0,0 +1,20 @@
+\section{主张与贡献}
+\splabel{C1}
+打分是 $(T\times d_h)(d_h\times T)\to(T\times T)$。$K^\top$ 必须物理换面,收缩边等长。
+
+先用中文说完,再写式子:
+
+\[
+S^{(i)}=Q^{(i)}K^{(i)\top}.
+\]
+
+\begin{itemize}
+\item $Q^{(i)}$ — 第 $i$ 头 query,$T\times d_h$
+\item $K^{(i)\top}$ — 转置后的 key,$d_h\times T$
+\item $S^{(i)}$ — 分数,不是 mask
+\end{itemize}
+
+\spfig{F2}{每头打分沿 $d_h$ 收缩。}{重绘自 fixture;toolkit: \texttt{supertensor};ledger id: F2。}
+
+\subsection{本章小结}
+轴长是这张图的主张,所以走 supertensor,不走 superfig。
diff --git a/examples/tensor-delegate/notes/sections/sec-03.tex b/examples/tensor-delegate/notes/sections/sec-03.tex
new file mode 100644
index 0000000..4390f1d
--- /dev/null
+++ b/examples/tensor-delegate/notes/sections/sec-03.tex
@@ -0,0 +1,2 @@
+\section{总结与延伸}
+形状对齐的公式图只交给 supertensor。
diff --git a/examples/tensor-delegate/outline.md b/examples/tensor-delegate/outline.md
new file mode 100644
index 0000000..34640c3
--- /dev/null
+++ b/examples/tensor-delegate/outline.md
@@ -0,0 +1,14 @@
+# Outline: Head scores
+
+## Lecture map
+
+| file | lecture_title | paper_sections | ledger_ids |
+|---|---|---|---|
+| sec-01.tex | 这篇论文在问什么 | 1 | Q1 |
+| sec-02.tex | 主张与贡献 | 1 | C1, F2 |
+| sec-03.tex | 总结与延伸 | 1 | C1 |
+| sec-app-a.tex | 符号表 | — | |
+
+## Locked
+- 首节标题必须是「这篇论文在问什么」
+- 末节(appendix 前)必须是「总结与延伸」
diff --git a/references/agents.md b/references/agents.md
new file mode 100644
index 0000000..a049465
--- /dev/null
+++ b/references/agents.md
@@ -0,0 +1,14 @@
+# Agents
+
+Must split when pages > 12 **or** top sections > 4 **or** redraws ≥ 2 **or** the user asks to spawn. Otherwise one agent (including 9–12 pages with ≤4 tops and ≤1 redraw).
+
+Writer jobs are lecture rows in `outline.md`, not `coverage.sections_in`. Outline writes `ledger.yaml`, `outline.md`, `notes.tex` inputs, and `F*.request.md`. Writers only touch their `sec-XX.tex`. Figure agents only see the request.
+
+```
+$superpaper <源> 请 spawn 多 sub agents,隔离上下文:
+ - 1 个 outline agent:ledger.yaml + outline.md
+ - N 个 writer agents:sections/sec-XX.tex
+ - 每个计划重绘图 1 个 figure agent:F*.request.md + sibling skill
+ - 1 个 consistency agent:符号、术语、claim 覆盖
+完成后用 scripts/lint.py 与 build.sh 收口。
+```
diff --git a/references/antipatterns.md b/references/antipatterns.md
new file mode 100644
index 0000000..0fd758f
--- /dev/null
+++ b/references/antipatterns.md
@@ -0,0 +1,8 @@
+# Antipatterns
+
+- Drawing a rewrite as `\sfnode` boxes (use `align`).
+- Sending a tensor-shape claim to superfig, or an architecture to supertensor.
+- Loading a figure `.sty` in the notes so sibling warnings leak into the article log.
+- `\input` of a standalone TikZ source instead of `\spfig`.
+- Reusing a `dropped` / retired id for a new object.
+- A decorative group or arrow that is not in the ledger.
diff --git a/references/api.md b/references/api.md
new file mode 100644
index 0000000..c0fcc43
--- /dev/null
+++ b/references/api.md
@@ -0,0 +1,14 @@
+# Notes macros
+
+Defined in `assets/notes-macros.tex` (loaded by `assets/notes-template.tex`).
+
+| macro | args |
+|---|---|
+| `\splabel{C1}` | hypertarget + label |
+| `\spref{C1}` | clickable id |
+| `\spsource{§3.2}` | source footnote |
+| `\spfig[w]{F1}{caption}{provenance}` | `figures/F1/build/F1.pdf` |
+| `\spscreenshot[w]{F2}{caption}{provenance}` | `figures/F2/orig.png` |
+| `quotebox` | short quotation |
+
+Scripts: `ingest.sh`, `lint.py`, `render_ledger.py`, `build.sh`, `screenshot.sh` all take `--work`.
diff --git a/references/checklist.md b/references/checklist.md
new file mode 100644
index 0000000..0858f23
--- /dev/null
+++ b/references/checklist.md
@@ -0,0 +1,9 @@
+# Delivery checklist
+
+- Ledger written first; every core claim has `\splabel`.
+- Formula three-beat present wherever display math appears.
+- Each figure toolkit matches `scripts/router.py`; mixed-class rows were split.
+- Notes do not `\usepackage{superfig|supertensor|superderive}`.
+- Vector figures are PDF includes; screenshots are `orig.png` with a source footnote.
+- `scripts/lint.py --work` and `scripts/build.sh --work` are green.
+- Overfull in the notes log is allowed; missing glyphs and undefined refs are not.
diff --git a/references/fallback.md b/references/fallback.md
new file mode 100644
index 0000000..ce56ba5
--- /dev/null
+++ b/references/fallback.md
@@ -0,0 +1,9 @@
+# Fallback
+
+`preflight.sh`: 0 full, 1 degraded, 2 no XeLaTeX/`article.cls`.
+
+- Exit 2: deliver `ledger.yaml` + Markdown; say there is no PDF.
+- No `pdftotext` / `pdftoppm`: only `--tex` / `--excerpt`.
+- No `jsonschema`: create `.venv` (`python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`).
+- Sibling `build.sh` fails: drop that figure to `align` or screenshot, keep the notes compiling.
+- No `magick`: `screenshot.sh` must fail if `crop_bbox` is set; do not silently ship the full page.
diff --git a/references/figure-request.md b/references/figure-request.md
new file mode 100644
index 0000000..ba20b10
--- /dev/null
+++ b/references/figure-request.md
@@ -0,0 +1,11 @@
+# Figure request
+
+Sibling figure agents see only `notes/figures/F*/F*.request.md` plus that sibling `SKILL.md`. They do not read `ledger.yaml`.
+
+- superfig: `\sfnode` keys `role, level, gap, bracket, at=`. `\sfconn` has no endpoints. `at` must be TikZ calc with `($…$)`.
+- supertensor: every `ststack` / `stface` / `stglyph` has `coord: ""`.
+- `allow-raw-tikz` must also appear as `% superfig-lint: allow-raw-tikz` in the `.tex`.
+- Build: `superfig/scripts/build.sh F1.tex F1/build` (second arg is the outdir).
+- Screenshots: `scripts/screenshot.sh --work --id F2` (`pdftoppm -r 200`, then optional `crop_bbox`).
+
+Worked files: `examples/pipeline-delegate/notes/figures/F1/F1.request.md`, `examples/tensor-delegate/notes/figures/F2/F2.request.md`.
diff --git a/references/input.md b/references/input.md
new file mode 100644
index 0000000..4c24884
--- /dev/null
+++ b/references/input.md
@@ -0,0 +1,12 @@
+# Input
+
+`scripts/ingest.sh --work (--arxiv ID|--pdf FILE|--tex FILE|--excerpt FILE)`
+
+| kind | ingest |
+|---|---|
+| `tex` | copy into `source/tex/`; do not compile |
+| `arxiv` | id regex `^(ar[Xx]iv:)?(\d{4}\.\d{4,5}(v\d+)?|[a-z-]+/\d{7})$`; PDF then optional e-print tar (read only) |
+| `pdf` | copy `source/paper.pdf`; `pdftotext`; pages `pg-%03d.png` at 120 dpi |
+| `excerpt` / `markdown` | `source/excerpt.md`; `coverage.mode=excerpt` |
+
+Do not OCR as the main path. Architecture figures: redraw with superfig. Numeric plots: screenshot or matplotlib. See `scripts/screenshot.sh` for 200 dpi page crops.
diff --git a/references/ledger.md b/references/ledger.md
new file mode 100644
index 0000000..ccaa657
--- /dev/null
+++ b/references/ledger.md
@@ -0,0 +1,9 @@
+# Ledger
+
+SSOT is `ledger.yaml`. Schema: `assets/ledger.schema.yaml`. Empty template: `assets/ledger.example.yaml`.
+
+Write the ledger before any `sections/*.tex`. Ids are `C1`, `Q1`, `D1`, `A1`, `L1`, `E1`, `DER1`, `F1`, `SA1` — no `F1a`. Retired ids go in `retired_ids`.
+
+`symbols[].kind` aliases (`activation` → `value`, `shape-parameter` → `shape parameter`, …) are normalized in `scripts/lint.py` **before** `SP001`. Canonical list is the schema enum.
+
+v1 derivations use `figure: null` and `align` in the notes. Do not invent a figure for a four-step rewrite.
diff --git a/references/pedagogy.md b/references/pedagogy.md
new file mode 100644
index 0000000..fd0d744
--- /dev/null
+++ b/references/pedagogy.md
@@ -0,0 +1,15 @@
+# Pedagogy
+
+Reuse `youtube-render-pdf` teaching order: motive → idea → mechanism → evidence → takeaway.
+
+Paper-side changes:
+
+- Cite `§` / `Eq.(n)` / Figure / Table / page, not timestamps.
+- Front page is a bibliography card, not a PDF cover screenshot.
+- Math is `\[` or `align`, never `$$`.
+- Formula three-beat: Chinese motive, display math, flat symbol list.
+- `quotebox` for a short quotation with a source; no long PDF paste.
+- End major sections with `\subsection{本章小结}`; end the notes with `\section{总结与延伸}`.
+- Figures stay outside boxes.
+
+Locked first/last titles: 「这篇论文在问什么」…「总结与延伸」, then appendix 符号表 / 推导链一览 / 图表清单.
diff --git a/references/router.md b/references/router.md
new file mode 100644
index 0000000..7747568
--- /dev/null
+++ b/references/router.md
@@ -0,0 +1,14 @@
+# Router
+
+Authority: `scripts/router.py`. `suggest()` returns a **class**, not a toolkit.
+
+| class | signals (any hit) | accepted toolkits |
+|---|---|---|
+| `numeric` | loss-curve, bar, scatter, histogram, numeric-plot | screenshot, matplotlib |
+| `raster` | table, photo, apparatus, ui | screenshot |
+| `tensor` | axis, shape, transpose, broadcast, gather, shard, contraction, face | supertensor |
+| `fig` | architecture, pipeline, data-flow, state, time, dependency, what-eats-what, argument-map | superfig |
+| `derive` | rewrite-figure, cancel-visual, subst-visual | v1: align |
+| `none` | otherwise | none |
+
+Mixed classes on one row → `SP011`. Split with the next integer ids. Default: do not draw.
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..d86d7ac
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+jsonschema>=4.18
+PyYAML>=6.0
diff --git a/scripts/build.sh b/scripts/build.sh
new file mode 100755
index 0000000..f9cc802
--- /dev/null
+++ b/scripts/build.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+# Lint, project the ledger, compile notes. Overfull is logged, not fatal.
+# ./scripts/build.sh --work
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+WORK=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --work|--out) WORK="$2"; shift 2 ;;
+ *) echo "usage: build.sh --work DIR" >&2; exit 2 ;;
+ esac
+done
+[[ -n "$WORK" ]] || { echo "usage: build.sh --work DIR" >&2; exit 2; }
+WORK="$(cd "$WORK" && pwd)"
+NOTES="$WORK/notes"
+[[ -f "$WORK/ledger.yaml" ]] || { echo "no ledger.yaml in $WORK" >&2; exit 2; }
+[[ -f "$NOTES/notes.tex" ]] || { echo "no notes/notes.tex in $WORK" >&2; exit 2; }
+
+echo "==> lint"
+"$ROOT/scripts/python" "$ROOT/scripts/lint.py" --work "$WORK"
+
+echo "==> render ledger"
+"$ROOT/scripts/python" "$ROOT/scripts/render_ledger.py" --work "$WORK"
+
+mkdir -p "$WORK/out"
+echo "==> xelatex"
+(
+ cd "$NOTES"
+ export TEXINPUTS="$ROOT/assets:$NOTES:"
+ xelatex -halt-on-error -interaction=nonstopmode notes.tex >/dev/null
+ xelatex -halt-on-error -interaction=nonstopmode notes.tex >/dev/null
+)
+
+LOG="$NOTES/notes.log"
+status=0
+if grep -q "Missing character" "$LOG"; then
+ echo "!! missing glyphs:" >&2
+ grep -m5 "Missing character" "$LOG" >&2
+ status=1
+fi
+if grep -qE "LaTeX Warning: (Reference|Citation|There were undefined references|Label\(s\) may have changed)" "$LOG"; then
+ if grep -q "undefined" "$LOG"; then
+ echo "!! undefined references:" >&2
+ grep -m5 "undefined" "$LOG" >&2 || true
+ status=1
+ fi
+fi
+if grep -q "multiply-defined" "$LOG"; then
+ echo "!! multiply-defined labels:" >&2
+ grep -m5 "multiply-defined" "$LOG" >&2
+ status=1
+fi
+if grep -qE "^(Overfull|Underfull) \\\\[hv]box" "$LOG"; then
+ echo "==> overfull/underfull (recorded, not fatal)"
+ grep -m5 -E "^(Overfull|Underfull) \\\\[hv]box" "$LOG" || true
+fi
+
+cp -f "$NOTES/notes.pdf" "$WORK/out/notes.pdf"
+echo "==> $WORK/out/notes.pdf"
+exit $status
diff --git a/scripts/ingest.sh b/scripts/ingest.sh
new file mode 100755
index 0000000..f859ed2
--- /dev/null
+++ b/scripts/ingest.sh
@@ -0,0 +1,108 @@
+#!/usr/bin/env bash
+# Ingest a paper source into a --work tree. Never compiles e-print TeX.
+set -euo pipefail
+
+WORK=""
+KIND=""
+SRC=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --work|--out) WORK="$2"; shift 2 ;;
+ --arxiv) KIND=arxiv; SRC="$2"; shift 2 ;;
+ --pdf) KIND=pdf; SRC="$2"; shift 2 ;;
+ --tex) KIND=tex; SRC="$2"; shift 2 ;;
+ --excerpt|--markdown) KIND=excerpt; SRC="$2"; shift 2 ;;
+ *) echo "usage: ingest.sh --work DIR (--arxiv ID|--pdf FILE|--tex FILE|--excerpt FILE)" >&2; exit 2 ;;
+ esac
+done
+[[ -n "$WORK" && -n "$KIND" ]] || { echo "need --work and a source" >&2; exit 2; }
+
+ARXIV_RE='^(ar[Xx]iv:)?([0-9]{4}\.[0-9]{4,5}(v[0-9]+)?|[a-z-]+/[0-9]{7})$'
+
+mkdir -p "$WORK/source/pages" "$WORK/notes/sections" "$WORK/out"
+meta="$WORK/source/meta.yaml"
+
+sha_of() { sha256sum "$1" | awk '{print $1}'; }
+
+write_meta() {
+ local extra="$1"
+ cat > "$meta" <&2; exit 2; }
+ id="${id#arxiv:}"; id="${id#arXiv:}"
+ mkdir -p "$WORK/source"
+ pdf="$WORK/source/paper.pdf"
+ if [[ ! -f "$pdf" ]]; then
+ curl -fsSL "https://arxiv.org/pdf/${id}.pdf" -o "$pdf" \
+ || curl -fsSL "https://export.arxiv.org/pdf/${id}.pdf" -o "$pdf"
+ fi
+ pdftotext -layout "$pdf" "$WORK/source/paper.txt" || true
+ pdftoppm -png -r 120 "$pdf" "$WORK/source/pages/pg"
+ python3 - <<'PY' "$WORK/source/pages"
+from pathlib import Path
+import sys
+d = Path(sys.argv[1])
+files = sorted(d.glob("pg*.png"))
+for i, p in enumerate(files, 1):
+ dest = d / f"pg-{i:03d}.png"
+ if p.resolve() != dest.resolve():
+ p.rename(dest)
+PY
+ # best-effort e-print; never compile
+ if [[ ! -d "$WORK/source/eprint" ]]; then
+ tmp=$(mktemp)
+ if curl -fsSL "https://arxiv.org/e-print/${id}" -o "$tmp"; then
+ mkdir -p "$WORK/source/eprint"
+ tar -xf "$tmp" -C "$WORK/source/eprint" --max-size=50M 2>/dev/null \
+ || tar -xf "$tmp" -C "$WORK/source/eprint" || true
+ fi
+ rm -f "$tmp"
+ fi
+ pages=$(find "$WORK/source/pages" -name 'pg-*.png' | wc -l)
+ write_meta "arxiv: $id
+local_pdf: source/paper.pdf
+sha256: $(sha_of "$pdf")
+pages: $pages"
+ ;;
+esac
+
+echo "ingested $KIND -> $WORK"
diff --git a/scripts/lint.py b/scripts/lint.py
new file mode 100755
index 0000000..406ae14
--- /dev/null
+++ b/scripts/lint.py
@@ -0,0 +1,243 @@
+#!/usr/bin/env python3
+"""Ledger and notes lint for superpaper. Codes live in DESIGN.md §10."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import sys
+from copy import deepcopy
+from pathlib import Path
+
+import yaml
+from jsonschema import Draft7Validator
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT / "scripts"))
+from router import SIBLING, check_row, phase_from_env # noqa: E402
+
+SCHEMA_PATH = ROOT / "assets" / "ledger.schema.yaml"
+KNOWN_TOP = {
+ "schema", "retired_ids", "paper", "coverage", "questions", "claims",
+ "definitions", "assumptions", "lemmas", "symbols", "derivations",
+ "figures", "evidence", "terms", "source_assets",
+}
+ID_GROUPS = (
+ "questions", "claims", "definitions", "assumptions", "lemmas",
+ "derivations", "figures", "evidence", "source_assets",
+)
+KIND_ALIASES = {
+ "activation": "value",
+ "value / activation": "value",
+ "logit": "score",
+ "score / logit": "score",
+ "coordinate": "index",
+ "index / coordinate": "index",
+ "order": "rank",
+ "rank / order": "rank",
+ "support": "mask",
+ "mask / support": "mask",
+ "shape-parameter": "shape parameter",
+ "shape_parameter": "shape parameter",
+}
+STY_RE = re.compile(r"\\usepackage(?:\[[^\]]*\])?\{(superfig|supertensor|superderive)\}")
+LABEL_RE = re.compile(r"\\splabel\{(C[1-9][0-9]*)\}")
+
+
+def emit(path: Path, messages: list[str]) -> None:
+ print(f"!! {path}", file=sys.stderr)
+ for msg in messages:
+ print(f" {msg}", file=sys.stderr)
+
+
+def normalize_kinds(data: dict) -> dict:
+ out = deepcopy(data)
+ for sym in out.get("symbols") or []:
+ if isinstance(sym, dict) and "kind" in sym:
+ kind = sym["kind"]
+ if kind in KIND_ALIASES:
+ sym["kind"] = KIND_ALIASES[kind]
+ return out
+
+
+def collect_ids(data: dict) -> list[str]:
+ ids: list[str] = []
+ for key in ID_GROUPS:
+ for row in data.get(key) or []:
+ if isinstance(row, dict) and "id" in row:
+ ids.append(str(row["id"]))
+ return ids
+
+
+def notes_text(notes_paths: list[Path]) -> str:
+ chunks: list[str] = []
+ for path in notes_paths:
+ if path.is_file():
+ chunks.append(path.read_text(encoding="utf-8"))
+ return "\n".join(chunks)
+
+
+def find_notes(work: Path | None, notes: Path | None) -> list[Path]:
+ found: list[Path] = []
+ if notes is not None:
+ found.append(notes)
+ if work is not None:
+ nd = work / "notes"
+ if (nd / "notes.tex").is_file():
+ found.append(nd / "notes.tex")
+ found.extend(sorted(nd.glob("sections/*.tex")))
+ # unique, keep order
+ seen: set[Path] = set()
+ uniq: list[Path] = []
+ for p in found:
+ rp = p.resolve()
+ if rp not in seen:
+ seen.add(rp)
+ uniq.append(p)
+ return uniq
+
+
+def lint(
+ ledger_path: Path,
+ *,
+ work: Path | None,
+ extra_notes: Path | None,
+ disk: bool,
+ notes_scan: bool,
+) -> tuple[list[str], list[str]]:
+ errors: list[str] = []
+ warnings: list[str] = []
+ raw = yaml.safe_load(ledger_path.read_text(encoding="utf-8"))
+ if not isinstance(raw, dict):
+ return ["SP001 ledger is not a mapping"], warnings
+
+ data = normalize_kinds(raw)
+ schema = yaml.safe_load(SCHEMA_PATH.read_text(encoding="utf-8"))
+ validator = Draft7Validator(schema)
+ for err in validator.iter_errors(data):
+ loc = ".".join(str(p) for p in err.absolute_path) or "$"
+ errors.append(f"SP001 {loc}: {err.message}")
+ if errors:
+ return errors, warnings
+
+ for key in data:
+ if key not in KNOWN_TOP:
+ warnings.append(f"unknown top-level key {key!r}")
+
+ coverage = data.get("coverage") or {}
+ if coverage.get("mode") == "full" and not coverage.get("sections_in"):
+ warnings.append("coverage.mode=full and sections_in is empty")
+
+ ids = collect_ids(data)
+ seen: set[str] = set()
+ for i in ids:
+ if i in seen:
+ errors.append(f"SP002 duplicate id {i}")
+ seen.add(i)
+
+ retired = set(data.get("retired_ids") or [])
+ for row_key in ID_GROUPS:
+ for row in data.get(row_key) or []:
+ if not isinstance(row, dict):
+ continue
+ rid = row.get("id")
+ if rid in retired and row.get("status") != "dropped":
+ errors.append(f"SP023 {rid} is in retired_ids")
+
+ claim_ids = {c["id"] for c in (data.get("claims") or []) if "id" in c}
+ phase = phase_from_env()
+ notes_paths = find_notes(work, extra_notes)
+ body = notes_text(notes_paths) if notes_scan else ""
+
+ if notes_scan:
+ for path in notes_paths:
+ text = path.read_text(encoding="utf-8")
+ if STY_RE.search(text):
+ errors.append(f"SP003 {path.name} loads a figure package")
+
+ labels = set(LABEL_RE.findall(body)) if notes_scan else set()
+
+ for fig in data.get("figures") or []:
+ fid = fig.get("id", "F?")
+ signals = set(fig.get("signals") or [])
+ toolkit = fig.get("toolkit")
+ status = fig.get("status", "planned")
+ include = fig.get("include", None)
+ code = check_row(
+ signals, toolkit, status=status, include=include,
+ fig_id=fid, phase=phase,
+ )
+ if code:
+ errors.append(f"{code} {fid}")
+ if toolkit == "superderive" and phase < 2:
+ errors.append(f"SP022 {fid} toolkit superderive is v2; use align")
+ if toolkit in SIBLING and status != "dropped" and disk:
+ req = fig.get("request")
+ req_path = (work / "notes" / req) if (work and req) else None
+ if not req or req_path is None or not req_path.is_file():
+ errors.append(f"SP012 {fid} missing request file")
+ if include and disk and work is not None:
+ on_disk = work / "notes" / include
+ if not on_disk.is_file():
+ errors.append(f"SP021 {fid} include not on disk: {include}")
+ claim = fig.get("claim")
+ if claim and claim not in claim_ids:
+ errors.append(f"SP024 {fid} claim {claim} is not a known C*")
+
+ for der in data.get("derivations") or []:
+ claim = der.get("claim")
+ if claim and claim not in claim_ids:
+ errors.append(f"SP024 {der.get('id')} claim {claim} is not a known C*")
+
+ if notes_scan:
+ for claim in data.get("claims") or []:
+ if claim.get("status") == "core" and claim.get("id") not in labels:
+ errors.append(f"SP020 core {claim['id']} has no \\splabel")
+ for sym in data.get("symbols") or []:
+ name = str(sym.get("name", ""))
+ latex = str(sym.get("latex", ""))
+ if name and name not in body and latex and latex not in body:
+ warnings.append(f"symbol {name} never appears in notes")
+
+ return errors, warnings
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="lint a superpaper ledger / notes tree")
+ parser.add_argument("--work", help="work tree root (ledger.yaml + notes/)")
+ parser.add_argument("--out", dest="work_alias", help="alias of --work")
+ parser.add_argument("--ledger", help="ledger.yaml (skips disk/notes codes unless --notes)")
+ parser.add_argument("--notes", help="extra notes.tex for SP003/SP020")
+ args = parser.parse_args()
+
+ work = Path(args.work or args.work_alias).resolve() if (args.work or args.work_alias) else None
+ if work is not None:
+ ledger = work / "ledger.yaml"
+ disk = True
+ notes_scan = True
+ elif args.ledger:
+ ledger = Path(args.ledger).resolve()
+ disk = False
+ notes_scan = args.notes is not None
+ else:
+ parser.error("need --work or --ledger")
+ return 2
+
+ extra = Path(args.notes).resolve() if args.notes else None
+ if not ledger.is_file():
+ print(f"!! {ledger}", file=sys.stderr)
+ print(" SP000 no such ledger", file=sys.stderr)
+ return 2
+
+ errors, warnings = lint(ledger, work=work, extra_notes=extra, disk=disk, notes_scan=notes_scan)
+ if errors:
+ emit(ledger, errors)
+ return 1
+ for warn in warnings:
+ print(f"warning: {warn}", file=sys.stderr)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/preflight.sh b/scripts/preflight.sh
new file mode 100755
index 0000000..90a3f9e
--- /dev/null
+++ b/scripts/preflight.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+# Notes toolchain preflight. 0 = full, 1 = degraded, 2 = no LaTeX engine.
+set -uo pipefail
+
+QUIET=0
+[[ "${1:-}" == "--quiet" ]] && QUIET=1
+say() { [[ $QUIET -eq 1 ]] || echo -e "$*"; }
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+ok=0; warn=0; fail=0
+check() {
+ local name="$1"; shift
+ if "$@" >/dev/null 2>&1; then say " ok $name"; ok=$((ok+1)); return 0
+ else say " MISS $name"; return 1; fi
+}
+
+say "superpaper preflight"
+say "--- engine ---"
+check "xelatex" command -v xelatex || fail=$((fail+1))
+check "article.cls" kpsewhich article.cls || fail=$((fail+1))
+
+say "--- template / CJK ---"
+cjk=0
+for pkg in ctex.sty FandolSong-Regular.otf amsmath.sty amssymb.sty \
+ tcolorbox.sty graphicx.sty hyperref.sty geometry.sty \
+ listings.sty booktabs.sty subcaption.sty float.sty tikz.sty etoolbox.sty; do
+ check "$pkg" kpsewhich "$pkg" || { warn=$((warn+1)); cjk=1; }
+done
+
+say "--- python ---"
+PY="$ROOT/scripts/python"
+check "python3" command -v python3 || warn=$((warn+1))
+check "PyYAML" "$PY" -c "import yaml" || warn=$((warn+1))
+check "jsonschema" "$PY" -c "import jsonschema" || warn=$((warn+1))
+
+say "--- ingest ---"
+check "pdftotext" command -v pdftotext || warn=$((warn+1))
+check "pdftoppm" command -v pdftoppm || warn=$((warn+1))
+
+say "--- optional ---"
+check "magick" command -v magick || true
+check "pdftocairo" command -v pdftocairo || true
+
+if [[ $fail -gt 0 ]]; then
+ say ""
+ say "RESULT: no LaTeX. Deliver ledger + Markdown; no PDF."
+ exit 2
+fi
+if [[ $warn -gt 0 ]]; then
+ say ""
+ say "RESULT: degraded."
+ [[ $cjk -eq 1 ]] && say " - missing CJK/template package -> English notes or later Missing character."
+ exit 1
+fi
+say ""
+say "RESULT: full notes path (XeLaTeX + CJK + lint)."
+exit 0
diff --git a/scripts/python b/scripts/python
new file mode 100755
index 0000000..11a6ec2
--- /dev/null
+++ b/scripts/python
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+# Prefer the local venv so jsonschema is available without a system install.
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+if [[ -x "$ROOT/.venv/bin/python" ]]; then
+ exec "$ROOT/.venv/bin/python" "$@"
+fi
+exec python3 "$@"
diff --git a/scripts/render_ledger.py b/scripts/render_ledger.py
new file mode 100755
index 0000000..a322af2
--- /dev/null
+++ b/scripts/render_ledger.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+"""Project ledger.yaml into notes/sections/symbols.tex and optional claims.md."""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+import yaml
+
+
+def tex_escape(text: str) -> str:
+ return (
+ text.replace("\\", "\\textbackslash{}")
+ .replace("&", "\\&")
+ .replace("%", "\\%")
+ .replace("#", "\\#")
+ .replace("_", "\\_")
+ )
+
+
+def render(work: Path) -> None:
+ ledger = yaml.safe_load((work / "ledger.yaml").read_text(encoding="utf-8"))
+ symbols = ledger.get("symbols") or []
+ claims = ledger.get("claims") or []
+ sec = work / "notes" / "sections"
+ sec.mkdir(parents=True, exist_ok=True)
+ lines = [
+ "% generated by render_ledger.py — do not edit",
+ "\\begin{itemize}",
+ ]
+ if not symbols:
+ lines.append("\\item (无符号)")
+ for sym in symbols:
+ name = tex_escape(str(sym.get("name", "")))
+ meaning = tex_escape(str(sym.get("meaning", "")))
+ kind = tex_escape(str(sym.get("kind", "")))
+ latex = sym.get("latex") or name
+ lines.append(f"\\item ${latex}$ — {meaning} ({kind};{name})")
+ lines.append("\\end{itemize}")
+ (sec / "symbols.tex").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+ md = ["# Claims", ""]
+ for c in claims:
+ md.append(f"- `{c.get('id')}` ({c.get('status')}): {c.get('text')}")
+ (work / "claims.md").write_text("\n".join(md) + "\n", encoding="utf-8")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--work", required=False)
+ parser.add_argument("--out", dest="work")
+ args = parser.parse_args()
+ if not args.work:
+ print("usage: render_ledger.py --work ", file=sys.stderr)
+ return 2
+ work = Path(args.work).resolve()
+ if not (work / "ledger.yaml").is_file():
+ print(f"no ledger at {work / 'ledger.yaml'}", file=sys.stderr)
+ return 2
+ render(work)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/router.py b/scripts/router.py
new file mode 100755
index 0000000..e33495d
--- /dev/null
+++ b/scripts/router.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+"""Figure-class router. suggest() returns a class, never a toolkit."""
+
+from __future__ import annotations
+
+NUMERIC = frozenset({"loss-curve", "bar", "scatter", "histogram", "numeric-plot"})
+RASTER = frozenset({"table", "photo", "apparatus", "ui"})
+TENSOR = frozenset({
+ "axis", "shape", "transpose", "broadcast", "gather",
+ "shard", "contraction", "face",
+})
+DERIVE = frozenset({"rewrite-figure", "cancel-visual", "subst-visual"})
+FIG = frozenset({
+ "architecture", "pipeline", "data-flow", "state", "time",
+ "dependency", "what-eats-what", "argument-map",
+})
+
+CLASS_SIGNALS = {
+ "numeric": NUMERIC,
+ "raster": RASTER,
+ "tensor": TENSOR,
+ "derive": DERIVE,
+ "fig": FIG,
+}
+
+VECTOR = frozenset({"superfig", "supertensor", "superderive", "matplotlib"})
+SIBLING = frozenset({"superfig", "supertensor", "superderive"})
+
+
+def classify(signals: set[str]) -> list[str]:
+ s = set(signals)
+ hit = [c for c, vocab in CLASS_SIGNALS.items() if s & vocab]
+ return hit or ["none"]
+
+
+def suggest(signals: set[str], *, phase: int = 1) -> str:
+ """Return a class name, or 'SPLIT:a+b+...' in CLASS_SIGNALS order."""
+ classes = classify(signals)
+ if len(classes) > 1:
+ return "SPLIT:" + "+".join(classes)
+ return classes[0]
+
+
+def accepted(cls: str, *, phase: int = 1) -> frozenset[str]:
+ if cls == "numeric":
+ return frozenset({"screenshot", "matplotlib"})
+ if cls == "raster":
+ return frozenset({"screenshot"})
+ if cls == "tensor":
+ return frozenset({"supertensor"})
+ if cls == "fig":
+ return frozenset({"superfig"})
+ if cls == "derive":
+ return frozenset({"align", "superderive"} if phase >= 2 else {"align"})
+ if cls == "none":
+ return frozenset({"none"})
+ raise KeyError(cls)
+
+
+def include_pdf(fig_id: str) -> str:
+ return f"figures/{fig_id}/build/{fig_id}.pdf"
+
+
+def include_png(fig_id: str) -> str:
+ return f"figures/{fig_id}/orig.png"
+
+
+def check_row(
+ signals: set[str],
+ toolkit: str,
+ *,
+ status: str = "planned",
+ include: str | None = None,
+ fig_id: str = "F1",
+ phase: int = 1,
+) -> str | None:
+ """First matching code, or None. SP010 is not an include/status rule."""
+ classes = classify(signals)
+ if len(classes) > 1:
+ return "SP011"
+ if toolkit not in accepted(classes[0], phase=phase):
+ return "SP010"
+ if toolkit == "none" and status != "dropped":
+ return "SP013"
+ if toolkit in {"align", "none"} and include is not None:
+ return "SP014"
+ if status == "included" and toolkit in VECTOR and include != include_pdf(fig_id):
+ return "SP015"
+ if status == "included" and toolkit == "screenshot" and include != include_png(fig_id):
+ return "SP016"
+ return None
+
+
+def phase_from_env() -> int:
+ import os
+ raw = os.environ.get("SUPERPAPER_PHASE", "1")
+ try:
+ return int(raw)
+ except ValueError:
+ return 1
diff --git a/scripts/screenshot.sh b/scripts/screenshot.sh
new file mode 100755
index 0000000..c1fd3b4
--- /dev/null
+++ b/scripts/screenshot.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+# Raster one ledger figure from paper.pdf via pdftoppm. crop_bbox is 200 dpi page pixels.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+WORK=""
+FID=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --work|--out) WORK="$2"; shift 2 ;;
+ --id) FID="$2"; shift 2 ;;
+ *) echo "usage: screenshot.sh --work DIR --id F2" >&2; exit 2 ;;
+ esac
+done
+[[ -n "$WORK" && -n "$FID" ]] || { echo "need --work and --id" >&2; exit 2; }
+
+"$ROOT/scripts/python" - "$WORK" "$FID" <<'PY'
+import sys, tempfile, subprocess, shutil
+from pathlib import Path
+import yaml
+
+work = Path(sys.argv[1])
+fid = sys.argv[2]
+ledger = yaml.safe_load((work / "ledger.yaml").read_text())
+fig = next((f for f in (ledger.get("figures") or []) if f.get("id") == fid), None)
+if fig is None:
+ sys.exit(f"no figure {fid}")
+pages = fig.get("source_pages") or []
+if not pages:
+ sys.exit("source_pages required")
+n = int(pages[0])
+pdf = work / "source" / "paper.pdf"
+if not pdf.is_file():
+ sys.exit(f"missing {pdf}")
+dest_dir = work / "notes" / "figures" / fid
+dest_dir.mkdir(parents=True, exist_ok=True)
+dest = dest_dir / "orig.png"
+bbox = fig.get("crop_bbox")
+with tempfile.TemporaryDirectory() as td:
+ prefix = Path(td) / "pg"
+ subprocess.run(
+ ["pdftoppm", "-png", "-r", "200", "-f", str(n), "-l", str(n), str(pdf), str(prefix)],
+ check=True,
+ )
+ produced = sorted(Path(td).glob("pg*.png"))
+ if not produced:
+ sys.exit("pdftoppm produced no page")
+ page = produced[0]
+ if bbox is None:
+ shutil.copy2(page, dest)
+ else:
+ if shutil.which("magick") is None:
+ sys.exit("crop_bbox set but magick is missing")
+ x0, y0, x1, y1 = bbox
+ w, h = int(x1 - x0), int(y1 - y0)
+ subprocess.run(
+ ["magick", str(page), "-crop", f"{w}x{h}+{int(x0)}+{int(y0)}", "+repage", str(dest)],
+ check=True,
+ )
+print(dest)
+PY
diff --git a/scripts/test.sh b/scripts/test.sh
new file mode 100755
index 0000000..8c47b1c
--- /dev/null
+++ b/scripts/test.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+# Superpaper v1 checks: router unit tests, ledger codes, work-tree builds.
+set -uo pipefail
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PY="$ROOT/scripts/python"
+LINT=("$PY" "$ROOT/scripts/lint.py")
+fail=0
+
+ok() { echo " ok $*"; }
+bad() { echo " FAIL $*"; fail=$((fail+1)); }
+
+echo "==> router_test"
+if "$PY" "$ROOT/tests/router_test.py"; then ok router_test.py; else bad router_test.py; fi
+
+echo "==> ledger-valid"
+if "${LINT[@]}" --ledger "$ROOT/tests/ledger-valid.yaml" >/dev/null 2>&1; then
+ ok ledger-valid.yaml
+else
+ bad ledger-valid.yaml
+fi
+
+expect_ledger() {
+ local file="$1" code="$2"
+ local out
+ out=$("${LINT[@]}" --ledger "$file" 2>&1) || true
+ if echo "$out" | grep -q "$code"; then
+ ok "$(basename "$file") ($code)"
+ else
+ bad "$(basename "$file") (wanted $code)"
+ echo "$out" | sed 's/^/ /'
+ fi
+}
+
+echo "==> ledger-invalid"
+for spec in \
+ "missing-paper-id.yaml:SP001" \
+ "bad-toolkit.yaml:SP001" \
+ "duplicate-id.yaml:SP002" \
+ "mixed-signals.yaml:SP011" \
+ "toolkit-mismatch.yaml:SP010" \
+ "none-not-dropped.yaml:SP013" \
+ "align-has-include.yaml:SP014" \
+ "included-null-include.yaml:SP015" \
+ "screenshot-bad-include.yaml:SP016" \
+ "superderive-v1.yaml:SP022" \
+ "retired-reuse.yaml:SP023" \
+ "dangling-claim.yaml:SP024"
+do
+ f="${spec%%:*}"; c="${spec##*:}"
+ expect_ledger "$ROOT/tests/ledger-invalid/$f" "$c"
+done
+
+expect_work() {
+ local dir="$1" code="$2"
+ local out
+ out=$("${LINT[@]}" --work "$dir" 2>&1) || true
+ if echo "$out" | grep -q "$code"; then
+ ok "$(basename "$dir") ($code)"
+ else
+ bad "$(basename "$dir") (wanted $code)"
+ echo "$out" | sed 's/^/ /'
+ fi
+}
+
+echo "==> work-tree lint"
+expect_work "$ROOT/tests/loads-sty" SP003
+expect_work "$ROOT/tests/missing-request" SP012
+expect_work "$ROOT/tests/unlabeled-core" SP020
+expect_work "$ROOT/tests/missing-include" SP021
+
+echo "==> compile"
+for dir in \
+ "$ROOT/tests/notes-smoke" \
+ "$ROOT/examples/excerpt-toy" \
+ "$ROOT/examples/pipeline-delegate" \
+ "$ROOT/examples/tensor-delegate"
+do
+ name="$(basename "$dir")"
+ if "$ROOT/scripts/build.sh" --work "$dir" >/dev/null 2>&1; then
+ ok "$name (build)"
+ else
+ bad "$name (build)"
+ "$ROOT/scripts/build.sh" --work "$dir" || true
+ fi
+done
+
+if [[ $fail -gt 0 ]]; then
+ echo "$fail failing check(s)" >&2
+ exit 1
+fi
+echo "all v1 checks passed"
diff --git a/tests/ledger-invalid/align-has-include.yaml b/tests/ledger-invalid/align-has-include.yaml
new file mode 100644
index 0000000..3866c19
--- /dev/null
+++ b/tests/ledger-invalid/align-has-include.yaml
@@ -0,0 +1,29 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: core claim, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures:
+ - id: F1
+ claim: C1
+ title: align
+ grammar: derivation
+ toolkit: align
+ signals: [rewrite-figure]
+ include: figures/F1/build/F1.pdf
+ status: included
+
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/ledger-invalid/bad-toolkit.yaml b/tests/ledger-invalid/bad-toolkit.yaml
new file mode 100644
index 0000000..5006deb
--- /dev/null
+++ b/tests/ledger-invalid/bad-toolkit.yaml
@@ -0,0 +1,28 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: core claim, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures:
+ - id: F1
+ claim: C1
+ title: bad
+ grammar: pipeline
+ toolkit: not-a-toolkit
+ signals: [pipeline]
+ status: planned
+
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/ledger-invalid/dangling-claim.yaml b/tests/ledger-invalid/dangling-claim.yaml
new file mode 100644
index 0000000..045bcb1
--- /dev/null
+++ b/tests/ledger-invalid/dangling-claim.yaml
@@ -0,0 +1,28 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: core claim, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures:
+ - id: F1
+ claim: C99
+ title: dangling
+ grammar: pipeline
+ toolkit: superfig
+ signals: [pipeline]
+ status: planned
+
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/ledger-invalid/duplicate-id.yaml b/tests/ledger-invalid/duplicate-id.yaml
new file mode 100644
index 0000000..156691a
--- /dev/null
+++ b/tests/ledger-invalid/duplicate-id.yaml
@@ -0,0 +1,21 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: one, kind: contribution, status: core}
+ - {id: C1, text: two, kind: contribution, status: supporting}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures: []
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/ledger-invalid/included-null-include.yaml b/tests/ledger-invalid/included-null-include.yaml
new file mode 100644
index 0000000..0a4a3c8
--- /dev/null
+++ b/tests/ledger-invalid/included-null-include.yaml
@@ -0,0 +1,30 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: core claim, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures:
+ - id: F1
+ claim: C1
+ title: vector
+ grammar: pipeline
+ toolkit: superfig
+ signals: [pipeline]
+ include: null
+ request: figures/F1/F1.request.md
+ status: included
+
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/ledger-invalid/missing-paper-id.yaml b/tests/ledger-invalid/missing-paper-id.yaml
new file mode 100644
index 0000000..0c8c7c0
--- /dev/null
+++ b/tests/ledger-invalid/missing-paper-id.yaml
@@ -0,0 +1,6 @@
+schema: superpaper.ledger/v1
+paper:
+ title: Missing id
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
diff --git a/tests/ledger-invalid/mixed-signals.yaml b/tests/ledger-invalid/mixed-signals.yaml
new file mode 100644
index 0000000..6226e89
--- /dev/null
+++ b/tests/ledger-invalid/mixed-signals.yaml
@@ -0,0 +1,28 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: core claim, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures:
+ - id: F1
+ claim: C1
+ title: mixed
+ grammar: architecture
+ toolkit: superfig
+ signals: [axis, architecture]
+ status: planned
+
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/ledger-invalid/none-not-dropped.yaml b/tests/ledger-invalid/none-not-dropped.yaml
new file mode 100644
index 0000000..1727f3b
--- /dev/null
+++ b/tests/ledger-invalid/none-not-dropped.yaml
@@ -0,0 +1,28 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: core claim, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures:
+ - id: F1
+ claim: C1
+ title: none
+ grammar: none
+ toolkit: none
+ signals: []
+ status: planned
+
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/ledger-invalid/retired-reuse.yaml b/tests/ledger-invalid/retired-reuse.yaml
new file mode 100644
index 0000000..a1f0da7
--- /dev/null
+++ b/tests/ledger-invalid/retired-reuse.yaml
@@ -0,0 +1,20 @@
+schema: superpaper.ledger/v1
+retired_ids: [C1]
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: core claim, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures: []
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/ledger-invalid/screenshot-bad-include.yaml b/tests/ledger-invalid/screenshot-bad-include.yaml
new file mode 100644
index 0000000..029b39c
--- /dev/null
+++ b/tests/ledger-invalid/screenshot-bad-include.yaml
@@ -0,0 +1,29 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: core claim, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures:
+ - id: F2
+ claim: C1
+ title: shot
+ grammar: screenshot
+ toolkit: screenshot
+ signals: [table]
+ include: figures/F2/wrong.png
+ status: included
+
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/ledger-invalid/superderive-v1.yaml b/tests/ledger-invalid/superderive-v1.yaml
new file mode 100644
index 0000000..424cb0e
--- /dev/null
+++ b/tests/ledger-invalid/superderive-v1.yaml
@@ -0,0 +1,28 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: core claim, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures:
+ - id: F1
+ claim: C1
+ title: derive
+ grammar: derivation
+ toolkit: superderive
+ signals: [rewrite-figure]
+ status: planned
+
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/ledger-invalid/toolkit-mismatch.yaml b/tests/ledger-invalid/toolkit-mismatch.yaml
new file mode 100644
index 0000000..a92be9d
--- /dev/null
+++ b/tests/ledger-invalid/toolkit-mismatch.yaml
@@ -0,0 +1,28 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: core claim, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures:
+ - id: F1
+ claim: C1
+ title: mismatch
+ grammar: screenshot
+ toolkit: superfig
+ signals: [loss-curve]
+ status: planned
+
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/ledger-valid.yaml b/tests/ledger-valid.yaml
new file mode 100644
index 0000000..1c25654
--- /dev/null
+++ b/tests/ledger-valid.yaml
@@ -0,0 +1,20 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: core claim, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures: []
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/loads-sty/ledger.yaml b/tests/loads-sty/ledger.yaml
new file mode 100644
index 0000000..1c25654
--- /dev/null
+++ b/tests/loads-sty/ledger.yaml
@@ -0,0 +1,20 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: core claim, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures: []
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/loads-sty/notes/notes.tex b/tests/loads-sty/notes/notes.tex
new file mode 100644
index 0000000..c8e1324
--- /dev/null
+++ b/tests/loads-sty/notes/notes.tex
@@ -0,0 +1,7 @@
+\documentclass{article}
+\usepackage{amsmath}
+\begin{document}
+\usepackage{superfig}
+
+hello
+\end{document}
diff --git a/tests/missing-include/ledger.yaml b/tests/missing-include/ledger.yaml
new file mode 100644
index 0000000..f8d58c2
--- /dev/null
+++ b/tests/missing-include/ledger.yaml
@@ -0,0 +1,30 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: core claim, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures:
+ - id: F1
+ claim: C1
+ title: missing pdf
+ grammar: pipeline
+ toolkit: superfig
+ signals: [pipeline]
+ request: figures/F1/F1.request.md
+ include: figures/F1/build/F1.pdf
+ status: included
+
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/missing-include/notes/figures/F1/F1.request.md b/tests/missing-include/notes/figures/F1/F1.request.md
new file mode 100644
index 0000000..5283e83
--- /dev/null
+++ b/tests/missing-include/notes/figures/F1/F1.request.md
@@ -0,0 +1 @@
+toolkit: superfig
diff --git a/tests/missing-include/notes/notes.tex b/tests/missing-include/notes/notes.tex
new file mode 100644
index 0000000..b22803b
--- /dev/null
+++ b/tests/missing-include/notes/notes.tex
@@ -0,0 +1,5 @@
+\documentclass{article}
+\begin{document}
+\splabel{C1}
+ok
+\end{document}
diff --git a/tests/missing-request/ledger.yaml b/tests/missing-request/ledger.yaml
new file mode 100644
index 0000000..5524d39
--- /dev/null
+++ b/tests/missing-request/ledger.yaml
@@ -0,0 +1,29 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: core claim, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures:
+ - id: F1
+ claim: C1
+ title: missing req
+ grammar: pipeline
+ toolkit: superfig
+ signals: [pipeline]
+ request: figures/F1/F1.request.md
+ status: planned
+
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/missing-request/notes/notes.tex b/tests/missing-request/notes/notes.tex
new file mode 100644
index 0000000..b22803b
--- /dev/null
+++ b/tests/missing-request/notes/notes.tex
@@ -0,0 +1,5 @@
+\documentclass{article}
+\begin{document}
+\splabel{C1}
+ok
+\end{document}
diff --git a/tests/notes-smoke/ledger.yaml b/tests/notes-smoke/ledger.yaml
new file mode 100644
index 0000000..41dfbd1
--- /dev/null
+++ b/tests/notes-smoke/ledger.yaml
@@ -0,0 +1,21 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: smoke
+ title: Smoke
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: smoke, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols:
+ - {name: x, latex: "x", meaning: "input", kind: value}
+derivations: []
+figures: []
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/notes-smoke/notes/notes.tex b/tests/notes-smoke/notes/notes.tex
new file mode 100644
index 0000000..e76ab90
--- /dev/null
+++ b/tests/notes-smoke/notes/notes.tex
@@ -0,0 +1,14 @@
+\documentclass[a4paper]{article}
+\input{notes-macros}
+\renewcommand{\notetitle}{Smoke}
+\begin{document}
+\section{这篇论文在问什么}
+一段动机。
+\section{主张与贡献}
+\splabel{C1} 这是核心主张。
+\section{总结与延伸}
+结束。
+\appendix
+\section{符号表}
+\input{sections/symbols.tex}
+\end{document}
diff --git a/tests/router_test.py b/tests/router_test.py
new file mode 100755
index 0000000..203250c
--- /dev/null
+++ b/tests/router_test.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+from router import accepted, check_row, suggest
+
+
+def expect(cond, msg):
+ if not cond:
+ raise SystemExit(f"FAIL: {msg}")
+
+
+expect(suggest({"loss-curve"}) == "numeric", "loss-curve class")
+expect(accepted("numeric") == frozenset({"screenshot", "matplotlib"}), "numeric accepted")
+expect(suggest({"table"}) == "raster", "table class")
+expect("matplotlib" not in accepted("raster"), "raster rejects matplotlib")
+expect(suggest({"axis", "architecture"}) == "SPLIT:tensor+fig", "split order")
+expect(
+ check_row({"axis", "architecture"}, "superfig") == "SP011",
+ "mixed row",
+)
+expect(check_row({"rewrite-figure"}, "align", phase=1) is None, "align ok")
+expect(check_row({"rewrite-figure"}, "superderive", phase=1) == "SP010", "derive v1")
+expect(suggest(set()) == "none", "empty")
+expect(suggest({"notation"}) == "none", "notation")
+expect(check_row(set(), "none", status="included") == "SP013", "none included")
+expect(
+ check_row({"rewrite-figure"}, "align", include="figures/F1/build/F1.pdf") == "SP014",
+ "align include",
+)
+expect(
+ check_row({"architecture"}, "superfig", status="included", include=None) == "SP015",
+ "vector null include",
+)
+expect(
+ check_row({"table"}, "screenshot", status="included", include="nope.png", fig_id="F2")
+ == "SP016",
+ "screenshot path",
+)
+print("router_test ok")
diff --git a/tests/unlabeled-core/ledger.yaml b/tests/unlabeled-core/ledger.yaml
new file mode 100644
index 0000000..1c25654
--- /dev/null
+++ b/tests/unlabeled-core/ledger.yaml
@@ -0,0 +1,20 @@
+schema: superpaper.ledger/v1
+retired_ids: []
+paper:
+ id: toy
+ title: Toy
+ source: {kind: excerpt}
+coverage:
+ mode: excerpt
+questions: []
+claims:
+ - {id: C1, text: core claim, kind: contribution, status: core}
+definitions: []
+assumptions: []
+lemmas: []
+symbols: []
+derivations: []
+figures: []
+evidence: []
+terms: []
+source_assets: []
diff --git a/tests/unlabeled-core/notes/notes.tex b/tests/unlabeled-core/notes/notes.tex
new file mode 100644
index 0000000..d74a5a8
--- /dev/null
+++ b/tests/unlabeled-core/notes/notes.tex
@@ -0,0 +1,7 @@
+\documentclass{article}
+\usepackage{amsmath}
+\begin{document}
+% no label
+
+hello
+\end{document}