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.modeSource text#
#starts a comment that runs to the end of the line.- Whitespace is spaces and tabs. Indentation is not significant.
- Line endings are
\nor\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 withelse(}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 = 3d 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). infis +∞. It is dimensionless unless it takes a unit (inf m);-infis 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"). piis a prelude constant (Scalar).up,forwardandrightare 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:
| Dimension | Unit words |
|---|---|
| Length | m, cm, mm, ft, in |
| Angle | rad, 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°). AScalarwhere aLengthorAngleis expected isE0201, 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^2has dimension Length², and3 mm^0is the plain number 3. A number too large for a float (1e999) isE0004; writeinfinstead. dmeans 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 aScalarto anAngle:angle0°,(a + b)°. On something that's already anAngleit isE0207.- A unit word after a number always binds to it:
2 mis two metres even if a variablemexists. Write2 * mto 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 isE0102.
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
xfields, or to theinputsof 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: aGaussian[Length]column's{mean, sd}(Gaussian columns), or a plainLengthvalue, 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 isR0014. - Outputs are matched by name to a data entry's
ycolumns, 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,)) # annotateda = ebinds;a, b = edestructures a tuple or a multi-output call, by position.- A multi-output call is also a
Recordwhose 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
secare the nodessec,sec#2,sec#3, …. a: T = eannotates 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 isE0202. - 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, andy.<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 nameE0303(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 exactlyf(x, a, k: v), andx |> fisf(x). The function may be a library member:x |> g.halfisg.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.
| Type | Dimension |
|---|---|
Scalar | dimensionless |
Length | L |
Angle | A |
Length^2 | L² |
1/Length (or Length^-1) | L⁻¹, e.g. curvature |
Length/Angle | L·A⁻¹ |
+,-and comparisons need identical dimensions (E0203);*and/combine them;^with a constant exponent scales them (a non-constant exponent needs aScalarbase).sqrthalves exponents;abs,min,max,clamp,sum,meankeep them.sin cos tantake anAngleand return aScalar;asin acos atantake aScalarand return anAngle;atan2(y, x)takes two values of the same dimension and returns anAngle.expandlogtake aScalar.
Value types#
| Type | Meaning |
|---|---|
Bool, Label | a 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 |
Point | a 3-D position |
Direction | a 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 |
Plane | an origin, a unit normal and an in-plane frame (u, v); members .origin (Point), .normal, .u, .v (Direction) |
Frame | a rigid transform |
Cloud | a weighted 3-D point set |
Section | a weighted 2-D point set in a plane's (u, v) frame: u lateral (0 on the midline), v up |
Curve | a fitted function v = f(u) |
Path | a 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 |
Categorical | a 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
mapis written (inputs, earlier bindings, imports, tensors, a fn's parameters); its own bindings stay inside it. - The result is a
Vectorof the body's type. An emptyxsgives 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 isk.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 } }). mapover anything but a vector isE0222.
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 withR0005. - The reductions
sum mean min max argmin argmaxtake a vector of quantities (sumandmeanalso a vector of Gaussians).argminandargmaxreturn 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)nmust be a whole number ≥ 0: a constant that isn't isE0224(not whole) orE0225(negative); at run timeR0017andR0018.range(0)is empty. At most 10,000,000 elements (R0019).rangeandlenare 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
mapresult's element has its input element's label and metadata, and so do elementwise operators on it (-scores / temp).argmin/argmaxand a Categorical's.modegive a label, whichmetatakes. meta(v, at)is the metadata of the element labelledat(or at positionat) 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 ofv.agreement(…, by:, among:)counts ±withinsteps 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:orby:) isE0245, and one whose type differs between elements isE0246; both name the elements, e.g.metadata field `.number` is missing on item 'X1'ormetadata 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
mapover it):meta(brackets, best).numbrisE0211with "did you mean.number?", a field the elements don't have inof:orby:isE0245, and a label ortrue/falsefield where a number is needed isE0246. 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, andR0016for 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").numberfails if any element lacksnumber, even whenL4has 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 independentaandb), because it is(1−r)·a + r·b + noise.- Anything nonlinear on a Gaussian (multiplying two Gaussians,
sin,sqrt,^, comparisons) isE0230, with help: usesample(x, n)for Monte Carlo, or.mean. Samplessupport any elementwise operation (seeded per entry and node, so results are reproducible).- Members:
.mean,.var,.sd,.mode(aCategoricalhas only.mode). Functions:quantile(d, p),interval(d, level: 0.9)(a central interval, outputsloandhi),cdf(d, x),prob(d, x)(mass for aCategorical, 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 aVector[Scalar], one per value. - Converting between kinds is an explicit node (
moment_match,discretize,sample,to_grid,to_categorical). All of them exceptsampledrop correlation with other values;sampledraws 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 allowedA 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,orandnot. Each comparison chain mentions exactly one coordinate (E0241). top(.v, fraction, at_least: L)andbottom(…)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 aCloudwithinLof a plane, e.g.discard(cloud, near(floor, within: 2 cm)); on aSectionit isE0244.clipandbumpneed bounds on a single coordinate, possibly a union of intervals joined withor(E0242).clipclamps 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 - dropsolve 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 mPure 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(E0204otherwise). For an elementwise choice over vectors usewhere(cond, a, b); to branch per element,mapand put theifinside. - Both branches have the same type:
E0203when their dimensions differ,E0204otherwise. A branch that iserror(…)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
elseisE0260, 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 comparisona < bby 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.warnanderrorin the branch the plain condition doesn't take still don't run. Conditions onBoolinputs, label comparisons and==/!=stay a hard choice. where(cond, a, b)computes both values, works elementwise on vectors and never blends;ifcomputes one branch and takes oneBool.- In the graph an
ifis a group with the condition's node and two collapsible subgroups,thenandelse.
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,foldisE0270("addimport library foldlib"). The import belongs to the file where thefoldis 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 hascumsum,running_maxandrunning_min(see foldlib). - The body's value must have the type of
init(E0203/E0204);xsmust 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, or4.20 cmwhere the display is metric; vectors as[…], Gaussians asmean ± sd);{{and}}are literal braces. warnadds warningW0710to 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.errorstops the run of that entry withR0020and your message, pointing at theerror(…); no output is returned. In training, that entry fails.- A statement has no value:
x = warn("…")isE0261. A statement line that does have a value (abs(x)alone) is warningW0003.
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 ashape:(inferred from the consumer when omitted). lower:andupper: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) / 4when both bounds are finite, otherwisemax(abs(init) / 2, 1 unit), where the unit is the one the initial value is written in (1 infortensor(0 in),1for aScalar), or one canonical unit (1 m, 1 rad, 1) with a named initialiser. Set it when the default is far off (for examplescale: 0.25 infor 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:andscale:must be finite. Bounds may beinfbut not NaN. Aname:is made of[A-Za-z0-9_]segments separated by.("sigma_2","lid.temp"). Each of these isE0401otherwise. - 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: truemakes 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 thename: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 asparcel/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>: inlength = linear(…, noise: tensor(3 mm, lower: 0 mm))the key islength.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: foro = f(s)withfn f(x) { g(x) + 1 mm }, the tensortofgiso.result.g.t. - Two call sites resolving to the same key is
E0411(give one aname:). - 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
leftagain for a renamed fn call site (inside a fn every key starts with the call site,name:included); - name the binding
hagain for implicit weights.
- add
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:
bracketsforshelf/brackets,sigma_tapeforparcel/tape.sigma_tape. A segment that isn'tsnake_casemakes an alias that draws naming warningW0001(parcel/lids/Awould beA), so give it one withas. Policy-owned tensors are imported by<policy path>.<key>. - A stored collection of shapes imports as a
Vector[Section],Vector[Cloud]orVector[Curve], labelled when its items are:import tensor lib/shapes as templates, thenmap 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 forkeda(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 inlinedfnat 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 andlossact 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)isalias.fn(x, a), andx |> alias.fnisalias.fn(x). - The alias is a namespace, not a value. Using it alone (
g) or a member without calling it (g.half) isE0110; calling the alias itself (g(…)) isE0306. A name the library doesn't have isE0441, with a did-you-mean (did you mean g.midpoint?). - Library members never enter the unqualified scope: after
import library geom_util as g,midpointalone is still undefined. Two libraries may define fns of the same name;a.best(…)andb.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 meanisE0436; 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 asnum_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 isE0440, with help naming the closest library. - Libraries can import other libraries; a cycle is
E0431. A library that doesn't compile isE0442at the import, naming its first error; arguments are checked against the fn's signature like any call (E0204for aPointwhere aLengthis expected), and an error inside the library fn that only shows up at a particular call is reported once, asE0442at 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_esanddelegate, and more built-in optimisers are coming. - Declare at most one non-delegate optimiser, such as
adamorcma_es;delegatemay be declared alongside it. Two non-delegate optimisers isE0510; any duplicate declaration isE0511. adam(lr, steps, batch: "full" | N, schedule: "cosine" | "constant", warmup, clip, weight_decay, patience, holdout).cma_es(sigma0, popsize, generations, restarts, tolfun, tolx, seed).delegatelets 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
W0520and training fails withE0520, 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), whereoutis a declared output.nllis-log_prob(out, y.out);mseis(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). Ay.<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 withlog_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 usenll).nllis computed in canonical SI units, so log densities compare across policies. - Without a
lossblock:nllfor each distribution output, against the y column of the same name. Only if the policy has no distribution output:msefor each point output (a quantity such asScalar,LengthorAngle), 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 aPoint,VectororSectionoutput), you getE0502: 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#
| Code | Meaning |
|---|---|
E0000 | internal compiler error (please report it) |
E0001–E0004 | syntax: unexpected token, missing token, unterminated string, bad number or unit |
E0101 | missing or duplicate policy(…) signature |
E0102 | header line after the first statement |
E0103 | undefined name |
E0104 | declared output never bound |
E0105 | y.<column> outside loss {} and metrics {} |
E0106 | fn used before its definition |
E0107 | a type annotation with more than one binding target |
E0108 | destructuring: the number of names doesn't match the values |
E0109 | duplicate name (input, output, import, loss term or metric) |
E0110 | a fn, operation, imported policy, library or library member used as a value instead of called |
E0201 | missing unit (a Scalar where a Length or Angle is expected) |
E0202 | output's type doesn't match its declaration |
E0203 | dimension mismatch in +, - or a comparison |
E0204 | type mismatch |
E0205 | operator not defined for these types |
E0206 | a non-constant exponent on a base that isn't a Scalar |
E0207 | ° applied to an Angle |
E0208 | a bare array where a search space is expected (use one_of/between) |
E0209 | unknown type |
E0210 | type can't be inferred; add an annotation |
E0211 | unknown member, method or output field |
E0220 | elementwise Vector length mismatch (the operands of an operator or math fn have different lengths) |
E0221 | a Vector where one value is expected; the help gives the map t in v { … } rewrite |
E0222 | map over something that isn't a Vector |
E0223 | constant index out of range, or not a whole number |
E0224 | range(n) with a constant n that isn't a whole number |
E0225 | range(n) with a negative constant n |
E0230 | nonlinear operation on a Gaussian |
E0240 | coordinate (.x, .u, …) outside a predicate |
E0241 | comparison chain without exactly one coordinate |
E0242 | clip/bump need bounds on a single coordinate |
E0243 | coordinate not valid for these points (.u .v on a Cloud, .x .y .z on a Section) |
E0244 | near(plane, within:) used on a Section (it measures 3-D distance, so it needs a Cloud) |
E0245 | a metadata field the collection's elements don't carry, or that some of them lack (of:, by:, meta(…).f; the message names the elements) |
E0246 | an 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 |
E0250 | invalid solve (bounds or sides that aren't quantities, or a body that doesn't depend on the variable) |
E0260 | an if with a value but no else |
E0261 | a statement (warn(…), an if of statements, error(…)) used as a value |
E0270 | fold without import library foldlib |
E0300 | unknown function (with did-you-mean) |
E0301 | wrong number of arguments, or a required argument missing |
E0302 | duplicate argument |
E0303 | unknown argument or optimiser option (with did-you-mean) |
E0304 | the argument must be a compile-time constant |
E0305 | not one of the allowed options |
E0306 | the value can't be called (a library alias included: call one of its members) |
E0401 | invalid tensor(…) declaration |
E0402 | invalid tensor bound or scale (not a constant, wrong dimension, init outside the bounds) |
E0403 | the policy's tensors have more than 50,000,000 elements |
E0410 | ambiguous unbound calls of a tensor-owning fn |
E0411 | two tensors with the same storage key |
E0420 | recursion |
E0421 | fn / unfrozen-import inlining creates more than 5,000 graph nodes and groups (names the chain) |
E0422 | compiling took more than 2 s of CPU; the graph is partial |
E0430 | import unresolved: not found, or an unpinned policy with no deployment |
E0431 | import cycle or self-import (policies, or libraries importing each other) |
E0432 | pinned tensor version deleted |
E0433 | no version of an imported tensor has its declared type, or a pinned version's stored type differs (editor, training and deploying) |
E0434 | when deploying: the training run doesn't match the commit, or didn't produce every trainable tensor |
E0435 | the imported policy has errors at the resolved commit |
E0436 | invalid 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 |
E0440 | library not found (help names the closest library) |
E0441 | the library has no member of that name (with did-you-mean) |
E0442 | the imported library has errors, or one of its fns can't be applied at this call |
E0443 | invalid library file |
E0444 | two members of one library have the same name |
E0501 | loss terms with mixed dimensions |
E0502 | can't choose a default loss |
E0503 | a Form-1 loss term names no declared output |
E0504 | nll on a point output, or mse on an output that isn't a quantity |
E0505 | a loss term or metric that isn't a number |
E0510 | two non-delegate optimisers |
E0511 | duplicate optimiser |
E0512 | unknown optimiser |
E0513 | invalid optimiser option value |
E0520 | trainable tensors no optimiser can train (W0520 in the editor) |
E0521 | an adam gradient path through a non-differentiable input |
E0522 | a delegate fit has no usable gradient mode under adam |
E0523 | stack nested more than two deep |
E0530 | more than 500 dimensions for cma_es (W0530 above 50) |
E0601 | no usable entries in the dataset |
E0602 | an entry's x field is incompatible with the policy input |
E0603 | a y column's kind or labels don't match the output it trains |
E0604 | the split can't be formed from the dataset's groups |
E0605 | an evaluate override or sweep target that isn't a tensor or node constant of the policy |
W0001 | naming convention |
W0002 | unused binding |
W0003 | a statement line's value is unused |
W0410 | a trained tensor would be orphaned by a rename |
W0433 | stored versions of a tensor have a different type than declared |
W0513 | a training run's optimiser_overrides entry was ignored (unknown option, a change of optimiser kind, or an optimiser the policy doesn't declare) |
W0520 | trainable tensors no optimiser can train (training fails with E0520) |
W0530 | more than 50 dimensions for cma_es |
W0540 | leakage risk: a frozen import was trained on validation entries |
W0601–W0607 | training 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 |
W0701 | a 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 |
W0702 | midline heading ambiguous: the capture heading is more than 70° off the body axis, so the front and back ends may be swapped |
W0710 | a warn(…) ran (runtime; its message) |
Runtime errors use R codes and point at the failing node (common ones):
| Code | Meaning |
|---|---|
R0001 | an operation or expression raised an error |
R0002 | a required input is missing |
R0003 | the policy has a broken line (placeholder node) |
R0005 | elementwise vectors differ in length or labels |
R0006 | a tensor value is unavailable |
R0007 | an imported policy failed |
R0010 | solve found no root in its bracket |
R0011 | solve bracket is invalid |
R0013 | v[i]: no element at that position or with that label, or a position that isn't a whole number |
R0014 | a Gaussian[T] input isn't {mean, sd} with a finite mean and a finite sd ≥ 0 (or a number) |
R0015 | meta, 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 |
R0016 | meta(v, at): no element with that label or position; expect / agreement(by:): a label that isn't one of the collection's |
R0017 | range(n): n isn't a whole number |
R0018 | range(n): n is negative |
R0019 | range(n): more than 10,000,000 elements |
R0020 | an error(…) ran: the entry's run is stopped with its message |
R0021 | a fold over more than 100,000 elements |
R0101 | plane3 points are collinear |
R0114 | near(plane, within:) reached a Section at run time |
R0115 | weight's prior is not a single Gaussian with a positive sd |
R0132 | fit_plane got fewer than 3 weighted points, or collinear ones |
R0133 | nearest_distance: to has no points (every weight is 0) |
R0201 | lowess needs at least 2 points |
R0202 | lowess span must be in (0, 1] |
R0203 | a curve node got the wrong kind of input (expected a Section or a Curve) |
R0204 | too few points for the spline degree, or the spline fit failed |
R0205 | fourier needs at least 4 points and a section with non-zero arc length |
R0210 | level_at_knee needs a curve with at least 4 fitted points |
R0211 | quadratic_vertex: fewer than 3 samples in the window, or no curvature |
R0213 | extremum kind must be "min" or "max" |
R0214 | the range searched by level_set_midpoint / flat_extremum is empty |
R0221 | trapezoid fit failed (empty cross-section or zero height span) |
R0222 | a curve node's search space (one_of / between) is empty or of the wrong form |
R0601 | adam aborted after repeated non-finite steps |
R0602 | cma_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.
