Skip to content

Fix fitted point optimisation#133

Open
terryspitz wants to merge 25 commits into
mainfrom
claude/debug-bracket-syntax-1GJ1K
Open

Fix fitted point optimisation#133
terryspitz wants to merge 25 commits into
mainfrom
claude/debug-bracket-syntax-1GJ1K

Conversation

@terryspitz
Copy link
Copy Markdown
Owner

Add FontTests.O_And_o_Outline_IsHorizontallyAndVerticallySymmetric which
verifies that every outline knot of 'O' and 'o' has a corresponding mirror
point within 1 unit, both horizontally (about the bounding-box cx) and
vertically (about cy).

The test revealed that the DactylSpline solver's Nelder-Mead optimiser was
perturbing the initial tangent angles and converging to a slightly asymmetric
solution for these four-point oval glyphs. Fixed by adding explicit cardinal
tangents (N/E/S/W) to the 'O' and 'o' backbone definitions so the solver
treats the tangent directions as fixed and only optimises handle lengths.

https://claude.ai/code/session_013obEC5m12xpz7Rcoz1NKH3

claude and others added 23 commits May 5, 2026 20:06
Add FontTests.O_And_o_Outline_IsHorizontallyAndVerticallySymmetric which
verifies that every outline knot of 'O' and 'o' has a corresponding mirror
point within 1 unit, both horizontally (about the bounding-box cx) and
vertically (about cy).

The test revealed that the DactylSpline solver's Nelder-Mead optimiser was
perturbing the initial tangent angles and converging to a slightly asymmetric
solution for these four-point oval glyphs. Fixed by adding explicit cardinal
tangents (N/E/S/W) to the 'O' and 'o' backbone definitions so the solver
treats the tangent directions as fixed and only optimises handle lengths.

https://claude.ai/code/session_013obEC5m12xpz7Rcoz1NKH3
'0' and 'Q' share the same four-point symmetric oval (hl~tc~hr~bc~) as 'O'.
Without explicit directions the Nelder-Mead optimiser perturbs the initial
tangent angles and converges to a slightly asymmetric solution, for the same
reason as was fixed for 'O' and 'o'.

Adds N/E/S/W tangent hints to the oval part of each definition so the
DactylSpline solver treats those directions as fixed, producing a
geometrically correct symmetric oval outline.

https://claude.ai/code/session_013obEC5m12xpz7Rcoz1NKH3
Any point using bracket notation (fitted coordinates) should carry an
explicit cardinal tangent — this pins the optimizer's direction at extremal
points and prevents the Nelder-Mead solver from drifting off the symmetric
solution.

Geometric rule: if y is fitted (y_fit=true) the point is a left/right
extremum → tangent is vertical (S); if x is fitted (x_fit=true) the point
is a top/bottom extremum → tangent is horizontal (W or E).

- 'a': x(c) → x(c)W  (top of bowl, going right→left)
- 'B': (bh)r → (bh)rS, (th)r → (th)rS  (rightmost of each bowl)
- '@': te(c) → te(c)W, be(c) → be(c)E  (top/bottom of inner loop)

'e', 'G', 'n', 'P', 's' already followed this rule correctly.

https://claude.ai/code/session_013obEC5m12xpz7Rcoz1NKH3
Fitted coordinates (bracket notation) mark extremal points where the
solver optimises one coordinate. The correct tangent direction follows
mechanically from which coordinate is fitted and the direction of
travel through the point, so there is no reason to repeat it in every
glyph string definition.

Rule added to parse_curve:
- y_fit=true (point slides along fixed x, a left/right extremum)
  → vertical tangent: S if prev.y > next.y, else N
- x_fit=true (point slides along fixed y, a top/bottom extremum)
  → horizontal tangent: E if next.x > prev.x, else W
Only applied to interior points (or all points of a closed curve)
where both neighbours are available.

Explicit tangents already in the string definitions always take
precedence; the auto-assignment is a fallback for None slots.

Remove the now-redundant explicit tangent suffixes from the eight
affected glyph definitions: @, a, B, e, G, n, P, s.

https://claude.ai/code/session_013obEC5m12xpz7Rcoz1NKH3
Extends the bracket-notation pattern to every glyph with extremal
curve points: bowl letters b/c/d/p/q, capitals C/D/G/R, arches h/J/U/u,
descenders g/j/y, digits 2/3/6/8/9, S/$/?/t.

