Skip to content

The build graph, generated for Ninja - #44

Merged
sesquideus merged 28 commits into
masterfrom
kvik/ninja
Nov 4, 2026
Merged

sesquideus merged 28 commits into
masterfrom
kvik/ninja

Conversation

@sesquideus

Copy link
Copy Markdown
Collaborator

The build moves from 1623 lines of GNU make to a Ninja graph generated from the source tree
by tools/ninja/generate.py. The Makefile is not deleted and still works — this adds a
second way to build, proves it is better, and leaves the migration's last step to a separate
pass.

The failure this ends

Every recipe in the Makefile shells out to Python, pandoc or xelatex; the 1623 lines are path
algebra and dependency declaration, which is what make is worst at. The concrete cost is that
a stale file in build/ lets a rule silently not apply, recorded twice in the Makefile's
own comments and met again while writing this:

where what it hid
Makefile:20-23 $(lang) was never set, so every picture rule passed an empty argument and died on invalid choice — invisible, "because a stale intermediate in build/ lets make skip the rule"
naboj language documents make …/languages/uk/booklet.pdf built a 40-page Ukrainian booklet for a volume that has en, es, sk, out of intermediates whose sources were gone

One mechanism each time: a pattern rule whose prerequisites come from $(wildcard …), which
yields nothing rather than failing, so the rule either does not apply — and an existing
target is declared current — or applies against leftovers.

Ninja has no globbing, no pattern rules and no implicit rule search. A target either has an
edge or it does not:

$ make  -n output/naboj/phys/29/languages/uk/booklet.pdf
make: Nothing to be done for 'output/naboj/phys/29/languages/uk/booklet.pdf'.

$ ./dgs-build -n output/naboj/phys/29/languages/uk/booklet.pdf
ninja: error: unknown target 'output/naboj/phys/29/languages/uk/booklet.pdf',
       did you mean 'output/naboj/phys/28/languages/uk/booklet.pdf'?

Ninja also records the command line per output and rebuilds when it changes, which make does
not. That alone would have caught Makefile:20-23 four years earlier.

The numbers

make ninja
no-op decision, one volume 0.100 s 0.006 s
no-op decision, whole repository (59 355 edges) — 0.3 s, including the generator's up-to-date check
writing the graph, 112 scopes n/a 1.8 s
cold volume booklet 135.2 s 42.7 s
output fidelity — 142/142 .tex byte-identical; the 44-page booklet differs only in the colophon's build clock

Most of the win is the warm interpreter, not the scheduler: one python -m renderer
invocation costs 0.498 s of which 0.459 s is interpreter and import startup, and one
booklet is 162 of them. tools/runner/ is a fork server that pays that once.

What is new

  • tools/ninja/generate.py writes every edge from a directory walk. The whole dependency
    graph is a pure function of source/ — no $(wildcard …) in the Makefile ever read
    build/ — which is what makes this feasible at all.
  • tools/ninja/fls.py turns xelatex's -recorder .fls into a depfile. It is the only
    honest answer to \protectedInput, whose include set is decided at run time against a path
    built from a TeX macro: touching core/latex/symbols.tex, which is declared nowhere,
    correctly rebuilds the booklet and nothing else.
  • tools/runner/, the warm-interpreter fork server behind $run.
  • ./dgs-build — generates build.ninja when it is missing and then becomes Ninja, so a
    fresh clone has nothing to remember. Verified on a worktree with no source/, no .venv
    and no graph: it creates the venv, writes the graph and builds.
  • A phony per listed directory. The generator edge depends on the directories the graph
    was read from, so adding or removing a problem re-runs it mid-build; the phonies are what
    make a removal recoverable rather than an unrecoverable missing and no known rule.

What changed around it

  • The editor and core/audit/build.py go through Ninja; probe_make became probe_ninja,
    and the advisories now name pdftk, pdftoppm and pdfjam.
  • ./dgs-container ninja <target>; no apt package needed, since uv sync already puts Ninja
    in the venv.
  • CI builds something for the first time. The one make line it had was commented out.
    It now generates the graph as its own step and then builds a complete seminar round, with
    the scope read out of ninja -t targets rather than typed into the workflow.
  • default core — Ninja's unsaid default is every root of the graph, 33 929 outputs here,
    where a bare make touched a stamp and stopped.

Defects found by building everything

