Skip to content

depth audit - #37

Merged
sesquideus merged 27 commits into
masterfrom
depth-audit
Sep 13, 2026
Merged

sesquideus merged 27 commits into
masterfrom
depth-audit

Conversation

@sesquideus

Copy link
Copy Markdown
Collaborator

Claude megasession that audited the entirety of Náboj and made lots of fixes along the way

  • Docs: preamble.md no longer exists
  • A tag vocabulary for chemistry
  • Remove the preamble mechanism
  • PhysicsQuantity: abs, and an order
  • The values verdict honours an answer-literal opt-out
  • Units render British, whichever way the source spells them
  • The n filters are idempotent*
  • A unit being defined is not a number the statement gives
  • Deduplicating across translations, and when not to
  • An e filter family: scientific notation, always
  • constants: water's thermal expansion was mercury's
  • constants: the solar constant is 1361, not 1366
  • constants: radius_earth is the mean radius, and gains an equatorial sibling
  • constants: density_ice is 916.7, the real figure
  • Two audit checks for the ways a wrong page stays green
  • Fractions: four tiers, and \nicefrac is no longer the default
  • An answer interval is rounded outward, not to nearest
  • A render target depends on the renderer, not only on its input
  • The booklet depends on which files a problem has, not only on their contents
  • listed-missing can fire at last
  • Claude install for pandoc
  • Close the dependency gaps that let make report a stale PDF up to date
  • Three layout faults the sources were patching by hand
  • Write down the conventions these changes establish

sesquideus and others added 27 commits September 1, 2026 21:42
The last nine moved into `derived:` (see the chem submodule), so every
passage describing `preamble.md` as a thing an author might write or a
step the renderer performs was documenting something absent. The layout
listing, the two-pass flow, the routing table, the `-P` in both example
invocations and the skill's own front matter all drop it.

Two mentions are kept deliberately, both saying it is gone: `layout.md`
because a directory listing is where someone looks to find out what a
problem may contain, and `jinja-templating.md` because `@J set` is still
a real tag inside a template and its history explains why `derived:` is
not merely the preferred spelling but the only one.

`core/builder/renderer.py` still carries `-P` and the prepending behind
it, now with no callers -- neither `module.mk`, nor the Makefile, nor the
editor mentions a preamble. Left in place; removing an argument is a
separate decision from emptying the sources it read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`VALID_TAGS` was a physics vocabulary, and chemistry Náboj had never used
it: all 144 problems in volumes 01 to 04 carried either no `tags:` key at
all or the placeholder `['?']`. This adds 41 chemistry tags, in six groups
-- the sub-disciplines, how much of what, solutions and equilibria, rates
and energy, structure, and the analytical methods themselves -- plus
`puzzle` and `matching`, which sit with `truth-or-dare` and `ordering`
because they describe the shape a problem takes rather than its subject.

Physics tags are shared wherever the word already means the right thing:
`gases` for the state equation, `nuclear` for decay, and `calorimetry`,
`mixing`, `buoyancy`, `geometry`, `math` and `units` as written. Every one
of the 41 is used at least once by the tags committed alongside in the
chem submodule; the vocabulary was written from the 144 statements rather
than from a textbook contents page, which is why there is a tag for
`qualitative` (one problem identifies a salt by flame colour and
precipitates) and none for, say, polymers.

The coarse five -- `inorganic`, `organic`, `analytical`,
`physical-chemistry`, `biochemistry` -- are deliberately coarse. They
answer "which course is this from", and they are the only tags that say
anything at all about a problem whose entire content is a structure to
draw.

`layout.md` also loses `authors: ['Kvík']`, which the problem schema has
never accepted -- it wants a mapping of the three roles -- and gains a
section on where the vocabulary lives and why a typo in a tag costs
nothing at build time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No source has had a `preamble.md` since the last nine moved into
`derived:`, so what remained was a code path with no callers. It is gone:

    JinjaConvertor.__init__   the `preamble` keyword, and `self.preamble`
    prepare_template          deleted; `run` hands the template to both
                              passes directly
    build_convertor           the open-and-check block
    add_extra_arguments       `-P` / `--preamble`
    import io                 there only for the preamble's type hint

The flag is *removed* rather than accepted and ignored, which is the one
choice here worth arguing about. An ignored `-P` would let an old command
line, an old note or an old transcript appear to work while the preamble
it named was never read -- the same failure shape as a missing translated
word resolving to something plausible. Argparse now exits on it.

