pub Rule, Cut in Parallel Only Where a Checked Frame Says So, Lowered Name for Name, and Its Actions Neither Pause Nor Fail Without a NameStatus: Accepted
Date: October 3, 2026
History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached
Answers: what the core parser is, and how foreign syntax ends and binds its
holes; where a format assumption may be written so that a parse can
be cut and run on several cores; what the lowering of a grammar onto
the parser backend is and refuses to do; how a program enters a
grammar, and which rule it enters through; whether a fold’s lambdas
are checked (#237);
what a grammar may write, from one page
(#202,
language-review.md §3.6); whether an action may pause;
what a parse fails with
Related: ADR-001 D2 and D4 (the parser backend; Stage 0 is a
transpiler) · ADR-005 D4, ADR-283 D11 (the tether a
view takes; content equality on views) · ADR-297 (cleanup always
runs, the rule D15 relaxes) · ADR-010 (the hasher, chosen by
provenance) · ADR-300 (errors reported against the .nika line)
· ADR-013 (the fold adapter D17 left to a typing pass) ·
ADR-014 (the mapping and the parallel driver) ·
ADR-023 (the grammar’s bindings and its commit-point failure) ·
ADR-037 D1 (a
switch selects a build, not a dialect) · ADR-292 and Part II 12.6
(the other places sync is demanded) · ADR-310
(asset("…"), what a build may read) · ADR-276 D16, D18 (the jump
walk D31 replaced, and the lowering’s backstop that stays) ·
ADR-133 D3 (an options-only call writes no ;) ·
ADR-140 D3 (a path is ::) · ADR-177 (a grammar
runs at build time by compiling the parser it generates) ·
ADR-186 (what an entry keeps) · ADR-280 (how a
failure travels; #179)
· ADR-309 (an f"…"’s holes, which are the language’s own
grammar and not this protocol)
What is a grammar in Nikaia - and what does the language promise about how
it reads its input, how a program enters it, when its parse may run on several
cores, how it reaches the language below, and what its actions may do?
One record, because every answer leans on the first: the core parser keeps no global token stream, so foreign syntax can be embedded (D1). That makes a grammar a construct of the language rather than a library, so its entry is an ordinary call (D24), its actions are ordinary code checked like any other (D28, D35), and what it fails with is an ordinary named type (D38). And because a grammar is already the file’s specification, it is the one place a format assumption may be written so that a parse can be cut blindly and run in parallel (D9-D13).
Without the answers the compiler cannot verify a frame, cannot know which rule a call enters, cannot tell a program what a parse threw, and leaves to the backend’s error - against a file nobody wrote - every defect in an action.
| Language / tool | Its answer | Source |
|---|---|---|
winnow-grammar (the backend) |
parses &str/&[u8], no token stream; uppercase rules lexical, lowercase syntactic; => the cut; recover(rule, sync); one function per public rule, no main rule; action blocks required, no auto-AST |
D25; ADR-001 D2 |
| ANTLR | uppercase names are lexer rules | D33’s neighbours, §6 |
| pest | a WHITESPACE rule steers implicit skipping; actions live outside the grammar |
D22, §6 |
| Ohm | semantic actions kept outside the grammar | D33 |
| The fast reference implementations of the one-billion-rows workload | split the buffer blindly, repair the seam; find the separator a word at a time; hash a key from its first bytes, and some also compare only the first 8-16 bytes (unsound) | D11, D12, D14 |
Rust asm! |
register constraints in the core language - what D6 removes, because WebAssembly is a stack machine | D6 |
What transfers: the backend’s model is adopted as the language’s (D1, D25),
and the case convention, WS and the cut stay with their precedent (D33’s
neighbours, §6). What does not (as a construct of the language; a program may
read words and scan by hand, ADR-320 D12): hand-written SWAR in user code, which is
unreadable and unportable - the grammar states the assumption and the compiler
exploits it (D12, D13).
| Option | Why it lost | See |
|---|---|---|
| A global lexer | cannot tokenize embedded languages whose delimiters conflict (JSON’s }, Rust’s 'a) |
D1 |
| A configurable or inferred DSL terminator | reintroduces the delimiter ambiguity scannerless parsing removes | D2 |
unsafe asm in the core with a WASM special case |
a second, register-free syntax: a DSL with extra steps and a permanent fork in the core | D6 |
| One binding mechanism for holes | scope binding is right for assembly and destroys the reuse of a prepared statement | D4 |
sync rule as the frame marker |
sync already has two meanings |
D9 |
Rewriting until(";") reached from a frame into until ; or newline |
the same rule text would parse differently depending on what reached it | D9 |
split_aligned(n, "\n") by hand |
asks the user to restate what the grammar knows and to write the pipeline that follows | D10 |
Parallelising any associative-looking fold |
a merge over f64 is not associative; output would depend on the core count |
D10 |
| Inferring the boundary without a marker | an unrelated edit would silently stop a grammar being parallel | D9 |
| Comparing only a key’s first bytes | two keys sharing a prefix merge silently | D14 |
| A user-facing SWAR/SIMD construct | hardware instructions live behind a backend DSL (D6); a library’s portable lanes and safe instruction functions are ADR-371’s | D12 |
| The grammar body kept as text | answers none of the questions the lowering asks | D16 |
| A second frame check in Nikaia | two verdicts drift; the provenance fix is spans | D18 |
| Inferring accumulator threading from a step’s shape | acc.merged(m) has the shape of acc.record(m) and would be mis-lowered |
D17 |
| A switch that changes more than the executor | two settings that mean different things are two languages | D19 |
dsl G from e kept; a distinguished entry rule |
two namespaces under one keyword; the emitter’s first pub rule pick chose wrong silently |
D25 |
A parse alias |
a name that appears and disappears with its neighbours | D26 |
| A second walk over a fold’s lambdas | two walks over one construct are two chances to disagree, and the old one gave wrong advice | D31 |
token NAME, a skip declaration, a change to => or # "…" |
each convention has precedent | §6 |
| Fields on the parse error now | nobody has asked; the smaller answer can be taken back | D39 |
The core parser keeps no global token stream. A grammar consumes the raw
character stream, and whitespace handling is a property of the rule. The
consequence is accepted: Nikaia can never adopt a token-stream frontend without
breaking dsl.
} eod.The core parser cannot find the end of foreign syntax by counting braces, so it
scans for the marker, skips the body and parses the rest of the file while the
DSL parser works. A body that contains the literal } eod is read from a file
and handed to its grammar by a call (D24, D27), never by a configurable
terminator.
comptime, where the bytes come from by asset("…").comptime CONFIG: Json::Value = Json::value(asset("config.json"))
let data = Json::value(user_input)
let query = dsl mysql { … } eod
A parse has one spelling at either stage, and its position never decides its
meaning: the same call reads its argument’s text at run time, and at build time
only because comptime asks for it and asset names the file. A dsl block
is an expression. How a grammar runs while the program is built is
ADR-177; what a build may read is ADR-310.
meta::capture(id) is immediate, resolved from the surrounding scope at
compile time (assembly: $v = in(reg) val means this val).
meta::parameter(name, type) is deferred, a named runtime parameter (a
prepared statement, which baking values in would destroy). It is the same
immediate/deferred axis as lambda capture and teardown
(ADR-297 D5), and Part II 10.5 states it as a principle.
The :name holes of a body become a type the call site is checked against: a
missing name is NK1112, an unknown one NK1113, on the .nika line. The
parameters are configuration, passed as named arguments after the subject -
query.execute(db; target_age: min_age), or stm.execute(target_id: 501)
with no ; when there is no subject (ADR-133 D3) - and a driver
receives them with the typed spread ...args: Self::dsl, which keeps its ;
in the signature. Following D17, the field types come from the call site (the
struct is generic and monomorphised where it is built), there is one struct per
parameter list rather than per DSL string, Self::dsl also names the result,
and a hole is found by scanning, so SELECT ':id' is read as one until a
grammar’s meta::parameter says otherwise. The driver’s own half, args.values(), needs a value type for the
parameters and is not decided further here. Flagged as the least proven
decision of the protocol.
unsafe asm, its register-constraint table and clobber are removed.
Assembly is a library DSL, dsl x86::Asm { … } eod (ADR-299 D20), using D4’s immediate capture.
A core construct that cannot be given meaning on WebAssembly, a stack machine,
is a defect in the core. unsafe stays reserved for FFI.
Actions are required (no generated structs for labelled sequences); fixed built-in terminals meet the intent of regex terminals. Specification text promises neither.
Identifiers and keywords are interned Symbols; bulk text (string literals,
comments, doc text, DSL bodies) is a tethered slice
(ADR-005 D4, ADR-283 D11). The guarantee is stated
positively: the source buffer cannot be freed while a token points into it.
@frame declares a resynchronisation unit, and the compiler checks it; it never rewrites the grammar.rule NAME -> ref String = s:until(";" | frame_end) { s }
@frame(boundary: "\n")
rule MEASUREMENT -> Reading = name:NAME ";" => temp:TENTHS frame_end { Reading { name, temp } }
A frame can be found from any offset by scanning to its boundary. The frontend
walks everything the rule reaches; safe: a literal without the boundary, a
built-in whose alphabet excludes it, lookahead, an until(t) whose terminator
covers the boundary (frame_end, the boundary literal or a prefix of it,
line_ending for a newline); rejected, naming rule and pattern: a literal
containing it, a built-in that can consume it (any, multispace0), the
implicit whitespace of a syntactic rule, an until that does not cover it
(the message says until(… | frame_end)), and recover(…) - recover per
frame instead, (item | until(frame_end)) frame_end. frame_end names the
boundary of the frame the rule is reached from and resolves statically. The
attribute is keyed - @frame, @frame(boundary: "\n"),
@frame(boundary: "\n", unchecked) - and unchecked is the greppable
assertion for formats a byte string cannot cut (quotes, start markers,
escapes), each a future key. A frame ends in its boundary. A syntactic
(lowercase) rule the frame reaches skips whitespace before each element, so it
is refused unless the grammar declares its own WS, which is then checked as
part of what the frame reaches. The par_fold rule that folds the frames
skips no whitespace whatever its name - not at its entry, not at its start,
not between frames - so whitespace between frames is not a frame and fails at
any chunk count (D10).
par_fold, written, never inferred.par_fold(ITEM, init, step, merge) is fold plus a merge, over an @frame
item, and the whole body of its rule; the split, seam repair, per-chunk
accumulators and merge are generated, and the program writes
Measurements::file(data). A written par_fold says results from different
chunk counts are the same to me. Chunk count changes neither what is accepted
nor what is rejected: a par_fold rule is lexical in all but name (D9). At user_parallelism = no it is a sequential fold - same accumulator,
same merge, same result - which keeps it compiling for wasm32.
n equal byte ranges (n = cpu::count() by default); every chunk but the
first advances past the first boundary at or after its start, so each frame is
owned by exactly one chunk, the one that finds its end; the last runs to EOF,
an unterminated last frame is still yielded and no empty trailing frame is; a
frame longer than a chunk is not an error; errors carry absolute offsets, and
under recover one chunk’s failure does not abort the others; chunk count
does not change results and is overridable for benchmarking. The driver takes
Parallelism::Off | Pieces(n) | Auto, Off identical to the plain parser, and
the cut and merge are public so the pieces can run on Nikaia’s own pool.
Changed by ADR-372 D4-D5: pieces are claimed while the work runs, not cut
into n ranges first; cpu::count() is task::cores(); order: decides
how accumulators are kept.
A boundary or until terminator is found by a portable word-at-a-time (SWAR)
scan - no target feature, works on wasm32; SIMD is a layer on top, never the
baseline. Up to three fixed alternatives are one pass. The same primitive
serves recover’s skip.
p{n,m} (as in digit{1,2}) is the only route by which a fixed-width fast
path may be emitted; digit+ says unbounded, and a compiler that assumed a
width would be inventing a constraint.
Hashing a view key from its first 8 bytes mixed with its length is sound; comparing only a prefix on a probe hit is not. Equality on views is content equality (ADR-283 D11). Which hash is used is chosen by provenance (ADR-010), never at the cost of this property.
A read-only mapping with no Cleanup and nothing observable attached may be
left to the kernel at exit - the one relaxation of “cleanup always runs”
(ADR-297), by proof of non-observability, not by a flag. It is
an allowance; ADR-014 records that it is not taken.
grammar Name { … } becomes a structure: rules, alternatives, patterns, the
commit point, repetition bounds, fold/par_fold with their expressions, and
the keyed @frame. Which rule a call enters, whether it is a par_fold, and
whether the grammar declares a frame are answered from the rules.
Changed by ADR-013 D2: a step that calls a &mut self method returning nothing has its accumulator threaded by the emitter, found by a lookup in the program’s declarations rather than guessed from the step’s shape.
Every action, every init/step/merge and every body is emitted as
written, name for name. Where Nikaia and Rust disagree about a name, the
emitted program fails to compile rather than the printer inventing semantics.
A step fn(acc, m) { acc.record(m) } with a &mut self method is not
threaded from the shape of its tail; that adapter is a typing question.
@frame lowers key for key, and Nikaia does not re-implement its check.@frame(boundary: "\n") becomes #[frame(boundary = "\n")], unchecked
passes through, and the boundary keeps its escape text. The check that a
boundary cannot occur inside a frame lives once, in the backend: two
implementations would drift, and the harmful drift is Nikaia accepting what
the backend rejects.
user_parallelism chooses the Parallelism, and nothing else.A call to a par_fold rule lowers to the piece driver with Parallelism::Auto
or, at no, the identical call with Parallelism::Off; the two programs
differ in one token. A switch selects a build, not a dialect
(ADR-037 D1). A rule that is not a par_fold gets its own
parser, run once over the whole input.
par_fold gets its binding from the compiler; every other alternative carries an action.A par_fold is the whole body of its rule, so its value is the rule’s and
nothing is written after it. Everywhere else an action is required, and the
pattern ends where its action block begins (D33).
A view in a rule’s result type is emitted with the backend input’s lifetime; a
struct with a view field carries the lifetime wherever it is named, named
inside the grammar module and elided in a function signature. Every view is
emitted as Borrowed; the tether-state lattice is not implemented here - a
named limitation.
WS = (WSE | COMMENT)* is where comment skipping is said. The compiler brings
its own lexical STRING, which keeps escapes as written, because the
backend’s string does not know \n - and keeping them is what makes D18’s
“the backend is handed the same "\n"” true.
The lowering emits grammar! { … }, name for name with the .nika file. A
grammar item is never dropped silently.
let data = Json::value(input)
Json is the grammar, value an entry rule in it, reached as every qualified
name is, by a path (ADR-140 D3; Json.value(x) is NK1147). A
call gets ?, catch, being passed on and being chained for free. dsl X
from e is refused at the parse with the call in the message; dsl takes a
driver name and only that.
entry rule is an entry, the compiler distinguishes none, and pub belongs to the grammar.Changed by ADR-299 D21: the author may mark one entry main.
As the backend does: one function per entry. The compiler picks no rule as the
grammar’s main one - such a pick chooses wrong silently - so there is no
“grammar has no entry to enter through” to refuse. The grammar’s author may
say which one is main (main entry rule, ADR-299 D21); that is a decision
written down, not a pick. Naming a rule that is not
an entry has its own message.
Whether a rule is an entry and whether other packages may use it are two
questions, and each has its word. entry is a decision about the rule:
it was written to be entered on its own - a helper such as 1BRC’s NAME,
whose frame_end means something only inside a frame, was not. So an entry is
never inferred from a call, which would make one out of a typo. pub is a
contract with other packages, and a grammar’s contract is all its entries:
pub grammar offers every one of them, as a pub enum offers its variants,
and a rule is never pub on its own - pub rule is refused with both words in
the message. A grammar is entered only from its own file today, so pub
grammar is decided and not built, and refused as such.
parse alias.A program that wants Json::parse(x) names its rule parse.
asset("…").comptime ROUTES = Routes::table(asset("routes.txt")): comptime says when,
asset where the bytes come from (ADR-310), the call what is done with them.
init, step and merge are walked by the ordinary expression walk.They are expressions inside a pattern; they get every rule the language has -
NK1117, NK1132, NK1128, and whatever comes next.
A binding beside the fold in the same sequence is in scope in its lambdas.
expected.The rule’s declared type is what an action builds; a fold’s init and step
build the backend’s running value, and holding them to the declared type would
refuse the arithmetic every fold is made of.
A jump in a fold’s step meets NK1132 through the lambda path every other
lambda takes - the nearest loop is outside this lambda - and no second walk
of the fold’s own exists beside it. ADR-276 D18’s backstop in the
lowering stays. Measured: nothing in the corpus is newly refused.
An element not on the page is not in the language; a built-in the engine
offers that the page does not name is unreachable from a .nika grammar.
->; a brace after a pattern is a bound.rule pair -> Pair = key:string ":" => val:value -> { Pair { key, val } }. The
first -> is the result type, the one before each block its action. A brace
after a pattern is a bound (digit{1}, digit { 1 } alike), and a brace in the
input is the literal "{". The form without the arrow is refused by name, with
the form to write in the message.
Changed by ADR-grammar-1 D1.
tag and digit1 are not spelled."x" and digit+; either engine name is NK1187, carrying the spelling. The
emitter keeps writing the engine’s own spelling.
sync is asked of every call an action makes and of every call a fold’s
init, step and merge make; a call that can pause is NK2209, naming the
rule. A parse that can be cut and run on several cores (D10) is one whose
steps do not wait on the world. Refused only where the ledger answered, as
every sync rule is.
sync, and says so in the ledger.[fn."Nums::number"]
sync = "asserted"
throws = ["ParseError"]
asserted because it is a rule of the language, not an inference; the entry
is a leaf of the sync fixpoint that holds, so a function whose body is one
call to it is sync again.
An action may fail, may call anything that does not pause, and a grammar may be entered from a body that pauses; what is refused is a pause inside the parse.
ParseError, and every entry says so.throws = ["ParseError"]. The type is nikaia-std’s, in the prelude, keyed
in std.contracts with no module in front, as Overtaken is. The lowering
wraps the backend’s rendered message, so a program prints exactly what it did
before; what changes is the channel - with a second named member a caller’s
set is a sum a program can match on (ADR-280).
Fields a program could read - line, column, expectation - are a second question nobody has asked.
throws graph does not hold still has a set.A grammar entry is not an Item::Fn, so the fixpoint reads its set from the
ledger; otherwise what a parse throws would reach no caller.
#[inline(always)]. (The first case of ADR-370 D2.)The step is the loop body over the input, so it runs once per item, and what
it calls usually has that one call site. LLVM left 1BRC’s Summary::record out
of line all the same, because hashbrown’s insert path makes it too big for its
heuristic; no profile setting changed that (16 or 1 codegen unit, thin LTO:
319.1 instructions a row; fat LTO 317.0). With the hint: 300.1, the same output
(#433). So a function
or method a fold’s or a par_fold’s step calls is written #[inline], found
by name, because the step’s receiver is the accumulator, whose type this
emitter does not know (D17). Only here: every function inlined would bloat
the code and the build.
#[inline] alone was not enough: with rustc 1.97 whether LLVM honoured it
depended on how the codegen units were cut. At one unit record was inlined
but hashbrown’s entry stayed a call; at sixteen the other way round, 1BRC
274 against 231 instructions a row (#695).
So the hint is #[inline(always)]: the callee has one call site, the loop’s,
and no heuristic of the backend’s decides it.
std’s own type over hashbrown’s table; text keys are hashed and compared a word at a time.Every Nikaia map lowers to one type of std’s own, built on
hashbrown::HashTable, with the hash and the equality std chooses per key
type. For a key that is text (a &str view or an owned String) both read the
key a word at a time through its pointer: the hash from its first one or two
words and its length, the equality word by word and then the tail, with no call
of memcmp. Which key type it is, is known when compiling (a type-id test the
optimiser removes), so one type serves every key and a map handed to a
generic function stays the same Rust type. Keys are not copied into the slot:
keys(), drain and a Map[ref String, V] hand back views that outlive the
map, and counting instructions shows no gain from an inline copy. A map’s
order is not promised (Part I), so the order a different hash gives is no
change of meaning.
In 1BRC’s plain program the name’s memcmp cost 31.5 instructions a row and
its hash 27.1 (#695’s attribution): Rust’s &str == always calls memcmp,
which hashbrown’s map with a &str key cannot avoid.
Rejected: a second map type only for text keys (a Map[String, V] given to a
generic function would be a different Rust type, and rustc would refuse it);
keys stored inline (the views above cannot be handed back).
Decided by the supervising session on the owner’s direction (October 20, 2026: decide what is needed for the goal).
comptime), 10.5 (dsl, holes, the shadow
type), 10.6 (zero-copy, recovery, failure names), 10.7 (frames and
par_fold), 10.8 (the vocabulary); Part III chapter 16 (assembly as a DSL).touches and locks are derived over its grammar’s
actions, and what it keeps is ADR-186..nika line by
ADR-300 and worded in Rust’s words - bearable because the
lowering is name for name.} eod must come from a file (D2); views emitted as plain borrows (D21);
NK2209 and NK1112/NK1113 only where the ledger or the scan can answer
(D35, D5).args.values(), a value type for the parameters,
and SqlAst[Signature = {…}] as a spelling.cpu::count() (NUMA, heterogeneous cores, a
container’s CPU quota), and par_fold over a stream rather than a mapped
buffer.@frame keys (quote, start, escape, scan) and inferring a
boundary without a marker.skip declaration in place of
WS, any change to => and # "…"; and the engine built-ins 10.8 leaves
out (hex_digit1, oct_digit1, binary_digit1, space0, space1,
count, lex, spaced), each reachable the day a line is added to the
page.async - the backend’s question first.ParseError (D39), and where an entry’s failure is still a
rendered string (#179).