Two designs accepted: arithmetic, and shapes of symbols

On Thu, 08 Oct 2026, by @lucasdicioccio, 864 words, 4 code snippets, 6 links, 0images.

Two designs accepted: arithmetic, and shapes of symbols

Two language designs were accepted this week. This page says what each one is and how far along it is. The short version:

designacceptedspecifiedimplemented
arithmetic, and integers and floats as two types2026-10-06yes, in the referenceall six implementations, with the command-line tools, the Halogen fold and the playground, on main, not released
?shape, a form that allocates a structure of symbols2026-10-08design onlyno

Both are recorded in specs/decisions.md, sections 18 and 19, with the alternatives that were rejected and why.

Arithmetic

Until now the reference said a template “compares and selects, it does not compute”. Every derived number was the host’s job. That works when the host and the template author are the same party. It fails when a template is handed counters by a host it does not control: it cannot show a subtotal or a share in percent unless someone upstream precomputes each one and stores it next to the raw numbers.

So the position is reversed, narrowly. The grammar does not change. There are no infix operators. Arithmetic is nine named builtins:

builtinoperandsresult
sum(…), product(…)all integers or all floatsthe sum, the product
negate(x)an integer or a float-x, of the same type
quotient(a, b), inverse(x)floatsfloat division
floor-quotient(a, b), modulo(a, b)integersinteger division and its remainder
floor(x)eitherthe largest integer not above x
real(x)eitherthe float nearest to x
@subtotal = sum($ctx.compute, $ctx.storage, negate($ctx.credit))
@share    = branch(0, gt($ctx.total, 0),
                   floor-quotient(product(100, $ctx.used), $ctx.total))
@total    = sum(0.0, map($ctx.lines,
                         (l) => product(real($l.qty), real($l.price))))

Integers and floats are two types

A counter must add exactly and a ratio must not, so the single number type splits in two. A literal with neither a fraction nor an exponent is an integer (1, -7); one with either is a float (1.0, 1e5). A JSON number in the context is read by the same rule.

Nothing converts between the two unless the program says so. sum(1, 2.5) is an error, eq(1, 1.0) is false, and gt(1.5, 0) is written gt(1.5, 0.0). real and floor are the two conversions, and each accepts both types, so a template that does not know which one its host sends normalizes with real($x).

This changes existing output in one case. A float keeps its type when it is written, so a whole-valued float renders with a fraction: str(1.0) is 1.0 where it was 1. An integer renders as before.

Over a symbol, arithmetic builds a term

In symbolic mode a number may not be known yet. sum(1, $s, 2) with a symbol $s does not fail and does not guess. It evaluates to a term, the computation handed to the host undone:

{"$term": "sum", "arguments": [1, {"$sym": "#0:\"s\"", "path": []}, 2]}

The operands are kept exactly as written, with nothing folded. That buys one law: a host that substitutes numbers for the symbols and evaluates the term by the reference’s rules gets what the template would have produced had those numbers been in the context. Tramaj still solves nothing.

Where it stands

The normative text is in the language reference and the symbols reference, and the conformance corpus has the fixtures. All six implementations (PureScript, Haskell, TypeScript, Rust, Go and Python) have the number split and the builtins on main, and so do the command-line tools (tramaj-cli --arithmetic), the Halogen fold and the playground. They are not in a release. A tenth builtin, round, was added to the nine above by the third design, below.

The split changes what some existing templates produce, beyond the str case above: see Migrating to integers and floats.

?shape: a structure of symbols in one form

In symbolic mode a template allocates a solver variable with ?(key). One variable at a time is fine. A structure of them is not: every variable is its own form, with a key composed by hand.

@cluster = {
  lb:         ?(["cluster", "lb"]),
  placements: map($ctx.vms, (vm) => {
    vm:   $vm.name,
    host: ?(["cluster", "host", $vm.name]),
    zone: ?(["cluster", "zone", $vm.name])
  })
}

The accepted design adds a second allocation form. ?shape(key, shape) takes the key once, and the shape says where the symbols go with a named marker, ~name:

@cluster = ?shape("cluster", {
  ~lb,
  placements: map($ctx.vms, (vm) => {vm: $vm.name, ~host($vm.name), ~zone($vm.name)})
})

This is “traverse” in the functional-programming sense: walk a structure, run an effect at each position, get the same structure back. The effect is allocation. The number of placements is whatever the caller’s vms holds.

The rules are few:

  • A marker is explicit and named. ~lb is a symbol; nothing else in the shape is. {~lb} is short for {lb: ~lb}.
  • A marker may be written anywhere in the shape: a field, an array element, a call’s argument, the body of a map.
  • Under a lambda a marker carries its own key, ~host($vm.name), which says which iteration it belongs to. A bare marker there is a parse error, so one variable is never shared across iterations by accident.
  • Identity is what the author wrote. The symbols above are #0:["cluster","lb"], #0:["cluster","host","a"] and so on: the form’s key, the marker’s name and the marker’s key. No position and no index is involved, so reordering the vms keeps every variable’s identity.
  • Mistakes are parse errors: the same marker name twice in one form, or a shape with no marker at all.

It is a pure rewriting onto ?(key). The form above means exactly what the hand-written version means, so the core language, the output envelope and every host decoder stay as they are.

Where it stands

This one is a design only. The normative text is not written and no implementation has it. PureScript and Haskell come first, as for arithmetic. One thing it does not cover is a caller that supplies a count rather than a list: that needs a range, which is a separate question.

Also accepted: sorting and number formatting

A third design was accepted on the same day, in section 20 of the same file: sort-by(list, fn) and sort-by-descending(list, fn), which take a key function as map does, and format-number(x, decimals, group), which turns 1234567.891 into 1,234,567.89 by one rounding rule every implementation must agree on, with round as a tenth arithmetic builtin. Its normative text is now in the language reference, marked [§20], and all six implementations have it on main, in no release yet.