Skip to content

Corner identity from the grid's declared topology - #15

Draft
hdrake wants to merge 13 commits into
topology-driven-neighborsfrom
corner-node-topology
Draft

Corner identity from the grid's declared topology#15
hdrake wants to merge 13 commits into
topology-driven-neighborsfrom
corner-node-topology

Conversation

@hdrake

@hdrake hdrake commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Targets topology-driven-neighbors (the branch behind MOM6-community#47), not master.

Supersedes #11, #13 and #14, which answered the same question three ways. It carries #14's q contract and its fold fixture forward; the mechanism underneath is different.

The problem

One physical vorticity corner can be stored under several native (face, j, i) indices. MOM6-community#47 re-derives which ones in five separate places, with four different tolerances, and mostly from geographic coincidence:

where how identity was decided
section.infer_grid_path geodesic coincidence, 1e-3 m
transports.uvindices_from_qindices (multi-tile) _OuterTopology.node_id — exact
transports._insert_seam_twins (single-tile) geodesic coincidence, 1e-3 m
transports.uvindices_from_qindices (both) geodesic coincidence again
regionate.boundaries (single-tile) round(xyz, 9) hash bucket, ~6.4 mm

Only one was exact. Each new grid shape got absorbed by adding a special case to whichever of the five it hit.

Geographic coincidence is the wrong instrument. On the MOM6 tripolar example grid, 260 corner points share a stored position, one of them 82 times — the bipolar pole, at exactly 0.0 m separation. Those are genuinely distinct corners. Conversely a grid's stored coordinates can simply be wrong.

What this does

Identity is the quotient of the corner lattice by the affine integer maps the grid declares — a periodic axis' (J, I) ≡ (J, I + Nxc), a fold's seam row glued to its own mirror (taken from xgcm's own _seam_partner_indices, so a fold means the same thing here as when xgcm pads across one), each face_connections gluing with its rotation and reversal. Labelled with scipy.sparse.csgraph.connected_components.

A section then becomes a sequence of physical corner nodes; native indices are a projection used for reporting and for indexing velocity arrays. The walk moves on that graph, so a periodic wrap, a bipolar fold and a rotated tile seam are all just edges: no direction vocabulary, no wrap-around arithmetic, and no test for whether two indices are the same corner. Faces are emitted per node pair, so "these two steps are the same corner" is an integer comparison rather than a distance.

New: sectionate/topology.py, sectionate/walk.py. Gone: _OuterTopology, build_neighbor_maps, _multitile_padded_maps, _insert_seam_twins, the four-direction walk, and every coordinate tolerance in the identity path. gridutils.py goes 1397 → 216 lines; the suite runs in 10 s rather than 29.

Geometry keeps two jobs it is good at — the walk's distance metric, and validate_positions, which checks the topology and never sets it.

Bugs this fixes

  • Reversed tile gluings were resolved wrongly. Deriving a seam's index map from a padded halo inherits a halo that does not carry the declared reverse. On a cubed sphere that merged two opposite vertices into one corner. Both cube fixtures now close on the polyhedron: 6N² + 2 corners with exactly 8 three-valent vertices.
  • A fold's duplicated row did not cancel. A fold stores one edge twice, in mirrored frames and with opposite signs; the velocity and the direction of travel could come from different copies, so a section running out and back along the seam came out at −0.86× and +2.31× the right answer instead of ±1.
  • lons_c disagreed with the emitted i_c at a seam, which also broke the longitude continuity regionate's antimeridian logic depends on.
  • Paths were not minimal. A geodesic from (0, 40 °N) to (120, 40 °N) on a 2° grid took 83 corners; it takes 81, which is exactly the distance covered — 60 zonal plus 10 up and 10 back down, one cell at a time.

Things that now work

  • Corners stored on no face — a cubed sphere stores 6N² corners for 6N² + 2 points — are real corners the walk can cross. They have no native index and asking for one raises rather than returning a sentinel (-1 is a valid numpy index). Sections route around them where a stored alternative exists.
  • A grid whose declared fold its coordinates do not carry is resolved correctly, and the disagreement is reported rather than silently steering identity.
  • Identifications face_connections cannot express can be declared. Its schema maps a whole tile edge to a whole tile edge, so it cannot describe LLC90's southern boundary, which folds onto itself across four tile edges. Declaring it takes LLC90 from 105481 nodes to 105302 and un-stored corners from 181 to 78 — exactly what the coordinate matching used to produce — and validate_positions() returns 0.0 m, so the archived coordinates confirm the declaration without having decided it.

Performance

before after
MOM6 tripolar 1.7 s 0.01 s
ECCO LLC90 3.7 s 0.05 s
¼° global (1.56 M corners) ~60 s (extrapolated) 1.5 s

Identity is O(seam) and labelling O(cells), both vectorized.

API

No backwards compatibility, by agreement. grid_section always returns (i_c, j_c, f_c, lons_c, lats_c) — a single-tile grid is one face, so f_c is zeros. GriddedSection gains n_c, the physical corner each point is, always derived and never persisted (a node id is an artefact of construction order, so a stored one could disagree with the grid it is loaded against).

Known limitation

A closed section with a corner exactly on a geographic pole gets positive_in inverted — it affects the four cells around a lat-lon-cap grid's Arctic pole. It is pre-existing, and unreachable on topology-driven-neighbors only because such sections could not be traced at all. It is not fixed here, because both repairs move things that should not move: measuring the area on the sphere is exact at the poles but degenerate for a section following a great circle (an equatorial latitude circle encloses exactly half the sphere either way round), and choosing the projection point per section silently redefines which cap positive_in means — it flipped notebook 5's tropical overturning. Both were tried and reverted. It is a strict xfail carrying the reproduction, and convergent_transport now warns.

Verification

  • 123 passed, 1 xfailed, 0 skipped.
  • Every transport oracle holds unchanged: the discrete divergence theorem on the cube, rotated-seam and split-grid equivalence, section transport = ψ(end) − ψ(start), LLC90, the 'left' multi-tile mask orientation.
  • All five notebooks re-executed. No figure and no numerical output changed — notebook 5's overturning range is identical to the last digit.
  • make html SPHINXOPTS="-W" clean; the algorithm animation regenerated, its replay still matching grid_section exactly.
  • Two independent adversarial review rounds. The first found four blockers — including a fixture of mine that was geometrically inside out with the sign error papered over by a constant, and a regression where sections that used to trace were refused. Both fixed, and both now have tests that fail without the fix.

Tests

No assertion was carried over on the grounds that it currently passes. Recorded numbers were replaced by the invariant they stood in for wherever one exists — an axis-aligned section on a regular grid takes one step per cell; a cubed sphere has 6N² + 2 corners; a corner's edges and its cells count the same way round it. New coverage: a section that zigs into a fold seam, runs along it and leaves down the mirrored side; a crossing of the degenerate polar column; identity under a 2° coordinate jitter; a 2-cell-wide periodic axis; a walled row's periodic twins; and a performance gate.


AI-assisted PR, disclosed per the project AI Usage Policy: commits carry Co-Authored-By: trailers.

hdrake and others added 13 commits August 11, 2026 18:51
One physical vorticity corner can be stored under several native indices, and
sectionate currently rediscovers which ones from geographic coincidence, in five
places with four different tolerances. That is fragile in a way that is easy to
miss: two corners a grid genuinely distinguishes can sit at the same position (a
bipolar cap's singular meridian stores one position for a whole column of
corners), and a corner's stored position can simply be wrong.

Every identification a structured grid can express is instead an affine integer
map on the corner lattice, uniform along the seam it belongs to -- a periodic
wrap, a fold's mirror, a `face_connections` gluing with its rotation. So take the
identity of a corner to be what the metadata declares, and nothing else: the
corner topology is the quotient of the lattice by the group those maps generate.

`CornerTopology` builds that for any grid, single- or multi-tile, on the 'outer'
lattice so every corner of every cell has a slot even where the native arrays
drop a row. The fold's mirror is taken straight from xgcm's own fold padding, so
a fold means the same thing here as it does when xgcm fills a halo across one.
Seam maps across `face_connections` are *fitted* from tracer cells, which pad
reliably however a seam is rotated, and then applied along the whole seam --
including its two end corners, where the cells needed to see the correspondence
have run out. That extension is what identifies the corners where a seam runs
into a wall or a pole.

Identifications can also be declared directly, for topology `face_connections`
cannot express: its schema maps a whole tile edge to a whole tile edge, so it
cannot describe an edge glued to several partners over different index ranges --
which is the shape of a lat-lon-cap grid's southern boundary fold.

Nothing consumes this yet; it is added alongside the existing machinery so the
two can be compared before anything switches over.

Measured against `_OuterTopology` on the cubed sphere and on ECCO LLC90, the new
partition is a strict refinement: no corner is merged that the cell-identity
construction kept apart, and the 179 nodes that differ on LLC90 are exactly the
merges it had inferred from coordinates. It also resolves what the old one could
not: a 2-cell-wide periodic axis (whose three corner columns share one pair of
cells, so no cell-set test can separate them), and a walled row's periodic twins
(a wall corner touches two cells, and two cells meet along an edge, not at a
point). On the real MOM6 tripolar grid the node count matches the declaration
exactly, and the build is 0.01s against 1.7s; LLC90 is 0.05s against 3.7s, and a
quarter-degree global grid resolves in 1.5s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What the rest of the pipeline needs from a corner, beyond knowing which indices
are the same one.

A node takes the position of its canonical representation, so a corner reports
the same position however it is spelled, and a distance measured along a section
no longer depends on which side of a seam the walk happened to be on. A corner
stored on no face -- a cubed sphere stores 6N^2 corners for 6N^2+2 points, and a
'left' staggering drops a whole row -- is placed at the centroid of the tracer
cells meeting there, which is what the node is, so the estimate follows from the
topology rather than from extrapolating a lattice. On the cube that lands the two
un-stored vertices on their analytic positions, latitude arcsin(1/sqrt(3)).
Where fewer than three cells survive there is nothing to average and the position
is left unknown rather than guessed; on LLC90 that is 180 corners, all on the
outer rows the staggering drops.

Asking for the native index of a corner stored on no face raises. A sentinel is
not available: -1 is a perfectly good numpy index, so it would not fail, it would
select the wrong cell.

`edge_velocities` reports each face in the frame it is stored in, together with
the tracer cells its positive direction points from and to -- enough to attribute
a transport across a rotated seam without rotating any vector.

`validate_positions` closes the loop the other way: the coordinates now *check*
the topology instead of setting it. Every representation of a corner should carry
the same position, and where they do not the grid's metadata and its coordinates
disagree. The fold fixture that declares a fold its plain lat/lon does not carry
is resolved correctly all the same, and reported -- 1.27e7 m between two copies
of one corner -- where before the disagreement silently steered identity itself.
On the real tripolar grid the copies agree to 8e-10 m, and on LLC90 exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NOT GREEN: 1 of 118 tests fails, deliberately. See the end of this message.

The walk now moves on physical corner nodes instead of native indices. A
periodic wrap, a fold seam and a rotated tile boundary are all just edges, so
the three rules -- enumerate neighbours, admit those that get closer, step to
the one nearest the curve -- need no case for any of them and never ask whether
two indices are the same corner. Gone with the index frame: the endpoint
coincidence stop, the seam-twin admission, the right/left/up/down vocabulary,
and `COINCIDENT_TOLERANCE_M` from section.py entirely.

Corners that are distinct but coincident are crossed as a group. Greedy descent
over a tripolar grid's polar fan has a flat metric across the whole column, so
the route through it was settled by index order; now the walk picks where to
*leave* the group from the neighbours outside it and crosses by the shortest
chain. The old walk took six-plus zero-length steps up that column.

Transports are attributed per node pair. Two spellings of one corner give
`a == b`, an integer comparison, and emit no face -- which is how a seam
crossing is written and why it is no longer counted twice. A pair that is not a
grid edge raises instead of quietly losing a face. `uvindices_from_qindices`
also reports `q`, the corner step each face came from, since faces and steps are
no longer one to one.

The transport sign is now read entirely in the frame the velocity is stored in:
does the section's step run along `+i` or `+j`, and is this face's `+x` or `+y`?
That is index arithmetic, so it is exact where the old cross product of two
local displacements was degenerate -- on a polar fan both displacements are
zero and it returned -1 arbitrarily, which matters because `is_mask_inside`
takes the whole section's orientation from one face. The one thing an index
frame cannot know is whether it is mirrored relative to the world, so that is
measured once per face rather than per edge.

Every transport oracle passes unchanged: the cube's streamfunction difference
and divergence theorem, the rotated-seam and split-grid equivalences, LLC90,
and the 'left' multi-tile mask orientation.

Paths change where they were not minimal. A geodesic from (0,40N) to (120,40N)
on a 2-degree grid now takes 81 corners rather than 83: 60 steps of longitude
plus 10 up and 10 back down, one cell at a time, never diagonal and never
doubling back, which is exactly the distance covered. The test now asserts that
property instead of the recorded number.

Neighbour maps are no longer used by anything and their tests are re-derived on
the corner graph, which states the same properties more directly -- a seam
shares its corners, a rotated seam maps them reversed, a section's consecutive
corners are one step apart.

**The known failure.** Deriving a seam's index map by fitting it to padded
tracer cells inherits whatever the halo gives, and for a *reversed* gluing the
halo does not carry the reversal -- the case the previous code refused outright
as unreliable. The fit then folds a face onto itself, merging two cube vertices
into one corner. The arithmetic is right (a reversed seam correctly derives
K2 = n - K); the input is not.

Rather than let that resolve to something quietly wrong, it is now detected:
two corners of one face cannot be the same point unless the grid glues that
face to itself, and saying so names the face and the corners. Both cube
fixtures with reverse gluings now raise, and `test_outer_reverse_gluing_matches_by_coincidence`
fails on that raise. Every grid without a reversed gluing -- LLC90, the 'left'
cube, all single-tile grids -- is exact.

The fix is to read the seam map from `face_connections` directly instead of
fitting it to a halo, which is what the rest of this design already does and
would also drop the tracer-centre requirement for multi-tile identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes the failure the previous commit left in place.

A `face_connections` gluing states which face, which of its axes, and -- through
`reverse`, which says the two faces meet on the *same* side of their axes -- which
end of it. Deriving the seam's index map by fitting it to padded tracer cells
instead went through the halo, and the halo does not carry a declared reversal.
The fitted map then folded a face onto itself: on a cubed sphere it merged two
opposite vertices into one corner, which would have handed their velocity faces
to the wrong place.

The fourth thing a gluing needs -- whether the tangential index runs the same way
along both edges -- does not have to be measured either. A grid is one oriented
surface, so the map between two faces that meet is a rotation, not a reflection:
crossing the seam turns one face's outward direction into the other's inward one,
there is exactly one rotation that does that, and where it sends the tangential
direction is then not a choice. A 90-degree gluing reverses the seam even though
nothing declares a reversal, and a same-side gluing reverses it too; both fall
out of the one rule rather than needing a case each.

Both cubed-sphere fixtures now close on the polyhedron: 6N^2+2 corners with
exactly 8 three-valent vertices, the synthetic one (which was merging two of
them) and the geometric one alike. LLC90 and the real tripolar grid are
unchanged, node for node.

Two things follow. Corner identity no longer reads tracer cells at all, so a
multi-tile grid without centre coordinates resolves exactly as one with them --
verified to give the same nodes either way. And the guard added last commit stays
useful rather than becoming dead: two corners of one face are the same point only
where the grid glues that face to itself, so if a future set of connections
contradicts itself it is still named rather than resolved to something plausible.

118 passed, 0 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fold stores one physical velocity face twice, in mirrored frames and therefore
with opposite sign -- the duplicated, sign-flipped row of an ORCA-style fold. The
face lookup returned both copies and the direction of travel was worked out
separately, so the two could come from different copies. A section that runs out
and back along such a row then failed to cancel: measured on a pinched-cylinder
fold, a path along the seam through the pivot came out at -0.86 and +2.31 times
the answer instead of +-1.

The velocity and the step through it now travel together, and the pair the
section was actually written in is preferred, so each traversal is read in its
own frame and an out-and-back cancels exactly.

Two expectations move, both to something truer.

The pinched-fold fixture is a *left-handed* chart: `+i` runs northward and `+j`
eastward. Its streamfunction identity is stated in index space, while
`convergent_transport` reports transport to the left of travel, which is a
geographic side -- so on a mirrored chart the two differ by a sign. That was
invisible while the sign came from a cross product of local displacements, which
answers in geography directly, and it is the one fixture in the suite separating
index handedness from the world's. Arbitrated independently: the boundary
integral of a divergent field over one cell equals that cell's convergence
computed straight from the staggered arrays, exactly, and for either traversal
direction -- the discrete divergence theorem, which no sign convention of ours
takes part in.

The fold connectivity test asked about neighbour maps, which no longer exist; it
now asks the question those maps existed to answer -- a declared fold makes the
seam row the same line of corners as itself reversed, and a wall does not.

New coverage. A section that zigs into the seam, runs along it and leaves down
the mirrored side: the shape raised against repairing a corner list after the
fact, and nothing covered it. Measured on the branch that repairs the list
instead, these paths come out right, so it is pinned as a property rather than
claimed as a reproduction. And a crossing of the degenerate polar column, where
the metric is flat and the two routes over an exactly mirror-symmetric cap are
genuinely tied: what must not depend on the direction of travel is the answer,
so both routes are the same length and both carry the transport the
streamfunction demands, every step one step.

124 passed, 0 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All of it is unreachable now that the walk moves on corner nodes: the
four-direction pathfinder and its two entry points, the neighbour-map builder and
its multi-tile fallback with the reciprocity check that guarded it, and in
transports the direction lookup, the velocity anchor, the range test that told a
source frame from a destination frame, and the geographic left-of-travel sign.
Around 850 lines, and with them the third convention for what a corner is: the
fallback kept each face's own copy of a shared corner where the outer-lattice path
canonicalised it, so which one you were looking at depended on which branch had
run.

Their tests are re-derived on what the maps existed to answer. "A vertical axis
must not change horizontal connectivity" is now asked of the topology rather than
of the padding that built the maps. The removed low-level walk was covered by
recorded path lengths, which are replaced by the counts the grid forces: an
axis-aligned section on a one-degree grid takes one step per cell, so 80S to 80N
is 161 corners and 179 degrees of longitude is 180.

Two tests are dropped rather than ported, because what they pinned no longer
exists: the walk terminating on a corner within a millimetre of its target, and
the velocity-range check that distinguished the two frames.

In their place, the seam contract stated directly: how many spellings a seam
corner has is a property of the staggering -- an 'outer' grid stores the column
twice, a 'right' grid once -- so what is asserted is that every step which is not
a repeat is one real velocity face, that `q` skips exactly the repeats, and that
consecutive corners really are one step apart on the grid.

120 passed, 0 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_OuterTopology` and `CornerTopology` answered the same question two ways, which
was the thing this set out to remove. The outer-lattice class goes, and with it
gridutils drops from 1204 lines to 216: the cell-fingerprint index and its 3-cell
junction variant, the diagonal recovery for a halo that is a pad of a pad, the
extrapolate-and-snap sweep with its two fractions and its ambiguous-band error,
the union-find over four different merge keys, and the projection back down to
native neighbour maps. The whole suite now runs in 10s rather than 25.

`padded_transports` moves across and gets shorter, because the corner graph
already knows what it needs: which physical edge a slot is, and which tracer
cells the stored velocity points between. Matching by cell also fixes a case the
old code needed padded halos for -- the face that stores a seam edge names only
the cell on its own side, since the other lies on the face being filled, so the
comparison matches whichever side is identifiable. An edge separates exactly two
cells, so one is enough to fix the direction. Bit-identical to the old output on
the cubed sphere, with cell convergence summing to 6e-17.

Three tests change because the behaviour deliberately did.

A corner stored on no face used to be given no edges at all, so it was a wall:
sections could not pass through a cubed sphere's un-stored vertices, and a
same-side gluing on a staggered grid -- which leaves a whole seam line unstored,
because it meets along the edge the staggering drops -- was refused outright.
Those corners are now resolved like any others and can be walked through; what
they do not have is a native index, and asking for one says so. So the cube's
eight vertices are all three-valent whether stored or not, and the same-side
variant resolves to 6N^2+2 corners instead of raising.

The LLC90 well-formedness test asked about the native neighbour maps, which no
longer exist. It now asks the question those maps existed to answer, and a
sharper one: a corner's edges and its cells count the same way round it -- a
closed ring of k cells has k edges, an open fan against a boundary has one more.

120 passed, 0 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`grid_section` always returns `(i_c, j_c, f_c, lons_c, lats_c)`. A single-tile
grid is one face, so `f_c` is zeros there rather than absent, and callers stop
having to branch on the arity of a tuple to find out what kind of grid they were
given -- which is what `regionate` currently keeps a normalising wrapper and seven
`if f_c is not None` guards to do.

`GriddedSection` gains `n_c`, the physical corner each of its points is. It is
always derived from the grid and never stored: a node id is an artefact of how the
topology was built, so a saved one could disagree with the grid it is loaded
against. Deriving it is also what makes a section reloaded from indices, or built
by hand, behave exactly like one this package traced -- there is no repair pass to
skip. The save format follows: `f_c` unconditionally, `n_c` never.

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

From an independent review. Four things were wrong.

**The fold fixture was inside out, and a constant was hiding it.** Its two
semi-axes moved in opposite directions as `j` rose, so consecutive ellipses
crossed and 16 of its 80 cells were mirror images of the rest. The handedness
probe read cell (0,0) -- one of the 16 -- concluded the whole chart was
left-handed, and I added a `MIRRORED_CHART = -1` to the tests to match. That made
four tests assert the negative of the right answer, consistently, so they could
not fail. Both semi-axes now shrink, the ellipses nest, every cell runs the same
way round, and the factor is gone.

The probe deserved the blame more than the fixture: it read one cell and never
checked the rest agreed. It now measures every cell and raises if a face is not
consistently oriented, so a grid of this shape says so instead of being quietly
mis-signed. Measuring it on the sphere rather than in a local east/north frame
matters too -- that frame degenerates at a pole, where one cell of an otherwise
uniform face reported the opposite handedness.

**Sections were refused where a route existed.** A corner stored on no face has
no native index, so a section through one cannot be written down -- but the walk
was picking such corners as readily as any other, and 2 of 100 random sections on
the cube died at the projection step where the old code traced all 100. The walk
now prefers corners the grid can name, and endpoints never snap to one. Nothing
is lost geometrically: a cube vertex and a dropped row always have stored
neighbours. 0 of 100 now fail. Where a section genuinely has no other way through
-- a seam whose corners are stored on neither side -- it still raises, and the
message now says which staggering causes it and what to do about it.

**Orientation inverted for a closed section with a corner on a pole.** The signed
area is measured on a stereographic projection, which sends one pole to infinity,
so a corner sitting there projects to a radius that swamps the area. That is not
an exotic case: it is a vertex of every cell of an Arctic cap. The configuration
is now turned to put its corners away from the projection point first -- a
rotation, so the orientation it measures is the same one. On the 'outer' cube 2 of
384 single-cell loops were inverted, and on LLC90 1 of 1035; both are now 0, and
a test pins it against the divergence theorem, which knows nothing of projections.

**Two tests had become tautologies.** Aliasing `outer_topology` to the new
implementation left them comparing it with itself. The refinement check against
the old implementation is deleted with its reason, and the LLC90 counts are
pinned to the numbers the declared topology gives rather than to `> 0`.

Also gone: `Usign`/`Vsign`, written into every result and read by nothing since
`Lsign` became the only sign; and two helpers left behind by the changes above.

121 passed, 0 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs page and its animation still described the four-direction walk over
neighbour maps, and every notebook unpacked four values from `grid_section`.

`make_algorithm_animation.py` replays the walk to record what each step decided,
so it had to move onto the corner graph too. Its fidelity assertion -- that the
replay reproduces `grid_section` exactly -- still holds, and the traced path is
unchanged: the same nine corners the page quotes. The panel changes, because two
of the eight steps used to be "arrivals" that the endpoint short-circuit took
without evaluating anything, and there is no short-circuit now; the walk stops
when it reaches the end corner, which is an integer comparison. Direction labels
survive only as display: the walk has no directions, so they are derived from
where the picture moved.

`algorithm.md` follows. "Enumerate the four neighbours" becomes "enumerate the
corner's neighbours", since a cube vertex has three and a walled corner has three
-- a wall is a *missing* neighbour now, not a corner that is its own. The
seam-twin exception to the admission rule is gone rather than reworded: a corner a
grid spells twice is one corner here, so stepping onto its twin is not a move the
walk can make. "Why the neighbour maps carry the topology" becomes "why the corner
graph carries the topology", and gets to say something truer -- the mesh drawn
behind the animation *is* that graph, edge for edge.

Notebook 4's low-level cell used two functions that no longer exist. It now shows
what `grid_section` actually resolves first: 43621 corner slots on the tripolar
grid resolving to 43321 physical corners, and the two identifications -- the
periodic wrap and the fold -- that say so.

Definition of Done: 121 tests, 0 skipped; all five notebooks re-executed cleanly;
`make html SPHINXOPTS="-W"` clean; the animation regenerated. Reviewing the
notebook diff, no figure and no numerical output changed -- notebook 5's
overturning range is the same to the last digit -- only timestamps, the recorded
version, and the cells edited above.

Also from the review, as a strict xfail rather than a fix: a closed section with a
corner exactly at a geographic pole gets `positive_in` inverted. It is
pre-existing, and unreachable on the base branch only because such sections could
not be traced at all. Fixing it needs a decision this change should not make --
measuring the area on the sphere is exact at the poles but degenerate for a
section following a great circle, and choosing the projection point per section
silently redefines which cap `positive_in` means. Both were tried; both moved
results that had no business moving. The xfail carries the reproduction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LLC90's domain edge folds onto itself along the great circle through 65E and 115W
under Antarctica, and `face_connections` declares those four tile edges as walls.
Not an oversight in the archive: its schema maps a whole tile edge to a whole tile
edge, and this edge is glued to several partners over different index ranges --
tile 0's runs into tile 3's, tile 9's and tile 12's in turn, and two of them fold
onto themselves at the pivots. So one line of physical corners is stored twice
with nothing in the metadata to say the two copies are the same point, and the
previous code recovered it by matching coordinates.

An identification is an affine map whether or not `face_connections` can hold it,
so it can simply be handed in: `declare_identifications` attaches extra ones to a
grid, and the ECCO loader declares the fold as five blocks of `k -> const - k`.

That the blocks are right is checkable independently, and the numbers are exact.
They merge 179 corner pairs, taking LLC90 from 105481 nodes to **105302** and its
un-stored corners from 181 to **78** -- both precisely what the coordinate-matching
implementation used to produce. And `validate_positions()` returns **0.0 m**: every
pair the declaration merges is bit-identically coincident in the archived
coordinates. The geometry agrees with the declaration without having been asked.

The test now pins both readings of the grid -- what `face_connections` alone says,
and what the fold adds -- so it is clear which is which.

120 passed, 1 xfailed, 0 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-ups. The two fixes from last round had no tests, which the reviewer
rightly called the thing most likely to regress silently.

`test_sections_route_around_corners_the_grid_cannot_name` uses waypoints found by
fuzzing with the preference disabled -- 8 of 400 random sections on the cubed
sphere route into an un-stored vertex without it -- so it fails when the fix is
removed and passes with it. There are now also tests that a seam stored on neither
face raises with a message naming the staggering and the way out, that most
sections on such a grid still trace, and that a face with a mirrored cell is
reported rather than signed backwards.

Two docstrings claimed more than the code does. Preferring corners the grid can
name is applied *before* the curve comparison, so it can discard a candidate that
lay closer to the requested curve -- it now says so, with the measurement: the
identity on every grid that stores all its corners, including LLC90; no path
longer on a cubed sphere; 12 of 200 longer only where a whole seam line is
un-stored, on a grid that could not be traced at all before. And endpoints no
longer snap to un-stored corners, which shifts a waypoint by up to a cell on such
a grid; that is user-visible and is now documented.

The polar limitation reaches further than the xfail implied: **all four cells
around a lat-lon-cap grid's Arctic pole** get an inverted `positive_in`, on the
production grid rather than a synthetic one. Still not fixed here, for the reasons
recorded last commit, but no longer silent -- a closed section with a corner on a
pole warns, and points at passing `positive_in` as a cell mask, which is decided
from the cells either side of a face and never touches the projection.

Also: the stale comment block describing the deleted neighbour maps is gone (a
previous pass left it truncated mid-sentence); `seam_halo` is deleted as dead --
no call sites, no coverage, and its sole helper with it; the `outer_topology`
alias is retired from the tests, including a test *named* after a function that no
longer exists; `_resolve_nodes` bounds-checks like `uvindices_from_qindices`
already did; and `load_gridded_section`'s refusal of pre-`f_c` files is marked as
deliberate so it does not get filed as a bug.

CLAUDE.md regains the corner-axis-versus-`sect`-axis contract and `q`, which is
what regionate consumes and which the rewrite had dropped, plus the polar
limitation; and its artifact-size note now matches the artifacts.

123 passed, 1 xfailed, 0 skipped; docs build clean; animation regenerated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The message quoted a position, and a corner stored on no face usually has too few
tracer cells around it to place -- so the one thing it printed was the one thing
unavailable: "near lon=nan, lat=nan". Reviewing the downstream port, that message
was what made a missing declaration look like a snapping bug for an hour.

It now names the corner slot, which is always known, and says plainly when the
grid does not determine the position either.

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

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

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