Nothing had ever built scholar, and seminar's solutions-full had never compiled at all:

  • solutions-full.jtex used \hline, a tabular command, so every solutions-full.pdf ever
    asked for died on Misplaced \noalign;
  • 34 venue documents took cs.tex whatever language the venue is in;
  • scholar's problem schema wanted title: where 73 metas said name:;
  • three naboj gnuplot scripts and one blocks: preamble still opened set terminal pdf,
    which silently wrote PDFs into files named .svg;
  • core/audit/stale.py grew the language half of its sweep, which is what the 40-page
    Ukrainian booklet needed.

Deliberately not in this PR

  • The Makefile stays. Removing it is a second, larger pass.
  • Deployment is gone from the build entirely. output/naboj/%/copy rsynced to a host and
    output/seminar/%/copy shelled out to dgs-copy.py with a $(user) the Makefile never
    defined, so it had never worked. Neither was ported; a graph that uploads because you named
    a target is a surprise worth not having. dgs-copy.py remains as a script.
  • output/<round>/instagram is red on every round. The images are now one declared output
    per problem, with the page count checked, and no round satisfies it — 42/1/1 is 12 pages for
    7 problems, 41/1/1 is 9. The cause is the problems, not the graph: 1800 characters at 12 pt
    do not fit a 160 mm square. It is in no aggregate, so nothing else notices.
  • Content defects reported and left: phys/29's 17 venue instructions.pdf, naboj/test's
    missing tearoff.instruction, scholar's nine gnuplot fonts (recorded in CLAUDE.md).

Verified

uv run pytest — 1793 passed, 10 skipped; 1613 passed in a worktree with no content
repositories and no build/
, which is the rule core/tests/ is held to. A seminar round,
a unit preview, an HTML page and the 44-page phys/29 booklet all build end to end.
Regeneration fires on a directory added and removed. make still plans the same booklet.

The CI workflow itself is unverified until it runs here for the first time.

🤖 Generated with Claude Code

sesquideus and others added 26 commits November 2, 2026 03:50
`uv lock --upgrade`, not mine: twelve packages move, numpy 2.5.3 to 2.5.4,
pint 0.26.1 to 0.26.2, flask 3.1.8 to 3.1.9, ruff 0.16.9 to 0.17.3 and the
rest of the dev tools with them. `pyproject.toml` is unchanged, so nothing
about what is required moved -- only what was resolved for it.

On master rather than on `kvik/ninja`, which is where it was sitting, because
it has nothing to do with that branch and everything else does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The upgrade changes how pint formats three units for siunitx, and this
repository declares its own macros for all three, so both halves of the
booklet's temperature typography went with them.

Up to 0.26.1 a Celsius quantity formatted as `\degree_Celsius`, which is not
valid TeX, so `PhysicsQuantity.PINT_TO_SIUNITX` caught it by name through
`_UNDERSCORE_MACRO` and wrote `\celsius`. 0.26.2 writes `\degreeCelsius`,
which *is* valid TeX and so never reaches that mapping at all: siunitx's own
`\degreeCelsius` sets a math ring operator with a space before the C, so
`25 °C` became `25 ∘ C` in the 146 source files that declare one.
`siunitx.tex` now declares `\degreeCelsius` as `\text{°}C` beside the
`\celsius` it already had, and the page is what it was.

Fahrenheit is worse and is a repair rather than a renaming. 0.26.2 writes
`\degree F` -- a macro and a bare letter -- and this document sets
`forbid-literal-units`, so siunitx raises *Literal units disabled* and the
unit disappears: `phys/23/yankees` printed `25` with nothing after it, in
five languages. `PINT_LITERALS` puts it back to `\fahrenheit`.

`\dcelsius` is collateral: pint now spells a temperature *difference*
`\degreeCelsius` too, so the rendered TeX no longer distinguishes it from an
absolute. Both set `°C`, so no page moves, and the macro stays for anything
written by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`make output/naboj/phys/29/languages/uk/booklet.pdf` built a 40-page
Ukrainian problems booklet for a volume that has `en`, `es` and `sk`. The
language documents are pattern rules, so `%` takes any string, and the rest
of each prerequisite list is `$(wildcard …)`, which yields nothing for a
language that is not there rather than failing. A target with no
prerequisites whose file exists is up to date, so make reported success over
whatever an older run had left in `build/`. Clearing `build/` was the only
thing that turned it back into an error.

Two halves, because there are two states. Cold: every language document now
takes `source/naboj/$*/meta.yaml`, the one prerequisite in those lists not
derived from something else, so a language that does not exist fails at once
and names the missing file. Warm, where the stale `build/` is already there:
`stale.py` splits into `unit_orphans` and `language_orphans` over a shared
`_sweep`, and `build-orphan` says which it found, since the consequences
differ -- a stale unit prints a deleted problem, a stale language is a whole
booklet for a translation nobody has.