`TestPrepareTemplate`'s six tests went with the method; three replace
them, asserting the absences on purpose -- no `preamble` parameter, no
`prepare_template`, and `-P` exits. The two-pass render stays covered by
`test_one_equation_serves_every_language`, which needs the second pass to
expand a tag out of an `eq:` entry.

679 passed, and `phys/29` and `chem/04` both rebuild from scratch at 51
and 37 pages -- `04` being the volume whose seven preambles started this.

Three documents said the flag still existed with no callers, which was
true when written and is not now. `jinja-templating.md` also points a
genuine control-flow need at a filter in `core/filters/`, where it would
come with tests, and notes that `@J set` still works in a `.jtex`
template, which is where control flow belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two results were inexpressible in `derived:` and both are ordinary physics:
the magnitude of a difference, and the larger of two branches.

Jinja reaches `abs`, `max` and `min` only as filters, and all three go
through Python's own -- `abs()` needs `__abs__`, and `|max` sorts, which
needs `<`. The class had `__neg__` and `__eq__` and neither of those, so
`|abs` and `|max` raised `TypeError` on a quantity while `|sum` worked,
since addition was already there.

Comparison does not reuse `_binop`, which wraps its result in a
`PhysicsQuantity`; a comparison yields a bool. Incomparable dimensions
raise `DimensionalityError` from pint rather than answering: a metre is
not less than a second, nor more, and either answer would be a comparison
of two magnitudes that mean nothing to each other. Units of the same
dimension convert first, so 400 cm is correctly more than 3 m.

The four comparisons are spelled out rather than derived from
`functools.total_ordering`, to match the arithmetic above them, which
writes out `__radd__` and `__rsub__` in full too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`value_status` folds in whether the answer is computed, but it worked the
answer out itself rather than asking the check, so a problem that has
legitimately opted out of `answer-literal` read `partial` for ever. Three
in volume 28 do: `central-lamp` answers 100 % whatever its refractive
index is, `gravity-sudoku` answers 0 because a solved sudoku's rows all
sum to 45 g, and `balance-me` answers which two of nine planets are left
over. None is the output of a calculation, so a typed number is the right
answer there and the column was reporting a problem where there is none.

That direction matters more than the other. This list can never be
complete, so a verdict that misses something is a known limit; a verdict
that flags a finished problem is one nobody can trust, and the fix is to
stop flagging.

Only the answer half is freed. A statement still spelling its numbers out
is a separate thing to fix, and `gravity-sudoku` has both -- an answer of
0 by symmetry, and a cell size written out -- so it stays `missing` until
the second is done. That is the fourth of the four tests; the others are
that a typed answer does hold the verdict, that the opt-out frees it, and
that opting out of an unrelated check does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pint names two SI units the American way, and only two: `meter`, 266
times across the repository's `values:`, and `liter`, 21 times. siunitx
declares both spellings of each and they typeset identically, so no
printed page moves -- phys/28, phys/29 and chem/04 all rebuild from
scratch at 48, 51 and 37 pages, exactly as before.

What changes is the rendered source, which the repository writes in
British English throughout. A hand-written `\qty{4}{\milli\litre}` and
the same quantity taken from `values:` came out spelled differently,
sometimes inside one sentence: `28/yoghurt-milk` reads that way today.
And the mixture was about to spread, because every literal that the
migration to computed answers turns into a tag converts one `\metre` in
the source into a `\meter` in the output.

`PINT_SPELLING` is deliberately separate from `PINT_TO_SIUNITX`. That one
exists for names which are not valid TeX at all -- `\astronomical_unit` --
and raises for anything it does not recognise. This one is a spelling
preference, so an unlisted name is simply left alone. It also could not
have lived there: `_UNDERSCORE_MACRO` only matches names containing an
underscore, and `\meter` has none, so it never reached that map.

Whole macro names only, via `_MACRO`. A `str.replace` would rewrite the
head of any longer name that starts the same way; `\minute` and
`\metric_ton` are the neighbours within reach.

