The Soma language

Soma is a strongly, statically typed, expression-oriented language. The syntax is F#-flavoured ML with some Python habits: significant indentation, blocks introduced with :, # comments, and and/or/not spelled as words.

It is young — what runs today is a tree-walking interpreter (compilers come later), and everything here is subject to change. Soma is a working title. You can try everything on this page in the playground.

A taste#

run ▶
# Comments start with '#'.

fn fib(n: int) -> int:
    if n < 2:
        n
    else:
        fib(n - 1) + fib(n - 2)

for i in 0..11:
    print(fib(i))

Bindings#

Values are introduced with let, and types are inferred unless you annotate. Bindings are immutable by default; let mutable opts in, and assignment uses <-, which only works on mutable bindings — the type checker enforces both.

run ▶
let x = 42                 # type inferred: int
let y: float = 1.5         # optional annotation, checked
let mutable count = 0
count <- count + 1

Shadowing is allowed, ML-style: let x = x + 1 creates a new x.

Functions#

Functions are introduced with fn. Parameters are a parenthesized, comma-separated list with type annotations; the return type is inferred unless annotated with ->. The body follows : — inline on the same line, or an indented block:

run ▶
fn add(a: int, b: int): a + b          # return type inferred: int

fn fib(n: int) -> int:                 # explicit return type
    if n < 2: n
    else: fib(n - 1) + fib(n - 2)

One wrinkle: recursive functions need an explicit return type — while the body is still being inferred there is nothing to assume for the recursive call. The checker reminds you if you forget.

Arguments can be passed by name, in any order after the positional ones, and trailing parameters can have defaults (evaluated at call time):

run ▶
fn area(w: float, h: float = 1.0): w * h
print(area(3.0))              # 3.0
print(area(h = 4.0, w = 3.0)) # 12.0

Anonymous functions are the named form minus the name — same parens, same colon-body rule:

run ▶
let double = fn(n: int): n * 2
fn twice(f: (:int) -> int, x: int): f(f(x))
print(twice(double, 10))      # 40

Functions are first-class values and close over their environment, including mutable bindings:

run ▶
fn make_counter():
    let mutable count = 0
    fn next() -> int:
        count <- count + 1
        count
    next

let tick = make_counter()
print(tick())    # 1
print(tick())    # 2

Everything is an expression#

A block’s value is its last expression:

run ▶
let x =
    let a = 10
    let b = 20
    a * b          # x = 200

if is an expression too. : introduces each body — an expression on the same line is an inline body; end of line means an indented block follows. There is no then and no elif; else followed directly by if chains without stacking indentation:

run ▶
let n = 7
let parity = if n % 2 == 0: "even" else: "odd"

let sign =
    if n < 0: "neg"
    else if n == 0: "zero"
    else: "pos"

Both branches must have the same type, and an if with no else must have type unit.

Values are not silently thrown away: discarding a non-unit value is an error. Write let _ = ... to discard one deliberately. _ works in any binding position — parameters, loop variables, destructuring, patterns — and always means the same thing: bind nothing. It can never be read.

Types#

Type names are lowercase: int (64-bit), float (64-bit), bool, str, and unit (written ()). Identifiers are case-sensitive, but case carries no meaning — capitalize your own names or don’t, as you like.

There are no implicit conversions, not even intfloat. Convert explicitly with the builtins:

builtintype
print(x)any → unit
str(x)any non-function → str
int(x)float → int (truncates)
float(x)int → float

Integer overflow and division by zero are runtime errors, not wraparound.

Tuples and records#

Tuples, records, and parameter lists are all one thing in Soma: a product type, an ordered set of fields. A field is [name] [: type] [= value] — a bare identifier is always a name, and a type only ever appears after a :. Fields without a name start with the colon: (:int, :str) is the type of an anonymous int/str pair.

run ▶
let pair = (1, "two")            # a tuple
let one = pair.0                 # positional projection
let (a, b) = pair                # destructuring

let p = (x = 1.0, y = 2.0)       # named fields (bind with '=')
print(p.x)

(5) is just 5 — grouping. A 1-tuple takes a trailing comma, Python style: (5,). A lone named field does too: (x = 1,).

Named types are declared with type, which always mints a new, distinct type:

run ▶
type meters = float                  # a newtype: same operations as
let m = meters(5.0)                  # float, but never mixes with it;
let f = float(m)                     # convert explicitly