At each geometric extremum (top, bottom, left, right of an arc), the
fixed coordinate is replaced with a fitted coord so the DactylSpline
solver optimises it, and the auto-tangent rule assigns the correct
cardinal direction — eliminating the need for explicit N/S/E/W suffixes
at these points.

https://claude.ai/code/session_013obEC5m12xpz7Rcoz1NKH3
From main: j redesigned (2-point arc with explicit dlE), t simplified
(xblc start, bccrW endpoint), u uses b(llcr) fitted coord.
Our change: D uses (h)r for the right extremum, combined with main's
tlE end-tangent.

https://claude.ai/code/session_013obEC5m12xpz7Rcoz1NKH3
Replaces explicit cardinal tangents (hlN, tcE, hrS, bcW) with bracket
notation ((h)l, t(c), (h)r, b(c)) so the auto-assignment rule handles
the N/E/S/W directions. Consistent with the approach applied to all
other curved glyphs.

https://claude.ai/code/session_013obEC5m12xpz7Rcoz1NKH3
When a point had x fixed (non-NaN) but y free (NaN) — e.g. `(xb)l`
with x_fit=false, y_fit=true — the copy condition `if IsNaN result.x`
was false so the solver's optimised y was never written back, leaving
y=NaN in the output. Extend the guard to `IsNaN x || IsNaN y`.

Add BracketFittingTests to verify:
- x(c) and x(cr) produce identical solved results (parsed value inside
  brackets is correctly discarded in favour of the solver-driven init)
- A bracket-free point keeps its fixed x=C after solving

https://claude.ai/code/session_013FzUDrEU4omtyJJtnSaqrP
Two bugs in the parseGlyph response handler:
1. Missing setSolveResult(null) meant the previous glyph's SVG path
   persisted on screen until the new solve completed, making it appear
   the tab didn't update.
2. Errors for id=-3 (parseGlyph) were silently ignored; now logged.

https://claude.ai/code/session_015jzWn7EFDMMYsmWwYsKn8F
When a knot has a fitted y-coordinate (y=null) but a fixed x-coordinate,
the result BezierPoint's y stayed NaN after solving because the copy
condition only checked isNaN(x). Affected glyphs like 'o', 'p', 'q' that
use parenthesised y-coordinates (e.g. "(xb)l") for extremum positions.

Change the combined if/then to two independent checks so each coordinate
is independently populated from the solver's output when NaN.

https://claude.ai/code/session_015jzWn7EFDMMYsmWwYsKn8F
…n solveAndGetPoints

DControlPoint gains x_init/y_init (float option) fields that carry the
bracket-syntax hint value (e.g. the parsed x of `x(c)`) for potential
future use without affecting current behaviour.  Solver.initialise() still
uses the neighbour-average as the Nelder-Mead starting point — using the
raw hint causes a degenerate simplex when the hint is 0 (e.g. `b(l)` with
L=0).

Also fix the copy-back condition in solveAndGetPoints: the previous
`if IsNaN result.[resIdx].x` guard missed points where x is fixed but y
is free, leaving y=NaN for glyphs like B, G, P that use `(y)x` patterns.

New BracketFittingTests fixture verifies that bracket content is ignored
for the free coordinate (x(c) ≡ x(cr)) and that a fixed coordinate is
unaffected by brackets.

https://claude.ai/code/session_013FzUDrEU4omtyJJtnSaqrP
…em size

The two paths diverge significantly:
  - dotnet test  → MathNet.Numerics.Optimization.NelderMeadSimplex
  - browser/Fable → fmin/nelderMead.js

fmin was under-iterated (fixed maxIter=100 vs its own default of N*200) and
its initial simplex was proportionally tiny (5% of starting value), making it
liable to get stuck in wrong local minima that MathNet would escape.

Changes:
  1. Initialise free coordinates from the bracket hint when it is non-zero;
     fall back to neighbour-average only for zero hints (e.g. x='(l)' where
     L=0 would produce a degenerate 1e-3-unit fmin simplex).  The hint is the
     designer's intent for where in the search space the optimum lies.
  2. In the FABLE path, scale maxIterations = N * max(maxIter, 200) so fmin
     gets at least as many iterations as its own heuristic would provide.

Update bracket tests: bracket content now intentionally steers the initial
search, so replace "content is ignored" assertions with "converges to finite,
non-degenerate values inside glyph bounds" assertions.

https://claude.ai/code/session_013FzUDrEU4omtyJJtnSaqrP
…minima

