Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
21b6b8c
A missing translated word fails the render instead of warning
sesquideus Sep 30, 2026
6e23440
VALID_TAGS: `troll` needs something to collapse, not just to be easy
sesquideus Sep 30, 2026
e02a3df
bug fix
084intheImpala Sep 30, 2026
a8cdfc0
A hyphen that repeats itself at a line break, for cs, sk and pt
sesquideus Oct 1, 2026
f08b091
Repeat the hyphen in Polish and Spanish too, and exempt Spanish prope…
sesquideus Oct 1, 2026
b9aa0c7
A hyphen joining a digit to a word must not break
sesquideus Oct 1, 2026
8145251
A problem statement never numbers or labels its equations
sesquideus Oct 1, 2026
428fd5d
The unnumbered-statement rule belongs to the module, not to core
sesquideus Oct 1, 2026
d3eb331
State equation numbering in both directions, per module
sesquideus Oct 1, 2026
6fc4e97
Render `.tikz` and `.svg` through Jinja, as `.gp` already was
sesquideus Oct 1, 2026
b9b70fa
`|txt`: a quantity as plain text, for the formats with no TeX
sesquideus Oct 1, 2026
9281460
`|txt` renders a range, a list and a product too
sesquideus Oct 1, 2026
2fbbda5
`|txt` rounds a range to nearest; outward is for the answer, not for …
sesquideus Oct 1, 2026
cb34cdc
Document the spaced-connective macros
sesquideus Oct 2, 2026
c1d25df
A reference for the whole Jinja vocabulary, generated and executed
sesquideus Oct 2, 2026
bb02a74
The highlighter names what it colours, for the editor to look up
sesquideus Oct 2, 2026
c72c093
The editor shows the reference on hover, and on F1
sesquideus Oct 2, 2026
f3c39af
The schemas actually validate now: `{K: V}`, not `dict[K, V]`
sesquideus Oct 2, 2026
bc52b14
The reference explains the vocabulary, not this repository
sesquideus Oct 2, 2026
c621cd0
A popup example is one line, however long it is
sesquideus Oct 2, 2026
50a7f0b
Better Avogadro and molar mass for water
sesquideus Oct 2, 2026
7701250
A figure may not drift out of its problem
sesquideus Oct 3, 2026
01d8f25
The editor's hover shows what a fragment renders to
sesquideus Oct 3, 2026
e494023
Test the hand-in codec, through its own JavaScript
sesquideus Oct 3, 2026
8155f52
The codec test reads the codec out of the page
sesquideus Oct 3, 2026
c71b45a
An answer file holds the answer, not a sentence about it
sesquideus Oct 3, 2026
8732cd8
test_submission_codec: evaluate the codec under strict mode too
sesquideus Oct 3, 2026
91a13b9
CLAUDE.md: the answer rule says why a bare symbol is harmful, and tha…
sesquideus Oct 3, 2026
2815b07
audit: report a value's symbol spelled out instead of asked for
sesquideus Oct 3, 2026
28a0496
CLAUDE.md: a symbol: may carry a words: tag, one level deep
sesquideus Oct 3, 2026
7ecdea1
A render settles instead of stopping at exactly two passes
sesquideus Oct 3, 2026
9bff9df
aligned-shorthand: the fix is to hoist it, not to rewrite it in place
sesquideus Oct 4, 2026
9ee792d
The colophon credits the competition, not always the physics team
sesquideus Oct 4, 2026
f52aa44
fixing formatting issues
084intheImpala Oct 7, 2026
ee122d1
Merge branch 'master' of https://github.com/084intheImpala/dgs
084intheImpala Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .claude/skills/dgs-editor/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,37 @@ uv run python tools/editor/app.py [--port 5001]
leaderboard, tag distribution, files by language, and a verdict per problem for
translations, equation de-duplication, pictures and `values:` extraction.

**The hover answers two questions, and they come from different places.** Point at a name in
either pane — a filter, a global, a quantity attribute, a `meta.yaml` key — and the popup shows
its entry from `core/builder/reference.py`, the same table `docs/filters.md` is generated from.
F1 asks the same of whatever is under the caret, since a hover is no use to someone mid-line.
Under that, for anything evaluable, the popup shows **what it renders to here**: the tag's own
output for this problem in this language, which is the question the table cannot answer.