The language half is self-limiting rather than a rule about Náboj: a module
with no `source/<scope>/languages/` yields nothing, which is how seminar and
scholar pass through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every recipe in this build shells out to `python -m something`, and 92 % of
what that costs is the interpreter coming up: 445 ms of wall and 1.10 s of
CPU to import `core.builder.jinja` and the pint/numpy stack under it,
against 53 ms to do the render once those are in memory. A volume booklet
runs about 320 such steps.

`server.py` holds the imports and forks a child per job; `client.py` is what
the build invokes, and imports four stdlib modules so that it starts in
14 ms. Measured end to end: 0.457 s per render becomes 0.067 s.

Fork, not a reused worker. The expensive state -- the pint registry at
`core/builder/jinja.py` module level, i18n's thirteen parsed and validated
locales, Jinja's caches -- is shared copy-on-write and read only, while
anything a job mutates dies with the child. That is what separates this from
a Bazel-style persistent worker, where state leaking between jobs is the
standard failure. The fork costs about 2 ms of the 53.

Three things it has to get right, each of which cost a round to find:

- The length prefix rides in the same `sendmsg` as the descriptors.
  Ancillary data attaches to one byte of the stream, so a plain `recv` of a
  separate header can swallow that byte and the descriptors are closed
  without a word.
- An acknowledgement byte divides "never started" from "failed". Before it
  the client falls back and runs the job itself; after it, a lost connection
  is a failure, because falling back would run half a job twice.
- `OPENBLAS_NUM_THREADS=1` before the first import. `import numpy` spawns a
  pool at 630 % CPU for a build that does no linear algebra, and forking a
  process whose threads hold locks is how children deadlock. The CPU it
  saves is incidental.

The daemon fingerprints every `.py`, `.yaml` and `.lua` under `core/`,
`modules/` and `tools/` before each fork -- 176 files, 1.2 ms -- and retires
rather than answer with code that is no longer on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`tools/ninja/generate.py` writes every edge of the build from a walk of
`source/`, which is possible because no `$(wildcard …)` in any of the four
makefiles reads `build/`: every `build/` prerequisite is
`$(subst source/,build/,…)` of a `source/` glob, so the graph is a pure
function of the source tree. 35 155 edges for the whole repository, 1.9 s to
generate, 0.13 s for Ninja to decide there is nothing to do.

What it is for is not speed. Make decides with pattern rules whose
prerequisites come from wildcards, and a wildcard that matches nothing
yields nothing rather than failing, so a rule silently does not apply and an
existing target is declared current. That has hidden three defects on record
-- `Makefile:20-23`, `Makefile:146`, and the Ukrainian booklet of the
previous commit. Ninja has no pattern rules and no implicit search: a target
either has an edge or it is `unknown target`, and it says which real one you
probably meant.

Checked against make rather than assumed: a volume's 142 `.tex` come out
byte-identical and the 44-page booklet differs in one line, the colophon's
build clock.

`ink.py` carries the Makefile's four colours onto Ninja's `description`
lines, so the build still says what it is doing in the words it used to. One
departure: make's single `jinja` macro called everything a "Markdown
template", which is right for a volume's 161 fragments and wrong for its 30
pictures, so `$kind` now names the format. A rule block may not carry a
default for such a variable -- that is a parse error, not something Ninja
ignores -- so every edge binds it.

Nothing in `Makefile` or `modules/*/module.mk` is touched. The two drive the
same trees, which is what lets them be diffed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`run_make` becomes `run_build`, the capability probe looks for `ninja`
instead of GNU make, and the launcher's argv follows. `ensure_graph`
generates `build.ninja` when it is absent, which is the one state Ninja
cannot recover from itself -- it keeps the graph current through the
`generator` edge, but a first run after a clone has none, and
`ninja: error: loading 'build.ninja'` names a file nobody has heard of.

The probe gets simpler rather than merely renamed. `probe_make` had to
reject a make that was not GNU, because BSD make accepts the invocation and
then misparses `.SECONDEXPANSION`, which every rule in this repository leans
on. There is one Ninja and no dialects, so finding it is the whole test.

