tramaj v5: numbers that compute, in all six implementations
On Thu, 15 Oct 2026, by @lucasdicioccio, 601 words, 1 code snippets, 9 links, 0images.
tramaj v5: numbers that compute, in all six implementations
tramaj has gone through four iterations so far: v1 (the original
block/template split), v2 (expression-oriented documents, static analyses
and the Node JSON format), v3 (symbols and constraints) and v4 (types).
This is the fifth. It changes what a number is, and lets a template do
something with one:
- Two number types. An integer and a float, with no implicit conversion between them.
- Arithmetic. Ten named builtins,
roundamong them, in an optional profile that a host turns on. - Sorting.
sort-byandsort-by-descendingorder a list by a key function. - Number formatting.
format-numberwrites a number with a fixed number of decimals and a group separator, rounded the same way everywhere.
And the part that makes it a version rather than a design: all six implementations have all of it, and run the same corpus.
@rows=sort-by-descending($ctx.rows, (r) => $r.spend)
@total=sum(map($rows, (r) => $r.spend))
.table(
map($rows, (r) => .tr(.td($r.name), .td(format-number($r.spend, 2, ","))))
, .tr(.td("total"), .td(format-number($total, 2, ","))))
What changed since v4
Numbers. A JSON number in the context is typed by its text: 3 is an
integer, 3.0 and 3e0 are floats. A template literal follows the same
rule. Integers are exact over a guaranteed range; an integer outside it is
refused instead of being rounded. Comparisons do not mix the two types, and
real(…), floor(…) and round(…) convert when the template asks.
Arithmetic. Until v4 the reference said a template “compares and
selects, it does not compute”. That is reversed, narrowly: the grammar has
no infix operators, and arithmetic is ten named builtins (sum, product,
negate, round, …) in a profile the host enables. A host that does not
enable it can refuse such a program up front, from its static analysis,
without evaluating it. The design and the alternatives that were rejected
are in Two designs accepted.
Sorting and formatting. sort-by(list, fn) and
sort-by-descending(list, fn) order a list by the key fn gives each
element, and are stable. format-number(x, decimals, group) writes
1234567.891 as "1,234,567.89". Both are part of the core language and
need no profile. The rounding rule of format-number is normative, because
the native formatters of the six host languages disagree on ties and on
negative zero: each implementation writes the rule by hand rather than
calling its own formatter.
All of this is specified in
reference.md §3, §11 and §13, and recorded,
with the alternatives, in
specs/decisions.md
sections 18 and 20.
Breaking changes
The arithmetic profile is an addition: a template that does not use it is unaffected. The number split is not optional, and it changes the output of some existing templates and hosts:
| what | v4 | v5 |
|---|---|---|
str of a whole-valued float | str(1.0) is 1 | str(1.0) is 1.0 |
eq across the two types | eq(1, 1.0) is true | false |
lt, lte, gt, gte across the two types | gt(1.5, 0) is true | a TypeMismatch |
| a JSON number in the context | a double, whatever its text | 3 is an integer, 3.0 and 3e0 are floats |
| an integer outside the range | rounded to the nearest double | refused: a parse error as a literal, a TypeMismatch in the context |
the object key "$term" | an ordinary key | reserved, like "$sym" and "$type" |
the type primitive number (v4 types) | a primitive | gone: int and float replace it |
Migrating to integers and floats takes each row in turn: who it affects, how to find the templates it affects, and what to change.
Every implementation runs the whole corpus
The conformance corpus
holds 646 cases: a template, a context and the expected output, compared
byte for byte, in both concrete and symbolic mode. Until now an
implementation could skip a case that needed a profile it did not provide,
and while the ports caught up, some did. That is over: "int-float" and
"arithmetic" are now required of every runner, and a case that lists one
fails, rather than being skipped, in an implementation without it.
The only cases any implementation still skips are the ones written for the other integer range. PureScript and TypeScript have the guaranteed 53-bit range, because their host number is a double; the other four have the 64-bit range. Each skips the few cases that pin down the other range’s edges.
| implementation | integer range | corpus |
|---|---|---|
tramaj (PureScript) | 53-bit | passes; skips only the 64-bit range cases |
tramaj-hs (Haskell) | 64-bit | passes; skips only the 53-bit range cases |
tramaj-rs (Rust) | 64-bit | passes; skips only the 53-bit range cases |
tramaj-js (TypeScript) | 53-bit | passes; skips only the 64-bit range cases |
tramaj-go (Go) | 64-bit | passes; skips only the 53-bit range cases |
tramaj-py (Python) | 64-bit | passes; skips only the 53-bit range cases |
Packages
Where to get v5 today:
| package | language | where |
|---|---|---|
tramaj-purs, tramaj-halogen | PureScript | PureScript registry, 0.4.1 |
tramaj-cli | PureScript | from the repository |
tramaj-hs | Haskell | Hackage, 0.4.0.0 |
tramaj-rs, tramaj-cli-rs | Rust | crates.io, 0.2.0 |
tramaj (tramaj-py) | Python | PyPI, 0.2.0 |
@lucasdicioccio/tramaj-js, @lucasdicioccio/tramaj-react | TypeScript | npm, 0.2.0 |
tramaj-go | Go | from the repository, as github.com/lucasdicioccio/tramaj/tramaj-go, at a commit on main |
Each package has its own changelog page and Atom feed. Integrating tramaj says how to depend on each one.
What’s next
v5 is a fifth iteration, not a stable release: the language can still
change. The roadmap lists what is wanted next: folds to
more rendering targets, and type definitions for the Python modules. Beyond v5,
?shape is an accepted design for
allocating a structure of symbols in one form; no implementation has it
yet.