Two things are evaluable, and `evaluableSpans` in `static/highlight.js` is the one definition of
both: a `(§ … §)` tag, evaluated as written, and an entry's own name in the meta, for which the
tag that would reach it is synthesised — hovering `snell` under `eq:` answers for
`(§ eq.snell §)`, `v0` under `values:` for `(§ v0 §)`, `air` under `words:` for
`(§ words.air §)`. The deeper level of `values:` and `words:` is not an entry — `magnitude:` is
a reference key and `sk:` is a language — so neither gets one.

`POST /api/evaluate` does the work, in-process and writing nothing. It reads the **buffers** out
of the request rather than the files, because the whole point is to answer for the text on
screen, which is usually unsaved; and it shares `build_render_context` and `render_twice` with
`core/builder/renderer.py`, so it cannot answer differently from what `make` would write —
including whether an `eq:` display carries its `{#eq:…}` label, which depends on the open tab
through the module's `equation_numbering`. A test asserts that agreement rather than trusting it.

A meta that does not validate, or a `derived:` entry that will not evaluate, is reported in the
popup instead of a value. That is deliberate and it is strict: the build would refuse the same
meta, and a popup that quietly accepted one it will not is the single disagreement this is meant
to prevent.

One request per buffer, not one per hover. Building the context evaluates every `derived:` entry
and costs about the same for forty fragments as for one, so the first hover asks for everything
in the pane and the rest are map lookups; a keystroke in either pane, a different tab or a
different problem throws the lot away.

**It degrades rather than failing.** The three output tabs are the pipeline's three stages --
Rendered Markdown is Jinja and needs only make, TeX adds pandoc, the PDF adds all of TeX Live and
the fonts -- and `tools/editor/capabilities.py` probes for each at startup. A stage this machine
Expand Down
468 changes: 453 additions & 15 deletions CLAUDE.md

Large diffs are not rendered by default.

28 changes: 19 additions & 9 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@ MAKEFLAGS += --no-builtin-rules --no-builtin-variables

SUPPORTED_LANGUAGES = sk en cs hu pl es de fr ru fa uk pt

# Language a picture (`.tikz`, `.gp`) is rendered in. It is not cosmetic: it selects the locale
# the Jinja tags format numbers with, so getting it wrong writes a decimal point where the Slovak
# figure wants a comma.
# Language a picture (`.tikz`, `.svg`, `.gp`) is rendered in. It is not cosmetic: it selects the
# locale the Jinja tags format numbers with, so getting it wrong writes a decimal point where the
# Slovak figure wants a comma.
#
# Resolved per picture, most specific first:
# 1. an explicit `make lang=en ...`, which always wins;
# 2. the language directory the picture sits in, for a picture that belongs to one translation
# (`problems/johan-august/sk/puzzle.tikz`);
# 2. the language directory the picture sits in, for a picture that belongs to one translation;
# 3. `$(lang)` below, for the usual case of a picture at the problem level, shared by every
# translation and with nothing in its path to infer from.
#
# Case 2 is kept and currently matches nothing. It used to name `johan-august/sk/puzzle.tikz`,
# which was the only picture in the repository inside a language directory and has been moved up
# beside its meta. Now that a picture is a template it can ask for `(* words.x *)` itself, so a
# per-language copy of a drawing has no reason to exist and the case should stay empty.
#
# Both rules already referenced `$(lang)`, but nothing ever set it, so they passed an empty
# argument and died on `invalid choice`. That stayed hidden because a stale intermediate in
# `build/` lets make skip the rule -- `make -B` on any `.tikz.tex` shows it.
Expand Down Expand Up @@ -189,8 +193,13 @@ build/%.tex: \
@exit 1

