seabiscuit

Docs / Language reference

Language reference

Language Reference#

The Seabiscuit 4.0 policy language, construct by construct. For every built-in operation see Nodes.

A policy is one UTF-8 file with the extension .sb, stored at a path in your policy repository (parcel/fused.sb). It compiles to a typed graph whose nodes are pure functions.

# parcel/fused.sb — a box's length from the scan × the user's tape measurepolicy(cloud: Cloud, tape: Length, sigma_h: Length = 1 mm)    -> (length: Gaussian[Length], best: Length, log_evidence: Scalar)import policy parcel/affine                          # frozen: runs affine's deployment as one nodeimport tensor parcel/tape.sigma_tape as sigma_tape   # reuse another policy's trained σloss    { length: nll }metrics { err: abs_error(length, y.length) }length_scan      = affine(cloud, sigma_h: sigma_h).lengthlength, evidence = bayes_update(length_scan, likelihood(tape, noise: sigma_tape))log_evidence     = log(evidence)best             = length.mode

Source text#

  • # starts a comment that runs to the end of the line.
  • Whitespace is spaces and tabs. Indentation is not significant.
  • Line endings are \n or \r\n.

Newlines end statements#

A statement (and each header line) ends at a newline, but only where a statement can end:

  • Inside ( … ) and [ … ], newlines are ignored, so calls and literals span lines freely.
  • Inside { … } (fn bodies, loss {}, metrics {}), newlines separate items; commas do too.
  • A line whose first token is |> continues the previous line. So does a line starting with else (} on one line, else { … } on the next).
  • A line that ends in a binary operator, ,, (, [ or -> continues on the next. So does a signature's -> (…) written on its own line, because a signature can't end before it.
  • Outside brackets, any other line that starts with an operator is an error (E0001, "a new line starts a new statement"): put the operator at the end of the previous line, or wrap the expression in parentheses.

So in

x = 90d = 3

d starts a new statement: 90 d is only a quantity when it's on one line.

Keywords and names#

Reserved everywhere: policy import tensor optimiser loss metrics fn solve as and or not true false inf map if else fold. After import, the words policy, tensor and library are reserved (elsewhere library is an ordinary name); after map <name> and after a fold's element name, the word in is (map t in xs { … }); after fold, the word each is; unit words are reserved only directly after a number, so in is still inches in 2 in. warn, error and range are prelude names, not keywords.

Identifiers are [A-Za-z_][A-Za-z0-9_]*. By convention values, operations, fns and keywords are snake_case and types are PascalCase; breaking the convention is warning W0001.

Numbers and strings#

  • Numbers: 12, 1.5, .5, 1e-3, 2.5E+3. Signs come from the unary operators (-1).
  • inf is +∞. It is dimensionless unless it takes a unit (inf m); -inf is unary minus.
  • Strings are "…" with the escapes \", \\, \n, \t, \u{…}. They're used only for names (name:), labels, meta field names, y-column keys and enum options (method: "ml").
  • pi is a prelude constant (Scalar). up, forward and right are prelude directions (+z, +y, +x); negate them with unary minus.

Operators#

+ - * / ^, comparisons < <= > >= == != (chainable: a < b < c), and or not, member access ., indexing v[i], the pipe |>, -> in signatures, @ in imports, and the postfix degree signs ° and º.

Precedence, loosest first: |>, or, and, not, comparisons, + -, * /, unary - +, ^ (right-associative), then calls, member access, indexing and °.

Units and quantities#

A number may be followed by a unit on the same line, with or without a space:

DimensionUnit words
Lengthm, cm, mm, ft, in
Anglerad, deg, d, °, º
  • Units are mandatory for lengths and angles. A bare number is a Scalar; there is no default unit and no exception for zero (0 m, 0°). A Scalar where a Length or Angle is expected is E0201, with help suggesting the unit (did you mean 0.5 m?).
  • Powers: mm^2, mm², m^-1, in³. The exponent is an integer literal. 4 mm^2 has dimension Length², and 3 mm^0 is the plain number 3. A number too large for a float (1e999) is E0004; write inf instead.
  • d means degrees only directly after a number. To give a variable a unit, multiply (v * 1 in, v * 1 d) or use the postfix degree sign (v°).
  • ° / º after any expression converts a Scalar to an Angle: angle0°, (a + b)°. On something that's already an Angle it is E0207.
  • A unit word after a number always binds to it: 2 m is two metres even if a variable m exists. Write 2 * m to multiply.
  • Constant arithmetic folds at compile time: 3 * pi / 2, 1.8 ft + 2 in.

Every value is stored and computed in canonical units, metres and radians. Display units are only for presentation.

Program structure#

policy(<inputs>) -> (<outputs>)      # the signature, exactly onceimport …                             # header lines, any orderoptimiser …loss { … }metrics { … }<bindings and fn definitions>        # the body
  • The signature policy(…) -> (…) is required exactly once (E0101). It has no policy name: the path is where the file lives and the database record is its identity, so a rename never leaves a stale header.
  • Header lines (import, optimiser, loss, metrics) come in any order, but all before the first statement. A header line after a statement is E0102.

The signature#

policy(cloud: Cloud, tape: Length, sigma_h: Length = 1 mm)    -> (length: Gaussian[Length], lid: Categorical, best: Length)
  • Inputs are matched by name to a data entry's x fields, or to the inputs of a run request. An input with a default is optional.
  • An input typed Gaussian[T] (tape_est: Gaussian[Length]) takes a value with its noise: a Gaussian[Length] column's {mean, sd} (Gaussian columns), or a plain Length value, which is exact (sd 0). In the policy it is a Gaussian, so its sd propagates through the Gaussian algebra. A run request may pass {"mean": 0.41, "sd": 0.003} or a number; anything else is R0014.
  • Outputs are matched by name to a data entry's y columns, for losses and metrics. A single output may drop the parentheses: -> width: Length.

Bindings and shadowing#

sec = slice(cloud, at_mid, thickness: 2 cm)sec = keep(sec, -0.3 m <= .u <= 0.3 m)        # shadows the first secwidth, angle = trapezoid(sec)                  # destructures a multi-output callw: Vector[Scalar] = tensor(init: "normal", std: 0.01, shape: (8,))   # annotated
  • a = e binds; a, b = e destructures a tuple or a multi-output call, by position.
  • A multi-output call is also a Record whose fields are its output names, so you can take one field anywhere, loss terms included: trapezoid(sec).angle, bayes_update(prior, lik).evidence, affine(cloud).length.
  • Rebinding a name shadows it. Every binding is a fresh value; earlier uses keep the earlier one. In the graph, the bindings of sec are the nodes sec, sec#2, sec#3, ….
  • a: T = e annotates the type (single targets only). Use it when the compiler can't infer a type.
  • Each declared output is the final top-level binding of its name. An output that's never bound is E0104; one whose final type doesn't match its declaration is E0202.
  • An unused binding that isn't an output or a tensor is warning W0002; prefix its name with _ to keep it quietly.
  • Using a name that isn't bound at that point is E0103.
  • Header blocks (loss, metrics) are evaluated after the body and can use any final top-level binding (an output or not), the inputs, imports and fns, their own local bindings, and y.<column>.

Calls and pipes#

slice(cloud, plane3(p1, p2, p3), thickness: 1 cm)cloud |> largest_cluster(eps: 5 mm) |> center
  • Positional arguments come first, then named ones. Each parameter may be given once: a duplicate is E0302, an unknown name E0303 (with "did you mean").
  • A literal argument (or any compile-time constant expression) is a frozen constant of that node. The graph lists a node's constants with their names and display units.
  • An argument that is, or depends on, a tensor is a trainable input of that node.
  • x |> f(a, k: v) is exactly f(x, a, k: v), and x |> f is f(x). The function may be a library member: x |> g.half is g.half(x) (see import library). A line starting with |> continues the previous one.
  • Methods and properties use .: path.at(s), path.height, curve.slope(u), length.mode.

Types and dimensions#

Quantities#

Every number has a dimension over length (L) and angle (A), with rational exponents.

TypeDimension
Scalardimensionless
LengthL
AngleA
Length^2L²
1/Length (or Length^-1)L⁻¹, e.g. curvature
Length/AngleL·A⁻¹
  • +, - and comparisons need identical dimensions (E0203); * and / combine them; ^ with a constant exponent scales them (a non-constant exponent needs a Scalar base).
  • sqrt halves exponents; abs, min, max, clamp, sum, mean keep them.
  • sin cos tan take an Angle and return a Scalar; asin acos atan take a Scalar and return an Angle; atan2(y, x) takes two values of the same dimension and returns an Angle.
  • exp and log take a Scalar.

Value types#

TypeMeaning
Bool, Labela boolean; a categorical label (a string)
Vector[T]an ordered collection of T, optionally labelled. T is any one type: quantities, points, distributions, or shapes such as Section, Cloud and Curve (elements may differ in size). Elements may carry metadata. See Vectors and map
Matrix[T]a 2-D array, e.g. a covariance
(T1, T2, …)a tuple, from tuple literals and multi-output calls
Pointa 3-D position
Directiona unit 3-vector (up, forward, right). cross(a, b) of two Directions is a Vector[Scalar] of length sin θ, not a Direction: normalize(cross(a, b)) is the unit normal
Planean origin, a unit normal and an in-plane frame (u, v); members .origin (Point), .normal, .u, .v (Direction)
Framea rigid transform
Clouda weighted 3-D point set
Sectiona weighted 2-D point set in a plane's (u, v) frame: u lateral (0 on the midline), v up
Curvea fitted function v = f(u)
Patha 3-D curve parameterised by arc length s
Gaussian[T]a Gaussian random value with tracked correlations
Mixture[T], Grid[T], Samples[T]other distributions over T
Categoricala distribution over ordered labels
Likelihood[T]a likelihood function over T
SearchSpace[T]one_of(…) or between(lo, hi)
Tensor[T]a stored or trainable value of type T; usable wherever T is
Record{…}the result of calling an imported policy; its fields are that policy's outputs

Conversions are explicit, except: a Tensor[T] is a T; a 3-tuple of lengths is a Point (only a 3-tuple whose items are all lengths: norm((3, 4, 0)) is E0204); an array literal is a Vector or Matrix. A plain value is not silently a Gaussian: write gaussian(x, 0 m) if you need one.

Inference#

Types are inferred in both directions: a call's arguments take their expected types from the operation's signature, and a binding takes its value's type. When a type can't be inferred, for example tensor(init: "zeros") with no consumer that fixes its shape, or an empty [], you get E0210 with help suggesting an annotation.

Vectors and map#

A Vector[T] is ordered. Either every element has a label (unique), or the elements are known by their 0-based position. Its elements can be of any one type, including shapes of different sizes: a Vector[Section] may hold a section of 20 points next to one of 400.

Calls never apply themselves to each element of a vector. Passing a Vector[T] to a fn, operation or method parameter that takes one T, or calling a method on a vector, is E0221. The help gives the rewrite:

error[E0221]: `score` expects one Section for `t`, found a Vector[Section] --> lib/match.sb:6:14  |6 | s    = score(templates)  |              ^^^^^^^^^ a Vector of Section  = help: to apply `score` to each element, map over the Vector: `map t in templates { score(t) }`

An operation that works on a whole vector says so in its signature, with a Vector[T] parameter (sum, mean, standardize, grid(support, logp), …).

map#

map t in xs { … } evaluates its body once for each element t of the vector xs and returns the results as a vector, with the labels and order of xs. It works over any vector: numbers, points, distributions, sections, clouds, curves. This policy scores a profile against each of a set of templates and picks the closest:

# lib/match.sb — which template is closest to a profilepolicy(profile: Section, templates: Vector[Section], temp: Scalar = 1) -> (best: Label, p: Categorical)fn score(t: Section) -> Length^2 { mean(nearest_distance(t, profile)^2) }s    = map t in templates { score(t) }               # Vector[Length^2], one per templatebest = argmin(s)                                     # the label of the lowest scorep    = categorical(logits: -s / (1 mm^2) / temp)
  • The body is a block with its own scope, like a fn body: bindings on their own lines (or separated by commas), then the result expression. It can use everything visible where the map is written (inputs, earlier bindings, imports, tensors, a fn's parameters); its own bindings stay inside it.
  • The result is a Vector of the body's type. An empty xs gives an empty vector.
  • A tuple result gives a tuple of vectors: lo, hi = map x in xs { (x - 1 mm, x + 1 mm) }.
  • Maps nest: map a in xs { map b in ys { a + b } } is a vector of vectors. The body may call fns, other maps and multi-output operations.
  • Each element is its own call. Randomness made in the body (noise, gaussian, sample, …) is drawn per element, even when it doesn't depend on the element: sum(map x in xs { x + noise(1 m) }) over three elements has three independent noise sources. Values defined outside the body are shared.
  • A tensor declared in the body is one tensor, shared by every element, never one per element. It's keyed like a tensor in a fn, with the binding as the call site: in k = map x in xs { scale = tensor(1.0) … } the key is k.scale. A map that isn't bound directly (nested in a larger expression, or in a loss term) is the call site <binding or term>.map.
  • In the graph, a map is a collapsible group labelled map t in xs. The vector enters the group through a single node, so the body may also use the whole vector (map x in xs { x - min(xs) }), and a nested map may run over the same one (map x in xs { map y in xs { x - y } }).
  • map over anything but a vector is E0222.

A grid of placements is a map too. place moves a section within its plane, so this tries five offsets of a template t against profile and keeps the best:

offsets = [-2 mm, -1 mm, 0 mm, 1 mm, 2 mm]fit     = map dx in offsets { mean(nearest_distance(place(t, du: dx), profile)^2) }dx      = offsets[argmin(fit)]

The first-party shape_match library packages this: every template of a collection, scored at its best placement (found smoothly with align), as a label, a Categorical or the best-placed template.

Operators stay elementwise#

Operators and the elementwise math fns need no map on vectors of numbers: + - * / ^, comparisons, and, or, not, unary -, postfix °, the members .mean .sd .var .mode .x .y .z, abs sqrt exp log sin cos tan asin acos atan atan2 min max clamp and where. Each argument is a value or a vector, and the result is a vector with the operands' labels (min and max take one vector, which they reduce, or two values). So nearest_distance(t, profile)^2 is a Vector[Length^2], and mean of it is a Length^2.

  • Vector operands must have the same length and the same labels. Lengths known at compile time that differ are E0220; otherwise the run fails with R0005.
  • The reductions sum mean min max argmin argmax take a vector of quantities (sum and mean also a vector of Gaussians). argmin and argmax return the label of the extreme element (its position when the vector has no labels).
  • On a vector of anything else (sections, curves, …) the operators, the math fns and the reductions are type errors (E0204, E0205).

Indexing#

v[i] is the element at 0-based position i; v["label"] is the element with that label. A constant position that is out of range, or not a whole number, is E0223; at run time a missing position or label is R0013. Since argmin returns a label or a position, v[argmin(v)] is the smallest element, and offsets[argmin(fit)] above is the offset with the lowest score.

range and len#

len(v) is the number of elements of a vector (a Scalar). range(n) is the vector 0, 1, …, n − 1 (Vector[Scalar], no labels), and n may come from the data, such as a vector's length. With indexing at a position computed in the body, this walks a vector in pairs:

# route/legs.sb — the length of each leg of a route, and in allpolicy(route: Vector[Point]) -> (legs: Vector[Length], total: Length)legs  = map i in range(len(route) - 1) { distance(route[i], route[i + 1]) }total = sum(legs)
  • n must be a whole number ≥ 0: a constant that isn't is E0224 (not whole) or E0225 (negative); at run time R0017 and R0018. range(0) is empty. At most 10,000,000 elements (R0019).
  • range and len are not differentiable (positions and counts carry no gradient); the values you index with them do.

Element metadata#

The elements of a stored collection may carry metadata: a few scalar fields per element, such as a template's size family and number ({"label": "L5", "meta": {"family": "L", "number": 5}, …}, see Collections of shapes). Fields are numbers (plain or with a unit), labels and true/false. A stored collection may mix: a field on some elements only, or with different types on different elements. That's fine until you use the field (below).

# shelf/size.sb — which bracket a profile matches, and its size numberpolicy(profile: Section) -> (size: Categorical, number: Scalar, expected: Scalar)import tensor shelf/bracketsimport library shape_match as smmetrics { near: agreement(size, y.size, within: 1, by: (.family, .number), among: brackets) }size     = sm.soft(profile, brackets, temp: 1 mm^2)number   = meta(brackets, size.mode).number             # the most probable bracket's numberexpected = expect(size, brackets, of: .number)          # Σ p(label) · number
  • Elements keep their metadata. A map result's element has its input element's label and metadata, and so do elementwise operators on it (-scores / temp). argmin / argmax and a Categorical's .mode give a label, which meta takes.
  • meta(v, at) is the metadata of the element labelled at (or at position at) as a record: meta(brackets, best).number.
  • expect(c, v, of: .number) is the probability-weighted mean of a numeric field over the Categorical's labels. It is differentiable in the Categorical's probabilities, so a loss on it trains whatever produced them (a softmin temperature, say). The Categorical's labels must all be labels of v.
  • agreement(…, by:, among:) counts ±within steps in the order of a numeric field instead of the order parsed from the labels; by: (.family, .number) compares only within the same family. See Metrics.
  • A field is written with a leading dot, like a predicate coordinate (.number).
  • A field you use must be on every element, with one type. Using a field that some elements lack (in meta(…).f, of: or by:) is E0245, and one whose type differs between elements is E0246; both name the elements, e.g. metadata field `.number` is missing on item 'X1' or metadata field `.width` has mixed types: a Length on items 'L4', 'L6', a Scalar on item 'L5'. Fields you never use never cause errors.
  • Checked at compile time when the schema is known, which it is for an imported tensor (and a map over it): meta(brackets, best).numbr is E0211 with "did you mean .number?", a field the elements don't have in of: or by: is E0245, and a label or true/false field where a number is needed is E0246. For a collection the compiler can't see (one passed in as an input), the same checks happen when the policy runs (R0015, naming the elements as above, and R0016 for a label that isn't in the collection). The run-time check covers the whole collection, as the compile-time one does: meta(v, "L4").number fails if any element lacks number, even when L4 has it.

Distributions#

gaussian, noise, mixture, grid, samples and categorical build distributions; likelihood and bayes_update combine them (see Nodes).

Gaussian algebra#

A Gaussian is a mean plus coefficients over named independent noise sources. Each source belongs to the node that created it, so a measurement used twice shares its source and correlations are exact.

  • + and - between Gaussians and quantities, and * or / by a non-random quantity, are exact.
  • x = a + r * (b - a) + noise(sigma_2) has variance (1−r)²σ_a² + r²σ_b² + σ_2² (for independent a and b), because it is (1−r)·a + r·b + noise.
  • Anything nonlinear on a Gaussian (multiplying two Gaussians, sin, sqrt, ^, comparisons) is E0230, with help: use sample(x, n) for Monte Carlo, or .mean.
  • Samples support any elementwise operation (seeded per entry and node, so results are reproducible).
  • Members: .mean, .var, .sd, .mode (a Categorical has only .mode). Functions: quantile(d, p), interval(d, level: 0.9) (a central interval, outputs lo and hi), cdf(d, x), prob(d, x) (mass for a Categorical, density otherwise), log_prob(d, y). To evaluate a density at each value of a vector, map over it: map x in xs { log_prob(prior, x) } is a Vector[Scalar], one per value.
  • Converting between kinds is an explicit node (moment_match, discretize, sample, to_grid, to_categorical). All of them except sample drop correlation with other values; sample draws per noise source, so samples of correlated values stay correlated.

Search spaces#

A bare array is always data. To ask a node to search, say so:

width, angle = trapezoid(sec,                         widths: one_of(60 cm, 75 cm, 90 cm),     # discrete candidates                         angles: between(80°, 100°))              # a continuous range; inf allowed

A node that accepts a SearchSpace (such as trapezoid) optimises over it on every evaluation, per scan. This has nothing to do with training.

Predicates#

keep, discard, clip and bump take a predicate over per-point coordinates, written with a leading dot: .x .y .z on a Cloud, .u .v on a Section.

sec = keep(sec, -0.3 m <= .u <= 0.3 m)sec = discard(sec, .v < 2 cm or .u > 25 cm)sec = keep(sec, top(.v, 0.5, at_least: 6 in))sec = clip(sec, .v <= cut)
  • Combine chained comparisons with and, or and not. Each comparison chain mentions exactly one coordinate (E0241).
  • top(.v, fraction, at_least: L) and bottom(…) select the band of size max(fraction × range, at_least) at the top or bottom of that coordinate's range.
  • near(plane, within: L) selects the points of a Cloud within L of a plane, e.g. discard(cloud, near(floor, within: 2 cm)); on a Section it is E0244.
  • clip and bump need bounds on a single coordinate, possibly a union of intervals joined with or (E0242). clip clamps the coordinate into the bound and never removes points.
  • A coordinate outside a predicate is E0240. Two kinds of argument use the same dotted form outside predicates: a coordinate name (weight(by: .z)) and an element-metadata field (expect(c, v, of: .number), see Element metadata).

solve#

root = solve lo < x < hi: lhs == rhsleft = solve -12 in < u < 0 in: curve(u) == crest - drop

solve returns the root of lhs − rhs inside the bracket, with the type of the bounds. The bracket is required (< or <= on both sides) and its bounds may be any expressions; the bound variable is local to the solve. If there are several roots, the first one from lo wins. If there is none, the run fails with R0010, reporting the range of both sides over the bracket. Gradients flow through solve by implicit differentiation, so it can sit on a trained path.

The two sides may be any expressions in the variable: arithmetic, fn and library calls, node calls, map, if, even another solve:

fn drop_at(t: Length) -> Length {    p = geodesic_point(sec, 2 in, "right", from: t)    q = geodesic_point(sec, 2 in, "left", from: t)    p.z - q.z}balance = solve -2 in < t < 2 in: drop_at(t) == 0 m

Pure arithmetic in the variable (math functions and methods such as curve.slope(u) included) is one fused node, evaluated on the whole bracket at once. Anything else is a group (solve, collapsed by default) whose nodes the solver runs again for every value of the variable it tries: about 260 times per solve, so keep expensive work that doesn't depend on the variable outside (the compiler already runs such nodes once). Randomness inside is fixed for the whole solve, so the sides are one function of the variable. A node inside that fails at some value fails the solve with that node's error. The gradient is the implicit one: at the root x\, dx\/dθ = −(∂g/∂θ)/(∂g/∂x) for g = lhs − rhs, computed through the group's nodes.

if#

if cond { a } else { b } chooses between two values. else if chains, and else may start the next line:

# parcel/grade.sb — a size grade from a length, with a warning for odd parcelspolicy(length: Length) -> (grade: Label, margin: Length)grade = if length < 20 cm { "small" } else if length < 50 cm {    "medium"}else { "large" }margin = if length < 50 cm {    room = 50 cm - length    room / 2} else {    warn("parcel longer than 50 cm: {length}")    0 cm}
  • The condition is one Bool (E0204 otherwise). For an elementwise choice over vectors use where(cond, a, b); to branch per element, map and put the if inside.
  • Both branches have the same type: E0203 when their dimensions differ, E0204 otherwise. A branch that is error(…) fits any type (the other branch decides). Tuples work: lo, hi = if a < b { (a, b) } else { (b, a) }.
  • A branch is a block like a fn body: bindings and statements, then the value. Its bindings stay inside it.
  • Leaving out else is E0260, unless the branch is a statement (warn, error): if x < 0 m { warn("negative") } is a statement on its own line.
  • Only the taken branch runs. In a run, an evaluation, validation, metrics and final fits, the other branch's operations, warnings and errors never run, so an index that would be out of range there is harmless.
  • Under adam, the choice is smooth. When training with Adam and the condition compares quantities (<, <=, >, >=) that depend on a trainable tensor, both branches run and the value is s·a + (1 − s)·b, where s replaces each comparison a < b by sigmoid((b − a)/τ) with the same temperature as the soft masks (see Training). This works for quantities, vectors of quantities, points and Gaussians (blended by matching mean and variance); other values (labels, sections, …) always take one branch. warn and error in the branch the plain condition doesn't take still don't run. Conditions on Bool inputs, label comparisons and == / != stay a hard choice.
  • where(cond, a, b) computes both values, works elementwise on vectors and never blends; if computes one branch and takes one Bool.
  • In the graph an if is a group with the condition's node and two collapsible subgroups, then and else.

fold#

fold acc = init, x in xs { … } runs its body once per element of the vector xs, in order. acc starts as init; each step's value becomes the next acc; the result is the last one. fold each keeps every step's acc instead, as a vector with the labels of xs. fold is available after import library foldlib:

# route/climb.sb — total climb along a route, and the running totalpolicy(route: Vector[Point]) -> (climb: Length, running: Vector[Length])import library foldlibclimb = fold up = 0 m, i in range(len(route) - 1) {    rise = route[i + 1].z - route[i].z    if rise > 0 m { up + rise } else { up }}running = foldlib.cumsum(map i in range(len(route) - 1) { max(route[i + 1].z - route[i].z, 0 m) })
  • Without import library foldlib, fold is E0270 ("add import library foldlib"). The import belongs to the file where the fold is written: a library fn that folds needs it in its own library file, and a policy calling that fn needs nothing (nor does a policy's import open the gate for a library's folds). The library also has cumsum, running_max and running_min (see foldlib).
  • The body's value must have the type of init (E0203 / E0204); xs must be a vector (E0222).
  • A fold runs at most 100,000 steps (R0021).
  • Gradients flow through every step, so a fold can sit on a trained path. Randomness drawn in the body is per step; a tensor declared in it is one tensor.
  • In the graph a fold is one group, collapsed by default.

Statements: warn and error#

A call, an if or a map on a line of its own is a statement, run for its effect. warn(message) and error(message) are statements you can use anywhere: at the top level and in fn, map, fold and if bodies.

# parcel/check.sb — refuse a scan that is too short, flag odd onespolicy(cloud: Cloud) -> (length: Length)axis   = midline(cloud)length = axis.lengthif length < 5 cm { error("the scan is only {length} long") }if length > 2 m { warn("unusually long parcel: {length}") }
  • The message is a string. {expr} inside it inserts a value with its display unit, as outputs are shown ({length} → 1.65 in, or 4.20 cm where the display is metric; vectors as […], Gaussians as mean ± sd); {{ and }} are literal braces.
  • warn adds warning W0710 to the run: it shows in the node's panel (Warnings), in the run's warnings and in the run response, and the run carries on.
  • error stops the run of that entry with R0020 and your message, pointing at the error(…); no output is returned. In training, that entry fails.
  • A statement has no value: x = warn("…") is E0261. A statement line that does have a value (abs(x) alone) is warning W0003.

Functions#

axis = midline(cloud)                                # bound before the fn, so the fn can use itfn profile_at(s: Length, set: Vector[Section]) -> Categorical {    temp    = tensor(4 mm^2, lower: 0 mm^2)          # one per call site    profile = slice(cloud, plane(axis.at(s), normal: axis.tangent(s)), thickness: 1 cm)    sm.soft(profile, set, temp: temp)                # import library shape_match as sm}left  = profile_at(5 cm, brackets)right = profile_at(axis.length - 5 cm, brackets)
  • Parameters are typed and may have defaults. The return type after -> is optional and can be any type, including a tuple: fn grade(x: Length) -> (Label, Categorical, Scalar) { … }; the result is checked against it. The last expression is the result; return a tuple for several, and destructure it: g, p, s = grade(x).
  • A fn may use the policy's inputs, top-level bindings defined before the fn, and prelude constants.
  • Constants pass through: a parameter given a constant at the call site (a literal, or an option string such as side: "left") is a constant inside the body, so it can feed arguments that must be constant.
  • A fn is inlined at every call site. Each call becomes a collapsible group in the graph, labelled profile_at → left.
  • Recursion, direct or mutual, is E0420.
  • Inlining multiplies, so a fn that calls another fn twice, nested ten levels deep, inlines 1,024 copies. One policy's inlining may create at most 5,000 graph nodes and groups; past that limit the call gets E0421, which names the chain of fns (or unfrozen policy imports) being inlined. A compile, imports included, may use at most 2 s of CPU (E0422, with a partial graph).
  • A tensor declared inside a fn is per call site (left.temp, right.temp). One declared outside, at top level, is shared by every call.

Tensors#

A tensor is a typed value that can be trained, or stored and imported frozen.

Inline tensors#

r       = tensor(0.35, lower: 0, upper: 1)sigma_2 = tensor(1 in, lower: 0 in, scale: 0.25 in, name: "sigma_2")w: Vector[Scalar] = tensor(init: "normal", std: 0.01, shape: (8,))shift   = tensor(0 in, frozen: true)                  # a named, stored constant
  • The first positional argument is the initial value. Or give a named initialiser: init: "zeros" | "ones" | "normal" (std:) | "uniform" (low:, high:) | "xavier" | "kaiming" with a shape: (inferred from the consumer when omitted).
  • lower: and upper: are constant bounds (default unbounded). Bounds are enforced by reparameterisation during training, never by clipping.
  • scale: is the typical step size, in the tensor's units. The default is (upper - lower) / 4 when both bounds are finite, otherwise max(abs(init) / 2, 1 unit), where the unit is the one the initial value is written in (1 in for tensor(0 in), 1 for a Scalar), or one canonical unit (1 m, 1 rad, 1) with a named initialiser. Set it when the default is far off (for example scale: 0.25 in for a residual spread of about an inch).
  • The tensor's type is its initial value's type, dimensions included.
  • The initial value must be a finite number, or a Point, Vector or Matrix of them. std:, low:, high: and scale: must be finite. Bounds may be inf but not NaN. A name: is made of [A-Za-z0-9_] segments separated by . ("sigma_2", "lid.temp"). Each of these is E0401 otherwise.
  • A policy's own tensors (inline, implicit weights, forks) hold at most 50,000,000 elements in total (E0403).
  • Inline tensors are trainable by default. frozen: true makes a named, stored constant. Frozen means frozen: no optimiser touches it, delegates included.

Storage keys#

Trained values are stored under a key owned by the policy. The display path is <policy path>.<key>, e.g. shelf/brackets_fit.left.temp.

  • At top level the key is the tensor's local name: its name: if given, else its binding name (sigma_2).
  • Inside a fn body the key is always prefixed by the call site: <call-site name>.<local name> (left.temp), where the local name is the name: or the binding name. Nested fns chain (outer.inner.temp). A call site is named after its first binding target.
  • Tensors of an unfrozen policy import are keyed <alias>.<key in the imported policy> (affine.a, shown as parcel/stacked.affine.a) and shared by every call of that import: an imported policy is one module.
  • An unnamed tensor(...) written directly as an argument gets <call-site name>.<param>: in length = linear(…, noise: tensor(3 mm, lower: 0 mm)) the key is length.noise.
  • A fn call that isn't bound directly (nested in an expression, or inside a loss term) is named <enclosing binding or term>.<fn name>, e.g. record.profile_at. Two such calls of the same fn in one binding are ambiguous: E0410 (bind one of them).
  • Inside a fn the result expression is named result. A tensor or an unbound fn call in it is keyed under <call site>.result: for o = f(s) with fn f(x) { g(x) + 1 mm }, the tensor t of g is o.result.g.t.
  • Two call sites resolving to the same key is E0411 (give one a name:).
  • Names never depend on line order, so reordering lines can't swap trained values. Renaming orphans a trained value. The editor compares the keys with the previous commit's and warns (W0410) when a key disappears and a new key of the same type appears in the same place. It never pairs keys across fns. The help gives the edit that restores the old key:
    • add name: "rate" for a renamed binding (inside a fn, give the local name; the call site is prefixed automatically);
    • name the call site left again for a renamed fn call site (inside a fn every key starts with the call site, name: included);
    • name the binding h again for implicit weights.

Implicit weights#

Operations that consume weights (mlp, pointnet) create a trainable tensor from their shape and init: arguments when you don't pass weights: yourself: h = mlp(feats, hidden: (64, 64), out: 1, init: "xavier") creates h.weights. The weights' size follows from the length of feats; when the compiler can't infer it, pass in_dim: (E0210 otherwise).

Imports#

import tensor#

import tensor shelf/brackets                          # frozen, floating: latest version of a matching typeimport tensor parcel/tape.sigma_tape as sigma_u      # another policy's trained tensorimport tensor shelf/brackets@3 as brackets_v3        # pinned to version 3import tensor parcel/scale.k as k (frozen: false)    # trainable: training writes a fork
  • Imported tensors are frozen by default.
  • With (frozen: false), training writes to a fork in this policy's namespace (<this policy>.<alias>), starting from the resolved source version. The source is never modified.
  • The alias defaults to the last segment of the path: brackets for shelf/brackets, sigma_tape for parcel/tape.sigma_tape. A segment that isn't snake_case makes an alias that draws naming warning W0001 (parcel/lids/A would be A), so give it one with as. Policy-owned tensors are imported by <policy path>.<key>.
  • A stored collection of shapes imports as a Vector[Section], Vector[Cloud] or Vector[Curve], labelled when its items are: import tensor lib/shapes as templates, then map t in templates { … }. Stored sections lie in the x–z plane (u = x, v = z). The payload is described under the tensor API.

import policy#

import policy parcel/affine                           # frozen: one opaque nodeimport policy parcel/affine@a1b2c3d as affine_old     # pinned to a commitimport policy parcel/offset (frozen: false)           # unfrozen: inlined, tensors forked

a(args…) calls an imported policy with its inputs (positional or named) and returns a Record of its outputs: affine(cloud).length.

  • Frozen (the default): a single node that runs the imported policy with its stored values. Unpinned, it uses that policy's current deployment (its commit and its lock); if it has none, E0430 (help: deploy it, or pin a commit with @). The editor can expand the node read-only.
  • Unfrozen ((frozen: false)): behaves exactly like an inlined fn at the resolved commit: a collapsible group per call, whose tensors are forked into <this policy>.<alias>.* (shared by every call of that import), starting from the imported deployment's values. Its own outputs and loss act as targets for its delegate fits, so those fits re-solve on every training fold (see Training and evaluation).
  • Import cycles, at any commit, and self-imports are E0431.

import library#

A library is a named collection of fns or operations that you call through its alias. Seabiscuit ships with a few first-party libraries, imported by their bare name:

policy(a: Point, b: Point) -> (mid: Point, gap: Length, near_a: Length)import library geom_util as g        # alias gimport library num_util              # alias num_util (the last segment of the path)mid    = g.midpoint(a, b)gap    = g.distance(a, b)near_a = gap |> g.half |> num_util.lerp(0 m, t: 0.25)
  • import library <name> [as <alias>]. The alias defaults to the last segment of the path. A library import takes no (…) options (E0436).
  • Calls are always qualified: alias.fn(args…), with positional and named arguments as usual. The pipe works too: x |> alias.fn(a) is alias.fn(x, a), and x |> alias.fn is alias.fn(x).
  • The alias is a namespace, not a value. Using it alone (g) or a member without calling it (g.half) is E0110; calling the alias itself (g(…)) is E0306. A name the library doesn't have is E0441, with a did-you-mean (did you mean g.midpoint?).
  • Library members never enter the unqualified scope: after import library geom_util as g, midpoint alone is still undefined. Two libraries may define fns of the same name; a.best(…) and b.best(…) are different fns.
  • Prelude names stay unqualified and can't be shadowed: an alias can't be a prelude name (import library geom_util as mean is E0436; pick another alias).
  • A call of a library fn is inlined like a call of your own fn: a collapsible group in the graph labelled g.midpoint → mid, whose nodes are ordinary operations. A tensor declared inside a library fn belongs to your policy and is keyed by the call site, exactly as for your own fns. A library operation (such as num_util.lerp) is a single node.
  • First-party libraries are versioned with the Seabiscuit release, so they can't be pinned with @ (E0436). An unknown library name is E0440, with help naming the closest library.
  • Libraries can import other libraries; a cycle is E0431. A library that doesn't compile is E0442 at the import, naming its first error; arguments are checked against the fn's signature like any call (E0204 for a Point where a Length is expected), and an error inside the library fn that only shows up at a particular call is reported once, as E0442 at that call, with a note naming the library file and line.

Writing your own libraries, in Seabiscuit or in Python, is coming; documentation will follow.

Floating, pinned and locked#

While you develop, unpinned imports float: import tensor follows the latest type-compatible version, import policy follows the current deployment. Moving a policy's deployment marker writes a lock that pins every import transitively, libraries included, and production runs use the lock. An @ in the source always wins. A pinned tensor version that has since been deleted is E0432.

Header blocks#

optimiser#

optimiser delegateoptimiser adam(lr: 3e-3, steps: 2000)optimiser cma_es(sigma0: 0.3)
  • The set of optimiser names is open. The built-ins today are adam, cma_es and delegate, and more built-in optimisers are coming.
  • Declare at most one non-delegate optimiser, such as adam or cma_es; delegate may be declared alongside it. Two non-delegate optimisers is E0510; any duplicate declaration is E0511.
  • adam(lr, steps, batch: "full" | N, schedule: "cosine" | "constant", warmup, clip, weight_decay, patience, holdout).
  • cma_es(sigma0, popsize, generations, restarts, tolfun, tolx, seed).
  • delegate lets nodes with a built-in fitter (linear, stack, conditional_logit) fit their own tensors.
  • Every option is optional. Defaults, and which tensors each optimiser trains, are in Training and evaluation.
  • If a trainable tensor has no declared optimiser that can train it, the editor shows W0520 and training fails with E0520, naming each tensor and why.

loss#

loss { length: nll, lid: nll(weight: 0.5) }          # form 1loss {                                                # form 2    sigma_c = tensor(5 mm, lower: 0 mm)    record: -log_prob(length + noise(sigma_c), y.billed)    # y.billed: an interval}
  • Form 1, out: nll | mse, optionally with (weight: w), where out is a declared output. nll is -log_prob(out, y.out); mse is (out − y.out)² (for a distribution output, its mean).
  • Form 2, name: expr, is any expression over final top-level bindings, y.<column> and the block's own bindings, which may declare tensors (observation-model parameters, for example). A y.<column> that isn't tied to an output takes its type from how it's used, and is checked against the kinds in the folder's schema when a run starts (one whose type can't be inferred, or that is used at two different types, is only checked for presence).
  • y.<column> reads the entry's target. An interval target ({"lo": a, "hi": b}) works with log_prob.
  • The total loss is the sum of weight × the mean over usable entries.
  • All terms must share a dimension (E0501; help: divide by a reference scale such as / (1 in)^2, or use nll). nll is computed in canonical SI units, so log densities compare across policies.
  • Without a loss block: nll for each distribution output, against the y column of the same name. Only if the policy has no distribution output: mse for each point output (a quantity such as Scalar, Length or Angle), which must all share a dimension. If distribution and point outputs are mixed, point outputs have different dimensions, or a trainable policy has neither kind (for example only a Point, Vector or Section output), you get E0502: declare a loss.
  • These rules (E0501, E0502, the default) apply only to a policy with trainable tensors. A policy with nothing to train needs no loss and can still be evaluated; loss { } is allowed, and states that evaluating it needs no y columns.

metrics#

metrics {    exact: agreement(lid, y.lid)    near:  agreement(lid, y.lid, within: 1)    err:   abs_error(length, y.length)}

Named expressions over final top-level bindings and y. They're evaluated per entry on validation folds and averaged (entries missing the y are skipped). With split none they're computed in-sample and labelled so. Metrics never influence training.

Diagnostics#

Every diagnostic has a code, a message, a primary span, optional labelled secondary spans, an optional help (a concrete fix) and notes. The same structure is rendered as text in the CLI, logs and training errors:

error[E0203]: can't subtract Scalar from Angle  --> shelf/level.sb:12:18   |12 |     target = tan(angle0 - 15)   |                  ^^^^^^^^^^^ angle0 is Angle, 15 is Scalar   = help: did you mean 15°?

and as JSON in the editor and the API:

{"severity":"error","code":"E0203","message":"can't subtract Scalar from Angle", "span":{"start_byte":500,"end_byte":511,"start_line":12,"start_col":18,"end_line":12,"end_col":29}, "labels":[{"span":{"start_byte":500,"end_byte":511,"start_line":12,"start_col":18,"end_line":12,"end_col":29},"message":"angle0 is Angle, 15 is Scalar"}], "help":"did you mean 15°?","notes":[]}

Lines are 1-based. In JSON, columns are 1-based UTF-16 code units (the editor's convention) and byte offsets index the UTF-8 source; in text, columns count characters. A diagnostic whose span lies in another file (inside an unfrozen import) also carries that file's path.

Compilation tolerates errors. Each top-level item compiles on its own, so one bad line never hides the rest, and a name bound by a failed item causes no follow-on errors. An item that doesn't parse becomes a placeholder node, and the graph is marked partial.

Codes#

CodeMeaning
E0000internal compiler error (please report it)
E0001–E0004syntax: unexpected token, missing token, unterminated string, bad number or unit
E0101missing or duplicate policy(…) signature
E0102header line after the first statement
E0103undefined name
E0104declared output never bound
E0105y.<column> outside loss {} and metrics {}
E0106fn used before its definition
E0107a type annotation with more than one binding target
E0108destructuring: the number of names doesn't match the values
E0109duplicate name (input, output, import, loss term or metric)
E0110a fn, operation, imported policy, library or library member used as a value instead of called
E0201missing unit (a Scalar where a Length or Angle is expected)
E0202output's type doesn't match its declaration
E0203dimension mismatch in +, - or a comparison
E0204type mismatch
E0205operator not defined for these types
E0206a non-constant exponent on a base that isn't a Scalar
E0207° applied to an Angle
E0208a bare array where a search space is expected (use one_of/between)
E0209unknown type
E0210type can't be inferred; add an annotation
E0211unknown member, method or output field
E0220elementwise Vector length mismatch (the operands of an operator or math fn have different lengths)
E0221a Vector where one value is expected; the help gives the map t in v { … } rewrite
E0222map over something that isn't a Vector
E0223constant index out of range, or not a whole number
E0224range(n) with a constant n that isn't a whole number
E0225range(n) with a negative constant n
E0230nonlinear operation on a Gaussian
E0240coordinate (.x, .u, …) outside a predicate
E0241comparison chain without exactly one coordinate
E0242clip/bump need bounds on a single coordinate
E0243coordinate not valid for these points (.u .v on a Cloud, .x .y .z on a Section)
E0244near(plane, within:) used on a Section (it measures 3-D distance, so it needs a Cloud)
E0245a metadata field the collection's elements don't carry, or that some of them lack (of:, by:, meta(…).f; the message names the elements)
E0246an element-metadata field that isn't a number where one is needed (expect's of:, the ordering field of agreement's by:), or whose type differs between elements
E0250invalid solve (bounds or sides that aren't quantities, or a body that doesn't depend on the variable)
E0260an if with a value but no else
E0261a statement (warn(…), an if of statements, error(…)) used as a value
E0270fold without import library foldlib
E0300unknown function (with did-you-mean)
E0301wrong number of arguments, or a required argument missing
E0302duplicate argument
E0303unknown argument or optimiser option (with did-you-mean)
E0304the argument must be a compile-time constant
E0305not one of the allowed options
E0306the value can't be called (a library alias included: call one of its members)
E0401invalid tensor(…) declaration
E0402invalid tensor bound or scale (not a constant, wrong dimension, init outside the bounds)
E0403the policy's tensors have more than 50,000,000 elements
E0410ambiguous unbound calls of a tensor-owning fn
E0411two tensors with the same storage key
E0420recursion
E0421fn / unfrozen-import inlining creates more than 5,000 graph nodes and groups (names the chain)
E0422compiling took more than 2 s of CPU; the graph is partial
E0430import unresolved: not found, or an unpinned policy with no deployment
E0431import cycle or self-import (policies, or libraries importing each other)
E0432pinned tensor version deleted
E0433no version of an imported tensor has its declared type, or a pinned version's stored type differs (editor, training and deploying)
E0434when deploying: the training run doesn't match the commit, or didn't produce every trainable tensor
E0435the imported policy has errors at the resolved commit
E0436invalid import: unknown option, bad frozen: value, or an alias already in use; for import library, any (…) option, a pin on a first-party library, or an alias that is a prelude name
E0440library not found (help names the closest library)
E0441the library has no member of that name (with did-you-mean)
E0442the imported library has errors, or one of its fns can't be applied at this call
E0443invalid library file
E0444two members of one library have the same name
E0501loss terms with mixed dimensions
E0502can't choose a default loss
E0503a Form-1 loss term names no declared output
E0504nll on a point output, or mse on an output that isn't a quantity
E0505a loss term or metric that isn't a number
E0510two non-delegate optimisers
E0511duplicate optimiser
E0512unknown optimiser
E0513invalid optimiser option value
E0520trainable tensors no optimiser can train (W0520 in the editor)
E0521an adam gradient path through a non-differentiable input
E0522a delegate fit has no usable gradient mode under adam
E0523stack nested more than two deep
E0530more than 500 dimensions for cma_es (W0530 above 50)
E0601no usable entries in the dataset
E0602an entry's x field is incompatible with the policy input
E0603a y column's kind or labels don't match the output it trains
E0604the split can't be formed from the dataset's groups
E0605an evaluate override or sweep target that isn't a tensor or node constant of the policy
W0001naming convention
W0002unused binding
W0003a statement line's value is unused
W0410a trained tensor would be orphaned by a rename
W0433stored versions of a tensor have a different type than declared
W0513a training run's optimiser_overrides entry was ignored (unknown option, a change of optimiser kind, or an optimiser the policy doesn't declare)
W0520trainable tensors no optimiser can train (training fails with E0520)
W0530more than 50 dimensions for cma_es
W0540leakage risk: a frozen import was trained on validation entries
W0601–W0607training notices: entries without the group_by field, k clamped to the number of groups, stratify_by field absent, failed or unusable entries skipped, a non-finite adam step skipped, too few groups for adam's inner holdout
W0701a pre-4.0 scan wasn't captured upright (its dominant plane is tilted more than 45°), so the legacy frame conversion may mis-orient it
W0702midline heading ambiguous: the capture heading is more than 70° off the body axis, so the front and back ends may be swapped
W0710a warn(…) ran (runtime; its message)

Runtime errors use R codes and point at the failing node (common ones):

CodeMeaning
R0001an operation or expression raised an error
R0002a required input is missing
R0003the policy has a broken line (placeholder node)
R0005elementwise vectors differ in length or labels
R0006a tensor value is unavailable
R0007an imported policy failed
R0010solve found no root in its bracket
R0011solve bracket is invalid
R0013v[i]: no element at that position or with that label, or a position that isn't a whole number
R0014a Gaussian[T] input isn't {mean, sd} with a finite mean and a finite sd ≥ 0 (or a number)
R0015meta, expect or agreement(by:): the elements carry no metadata, not that field (or not all of them), a field of mixed types, or a field that isn't a number where one is needed
R0016meta(v, at): no element with that label or position; expect / agreement(by:): a label that isn't one of the collection's
R0017range(n): n isn't a whole number
R0018range(n): n is negative
R0019range(n): more than 10,000,000 elements
R0020an error(…) ran: the entry's run is stopped with its message
R0021a fold over more than 100,000 elements
R0101plane3 points are collinear
R0114near(plane, within:) reached a Section at run time
R0115weight's prior is not a single Gaussian with a positive sd
R0132fit_plane got fewer than 3 weighted points, or collinear ones
R0133nearest_distance: to has no points (every weight is 0)
R0201lowess needs at least 2 points
R0202lowess span must be in (0, 1]
R0203a curve node got the wrong kind of input (expected a Section or a Curve)
R0204too few points for the spline degree, or the spline fit failed
R0205fourier needs at least 4 points and a section with non-zero arc length
R0210level_at_knee needs a curve with at least 4 fitted points
R0211quadratic_vertex: fewer than 3 samples in the window, or no curvature
R0213extremum kind must be "min" or "max"
R0214the range searched by level_set_midpoint / flat_extremum is empty
R0221trapezoid fit failed (empty cross-section or zero height span)
R0222a curve node's search space (one_of / between) is empty or of the wrong form
R0601adam aborted after repeated non-finite steps
R0602cma_es aborted: more than half a generation failed

When a node fails, everything downstream of it is marked unreached, independent branches still run, and healthy outputs are still returned. The exception is error(…) (R0020): it stops the entry's run, and no output is returned.