Architecture
June 23, 2026 · View on GitHub
Nelumbo is built in layers. Reading a single .nl file can make it feel like one monolithic language, but the reality is more interesting: most of what looks like "Nelumbo" is actually the standard library, which is itself written in Nelumbo on top of a small Java core. Understanding the layers makes it clearer where your code fits, where the stdlib fits, and where to extend the system.
The layers
┌──────────────────────────────────────────────────┐
│ User programs and tests │ .nl files
│ family.nl, fibonacci.nl, your DSL, your rules │
├──────────────────────────────────────────────────┤
│ User-written libraries │ .nl files
│ your reusable modules and transformations │
├──────────────────────────────────────────────────┤
│ Numeric / data stdlib modules │ .nl files
│ integers, rationals, strings, collections │ in src/main/resources/
├──────────────────────────────────────────────────┤
│ nelumbo.logic — three-valued logic │ .nl file
│ Boolean, !, &, |, ->, <->, =, !=, E[], A[], │
│ plus the top-level forms `fact`, `<=>`, `?` │
├──────────────────────────────────────────────────┤
│ nelumbo.lang — syntactic bootstrap │ .nl file
│ token types, Object hierarchy, the pattern │
│ meta-grammar, `import`, `::`, `::=`, `::>`, │
│ variable / type / functor declarations │
├──────────────────────────────────────────────────┤
│ Nelumbo Java core │ .java files
│ tokenizer, hardcoded bootstrap parser for │
│ lang.nl, reasoner, binder, @-bound natives │
└──────────────────────────────────────────────────┘
Each layer is built out of the layer below, and each layer is accessible to layers above. The diagram is not aspirational; it is literally how the shipped code is organised.
The split between nelumbo.lang and nelumbo.logic is important. The entire syntax of Nelumbo is defined in .nl files — lang.nl declares the grammar of patterns, types, variables, and top-level statements; logic.nl declares the three-valued Boolean layer plus the fact, <=>, and ? statements that drive execution. The Java core contains just enough hardcoded parsing to load lang.nl; from that point on, every file (including lang.nl itself, on a second pass) is parsed using the ::= patterns the loaded files have installed.
Layer by layer
The Java core
At the bottom is a Java codebase in src/main/java/org/modelingvalue/nelumbo/. It does four jobs:
- Tokenize and bootstrap-parse
lang.nl. The tokenizer is in Java. The parser used to readlang.nlis a hand-coded bootstrap — it knows just enough about::,::=,<NAME>, and the token types to loadlang.nl. Every subsequent file (including a re-read oflang.nlitself) is parsed using the::=declarations installed by the loaded files. - Resolve names, apply visibility and scope rules, and load imported modules.
- Run the reasoner — the navigator that produces candidate bindings, together with the three-valued logic that classifies them as facts, falsehoods, or unknown.
- Host native primitives — Java classes referenced by
@...annotations from.nlsource, providing operations the language cannot derive from itself.
The Java core is small and focused. It deliberately does not know about integers, rationals, strings, or collections, and not even about Boolean, <=>, ?, or fact. Those are all introduced from .nl files.
The standard library (nelumbo.* modules)
Six .nl files under src/main/resources/org/modelingvalue/nelumbo/ collectively form the stdlib:
lang/lang.nl— the syntactic bootstrap: tokens, theObject/Type/Variable/Root/Pattern/Namespace/Functorhierarchy, the pattern meta-grammar (<T>,<(>...<)+>,<(>...<)?>, …), and the top-level formsimport,::,::=,::>.logic/logic.nl— Boolean values, connectives (!,&,|,->,<->), quantifiers (E[],A[]), equality (=,!=), and the three execution-driving statement formsfact,<=>,?.integers/integers.nl— arbitrary-precision integers, arithmetic, comparisonrationals/rationals.nl— exact rationals built on integersstrings/strings.nl— strings and integer-string conversioncollections/collections.nl— genericSet<E>andList<E>
These files are ordinary Nelumbo. They use import, ::, ::=, <=>, ::>, and private exactly the way your code does. What sets them apart is that they bind certain patterns to Java classes using @:
private Boolean ::= add(<Integer>, <Integer>, <Integer>) @org.modelingvalue.nelumbo.integers.Integers
What this means in practice:
- The language's syntax lives in
lang.nl. The hand-coded Java bootstrap exists only to load that file — oncelang.nlis in place, the same::=declarations the user writes are what parse the rest. - The three-valued logic lives in
logic.nl. That includes<=>itself: rules are a statement form declared inlogic.nl, not a Java keyword. Withoutnelumbo.logic, a.nlfile can declare types and patterns but cannot write rules, assert facts, or run queries. - Most of what looks like language features —
->,<->,-as unary,|x|,<=,int(s),str(i)— is defined in Nelumbo, not native. Only a handful of genuinely irreducible primitives (the tokenizer,add,mult, integer comparisongt, string concat, and so on) are implemented in Java. - You can study the stdlib to learn idiom. It is around 300 lines of Nelumbo across seven files, and it demonstrates almost every feature of the language.
User-written libraries
Nothing prevents your own code from living at the library layer. Any .nl file you write can be import-ed by other .nl files — see the whoIs.nl example, which imports org.modelingvalue.nelumbo.examples.friends. Modules of your own are a full-fledged way to organise reusable rules, types, and DSL constructs.
There is no formal distinction between "standard library" and "user library" apart from location. A stdlib module is one that ships in Nelumbo's own source tree under nelumbo.*; a user library is one you write and distribute separately.
User programs and tests
At the top are the .nl files in your own project — the ones that import the modules you need, define your own types and rules, and run queries or tests against them. The Fibonacci, family, friends, tax (belasting.nl), and attribute examples all live here.
A user program is not structurally different from a library; the difference is intent. A library is meant to be imported; a program is meant to be run. Both are the same kind of file.
Two extension paths
Given the layering, there are two distinct ways to extend Nelumbo:
In-language extension — another .nl file
You write a new .nl file, declare types, patterns, rules, and optionally transformations. Import it from your program (or have the program import a module that imports yours, transitively). The new code lives at the library or program layer; no Java is involved.
This is the right choice for:
- Domain rules (tax law, family relations, custom DSLs)
- New operators defined in terms of existing operators (
->and<->inlogic.nlare built this way) - Language transformations (
::>expansions that introduce new keywords) - Generic abstractions using
Type E
Host-language extension — a new Java native
You write a Java class extending Predicate, Function, or Node (depending on what you're defining), implement its infer method, and bind it from an .nl file with @. The new code lives at the Java core layer.
This is the right choice for:
- Primitive operations the language genuinely cannot derive from itself (arithmetic, string manipulation, I/O)
- Performance-critical paths where a rule-based implementation is too slow
- Integration with external Java libraries
The stdlib itself follows a clear discipline on this: the minimum possible is native, and everything else is in Nelumbo. add and mult are native; subtraction, division, unary minus, and absolute value are in Nelumbo. > is native; <, <=, >= are in Nelumbo. NBoolean, And, Or, Not, the equality natives NIs and Equal, E[], and A[] are native; ->, <->, and != are in Nelumbo.
When in doubt, reach for the in-language path first. Drop to Java only when the in-language path cannot express what you need.
A concrete walk through one line
Consider the familiar Fibonacci test:
fib(5) = f ? [(f=5)][..]
What actually happens when this runs:
- Parser (Java + loaded
.nldeclarations) tokenises the line. The tokenizer is Java; the actual grammar (?,[..][..],=,fib(<Integer>), the<NUMBER>5) is recognised using pattern declarations loaded fromlang.nl,logic.nl,integers.nl, and your own file. - Name resolver (Java) ties
fto its variable declaration (Integer n, f),fibto your pattern declaration,=to the nativeNIspredicate declared inlogic.nl. - Reasoner (Java) starts with the query
fib(5) = f. Because=is a relation, the reasoner treats it as an equation to solve. - Your rule in Nelumbo fires:
fib(n) = f <=> f = fib(n-1) + fib(n-2) if n > 1. The reasoner recursively evaluatesfib(4)andfib(3). - Stdlib rule in Nelumbo (
integers.nl):a + b = c <=> add(a, b, c). The reasoner turns each addition into a call to the privateaddrelation. - Native
Add(Java): with two arguments bound and one unbound (the sum), it returnsset(2, NInteger.of(...)).factCI()— a fact, facts side closed, falsehoods side open. - Result bubbles back up through all the rule invocations, unifying
f = 5at the top. - Test comparison (Java) runs the expected-result parser on
[(f=5)][..], compares it to the actual result, and emits pass or fail.
Four layers touched: your file, stdlib integers, stdlib logic, Java native. Every layer does its own job; the @ annotations are the only bridge between Nelumbo and Java.
Why this matters for documentation
The layered architecture shapes the rest of the docs:
- Reference pages document the Java-core concepts and the syntax of the language itself.
- Stdlib pages document each module as a library: what it exports, how it is built, which pieces are native vs. Nelumbo-defined.
- Guides cover the two extension paths as separate topics: in-language (rules, transformations, modules) and host-language (the native cookbook).
- Tutorials teach reading a
.nlfile, working outward from the user-program layer.
When you are looking for how to do something, ask: which layer is the right one? A rule lives in your program; a custom operator usually lives in a module; a genuinely new primitive lives in Java. The layers are the map.
See also
../reference/grammar.md— what the core grammar is../reference/stdlib/logic.md— the foundation stdlib module../guides/stdlib-tour.md— guided read-through of all seven stdlib modules../guides/writing-your-own-module.md— the in-language extension path../guides/native-cookbook.md— the host-language extension path../reference/native-classes.md— catalogue of every shipped native