Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Ubel Stratum

The right memory model for every function.

Ubel Stratum is a statically-typed systems and game-development language. Its defining feature is a tier system: every function declares which memory strategy it uses, and the compiler enforces the rules statically.

TierAnnotationMemoryAsyncUse case
HIGH@tier(high) (default)Garbage collectedYesBusiness logic, I/O
MID@tier(mid)Arena allocatedNoParsers, hot paths
LOW@tier(low)Manual, borrow-checkedNoSystems, FFI, packets

Functions without a @tier annotation default to HIGH. Tiers are opted into for performance, not opted out of for convenience.

Why a tier system

Most languages make one memory bet for an entire program:

LanguageBetCost
Go, Java, C#GC everywherePauses, GC pressure, heap bloat
RustOwnership everywhereSteep learning curve, slow compiles
C, C++Manual everywhereSafety bugs, undefined behavior

Ubel Stratum lets each function make its own bet: garbage collection where that is the easier choice, arenas where speed matters, manual ownership where correctness under tight constraints matters most. The compiler guarantees the bets never clash at runtime, and cross-tier calls follow an explicit set of rules covered in The Tier Model.

Current status

Ubel Stratum is early and under active development. The end goal is native machine code through an LLVM backend; today the language runs on a tree-walking interpreter, a deliberate stepping stone rather than the final architecture. Building the interpreter first proves out the language’s semantics completely before the much heavier lift of LLVM integration begins.

PhaseStatusCovers
1DoneCore design, memory model, lexer, arena AST, parser
2DoneSemantic analysis: name resolution, type inference, tier enforcement
3DoneTree-walking interpreter, the current execution model
4StartedLLVM backend, native binaries. So far a proof of concept: hand-written IR measures the three tiers, and a small emitter turns a subset of Ubel (int and bool functions, control flow, arena blocks) into LLVM IR that matches the interpreter (docs/LLVM_POC.md)
5Not startedStandard library, tooling, package manager

Source files use the .ubl extension. There is no installable compiler or package manager yet; running Ubel Stratum today means building the interpreter from source, covered in Getting Started.

The source, issue tracker, and full engineering documentation live at github.com/MidManStudio/ubel_stratum. A browser playground runs the tokenizer, parser, semantic analyzer, and interpreter on arbitrary .ubl source without installing anything, and the CI results page tracks the fixture suite and benchmark history that back every change to the language.

Getting Started

There is no installable compiler or package manager yet (that is Phase 5). Running Ubel Stratum today means building the interpreter from source and driving it through the pipeline example, or trying source directly in the browser playground without installing anything.

Prerequisites

Rust 1.75 specifically. The project targets free GitHub Actions runners and older local hardware, so the toolchain is pinned rather than tracking stable. A handful of dependencies need pinning to versions that still support 1.75 after a fresh cargo generate-lockfile:

cargo update -p owo-colors  --precise 4.0.0
cargo update -p backtrace   --precise 0.3.69
cargo update -p proptest    --precise 1.4.0
cargo update -p tempfile    --precise 3.14.0
cargo update -p clap        --precise 4.4.18
cargo update -p rayon       --precise 1.10.0
cargo update -p rayon-core  --precise 1.12.1
cargo update -p half        --precise 2.4.1
cargo update -p textwrap    --precise 0.16.0

LALRPOP is not required. crates/parser is an inactive reference implementation, not a default workspace member; it is only relevant when working on that crate specifically.

Build and test

cargo build --workspace --all-targets
cargo test  --workspace --lib --bins
cargo bench   # crates/core and crates/rd_parser each have benches/

Running the pipeline

# Run the full pipeline against every fixture
cargo run -p ubel_stratum_rd --example pipeline -- tests/fixtures

# Against a single file
cargo run -p ubel_stratum_rd --example pipeline -- path/to/file.ubl

This is the same command the CI pipeline runs on every push; its output backs the CI Results page.

A first program

fn main() {
    println("Hello, Stratum!")
}

Functions default to the HIGH tier, so this needs no @tier annotation. Saved as hello.ubl and run through the pipeline above, it prints its one line and exits. The Language Tour covers the rest of the surface syntax; The Tier Model covers what @tier actually changes about how a function compiles and runs.