This is also what lets `/api/compile` work again: its target is
`modules/naboj/editor.yaml`'s `preview:`, `output/…/standalone.pdf`, which
was missing from the graph until the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Starting the editor the documented way -- `python tools/editor/app.py` from
the root, no activated venv -- reported **markdown, tex and pdf all
unusable** on a machine that builds all three. `_which('ninja')` searched
`PATH` and nothing else, and Ninja is a project dependency, so the one the
build runs is `.venv/bin/ninja`, which is on `PATH` only inside an activated
venv. A tier is never usable when one below it is missing, so that single
lookup took the whole pipeline down.

The launcher already knew better: `probe_python_launcher` prepends exactly
that directory when uv is absent, and returns `uv run ninja` when it is not.
The probe and the thing it probes for disagreed.

`_which` now falls back to the interpreter's own directory, which is the
same directory and the same reason, and `probe_ninja` runs the path it found
rather than the bare name -- the two are different questions once the venv
is not activated.

Verified against a running editor: all four tiers report true, `/api/render`
and `/api/tex` build, `/api/compile` writes `standalone.pdf` in 3.9 s, and
the audit's build checks run 15 of 15 targets across en, es and sk in 61 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`run_make` became `run_build` when the editor switched over and the rest of
the names did not, so the module read half in one vocabulary and half in the
other: `make_env`, `make_result`, twenty-odd `make_target` locals, and the
docstrings around them. They are `build_env`, `build_result` and
`build_target` now, and `core/audit/build.py`'s injected runner with them.

Two of those were not cosmetic. `build_result` and `refusal` both wrote
`"command": f"make {target}"` into the payload the editor displays, so every
build the page reported named a command nobody had run -- and `app.js`
printed "make reported success but wrote no fragment" in the preview pane
for the same reason. Both say `ninja` now.

The two mentions of make that remain are deliberate: `tex_target` explains
what the Makefile's fall-through rule used to print for a `.gp.tex`, and
`missing_meta_result` quotes `No rule to make target` beside Ninja's
`unknown target`, because both name the PDF rather than the meta.yaml that
is actually missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every Markdown file was rendered once and converted once, to TeX. The second
writer was missing, and with it everything downstream of it: the HTML
fragments, the pictures in the format a browser wants, and the pages those
two assemble into. `ninja output/naboj/phys/29/problems/chopper/sk/index.html`
answered `unknown target`, which is the honest version of what make said --
its own `output/%.html` has been an XFAIL fall-through since the per-module
rules landed.

Four rules and one helper each side of them:

  - `pandoc_html` hangs off the same `render/…md` as `pandoc_tex`. One Jinja
    stage, two writers, which is the point: a second route to the web would
    be a second chance for the page and the booklet to disagree about what
    the problem says.
  - `pdf_svg` and `gnuplot_svg` give every drawing its web twin. There is no
    separate web picture -- a TikZ drawing and a PDF copied in are converted
    from the artefact the booklet prints, and a plot is drawn a second time
    by gnuplot, which is one step shorter and keeps the PDF parser out of the
    chain for the files that provoked the conversion bug. A hand-drawn `.svg`
    is copied.
  - `page` runs `core.html.page`, which is the `output/` step: fragments in,
    one `index.html` with its pictures beside it and its maths typeset.

`_page` takes the module rather than inferring it from the path, because
where a fragment lives is the module's to say and `page.py` reads the same
fact out of `modules/<module>/editor.yaml`: Náboj nests a directory per
language inside a problem, the other two do not. `_beside` matches the unit's
directory exactly rather than by prefix -- scholar's issue directory contains
its problems, and a prefix would hand an issue's page every drawing below it.

New names: `output/naboj/<comp>/<vol>/languages/<lang>/pages`, and `pages`
over all of a volume's languages; `output/seminar/<round>/pages`. Not folded
into `all`, which is the Makefile's reading too -- the documents are what a
volume is run from and the pages are a second output of the same sources.

Scholar gets the fragments and no pages, and that asymmetry is deliberate:
`modules/scholar/editor.yaml` says `targets: [text]` where 28 of its issues
hold a `content.md`, so `page.py` would find nothing to assemble and the edge
could only ever fail. The fragments are still right whatever the descriptor
calls them, and that is what the editor's HTML tab shows.