# Standalone TeX file from .tikz.tex
# From `render/`, not from `source/`: a picture is a Jinja template like everything else, so the
# tags in it are expanded by the per-module `render/<mod>/%.tikz` rule before `standalone.jtex`
# wraps the result. Reading `source/` here is what used to make a tag in a `.tikz` reach LaTeX
# verbatim -- `standalone.jtex` splices the content in as `(* content *)`, and Jinja substitutes a
# variable's value literally rather than rendering it again.
build/%.tikz.tex: \
source/%.tikz \
render/%.tikz \
core/templates/standalone.jtex
@mkdir -p $(dir $@)
./standalone.py $(call pathlang,$*) $< $@
Expand All @@ -201,7 +210,7 @@ build/%.py: source/%.py
$(call _copy,Python)

# Convert SVG image to PDF (for XeLaTeX output)
build/%.pdf: source/%.svg
build/%.pdf: render/%.svg
@echo -e '$(c_action)[rsvg-convert] Converting $(c_filename)$<$(c_action) to $(c_extension)PDF$(c_action) file $(c_filename)$@$(c_action):$(c_default)'
@mkdir -p $(dir $@)
rsvg-convert --format pdf --keep-aspect-ratio --output $@ $<
Expand Down Expand Up @@ -248,13 +257,14 @@ build/%.dat: source/%.dat
$(call _copy,dat)

# Output PNG from SVG (for web)
output/%.png: source/%.svg
# From `render/` for the same reason the PDF rule is: the web would otherwise ship the tags.
output/%.png: render/%.svg
@echo -e '$(c_action)[rsvg-convert] Converting SVG file $(c_filename)$<$(c_action) to PNG file $(c_filename)$@$(c_action):$(c_default)'
@mkdir -p $(dir $@)
rsvg-convert -f png -h 500 -a -o $@ $<

# Copy SVG (for web)
output/%.svg: source/%.svg
output/%.svg: render/%.svg
@echo -e '$(c_action)[rsvg-convert] Converting SVG file $(c_filename)$<$(c_action) to PNG file $(c_filename)$@$(c_action):$(c_default)'
@mkdir -p $(dir $@)
rsvg-convert -f svg -h 500 -a -o $@ $<
Expand Down
150 changes: 138 additions & 12 deletions core/audit/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,11 +459,15 @@ def word_missing(sources):
"""
A word a source asks for and its language has not got.

The renderer no longer stops for this: it boxes the word in red and carries on, so the
booklet still builds with the hole marked. That box is not the safety net -- volume 19
printed `Missing file …onion…!` on page 42 in every language for years while `make` stayed
green, and the output already carries some 1500 such boxes, so one more does not stand out.
This check is the safety net, and it reads the sources rather than the PDF.
The renderer boxes the word in red and carries on to the end of the render, so the booklet
still builds with every hole marked -- and then fails, so nobody ships it. That box alone was
never the safety net: volume 19 printed `Missing file …onion…!` on page 42 in every language
for years while `make` stayed green, and the output already carries some 1500 such boxes, so
one more does not stand out.

This check is the half that does not need a render. It reads the sources, so it finds the gap
before a build is attempted and in every language at once, which is what an editor wants; the
render's own failure is what stops a gap reaching a PDF.
"""
for unit in sources.unit_list:
unit_words = (unit.meta or {}).get('words') or {}
Expand Down Expand Up @@ -594,21 +598,34 @@ def aligned_shorthand(sources):
form that `\begin{aligned}` says in standard LaTeX, and the rewrite is line-oriented, so it is
fragile in exactly the ways a line-oriented rewrite always is.

`|align` stopped emitting it -- `MathObject` writes the longhand directly now, the same shape
as `arr`. This reports the 351 places that still write it by hand. The rewrite regexes stay
until those are gone, so nothing is broken meanwhile; a finding here is a file to convert, not
a file that fails.
**The fix is to hoist it, not to rewrite it in place.** The block moves into `eq:` and the
source calls `(§ eq.<key>|align('.') §)`; `MathObject` writes the longhand, which is exactly
what the rewrite was producing. Writing `\begin{aligned}` into the source by hand trades one
local dialect for a block that is still a per-language copy -- the duplication this repository
spends most of its effort removing. A block that stays in the source stays as `$${ … }$$`.

So a finding is a file to hoist, not a file that fails, and the rewrite regexes stay until the
last of them is gone.

