BOVNAR.bvnr

Bovnar Specification

Bovnar (BVNR) v1.1 documentation · Also available as Markdown and PDF.

Version: 1.1 Status: Released (1.x line; additive over the frozen 1.0 baseline) Last updated: 2026-06-21


Table of Contents

  1. Overview
  2. File Format at a Glance
  3. Character Encoding & BOM
  4. Lexical Structure
  5. Type Annotations
  6. Value Tokens
  7. Arrays
  8. Structs (Scopes)
  9. Octet Streams (Binary Mode)
  10. Default Type Synthesis
  11. Units System
  12. Validation & Constraints
  13. Error Handling & Recovery
  14. Formal EBNF
  15. Complete Examples
  16. Reference API
  17. Versioning & Stability - Appendix A: Event Sequence Reference - Appendix B: Implementation Notes - Appendix C: Limits Summary

1. Overview

Bovnar (BVNR) is unit-safe serialization for scientific and industrial systems: a typed, self-describing, text-binary hybrid format that carries a validated physical unit with every value. It combines a human-readable text layer with an efficient binary octet-stream escape mechanism and a rich, unit-aware type annotation system.

Design Goals

Architecture

The reference implementation is a two-phase parser:

Phase Callback Purpose
Unverified (on_unverified) Raw token events before semantic validation Inspection, logging, partial consumption
Verified (on_verified) Fully validated events Actual data consumption

Both phases receive the same stream of events (bvnr_event_t). The validator sits between them.

Stream Model

stream_begin →
  (assignment)* →
  EOF

Each assignment is:

.identifier = value;

2. File Format at a Glance

# This is a comment

.my_first_value = 42;
.my_next_value = 3.14159;
.a_string = "hello world";
.a_typed_value = <uint:32> 1000;
.a_utf8_value = <utf8> "text";
.an_array = [1, 2, 3]/[4, 5, 6];
.a_struct = {
    .nested = true;
};

Key Syntax Rules

Construct Syntax Example
Comment # … newline # this is a comment
Assignment .key = value ; .foo = 42;
Type annotation <type-spec> placed after =, before the value .foo = <uint:32> 42;
Number [-]digits[.digits][e[+/-]digits] 42, -3.14, 1e6
Special number nan, inf, ninf .x = inf;
Boolean true / false / on / off .b = true;, .b = off;
String "…" with escapes (\u{…}/\xHH in spec 1.1) .s = "hello\nworld";, .s = "caf\u{e9}";
Symbol bare identifier (no quotes) .s = ok;, .day = Monday;
Reference &.path.to.key; array indexing &.path[i][j] (spec 1.1) .ref = &.config.host;, .c = &.m[0][1];
Array [ … ] rows separated by / .a = [1,2,3]/[4,5,6];
Struct { … } .s = {.x = 1; .y = 2;};
Octet stream \x00 … binary … \x00 binary escape
Null value absent token or null keyword .x = ;, .x = null;, [,1,]
Version directive (spec 1.1) #!bovnar <major>.<minor> on the first line #!bovnar 1.1
Datetime (spec 1.1) <datetime:width,epoch> — signed epoch seconds .t = <datetime:64,unix> 1750000000;
Unit (type annotation) inside <…> as a type param <float:64,m/s>
Unit (inline suffix) after scalar value, before ; .speed = 9.81 m/s;
Fixed-point type <float_fix:[width,]qN[,unit]> <float_fix:32,q16>, <float_fix:q8>
Decimal float type <float_dec:width[,unit]> <float_dec:64,Pa>
Unit (compound) unit[*·/unit…] m/s², k~g·m/s², m*s

3. Character Encoding & BOM

3.1 UTF-8

All text-layer bytes (outside octet-stream regions) must form valid UTF-8. The parser runs a parallel UTF-8 validator (bvn_utf8_feed) alongside the state machine. Violations produce error_invalid_utf8_byte.

Overlong sequences and surrogate halves (U+D800–U+DFFF) are rejected.

3.2 Byte Order Mark (BOM)

The UTF-8 BOM EF BB BF is only legal at byte offset 0 of a stream.

The lexer runs a dedicated first_comment_* state machine over the first comment line to detect misplaced BOMs:

# BOM at byte offset 0: valid
EF BB BF.foo = 1;

# BOM bytes inside the first comment → error_invalid_byte_order_mark
# comment text: # EF BB BF text

# BOM bytes after the first comment line → error_unexpected_input_byte
# comment line
EF BB BF.foo = 1;

A BOM appearing inside any later comment or string is valid UTF-8 and accepted (only the first_comment_* states guard against BOM).

3.3 Byte Classes

Class Bytes Usage
Whitespace 0x09 (HT), 0x0A (LF), 0x0B (VT), 0x0C (FF), 0x0D (CR), 0x20 (SP) Token separators
Control (rejected) 0x00–0x08, 0x0E–0x1F, 0x7F (DEL) Hard errors outside strings
Safe ASCII 0x20–0x7E except reserved punctuation Identifier/symbol/reference bodies
High bytes 0x80–0xFF UTF-8 multi-byte sequences

3.4 Version Directive (spec 1.1)

A document may declare the spec version it targets with a directive on its very first line:

#!bovnar 1.1
.x = 42;

An unversioned document is spec 1.0. A document with no directive is treated as targeting spec 1.0 — the frozen baseline grammar. The directive only ever opts in to a newer version; its absence is never ambiguous. Consequently, any syntax introduced after 1.0 (a future minor revision) is available only to a document that declares the matching version: a 1.1-only construct in an unversioned (i.e. 1.0) document is invalid, exactly as it would be to a 1.0 reader. bvnr_reader_get_declared_version returns false for such a document (there is nothing declared), and a consumer that wants a concrete number should default to 1.0.

Form. # !bovnar followed by one or more spaces/tabs, a <major>.<minor> decimal version, optional trailing whitespace, then end-of-line (LF/CR) or EOF. Each component is a decimal integer with no leading zero (a bare 0 excepted) and must fit in 16 bits.

