title: "Curves and typed variables" description: "Piecewise curves with per-segment interpolation or a safe math expression, typed outputs, and the sweep engine built on them." nodes: 24 progress: 88% generated: ohm spec editor#

Curves and typed variables#

Piecewise curves with per-segment interpolation or a safe math expression, typed outputs, and the sweep engine built on them.

Contents#

☑ Overview#

☑ Scope#

note

packages/curves (curve, expr, presets, variables) and apps/ui/src/views/CurveEditor.tsx.

☑ A curve is a schedule, and a schedule can produce any type#

decision

Hyperparameters are rarely a single number: a temperature follows a warmup then a cosine decay, a style cycles through a list, a flag flips halfway. So a curve maps x ∈ [0,1] to a raw number, and a typed output stage turns that number into a float, int, bool, enum, string, list or set.

The same engine backs both the curve editor and experiment sweeps.

  • spec: 06-experiments

☑ Curve model#

☑ CurveDoc#

data-model

{ version: 1, name, description?,
  points: { x, y }[],
  segments: CurveSegment[],          // exactly points.length − 1
  output: CurveOutput,
  domain?: { label?, min?, max? } }  // display only

CurveSegment { interp, k?, expr?, handles?: [x1,y1,x2,y2] }
CurveOutput  { type, min?, max?, decimals?, rounding?, threshold?, options? }

☑ Normalisation is the invariant#

requirement #regression

normalizeCurve drops non-finite points, clamps x to [0,1], stable-sorts by x, guarantees at least two points anchored at x=0 and x=1, and rebuilds the segment array to exactly points.length − 1 entries, reusing segments by index.

Because segments are matched to gaps by index, any insert or delete of a point must splice the segment array in the same operation or every later segment shifts onto the wrong gap.

  • node: curve-doc

☑ Interpolations#

data-model

With u the normalised position in the segment and lerp(t) = y0 + (y1−y0)t:

interp formula
linear lerp(u)
step y0 until the right edge, then y1
smooth lerp(u²(3−2u))
ease-in lerp(u^k), k = 2
ease-out lerp(1 − (1−u)^k), k = 2
ease-in-out lerp(u<0.5 ? (2u)^p/2 : 1 − (2−2u)^p/2), p = 3
cosine y1 + (y0−y1)(1+cos πu)/2
exp y0·(y1/y0)^u when both endpoints > 0, else linear
log lerp(log1p(s·u)/log1p(s)), s = 9
sigmoid logistic with steepness s = 10, endpoint-normalised so f(0) and f(1) are exact
spline uniform Catmull-Rom on y, neighbours clamped at the ends
bezier CSS-style cubic with implied P0=(0,0), P3=(1,1), solved by 40 bisection steps
expr a compiled expression over u, x, y0, y1, i, n

A knot value belongs to the left segment. A zero-width segment evaluates at u = 1, i.e. its right endpoint.

☑ Typed output#

requirement

toTyped clamps with min/max before typing — so the clamps bound the raw value, never an enum index — then:

  • float: optional fixed decimals

  • int: floor / ceil / round (default round)

  • bool: v >= threshold ?? 0.5

  • enum / string: options[clamp(round(v), 0, len−1)], or '' with no options

  • list / set: the leading clamp(round(v), 0, len) options; set also dedupes by JSON round-trip, so option values must be JSON-serialisable.

  • node: curve-idea

☑ The expression language#

☑ No eval, ever#

requirement #security

A hand-written tokenizer and Pratt parser — there is no eval and no Function anywhere in the package. The only reachable names are a fixed function table, three constants (pi, e, tau) and the variables the caller allows; anything else is rejected at compile time with the allowed list in the message.

Errors carry a 1-based position, so the editor can point at the character.

☑ Grammar#

data-model

Precedence: || 1, && 2, ==/!= 3, comparisons 4, +/- 5, *///% 6, unary 7, ^ 8 right-associative (** is normalised to ^). Ternary c ? a : b sits at the top and nests right. Booleans are numbers; &&/|| short-circuit.

Functions: sin cos tan asin acos atan atan2 sinh cosh tanh exp log ln log2 log10 sqrt abs sign floor ceil round fract min max pow mod clamp lerp smoothstep step sigmoid — with mod always positive, log(x, b) taking an optional base, and min/max accepting 2…16 arguments. Arity is checked at parse time.

Compiled functions are memoised, keyed by source plus the allowed-variable list.

☑ Presets#

data-model