**Only a block that already carries a `{#eq:…}` label can go**, because the key becomes the
label: hoisting an unlabelled one would *give* a solution's display a number it did not have,
which renumbers everything after it. Those wait on `solution-unlabelled`, which is the check
that asks whether they should have been numbered in the first place.

This check used to say the opposite -- it reported `\begin{aligned}` and asked for `$${`.
"""
for unit in sources.unit_list:
for lang, name, text in unit.files():
for m in blocks_of(text):
if m['open']:
label = RE_LABEL.search(m['tail'] or '')
key = label['name'].split(':', 1)[-1] if label else None
how = (f"hoist it into `eq:` as `{key}` and call `(§ eq.{key}|align('.') §)`"
if key and RE_EQ_KEY.match(key)
else 'it carries no `{#eq:…}` label, so hoisting it would give it a '
'number; label it first, or leave it')
yield Finding('aligned-shorthand', 'warning',
'the `$${ … }$$` shorthand is deprecated: write '
'`\\begin{aligned}` and `\\end{aligned}` inside a plain `$$` '
'block, which is what the convertor rewrites it into anyway',
f'the `$${{ … }}$$` shorthand is deprecated -- {how}',
unit.path, unit.label(lang, name), line_of(text, m.start()))


Expand Down Expand Up @@ -1442,6 +1459,115 @@ def hoistable_inline(sources):
yield Finding('hoistable-inline', 'info', message, unit.path, ' '.join(places))


#: `$X = (§ x §)$`: a symbol, a relation and a value tag, where the symbol is the one the
#: quantity declares. The whole span is `(§ x.eq §)`.
RE_VALUE_EQUATION = re.compile(r'(?P<symbol>[^=\n]{1,24}?)\s*=\s*'
r'\(§\s*(?P<name>[A-Za-z_][A-Za-z_0-9]*)\s*§\)')


def declared_symbols(unit):
"""name -> symbol, for the `values:` entries that declare one."""
values = (unit.meta or {}).get('values')
if not isinstance(values, dict):
return {}
return {str(name): entry['symbol'] for name, entry in values.items()
if isinstance(entry, dict) and isinstance(entry.get('symbol'), str)}


def inline_bodies(unit):
"""Every inline span in the problem, stripped, each real file counted once."""
seen, out = set(), []
for lang, name, text in unit.files():
place = unit.real_label(lang, name)
if place in seen:
continue
seen.add(place)
out += [(place, m.group(1).strip()) for m in RE_INLINE.finditer(text)]
return out


def has_decorated_sibling(symbol, bodies):
r"""
`$F_M$` written beside a symbol `F`.

Then the letter is a family rather than a name, and which value a bare `$F$` means is an
author's call: `24/crane`'s statement pulls with `F` while its solution calls the same force
`F_M`. Ten problems in phys are like this and none of them wants a tag.
"""
pattern = re.compile(re.escape(symbol) + r"[_^']")
return any(pattern.match(body) for body in bodies)


@check('value-equation-literal', 'info', 'A value written as `symbol = tag` rather than `.eq`')
def value_equation_literal(sources):
r"""
`$X = (§ x §)$` where `values.x` already declares the symbol `X`.

Both halves of that span are facts the meta holds. The symbol is one -- a symbol is declared
on the quantity, not written beside it -- and so is the relation, which `eq` picks by
`prints_exactly` rather than taking on trust: `const.speed_sound` prints back perfectly and
is still not the speed of sound, and only the declaration knows that. Written out, a value
that stops being exact keeps its `=` and nobody finds out.

So it is `(§ x.eq §)`, keeping the dollars -- `eq` returns a string and `|inl` raises on one.
A span that prints at a precision is `(§ x|ef(2) §)` or `(§ x|af(2) §)`, which assert the
relation outright, and is not reported here: choosing between those two is a judgement about
the value and the check has no opinion on it.