Eleven assertions across four test files encoded the American spelling
and now read British. The *inputs* stay as they were -- pint accepts
`'meter'` and several tests still feed it that, which is now the
interesting direction to test. `test_audit.py` also keeps a fixture whose
two translations spell the unit differently on purpose, since that is a
check on source text and not on rendering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`num`, `num_float` and `num_general` exist to put a bare number inside
`\num{}`. Handed a quantity they wrapped it again: `format_float`
delegates to the quantity's own `__format__`, which already returns a
complete `\num{0.0072}`, so `|ng` on a dimensionless quantity produced
`\num{\num{0.0072}}` -- not input siunitx can parse. Dimensional
quantities were worse still, `\num{\qty{5}{\metre}}` losing the unit
inside a number.

Latent until now because nothing passed a quantity to them, but it is a
trap rather than a curiosity: `28/enrichment` states its enrichment as a
percentage and needs it as a fraction in the algebra, and the obvious way
to write that hands a dimensionless quantity straight to `|ng`.

They now return such a value unchanged, which covers `QuantityRange`,
`QuantityList` and `QuantityProduct` too -- `\qtyrange` is a call of its
own and equally must not be wrapped. Precision still reaches the quantity,
so `|nf3` on 0.0072 gives `\num{0.007}` whether it arrives as a float or
as a quantity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`\qty[forbid-literal-units=false]{1}{glg}` says "one glg is the volume he
can swallow". The 1 is part of the definition of a unit the problem
invents; there is no version of the problem where it is a 2, so it is not
a parameter and extracting it into `values:` would name nothing.

`magnitudes` counted it all the same, which held the `values` verdict of
`28/john-doe` at `partial` after everything else in it had been migrated.
`23/bats` and `27/escalator` define two units apiece the same way.

Narrow deliberately: a magnitude of exactly 1, and the option that marks
the unit as invented. A real `\qty{1}{\metre}` is still counted, and so is
`\qty[forbid-literal-units=false]{5}{ugh}` -- `23/bats` asks for a mass in
its invented unit, and that number is an answer, not a definition. Both of
those are tests, alongside the one that the definition is skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hoisting an equation into `eq:` takes away per-language latitude on
purpose -- the physics is the same everywhere and copies drift -- but not
at any cost. A troll answer, an argument only prose carries, an answer
made of words: those are worth leaving alone, and the metas that leave
them say why.

Volume 28 was the first volume driven to zero on this, and all 22 of its
drifted equations turned out to be localised notation rather than
disagreement. Three questions decided every one of them, and they are
worth stating because the answers are not obvious and two of them point in
opposite directions:

A subscript that merely abbreviates a word follows the language, and is
upright; one the sentence declares is already right for its reader
whatever it abbreviates, and is italic, being an index. A term the
statement *defines* -- an invented unit, say -- follows the reader's own
statement rather than the prose it sits in, which is why Polish reads an
English solution using `łyk`. And derivations that genuinely differ are
not made to agree: `28/egging`'s Ukrainian takes another route, so the
four that agree share an entry and it keeps its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`24/venus` answers a mass fraction of `1.004e-4` and the booklet prints it as a
power of ten. Python's `g` -- and so `|g` -- only reaches for an exponent below
`1e-5`, so it gives `0.0001004` instead, and no precision argument will change
that. Hence a third format alongside `f` and `g`, complete with the same four
wrappers and the same ten precisions: `e`, `ne`, `ee`, `ae`.

The last letter of a filter name is the format and the first is the wrapper, so
`ee` is equals-exponential, next to `ef` and `eg`. `24/thermometer` uses `|e1` to
print the mercury coefficient the statement gives.

The exponent carries Python's two digits -- `1.004e-04` where a hand-written
source says `1.004e-4`. siunitx sets the two identically; verified.
`thermal_expansion_water` read 1.8e-4, which is mercury's coefficient, not
water's. Water's is 2.07e-4 at 20 degC -- and only means anything with a
temperature attached, since it is negative below 4 degC and 3.03e-4 at 40, so
the entry now says which one it is.

Nothing in the repository was reading it as water. `24/thermometer` was reading
it for a *mercury* thermometer and getting the right answer by coincidence, and
`26/heating` writes the same 1.8e-4 for an invented liquid whose specific heat
is 4000, not water's 4180 -- deliberately not water, and it keeps its literal.

So `thermal_expansion_mercury` joins it, and `24/thermometer` reads that, in the
derivation and in the statement that prints it. Its answer is unchanged at 8 ml.
The post-2010 accepted figure. Three phys problems already used 1361 or 1370,
and 23/yankees claimed to be quoting this table while printing 1361.
…ibling

