PatLang Paradigms Guide

PatLang is not built around one programming model with the others bolted on as libraries. Nine paradigms sit at roughly equal weight in the language and its host-function surface, each with real working example code in the repository. This page walks each one: the keywords or host functions involved, why you'd reach for it, and a minimal worked example. See the Grammar & Syntax reference for exact syntax and the Use Cases page for larger programs that combine several of these at once — most real PatLang programs do.

Functional

Functions are values. apply(fn, args) and closures (|x| { ... } / |x| do ... end) give map/filter/reduce-style composition without dedicated syntax for any of them — they're ordinary higher-order function calls.

let square = |x| { x * x }
let squared = apply(square, [1, 2, 3])

Reach for this when a transformation is naturally a pure function of its input and you want to pass it around rather than name a statement block.

Object-Oriented

There is no class keyword in either grammar stage. Objects are created and manipulated entirely through host functions in the oo chunk: new, set_var, get, send. This is a deliberate minimalism, not an oversight — it keeps OO as a thin, inspectable layer over the same object store everything else uses, rather than a parallel type system.

let acct = new("Account")
set_var(acct, "balance", 100)
send(acct, "deposit", [50])
print(get(acct, "balance"))

Reach for this when you want mutable, named state with dispatchable behaviour — the point-of-sale demo's product catalogue is a representative case.

Event-Driven

when EVENT { ... } (Stage 0) or when EVENT do ... end (both stages) registers a handler; emit fires it — synchronously, on whichever thread calls emit, in the order handlers were registered.

when "scan" do
  print("item scanned")
end

emit("scan", [])

Cross-thread since this session's fix: the compiled/codegen backend used to store the handler registry (EVENT_HANDLERS) as a thread_local!, so every real OS thread a program spawns — a fiber thread, a parallel_map worker, a WASI-threads Worker — started with its own fresh, empty copy: calling emit(...) from inside a fiber was a silent no-op, even for handlers registered before the fiber was spawned. It's now a shared, Mutex-protected registry (gated on target_feature = "atomics", the same signal used for fiber support itself), so a handler registered anywhere fires correctly no matter which thread calls emit. Dispatch itself is unchanged — still synchronous, same call stack, whichever thread emitted runs the handler body directly. See the robots demo for a live, hands-on example: three real fibers signaling a main-thread handler as they navigate a grid. One deliberate scope limit: the object store (new/get/set_var, backing send) is still thread-local, so shared mutable state via the object-store idiom doesn't cross threads yet — only the event payload itself (an ordinary function argument) does.

Reach for this when control flow is naturally driven by external occurrences rather than a single top-to-bottom script — servers, UIs, simulations with a timeline.

Logic / Goal-Oriented

This is the paradigm most worth reading the fine print on. fact NAME(...), query, goal, and rule exist as recognisable keyword-shaped syntax in Stage 0's lexer/parser, but the parser only tokenises and discards them as no-ops when written that way — it never actually constructs the Fact/Query AST nodes that ast.rs declares. The paradigm works entirely through ordinary function calls: fact(name, args), query(pattern), goal(target, strategy), all routed through the logic host chunk. Stage 1's self-hosted parser has no node shape for any of this at all, in any form — logic programming currently only runs through the Stage 0 native frontend and interpreter.

# Works — call form, both interpreted and Stage-0-compiled
fact("open", ["room1", "room2"])
fact("open", ["room2", "room3"])
query("open", ["room1", "room2"])

Reach for this when a problem is naturally a search over facts and relationships rather than an imperative walk — the maze solver builds its map as open facts and asks the goal engine to find a path.

Design by Contract

require (precondition), ensure (postcondition), and bare assert are genuine, shared grammar in both stages, parsing to one Assert statement distinguished only by a kind tag. All three lower, at codegen and interpreter time alike, to a single host call: contract_check(func_name, kind, text, ok). One primitive backing three keywords keeps failure messages uniform ("precondition failed", "postcondition failed", plain assertion text) regardless of which of the three actually fired.

make a function called divide takes a, b returns q
  require b != 0
  let q = a / b
  ensure q * b == a
  return q
end

Reach for this when a function's correctness depends on caller-supplied invariants you want checked at the boundary rather than defended against defensively inside the body.

Numeric Tower

PatLang's Value type includes Int, Float, BigInt, Rational, and Complex — but none of these has dedicated literal syntax. You always write plain digits; promotion happens automatically at runtime. Three rules matter in practice:

  • Int overflow promotes silently to BigInt. A factorial that outgrows i64 keeps computing correctly rather than wrapping or erroring.
  • / on integers that don't divide evenly promotes to an exact Rational, never a truncated integer and never a lossy float. 7 / 2 is the exact fraction 7/2, not 3 and not 3.5. This is the single most common surprise for anyone arriving with C-family intuitions about integer division — see the worked bug in the Idioms & Patterns page.
  • sqrt of a negative number promotes to Complex; sqrt itself explicitly rejects a Complex input rather than attempting a complex square root.

Reach for this when exactness matters more than raw speed — financial or combinatorial code where a silently-truncated division or a silently-overflowed integer would be a real bug, not a rounding nicety.

Runtime-Extensible Syntax DSLs

syntax NAME { ... } blocks let a program declare new trigger tokens that are expanded against raw source text before lexing — a genuine runtime capability, not just a compile-time macro trick, as the dynamic-syntax demo specifically sets out to prove. The router DSL is the clearest example: a small HTTP routing grammar declared entirely in PatLang source, expanded, then compiled normally.

Reach for this when a sub-problem (routing tables, a small config language) is genuinely better expressed as its own compact notation than as calls into a general-purpose API.

Concurrency: Real Threads, Cooperative Fibers, and Time-Budgeted Blocks

Three distinct, honestly-different concurrency primitives exist side by side:

  • parallel_map(items, "func_name") is genuine OS-thread parallelism — one real std::thread per item, via std::thread::scope, so worker threads can borrow the program directly without cloning. This is where actual concurrent execution happens.
  • fiber_new/fiber_resume/fiber_yield/fiber_alive are cooperative, Ruby-fiber-style coroutines. Each fiber does get its own OS thread under the hood, but a mutex/condvar handshake ensures only one fiber's thread is ever unparked at a time — it is deliberately not concurrent. The parked thread's own native call stack serves as the saved coroutine state, which is why no separate stack-switching machinery was needed to build it. Fibers now work identically across all three execution paths — interpreted, natively compiled (pat --patc), and self-hosted compiled (patc1.exe) — the compiled paths port the same mutex/condvar design directly into the generated program's own runtime text.
  • budgeted(ms[, existing]) { ... } / do ... end is sugar built on fibers: a block runs on an implicit fiber and yields once its millisecond budget is exhausted — or, using a rolling window of recent iteration durations and a linear-regression trend fit, once the next iteration is predicted to blow the budget, so a loop trending slower gets caught before it overruns rather than after. It evaluates to a tagged result, ["done", value] or ["paused", fiber_id]; passing that fiber_id back in as the second argument resumes the same fiber with a freshly refreshed deadline, continuing exactly where it left off rather than restarting.

None of the three is routed through the general HOST_CHUNK_TABLE chunk system that everything else in this guide uses — all are special-cased directly in the interpreter and Rust codegen (and, for budgeted/fibers, mirrored into self_hosting/lib/runtime_rs.patlang for the self-hosted compiled path too). Captured locals in a budgeted block are by-value, exactly like this language's closures: mutations inside aren't visible to the caller after the block runs — use the object store (new/get/set_var) for state that genuinely needs to be shared across the pause boundary.

let results = parallel_map(work_items, "process_item")   # real parallelism

let f = fiber_new("producer")
let v = fiber_resume(f, [])                                # cooperative handoff

let mut handle = false
let mut done = false
while not done do
  let r = budgeted(16, handle) do
    while some_condition do
      process_heavy_data()
    end
  end
  if r[0] == "done" then done = true else handle = r[1] end
end

Reach for parallel_map when independent items of work genuinely benefit from wall-clock parallelism (map-reduce-shaped problems). Reach for bare fibers when you want producer/consumer-style suspend-and-resume control flow and don't need — or specifically want to avoid — true concurrency. Reach for budgeted when a single loop needs to timeslice itself inside a cooperative scheduler without you hand-rolling the deadline-check-and-yield logic yourself.

WASM threading is real now, on a second, opt-in target: wasm32-wasip1 (this project's default WASM target) still has no real OS-thread support, so fibers — and therefore budgeted — still return a clear runtime error there. But wasm32-wasip1-threads (nightly toolchain, -C target-feature=+atomics,+bulk-memory,+mutable-globals) genuinely runs them, both under wasmtime and in a real browser. Getting it working in-browser meant hand-rolling the WASI threads proposal's ABI in JavaScript — no wasm-bindgen — since a WASM program's execution is one continuous synchronous call and the calling thread only ever yields at real blocking waits (Atomics.wait, compiled from this same fiber design's Condvar::wait()), which don't drain the JS microtask queue. That rules out spawning a new Worker synchronously from inside a blocking call (verified directly: it deadlocks), so the shim pre-warms a fixed, generous pool of idle workers (64) before the program starts and hands off to one already booted, rather than growing the pool on demand — a real, load-bearing architectural constraint, not an implementation shortcut: a genuinely unbounded synchronous burst of fiber creation can still exceed the pool (verified with a 20-fiber burst test), though realistic PatLang fiber/budgeted usage comfortably fits under it. See the fiber demo page for a real "run in browser" button using this, and Capabilities & Honest Limitations for the current split between the two WASM targets.

Self-Hosting

The lexer, parser, lowerer, and code generator (self_hosting/lib/{lexer,parser,lower,codegen}.patlang) are themselves written in PatLang, compiled once via rustc into patc1.exe, and then used to compile all further ordinary PatLang programs, including further versions of themselves. patc1.exe is fixpoint-verified: recompiling its own source through itself produces byte-identical Rust output. This isn't a novelty demo — it's the actual, current compilation path for everyday PatLang development (see Idioms & Patterns for the self-hosted-mirror-with-parity-test pattern this relies on).

Reach for this when you're extending the compiler itself, or when you simply want to compile a program — patc1.exe is the ordinary tool for that, not a special case.

See also