493 sites in phys were of this shape before the sweep that added this check, and every one
of them rendered byte-identically afterwards, which is what makes it mechanical.
"""
for unit in sources.unit_list:
if ignored(unit, 'value-equation-literal'):
continue
symbols = declared_symbols(unit)
if not symbols:
continue
found = defaultdict(list)
for place, body in inline_bodies(unit):
m = RE_VALUE_EQUATION.fullmatch(body)
if m and symbols.get(m['name']) == m['symbol']:
found[m['name']].append(place)
for name, places in sorted(found.items()):
yield Finding('value-equation-literal', 'info',
f"`${symbols[name]} = (§ {name} §)$` is `$(§ {name}.eq §)$` -- the "
f"symbol is on the quantity and `eq` picks the relation",
unit.path, ' '.join(sorted(set(places))))


@check('value-symbol-literal', 'info', 'A declared symbol spelled out rather than `.s`')
def value_symbol_literal(sources):
r"""
`$X$` on its own, where `values.x` declares `X` as its symbol.

`symbol:` states that this symbol names that quantity, so the prose should ask for it rather
than keep a second copy. Renaming the symbol then reaches the prose, which is the whole point
of declaring it; spelled out, the two drift and nothing says so.

Only a span that is *nothing but* the symbol is reported. A symbol inside a longer span is
a worse bet, not a better one: the span is usually a relation, and a tag inside a literal
copy of it makes three spellings out of two. `hoistable-inline` is the check for those.

`has_decorated_sibling` is the one carve-out, and it is not hypothetical -- see its docstring.
"""
for unit in sources.unit_list:
if ignored(unit, 'value-symbol-literal'):
continue
symbols = declared_symbols(unit)
if not symbols:
continue
bodies = inline_bodies(unit)
plain = [body for _, body in bodies]
for name, symbol in sorted(symbols.items()):
places = sorted({place for place, body in bodies if body == symbol})
if not places or has_decorated_sibling(symbol, plain):
continue
yield Finding('value-symbol-literal', 'info',
f"`${symbol}$` is `$(§ {name}.s §)$` -- the symbol is declared on the "
f"quantity",
unit.path, ' '.join(places))


@check('file-empty', 'error', 'A source file exists but has no content')
def file_empty(sources):
"""
Expand Down
20 changes: 18 additions & 2 deletions core/builder/context/context.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import abc
import copy
import io
import logging
import pprint
from pathlib import Path
Expand Down Expand Up @@ -105,12 +106,27 @@ def load_yaml(self, path: Path):
log.debug(f"Loading {c.name(self.__class__.__name__)} metadata from {c.path(path)}")
try:
with open(path, 'r') as f:
contents = yaml.load(f, Loader=UniqueKeyLoader)
self._data = copy.deepcopy(self._defaults) | ({} if contents is None else contents)
return self.load_string(f.read(), where=path)
except FileNotFoundError:
log.critical(c.err(f"[FATAL] Could not load YAML file {c.path(path)}"))
raise

def load_string(self, text: str, *, where: Path | str = '<yaml>'):
"""
The same, from text that is not on disk yet.

`tools/editor` holds the meta in a textarea and previews what a tag evaluates to while it
is being typed, so the context it builds has to come from the buffer. Reading the file
instead would preview the last save, which is the one thing a live preview must not do.

`where` is only for the messages, and it has to be passed: PyYAML takes a mark's name off
the stream's own `name`, so parsing a bare string names the error `<unicode string>` and
`DuplicateKeyError` stops saying which file has the duplicate.
"""
stream = io.StringIO(text)
stream.name = str(where)
contents = yaml.load(stream, Loader=UniqueKeyLoader)
self._data = copy.deepcopy(self._defaults) | ({} if contents is None else contents)
return self

def ident(self, *path: Any) -> tuple[Any, ...]:
Expand Down
11 changes: 9 additions & 2 deletions core/builder/context/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@
class FileContext(Context):
"""
A Context that is loaded from a file (currently only YAML).

`text` overrides what the file holds while keeping `path` as the identity -- an unsaved
buffer, which is what `tools/editor` previews from. Everything downstream sees an ordinary
context, so a preview and a build cannot read the meta differently.
"""
def __init__(self, new_id: str, path: Path, **defaults):
def __init__(self, new_id: str, path: Path, *, text: str | None = None, **defaults):
super().__init__(new_id, **defaults)
self.load_yaml(path)
if text is None:
self.load_yaml(path)
else:
self.load_string(text)
Loading