Surf syntax reference
Surf is Chelis's source syntax. The Surf syntax specification defines the full grammar; the Deep syntax specification defines the corresponding Deep representation.
Examples here illustrate individual syntax forms. Some are complete definitions; others depend on surrounding bindings, imports, or declarations. For a complete file you can check and run, see the first program.
Modules
Section titled “Modules”One module per file. The module declaration is the first non-comment line. Module names
are PascalCase and dot-separated. In a package with module_prefix = "Shoals",
src/pricing/options.ch declares:
module Shoals.Pricing.OptionsA script or snippet can omit module. Package source must declare the name fixed by its
package prefix and path beneath the source root.
Comments
Section titled “Comments”-- line comment to end of line{- block comment, which {- nests -} cleanly -}Function definitions
Section titled “Function definitions”A def binds a name to a function. The body is a single expression, which may be a block.
Type annotations on parameters and the return are optional; the compiler infers what you
leave off. The return arrow is ->.
def add_vec[n](x: tensor[n, f32], y: tensor[n, f32]) -> tensor[n, f32] = add(x, y)The body can be a { ... } block of bindings ending in a result expression:
def twice_then_relu[n](x: tensor[n, f32]) -> tensor[n, f32] = { y = add(x, x) relu(y)}The [...] clause declares variables used in a function's types. Depending on where a
name appears, it can stand for a dimension, rank, dtype, or general type. Calls
instantiate these variables by unification:
def identity[a](x: tensor[a, f32]) -> tensor[a, f32] = xA binder may carry a dtype-family bound, written after the name. The families are Float,
Int, and Numeric; a sig takes the same clause in the same position:
sig scale_ints[p: Int]: p -> p -> pdef scale_ints(x, k) = mul(x, k)Direct calls are flat: write f(x, y). To call a function returned by an ordinary
expression, group the callee: (make_adder(x))(y). The ungrouped f(x)(y)
form is rejected. Compiler transforms are self-delimiting, so grad(f)(x)
and vmap(f)(xs) need no extra grouping.
Empty brackets are not decorative syntax: write Option and def f(x), not
Option[] or def f[](x). A constructor value such as None is bare;
None() calls the zero-argument constructor, while Empty {} constructs a
zero-field record. These forms have distinct meanings.
A standalone sig must precede a matching definition in the same module, but need not
be adjacent to it. The scale_ints signature above is a complete example. A sig
without a matching definition is rejected.
Local bindings and blocks
Section titled “Local bindings and blocks”There is no let keyword. Inside a block, name = expr introduces a binding; bindings are
separated by newlines, and the final bare expression is the block's value. A block needs
at least one binding and a final expression. For ordered expression sequencing, use
semicolons in do { first; second }. par { ... } is reserved syntax and is
rejected by the checker.
{ hidden = matmul(x, w) biased = add(hidden, b) relu(biased)}A tuple pattern on the left destructures a tuple result:
(values, indices) = sort(scores, 0)Anonymous functions
Section titled “Anonymous functions”A lambda is fn (params) -> expr. Note the body arrow is ->, distinct from => used in
match arms.
loss_fn = fn (w, b) -> mse_loss(predict(x, w, b), y)Literals
Section titled “Literals”- Integers: canonical decimal such as
42and1000000. Default typei32. - Floats: finite shortest round-trippable spellings such as
1.0,1e-5, and31400000000.0. Default typef32. You may write a longer body that decodes to the same value. For example, you can transcribe a constant at published precision as0.319381530f64.chelis fmtprints the shortest spelling for it. A float literal must be finite at the type it binds at:70000.0f16and an unsuffixed1e40(anf32) are rejected because they round to infinity there. A cast of a finite wider value,cast(70000.0f32, f16), is an operation and does yield infinity. - A literal can carry a precision suffix that binds it exactly: float suffixes
f32 f64 bf16 f16(for example42.0f32,1.0f64), integer suffixesi8 i16 i32 i64(int tokens only). The suffix must follow the digits with no space. A float suffix on an integer body (42f32) is a different literal from the decimal-bodied one: it binds the exact integer directly at that width instead of decoding a decimal, and the formatter keeps whichever you wrote. Literal patterns are unsuffixed because Deep patterns preserve only the raw value. - Strings:
"hello"with escapes\" \\ \n \t \r \0. - Booleans:
true,false. - Unit:
()is the unit value;unitis the unit type. - Tuples:
(a, b, c).(a)is grouping; a one-element tuple is(a,). - Bracket literals:
[1.0, 2.0, 3.0]builds aList, whatever its elements and wherever it stands, and bracket lists pass list arguments to operators, for example the window and stride lists inreduce_window_max(grid, [2i64, 2i64], [1i64, 1i64]).to_tensor([1.0, 2.0, 3.0], f32)builds a tensor, and so does a bracket literal whose own binding or function result declares a tensor type. A negative numeral is unary minus applied to a literal, so writef(-42)to pass a negative argument.
Delimited nonempty lists may carry one trailing comma (or a trailing semicolon in
do). The parser discards it and the formatter omits it; the comma in (a,)
remains because it distinguishes a one-element tuple from grouping.
The parser accepts value-preserving digit separators, hexadecimal/binary integers, every
finite decimal float body that decodes to the literal's value, and equivalent valid
Unicode escapes. chelis fmt prints their canonical decimal/string spelling; fmt --check
rejects the resulting source diff. Malformed separators, a redundant leading zero on an
integer body, invalid escapes, and semantic suffix changes remain errors.
A numeric declaration states the dtype of an unsuffixed literal it directly
contains. A direct cast does so when the literal's kind admits its numeric
target: cast(1.1, f64) is exactly 1.1f64. A tensor declaration states the
element dtype of its bracket literal. to_tensor can state it with a dtype argument:
to_tensor([1.0, 2.0], f64). Without that argument, every numeric literal
element needs a suffix: to_tensor([1.0f64, 2.0f64]). Unsuffixed numeric
literal elements inside to_tensor, including integers and negative values,
have no default, so to_tensor([1, 2, 3]) is rejected.
A dtype argument checks an already-typed element; it does not convert it.
For xs = [1.0, 2.0], the named list already has f32 elements and
to_tensor(xs) keeps them. A tensor parameter or a cast never turns a
bracket literal into a tensor. Structural lists such as the window sizes
above need explicit i64 elements. See
precision rules for the full
dtype-stating rules.
to_tensor is reserved: no definition, parameter, local binding, pattern,
or import may reuse the name, even inside a Reef package.
Operators
Section titled “Operators”Binary and unary operators desugar to named builtin calls. Precedence from loosest to tightest binding:
| Operators | Notes |
|---|---|
|> | pipe, left associative |
|| | logical or |
&& | logical and |
== != | equality, non-associative |
< > <= >= | comparison, non-associative |
+ - | additive |
* / % | multiplicative, % is integer modulo |
unary - ! | prefix |
| function application | |
. | field and tuple access |
Equality and comparison do not chain: a == b == c is a parse error. There is no operator
overloading and no infix bitwise operator; use the named builtins bitand, bitor,
bitxor, shl, shr, and pow for exponentiation.
The pipe operator threads its left value as the first argument of the call on its right.
x |> f(y) is f(x, y), and stages chain from left to right:
hidden = matmul(x, w1) |> add(b1) |> reluUse pipes when left-to-right stage order helps review a linear flow. Calls,
operator sugar and pipes have equal standing. Pipes exist only in Surf: desugaring
normalizes them before literal typing, and chelis deep and chelis surf print
the resulting calls. chelis fmt preserves authored pipes.
Explicitly group pipes mixed with another operator or an open-ended form:
(a * b) |> f, a * (b |> f), (if c then a else b) |> f, and
fn (v) -> (v |> f) are valid. Ungrouped equivalents are rejected.
When the piped value belongs in a later position, pipe into a lambda:
complement = p |> (fn (q) -> sub(1.0, q))Function application
Section titled “Function application”Application is f(x, y). Ordinary arguments are positional. These specific
forms accept a named argument:
grad(f, wrt=w)selects parameters to differentiate.vmap(f, axis=1)selects a nonzero batch axis. Axis zero usesvmap(f).matmul,sum, andeinsumaccept a finalaccumulator=p, as insum(x, 0i32, accumulator=f64). See accumulator precision rules.
Other calls do not accept arbitrary keyword arguments.
Tuples and projection
Section titled “Tuples and projection”Build a tuple with commas; project a field with . and an integer index.
pair = (w_new, b_new)w = pair.0b = pair.1A call result can be projected directly, which is how you read the two outputs of sort:
sorted_values = sort(diag, 0).0sorted_indices = sort(diag, 0).1gather determines its result shape from the selected axis, so its i32 axis
must be a literal or an integer-cast-wrapped literal, including negative
axes. Variables, helper calls and other computed expressions are rejected
at checking, even if they return a constant. sort preserves the input
shape and accepts computed i32 axes; invalid runtime axes trap in both
the evaluator and compiled programs. Negative axes count from the end.
Reductions, expand, and insert require a static constant or named axis.
When projecting through nested tuples, group the inner numeric projection so the next suffix cannot merge with it as a float:
first = (nested.0).0Control flow
Section titled “Control flow”if is an expression and else is mandatory:
if cond then a else bmatch performs pattern matching. Arms use =>. Matching must be exhaustive over the
scrutinee's type; a missing variant is a compile error.
type Activation = | Relu | Sigmoiddef activate[n](act: Activation, x: tensor[n, f32]) -> tensor[n, f32] = match act with { | Relu => relu(x) | Sigmoid => sigmoid(x) }Patterns include variables, the wildcard _, literals, constructors with payloads,
records with field punning, tuples, and guards introduced by if before the =>:
match n with { | x if x > 0 => positive(x) | x if x < 0 => negative(x) | _ => zero_case}Negative numeric patterns are written directly (-42, -1.5, or -0.0);
unlike expression position, pattern position has no unary-expression node.
Types and constructors
Section titled “Types and constructors”Type declarations introduce algebraic data types with type. Variants can be nullary,
carry positional payloads, or carry named record fields.
type Optimizer = | Sgd { lr: tensor[f32] } | Adam { lr: tensor[f32], beta1: tensor[f32], beta2: tensor[f32], eps: tensor[f32] }def learning_rate(opt: Optimizer) -> tensor[f32] = match opt with { | Sgd { lr } => lr | Adam { lr, beta1, beta2, eps } => lr }A type without variants (no |) is a transparent alias, expanded at desugaring:
type Weights = tensor[n, f32]def keep(w: Weights) -> Weights = wRecords are constructed with field names (punning allowed) and read with dot access:
opt = Adam { lr, beta1, beta2, eps }rate = opt.lrFor the full type-expression surface, named dimensions, precision rules, effects, and linearity, see the Type System Reference.
Imports and exports
Section titled “Imports and exports”import Std.Tensor.Construct (linspace, arange)import Nautilus.LinAlg (..)import Std.Sortimport M (a, b) brings specific names into unqualified scope, import M (..) brings every
exported name, and import M makes only qualified access (M.name) available. A qualified
reference works for values, constructors, and types, and is the way to disambiguate two
modules that export the same name.
A module cannot both import a name into unqualified scope and declare it:
import Std.Scalar (max), or import Std.Scalar (..), beside a local def max is an error
in every command, and the diagnostic names both. Rename the local declaration, or import the
module qualified (import Std.Scalar) and call Std.Scalar.max. Except for
reserved names, parameters and local bindings may still reuse an imported name.
A file belongs to the Reef package found by walking up from the file's own directory,
whatever directory a command runs in. A file inside a package but outside its source roots,
such as a script beside reef.toml or a test under tests/, is an entry of that package and
can import its modules, with or without a module line. A file outside every Reef package
imports from the compiler-bundled chelis-std (Std.*) under the same rules.
Importing any other module needs a reef.toml package manifest, and an import that names
no reachable module is a chelis check error.
With no export declaration, every top-level def and type is public. Once any export
appears, only the listed names are public. Exporting a type also exports its constructors.
export (forward, Linear)Dimensions
Section titled “Dimensions”A module-level dim declares concrete named dimensions used across the file. Function-level
[...] parameters declare dimension variables local to one function.
dim batch, vocab_sizedef transpose[a, b](x: tensor[a, b, f32]) -> tensor[b, a, f32] = permute(x, 1, 0)Effects and device regions
Section titled “Effects and device regions”A function's effects can be annotated with a ! { ... } suffix on its sig or
def. IO is inferred from host operations such as print. Random draws take
a key and add no effect. Omitting the clause leaves effects inferred; ! {}
declares a pure upper bound. The with form introduces a device region:
def local_region() -> i32 = with device("cpu") { 1i32 }with device("...") takes a string literal. For a host-C build, only the exact
device name "cpu" is accepted; other names are rejected before output is written.
See Effects for the full model.
Transforms
Section titled “Transforms”grad and vmap use call syntax but are compiler transforms. Each requires a
function argument, and their results are functions that can be called or bound to
a name. Transform targets that are aliases of top-level functions can
be rejected; use a direct, unshadowed top-level function when that occurs. See
Transforms for supported target forms.
(dw, db) = grad(loss_fn, wrt=(w, b))(w, b)
batched = vmap(process)(xs)cast(e, p) converts a scalar or tensor to the named numeric or boolean dtype;
tensor dimensions stay the same. copy(e) produces an owned duplicate where
copying is permitted; keys cannot be copied. See the Type System Reference
for details.
Naming conventions
Section titled “Naming conventions”- Function names use snake_case. Types, constructors, and module path segments use PascalCase. Descriptive value, parameter, dimension, and field names use snake_case; a single uppercase letter is also valid for a value binding or parameter.
- The parser enforces identifier roles;
chelis lintchecks additional naming conventions, includingdefandtypedeclaration names. Runchelis fmtto format source consistently. See the nomenclature specification for the full rules.