Single-start Nelder-Mead can get stuck in different local minima depending
on the starting point.  Fix: after the hint-based run, also run from the
neighbour-average starting point and keep whichever achieves lower error.
Applied to both the FABLE (fmin) and .NET (MathNet) paths for consistency.

The new BracketX_MultiStartAvoidsDegenerate test proves what multi-start
guarantees: even the hardest hint (x(r)W starting at x=R=300) is pulled
back from the degenerate "collapsed first segment" solution (x≈R).
Different hints still legitimately find different local minima (~153, ~188,
~263 for C, (C+R)/2, R hints) — these are genuine objective-function minima
with different values, not a convergence failure.

https://claude.ai/code/session_013FzUDrEU4omtyJJtnSaqrP
… limit

Solver.Solve (.NET path): wrap FindMinimum in try/catch so hitting the
iteration limit doesn't crash the caller — fall back to the last evaluated
candidate rather than propagating MaximumIterationsException.

Add PlotObjectiveVsTopX (Explicit/diagnostic) to BracketFittingTests: sweeps
the top-point x of the 'c' glyph across [0,R] with x fixed and other
parameters optimised, printing CSV + ASCII bar chart of the objective
function landscape.

https://claude.ai/code/session_013FzUDrEU4omtyJJtnSaqrP
The bracket syntax (e.g. x(cr)) sets x=None (free) with a hint for the
initial Nelder-Mead start.  Previously, starting from a far hint like
cr=225 gave a different result than c=150, because the inconsistent
hint/avg pair landed in different objective-function basins.

Key changes:
- Add `reinitAnglesAndDistances()` to recompute tangent angles and
  Bezier handle distances from current _points geometry, independent
  of the hint-based initialisation.
- In Solver.Solve(), build a 3rd start (initialAvgConsistent) by
  applying the neighbour-average x/y to _points, calling
  reinitAnglesAndDistances(), and snapshotting the result.  This gives
  a geometrically-coherent starting point that avoids the hint-angle
  mismatch that trapped the optimizer in the wrong basin.
- Only use wide perturbation (30%, floor 15) for the 3rd start, and
  only when initialAvgConsistent meaningfully differs from initialArr
  (> 5 units); when starts are identical, skip the wide-perturb run
  to avoid finding unexpected local minima in other tests.
- Switch MathNet path to best-tracking objective that captures the
  lowest-error point seen during any optimization run (guards against
  iteration-limit fallback returning a non-best point).
- fmin path: nonZeroDelta=1.3, zeroDelta=15.0 (unchanged from earlier
  in session).
- New test BracketX_MultiStartConvergesFromAllHints: all 5 hints
  (l, cl, c, cr, r) converge to the same x within 30 units.
  Observed spread after fix: ~11.7 units.

All 76 existing .NET unit tests pass.

https://claude.ai/code/session_013FzUDrEU4omtyJJtnSaqrP
@terryspitz terryspitz changed the title Add symmetry test for O and o outline; fix glyph definitions Fix fitted point optimisation May 15, 2026
claude added 2 commits May 15, 2026 16:20
Previously all solver sections paid the 3-start cost regardless of
whether bracket syntax was used.  For ordinary glyphs this caused a
large slowdown because:
- scaledIter = N * max(maxIter, 200) inflated iterations 10x
- 3 optimizer runs instead of 1

Now: check whether the hint-based initialisation differs from the
neighbour-average by > 5 units.  When it does not (no bracket syntax,
or hint ≈ avg), a single run is used — identical to main.  When it
does, the 3-run strategy fires: hint-start, avg-start, and the
geometry-consistent avg start with wide perturbation.

The reinitAnglesAndDistances / save-restore is also skipped for the
common (no-diff) case.

https://claude.ai/code/session_013FzUDrEU4omtyJJtnSaqrP
…ng points

Tests 5 starting heuristics (designer hint, midpoint, 7-point grid scan,
perpendicular-arc offset, tangent intersection) across 11 bracket-using glyphs,
5 axis configurations, and width tweens for 'c' and 'S'.

Key finding: H4perp (arc perpendicular) wins 83% of 170 cases with mean rank 1.38,
outperforming the legacy designer hint (worst, mean rank 1.71). For open-arc glyphs
(c, b, 3top, 3bot) the improvement is 31–82%.  The Recommendation section in the
plan file proposes replacing bracket hints with geometry-driven H4perp in production.

The new test is [<Explicit>] and does not run in normal CI.

https://claude.ai/code/session_013FzUDrEU4omtyJJtnSaqrP
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants