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 decimalsint: floor / ceil / round (default round)bool:v >= threshold ?? 0.5enum/string:options[clamp(round(v), 0, len−1)], or''with no optionslist/set: the leadingclamp(round(v), 0, len)options;setalso 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
randomsource 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
kshape parameter where it applies, four Bézier handles, and forexpran 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
expinterpolation 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.
Links between nodes#
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"]