Skip to content

Emit GLE that compiles: grid margins, log-axis limits, inverted axes - #26

Merged
BenHuddart merged 3 commits into
mainfrom
claude/compassionate-poitras-c11890
Aug 6, 2026
Merged

BenHuddart merged 3 commits into
mainfrom
claude/compassionate-poitras-c11890

Conversation

@BenHuddart

@BenHuddart BenHuddart commented Aug 6, 2026 •

Copy link
Copy Markdown
Owner

Three defects in what gleplot writes, all of which the existing test batteries structurally could not catch: they round-trip generated GLE as text and never compile it. Each was found by compiling with the real gle binary (4.3.10) and measuring the result.

1. feat: grid decoration margins are sized like the lone plot's

Figure._auto_margins_cm had three margin policies — one for a lone plot (hei-proportional, measured in G2.2) and two for grids (fixed centimetres, predating any measurement). A grid's margins bound the whole layout, so what has to fit in them is the decoration of its outermost row and column — exactly the decoration a lone plot carries. The two cases cannot want different numbers, and it now has one policy for every layout shape, sharing mode and grid size.

The old grid margin (left 1.0 cm plain / 1.5 cm shared) is less than the ~1.32 cm a rotated y-axis title plus tick labels like -0.5 occupy at 12 pt, so a 2×2 grid with axis labels drew off the left edge of the page.

Overhang past the page edge, in cm (positive = clipped off-page), 8×6 in figures with x/y titles and -1/-0.5 tick labels:

figure before after
2×2 grid L +0.476 L −0.217
1×2 / 3×1 grid L +0.476 L −0.217
2×2 at 18 pt L +1.214, B +0.429 L −0.326, B −0.475
2×2 sharex+sharey L −0.024 (0.2 mm slack) L −0.217
1×1 L −0.217 unchanged

This moves every existing multi-plot figure's frames — cells shrink slightly and shift right and up. That is the visible cost of the fix, and the reason this is a feat. Single-plot output is byte-identical. Five battery figures' geometry changed; the fixed-point invariant still holds with an empty exemption set.

2. fix: log-axis limits resolve to a range GLE will compile

GLE rejects a log axis whose range reaches zero, and it derives the range from the data when the script omits a bound — so an omitted bound is no escape. Each of these produced a file that could not be compiled at all:

ax.set_ylim(0, 400); ax.set_yscale("log")     # explicit, on x / y / y2
ax.plot(x, [0, 2, 3]); ax.set_yscale("log")   # autoscaled over a zero
ax.plot(x, [-1, 2, 3]); ax.set_xscale("log")  # ... or a negative

The reported case was the narrowest one: the secondary_yaxis builder in the golden and project batteries has been asking for set_ylim(0, 400, axis="y2") with a log y2.

The fix follows matplotlib, which keeps drawing rather than failing: mask non-positive values when autoscaling a log axis, replace a non-positive explicit bound with the smallest positive value plotted, and warn. gleplot additionally has to write the resolved range down, since leaving a bound out hands the decision back to GLE, which fails on the same raw data.

It stays out of the way of everything GLE already handles — positive data keeps its ordinary autoscaled range, a positive y2 with no limits keeps emitting a bare y2axis log, a file series keeps its autoscale (gleplot never read those numbers; inventing a range would be worse than the error it avoids), and linear axes are untouched.

3. feat: descending limits invert an axis, via GLE's negate

ax.set_ylim(3, 1) — matplotlib's idiom for inverting an axis — emitted yaxis min 3 max 1, which GLE refuses outright (illegal range for yaxis). So the normal way to draw depth below ground produced an unusable file.

GLE does support this: negate mirrors data coordinates inside an ascending range, and graph_ygraph() honours it, so text and guides placed via xg()/yg() follow the flip. Descending limits now emit an ascending range plus negate — what the caller asked for, rather than a normalization of it.

The descending pair stays the model, as in matplotlib: no separate inverted flag to serialize, get_ylim() returns what was set, and the recognizer recovers an inversion by reading negate back as a descending pair. GLEWriter._axis_direction is the single place model becomes GLE.

Log axes are the exception, and the reason is measurable: GLE's negate mirrors linearly and only then takes the logarithm, so yaxis min 1 max 100 log negate puts 1, 10 and 100 at 7.00, 6.88 and 1.00 cm — the decades smeared, not reversed. There is no way to spell a reversed log axis, so the inversion is dropped with a warning rather than drawn wrong. It composes with (2): set_ylim(3, 0) on a log axis comes out as min 1 max 3 log.

