Collapsing the Two Passes: One Propagation, Printable Equations
Outcome (June 2026) — the fusion this note explores was NOT adopted. What landed from it (all gated REAL = 0): the per-node
Monotonicbbox ledger is the geometry authority and(intrinsicDims, transform)is now a projection of it (the redundanttransform.translatewrites are retired across pins and operator self-placement;intrinsicDimsstays the frame-invariant local box); the constraints (distribute/align/position/span) are facet-equation emitters (emit*produces the equations as data,apply*commits). What was rejected is the headline — fusing sizing and placement into one simultaneous solver. On inspection its justification (a placed edge feeding back into σ) didn't hold: genuine size↔place cycles are rare-to-nonexistent in dataviz (layout is one-way, size→place); the "labels fit" motivation is a sizing-time claim (text extent is known without placement — see that section, now reframed); aspect-ratio is a trivial cross-axismin(#582); floors/caps are piecewise sizing claims (#580). So the direction is two constraint-based passes kept separate — sizing (already σ-affine) then a placement pass that derives from the sizing solution where determined, leaving the free DOF (a scope's origin) to bubble up. Read the rest as the explored target; the roadmap section below records what's actually true.
Claim. GoFish's layout core is two passes — a max-plus fold that solves the scale factor σ from size claims, then a separate placement walk that positions things once σ is known. Most of the engine's apparent complexity is the seam between them: a zoo of placement modes (distribute's edge/center walk, align's anchor walk, position's write-once pin, span's two-edge stamp, nest's and contain's 2-of-3 arithmetic), each a bespoke way to write a child's geometry. Replace the whole thing with one model — a per-node, per-axis ledger of facet equations whose values are σ-affine Monotonics, solved by single-assignment propagation — and that zoo collapses into "emit equations, then propagate." The payoff is not fewer lines; it is uniformity (one mechanism to reason about) and printable equations (every facet is a max(aσ+b, …) you can read off). (The original claim went further — that fusing the passes lets a placed extent feed back into σ so labels stop overhanging. The emitter half landed; the fusion did not — see the Outcome banner above. The ledger + emitters give the uniformity and printability without it.)
This note records the target, what genuinely collapses, the three couplings that deliberately don't, the one open fork, and a staged path. It is the end-state size-claims designs the ownership for and layout-synthesis frames the algebra for; the linsys bbox (underlying-space, #39) is its seed.
The model
A node owns, per axis, a ledger of facet equations. The facets are min/max/center/size; the unknowns per axis are (min, size) (the other two are affine in those — max = min + size, center = min + size/2). Equations come from three places:
- Owned affine relations — the
max = min + sizefamily. Already the bbox'sCOEFFS. - Constraints, as equation emitters rather than placement walks (table below).
- The scope's σ — the one unknown closed when a scope's content claim meets its allotted pixels. A facet's value may be a
Monotonicin σ (a bar'ssize = count·σ), not just a number.
Layout is then single-assignment propagation: seed the known facets, derive whatever a rank-2 fact determines, and when a scope closes, fold its children's edges (position + size, σ-affine) into the scope's σ-claim, invert once, back-substitute. Over- and under-determination are named reports, not silent last-writer-wins (the size-claims ownership rule, generalized from positions to all facets).
What collapses
The placement-time mode zoo becomes "add facet equations; let the solver place." These stop being separate code paths:
| today (a bespoke placement) | unified (a facet equation) |
|---|---|
distribute edge-walk vs center-walk | difference constraint on the min-chain vs the center-chain |
align anchor walk | equality on the chosen facet between siblings |
position pin (write-once place()) | one owned facet equation |
span two edges + setExtent rank-1/rank-2 dispatch | add facets; rank 2 ⇒ size falls out (the bbox already does this) |
nest 2-of-3, contain 2-of-3 | the same rank-2 solve, not bespoke arithmetic |
| pass-1 SIZE fold and pass-2 placement | one propagation |
align's SIZE→POSITION conversion (makes the count axis) | read the axis domain off the resolved facets |
Most apply* functions and the setExtent rank dispatch reduce to emitting facet equations (this part landed — emit*/apply*). The two-pass structure, though, was kept (the Outcome banner): sizing and placement stay separate solves, not one fused propagation. Edge-vs-center survives only as which facet the difference constraint relates — a parameter, not a branch.
And because every facet is a Monotonic, the equations are printable: bar.size = 30σ, label.min = 30σ + 10, plot.size = max(30σ + 10 + th, …). That is the readability win layout-synthesis calls for, and the reason to keep Monotonic structure-preserving (#568) rather than collapsing maxima into opaque closures.
What deliberately does not collapse
Three couplings are irreducibly non-local. They are accepted as explicit overlays on the per-axis propagation — keeping them separate is the point, not a wart:
- σ is per-scope, cross-child. It is not a per-node facet; it is solved when a scope (a
sharedScaleboundary) closes, by inverting the max-plus fold of that scope's content edges. The scope concept stays; σ is a special unknown the propagation pauses to solve. This is where fold and ledger meet. - Aspect ratio is cross-axis.
size_y = r · size_xcouples the two per-axis systems — the one place per-axis decomposition breaks. It rides on top as a single cross-axis equation (circles/images/waffle), not as part of either axis's propagation. - Measure / scale kind stays. A SIZE-in-σ and a data-POSITION are placed by the same propagation, but they remain different kinds for axis rendering and unit-checking (underlying-space).
UnderlyingSpacedoesn't vanish; it stops driving the placement walk.
These three are exactly the structure that makes GoFish charts, not just diagrams: a diagram solver (Bluefish) needs none of them. Carrying them as named overlays — rather than dissolving them — is what keeps scales, shared scopes, and aspect locks first-class.
The one open fork
Override / authority. A pie glyph (placed by a polar coordinate transform) and a scatter glyph (self-placed in a linear frame) both emit a position facet owned by "layout". A parent pin must override the scatter one and must not override the polar one — and owner identity alone can't tell them apart, which is why the current override flag is a per-call opt-in (and is genuine, not removable by naive owner-priority). The unification does not automatically dissolve this. The hypothesis to test: a coordinate-transform-derived position is a hard equation, so a parent pin over it is a named over-determination (correctly — you can't reposition a polar-placed glyph), whereas a linear self-place is a default the pin supersedes. If that holds, authority becomes equation strength (hard vs default) rather than a flag. If it doesn't, a per-call authority signal stays. This is the one design decision to resolve with a spike before committing the solver.
What it takes (staged, each gated capture-diff REAL = 0)
Ledger holds
Monotonicfacets — σ-affine values, not only numbers (finishes #39 step 1). Type + tests only; no behavior change. ✅ Landed (BBoxnow holds aMonotonicper facet;read(facet, σ)evaluates,readMonoreturns the claim; all-numeric callers unchanged).dimsreads the ledger;place/setExtent/intrinsic writes record into it — the risky core. Behavior-preserving; switch readers over one at a time. Watch thebaselineanchor,embedded, and thetranslate === undefined"unplaced" signal (it must survive as "facet not yet determined", never become 0). 🟡 Down-payment attempted and reverted (recorded so the next attempt doesn't repeat it): reroutingplace()'s positional write throughsetExtent(so all of align/distribute/nest/position/span funnel through one bbox primitive) was gated REAL = 0, but/code-reviewfound a latent divergence —setExtentreconstructs acenter/maxanchor geometrically (min + size/2,min + size), which disagrees withplace()'s use of the storedintrinsic.center/maxwhen a box is asymmetric (e.g. nodesposition.tsxbuilds with a nonzero local min, reached via nest/distribute/ grid/treemap center-placement). No current story triggers it, but a latent divergence in the hottest layout method — plus a per-placementBBoxallocation — is not worth a standalone reroute. The correct form is the authoritative stage 2: a persistent per-node ledger thatplace,setExtent, anddimsall share, with the local-frame/absolute split reconstructed faithfully (a node knows its size before its position: rank-1,min/centerundefined), plus migrating the 108 directintrinsicDimssites. That is the interactive, story-by-story migration below — not a blind reroute.✅ Down-payment re-landed correctly (the root cause, not the reroute):
place()andsetExtent's rank-1 pin now both place an anchor through a single purelocalAnchorPoint(anchor, min, size)(dims.ts) that derivescenter/maxfrom(min, size)instead of reading a stored facet — so the two paths cannot diverge on an asymmetric box, and the rank-1 pin allocates noBBox(the genuine 2-unknown solve stays only for rank-2 size-setting). Both of the reasons the reroute was reverted are gone, gated REAL = 0 across 189 stories + alocalAnchorPointcontract test.✅ All
dimsreaders now derivecenter/max(next gated step): thedimsgetter (GoFishNode+ theGoFishRefmirror) andplace()'s anchor guard computecenter/maxfrom the placed(min, size)instead of reading the stored facet — they read back only once a box is placed AND sized. This closes the stored-vs-derived inconsistency: a stored asymmetriccenter(whatposition.tsxwrote ascenter: xPos) can no longer be observed throughdimsbyalign/distribute/etc.position()now stores only its local(min, size). REAL = 0.✅ Shape renders derive; stored-box
center/maxwrites removed. The shape_renderfunctions (rect/ellipse/petal) shared an identical block that read their ownintrinsicDims[i].center/maxto draw; they now call onedisplayDims()helper that derives. With no reader left,setExtentand all six shapelayout()s stopped writingcenter/max— every stored box is now{min, size}only. All four derivation sites (localAnchorPoint, bothdimsgetters,displayDims) anchorcenter/maxoff the magnitude|size|, so a negative bar stays correct. (As of the item below,rectstores that box canonically — truemin+ unsignedsize— so|size|is now belt-and- suspenders rather than load-bearing.)center/maxdo NOT leaveInterval— and shouldn't. The investigation corrected the premise:Intervalis a general dim type with three uses that legitimately carrycenter/max— input elaboration (cx/x2, read byimage/textfor center-positioning andrectfor min/max spans viaelaborateDims), the computeddimsoutput (read byalign/distribute/coord/overhang), and coordinate-transform domains ({min, max, size}, wheremaxis a real domain endpoint). Only the stored node box should omit them, and it now does. A dedicatedLocalBoxtype forintrinsicDimswould add compiler enforcement but buys little (structural typing already accepts the narrower shape), and the remaining operator writes (treemap/offset/arrow/connect) are now dead — the getter derives, ignoring them — so they're cosmetic cleanup, not correctness. The 108-writer migration off directintrinsicDimsmanipulation (the path to a single authoritative ledger) remains the larger structural work.Deferred follow-ups (PR #576, stage-2 cleanup), roughly in order:
- ✅ Killed the
GoFishNode↔GoFishRefdims-getter duplication. Both getters were verbatim copies; the body is now the sharedcombineDims(intrinsicDims, transform)indims.ts— theundefined-preserving sibling ofdisplayDims.place()stays per-class (Node has a write-once guard +ensureTranslate; Ref does not), so a sharedplaceAnchor/Placeablebase is still the deeper version available later. - ✅ Removed the dead operator
center/maxwrites (treemap/offset/arrow/connectintrinsicDimsliterals). Dead since the getter derives, andsize = max − min ≥ 0at every site, so the derivation reproduced them. - ✅ Extracted a private
_pinAnchorshared byplace()'s determined branch andsetExtent's rank-1 pin — both wereensureTranslate()[dir] = value − localAnchorPoint(...). - ✅ Normalized
rect's signedsizeat the source.rect.tsxnow storesmin: Math.min(0, w)+size: Math.abs(w)— true min, unsigned extent — instead ofmin: w>=0?0:w+size: w. The consumer audit found thatminwas already canonical andmax/centerwere already derived via|size|, so the only signed reads wererect's own_render: branch-1's manualmin + size/2(a latent wrong-center for negative point-rendered rects, nowdisplayDims.center) and the SVGwidth/height. TheMath.abs(rawWidth)/rawWidth < 0sign-correction in the line/area branches was already dead (max − minis the unsigned extent) and is now removed.petal's-size/2readspetal's own dims, unaffected. Gated: capture-diff REAL = 0 (189 stories, incl. the negative-bar story), 64 tests pass, negative bar verified by screenshot.
The big remaining structural work is the migration off the dual
(intrinsicDims-local box, translate)representation onto one persistent per-node ledger thatplace/setExtent/dimsall share, then stages 3–4 below. It proceeds in gated increments:- ✅ Stage 0 — persistent ledger, observe-only.
setExtent's rank-2 solve now accumulates into a persistent per-axisBBoxon the node (GoFishNode._bbox) instead of a fresh-per-call one, so a second constraint pinning the same axis is checked against the first (a named over-determination, not a silent re-solve — the debt the old interim note flagged).dims/render still derive from(intrinsicDims, transform), so output is byte-identical (REAL = 0, 64 tests). - ✅ Stage 1 — every mutator records into the ledger, faithfully. The
layout()wrapper seeds each axis'ssize(and the absoluteminfor a self-placing shape);_pinAnchorrecords the absolute anchor facet (and rebuilds the axis on an override pin); a rank-2setExtentresets the axis and records its determining facets — so the ledger always mirrors the written(intrinsicDims, transform). A dev-only assertion (GOFISH_LEDGER_CHECK, zero-cost off) compares ledger-derived(min,size)againstcombineDimson everydimsread; zero divergences across all 189 stories, the confidence stage 2 needs before flipping the read. (At this stagebaselineandembeddedstayed out of the ledger — origin pin / layout-fold flag, not min/max/center facets.baselinewas folded in later, in stage 3, once the σ-affine model recognized the origin as the intercept;embeddedis still out.) Gated REAL = 0 + 64 tests. - ✅ Stage 2 —
dimsreads from the ledger (the risky flip). Thedimsgetter now derives its absolute(min, size)from the persistent ledger on every axis the ledger fully solves (re-deriving center/max vialocalAnchorPoint), falling back tocombineDimson the(intrinsicDims, transform)split where the ledger is under-determined or absent. Render still reads the split directly, so onlydims-getter consumers (constraints, align/distribute, the layer bbox fold) change — no pixels move through render.embeddedis still read off the local box (a layout-fold flag, not a ledger facet). The stage-1 assertion stays wired in, now fedcombineDimsindependently of the getter's result, and reports zero divergences across all 189 stories. Gated REAL = 0 + 64 tests. - Stage 3 —
(intrinsicDims, transform)becomes a projection of the ledger; the ~29 direct write sites stop double-writing, migrated one increment each, each gated REAL = 0 + 64 tests:- 3-A (done) — dropped the last operator center/max literal writes (
layer;treemap/offset/arrow/connectwent in stage-2 cleanup), so stored boxes are{min, size}only anddims/the ledger re-derive center/max. - 3-B (done) —
transform.translatebecomes a derived view of the ledger:ledger.min − intrinsicDims.minon a fully solved axis, else the written translate. Added as a private projector plus a dev assertion (GOFISH_LEDGER_CHECK) that projected == written translate on every solved axis — zero divergences across all stories. The field is still written (render/flatten untouched), so this is provably inert; it establishes the invariant 3-C relies on. - 3-D (the coord-render decoupling, done as the unblock) —
flattenLayoutno longer mutatesnode.transform. D0 extracted it into its ownbake.tsmodule (pure move). D1 made the bake emit aDisplayObject[]rendering IR (_displayObject.ts, formerly an empty stub) rather than mutating: each entry pairs a mark with its baked absolute transform, which coord feeds intoINTERNAL_render(coordTransform, transform)as an override threaded to the node's_render/_renderLabel. The scenegraph's parent-relative transforms stay intact, removing the one place the split and the ledger diverged — which was the whole reason 3-D blocked 3-C. (The originally-planned terminal whole-tree bake pass was dropped: a global post-layout bake would make a coord-transformed node's screen bbox unreadable by downstream layout — e.g. a radar chart's cartesian layer connecting refs to polar points — so the coord boundary must bake during layout. Tracked as a separate future track.) Groundwork from earlier: render-sidetransform.translatereads go through a singledisplayTranslate/translateStringchokepoint indims.ts. (This records stage 3-D as it landed. The draw path has since moved on:_render/INTERNAL_renderare gone, replaced by per-shapelower()/INTERNAL_loweremitting a display-list IR — see Rendering. The bake still feeds each entry its baked absolute transform; only the method it calls changed.) - 3-C (in progress) — make
transform.translatea ledger projection and retire the writes. Value reads migrated (both inert, REAL = 0): render reads a ledger-derived_displayTransformgetter, and_refaccumulation readsprojectedTranslate— so no value-reader depends on the raw written field where the ledger is solved. Then write retirement hit a real gap, now closed: deleting the writes needs the placement-state checks (place()'s already-placed short-circuit,_pinAnchor's override) to read the ledger instead, which needs ledger-min-defined ⟺ translate-defined. That held everywhere exceptbaseline: abaselinepin wrote the translate but recorded no ledger facet, andbaselineis pervasive (the root placement, coord placing its children). The σ-affine model resolved it: abaselinepin sets the box's local-0 origin (the affine's intercept), and screen-min = origin + localMin, so_pinAnchornow recordsmin = value + localMinfor a baseline pin — the ledger represents baseline like any other anchor and_projectTranslate/dimsderive it. Gated REAL = 0 + theGOFISH_LEDGER_CHECKmirror clean across all stories. Next: migrate the placement-state checks offtransform.translate(now that ⟺ holds), then retire the translate writes one site at a time.
- 3-A (done) — dropped the last operator center/max literal writes (
- ✅ Killed the
✅ Constraints are facet-equation emitters.
distribute/align/position/spaneach split into a pureemit*(produces the placement equations as data) + a thinapply*commit, behind unchanged signatures — the seam a constraint-based placement pass consumes. Gated REAL = 0.Two constraint-based passes — kept separate (NOT fused). Decision (June 2026): do not fuse sizing and placement into one solver. Sizing is already a σ-affine constraint solve (the
MonotonicSIZE domains composed bottom-up and inverted per scope — see below). Placement becomes its own constraint-based pass that resolves the emitted equations, and — the key point — for the determined common case it is a derivation of the sizing solution, not a separate solve: a stack's positions are the running cumsum of the solved sizes. Only genuinely under-determined placement needs solving, and then the lone free DOF is the scope origin (it bubbles up astranslate-undefined / baseline for the parent to pin); over-determined → a named conflict (BBoxalready returns these). The two passes run one-way (size → place); placement reads the sizing solution but they stay distinct.Cross-cutting features layer on the sizing pass, additively — not via fusion: equal-aspect / shared scale across axes (
σ_x = σ_yreconciliation, #582) and min/max size floors & caps (piecewise σ-affine claims, #580). Both sit on the already-σ-affine sizing solve; neither needs the placement pass.
Making the ledger authoritative is a representation migration, not a place() refactor
Flipping the dims getter to read the ledger (stage 2) was the small step — it landed as a clean getter change because stages 0–1 had already made the ledger a faithful mirror, so the read-flip was provably REAL = 0. The big remaining work is retiring the redundant split writes (stage 3), and that was initially under-estimated as "~38 place() callers". The reality, measured: intrinsicDims is referenced 108 times across 20 files, and transform.translate across 23 — and crucially it is not encapsulated. Shapes set their own intrinsicDims (rect/ellipse/text/ petal/polygon/image), and operators read and write it and translate directly (treemap, offset, porterDuff, scatter, arrow, connect, enclose, position, coord). Making the per-node ledger the sole source of geometry therefore means migrating every one of those sites off direct intrinsicDims/translate manipulation and onto facet writes — not flipping a single getter. A purely additive shadow ledger (one that records but doesn't drive geometry) would be redundant state, not authority. And the local-frame / absolute-position split that (intrinsicDims, translate) encodes — used during a node's own layout, before its parent places it — has to be reconstructed in the ledger (a node knows its size before its position: rank-1, the min/center facets undefined). The asymmetric-center / baseline cases are where a naive absolute-only ledger silently diverges.
Stage 3 was a deliberate, interactive, multi-session migration, gated story by story — not landed in one blind pass — and it is now substantially done: the ledger is the geometry authority and (intrinsicDims, transform) is a projection of it on every solved axis (transform.translate retired across pins and operator self-placement; intrinsicDims stays as the frame-invariant local box). Its blast radius was the whole layout core (_node.ts, layer.tsx, every apply*, compose.ts, all shapes + geometry operators); the pixel gate was the net, REAL = 0 throughout.
A fused solver was considered and rejected (June 2026). The plan once had a step 4 that fused sizing and placement into one Bluefish-style constraint solver — justified by "a placed edge feeds back into the σ-claim." On inspection that justification didn't hold: the motivating cases reduce to the sizing pass (which is already σ-affine) or to trivial cross-axis reconciliation, and genuine size↔place cycles are rare-to-nonexistent in dataviz (layout is one-way: size then place). So the two passes stay separate and constraint-based (item 4 above) — placement derives from the sizing solution where determined, which is REAL = 0 and carries no convergence risk, rather than adopting a simultaneous solver the corpus doesn't need.
A motivating consequence: labels that fit (a sizing-pass claim, not a fusion)
Today the label overhang is structural. Bars A=10, B=30, C=20; a value label spacing = 10 above each σ-scaled bar top, label height th:
- Today. The bars' claim
max(10σ, 30σ, 20σ) = 30σsolves against heightH⇒σ = H/30. The label's+10+this not in that claim, so the tallest bar's label overhangs the top by10 + th. - The fix. The "bar + its label" effective top edge is
30σ + 10 + th— and cruciallythis known at sizing time (text metrics need only the string + font, not placement), and+10is a constant. So this is a richer sizing claim, not placement feedback: fold the label's extent into the bar's own SIZE claim —max(30σ + 10 + th, …) = H⇒σ = (H − 10 − th)/30— and the bars shrink just enough that the tallest label fits.Monotonic.smul/adds, max-folded, inverted — entirely within the sizing pass.
This is the case that once seemed to require fusing placement back into σ. It doesn't: because the label's size is a sizing-time fact (size↔size, not size↔place), "the mark's claim includes its label" lives in the sizing solve. It is the same family as the floors/caps work (#580) — enriching what a size claim can express — and needs neither the placement pass nor a fused solver.
The visible symptom today: content sits at the origin, not fitted to the canvas
The label-overhang above is the small version. The large, already-visible bug (pre-dating this work) is that many graphics render translated too low / into the bottom-left, with empty space above and to the right. Confirmed examples: the bump, croissant, flower, nested-boxes-tree, and python-tutor-memory diagrams.
Measured mechanism (gofish.tsx, PADDING = 40): the root frame is scale(1, -1) translate(leftReserve, -(height + topReserve)), i.e. content is pinned at the origin with a 40px margin, and the canvas is the requested size. When the content is smaller than the request, it does not fill or center — it piles up at the origin, which y-up renders at the bottom-left. Two variants of one gap:
There are two genuinely different situations, and they must NOT be treated the same:
- Data charts that DO have a σ-scope DOF (flower: x-axis runs to 140, data reaches ~50): here the scale can stretch, so it should — fit σ to the content's actual extent so the axes span the canvas. This is a sizing-pass fix (resolve σ against the real content bbox, not a stale request); no fusion.
- Fixed-size content with NO scaling DOF (nested-boxes-tree requests 720×560; the tree is ~150×400): there is nothing to fill with — no σ to stretch, no rule for how a diagram would grow to 720×560. So it must not be stretched. The only question is where the intrinsic-size content sits in the larger canvas. It currently lands bottom-left purely because of the coordinate system (
scale(1,-1)+ origin anchoring) — that's not "filling," it's just the default origin. The open call is top-left vs centered (charts naturally want a bottom-left origin; free-space / diagrammy graphics want top-left), and it's deferred. Tracked at #574 (centered-origin roots) and addressed in part by PR 575 (render intrinsic-size content at its intrinsic size).
So "fill the canvas" applies only to the σ-DOF case; the no-DOF case is a placement/coordinate-origin question, not a scaling one. The baseline = origin anchoring ([[feedback_baseline_origin_semantics]]; place() still carries a // TODO: revisit baseline case) is what fixes the corner. Both are reachable within the two separate passes — neither needs the fusion.
