Skip to content
Speculative. This is design exploration — it describes ideas and direction, not necessarily shipped behavior.

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 unit

Per 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.

  1. Drop the payload: Placement = "free" | "determined" | "conflict". Rewrite offsetSpace (positionNode.tsx:19-35) to read continuousInterval(space)?.min. Delete originOf (zero callers).
  2. Stop storing placement at all: it is in bijection with the shape of dataDomain (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.
  3. Naming: the builder Origin type (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 main empty; 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 that span uses today.

Producers/consumers to migrate (the whole surface):

  • scatter.tsx:116-125 — emit interval-form position instead of Constraint.span;
  • constraints/index.ts — factory, ConstraintSpec union, collectPositionDomains (merge the two branches at index.ts:203-224);
  • compose.ts — the span-specific special cases (spans filter, 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-python untouched 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).

  1. Close the cliff: throw on grid mixed with any non-z-order constraint. Honest error now; routing them through is Stage 6 work.
  2. Demote Constraint.grid to table's private elaboration target (it has no other producer). The public surface for grids is table.
  3. 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, opaque

with 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-diff earns 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

  1. Anchor facts. Facts reference (node, anchor) with anchor ∈ {min, center, max, baseline} (plus a size equation): pin(node, anchor, value, owner) and relate(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-pin and the spannedSize callback are deleted; span's two edges are ordinary min/max anchor pins.
  2. 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's PlacementOwnershipPlan.initiallyPlaced/authoritative sets) seed as weak defaults that a strong rank-2 determination discards — which is exactly what setExtent's ledger reset does today, made explicit as tiers instead of four hand-maintained name-sets.
  3. 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 localAnchorPoint arithmetic anchorOffset does today, moved from lowering-time to post-closure). The BFS components, pin application, distributeOriginFor sequence origin, and normalized-origin fallback (placementSolver.ts:222-251) are preserved verbatim — that part of the solver is already general.
    • baseline participates in closure as min + c where c is a node-local constant from localAnchor (independent of the size unknown; a span-determined box has baseline ≡ min since its local frame resets to [0, size]).
  4. 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 at placementSolver.ts:289-301. BBox conflicts and graph conflicts merge into one named-conflict report shape (same owner/asserted/implied fields).
  5. Deletions. span.ts's collectSpanExtents/SpanExtent/ lowerSpanEdgePins, the edge-pin fact kind, the classifyAxisFacts rewrite, PlacementOwnershipPlan.spanPinned, the spannedSize lowerer parameter, and the extent maps threaded through lowerPlacementConstraints/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.ts runs the two-phase solve and compares final (min, size) per (node, axis) against the shipped positions, and tests/scripts/capture-sweep.ts renders 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:solvePlacementConstraints now 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) and rank2Placement.ts are gone; the interval form's edges are ordinary strong anchor pins emitted by position.ts; spanDatumInterval folded into position.ts; the edge-pin fact kind, the rank-1 PlacementProgram, the spannedSize lowerer callback + anchorOffset branch, and PlacementOwnershipPlan.spanPinned are deleted. differenceGraph.ts is 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 for minCoeff = 0 boxes. 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.abs semantics 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_CHECK sweep 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:

  1. the root: gofish.tsx:385-392 (width.inverse against the canvas), posScaleFromSpace at gofish.tsx:371-377, and the recentering writeback around gofish.tsx:405-437;
  2. self-scaling regions (explicit pixel size on an axis): buildChildScalePlan step 2 (proposalPlan.ts:205-216);
  3. 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;
  4. shared-scale scopes: step 4 (proposalPlan.ts:246-257) + spread's sharedScale annotation;
  5. 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 (buildChildScalePlan step 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.ts coverage to the modes it skips: center-mode distribute, pre-placed/data-positioned chains, nest, grid, coord scopes. Run all stories under GOFISH_SOLVER_CHECK until 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 buildChildScalePlan steps 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.ts holds a per-render ScopeRegistry (on the RenderSession) whose solveSize / solvePosition are the ONE place σ / posScale is derived; the render root (gofish.tsx), buildChildScalePlan's self-scaled / constraint-budget / shared steps, and the coord boundary (coord.tsx fitAxis) all call it with bit-identical arithmetic. The #618 propagate-vs-re-root guard is now the structural "is this a scope root?" predicate in buildChildScalePlan (an intermediate budget skips the solve and inherits). GOFISH_DUMP_SCOPES prints 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 σ = 1 placeholder (pixel-identical — no consumer read it), and the #582 equal-measure recentering moved off gofish.tsx into a named ScopeRegistry.recenterEqualMeasure operation (so GOFISH_DUMP_SCOPES records 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's scaleFactor path (rect, layer, treemap), the size × σ pre-multiply in petal/ellipse, and distribute's scaleFactor callbacks all still evaluate a width at a known σ at the consumer rather than flowing a Monotonic claim to the scope boundary. Making cells carry Monotonic claims (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-local toPixel closure and lower its children parent-relative. Instead it flattens its own subtree via the new bakeChildren (the root bake's z-ordered children-flatten, factored out of bake) 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-identity scale exception stays a group wrapper (a flat list can't fold scale). The dead translateString chokepoint (dims.ts) — every legacy <g transform> render had already migrated to the display-list toPixel fold, leaving it imported but never called — is deleted along with its four dead imports. displayTranslate stays: it is the read of a baked absolute transform that the space-remap boundary (coord's contentToPixel) 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.translate write 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, the layout() seed) feed _projectTranslate's fallback, which refs and constraints also read — so none is render-only, and the translate === undefined "parent may place me" sentinel is untouched.

    Gate — pixel, not DOM. The zero-pixel gate is a new capture-pixels script (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 the scale group), collapsing the closures is byte-identical in the normalized DOM too: capture-diff and capture-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 sliceExtent box-division. Content-sized tracks emerge automatically when cells carry claims (a track sizes to its widest cell; fill tracks share the remainder). max and add are BOTH closed over the convex piecewise-linear (max, +) algebra — the composed claim stays a Monotonic, 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, the monoEqual two-point probe in bbox.ts was upgraded to Monotonic.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, by gridTracksFromSizes from 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 (a position pin 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_i naming 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 a Placeable.placementOn(dir) method that rebuilt the free/determined/conflict lattice from the target's dataDomain mid-lowering. Instead the placement solve's own authority record — the PlacementOwnershipPlan — answers it, through one predicate isDataPositioned(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's dataDomain present 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 alongside initiallyPlaced/positionPinned. So the constraint path no longer consults spacePlacement at all (the debug printer and the space folds still do, which is where a determinacy read belongs), and placementOn is deleted from the Placeable contract and GoFishNode.

    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.min undefined); 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 resolveNiceDomains tree 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; fitAxis never 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. resolveAxes leaves persistent axisDemand stamps (the axis work flags are cleared by elaboration); GoFishNode.scopeRendersAxis walks 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 solve 44σ = 80 now 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-diff over all 260 stories): the only movers are two axes-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 truthy axes value), and now stays honestly raw, which also makes { x: false, y: false } render identically to axes: 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 (both max and add are closed over it), so the representation is unchanged — the only fix needed was the equality probe: monoEqual now delegates to Monotonic.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):

StageStatus
0 — docs / vocabularylanded
1 — placement latticelanded
2 — span → positionlanded
3 — grid clifflanded
4 — one scale carrierlanded
5 — rank-2 placement (5a/5b/5c)landed
6a — observe (shadow coverage)landed
6b — one solve site (scope registry)landed
6c — one slope per σ-scopelanded (carrier + sole producer; residue deferred)
6d — translate retirementlanded
6e — grid as trackslanded
6f — the guards ask the solverlanded
#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).