Measured on `phys/29`: 1774 edges against 1507, 118 of 120 pages in 64 s
warm, and the no-op decision still 0.006 s. The two that do not build are
`chopper`, whose `freq.gp` still opens with `set terminal pdf size 6, 1.5` --
the pre-`@gpterm` spelling, which overrides the terminal the build supplies
and makes gnuplot write a PDF into a file named `.svg`. The sniff in
`gnuplot_svg` is what says so rather than leaving a blank figure on the page.
Thirteen scripts across the repository are in that state, `FKS/42/1/1/03`'s
`blocks:` preamble among them; each wants
`set terminal @gpterm size W*gppt, H*gppt` and each changes the printed
figure's typeface when it gets it, so none is touched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`UnitKind.html` derived `output/<module>/…/<target>.html` from the render
target, and said so in its docstring: "the root Makefile has one
`output/%.html: render/%.md` for every module". That rule became an XFAIL
fall-through when the per-module `.html` rules landed beside the `.tex` ones,
and this did not follow them -- so `/api/html` has been asking the build for
a target nothing makes. Under make that is `Incorrect fall-through rule
called`; under Ninja it is `unknown target`, which is how it was found.

A fragment is an intermediate and belongs in `build/`, for the same reason a
`.tex` does: no `<head>`, nothing to typeset its `\(…\)`, and `<img>`
pointing at files that are not beside it. So `html` is now `tex` with the
other extension, which is also what every `module.mk` writes.

That moves where the preview pane's pictures resolve, and the fragment's own
directory is the wrong answer for Náboj: a fragment sits inside the language
directory and its drawings beside the problem above it. The server names the
directory instead -- `html_base`, the unit's own `build/` directory, which is
where the generator puts a picture in all three modules -- and `/api/build/`
serves it, path-checked exactly as `/api/output/` is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The note said "a script that wants the default writes nothing at all, which
is 39 of the 42", and 30 of the 42 do: nine of scholar's plots size their own
canvas and were written with `@gpterm`, and naboj's three were not. Those
three and `FKS/42/1/1/03`'s `blocks:` preamble still opened
`set terminal pdf size 6, 1.5`; all four are converted now, in their own
repositories.

The reason they lasted is worth writing down, because it is the opposite of
what the section implies. Re-issued over a `pdfcairo` that is already
current, `set terminal pdf` keeps both the font and the `fontscale` it finds,
so the printed figure is unchanged -- the six plots and volume 29's whole
booklet are pixel-identical across the conversion. Only the `svg` terminal
notices, because there it is a different terminal rather than a reset one,
and gnuplot then writes a PDF into a file named `.svg`. Nothing was wrong
until the web path was built, and then three problems' pages were blank
figures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`build/seminar/FKS/.static/` was in no edge, so the first round document Ninja
reached died on `File 'build/seminar/FKS/.static/format-override.tex' not found`.

make had it as `copy-static`, one `cp -r` of the whole tree -- and a rule whose
own target it never touched, so it had no prerequisites, re-ran on every build
and told the documents nothing about what they read. Five files here, one copy
edge each, named in the PDFs' implicit deps. FKS is the only competition with a
`.static`; a module without one yields nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Its one block put a bare `\hline` between the restated problem and its
solution. `\hline` is a tabular command, so in ordinary body text it is
`Misplaced \noalign` every time -- not for this round, for every round of
every seminar since the template was written. Nothing caught it because
nothing builds that document: `output/seminar/%/pdf` asks for `problems`
and `solutions` and leaves the third alone.

A rule across the measure is vertical-mode material, so `\hrule`, with the
`\par` that gets there and a `\medskip` on each side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An optician writes `+5 D` and never `+5 m^{-1}`, so the unit wants declaring
rather than spelling out. `forbid-literal-units` is on, which is what made
`FKS/42/1/3/05` fail -- the eye-model problem that asked for it -- and the
message it fails with, `Literal units disabled`, says nothing about which
unit or where.

`core/filters/siunitx.py` reads this file, so the web side needs nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generator edge takes every source directory the graph was read from as an
input, which is how adding a problem or a translation re-runs it. Removing one
was the other half and did not work: an input with no rule that has gone
missing is a hard error --

    ninja: error: rebuilding 'build.ninja':
    'source/naboj/phys/29/problems/amusement-speed/es', needed by 'build.ninja',
    missing and no known rule to make it

-- and an unrecoverable one, because the edge that would have noticed the
removal is the edge Ninja is refusing to run. Deleting a language left the
whole tree unbuildable until somebody ran the generator by hand, which is
exactly the state a `generator` rule exists to prevent.

A `phony` for each of those directories fixes it: a phony whose output does not
exist is simply dirty, so `build.ninja` is out of date, the generator reruns,
and the directory leaves the graph. Same trick CMake uses for its own manifest
dependencies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`build.ninja` is generated and gitignored, so a fresh clone has none, and Ninja
has no bootstrap of its own -- it does not find a manifest the way make finds a
Makefile. What a new machine got was

    ninja: error: loading 'build.ninja': No such file or directory