type point = (x: float, y: float)    # a record
let p = point(x = 1.0, y = 2.0)      # construct by name or position

type alias distance = meters         # transparent alias, no new type

meters + meters works; meters + float is an error. Types are nominal: two distinct declared types never mix, even if they look identical.

Records can also be declared multiline, and fields can have defaults — supplied when construction doesn’t mention them (defaults must be trailing, and are evaluated per construction):

run ▶
type widget =
    name: str
    price: float = 0.0
    tags: [str] = []

print(widget("plain"))               # ("plain", 0.0, [])
print(widget(name = "t", tags = ["a"]))

Variant payloads take defaults the same way (type event = click: (x: int = 0, y: int = 0) | quit). Defaults belong to declarations — a type annotation can’t carry one. A field with only a default infers its type from it (size = 42), and small records fit on one line without parens: type widget = name: str, size = 42.

Contextual construction: where the expected type is already known, the constructor name can be elided — a tuple constructs, going through whatever constructor the type has (memberwise or explicit):

run ▶
type widget = name: str, size = 42

let w: widget = ("kettle", 3)
let d: widget = ("toaster",)          # defaults fill in
fn describe(w: widget): print(w.name)
describe(("socket", 9))
let ws: [widget] = [("a", 1), ("b", 2)]

In the multiline form, an annotated binding takes one constructor argument per line, and -- lines separate the objects of a typed array (the annotation selects the container kind — without one, bare lines are a list, as before):

run ▶
let inventory: [widget] =
    "hammer"
    12
    --
    "wrench"
    9

A tuple-typed value still never converts — nominal identity is not structural; only literals construct.

You can ascribe a type to any parenthesized expression: (e : ty).

Constructors#

A type has at most one constructor (@init(.none), below, opts out entirely). If you don’t write one, you get the memberwise default seen above — fields by position or name. To write your own, add a with impl: block directly after the declaration; the unnamed fn (params) -> Self: is the constructor, and .field = value initializes a field:

run ▶
type circle =
    radius: float
    area: float

with impl:
    fn (r: float) -> Self:
        .radius = r
        .area = 3.14159 * .radius * .radius

let c = circle(2.0)

Once initialized, a field can be read (.radius above). The checker makes sure every field is initialized exactly once on every path before the body ends — except fields with declaration defaults, which may be left alone and take their default when the body finishes.

Writing anything in an impl block — a constructor or methods — replaces the default constructor. If you want a custom impl and the default constructor, opt back in with @init(.auto). The other direction exists too: @init(.none) generates no constructor at all — nothing constructs the type unless an impl provides an explicit one. Either mode may be written before the type declaration or before its impl (they must agree if both). The modes govern what the compiler generates; constructors you write yourself are always a door.

Methods#

Methods live in the same with impl: block: a named fn whose first parameter is self (written bare — its type is always the type being extended). Inside a method, .name is shorthand for self.name, and that includes calling sibling methods:

run ▶
type circle =
    radius: float

@init(.auto)
with impl:
    fn area(self) -> float:
        3.14159 * .radius * .radius
    fn scaled(self, k: float) -> Self:
        circle(radius = .radius * k)
    fn compare(self) -> str:
        "doubling multiplies area by " + str(.scaled(2.0).area() / .area())

let c = circle(radius = 2.0)
print(c.area())
print(c.scaled(3.0).radius)
print(c.compare())

(The @init(.auto) is doing real work there: any impl — even methods-only — replaces the default constructor.)

Newtypes and sum types take methods the same way:

run ▶
type shape =
    | square: (side: float)
    | dot

with impl:
    fn area(self) -> float:
        match self:
            is .square(s): s * s
            is .dot: 0.0

print(shape.square(side = 3.0).area())

A few rules to know:

Lists#

[t] is the list type, [1, 2, 3] the literal. Elements are separated by commas or newlines. Indexing is bounds-checked, + concatenates, == compares by content, and .len is a property (no parens):

run ▶
let primes = [2, 3, 5, 7]
print(primes[0])
print(primes.len)
print([1, 2] + [3])        # [1, 2, 3]

An empty list needs a type from context: let xs: [int] = [].

In a multiline =-binding, if the first line is a bare value the whole body is a list literal — one element per line, no brackets needed:

run ▶
let primes =
    2
    3
    5

