Twill language guide
This is the reference for Twill v1.7. The language is small, so this is short.
Running programs
twill path/to/program.tw # shape-check, then run
twill run path/to/program.tw
twill check path/to/program.tw # shape-check only
twill fmt path/to/program.tw # canonically format (add --write to edit in place)
twill # REPL
twill fmt reprints a program in a canonical style, preserving comments. It
refuses rather than move a comment it can't place.
Pass --no-check to run without the static shape check. In the REPL, each line's
value is printed; :help and :quit do the obvious things.
Lexical structure
Comments run from
#to end of line.Whitespace is insignificant, with one exception: a token that could either continue the previous line or start a new one is resolved by indentation. A line that opens with
+or-continues the previous expression when it is indented past the column the statement began at, and starts a new statement when it lines up with that column or sits to its left:let total = base_rate + adjustment # continues: indented past `let` - discount let x = f(a) -mean(y) # a new statement: lines up with `let`The same rule settles a line that opens with a bitwise word followed by
(, such asxor(a, b), which is a call starting a new statement rather than the previous expression continued byxor. A leading(or[always begins a new expression rather than a call or index on the previous line. Ending a line with the operator continues an expression too, and always did.Identifiers match
[A-Za-z_][A-Za-z0-9_]*.Numbers are floating point:
3,3.14,1e-3,.5.Strings use double quotes with
\n,\t,\",\\escapes.
Values
| Type | Example | Notes |
|---|---|---|
| Tensor | 3.0, [1.0, 2.0], [[1.0],[2.0]] |
The core type. Scalars are rank-0 tensors. |
| Bool | true, false |
From comparisons and logic. |
| String | "hello" |
For print and messages. |
| List | range(5), [grad(f), 2] |
Heterogeneous; from [...] of non-numbers, list(...), or range. |
| Record | { w: [1.0], b: 0.0 } |
Named fields; access with .. |
| Function | fn(x) = x + 1 |
Closures capture their scope. |
| Unit | () |
The result of print, loops, etc. |
A bracketed literal whose elements are all numbers (or nested numeric brackets)
is a tensor. If any element isn't numeric, it's a list. Build a tensor from
computed values with tensor([...]).
[1.0, 2.0, 3.0] # tensor, shape [3]
[[1.0, 2.0], [3.0, 4.0]] # tensor, shape [2, 2]
[grad(f), "x", true] # list
Systems-mode types
A file whose first non-comment line is mode systems gets the subset designed
in docs/self-hosting.md. Annotation is mandatory there, so every type has to
have a name that can be written. This is the complete list of those names.
| Name | What it is |
|---|---|
I64 |
signed 64-bit integer, two's complement, wrapping |
F64 |
IEEE 754 binary64 scalar, not a rank-0 tensor |
Bool |
true or false, the result of a comparison or of and/or/not |
Byte |
an I64 constrained to 0..255 at construction |
Bytes |
a growable, mutable byte sequence |
Str |
an immutable byte string, O(1) length and byte indexing |
Arr[T] |
a growable, mutable, homogeneous array |
Dict[K, V] |
a hash map, K is Str or I64, iterated in insertion order |
Opt[T], Res[T, E] |
the two standard enums |
a struct name |
a nominal record declared with struct |
Unit |
the type of () |
docs/self-hosting.md section 1.2 lists all of these except Bool and F64,
and section 1.3 makes annotation mandatory, which is a contradiction rather than
an omission: a Bool field cannot be declared and a F64 cannot be declared,
so no file that has either can be written at all. Both are named here.
A parameter or a field may also be annotated with a function type,
fn(I64) -> F64, which is what a callback is declared as.
The annotations are checked
Until 1.6 these names were advisory: the checker knew I64 was a type rather
than a unit and nothing further, so let x: I64 = "hello" passed the check and
whatever happened next happened at run time. They are real now, and a definite
mismatch is a diagnostic:
lexer.tw:14: shape error: "kind" is declared I64 but the value is Str
lexer.tw:22: shape error: field "src" of Lexer is declared Str but the value is F64
lexer.tw:31: shape error: argument 2 ("n") is declared I64 but the value is Bool
lexer.tw:38: shape error: function "peek" returns Opt[I64] but its signature declares I64
It is checked at a binding, an argument, a return, a struct field both at construction and at every later assignment, and an enum payload.
The policy is the shape checker's, not a stricter one. A mismatch is
reported only when both sides are known and cannot be the same value; anything
the checker cannot resolve -- a type from another module, an unbound type
parameter, a value whose type is unknown -- judges nothing rather than
guessing. docs/self-hosting.md section 1.3 asks for the opposite policy, where
a type that is still unknown at the end of inference is itself an error, and
that is deliberately not what this is: it would make every unannotated
expression in a mostly-annotated file an error, and a checker that is wrong
often gets turned off. The stricter policy stays open as docs/needs.md
NEEDS-49.
I64 and F64 stand for each other, and one case does not. An I64
annotation converts the value, so let mid: I64 = (lo + hi) / 2 truncates and
is meant to; a scalar of either kind is therefore accepted where the other is
declared. The exception is a written fraction: let n: I64 = 2.5 is reported,
because truncating a literal somebody typed produces a number nobody typed.
Bool
Bool is a type name in systems mode, spelled exactly like that, and it is
legal anywhere a type is: a parameter, a return type, a struct field, a let
annotation, and an Arr or Dict element type.
struct Tok { kind: I64, text: Str, trailing: Bool }
fn is_space(c: I64) -> Bool = c == 32 or c == 9
There is no conversion between Bool and I64 in either direction, and none is
implied by a comparison. if and while take a Bool and nothing else, so an
I64 used as a condition is a checker error rather than a test against zero.
Numeric mode is unaffected: there a comparison still yields the Bool value
described under Values, and Bool is not a name the checker knows,
because numeric mode has no annotations to write it in.
F64, and what a systems-mode scalar is
A systems-mode scalar is not a tensor. F64 is a plain immutable 64-bit
float, held by value, with no shape, no gradient, no tape entry, and no
allocation. mode systems has no tensor type at all, so a rank-0 tensor is not
merely discouraged there, it cannot be named.
This is the answer to a question that was priced before it was asked. loom's
src/metrics.tw accumulates a running total once per training step. If a
systems-mode scalar were a rank-0 tensor, every total = total + x would
allocate a tensor and a tape node, and an epoch would build a chain of them; the
accumulator would cost more than the model. It does not. Meter.total: F64 is a
machine word and + on it is an instruction.
The two halves of the language are separated by a conversion and not by a coincidence of representation:
f64(n)widens anI64toF64, losing precision above 2^53.i64(x)narrows anF64toI64, truncating toward zero, and is undefined outside theI64range.- There is no implicit conversion in either direction, ever, and no implicit
conversion between
F64and a numeric-mode tensor. Crossing that seam is entry 17 ofdocs/roadmap.mdand is deliberately not answered here.
F64 carries the ordinary IEEE rules, which are the same rules numeric mode
already has: NaN != NaN, division by zero gives an infinity rather than
failing, and -0.0 and 0.0 compare equal while being different values. The
comparison operators are defined on F64 and return Bool.
Arithmetic is + - * / and %. % on F64 is the floored modulo numeric mode
already uses (see Integer division and modulo
for why the I64 rule is different). The transcendental functions on F64 are
entry 15 of the roadmap and are not specified here; what is specified is that
when they arrive they take and return F64, not a rank-0 tensor.
Numeric mode is unaffected. There a scalar is still a rank-0 tensor, which is
principle 1 in docs/design.md and is what keeps autodiff, broadcasting and
printing uniform. The two answers differ because the two modes want different
things, and the mode gate is what lets both be true.
Operators
Lowest to highest precedence:
| Operators | Meaning |
|---|---|
or / ||, and / && |
short-circuiting logic |
== != < <= > >= |
comparison (returns Bool); see Equality |
+ - |
add / subtract (elementwise) |
* / % @ |
multiply / divide / modulo (elementwise), matmul (@) |
^ |
power (right-associative, scalar exponent) |
unary -, not / ! |
negation, logical not |
Elementwise operators broadcast NumPy-style: a scalar against a tensor, a row
vector across a matrix, a column vector down its rows, and so on. Two shapes
combine when, aligned from the right, each pair of dimensions is equal or one of
them is 1. @ covers vector·vector (dot), matrix·vector, vector·matrix, and
matrix·matrix.
Integer division
/ is exact division and always gives a float: 7 / 2 is 3.5. // divides
and truncates toward zero, which is the integer division a count, an index or a
midpoint wants:
7 // 2 # 3
-7 // 2 # -3, truncated toward zero, not floored
(n + k - 1) // k # the ceiling of n/k
314 % 100 # 14, the matching remainder
Both intents are written down rather than inferred from the operands, because
every number runs as an F64 and there is nothing to infer from. x // 0 is an
error, where x / 0 is an infinity.
An I64 annotation also truncates, and it does so in both places it can appear
— a binding and a return:
let mid: I64 = (lo + hi) / 2 # truncated at the binding
fn ceil_div(n: I64, k: I64) -> I64 { # truncated at the return
(n + k - 1) / k
}
Bitwise operators on I64
These belong to mode systems and to I64 only. There is no bitwise operator
on F64 and no unsigned integer type. Each is available infix and as a call,
and the two spellings are the same operation.
| Infix | Call | Meaning |
|---|---|---|
a band b |
band(a, b) |
bitwise AND |
a bor b |
bor(a, b) |
bitwise OR |
a xor b |
xor(a, b) |
bitwise XOR |
bnot(a) |
bitwise complement, every bit flipped | |
a shl k |
shl(a, k) |
left shift, zeros shifted in at the bottom |
a shr k |
shr(a, k) |
arithmetic right shift, sign bit shifted in |
The shifts bind like * and xor/bor bind like +, which is where their
symbolic equivalents sit in C and Go.
The bitwise AND and OR are band and bor, not and and or. and and
or infix are the short-circuiting boolean operators and return an operand,
so x and 255 is 255 for any non-zero x — a silent wrong answer, not an
error. The bitwise meaning has its own name for the same reason the bitwise
complement is bnot and not not. and(a, b) and or(a, b) in call form
remain the bitwise operations, for the code that already wrote them that way.
I64 is two's complement and exactly 64 bits. and, or, xor and not are
defined bit by bit on that representation and have nothing to say about sign.
shl discards bits shifted off the top, so it wraps, and it is the same
operation for negative and non-negative operands.
shr is arithmetic, not logical. With a negative left operand it shifts
copies of the sign bit in from the top, so shr(-8, 1) is -4 and shr(-1, k)
is -1 for every k. Equivalently, shr(a, k) is floor(a / 2^k) for every
a, which is what makes it the right shift for arithmetic and the wrong one for
bit manipulation.
This matters more often than it looks, because the subset has no unsigned type,
so any 64-bit quantity that is conceptually unsigned is carried in an I64 and
has its top bit set half the time. Hash mixers, IEEE 754 bit patterns and
multiprecision limbs are all in that position.
Shift counts. k is masked to its low six bits, so the effective count is
k and 63. A count of 64 or more therefore wraps rather than saturating to zero
or to the sign, and a negative count is masked into the same 0..63 range rather
than shifting the other way. Both are almost always a bug at the call site; the
masking exists so the operation is total and platform-independent, not because
either is a useful thing to write. Do not rely on it. Where a shift count is
computed, range-check it.
Getting a logical right shift
There is no ushr operator. Build one. src/float.tw's ushr is the idiom,
and it is what every caller in the ecosystem should use or copy:
let SIGN_BIT: I64 = shl(1, 63)
fn ushr(x: I64, k: I64) -> I64 {
if k == 0 { return x }
if x >= 0 { return shr(x, k) }
or(shr(and(x, not(SIGN_BIT)), k), shl(1, 63 - k))
}
Clearing the sign bit makes the value non-negative, where shr is already the
logical shift, and the bit is then put back at the position it would have
shifted to. The k == 0 guard is not decoration: without it shl(1, 63 - k)
would be shl(1, 63), which sets the sign bit rather than clearing nothing.
The same construction appears in std/random.tw for splitmix64 and xoshiro.
Anything porting a reference implementation written over uint64 needs it,
because such a reference's >> is logical and shr is not.
Related: docs/needs.md NEEDS-2 (the type and its operators) and NEEDS-85 (why
this is specified here rather than left to the eventual implementation).
Integer division and modulo on I64
When both operands are I64, / is integer division and % is its remainder.
Neither promotes to F64 and neither yields a fraction.
A bare literal is not an I64. An I64 is a value the program said was
one -- through an annotation, i64(), arithmetic on one, or a literal too large
for an f64 to hold -- so 7 / 2 written out is 3.5 in either mode, and
a / b is 3 when a and b are bound at I64. That is what keeps numeric
mode's arithmetic exactly as it was, and it means the two ways to ask for
integer division are an annotation, which converts at the binding, and //,
which truncates whatever it is given:
let mid: I64 = (lo + hi) / 2 # truncated at the binding
let half = 7 // 2 # 3, said at the operator
Mixing an I64 and an F64 with a fractional value gives an F64, which is
what the same expression computed before I64 was a distinct type.
Rounding. / truncates toward zero and % takes the sign of the
dividend. That is Go's rule, C99's rule and Rust's rule, and the identity it
preserves is the one worth naming:
(a / b) * b + a % b == a for every a, and every b that is not 0
| Expression | Value |
|---|---|
7 / 2, 7 % 2 |
3, 1 |
-7 / 2, -7 % 2 |
-3, -1 |
7 / -2, 7 % -2 |
-3, 1 |
-7 / -2, -7 % -2 |
3, -1 |
This is not the numeric-mode rule, and the difference is deliberate. In
numeric mode % is the floored modulo, x - floor(x / y) * y, so -7 % 3 is
2 there and -1 here. Numeric mode wants the floored answer because a modulo
on tensor data is nearly always a wrap into a range, where a negative result is
a bug. Systems mode wants the truncating answer because the code that uses it is
digit extraction, quantisation and packing, all of which are ports of integer
code that assumes it. Two modes, two rules, one sentence each, rather than one
rule that is wrong for one of them. When a systems-mode program really wants the
floored answer, write ((a % b) + b) % b.
shr is not division. shr(a, k) is floor(a / 2^k) and / truncates, so
the two agree for a non-negative a and disagree for a negative one:
shr(-7, 1) is -4 and -7 / 2 is -3. Replacing a division by a power of two
with a shift is a valid rewrite only when the dividend cannot be negative. This
is the sharpest edge in the integer half of the subset and it is the reason both
rules are written on the same page.
Overflow. / wraps like every other I64 operation rather than trapping,
so MIN_I64 / -1 is MIN_I64 and MIN_I64 % -1 is 0. It is the only
division that overflows.
Division by zero. a / 0 and a % 0 abort the program with a diagnostic
naming the operation and its position. They do not return 0, they do not
return a NaN, and they do not return a Res.
docs/self-hosting.md section 1.2 says "division by zero is an error value, not
a panic", and this narrows that sentence rather than restating it. What section
1.2 is ruling out is the Go bootstrap's behaviour, a host-language crash with a
stack trace and no source position, and that is ruled out. What it must not be
read as asking for is / returning Res[I64, Str]: that would put a ? on
every arithmetic expression in the self-hosted compiler, and a language where
i + 1 is fallible is a worse language than one where a zero divisor is a bug
that stops. A caller that expects a zero divisor tests for it, which is one line
at the one place it can happen rather than a type change at every place it
cannot.
Equality
== and != are deep structural comparison. Two values are equal when they
have the same type and the same contents, all the way down:
[1.0, 2.0] == [1.0, 2.0] # true (tensors)
[1.0, "x"] == [1.0, "x"] # true (lists, element by element)
{ w: [1.0], b: 0.5 } == { w: [1.0], b: 0.5 } # true (records, field by field)
The details:
- A tensor's shape is part of its value:
[[1.0, 2.0], [3.0, 4.0]]and[1.0, 2.0, 3.0, 4.0]hold the same numbers but are not equal. Numbers compare by IEEE rules, so a tensor holding aNaNis not equal to itself. - Lists compare elementwise, and must be the same length.
- Records compare field by field, matched by name, so declaration order
doesn't change the answer:
{ a: 1.0, b: 2.0 } == { b: 2.0, a: 1.0 }. A record with an extra field is not equal. - Values of different types are never equal:
[1.0] == 1.0is false, not an error. - Functions have no structure worth walking, so they compare by identity: a
function equals itself, and two separately written
fn(x) = xdo not. !=is exactly the negation of==.
The ordering operators (<, <=, >, >=) are only defined on scalars;
applying one to a list, record, string, or non-scalar tensor is an error.
For elementwise comparison of two tensors into a tensor of 1s and 0s, use the
equal builtin. == on tensors gives one Bool for the whole value.
Strings
A Str is an immutable byte string. It is bytes that print, not text: there is
no character type, no rune, and no unicode normalization anywhere in the
language. Everything below follows from that and holds in both modes.
Equality
== on two Str values is true when they have the same length and the same
bytes, in order. There is no case folding, no whitespace trimming, no unicode
normalization, and no locale. Two strings that a person would call the same word
are equal only if they are the same bytes, so a decomposed and a precomposed
form of the same accented letter are different strings, and that is the answer
rather than a limitation: a lexer that compares source bytes against literals
needs byte equality and would be wrong under any other rule.
This is the general deep-equality rule from Equality applied to
Str, not a special case, so the surrounding rules come with it. A Str is
never equal to a value of another type, and that is false rather than an
error: "1" == 1 is false. != is exactly the negation.
src/term/caps.tw leans on this for every environment comparison, and
docs/needs.md NEEDS-46 records it as a constraint on the Str rewrite: making
Str a distinct indexable value must not change what == means.
Ordering
<, <=, > and >= order two Str values by their bytes, as of 1.5.1.
They were scalar-only before that, and every codebase that wanted a sorted list
of names had written its own compare_str returning -1, 0 or 1 and compared
that against zero -- three of them, independently.
The ordering is the one those hand-written comparisons implemented, written down here so that eleven copies of it in six repositories agree:
Compare byte by byte from index 0. At the first index where the two differ, the string with the smaller byte value is smaller. If one string runs out first and every byte matched, the shorter one is smaller. Bytes are compared as unsigned values in 0..255.
That is Go's sort.Strings and it is lexicographic on bytes, not on characters:
for ASCII it is alphabetical with uppercase before lowercase, and for anything
else it is UTF-8 code-point order, which is a well-defined order and not a
linguistic one. Any eventual str_less builtin or comparator means exactly
this.
The reason ordering is a function rather than an operator, when equality is an
operator, is that equality has one obvious meaning on bytes and ordering has
several plausible ones. < on a string would read as "alphabetically before",
which is a promise about language that a byte comparison does not keep. A named
function is a place to put that sentence.
sort applies this order directly to a list of strings: sort(["b", "a"]) is
["a", "b"], and a truthy second argument sorts descending. It returns a new
list and leaves its argument untouched.
A comparison between a Str and a value of another type is still an error. The
ordering is defined on bytes, and there is no byte order between a string and a
number to appeal to.
Concatenation
Str + Str exists and produces a new Str. It is the one overload of +
that is not numeric, and the result is the left operand's bytes followed by the
right operand's.
"col_" + name + "_0"
+ on a Str and a non-Str is an error, with no coercion in either
direction. A number is converted with str() first, deliberately: an implicit
str() inside + would make 1 + 2 ambiguous the moment either side came from
a dictionary, and the explicit call is one function name in exchange for that.
docs/self-hosting.md gave Bytes a concat and gave Str length, indexing
and slicing, and never said how two strings join. Every codebase in the
ecosystem assumed + and spool calls it the single most-used operation in its
source, so this ratifies what was already assumed rather than overriding it.
Building a string in a loop with + is quadratic. Each + allocates and
copies the whole left operand, so out = out + piece across n pieces copies
O(n^2) bytes. This is stated here because it is the shape everything reaches for
first and it is affordable exactly until it is not: weft builds a frame of a live
plot from a few hundred pieces at 30 repaints a second, and twill's own
src/cli/progress.tw builds a bar a cell at a time. Use the Bytes builder for
those:
let out: Bytes = bytes.new()
bytes.push_text(out, piece) # amortized O(1) per push
bytes.to_str(out) # one copy, at the end
src/bytes.tw wraps that surface (new, push, push_text, to_str, plus
concat, join and repeat), and it is where a renderer's inner loop belongs.
+ is for the outer one.
Bindings and assignment
let x = 10 # new binding in the current scope
x = x + 1 # reassign an existing binding (error if not yet bound)
let always introduces a new variable. Plain assignment updates the nearest
existing binding, which is what makes training loops work.
Functions
fn square(x) = x * x # expression body
fn norm(v) { # block body; last expression is returned
let s = sum(v * v)
sqrt(s)
}
let inc = fn(x) = x + 1 # anonymous function
Functions are values and close over their environment:
fn adder(n) = fn(x) = x + n
let add5 = adder(5)
add5(10) # 15
return exits early; a bare return yields ().
Parameters may carry shape annotations, and a function may declare its return shape. These are checked statically (see below); at runtime they're ignored.
fn matvec(A: [3, 2], x: [2]) -> [3] {
A @ x
}
A dimension can be a concrete size, _ for an unknown, or a name (a shape
variable). A name used in more than one place must stand for the same size, so
the checker can tie shapes together and verify the return:
fn matmul2(A: [n, k], B: [k, m]) -> [n, m] {
A @ B
}
Here k must match between A and B, and the result is checked against
[n, m].
Control flow
if is an expression:
let sign = if x > 0.0 { 1.0 } else if x < 0.0 { -1.0 } else { 0.0 }
while and for are statements:
while i < n { i = i + 1 }
for k in range(5) { print(k) } # over a list
for xi in [1.0, 2.0, 3.0] { ... } # over a 1-D tensor
break and continue
In systems mode a loop body may contain break and continue.
while i < len(src) {
let c: I64 = src[i]
if c == 32 { i = i + 1 continue }
if c == 35 { break }
push(toks, scan(lx))
}
breakleaves the innermost enclosing loop immediately.continueskips the rest of the body and begins the next iteration: in awhilethat means re-evaluating the condition, and in aforthat means advancing to the next element. In neither case does it re-run anything already run in this iteration, so acontinuein awhilewhose counter is advanced at the bottom of the body is an infinite loop, and that is the caller's bug and not a special case here.- Both bind to the innermost enclosing loop. There are no labels and no multi-level break. A loop that wants to leave two levels sets a flag or is a function that returns.
- Both are statements, not expressions. A loop still evaluates to
(), so neither carries a value andbreak xis a syntax error. - Neither crosses a function boundary. A
fnwritten inside a loop body is a new scope for this purpose, and abreakin it is an error rather than a way to leave the loop that lexically encloses it. Usereturn. - Both outside any loop are a checker error naming the statement.
They are keywords in systems mode only, at statement position, which follows
the rule match already uses. A numeric-mode file that writes let break = 3
keeps working, which is why the mode gate is worth the sentence: nothing in
docs/language-guide.md's numeric half changes meaning.
Five parsers in the ecosystem were written against these and none of them could
point at a rule. docs/needs.md NEEDS-12 has the cost: twill's own scanner loop
is a chain of continues and nests eight deep rewritten as nested else, and
bobbin's sampling loop carries a done flag for a loop with four exit
conditions.
Indexing and slicing
let v = [10.0, 20.0, 30.0]
v[0] # 10 (scalar)
let m = [[1.0, 2.0], [3.0, 4.0]]
m[1] # tensor([3, 4], shape=[2]), a row
m[1][0] # 3
Indexing a tensor along the first axis returns a scalar (rank-1) or a slice (higher rank). Lists index directly.
Slicing takes a half-open range along the first axis; either bound may be
omitted. Both indexing (x[i]) and slicing (x[a:b]) a tensor are
differentiable: gradient flows to the selected element or rows.
v[1:3] # tensor([20, 30], shape=[2])
v[:2] # first two elements
v[1:] # from index 1 to the end
m[0:1] # the first row, kept as a [1, 2] tensor
range(10)[2:5] # works on lists too
Differentiation
grad(f) # -> function returning df/d(arg0)
grads(f) # -> function returning [df/d(arg0), df/d(arg1), ...]
value_and_grad(f) # -> function returning [f(args), df/d(arg0)]
jacobian(f) # -> function returning the [m, n] Jacobian of a vector output
hessian(f) # -> function returning the [n, n] Hessian of a scalar output
jvp(f) # -> function of (x, v) returning [f(x), J v]
vjp(f) # -> function of (x, v) returning [f(x), vᵀ J]
hvp(f) # -> function of (x, v) returning H v
grad, grads, and value_and_grad require the differentiated function to
return a scalar; a gradient has the same shape as the argument it corresponds to,
including nested lists. jacobian(f)(x) instead takes a function with a vector
output and returns the full matrix of partials, where row i is the gradient of
output i, computed by one reverse-mode pass per output. See
examples/jacobian.tw.
grad(fn(x) = x * x)(4.0) # 8
grad(fn(w) = sum(w * w))([1.0, 2.0]) # [2, 4]
let g = grads(fn(a, b) = sum(a * b))([1.0, 2.0], [3.0, 4.0])
g[0] # [3, 4] d/da
g[1] # [1, 2] d/db
Differentiable primitives: + - * / % @ ^, relu, sigmoid, tanh, exp,
log, sin, cos, sqrt, sum, mean, abs, pow.
hessian(f)(x) gives the exact matrix of second partial derivatives of a scalar
function, by second-order autodiff via forward-mode jets (see examples/hessian.tw
for Newton's method). It supports functions built from arithmetic, the unary
math functions, matmul, sum, mean, and the structural ops indexing
(x[i]), slicing (x[a:b]), reshape, transpose, concat, and gather; a
function using an op outside this set raises a clear error. The reverse-mode grad remains
first-order, so a nested gradient is not supported: it is refused with an error
naming hessian, rather than returning the zero it would otherwise compute. The
gradient grad hands back is a plain value with no history, so differentiating
it again differentiates a constant.
The two primitives underneath: jvp and vjp
The five operations above are conveniences over two passes, and those two passes are nameable in their own right. Reach for them when you want a derivative in one direction rather than all of them, or when you are writing the derivative rule for something the language does not already have.
jvp(f)(x, v) # [f(x), the Jacobian of f at x times v] forward mode
vjp(f)(x, v) # [f(x), v times the Jacobian of f at x] reverse mode
Both return a two-element list rather than only the derivative, matching
value_and_grad, because the value at the point you differentiated is almost
always wanted and computing it twice is waste.
jvp(f)(x, v) is forward mode. v is a tangent: it lives in the input's
space, so it must have the same structure and the same shapes as x -- the same
record fields, the same list lengths, the same tensor shapes -- and the answer has
the shape of f(x). For a scalar f it is the directional derivative
grad(f)(x) . v, computed without ever forming grad(f)(x).
vjp(f)(x, v) is reverse mode. v is a cotangent: it lives in the output's
space, so it must have the shape of f(x), and the answer has the structure of
x. grad(f)(x) is exactly vjp(f)(x, 1.0) on a scalar output, which is all
grad has ever been.
fn f(x) = sum(x * x * x)
vjp(f)([1.0, 2.0, 3.0], 1.0) # [36, [3, 12, 27]] -- the same as grad
jvp(f)([1.0, 2.0, 3.0], [0.5, -1.0, 2.0]) # [36, 43.5] -- grad . v, in one pass
Both follow a parameter tree the way grad does, so a record of model weights is
a legal input; jvp's tangent is then a record with the same fields, and vjp's
result is. See examples/jvp.tw.
What they cost. jvp is one evaluation of f and nothing else, whatever the
size of the input, which is why forward mode is the cheap direction when a
function has few inputs and many outputs. vjp is one evaluation plus one
backward sweep, the same cost as grad, which is the cheap direction when a
function has many inputs and one output -- the machine-learning case, and the
reason grad is reverse mode (docs/DECISIONS.md, decision 1). Getting the full
jacobian(f)(x) costs one vjp per output row, so when what you need is J v or
vᵀ J, forming the matrix first is the expensive way to get it.
The cotangent is an argument, not a closure. In JAX, vjp(f)(x) returns the
value together with a pullback function you call later, once per cotangent,
reusing the graph. twill cannot offer that, and the reason is decision 2: the tape
is the tensor graph, so there is no recorded program to replay. A retained
pullback would pin the whole graph, would accumulate into the same per-node
gradient buffers on a second call rather than replacing them, and would outlive
the graph-building region the interpreter opens around a differentiated call. A
closure that quietly gives a different answer the second time is the plausible
wrong number this language refuses to return, so the cotangent is a second
argument and each call is one pass.
Ops without a forward rule. Reverse mode covers more operations than forward
mode does; einsum is one that has a gradient but no jet. jvp and hvp on such
a function report it by name rather than propagating a zero tangent, because a
zero tangent is a plausible derivative and an error is not. vjp on the same
function works.
hvp: curvature without the matrix
hvp(f)(x, v) # H v, for a scalar f, with the shape of x
hessian(f)(x) builds the whole [n, n] matrix. Second-order optimisation almost
never wants the matrix: Newton-CG, trust-region methods and Gauss-Newton all only
ever ask for the Hessian times a vector, and hvp is that product directly.
examples/jvp.tw runs conjugate gradients on it for a Newton step.
What it costs, plainly. 2n+1 forward passes for an n-element input, not
the single forward-over-reverse pass JAX charges. Decision 2 again: twill's
reverse pass is not re-differentiable, so the gradient cannot be sent back through
forward mode, and the exact second-order machinery here is the forward 2-jet.
What hvp does buy over hessian(f)(x) @ v is still real -- n(n+1)/2 passes
become 2n+1, and the [n, n] result is never allocated -- but it is a constant
factor better, not an asymptotic one, and a model with many parameters is out of
reach for both. It supports the same operations hessian does and refuses the
same ones.
The refusal covers the nesting wherever it is written, not only the literal
grad(grad(f)). Putting a function between the two --
let g = grad(f)
grad(fn(x) = sum(g(x)) * 2.0) # refused
-- is the same mistake and used to return zeros silently, which is the worst way
for a derivative to be wrong. The nesting hessian and jacobian do themselves
is a different thing and still works.
Shape checking
twill check (and the check that runs before twill run) infers tensor shapes
and reports mismatches it can prove. It stays quiet when a shape can't be
determined, so dynamic code doesn't produce false alarms.
$ twill check bad.tw
bad.tw:3: shape error: shape mismatch in @: [2, 3] @ [2] (inner 3 != 2)
Annotations ([3, 2], [2], [], _ for unknown, or named shape variables)
let you state a contract that the checker enforces at call sites and against the
function body. A shape variable used more than once must resolve to the same
size. Annotated function bodies are also checked at their definition, so a
mistake is caught even if the function is never called.
Units of measure
Declare a base unit at the top level with unit, then annotate scalar
quantities with it. The checker tracks units through arithmetic and reports a
mismatch, the same way it does for shapes, but units are erased at runtime, so
annotated code runs as plain numbers with zero overhead.
unit USD
unit share
fn notional(px: USD/share, qty: share) -> USD { px * qty }
An annotation is a single unit (USD) or a compound expression: a product
(USD*share), a quotient (USD/year), or a power (year^-1, USD^2). The
checker applies the natural rules:
*multiplies units,/divides them, and^with a constant integer exponent raises them (sqrthalves them).+,-,%, and comparisons require both sides to share a unit. AddingUSDtoshareis an error.matmul/dotmultiply the operand units; indexing and slicing preserve them.exp,log,sin,cos,tanh, andsigmoidrequire a dimensionless argument (their result is dimensionless).
A bare numeric literal is dimensionless. To give a value a unit, annotate the
let that binds it: the literal is adopted into the declared unit:
let price: USD/share = 150.0
let qty: share = 200.0
let value = notional(price, qty) # inferred: USD
Naming a unit that was never declared (a typo like USD/yr) is a checker error.
Code with no unit annotations is entirely dimensionless and unaffected.
Records
A record groups named fields. Fields are accessed with ..
let p = { w: [1.0, 2.0], b: 0.5 }
p.w # tensor([1, 2], shape=[2])
p.b # 0.5
{ inner: { x: 3.0 } }.inner.x # 3
grad follows record structure: if a loss takes a record of parameters, the
gradient is a record with the same fields.
fn loss(m) = sum(m.w) + m.b
grad(loss)({ w: [1.0, 2.0], b: 0.5 }) # { w: [1, 1], b: 1 }
This makes a record a natural container for a model's parameters. A { starts a
record only when it is followed by name:; otherwise it is a block.
A record type can be declared and used to annotate a parameter. The checker then verifies that the record passed in has the declared fields with the declared shapes, and that field accesses name real fields:
type Model = { w: [3, 2], b: [3] }
fn predict(m: Model, x: [2]) -> [3] {
m.w @ x + m.b
}
Accessing a field a record doesn't have (m.wieght) is a checker error, whether
the record is a literal or a declared type.
struct, and what a parameter is
struct is a systems-mode type, declared by name, with typed fields that are
mutable in place. It is a different type from Record and the two are not
unified, deliberately: grad walks a record's structure and depends on records
not aliasing, so mutation is not retrofitted onto them.
struct Lexer { src: Str, i: I64, line: I64 }
The rule
A struct has reference semantics. Passing one passes a handle, not a copy.
Assigning to a field of a parameter mutates the caller's struct, and the caller
sees it. The same holds for Arr, Dict and Bytes, and it holds through
any number of levels: mutating a field of a struct reached through a field of
another struct is visible at the outermost handle.
fn advance(lx: Lexer) {
lx.i = lx.i + 1
if lx.src[lx.i] == 10 { lx.line = lx.line + 1 }
}
let lx: Lexer = Lexer { src: text, i: 0, line: 1 }
advance(lx)
# lx.i is 1 here, not 0.
Copying is always explicit. There is no implicit copy at a call, at an
assignment, at a push into an Arr, or at a return. let b = a on a struct
binds a second name to the one struct, and mutating through either is visible
through the other. A copy is made by writing one.
F64, I64, Bool, Byte and Str are the values that are not handles.
They are immutable, so the distinction is unobservable for them, which is the
point: the line between the two halves of the language is exactly the line
between mutable aggregates and immutable scalars, and there is nothing in
between to remember.
Why this is written down
Three codebases were built on this and none of them could point at a rule.
docs/self-hosting.md section 1.2 says a struct has reference semantics, and
then says nothing about what happens when a function assigns to a field of a
parameter, and nothing at all about Arr. That is the gap this closes, and it
is the whole of it: nobody was asking for a feature.
The cost of leaving it open is not evenly distributed, which is why it is worth
a paragraph rather than a line. If the answer had been by-value, loom's fit
could not advance the run it was given and every function in loom's
src/metrics.tw would have to return a new meter, which is loud: nothing works
and it is obvious that nothing works. src/tensor.tw's is the quiet one.
accumulate(cot, touched, node, buf) mutates cot[node].data and expects the
caller to see it, so if an Arr parameter were copied the mutation would be a
no-op, the backward pass would return zeros, and a gradient of zero is not an
error. It is a model that does not learn, and the search for the reason starts at
the learning rate.
This is also why the Arr half and the struct half must give the same answer
and are stated together. Odometer is a struct holding arrays and is mutated
through both at once; two different answers would make it work in one direction
and not the other.
What the Go bootstrap does
The bootstrap agrees, as far as it can be asked. internal/value's aggregates
are *Record and *List, Go pointers, and the interpreter passes them to a
call without copying, so aggregates are already handles there.
What the bootstrap does not have is any syntax that mutates one. Field
assignment (p.b = 1.0) is a parse error, Arr element assignment is
docs/needs.md NEEDS-43, and the only builtins that look like mutation are
append and with_field, both of which return a new value and leave the
original alone. So the reference-versus-value distinction is currently
unobservable from a twill program, and the rule above is a decision about
systems mode rather than a measurement of numeric mode. It is the decision every
existing caller already assumed, which is the evidence for it being the right
one.
Record in numeric mode keeps its own rule and is unaffected: fields are not
mutable in place, and you rebuild the record.
enum, and match
An enum is a type with a fixed set of cases, each of which may carry one
value. It is how a systems-mode program says "one of these, and nothing else",
which is the thing an integer tag cannot say.
enum Verdict { Faster, Slower, Same, Noisy }
enum Tok { Ident(Str), Num(F64), Punct(Str), Eof }
A case carries zero payloads or one. Not two. Twill has no tuple type, and
adding positional payloads would introduce v.0 as a second field syntax beside
.name. A case that needs several values carries a struct:
struct BinOp { op: Str, lhs: Expr, rhs: Expr }
enum Expr { ENum(F64), EBinary(BinOp) }
The payload may be the enum being declared, directly or through a container, so a tree is an ordinary declaration and needs no explicit indirection:
enum Json {
JNull,
JBool(Bool),
JNum(F64),
JStr(Str),
JArray(Arr[Json]),
JObject(Dict[Str, Json]),
}
A case is written bare (Eof) or qualified by its enum (Tok.Eof), and the two
are the same thing. The qualifier is read and dropped; it is there for the reader
and for the case where two enums in scope share a case name, which is otherwise
ambiguous.
Opt[T] and Res[T, E] are ordinary enums that happen to be built in:
enum Opt[T] { Some(T), None }
enum Res[T, E] { Ok(T), Err(E) }
match
fn describe(t: Tok) -> Str {
match t {
Ident(name) => "identifier " + name,
Num(v) => "number " + str(v),
Punct(p) => p,
Eof => "end of input",
}
}
Arms are pattern => expression, comma separated, with no braces around each
arm -- braces would re-enter the block-versus-record ambiguity a { already
carries. match is an expression, so it has a value, and it is a keyword only
at expression position in a systems-mode file: a numeric-mode file that writes
let match = 3 keeps working.
What a pattern is
A pattern is one of five things, and the last nests:
| Pattern | Matches |
|---|---|
_ |
anything, binding nothing |
name |
anything, binding it |
3, "hi", true, -1 |
that value, by the equality == gives |
Some |
the case, whatever it carries |
Some(pat) |
the case, when its payload matches pat |
match result {
Ok(Some(0)) => "present but empty",
Ok(Some(v)) if v > 100 => "large",
Ok(Some(v)) => "present",
Ok(None) => "absent",
Err(msg) => msg,
}
A case name starts with a capital letter; anything else binds. That is the
rule that tells Some(x), where x is a binder, from Ok(None), where None is a
case. It also means a catch-all can say what it caught -- other => str(other)
is a _ with a name -- and that some(v) is refused, by name, as a binding
used where a case was meant.
A guard is if cond between the pattern and the arrow. It sees the
pattern's bindings, and a false guard falls through to the arms below rather
than failing the match. Because a literal pattern matches by ordinary equality,
a match over numbers or strings needs no enum written around it:
fn classify(n) {
match n {
0 => "zero",
1 => "one",
x if x > 100 => "big",
_ => "other",
}
}
A match must cover every case. The checker knows the enum's cases and
reports the ones with no arm, by name:
model.tw:14: match on Verdict is not exhaustive: missing Noisy
That is an error and not a warning, and it is the reason to have an enum at all:
adding a case to a declaration makes every match that has not been updated say
so, at check time, instead of one of them falling through at run time in a month.
Four related mistakes are reported the same way: an arm that repeats a case, an
arm after _ (which already matched everything), a _ when every case is
already handled, and arms that name cases of two different enums.
A _ is right when every unlisted case maps to the same answer for a reason. It
is wrong when the match is dispatch, because then it is the thing that stops the
compiler telling you about the case you have not written yet.
Exhaustiveness recurses, and counts only what it can prove. Some(Ok(v)),
Some(Err(e)) and None together cover an Opt[Res[..]]; drop the Err arm
and the diagnostic names the value that gets through:
load.tw:14: match on Opt is not exhaustive: missing Some(Err)
An arm counts towards this only when nothing but the value's shape decides
whether it runs. A guard is a condition the checker cannot evaluate, and a
narrower nested pattern leaves the rest of its case unmatched, so neither
proves a case handled -- Some(v) if v > 3 and None is not exhaustive, and
saying so is the point. A position holding only literals is left unjudged,
since a finite set of them cannot cover a number or a string. Bool is the one
exception: true and false do exhaust it.
Type parameters
A struct, an enum or a fn may be written in terms of a type it does not
name. The parameters go in [] after the name, and stand for whatever the use
site supplies.
struct Box[T] { value: T, tag: Str }
enum Tree[T] { Leaf(T), Branch(Arr[T]), Empty }
fn first[T](xs: Arr[T]) -> T = xs[0]
They are erased. The runtime is the same code whatever T is, so nothing is
specialised and nothing is generated: the parameters exist for the checker and
disappear before the program runs. What they buy is that the checker knows what
is in the box:
let b: Box[I64] = Box { value: 3, tag: "count" }
let s: Str = b.value # "s" is declared Str but the value is I64
Substitution goes as deep as the type does, so a Branch(xs) arm on a
Tree[I64] binds xs as an Arr[I64]. Two uses of one declaration are
different types when their arguments differ -- a Box[Str] is refused where a
Box[I64] was declared -- while a non-generic struct still compares by name
alone, as it always has.
Arguments are read out of the value, not written at the use site, because
there is nowhere to write them: a constructor and a struct literal take no type
arguments. Leaf(n) with an I64 n is a Tree[I64], and inside
fn swap[A, B](p: Pair[A, B]) -> Pair[B, A], the literal
Pair { first: p.second, second: p.first } is a Pair[B, A] by what it is
built from.
A parameter the use site leaves off is unknown and judges nothing, so a bare
Box is exactly as informative as it was before type parameters existed. There
are no bounds and no traits.
Opt, Res, and ?
An operation that can fail returns a Res, and one that may have nothing to
return gives an Opt. Neither is special to the compiler; what is special is
one piece of syntax for the thing every caller does with them.
Postfix ? unwraps a success and returns a failure from the enclosing
function.
fn load(path: Str) -> Res[Config, Str] {
let text: Str = read_file(path)?
let doc: Doc = parse(text)?
Ok(build(doc))
}
Without it that function is three match statements deep and the interesting
line is at the bottom of them. With it the failure path is a character wide and
the success path reads straight down.
The rules, all checked:
- The enclosing function must return a
Resor anOptfor the failure to be returned in.?in a function returningI64is an error naming the return type, and?at the top level of a file, where there is no function at all, is an error too. It used to end the program quietly with status 0, which is the one thing a failed read must not do. ?on something that is neither aResnor anOptis an error.- The error types must match. There is no automatic conversion between them, because that needs a trait system and twill does not have one; convert at the seam instead.
- What
?yields is the success payload, and it is typed:let n: I64 = read_file(p)?is a diagnostic, becauseread_filegives back aStr.
abort(msg) is the other way to fail, and it means something different. A Res
is for what a caller may reasonably handle: a missing file, a malformed line, a
number that does not parse. abort is for what no caller can handle because it
should not be possible -- an invariant broken inside the implementation. Every
abort in the self-hosted compiler should be unreachable by any input; a Res
is what a user's mistake produces.
Making arithmetic fallible was considered and rejected. a / 0 on an I64
aborts naming the operation and its position, rather than returning
Res[I64, Str], because a language where i + 1 needs a ? is a worse
language than one where a zero divisor is a bug that stops.
Imports
There are two kinds of import path, and the spelling tells you which is which.
import "std/nn" # a standard-library module (ships inside the binary)
import "helpers.tw" # a file, relative to the importing file
A path beginning with std/ names a module of the standard library, not a
file: no extension, no directory, and it means the same thing from anywhere,
because the library is compiled into the twill binary. std/ is reserved, so a
directory called std next to your program does not shadow it. Every other path
is a file, resolved relative to the importing file first, then the working
directory; import "./std/local.tw" reaches a real directory named std.
Either kind can be namespaced:
import "std/nn" # drops the module's definitions into this scope
import "std/nn" as nn # binds them as a namespace record instead
A plain import evaluates the module and adds its top-level definitions to the
importing scope; each module loads once, so re-imports and cycles are fine. An
as name import instead evaluates it into its own scope and binds a record of
its definitions under name, so you call nn.dense(...). That record's fields
are in the module's declaration order, so printing or iterating a namespace gives
the same result on every run.
A standard-library module may only import other std/ modules. It has no
directory of its own to resolve a relative path against.
To work on the library itself without rebuilding, set TWILL_STD to a directory
of .tw files; it replaces the embedded library wholesale, so import "std/nn"
reads $TWILL_STD/nn.tw. Unset it and you are back to the copy in the binary.
Standard library
Elementwise math (differentiable): relu, sigmoid, tanh, exp, log,
sin, cos, sqrt, square, abs, pow(x, p), clip(x, lo, hi).
Elementwise combine: maximum(a, b), minimum(a, b), where(cond, a, b), and
the comparisons greater, less, greater_equal, less_equal, equal
(each returns a tensor of 1s and 0s).
Reductions: sum, mean, max, min, prod and median reduce the whole
tensor to a scalar, or one axis with a second argument (sum(t, 0)).
argmax(t[, axis]) gives the index of the maximum.
All of them are differentiable, including the two order-based ones, though what
that means is worth being clear about. median routes the whole gradient to
whichever element was selected, the way max does, and splits it in half
between the middle two when the run has even length. prod gives each factor
the product of the others, which is the total divided by that factor, except
where a factor is zero and the division is not available. There, a single zero
takes the product of the rest and everything else gets nothing, and two or more
zeros flatten the gradient entirely, because every product of the others still
contains a zero. softmax(t[, axis]) and logsumexp(t[, axis]) default to
the last axis.
split(t, n | sizes[, axis]) is the inverse of concat, returning a list of
pieces. A number means that many equal pieces (split(x, 2, 1) halves the
columns) and a list means those exact lengths (split(x, list(1, 3), 1)). The
axis defaults to 0. The sizes must account for the axis exactly and an equal
split must divide evenly; both are errors rather than ragged output, because a
split that quietly loses a row shows up later as a wrong loss rather than as a
crash. Each piece keeps its own gradient path, so
concat(split(t, 2, 1), 1) is t in both directions.
broadcast_to(t, ...shape) expands a tensor to a given shape under the usual
right-aligned rules, where every axis must already match or be 1. It is what
you need after a reduction: reducing axis 1 of a [2, 3] gives a [2], and
[2] will not broadcast back against [2, 3], because alignment is from the
right. broadcast_to(reshape(mu, list(2, 1)), list(2, 3)) puts it back. Other
array libraries spell this as keepdims=True on the reduction itself; here it
is an operation, and num.keep wraps the two steps.
Sorting: sort(t[, axis[, descending]]) and argsort give the values and the
positions; topk(t, k[, axis[, smallest]]) and argtopk keep the k largest,
largest first, shrinking that axis to k. All four default to the last axis,
because sorting a matrix almost always means sorting each row. The flags are
numbers, since a comparison in Twill already yields 1 and 0.
sort and topk are differentiable and exactly so. Sorting is a permutation
and the derivative of a permutation is its inverse: whatever gradient arrives at
the element now in a position belongs to whichever element started there. A
value outside the top k does not move the output at all, so its gradient is
zero, which is correct rather than a simplification. The sort is stable, so ties
keep their original order and therefore their own gradients.
argmin(t[, axis]) is argmax's counterpart, and flip(t[, axis]) reverses
along an axis. flip is differentiable and exactly so, since a reversal is a
permutation and is its own inverse, which makes the backward pass the same
reversal. All three default to the last axis. Ties in argmax and argmin go to
the first occurrence, the same rule the cumulative extremes and the sort use.
roll(t, shift[, axis]) shifts along an axis and wraps what falls off the end
back to the start; diff(t[, axis]) is the difference between neighbours,
shortening that axis by one. Both are differentiable. A positive shift moves
elements towards the end, so roll(x, 1) is the previous value and
x - roll(x, 1) compares a series with its own past. diff shortens rather than
pads, because there is no honest first difference: a zero there claims "no
change" about data that does not exist, and it is exactly the claim whatever
consumes the series next will believe.
argsort and argtopk are not differentiable, and not by omission: an index
does not move when an input moves slightly, then jumps when two values cross.
The derivative is zero almost everywhere and undefined on the boundaries.
Cumulative scans (preserving length): cumsum, cumprod, cummax, cummin.
These build signals, equity curves, and running peaks, and they are
differentiable: cumsum and cumprod have exact gradients (cumprod handles
zeros in the series), and cummax/cummin send each output's gradient to the
element the running extreme came from, ties going to the earlier one.
Each takes an optional axis: cumsum(t) scans the tensor's elements in order
and cumsum(t, 1) scans along axis 1, one run per row, keeping the shape. The
split follows the reductions, where sum(t) covers everything and sum(t, 0)
works per axis. On a 1-D tensor, which is what a sequence is, the two forms are
the same thing, so the axis is a widening rather than a second meaning.
Elementwise rounding floor, ceil, and round are forward-only (their
derivative is zero wherever it exists), handy for turning random draws into
integer ids.
Linear algebra / shape: matmul(a, b) / dot(a, b) (same as @),
transpose(t[, ...axes]), reshape(t, ...shape), concat(list, axis),
einsum(spec, ...tensors).
Indexing / batching: gather(x, indices) selects rows of x (its first axis)
by an index list or 1-D tensor, and is differentiable: the gradient scatters
back to the selected rows, so repeated indices (embedding lookups) accumulate.
permutation(n) returns a seeded random ordering of 0..n-1 (for shuffling),
and int(x) truncates a scalar toward zero.
Convolutions (differentiable): conv2d(input, weight) is a 2-D cross-correlation
with input shaped [Cin, H, W] and weight shaped [Cout, Cin, KH, KW],
producing [Cout, H-KH+1, W-KW+1] (valid padding, unit stride).
maxpool2d(input, k) does non-overlapping k×k max pooling over each channel of
a [C, H, W] tensor. grad flows through both, so a convolutional net trains
like any other model. See examples/cnn.tw.
einsum is a general Einstein-summation contraction and is differentiable:
einsum("ij,jk->ik", A, B) # matrix multiply
einsum("ij->ji", A) # transpose
einsum("ij->i", A) # sum over the second axis
einsum("i,ij,j->", x, W, y) # bilinear form x' W y
Each label names an axis; repeated labels across operands are summed, and only
the labels in the output remain. Omitting -> keeps the labels that appear once.
(A label repeated within one operand, a trace or diagonal, is not supported
yet.)
Construction: tensor(list), scalar(x), zeros(...shape), ones(...shape),
fill(value, ...shape), eye(n), linspace(start, stop, n) (n points, both
endpoints included), arange(start, stop, step) (half-open, like range with a
float step), randn(...shape) (standard normal), rand(...shape) (uniform),
seed(n). Shapes may be separate args or a list.
Randomness is deterministic by default: a program gives the same result
every run, and seed(n) chooses the starting point. That reproducibility
matters for model governance and audit.
Lists / higher-order: range(...), list(...), map(f, xs), zip(...),
fold(f, init, xs), append(xs, x), enumerate(xs), len(x).
Trees (tensors nested in lists/records): map_leaves(f, tree) applies f to
every tensor leaf; zip_leaves(f, trees) walks a list of same-shaped trees in
parallel, calling f with the list of leaves at each position. Optimizers use
these, so they work on any model structure.
Files and paths: read_file(path) and write_file(path, text) return a Res;
path_exists, path_is_dir and mtime ask about a path without reading it
(mtime is whole seconds, or -1 when the path cannot be read, the convention
file_size follows); mkdir_all, remove_file, remove_dir, remove_all,
rename and temp_dir change what is there, each returning a Res; cwd()
and list_dir(path) report. remove_file and remove_dir each refuse the
other's argument, because the recursive one is the dangerous one and a caller
should have to name it; remove_all is that name, and it succeeds on a path
that was already gone.
path_join, path_base, path_dir, path_ext, path_stem,
path_normalize and path_is_abs are string handling and touch nothing. They
emit a forward slash on every platform -- a program's paths are written in its
source, and one that renders them differently on Windows writes a different
manifest there -- and they read a backslash as a separator, so a path handed
over by the operating system still splits.
Time: mono_ns() is a monotonic clock in nanoseconds, whose zero point is
arbitrary and whose differences are the only thing that means anything.
clock_now_ms() is the wall clock, which is what to print in a log and what
not to measure a duration with, since it steps when the system's time is
corrected.
Gradient checking: std/gradcheck compares grad(f) against a difference
quotient, for a tensor argument (check_at) or a whole parameter tree
(check_tree). It is a development tool, costing two evaluations of f per
element, and it is what to reach for when a model trains worse than it should.
Inspection: shape(t), item(t), dtype(t), str(x), print(...). dtype(t)
returns the name of a tensor's dtype as a string, one of "f64", "f32",
"bf16", "f16", "i32", "i8", "bool"; it is the read-back the to cast
and the dtype constructors otherwise have no inverse for.
str on a number
str(n) for an I64 is the digits and nothing else: no decimal point, no
trailing .0, no exponent, no thousands separator, no padding, and a leading
- exactly when the value is negative. str(0) is "0". str(-7) is
"-7". The most negative I64 renders in full as
"-9223372036854775808", which is the one value whose negation is not
representable and therefore the one a digit loop gets wrong.
This is stated because str on a scalar goes through the tensor printer, and a
.0 from that path would land in every line number, every column count and
every axis index in every diagnostic the self-hosted compiler emits. It is not a
formatting preference, it is the difference between lex.tw:294:12 and
lex.tw:294.0:12.0.
str(x) for an F64 prints a whole number with no decimal point and everything
else in Go's shortest round-tripping %g, which is what the bootstrap's
internal/value.FormatNumber does and what src/float.tw's format_number
reimplements. The switch to exponent form happens at decimal exponent below -4
or at or above 6, so 1000000 prints as 1000000 and 1234567.5 prints as
1.2345675e+06.
The two rules meet where an F64 holds an integral value, and format_number
routes that case to the I64 renderer on purpose, so a whole number prints the
same however it was computed.
str does no padding and no alignment. str(7) is "7" and never " 7".
Column alignment is a caller's job until a formatting builtin exists
(docs/needs.md NEEDS-20), and bobbin's pad_left and pad_right are what
that looks like meanwhile.
A caveat worth knowing before it is discovered: in numeric mode there is no
I64, so an integer is a float64 and str is exact only up to 2^53.
str(123456789012345678) prints 123456789012345680. Systems mode is where an
integer is an integer, and this is one of the reasons it exists.
Data: read_csv(path) loads a file of numeric rows (comma- or
whitespace-separated, # lines skipped) into a [rows, cols] tensor.
Persistence: save(value, path) writes any value, whether a tensor, a record or list
of tensors (a model's whole parameter tree), or a fitted gbm model, to a file
in an exact binary format, and load(path) reads it back. Paths are relative to
the running script. This is the deploy path: train once, save the model, and
ship it with the single binary for inference (examples/save_load.tw).
Frames: a frame is a record whose fields are named column tensors, so field
access, slicing, and grad all work on it. read_frame(path) loads a CSV whose
first row is a header into such a record; write_frame(frame, path) writes one
back. columns(rec) lists the field names, field(rec, name) looks one up by
string, and with_field(rec, name, value) returns a copy with a field set. See
examples/frames.tw.
Gradient-boosted trees: gbm_fit(X, y) (or gbm_fit(X, y, opts)) trains a
native gradient-boosting model on a [n, d] feature matrix and an [n]
target/label vector, and gbm_predict(model, X) scores a [n, d] matrix into an
[n] vector. opts is a record of hyperparameters: rounds, learning_rate,
max_depth, min_leaf, lambda, gamma, and objective ("squared" for
regression, "logistic" for binary classification, where predictions are
probabilities). The engine is pure Go and deterministic. See examples/gbm.tw.
Libraries written in Twill ship inside the binary and are imported as
std/<module>: std/nn (layers including dense, conv, embed, and
self_attention; activations, initializers, losses), std/optim (SGD,
momentum, Adam), std/data (standardize, train_test_split, shuffle for
real training loops, see examples/minibatch.tw), and std/backtest
(returns, moving averages, equity curves, drawdown, Sharpe, Sortino, volatility,
CAGR). Their sources are the .tw files in std/ in the repository. The
optimizers are container-agnostic: the same sgd_step/adam_step
update a model held in a positional list or a named record. The backtest Sharpe
and Sortino are differentiable in the return series, so a smooth signal can be
tuned by gradient ascent through the backtest (examples/signal_opt.tw).
Example
# Fit y = X w + b by gradient descent.
let X = [[1.0, 1.0], [2.0, 1.0], [1.0, 3.0]]
let y = [-0.5, 1.5, -6.5]
fn loss(w, b) {
let err = X @ w + b - y
mean(err * err)
}
let w = [0.0, 0.0]
let b = 0.0
for step in range(400) {
let g = grads(loss)(w, b)
w = w - g[0] * 0.05
b = b - g[1] * 0.05
}
print("w =", w, "b =", b)