naming a file nobody has heard of and saying nothing about a generator.

The editor already handled this in `run_build`. That rule moves to
`tools/ninja/bootstrap.py` so there is one of it, and `./dgs-build` is the same
thing for a terminal: generate the graph if it is absent, then become Ninja.
How to invoke Ninja is `capabilities.probe_python_launcher`'s answer rather
than a second one of our own, so the editor and the command line cannot drift.
`exec` rather than a subprocess, so Ninja owns the terminal and Ctrl-C reaches
it. The shell half chooses an interpreter, which is the one thing that cannot
be done from Python; the venv goes before `uv run` because it is a process
cheaper and reaches the same place.

Not in `install.sh`, which is where one would first look. The graph is a
function of `source/`, cloned in step 2 and added to whenever another content
repository is, so a graph written during the install would be wrong by the end
of it; and `install.sh` is the PDF tier, which a translator never runs and
still needs a build. `ninja` itself arrives with `uv sync`, as a project
dependency, and `capabilities.py` already says so when it is missing.

`install.md` gets the section, including the three changes the generator edge
does not notice -- a new volume, a new template, a different scope set -- and
loses three references to make that the editor stopped making a while ago.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A full cold rebuild of the last volume of every content repository -- phys/29,
chem/04, test/01, FKS/42, TA1/2025, STA/2023 -- turned these up. They are
independent but they are one file and one sitting, and they share a shape: the
generator deciding what counts as content.

**A venue is set in its own language.** `output/naboj/phys/29/all` failed 34
documents at once on `File 'build/core/i18n/cs.tex' not found`. The venue edges
took the *volume's* languages, and a venue need not be in one of them: 29 has
`en` and `sk` and runs in Praha, Olomouc, Budapest, Gdańsk, Lisboa, Madrid,
Toruń, Wien, Wrocław and Uzhhorod. Under make nothing showed, because
`i18n.stamp` built every locale whether anything asked for one or not. Each
venue's documents now take their own locale, and that locale joins the set
`core_edges` emits -- which also fixes the narrow case, where `--scope phys/29`
emitted two locales and the Czech venue had no rule to reach a third.

The language is read out of the venue meta, the first time this generator reads
a file rather than a directory listing, so the meta joins the generator edge's
inputs -- otherwise changing `language:` would leave the graph pointing at the
locale the file used to name. A line scan, not `yaml.safe_load`, because
`tools/ninja/bootstrap.py`'s last-resort path runs this module under whatever
`python3` is on PATH. 69 of the 144 venue metas name no language at all
(`phys/02`, `20`-`25`, several of them empty); they get no dependency and no
guess, and they cannot build today for a reason that is not the graph's:
`contexts/buildable.py:66` subscripts `i18n.languages[venue.language]`
outright.

**A dot directory is not a unit.** `pathlib.glob` is not the `glob` module --
`*` matches a leading dot -- so `seminar/FKS/.template` became six scopes and 48
problems of scaffolding, and `scholar/STA/2023/homework/02/.priors`, a draft
kept beside the real problems, was rendered and failed on a meta the schema
refuses. 118 scopes become 112 and 1893 problems 1845.

**Scholar's `.static/` is copied.** The same gap seminar's had: every document
of a course `\input`s `source/scholar/<course>/.static/format-override.tex` and
nothing built it. The comment here used to say the fix could not be checked
because scholar built under neither system; it builds now, and `STA/2023` goes
from five failures to none.

While in there, `A and B or C` in the scholar scope discovery gets the brackets
it meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The picture list accumulated across every issue of a year and every document
took all of it, so one drawing that would not render stopped the issues after
it as well: `TA1/2025/homework/03/monte-carlo.gp` fails on its own line 1, and
`04` -- which names no drawing of 03's -- never got built at all.

It is collected per issue now and added to the year's total afterwards, so the
count the generator prints is unchanged. An issue is a document; what it
depends on is what sits under it.

The other two modules were already right for their own shape: a naboj volume's
documents really do take every problem's drawings, and seminar collects per
round, which is its scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Recorded rather than fixed. All nine of scholar's gnuplot scripts write
`set terminal @gpterm size … font "Minion Pro, 10"`, and `@gpterm` carries the
font, so pdfcairo refuses the line: *Duplicated or contradicting arguments in
cairo terminal options*. They have been broken since `@gpterm` landed and
nothing reported it, because nothing built scholar until the Ninja graph did.

