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 was transitional debt — discharged in 6c as two scopes, not independent state. Stage 4's AxisScale.map carried its own slope because σ was solved in the four+ places above against four different pixel budgets, so a mark could 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. After 6c that holds everywhere the carrier flows — the scope registry is the sole producer of every slope, so neither sigma nor map.sigma is ever a free number. The remaining divergences became what they really are:
- Nicing asymmetry (case 1) resolves to two scopes (case 3). The corpus (
GOFISH_DUMP_SCOPES+ the 6b shadow) shows the only anchored-vs-size disagreement is a marginal panel whose count axis is a self-scaled region: it carries an explicit pixel size, so it stashes its own space and builds a LOCAL scale against its own box (buildChildScalePlanstep 2). That stashed space escapes the nicing applied to the shared/outer axis domain (#659), so the panel's own count scale (45σ=80, σ=1.778, the SIZE scope) and the shared niced map read by the ticks ([0,44]→[0,80], σ=1.818, the POSITION scope) are two distinct scopes, each consumed by its own channel — exactly the self-scaled-vs-inherited shape, so case 1 needs no separate machinery. (It is NOT one axis whose ticks are niced and whose bars are un-niced; it is the self-scaled stash sidestepping nicing entirely.) This stash-escape was subsequently closed by the #659 stage below: nicing is now a per-scope, demand-driven operation applied at the solve, so a scope's map and σ read one domain by construction. - Spacing / non-data pixels (case 2) are already the Monotonic intercept in the frame equation (
width.inverse(allocated)), so the per-segment slope is the scope σ, not a secant. No change needed. - Sub-budget vs inherited (case 3) is a SIZE scope (the panel's local budget) and a POSITION scope (a shared ancestor map) coexisting on one axis. The
AxisScale's two halves are precisely these two scope views; each is a single registry-solved σ.
So AxisScale is a two-scope view, not a slope with a redundant twin. Every map comes from computePosScale via solvePosition (or the equal-measure recentering), every sigma from solveSize; there is no independent slope after Stage 6. The two former fabrication sites are closed: a coord boundary's POSITION-only axis no longer hands down a scope-less σ = 1 (it carries no size σ), and the #582 recentering is a named recenterEqualMeasure operation on the registry (so the dump shows the final σ). If a carrier ever shows two independent slopes — a slope not traceable to a registry scope — that is a bug; two scopes on one axis is the sanctioned multi-scale reading.
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. Landed:ast/solver/scopes.tsholds a per-renderScopeRegistry(on theRenderSession) whosesolveSize/solvePositionare the ONE place σ / posScale is derived; the render root (gofish.tsx),buildChildScalePlan's self-scaled / constraint-budget / shared steps, and the coord boundary (coord.tsxfitAxis) all call it with bit-identical arithmetic. The #618 propagate-vs-re-root guard is now the structural "is this a scope root?" predicate inbuildChildScalePlan(an intermediate budget skips the solve and inherits).GOFISH_DUMP_SCOPESprints one frame equation per scope. The sweep (capture-sweep) stayed clean and the coord/confluence tests (flat ≡ nested σ) pass, confirming goTree/polar render identically.6c — one slope per σ-scope, by construction. Landed (carrier + sole producer): the scope registry is now the ONLY producer of every slope, so the
AxisScale's two halves (sigma= SIZE scope σ,map.sigma= POSITION scope σ) are each a registry-solved scope view — never independent state. The two former fabrication sites were closed: a coord boundary's POSITION-only axis dropped its scope-lessσ = 1placeholder (pixel-identical — no consumer read it), and the #582 equal-measure recentering moved offgofish.tsxinto a namedScopeRegistry.recenterEqualMeasureoperation (soGOFISH_DUMP_SCOPESrecords the FINAL σ). The pre-change inventory (dump + a temporary[solver-check]dual-slope shadow driven to zero across the 260-story sweep) confirmed the only surviving anchored-vs-size disagreements are legitimate two-scope cases (nicing and sub-budget), each half consumed by its own channel. Deferred (residue, acceptable per the plan): the "evaluate at σ, hand concrete sizes down" double bookkeeping still stands —computeSize'sscaleFactorpath (rect,layer,treemap), thesize × σpre-multiply inpetal/ellipse, and distribute'sscaleFactorcallbacks all still evaluate a width at a known σ at the consumer rather than flowing aMonotonicclaim to the scope boundary. Making cells carryMonotonicclaims (so evaluation defers to the boundary) is the larger 6e-adjacent change and was held to keep the carrier landing pixel-clean.6d — translate retirement (#39 stage 3-D). Landed. The per-container translate-composition closures collapse: a pure translate-only bake boundary (
box/frame,offset,enclose) no longer composes its translate into a child-localtoPixelclosure and lower its children parent-relative. Instead it flattens its own subtree via the newbakeChildren(the root bake's z-ordered children-flatten, factored out ofbake) seeded at its absolute translate, and lowers each descendant at its baked absolute transform (INTERNAL_lower(coord, d.transform)) — the identical mechanism the root bake already uses, so the two paths can't drift. The non-identityscaleexception stays agroupwrapper (a flat list can't fold scale). The deadtranslateStringchokepoint (dims.ts) — every legacy<g transform>render had already migrated to the display-listtoPixelfold, leaving it imported but never called — is deleted along with its four dead imports.displayTranslatestays: it is the read of a baked absolute transform that the space-remap boundary (coord'scontentToPixel) and the self-drawers (connect/arrow) apply to their own geometry — those are NOT pure translate-only containers, so they keep composing (the documented exception).No
transform.translatewrite was retired. Render/lower already reads the ledger projection (projectedTranslate/_displayTransform), never the raw written field; the surviving raw writes (_pinAnchor's under-determined-axis fallback, thelayout()seed) feed_projectTranslate's fallback, whichrefs and constraints also read — so none is render-only, and thetranslate === undefined"parent may place me" sentinel is untouched.Gate — pixel, not DOM. The zero-pixel gate is a new
capture-pixelsscript (tests/scripts/capture-pixels.ts): render every story to PNG at HEAD and at the base ref in the SAME local Chromium, pixelmatch at threshold 0. Result: 260/260 stories, 0 pixels moved. Because the display-list path had already inlined translate composition into coordinates (no<g translate>wrappers survived for these boundaries — only thescalegroup), collapsing the closures is byte-identical in the normalized DOM too:capture-diffandcapture-sweep(solver shadow) both report 260/260 identical / clean. The anticipated "benign DOM reshuffle" did not materialize — the reshuffle was spent by the earlier_render→display-list migration, so 6d is pixel- AND DOM-neutral.6e — grid as tracks. Landed. A grid resolves its tracks under the ONE unified sizing rule — the same (max, +) fold every other operator uses:
track claim = Monotonic.max(claims of the cells in that track) grid claim = Monotonic.add(track claims) + gaps (the σ-frame LHS)A claim-less (fill) cell contributes nothing, so an all-fill grid has no track claims and the leftover (allocated − gaps) splits equally — bit-identical to the former
sliceExtentbox-division. Content-sized tracks emerge automatically when cells carry claims (a track sizes to its widest cell; fill tracks share the remainder).maxandaddare BOTH closed over the convex piecewise-linear (max, +) algebra — the composed claim stays aMonotonic, so when every track is claimed with a σ-dependent claim the grid claim inverts against the allocated size through the scope registry exactly like any other frame equation (the common all-constant, fixed-px case is σ-independent and reads back its intercept). Because piecewise claims now flow into cells, themonoEqualtwo-point probe inbbox.tswas upgraded toMonotonic.approxEqual— structural (compare at 0, 1, and every pairwise breakpoint) for piecewise operands, the same cheap two-point line probe for the all-linear common case.Design choice — pre-resolution, not tracks-in-the-graph. Tracks are resolved by
resolveGridTracks(the sizing budget for fill cells) and, for placement, bygridTracksFromSizesfrom the ACTUAL laid-out cell sizes, so the cell-center pins match the real geometry and the solver shadow cannot drift from what rendered. This reuses the rank-2 placement solver as-is (cells pin to their track-intersection centers) — the equations are identical to naming each track a cell in the difference graph, but lighter. The Stage-3 layer bypasses are deleted (space-fold early return, the whole-layer cell-budget special case, the mixing throw); grid now GENUINELY composes — its per-track claim participates in the fold and its cell pins solve jointly with align / position / z-order (apositionpin on a cell overrides its track centering, the authoritative-pin pattern).gridSpaces' ORDINAL track axes stay for axis rendering (a categorical axis can't also be a SIZE magnitude), composing through the same return path rather than an early exit. The at-most-one-grid rule and__grid_cell_inaming stay. Gate: all-fill tables pixel-identical (heatmaps unchanged); one intended mover — the Titanic facet unit grid, whose cells carry intrinsic waffle-size claims, now content-sizes its tracks (facets pack to content instead of stretching to equal box-division).6f — the guards ask the solver. Landed. The last layout guard that reconstructed a fact from the space pass retires.
align's "is this target already positioned?" no longer calls aPlaceable.placementOn(dir)method that rebuilt thefree/determined/conflictlattice from the target'sdataDomainmid-lowering. Instead the placement solve's own authority record — thePlacementOwnershipPlan— answers it, through one predicateisDataPositioned(axis, name). The fact it reads (which children are anchored to a POSITION scope on each axis) is a pure data/scope fact — a child'sdataDomainpresent on that axis — collected once at the layer boundary (layer.tsx, where the child spaces already live) and handed to the solve as an explicit ownership input alongsideinitiallyPlaced/positionPinned. So the constraint path no longer consultsspacePlacementat all (the debug printer and the space folds still do, which is where a determinacy read belongs), andplacementOnis deleted from thePlaceablecontract andGoFishNode.What "determinacy from rank" meant vs. what shipped. The plan framed this as reading free/determined/conflict off the placement subsystem's rank. The corpus forensics (376 guarded cells across every faceted/stacked story) showed the load-bearing targets — faceted scatter/stacked panels — carry no strong pin and no self-placement (
dims.minundefined); their determinacy comes entirely from an anchored posScale (a SIZE/scope fact), which the placement rank cannot see. So a literal "rank of the placement subsystem" reading would have called them free and moved them — a behavior change. The realized form keeps the same fact but sources it honestly (scope membership, a data fact), routing it through the ownership plan so the guard's input is the solver's authority record rather than a late reconstruction. Behavior-preserving: the new path fires on exactly the same 376 cells; full suite + solver-shadow sweep (260/260 clean) + pixel gate all green.This closes the "guards should be blindingly obvious" thread that started the whole design arc: space resolution now keeps only data facts (
width,dataDomain,measure), and every layout guard reads the placement solve's ownership plan, not the space pass.#659 — nicing on the scope, demand-driven. Landed. The pre-layout
resolveNiceDomainstree walk (which mutated every node's POSITION domain in place, and which the self-scaled stash escaped) is deleted. Nicing is now one pure function,niceContinuous(underlyingSpace.ts), applied per σ-scope at the scope's solve: the render root (gofish.tsx), the self-scaled stash and the shared-scale step (buildChildScalePlan), and the layer-local datum-position scale (buildPositionScalePlan). POSITION domains only — never SIZE magnitudes, never deltas — and a coord scope never nices (unchanged exemption;fitAxisnever calls it). Per the semantics settled on the issue (scale resolution is per-scope; axis rendering is per-node — an axis is a view of a scope), nicing is demand-driven: a scope nices iff at least one node in its space-flow region renders an axis on the dim.resolveAxesleaves persistentaxisDemandstamps (theaxiswork flags are cleared by elaboration);GoFishNode.scopeRendersAxiswalks the region — up while neither a self-scaled stash (selfScaledSpace) nor a coord boundary cuts the space flow, then across the region subtree stopping at deeper stashes/coords — so an inner shared scope under an axis-drawing root inherits the root's demand, while a stashed panel (whose space never reached that axis) does not. Result on the exemplar: the marginal histogram's panel count scopes collapse to ONE σ each — the self-scaled map[0,44]→[0,80]and the shared width solve44σ = 80now agree at σ = 1.8182 on the raw domain (the panels draw no count axis, so no nicing demand) — pixel-identical, with the orphan dual solve gone. Corpus enumeration (capture-diffover all 260 stories): the only movers are twoaxes-permutation stories whose bar chart suppresses the y axis ({ x: true, y: false }and{ x: false, y: false }) — their anchored y domain was previously niced by the walk despite drawing no y axis (the old gate niced for ANY truthyaxesvalue), and now stays honestly raw, which also makes{ x: false, y: false }render identically toaxes: false. Everything else — including every axis-less chart and every axis-drawing chart — is pixel-identical. Full suite + solver-shadow sweep (260/260) clean.
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 (resolved in 6e):
max-composition keeps claims in the convex piecewise-linear (max, +) algebra (bothmaxandaddare closed over it), so the representation is unchanged — the only fix needed was the equality probe:monoEqualnow delegates toMonotonic.approxEqual, which compares piecewise operands structurally (at 0, 1, and every breakpoint) and keeps the cheap two-point line probe for the all-linear case. A future non-monotone clamp would still need more.
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).
Status (per stage):
| Stage | Status |
|---|---|
| 0 — docs / vocabulary | landed |
| 1 — placement lattice | landed |
| 2 — span → position | landed |
| 3 — grid cliff | landed |
| 4 — one scale carrier | landed |
| 5 — rank-2 placement (5a/5b/5c) | landed |
| 6a — observe (shadow coverage) | landed |
| 6b — one solve site (scope registry) | landed |
| 6c — one slope per σ-scope | landed (carrier + sole producer; residue deferred) |
| 6d — translate retirement | landed |
| 6e — grid as tracks | landed |
| 6f — the guards ask the solver | landed |
| #659 — nicing on the scope (demand-driven) | landed |
The whole staged plan (0–6f, plus the #659 nicing stage) is landed. The last known dual-slope — a self-scaled marginal panel whose stashed count space sidestepped nicing, so its ticks and bars read two slopes of the same space — is closed by the #659 stage above: nicing lives on the scope, demand-driven, and the panel's two solves agree on one σ by construction. The three other two-scope-on-one-axis stories the 6c inventory flagged (Faceted Chart/Default, Labels/LabelOnSpread, Layered Bars and Area/HoistedVarietySpread) are not #659 variants: each carries one shared POSITION scope (the y map) plus several panel-local SIZE scopes solved by the spread/stack sharedScale machinery, but the marks consume the inherited POSITION σ — the local SIZE σ is computed and not consumed, so there is no rendered dual slope and no correctness issue.
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).