Backward compatible by construction. Lexically the directive is a comment (#…), so a spec-1.0 reader skips it transparently — declaring a version never breaks an older parser. This is the one place where a comment carries meaning; everywhere else comments remain semantically inert.

Recognition rules.

Enforcement. A 1.1+ reader records the declared version and exposes it (bvnr_reader_get_declared_version). By default a version newer than the reader supports is accepted leniently — recorded, but not rejected up front; the parse only fails if it later meets syntax the reader does not implement. A reader opened with strict_version instead rejects any unsupported version (error_unsupported_spec_version) at the directive. A reader supports spec <major>.<minor> when major equals its own and minor is ≤ its own (BVNR_SPEC_VERSION_*).

#!bovnar 1.1        # current — accepted
#!bovnar 1.0        # older minor — accepted
#!bovnar 1.9        # newer minor — lenient: accepted; strict: error
#!bovnar 2.0        # newer major — lenient: accepted; strict: error
#!bovnar 1          # malformed (no minor) — error_invalid_spec_version

4. Lexical Structure

4.1 Whitespace & Comments

Whitespace and comments are freely interleaved between all tokens.

Whitespace characters:

HT (0x09), LF (0x0A), VT (0x0B), FF (0x0C), CR (0x0D), SP (0x20)

Comments:

# any bytes except 0x00–0x08, 0x0E–0x1F, 0x7F
# terminated by LF or CR (or EOF)
# A full-line comment
.foo = 42;  # an inline comment

4.2 Identifiers (Keys)

Every assignment begins with . followed by an identifier (the key).

Syntax:

id-start  = A–Z | a–z | "_" | UTF-8 leader bytes 0xC3–0xF4
id-body   = id-start | "+" | "-" | DIGIT | UTF-8 continuation bytes

Constraints:

Valid identifiers:

.foo = 1;
.FooBar = 2;
._private = 3;
.my-key = 4;           # hyphen allowed
.my+key = 5;           # plus allowed
.user_defined = 6;

Invalid identifiers:

. = 1;                 # error_empty_identifier
.123 = 2;              # starts with digit
.foo,bar = 3;          # comma inside identifier – error

4.3 String Literals

Syntax:

string-literal = '"' { safe-byte | escape-seq } '"'
string         = string-literal { ws string-literal }   # concatenation

Safe bytes: 0x09–0x0D (HT, LF, VT, FF, CR), 0x20–0x7E except " (0x22) and \ (0x5C), and 0x80–0xFF. 0x7F (DEL) is rejected even inside strings.

Control bytes 0x00–0x08, 0x0E–0x1F, and 0x7F (DEL) are hard errors inside strings. The whitespace bytes HT (0x09), LF (0x0A), VT (0x0B), FF (0x0C), and CR (0x0D) are accepted as raw string content.

Escape Sequences

Escape Meaning Byte
\t Horizontal Tab 0x09
\n Line Feed 0x0A
\v Vertical Tab 0x0B
\f Form Feed 0x0C
\r Carriage Return 0x0D
\" Double Quote 0x22
\\ Backslash 0x5C

In a spec 1.1 document (one declaring #!bovnar 1.1 or newer — see §3.4) two further escapes are available:

Escape Meaning
\xHH the single byte HH (exactly two hex digits)
\u{H…} the Unicode scalar value U+H… (1–6 hex digits), UTF-8 encoded

Any byte other than t, n, v, f, r, ", \ (plus x, u in 1.1) after \ causes error_illegal_escape_sequence.

String concatenation: Two or more adjacent string literals (separated only by whitespace/comments) are concatenated into a single token:

.long = "hello " "world";     # → "hello world"

The combined byte length must not exceed max_string_length (default 65535).

Examples

.simple = "hello";
.with_escapes = "tab:\there\nnewline";
.unicode = "café";            # UTF-8 bytes for é = 0xC3 0xA9
.empty = "";

4.4 Symbols

A symbol is an unquoted bare-word token that appears in value position. It starts with an id-start character and continues with id-body characters.

Differences from identifier keys:

Context , ] ; =
Identifier body hard error hard error hard error transitions to value
Symbol body terminates → new array element terminates → close array terminates → end value hard error
.status = ok;              # symbol "ok"
.day = Monday;             # symbol "Monday"
.flags = [red, green, blue];  # symbols as array elements

Reserved keywords. Eight exact bare words are not symbols: null, true, false, on, off, nan, inf, and ninf. Lexically they are still symbol tokens, but the validator reserves the exact spellings and reclassifies them — null to a null value, true/false/on/off to a bool value (ontrue, offfalse), and the special floats nan/inf/ninf to numeric special values (§6.4). A bare boolean with no annotation synthesises a <bool> type (§10); an explicit <bool> annotation accepts only the four bool keywords. A longer word that merely begins with a keyword (ontology, nullable, truthy, infinity) remains an ordinary symbol.

.enabled = true;           # bool value (vt_bool), not a symbol
.debug   = off;            # bool false
.choice  = <bool> on;      # explicit; on == true
.label   = truthy;         # symbol "truthy" — not a keyword

4.5 References

A reference is a dotted path to another key, introduced by &.

Syntax:

reference     = "&" ref-segment { ref-segment | index }
ref-segment   = "." id-start { ref-body-char }
ref-body-char = same as symbol-body-char | "."
index         = "[" digit { digit } "]"        (* spec 1.1 *)

The stored text includes the leading dot and all intermediate dots:

.host = "localhost";
.port = 8080;

.connection = &.host;             # stored as ".host"
.full = &.config.host;            # stored as ".config.host"

Examples:

.config = { .host = "example.com"; };  # nested struct
.ref = &.config.host;                  # reference → ".config.host"

Resolution. A reference is stored unresolved — as the path string only. The parser and library never dereference it, so:

A reference path is bounded by max_reference_length (error_reference_too_long otherwise). In an array, references are homogeneous by kind — an array of references is uniform regardless of what its targets resolve to (their target types are unknown to the parser).

Array indexing (spec 1.1). A reference path may address array elements with [N] index suffixes (&.matrix[0][1]). Like the rest of the path the index is captured verbatim and unresolved at the byte layer — the library never dereferences a reference, so bvn_dom_lookup on the reference node returns its stored path string, not the target. The index is interpreted only when an application itself resolves that path string against the tree by calling bvn_dom_lookup(doc, ".matrix[0][1]") directly (this is the same path-walker the CLI query command exposes — bovnar query .matrix[0][1] → the element). The index syntax is a 1.1 feature: in a 1.0/unversioned document a [ in a reference is error_unexpected_input_byte, exactly as a 1.0 reader reports.

Resolution semantics follow the array model (§7): a flat /-row matrix ([10,20,30]/[40,50,60]) is addressed as [row][col]&.matrix[0][1]20 — and a 1-D array as [i]; genuine nested arrays ([[1,2],[3,4]]) descend one index per level. A partial index of a flat matrix (&.matrix[0]), an out-of-range index, or indexing a non-array does not resolve (the application sees no node).

#!bovnar 1.1
.matrix  = [10, 20, 30]/[40, 50, 60];
.row0c1  = &.matrix[0][1];   # resolves to 20

4.6 Numbers

Bare Number Literals

number = ["-"] ( int-led | dot-led ) [ dec-exponent ]

int-led       = DIGIT { DIGIT } [ "." { DIGIT } ]
dot-led       = "." DIGIT { DIGIT }
dec-exponent  = ("e" | "E") [ "+" | "-" ] DIGIT { DIGIT }
.i = 42;
.neg = -17;
.float = 3.14;
.trailing = 123.;
.dot_led = .5;
.sci = 1e6;
.neg_sci = -2.5e-3;

Special Number Literals

special-number = "nan" | "inf" | "ninf"

The special IEEE-754 values are bare reserved keywordsnan, inf, and ninf (negative infinity) — with no sigil. Like null/true/false/on/off, they are lexed as symbols and reclassified to numeric special values by the validator; a bare word that is not one of these exact spellings (e.g. infinity, nans) stays an ordinary symbol. The stored token text is the keyword itself: nan, inf, ninf. A special-number keyword takes no inline unit suffix — supply a unit through the type annotation instead (<float:64,m/s> inf).

.not_a_number = nan;
.infinite = inf;
.neg_infinite = ninf;

4.7 Null Values

A null value is the absence of a raw-value token, or the reserved keyword null. It occurs when:

.null = ;                    # null value
.also = null;                # the null keyword — identical to the empty slot
.items = [,1, ,2,];          # null, 1, null, 2, null

When a type annotation precedes a null value, the null carries the annotated type:

.null_typed = <uint:32> ;
.nulls_in_array = [<uint:32>, <sint:64> , ];

5. Type Annotations

5.1 Syntax

type-annotation = "<" ws type-spec ws ">"

The type annotation must be placed in one of four positions:

  1. Immediately after the = sign of an assignment, before the value: .key = <uint:32> 42;
  2. Before the opening [ of an array — a whole-array annotation that is inherited by every element that does not carry its own annotation: .ports = <uint:16> [80, 443, 8080];
  3. After the opening [ of an array, before the first element.
  4. After a , inside an array, before the next element.

In all cases the annotation comes before the value it describes.

Correct placement:

.an_int  = <uint:32> 42;
.a_float = <float:64> 3.14;
.arr = [<uint:8> 1, <sint:16> -2];

# Whole-array annotation — applies to all elements that lack their own annotation
.ports = <uint:16> [80, 443, 8080];

# Per-element annotations override the whole-array annotation
.mixed = <uint:8> [1, <sint:8> -1, 255];

Incorrect placement (hard error):

.key<uint:32> = 42;    # ERROR: type annotation must follow '=', not the key

Seven type families are recognized in spec 1.0, plus datetime in spec 1.1:

Family Keyword Parameter syntax Default Width
Unsigned integer uint :width,_base,unit 64
Signed integer sint :width,_base,unit 64
Binary floating-point float :width,_base,unit (base _10 or _16 only) 64
Fixed-point (Q-format) float_fix :width,qN,unit 64
Decimal floating-point (IEEE 754-2008) float_dec :width,unit 64
UTF-8 string utf8 none (any parameter → error_illegal_value_type)
Boolean bool none (any parameter → error_illegal_value_type)
Timestamp (spec 1.1) datetime :width,epoch (no numeric base/unit) 64

The datetime family (spec 1.1). A datetime value is a signed integer count of seconds since a named epoch — a timestamp, as distinct from a duration (which is just a number with a time unit, e.g. <float:64,s>). The carrier is validated exactly like sint (signed, decimal, range per width; negative values denote instants before the epoch). Its one family-specific parameter is the epoch name, one of:

unix (default), tai, gps, mjd, ntp, galileo, glonass, y2000, beidou.

A numeric base, q, or physical unit parameter is error_illegal_value_type; a fractional or exponent numeric carrier (e.g. 1.5 or 1e3 — as opposed to the sub-second fraction of an ISO-8601 literal, covered below) is error_type_value_mismatch. As a 1.1 feature it requires a #!bovnar 1.1 declaration (§3.4) — in a 1.0/unversioned document datetime is error_illegal_value_type. Recover the civil date/time with the bvn_datetime.h helpers (bvnr_datetime_epoch_mjd() → the epoch, then bvn_dt_epoch_seconds_to_datetime()).

#!bovnar 1.1
.created = <datetime:64,unix> 1750000000;   # 2025-06-15T...Z
.tai_t   = <datetime:tai>     1400000000;
.before  = <datetime>         -100;          # 100 s before 1970-01-01Z

ISO-8601 literals (spec 1.1). Instead of a raw integer, a datetime value may be written as an ISO-8601 literal:

datetime-literal = YYYY-MM-DD [ "T" HH:MM:SS [ "." fraction ] [ zone ] ]
zone             = "Z" | ( "+" | "-" ) HH:MM

The literal is converted at parse time to the integer epoch-seconds carrier; that integer is what is stored and re-emitted, so a pretty-print round-trip is idempotent (2026-06-15 becomes <datetime:64> 1781481600). A bare literal with no annotation infers <datetime:64,unix>, so .t = 2026-06-15; is a timestamp without any annotation. Fields are strictly validated (month 0112, a valid day-of-month, hour 0023, minute 0059, second 0060, offset ±HH:MM with two-digit components); a malformed or out-of-range literal is error_invalid_datetime_literal. A second of 60 is a UTC leap second and is accepted. What it stores depends on the epoch:

A :60 at an instant the implementation's leap-second table does not record as an insertion collapses on tai too. The table is a static snapshot, and a build that predates an IERS announcement must not reject a document spelling a genuine future leap second.

A ±HH:MM offset shifts the written civil time to true UTC before the conversion (12:00:00+02:00 is 10:00:00Z); for tai the offset is applied before the leap-second lookup, so the atomic value stays correct. The ., Z, and ±HH:MM parts are valid only after a full HH:MM:SS time.

Fractional seconds (. then one or more digits — ISO 8601 sets no upper bound on the digit count) are accepted. The integer carrier is still whole seconds, so the fraction takes no part in the value's arithmetic or comparison (<datetime:64> 1781524800 and a literal flooring to that second compare equal at the carrier). But the verbatim digits are preserved: they are surfaced to consumers as a string (the streaming bvnr_data_t.frac_data / .frac_length, or bvn_dom_get_datetime_fraction() on a DOM node) and are re-emitted so the value round-trips. A datetime that carries a fraction is pretty-printed back as an ISO literal — 2026-06-15T12:00:00.5Z canonicalises to <datetime:64> 2026-06-15T12:00:00.5Z (always normalised to UTC Z, with the annotation made explicit) and re-printing that is idempotent; a datetime written as a bare integer carrier still canonicalises to the integer. The fraction is informational only — for sub-second values that participate in computation, use a finer integer carrier (e.g. milliseconds since the epoch).

The UTC→epoch conversion is leap-second correct: the civil epochs (unix, mjd, ntp, y2000) use the uniform 86 400 s/day scale (so unix is ordinary POSIX time) and tai applies the IERS leap-second table. The atomic GNSS epochs (gps, galileo, glonass, beidou) reject a literal with error_datetime_literal_unsupported_epoch — those scales have no round-trippable civil⇄seconds inverse in this implementation, so supply an integer epoch-seconds carrier for them. A literal carries no unit, and an ISO literal under a non-datetime annotation is error_type_value_mismatch.

#!bovnar 1.1
.a = 2026-06-15;                                  # bare -> <datetime:64,unix>
.b = 2026-06-15T12:00:00Z;                        # date-time, UTC
.c = <datetime:64,tai> 2017-01-01T00:00:00Z;      # tai: leap-second correct
.f = <datetime:64,tai> 2016-12-31T23:59:60Z;      # the insertion itself, one second earlier
.d = 2026-06-15T12:00:00+02:00;                   # offset -> 10:00:00Z
.e = 2026-06-15T12:00:00.5Z;                      # fraction preserved; round-trips as a literal

Parameter syntax:

type-spec = param-type [ ":" type-param-list ]

param-type = "uint" | "sint" | "float" | "float_fix" | "float_dec" | "utf8"
           | "bool" | "datetime"            (* datetime: spec 1.1 *)

type-param-list = type-param { "," type-param }

type-param = width-param    # decimal digits only, e.g. 32
           | base-param     # "_" followed by digits, e.g. _16
                            #   (forbidden for float_fix and float_dec)
           | q~param        # "q" followed by digits, e.g. q8, q16
                            #   (only valid for float_fix)
           | unit-param     # unit string, e.g. m/s, k~g·m/s²
           | epoch-param    # datetime epoch name, e.g. unix, tai (spec 1.1)

Lexer note on float_fix / float_dec: The lexer keyword state machine recognises float as the type-family prefix and then accumulates the remaining annotation bytes (_fix, _dec, or the parameter separator : / closing >) via copy_type_byte into type_data. bvn_parse_type_annotation then distinguishes float, float_fix, and float_dec from the fully accumulated string.

5.2 Parameters

Parameter Syntax Valid Values Applies To
Width N (decimal digits) 0, 16, or any multiple of 32 up to 32768 for float; 0,16,32,64,128,256 for float_fix/float_dec; 0 to BVN_MAX_INT_WIDTH (32768) for uint/sint uint, sint, float, float_fix, float_dec
Base _N (underscore + decimal digits) 2–62 (uint/sint); 64, 85 are uint-only (their Base64/Ascii85 alphabets use +/- as digits, so signed values are illegal — error_illegal_value_type); float accepts only 10 or 16; forbidden for float_fix and float_dec uint, sint, float
Q (fractional bits) qN (lowercase q + decimal digits) 0 ≤ N < effective_width float_fix only
Unit unit-string See Units System — supports compound units uint, sint, float, float_fix, float_dec

Width 0 means "default width" — bvn_effective_width returns 64.

For float_fix, the Q value (stored in value_type_spec_t.base) is the number of fractional bits. bvn_effective_q returns this value; bvn_effective_base always returns 10 for float_fix and float_dec.

For float_dec, the encoding is a custom binary-storage format (not DPD/BID wire format) parameterised by width:

Width exp_bits coeff_bits bias max decimal digits
16 6 9 24 2
32 8 23 101 7
64 10 53 398 16
128 14 113 6176 34
256 20 235 611867 70

For float_fix, the wire representation is a signed Q-format integer stored in width bits; the mathematical value is raw_integer × 2^(-Q).

5.3 Parameter Order

Parameters are identified by class — each class is recognised by its syntactic form — and at most one of each class may appear. They can appear in any order:

# All of these annotations are equivalent (parameter order is free):
.val = <uint:32,_10,no_unit> 42;
#      <uint:_10,no_unit,32>      — identical
#      <uint:no_unit,_10,32>      — identical

Because parameters are class-identified rather than positional, an empty positional slot is never needed, so the parameter list is parsed strictly: a comma must introduce a real parameter, and a : must be followed by at least one parameter. Empty, trailing, or doubled components — <uint:8,>, <uint:8,,>, <uint:,_16>, and the bare <uint:> — are error_illegal_value_type.

5.4 Examples

# Simple types
.a = <uint:32> 1000;
.b = <sint:16> -32768;
.c = <float:64> 3.14159;
.d = <utf8> "text";

# Width only
.g = <uint:8> 255;

# Base (requires quoted string for non-decimal)
.h = <uint:_16> "ff";
.i = <sint:_2> "101010";

# Unit
.j = <uint:32,no_unit> 42;      # explicitly dimensionless
.k = <float:64,m/s> 9.81;       # meters per second (compound)
.l = <uint:64,Ki~B> 1024;       # kibibytes
.m = <float:64,k~g·m/s²> 9.81;  # kilograms · meters per second squared
.n = <float:64,m*s> 1.0;        # meter-seconds (product)

# Type annotation with null value
.o = <uint:32> ;                # null of type uint:32

# Fixed-point (Q-format): 16-bit, 8 fractional bits → resolution 2^-8 ≈ 0.0039
.fx1 = <float_fix:16,q8> 3.14;

# Fixed-point: 32-bit, Q16 (16 fractional bits, 15 integer + 1 sign)
.fx2 = <float_fix:32,q16> -1.5;

# Fixed-point with unit
.fx3 = <float_fix:32,q8,m/s> 9.81;

# Fixed-point: width defaults to 64, Q=0 means pure integer fixed-point
.fx4 = <float_fix:64,q0> 42;

# Decimal float: 32-bit (7 significant decimal digits)
.df1 = <float_dec:32> 3.14;

# Decimal float: 64-bit (16 significant decimal digits) with unit
.df2 = <float_dec:64,Pa> 101325;

# Decimal float: 128-bit (34 significant decimal digits)
.df3 = <float_dec:128> 1.2345678901234567890123456789012345;

# float_fix and float_dec do NOT accept a base parameter:
# .bad = <float_fix:32,q8,_10> 1.0;   # ERROR: base forbidden for float_fix
# .bad = <float_dec:64,_10> 1.0;      # ERROR: base forbidden for float_dec

5.5 Non-decimal Base with Bare Numbers

A non-decimal base with a bare number literal is not caught by the validator (no error is raised). In practice, a bare non-decimal value such as ff is parsed as a symbol token, not a number, so type/value compatibility validation will flag the mismatch instead. Use a quoted string for non-decimal values:

# CORRECT: quoted string
.n = <uint:_16> "ff";     # hex value 255

6. Value Tokens

6.1 Type/Value Compatibility

Type Family Accepts
vt_plain (default) Any value
vt_utf8 String only
vt_bool Boolean keyword only (true/false/on/off)
vt_uint Number or string (digits)
vt_sint Number or string (digits, may be negative)
vt_float Number or string (may have ., e/E; base 16 strings use p/P) — only base 10 or 16
vt_float_fix Number or string (may have ., e/E) — base 10 only
vt_float_dec Number or string (may have ., e/E) — base 10 only
vt_datetime (spec 1.1) Number only — a decimal signed integer (epoch seconds); ./e and string carriers are rejected

6.2 Validation Rules per Numeric Type

uint (unsigned integer):

.valid = <uint:8> 255;
.valid = <uint:8> 0;

.invalid = <uint:8> -1;       # error_value_out_of_range (negative)
.invalid = <uint:8> 256;      # error_value_out_of_range (overflow)

sint (signed integer):

.valid = <sint:8> 127;
.valid = <sint:8> -128;

.invalid = <sint:8> 128;      # error_value_out_of_range
.invalid = <sint:8> -129;     # error_value_out_of_range

float (binary floating-point):

Valid widths: 0 (default 64), 16, or any multiple of 32 up to 32768. Base 10 (default) or 16 are accepted; all other bases are rejected.

.valid = <float:64> 3.14;
.valid = <float:64> 1e100;
.valid = <float:64> nan;
.valid = <float:16> 3.14;      # half-precision
.valid = <float:256> 3.14;     # 256-bit extended precision

.invalid = <float:8> 3.14;     # width 8 → error_illegal_value_type
.invalid = <float:12> 3.14;    # not 16 or a multiple of 32 → error_illegal_value_type
.invalid = <float:64,_2> 3.14; # base 2 → error_illegal_value_type

float_fix (fixed-point Q-format):

Valid widths: 0 (default 64), 16, 32, 64, 128, 256. The Q parameter (qN) specifies fractional bits; 0 ≤ N < effective_width. Base parameter (_N) is forbidden. The mathematical value of a fixed-point datum is raw_integer × 2^(-Q).

Range. A float_fix value must lie within the declared format's signed range — raw_integer = round(value × 2^Q) must fit a signed width-bit field, i.e. value ∈ [-2^(width-1-Q), 2^(width-1-Q) − 2^(-Q)]. A value outside that range is error_value_out_of_range, exactly as for uint/sint overflow (special numbers nan/inf/ninf are range-exempt, §6.4). The C encoders bvn_float_to_fixNN additionally saturate to the representable extreme on overflow rather than wrapping, so a fixed-point datum can never silently decode to an unrelated value.

.valid   = <float_fix:16,q8> 3.14;    # Q8 in 16-bit: range [-128, 127.99609375]
.valid   = <float_fix:16,q8> 127.99609375;  # max in range
.valid   = <float_fix:16,q8> -128;    # min in range
.valid   = <float_fix:32,q16> -1.5;   # Q16 in 32-bit
.valid   = <float_fix:64,q0> 42;      # Q0 = pure integer, no fractional part
.valid   = <float_fix:32,q8,m/s> 9.81;

.invalid = <float_fix:16,q8> 128;     # exceeds Q8/16-bit range → error_value_out_of_range
.invalid = <float_fix:16,q0> 70000;   # exceeds signed-16-bit range → error_value_out_of_range
.invalid = <float_fix:16,q16> 1.0;    # Q >= width → error_illegal_value_type
.invalid = <float_fix:8> 1.0;         # width 8 not in {0,16,32,64,128,256}
.invalid = <float_fix:32,q8,_10> 1.0; # base param forbidden → error_illegal_value_type

float_dec (IEEE 754-2008 decimal floating-point):

Valid widths: 0 (default 64), 16, 32, 64, 128, 256. Base parameter (_N) is forbidden (decimal base is implicit). Values are written as ordinary decimal literals or special numbers.

.valid   = <float_dec:32> 3.14;
.valid   = <float_dec:64,Pa> 101325;
.valid   = <float_dec:128> nan;

.invalid = <float_dec:8> 1.0;         # width 8 not valid → error_illegal_value_type
.invalid = <float_dec:64,_10> 1.0;    # base param forbidden → error_illegal_value_type

6.3 Digit Validation

Digits in values are checked against the declared base:

.value = <uint:_16> "ff";      # OK: f and f are valid in base 16
.value = <uint:_16> "fg";      # error_digit_not_in_base: 'g' > base 16
.value = <uint:_2> "1010";     # OK: valid binary
.value = <uint:_2> "210";      # error_digit_not_in_base

Exponent Markers in Quoted String Literals

Bare number literals always use e/E as the exponent separator (§4.6). Quoted string number literals follow the same rule except for base-16 (_16) float values, where p/P must be used instead of e/E. This is required because e and E are valid hexadecimal digits: with base 16 you must write "1.8p+2", not "1.8e+2" — the e would be read as a mantissa digit rather than an exponent marker, leaving the trailing +2 invalid. Float values support a decimal base (the default) and base-16 (_16); the p/P exponent separator applies to the base-16 form.

.hex_float  = <float:64,_16> "1.8p+2";   # OK: 1.8₁₆ × 2² = 6.0  (p = binary exponent)
.hex_mant   = <float:64,_16> "1.8e";     # OK: 'e' is a hex digit → mantissa 1.8e₁₆
.dec_float  = <float:64> 12.0;           # OK: default decimal base

The p/P exponent value is always interpreted as a decimal integer (the binary exponent bias), matching the C99 hexadecimal floating-point literal convention.

6.4 Special Number Semantics

nan, inf, ninf are accepted by any numeric type family (uint, sint, float, float_fix, float_dec) and in untyped context. bvn_check_acc_range explicitly returns true when bvn_is_special_number_string matches, bypassing all range validation regardless of the declared family. They are rejected under the non-numeric families whose token kind is incompatible with token_is_numberutf8 (string-only) and bool (boolean-keyword-only) — both producing error_type_value_mismatch. (A datetime annotation, by contrast, accepts them: the special-string check short-circuits before the datetime carrier is parsed.)

.okay_f64  = <float:64>  inf;
.okay_f32  = <float:32>  nan;
.okay_u8   = <uint:8>    nan;         # accepted — range check bypassed
.okay_s16  = <sint:16>   ninf;   # accepted — range check bypassed

# Fine in plain/untyped context too
.untyped = inf;       # defaults to float:64

6.5 Inline Unit Suffix

A scalar number or string value may carry an optional unit suffix separated from the literal by at least one whitespace character. The suffix uses the same character set as the unit parameter inside a type annotation (unit-param).

Separator form Example Valid?
Space .a = 9.81 m/s;
No separator .b = 9.81m; ✗ error
.distance = 100 m;            # plain integer with inline unit: meter
.speed    = 9.81 m/s;         # plain float with inline compound unit
.mass     = 70.0 k~g;         # with SI prefix
.storage  = 4 Gi~B;           # with IEC prefix
.ratio    = 3.14 no_unit;     # explicitly dimensionless via inline suffix

The inline unit suffix is forbidden inside arrays; any character that would begin an inline unit — a letter, _, $, %, (, or a UTF-8 lead byte — following a value inside [ … ] is a lexical error (error_unexpected_input_byte).

Interaction with type annotations:

Situation Outcome
No annotation, inline unit present Inline unit is used as the effective unit
Annotation has no unit, inline unit present Inline unit is used as the effective unit
Annotation unit and inline unit match Valid; the common unit is used
Annotation unit and inline unit differ error_unit_mismatch
# Annotation unit with no inline unit — normal
.dist = <float:64,m> 1.5;

# No annotation unit, inline unit — unit from suffix
.dist = <float:64> 1.5 m;

# Both present and identical — valid, redundant but allowed
.dist = <float:64,m> 1.5 m;

# Both present and different — error_unit_mismatch
.dist = <float:64,m> 1.5 s;     # ERROR: annotation says m, inline says s

An invalid inline unit string (unrecognised base unit, bad prefix, etc.) produces error_unit_illegal. The suffix may appear after no_unit checks as with any other unit string.


7. Arrays

7.1 Row Syntax

An array consists of one or more bracket-enclosed rows separated by /:

.array_2d = [1, 2, 3]/[4, 5, 6];

This produces the event sequence:

ev_array_row_start → 3× ev_data  → ev_array_row_end
ev_array_dim_start → ev_array_row_start  → 3× ev_data  → ev_array_row_end

7.2 Null Elements

Leading/trailing commas, consecutive commas, and the bare keyword null all produce null elements:

.items = [,1,,2,];   # 5 elements: null, 1, null, 2, null
.one   = [null];     # 1 element: a single null

Empty arrays. A row with nothing between the brackets is empty — zero elements, no ev_data emitted. It is distinct from a one-null row:

.empty = [];         # 0 elements
.null1 = [null];     # 1 element (null)

So bvn_dom_array_count([]) is 0 and bvn_dom_array_count([null]) is 1. The canonical serialiser writes a null array element as the explicit null keyword (e.g. [, 1] round-trips through [null, 1]), so an empty slot never collides with the empty array. Empty rows interact with row-size consistency (§7.3): all /-rows must share one width, and 0 is a valid width — []/[] is two empty rows, but []/[1] is error_array_row_size_mismatch.

7.3 Row-Size Consistency

A single array's /-separated dimension rows (e.g. [1,2,3]/[4,5,6]) must all have the same element count. A mismatch produces error_array_row_size_mismatch; the lexer performs this check as each row closes, so an offending row is rejected at the earliest possible byte — the element that overshoots, or the ] that falls short. This makes one /-array a clean rectangular N-dimensional block.

Comma-separated elements are distinct values, but since spec 1.0 they are no longer unconstrained: they must be homogeneous (§7.4).

7.4 Element Homogeneity

Since spec 1.0 the elements of an array must be homogeneous. The rule is "shape uniform, fields free", checked over the materialised value (above the lexer; it complements the streaming reader's per-value type and unit checks):

.ok_scalars = [1, 2.5, 3];                 # valid: same (no) dimension, encodings may mix
.ok_matrix  = [[1, 2], [3, 4]];            # valid: rectangular
.ok_records = [{.cur = USD; .bal = <float_dec:64,$USD> 1.0;},
               {.cur = EUR; .bal = <float_dec:64,$EUR> 2.0;}]; # valid: same keys, fields free
.ok_sparse  = [1, , 3];                    # valid: null hole

.bad_kind   = [1, "two"];                  # error_array_element_type_mismatch
.bad_dim    = [<float:64,m> 1.0, <float:64,k~g> 2.0];  # error_array_element_type_mismatch
.bad_ragged = [[1, 2], [3, 4, 5]];         # error_array_row_size_mismatch
.bad_keys   = [{.x = 1;}, {.y = 1;}];      # error_struct_shape_mismatch

A bare array of measurements is therefore uniform — a consumer may treat its elements identically — while records (structs) describe genuinely different things. Heterogeneous data is modelled with a struct, not an array. (This is a deliberate tightening over pre-1.0 drafts, which allowed ragged and mixed-type arrays; it also means a ragged or mixed-type JSON array has no bovnar representation and the json → bvnr converter rejects it rather than losing structure.)

7.5 Array Elements with Type Annotations

Individual elements may carry type annotations. The annotation appears before each element value, after the comma or opening bracket:

.mixed = [<uint:8> 1, <sint:8> -1, <float:64> 3.14];
.nulls = [<uint:32> , <sint:64> ];    # null, null with types

7.6 Constraints

Constraint Limit
Array nesting depth max_array_nesting — default 64, hard cap 255
Total array items max_array_items (configurable; 0 → 2 147 483 647 internal default)

8. Structs (Scopes)

8.1 Syntax

struct = "{" ws { assignment } ws "}"

Structs group related assignments into a nested scope:

.person = {
    .name = "Alice";
    .age = 30;
    .address = {
        .street = "123 Main St";
        .city = "Springfield";
    };
};

Key uniqueness. Keys must be unique within a single scope — a struct, and the top-level document. A repeated key is error_duplicate_struct_key, so lookup, references, and iteration always agree on the value of a key:

.bad = {.x = 1; .x = 2;};   # error_duplicate_struct_key
.ok  = {.x = 1; .y = 2;};   # fine

The same key in different scopes is unrelated and always allowed (e.g. .a = {.x = 1;}; .b = {.x = 2;};). The rule is enforced over the materialised document, alongside array homogeneity (§7.4).

8.2 Nesting

Structs can be nested up to max_struct_nesting levels — default 64, hard cap 255 (the limit field is a uint8_t; setting it to 0 selects the default of 64). Exceeding the configured limit produces error_struct_nesting_too_high.

8.3 Empty Structs

.empty = {};

8.4 Structs as Array Elements

.people = [
    {.name = "Alice"; .age = 30;},
    {.name = "Bob"; .age = 25;}
];

8.5 Unmatched Braces

A } seen with struct_nesting_level == 0 is error_illegal_struct_close.


9. Octet Streams (Binary Mode)

9.1 Overview

A NUL byte (0x00) where a value is expected switches from text mode to binary chunk mode. The parallel UTF-8 validator is suspended for the duration.

9.2 Wire Protocol

octet-stream = 0x00 { os-chunk } 0x00
os-chunk     = 0x01 os-length os-data
os-length    = uint16 (little-endian); 0x0000 encodes 65536 bytes
os-data      = exactly os-length bytes
Byte Meaning
0x00 End-of-stream marker (at binary level)
0x01 Data chunk follows
0x01 + LL LL + data Chunk of L bytes (0x0000 = 65536)
Any other tag byte error_octet_stream_out_of_sync

9.3 Events

ev_octet_stream_start  →  (emitted on the leading 0x00)
ev_data                →  (emitted for each binary chunk)
ev_octet_stream_end    →  (emitted on the trailing 0x00)

9.4 Example

.data = ;
.binary = \x00\x01\x05\x00hello\x01\x03\x00bye\x00;

This binary region encodes two chunks: "hello" (5 bytes) and "bye" (3 bytes), producing:

ev_octet_stream_start
ev_data (type=octet_stream, data="hello",  length=5)
ev_data (type=octet_stream, data="bye",    length=3)
ev_octet_stream_end

9.5 Constraints


10. Default Type Synthesis

When a number or string value carries no explicit type annotation (i.e. the validator's value_type is still vt_plain), the validator synthesises a default type annotation before emitting ev_data.

10.1 Rules

Value Form Synthesised Type
Quoted string <utf8>
Boolean keyword (true/false/on/off) <bool>
Special number (nan, inf, ninf) <float:64,_10,no_unit>
Number with . or e/E (float literal) <float:64,_10,no_unit>
Negative integer <sint:64,_10,no_unit>
Plain integer <uint:64,_10,no_unit>
Number with an inline currency unit ($XXX) <float_dec:64,$XXX>
ISO-8601 datetime literal, no annotation (spec 1.1) <datetime:64,unix>

The currency row takes precedence over the float/integer rows: any number (integer or decimal) that carries an inline currency unit synthesises float_dec, so money is never stored as a binary float — .price = 5 $USD; becomes <float_dec:64,$USD>, not <uint:64>.

A bare integer inside a datetime array inherits the array's datetime type (width and epoch) rather than synthesising uint/sint, so a datetime array's canonical form — annotation on the first element, bare integers after — stays homogeneous.

float_fix is never auto-synthesised, because it requires a Q parameter that cannot be inferred from the value literal; it must be introduced by an explicit type annotation. float_dec is auto-synthesised in exactly one case — a number carrying an inline currency unit (the row above) — so that monetary values are never held as a binary float; in every other context it too requires an explicit annotation.

10.2 Event Sequence

The synthesised annotation produces the same event sequence as an explicit one. For numeric types (uint, sint, float) the sequence includes three parameter events; for utf8 no parameter events are emitted:

# numeric (uint / sint / float)
ev_type_annotation_start
ev_type_annotation_type_family  → "uint" / "sint" / "float"
ev_type_annotation_type_family_parameter  → width:64
ev_type_annotation_type_family_parameter  → base:_10
ev_type_annotation_type_family_parameter  → unit:no_unit
ev_type_annotation_end
ev_data

# string
ev_type_annotation_start
ev_type_annotation_type_family  → "utf8"
ev_type_annotation_end
ev_data

10.3 Examples

# No annotation → synthesised <uint:64,_10,no_unit>
.x = 42;

# No annotation → synthesised <float:64,_10,no_unit>
.y = 3.14;
.z = inf;

# No annotation → synthesised <sint:64,_10,no_unit>
.w = -7;

# No annotation → synthesised <utf8>
.s = "hello";

11. Units System

11.1 Base Units

The unit system supports 163 named base units across SI, IEC-binary, Imperial/US customary, CGS electromagnetic, radiation, electrical-power, and other categories. The table below covers the SI base units, all 22 named SI-derived units (degree Celsius among them — it is listed under Non-SI units accepted for use with SI below, where it is conventionally grouped), and the other non-SI units accepted for use with SI. For the complete reference — including Imperial/US customary, CGS, radiation, electrical-power, rotational, textile, surveying, volume, and other unit families — see doc/2_bovnar_unit_system.md.

SI base units and digital units

Symbol Unit Enum
b bit bu_bit
B byte bu_byte
s second bu_second
m meter bu_meter
g gram bu_gram
A ampere bu_ampere
K kelvin bu_kelvin
mol mole bu_mol
cd candela bu_candela

Named SI-derived units

Symbol Unit Enum
Hz hertz bu_hertz
N newton bu_newton
Pa pascal bu_pascal
J joule bu_joule
W watt bu_watt
V volt bu_volt
ohm bu_ohm
F farad bu_farad
C coulomb bu_coulomb
S siemens bu_siemens
Wb weber bu_weber
T tesla bu_tesla
H henry bu_henry
lm lumen bu_lumen
lx lux bu_lux
Bq becquerel bu_becquerel
Gy gray bu_gray
Sv sievert bu_sievert
kat katal bu_katal
rad radian bu_radian
sr steradian bu_steradian

Non-SI units accepted for use with SI

Symbol Unit Enum
L, l liter bu_liter
min minute bu_minute
h hour bu_hour
d day bu_day
wk week bu_week
yr year bu_year
°, deg, degr, degree, degrees degree (angle) bu_degree
°C, degC, degrC degree Celsius bu_celsius
t tonne bu_tonne
bar bar bu_bar
eV electronvolt bu_electronvolt
Da, dalton, amu, u dalton bu_dalton
au astronomical unit bu_astronomical_unit
ha hectare bu_hectare

For Imperial/US customary length (in, ft, yd, mi, nmi, Å, ly, pc, fur, fath, ch, rd, thou/mil), mass (lb, oz, gr, st, tn_sh, tn_l, oz_t, ct, slug, dr, dwt), temperature (°F, Ra), pressure (atm, mmHg, Torr, psi, inHg, at), energy (cal, Btu, erg, thm, ft_lb), power (hp, PS/CV), force (lbf, dyn, kip, kgf), speed/frequency/rotation (kn, rpm, rev), volume (US and UK gallons, pints, fluid ounces, and many more), area (ac, barn), angle (arcmin, arcsec, grad), CGS (P, St, G, Mx, Oe, sb, ph, Gal), radiation (Ci, R, rem), logarithmic (Np, dB), electrical power (var, VA), acceleration (gn), time (mo, fn), textile linear density (tex, den), and apothecary/dry volume (fl_dr, minim, pk, bsh) — see the Unit System Reference.

11.2 SI Prefixes

Prefix Symbol Factor Enum
quetta Q 10³⁰ si_quetta
ronna R 10²⁷ si_ronna
yotta Y 10²⁴ si_yotta
zetta Z 10²¹ si_zetta
exa E 10¹⁸ si_exa
peta P 10¹⁵ si_peta
tera T 10¹² si_tera
giga G 10⁹ si_giga
mega M 10⁶ si_mega
kilo k 10³ si_kilo
hecto h 10² si_hecto
deca da 10¹ si_deca
deci d 10⁻¹ si_deci
centi c 10⁻² si_centi
milli m 10⁻³ si_milli
micro µ (or u) 10⁻⁶ si_micro
nano n 10⁻⁹ si_nano
pico p 10⁻¹² si_pico
femto f 10⁻¹⁵ si_femto
atto a 10⁻¹⁸ si_atto
zepto z 10⁻²¹ si_zepto
yocto y 10⁻²⁴ si_yocto
ronto r 10⁻²⁷ si_ronto
quecto q 10⁻³⁰ si_quecto

Note: µ = U+00B5 (MICRO SIGN), encoded as 0xC2 0xB5 in UTF-8. ASCII u is accepted as an input-only alias for the micro prefix (u~m = µ~m); bvn_unit_to_string and the bvnr_write_* helpers always render it as µ, though the pretty-print / canonicalize path preserves the input u at default unit_flags.

11.3 IEC Binary Prefixes

Prefix Symbol Factor Enum
kibi Ki 2¹⁰ iec_kibi
mebi Mi 2²⁰ iec_mebi
gibi Gi 2³⁰ iec_gibi
tebi Ti 2⁴⁰ iec_tebi
pebi Pi 2⁵⁰ iec_pebi
exbi Ei 2⁶⁰ iec_exbi
zebi Zi 2⁷⁰ iec_zebi
yobi Yi 2⁸⁰ iec_yobi
robi † Ri 2⁹⁰ iec_robi
quebi † Qi 2¹⁰⁰ iec_quebi

Ri/Qi are a forward-looking extension: IEC 80000-13 stops at yobi (Yi, 2⁸⁰).

11.4 Unit Notation

The unit system supports compound units composed of multiple base-unit terms combined with product and division separators.

compound-unit  = "no_unit" | unit-expr
unit-expr      = unit-factor { unit-sep unit-factor }
unit-factor    = unit-component | "(" unit-expr ")"

unit-sep       = "*" | "/" | "·"       (* "·" = U+00B7 MIDDLE DOT *)

unit-component = [ prefix "~" ] base-unit [ unit-exponent ]

Separators:

Separator Code Point Meaning
* U+002A Product (multiplication)
· U+00B7 Product (multiplication) — visually preferred
/ U+002F Division — subsequent components are in the denominator

The · (middle dot, U+00B7, encoded as 0xC2 0xB7) and * (asterisk) are semantically equivalent; both indicate multiplication of the adjacent unit components.

The / separator divides the preceding components by the following ones. The first / switches all subsequent components into the denominator; additional / separators do not toggle back to the numerator. Every component after the first / is always in the denominator.

Parenthesised grouping. A (…) group is a sub-expression evaluated independently; like any factor it obeys the latching denominator, so a / before a group negates the group's net exponents as a whole. Thus k~g/(m·s²) parses to kg·m⁻¹·s⁻² (identical to k~g/m·s²), while (k~g/m)·s² is kg·m⁻¹·s². An explicit separator is required before a group (m·(s), not m(s)); a group is not followed by its own exponent ((m·s)² is rejected); parentheses must balance and nest no deeper than 16. The bvnr_write_* helpers and bvn_unit_to_string, which build the unit from a parsed value_unit, emit the canonical, parenless form (k~g/(m·s²)k~g/m·s²); the reader-driven pretty-print / canonicalize path preserves the source spelling verbatim unless a unit flag such as BVN_UNIT_REDUCE is set.

Within each unit-component:

# Simple (single-component) units — same as before
.time = <float:64,s> 2.5;               # seconds
.speed = <float:64,k~m> 1.5;            # kilometers (kilo-meter)

# Compound units
.velocity = <float:64,m/s> 9.81;        # meters per second
.accel = <float:64,m/s²> 9.81;          # meters per second squared
.force = <float:64,k~g·m/s²> 9.81;      # kilogram-meters per second squared
.energy = <float:64,k~g·m²/s²> 1000;    # kilogram-square-meters per second squared
.moment = <float:64,m*s> 1.0;           # meter-seconds
.area_density = <float:64,k~g/m²> 5.0;  # kilograms per square meter
.three_term = <float:64,k~g·m·s⁻²> 9.81;  # equivalent to k~g·m/s²
.pressure = <float:64,k~g/(m·s²)> 101325; # grouped denominator (= k~g/m·s²)

# Explicitly dimensionless
.no_unit_float = <float:64,no_unit> 3.14;

11.5 Unit Exponents

Exponents can be written in two forms:

Form Example Meaning
Unicode superscript , m⁻³ Using U+00B2/00B3 etc.
ASCII caret m^2, m^-3, m^+2 Using ^[+-]?[1-9] (single digit; ^0 is not a valid exponent)

Superscript mapping:

Glyph Code Point Exponent
¹ U+00B9 1
² U+00B2 2
³ U+00B3 3
U+2074 4
U+2075 5
U+2076 6
U+2077 7
U+2078 8
U+2079 9
U+207A positive sign (no-op)
U+207B negate exponent

11.6 Examples

.distance = <float:64,k~m> 1.5;             # kilometers
.mass = <float:64,g> 500;                   # grams
.velocity = <float:64,m/s> 9.81;            # meters per second
.acceleration = <float:64,m/s²> 9.81;       # meters per second squared
.pressure = <float:64,Pa> 101325;           # pascals (= N/m² = k~g/(m·s²))
.energy = <float:64,k~J> 1000;              # kilojoules
.storage = <uint:64,Ti~B> 2;                # tebibytes
.frequency = <float:64,k~Hz> 2.4;           # kilohertz
.force = <float:64,k~g·m/s²> 9.81;         # kilogram-meters per second squared
.momentum = <float:64,k~g·m/s> 0.5;        # kilogram-meters per second
.density = <float:64,k~g/m³> 7800;          # kilograms per cubic meter

11.7 Compound Unit Constraints

Constraint Limit
Maximum components per compound unit 8 (BVNR_MAX_UNIT_COMPONENTS)

If a compound unit string contains more than BVNR_MAX_UNIT_COMPONENTS components after parsing, the validator raises error_unit_illegal.

Empty components between separators (e.g., m//s, m*·s) produce error_unit_illegal.

11.8 The no_unit Keyword

The literal string no_unit in the unit parameter position means "explicitly dimensionless":

.dimensionless = <uint:32,no_unit> 42;

Within an explicit annotation, omitting the unit parameter yields the same internal representation as an explicit no_unit: BVN_UNIT_NONE with num_components == 0. (bvn_parse_type_annotation initialises the unit to BVN_UNIT_NONE and only overwrites it when a dimensioned unit parameter is actually present.)

A fully untyped value (no annotation at all) instead defaults to dimensionless via default-type synthesis, producing BVN_UNIT_NO_PREFIX(bu_none) with num_components == 1 and base == bu_none. All three forms are semantically equivalent — they compare as compatible via bvn_units_compatible and both encodings serialize to "no_unit" via bvn_unit_to_string — but the untyped-default form (num_components == 1) is a structurally distinct internal state from the annotated forms (num_components == 0).


12. Validation & Constraints

12.1 UTF-8 Validation

Constraint Error
Non-UTF-8 byte sequence error_invalid_utf8_byte
Overlong encoding Rejected (by UTF-8 rules)
Surrogate halves (U+D800–U+DFFF) Rejected

12.2 Size Limits

Quantity Configurable Default Overflow Error
Identifier length Yes (max_identifier_length) 255 error_identifier_too_long
String length Yes (max_string_length) 65535 error_string_too_long
Number length Yes (max_number_length) 65535 error_number_too_long
Symbol length Yes (max_symbol_length) 255 error_symbol_too_long
Reference length Yes (max_reference_length) 65535 error_reference_too_long
Array items Yes (max_array_items) 2 147 483 647 error_too_many_array_items
Text bytes Yes (max_text_bytes) 2 147 483 647 error_text_data_too_long
File size Yes (max_file_size) 0 (→ unlimited / endless) error_file_too_long
Struct nesting Yes (max_struct_nesting) 0 (→64 internal) error_struct_nesting_too_high
Array nesting Yes (max_array_nesting) 0 (→64 internal, hard cap 255) error_array_nesting_too_high

Setting most fields to 0 in bvnr_read_flags_t substitutes an internal default — 64 for both nesting depths, and 2 147 483 647 (2³¹ − 1) for max_array_items and max_text_bytes. max_file_size is the exception: 0 means unlimited / endless (no byte-count cap accumulated), so endless streams are the default; set a positive value to cap. These defaults apply to both the reader and the writer. The writer does not internally limit array items, text bytes, or file size.

12.3 Value Validation

Check Error
Number in base-N string contains out-of-base digit error_digit_not_in_base
Integer value exceeds declared width error_value_out_of_range
float_fix value outside the declared Q-format range (§6.2) error_value_out_of_range
Negative number with uint type error_value_out_of_range
Mismatched type family for value token error_type_value_mismatch
Dot or exponent in integer-typed value error_type_value_mismatch
Malformed or out-of-range ISO-8601 datetime literal (spec 1.1) error_invalid_datetime_literal
ISO-8601 literal for an atomic GNSS epoch — gps/galileo/glonass/beidou (spec 1.1) error_datetime_literal_unsupported_epoch
Invalid unit string error_unit_illegal
Empty parameter component — <uint:>, <uint:8,>, <uint:8,,>, <uint:,_16> (§5.3) error_illegal_value_type
Wholly empty annotation <> / < > (no family keyword) error_unexpected_input_byte
utf8 (string) type given a number value error_type_value_mismatch
Non-decimal base for float type error_illegal_value_type
Base _N (N≠10, N≠16) for float error_illegal_value_type
Base param (_N) used with float_fix or float_dec error_illegal_value_type
Q param (qN) used with family other than float_fix error_illegal_value_type
Q value ≥ effective width for float_fix error_illegal_value_type
Invalid float width (not 0/16/multiple-of-32) for float error_illegal_value_type
Invalid float_fix/float_dec width (not 0/16/32/64/128/256) error_illegal_value_type

12.4 Array Validation

Check Error Tier
Element count mismatch across the /-dimension rows of a single array error_array_row_size_mismatch Streaming
Ragged sibling sub-arrays — differing lengths between siblings (§7.4) error_array_row_size_mismatch DOM
Array elements of mixed kind or dimension, or a struct field that differs in kind across record elements (§7.4) error_array_element_type_mismatch DOM
Array nesting overflow (exceeds max_array_nesting) error_array_nesting_too_high Streaming
Comma outside array context error_unexpected_input_byte Streaming

12.5 Struct Validation

Check Error Tier
Unmatched } error_illegal_struct_close Streaming
A key repeated within one scope (struct or top-level document) (§8.1) error_duplicate_struct_key DOM
Struct array elements with differing key sets (§7.4) error_struct_shape_mismatch DOM
Nesting depth exceeded error_struct_nesting_too_high Streaming

Validation tier. Streaming checks are raised by the pull-based reader (bvnr_read) during the on_verified event stream. DOM checks — the spec-1.0 homogeneity (§7.4), struct-shape, and duplicate-key (§8.1) rules — are enforced only when a document is materialised into a tree (bvn_dom_parse), because they require comparing sibling elements that the streaming reader sees one at a time. A streaming-only consumer therefore accepts a heterogeneous array, a ragged sibling sub-array, a shape-mismatched record set, or a duplicate key without error; a full spec-1.0 implementation must apply these four checks (error_array_row_size_mismatch for ragged siblings, error_array_element_type_mismatch, error_struct_shape_mismatch, error_duplicate_struct_key) in its document/tree API. See the conformance tool's "Validation tiers" (§3 of doc/7_bovnar_conformance.md).

12.6 Identifier Validation

Check Error
Empty key (.= or . + non-identifier char) error_empty_identifier
Invalid character in identifier error_unexpected_input_byte

12.7 String Validation

Check Error
Unknown escape sequence (incl. \x/\u outside spec 1.1) error_illegal_escape_sequence
\u{…} surrogate or value > U+10FFFF (spec 1.1) error_invalid_codepoint
\x byte(s) that break the string's UTF-8 validity (spec 1.1) error_invalid_utf8_byte
Control byte in string error_unexpected_input_byte
String length exceeded error_string_too_long

12.8 Unit Validation

Check Error
Invalid unit string error_unit_illegal
Compound unit exceeds BVNR_MAX_UNIT_COMPONENTS error_unit_illegal
Empty component between separators error_unit_illegal
Unit string too long error_unit_too_long
Inline unit suffix differs from type-annotation unit error_unit_mismatch
Inline unit suffix inside an array element error_unexpected_input_byte

12.9 Octet Stream Validation

Check Error
Unknown tag byte in binary mode error_octet_stream_out_of_sync
Incomplete chunk read error_read_complete_chunk_failed
EOF in binary mode Preserves current error

13. Error Handling & Recovery

13.1 Error Model

Errors are reported through the on_error callback:

typedef void (*bvnr_on_error_fn)(
    void* userdata, error_code_t err,
    uint64_t line, uint64_t column,
    uint32_t byte, uint64_t offset);

13.2 Recovery Mode

When bvnr_read_flags_t.continue_on_error is true, the parser enters resync mode after any error:

  1. The on_error callback is invoked with error details
  2. A resync state machine (state_t: resync, resync_string, resync_string_escape, resync_comment) skips bytes
  3. Tracking bracket [] and brace {} nesting
  4. On encountering ; at the saved nesting depth, parsing resumes
  5. recovery_count (accessible via bvnr_reader_get_recovery_count) is incremented immediately when an error triggers entry into resync mode

State machine behavior during resync:

Byte(s) Action
0x00–0xFF (most) Skip (consume and continue)
" Enter resync_string: skip until matching "
# Enter resync_comment: skip until newline
[, { Increment resync_depth
], } Decrement resync_depth; if 0, emit array/struct close
; at depth 0 Reset state, resume normal parsing

13.3 EOF in Resync

If EOF is reached while in any resync state, error_got_incomplete_bvnr_stream is fired as a second on_error notification in addition to the original error that triggered resync entry. Both error codes are delivered to the caller's on_error callback in order: the original error first, then error_got_incomplete_bvnr_stream when EOF is detected.


14. Formal EBNF

The complete grammar is maintained as a standalone file:

doc/5_bovnar.ebnf

The grammar uses ISO/IEC 14977:1996 notation and is derived from and verified against the reference implementation. It covers:


15. Complete Examples

15.1 Simple Configuration

# Application configuration
.app_name = "Bovnar Demo";
.version = 1;
.debug = false;
.max_connections = 100;
.timeout_s = 30;

15.2 Typed Scientific Data

# Physical measurements with units
.measurements = [
    {.name = "temperature";
     .value = <float:32,°C> 23.5;
     .precision = <float:32,°C> 0.1;},
    {.name = "pressure";
     .value = <float:64,Pa> 101325;
     .precision = <float:64,Pa> 100;},
    {.name = "humidity";
     .value = <float:32> 0.45;
     .precision = <float:32> 0.01;},
    {.name = "wind_speed";
     .value = <float:32,m/s> 5.2;
     .precision = <float:32,m/s> 0.1;}
];

.calibration = <float:64,no_unit> 1.00042;
.density = <float:64,k~g/m³> 7800;
.accel = <float:64,m/s²> 9.81;

15.3 Inline Unit Suffix

The unit may be written directly after the value literal instead of — or redundantly alongside — the type annotation:

# No type annotation: inline unit supplies both type default and unit
.distance   = 1500 m;           # uint:64, no_unit → unit overridden to m
.speed      = 9.81 m/s;         # float:64, unit = m/s
.mass       = 70.5 k~g;         # float:64, unit = k~g

# Type annotation without unit: inline suffix supplies the unit
.dist       = <float:32> 1.5 k~m;

# Annotation and inline unit match: valid (redundant)
.pressure   = <float:64,Pa> 101325 Pa;

# Annotation and inline unit differ: error_unit_mismatch
# .bad      = <float:64,m> 1.5 s;    # ERROR

15.4 Binary Data with Octet Stream

# An image file embedded as binary
.image = \x00
    \x01\x10\x00\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00
    \x01\x00\x00\x00\xFF\xD9
    \x00;

# A checksum alongside
.checksum = <uint:_16> "abcd";

15.5 Arrays with Mixed Dimensions

# 2D matrix (uniform rows)
.matrix = [1, 2, 3]/[4, 5, 6];

# Uniform nested arrays (all inner arrays same size)
.uniform_nested = [[1,2],[3,4]];

# Multi-dimensional uniform array
.cube = [[1,2],[3,4]]/[[5,6],[7,8]];

# Array with typed nulls
.nullable = [<sint:16> 1, <sint:16> , <sint:16> 3];

# Sibling sub-arrays must match in length (homogeneity, §7.4):
.rect_nested = [[1,2],[3,4]];

# These produce error_array_row_size_mismatch:
# .bad1 = [1,2,3]/[4,5];           # /-row sizes differ: 3 vs 2
# .bad2 = [[1,2]/[3,4,5]];         # inner /-array's own rows differ: 2 vs 3
# .bad3 = [[1,2],[3,4,5]];         # ragged sibling sub-arrays differ: 2 vs 3

15.6 Deeply Nested Struct

.api_response = {
    .status = ok;
    .code = 200;
    .data = {
        .users = [
            {.id = 1; .name = "Alice"; .roles = ["admin", "user"];},
            {.id = 2; .name = "Bob"; .roles = ["user"];}
        ];
        .pagination = {
            .page = 1;
            .per_page = 50;
            .total = 2;
        };
    };
};

15.7 References and Symbols

# Configuration with references
.config = {
    .host = "api.example.com";
    .port = 443;
    .tls = true;
};

.endpoint_defaults = {
    .host = &.config.host;
    .port = &.config.port;
    .tls = &.config.tls;
};

# Using symbols as enum-like values
.status = ok;
.mode = readonly;
.direction = north;

15.8 Compound Unit Examples

# Velocity
.velocity = <float:64,m/s> 9.81;

# Acceleration
.acceleration = <float:64,m/s²> 9.81;

# Force (Newton = kg·m/s²)
.force = <float:64,k~g·m/s²> 9.81;

# Energy (Joule = kg·m²/s²)
.energy = <float:64,k~g·m²/s²> 100;

# Momentum (kg·m/s)
.momentum = <float:64,k~g·m/s> 5.0;

# Pressure (Pa = kg/(m·s²))
.pressure = <float:64,k~g/(m·s²)> 101325;

# Area density (kg/m²)
.area_density = <float:64,k~g/m²> 5.0;

# Electric field (V/m)
.electric_field = <float:64,V/m> 150;

# Magnetic flux density (T = kg/(A·s²))
.mag_flux_density = <float:64,k~g/(A·s²)> 0.5;

# Product form with asterisk
.moment = <float:64,m*s> 1.0;

# Alternative superscript notation for compound units
.force_alt = <float:64,k~g·m·s⁻²> 9.81;

15.9 Fixed-Point and Decimal Float Examples

# ── float_fix: fixed-point Q-format ────────────────────────────────

# 16-bit Q8: 8 fractional bits, resolution 2^-8 ≈ 0.00390625
# range: [-128.0, +127.99609375]
.adc_reading   = <float_fix:16,q8> 3.14;

# 32-bit Q16: 15 integer bits + sign + 16 fractional bits
.fine_angle    = <float_fix:32,q16> -1.5;

# 64-bit Q0: no fractional bits — pure integer in fixed-point shell
.sample_count  = <float_fix:64,q0> 4096;

# 32-bit Q8 with unit (meters/second at 1/256 resolution)
.velocity_fx   = <float_fix:32,q8,m/s> 9.81;

# null of a fixed-point type
.missing_fx    = <float_fix:16,q8> ;

# inline unit suffix also works with float_fix
.temperature   = <float_fix:32,q8> 23.5 °C;


# ── float_dec: IEEE 754-2008 decimal floating-point ─────────────────

# 32-bit decimal float (7 significant decimal digits)
.price         = <float_dec:32> 12.99;

# 64-bit decimal float with unit (16 significant decimal digits)
.pressure_dec  = <float_dec:64,Pa> 101325.0;

# 128-bit decimal float (34 significant decimal digits)
.pi_dec        = <float_dec:128> 3.14159265358979323846264338327950288;

# 256-bit decimal float (70 significant decimal digits)
.big_constant  = <float_dec:256> 1.4142135623730950488016887242096980785696718753769480731766797;

# Special values are accepted
.nan_dec       = <float_dec:64> nan;
.inf_dec       = <float_dec:32> inf;

# null of a decimal float type
.missing_dec   = <float_dec:64> ;


# ── Contrast with binary float ───────────────────────────────────────

# Binary IEEE float (existing)
.val_bin       = <float:64> 3.14;

# Decimal float — same text representation, different wire encoding
.val_dec       = <float_dec:64> 3.14;

# Fixed-point — same text representation, Q-format wire encoding
.val_fix       = <float_fix:32,q16> 3.14;

15.10 Error Examples

# These will produce parse errors:

# Empty identifier
. = 42;                          # error_empty_identifier

# Type violation (type annotation on the identifier, not the value)
.x<utf8> = 42;                   # error (annotation must be after '=')

# Correct: .x = <utf8> "text";

# Type violation – value doesn't match type
.x = <utf8> 42;                   # error_type_value_mismatch

# Value out of range
.y = <uint:8> 300;                # error_value_out_of_range

# Negative unsigned
.z = <uint:8> -1;                 # error_value_out_of_range

# Unmatched struct close — a '}' at the top level (struct nesting 0)
.stray_close = 1;}               # error_illegal_struct_close

# Unknown escape
.string = "\x";                  # error_illegal_escape_sequence

# error_array_row_size_mismatch — /-dimension rows of one array, and (since 1.0)
# ragged sibling sub-arrays, must match in length:
# .bad1 = [1,2,3]/[4,5];           # /-row sizes differ: 3 vs 2
# .bad2 = [[1,2]/[3,4,5]];         # inner /-array's own rows differ: 2 vs 3
# .bad3 = [[1,2],[3,4,5]];         # ragged sibling sub-arrays differ: 2 vs 3
# Uniform /-rows and rectangular sibling sub-arrays are valid:
.ok1 = [1,2,3]/[4,5,6];    # valid — both dimension rows have 3 elements
.ok2 = [[1,2],[3,4]];       # valid — rectangular sub-arrays

# Comma outside array
.comma_outside = 42,;            # error_unexpected_input_byte

# Non-decimal base — bare token is parsed as a symbol, causing type mismatch
.hex = <uint:_16> ff;             # error: symbol value for numeric type

# Empty component in compound unit
.x = <float:64,m//s> 1.0;       # error_unit_illegal

# Too many components (> 8)
.y = <float:64,m*s*g*A*K*mol*cd*b*V> 1.0;  # error_unit_illegal (9 components)

# float_fix: Q >= effective width
.bad_q = <float_fix:16,q16> 1.0;            # Q=16 >= width=16 → error_illegal_value_type

# float_fix: invalid width
.bad_fw = <float_fix:8,q4> 1.0;             # width 8 not in {0,16,32,64,128,256}

# float_fix: base param forbidden
.bad_fb = <float_fix:32,q8,_10> 1.0;        # error_illegal_value_type

# float_dec: invalid width
.bad_dw = <float_dec:24> 1.0;               # width 24 not in {0,16,32,64,128,256}

# float_dec: base param forbidden
.bad_db = <float_dec:64,_10> 1.0;           # error_illegal_value_type

# q~param on non-float_fix type
.bad_qu = <float:64,q8> 1.0;               # q~param only valid for float_fix

16. Reference API

16.1 Core Types

typedef enum bvnr_event_e {
    ev_stream_start,
    ev_assignment_start,
    ev_octet_stream_start,
    ev_octet_stream_end,
    ev_struct_start,
    ev_struct_end,
    ev_array_row_start,
    ev_array_row_end,
    ev_array_dim_start,
    ev_data,
    ev_type_annotation_start,
    ev_type_annotation_end,
    ev_type_annotation_type_family,
    ev_type_annotation_type_family_parameter,
    ev_stream_end
} bvnr_event_t;

typedef enum value_type_family_e {
    vt_plain,
    vt_utf8,
    vt_sint,
    vt_uint,
    vt_float,
    vt_float_fix,  /* fixed-point binary, Q-format; Q stored in value_type_spec_t.base */
    vt_float_dec,  /* IEEE 754-2008 decimal floating-point                               */
    vt_bool,       /* boolean (true/false/on/off); see §4.4 and §6.1                     */
    vt_datetime,   /* spec 1.1 — timestamp: signed epoch-seconds; see §5                  */
    vt_illegal
} value_type_family_t;

typedef struct value_type_spec_s {
    value_type_family_t family;
    uint32_t            width; /* bit-width; 0 = default (64)                         */
    uint32_t            base;  /* for uint/sint/float: numeral base; 0 = default (10) */
                               /* for float_fix:        Q (fractional bits)           */
                               /* for float_dec:        unused (always 0)             */
} value_type_spec_t;

#define BVNR_MAX_UNIT_COMPONENTS     8

typedef struct value_unit_prefix_s {
    prefix_system_t system;
    union {
        si_prefix_id_t  si;
        iec_prefix_id_t iec;
    } id;
} value_unit_prefix_t;

typedef struct value_unit_component_s {
    value_base_unit_t   base;
    unit_exponent_t     exponent;
    value_unit_prefix_t prefix;
} value_unit_component_t;

typedef struct value_unit_s {
    uint32_t                num_components;
    value_unit_component_t  components[BVNR_MAX_UNIT_COMPONENTS];
} value_unit_t;

typedef enum token_type_e {
    token_is_identifier,
    token_is_string,
    token_is_number,
    token_is_symbol,
    token_is_reference,
    token_is_array_number,
    token_is_array_string,
    token_is_type,
    token_is_octet_stream,
    token_is_null_value,
    token_is_structure,
    token_is_unit,
    token_is_type_width,
    token_is_type_base,
    token_is_type_q,
    token_is_bool,
    token_is_unknown
} token_type_t;

typedef struct bvnr_data_s {
    token_type_t      type;
    value_type_spec_t value_type;
    value_unit_t      value_unit;
    const void*       data;
    uint32_t          length;
    const void*       frac_data;    /* spec 1.1 — ISO datetime sub-second digits, else NULL */
    uint32_t          frac_length;  /* spec 1.1 — length of frac_data, else 0 */
    bool              converted;    /* a want_unit read-time conversion was applied (§ read-time conversion) */
    bvnr_converted_t  conv;         /* the exact converted value (unit + text + rational); zeroed when converted is false */
} bvnr_data_t;

16.2 Type Construction Macros

/* Type-spec convenience constructors (from bovnar.h) */
#define BVN_TYPE_PLAIN          ((value_type_spec_t){ .family = vt_plain, .width = 0,  .base = 0  })
#define BVN_TYPE_UTF8           ((value_type_spec_t){ .family = vt_utf8,  .width = 0,  .base = 0  })
#define BVN_TYPE_BOOL           ((value_type_spec_t){ .family = vt_bool,  .width = 0,  .base = 0  })
#define BVN_TYPE_UINT(w)        ((value_type_spec_t){ .family = vt_uint,      .width = (w) })
#define BVN_TYPE_SINT(w)        ((value_type_spec_t){ .family = vt_sint,      .width = (w) })
#define BVN_TYPE_FLOAT(w)       ((value_type_spec_t){ .family = vt_float,     .width = (w) })
/* float_fix: .base is repurposed to store Q (fractional bits). */
#define BVN_TYPE_FLOAT_FIX(w,q) ((value_type_spec_t){ .family = vt_float_fix, .width = (w), .base = (q) })
/* float_dec: base field is unused (always 0).                   */
#define BVN_TYPE_FLOAT_DEC(w)   ((value_type_spec_t){ .family = vt_float_dec, .width = (w) })
/* With explicit numeral base (uint/sint only):                   */
#define BVN_TYPE_UINT_BASE(w,b) ((value_type_spec_t){ .family = vt_uint, .width = (w), .base = (b) })
#define BVN_TYPE_SINT_BASE(w,b) ((value_type_spec_t){ .family = vt_sint, .width = (w), .base = (b) })

16.3 Unit Macros

#define BVN_UNIT_NO_PREFIX(b) \
    ((value_unit_t){ \
        .num_components = 1, \
        .components = {{ \
            .base = (b), .exponent = exp_linear, \
            .prefix.system = prefix_si, .prefix.id.si = si_none \
        }} \
    })

#define BVN_UNIT_SI(b, p) \
    ((value_unit_t){ \
        .num_components = 1, \
        .components = {{ \
            .base = (b), .exponent = exp_linear, \
            .prefix.system = prefix_si, .prefix.id.si = (p) \
        }} \
    })

#define BVN_UNIT_IEC(b, p) \
    ((value_unit_t){ \
        .num_components = 1, \
        .components = {{ \
            .base = (b), .exponent = exp_linear, \
            .prefix.system = prefix_iec, .prefix.id.iec = (p) \
        }} \
    })

#define BVN_UNIT_SI_EXP(b, p, e) \
    ((value_unit_t){ \
        .num_components = 1, \
        .components = {{ \
            .base = (b), .exponent = (e), \
            .prefix.system = prefix_si, .prefix.id.si = (p) \
        }} \
    })

#define BVN_UNIT_NONE \
    ((value_unit_t){ .num_components = 0 })

/* Compound-unit helper: two SI-prefixed components */
#define BVN_UNIT_COMPOUND2(b1, p1, e1, b2, p2, e2) \
    ((value_unit_t){ \
        .num_components = 2, \
        .components = { \
            { .base = (b1), .exponent = (e1), \
              .prefix.system = prefix_si, .prefix.id.si = (p1) }, \
            { .base = (b2), .exponent = (e2), \
              .prefix.system = prefix_si, .prefix.id.si = (p2) } \
        } \
    })

16.4 Reader Setup

typedef struct bvnr_read_flags_s {
    uint16_t  max_identifier_length;  // default 255
    uint16_t  max_string_length;      // default 65535
    uint16_t  max_number_length;      // default 65535
    uint16_t  max_symbol_length;      // default 255
    uint16_t  max_reference_length;   // default 65535
    uint64_t  max_array_items;        // 0 → 2 147 483 647 internal default
    uint64_t  max_text_bytes;         // 0 → 2 147 483 647 internal default
    uint64_t  max_file_size;          // 0 → unlimited / endless (default); 16 777 216 recommended for a cap
    uint8_t   max_struct_nesting;     // 0 → 64 internal default; hard cap 255
    uint8_t   max_array_nesting;      // 0 → 64 internal default; hard cap 255
    void*     userdata;
    bool    (*on_unverified)(void*, bvnr_event_t, bvnr_data_t*);
    bool    (*on_verified)(void*, bvnr_event_t, bvnr_data_t*);
    bool      continue_on_error;
    bvnr_on_error_fn on_error;
    bool      strict_version;                 // reject a declared spec version newer than this build
    bool      want_unit_allow_nonterminating; // deliver a non-terminating exact conversion as a rational
    uint32_t  max_conversion_length;          // 0 → 1024; longest want_unit conversion text, in chars
    bool    (*want_unit)(void*, const bvnr_data_t*, value_unit_t*, uint32_t*);  // read-time lossless unit/base conversion hook
    uint64_t  _reserved[2];
} bvnr_read_flags_t;

16.5 Source/Sink Creation

void bvnr_source_from_fd(bvnr_source_t* s, int fd);
void bvnr_source_from_mem(bvnr_source_t* s, const void* buf, uint64_t len);
void bvnr_sink_to_fd(bvnr_sink_t* s, int fd);
void bvnr_sink_to_mem(bvnr_sink_t* s, void* buf, uint64_t cap);
uint64_t bvnr_sink_bytes_written(const bvnr_sink_t* s);

bvnr_sink_bytes_written queries the total bytes written to a memory sink created with bvnr_sink_to_mem by the caller; bvnr_open_write_sink copies the sink, so writer output never advances the caller's struct and this stays 0. For writer output use bvnr_writer_bytes_written (§16.7).

16.6 Reading

bool bvnr_open_read_source(bvnr_reader_t* r, const bvnr_source_t* src,
                        const bvnr_sink_t* src_mirror,
                        bvnr_read_flags_t* options);

bool bvnr_open_read_mem(bvnr_reader_t* r, const void* buf, uint64_t len,
                        void* mirror_buf, uint64_t mirror_cap,
                        bvnr_read_flags_t* options);

bool bvnr_read(bvnr_reader_t* r);

16.7 Error Queries

error_code_t bvnr_reader_get_error(const bvnr_reader_t* r);
uint64_t     bvnr_reader_get_error_line  (const bvnr_reader_t* r);
uint64_t     bvnr_reader_get_error_column(const bvnr_reader_t* r);
uint32_t     bvnr_reader_get_error_byte  (const bvnr_reader_t* r);
uint64_t     bvnr_reader_get_error_offset(const bvnr_reader_t* r);
uint64_t     bvnr_reader_get_recovery_count(const bvnr_reader_t* r);
const char*  bvn_error_to_string(error_code_t code);

16.8 Utility Functions

bool bvn_validate_identifier(const char* id);
bool bvn_validate_symbol(const char* surr);
bool bvn_validate_reference(const char* link);
bool bvn_validate_number(const char* s);
bool bvn_validate_string(const uint8_t* data, size_t len);

bool bvn_is_special_number_string(const char* s);
bool bvn_validate_digits_for_base(const char* s, uint32_t base);
bool bvn_validate_number_in_base(const char* s, uint32_t base);

bool bvn_validate_uint_range(const char* s, uint32_t w, uint32_t base);
bool bvn_validate_sint_range(const char* s, uint32_t w, uint32_t base);

uint32_t bvn_char_to_digit(uint32_t c, uint32_t base);
uint32_t bvn_min_digits_for_type(value_type_spec_t vt);

int32_t bvn_format_uint64(char* buf, size_t bufsize,
                           uint64_t value, uint32_t base, uint32_t min_digits);
int32_t bvn_format_int64(char* buf, size_t bufsize,
                          int64_t value, uint32_t base, uint32_t min_digits);
int32_t bvn_format_double(char* buf, size_t bufsize,
                           double value, value_type_spec_t vt);

bool bvn_parse_int64(const char* s, value_type_spec_t vt, int64_t* out);
bool bvn_parse_uint64(const char* s, value_type_spec_t vt, uint64_t* out);
bool bvn_parse_double(const char* s, value_type_spec_t vt, double* out);
bool bvn_parse_double_in_base(const char* s, uint32_t base, double* out);
bool bvn_looks_like_double(const char* s);

/* Parse a NUL-terminated unit string into a value_unit_t.
   Sets *ok to false on error. */
value_unit_t bvn_parse_unit(const uint8_t* unit, bool* ok);

/* Length-bounded variant; does not require a NUL terminator. */
value_unit_t bvn_parse_unit_n(const uint8_t* unit, uint32_t len, bool* ok);

/* Serialize a value_unit_t (possibly compound) back to a string.
   Numerator components are joined by "·", followed by "/" and
   denominator components joined by "·".  Returns bytes written,
   or -1 on buffer overflow. */
int32_t bvn_unit_to_string(value_unit_t u, char* buf, size_t bufsize);

/* Extended variant accepting bvn_unit_flags_t:
     BVN_UNIT_FLAGS_NONE  (0)      – Unicode superscript exponents, no reduction
     BVN_UNIT_ASCII_EXP  (1 << 1) – use ^N caret notation for exponents
     BVN_UNIT_REDUCE     (1 << 0) – reduce compound unit before serialising
   Flags may be OR-combined.  Returns bytes written, or -1 on overflow. */
int32_t bvn_unit_to_string_ex(value_unit_t u, char* buf, size_t bufsize,
                               bvn_unit_flags_t flags);

/* Returns true if every component in u has a valid exponent (not
   exp_invalid), a known base unit, and a prefix legal for that base
   unit per bvn_prefix_unit_valid.  Both serialisation functions call
   this predicate internally before writing. */
bool bvn_unit_valid(value_unit_t u);

/* Structural equality of two units: same num_components and the same set
   of components (matching base, exponent, and prefix).  The comparison is
   ORDER-INSENSITIVE — unit multiplication is commutative, so components are
   matched as multisets and "N·m" equals "m·N".  This is the comparison the
   validator uses to match an inline unit suffix against a type-annotation
   unit (error_unit_mismatch on disagreement).  For dimensional equivalence
   — units that measure
   the same physical quantity but differ in spelling or factoring (e.g.
   W vs VA, or the two no_unit forms) — use bvn_units_compatible from
   bovnar_si_units.h (§11.8) instead. */
bool bvn_unit_equal(value_unit_t a, value_unit_t b);

/* Compute the combined prefix factor across all components (ignoring
   base-unit conversion factors).  Each component's prefix factor is
   raised to |exponent| and multiplied together; denominator components
   are inverted. */
double bvn_unit_prefix_factor(value_unit_t u);

/* Compute the combined prefix exponent (sum of prefix_base_exponent ×
   |unit_exponent| across all components, negated for denominator
   components). */
int32_t bvn_unit_prefix_exponent(value_unit_t u);

const uint8_t* bvn_get_escape_repl_table(void);

16.9 Typed Write Helpers

Convenience functions that emit a type annotation + value in one call. All functions return false on serialisation error.

/* ── Plain scalar writers ──────────────────────────────────────────── */
bool bvnr_write_string(bvnr_writer_t* w, const char* key, const char* value);
bool bvnr_write_plain (bvnr_writer_t* w, const char* key, const char* value);
bool bvnr_write_null  (bvnr_writer_t* w, const char* key);
bool bvnr_write_bool  (bvnr_writer_t* w, const char* key, bool value);

/* ── Integer writers ───────────────────────────────────────────────── */
bool bvnr_write_uint(bvnr_writer_t* w, const char* key,
                     uint32_t width, uint64_t value);
bool bvnr_write_sint(bvnr_writer_t* w, const char* key,
                     uint32_t width, int64_t value);

/* ── Binary float writers ─────────────────────────────────────────── */
bool bvnr_write_float(bvnr_writer_t* w, const char* key,
                      uint32_t width, double value);

/* ── Fixed-point writers (float_fix) ─────────────────────────────── */
/* q = number of fractional bits (Q parameter). */
bool bvnr_write_float_fix(bvnr_writer_t* w, const char* key,
                           uint32_t width, uint32_t q, double value);

/* ── Decimal float writers (float_dec) ───────────────────────────── */
bool bvnr_write_float_dec(bvnr_writer_t* w, const char* key,
                           uint32_t width, double value);

/* ── Writers with explicit unit ───────────────────────────────────── */
bool bvnr_write_uint_unit     (bvnr_writer_t* w, const char* key,
                                uint32_t width, uint64_t value, value_unit_t unit);
bool bvnr_write_sint_unit     (bvnr_writer_t* w, const char* key,
                                uint32_t width, int64_t value, value_unit_t unit);
bool bvnr_write_float_unit    (bvnr_writer_t* w, const char* key,
                                uint32_t width, double value, value_unit_t unit);
bool bvnr_write_float_fix_unit(bvnr_writer_t* w, const char* key,
                                uint32_t width, uint32_t q,
                                double value, value_unit_t unit);
bool bvnr_write_float_dec_unit(bvnr_writer_t* w, const char* key,
                                uint32_t width, double value, value_unit_t unit);

/* ── Wide-precision float writers (bvn_float_t) ─────────────────── */
/* vt.width must match f->_prec or be 0 (auto).                      */
bool bvnr_write_bvnf     (bvnr_writer_t* w, const char* key,
                           const bvn_float_t* f, uint32_t width);
bool bvnr_write_bvnf_unit(bvnr_writer_t* w, const char* key,
                           const bvn_float_t* f, uint32_t width, value_unit_t unit);

/* ── Struct helpers ───────────────────────────────────────────────── */
bool bvnr_write_struct_start(bvnr_writer_t* w, const char* key);
bool bvnr_write_struct_end  (bvnr_writer_t* w);

16.10 Error Codes

typedef enum error_code_e {
    error_none                          = 0,
    error_unknown_token_type            = 1,
    error_array_row_size_mismatch       = 2,
    error_identifier_too_long           = 3,
    error_empty_identifier              = 4,
    error_struct_nesting_too_high       = 5,
    error_array_nesting_too_high        = 6,
    error_illegal_struct_close          = 7,
    error_string_too_long               = 8,
    error_illegal_escape_sequence       = 9,
    error_number_too_long               = 10,
    error_symbol_too_long               = 11,
    error_reference_too_long            = 12,
    error_read_complete_chunk_failed    = 13,
    error_octet_stream_out_of_sync      = 14,
    error_unexpected_input_byte         = 15,
    error_text_data_too_long            = 16,
    error_reading_from_source_fd        = 17,
    error_got_incomplete_bvnr_stream    = 18,
    error_invalid_utf8_byte             = 19,
    error_invalid_byte_order_mark       = 20,
    error_type_too_long                 = 21,
    error_unit_too_long                 = 22,
    error_expected_string_in_array      = 23,  /* reserved; never set by the library */
    error_expected_number_in_array      = 24,  /* reserved; never set by the library */
    error_illegal_value_type            = 25,
    error_scanner_callback_failed       = 26,
    error_file_too_long                 = 27,
    error_invalid_argument              = 28,
    error_too_many_array_items          = 29,
    error_writing_to_sink               = 30,
    error_sink_buffer_exhausted         = 31,
    error_unit_illegal                  = 32,
    error_base_requires_string_literal  = 33,
    error_type_value_mismatch           = 34,
    error_value_out_of_range            = 35,
    error_digit_not_in_base             = 36,
    error_recovered                     = 37,  /* reserved; never set by the library */
    error_unit_mismatch                 = 38,
    /* Array element homogeneity (spec 1.0): every non-null element of an array
     * must share the same kind and physical dimension; sibling sub-arrays must
     * match in length and element shape (recursively); sibling structs must
     * share the same keys with recursively-matching fields. */
    error_array_element_type_mismatch   = 39,
    error_struct_shape_mismatch         = 40,
    /* A struct (or the top-level document) repeats a key. Keys must be unique
     * within one scope so lookup, references and iteration always agree. */
    error_duplicate_struct_key          = 41,
    /* spec 1.1 — a leading "#!bovnar …" directive is present but malformed. */
    error_invalid_spec_version          = 42,
    /* spec 1.1 — the declared version exceeds what the reader supports and
     * strict_version was set. */
    error_unsupported_spec_version      = 43,
    /* spec 1.1 — a \u{…} escape names a non-scalar value (a surrogate, or a
     * code point above U+10FFFF). */
    error_invalid_codepoint             = 44,
    /* spec 1.1 — an ISO-8601 datetime literal is malformed or has an
     * out-of-range field (bad width, separator, month/day/time component). */
    error_invalid_datetime_literal      = 45,
    /* spec 1.1 — an ISO-8601 literal was given for an atomic GNSS epoch
     * (gps/galileo/glonass/beidou), which has no round-trippable inverse. */
    error_datetime_literal_unsupported_epoch = 46,
    /* a lossless read-time unit/base conversion (want_unit) could not be
     * performed: an irrational factor, or a non-terminating expansion. */
    error_unit_inexact                       = 47,
    /* a multiplexed message was still short when its octet stream ended. */
    error_octet_stream_truncated             = 48,
} error_code_t;

17. Versioning & Stability

Bovnar follows semantic versioning of the format, independent of any implementation's version.

Implementation version. The reference implementation exposes its own version, which tracks the format version but may advance independently for implementation-only fixes. The C header defines BVNR_VERSION_MAJOR, BVNR_VERSION_MINOR, BVNR_VERSION_PATCH, the comparable integer BVNR_VERSION (major*10000 + minor*100 + patch, so #if BVNR_VERSION >= 10100 tests "≥ 1.1.0"), and BVNR_VERSION_STRING ("1.1.0"). The Python package mirrors this as bovnar.__version__. Separately, BVNR_SPEC_VERSION_MAJOR / BVNR_SPEC_VERSION_MINOR name the highest spec version the build understands (1.1); bvnr_version(), bvnr_version_string() and bvnr_spec_version() expose both at runtime.

Declaring a document's version. Since 1.1 a document may state which spec version it targets with a leading #!bovnar <major>.<minor> directive (§3.4). Because the directive is lexically a comment, this is fully backward compatible: a 1.0 reader ignores it. A 1.1+ reader records it (bvnr_reader_get_declared_version) and, in strict_version mode, rejects a version it does not support.

What 1.0 freezes. The grammar is stable. A .bvnr document that is valid under spec 1.0 will remain valid, and will decode to the same values, under every 1.x revision. This covers the lexical structure, the type families and their annotations, arrays (including the homogeneity rules of §7.4), structs, octet streams, references, and the error-code values in §16.10. Conforming archives may rely on this for long-term storage.

What may still grow in 1.x (additive only). The following may be extended without breaking existing documents, and such extensions ship as minor (1.x) revisions:

A reader from an older 1.x point release may not recognise a unit or currency added in a newer one; that is the expected direction of forward compatibility and is not a break of the 1.0 promise.

What requires a 2.0. Any change that could render a valid 1.x document invalid, change how it decodes, renumber an error code, or alter the grammar is a breaking change and is reserved for a major (2.0) revision. The changes that motivated the 1.0 freeze — the mandatory $ currency sigil (§10.4 of the unit-system reference), array element homogeneity (§7.4), and float_fix value-range validation (§6.2, rejecting a value the declared Q-format cannot represent) — were exactly such breaks, so they were made before 1.0 and cannot be reconsidered within 1.x.


Appendix A: Event Sequence Reference

A.1 Simple Assignment (Untyped)

Input: .foo = 42;

ev_stream_start
ev_assignment_start            data="foo"
ev_type_annotation_start       (synthesised)
ev_type_annotation_type_family "uint"
ev_type_annotation_type_family_parameter  (width:64)
ev_type_annotation_type_family_parameter  (base:_10)
ev_type_annotation_type_family_parameter  (unit:no_unit)
ev_type_annotation_end
ev_data                        data="42"
ev_stream_end

Every stream is bracketed by ev_stream_startev_stream_end; the reader emits ev_stream_end once after the final assignment (it is omitted from the remaining appendix examples for brevity).

A.2 Typed Assignment

Input: .bar = <float:32,m/s> 9.81;

ev_assignment_start            data="bar"
ev_type_annotation_start       data="float:32,m/s"
ev_type_annotation_type_family "float"
ev_type_annotation_type_family_parameter  (width:32)
ev_type_annotation_type_family_parameter  (unit:m/s)
ev_type_annotation_end
ev_data                        data="9.81"

A.3 Compound Unit Assignment

Input: .force = <float:64,k~g·m/s²> 9.81;

ev_assignment_start            data="force"
ev_type_annotation_start       data="float:64,k~g·m/s²"
ev_type_annotation_type_family "float"
ev_type_annotation_type_family_parameter  (width:64)
ev_type_annotation_type_family_parameter  (unit:k~g·m/s²)
  → value_unit = {
      num_components = 3,
      components = [
        { base=bu_gram,   exponent=exp_linear,     prefix={prefix_si, si_kilo} },
        { base=bu_meter,  exponent=exp_linear,     prefix={prefix_si, si_none} },
        { base=bu_second, exponent=exp_neg_square, prefix={prefix_si, si_none} }
      ]
    }
ev_type_annotation_end
ev_data                        data="9.81"

A.4 Array

Input: .arr = [1, 2]/[3, 4];

ev_assignment_start            data="arr"
ev_array_row_start
ev_type_annotation_start       (synthesised for 1)
ev_type_annotation_type_family "uint"
...params...
ev_type_annotation_end
ev_data                        data="1"
ev_type_annotation_start       (synthesised for 2)
...params...
ev_type_annotation_end
ev_data                        data="2"
ev_array_row_end
ev_array_dim_start
ev_array_row_start
ev_type_annotation_start       (synthesised for 3)
...params...
ev_type_annotation_end
ev_data                        data="3"
... (4) ...
ev_array_row_end

A.5 Struct

Input: .s = {.x = 1; .y = 2;};

ev_assignment_start            data="s"
ev_struct_start
ev_assignment_start            data="x"
...ev_data for 1...
ev_assignment_start            data="y"
...ev_data for 2...
ev_struct_end

A.6 Octet Stream

Input: .bin = \x00\x01\x03\x00abc\x00;

ev_assignment_start            data="bin"
ev_octet_stream_start
ev_data (octet_stream)         data="abc", length=3
ev_octet_stream_end

Appendix B: Implementation Notes

B.1 Keyword State Machine

Type family keywords are recognised through a dedicated state machine in the lexer. The lexer fires ACT_tf_float_done after the shared f→l→o→a→t path, storing "float" in type_data and transitioning to type_body_outro. If the next bytes are _fix or _dec, they are accumulated via copy_type_byte, so the final string is "float_fix" or "float_dec". bvn_parse_type_annotation then dispatches on the full accumulated string.

u → i → n → t           → keyword "uint"
u → t → f → 8           → keyword "utf8"
s → i → n → t           → keyword "sint"
f → l → o → a → t       → ACT_tf_float_done → type_body_outro
                                              → accumulate "_fix" → "float_fix"
                                              → accumulate "_dec" → "float_dec"
                                              → (nothing)         → "float"

B.2 Special Number Keywords

The special floats are bare reserved keywords — nan, inf, and ninf (negative infinity) — with no sigil. The lexer reads them as ordinary symbols; the validator then reclassifies a symbol whose text is exactly one of these three spellings into a numeric special value (token_is_number), the same way it reclassifies null/true/false/on/off:

symbol "nan"   → reclassify → special number  "nan"   (3 bytes)
symbol "inf"   → reclassify → special number  "inf"   (3 bytes)
symbol "ninf"  → reclassify → special number  "ninf"  (4 bytes)

Any other bare word (e.g. infinity, nans) stays an ordinary symbol. The stored token text is the keyword itself: nan, inf, ninf. A special-number keyword takes no inline unit suffix; a unit is supplied through the type annotation (<float:64,m/s> inf).

B.3 Default Width, Base, and Q

static inline uint32_t bvn_effective_width(value_type_spec_t s) {
    return s.width ? s.width : 64u;
}

/*
 * For float_fix, float_dec and datetime the .base field has a different meaning
 * (Q for float_fix, unused for float_dec, epoch index for datetime); always
 * report base 10 for those — the datetime carrier is decimal epoch-seconds, so
 * decoding it in the epoch index stored in .base would corrupt the value.
 */
static inline uint32_t bvn_effective_base(value_type_spec_t s) {
    if (s.family == vt_float_fix || s.family == vt_float_dec ||
        s.family == vt_datetime)
        return 10u;
    return s.base ? s.base : 10u;
}

/*
 * Returns the Q (fractional bits) for float_fix, 0 for all other families.
 * Q is stored in the .base field of value_type_spec_t.
 */
static inline uint32_t bvn_effective_q(value_type_spec_t s) {
    return (s.family == vt_float_fix) ? s.base : 0u;
}

B.4 Type Equality

static inline bool bvn_type_spec_eq(value_type_spec_t a, value_type_spec_t b) {
    return a.family == b.family && a.width == b.width && a.base == b.base;
}

For float_fix, .base holds Q, so two float_fix specs are equal only if they share the same width and the same Q.

B.5 Numeric Type Check

static inline bool bvn_type_is_numeric(value_type_spec_t s) {
    return s.family == vt_sint      || s.family == vt_uint    ||
           s.family == vt_float     ||
           s.family == vt_float_fix || s.family == vt_float_dec;
}

B.6 Unit Component Access

When iterating compound units, always check num_components:

for (uint32_t i = 0; i < u.num_components && i < BVNR_MAX_UNIT_COMPONENTS; i++) {
    value_unit_component_t* c = &u.components[i];
    /* c->base, c->exponent, c->prefix.system, c->prefix.id */
}

B.7 Fixed-point and Decimal Float Wire Representations

float_fix and float_dec are both serialised as ordinary decimal number literals in the Bovnar text layer. The type annotation is the sole indicator of wire encoding. At the C API level, the conversion path is:

float_fix (width ≤ 64):
    text literal → bvn_float_t → bvn_float_to_fixNN(f, frac_bits) → signed integer return value

float_fix (width = 128, 256):
    text literal → bvn_float_t → bvn_float_to_fixNN(f, frac_bits, out) → wire bits in out[]

float_dec:
    text literal → bvn_float_t → bvn_float_to_decNN(f, out) → wire bits in *out / out[]

The bvn_float_t intermediate representation is MPFR-layout-compatible (see bvn_float.h) and provides exact round-trip fidelity up to the declared precision.


Appendix C: Limits Summary

Constant Value Description
reader default struct nesting 64 Default applied by the reader when max_struct_nesting is 0; hard cap is 255
reader default array nesting 64 Default applied by the reader when max_array_nesting is 0; hard cap is 255
writer default struct nesting 64 Default applied by the writer when max_struct_nesting is 0; hard cap is 255
writer default array nesting 64 Default applied by the writer when max_array_nesting is 0; hard cap is 255
reader default max_array_items 2 147 483 647 Default applied by the reader when max_array_items is 0
reader default max_text_bytes 2 147 483 647 Default applied by the reader when max_text_bytes is 0
reader default max_file_size 0 (unlimited / endless) A max_file_size of 0 imposes no byte-count cap; set to 16 777 216 (16 MiB) in production
recommended file size cap 16 777 216 Suggested explicit value for max_file_size (16 MiB)
BVNR_MAX_UNIT_COMPONENTS 8 Maximum number of unit components in a compound unit
BVN_MAX_INT_WIDTH 32768 Maximum bit-width for uint and sint types. The validator and writer reject any declared width exceeding this value with error_illegal_value_type.

End of Bovnar Specification (v1.1)