The fix is a choice rather than a repair -- drop the font and the labels come
out at 6pt instead of the 10pt somebody chose for a 510pt canvas, or teach
`@gpterm` a size, which is a change in dgs. Both wait; scholar is not being
run and Náboj is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`modules/<module>/editor.yaml` names `output/<unit>/standalone.pdf` as its
`preview:` and `/api/compile` builds that and nothing else, so a module whose
standalone has no edge has an editor that renders Markdown, converts TeX and
then cannot show the page. The graph had 3523 of naboj's and none of the other
two, while make builds all three -- the one place where the spike had become a
regression rather than an addition, because `capabilities.py` and `run_build`
were switched to Ninja a while ago and the Makefile was not consulted again.

A second rule, because the two builders take the unit as one path where
naboj's takes the competition, volume, problem and language apart; `-l`
defaults to `source/<module>/`, so what they are given is the path below that
and not the one the graph uses. Scholar gets one for both shapes
`editor.yaml` declares -- the sheet with its `text.md`, and a problem below it.

The edge names only the unit's own sources, which keeps the property both
builders were written for: each reads its meta through a `read_yaml` that
answers `{}` to anything unparseable, so a preview compiles for the problem in
front of you even when the round around it does not.

Checked by building four of them -- two seminar problems, a scholar sheet and
a scholar problem -- one page each, the figure of `FKS/42/1/3/03` among them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three of them had no edge, so a build command that worked yesterday answered
`unknown target` today -- which is the right answer for a volume with no
Ukrainian and the wrong one for a target nobody retired.

`instagram.pdf` is built now. The round builder already wrote `instagram.tex`
and the graph stopped there; it stays out of `all`, which is
`modules/seminar/module.mk:270`'s own reading -- its line in the aggregate is
commented out, and what a round is run for is its problems and its solutions.
`output/.../instagram` splits that into one PNG per page, as make did. How many
there are depends on how long the document came out, so the outputs cannot be
declared: a stamp carries the date, the way `burst` already does, and a phony
carries the name people type. Make had it as a file target whose recipe never
created it, so it re-ran on every build.

`pdf`, `problems` and `solutions` are phonies over what they were there, and
the last two reach the HTML fragments as well, which is what `html-problems`
and `html-solutions` were for.

**`output/*/copy` is deliberately not here.** Those two are deployment --
naboj's rsyncs to `ago:/home/balaz/public_html/`, seminar's shells out to
`dgs-copy.py` with a `$(user)` the Makefile never defines, so it has been
passing an empty argument. A build graph that uploads to a host when you name
a target is a surprise worth not building; they want to be a script, and until
they are, `make` still has them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`./dgs-container ninja <target>` alongside `make`. It runs `./dgs-build`
rather than `ninja`, because a fresh mount has no `build.ninja` and no image
can carry one -- the graph is a function of `source/`, which is bind-mounted
from the host and deliberately never baked. No apt package was needed: `ninja`
is a project dependency, so `uv sync` has already put it in `/opt/venv/bin`,
which is first on PATH.

CI gets the two steps its commented-out `make` line was standing in for. The
generator runs on its own, so a crash or a pair of edges claiming one output
is named in under two seconds instead of arriving as a Ninja parse error. Then
one real round is built end to end -- Jinja, pandoc, the pictures, two xelatex
passes -- which is the first time this repository has compiled anything in CI.

The round is **read out of the graph**, not typed into the workflow: a path
written there is a second copy of this repository's layout and goes stale in
silence, which is what the line being replaced had already done. The testing
repository is also cloned to `source/seminar/test` now, where
`modules/seminar/editor.yaml` says it goes, rather than `.../testing`; nothing
read it before, and a scope is named after its directory.

The discovery line is tested here against `source/seminar/FKS`, where it finds
`FKS/30/1/1` and the target resolves. The workflow itself is unverified until
it runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`instagram.jtex` sets each problem on its own page and ends it with a
`\clearpage`, so the document's page count is the round's problem count --
a fact the generator already holds. The split is therefore not `burst`,
whose count genuinely is unknowable, and the stamp it borrowed is gone:
each PNG is a declared output of its own edge, named by `-f`/`-l` and
`-singlefile` rather than by pdftoppm's digit-padding rule.

The count is also checked, once per document, and every page hangs off the
check. Without it a document that came out longer would be cut into images
silently misnumbered -- `instagram-02.png` holding the tail of the first
problem rather than the second -- which is worse than no image at all.
pdftoppm does not treat a page number past the end as an error, it writes
nothing, so the per-page edges end in `test -f` as well.