(If the first line is a statement, the body is an ordinary computed block instead. Data or code, decided by the first line.)

Dicts#

Dicts use brackets too: [str: int] is a type, ["one": 1, "two": 2] a literal, and [:] the empty dict. The colon after the first element is what makes it a dict rather than a list.

Lookup returns an optional — a missing key is ordinary data, not an error (see the Optionals section; is makes the read pleasant):

run ▶
let scores = ["anna": 3, "ben": 5]
print(scores["anna"])          # option.some(3)
print(scores["zoe"])           # option.none
if scores["ben"] is .some(n):
    print(n * 10)              # 50
print(scores.len)

Elements are assigned with d[k] <- v (insert-or-update; the variable must be mutable), and ?? supplies a default for a missing key — together they make the counting idiom a one-liner:

run ▶
let mutable counts: [str: int] = [:]
for w in ["a", "b", "a"]:
    counts[w] <- (counts[w] ?? 0) + 1
print(counts)          # ["a": 2, "b": 1]
print(counts.keys)     # ["a", "b"]
print(counts.values)   # [2, 1]

The bare multiline form works here too — a key: value first line commits to a dict:

run ▶
let scores =
    "anna": 3
    "ben": 5

In positions where the type is known (annotated lets, arguments, assignments…), () can stand in for an empty container: let xs: [int] = ().

Sets#

set<t> is the set type: insertion-ordered, unique, equatable elements. There’s no set literal (braces are reserved) — construct with set(...):

run ▶
let s = set(3, 1, 2)
print(s.len)          # 3
print(2 in s)         # membership: true
print(s + set(2, 9))  # union: set(3, 1, 2, 9)

for x in s:           # insertion order
    print(x)

Sum types and match#

A sum type is a set of |-separated variants, each with an optional payload using the same field syntax as records. Variants are scoped to their type — qualify them with the type name, like shape.circle:

run ▶
type shape =
    | circle: (radius: float)
    | rect: (w: float, h: float)
    | dot

let c = shape.circle(radius = 1.0)  # payload variants are constructors
let d = shape.dot                   # payload-less variants are values

Wherever the expected type is already known — an annotated binding, an argument, a list element, a match arm — the qualifier can be elided, leaving just the dot. (The dot-prefix always means “a name from the context”, the same way .field works inside methods.)

run ▶
let e: shape = .dot
let all: [shape] = [.dot, .circle(radius = 1.0), .rect(w = 2.0, h = 1.0)]

type intlist = nil | cons: (head: int, tail: intlist)   # recursion works
let l: intlist = .cons(head = 1, tail = .cons(head = 2, tail = .nil))

match is an expression. Arms live in an indented block, each introduced by is; the scrutinee supplies the type, so arms always use the dotted form. The default is an else: at the match’s own indentation — the same rule as if/else:

run ▶
fn area(s: shape) -> float:
    match s:
        is .circle(r): 3.14159 * r * r
        is .rect(w, h): w * h
        is .dot: 0.0

fn describe(s: shape) -> str:
    match s:
        is .dot: "a dot"
    else: "something with area"

print(describe(.circle(radius = 1.0)))

Read an arm with the scrutinee: “s is .circle(r)?”

Optionals#

t? is the optional type: a value that is either .some(...) or .none. It is a sum type — option with arms none and some: t — so everything from the previous section applies: the contextual dot, match, exhaustiveness, structural equality.

run ▶
fn describe(o: int?) -> str:
    match o:
        is .some(v): "got " + str(v)
        is .none: "nothing"

let a: int? = .some(5)
let b: int? = .none
print(describe(a))
print(describe(b))
print(describe(.some(7)))

option is the qualifier when there’s no context to infer from — option.some(5) works anywhere (the payload type comes from the argument). option.none on its own can’t know its type, so it needs a checking position, where you’d just write .none anyway.

For the common “if present” shape, is works as an expression: it tests a value against a pattern, and its bindings flow into the branch it guards — including through and:

run ▶
let o: int? = .some(5)
if o is .some(v):
    print(v)
else:
    print("nothing")

if o is .some(v) and v > 3:
    print("big: " + str(v))

while works the same way, which makes short work of recursive sums:

run ▶
type intlist = nil | cons: (head: int, tail: intlist)
let mutable l: intlist = .cons(1, .cons(2, .nil))
while l is .cons(h, t):
    print(h)
    l <- t