The Tier Model

Every function in Ubel Stratum declares which memory strategy it uses. The compiler enforces the rules statically, at every tier, regardless of whether the program runs through the interpreter or, eventually, compiles to native code.

TierAnnotationMemoryawaitTypical use
HIGH@tier(high) (default)Garbage collectedAllowedBusiness logic, I/O
MID@tier(mid)Arena allocatedNot allowedParsers, hot paths
LOW@tier(low)Manual, borrow-checkedNot allowedSystems code, FFI, packet handling

A function with no @tier annotation is HIGH. Lower tiers are opted into for performance, never opted out of by default.

The core constraint

A MID-tier function allocates data in an arena. Once that arena is freed, every pointer into it becomes invalid, so HIGH-tier code must never hold a live pointer into a freed arena. The type system enforces this at compile time: a value living in arena A carries a type parameterized by the arena’s lifetime (&'a T), and the compiler rejects any program where an &'a T appears in a type that outlives arena A.

Three patterns cross the MID to HIGH boundary safely. These are illustrative of the pattern the tier checker enforces, not a claim that these exact function names ship in a standard library yet; there is no standard library today.

Pattern 1: callback

MID parses into an arena, calls a HIGH-tier closure with a borrow into the arena, the closure produces a GC-owned result, then the arena frees. The closure’s borrow never outlives the arena.

@tier(mid)
fn parse_json_with<F, R>(input: string, callback: F) R
    where F: fn(&JsonView) R   // R must contain no arena references
{
    with arena(1MB) {
        let view = build_json_view(input)
        return callback(&view)
    }
}

@tier(high)
fn handle_request(req: Request) Response {
    parse_json_with(req.body, fn(json) {
        let user_id = json.get("user_id").as_int()
        return fetch_user(user_id)
    })
}

Pattern 2: iterator

For processing large datasets without materializing the whole result at once. MID drives the iteration; HIGH only ever sees GC-owned values, one at a time.

@tier(mid)
fn transform_each<R>(items: &[Item], f: fn(&TransformedItem) R) List<R> {
    with arena(10MB) {
        let mut results = List.new()
        for item in items {
            let transformed = expensive_transform(item)
            results.push(f(&transformed))
        }
        return results
    }
}

Pattern 3: view

Syntactic sugar over the callback pattern for read-only access, following the same rules.

@tier(high)
fn parse_config(path: string) Config {
    let config_view = read_toml_view(path)
    using let v = config_view {
        let host = v.get("host").to_owned()
        let port = v.get("port").as_int()
        return Config { host, port }
    }
}

Rejected at compile time

// storing an arena reference in a GC-managed struct
@tier(high)
struct BadCache {
    data: &JsonView   // error: contains an arena lifetime
}

// HIGH tier constructing an arena directly
@tier(high)
fn bad() {
    with arena(1MB) { }   // error: 'with arena' is MID-tier only
}

// a cross-tier function returning an arena reference
@tier(mid)
fn bad_leak(input: string) &JsonView {
    with arena(1MB) {
        return &parse(input)   // error: return type carries an arena lifetime
    }
}

The cross-tier call matrix

Not simply “LOW cannot call HIGH”: every direction is a separate rule.

CallerCalleeAllowed
HIGHMIDYes (callback/view patterns encouraged, not required)
HIGHLOWYes
MIDHIGHNo, an arena lifetime could escape
MIDLOWYes
LOWHIGHNo
LOWMIDNo

What is enforced today

The cross-tier call matrix above, arena-escape checking for the patterns shown, lifetime well-formedness (declared lifetime names must exist, where clauses can only reference declared names, no outlives cycles), and outlives enforcement across a boundary are real, running checks in the semantic analysis pass, independent of which backend eventually executes the program.

Calls on a value. value.method(), Type.make(), a call through a trait bound and a qualified call (Trait.f(v)) obey the same call matrix as a call by name, using the tier written for the method. A method is HIGH unless it, the impl, extend, struct or trait around it, or a @tier(...) { } block around them says otherwise, and an unrelated attribute such as @inline never changes that.

Trait methods. A trait method has a tier: its own @tier, otherwise the trait’s, otherwise HIGH. A call through a bound is checked against it, because the impl is not known there, so every impl method has to be at exactly that tier (an impl method that writes no tier takes it). The prelude traits (PartialEq, Eq, PartialOrd, Ord, Hash, Clone) are @tier(low): code at any tier can call them through a bound, and a hand-written impl of one is LOW, so its body cannot read a static, build a collection or call a HIGH function.

Outlives / subset enforcement across a boundary. LOW-tier borrow checking is real: a control-flow graph is built per function, and loan and liveness checking on top of it rejects a conflicting borrow with NLL-style precision, where a reference’s last actual use determines when it stops conflicting, not the block it was declared in, with move checking (use-after-move, loop-carried moves, reinitialization) alongside it. Outlives enforcement extends that across two boundaries: a call to a function with declared [lifetime ...] parameters, and the construction of an edge struct whose fields carry declared lifetimes. Declared outlives relationships propagate across multiple lifetimes, and a boundary argument that cannot be traced to a local is rejected conservatively rather than accepted. Three shapes are outside the current scope, documented in the repository’s docs/OUTLIVES_RULES.md: methods (which the loan checker does not cover yet either), references nested inside generic arguments, and closures that capture references.

One thing is still in progress rather than complete:

The interpreter’s memory model. The tree-walking interpreter runs every tier on the same reference-counted representation. with arena blocks are recognized and validated by the tier checker but do not yet allocate or free real memory in the interpreter; genuine bump-allocation arrives with the LLVM backend. The static rules above are enforced regardless, since tier checking happens independently of execution, but running a MID-tier function today does not yet exercise real arena memory pressure or reclamation timing.

Language Tour

A tour of the surface syntax. Every example below is real syntax the project documents; none is invented for this page.

Tier annotations

// Default: HIGH, no annotation needed for most code
fn handle_request(req: Request) Response {
    let user = fetch_user(req.user_id)
    return Response.ok(user.to_json())
}

// Opt into MID for a hot path
@tier(mid)
fn parse_payload(body: string) ParsedData {
    with arena(1MB) { }
}

// Opt into LOW for systems code
@tier(low)
fn write_packet(buf: &mut [u8]) usize {
    // raw ownership; borrow checker enforcement is still in progress
}

Collections

Collection names follow C# convention rather than Rust’s.

let mut numbers = List.new()
numbers.push(1)
numbers.push(2)

let mut scores = Dictionary<string, int>.new()
scores.set("Alice", 100)   // set/get, not insert, for symmetry

let items = [1, 2, 3, 4, 5]
let names = ["Alice", "Bob"]   // inferred as List<string>

Unified member access

There is no ::. Type-level calls and instance calls both use ..

summon std.collections.List
let list = List.new()   // type-level call
list.push(42)            // instance call

Error handling

A trailing ! on a return type means the function may fail; ? propagates a failure out of the current function.

fn parse_int(s: string) int! {
    // returns a Result-like value
}

fn process(s: string) int! {
    let n = parse_int(s)?
    return n * 2
}

Async

Async is HIGH tier only. MID and LOW are synchronous by design: arenas have lexical lifetimes, which do not compose with the way an async function suspends and resumes across await points.

@tier(high)
async fn fetch_user(id: int) Task<User>! {
    let resp = await http_get($"/users/{id}")?
    return await parse_user(resp.body)?
}

Structs and methods

struct Rectangle {
    width: int,
    height: int

    pub fn new(w: int, h: int) Rectangle {
        return Rectangle { width = w, height = h }
    }

    pub fn area(self) int {
        return self.width * self.height
    }
}

Pattern matching

match response {
    Ok(data) where data.status == 200 => process_success(data),
    Ok(data) => log_warning($"Status: {data.status}"),
    Err(NetworkError(extract { code, message })) => {
        log_error($"Network error {code}: {message}")
    }
    Err(e) => log_error($"Unknown: {e}"),
}

Pipe operator

let result = data
    |> parse?
    |> validate?
    |> transform
    |> save

Extension functions

extend int {
    fn is_even(self) bool { return self % 2 == 0 }
}

if 42.is_even() { println("Even!") }

References and lifetimes (LOW tier)

&/ref and &mut/ref mut are dual spellings of the same borrow operator, */deref likewise for dereference, the same relationship and/&& and or/|| already have. Either spelling compiles to the identical AST node; named lifetimes are only needed for cross-function borrows complex enough that inference cannot resolve them alone.

// inferred, no annotation needed
fn first(list: &List<int>) &int {
    return &list[0]
}

// same thing, keyword spelling
fn first(list: ref List<int>) ref int {
    return ref list[0]
}

// explicit lifetime for a genuinely ambiguous case
fn longest[lifetime L](x: &L str, y: &L str) &L str {
    if x.len() > y.len() { return x } else { return y }
}

The syntax and structural typing for references are complete. The borrow checker that actually verifies a program’s borrows are sound (loan tracking, liveness, move checking) is still in progress; see The Tier Model for exactly where that line sits today.

RAII with using

using let file = File.open("data.txt") {
    let content = file.read()
    process(content)
}   // file.close() runs automatically

Query pipelines

Linqerizer<T> is a lazy, chainable query pipeline over any collection, HIGH tier only. .query() snapshots the source once; nothing runs until a terminal call (.to_list(), .first(), .count()) walks the pipeline, and each chained call returns a new pipeline rather than mutating in place.

@tier(high)
fn active_adult_names(users: List<User>) List<string> {
    return users.query()
        .where(fn(u) u.age >= 18 and u.status == "active")
        .order_by(fn(u) u.name)
        .select(fn(u) u.name)
        .to_list()
}

.group_by(...) produces a real Dictionary<Key, List<Value>>. An earlier query-comprehension grammar (from x in ... where ... select, closer to C#’s LINQ syntax) was removed outright rather than kept alongside this: it was eager, List-only, group_by was a stub, and it had no fixture coverage. Linqerizer<T> needs no dedicated grammar at all; it parses as ordinary method calls.

Project Status

Ubel Stratum is early and under active development. This page tracks what exists today at a language level; the CI Results page tracks the fixture suite and benchmark history behind these claims.

Phases

PhaseStatusCovers
1DoneCore design, memory model, lexer, arena AST, the recursive-descent/Pratt parser
2DoneSemantic analysis: name resolution, type inference, tier enforcement, generics, the three-tier escape-boundary checker
3DoneTree-walking interpreter, the current execution model
4StartedLLVM backend, native binaries. Hand-written IR measured the three tiers, and an emitter turns a growing subset of Ubel into LLVM IR that is checked against the interpreter at every step: int, bool and string values, structs, enums and match, closures, arenas and the garbage-collected heap, Unicode string methods. The target program examples/arena_demo/ast_builder.ubl compiles. Collections, floats and sized integers, generics, traits and a ubel build command are not there yet (docs/LLVM_BACKEND_STATUS.md)
5Not startedStandard library, tooling, package manager

Phase 2 being marked done covers full generics for structs and enums, enum discriminants and payloads, arena and pool escape checking, and generational handles through Pool<T>/Handle<T>. It does not mean every corner of semantic analysis is finished: LOW-tier borrow checking has real, CFG-based loan and liveness enforcement in place (genuinely non-lexical — a borrow’s last actual use determines when it stops conflicting, not the enclosing block) and move checking alongside it, but the loan and move checking itself is intra-function. Declared lifetime parameters ([lifetime L]) are checked for well-formedness, and outlives enforcement verifies that a reference crossing a function call or an edge struct field actually respects the declared relationship, with a few documented scope limits (methods, references nested inside generics, closures capturing references) — see The Tier Model for the exact line between what parses and type-checks versus what is actually verified safe.

Recently landed

  • _ as both a match wildcard and a parameter placeholder
  • @derive for PartialEq, with Eq, Hash, Ord/PartialOrd, and Clone following
  • Outlives/subset enforcement: lifetime well-formedness (declared names must exist, where bounds reference declared names, no outlives cycles), plus boundary checks at a function call and at edge struct construction, with multi-lifetime constraint propagation
  • edge struct now participates in arena-escape checking: a named reference field on an edge struct is checked against its declared lifetime rather than the general arena boundary rule
  • Method dispatch on user-defined structs through extend and inherent impl blocks, both instance methods and Type.method() static calls, with self now type-checked inside those bodies
  • Method dispatch through Unique<T>/Shared<T>/SyncShared<T> ownership wrappers, for builtin collections and for user-defined struct methods, and calling a user-declared method on a Unique<T> local no longer counts as moving it, so several calls in a row are accepted while a genuine second move is still rejected
  • Global const items are evaluated before main runs, in any declaration order, and assigning to one is a compile error
  • type aliases are transparent: with type Score = int, a Score and an int are the same type, with no cast. That holds for aliases to primitives, collections, generic struct instances and function types, for generic aliases (type Wrapped<T> = Box<T>), for an alias of an alias, and for an alias declared after its use. An alias to a struct or enum also works where the name is built or matched (P { x = 1 }, P.origin(), C.Red, patterns), and an alias cycle is an error
  • Methods on enums: an extend block on an enum adds instance methods and associated functions (Color.make()), an unknown method on an enum value is a compile error, and a struct field of function type can be called directly (config.callback(4))
  • A type mismatch inside a generic type (List<int> against List<string>) now points at the offending expression instead of the top of the file
  • Mutable globals: static NAME: Type = expr (the type is required, and pub static exports once modules exist). A static is one value shared by every function: assign to it in one function and read it in another. It lives in the HIGH tier, so only @tier(high) code can read or write it, a const cannot read it, and a local or parameter of the same name shadows it. Pass the value into a MID or LOW function as a parameter instead. Initializers run once before main, in any declaration order, and the declared type types a bare literal (static HP: u8 = 250). A @tier(...) written on a const is now an error rather than ignored
  • Nested generic arguments (List<List<int>>, Dictionary<string, List<int>>) parse, and annotated dictionaries type-check
  • A return type annotation on a lambda is one clear parse error instead of two unrelated name errors, and an unknown method on a struct is reported once instead of twice
  • Tooling stubs: a ubel command line (check, run, debug-tokens, with --json output and stable exit codes) and an ubel-lsp language server that publishes the compiler’s diagnostics as you type. Both call the same one-function check, so an editor and the command line agree
  • Fixed-width integers with real wrapping arithmetic and the full u64 range (u8, i8, u16, i16, u32, u64, and the rest), numeric literal suffixes (255u8), and a literal-out-of-range diagnostic
  • Unsuffixed integer literals take their type from context, the way Rust does: let x: u8 = 250, a struct field, a call argument, a return value, an assignment, the other operand of + or <, and a match pattern all give the literal the type the position expects, with the same out-of-range check (let x: u8 = 300 is an error, not a silent wrap). A literal with nothing to constrain it stays a plain int. An integer literal never becomes a float on its own (let f: float = 5 needs 5.0). Ordering comparisons now work on the fixed-width integers
  • A parser ambiguity fix: a bare identifier condition immediately followed by a block whose first statement was a plain assignment (if x == y { hit_count = hit_count + 1 }) could misparse as a struct literal; if/while/for/match heads no longer read a struct literal unless it is parenthesized

Active work

  • The trait system is being built in slices (design in docs/TRAITS_DESIGN.md). Built: impl Trait for Type works and is checked (missing and extra methods, signature mismatches, overlap), default methods are inherited or overridden, Self resolves, an inherent method beats a trait method, two traits supplying one name are told apart with Trait.method(value), and a call made through a trait runs the trait’s method. Trait bounds on generic functions, structs and enums are enforced: a call or construction with a type that lacks the impl is an error, and a method call on a type parameter with no bound is an error. PartialEq, Eq, PartialOrd, Ord and Clone exist without being declared, with an Ordering enum, and work as bounds and as methods on built-in and derived types. Hash and a Hasher struct exist too, and a Dictionary key must be Hash. Hand-written impls of those traits are accepted and called by ==, the ordering operators, contains, dictionary lookups, sorting and grouping, and by derived comparison, hashing and cloning inside other values. A trait can build on others (trait B: A + C): an impl needs the impls beneath it, a bound on B also gives what A and C provide, and a trait that builds on itself is an error. A trait can take type parameters (trait Convert<T>), a bound can name them (T: Convert<int>), and a trait can build on a generic one. A method can declare generic parameters of its own, with bounds, and a trait can declare generic methods that an impl repeats; a bound on a type alias’s parameter holds at every use of the alias. A primitive, a tuple or an array can implement a trait and take extend methods, and a prelude trait can be written by hand where the type lacks it (Hash for double). Impls and extend blocks also work for generic types (impl<T: Show> Show for Box<T>, impl Show for Box<int>, extend<T> List<T>), for List, Dictionary, Queue, Stack and InlineList, and an impl can be conditional on a bound of its parameter. A call on a value (value.method()), on a type (Type.make()) or through a trait obeys the tier rule: a LOW or MID function cannot call a method that is HIGH. A method has the tier written on it, on its impl, extend, struct or trait, or on a @tier(...) { } block around them. A trait method has a tier too, and an impl method has to equal it (a method that writes none takes the trait’s). The prelude traits are @tier(low), so a hand-written eq, hash or clone is LOW. A struct or enum can implement Display (fn to_string(self) string) to decide how println, {x} and to_string show it, at any depth; a struct prints its fields in declaration order. A struct or enum can implement Iterator<T> (fn next(self) T?) so that for x in value calls next until it returns null; Queue, Stack and Dictionary can be looped over too, and a for over anything that cannot be walked is a compile error instead of a run-time panic. A struct or enum can implement the operator traits (Add, Sub, Mul, Div, Rem, BitAnd, BitOr, BitXor, Shl, Shr, Neg and BitNot), so a + b, a *= b, -a and ~a call it; a type can have one impl per kind of right operand (Vec2 * double and Vec2 * Vec2), a number can take a struct on its right (2.0 * v), and an operator with no impl, or on an unbounded type parameter, is a compile error instead of a run-time panic. A struct or enum can also implement Index and IndexSet, so a[i], a[i] = v and a[i] += v call it (one impl per kind of index, and a List or a Dictionary satisfies the same bounds); List, Dictionary, string and tuple indexing is typed, and indexing what the run time cannot index is a compile error. A dyn Trait type (dyn Shape, &dyn Shape, Unique<dyn Shape>) holds a value of any type that implements the trait, in every tier: a list can hold a circle and a square, a call on an element runs its own impl, a trait that cannot work that way (a method with no self, a generic method, or Self in a signature) is a compile error, and a value whose type does not implement the trait is a compile error instead of a run-time panic. Next: associated types
  • Still to decide: whether struct fields get default values. The literal typing it depended on has landed, so it can be taken up next
  • The language server publishes diagnostics only; hover, go to definition and completion are not built

Known gaps, tracked rather than hidden

  • Arguments to built-in collection methods are not type-checked, so list.push(200) on a List<u8> stores a plain int and a later list[0] + 100 fails when the program runs
  • Format specs for sign, zero padding and numeric base ({x:+}, {x:05}, {x:x}) work on plain int only, not on the fixed-width integers
  • Constructing through an alias to a type that is not a struct or enum (type Score = int, then Score { x = 1 }) is not rejected at compile time
  • pub written on a const or type item, and @tier(...) written on a type item, are parsed and then ignored; constants are global to the file and readable from every tier
  • The interpreter runs every tier on the same reference-counted values; with arena blocks are validated by the tier checker but do not yet allocate or free real memory, that lands with the LLVM backend
  • No package manager, no installable compiler, no standard library beyond the built-in collection and instance methods documented in the Language Tour

Source and deeper documentation

The GitHub repository carries the full engineering documentation this site draws from: MEMORY_MODEL.md, PARSER_RULES.md, DIAGNOSTICS_RULES.md, ENUM_RULES.md, GENERICS_RULES.md, DATASTRUCTURES.md, and PARKED_IDEAS.md among others, each maintained alongside the code it describes rather than as a separate, drifting reference.