**No round satisfies the invariant today**, which is what the check is for:
FKS/42/1/1 is 12 pages for 7 problems, 42/1/2 is 10, 42/1/3 is 12 and
41/1/1 is 9. The cause is the problems, not the template -- 1800 characters
at 12pt do not fit a 160mm square, and `\vspace*{\fill}` then spends a whole
page on the glue alone. `output/<round>/instagram` is red everywhere until
that is settled; it is in no aggregate, so nothing else notices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`output/naboj/%/copy` rsynced a volume to `ago:/home/balaz/public_html/`
and `output/seminar/%/copy` shelled out to `dgs-copy.py`, and neither was
ever a build: they were a temporary workaround that stuck. A graph that
uploads to a host because you named a target is a surprise worth not
having, and it is the one kind of edge that cannot be undone by `rm -rf
build/`.

Both are gone from make and neither was ported to the Ninja graph, so
deployment now lives nowhere on it. `dgs-copy.py` stays as the script it
always was, to be run by hand.

The seminar one had never worked anyway: `$(user)` is defined nowhere in
the Makefile, so the recipe passed an empty fifth argument for as long as
it has existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ninja's default, unsaid, is every root of the graph -- here 33 929 outputs,
the whole repository down to each unit's preview PDF. A bare `make` touched
a stamp and stopped, so the two spellings of "I typed the command to see
what happens" differed by about four hours of xelatex.

`default core` now, where `core` is the i18n tables: core's own, needed by
every other edge, and a second or two. `Writer.default` is the one line of
manifest syntax that was missing.

Along the way, `probe_advisories` learns the three tools that take a
finished PDF apart -- `pdftk` for a volume's online burst, `pdftoppm` for
a round's instagram images, `pdfjam` for a semester's imposition. None was
listed, which is how this machine reports the PDF tier green, has no
`pdftk`, and fails exactly one edge out of 59 355 with nothing having said
why. They are advisories and not a gate, because most documents want none
of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three sentences that named make for something the graph now does: the
audit's build layer takes an injected runner and the editor's is Ninja's,
`build-orphan`'s message told the reader to run `make prune`, and
CLAUDE.md said the same. `make` still has both targets and still works, so
this is accuracy rather than removal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sesquideus
sesquideus requested a review from TomasVoros November 4, 2026 03:56
sesquideus and others added 2 commits November 4, 2026 05:32
Every formula on an HTML page goes through `dvisvgm --pdf`, and Ghostscript
dropped the interpreter dvisvgm drove in 10.01.0. So on anything that new --
Debian 13 ships 10.05.1, and so does the runner -- the conversion does not
degrade, it refuses:

    ERROR: To process PDF files, either Ghostscript < 10.01.0 or mutool is
    required. The installed Ghostscript version 10.05.1 isn't supported.

`mutool` is the answer dvisvgm itself names, and it picks it up by itself when
it finds it; `DVISVGM_PDF_PROC` forces the choice either way, which is what
this was verified with here, where Ghostscript is still 10.00. So the package
goes into the three places that install the toolchain -- CI, the container and
`install.sh` -- and `mutool` joins the two lists that fail by name rather than
letting a test skip itself: CI's prerequisite check and the image's.

The editor's advisories name it too, and deliberately without asking
Ghostscript's version: a machine where the old interpreter is still there is
one upgrade away from this, and there is no reading of a version that makes it
safe to be missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`canvas()` read a gnuplot PDF's `/MediaBox` with a regex over the raw bytes,
which works only while cairo writes the page dictionary as a plain object. From
1.17 it writes it into a *compressed object stream*, so on a newer machine the
regex finds nothing and the test dies as `NoneType has no attribute groups` --
with nothing to say that the PDF was perfectly good and the reader was wrong.
It passed here on cairo 1.16 and failed in CI, which is the worst shape for a
test to be in.

`pdfinfo` answers the question instead. It is poppler, the same package as the
`pdftocairo` this module already requires, so it costs no new dependency, and
it keeps working whatever cairo does with its object layout next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sesquideus
sesquideus requested a review from MarekSliva November 4, 2026 06:40

@TomasVoros TomasVoros left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It works faster than before.

@sesquideus
sesquideus merged commit 20ae1da into master Nov 4, 2026
3 of 4 checks passed
@sesquideus
sesquideus deleted the kvik/ninja branch November 4, 2026 12:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants