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.
| Tier | Annotation | Memory | Async | Use case |
|---|---|---|---|---|
| HIGH | @tier(high) (default) | Garbage collected | Yes | Business logic, I/O |
| MID | @tier(mid) | Arena allocated | No | Parsers, hot paths |
| LOW | @tier(low) | Manual, borrow-checked | No | Systems, 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:
| Language | Bet | Cost |
|---|---|---|
| Go, Java, C# | GC everywhere | Pauses, GC pressure, heap bloat |
| Rust | Ownership everywhere | Steep learning curve, slow compiles |
| C, C++ | Manual everywhere | Safety 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.
| Phase | Status | Covers |
|---|---|---|
| 1 | Done | Core design, memory model, lexer, arena AST, parser |
| 2 | Done | Semantic analysis: name resolution, type inference, tier enforcement |
| 3 | Done | Tree-walking interpreter, the current execution model |
| 4 | Started | LLVM 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) |
| 5 | Not started | Standard 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.
| Tier | Annotation | Memory | await | Typical use |
|---|---|---|---|---|
| HIGH | @tier(high) (default) | Garbage collected | Allowed | Business logic, I/O |
| MID | @tier(mid) | Arena allocated | Not allowed | Parsers, hot paths |
| LOW | @tier(low) | Manual, borrow-checked | Not allowed | Systems 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.
| Caller | Callee | Allowed |
|---|---|---|
| HIGH | MID | Yes (callback/view patterns encouraged, not required) |
| HIGH | LOW | Yes |
| MID | HIGH | No, an arena lifetime could escape |
| MID | LOW | Yes |
| LOW | HIGH | No |
| LOW | MID | No |
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
| Phase | Status | Covers |
|---|---|---|
| 1 | Done | Core design, memory model, lexer, arena AST, the recursive-descent/Pratt parser |
| 2 | Done | Semantic analysis: name resolution, type inference, tier enforcement, generics, the three-tier escape-boundary checker |
| 3 | Done | Tree-walking interpreter, the current execution model |
| 4 | Started | LLVM 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) |
| 5 | Not started | Standard 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@deriveforPartialEq, withEq,Hash,Ord/PartialOrd, andClonefollowing- Outlives/subset enforcement: lifetime well-formedness (declared names
must exist,
wherebounds reference declared names, no outlives cycles), plus boundary checks at a function call and atedge structconstruction, with multi-lifetime constraint propagation edge structnow participates in arena-escape checking: a named reference field on anedge structis checked against its declared lifetime rather than the general arena boundary rule- Method dispatch on user-defined structs through
extendand inherentimplblocks, both instance methods andType.method()static calls, withselfnow 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 aUnique<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
constitems are evaluated beforemainruns, in any declaration order, and assigning to one is a compile error typealiases are transparent: withtype Score = int, aScoreand anintare 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
extendblock 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>againstList<string>) now points at the offending expression instead of the top of the file - Mutable globals:
static NAME: Type = expr(the type is required, andpub staticexports 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, aconstcannot 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 beforemain, in any declaration order, and the declared type types a bare literal (static HP: u8 = 250). A@tier(...)written on aconstis 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
ubelcommand line (check,run,debug-tokens, with--jsonoutput and stable exit codes) and anubel-lsplanguage 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
u64range (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 amatchpattern all give the literal the type the position expects, with the same out-of-range check (let x: u8 = 300is an error, not a silent wrap). A literal with nothing to constrain it stays a plainint. An integer literal never becomes a float on its own (let f: float = 5needs5.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/matchheads 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 Typeworks and is checked (missing and extra methods, signature mismatches, overlap), default methods are inherited or overridden,Selfresolves, an inherent method beats a trait method, two traits supplying one name are told apart withTrait.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,OrdandCloneexist without being declared, with anOrderingenum, and work as bounds and as methods on built-in and derived types.Hashand aHasherstruct exist too, and aDictionarykey must beHash. 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 onBalso gives whatAandCprovide, 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 takeextendmethods, and a prelude trait can be written by hand where the type lacks it (Hashfordouble). Impls andextendblocks also work for generic types (impl<T: Show> Show for Box<T>,impl Show for Box<int>,extend<T> List<T>), forList,Dictionary,Queue,StackandInlineList, 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: aLOWorMIDfunction cannot call a method that isHIGH. A method has the tier written on it, on itsimpl,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-writteneq,hashorcloneisLOW. A struct or enum can implementDisplay(fn to_string(self) string) to decide howprintln,{x}andto_stringshow it, at any depth; a struct prints its fields in declaration order. A struct or enum can implementIterator<T>(fn next(self) T?) so thatfor x in valuecallsnextuntil it returnsnull;Queue,StackandDictionarycan be looped over too, and aforover 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,NegandBitNot), soa + b,a *= b,-aand~acall it; a type can have one impl per kind of right operand (Vec2 * doubleandVec2 * 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 implementIndexandIndexSet, soa[i],a[i] = vanda[i] += vcall it (one impl per kind of index, and aListor aDictionarysatisfies the same bounds);List,Dictionary, string and tuple indexing is typed, and indexing what the run time cannot index is a compile error. Adyn Traittype (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 noself, a generic method, orSelfin 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 aList<u8>stores a plainintand a laterlist[0] + 100fails when the program runs - Format specs for sign, zero padding and numeric base (
{x:+},{x:05},{x:x}) work on plainintonly, not on the fixed-width integers - Constructing through an alias to a type that is not a struct or enum
(
type Score = int, thenScore { x = 1 }) is not rejected at compile time pubwritten on aconstortypeitem, and@tier(...)written on atypeitem, 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 arenablocks 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.