`radius_earth` was the equatorial figure, 6378 km, and phys was split between it
and the mean 6371. Per the curator's ruling it is now the mean radius,
6371.0087714 km, with `digits: 4` so `.approx` prints the familiar 6371 km, and
`radius_earth_equatorial` (6378.137 km) joins it for the one problem that
genuinely circles the equator and for computing what a solver who reached for
the wrong radius would get.

`PhysicsQuantity.round` and a `round` global come with it -- `ceil` and `floor`
were there, `round` was not, and `20/equinox` rounds an arc length to tens of
kilometres.
The table rounded it to 917 while 27/antifreeze rounded it down to 916. Storing
the real value lets each caller round as it likes, and `.approx` still gives 917.
`jinja-string-escape` -- a LaTeX macro inside a Jinja string literal is being
eaten. Two layers take a backslash: YAML unescapes a double-quoted scalar, and
Jinja decodes the literal with `unicode-escape`. `.alias('f_{\text{min}}')`
therefore aliases `f_{<TAB>ext{min}}`, which sets as an italic "ext". It is
silent, because a tab is whitespace to TeX. Volume 29 carried thirteen such
lines since `\mathrm` was rewritten to `\text`: `\m` is not an escape and `\t`
is, so the rewrite turned a working spelling into a broken one and every
document still built.

`file-empty` -- a source file that exists and has nothing in it. A *missing*
file gets `\protectedInput`'s red box, so a hole in a booklet is loud; an empty
one renders as nothing and `make` exits 0. Volume 29 had three zero-byte files
in its `en/` directories.

The quiet halves are both real. `jinja-string-escape` looks only inside
`(§ … §)` and `derived:`, because `29/starlight` writes `t'_1 = 0 … t'_2 = T`
and those apostrophes are maths, not a string. `file-empty` exempts an empty
`answer.md` beside a written `answer-extra.md`: that is a documented idiom for
an answer needing translated words, `blocks/answer-body.jtex` suppresses the
joining comma for exactly that case, and eight problems across phys rely on it.
Without the exemption the check fired on all eight.

What is left after both exemptions, across all of phys: `02/rolling-ball` and
`22/hop`, each with an empty `answer.md` and no extra to carry it -- and `hop`'s
comma is not suppressed, so its answer cell opens with a stray one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per the curator's ruling. A Unicode vulgar glyph where the fraction is a
standalone value, and above all a mixed number -- which is what all 47 existing
uses already are, 42 of them in prose as `33\OneThird` or `666\TwoThirds`.
`\dfrac` in an answer file, where 251 of the repo's 309 already sit.
`\nicefrac` only inside `^{}` and `_{}`, where `\frac` would stack a full-size
fraction at script size. `\frac` everywhere else, prose included, and for a
coefficient in a formula: `\frac{1}{2} m v^2` stays.

`\nicefrac` stays defined regardless -- `math.tex` takes it as one of `\Drv`'s
fraction styles, so removing the macro would break the derivative notation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A `QuantityRange` in this repository is the set of answers a marker accepts --
every one of the nine that phys prints is an `answer-interval.md`, and there is
no other use. Rounding both ends to nearest can only shrink such a set, and
shrinking it rejects correct work.

Five of the nine were cut:

  28/ice-ice-baby  [75.6719, 77.1639]  printed 76 - 77       0.33 and 0.16 lost
  29/bouncy-v      [3.67749, 3.75]     printed 3.7 - 3.8     0.023 lost
  24/crane         [0.427521, 0.440973] printed 0.428 - 0.441
  28/avocado       [2.4644, 2.51327]   printed 2.46 - 2.51
  22/solar-shield  [4.6714, 5.6714]    printed 4.671 - 5.671

`bouncy-v` is the clearest: a solver who used the exact `g` computes 3.677 and
hands in 3.68, and the interval written to accept exactly that reader turned him
away. The other two passed only because a `|w` happened to be generous enough,
which is luck rather than design.

So the minimum is floored and the maximum ceiled at whatever precision the
format spec prints. With no precision nothing is dropped and it is a no-op.
An endpoint already on the grid must not be nudged off it, so the comparison
carries a relative tolerance -- `3.7` at `|f1` stays `3.7`.

Seven tests, one of them the property itself: at every precision from one to
four digits, the printed interval contains the computed one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Correcting `QuantityRange.__format__` to round an answer interval outward moved
five printed intervals, and `make` reported every `.tex` up to date. The
per-problem files had to be deleted by hand, and `answers.pdf` would otherwise
have been rebuilt from parts that were already wrong -- the same class as the
`lang` bug the Makefile already documents: a stale intermediate lets make skip
the rule and the build stays green.

`PIPELINE_SOURCES` is everything whose change can alter a rendered document --
the Jinja environment and its filters, the quantity classes, the constants
table, the i18n words, every module's renderer, and pandoc's wrapper: 96 files.
Sixteen rules across three modules, every one whose recipe calls `jinja` or
`pandoctex`, now list `$(PIPELINE_STAMP)`.

A stamp rather than the list itself, because make would otherwise compare
eighty-odd files against each of several thousand render targets.

Verified: `touch core/data/constants.yaml` re-renders 51 files, re-converts 50
and rebuilds `29/answers.pdf`; a second `make` reports it up to date; `poetry`
and `simple` have no such rule and are untouched; `seminar` and `naboj` targets
still parse.

The first build after this lands re-renders everything once, since the stamp
does not exist yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ontents

`booklet.tex` and `answers.tex` fall out of the `build-language` recipe, and
which `\protectedInput` lines they carry depends on which files each problem
has -- `blocks/answer-body.jtex` asks `path_exists` at render time. Nothing in
that rule's prerequisites named the problems at all.

So deleting `22/solar-shield/answer-interval.md` left the line in `answers.tex`,
and the answer sheet printed `Missing file …/answer-interval.tex` in a red box
with make reporting success. Adding a file was equally invisible.

The prerequisite is the problem *directories*, not the files in them: `wildcard`
only names what exists, so a deletion would shrink the list and leave every
remaining prerequisite older than the target. A directory's mtime moves when a
file inside it is added or removed, which is the event that has to invalidate
the booklet.

Verified: with the interval file gone, `make answers.tex` regenerates it without
the line, and volumes 22, 28 and 29 rebuild with no missing-file box where 22
had one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It is an error-severity check, and its docstring says it exists so nobody has to
compile a volume and read 46 pages to find the hole. It had never once run:
`read_unit` called `iterdir()` before any check did, so a problem the volume
lists without a directory raised `FileNotFoundError` and took the whole audit
down with it. `phys/03` lists one problem and has no `problems/` directory at
all, and the editor's `/audit` page aggregates at volume scope, so that page
would have five-hundred'd there.

The guard alone was not enough: returning an empty `Unit` made the problem
*present*, and `missing_from_disk` compares the listed ids against what is
present, so the check stayed silent for a second reason. Hence `Unit.exists`.

`meta-missing` now skips a unit with no directory. It has no meta.yaml either,
but saying so is true and misleading in the same breath -- it sends the reader
looking for a file when the whole directory is what is gone.

Four tests, including the two quiet halves: `meta-missing` still fires for a
directory that is really there, and one absent problem does not cost the other
thirty-nine their audit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven sources could change without anything rebuilding. The common shape: the
target that named them has an *empty* recipe, so make marked it out of date, ran
nothing, and left the stale file in place. `build-standalone` documented that
trap while it was still live three rules above it.

- Templates hang off the stamps, which have real recipes, as a wildcard rather
  than a list -- the list was wrong in both directions. `booklet.jtex`,
  `answers.jtex`, `solutions.jtex`, `cover.jtex`, `blocks/problem.jtex` and
  `blocks/solution.jtex` were named by no rule at all.
- `intro.jtex` moves to `build-language` for the same reason. Editing any intro,
  in any volume, in any language, changed no PDF -- which is how five volumes
  printed the literal keys of the authors mapping on page 1 and no rebuild would
  have shown it.
- `naboj_problem_dirs` globbed `problems/*` only, one level short of the
  `<language>/` directories where problem.md and solution.md actually live.
- The volume and competition metas were missing from `build-language`, so
  `29/meta.yaml` -- running order, authors, constants sheet -- could change
  without the booklet moving.
- seminar and scholar had the same template gap.

Each was probed before and after: touch the file, and the chain now runs to the
PDF instead of reporting it up to date.

Also a note beside `.DELETE_ON_ERROR:`, because deleting a source leaves its
built `.tex` orphaned and `\protectedInput` will happily include it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three are about vertical versus horizontal mode, and all three had spread
into the problem sources as idioms nobody could explain.