Without bindings, is is an ordinary boolean anywhere (let present = o is .some(_)); a binding pattern outside an if/while condition is an error — the binding would have nowhere to flow. Bindings don’t escape their branch, and don’t survive not or or.

A few more things worth knowing:

Generic types#

Types can take type parameters, declared with a tick after the name and used anywhere a type can appear:

run ▶
type result<'t, 'e> =
    | ok: 't
    | err: 'e

let r: result<int, str> = .ok(5)
match r:
    is .ok(v): print(v + 1)
    is .err(m): print(m)

Everything from sums carries over: the contextual dot, patterns, exhaustiveness. Where the arguments determine the parameters, no annotation is needed (result.ok(5) can infer 't but not 'e, and says so); recursion works (type tree<'t> = leaf | node: (left: tree<'t>, value: 't, right: tree<'t>)); products work too (type pair<'a, 'b> = (first: 'a, second: 'b), with pair(1, "x") inferring both).

Functions are generic too. Any tick in a signature that isn’t already in scope binds at that function — no separate declaration needed (though an explicit fn first<'a, 'b>(...) list is also accepted). The parameters are inferred at each call from the arguments:

run ▶
fn map(xs: ['t], f: (:'t) -> 'u) -> ['u]:
    let mutable out: ['u] = []
    for x in xs:
        out <- out + [f(x)]
    out

let ns = [1, 2, 3]
print(map(ns, fn(n: int): n * n))       # [1, 4, 9]
print(map(ns, fn(n: int): str(n) + "!")) # ["1!", "2!", "3!"]

Parameters are unconstrained, and that means the permissive thing: a 't supports whatever the body asks of it. A generic body is checked at each call, against that call’s actual types — so sum (which needs + on the elements), sort (an ordering), contains (equality) are all just writable:

run ▶
fn sum(xs: ['t]) -> 't:
    let mutable a = xs[0]
    for i in 1..xs.len:
        a <- a + xs[i]
    a

print(sum([1, 2, 3, 4]))    # 10
print(sum(["con", "cat"]))  # concat

A type that can’t do what the body asks fails at that call, with an instantiation trace pointing at the operation and every call that led there (sum([true]) reports “‘+’ is not defined for bool — in sum<bool>, for the call at …”). Two honest costs come with the permissiveness: a generic body that is never called is never checked, and a signature no longer documents what it needs — the body is the spec. When trait bounds arrive they’ll be the opt-in strict dial: a bounded parameter is checked once, at the declaration, against the bound. (Pheno flips the default: bounds required, duck typing off.)

Generic types take methods too. The type’s ticks are bound by the receiver; a method can add its own ticks, bound afresh at each call from the arguments:

run ▶
type maybe<'t> = | nothing | just: 't

with impl:
    fn or_else(self, d: 't) -> 't:
        match self:
            is .just(v): v
            is .nothing: d
    fn convert(self, f: (:'t) -> 'u) -> maybe<'u>:
        match self:
            is .just(v): .just(f(v))
            is .nothing: .nothing

let m: maybe<int> = .just(21)
print(m.or_else(0))                     # 't from the receiver: int
print(m.convert(fn(n: int): str(n)))    # 'u from this call: str

(On a generic product, add @init(.auto) to keep the memberwise constructor — explicit constructors on generic types aren’t supported yet.)

Unused parameters need no names. When a signature never mentions the parameter again, the generic name can stand bare — it means “some instance”:

run ▶
fn describe(m: maybe) -> str:       # a maybe of anything
    match m:
        is .just(_): "something"
        is .nothing: "nothing"

print(describe(m))

Each bare occurrence is a distinct fresh parameter — two bare maybes are unrelated — so the moment two positions must agree, or the body needs the payload’s type, name it (maybe<'t>). A bare generic in a return type is rejected at the declaration (no call could ever infer it), and in data positions — fields, let annotations — the arguments are still required. The rule is uniform, prelude included: a parameter o: option accepts any optional.

In fact option itself is an ordinary declaration in std, the standard library. The prelude is simply the subset of std that is in scope everywhere — it declares nothing of its own, it imports and re-exports:

# std.soma
export type option<'t> = none | some: 't
export type result<'t, 'e> = ok: 't | err: 'e

# prelude.soma — what you get without asking
import from std: option, result
export option, result

t? is sugar for option<t>, and everything optionals do falls out of the machinery above. result is there for the same reason and needs no declaring:

run ▶
fn parse(s: str) -> result<int, str>:
    if s == "42": .ok(42)
    else: .err("not a number I know")

match parse("42"):
    is .ok(n): print(n)
    is .err(m): print(m)

Because the prelude is just an outer scope, your own declarations shadow it: a type result = | yes | no of your own is simply the one in scope, and t? keeps meaning what it always meant. Nothing is lost when you shadow — the names live canonically in std, so import std (or import from std: result as res) gets them back, and they’re the same types either way.

(A leading | is only ever required for a single-variant sum — type t = | foo — where type t = foo would mean a newtype over foo. Multiline sums keep a | per line.)

Loops#

while cond: and for x in xs: take the usual inline-or-block body and have type unit. Both accept an else: which runs iff the body never executed — the “it was empty” case (not Python’s no-break rule):

run ▶
let primes: [int] = []
for p in primes:
    print(p)
else:
    print("no primes")

for also iterates ranges: a..b counts from a up to but not including b (so 0..xs.len is exactly a list’s indices). A range is an ordinary value with its own type:

run ▶
for i in 0..3:
    print(i)           # 0, 1, 2

let xs = ["a", "b", "c"]
for i in 0..xs.len:
    print(xs[i])

Endpoints are ints; an empty range (3..3, or a high end below the low end) runs the else:. That’s all a range does for now — no steps, no floats, no membership tests yet.

while is there when the trip count isn’t known up front:

run ▶
let mutable n = 27
let mutable steps = 0
while n != 1:
    n <- if n % 2 == 0: n / 2 else: 3 * n + 1
    steps <- steps + 1
print(steps)

Operators#

Precedence, loosest to tightest:

or
and
not
==  !=  <  <=  >  >=      (no chaining; combine with 'and')
+  -                      ('+' also concatenates strings and lists)
*  /  %
unary -
f(args)

and/or short-circuit. Comparisons don’t chain — write 0 <= x and x < 10.

Spacing around operators is meaningful, and two simple rules cover it:

Together these keep negation unambiguous: x <- 1 (or x<-1) assigns, x < -1 compares.

Modules#

A module is — canonically — a source file. A bare identifier imports the matching sibling file; a string imports an exact path (relative to the importing file, extension required). Either way as renames, and without it the name derives from the locator:

import widgets                    # widgets.soma, as widgets
import widgets as w               # ... as w
import "../shared/util.soma"      # exact path, as util
import "my-lib.soma" as mylib     # unclean stem: 'as' required

Importing executes the file once (at the first import statement, wherever that is), and every later import — under any name, from any file — binds the same module. Access is dotted: widgets.make(...). Import cycles are an error, reported with the chain.

The selective form binds chosen names unqualified — and every import statement starts with import, so a file’s dependencies are one search away (a deliberate un-Python):

import from widgets: make, style as s
import from widgets:              # the colon takes a block, as ever
    make
    style
import from math: .cos, .sin, pi  # .name picks bind QUALIFIED:
                                  # math.cos, math.sin — and pi bare

A dotted as target introduces a namespace — surgical conflict resolution, and namespaces you introduce stay open for more imports:

import from circles: draw as gfx.draw
import from squares: fill as gfx.fill    # same gfx, composed

Nothing crosses the module boundary unless exported. Within a module every top-level declaration is visible, unmarked — but the module’s surface is exactly what it marks with export (the other half of import):

fn helper(n: int) -> int: n * 37     # module-internal
export fn api(n: int) -> int: helper(n) + 1
export let version = 3
export type widget = name: str, size = 42

export also takes a list of names, mirroring the import list — so a module’s interface can be stated in one place, at the top, ahead of the declarations it names:

export:
    circle
    area
    make_widget as widget      # publish under a different name

import from geometry: scale    # ...
export scale                   # ...and re-export it as your own

The names may be declared anywhere in the module (the list resolves once the module is checked), and they may be imported ones — which is how a module re-exports, or composes a façade from several sources. An as renames only what clients see; the thing itself is unchanged, so a type re-exported under a new name is still the same type.

export also takes a block of declarations — the modifier distributes over every one under it (an impl rides along with its type):

export:
    type circle =
        radius: float

    with impl:
        fn area(self) -> float: 3.14159 * .radius * .radius
    fn api(n: int) -> int: helper(n)

One thing to know: that’s grouping, not scoping. Declarations under export: stay top-level — the rest of the module sees them exactly as if they weren’t indented. (The rule of thumb: a colon block under a value construct scopes; under a declaration construct — with impl:, import from:, export: — it groups.)

Exporting a type carries its member surface — constructors, methods, properties — along with it. (export is not “public”: within the module nothing needs marking; export is interface membership.) Imports are plumbing, never part of your surface.

Members take the finer dials, inline or as blocks: private scopes a member to its own type; internal keeps it inside the defining module (its boundary enforcement arrives when types cross modules):

run ▶
type circle =
    radius: float

@init(.auto)
with impl:
    private fn base(self) -> float: 3.14159
    fn area(self) -> float: .base() * .radius * .radius

print(circle(2.0).area())     # fine — but c.base() is an error

At the top level there’s nothing to mark: unexported declarations are already module-internal, and the checker says so if you try.

Types cross the boundary with their surface. An exported type can be imported by name or reached qualified, and its constructor, methods and properties come along:

import from shapes_lib: circle, shade, describe
let c: circle = circle(2.0)
print(c.area())

import shapes_lib
let d: shapes_lib.circle = c     # qualified, wherever a type goes
let s: shade = .dark             # sums bring their variants

Identity is by declaration, not by name: your own circle and an imported circle are different types, and the checker says so if they meet. private members stay inside their type, internal ones inside the module that declared them — enforced across the boundary now that types can travel.

Soma imports Ribo documents. A .ribo file is a document — the declarative subset — and importing one gives you a module of pure data, schema included:

import "config.ribo" as cfg
print(cfg.staging.name)
let extra: cfg.server = ("stage-3",)   # the document's types, too

Nothing in a document needs export: a document’s boundary is its data. The subset is enforced when you import one, so a .ribo that strayed into Soma (a fn, a loop) is an error at the import, not a surprise later. Documents import by path — the extension is how you say “this is a document”.

Generics cross as well — a generic type keeps its parameters, and a generic function is instantiated per call in your module while its body is still checked against the module that wrote it:

import from shapes_lib: pair, swap
let p: pair<int> = pair(1, 2)
print(swap(p))
print(swap(pair("a", "b")))      # a fresh instantiation

import coll
let s: coll.stack<str> = coll.stack(["a"])

Attributes#

An attribute passes a value to processors as metadata, written @ before a declaration. There is no separate annotation language: @Doc("...") constructs a value of the declared type Doc, and a unit type is a marker. A typo’d attribute is an unknown type; wrong arguments are ordinary constructor errors.

run ▶
type Doc = (text: str)
type Deprecated = ()
type Range = (lo: int, hi: int)

@Doc("The service port")
@Range(1, 65535)
let port = 8080

@Deprecated
type widget =
    name: str
    @Doc("in millimetres")
    size: int
    height: int @Range(0, 100)

print(port)

Attributes attach to let bindings, type declarations, fns, methods, with impl: blocks, and fields — where they may also trail on the same line (height: int @Range(0, 100)). They stack, and they’re checked (in the enclosing scope, before the declaration they describe exists) but never executed by Soma itself — processors read them. The Ribo processor exposes them as typed values on its Document API and in --json output under $attributes/$types.

@init(...) is itself an ordinary attribute of prelude-declared types (type init_mode = auto | none and type init = (mode: init_mode) — note the contextual dot resolving the mode inside the argument) that the compiler consults: .auto keeps the memberwise constructor despite an impl, .none generates none. It attaches to a type declaration or its impl, whichever reads better. Because the compiler reads it while checking, the mode must be written directly (.auto), not computed. Bare value attributes (@"see the wiki") are reserved for later.

Nuts and bolts#

Rough edges today#

An honest list, while things are young:

Where it’s going#

Near-term: spread (point(...p, x = 2.0)), richer patterns, date/time literals, and modules/imports. On the types side: trait bounds as the opt-in strict dial for generics, and traits that let user types join the builtin capabilities (iterable, printable…) — with attributes set to become executable “macro” values that can reflect over declarations and generate code. Further out: user-defined operators, an effects system (fx beside fn), and — once the language settles — a bytecode VM, then JIT and AOT compilation. Soma is the middle of a family: Ribo, the purely declarative subset (its processor ships today and loads .ribo documents), and Pheno, a full systems language with the strict defaults.