Reviewer notes

  • Measurement method. Off-page ink cannot be seen by a raster or PDF-bbox measurement — the page clips it away, which is how it survived. The script body is wrapped in a begin name block and its corner points printed (ptx/pty), giving the true ink box including ink outside the page. Axis direction is measured the same way, by printing GLE's own yg()/xg() mapping: on an inverted y axis, y=1 lands at 14.6 cm and y=3 at 1.27 cm. Those measurements are now the three tests/integration/test_* files; all skip without a GLE binary, so runners without one keep the model-level coverage in tests/unit/.
  • Guards are non-vacuous: 9/14 ink-box cases, 18/25 log-limit cases and 11/20 inverted-axis cases fail without their respective fix.
  • Two ratio assertions in test_axes.py were loosened. They compared numbers read back from emitted text at places=6; the writer emits 6 significant digits, so they were only ever good to ~1e-5 relative and passed by luck of the old margins being round numbers. They now go through an assertEmittedAlmostEqual helper that says so.
  • 39 of 40 battery figures now compile with real GLE (the 40th, passthrough_and_metadata, carries a deliberately unmodelled directive) and none has off-page ink. Full suite: 2149 passed, 2 skipped.

Known gap, deliberately not fixed here

Inner-panel gaps are still fixed centimetres (default_hspace_cm = 1.5) and carry the same decoration an outer margin does. That clears the measured requirement at 12 pt but not at 18 pt, where inner panels need subplots_adjust(wspace=…). Noted in the code.

🤖 Generated with Claude Code

BenHuddart and others added 3 commits August 6, 2026 06:29
A multi-plot grid reserved fixed centimetre margins around the whole
layout (left 1.0 cm plain, 1.5 cm shared) -- inherited from before any
of the decoration overflow was measured. A rotated y-axis title plus
4-character tick labels ("-1", "-0.5") occupy 3.11 hei, i.e. ~1.32 cm at
the default 12 pt, so a 2x2 grid with axis labels drew its leftmost
decoration off the left edge of the page. Measured against GLE 4.3.10,
that figure's ink box started at x = -0.32 cm; at 18 pt it was off both
the left and bottom edges. The workaround was subplots_adjust(left=...).

G2.2 already measured and fixed this for the 1x1 case with margins
expressed in units of the text height hei. What has to fit in a grid's
margins is the decoration of its outermost row and column -- the same
decoration a lone plot carries -- so the two cases cannot want different
numbers. _auto_margins_cm now has ONE policy for every layout shape:
the same hei-proportional constants, whatever the grid's dimensions and
whether or not its axes are shared. The title and y2 conditionals become
figure-wide (any axes), which over-reserves only when the titled or
y2-bearing axes is not on the edge that needs the room.

This moves every existing multi-plot figure's frames: cells shrink
slightly and shift right and up. Single-plot output is unchanged.
Inner-panel gaps are untouched and remain fixed centimetres (noted in
_layout_rects: 1.5 cm clears the decoration at 12 pt but not at 18 pt).

Verified by compiling every golden- and project-battery figure with GLE
4.3.10 and measuring the true ink box -- the script body wrapped in a
"begin name" block and its corner points printed, which sees ink that
falls OFF the page where a raster or PDF bbox would clip it away. No
battery figure has off-page ink after the change (nor did any before:
none of them carries the defective shape, which is why this survived).
That measurement is now a test, tests/integration/test_ink_on_page.py,
covering every grid shape, sharing mode and font size; 9 of its 14 cases
fail without this commit. Model-level assertions that need no GLE binary
are in tests/unit/test_axes.py::TestDecorationMargins.

Two ratio assertions in test_axes.py's grid-ratio tests were comparing
numbers read back from emitted text at places=6. The writer emits 6
significant digits, so those comparisons are only good to ~1e-5 relative
however exactly the model divides; they passed only because the old
margins happened to be round numbers. They now go through an
assertEmittedAlmostEqual helper that says so.

The fixed-point invariant is unaffected: writer -> parse -> writer stays
byte-identical for every builder, exemption set still empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GLE refuses to compile a log axis whose range reaches zero -- "Error:
illegal range for log axis: min = 0 max = 3" -- and it derives that range
from the data whenever the script omits a bound. An omitted bound is
therefore no escape: `yaxis log` over data containing a zero is rejected
exactly like `yaxis min 0 log` is.

gleplot emitted both. Every one of these produced a file that could not
be compiled at all:

  ax.set_ylim(0, 400); ax.set_yscale("log")     # explicit, x / y / y2
  ax.plot(x, [0, 2, 3]); ax.set_yscale("log")   # autoscaled over a zero
  ax.plot(x, [-1, 2, 3]); ax.set_xscale("log")  # ... or a negative

The reported case was the narrowest one: the `secondary_yaxis` builder in
the golden and project batteries asks for `set_ylim(0, 400, axis="y2")`
with a log y2. Nothing noticed, because the batteries round-trip their
GLE as text and never compile it.

Fix, following matplotlib, which keeps drawing rather than failing: mask
the non-positive values when autoscaling a log axis, and replace a
non-positive explicit bound with the smallest positive value plotted,
warning about what was used instead. gleplot additionally has to WRITE
the resolved range down, because leaving a bound out would hand the
decision back to GLE, which would fail on the same raw data.

Figure._apply_log_limits does that for x, y and y2, and only when it has
to. It stays out of the way of everything GLE already handles:

  - positive data keeps the range ordinary autoscale gave it;
  - a positive y2 with no limits keeps emitting a bare `y2axis log`;
  - a file series keeps its autoscale -- this process never read those
    numbers, and inventing a range for them would be worse than the
    error it avoids;
  - linear axes are untouched: zero is a fine limit on one.

Only the offending bound is replaced, so set_ylim(0, 400) keeps its 400,
and the repaired limits are stored back onto the axes (as ordinary
autoscale already does), so a re-save finds nothing to repair and warns
no further. The repair runs before shared-axis synchronization, so a
shared log axis is unified over ranges that are already positive rather
than each panel getting its own guess.

Supporting changes: _get_data_xlim/_get_data_ylim take positive_only
(identity when off, so every non-log axis is bounded by exactly the
values it always was); a new _get_data_y2lim bounds the series actually
drawn against y2; and the duplicated autoscale blocks in the single-plot
and multi-plot emission paths become one _resolve_axis_limits.

Verified against GLE 4.3.10: all eight previously-uncompilable shapes now
build, and 39 of the 40 golden/project battery figures compile (the 40th,
passthrough_and_metadata, carries a deliberately unmodelled directive).
tests/integration/test_log_axis_compiles.py is the acceptance bar the
batteries could not provide, with model-level coverage of the policy --
including its restraint -- in tests/unit/test_log_limits.py; 18 of the 25
new cases fail without this commit. The three `secondary_yaxis` builders
now ask for a legal 50..400, so the batteries exercise the y2-log writer
path with something GLE can draw.

Not fixed, and unrelated except by family: GLE rejects a DESCENDING range
on any axis ("illegal range for yaxis: min = 3 max = 1"), so set_ylim(3,
1) -- matplotlib's way of inverting an axis -- still emits a script that
will not compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ax.set_ylim(3, 1)` is matplotlib's idiom for inverting an axis. gleplot
passed the pair straight through as `yaxis min 3 max 1`, which GLE
refuses to compile at all: "Error: illegal range for yaxis: min = 3
max = 1". So the normal way to draw depth below ground, or magnitudes,
silently produced an unusable file.

GLE does support this. `xaxis`/`yaxis negate` (graph.cpp's NEGATE
keyword) mirrors data coordinates inside an ascending range -- fny() in
graph2.cpp, and graph_ygraph() with it, so the xg()/yg() coordinates of
text and guides follow the flip too. Descending limits now emit an
ascending range plus `negate`, which is what the caller asked for rather
than a normalization of it.

The descending pair stays the model, exactly as in matplotlib: there is
no separate inverted flag to serialize, get_ylim() keeps returning what
was set, and the recognizer recovers an inversion by reading `negate`
back as a descending pair. GLEWriter._axis_direction is the single place
the model becomes GLE, so every emission path (single, grid, broken)
picks it up at once.

Three details worth their code:

- Log axes cannot be inverted. GLE's negate mirrors the value LINEARLY
  and only then takes the logarithm (fnAxisX in axis.cpp), which does
  not reverse the decades, it smears them: measured against GLE 4.3.10,
  `yaxis min 1 max 100 log negate` puts 1, 10 and 100 at 7.00, 6.88 and
  1.00 cm. There is no way to spell a reversed log axis, so
  _normalize_inverted_log_limits drops the inversion and says so, rather
  than drawing it wrong. It runs before the positivity repair added in
  "fix: resolve log-axis limits to a range GLE will compile", so the two
  compose: set_ylim(3, 0) on a log axis comes out as min 1 max 3 log.
- A hand-written `negate` with only one bound (or none) cannot be
  expressed as a descending pair, so the recognizer leaves it in
  passthrough with the other unmodelled axis options instead of dropping
  the inversion on the floor.
- Shared axes unify a SPAN, not a limit order: _synchronize_x/y_limits
  now read each panel's extent through min/max and carry an inversion
  over to the shared result, so sharing an axis cannot flip a panel back
  upright.

Verified against GLE 4.3.10 by compiling and then reading GLE's own
yg()/xg() mapping back out: on an inverted y axis, y=1 lands at 14.6 cm
and y=3 at 1.27 cm. tests/integration/test_inverted_axis_compiles.py
measures that; tests/unit/test_inverted_axes.py covers the model, the
save -> parse -> save fixed point (byte-identical, with `negate` really
in the file) and the log and sharing interactions. 11 of the 20 new
cases fail without this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@BenHuddart BenHuddart changed the title Fix off-page grid margins and uncompilable log-axis limits Emit GLE that compiles: grid margins, log-axis limits, inverted axes Aug 6, 2026
@BenHuddart
BenHuddart merged commit 30a9f47 into main Aug 6, 2026
4 checks passed
@BenHuddart
BenHuddart deleted the claude/compassionate-poitras-c11890 branch August 6, 2026 06:45
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