`\tightPictures` -- a picture that is the whole answer. The boxed problem number
is a titlesec [runin] title and needs a paragraph to sit in, but `\insertPicture`
opens a `center`, so the title was deferred to the next paragraph and the number
printed under its own picture; `center` then added a \topsep that floated it
further. Eleven `answer.md` files cancelled both by hand, with an escaped
non-breaking space and a \vspace tuned per drawing. Now `\leavevmode` opens the
paragraph and the drawing sits on the number's own line, raised so its top edge
is level and centred between two \hfill in whatever width the number leaves -- so
it cannot collide with the number however wide it grows, and nothing needs tuning
when a picture is rescaled.

`\answerJoin` -- the comma before an `answer-extra`. A literal `, ` is right only
while the answer is running text. Once the answer has ended its own paragraph
there is no line for the comma to sit on, so it opened a new one and printed
alone: `21/pv-to-vt-2` had a diagram, then a paragraph beginning ", Dbajte na
to". `\ifvmode` asks TeX which happened rather than guessing from the source,
which also retires the `file_size` test that existed to catch an empty
`answer.md`.

`footerBlock` -- the closing credits. `21/sk` split them four names from the end,
with `Obrazky` dangling on one page foot and the colophon at the top of the next.
A \vfill cannot fix that from either side: glue is a legal breakpoint, so TeX
breaks inside the block, and glue is discarded both at a break and at the head of
a fresh page. So the block is boxed, measured, and given a new page before the
fill if it will not fit -- with `\par\penalty-100` first, because \pagegoal and
\pagetotal otherwise still describe the page before and chem/03 read 744pt used
of 692pt on a page holding one structural formula.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An answer interval as a span rather than a tolerance, with the `result` /
`result_approx` pair and why the names go that way round; a picture as the whole
answer, and the punctuation that follows from it; the closing credits as one
block; and the corrected count of files ending a line with an escaped
non-breaking space, which is now one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It said the solution and answer.md print `result_approx`, full stop. `29/bolognese`
does not, and is right not to: it quotes rho, c and l from constants.yaml in its
prose, so it has to compute with those and print `result`. The invariant is that
every number shown comes from one chain; printing `result_approx` is the usual way
of satisfying it, not the rule itself.

Recorded with the three problems that legitimately take the other form, and with
`29/drenched` as the worked example of the usual one -- it names the air's density
only as a symbol, so nothing anchors it to the table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`disp` and `align` close at column 0 whatever indent the tag sits at, so a
hoisted equation inside a bullet put its `$$ {#eq:…}` flush left and broke out of
the list. That is why nothing in the repository had ever indented an equation, and
why `28/tetristor` had its three derivations written out in five solution files
instead of hoisted.

No new filter is needed. Jinja's own `indent` chains onto `disp` and its defaults
are exactly the ones wanted: `first=False`, because the tag's own indentation
already covers the opening `$$`, and `blank=False`, so no trailing whitespace is
invented. The filter table is merged with `|=`, so the builtins were there the
whole time.

Two tests, the pair the audit checks are held to: one that a bare `disp` closes
flush left, one that chained with `indent(4)` it aligns. The first is what makes
the second worth having -- without it the test would pass for the wrong reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y carry a meta at all

Two bugs, one of which hid the other.

`blocks:` is the new key: text stored exactly as written, namespaced, reached as
`(§ blocks.setup §)`. Nothing else could hold a gnuplot preamble -- `derived:`
evaluates its value as a Jinja *expression* and gives up at the second directive
with `chunk after expression`, `eq:` wraps its fragment in a labelled MathObject,
and `values:` passes a bare string through but then calls a block of axis settings
a given quantity. Namespaced rather than spread into the top level for the reason
`words` is: a block shadows nothing, so it may be called anything, and a test says
`blocks.g` and the constant `g` coexist. `blocks` joins RESERVED_NAMES, checked
against every meta under source/ first -- reserving `w` once broke eight problems.

The second bug is why moving the preamble to `values:` appeared not to help. A
problem's meta is read twice -- by the standalone renderer, and, for seminar only,
by `ContextProblem` when a whole round is built -- and `ContextProblem`'s schema
was closed and knew none of the four content keys. So a meta carrying `values:`
built fine on its own and failed the round with `Wrong key 'values'`. It now
extends `StandaloneContext._schema` instead of restating a subset of it, which is
also what gets it `blocks:` for free. Náboj never met this: its hierarchy has no
problem-level context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sesquideus
sesquideus merged commit 998a4ed into master Sep 13, 2026
1 check passed
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.

1 participant