Plan: unify sizing/positioning around the σ-affine model
Staged simplification of the layout engine, from vocabulary fixes to the #39 endgame. Each stage lands independently and is gated on pixel equality across all stories (capture-diff as inner loop; the CI visual baselines as the outer gate). No compatibility shims anywhere — callsites migrate in the same change.
The one equation, and three roles for one unknown
Every continuous axis is one affine map per σ-scope:
px(d) = pxMin + σ·(d − domainMin) σ = pixels per data unitPer node and axis there is one position unknown — the baseline, the screen coordinate of the node's local data-0. Three things currently share the word "origin" and must be kept distinct:
- alignment is a constraint: equations between baselines (
baseline_A = baseline_B), or between other anchors for other alignments; - placement (
free | determined | conflict) is the abstract value: is the baseline subsystem under-determined / solvable / inconsistent — all that bottom-up space resolution can know, since pixels don't exist yet; - the intercept is the concrete value: the solved shared baseline of a σ-scope, in pixels. It exists only after σ and the frame anchor resolve, and should always be a derived read (
posScale(0)), never stored state.
False friend to never conflate: the width Monotonic's own intercept is the σ-independent pixel part of an extent (spacing, fixed chrome) — an intercept of the size-vs-σ line, not of the data→screen map.
Root cause the stages chip away at: the placement solve gives each node one unknown per axis (its min) while sizing runs in a separate σ-fold pass, with the intercept held off-ledger in transform.translate. span and grid are the two places that model can't express something, so each grew a bespoke side-regime; the align guards and scale-forwarding rules are hand-maintained consistency between the two passes.
Stage 0 — vocabulary and docs (no behavior)
Write the equation and the three-roles framing into apps/docs/docs/internals/core/underlying-space.md, plus one paragraph naming the current two carriers honestly: scaleFactors carries only the slope σ for unanchored extents (intercept implicit in baseline placement + translate); posScales carries the whole map for anchored ones.
- Files: the essay; touch-ups to doc comments in
underlyingSpace.ts,solver/index.ts. - Gate:
pnpm --filter docs check-backlinks, docs build. - Size: small. Can ride along with Stage 1.
Stage 1 — placement becomes a bare lattice, then a derived view
placement.at is stored redundantly: it always equals dataDomain.min (both are built from the same origin argument in CONTINUOUS(), underlyingSpace.ts:153-165; the one mutation site — nicing, _node.ts:729-736 — rewrites them in lockstep). Every reader consumes only the tag except one: positionNode.tsx:28.
- Drop the payload:
Placement = "free" | "determined" | "conflict". RewriteoffsetSpace(positionNode.tsx:19-35) to readcontinuousInterval(space)?.min. DeleteoriginOf(zero callers). - Stop storing
placementat all: it is in bijection with the shape ofdataDomain(free ↔ undefined,determined ↔ interval,conflict ↔ "delta") — make it a derived getter. The stored space is then{ width, dataDomain, measure, … }and the three-field lockstep at nicing collapses to two. - Naming: the builder
Origintype (number | "free" | "impossible") stays as constructor input but gets renamed to say what the number is (the domain min, not a zero point) — e.g.anchor: number | "unanchored" | "delta". Bikeshed at implementation time; the constraint is that "origin" disappears from the space vocabulary.
- Files:
underlyingSpace.ts,positionNode.tsx,_node.ts(nicing,placementOn, debug printer),alignment.ts(tag reads), essay. - Gate:
capture-diff mainempty; typecheck. - Size: small (≈ a day).
Stage 2 — span folds into position (surface unification)
span is not a new concept: it is two position pins on two anchors of one target. Give position an interval form and delete the eighth constraint type:
Constraint.position({ x: v })— point (today's behavior);Constraint.position({ x: [a, b] })— interval; lowers to the edge-pin + extent path thatspanuses today.
Producers/consumers to migrate (the whole surface):
scatter.tsx:116-125— emit interval-formpositioninstead ofConstraint.span;constraints/index.ts— factory,ConstraintSpecunion,collectPositionDomains(merge the two branches atindex.ts:203-224);compose.ts— the span-specific special cases (spansfilter, presence test,spanCover) key on "position with interval form" instead;src/tests/constraintConfluence.test.ts— the one direct callsite.
The internal lowering machinery (edge pins, extent side-channel) stays until Stage 5; this stage removes the type and the parallel code paths that dispatch on it. Not in the wire IR (span is created during JS elaboration; scatter serializes as xMin/xMax), so no schema/Python work.
- Gate: pixel equality;
validate-python-ir+capture-pythonuntouched but run once to confirm. - Size: small-medium.
Stage 3 — contain grid
grid is a third layout regime, not a constraint: a grid layer exits the pipeline at layer.tsx:134-135 (space fold early-return), layer.tsx:211-212 (cell budget), with an exclusivity rule (proposalPlan.ts:271-285) no other constraint needs. Latent inconsistency: placement applies all constraints on a grid layer while spaces/sizing see only the grid, so grid + position/ nest/align silently half-applies (a datum position never gets a posScale and its facts are dropped).
- Close the cliff: throw on
gridmixed with any non-z-order constraint. Honest error now; routing them through is Stage 6 work. - Demote
Constraint.gridtotable's private elaboration target (it has no other producer). The public surface for grids istable. - Leave the flex-track limitation (equal tracks only) documented as is — content-sized tracks are the Stage 6 generalization, where a grid becomes 2·(numCols+numRows) track variables in the linear system.
- Files:
proposalPlan.ts(or the layer constraint intake),constraints/index.ts(factory removal),table.tsx, confluence test, essay. - Gate: pixel equality; new throw covered by a unit test.
- Size: small.
Stage 4 — one affine scale carrier (refactor-first enabler)
Replace the two parallel per-axis channels
scaleFactors: Size<number | undefined> // slope only
posScales: Size<((d: number) => number) | undefined> // whole map, opaquewith a single per-axis affine scale record, e.g.
type AxisScale = { sigma: number; map?: { domainMin: number; pxMin: number } }(exact shape decided at implementation; the requirements are: slope readable alone, intercept explicit rather than closed over, and "anchored" = presence of the map half). Unanchored consumers read sigma; anchored consumers evaluate the map; posScale(0) — the intercept — becomes a one-line derived read.
What this dissolves:
positionTargetDims+childPosScalesFor's three-way pick (proposalPlan.ts:287-363): "a constraint consumed the scale that placed this child" becomes an explicit per-axis field decision instead of a reconstructed name-set;- the dual stash logic in
buildChildScalePlan(proposalPlan.ts:205-257), including making the #618 inherited-σ guard a statement about one object; - every
computeSize(size, scaleFactor)/computeAesthetic(v, posScale)pair dispatching on which channel happens to be defined.
Wide but mechanical: ~22 files mention the two carriers (Layout signature in _node.ts:150-157, gofish.tsx root, layer.tsx, spread.tsx, coord transforms, marks). Strictly behavior-preserving — the record is built from exactly the numbers the two channels carry today.
- Gate: pixel equality across all stories (this is the stage where
capture-diffearns its keep); no per-story exceptions. - Size: medium (a few days). Do NOT bundle any behavior change into it.
Stage 5 — rank-2 placement solve
Extend the relational placement solver from one unknown per (node, axis) to the per-node box (min, size) — i.e., adopt the ledger/BBox cell inside the cross-node solve (placementSolver.ts). The node-side ledger (_node.ts_bbox) is already rank-2; only the cross-node pass is rank-1.
Where rank-1 is baked in today
The fact vocabulary (placementFacts.ts) has four fact kinds — pin, relation, edge-pin (span only), participant — and every one of them is affine in a single variable per node (start), because PlacementProgramLowerer.anchorOffset (placementProgramLowerer.ts:31-53) pre-evaluates every anchor to a numeric offset from start using the target's already-known size (localAnchor/dims) at lowering time. A target whose size isn't known yet (a span target) can't be offset, hence the spannedSize side-channel callback threaded through the lowerer, the edge-pin fact kind, the max → min − size rewrite in classifyAxisFacts (placementSolver.ts:107-115), and the post-solve setExtent re-application (placementSolver.ts:295-298).
The rank-2 design
- Anchor facts. Facts reference
(node, anchor)with anchor ∈ {min, center, max, baseline} (plus a size equation):pin(node, anchor, value, owner)andrelate(from(node, anchor), to(node, anchor), gap, owner). The align-anchor→box-anchor mapping (BOX_ANCHOR) stays in lowering, but no numeric pre-evaluation happens there — offsets move into the solver.edge-pinand thespannedSizecallback are deleted; span's two edges are ordinarymin/maxanchor pins. - Per-node cells with two-tier authority. Each (node, axis) gets a
BBox(constraints/bbox.ts— the existing 2-unknown cell with named-owner conflicts). Constraint-owned equations are strong; the node's self-layout size and any self-placed min (today'sPlacementOwnershipPlan.initiallyPlaced/authoritativesets) seed as weak defaults that a strong rank-2 determination discards — which is exactly whatsetExtent's ledger reset does today, made explicit as tiers instead of four hand-maintained name-sets. - Two-phase solve per axis.
- Cell closure: apply strong pins; a cell reaching rank 2 has its size determined (the span case); every other cell takes its weak layout size. After closure all participating sizes are known — reachable programs never leave a size free (align/distribute/nest/grid emit no size equations).
- Difference graph, unchanged: relation offsets are now computed inside the solver from closed cells (the same
localAnchorPointarithmeticanchorOffsetdoes today, moved from lowering-time to post-closure). The BFS components, pin application,distributeOriginForsequence origin, and normalized-origin fallback (placementSolver.ts:222-251) are preserved verbatim — that part of the solver is already general. baselineparticipates in closure asmin + cwherecis a node-local constant fromlocalAnchor(independent of the size unknown; a span-determined box has baseline ≡ min since its local frame resets to[0, size]).
- One commit path. Every solved cell writes back through a single function — rank-2-determined →
setExtent({min, max}), position-only →pinAnchor(min)— replacing the three-way branch atplacementSolver.ts:289-301.BBoxconflicts and graph conflicts merge into one named-conflict report shape (same owner/asserted/implied fields). - Deletions.
span.ts'scollectSpanExtents/SpanExtent/lowerSpanEdgePins, theedge-pinfact kind, theclassifyAxisFactsrewrite,PlacementOwnershipPlan.spanPinned, thespannedSizelowerer parameter, and the extent maps threaded throughlowerPlacementConstraints/solvePlacementConstraints.
Landing sequence (shadow-first, same discipline as the ledger)
- 5a. Emit anchor-facts alongside the current program; run the rank-2 solve in shadow and assert it reproduces the rank-1 result across all stories (extend
GOFISH_SOLVER_CHECK). No behavior. Landed: the lowerer emits both programs,constraints/rank2Placement.tsruns the two-phase solve and compares final(min, size)per (node, axis) against the shipped positions, andtests/scripts/capture-sweep.tsrenders every story with the flag injected to collect any[solver-check]/[bbox-conflict]divergence. - 5b. Flip the commit path to the rank-2 solve. Pixel gate. Landed:
solvePlacementConstraintsnow solves each(node, axis)box from the anchor program (cell closure → difference graph → single write-back: size-strong →setExtent, position-only →pinAnchor); the shadow inverted to check the old rank-1 result against the shipped rank-2 one, and the sweep stayed clean. - 5c. Delete the rank-1 path and the span side-channel. Landed:
span.ts(collectSpanExtents/SpanExtent/lowerSpanEdgePins) andrank2Placement.tsare gone; the interval form's edges are ordinary strong anchor pins emitted byposition.ts;spanDatumIntervalfolded intoposition.ts; theedge-pinfact kind, the rank-1PlacementProgram, thespannedSizelowerer callback +anchorOffsetbranch, andPlacementOwnershipPlan.spanPinnedare deleted.differenceGraph.tsis the only graph and the rank-2 solve is the only solve.
Running the sweep. With the workspace installed (pnpm install; the harness aliases gofish-graphics to src/, so source edits render live without a rebuild), run pnpm --filter @gofish/tests capture-sweep. It injects window.GOFISH_SOLVER_CHECK = 1 into every story, captures the browser console, and exits non-zero listing each story that logged a [solver-check] (rank-2 vs rank-1 placement divergence) or [bbox-conflict] (ledger over-determination) line. A clean run is the 5a gate. Pass a substring to scope to one story (pnpm --filter @gofish/tests capture-sweep bar).
Non-goals, held explicitly
- The align
start/end-as-baseline transfer function in space resolution (alignment.ts:135-136) is unchanged; it is only exact forminCoeff = 0boxes. The rank-2 solver computes the true placement for asymmetric boxes — observe any divergence under the shadow check first, then decide adoption as a separate visible change. - Signed sizes stay magnitudes (
Math.abssemantics preserved; sign is a layout fact carried by the local frame, not a solver unknown). - No changes to the space folds (
compose.ts) beyond what Stage 2 already re-keyed.
Tests and gates
- Confluence test grows cases: interval-position + align on the same axis (both declaration orders), interval-position inside a distribute chain, authoritative override pins on rank-2 cells, and a conflict case asserting both owners are named.
- Gates: pixel equality across stories;
GOFISH_CONFLICT_CHECKsweep clean; the 5a shadow assertion clean before 5b flips. - Size: medium-large. Depends on Stage 4 only softly; do 4 first anyway (it simplifies what the solver hands back to scale consumers).
Stage 6 — σ into the same system (the #39 endgame)
Fold the σ resolution into the same linear system, per scope. The two hooks already exist: BBox key values are already σ-affine Monotonics (the "unified-propagation stage 1" note in bbox.ts — every caller just happens to pass constants today), and AxisScope/SolverBox (solver/index.ts) is the validated Phase-0 model.
The sites that become one mechanism
σ (or its posScale twin) is solved today at four+ places, in a hand-ordered priority. All become one rule — only a σ-scope root solves; everyone else inherits:
- the root:
gofish.tsx:385-392(width.inverseagainst the canvas),posScaleFromSpaceatgofish.tsx:371-377, and the recentering writeback aroundgofish.tsx:405-437; - self-scaling regions (explicit pixel size on an axis):
buildChildScalePlanstep 2 (proposalPlan.ts:205-216); - composed constraint budgets: step 3 (
proposalPlan.ts:218-244) — whose #618 propagate-vs-re-root guard is exactly the "intermediates must not re-root" rule, hand-written; it becomes structural; - shared-scale scopes: step 4 (
proposalPlan.ts:246-257) + spread'ssharedScaleannotation; - coord boundaries (the scoped-resolution thread: bake/resolution must be boundary-recursive, not root-global).
σ-scope roots are therefore: the root, an explicit-pixel-size axis, a sharedScale operator, a coord boundary. One AxisScope per (scope root, axis): a registry of member cells; the frame equationcontent(σ) = allocated solved once by Monotonic.inverse; the scope's posScale and σ are views of the solved system (the Stage-4 AxisScale record becomes exactly this view: σ = slope, map = anchored min anchor + pixel min; the intercept is posScale(0), derived, never stored).
The dual slope is transitional debt, eliminated here — not a keeper. Stage 4's AxisScale.map carries its own slope because today σ is solved in the four+ places above against four different pixel budgets, so a mark can read size-σ and position-slope from different extents on one axis (nicing asymmetry, spacing's slope-vs-secant, sub-budget scopes vs an inherited map). Stage 6's invariant is one slope per σ-scope, by construction: the frame equation solves σ once at the scope root and the posScale is a derived view of the same solve, so within a scope the two cannot disagree. The divergences that remain must then become what they really are — two scopes on one axis (keyed by measure: the multi-scale/dual-axis design), or explicit non-data pixels (spacing as a piecewise gap, not a secant that papers over it). AxisScale collapses back to a single-slope view when 6b/6c land; if it still has two independent slopes after Stage 6, that is a bug in Stage 6.
Sub-stages, each gated
- 6a — observe. Extend
solver/shadow.tscoverage to the modes it skips: center-mode distribute, pre-placed/data-positioned chains, nest, grid, coord scopes. Run all stories underGOFISH_SOLVER_CHECKuntil clean. No behavior change; this is the risk-retirement step and can start any time after Stage 5a exists. - 6b — one solve site. Move the root inversion + the three
buildChildScalePlansteps behind a single scope-root API with the same numbers and priority. The #618 guard becomes "not a root → inherit". Pixel gate. - 6c — σ-affine claims flow. Marks/folds contribute width
Monotonics into cells instead of pre-multiplied numbers; evaluation defers to the scope boundary; the "evaluate at σ, hand concrete sizes down" double bookkeeping (computeSize's scaleFactor path) collapses. Pixel gate. - 6d — translate retirement (#39 stage 3-D). Render consumes baked absolute coordinates through the
displayTranslate/translateStringchokepoints (dims.ts:268-294was written to make this a one-function change); the per-container<g translate>wrappers collapse. Expect benign DOM reshuffles — this is precisely the pixel-not-DOM gate case. - 6e — grid as tracks. A grid scope introduces
numCols + numRowstrack cells; each cell(i, j) gets equationscell.min = track.minandcell.size = track.sizeper axis. Equal-flex is "all track sizes equal + Σ tracks + gaps = W" (today'ssliceExtent, as equations); content-sized tracks aretrack.size ≥ max(cell claims)— notemaxleaves the linear fragment (piecewise claims; see themonoEqualtwo-point-probe caveat inbbox.ts), so this lands as an iterate-or-piecewise extension, and is the point wheretablegains content-sized tracks. The Stage-3 layer bypasses (space-fold early return, cell-budget special case, mixing throw) delete;gridSpaces' ORDINAL axes contribution stays but composes. - 6f — determinacy from rank. The Stage-1
spacePlacementview retires: free/determined/conflict is read off the scope system's baseline-subsystem rank/consistency. Space resolution keeps only data facts (width,dataDomain,measure). The align transfer functions become transfer functions of the solver's abstract domain, closing the "guards should be blindingly obvious" thread.
Open design questions (resolve during 6, tracked now)
- Authority model (#583): Stage 5's weak/strong tiers may need a third tier (user-explicit vs constraint-derived) once σ-affine claims and placement claims live in one system.
- Multi-measure scopes: a scope keyed by (axis, measure) — the measure-keyed set of underlying spaces idea — would retire the childPosScales workaround and permit dual-axis charts; decide whether 6b's scope registry is keyed that way from the start.
- Where scope state lives: on the render session (like
toPixel) vs a map keyed by scope-root uid; must survive resume/re-layout. - Piecewise claims:
max-composition (6e) and any future clamp break the two-pointmonoEqualprobe; decide the claim representation before 6e.
Debuggability requirement
Every scope must be dumpable as printable equations (Monotonic.print per box key, one line per member cell, frame equation last) behind a debug flag — the printable-equations bar the σ-affine model was chosen for.
- Gate: shadow-clean per covered mode before each flip; pixel equality at every flip; benign DOM reshuffles acceptable only at 6d.
- Size: large; each sub-stage is its own PR. This document is the map, not the schedule, for Stage 6.
Order and dependencies
0 (docs) ─┐
1 (lattice) ─┐ independent quick wins, any order
2 (span→position) ─┤
3 (grid cliff) ─┘
4 (one scale carrier) ──► 5 (rank-2 placement) ──► 6 (σ in-system)Stages 0–3 are each a small PR and can land this week in any order. Stage 4 is the enabling refactor and should land alone, with nothing else in the diff. Stage 5 consumes 4. Stage 6 consumes 4+5 and subsumes the leftovers of 2 (span's internal machinery) and 3 (grid's bypasses).
Wiki obligation: most touched files carry the @wiki Underlying Space backlink — underlying-space.md must be updated in the same change for every stage, and constraints-as-core.md / the solver notes for Stages 5–6 (pnpm --filter docs sync-backlinks when covers: changes).