Twelve curve presets, each with named parameters: constant, linear, cosine, warmup-cosine, exp-decay, step-decay, triangular, one-cycle, warm-restarts, inverse-sqrt, polynomial, sigmoid. Plus twelve ready-made equations for expr segments.

☑ Variables and sweeps#

☑ VariableSpec#

data-model

{ name, type, description?, options?,
  source: { const } | { values } | { range: {min,max,step?,count?,log?} }
        | { random: {min?,max?,log?} } | { curve: {curve, samples?} } }

range with count produces evenly (or logarithmically) spaced values; with step it walks min + k·step with a small epsilon and a 10000-iteration guard. curve samples the named curve, de-duplicating in order — so a saturating curve contributes fewer grid points, which is the desired behaviour.

☑ Sweep modes#

requirement

  • grid — cartesian product in spec order, last spec varying fastest, with the combination count checked after each spec against a limit (500 in experiments) and a message that says how to narrow it. A random source inside a grid sweep is an error, not a silent sample.
  • random — n = min(limit, max(1, trials ?? 10)) draws from a seeded RNG.
  • schedule — the same n trials, but the position is t = index/(n−1).

In random mode, curve-backed variables still follow the schedule — only the other sources are sampled randomly. That is what makes "random search with an annealed temperature" expressible.

  • spec: 06-experiments

☑ Determinism#

requirement #gotcha

The RNG is mulberry32 seeded from seed ?? 42. Reproducibility holds for a fixed (spec order, mode, trials, seed).

Caveat worth documenting: in random mode every spec consumes a draw even when its source is constant, and a random list/set source consumes one draw per option — so adding an unrelated constant variable changes every later sampled value for the same seed.

☑ The editor#

☑ Plot and direct manipulation#

component

Drag a point (the first is pinned to x=0, the last to x=1, interior points clamped between their neighbours); double-click to insert a point on the curve, guarded by a minimum distance so an insert at the pinned endpoints cannot corrupt the arrays; click to select the segment under the cursor. The y-range is frozen while dragging so the plot does not jump under the pointer.

☑ Panels#

component

  • Segment — interpolation with its one-line explanation, the k shape parameter where it applies, four Bézier handles, and for expr an equation picker, a monospace editor and a live compile-error banner. Merge with next / split in half.
  • Point — x (disabled for the endpoints) and y, delete.
  • Preset — pick, adjust parameters, apply (keeping the output config).
  • Output — type, clamps, decimals/rounding/threshold, and the options list.
  • X axis — label and display range, explicitly cosmetic.

☑ Characteristics#

component

Four tabs. Characteristics: min, max, mean, area (trapezoid integral over 0…1), shape (increasing/decreasing/constant/mixed), jump positions, segment count, output type. Slope: the derivative by central difference with a zero baseline. Samples: a table of x/raw/typed at N points. Value histogram: for non-float outputs, the distribution of typed values.

  • node: risk-sample-n

☑ Review findings#

☐ expr segments fail silently#

risk high

Any parse error, runtime error or non-finite result in an expr segment returns y0 — so a typo flattens the segment with no signal anywhere except the editor's own validation banner. Evaluation should surface the error to the caller (a curve used in an experiment sweep just goes quiet today).

  • node: interp

☐ Analysis depends on the sample count#

risk

Jump detection ("|Δy| > 0.2·range") and the histogram are computed over a sampled curve — 201 points by default, 241 in the editor, 101 in tests. A specification should pin the sample count, or the reported jumps change with the caller.

  • node: characteristics

☐ Documented quirks to keep#

note low

  • exp interpolation degrades to linear when an endpoint is ≤ 0 — correct, since the geometric form is undefined there, but it should say so in the UI.
  • Catmull-Rom uses uniform parameterisation on y only, so unevenly spaced points overshoot in ways the formula does not anticipate.
  • The Bézier solver's comment claims "bisection + Newton" but only bisection is implemented; 40 iterations is numerically fine, the comment is wrong.
  • The expression cache is cleared wholesale at 500 entries rather than evicted LRU.
graph LR
  normalise["Normalisation is the invariant"] -.-> curve_doc["CurveDoc"]
  typed_output["Typed output"] -.-> curve_idea["A curve is a schedule, and a schedule can produce any type"]
  characteristics["Characteristics"] -.-> risk_sample_n["Analysis depends on the sample count"]
  expr_segments_fail_silently["expr segments fail silently"] -.-> interp["Interpolations"]
  risk_sample_n["Analysis depends on the sample count"] -.-> characteristics["Characteristics"]