# Bovnar (BVNR) — Full documentation

This single file concatenates the Bovnar landing page and the complete documentation set, for one-fetch ingestion by LLM tools. The canonical, individually-linkable Markdown sources live under https://www.bovnar.io/doc/.

License:

- Documentation text (under /doc/ and /docs/, and this file): [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Use, quoting, summarising and AI training are permitted with attribution to "Janos Sonntag, Bovnar (https://www.bovnar.io)".
- Except: the identifier strings and unit names from external vocabularies (UCUM, QUDT, OM 2, UDUNITS-2, the CF standard name table, UN/ECE Rec 20/21) quoted in the [unit profiles](https://www.bovnar.io/doc/11_bovnar_unit_profiles.md) belong to their publishers and are not covered by the grant above; their terms are listed in its section 18 and in [THIRD_PARTY_NOTICES.md](https://github.com/sothis/bovnar/blob/main/THIRD_PARTY_NOTICES.md).
- Source code: [MIT](https://github.com/sothis/bovnar/blob/main/LICENSE).
- Examples: [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/).

<!-- ==================== Landing page ==================== -->

# Bovnar (BVNR)

**Unit-safe serialization for scientific and industrial systems — with a C99 reference implementation.**

## Links

- **Website:** https://www.bovnar.io
- **Prebuilt downloads:** https://github.com/sothis/bovnar/releases
- **IANA media type (`text/vnd.bovnar`):** https://www.iana.org/assignments/media-types/text/vnd.bovnar
- **DOI — Documentation and Specification (latest):** https://doi.org/10.5281/zenodo.21443295
- **DOI — Source (latest):** https://doi.org/10.5281/zenodo.21443008

---

## Overview

In scientific and industrial systems, the expensive failures are rarely bad syntax — they are unit confusion: a value sent in pounds-force and read as newtons, feet read as meters. The number parses fine; the dimension is wrong.

Bovnar closes that gap. Every value in a `.bvnr` document carries its own type family, bit-width, numeric base, and **physical unit** — inline, in the byte stream, with no external schema. The unit is not a comment or a naming convention; it is part of the value and is validated by the parser. Annotate a measurement as `m/s` and write a mismatched inline unit, and parsing fails with `error_unit_mismatch`. Hand the file to anyone and they have everything required to interpret — and to dimensionally trust — every reading.

<!-- bovnar-example: illustrative -->
```bovnar
#!bovnar 1.1
# The optional spec-version directive above carries the version and nothing
# else; a comment on that line is error_invalid_spec_version. Comments like
# this one, and the trailing ones below, are ordinary and inert.
# A self-describing configuration document
.config = {
    .host      = "api.example.com";
    .port      = <uint:16> 443;
    .limits    = {
        .timeout    = <float:64,s> 2.5;
        .max_packet = <uint:64,Mi~B> 16;
    };
};
.acceleration = 70.5 k~m·s⁻²;
.velocity     = <float:64,m/s> 9.81;
.price        = <float_dec:64,$USD> 19.99;
.btc_balance  = <uint:64,$BTC> 547820000;   # satoshis
.created      = <datetime:64,unix> 1750000000;   # a timestamp (spec 1.1)
.payload      = \x00 … binary stream … \x00;
.matrix       = [1, 2, 3]/[4, 5, 6];
.cell         = &.matrix[0][1];             # reference indexing (spec 1.1) → 2
```

---

## Key Features

- **Strong, optional typing** — seven core families (`uint`, `sint`, `float`, `float_fix`, `float_dec`, `utf8`, `bool`), plus `datetime` (spec 1.1), with explicit bit-width (`8`, `16`, `32`, `64`, …) and numeric base (`_2`, `_16`, `_36`, `_85`, …).
- **First-class physical units** — SI base units, derived SI units, and IEC binary prefixes. Compound units such as `m/s²`, `k~g·m/s²`, and `Gi~B` are written inline; no external schema is needed. A prefix may also be written the way everyone writes it — `kg`, `km`, `MHz`, `MiB`, `k$USD` — with `k~g` remaining the canonical output form.
- **Currency units** — 166 ISO 4217 fiat currencies and 50 cryptocurrencies are first-class units, written with a mandatory `$` sigil (`<float_dec:64,$USD> 19.99`, `<uint:64,$BTC> 547820000`), each carrying minor-unit metadata and prefix-validity rules.
- **Inline unit suffix** — `9.81 m/s` is valid without a full type annotation.
- **Native binary embedding** — Octet streams (`\x00 … \x00`) carry raw bytes without Base64 overhead. Chunks are length-prefixed, so there is no escaping, no expansion, and no byte a payload may not contain — and a reader can skip a binary region without inspecting it. The cost is that such a document is **not** transport-safe through anything that rewrites bytes: line-ending normalisation inside a payload desynchronises the length prefixes and the damage is unrecoverable. A consumer whose pipeline is text-shaped can refuse the binary half outright with `--text-only` (`bvnr_read_flags_t.text_only`), which fails at the stream's opening byte with `error_octet_stream_forbidden`.
- **Multi-dimensional arrays** — Rows separated by `/`; `[1,2,3]/[4,5,6]` is a native 2D structure.
- **Schema-free yet type-safe** — Omit annotations and get well-defined defaults (`uint:64`, `float:64`, …); add them and the parser validates on the fly.
- **Streaming SAX-style reader** — Incremental parsing from memory, a file descriptor, or a socket via a symmetric `on_unverified` / `on_verified` callback pair.
- **Error recovery** — Optional resync mode skips broken assignments and continues parsing — suitable for log streams and unreliable transports.
- **Python bindings** — Pure-`ctypes`, no compiled extension required. Exposes both a high-level `loads`/`dumps` dict-like API and a low-level event-driven streaming API.
- **Rust bindings** — the `bovnar` crate compiles the C library in, so there is no system library to install. It offers `parse`/`to_string` over a dynamic `Value`, the streaming `Reader`/`Writer`, the DOM, exact numbers and JSON conversion ([doc/14](doc/14_bovnar_rust_bindings.md)).
- **Command-line tool** — `bovnar` validates, queries values by path, pretty-prints, converts to and from JSON, dumps the lexer/validator event stream, and benchmarks parsing and streaming throughput.
- **Browser playground** — the real C reference parser, compiled to WebAssembly (`bovnar_parser_wasm.js` over `bovnar_wasm_core.js`), runs the reference verified event stream (with full type/unit/value validation) in the browser and powers an interactive web playground.
- **Syntax highlighting** — Ready-made grammars for VS Code, Sublime Text, Geany, Vim, and CLion (JetBrains), all sharing one "cyberpunk" colour scheme with depth-cycling brackets.
- **Extensively tested** — Unit tests, socket-pair round-trip tests, a 470-case conformance suite, fuzz harnesses (reader, push-fed reader, writer, DOM, utils), Python and Rust test suites with cargo-fuzz targets for the crate, and a built-in benchmark mode (`bovnar bench`).

---

## Format at a Glance

| Construct | Syntax | Example |
|---|---|---|
| Assignment | `.key = value ;` | `.x = 42;` |
| Comment | `# … newline` | `# a remark` |
| Type annotation | `<family:width,base,unit>` before value | `<uint:32,_10,k~m> 1000` |
| Integer | `[-]digits` | `42`, `-7` |
| Float | `[-]digits[.digits][e[±]digits]` | `3.14`, `1e-6` |
| Special float | `nan` `inf` `ninf` | `.x = nan;` |
| Boolean | `true` `false` `on` `off` (`<bool>`) | `.b = on;` |
| String | `"…"` with C-style escapes (`\u{…}`/`\xHH` in spec 1.1) | `"hello\nworld"`, `"caf\u{e9}"` |
| Symbol | bare identifier (no quotes) | `ok`, `Monday` |
| Reference | `&.path.to.key`; array indexing `&.path[i][j]` (spec 1.1) | `&.config.host`, `&.matrix[0][1]` |
| Array | `[ … ]` rows separated by `/` | `[1,2]/[3,4]` |
| Struct | `{ .key = val; … }` | `{.x = 1; .y = 2;}` |
| Null | empty slot or `null` keyword | `.x = ;`, `.x = null;` |
| Octet stream | `\x00 … binary … \x00` | raw bytes |
| Version directive (spec 1.1) | `#!bovnar <major>.<minor>` (first comment) | `#!bovnar 1.1` |
| Datetime (spec 1.1) | `<datetime:width,epoch>` signed epoch seconds | `<datetime:64,unix> 1750000000` |
| Datetime literal (spec 1.1) | ISO-8601 (`Z` or `±HH:MM` offset, optional fraction); bare form infers `<datetime:64,unix>` | `2026-06-15T12:00:00+02:00` |

---

## Documentation

- [Tutorial](https://www.bovnar.io/doc/01_bovnar_tutorial.md) — A guided introduction to writing and reading Bovnar documents.
- [FAQ](https://www.bovnar.io/doc/02_bovnar_faq.md) — Common questions on types, units, limits, encoding, and the API.
- [Specification](https://www.bovnar.io/doc/03_bovnar_spec.md) — The complete format specification (spec 1.2): grammar, types, units, limits.
- [Unit & currency cheatsheet](https://www.bovnar.io/doc/04_bovnar_unit_cheatsheet.md) — Every unit symbol, prefix, and currency code in one reference table.
- [Unit system](https://www.bovnar.io/doc/05_bovnar_unit_system.md) — The 264 physical units, SI/IEC prefixes, and 216 currencies, with dimensions and factors.
- [Unit policy](https://www.bovnar.io/doc/06_bovnar_unit_policy.md) — What a policy validates and converts, the order it resolves in, and the errors it raises.
- [Unit ambiguities](https://www.bovnar.io/doc/07_bovnar_unit_ambiguities.md) — Which reading wins when a token could mean two things, and how to write the other.
- [Read & Write C API](https://www.bovnar.io/doc/08_bovnar_readwrite_api.md) — The C reader/writer/DOM API reference (bovnar.h, bovnar_dom.h).
- [Python bindings](https://www.bovnar.io/doc/09_bovnar_python_bindings.md) — The pure-ctypes Python package: loads/dumps, streaming, DOM, Quantity, NumPy/Pint bridges.
- [Streaming & framing](https://www.bovnar.io/doc/10_bovnar_streaming.md) — Octet streams, frames, multiplexing/demultiplexing, and embedded documents.
- [Unit profiles](https://www.bovnar.io/doc/11_bovnar_unit_profiles.md) — The ucum:, unece:, qudt:, qudt-qk:, udunits:, om: and cf: notations, their transliteration tables, what they refuse, and how they are checked against each other.
- [EBNF grammar](https://www.bovnar.io/doc/12_bovnar.ebnf) — The formal grammar, annotated against the reference lexer/validator.
- [Conformance](https://www.bovnar.io/doc/13_bovnar_conformance.md) — The 470-case conformance suite and the IUT protocol for third-party implementations.
- [Rust bindings](https://www.bovnar.io/doc/14_bovnar_rust_bindings.md) — The Rust crate: parse/to_string, streaming reader/writer, DOM, Quantity and exact numbers, JSON.

Browse the documentation as HTML at [https://www.bovnar.io/docs/](https://www.bovnar.io/docs/). A single-file copy of the whole set is at [https://www.bovnar.io/llms-full.txt](https://www.bovnar.io/llms-full.txt).

<!-- ==================== Tutorial (https://www.bovnar.io/doc/01_bovnar_tutorial.md) ==================== -->

# Bovnar — Tutorial

> **Spec version:** 1.2
> **Status:** Non-normative — a guided, example-driven introduction
> **Scope:** Writing and reading Bovnar documents by hand: types, units, arrays, structs, references, and the event model.

---

## Table of Contents

1. [Why Bovnar Exists](#1-why-bovnar-exists)
2. [The Absolute Minimum: A Valid Bovnar File](#2-the-absolute-minimum-a-valid-bovnar-file)
3. [Keys: Identifiers](#3-keys-identifiers)
4. [Value Tokens: What Can Follow the `=` Sign](#4-value-tokens-what-can-follow-the--sign)
5. [Numbers and the Type Annotation](#5-numbers-and-the-type-annotation)
    - 5.1 [Bare Literals](#51-bare-literals)
    - 5.2 [Type Annotation Syntax](#52-type-annotation-syntax)
    - 5.3 [Type Families](#53-type-families)
    - 5.4 [Parameters](#54-parameters)
6. [Units](#6-units)
    - 6.1 [Simple Units](#61-simple-units)
    - 6.2 [SI Prefixes](#62-si-prefixes)
    - 6.3 [Compound Units](#63-compound-units)
    - 6.4 [Explicitly Dimensionless: `no_unit`](#64-explicitly-dimensionless-no_unit)
    - 6.5 [Inline Unit Suffix](#65-inline-unit-suffix)
    - 6.6 [Units From Another Vocabulary (spec 1.2)](#66-units-from-another-vocabulary-spec-12)
7. [Fixed-Point Numbers: `float_fix`](#7-fixed-point-numbers-float_fix)
8. [Decimal Floating-Point: `float_dec`](#8-decimal-floating-point-float_dec)
9. [Strings](#9-strings)
    - 9.1 [Escape Sequences](#91-escape-sequences)
    - 9.2 [String Concatenation](#92-string-concatenation)
10. [Symbols](#10-symbols)
11. [Booleans](#11-booleans)
12. [Null Values](#12-null-values)
13. [References](#13-references)
14. [Arrays](#14-arrays)
    - 14.1 [Multi-Dimensional Rows](#141-multi-dimensional-rows)
    - 14.2 [Per-Element Type Annotations](#142-per-element-type-annotations)
    - 14.3 [Structs as Array Elements](#143-structs-as-array-elements)
15. [Structs](#15-structs)
16. [Octet Streams: Inline Binary](#16-octet-streams-inline-binary)
17. [Putting It Together: Realistic Examples](#17-putting-it-together-realistic-examples)
    - 17.1 [Hardware Configuration](#171-hardware-configuration)
    - 17.2 [Scientific Measurement Batch](#172-scientific-measurement-batch)
    - 17.3 [Multi-Dimensional Matrix](#173-multi-dimensional-matrix)
    - 17.4 [Mixed Binary and Text](#174-mixed-binary-and-text)
18. [Common Mistakes and the Errors They Produce](#18-common-mistakes-and-the-errors-they-produce)
19. [Error Recovery Mode](#19-error-recovery-mode)
20. [Size Limits](#20-size-limits)
21. [Event Model (C API Overview)](#21-event-model-c-api-overview)
22. [Quick-Reference: Syntax Cheat Sheet](#22-quick-reference-syntax-cheat-sheet)

- [See also](#see-also)

---

## 1. Why Bovnar Exists

Bovnar is **unit-safe serialization for scientific and industrial systems**. In those domains, the expensive failures are rarely bad syntax — they are unit confusion. A thrust value sent in pounds-force and read as newtons. An altitude in feet read as meters. The number parsed perfectly; the dimension was wrong, and nothing in the data said otherwise.

A bare `9.81` carries no dimension on its own: it could be meters per second, volts, or a dimensionless ratio, and the receiving application has no way to know without an external schema, a naming convention, or documentation. The unit lives outside the data, and that gap is where the expensive failures hide.

Bovnar makes a different choice: every value in a Bovnar document is **self-describing and unit-safe**. The type family (signed integer, float, string…), the bit-width, the numeric base, and the physical unit all travel *with* the value, in the same byte stream, without any external schema — and the unit is validated by the parser rather than assumed. Annotate a value as `m/s` and write a mismatched inline unit, and parsing fails. Hand a `.bvnr` file to anyone, and they have everything needed to interpret *and dimensionally trust* it: a 64-bit signed integer measured in kilopascals looks different from a 32-bit unsigned integer measured in mebibytes, and the format makes that difference explicit.

The format also supports raw binary embedding, multi-dimensional arrays with a clean row-separator syntax, and incremental streaming parsing — all in a text layer that is pleasant to read and write by hand.

---

## 2. The Absolute Minimum: A Valid Bovnar File

A Bovnar file is a sequence of **assignments**. Every assignment looks like this:

```bovnar
.key = value;
```

Three things to notice immediately:

1. The key always starts with a dot.
2. The value comes after `=`, optionally preceded by a type annotation.
3. Every assignment ends with a semicolon.

The simplest possible complete file:

```bovnar
.greeting = "hello";
.count    = 42;
.ratio    = 3.14;
```

Comments begin with `#` and run to end-of-line:

```bovnar
# This is a config file
.host    = "localhost";     # the server address
.port    = 5432;            # PostgreSQL default
```

The format is UTF-8 throughout. A UTF-8 BOM is accepted only at the very first byte of the stream.

---

## 3. Keys: Identifiers

A key is the bare word after the leading dot. Identifiers follow these rules:

- Must begin with a letter (`A–Z`, `a–z`), an underscore, or a UTF-8 leader byte in the range 0xC3–0xF4 (i.e., most non-ASCII letters).
- The body may also contain `+`, `-`, and digits.
- Byte `0xC2` (the UTF-8 leader for U+0080–U+00BF) is rejected at both the start and body of an identifier.
- Most ASCII punctuation is forbidden — specifically `! " # $ % & ' ( ) * , . / : ; < = > ? @ [ \ ] ^ ` { | } ~`. If you hit a parse error on what looks like a valid identifier, one of these characters is likely hiding inside it.
- An empty key (`.=`) is a hard error.

```bovnar
.simple       = 1;
.with_under   = 2;
.with-hyphen  = 3;
.with+plus    = 4;
.camelCase    = 5;
.ALL_CAPS     = 6;
.v2           = 7;     # digit in body is fine; not at start
```

This would fail:

<!-- bovnar-example: rejected -->
```bovnar
.123invalid = 1;       # error: starts with a digit
.bad,key    = 1;       # error: comma is forbidden in identifiers
```

---

## 4. Value Tokens: What Can Follow the `=` Sign

These are the categories of raw values you will write by hand (two more — the ISO-8601 datetime literal of spec 1.1, §5.3, and the octet stream, §16 — complete the list):

| Category | Example |
|---|---|
| Integer literal | `42`, `-7`, `"FF"` (base-16, quoted) |
| Float literal | `3.14`, `1e6`, `-.5` |
| Special number | `nan`, `inf`, `ninf` |
| Boolean | `true`, `false`, `on`, `off` |
| Quoted string | `"hello world"` |
| Symbol (bare word) | `ok`, `Monday`, `read_only` |
| Reference | `&.other.key` |
| Array | `[1, 2, 3]` |
| Struct | `{.x = 1; .y = 2;}` |
| Null | `;` (empty) or the `null` keyword |

Each is covered in depth below, starting with numbers because that is where Bovnar's type system is most visible.

---

## 5. Numbers and the Type Annotation

### 5.1 Bare Literals

Numbers are written as plain text. Positive integers default to `uint:64`, negative integers to `sint:64`, and anything with a `.` or `e` to `float:64`:

```bovnar
.a = 42;            # → uint:64
.b = -7;            # → sint:64
.c = 3.14;          # → float:64
.d = 1e6;           # → float:64 (1 000 000)
.e = -.5;           # → float:64 (leading-dot form, -0.5)
.f = 123.;          # → float:64 (trailing dot, valid)
.g = 007;           # → uint:64 (leading zeros are legal; value is 7)
```

This automatic type assignment is called **default type synthesis**. It is convenient for quick files and config work. For anything where precision matters, you add an explicit type annotation.

### 5.2 Type Annotation Syntax

The annotation is enclosed in angle brackets and placed **between `=` and the value** — not on the key, not after the value:

```bovnar
.port   = <uint:16> 443;
.temp   = <float:32> 23.5;
.offset = <sint:64> -2147483648;
```

Placing the annotation anywhere else is a hard error:

<!-- bovnar-example: rejected -->
```bovnar
.port<uint:16> = 443;      # WRONG: annotation must come after '='
```

### 5.3 Type Families

There are seven core type families (spec 1.0), plus `datetime` added in spec 1.1:

| Keyword | Description |
|---|---|
| `uint` | Unsigned integer |
| `sint` | Signed integer |
| `float` | IEEE 754 binary floating-point |
| `float_fix` | Q-format signed fixed-point |
| `float_dec` | IEEE 754-2008 decimal floating-point |
| `utf8` | UTF-8 string |
| `bool` | Boolean (`true`/`false`); takes no parameters |
| `datetime` | Timestamp: signed epoch-seconds, or an ISO-8601 literal like `2026-06-15T12:00:00Z` (spec 1.1; needs a `#!bovnar 1.1` directive) |

### 5.4 Parameters

After the family keyword you may add parameters separated by commas after a `:`. The three parameter classes are **width**, **base**, and **unit**. They are identified by their syntactic form and may appear in any order:

```bovnar
.a = <uint:32>           1000;   # width only
.b = <uint:32,_16>       "FF";   # width + base (hex)
.c = <uint:32,m>         500;    # width + unit (meters)
.d = <uint:32,_16,m>     "1F4";  # all three — 500 meters in hex
.e = <uint:_16,m,32>     "1F4";  # same thing; order doesn't matter
```

#### Width

Width is a plain decimal number of bits. `0` means "use the default," which is always 64:

```bovnar
.a8  = <uint:8>   255;
.a16 = <uint:16>  65535;
.a32 = <uint:32>  4294967295;
.a64 = <uint:64>  18446744073709551615;
.def = <uint:0>   99;           # same as <uint:64>
```

For `float`, valid widths are `0`, `16`, and any multiple of `32` up to `32768`. Width `8` or a non-multiple-of-32 (other than 16) is a hard error. For `float_fix` and `float_dec`, valid widths are `0`, `16`, `32`, `64`, `128`, and `256`.

```bovnar
.half   = <float:16>  3.14;    # half-precision
.single = <float:32>  3.14;    # single-precision
.double = <float:64>  3.14;    # double-precision
.quad   = <float:128> 3.14;    # quad-precision
```

For `sint` and `uint`, any width from 1 up to 32768 bits (`BVN_MAX_INT_WIDTH`) is accepted — `<uint:12>` is perfectly valid for a 12-bit ADC sample.

#### Base

The base parameter starts with `_` followed by a decimal number. For `uint` and `sint`, every base from `_2` through `_62` (consecutive) is allowed. Bases `_64` (standard Base64) and `_85` (Ascii85) are also available, but **only for `uint`**: their alphabets claim the sign glyphs as digits — Base64 uses `+` (digit 62) and `/`, Ascii85 uses both `+` and `-` — so no unambiguous sign character remains and signed values are illegal (`error_illegal_value_type`). The base selects the numeral system used to interpret the value.

This is the biggest surprise for newcomers: **non-decimal values should be provided as quoted strings**. A bare `ff` in value position is lexed as a *symbol*, not a number, and will produce a type mismatch error. A bare literal made only of digits of the base does parse — and is read in that base, so `<uint:8,_16> 10` is sixteen, and `<uint:_16> 1e3` is 483 because `e` is a hex digit — which is one more reason to always quote non-decimal digits:

<!-- bovnar-example: rejected -->
```bovnar
.hex = <uint:8,_16>  "FF";       # correct — 255
.hex = <uint:8,_16>  FF;         # WRONG — FF is a symbol
.bin = <uint:8,_2>   "11010110"; # correct — 214
.oct = <uint:16,_8>  "755";      # correct — Unix permission rwxr-xr-x
```

Signed values use a minus prefix inside the string:

```bovnar
.neg_hex = <sint:32,_16> "-7FFFFFFF";
.neg_bin = <sint:8,_2>   "-10000000";   # -128 in binary
```

`float` accepts only `_10` (the default) and `_16`. `float_fix` and `float_dec` **do not accept a base parameter at all** — specifying one is a hard error.

#### Special Number Literals

Three special values bypass normal numeric parsing. They are **bare reserved keywords** — no sigil — and, like `null`/`true`/`false`, are reclassified out of the symbol space by the validator:

```bovnar
.nan = nan;
.inf = inf;
.neg = ninf;     # negative infinity
```

Only these exact spellings are reserved: any other bare word (e.g. `infinity`, `nans`) is an ordinary symbol. They may be combined with any float type annotation and are valid in untyped context too. A special-number keyword takes no inline unit suffix — give its unit via the annotation (`<float:64,m/s> inf`).

---

## 6. Units

This is the feature most unique to Bovnar. Physical units are a first-class part of the type annotation, not a comment or a string field you hope someone reads.

### 6.1 Simple Units

The unit goes inside the angle brackets, after the other parameters:

```bovnar
.timeout  = <float:64,s>    2.5;      # seconds
.distance = <uint:64,m>     384400000; # meters (Earth-Moon distance)
.voltage  = <float:32,V>    3.3;      # volts
.temp     = <float:32,°C>   23.5;     # degrees Celsius (UTF-8 is fine)
```

### 6.2 SI Prefixes

SI prefixes are written before the base unit symbol, either with the `~` separator or compactly without it:

```bovnar
.k_ohm  = <float:32,k~Ω>   4.7;     # kilo-ohm
.milli  = <float:32,m~V>   330.0;   # millivolt
.micro  = <float:32,µ~s>   50.0;    # microsecond (µ = U+00B5)
.giga   = <float:64,G~Hz>  2.4;     # gigahertz
.kibi   = <uint:64,Ki~B>   1024;    # kibibytes (IEC binary prefix)
.mebi   = <uint:64,Mi~B>   4096;    # mebibytes
```

`<float:32,kΩ>` means exactly the same thing as `<float:32,k~Ω>` — the `~` is optional on a physical unit, and `kg`, `km`, `MHz`, `MiB` all parse as you would expect. Two things to know about the compact spelling. A token that is *itself* a unit always wins (`min` is the minute, so milli-inch has to be written `m~in`), and the canonical spelling is the `~` form: that is what the library writes for a unit it is handed without a spelling. (Re-serialising a document keeps the spelling it was written in; `bovnar pretty-print --canonical` goes the other way: it reduces each unit, folding the prefix of a base unit into the value and writing any prefix that remains in the `~` form.) A prefixed **currency** works the same way (`k$EUR` = `k~$EUR`), but its `$` sigil is never optional.

IEC binary prefixes (`Ki`, `Mi`, `Gi`, `Ti`, `Pi`, `Ei`, `Zi`, `Yi`, and the forward-looking `Ri`, `Qi` — IEC 80000-13 itself stops at `Yi`) follow the same rule:

```bovnar
.ram  = <uint:64,Gi~B>  8;     # 8 gibibytes
.disk = <uint:64,TiB>   2;     # 2 tebibytes (compact spelling of Ti~B)
```

### 6.3 Compound Units

Compound units are built from multiple components joined by `*` (or the middle dot `·`, U+00B7) for multiplication, and `/` for division:

```bovnar
.velocity     = <float:64,m/s>         9.81;   # meters per second
.acceleration = <float:64,m/s²>        9.81;   # m·s⁻²
.force        = <float:64,k~g·m/s²>    9.81;   # Newton (kilogram × meter / second²)
.energy       = <float:64,k~g·m²/s²>   1000;   # Joule
.pressure     = <float:64,k~g/(m·s²)>  101325; # Pascal
.momentum     = <float:64,k~g·m/s>     0.5;
.field        = <float:64,V/m>         150.0;
.moment       = <float:64,m*s>         1.0;    # asterisk = middle dot
```

The `/` separator is a **one-way switch**: once you write `/`, every subsequent component is in the denominator. Writing another `/` does not switch back to the numerator. If you need a component back in the numerator after a divisor, use a negative exponent:

```bovnar
.force_alt = <float:64,k~g·m·s⁻²> 9.81;   # same as k~g·m/s²
```

Exponents can be written as Unicode superscripts (`²`, `³`, `⁻¹`) or with an ASCII caret (`^2`, `^-2`). Both are valid:

```bovnar
.area1 = <float:64,m²>   100.0;
.area2 = <float:64,m^2>  100.0;   # same thing
.inv1  = <float:64,s⁻¹>  50.0;
.inv2  = <float:64,s^-1> 50.0;    # same thing
```

The maximum number of unit components in a compound unit is 32 (`BVNR_MAX_UNIT_COMPONENTS`). More than that is `error_unit_illegal`.

### 6.4 Explicitly Dimensionless: `no_unit`

When you want to be explicit that a value carries no physical dimension, use the keyword `no_unit`:

```bovnar
.ratio = <float:64,no_unit> 1.4142;
.count = <uint:32,no_unit>  42;
```

There are three ways to be dimensionless, and only one of them is a *different* internal state:

| Written | Internal | |
|---|---|---|
| `<float:64,no_unit> 1.0` | `BVN_UNIT_NONE`, `num_components == 0` | you said so |
| `<float:64> 1.0` | `BVN_UNIT_NONE`, `num_components == 0` | annotated, unit parameter omitted — the *same* state |
| `1.0` | `BVN_UNIT_NO_PREFIX(bu_none)`, `num_components == 1` | no annotation at all; the default type is synthesised |

Omitting the unit parameter *inside an annotation* is the same thing as writing `no_unit` — `bvn_parse_type_annotation` initialises the unit to `BVN_UNIT_NONE` and only overwrites it when a dimensioned unit parameter is actually present. It is a **fully untyped** value that gets the one-component form. All three compare as compatible via `bvn_units_compatible` and all three serialise to `"no_unit"` via `bvn_unit_to_string`. The explicit `no_unit` is useful for documentation: it tells a reader that the author actively chose dimensionless, not that they forgot.

### 6.5 Inline Unit Suffix

For untyped or partially-typed values, you can append a unit suffix directly after the value literal, separated by at least one whitespace character:

```bovnar
.speed  = 9.81 m/s;       # valid
.mass   = 70.0 k~g;       # result: float:64, unit = k~g
.heap   = 65536 B;         # result: uint:64, unit = B (bytes)
```

This is forbidden:

<!-- bovnar-example: rejected -->
```bovnar
.bad = 9.81m;             # ERROR: no separator at all
```

If a type annotation also specifies a unit and you additionally write an inline suffix, they must match exactly. A mismatch is a hard error:

<!-- bovnar-example: rejected -->
```bovnar
.ok  = <float:64,m/s> 9.81 m/s;   # redundant but valid: both say m/s
.bad = <float:64,m>   9.81 s;     # ERROR: annotation says m, inline says s
```

**The inline unit suffix is completely forbidden inside array elements.** This is a lexer-level restriction, not a semantic one — any letter or underscore following a value token inside `[…]` is an immediate hard error.

### 6.6 Units From Another Vocabulary (spec 1.2)

> **Spec 1.2.** A document reaches this notation only by declaring `#!bovnar 1.2`; under 1.1, or with no directive, every unit below is `error_unit_illegal`.

Data rarely starts out in Bovnar. A lab system hands you UCUM codes, an invoice carries UN/ECE codes, a netCDF file has UDUNITS strings. You do not have to translate them by hand: write `namespace:code` in the unit slot and the parser translates it.

```bovnar
#!bovnar 1.2
.systolic = <float:64,ucum:mm[Hg]>           120.0;   # UCUM
.weight   = <float_dec:64,unece:KGM>          12.5;   # UN/ECE Rec 20: kilogram
.speed    = <float:64,qudt:M-PER-SEC>          9.81;  # QUDT
.flux     = <float:64,udunits:kg m-2 s-1>      0.5;   # UDUNITS: a space multiplies
.glucose  = 5.4 ucum:mmol/L;                          # inline works too
```

Seven namespaces exist: `ucum`, `unece`, `qudt`, `qudt-qk`, `udunits`, `om` and `cf`. Three things are worth knowing before you use one.

**It is a spelling, not a second kind of unit.** `ucum:mm[Hg]` and `mmHg` are the same unit after parsing — they compare equal, convert to each other with a factor of exactly one, and satisfy the same policies. That is also why the match rule of §6.5 is about the unit and not the text:

```bovnar
#!bovnar 1.2
.ok = <float:64,mmHg> 120.0 ucum:mm[Hg];   # valid: one unit, written two ways
```

**A code either becomes a real unit or it is an error.** There is no "unknown unit, passed through". A code the vocabulary does not define is `error_unit_illegal`; one it defines but Bovnar cannot represent — a decibel referenced to a fixed level, say — is `error_unit_profile_unsupported`; a namespace this build does not have is `error_unit_profile_unknown`.

<!-- bovnar-example: rejected -->
```bovnar
#!bovnar 1.2
.bad = <float:64,ucum:furlongs> 1.0;       # ERROR: not a UCUM code
```

**Your spelling is kept.** Re-serialising a document writes `ucum:mm[Hg]` back as `ucum:mm[Hg]`, including a UCUM annotation such as `ucum:mL{total}` whose `{total}` is not part of the unit at all. The reader also hands the spelling to your code beside the parsed unit, so you can tell what was written from what it means.

The full rules, the tables and the codes that are refused are in [Unit Profiles](11_bovnar_unit_profiles.md).

---

## 7. Fixed-Point Numbers: `float_fix`

Fixed-point (Q-format) numbers store a value as a signed integer, with the binary point shifted by a declared number of fractional bits. The wire representation is an integer; the mathematical value is `raw_integer × 2⁻Q`.

The annotation takes a `qN` parameter giving the number of fractional bits, beside the width. An omitted `qN` is `q0`, so `<float_fix:32>` is `<float_fix:32,q0>` — no fractional bits at all — and a fixed-point value that has any fractional bits must say how many:

```bovnar
.adc = <float_fix:16,q8>  3.14;    # 16-bit, 8 fractional bits
                                    # range: [-128, 127.996], resolution ≈ 0.0039
.pid = <float_fix:32,q16> -1.5;    # 32-bit Q16: 15 integer bits + sign + 16 fractional
.vel = <float_fix:32,q8,m/s> 9.81; # with a unit
.cnt = <float_fix:64,q0>  4096;    # Q0 = no fractional bits (pure integer shell)
```

Constraints:

- `Q` must be in the range `0 ≤ Q < effective_width`. `<float_fix:16,q16>` is illegal because Q must be strictly less than the width.
- The base parameter (`_N`) is forbidden. `<float_fix:32,q8,_10>` is an error.
- Valid widths: `0` (→64), `16`, `32`, `64`, `128`, `256`. Width `8` is not valid.

---

## 8. Decimal Floating-Point: `float_dec`

`float_dec` is IEEE 754-2008 decimal floating-point — useful in financial and metrological contexts where exact decimal representation matters (binary floats cannot represent `0.1` exactly; decimal floats can):

```bovnar
.price   = <float_dec:32>     12.99;    # 7 significant decimal digits
.voltage = <float_dec:64,Pa>  101325.0; # 16 significant decimal digits
.pi_big  = <float_dec:128>    3.14159265358979323846264338327950288; # 34 digits
```

Rules are similar to `float_fix`: no base parameter, same set of valid widths.

---

## 9. Strings

Strings are enclosed in double quotes. The `<utf8>` annotation is optional — a bare quoted literal is automatically synthesised as `utf8`:

```bovnar
.greeting = "hello";           # → <utf8>
.typed    = <utf8> "world";    # explicit, same result
.empty    = "";
```

### 9.1 Escape Sequences

Seven escape sequences are defined in spec 1.0:

| Escape | Meaning |
|---|---|
| `\t` | Horizontal tab |
| `\n` | Line feed |
| `\v` | Vertical tab |
| `\f` | Form feed |
| `\r` | Carriage return |
| `\"` | Double quote |
| `\\` | Backslash |

In spec 1.0 any other character after a backslash is a hard error. A document that declares `#!bovnar 1.1` additionally gets `\xHH` (one byte) and `\u{H…}` (a Unicode scalar, UTF-8 encoded) — but a control byte (other than the whitespace ones above) is still rejected, so for arbitrary binary use an octet stream (see below). In a 1.0/unversioned document `\x`/`\u` remain hard errors.

Raw whitespace bytes (HT, LF, VT, FF, CR) are accepted unescaped inside string literals, so a multi-line string is valid:

```bovnar
.poem = "roses are red
violets are blue";
```

### 9.2 String Concatenation

Adjacent string literals separated only by whitespace or comments are concatenated at lex time:

```bovnar
.url  = "https://" "api.example.com" "/v1";
.long = "first part "
        "second part "    # a comment is fine here
        "third part";
```

This is the idiomatic way to write a long string across multiple lines or to construct a non-decimal value that happens to be split for readability:

```bovnar
.guid = <uint:128,_16> "deadbeef"
                       "cafebabe"
                       "01234567"
                       "89abcdef";
```

---

## 10. Symbols

A symbol is a bare word in value position — no quotes. It is its own token type, distinct from strings:

```bovnar
.status  = ok;
.mode    = read_only;
.day     = Monday;
```

Symbol bodies follow the same character rules as identifier bodies: letters, digits, `+`, `-`, `_`, and valid UTF-8 high bytes. The key distinction from an identifier is context: identifiers appear as keys (after the opening dot), symbols appear as values.

Inside an array, `]` and `,` terminate a symbol. At assignment level, `;` terminates it.

Eight bare words are **reserved keywords** and are *not* symbols: `null`, `true`, `false`, `on`, `off`, and the special floats `nan`, `inf`, `ninf` (see the special-number section). A longer word that merely starts with one — `ontology`, `nullable`, `truthy`, `infinity` — is still an ordinary symbol.

---

## 11. Booleans

`true`, `false`, `on`, and `off` are reserved keywords carrying the `bool` type family. `on` is an alias for `true` and `off` for `false`; all four collapse to a boolean value and serialize canonically as `true` / `false`:

```bovnar
.enabled = true;
.debug   = off;          # same value as false
.typed   = <bool> on;    # explicit annotation; on == true
```

A bare boolean synthesises a `<bool>` annotation automatically. The `<bool>` family takes no parameters (`<bool:8>` is a hard error), and only the four keywords are valid `<bool>` values.

---

## 12. Null Values

A null is the explicit absence of a value. It appears as an empty slot — nothing between `=` and `;` — or, equivalently, the reserved keyword `null`; extra commas inside an array also produce nulls:

```bovnar
.nothing    = ;              # null, no type
.also_null  = null;          # the null keyword — same as the empty slot
.typed_null = <uint:32> ;    # null with an explicit type annotation
```

Inside arrays, null elements arise from leading, trailing, or consecutive commas:

```bovnar
.sparse = [, 1, , 2, ];      # null, 1, null, 2, null — five elements
.holes  = [<sint:16> , <sint:16> 0, <sint:16> ];  # typed nulls
```

---

## 13. References

A reference is a dotted path to another key in the document, introduced by `&`. The parser stores the path as a string token; it does not resolve it — resolution is entirely up to the application:

```bovnar
.host       = "db.internal";
.port       = 5432;

.conn_host  = &.host;          # stored as ".host"
.conn_port  = &.port;          # stored as ".port"
```

Multi-segment paths:

```bovnar
.server = {
    .address = "10.0.0.1";
    .tls     = { .cert = "/etc/ssl/server.pem"; };
};

.proxy_addr = &.server.address;     # ".server.address"
.cert_path  = &.server.tls.cert;    # ".server.tls.cert"
```

References are valid in arrays and structs:

```bovnar
.nodes   = [&.master, &.replica-a, &.replica-b];

.worker  = {
    .retries = &.defaults.retries;
    .delay   = &.defaults.delay_s;
};
```

A reference is not a null. If a field is optional and currently has no referent, represent the absence with an explicit null:

```bovnar
.override = ;             # null — no reference, use internal default
# .override = &.config.x; # or, non-null — follow this reference
```

**Array indexing (spec 1.1).** In a `#!bovnar 1.1` document a reference may end
with one or more `[N]` index suffixes to address an array element. For a flat
`/`-row matrix the two indices are `[row][col]`:

```bovnar
#!bovnar 1.1
.matrix = [10, 20, 30]/[40, 50, 60];
.cell   = &.matrix[1][2];   # the element at row 1, column 2 → 60
.first  = &.vector[0];      # a 1-D array takes a single [i]
```

The path (including the indices) is stored verbatim; the DOM layer
(`bvn_dom_lookup` / the CLI `query`) is what resolves it to a value. Index
suffixes are rejected in a 1.0 or unversioned document.

---

## 14. Arrays

An array is enclosed in square brackets. A single row is a comma-separated list of values:

```bovnar
.primes = [2, 3, 5, 7, 11, 13];
.names  = ["Alice", "Bob", "Carol"];
```

### 14.1 Multi-Dimensional Rows

Multiple rows are separated by `/`. Each bracketed group is one row:

```bovnar
.matrix = [1, 2, 3]/[4, 5, 6];    # 2 rows × 3 columns
```

The event stream reflects this: `ev_array_row_start`, elements, `ev_array_row_end`, then `ev_array_dim_start` before the second `ev_array_row_start`. Row-separator `/` and array nesting are independent concepts — do not confuse them. The `/` between `]` and `[` creates a new dimension in the *same* array, not a nested one.

The `/`-separated dimension rows of a single array must have a **consistent element count**, and (since spec 1.0) array elements are **homogeneous**: sibling sub-arrays must also match in length, so bare arrays and matrices are rectangular:

<!-- bovnar-example: rejected -->
```bovnar
.ok1 = [1, 2, 3]/[4, 5, 6];   # valid: both /-rows of one array have 3 elements
.ok2 = [[1, 2], [3, 4]];       # valid: rectangular sub-arrays
# .ok3 = [[1, 2], [3, 4, 5]];  # error_array_row_size_mismatch: sibling sub-arrays differ (2 vs 3)

.bad1 = [1, 2, 3]/[4, 5];      # error_array_row_size_mismatch: 3 vs 2 (/-rows of one array)
.bad2 = [[1, 2]/[3, 4, 5]];    # error_array_row_size_mismatch: 2 vs 3 (one /-array's own rows)
```

### 14.2 Per-Element Type Annotations

Each element may carry its own annotation, placed immediately after the comma (or the opening bracket for the first element):

```bovnar
.mixed = [<uint:8> 255, <sint:8> -128, <float:64> 3.14];
```

A single annotation before `[` applies to all elements — it is a whole-array annotation:

```bovnar
.ports = <uint:16> [80, 443, 8080, 8443];
.temps = <float:32,°C> [23.5, 24.1, 22.8];
```

Per-element annotations can still appear inside a whole-array-annotated array.

### 14.3 Structs as Array Elements

Arrays of structs are the standard idiom for a list of records:

```bovnar
.users = [
    {.id = <uint:32> 1; .name = "Alice"; .role = admin;},
    {.id = <uint:32> 2; .name = "Bob";   .role = user;}
];
```

---

## 15. Structs

A struct is a nested scope, grouping zero or more assignments inside `{…}`. Every field inside is a complete assignment with `.key = value;` syntax. The struct value at the parent level ends with `};`:

```bovnar
.person = {
    .name   = "Alice";
    .age    = 30;
    .active = true;
};
```

Structs nest deeply — the limit is `max_struct_nesting`, which defaults to 64 and has a hard cap of 255:

```bovnar
.config = {
    .database = {
        .primary = {
            .host = "db1.internal";
            .port = <uint:16> 5432;
            .tls  = {
                .enabled = true;
                .cert    = "/etc/ssl/db.pem";
            };
        };
    };
};
```

An empty struct is valid:

```bovnar
.placeholder = {};
```

An unmatched closing brace — a `}` with no matching `{` — is `error_illegal_struct_close`.

---

## 16. Octet Streams: Inline Binary

When you need to embed raw binary data without Base64 overhead, a NUL byte (`0x00`) in value position switches the parser into binary chunk mode.

The wire protocol for an octet stream is:

```
0x00                     ← stream open
  0x01 <LL> <LL> <data>  ← one chunk: tag 0x01, length as little-endian uint16, then data
  0x01 <LL> <LL> <data>  ← another chunk
0x00                     ← stream close
```

A length value of `0x0000` means exactly 65536 bytes. Any tag byte other than `0x01` (chunk) or `0x00` (close) inside a stream is `error_octet_stream_out_of_sync`.

In practice, you will not hand-author octet streams — the writer API does this for you. In a `.bvnr` file shown for documentation purposes it appears as an escaped binary sequence:

<!-- bovnar-example: illustrative -->
```bovnar
.binary = \x00\x01\x05\x00hello\x01\x03\x00bye\x00;
```

This produces two data events with payloads `"hello"` (5 bytes) and `"bye"` (3 bytes). The UTF-8 validator is suspended for the duration of a binary region.

---

## 17. Putting It Together: Realistic Examples

### 17.1 Hardware Configuration

```bovnar
# Sensor node configuration
.node_id        = <uint:8>  7;
.firmware_ver   = "2.4.1";

.radio = {
    .frequency  = <float:64,M~Hz>  868.1;
    .tx_power   = <sint:8,dB>     14;          # dB (power level); 'dBm' is not a bovnar unit
    .bandwidth  = <float:32,k~Hz>  125.0;
    .sf         = <uint:8>         7;          # spreading factor
};

.sampling = {
    .interval   = <uint:32,s>      60;
    .burst_size = <uint:16>        8;
    .adc_bits   = <uint:8>         12;
};

.calibration = {
    .offset     = <float_fix:32,q8,°C>  -0.5;
    .gain       = <float_fix:32,q16>     1.002;
};
```

### 17.2 Scientific Measurement Batch

```bovnar
.experiment = "thermal_runaway_001";
.timestamp  = <uint:64,s> 1715000000;

.measurements = [
    {.channel = <uint:8> 0;
     .value   = <float:32,°C>  27.3;
     .valid   = true;},
    {.channel = <uint:8> 1;
     .value   = <float:32,°C>  31.7;
     .valid   = true;},
    {.channel = <uint:8> 2;
     .value   = <float:32,°C> 112.4;
     .valid   = true;}
];

.threshold = <float:32,°C>  80.0;
.alarm     = triggered;
```

### 17.3 Multi-Dimensional Matrix

```bovnar
# 3×3 rotation matrix in float64
.R = <float:64> [1.0, 0.0, 0.0]
                /[0.0, 0.866, -0.5]
                /[0.0, 0.5,   0.866];
```

### 17.4 Mixed Binary and Text

<!-- bovnar-example: illustrative -->
```bovnar
.packet = {
    .type      = data_frame;
    .sequence  = <uint:32>  42;
    .timestamp = <uint:64,µ~s> 1715000000123456;
    .payload   = \x00\x01\x08\x00\xDE\xAD\xBE\xEF\xCA\xFE\xBA\xBE\x00;
    .crc32     = <uint:32,_16> "A1B2C3D4";
};
```

---

## 18. Common Mistakes and the Errors They Produce

Understanding the error codes is essential for debugging. The parser reports line, column, the offending byte, and a byte-offset into the stream.

**Annotation on the wrong side of `=`:**
<!-- bovnar-example: rejected -->
```bovnar
.x<uint:32> = 42;    # error_unexpected_input_byte (< after identifier)
```

**Non-decimal value not quoted:**
<!-- bovnar-example: rejected -->
```bovnar
.x = <uint:_16> FF;  # FF is parsed as a symbol → error_type_value_mismatch
```

**Signed value in unsigned type:**
<!-- bovnar-example: rejected -->
```bovnar
.x = <uint:8> -1;    # error_value_out_of_range
```

**Overflow:**
<!-- bovnar-example: rejected -->
```bovnar
.x = <uint:8> 256;   # error_value_out_of_range (max is 255)
```

**Float with an unsupported width:**
<!-- bovnar-example: rejected -->
```bovnar
.x = <float:8>  1.0; # error_illegal_value_type
.x = <float:12> 1.0; # error_illegal_value_type (not 16 or multiple of 32)
```

**Base parameter on `float_fix` or `float_dec`:**
<!-- bovnar-example: rejected -->
```bovnar
.x = <float_fix:32,q8,_10> 1.0;  # error_illegal_value_type
.x = <float_dec:64,_10>    1.0;  # error_illegal_value_type
```

**Q value too large for `float_fix`:**
<!-- bovnar-example: rejected -->
```bovnar
.x = <float_fix:16,q16> 1.0;  # error_illegal_value_type (Q must be < width)
```

**Empty unit component:**
<!-- bovnar-example: rejected -->
```bovnar
.x = <float:64,m//s> 1.0;    # error_unit_illegal (empty component between //)
```

**Too many unit components:** a compound unit may carry at most
`BVNR_MAX_UNIT_COMPONENTS` = **32**; a thirty-third is `error_unit_illegal`. Nine is fine:

```bovnar
.ok = <float:64,m*s*k~g*A*K*mol*cd*b*V> 1.0;  # nine components — valid
```

**Inline unit suffix inside array:**
<!-- bovnar-example: rejected -->
```bovnar
.x = [9.81 m/s, 3.14 m];  # error_unexpected_input_byte (units forbidden in arrays)
```

**Comma outside array:**
<!-- bovnar-example: rejected -->
```bovnar
.x = 42,;   # error_unexpected_input_byte
```

**Unknown escape sequence:**
<!-- bovnar-example: rejected -->
```bovnar
.x = "\q";   # error_illegal_escape_sequence (\q is not a defined escape)
```
(`\xHH` and `\u{…}` *are* defined in spec 1.1 — see the escapes section above;
they are rejected only in a 1.0 or unversioned document.)

**Unmatched struct close:**
<!-- bovnar-example: rejected -->
```bovnar
.x = 1;}   # error_illegal_struct_close (a stray '}' at the top level)
```

**A `/`-array's dimension rows must match:**
<!-- bovnar-example: rejected -->
```bovnar
.ok  = [[1, 2]/[3, 4]];          # valid — the /-array's two rows both have 2 elements
.bad = [[1, 2]/[3, 4, 5]];       # error_array_row_size_mismatch — 2 vs 3 (/-rows)
# note: [[1,2],[3,4,5]] is also rejected — sibling sub-arrays must match in length (1.0 homogeneity)
```

---

## 19. Error Recovery Mode

The parser supports an optional `continue_on_error` mode. When enabled, a parse error invokes the `on_error` callback and then enters a resync state machine that skips bytes while tracking bracket and brace nesting. The `recovery_count` counter (accessible via `bvnr_reader_get_recovery_count`) is incremented immediately when an error triggers entry into resync mode — not when the resync eventually completes. Normal parsing resumes at whichever comes first: a `;` at the original nesting depth, or the start of the next assignment — a `.` at that depth followed by a byte that can begin an identifier.

This mode is intended for log streams and unreliable transports — situations where one malformed assignment should not discard the rest of the file. For configuration and data serialization, disable it: fail fast and reject corrupt input entirely.

---

## 20. Size Limits

All limits are configurable via `bvnr_read_flags_t`. The defaults are intentionally permissive — 2 147 483 647 for array items and text bytes, and **unlimited** (`0`) for file size. Production deployments should set explicit caps:

| Field | Default | Suggested cap |
|---|---|---|
| `max_identifier_length` | 255 | 255 |
| `max_string_length` | 65535 | application-defined |
| `max_number_length` | 65535 | 65535 |
| `max_symbol_length` | 255 | 255 |
| `max_reference_length` | 65535 | 65535 |
| `max_array_items` | 2 147 483 647 | application-defined |
| `max_text_bytes` | 2 147 483 647 | application-defined |
| `max_file_size` | 0 (no total-byte cap) | `16777216` (16 MiB) |
| `max_array_nesting` | 0 (→64 internal) | 32 or less for most applications |
| `max_struct_nesting` | 0 (→64 internal) | 32 or less for most applications |

Setting most fields to `0` in `bvnr_read_flags_t` causes the reader to substitute an internal default — **64** for both nesting depths, and **2 147 483 647** for `max_array_items` and `max_text_bytes`. **`max_file_size` is the exception: `0` means no total-byte cap**. That is not endless: the text is still bounded by `max_text_bytes`, so an endless text stream raises that as well. Explicitly setting `max_file_size = 16777216` (16 MiB) is the recommended practice for production deployments.

---

## 21. Event Model (C API Overview)

The C reader is a two-phase, callback-driven SAX-style parser. You supply two optional callbacks: `on_unverified` receives events before semantic validation, `on_verified` receives them after. For normal consumption, `on_verified` is sufficient.

The event sequence for `.port = <uint:16> 443;` is:

```
ev_stream_start
ev_assignment_start            → key = "port"
ev_type_annotation_start       → raw = "uint:16"
ev_type_annotation_type_family → "uint"
ev_type_annotation_type_family_parameter → width: 16
ev_type_annotation_end
ev_data                        → value = "443", type = {uint, 16}
```

The `ev_type_annotation_type_family_parameter` event fires once per **present** parameter. Because the explicit annotation `<uint:16>` carries neither a base nor a unit parameter, only the width event is emitted. Contrast this with a bare `42;` (no annotation), where the validator **synthesises** `<uint:64,_10,no_unit>` and therefore emits three parameter events:

```
ev_stream_start
ev_assignment_start            → key = "count"
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                        → value = "42", type = {uint, 64}
```

The synthesised annotation always produces all three parameter events; explicit annotations only produce events for the parameters that are actually present.

**`ev_type_annotation_start` asymmetry:** This event is emitted to `on_unverified` first (with raw annotation bytes in `data`/`length` but no `value_type` or `value_unit` populated), and then separately to `on_verified` (with `value_type` and `value_unit` filled in). All other type-annotation events and `ev_data` are emitted to both callbacks simultaneously via the same call.

The `bvnr_data_t` structure passed with `ev_data` carries:

- `type` — the token type (`token_is_number`, `token_is_string`, `token_is_symbol`, `token_is_reference`, `token_is_array_number`, `token_is_array_string`, `token_is_bool`, `token_is_null_value`, `token_is_octet_stream`)
- `value_type` — the `value_type_spec_t` (family, width, base/Q)
- `value_unit` — the `value_unit_t` (up to `BVNR_MAX_UNIT_COMPONENTS` components)
- `data` — pointer to the raw value bytes
- `length` — byte count

For an octet stream, `ev_octet_stream_start` and `ev_octet_stream_end` bracket one or more `ev_data` events with `type == token_is_octet_stream`.

For arrays, `ev_array_row_start` opens each row, `ev_array_row_end` closes it, and `ev_array_dim_start` separates dimensions (the `/` rows).

For structs, `ev_struct_start` and `ev_struct_end` bracket the nested assignments. No `ev_data` event is emitted for the struct value itself.

---

## 22. Quick-Reference: Syntax Cheat Sheet

<!-- bovnar-example: illustrative -->
```bovnar
# ── Assignments ────────────────────────────────────────────────────
.key = value;
.key = <type-family:width,_base,unit> value;

# ── Type families ──────────────────────────────────────────────────
<uint:32>              # unsigned 32-bit integer
<sint:64>              # signed 64-bit integer
<float:32>             # IEEE 754 single-precision
<float:128>            # IEEE 754 quad-precision (or any multiple of 32)
<float_fix:32,q16>     # Q-format fixed-point, 32-bit, 16 fractional bits
<float_dec:64>         # IEEE 754-2008 decimal, 64-bit
<utf8>                 # UTF-8 string
<bool>                 # boolean (true/false); no parameters

# ── Units ──────────────────────────────────────────────────────────
<float:64,s>           # seconds
<float:64,k~m>         # kilometers (SI prefix~unit; `km` is the same unit)
<float:64,m/s>         # meters per second
<float:64,m/s²>        # meters per second squared
<float:64,k~g·m/s²>    # kilogram-meters per second squared
<uint:64,Ki~B>         # kibibytes
<float:64,no_unit>     # explicitly dimensionless

# ── Special values ─────────────────────────────────────────────────
nan  inf  ninf

# ── Booleans and null (reserved keywords) ──────────────────────────
.b = true;   .b = false;   .b = on;   .b = off;   # on==true, off==false
.n = null;   .n = ;                                # null keyword == empty

# ── Inline unit suffix (scalar context only) ───────────────────────
.x = 9.81 m/s;

# ── Non-decimal (must be a quoted string) ──────────────────────────
.x = <uint:_16> "DEADBEEF";
.x = <sint:_2>  "-10000000";

# ── Arrays ─────────────────────────────────────────────────────────
.a = [1, 2, 3];
.a = [1, 2, 3]/[4, 5, 6];     # 2D: two rows
.a = <float:32> [1.0, 2.0];   # whole-array annotation
.a = [<uint:8> 1, <sint:8> -1]; # per-element annotations
.a = [, 1, , 2, ];            # null, 1, null, 2, null

# ── Structs ────────────────────────────────────────────────────────
.s = {.x = 1; .y = 2;};
.s = {};

# ── Symbols ────────────────────────────────────────────────────────
.state = running;
.ok    = true;

# ── References ─────────────────────────────────────────────────────
.ref  = &.other_key;
.deep = &.config.db.host;

# ── Null ───────────────────────────────────────────────────────────
.x = ;
.x = <uint:32> ;

# ── Strings ────────────────────────────────────────────────────────
.s = "hello\nworld";
.s = "part one " "part two";   # concatenation
```

---

## See also

- [Specification](03_bovnar_spec.md) — the normative rules behind everything shown here
- [Unit & Currency Reference](05_bovnar_unit_system.md) — every unit and currency the parser accepts
- [Unit & Currency Cheat Sheet](04_bovnar_unit_cheatsheet.md) — the symbol tables, for looking one up quickly
- [Read & Write API](08_bovnar_readwrite_api.md) — the C API sketched in the event-model section
- [Python Bindings](09_bovnar_python_bindings.md) — the same document set, read and written from Python
- [Rust Bindings](14_bovnar_rust_bindings.md) — the same document set, read and written from Rust
- [FAQ](02_bovnar_faq.md) — the questions this tutorial raises next

---

*End of Bovnar — Tutorial (Bovnar spec 1.2).*

<!-- ==================== FAQ (https://www.bovnar.io/doc/02_bovnar_faq.md) ==================== -->

# Bovnar — Frequently Asked Questions

> **Spec version:** 1.2
> **Status:** Non-normative — answers indexed to the specification
> **Scope:** Common questions on syntax, types, units, numbers, limits, and the C and Python APIs.

---

## Table of Contents

1. [General](#1-general)
2. [File Format and Syntax](#2-file-format-and-syntax)
3. [Type System and Annotations](#3-type-system-and-annotations)
4. [Units](#4-units)
5. [Numbers and Special Values](#5-numbers-and-special-values)
6. [Strings](#6-strings)
7. [Arrays](#7-arrays)
8. [Structs](#8-structs)
9. [Null, Symbols, and References](#9-null-symbols-and-references)
10. [Octet Streams](#10-octet-streams)
11. [Error Handling and Debugging](#11-error-handling-and-debugging)
12. [C API](#12-c-api)
13. [Python Bindings](#13-python-bindings)
14. [Limits and Performance](#14-limits-and-performance)
15. [Why C99 and Not C23?](#15-why-c99-and-not-c23)

- [See also](#see-also)

---

## 1. General

**What problem does Bovnar solve?**

A bare `9.81` tells you nothing about whether the value is a float or an
integer, 32-bit or 64-bit, and it carries no physical meaning. The type and the
unit live outside the data — in an external schema, a naming convention, or
documentation — and that gap is where unit-confusion failures hide.

Bovnar is self-describing at the per-value level: every value in a `.bvnr`
stream carries its type family, bit-width, numeric base, and physical unit
inside the same byte stream. No external schema is required. You can hand a
`.bvnr` file to anyone and they have everything they need to interpret it
exactly as the author intended.

---

**Is Bovnar a binary or a text format?**

It is primarily a text format. The syntax is UTF-8 prose that is readable and
writable by hand. The one binary escape mechanism — the octet stream (`0x00`
framing) — lets you embed opaque binary payloads without Base64 overhead.
Outside of octet-stream regions the parser enforces strict UTF-8 validity.

---

**Does Bovnar perform unit conversion or dimensional analysis?**

Both, but never on its own initiative. Nothing is converted unless the consumer
asks for it, and a value the library was not asked about is delivered exactly as
the document wrote it.

Dimensional analysis is always available: `bvn_units_compatible`,
`bvn_unit_dimension_vector` and `bvn_unit_convert_factor` in C
(`units_compatible`, `units_convertible`, `unit_convert_factor`,
`convert_value` in Python) answer whether two units measure the same quantity
and what relates them.

Conversion happens at read time when you ask for it, in three escalating forms
(read/write API §1.10 and §1.12):

- **`want_unit`** — a per-value callback that converts to a unit you name. The
  result is exact: an irrational factor, or one with no terminating expansion in
  the output base, aborts the parse with `error_unit_inexact` rather than
  handing back a rounded number.
- **`bvnr_reader_set_unit_policy`** — the same without a callback. Name target
  units, per-field rules by key path, or `bvnr_normalise_si` to deliver
  everything else in coherent SI. A rule is an assertion as well as a target: a
  value that cannot satisfy it is `error_unit_mismatch`.
- **The writer half** (`bvnr_writer_set_unit_policy`) holds a *producer* to the
  same rules.

The CLI exposes all of it: `bovnar events --unit m/s --si`,
`bovnar validate --require-field '.inlet.temperature=K'`.

What the library will not do is guess. It refuses a cross-currency conversion
outright, because it carries no exchange rates; it refuses to relate quantities
that share a dimension but not a kind (a bit is not a byte, `pH` is not a plain
number, an NTU is not an FNU); and an affine scale such as `°C` has no
meaningful conversion inside a compound unit, so it yields none.

---

**Is there an official file extension?**

Yes. The canonical extension is `.bvnr`.

---

**Which version of the specification does the reference implementation target?**

Spec 1.2 — spec 1.1's additive features (the `#!bovnar` version directive
itself, the `\u`/`\x` escapes, the `datetime` family and reference array
indexing) plus the unit-profile notation. `bvnr_spec_version()` reports it.

Spec 1.0 remains the frozen, stable baseline: a document that declares no
`#!bovnar` directive is treated as 1.0, and a 1.0 document parses unchanged (the specification's §17 lists the narrow exceptions made in 1.2).
`bvnr_version_string()` reports the **library** version, which is a different
number from the spec version and has been since library 2.0.0: that release
broke the C ABI and the Python API, which is what a library major is for, while
the format it reads grew only additively. `bovnar version` prints both —
`bovnar 2.1.0 (spec 1.2)`.

---

## 2. File Format and Syntax

**What is the minimum valid Bovnar file?**

An empty file (zero bytes) is valid. A file with a single assignment is the
practical minimum:

```bovnar
.key = value;
```

---

**Why do keys start with a dot?**

The leading dot is a hard syntactic marker. It unambiguously separates key
tokens from value tokens anywhere in the stream, including inside structs and
arrays-of-structs. The parser uses it as the entry point for the identifier
state machine.

---

**Can I use Unicode characters in key names?**

Yes, with restrictions. UTF-8 leader bytes in the range `0xC3–0xF4` are valid
at both the start and body of an identifier, covering most non-ASCII letters.
The one excluded range is `0xC2` (U+0080–U+00BF), which is explicitly rejected
everywhere in identifiers. The following ASCII punctuation characters are also
hard errors inside identifier bodies:

```
! " # $ % & ' ( ) * , . / : ; < = > ? @ [ \ ] ^ ` { | } ~
```

The characters `+`, `-`, and `_` are allowed in identifier bodies (but not as
the first character, except for `_`).

---

**Can a key start with a digit?**

No. Identifiers must begin with a letter (`A–Z`, `a–z`), an underscore, or a
valid UTF-8 leader byte. A leading digit is a hard parse error.

---

**Are comments supported?**

Yes. A `#` character starts a comment that runs to the end of the line. Comments
are legal wherever whitespace is legal — before the first assignment, between
tokens, and at the end of a value line:

```bovnar
# full-line comment
.port = 5432;   # inline comment
```

One restriction: `#` does not *end* a bare token. Directly after a key, a number,
a symbol, a keyword, a reference or a datetime literal it is
`error_unexpected_input_byte` (`.x = 42# note` fails), so put a space first.
After a string, a bracket, `=`, `;` or an inline unit it may follow directly.

---

**Is whitespace significant?**

No, except as a token separator and (required) separator between a scalar value
and an inline unit suffix. Indentation and blank lines are purely cosmetic and
are freely interleaved between all tokens.

---

**Is a UTF-8 BOM accepted?**

A UTF-8 BOM (`EF BB BF`) is accepted only at byte offset 0 of the stream.
Anywhere else it is an error or ordinary content, depending on whether the first
assignment has begun (specification §3.2).

Before the first assignment:

- A BOM **inside any comment** — the first comment line, a version directive line, or a later comment line — produces `error_invalid_byte_order_mark`.
- A BOM **after leading whitespace**, with no comment line before it, produces `error_invalid_byte_order_mark`.
- A BOM **at the start of a line that follows a comment line or a version directive** produces `error_unexpected_input_byte`.

Once the first assignment has begun, a BOM inside a comment or a string is valid UTF-8 and passes through without error; between assignments it is `error_unexpected_input_byte`.

---

**Can a document declare which bovnar version it uses?**

Yes, since spec 1.1. Put a directive in the document's first comment — in
practice its first line:

```bovnar
#!bovnar 1.1
.x = 42;
```

A document with **no** directive is treated as spec **1.0** — the frozen
baseline grammar — so existing files need no change. The directive only opts in
to a newer version.

Opting in is what makes the construct legal, not merely what labels the
document. A `datetime` value needs a declared 1.1, and the unit-profile notation a
declared 1.2; used in a document that declares less — or
nothing — each is rejected. That is deliberate: it stops a document from carrying
a construct that a conforming reader of its own declared version would have to
refuse.

It is recognised only as the very first comment (after an optional BOM and
leading whitespace, blank lines included). A reader records the declared version
(`bvnr_reader_get_declared_version`, `bovnar validate` reports it, and
the Python `Reader.declared_version` / `bovnar.peek_version()` expose it), and a
reader opened with `strict_version` rejects a version it does not support with
`error_unsupported_spec_version`. A malformed directive is
`error_invalid_spec_version`.

---

**Wait — isn't a comment supposed to be meaningless? How can `#!bovnar …` matter?**

The version directive is the single, deliberate exception. Everywhere else a
comment is semantically inert. The directive is *shaped* like a comment on
purpose: that is exactly what makes it backward compatible — a spec-1.0 reader
(which knows nothing about versions) skips it as a plain comment and parses the
rest of the document normally, while a 1.1+ reader additionally recognises the
`#!bovnar` prefix in the first comment and acts on it. Nothing else about comment
semantics changes, and a `#!bovnar …` after any other comment, or after any
content, is still just a comment. Blank lines before it do not count: the
directive may follow an optional BOM and leading whitespace, blank lines
included.

---

## 3. Type System and Annotations

**What are the seven type families?**

| Keyword | Description |
|---|---|
| `uint` | Unsigned integer |
| `sint` | Signed integer |
| `float` | IEEE 754 binary floating-point |
| `float_fix` | Q-format signed fixed-point |
| `float_dec` | IEEE 754-2008 decimal floating-point |
| `utf8` | UTF-8 string |
| `bool` | Boolean (`true`/`false`); takes no parameters |

Spec 1.1 adds an eighth, `datetime` (see below).

---

**Is there a date/time type?**

Yes, since spec 1.1: the `datetime` family. A `datetime` value is a **signed
integer count of seconds since a named epoch** — a timestamp:

```bovnar
#!bovnar 1.1
.created = <datetime:64,unix> 1750000000;
```

The epoch parameter is one of `unix` (default), `tai`, `gps`, `mjd`, `ntp`,
`galileo`, `glonass`, `y2000`, `beidou`. The value validates like `sint`
(negative = before the epoch); a numeric base/unit/`q` is `error_illegal_value_type`
and a fractional value is `error_type_value_mismatch`. Being a 1.1 feature it
requires a `#!bovnar 1.1` declaration. Convert to civil time with the
`bvn_datetime.h` helpers, which the Python and Rust bindings carry as
`bovnar.timekeeping` / `bovnar::timekeeping`.

You can also write the value as an **ISO-8601 literal** instead of a raw
integer — `2026-06-15`, `2026-06-15T12:00:00`, a trailing `Z`, or a numeric
`±HH:MM` offset, with an optional fractional second:

```bovnar
#!bovnar 1.1
.created = 2026-06-15T12:00:00Z;        # bare literal infers <datetime:64,unix>
.local   = 2026-06-15T12:00:00+02:00;   # offset folds to 10:00:00Z
.logline = 2026-06-15T12:00:00.123Z;    # fraction preserved as a string; round-trips
```

It is converted to the epoch-seconds carrier at parse time (the integer is what
is stored, so round-trips are idempotent). A bare literal with no annotation
infers `<datetime:64,unix>`. A `±HH:MM` offset shifts the time to true UTC before
the conversion. A fractional part (any number of digits) does not change the
whole-second carrier, but the digits are kept verbatim — consumers read them as
a string (`bvnr_data_t.frac_data`, or `bvn_dom_get_datetime_fraction()`), and a
value carrying a fraction is pretty-printed back as an ISO literal so it
round-trips. For sub-second values you compute on, use a finer integer carrier
(e.g. milliseconds). The UTC→epoch conversion is leap-second correct for the civil epochs
and `tai` (the offset is folded before `tai`'s leap-second lookup). A second of
`60` is an inserted leap second: on the civil epochs it folds onto the following
second (POSIX time has none to spend on it), but on `tai` it keeps its own value
one below the boundary, which is what makes UTC⇄TAI injective there — for the
leap seconds the build's table records; a `:60` the table does not know
collapses on `tai` too (spec §5.1). The atomic
GNSS epochs (`gps`/`galileo`/`glonass`/`beidou`) reject a literal
(`error_datetime_literal_unsupported_epoch`) — give those an integer carrier.
A literal with every separator in place but a bad field — out of range, the
wrong width (`2026-6-15`, `+2:00`) or an empty fraction — is
`error_invalid_datetime_literal`; one with a separator missing or wrong (no
seconds, `+0200`, a lowercase `z`) is a lexical error,
`error_unexpected_input_byte`.

**Timestamp vs. duration — don't confuse them.** A *timestamp* (an instant) is a
`datetime`; a *duration* (an elapsed amount) is a plain number with a time unit,
e.g. `<float:64,s> 2.5` (2.5 seconds) or `<uint:32,h> 8` (8 hours). The time
*units* (`s`, `min`, `h`, `d`, …) measure spans; the `datetime` *family* names a
point on a timeline.

---

**What is default type synthesis?**

When no explicit type annotation is present, the parser synthesises one based
on the literal:

- Positive integer literal → `uint:64`
- Negative integer literal → `sint:64`
- Literal containing `.` or `e` → `float:64`
- Special number (`nan`, `inf`, `ninf`) → `float:64`
- Any number with an inline currency unit (`5 $USD`) → `float_dec:64` with that
  unit — this takes precedence over the three rows above
- Quoted string → `utf8`
- Boolean keyword (`true`/`false`/`on`/`off`) → `bool`
- ISO-8601 datetime literal (spec 1.1) → `datetime:64,unix`

This is called default type synthesis and is convenient for configuration files
and quick hand-authored data. For precision-sensitive use cases, always write an
explicit annotation.

---

**Where exactly does the type annotation go?**

Between `=` and the value — never on the key, and never after the value:

<!-- bovnar-example: rejected -->
```bovnar
.port = <uint:16> 443;       # correct
.port<uint:16> = 443;        # WRONG — hard error
.port = 443 <uint:16>;       # WRONG — hard error
```

---

**Can annotation parameters appear in any order?**

Yes. Width, base, and unit are distinguished by their syntactic form, not by
position. The following three are equivalent:

```bovnar
.x = <uint:32,_16,m> "1F4";
#    <uint:_16,m,32>     — identical (parameter order is free)
#    <uint:m,32,_16>     — identical
```

---

**What widths are valid for each type family?**

| Family | Valid widths |
|---|---|
| `uint`, `sint` | Any positive integer up to `BVN_MAX_INT_WIDTH` (32768), e.g. `<uint:12>` is legal for a 12-bit ADC |
| `float` | `0` (→64), `16`, and any multiple of `32` up to `32768` |
| `float_fix` | `0` (→64), `16`, `32`, `64`, `128`, `256` |
| `float_dec` | `0` (→64), `16`, `32`, `64`, `128`, `256` |
| `datetime` | Same as `sint` — any positive integer up to `BVN_MAX_INT_WIDTH` (the carrier is a signed epoch-seconds integer); `0` →64 |

Width `0` always means "use the default," which is 64 bits for all families.
Width `8` is legal for `uint`/`sint`/`datetime` but is a hard error for all float families.
A non-multiple-of-32 (other than 16) width for `float` is `error_illegal_value_type`.

---

**What is the `float_fix` (Q-format) type and how is it annotated?**

`float_fix` encodes a value as a signed integer with a declared number of
fractional bits. The mathematical value is `raw_integer × 2⁻Q`. The annotation's
`qN` parameter gives the number of fractional bits; omitted, it is `q0`
(`<float_fix:32>` is `<float_fix:32,q0>`, no fractional bits):

```bovnar
.pid = <float_fix:32,q16> -1.5;    # 32-bit, 16 fractional bits
.adc = <float_fix:16,q8,°C> 25.0;  # 16-bit Q8 with a unit
```

Q must satisfy `0 ≤ Q < effective_width`. `<float_fix:16,q16>` is illegal.
The base parameter (`_N`) is forbidden for `float_fix`.

---

**What is `float_dec` and when should I use it?**

`float_dec` is IEEE 754-2008 decimal floating-point. Binary floats cannot
represent `0.1` exactly; decimal floats can. Use `float_dec` in financial,
metrological, or billing contexts where exact decimal representation matters.
The base parameter is forbidden for `float_dec` as well.

---

**Can I annotate a string value with a type?**

Yes, with `<utf8>`:

```bovnar
.label = <utf8> "hello";
```

This is redundant — a bare quoted literal is already synthesised as `utf8` — but
it makes the type explicit, which is useful for documentation and tooling. `utf8`
is a parameterless family: a width, base, q, or unit parameter inside a `<utf8>`
annotation is `error_illegal_value_type`.

---

## 4. Units

**What is the purpose of the unit annotation?**

Physical units are a first-class part of the type system, not a comment field.
Attaching a unit to a value makes the data self-documenting and enables the
consuming application to perform dimensional verification and conversion. The
parser fully validates every unit expression and exposes the structured result
through the `value_unit_t` field of the `ev_data` event.

---

**How do I write a simple unit?**

Place the unit symbol inside the angle brackets after the other type parameters:

```bovnar
.distance  = <uint:64,m>      384400000;
.voltage   = <float:32,V>     3.3;
.frequency = <float:64,Hz>    2400000000;
```

---

**How do SI prefixes work, and what is the `~` separator for?**

An SI prefix is written before the unit symbol, either with the `~` separator —
`k~m` (kilometer), `m~V` (millivolt), `G~Hz` (gigahertz) — or compactly, in the
spelling everyone else uses: `km`, `mV`, `GHz`. Both parse to the same unit.
The `~` form is canonical: it is what the writer emits for a unit it is handed
without a spelling, and it is the form that
says *exactly* which reading you mean, which matters for the handful of tokens
that are a unit in their own right (`min` is the minute, so milli-inch must be
written `m~in`).

```bovnar
.k_ohm = <float:32,k~Ω> 4.7;
.micro = <float:32,µ~s> 50.0;
.giga  = <float:64,GHz> 2.4;      # same as G~Hz
```

IEC binary prefixes (`Ki`, `Mi`, `Gi`, `Ti`, …) follow the same rule:

```bovnar
.ram  = <uint:64,Gi~B> 8;
.disk = <uint:64,TiB> 2;          # same as Ti~B
```

---

**When is the compact spelling *not* accepted?**

In four situations, all of which are `error_unit_illegal`:

1. **A bare unit alias already owns the token.** The base unit is matched as the
   longest alias suffix, so `min` is the minute (not milli-inch), `cd` the
   candela (not centi-day), `ft` the foot (not femto-tonne), `dB` the decibel
   (not deci-byte). That is what guarantees no existing document changed meaning
   when the compact form was introduced. Use `~` for the other reading.
2. **Stacked prefixes.** `kkg` and `k~kg` are both errors — a component carries
   at most one prefix.
3. **A prefix the unit does not accept.** `Kim` fails exactly as `Ki~m` does
   (IEC prefixes are for `b`/`B` only), as do `mB`, `kPfd` and `kppm`.
4. **A spelling refused by name.** `usb` would otherwise resolve to the
   microstilb, `kt` to the kilotonne — but `kt` is equally the standard
   abbreviation for the knot, and only the author knows which was meant — and
   `ppt` to the picopint, where everyone means parts per trillion or per
   thousand. `MBtu` and `Mlb` would be mega-Btu and mega-pound, where the US
   gas and commodity trades mean a thousand. All stay errors: write `u~sb`;
   `k~t` (mass) or `kn` (speed); `pptr`, `‰` or `p~pt`; `k~Btu`/`k~lb` or
   `M~Btu`/`M~lb`. The list lives in `src/gendata/units.bvnr`.

   `pH`, `mph` and `kph` were on that list until Bovnar gained the units they
   name — the acidity scale and two speeds. A bare unit alias outranks any
   prefixed reading, so they now parse as themselves and need no exception,
   while `p~H` (picohenry), `m~ph` and `k~ph` (milli-/kilophot) still mean what
   they always did.

Symbols are case-sensitive in either spelling, so `Kg`, `KB` and `KG` are
`error_unit_illegal` rather than silently-accepted variants of `kg` and `kB` —
`K` is the kelvin, not the kilo prefix.

Every token that could be read two ways is listed in
[`07_bovnar_unit_ambiguities.md`](07_bovnar_unit_ambiguities.md), with the spelling to use for each
meaning.

A prefixed **currency** works the same way — `k$EUR` is `k~$EUR`, since the `$` sigil already separates the two. The sigil itself is never optional: `kUSD` is `error_unit_illegal`, because a bare code is not a currency.

---

**What is the difference between `M~B` (megabytes) and `Mi~B` (mebibytes)?**

`M~` is the SI decimal prefix (mega = 10⁶). `Mi~` is the IEC binary prefix
(mebi = 2²⁰ = 1,048,576). The difference matters for storage:

```bovnar
.link_rate = <uint:32,M~b>  1000;   # 1 000 × 10⁶ bits
.cache     = <uint:64,Mi~B> 512;    # 512 × 2²⁰ bytes
```

---

**How do I write compound units like m/s² or kg·m/s²?**

Use `*` or the middle-dot `·` (U+00B7) for multiplication and `/` for division:

```bovnar
.velocity     = <float:64,m/s>          9.81;
.acceleration = <float:64,m/s²>         9.81;
.force        = <float:64,k~g·m/s²>     9.81;
.energy       = <float:64,k~g·m²/s²>    1000.0;
```

The `/` separator is a one-way switch: once written, every subsequent component
is in the denominator. To place a component back in the numerator after a
divisor, use a negative exponent instead:

```bovnar
.force_alt = <float:64,k~g·m·s⁻²> 9.81;   # identical to k~g·m/s²
```

The maximum number of unit components in a compound unit is 32; exceeding that
is `error_unit_illegal`.

---

**Can exponents be written in ASCII instead of Unicode superscripts?**

Yes. Both forms are accepted and produce identical internal representations:

```bovnar
.area1 = <float:64,m²>   100.0;   # Unicode superscript
.area2 = <float:64,m^2>  100.0;   # ASCII caret form — same result
.inv1  = <float:64,s⁻¹>  50.0;
.inv2  = <float:64,s^-1> 50.0;
.big   = <float:64,m^100> 1.0;    # multi-digit, up to ±100
.big2  = <float:64,m¹⁰⁰>  1.0;    # the same unit
```

Up to three ASCII digits are supported after `^` — exactly what the ±100 range
needs. A longer run is not an over-large exponent but an unrecognised token:
`m^1000` fails because the scan finds no `^` where it expects one.

---

**What does `no_unit` mean, and how does it differ from omitting the unit?**

Both mean "dimensionless," but they produce distinct internal states:

- Writing `no_unit` explicitly → `BVN_UNIT_NONE`: `num_components == 0`.
- Omitting the unit parameter *inside an annotation* (`<float:64> 1.0`) → the
  **same** `BVN_UNIT_NONE`, `num_components == 0`. `bvn_parse_type_annotation`
  initialises the unit to `BVN_UNIT_NONE` and only overwrites it when a
  dimensioned unit parameter is present, so there is nothing to tell apart here.
- A **fully untyped** value (`1.0`, no annotation at all) → the one that differs:
  `BVN_UNIT_NO_PREFIX(bu_none)`, `num_components == 1`, `base == bu_none`, from
  default-type synthesis.

Both compare as compatible via `bvn_units_compatible` and both serialise to
`"no_unit"` via `bvn_unit_to_string`. The distinction is expressive: explicit
`no_unit` signals that the author actively chose dimensionless, whereas an
omitted unit might mean the author simply did not think about units. For
documentation-grade data, prefer the explicit form.

---

**Can I write units as UCUM, UNECE, QUDT, UDUNITS, OM or CF codes?**

Yes — seven foreign vocabularies are accepted in the unit slot. The notation is
**spec 1.2**, and like every construct added after 1.0 a document opts in to it
with the version directive:

```bovnar
#!bovnar 1.2
.systolic = <float_dec:64,ucum:mm[Hg]>    120.00;   # UCUM
.count    = <uint:32,ucum:10*3/uL>       4500;
.mass     = <float:64,unece:KGM>           12.5;    # UN/ECE Rec 20
.velocity = <float:64,qudt:M-PER-SEC>       9.81;   # QUDT
.length   = <float:64,qudt-qk:Length>       3.0;    # a QUDT quantity KIND
.flux     = <float:64,udunits:kg*m-2*s-1>   0.5;    # UDUNITS / CF
```

A profile code is translated at parse time into the same unit a native spelling
produces, so `ucum:mm[Hg]` and `mmHg` compare equal, convert identically, and
satisfy the same unit-policy rule. That holds *across* vocabularies too:
`ucum:kg`, `unece:KGM`, `qudt:KiloGM`, `udunits:kg` and `qudt-qk:Mass` are one
and the same unit, and a cross-vocabulary suite checks every spelling against
every other one. The `#!bovnar 1.2` directive is required — without it the
notation is `error_unit_illegal`.

The translation either succeeds or fails; nothing is passed through unchecked.
A code that is not valid in its vocabulary, or that names something the profile
does not know, is `error_unit_illegal`; one that is valid but has no
representation here is `error_unit_profile_unsupported`; an unknown namespace is
`error_unit_profile_unknown`.

Watch the places where two vocabularies disagree on a spelling — UCUM's `st` is
the stere and Bovnar's is the stone, UCUM's `B` is the bel where QUDT's `BYTE`
is the byte — which is exactly why the namespace is mandatory rather than a
fallback. A flat vocabulary's code is also never decomposed: `unece:KGM` is the
kilogram, not a `k` prefix on a `GM`, and `qudt:MI` is the mile rather than a
milli-anything.

The full transliteration tables, the collisions, the codes that have no
representation, and the cross-vocabulary suite are in
[Unit Profiles](11_bovnar_unit_profiles.md).

---

**Can I write a unit as an inline suffix instead of inside the annotation?**

Yes, for scalar values in non-array context. Write the unit directly after the
value literal, separated by at least one whitespace character:

```bovnar
.speed   = 9.81 m/s;       # no annotation; inline unit adopted
.mass    = 70.0 k~g;
.storage = 65536 B;
```

The inline unit is forbidden inside array elements — any letter or underscore
following an array-element value is `error_unexpected_input_byte`. If both
an annotation unit and an inline unit are present, they must be identical;
a mismatch is `error_unit_mismatch`.

---

**How many base units does Bovnar support?**

264 named base units across the following categories:

- **7 SI base units** — second, meter, gram, ampere, kelvin, mole, candela.
- **21 named SI-derived units** — hertz through katal (Hz, N, Pa, J, W, V, Ω, F, C, S, Wb, T, H, lm, lx, Bq, Gy, Sv, kat, rad, sr).
- **14 non-SI units accepted for use with SI** — liter, minute, hour, day, week, year, degree (angle), degree Celsius, tonne, bar, electronvolt, dalton, astronomical unit, hectare.
- **2 digital units** — bit (`b`), byte (`B`), with IEC binary prefixes (kibi through quebi).
- **13 Imperial/US customary length units** — inch, foot, yard, mile, nautical mile, ångström, light-year, parsec, furlong, fathom, **chain** (`ch`), **rod** (`rd`), and **thou** (thousandth of an inch, alias `mil`).
- **11 Imperial/US customary mass units** — pound, ounce, grain, stone, short ton, long ton, troy ounce, carat, **slug**, **dram** (`dr`), **pennyweight** (`dwt`).
- **6 temperature scales** — degree Fahrenheit (affine), **degree Rankine** (linear, absolute), and the historical **Delisle** (`°De`), **Newton** (`°N`), **Réaumur** (`°Re`), and **Rømer** (`°Ro`) scales (all affine). Degree Celsius is counted among the non-SI accepted units above.
- **6 temperature *differences*** — **ΔK** (`delta_K`; `Δ°C` is the same unit, since the Celsius interval *is* the kelvin), **Δ°F** (`Δ°Ra` likewise), and the intervals of the four historical scales: **Δ°De**, **Δ°N**, **Δ°Re**, **Δ°Ro**. Each is a ratio scale — no offset — with its own quantity kind, so `ΔK` never converts to `K`: 25 °C is 298.15 K but a *rise* of 25 degrees is 25 K.
- **6 pressure units** — atmosphere, mmHg, Torr, psi, **inch of mercury** (`inHg`), and **atmosphere technical** (`at`, = 1 kgf/cm² = 98 066.5 Pa).
- **5 energy units** — calorie, BTU, erg, therm, **foot-pound** (`ft_lb`).
- **2 power units** — horsepower (`hp`), **metric horsepower** (`PS`, also `CV`; = 735.49875 W).
- **4 force units** — pound-force, dyne, kip, **kilogram-force** (`kgf`).
- **1 acceleration unit** — **standard gravity** (`gn`, = 9.80665 m/s², exact).
- **3 speed/frequency/rotation units** — knot, **revolutions per minute** (`rpm`), **revolution** (`rev`, full angular turn = 2π rad).
- **15 US and UK volume units** — gallon, gallon (UK), quart, pint, cup, fluid ounce, tablespoon, teaspoon, barrel, **US gill** (`gi`), **imperial gill** (`gi_uk`), **fluid dram** (`fl_dr`), **minim**, **peck** (`pk`), **bushel** (`bsh`).
- **3 UK imperial volume units** — **pint_uk**, **fluid_ounce_uk**, **quart_uk**.
- **2 area units** — acre, barn.
- **3 angle units** — arcminute, arcsecond, gradian; plus **revolution** (see rotation above).
- **8 CGS units** — poise, stokes, gauss, maxwell, oersted, stilb, phot, galileo.
- **3 radiation units** — curie, röntgen, rem.
- **2 logarithmic units** — neper, **decibel** (`dB`).
- **2 electrical power units** — **var** (reactive power), **volt-ampere** (`VA`, apparent power).
- **2 time extensions** — **month** (`mo`, Julian month = 2 629 800 s), **fortnight** (`fn` = 14 d = 1 209 600 s).
- **2 textile linear density units** — **tex** (1 g/km = 10⁻⁶ kg/m, ISO 1144), **denier** (`den`, 1 g/9 000 m; 9 den = 1 tex).
- **13 Old German units** — **Pfund** (`Pfd`), **Zentner** (`Ztr`), **Doppelzentner**, **Lot** (mass); **Prussian line, Zoll, Fuß, Elle, Rute**, **Klafter**, and **German (geographical) mile** (length); **Morgen** (area); **Scheffel** (volume). None accept an SI or IEC prefix.
- **7 surveying & signalling units** — **US survey foot**, **league**, **cable length**, **hand** (length); **quintal**, **scruple** (mass); **baud** (`Bd`, signalling rate).
- **8 ratio / proportion units** — **percent** (`%`), **per mille** (`‰`), **per myriad** (`‱`), **per cent mille** (`pcm`), **ppm**, **ppb**, **parts per trillion** (`pptr`, 10⁻¹²) and **parts per quadrillion** (`ppq`, 10⁻¹⁵) — dimensionless scaling factors that take no prefix. The last two are spelled `pptr`/`ppq` rather than `ppt`/`ppq` because `ppt` resolved as `p~pt`, the picopint, and because `ppt` means parts per *thousand* in some industries; UCUM makes the same split, spelling them `[ppth]` and `[pptr]`. The compact token `ppt` is now **refused outright** rather than read as either — the two ratios it might mean are 10⁹ apart, and only the author can say which. Write `pptr`, `‰`, or `p~pt`.
- **1 acidity scale** — **pH** (`pH`), a logarithm of hydrogen-ion activity; its own quantity kind, so it never converts to a percentage or a plain number.
- **2 named speed units** — **mile per hour** (`mph`), **kilometre per hour** (`kph`, also `kmh`). Neither takes a prefix; `mi/h` and `k~m/h` remain valid as compounds.
- **7 water-analysis units** — the five hardness degrees **German** (`°dH`), **English/Clark** (`°e`), **French** (`°fH`), **Russian** (`°rH`), **American** (`°aH`), plus **grains per US gallon** (`gpg`) and the **equivalent** (`val`, also spelled `eq`, ½ mol as water analysis uses it — so native `meq/L` is half of `ucum:meq/L`). The six degrees are amount concentrations and all inter-convert.
- **5 turbidity scales** — **NTU**, **FNU**, **FTU**, **FAU**, **JTU**: one quantity kind each, because each names a *measurement method* and no factor relates any pair of them.
- **1 salinity scale** — **PSU** (practical salinity, PSS-78): a conductivity ratio, so not a mass fraction and not convertible to `‰`.
- **1 conductivity scale** — **CF** (hydroponic conductivity factor, 1 CF = 0.1 mS/cm exactly), which *does* convert, being a rescaled conductivity.
- **6 units the unit profiles needed** — **metre of water column** (`mH2O`, the conventional 9 806.65 Pa; `c~mH2O` is the centimetre a ventilator is set in), **IT calorie** (`cal_IT`, 4.1868 J — native `cal` is the thermochemical 4.184 J), **thermochemical BTU** (`Btu_th` — native `Btu` is the international-table one), **troy pound** (`lb_t`, twelve troy ounces; also the apothecary pound), **apothecary dram** (`dr_ap`, three scruples — native `dr` is the avoirdupois dram, 2.2× smaller), and the **long hundredweight** (`cwt_l`, 112 lb; the short one is exactly `h~lb` and needs no unit of its own). Each was the sole reason a run of UCUM, UDUNITS-2, QUDT and UN/ECE codes had to be refused.
- **12 calendar and sidereal time units** — the **tropical** (`yr_trop`), **Gregorian** (`yr_greg`), **sidereal** (`yr_sid`) and **common** (`yr_com`) years; the **synodal** (`mo_syn`), **tropical** (`mo_trop`), **sidereal** (`mo_sid`) and mean **Gregorian** (`mo_greg`, a twelfth of `yr_greg`) months; and the sidereal **day**, **hour**, **minute** and **second** (`d_sid`, `h_sid`, `min_sid`, `s_sid`). `yr` is still the Julian year and `mo` still a twelfth of it — naming the others is what lets a UDUNITS or CF document that says `year` be read at all.
- **9 CGS electrostatic units** — **statvolt** (`statV`), **statampere** (`statA`), **statcoulomb** (`statC`, the franklin), **statfarad** (`statF`), **statohm** (`statΩ`), **stathenry** (`statH`), **statsiemens** (`statS`), **statweber** (`statWb`) and **stattesla** (`statT`). All are built on `c`, exact since the 1983 definition of the metre, so all are exact rationals.
- **3 luminance units** — **lambert** (`Lmb`), **apostilb** (`apostilb`, the blondel) and **foot-lambert** (`footlambert`), each 1/π-based and so marked inexact.
- **3 water-vapour permeances** — `perm_0C`, `perm_23C` and the metric `perm_m`.
- **6 heat conventions** — the **mean** (`cal_m`), **15 °C** (`cal_15`) and **20 °C** (`cal_20`) calories, and the **59 °F** (`Btu_59`), **60 °F** (`Btu_60`) and **mean** (`Btu_m`) BTUs, beside the thermochemical and IT ones already here.
- **5 singles** — the **elementary charge** (`e`, exact since 2019), the **spere** (`sph`, 4π sr, carrying the steradian's quantity kind), the **circular mil** (`cml`), the **unit pole** (`unit_pole`) and the **water horsepower** (`hp_W`).
- **9 more, corroborated by two and three vocabularies at once** — the **printer's point** (`pnt_pr`, 0.013837 in — `pnt` is the DTP point) and **printer's pica** (`pca_pr`); the **electric** (`hp_E`, 746 W exactly) and **boiler** (`hp_B`, 9809.5 W) horsepowers, beside the mechanical `hp` and metric `PS`; the **abvolt** (`abV`, 10⁻⁸ V, which no decade prefix reaches); the **assay ton** (`AT`, 175/6 g); the **imperial bushel** (`bsh_uk`, 8 `gal_uk` — `bsh` is the US dry one); the **clo** (0.155 K·m²/W); and the **debye** (10⁻²¹/c C·m, exact since the 1983 definition of the metre fixed c). Each was refused across UCUM, UDUNITS-2, QUDT and OM for want of a native unit of the magnitude.
- **23 more the unit profiles needed** — the **US survey lengths** built on the survey foot (`inUS`, `ydUS`, `fathUS`, `rdUS`, `chUS`, `lkUS`, `furUS`, `miUS`, `acUS`), which is 2 ppm longer than the international foot; the typographic **point** (`pnt`, ¹⁄₇₂ in), **pica** (`pca`) and **line** (`lne`) — not `pt`, which is the pint; the **US dry** gallon, quart and pint, 16 % larger than the liquid ones; the trade measures **board foot** (`fbm`), **cord** and survey **acre-foot** (`ac_ft`); and the singles **darcy** (permeability, takes prefixes so the millidarcy is `m~darcy`), **EC therm** (`thm_ec`, 10⁵ `Btu`), **ton of refrigeration** (`ton_ref`), **Dobson unit** (`DU`, ozone column) and **shake** (10⁻⁸ s, where no SI prefix reaches).

The `bu_gram` base unit is used for mass so that the `k~` prefix can carry the kilo: `k~g` = kilogram. The Rankine symbol is `°Ra` (also accepted as `Ra`); note `R` alone is reserved for röntgen. Thou accepts `mil` as an alternative spelling. `var` and `VA` share the same SI dimensional signature as watt but are kept distinct for physical clarity. `rpm` has the same SI dimension as `Hz` (s⁻¹) but a distinct base unit for semantic clarity in rotational contexts. `at` (atmosphere technical) must not be confused with `atm` (standard atmosphere): 1 at = 98 066.5 Pa; 1 atm = 101 325 Pa.

---

## 5. Numbers and Special Values

**Do I need to quote non-decimal numbers?**

Yes. Bovnar's lexer distinguishes bare words (symbols) from numbers by context.
A bare `FF` in value position is a symbol, not a hex number, and will produce
`error_type_value_mismatch`. A bare literal made only of digits of the base does
parse, and is read in that base — `<uint:8,_16> 10` is sixteen, and
`<uint:_16> 1e3` is 483, because `e` is a hex digit — so write every
non-decimal value as a quoted string:

<!-- bovnar-example: rejected -->
```bovnar
.hex = <uint:8,_16>  "FF";       # correct — 255
.hex = <uint:8,_16>  FF;         # WRONG — FF is a symbol
.bin = <uint:8,_2>   "11010110"; # correct
```

For signed non-decimal values, the minus sign goes inside the string:

```bovnar
.neg = <sint:32,_16> "-7FFFFFFF";
```

---

**What base systems are supported?**

For `uint` and `sint`: every base from `_2` through `_62` (consecutive). Bases
`_64` (standard Base64) and `_85` (Ascii85) are also supported, but **for `uint`
only** — their alphabets claim the sign glyphs as digits (Base64 uses `+` and
`/`; Ascii85 uses both `+` and `-`), leaving no unambiguous sign character, so a
signed value in these bases is a hard error (`error_illegal_value_type`). For
`float`: `_10` (decimal, the default) and `_16` (hexadecimal) only. `float_fix`
and `float_dec` do not accept a base parameter at all — specifying one is a hard
error.

---

**What are the special number literals?**

Three special values bypass normal numeric parsing. They are bare reserved
keywords — no sigil — and, like `null`/`true`/`false`, are reclassified out of
the symbol space by the validator. Only these exact spellings are reserved;
any other bare word (e.g. `infinity`, `nans`) is an ordinary symbol:

```bovnar
.nan = nan;
.inf = inf;
.neg = ninf;     # negative infinity
```

They are valid in both typed and untyped context and may be combined with any
float type annotation. A special-number keyword takes no inline unit suffix —
give its unit via the annotation (`<float:64,m/s> inf`).

---

**Are leading zeros legal in decimal integers?**

Yes. `007` parses as the integer `7`. There is no octal interpretation — the
leading zeros are ignored.

---

**Are there shorthand float forms like `-.5` or `123.`?**

Yes. Both leading-dot (`.5` or `-.5`) and trailing-dot (`123.`) forms are valid
for `float:64`:

```bovnar
.a = -.5;     # -0.5 as float:64
.b = 123.;    # 123.0 as float:64
```

---

## 6. Strings

**What escape sequences are defined?**

Exactly seven:

| Escape | Meaning |
|---|---|
| `\t` | Horizontal tab |
| `\n` | Line feed |
| `\v` | Vertical tab |
| `\f` | Form feed |
| `\r` | Carriage return |
| `\"` | Double quote |
| `\\` | Backslash |

In **spec 1.0** that is all — any other character after a backslash is
`error_illegal_escape_sequence`.

A **spec 1.1** document (declaring `#!bovnar 1.1`, see the [Specification
§3.4](03_bovnar_spec.md#34-version-directive-spec-11)) adds two more:

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

`\u{…}` rejects surrogates and values above `U+10FFFF` (`error_invalid_codepoint`).
`\x` writes a raw byte but the string must stay valid UTF-8, so `"\xC3\xA9"` is
`"é"` while a lone `"\xFF"` is `error_invalid_utf8_byte` — for arbitrary binary
data, still use an octet stream. Both `\x` and `\u` are **1.1-only**: in a
1.0/unversioned document they remain `error_illegal_escape_sequence`, so existing
documents are unaffected.

---

**Can strings span multiple lines?**

Yes. Raw whitespace bytes (HT, LF, VT, FF, CR) are accepted unescaped inside
string literals:

```bovnar
.poem = "roses are red
violets are blue";
```

---

**How does string concatenation work?**

Adjacent string literals separated only by whitespace or comments are
concatenated at lex time. This is the idiomatic way to split a long string
across lines:

```bovnar
.url  = "https://" "api.example.com" "/v1";
.guid = <uint:128,_16> "deadbeef"
                       "cafebabe"
                       "01234567"
                       "89abcdef";
```

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

---

**Is the `<utf8>` annotation ever required?**

No. A bare quoted literal is always synthesised as `utf8`. The annotation is
purely optional and expressive.

---

## 7. Arrays

**What is the basic array syntax?**

Comma-separated values inside square brackets:

```bovnar
.primes = [2, 3, 5, 7, 11];
```

---

**How are multi-dimensional arrays written?**

Rows are separated by `/`:

```bovnar
.matrix = [1, 2, 3]/[4, 5, 6]/[7, 8, 9];
```

Each `/[…]` is a new row dimension. The parser emits `ev_array_row_start` and
`ev_array_row_end` events for each row and `ev_array_dim_start` for each `/`
separator.

---

**Can array elements have different types?**

Array elements must be **homogeneous** (spec 1.0): every non-null element shares
the same kind, and every element of a bare array carries the same **unit**. The
numeric *encodings* may still mix, since the encoding is not part of the unit:

```bovnar
.mixed = [<uint:8> 1, <sint:8> -1, <float:32> 3.14];   # valid: all dimensionless
.same  = [<uint:32,m> 1, <float:64,m> 2.0];            # valid: one unit, two encodings
```

Different kinds (`[1, "two"]`) are rejected with
`error_array_element_type_mismatch`, and so is anything that is not the same
unit — a different dimension (`[<float:64,m> 1.0, <float:64,k~g> 2.0]`), a
different scale (`m` beside `ft`), a different prefix (`$USD` beside `k~$USD`),
or a different zero (`°C` beside `K`, which are 273.15 apart). The rule is the
unit rather than the dimension so that reading a bare array as one block is
actually safe; a **struct** is where genuinely different things go, and a
struct's fields keep their own units.

Homogeneity is a **materialised-document** rule: it is checked over the whole
value once it is assembled, so it is enforced by the DOM parser
(`bvn_dom_parse` / `bovnar.dom_parse`), not by the streaming SAX `Reader` (nor
by `loads`, which is built on it) — the streaming layer surfaces per-value
type/unit errors as the bytes arrive but cannot see sibling elements. Parse
through the DOM tier (or the `bovnar` CLI's `convert`) if you need these rules
enforced. A whole-array annotation (placed before `[`) is the default type for
elements that do not carry their own annotation:

```bovnar
.ports = <uint:16> [80, 443, 8080];
```

---

**How do null elements work inside arrays?**

Leading, trailing, or consecutive commas produce null elements:

```bovnar
.sparse = [, 1, , 2, ];   # null, 1, null, 2, null — five elements
```

---

**Can I nest arrays?**

Yes, inner arrays are just element values:

<!-- bovnar-example: rejected -->
```bovnar
.nested = [[1, 2], [3, 4]];    # valid; rectangular sub-arrays
.ragged = [[1, 2], [3, 4, 5]];  # error_array_row_size_mismatch — sibling sub-arrays differ in length
```

Note: since spec 1.0 sibling sub-arrays must match in length (and element shape), so bare arrays and matrices are rectangular — `[[1, 2], [3, 4, 5]]` is rejected. `error_array_row_size_mismatch` fires both when the `/`-rows of one array disagree (`[1,2,3]/[4,5]`) and when ragged sibling sub-arrays disagree (`[[1,2],[3,4,5]]`).

---

**Are inline unit suffixes allowed inside arrays?**

No. The inline unit suffix is forbidden at the lexer level for all array
elements. Any letter or underscore following an array-element value token is
`error_unexpected_input_byte`.

---

## 8. Structs

**What is the struct syntax?**

A struct groups zero or more assignments inside `{…}`. Every field inside is a
complete `.key = value;` assignment. The struct value at the parent level ends
with `};`:

```bovnar
.server = {
    .host = "db.internal";
    .port = <uint:16> 5432;
};
```

---

**How deeply can structs nest?**

Up to `max_struct_nesting` levels — the reference reader defaults to 64 and the field (a `uint8_t`) has a hard cap of 255. An empty struct (`{}`) is valid. An
unmatched closing brace is `error_illegal_struct_close`.

---

**Are structs valid as array elements?**

Yes:

```bovnar
.sensors = [
    {.id = <uint:8> 0; .value = <float:32,°C> 27.3;},
    {.id = <uint:8> 1; .value = <float:32,°C> 31.7;}
];
```

---

## 9. Null, Symbols, and References

**What is a null value?**

An explicit absence of value — nothing between `=` and `;`, or the reserved
keyword `null`:

```bovnar
.nothing    = ;              # null, no type annotation
.also_null  = null;          # the null keyword — identical to the empty slot
.typed_null = <uint:32> ;    # null with an explicit type
```

---

**Does Bovnar have booleans?**

Yes. `true`, `false`, `on`, and `off` are reserved keywords carrying the
`bool` type family (`on` ≡ `true`, `off` ≡ `false`). A bare boolean
synthesises a `<bool>` annotation; `<bool>` takes no parameters and accepts
only those four keywords. They serialize canonically as `true` / `false`:

```bovnar
.enabled = true;
.debug   = off;          # == false
.typed   = <bool> on;    # explicit; on == true
```

---

**What is a symbol?**

A bare, unquoted word in value position — for application-defined enums and
the like. The reserved keywords `null`, `true`, `false`, `on`, `off` and the
special floats `nan`, `inf`, `ninf` are *not* symbols (a word that merely
starts with one, like `Monday`, `ontology`, or `infinity`, is):

```bovnar
.status  = ok;
.mode    = read_only;
.day     = Monday;
```

Symbol bodies follow the same character rules as identifier bodies. The key
distinction from an identifier is context: identifiers appear after the leading
dot, symbols appear as values.

---

**What is a reference?**

A dotted path to another key in the document, introduced by `&`. The parser
stores the path as a string token; it does not resolve it — resolution is left
entirely to the application:

```bovnar
.host       = "db.internal";
.conn_host  = &.host;             # stored as ".host"
.deep       = &.config.db.host;   # multi-segment path
```

---

**Can a reference index into an array?**

Yes, since spec 1.1: a reference path may carry `[N]` index suffixes.

```bovnar
#!bovnar 1.1
.matrix  = [10, 20, 30]/[40, 50, 60];
.row0c1  = &.matrix[0][1];        # stored as ".matrix[0][1]"
```

As with the rest of a reference, the index is stored **verbatim and unresolved**;
it is interpreted only when the application resolves the path against the
materialised tree (`bvn_dom_lookup`, also used by the CLI `query` command), where
`&.matrix[0][1]` resolves to `20`. A flat `/`-row matrix is addressed
`[row][col]`, a 1-D array `[i]`, and nested arrays descend one index per level; a
partial/out-of-range index simply doesn't resolve. Being a 1.1 feature, a `[` in
a reference is `error_unexpected_input_byte` in a 1.0/unversioned document.

---

## 10. Octet Streams

**When should I use an octet stream?**

When you need to embed raw binary data without Base64 overhead. A NUL byte
(`0x00`) in value position switches the parser into binary chunk mode. The
UTF-8 validator is suspended for the duration of the binary region.

In practice you will not hand-author octet streams. Use the writer API, which
generates the correct framing automatically:

```
0x00                     ← stream open
  0x01 <LL> <LL> <data>  ← chunk: tag 0x01, length as little-endian uint16, data
0x00                     ← stream close
```

A length value of `0x0000` means exactly 65536 bytes. Any tag byte other than
`0x01` (chunk) or `0x00` (close) inside a stream is
`error_octet_stream_out_of_sync`.

---

## 11. Error Handling and Debugging

**What information does a parse error carry?**

Every error includes:

- `error_code_t` — a named code (e.g. `error_value_out_of_range`)
- Line number and column number in the input stream
- Byte offset from stream start
- The raw byte value that triggered the error

Retrieve these after a failed `bvnr_read`:

```c
fprintf(stderr, "%s at line %" PRIu64 " col %" PRIu64
        " offset %" PRIu64 " byte 0x%02X\n",
        bvn_error_to_string(bvnr_reader_get_error(r)),
        bvnr_reader_get_error_line(r),
        bvnr_reader_get_error_column(r),
        bvnr_reader_get_error_offset(r),
        bvnr_reader_get_error_byte(r));
```

---

**What is `continue_on_error` mode and when should I use it?**

When `continue_on_error` is set in `bvnr_read_flags_t`, a parse error invokes
the `on_error` callback and enters a resync state machine that skips bytes while
tracking bracket and brace nesting. Normal parsing resumes at whichever comes
first: a `;` at the original nesting depth, or the start of the next
assignment — a `.` at that depth followed by a byte that can begin an
identifier. The recovery count
(accessible via `bvnr_reader_get_recovery_count`) is incremented at error entry,
not on resync completion.

Use this mode for log streams and unreliable transports where a single malformed
assignment should not discard the rest of the file. For configuration and data
serialization, disable it and fail fast.

---

**What does `error_type_value_mismatch` mean?**

The value token is incompatible with the declared type annotation. The most
common cause is a non-decimal number written as a bare word (symbol) instead of
a quoted string:

<!-- bovnar-example: rejected -->
```bovnar
.x = <uint:8,_16> FF;   # FF is a symbol → error_type_value_mismatch
.x = <uint:8,_16> "FF"; # correct
```

---

**What causes `error_unit_mismatch`?**

An inline unit suffix and a type-annotation unit are both present but resolve
to different `value_unit_t` values:

<!-- bovnar-example: rejected -->
```bovnar
.bad = <float:64,m> 1.0 s;   # annotation says m, inline says s → error
```

Semantically equivalent notations that parse to the same internal representation
(e.g. `m/s` and `m·s⁻¹`) compare as equal and do not trigger this error.

---

**What are the most common mistakes?**

| Mistake | Error produced |
|---|---|
| Annotation on the key side of `=` | `error_unexpected_input_byte` |
| Non-decimal value not quoted | `error_type_value_mismatch` |
| Signed value in an unsigned type | `error_value_out_of_range` |
| Integer overflow (e.g. `<uint:8> 256`) | `error_value_out_of_range` |
| `<float:8>` or a non-16, non-multiple-of-32 width for `float` | `error_illegal_value_type` |
| Base parameter on `float_fix` or `float_dec` | `error_illegal_value_type` |
| `q ≥ width` in `float_fix` | `error_illegal_value_type` |
| Empty unit component (`m//s`) | `error_unit_illegal` |
| More than 32 unit components (`BVNR_MAX_UNIT_COMPONENTS`) | `error_unit_illegal` |
| Inline unit suffix inside an array | `error_unexpected_input_byte` |
| Unknown escape sequence (`\0`, `\uXXXX`; `\x`/`\u{}` outside spec 1.1) | `error_illegal_escape_sequence` |
| `\u{…}` surrogate or `> U+10FFFF` (spec 1.1) | `error_invalid_codepoint` |
| Unmatched `}` | `error_illegal_struct_close` |
| Empty key (`.= value;`) | `error_empty_identifier` |

---

## 12. C API

**What is the overall reader lifecycle?**

```
bvnr_reader_create()
  → bvnr_source_from_fd() or bvnr_source_from_mem()
  → bvnr_open_read_source() or bvnr_open_read_mem()
  → bvnr_read()
  → bvnr_reader_destroy()
```

The reader's buffers are allocated by the first `bvnr_open_read_*` and reused
by every later open of the same reader; `bvnr_read` itself allocates only for
unit conversions and wide numbers. The `bvnr_read_flags_t` struct is read only
during `bvnr_open_read_source` and may live on the stack.

---

**Which callback should I implement — `on_verified` or `on_unverified`?**

Almost always `on_verified`. This callback receives fully validated events after
semantic checking. `on_unverified` fires before validation and receives raw token
events; use it only for diagnostics or partial pre-inspection.

There is an important asymmetry for `ev_type_annotation_start`:
- `on_unverified` receives it with raw annotation bytes but no `value_type` or
  `value_unit` populated.
- `on_verified` receives it with `value_type` and `value_unit` filled in.

All other type-annotation events and `ev_data` are emitted to both callbacks
simultaneously.

---

**Do callbacks need to return anything?**

Yes. Both `on_verified` and `on_unverified` must return `bool`. Return `true`
to continue parsing; return `false` to abort — the parser stops and
`bvnr_read` returns `false` with `error_scanner_callback_failed`.

---

**How do I read a value from the `ev_data` event?**

The `bvnr_data_t` struct provides the raw bytes in `data`/`length`. Use the
`bvn_parse_*` helpers to convert them to native C types:

```c
uint64_t v;
bvn_parse_uint64(vbuf, d->value_type, &v);

double f;
bvn_parse_double(vbuf, d->value_type, &f);
```

The `d->value_type` contains `family`, `width`, and `base` (which holds the numeric base, or the Q for `float_fix`, or the epoch index for `datetime`). Use
`bvn_effective_width(d->value_type)` to get the resolved width (handles the
`width == 0` → 64 default).

---

**How is the writer API structured?**

It is symmetric with the reader. You construct events and push them through
`bvnr_write_event`, then call `bvnr_write_finish`:

```
bvnr_writer_create()
  → bvnr_sink_to_fd() or bvnr_sink_to_mem()
  → bvnr_open_write_sink() or bvnr_open_write_mem()
  → bvnr_write_event() × N
  → bvnr_write_finish()
  → bvnr_writer_destroy()
```

Use `bvnr_write_type_annotation(w, vt, vu)` as a convenience to emit the full
annotation event sequence in one call. Use `bvnr_write_bvnf_base` and
`bvnr_write_bvni` for the convenience integer/float helpers that generate the
scalar key+value pair in a single call.

---

**Does the library close file descriptors for me?**

No. Both `bvnr_source_from_fd` and `bvnr_sink_to_fd` leave ownership of the
file descriptor with the caller. Close it yourself after the reader or writer
is destroyed.

---

**What is the `max_array_nesting` limit and why is it capped at 255?**

The lexer stores per-nesting-level state in a heap array sized to `max_array_nesting + 1` entries (so at most 256 at the 255 hard cap).
`max_array_nesting` is a `uint8_t` field, so values above 255 cannot be
represented; no runtime rejection is performed. Zero-initialising
`bvnr_read_flags_t` is safe: a zero value causes the reader to substitute the
internal default of **64**. The hard maximum is 255. The same default and cap
apply to `max_struct_nesting`.

---

## 13. Python Bindings

**Do the Python bindings require a compiled extension module?**

No. The bindings are pure `ctypes` and load `libbvnr.so` through
`ctypes.CDLL` — not at import: `import bovnar` never loads it, and the library is
located the first time something needs it (`loads`, `Reader()`, `parse_unit`, …). The PyPI wheels bundle that library inside the package, so
`pip install bovnar` needs no compilation step at all; building the C library is
only the source-checkout path.

---

**How does the library discovery order work?**

1. `LIBBOVNAR_PATH` environment variable — absolute path to the `.so`.
2. `LIBBOVNAR_DIR` environment variable — directory containing the `.so`.
3. The copy bundled next to `_ffi.py` inside the installed package — how an
   installed wheel finds its own library, with no environment variable set.
4. `ctypes.util.find_library('bvnr')` — standard `ldconfig`/`LD_LIBRARY_PATH` search.
5. In-tree build paths relative to `_ffi.py` (`../../build/`, etc.).

If none of these resolves the library, that first call raises
`BovnarLibraryNotFound`; its message lists every path tried and why each failed.

---

**What is the difference between `loads`/`dumps`, the SAX reader, and the DOM?**

| Interface | When to use |
|---|---|
| `bovnar.loads` / `bovnar.dumps` | Simple dict serialization — loses type and unit metadata |
| `Reader` (SAX) | Streaming; low memory; full access to type and unit data via callbacks |
| `bovnar.dom_parse` / `DomDoc` | Random-access queries on a fully parsed in-memory tree |

---

**How do I capture state in a SAX callback?**

The Python `on_verified` callback receives exactly two positional arguments —
the event code and the data payload — with no userdata parameter. Use a closure
to capture external state:

```python
state = {"current_key": ""}

def on_event(ev, data):
    if ev == Event.ASSIGNMENT_START:
        state["current_key"] = data.raw_str()
    elif ev == Event.DATA:
        print(state["current_key"], "→", data.raw_bytes())
    return True
```

---

**What happens when a callback raises a Python exception?**

The exception is captured, the C callback returns `False` to stop parsing, the C
call returns, and then the original Python exception is re-raised from
`read_mem` / `read_fd`. The exception is not wrapped; it propagates as-is.

---

**How do I access unit information in Python?**

The `data.value_unit` field of an `Event.DATA` payload is a `ValueUnit` ctypes
struct. Use the helper functions to work with it:

```python
import bovnar

vu = bovnar.parse_unit("k~g·m/s²")          # parse string → ValueUnit
s  = bovnar.unit_to_str(vu)                   # → "k~g·m/s²"
ok = bovnar.units_compatible(vu_a, vu_b)      # dimensional compatibility
c  = bovnar.unit_convert_factor(vu_from, vu_to)
kelvin = bovnar.convert_value(25.0, vu_celsius, vu_kelvin)
```

`unit_dimension_vector` returns a 7-element list of SI dimension exponents in
the order `[m, kg, s, A, K, mol, cd]`.

---

**What exception classes exist?**

| Exception | Trigger |
|---|---|
| `BovnarLibraryNotFound` | `libbvnr.so` not found, on first use (not at import) |
| `BovnarParseError` | Parse error (carries `code`, `line`, `column`, `offset`, `byte`) |
| `BovnarWriteError` | Write error (carries `code`, `offset`) |
| `BovnarArgumentError` | Invalid argument to a helper function |

All are subclasses of `BovnarError`.

---

**What Python version is required?**

Python ≥ 3.10. The bindings use `dataclasses`, `enum.IntEnum`, and union-type
annotations (`X | Y`).

---

## 14. Limits and Performance

**What are the default size limits and what should I set in production?**

All limits are configurable via `bvnr_read_flags_t`. Defaults are permissive.

| Field | Default | Suggested production cap |
|---|---|---|
| `max_identifier_length` | 255 | 255 |
| `max_string_length` | 65535 | application-defined |
| `max_number_length` | 65535 | 65535 |
| `max_symbol_length` | 255 | 255 |
| `max_reference_length` | 65535 | 65535 |
| `max_array_items` | 0 (→ 2 147 483 647 internal default) | application-defined |
| `max_text_bytes` | 0 (→ 2 147 483 647 internal default) | application-defined |
| `max_file_size` | 0 (→ no total-byte cap) | `16777216` (16 MiB) |
| `max_array_nesting` | 0 (→ 64 internal default; hard cap 255) | 32 or less |
| `max_struct_nesting` | 0 (→ 64 internal default; hard cap 255) | 32 or less |

Setting a field to `0` causes the reader to substitute a finite internal
default — **2 147 483 647** for `max_array_items`/`max_text_bytes` and **64**
for both nesting depths. **`max_file_size` is the exception: `0` means
no total-byte cap**. That is not endless: the text is still bounded by
`max_text_bytes`, so an endless text stream raises that too; only octet-stream
payload runs without bound. Production deployments should set `max_file_size`
explicitly at minimum. For untrusted input, set `max_array_items`,
`max_text_bytes`, and the nesting limits as well.

In both bindings a nesting cap of `0` means **255**, the format's limit, not 64.

---

**Does `bvnr_read` allocate heap memory?**

Rarely. The reader's buffers (about 120 KB of scratch space and the nesting
stacks) are allocated by the first `bvnr_open_read_*` on a reader and kept for
every later open of it, so a reused reader allocates nothing per document.
During `bvnr_read` only a unit conversion (the converted value's text and
exact rational) and a number wider than 64 bits allocate. Event data pointers
(`d->data`) point into the reader's internal buffer, or for a fed reader into
the bytes being fed, and are valid only for the duration of the callback
invocation.

---

**What throughput can I expect from the parser?**

Performance depends heavily on the input profile (scalars, typed values, structs,
arrays, units) and hardware. The CLI's benchmark subcommand (`bovnar bench`) can
measure MB/s, assignments/s, and events/s across configurable profiles and
payload sizes. Run `bovnar bench --profile all --size 1024,65536 --iterations 200`
for a representative baseline. Use `--min-overhead` to drop the `on_unverified`
(lexer) callback and keep only `on_verified`. Add `--stream` to measure the
streaming paths as well -- raw octet-stream read and write, mux send and demux,
the push-fed demux, and framed documents, with per-chunk, per-message and
per-document costs; `--stream-only` runs just those, and `--stream-seconds <S>`
sets the wall-clock budget per streaming cell (default 0.2 s).

---

**How many unit components can a compound unit have?**

A maximum of 32 (`BVNR_MAX_UNIT_COMPONENTS`). Exceeding this limit during
parsing is `error_unit_illegal`.

---

## 15. Why C99 and Not C23?

**What is the stated language standard for the reference implementation?**

Strict C99 (`-std=c99 -pedantic`). No C11, C17, or C23 extensions are used
intentionally.

---

**Why not C23, given that it was finalised in 2024?**

Several compilers that are still in active use on embedded and cross-compilation
targets do not yet ship a complete C23 implementation, and some may never
receive one for cost or lifecycle reasons:

| Toolchain / target | C23 status (as of 2026) |
|---|---|
| GCC < 14 | Partial; `_BitInt`, `typeof`, `nullptr` missing or gated behind `-std=c2x` |
| Clang < 17 | Partial; several C23 headers and attributes absent |
| IAR Embedded Workbench | Trails the standard by several years; C11 support only on recent versions |
| Arm Compiler 5 (armcc) | Frozen at C99/C11 subset; no C23 roadmap |
| Green Hills MULTI | Embedded-safety toolchain; C23 not yet qualified |
| SDCC (small-device C compiler) | C11 subset; no C23 |
| Keil MDK (ARMCC legacy) | C99 only |

C99 has essentially universal compiler support across every architecture Bovnar
is expected to run on — x86-64, Arm Cortex-M/R/A, RISC-V, MIPS, PowerPC, and
various DSP families. C23 does not.

---

**Which specific C23 features would have been attractive but were ruled out?**

| Feature | C23 addition | Why skipped |
|---|---|---|
| `bool`, `true`, `false` keywords (no `<stdbool.h>`) | C23 | Requires C23-capable front-end; `<stdbool.h>` works everywhere |
| `nullptr` | C23 | Cosmetic; `NULL` or `(void*)0` is unambiguous |
| `typeof` / `typeof_unqual` | C23 | Useful for generic macros; GCC/Clang extensions exist but are non-standard pre-C23 |
| `#embed` | C23 | Attractive for embedding binary tables at compile time; workaround via `xxd -i` is portable |
| `[[attributes]]` | C23 | `__attribute__((…))` is already used where needed via compiler-specific guards |
| `_BitInt(N)` | C23 | Would help for arbitrary-width integer types; current code uses `uint64_t` + masking |
| Improved `constexpr` | C23 | Not applicable to a runtime library |

None of these are blockers. Every C23 feature Bovnar would have used has a
correct, readable C99 equivalent.

---

**What about C11? It has `_Atomic` and `_Static_assert`.**

C11 adds atomics, `_Generic`, `_Static_assert`, and `<threads.h>`. `_Static_assert`
is useful and is approximated in the codebase with a macro. The atomics and
threading additions are irrelevant, but for a more specific reason than "not
thread-safe":

* A **single** reader or writer object must not be used from two threads at once
  — it carries the whole parse state. Guard it with your own primitive.
* **Distinct** objects on distinct threads need no synchronisation at all. The
  library keeps no mutable shared state: the lexer's run lookup tables are built
  from a pre-main constructor precisely so their init flag can never be raced on,
  and nothing else is written after startup. `tests/bovnar_concurrency_test.c`
  pins this (12 000 concurrent reader / DOM / writer operations across 8
  threads, clean under ThreadSanitizer).

So no atomics are needed — not because concurrency is unsupported, but because
the only shared state is immutable by the time any thread exists. Pulling in C11
just for `_Static_assert` is not worth fragmenting compiler compatibility.

---

**Does C99 impose any meaningful limitations on the implementation?**

In practice, no. The limitations that do exist are handled cleanly:

- **Variable-length arrays (VLAs):** Not used. All fixed-size buffers are
  declared with compile-time constants. VLAs are optional in C11 and later and
  are dangerous on stack-limited embedded targets regardless of standard version.
- **`<stdint.h>` and `<stdbool.h>`:** Both are C99 additions and are available
  everywhere the library targets.
- **`//` comments:** C99. No issue.
- **Designated initialisers:** C99. Used extensively for `bvnr_data_t` and flag
  struct setup.
- **Flexible array members:** C99. Not currently used.
- **`restrict`:** C99. Used on internal buffer pointers where beneficial.

The one area where C99 imposes a real discipline is the absence of `_Static_assert`
— the implementation works around this with the classic `typedef char
static_assert_failed[condition ? 1 : -1]` pattern.

---

**Is this likely to change in the future?**

The minimum standard may be raised to C11 if a compelling use of `_Generic` or
`_Static_assert` arises and the embedded toolchain matrix shifts accordingly.
C23 as a baseline is not on the roadmap until the IAR, Keil, and Green Hills
ecosystems ship production-qualified C23 front-ends, which historically lags the
standard publication by three to five years.

---

## See also

- [Specification](03_bovnar_spec.md) — the normative answer behind every entry here
- [Tutorial](01_bovnar_tutorial.md) — the guided introduction, if a question here assumes too much
- [Unit & Currency Reference](05_bovnar_unit_system.md) — the registry behind §4
- [Read & Write API](08_bovnar_readwrite_api.md) — the C functions named in §12
- [Python Bindings](09_bovnar_python_bindings.md) — the package described in §13
- [Rust Bindings](14_bovnar_rust_bindings.md) — the same tiers as a Rust crate

---

*End of Bovnar — Frequently Asked Questions (Bovnar spec 1.2).*

<!-- ==================== Specification (https://www.bovnar.io/doc/03_bovnar_spec.md) ==================== -->

# Bovnar — Specification

> **Spec version:** 1.2
> **Status:** Normative — released (1.x line; additive over the frozen 1.0 baseline)
> **Scope:** Grammar, lexical structure, type system, units, limits, and error semantics.

---

## Table of Contents

1. [Overview](#1-overview)
    - 1.1 [Design Goals](#11-design-goals)
    - 1.2 [Architecture](#12-architecture)
    - 1.3 [Stream Model](#13-stream-model)
2. [File Format at a Glance](#2-file-format-at-a-glance)
    - 2.1 [Key Syntax Rules](#21-key-syntax-rules)
3. [Character Encoding & BOM](#3-character-encoding--bom)
    - 3.1 [UTF-8](#31-utf-8)
    - 3.2 [Byte Order Mark (BOM)](#32-byte-order-mark-bom)
    - 3.3 [Byte Classes](#33-byte-classes)
    - 3.4 [Version Directive (spec 1.1)](#34-version-directive-spec-11)
4. [Lexical Structure](#4-lexical-structure)
    - 4.1 [Whitespace & Comments](#41-whitespace--comments)
    - 4.2 [Identifiers (Keys)](#42-identifiers-keys)
    - 4.3 [String Literals](#43-string-literals)
    - 4.4 [Symbols](#44-symbols)
    - 4.5 [References](#45-references)
    - 4.6 [Numbers](#46-numbers)
    - 4.7 [Null Values](#47-null-values)
5. [Type Annotations](#5-type-annotations)
    - 5.1 [Syntax](#51-syntax)
    - 5.2 [Parameters](#52-parameters)
    - 5.3 [Parameter Order](#53-parameter-order)
    - 5.4 [Examples](#54-examples)
    - 5.5 [Non-decimal Base with Bare Numbers](#55-non-decimal-base-with-bare-numbers)
6. [Value Tokens](#6-value-tokens)
    - 6.1 [Type/Value Compatibility](#61-typevalue-compatibility)
    - 6.2 [Validation Rules per Numeric Type](#62-validation-rules-per-numeric-type)
    - 6.3 [Digit Validation](#63-digit-validation)
    - 6.4 [Special Number Semantics](#64-special-number-semantics)
    - 6.5 [Inline Unit Suffix](#65-inline-unit-suffix)
7. [Arrays](#7-arrays)
    - 7.1 [Row Syntax](#71-row-syntax)
    - 7.2 [Null Elements](#72-null-elements)
    - 7.3 [Row-Size Consistency](#73-row-size-consistency)
    - 7.4 [Element Homogeneity](#74-element-homogeneity)
    - 7.5 [Array Elements with Type Annotations](#75-array-elements-with-type-annotations)
    - 7.6 [Constraints](#76-constraints)
8. [Structs (Scopes)](#8-structs-scopes)
    - 8.1 [Syntax](#81-syntax)
    - 8.2 [Nesting](#82-nesting)
    - 8.3 [Empty Structs](#83-empty-structs)
    - 8.4 [Structs as Array Elements](#84-structs-as-array-elements)
    - 8.5 [Unmatched Braces](#85-unmatched-braces)
9. [Octet Streams (Binary Mode)](#9-octet-streams-binary-mode)
    - 9.1 [Overview](#91-overview)
    - 9.2 [Wire Protocol](#92-wire-protocol)
    - 9.3 [Events](#93-events)
    - 9.4 [Example](#94-example)
    - 9.5 [Constraints](#95-constraints)
10. [Default Type Synthesis](#10-default-type-synthesis)
    - 10.1 [Rules](#101-rules)
    - 10.2 [Event Sequence](#102-event-sequence)
    - 10.3 [Examples](#103-examples)
11. [Units System](#11-units-system)
    - 11.1 [Base Units](#111-base-units)
    - 11.2 [SI Prefixes](#112-si-prefixes)
    - 11.3 [IEC Binary Prefixes](#113-iec-binary-prefixes)
    - 11.4 [Unit Notation](#114-unit-notation)
    - 11.5 [Unit Exponents](#115-unit-exponents)
    - 11.6 [Examples](#116-examples)
    - 11.7 [Compound Unit Constraints](#117-compound-unit-constraints)
    - 11.8 [The `no_unit` Keyword](#118-the-no_unit-keyword)
    - 11.9 [Unit Profiles (spec 1.2)](#119-unit-profiles-spec-12)
12. [Validation & Constraints](#12-validation--constraints)
    - 12.1 [UTF-8 Validation](#121-utf-8-validation)
    - 12.2 [Size Limits](#122-size-limits)
    - 12.3 [Value Validation](#123-value-validation)
    - 12.4 [Array Validation](#124-array-validation)
    - 12.5 [Struct Validation](#125-struct-validation)
    - 12.6 [Identifier Validation](#126-identifier-validation)
    - 12.7 [String Validation](#127-string-validation)
    - 12.8 [Unit Validation](#128-unit-validation)
    - 12.9 [Octet Stream Validation](#129-octet-stream-validation)
13. [Error Handling & Recovery](#13-error-handling--recovery)
    - 13.1 [Error Model](#131-error-model)
    - 13.2 [Recovery Mode](#132-recovery-mode)
    - 13.3 [EOF in Resync](#133-eof-in-resync)
    - 13.4 [The Error Getters Under Recovery](#134-the-error-getters-under-recovery)
14. [Formal EBNF](#14-formal-ebnf)
15. [Complete Examples](#15-complete-examples)
    - 15.1 [Simple Configuration](#151-simple-configuration)
    - 15.2 [Typed Scientific Data](#152-typed-scientific-data)
    - 15.3 [Inline Unit Suffix](#153-inline-unit-suffix)
    - 15.4 [Binary Data with Octet Stream](#154-binary-data-with-octet-stream)
    - 15.5 [Arrays with Mixed Dimensions](#155-arrays-with-mixed-dimensions)
    - 15.6 [Deeply Nested Struct](#156-deeply-nested-struct)
    - 15.7 [References and Symbols](#157-references-and-symbols)
    - 15.8 [Compound Unit Examples](#158-compound-unit-examples)
    - 15.9 [Fixed-Point and Decimal Float Examples](#159-fixed-point-and-decimal-float-examples)
    - 15.10 [Error Examples](#1510-error-examples)
16. [Reference API](#16-reference-api)
    - 16.1 [Core Types](#161-core-types)
    - 16.2 [Type Construction Macros](#162-type-construction-macros)
    - 16.3 [Unit Macros](#163-unit-macros)
    - 16.4 [Reader Setup](#164-reader-setup)
    - 16.5 [Source/Sink Creation](#165-sourcesink-creation)
    - 16.6 [Reading](#166-reading)
    - 16.7 [Error Queries](#167-error-queries)
    - 16.8 [Utility Functions](#168-utility-functions)
    - 16.9 [Typed Write Helpers](#169-typed-write-helpers)
    - 16.10 [Error Codes](#1610-error-codes)
17. [Versioning & Stability](#17-versioning--stability)
18. [Security Considerations](#18-security-considerations)
    - 18.1 [Resource Exhaustion](#181-resource-exhaustion)
    - 18.2 [Recovery Mode Discards Data Silently](#182-recovery-mode-discards-data-silently)
    - 18.3 [Version Leniency](#183-version-leniency)
    - 18.4 [Transport Corruption of Octet Streams](#184-transport-corruption-of-octet-streams)
    - 18.5 [Reference Resolution](#185-reference-resolution)
    - 18.6 [Numeric Interpretation](#186-numeric-interpretation)
    - 18.7 [Units Are Validated, Not Verified](#187-units-are-validated-not-verified)
    - 18.8 [Leap-Second Table Drift](#188-leap-second-table-drift)
    - 18.9 [Confusable and Non-Normalized Keys](#189-confusable-and-non-normalized-keys)
    - 18.10 [No Confidentiality, Integrity, or Authenticity](#1810-no-confidentiality-integrity-or-authenticity)
- [Appendix A: Event Sequence Reference](#appendix-a-event-sequence-reference)
    - A.1 [Simple Assignment (Untyped)](#a1-simple-assignment-untyped)
    - A.2 [Typed Assignment](#a2-typed-assignment)
    - A.3 [Compound Unit Assignment](#a3-compound-unit-assignment)
    - A.4 [Array](#a4-array)
    - A.5 [Struct](#a5-struct)
    - A.6 [Octet Stream](#a6-octet-stream)
- [Appendix B: Implementation Notes](#appendix-b-implementation-notes)
    - B.1 [Keyword State Machine](#b1-keyword-state-machine)
    - B.2 [Special Number Keywords](#b2-special-number-keywords)
    - B.3 [Default Width, Base, and Q](#b3-default-width-base-and-q)
    - B.4 [Type Equality](#b4-type-equality)
    - B.5 [Numeric Type Check](#b5-numeric-type-check)
    - B.6 [Unit Component Access](#b6-unit-component-access)
    - B.7 [Fixed-point and Decimal Float Wire Representations](#b7-fixed-point-and-decimal-float-wire-representations)
- [Appendix C: Limits Summary](#appendix-c-limits-summary)
- [See also](#see-also)

---

## 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.

### 1.1 Design Goals

- **Unit-safe** – every value may carry a physical unit that is validated against a built-in unit table; a unit that conflicts with the type annotation is a parse error (`error_unit_mismatch`)
- **Self-describing** – values carry explicit type metadata (family, bit-width, base, measurement unit)
- **Human-readable** for common cases (numbers, strings, symbols, nested structures)
- **Binary-friendly** via octet stream escape for opaque payloads
- **Schema-free** but type-safe – the type annotation is per-value, optional, and validated
- **Streamable** – parsed incrementally via a pull-based reader; events emitted to callbacks
- **Error-recoverable** – optional resync mode for parsing through minor corruption

### 1.2 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.

### 1.3 Stream Model

```
stream_begin →
  (assignment)* →
  EOF
```

Each assignment is:

```
.identifier = value;
```

---

## 2. File Format at a Glance

```bovnar
# 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;
};
```

### 2.1 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]`, or `[-].digits[…]`; `e` or `E` (§4.6) | `42`, `-3.14`, `1e6`, `.5` |
| 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>` as the first comment | `#!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]>` (Q defaults to 0) | `<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.

A BOM anywhere else is an error or is ordinary content, depending on whether the first assignment has begun.

**Before the first assignment:**

- A BOM (`EF BB BF`) **inside any comment** — the first comment line, a version directive line, or a later comment line — produces `error_invalid_byte_order_mark`.
- A BOM **after leading whitespace**, with no comment line before it, produces `error_invalid_byte_order_mark`.
- A BOM **at the start of a line that follows a comment line or a version directive** produces `error_unexpected_input_byte`, as does a second BOM directly after the one at offset 0.

**Once the first assignment has begun:**

- `EF BB BF` inside a comment or a string is **valid UTF-8** and is accepted as content.
- `EF BB BF` between assignments produces `error_unexpected_input_byte`.
- `EF BB BF` *inside* an assignment is none of these: it is the three bytes of
  U+FEFF, and U+FEFF is a character a key or a symbol may contain (§4.2, §4.4).
  `.foo<U+FEFF> = 1;` assigns to a four-character key, and `.foo =<U+FEFF>1;`
  assigns the **symbol** `<U+FEFF>1`, not the number 1. Where a key or a symbol
  cannot stand — directly after a number, or before a `{` member's `.` — it is
  `error_unexpected_input_byte`. §18.9 is about exactly this kind of character.

<!-- bovnar-example: illustrative -->
```bovnar
# BOM at byte offset 0: valid
EF BB BF.foo = 1;

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

# BOM bytes after leading whitespace → error_invalid_byte_order_mark
   EF BB BF.foo = 1;

# BOM bytes at the start of the line after a comment line → error_unexpected_input_byte
# comment line
EF BB BF.foo = 1;
```

### 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 everywhere outside octet streams, strings and comments included (`0x00` where a value is expected opens an octet stream) |
| 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 as its
very first comment — in practice its first line:

```bovnar
#!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
**construct** introduced after 1.0 is available only to a document that declares
the version that introduced it, or a newer one. A 1.1 construct in an unversioned
(i.e. 1.0) document is invalid, exactly as it would be to a 1.0 reader, and so
is a 1.2 construct in a document declaring 1.1.

The gate is on constructs, and the constructs are a closed list: the `datetime`
family and its ISO-8601 literals, the `\x`/`\u{}` escapes and reference
indexing (1.1), and the unit-profile notation (1.2). The **unit registry is not
gated**. A unit, an alias, a currency code or an input spelling of a unit added
in a later release — the compact prefix form `kg` among them (§11.4) — is
accepted in every document whatever it declares, because it is only ever
accepted where the parser used to answer `error_unit_illegal` (§17). A
directive therefore says which constructs a document uses; it does not promise
that a reader of that version knows every unit in it.
`bvnr_reader_get_declared_version` returns `false` for an unversioned 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 spaces/tabs, 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.**

- It is recognised only as the **very first comment**, after an optional BOM and
  leading whitespace (blank lines included). A `#!bovnar …` appearing after
  any other comment, or after any content, is an ordinary comment and is
  ignored.
- `#!bovnar` not followed by a space or a tab (e.g. `#!bovnarish`, or the bare
  word at the end of a line) is an ordinary comment, not a directive.
- A directive prefix followed by a malformed version (missing component,
  non-numeric, leading zero, a component that does not fit in 16 bits, or
  trailing junk) is `error_invalid_spec_version`.

**Enforcement.** A 1.1+ reader records the declared version and exposes it
(`bvnr_reader_get_declared_version`). By default a version the reader does not
support — a newer minor, or any other major, `0.x` included — is **accepted
leniently**: recorded, but not rejected up front; the parse only fails if it
later meets syntax the reader does not implement. The constructs available are
those of the declared version as far as the reader knows them, so a document
declaring `0.9` gets the 1.0 grammar and one declaring `2.0` everything this
reader has. A reader
opened with `strict_version` instead rejects any unsupported version
(`error_unsupported_spec_version`), and a malformed directive
(`error_invalid_spec_version`), at the directive. The refusal ends the read
even under `continue_on_error`: recovering from it would read the document
the consumer asked not to read. A reader supports spec
`<major>.<minor>` when `major` equals its own and `minor` is ≤ its own
(`BVNR_SPEC_VERSION_*`).

| Directive line | Outcome |
|---|---|
| `#!bovnar 1.2` | current — accepted |
| `#!bovnar 1.1` | older minor — 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 0.9` | older major — lenient: accepted, with the 1.0 grammar; strict: error |
| `#!bovnar 1.65536` | component over 16 bits — `error_invalid_spec_version` |
| `#!bovnar 1` | malformed (no minor) — `error_invalid_spec_version` |
| `#!bovnar 1.1 # note` | **trailing junk** — `error_invalid_spec_version` |

The outcomes are given in a table rather than as comments on the directives
themselves, because a comment there is precisely the *trailing junk* the rule
above forbids: `#!bovnar 1.1 # current` is not an annotated directive, it is a
rejected one. The directive line carries the version and nothing else. Comments
resume on the next line, where they are ordinary and inert.

---

## 4. Lexical Structure

### 4.1 Whitespace & Comments

Whitespace and comments are freely interleaved between all tokens, with the one restriction on where a comment may *start* that is stated below.

**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)
```

```bovnar
# A full-line comment
.foo = 42;  # an inline comment
```

**`#` is not a terminator of a bare token.** A comment may not start immediately after a key, a number, a symbol, a reserved word, a reference, a datetime literal or a type-annotation parameter (`<uint:8# c` — after the family keyword itself, `<uint# c`, it may): there `#` is `error_unexpected_input_byte`, and at least one whitespace byte must come first. After a string, `]`, `}`, `>`, `=`, `;` or an inline unit, `#` may follow directly.

<!-- bovnar-example: rejected -->
```bovnar
.ok1 = 42 # whitespace first: valid
;
.ok2 = "text"# directly after a string: valid
;
.bad = 42# directly after a number: error_unexpected_input_byte
;
```

### 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:**

- At least one `id-start` character is required after `.` (`.=` is `error_empty_identifier`)
- Byte `0xC2` is rejected at identifier start **and body** positions (U+0080–U+00BF are forbidden in identifiers everywhere)
- The following ASCII punctuation characters are **hard errors** inside identifier bodies: `! " # $ % & ' ( ) * , . / : ; < > ? @ [ \ ] ^ ` { | } ~`. (`=` is not among them: it ends the key and begins the value, so `.foo=1;` is valid.)

**Valid identifiers:**

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

**Invalid identifiers:**

<!-- bovnar-example: rejected -->
```bovnar
. = 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 |

- `\u{…}` rejects surrogates (`U+D800`–`U+DFFF`) and values above `U+10FFFF`
  with `error_invalid_codepoint`; a missing/empty/over-long brace group or a
  non-hex digit is `error_illegal_escape_sequence`.
- `\x` writes a raw byte, but a string's contents must still be valid UTF-8 (the
  `utf8` family guarantee). So `"\xC3\xA9"` is `"é"`, whereas a lone `"\xFF"` is
  `error_invalid_utf8_byte`. Use an octet stream for arbitrary, non-textual bytes.
- A `\xHH` or `\u{…}` escape that *resolves to* an ASCII control byte
  (`0x00`–`0x08`, `0x0E`–`0x1F`, `0x7F` — every control except the whitespace
  controls `0x09`–`0x0D`) is rejected with `error_unexpected_input_byte`, exactly
  as a raw control byte in a string is: the escape is no way around the
  control-byte rule.
- **Gating.** `\x` and `\u` are 1.1-only. In a 1.0 or unversioned document
  (§3.4) the `x`/`u` after a backslash is not a recognised escape and yields
  `error_illegal_escape_sequence`, exactly as a 1.0 reader reports.

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:

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

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

UTF-8 validity is checked at two points. A raw byte must belong to a valid
sequence within its own literal, so `"\xc3" "\xa9"` written as two raw bytes is
`error_invalid_utf8_byte` at the second `"`. A `\x` escape is checked on the
decoded value of the whole token, so the escapes of adjacent literals may build
one character between them: `"\xC3" "\xA9"` is `"é"`. An escape-built invalid
sequence is reported at the first byte after the token.

#### Examples

```bovnar
.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 |

```bovnar
.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 (`on` ≡ `true`, `off` ≡ `false`), 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.

```bovnar
.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 *)
```

A `.` never extends a segment: it starts the next one, which must begin with an `id-start` character. A trailing or doubled dot (`&.x.`, `&.x..y`) is `error_unexpected_input_byte`.

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

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

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

**Examples:**

```bovnar
.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:

- the target **need not exist** at parse time; a reference to a missing key
  (`&.nope`), a forward reference (`&.x` before `.x` is defined), or a reference
  to a value outside the document is accepted and stored verbatim;
- **cycles are not detected** (`.a = &.b; .b = &.a;` parses), and cannot hang the
  library, which never follows a reference (`bvn_dom_lookup` navigates literal
  structure only, stopping at a reference rather than dereferencing it);
- **resolution is entirely the application's responsibility**, including how to
  treat dangling paths and cycles.

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
#!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 }
```

- Only `e`/`E` accepted as exponent marker in bare literals (base-16 float values in quoted string literals use `p`/`P` — see §6.3). Under a declared base of 15 or above the `e`/`E` of a bare literal is read as a digit, not as an exponent marker (§5.5)
- Leading zeros are valid (`007` is accepted)
- A trailing dot without fractional digits is valid (`123.`)
- `.` alone is a hard error

```bovnar
.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 keywords** — `nan`, `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`).

```bovnar
.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:

- At assignment level: `.key = ;` (nothing between `=` and `;`), or `.key = null;`
- In array context: leading/trailing commas, or consecutive commas: `[,1,,2,]`; a bare `null` element is equivalent

```bovnar
.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:

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

---

## 5. Type Annotations

### 5.1 Syntax

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

(Whitespace is also allowed beside the `:` and the `,` inside it — §5.3 — which
the summary productions in this section leave out; `doc/12_bovnar.ebnf` carries
every `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.

**A struct takes no annotation.** Its members carry their own, so an annotation
in force where a `{` opens — written before the struct, or inherited from the
array the struct is an element of — is `error_type_value_mismatch`:
`<utf8> {}`, `<uint:8> {.x = 1;}` and `<uint:8> [{.x = 1;}]` are all refused.

**Correct placement:**

```bovnar
.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):**

<!-- bovnar-example: rejected -->
```bovnar
.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), and its width parameter takes
what `sint`'s does: `0` (the default, 64) to 32768. 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`, and so is a quoted string (`<datetime> "123"`) —
unlike `sint`, a datetime has no base to write digits in. 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
#!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
```

* `YYYY-MM-DD` — a calendar date (interpreted at `00:00:00Z`)
* `YYYY-MM-DDTHH:MM:SS` — a date and time (UTC when no zone is given)
* a trailing `Z` (UTC) or a numeric `±HH:MM` time-zone offset
* an optional `.fraction` (one or more digits) after the seconds

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 `01`–`12`,
a valid day-of-month, hour `00`–`23`, minute `00`–`59`, second `00`–`60`, offset
`±HH:MM` with two-digit components, its hour `00`–`23` and its minute
`00`–`59`). The year is four digits, `0000`–`9999`. Which of two errors a
malformed literal gets is decided by its **skeleton**: the fields each taken as
a run of one or more digits, with every separator in its place —
`D-D-D`, optionally followed by `TD:D:D`, then optionally `.` and zero or more
digits, then optionally `Z` or `±D:D`, where `D` is one or more digits. A
literal that has the skeleton but is otherwise wrong — an out-of-range field, a
field of the wrong width (`2026-6-15`, `12:00:0Z`, `+2:00`) or an empty
fraction — is `error_invalid_datetime_literal`. One that departs from the
skeleton (a missing seconds field, `+0200` without its colon, a lowercase `t`
or `z`) is a lexical error, `error_unexpected_input_byte`, reported at the
first byte that does not fit. A **sign in front of the literal** is not part of
the skeleton, but it is not a departure from it either: a `-` opens a number
token, and it is the second `-` that makes the token a date. So
`-2026-06-15` — a year before `0000` — has the skeleton and is
`error_invalid_datetime_literal`, reported where the value ends (the `;`, `,` or
`]` after it); a signed literal without the skeleton (`-2026-06`) is the lexical
error as above. A `+` opens no token, so `+2026-06-15` is
`error_unexpected_input_byte` at the `+`. A literal in a document that
declares less than 1.1 is `error_illegal_value_type`. A second of `60` is a **UTC leap second** and
is accepted. What it stores depends on the epoch:

* On the **civil epochs** (`unix`, `mjd`, `ntp`, `y2000`) it normalises onto the
  following second — `2016-12-31T23:59:60Z` and `2017-01-01T00:00:00Z` store the
  same `unix` value. Those scales run a uniform 86 400 s day and have no second
  to spend on the insertion; collapsing is the correct POSIX reading.
* On **`tai`** it does not. TAI is a continuous atomic count, the inserted second
  is a real instant on it, and the two literals above store `1861920036` and
  `1861920037` respectively. Over the insertions the implementation's
  leap-second table records, this makes UTC⇄TAI **injective**: every TAI second
  has exactly one civil spelling and every civil spelling one TAI second, so a
  `tai` value survives any number of write/read cycles unchanged.

A `:60` at an instant the implementation's leap-second table does not record as
an insertion collapses on `tai` too: it stores the value of the following
second, as on a civil epoch, and that is the one case in which two civil
spellings share a `tai` value. 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. Because a
literal with a fraction is written back as a literal, its instant must have a
four-digit year **in UTC**: one that an offset carries outside `0000`–`9999`
(`0000-01-01T00:00:00.5+23:59`, which is the last day of the year before) is
`error_invalid_datetime_literal`. Without a fraction the same literal is valid —
it is stored and written back as its integer, which has no year to overflow. 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
#!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`/`datetime` | uint, sint, float, float_fix, float_dec, datetime |
| Base | `_N` (underscore + decimal digits) | `2–62` (uint/sint); `64`, `85` are **uint-only** (their alphabets take the sign characters as digits — Base64 has `+`, Ascii85 has `+` and `-` — so signed values are illegal — `error_illegal_value_type`); every other value (`_0`, `_1`, `_63`, `_99`) is `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`; **omitted → 0** | **float_fix only** |
| Epoch (spec 1.1) | a bare name | `unix` (default), `tai`, `gps`, `mjd`, `ntp`, `galileo`, `glonass`, `y2000`, `beidou` (§5.1) | **datetime only** |
| Unit | unit-string | See [Units System](#11-units-system) — supports compound units | uint, sint, float, float_fix, float_dec |

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

**Digit alphabets.** The digits of a base are, in order of value:

| Base | Digits, lowest first | Case |
|------|----------------------|------|
| 2–36 | `0`–`9`, then the letters `a`–`z` (10–35) | insensitive: `F` and `f` are both 15 |
| 37–62 | `0`–`9`, `a`–`z` (10–35), `A`–`Z` (36–61) | sensitive: `a` is 10, `A` is 36 |
| 64 | `A`–`Z` (0–25), `a`–`z` (26–51), `0`–`9` (52–61), `+` (62), `/` (63) — the RFC 4648 alphabet, without padding | sensitive |
| 85 | the 85 characters `!` (0x21, value 0) to `u` (0x75, value 84) — Ascii85 | sensitive |

So `<uint:8,_62> "A"` is 36 and `<uint:8,_36> "A"` is 10. A base-85 value that
needs `"` or `\` writes it with the string escape (`\"`, `\\`, §4.3). A
character outside the alphabet is `error_digit_not_in_base`, an empty string
included.

**An omitted Q is 0.** `<float_fix:32>` is `<float_fix:32,q0>`: a fixed-point
value with no fractional bits.

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 binary encoding the C API reads and writes
(`bvn_float_to_decNN` / `bvn_float_from_decNN`) is parameterised by width. The
32-, 64- and 128-bit widths are the IEEE 754-2008 decimal interchange formats in
their **binary-integer (BID)** encoding; 16 bits is not an IEEE width and is
encoded by the same scheme with the parameters below; 256 bits is an in-house
layout that uses the whole exponent field. None of this reaches a document —
there a `float_dec` is a decimal literal (Appendix B.7):

| 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 |

`coeff_bits` is the coefficient as stored in the common case. In the BID widths
a coefficient too large for it is written with the combination field's `11`
prefix, which implies its top three bits and frees one more — that is how 7
digits fit beside 23 bits and 16 beside 53. *Max decimal digits* is the
format's precision, not what `coeff_bits` alone would hold.

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:

```bovnar
# 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`.
So is a second parameter of one class (`<uint:8,16>`, `<uint:_10,_10>`,
`<float:64,m,s>`), and a parameter of a class the family does not take.

#### Whitespace inside an annotation

Whitespace (and a comment, which is whitespace) is allowed **beside a
separator** and nowhere else. The separators are the `:` that introduces the
parameter list, the `,` between parameters, the `<` / `>` that delimit the
annotation, and the `:` that ends a profile namespace (§11.9) — so
`<float:64,udunits : kg>` is `udunits:kg`, the whitespace dropped as it is
beside any other separator. That is where the namespace ends and the code
begins, and nothing can be misread across it:

```bovnar
.a = <float : 64> 1.0;          # valid — either side of the family colon
.b = <uint:8 , _16> "ff";       # valid — either side of a parameter comma
.c = <float:64,m/s > 1.0;       # valid — before the closing bracket
.d = <float:64, # which unit
      m/s> 1.0;                 # valid — a comment stands in for the space
```

**Inside a parameter it is `error_type_param_whitespace`**, reported at the
first byte after the whitespace:

<!-- bovnar-example: rejected -->
```bovnar
.e = <float:64,k g> 1.0;        # error_type_param_whitespace
.f = <uint:6 4> 1;              # error_type_param_whitespace
.g = <float:64,m / s> 1.0;      # error_type_param_whitespace
```

This is a rule about a *wrong value*, not about tidiness. Whitespace inside a
parameter used to be deleted, so `<float:64,m in>` was accepted as `min` — the
minute — and `<uint:6 4>` as a 64-bit width: a wrong unit and a wrong type, each
produced silently. Two things new in 1.2 would have made the trap much larger
had the deletion stayed. With the compact prefix form, `<float:64,k g>` would
have become `k~g`; and UDUNITS and CF multiply with a space, so
`<float:64,udunits:m s-1>` would have been read as `ms-1`, **reciprocal
milliseconds**, for a value the author wrote as a speed. (Neither of those two
was ever accepted by a released reader: 1.1 has no compact form and no
profiles.) The exception below is what makes the space-multiplied form mean what
it says.

A comma nested inside a UCUM annotation (`{…}`) or a bracketed atom (`[…]`) is
part of the code rather than a separator, so it does not end the parameter:
`ucum:mL{cells,tot}` is one parameter, not two.

#### The one exception: a namespaced parameter

Inside a parameter that carries a **profile namespace** (`udunits:`, `ucum:`, …),
whitespace is neither deleted nor refused — it is **kept verbatim** and handed to
the vocabulary, which decides what it means. This exists for one concrete reason:
UDUNITS multiplies with a space, and `kg m-2 s-1` is the commonest spelling of a
flux in netCDF and CF metadata.

<!-- bovnar-example: rejected -->
```bovnar
#!bovnar 1.2
.flux = <float:64,udunits:kg m-2 s-1> 0.5;   # valid — the same unit as udunits:kg*m-2*s-1
.speed = <float:64,udunits:m s-1> 9.81;      # valid — m/s
.t = <float:64,udunits:ms-1> 1.0;            # valid, and DIFFERENT: m~s⁻¹, a
                                             # reciprocal millisecond. The space
                                             # is what tells the two apart
.count = <float:64,ucum:mL{cells, tot}> 1.0; # valid — an annotation is inert
                                             # text and keeps its spacing
.bad = <float:64,ucum:[in i]> 1.0;           # error_unit_illegal — from UCUM's
                                             # grammar, not from the lexer
.bad = <float:64,qudt:Kilo GM> 1.0;          # error_unit_illegal — a flat
                                             # vocabulary has no operators
```

Four details of that exception:

- **The family `:` is not a namespace.** Only a second `:` at bracket depth 0
  makes a parameter namespaced, so `<uint:6 4>` is still
  `error_type_param_whitespace`.
- **Trailing whitespace is trimmed**, since it sits beside the separator that
  ends the parameter: `<float:64,udunits:m >` is `udunits:m`.
- **A comment cannot follow a namespaced code.** Whitespace after it is
  trimmed, but the code runs to the `,` or `>` that ends the parameter, and `#`
  is not a code byte: `<float:64,ucum:kg # note⏎>` is
  `error_unexpected_input_byte`, where `<float:64,kg # note⏎>` is fine. Put the
  comment before the parameter, or beside its namespace colon.
- **A line break inside the code is still an error**, even in a namespaced
  parameter — `error_type_param_whitespace`, as it would be in a native one.
  No vocabulary spells a unit across a line, and admitting one would let a
  malformed code consume a document's layout. A line break *after* the code,
  before its `,` or `>`, is trailing whitespace like any other.

An **inline** unit suffix cannot carry a space and never will — whitespace is
what terminates that token, so `1.0 k g` has always been
`error_unexpected_input_byte`, and the space-separated profile spelling is
available in a type annotation only.

### 5.4 Examples

```bovnar
# 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 bare number literal (§4.6 — the token is lexed before its base is known, so it must be a number in *that* grammar) under a non-decimal base is valid when every digit of it is a digit of that base, and it is read **in that base**: `<uint:_16> 10` is sixteen, not ten. In base 15 and above `e` and `E` are digits, so a bare `1e3` under `_16` is the integer 0x1E3 (483) and not an exponent form; below base 15 the `e` is still an exponent marker, and under an integer family that is `error_type_value_mismatch`. A value that needs a letter the number lexer does not accept cannot be written bare — `ff` is lexed as a symbol, and the type/value check reports `error_type_value_mismatch`; `1e`, an `e` with no digit after it, is not a number token at all and is `error_unexpected_input_byte` — so it must be a quoted string. Producers SHOULD quote every non-decimal value:

```bovnar
.n = <uint:_16> "ff";     # hex value 255 — the form to write
.m = <uint:_16> 10;       # valid, and sixteen: the bare digits are read in base 16
.k = <uint:_16> 1e3;      # valid, and 483: in base 16 the 'e' is a digit
```

---

## 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) | A decimal signed integer (epoch seconds), an ISO-8601 literal (§5.1), or a special number (§6.4); `.`/`e` and string carriers are rejected |

Three value kinds stand outside this table. A **null** takes any annotation and carries its type (§4.7). A **struct** takes none: under any annotation it is `error_type_value_mismatch` (§5.1). An **octet stream** under an annotation is accepted under *every* family: the annotation is carried on the stream's start and end events (its chunks are delivered plain) and is **not checked** against it, so `<uint:8>`, `<utf8>` or `<bool>` in front of an octet stream is not `error_type_value_mismatch`. It says nothing about the payload, and producers SHOULD NOT write one.

### 6.2 Validation Rules per Numeric Type

**uint (unsigned integer):**

<!-- bovnar-example: rejected -->
```bovnar
.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):**

<!-- bovnar-example: rejected -->
```bovnar
.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.

**No range or precision check.** A `float` or `float_dec` literal with more
precision or a larger exponent than the declared width can hold is valid
(`<float:16> 1e30`, `<float_dec:16> 1234`, a bare `1e999`). The excess is the
consumer's to round, and a consumer may deliver ±infinity. For these two
families the width bounds what a producer promises, not what the reader
rejects; only the integer families and `float_fix` are range-checked.

<!-- bovnar-example: rejected -->
```bovnar
.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 be representable in the declared format:
`raw_integer = round(value × 2^Q)`, rounded once to nearest with ties to even,
must fit a signed `width`-bit field. The representable values are
`[-2^(width-1-Q), 2^(width-1-Q) − 2^(-Q)]`, and the test is made **after**
rounding — so a literal just past either end that rounds back onto it is valid
(`<float_fix:16,q8> 127.998` rounds to the maximum, 127.99609375; `127.999`
rounds to 128 and is out of range). A value whose rounded integer does not fit
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.

<!-- bovnar-example: rejected -->
```bovnar
.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.

<!-- bovnar-example: rejected -->
```bovnar
.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:

<!-- bovnar-example: rejected -->
```bovnar
.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 use `e`/`E` as the exponent separator (§4.6) — in a base below 15; from base 15 up the letter is a digit, bare or quoted (§5.5). 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.

```bovnar
.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.

#### Signs in Quoted String Literals

The bare number rule (§4.6) admits a leading `-` and no `+`. A quoted **integer** literal (`uint`, `sint`) follows it: `<sint:8> "-1"` is −1, and `<sint:8> "+1"` is `error_digit_not_in_base`, the `+` being no digit of any base. A quoted **float** literal — `float` in either base, `float_fix`, `float_dec` — departs from it in one respect: it may also begin with a single `+`, which leaves the value unchanged. The 1.1 reader accepted this, and refusing it now would refuse documents 1.1 accepted, so it is part of the format:

<!-- bovnar-example: rejected -->
```bovnar
.f = <float:64> "+1.5";            # OK: 1.5
.h = <float:64,_16> "+1.8p+2";     # OK: 6.0
.q = <float_fix:32,q8> "+1.5";     # OK
.i = <sint:8> "+1";                # error_digit_not_in_base
.d = <float:64> "++1";             # error_digit_not_in_base: one sign only
.s = <float:64> " +1";             # error_digit_not_in_base: the sign comes first
```

The sign must be the first byte inside the quotes, and there is at most one. Producers SHOULD NOT write the `+`.

### 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_number` — `utf8` (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.)

```bovnar
.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 |

```bovnar
.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` |

**Match** means the two parse to the same components — the same base unit,
prefix and exponent in each, compared as a set, so order and spelling are free
and algebra is not. `m/s` matches `m*s^-1`, `km` matches `k~m`, `L` matches `l`
and `m*s` matches `s*m`; `N` does not match `k~g·m/s²`, and `m` does not match
`no_unit`. An omitted annotation unit matches anything — the inline unit is then
simply the unit. (This is `bvn_unit_equal`, §16.8; it is also the comparison
§7.4 makes between array elements, except that the two dimensionless forms of
§11.8 count as one unit there.)

<!-- bovnar-example: rejected -->
```bovnar
# 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`. `no_unit` is a valid suffix and is checked like any other.

**An inline unit after a `utf8` string is accepted.** The inline path does not consult the type family: `<utf8> "text" m` and a bare `"text" m` are valid, the unit is carried on the value and is written back on re-serialisation — although the same unit as an annotation parameter (`<utf8,m>`) is `error_illegal_value_type` (§5.1). The suffix exists for quoted *numeric* literals (`<uint:_16> "ff" m`); producers SHOULD NOT attach one to text.

---

## 7. Arrays

### 7.1 Row Syntax

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

```bovnar
.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:

```bovnar
.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:

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

An element may be empty only beside a comma, so whitespace alone (`[ ]`) is
still the empty row. A type annotation alone is an element: `[<uint:8>]` is one
typed null, not an empty row (§7.5).

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):

- **Kind.** Every non-null element shares the same kind — number, string, symbol, bool, reference, octet stream, array, or struct. `[1, "two"]` and `[1, {.x=1;}]` are `error_array_element_type_mismatch`.
- **Unit** (bare scalar arrays and matrices). Numeric elements must carry the **same unit**, not merely the same dimension (through spec 1.1 this rule read *the same dimension*; §17 records the correction); the numeric encodings (`uint`, `sint`, `float`, `float_fix`, `float_dec`) may mix freely. `[<float:64,m> 1.0, <float:64,k~g> 2.0]` (length vs mass) is rejected, and so are `[<float:64,m> 1.0, <float:64,ft> 2.0]` (a scale apart), `[<float_dec:64,$USD> 1.0, <float_dec:64,k~$USD> 2.0]` (a prefix apart) and `[<float:64,°C> 1.0, <float:64,K> 2.0]` (an affine **offset** apart). `[1, 2.5, 3]` (all dimensionless) is fine, and so is `[<float:64,m*s> 1.0, <float:64,s*m> 2.0]` — the comparison is order-insensitive, because unit multiplication commutes — as is an explicit `no_unit` beside an omitted unit, which are two spellings of the same thing. Each currency is its own unit, so a bare array may not mix `$USD` and `$EUR` values.

  The rule is the unit and not the dimension because of the sentence that closes this section: *a consumer may treat the elements identically*. Under dimensional homogeneity that sentence was false — a reader that took it at its word and applied one unit to `[1.0 m, 2.0 ft]` was wrong by 0.3048, and on `[1.0 °C, 2.0 K]` wrong by an added 273.15. An offset between two neighbouring cells is not a mistake anything downstream can notice, so it is refused at the parse, where every other wrong unit in this format is refused.
- **`datetime` (spec 1.1).** A `datetime` is its own kind: it does **not** mix with the plain numeric encodings above (`[<datetime:64,unix> 1, <sint:64> 2]` is `error_array_element_type_mismatch`). And, exactly as for currencies, its **epoch is a dimension** — a bare array may not mix epochs (`[<datetime:64,unix> 1, <datetime:64,tai> 2]` is rejected); a homogeneous same-epoch datetime array is fine. (These are materialised-document/DOM-tier rules, like the rest of §7.4.)
- **Rectangular** (nested arrays). Sibling sub-arrays must have the same length and recursively-matching element shape: `[[1,2],[3,4]]` is valid, `[[1,2],[3,4,5]]` is `error_array_row_size_mismatch`.
- **Structs — same keys, fields free.** Sibling structs must share the same keys, in order, with the same per-field *kinds* and nesting. Differing keys are `error_struct_shape_mismatch`; a field that is a number in one record and a string in another is `error_array_element_type_mismatch`. But a scalar field may carry a **different unit** in each record, and a list field a **different length** — so a multi-currency ledger and per-record argument lists are valid.
- **Null is a hole.** `null` / empty array elements match any shape and never establish or break homogeneity, so sparse arrays like `[1, , 3]` remain valid.

<!-- bovnar-example: rejected -->
```bovnar
.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_scale  = [<float:64,m> 1.0, <float:64,ft> 2.0];   # same dimension, different unit
.bad_offset = [<float:64,°C> 1.0, <float:64,K> 2.0];   # 273.15 apart
.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 read the whole block under the unit of any one element — 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:

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

**An element annotation describes that element and no other.** In
`[<uint:8> 1, 300]` the `300` is a plain integer (`uint:64` by §10), not a
`uint:8` out of range. To type every element, annotate the array (§5.1).

**The one exception is `datetime` (spec 1.1).** After an element annotated
`datetime`, a following **bare integer** is read as a datetime of the same width
and epoch — `[<datetime:64,tai> 1, 2]` is two `tai` instants, and
`[<datetime:8> 1, 300]` is `error_value_out_of_range`. "Following" is in document
order across the whole array, not only within the row: bare integers in later
sibling sub-arrays and later `/`-rows inherit it too — `[[<datetime:8> 1], [300]]`
is `error_value_out_of_range` and `[<datetime:8> 1]/[2]` is two datetimes. A
bare ISO literal sets that type as an annotation does: `[2020-01-01, 5]` is two
`<datetime:64,unix>` instants, while `[5, 2020-01-01]` is
`error_array_element_type_mismatch`. Without that, the second
element would be a `uint` beside a `datetime` and the array could never be
homogeneous (§7.4). It reaches integers only: a bare ISO literal still infers
`<datetime:64,unix>` (§10.1), so `[<datetime:64,tai> 1, 2026-06-15]` mixes
epochs and is `error_array_element_type_mismatch`; annotate the array, or each
literal.

### 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) |

`max_array_items` is a budget rather than an element count: every `[` and every
`,` under one outermost array costs one, so an empty row costs one too —
`[]/[]` needs 2, `[[],[]]` needs 4, `[[1,2],[3,4]]` needs 6.

---

## 8. Structs (Scopes)

### 8.1 Syntax

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

Structs group related assignments into a nested scope:

```bovnar
.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:

<!-- bovnar-example: rejected -->
```bovnar
.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

```bovnar
.empty = {};
```

### 8.4 Structs as Array Elements

```bovnar
.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.

> **Transport.** Chunks are length-prefixed rather than delimited, so a payload may contain any byte, needs no escaping, and does not expand — and a reader can skip a region without inspecting it. The consequence is that a document containing an octet stream is **not** safe through a transport that rewrites bytes. Line-ending normalisation on a `0x0D` inside a payload desynchronises the length prefixes, and the result is unrecoverable: it reads as a malformed document rather than a mangled one. Store such documents as binary (`*.bvnr binary` in `.gitattributes`), and where a channel must stay text, have the reader enforce it with `text_only` (read/write API §1.13) rather than assume it.

### 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

An octet stream carries raw bytes, so it cannot be written literally in a text document. The form below is the conventional *escaped* notation used throughout this specification: each `\xNN` stands for one byte of the file, not for those four characters. Pasted verbatim into a `.bvnr` file it is `error_unexpected_input_byte`; the writer API emits the real bytes.

<!-- bovnar-example: illustrative -->
```bovnar
.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

- File size: `max_file_size` (0 → **no total-byte cap**, the default; set to `16777216` for the recommended 16 MiB cap). The text is still bounded by `max_text_bytes` (§12.2)
- Octet stream bytes contribute to the file size limit but not to `max_text_bytes`
- EOF inside an octet stream region is `error_read_complete_chunk_failed`, never `error_got_incomplete_bvnr_stream`, and it is not recovered under `continue_on_error`

---

## 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 synthesised type is checked like a written one.** A bare integer above
2⁶⁴ − 1, or below −2⁶³, does not fit the `uint:64` / `sint:64` it is given and is
`error_value_out_of_range`; write it under an annotation wide enough
(`<uint:128> 340282366920938463463374607431768211455`). A bare float is never
range-checked (§6.2).

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 `datetime` type
> (width and epoch) of the array's annotation, or of the datetime element
> before it — annotated, or a bare ISO literal, which synthesises
> `<datetime:64,unix>` (§7.5) — rather than synthesising `uint`/`sint`, so
> `[2020-01-01, 5]` is two `unix` instants and a datetime array
> as the pretty-printer writes it — 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 a scalar value, on every value; inside an array only when the synthesised type changes from one element to the next (Appendix A.4). 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

```bovnar
# 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 **264 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/05_bovnar_unit_system.md`](05_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](05_bovnar_unit_system.md).

### 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. Two input-only aliases are accepted for the micro prefix: `μ` = U+03BC (GREEK SMALL LETTER MU, `0xCE 0xBC`) and ASCII `u` (`μ~m` = `u~m` = `µ~m`). `bvn_unit_to_string` and the `bvnr_write_*` helpers always render the prefix as `µ` (U+00B5), though the pretty-print / canonicalize path preserves the input spelling 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`:**

- A bare base unit with no prefix requires no separator.
- When a prefix is present on a **physical** unit, the `~` separator is optional: `k~g` and `kg` are the same unit, as are `Mi~B` and `MiB`. The base unit is matched as the longest alias suffix, so a bare unit always outranks a prefixed reading of the same token (`min` is the minute, not milli-inch) — a compact spelling is therefore only ever accepted where the separated form would have been `error_unit_illegal`, and no existing document decodes differently because of it. Prefixes still cannot be stacked (`kkg`, `k~kg` → `error_unit_illegal`), prefix–unit validity is unchanged, and a few compact spellings are refused by name because the token is a well-known annotation for something else: `usb` (the bus, not microstilb — write `u~sb`), `kt` (a standard abbreviation for *two* units this format models, the kilotonne and the knot — write `k~t` or `kn`), `ppt` (parts per trillion or per thousand in the wild, a picopint by the compact rule — write `pptr`, `‰` or `p~pt`), and `MBtu`, `MBTU`, `Mbtu`, `Mlb`, `Mlbs` (a thousand in US trade use, where the compact rule reads mega — write `k~Btu`/`k~lb` or `M~Btu`/`M~lb`). `pH`, `mph` and `kph` needed an exception until the quantities they name became units in their own right; a bare alias outranks any prefixed reading, so they now resolve without one. See §4.3 of the unit-system reference.
- The same holds for a **currency**: `k~$EUR` and `k$EUR` are the same unit. The `$` sigil already separates the prefix from the code, so the compact form is unambiguous by construction — but the sigil itself stays mandatory, and `kUSD` is `error_unit_illegal` just as bare `USD` is.
- The canonical output form always carries the separator: `bvn_unit_to_string` and the `bvnr_write_*` helpers emit `k~g`. A *re-serialisation* is a different matter — it writes back the spelling the document carried (`unit_source`, §16.1), so `km` stays `km` through `bovnar pretty-print`. `bovnar pretty-print --canonical` does not keep the spelling either way: it writes every unit in reduced form and rescales the value to match (`1.0 km` becomes `1000.0 m`, `2.5 kg` becomes `2500.0 g`). A prefix on a base unit, a currency or a non-SI unit is folded into the value; a unit that is, or reduces to, a single named derived SI unit keeps its prefix, in the separated form (`kPa` → `k~Pa`, `k~N·m` → `k~J`), so `2.5 k~N` and `2500 N` are not unified. An integer that would stop being an integer, or stop fitting its width, fails with `error_value_out_of_range`; nothing is rounded.

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

# 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²`, `m⁻³`, `m¹⁰⁰` | `[⁺⁻]? [⁰-⁹]{1,3}` — one to three superscript digits |
| ASCII caret | `m^2`, `m^-3`, `m^+2`, `m^100` | `^[+-]?[0-9]{1,3}` |

An exponent is an **integer in `[BVN_EXPONENT_MIN, BVN_EXPONENT_MAX]` = `[-100, 100]`**, with **zero
reserved**: `m^0` and `m⁰` are not units. The two forms carry the same values; `⁺` and `+` are
accepted and have no effect.

At most **three digits** are scanned, which is exactly what 100 needs, and leading zeros count
among them: `m^007` and `m⁰⁰⁷` are `m⁷`, and an exponent that is zero however it is written (`m^0`,
`m^00`, `m⁰`) is not a unit. A longer run is therefore not an over-large exponent but an
unrecognised token: the exponent of `m^1000` or `m^0001` is not a three-digit one, and what is
left in front of it does not resolve as a base symbol. A value that scans cleanly and lands outside the range —
`m^200` — is refused on the range check. Both are `error_unit_illegal`.

> The named enumerators of `unit_exponent_t` cover only ±1…±9 and are kept for callers that use
> them; they are **not** the range. Do not switch exhaustively over that enum, and do not treat a
> value outside the named nine as invalid — ask `bvn_int_to_exponent`, which returns `exp_invalid`
> for anything genuinely out of range.

**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+2070 | 0 — a digit like the others, e.g. `m¹⁰⁰`; an exponent that comes to zero (`m⁰`) is not a unit |
| `⁺` | U+207A | positive sign (no-op) |
| `⁻` | U+207B | negate exponent |

### 11.6 Examples

```bovnar
.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 | 32 (`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`. A **profile** expression (§11.9) that is valid in its vocabulary and translates to more than that is `error_unit_profile_unsupported` instead: the code is not malformed, this unit system cannot hold it.

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":

```bovnar
.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`).

### 11.9 Unit Profiles (spec 1.2)

A unit parameter that begins with a namespace and a colon — `name:code` — is a **unit profile**: a foreign notation for the same unit slot. The profile translates the code into exactly the value described in §11.1–§11.8, so the result is a unit like any other and every rule in this section applies to it unchanged. A namespace is lowercase letters, digits and `-`; the hyphen may not lead. Anything else before the colon is not a namespace at all, so the parameter is read as a native unit and fails as one: `UCUM:kg` is `error_unit_illegal`, not an unknown profile. Every defined namespace begins with a letter, and a new one must: a parameter that begins with a digit is read as a width, so `<float:64,1u:kg>` is `error_illegal_value_type` and not an unknown profile.

Seven namespaces are defined:

| Namespace | Vocabulary | Code shape |
|-----------|------------|------------|
| `ucum` | UCUM — Unified Code for Units of Measure | an expression over prefixed atoms |
| `unece` | UN/ECE Recommendation 20 and 21 | one whole code, never decomposed |
| `qudt` | QUDT unit local names | one whole code, never decomposed |
| `qudt-qk` | QUDT quantity kinds — translated to the **coherent SI unit** of the kind | one whole code, never decomposed |
| `udunits` | UDUNITS-2, the CF/netCDF units syntax | an expression over prefixed atoms |
| `om` | OM 2 — Ontology of units of Measure | one whole code, never decomposed |
| `cf` | CF standard names — translated to the **`canonical_units`** CF states for the name; **read-only**: no unit is ever translated *into* a `cf:` code, though a spelling a document already carried is kept when it is re-serialised | one whole code, never decomposed |

```bovnar
#!bovnar 1.2
.systolic = <float_dec:64,ucum:mm[Hg]>  120.00;   # the same unit as <...,mmHg>
.mass     = <float:64,unece:KGM>         12.5;    # the same unit as <...,k~g>
.velocity = <float:64,qudt:M-PER-SEC>     9.81;   # the same unit as <...,m/s>
.length   = <float:64,qudt-qk:Length>     3.0;    # the coherent unit of the kind: m
.flux     = <float:64,udunits:kg*m-2*s-1> 0.5;
```

A code that is one whole token is **never decomposed**: `unece:KGM` is the kilogram, not a `k` prefix on a `GM`, and `qudt:MI` is the mile, not a milli-anything. Only an expression namespace strips prefixes and recognises operators.

**Gated on the declared version.** A profile unit requires a `#!bovnar` directive declaring 1.2 or later, exactly as the datetime family and the `\x`/`\u` escapes require 1.1 (§3.4). In a document declaring less — or declaring nothing, which declares less than anything — a profile unit is `error_unit_illegal`, because in that version it is not a unit. A **native** unit is unaffected in every version; the notation is purely additive.

**A unit, an opaque unit, or one of three errors — and nothing else.** A profile expression becomes a unit the parser can compare — a registry unit, or an opaque one that equals only itself — or it becomes an error. There is no state in which a value carries a unit the parser cannot reason about — every guarantee this section makes is a guarantee about units the parser understands. The refusals are distinguished so a producer can act on them:

| Outcome | Result |
|---------|--------|
| Translates onto the unit registry | An ordinary unit; indistinguishable from the native spelling |
| An **opaque** unit — a UCUM *arbitrary* atom (`[IU]`, `[PFU]`, …) or a UNECE package/count code (`XBX`, `C62`, …) | A unit with no native spelling and no dimension: comparable with itself, and convertible to no other unit — only across a change of prefix (`ucum:[IU]/L` to `ucum:[IU]/mL` is a factor of 1000). Two different opaque units are two different units — `error_unit_mismatch` between an annotation and an inline suffix (§6.5), `error_array_element_type_mismatch` in a bare array (§7.4). It serialises back in the notation of the namespace that owns it |
| Not a valid expression in the profile, or an atom it does not define | `error_unit_illegal` |
| Valid in the profile, with no representation in this unit system | `error_unit_profile_unsupported` |
| The namespace is not a profile this implementation supports | `error_unit_profile_unknown` |

**Character set.** A profile unit may use `'`, `[`, `]`, `{` and `}` in addition to the native unit characters (§11.4). A `,` inside `{…}` or `[…]` belongs to the unit rather than ending the type parameter, and inside a type annotation SP and HT within the code belong to it too (§5.3) — UDUNITS multiplies with a space. An inline suffix admits neither, but is otherwise a profile unit like any other (`.p = 120 ucum:mm[Hg];`), under the same version gate. `;`, `#`, `<`, `>` and `"` remain excluded, so an unterminated bracket or annotation cannot consume the remainder of the document.

The complete notation, the transliteration table, and the codes that have no representation are specified in [Unit Profiles](11_bovnar_unit_profiles.md).

---

## 12. Validation & Constraints

### 12.1 UTF-8 Validation

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

### 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 (→ no total-byte cap; text stays bounded by `max_text_bytes`) | `error_file_too_long` |
| Struct nesting | Yes (`max_struct_nesting`) | 0 (→64 internal, hard cap 255) | `error_struct_nesting_too_high` |
| Array nesting | Yes (`max_array_nesting`) | 0 (→64 internal, hard cap 255) | `error_array_nesting_too_high` |
| Type-annotation body (the bytes between `<` and `>`, whitespace beside separators not counted) | No | 255 | `error_type_too_long` |
| Inline unit suffix | No | 255 | `error_unit_too_long` |
| Unit components | No (`BVNR_MAX_UNIT_COMPONENTS`) | 32 | `error_unit_illegal` (§11.7); a profile expression, `error_unit_profile_unsupported` |
| Unit exponent | No | −100 … 100, zero excluded | `error_unit_illegal` (§11.5) |
| Unit `(…)` group nesting | No | 16 | `error_unit_illegal` (§11.4) |

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 no total-byte cap**; set a positive value to cap. That is not the same as endless: the text of a document is still bounded by `max_text_bytes` (default 2³¹ − 1, `error_text_data_too_long`) and each array by `max_array_items`, and only octet-stream payload runs without bound. For an endless text stream raise `max_text_bytes` as well. The writer applies the two nesting defaults and consults no other field: it does not limit array items, text bytes or file size, and it ignores the five `max_*_length` fields — instead it refuses, whatever the flags say, an identifier, string or symbol over 65 535 bytes, the most any reader configuration accepts.

### 12.3 Value Validation

| Check | Error |
|-------|-------|
| Number literal, bare or quoted, contains a digit outside its base | `error_digit_not_in_base` |
| Whitespace inside a type-annotation parameter (§5.3) | `error_type_param_whitespace` |
| 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 (an `e`/`E` is an exponent only in a base below 15, §5.5) | `error_type_value_mismatch` |
| ISO-8601 datetime literal with an out-of-range or wrong-width field, or an empty fraction (spec 1.1); one that breaks off part-way is `error_unexpected_input_byte` | `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` |
| 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 unit, 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/13_bovnar_conformance.md`).

### 12.6 Identifier Validation

| Check | Error |
|-------|-------|
| Empty key (`.` followed by `=`, with or without whitespace between) | `error_empty_identifier` |
| `.` followed by any other byte that cannot start an identifier (`.1`, `.;`, `.-a`) | `error_unexpected_input_byte` |
| 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` (a profile expression: `error_unit_profile_unsupported`, §11.7) |
| Empty component between separators | `error_unit_illegal` |
| Inline unit suffix too long (255-byte lexer buffer) | `error_unit_too_long` |
| Unit written in an annotation, over the 255-byte type-annotation body cap (§12.2) | `error_type_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, or EOF anywhere before the closing `0x00` | `error_read_complete_chunk_failed` — never followed by `error_got_incomplete_bvnr_stream`, and it ends the read even under `continue_on_error` (§13.2) |
| An octet stream under `text_only` | `error_octet_stream_forbidden` |

---

## 13. Error Handling & Recovery

### 13.1 Error Model

Errors are reported through the `on_error` callback:

```c
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 an error in the text — any error but one inside a comment (below). A few errors end the read even so: a fault in an octet stream's framing (`error_octet_stream_out_of_sync`, `error_read_complete_chunk_failed`), `error_text_data_too_long`, `error_file_too_long`, a version directive refused under `strict_version` (§3.4), an octet stream refused under `text_only` (`error_octet_stream_forbidden`, §9), and a callback that returns `false`. The other limits of §12.2 — the five lengths, `max_array_items`, both nesting depths — are recovered from like any other error.

1. The `on_error` callback is invoked with error details
2. A **resync state machine** (`state_t: resync, resync_dot, resync_string, resync_string_escape, resync_comment`) skips bytes
3. Tracking bracket `[]` and brace `{}` nesting, each with its own counter (table below)
4. Parsing resumes at whichever comes first: a `;` at the saved nesting depth, or the **start of the next assignment** — a `.` at the saved nesting depth followed by a byte that can begin an identifier
5. `recovery_count` (accessible via `bvnr_reader_get_recovery_count`) is incremented immediately when an error triggers entry into resync mode
6. The bytes discarded while recovering — from the byte the error was raised on up to, not including, the boundary recovery resumes at (the `;`, or the `.` that begins the next key) — are added to a running total, readable with `bvnr_reader_get_skipped_bytes`. `recovery_count` says how *often* recovery ran; this says what it *cost*, and it is the only way to learn it — the skipped bytes were never parsed, so no callback mentions them. A non-zero total means the document delivered to the callbacks is not the whole document. The converse does not hold: a value rejected right before its `;` skips zero bytes, so test `recovery_count`, not this, for "something was dropped" (§18.2)

Both boundaries matter, because they cover different errors. When the error is
*inside* a statement, that statement's own `;` is the next one, so the `;` rule
discards exactly the broken statement. When the error is *between* statements —
a stray byte in the whitespace separating two assignments — the next `;` belongs
to the following, perfectly good statement; resuming only there would discard it
whole, however large it is, so a single stray byte in front of a large struct
took the entire struct with it. The assignment boundary stops recovery at the
first point the document plausibly becomes readable again.

The `.` must be followed by a byte that can begin an identifier, so a `.` inside
skipped-over junk (`1.5`, `.5`, a `.` in binary corruption) is just another
skipped byte. A `.` inside a bracket opened *since* recovery began opens no
assignment either, and is skipped. Neither boundary recovers less than the other
alone would: `;` remains a boundary in every case it was one before. The other
side of the rule: a `.` followed by an identifier byte *anywhere* in the rest of
the broken statement is a boundary too — a reference such as `&.c`, or `x.y` —
so parsing restarts there, usually to report a further error, which
`recovery_count` counts.

**What recovery leaves open.** On entry the reader closes — with their
`ev_array_row_end` events — only the arrays opened *inside* the innermost open
struct, the ones the broken statement itself was in. An array that struct is an
*element of* stays open, with its row and element counts, so the struct's own
`}` and the array's `,` or `]` are read normally afterwards and the start/end
events a consumer sees always nest — when recovery resumes inside that struct.
When the struct's `}` is skipped instead (the error was its last member, with no
`;` or `.name` after it), recovery runs on past the array's `,` and `]` to the
next boundary, and the array's remaining elements are lost with the struct:
`.a=[{.x=1 @},{.x=2;}];` delivers `.a` as one struct whose member `.x` has no value. A `}` that is itself the offending byte
(`.s = {.a = 1 };`, a member missing its `;`) is not discarded: it closes its
struct.

**An error inside a comment does not enter resync.** An invalid UTF-8 sequence
or a control byte in a comment is reported through `on_error` and counted —
`recovery_count` and the skipped-byte total each go up by one — and the comment
continues to its line end. Nothing is unwound and no value is lost, and the rest
of the comment is never read as document text.

**State machine behavior during resync:**

| Byte(s) | Action |
|---------|--------|
| `0x00–0xFF` (most) | Skip (consume and continue) — malformed UTF-8 included: it raises no error of its own while recovery is skipping |
| `"` | Enter `resync_string`: skip until matching `"` |
| `#` | Enter `resync_comment`: skip until newline |
| `[`, `{` | Increment its own counter — `resync_array_depth` or `resync_struct_depth` — for brackets opened since recovery began |
| `]`, `}` | If its own counter is non-zero, decrement it and keep skipping. Otherwise it closes a container that was open before the error — an array the innermost struct owns, or that struct — emitting its end event |
| `;` at depth 0 | Reset state, resume normal parsing |
| `.` at depth 0 | Candidate assignment start: resume if the next byte can begin an identifier, else keep skipping |

The two counters are independent: a `}` does not balance a junk `[`, so a
statement that opens a bracket it never closes keeps recovery from resuming
until the bracket is closed or the input ends (§13.3). A `[` or `{` that is
itself the offending byte opened nothing, and counts as a bracket opened since
recovery began, so its own closing bracket does not close the enclosing
container. A `#` that is the offending byte (`42# note`, §4.1) opens a comment,
which recovery skips as such.

**Recovery cannot read an octet stream's framing.** When recovery skips over an
octet stream — the error came before its `0x00`, or inside the value that
holds it — the payload is scanned as text like any other skipped bytes, and a
`;` or `.name` inside the payload can end recovery there. A consumer that needs
`continue_on_error` and reads documents with octet streams should treat values
delivered after a recovery as untrusted (`recovery_count` says one happened).

### 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.

### 13.4 The Error Getters Under Recovery

The error getters of §16.7 describe the error that **ended** a read, not the
errors recovered from. A recovered error is reported through `on_error` and
nowhere else:

- **`bvnr_read` returned `false`:** the getters hold the error that stopped it.
  Under `continue_on_error` that is one of the errors recovery does not continue
  past (§13.2: `max_text_bytes`, `max_file_size`, an octet-stream framing error, a
  directive refused under `strict_version`, an octet stream refused under
  `text_only`, a callback that returned `false`) or the `error_got_incomplete_bvnr_stream` of
  §13.3, positioned at the end of the input.
- **`bvnr_read` returned `true`:** `bvnr_reader_get_error` returns `error_none`
  and the line, column, byte and offset getters return `0`, however many errors
  were recovered. Whether any were, and what they cost, is what
  `bvnr_reader_get_recovery_count` and `bvnr_reader_get_skipped_bytes` report.
  A consumer that needs the recovered errors themselves collects them in
  `on_error`.
- **During the read:** inside `on_error` the getters agree with the callback's
  arguments. After the callback returns they are unspecified until `bvnr_read`
  returns: the position is cleared when recovery begins, and the code may still
  hold the recovered error.

---

## 14. Formal EBNF

The complete grammar is maintained as a standalone file:

**[`doc/12_bovnar.ebnf`](12_bovnar.ebnf)**

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

- Top-level stream and assignment structure
- Type annotations (seven core families: `uint`, `sint`, `float`, `float_fix`, `float_dec`, `utf8`, `bool`; plus `datetime` in spec 1.1)
- Value forms: numbers, special numbers, booleans, strings, symbols, references, arrays, structs, octet streams, inline unit suffixes
- Lexical primitives and UTF-8 byte class definitions
- Unit sub-grammar (SI/IEC prefixes, base units, compound units, exponents)
- Constraints not expressible in context-free EBNF (UTF-8 validity, BOM placement, nesting limits, type/value compatibility, error recovery behaviour)

---

## 15. Complete Examples

### 15.1 Simple Configuration

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

### 15.2 Typed Scientific Data

```bovnar
# 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:

```bovnar
# 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

<!-- bovnar-example: illustrative -->
```bovnar
# An image file embedded as binary: a 20-byte chunk (length 14 00), a 2-byte
# chunk (length 02 00), then the end marker. The line breaks and indentation
# between them are layout for this page only -- inside an octet stream every
# byte is a tag, a length or payload, and a newline there is out of sync.
.image = \x00
    \x01\x14\x00\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00
    \x01\x02\x00\xFF\xD9
    \x00;

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

### 15.5 Arrays with Mixed Dimensions

```bovnar
# 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

```bovnar
.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

```bovnar
# 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

```bovnar
# 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

```bovnar
# ── 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

<!-- bovnar-example: rejected -->
```bovnar
# 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_unexpected_input_byte (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 (> 32 — see §11.7)
.y = <float:64,m*s*g*A*K*mol*cd*b*V*Hz*N*Pa*J*W*Ω*F*C*S*Wb*T*H*lm*lx*Bq*Gy*kat*L*min*h*d*bar*eV*Da> 1.0;
                                            # error_unit_illegal (33 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

```c
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)             */
                               /* for datetime:         epoch index (0 = unix)        */
} value_type_spec_t;

#define BVNR_MAX_UNIT_COMPONENTS     32

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 */
    const void*       unit_source;        /* library 2.0 — the unit as the document spelled it, else NULL */
    uint32_t          unit_source_length; /* library 2.0 — length of unit_source, else 0 */
    bool              converted;    /* a want_unit read-time conversion was applied (read/write API §1.10) */
    bvnr_converted_t  conv;         /* the exact converted value (unit + text + rational); zeroed when converted is false */
} bvnr_data_t;
```

### 16.2 Type Construction Macros

```c
/* 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:                                   */
#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) })
/* float: base 10 or 16 only (read/write API §2.14)                */
#define BVN_TYPE_FLOAT_BASE(w,b) ((value_type_spec_t){ .family = vt_float, .width = (w), .base = (b) })
```

### 16.3 Unit Macros

```c
#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

```c
typedef struct bvnr_read_flags_s {
    uint16_t  max_identifier_length;  // 0 → 255 default
    uint16_t  max_string_length;      // 0 → 65535 default
    uint16_t  max_number_length;      // 0 → 65535 default
    uint16_t  max_symbol_length;      // 0 → 255 default
    uint16_t  max_reference_length;   // 0 → 65535 default
    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 → no total-byte cap (default; text still bounded by max_text_bytes); 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      text_only;                      // refuse a document containing an octet stream (§16.10 code 51)
    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

```c
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);
void bvnr_source_from_callback(bvnr_source_t* s, bvnr_pull_fn pull, void* userdata);
void bvnr_sink_to_callback(bvnr_sink_t* s, bvnr_push_fn push,
                           bvnr_flush_fn flush, void* userdata);
uint64_t bvnr_sink_bytes_written(const bvnr_sink_t* s);
```

The callback constructors take the I/O from the caller's functions (read/write API §1.14, §2.17). `bvnr_sink_bytes_written` queries the total bytes written to a sink **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` (read/write API §2.4, §2.10).

### 16.6 Reading

```c
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);

/* Push-fed reading: bytes handed over in pieces (read/write API §1.15) */
bool bvnr_open_read_feed(bvnr_reader_t* r, const bvnr_sink_t* src_mirror,
                         bvnr_read_flags_t* options);
bool bvnr_read_feed(bvnr_reader_t* r, const void* buf, uint64_t len);
bool bvnr_read_finish(bvnr_reader_t* r);

bvnr_reader_t* bvnr_reader_create(void);   /* the reader is opaque */
void           bvnr_reader_destroy(bvnr_reader_t* r);
bool bvnr_reader_get_declared_version(const bvnr_reader_t* r,
                                      uint16_t* major, uint16_t* minor); /* §3.4 */

uint32_t    bvnr_version(void);            /* library version, §17 */
const char* bvnr_version_string(void);
void        bvnr_spec_version(uint16_t* major, uint16_t* minor);
```

### 16.7 Error Queries

```c
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);
uint64_t     bvnr_reader_get_skipped_bytes(const bvnr_reader_t* r);
const char*  bvn_error_to_string(error_code_t code);
```

The getters describe the error that ended the read. After a read that returned
`true` they report `error_none` and position `0`, even if errors were recovered
under `continue_on_error` (§13.4).

### 16.8 Utility Functions

```c
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 "·"; when every exponent is
   negative there is no "/" (m⁻¹·s⁻²).  Returns bytes written, or -1
   on buffer overflow, an invalid unit, or an opaque unit with no
   text. */
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) – ^N caret exponents and "*" for "·"
     BVN_UNIT_REDUCE     (1 << 0) – reduce compound unit before serialising
     BVN_NUM_CANONICAL   (1 << 2) – see bovnar.h
   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.

```c
/* ── 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) ─────────────────── */
/* width: the annotation width, any legal float width; 0 = use       */
/* f->_prec, which must then itself be a legal float width.          */
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

```c
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 every element of a BARE array the same UNIT
     * (§7.4 -- not merely the same dimension, so m beside ft and °C beside K are
     * both refused); sibling sub-arrays must match in length and element shape
     * (recursively); sibling structs must share the same keys with
     * recursively-matching fields, whose units stay free. */
    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,
    /* spec 1.2 (§11.9) — the namespace before the ':' is not a unit
     * profile this build supports. Distinct from error_unit_illegal so a
     * producer can tell "no such profile compiled in" from "malformed unit". */
    error_unit_profile_unknown               = 49,
    /* spec 1.2 (§11.9) — valid in its vocabulary, but with no
     * representation in this unit system: a reference level, a scale factor
     * outside the SI prefix decades, an expression wider than
     * BVNR_MAX_UNIT_COMPONENTS, or a construct the unit slot cannot reach at
     * all (UDUNITS reference time). */
    error_unit_profile_unsupported           = 50,
    /* the document contains an octet stream and the reader was opened with
     * text_only. NOT a defect in the document — an octet stream is a
     * first-class part of the format (§9) — but an assertion by the CONSUMER
     * that this particular channel carries text, made at the door rather than
     * discovered as corruption downstream. */
    error_octet_stream_forbidden             = 51,
    /* whitespace split a type-annotation PARAMETER in two (§5.3). Every `ws` the
     * grammar allows inside an annotation sits beside a separator; in the middle
     * of a parameter there is no production for it, and the lexer used to drop it
     * anyway -- so "<float:64,m in>" was accepted as the minute and "<uint:6 4>"
     * as a 64-bit width. Reported at the first byte after the whitespace. A byte
     * that is illegal anywhere in an annotation is still
     * error_unexpected_input_byte. */
    error_type_param_whitespace              = 52
} 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, and it is a different number from the format's. Through 1.1.0 the two
moved together; they parted at library **2.0.0**, which broke the ABI and the
Python API while the format it reads grew only additively, to spec **1.2**. The
library's major counts breaks in what a *program* is built against; the format's
major counts breaks in what a *document* means, and has not moved. 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 >= 20000` tests "≥ 2.0.0"), and `BVNR_VERSION_STRING`
(`"2.1.0"`). The Python package mirrors this as `bovnar.__version__`, the Rust crate as
`bovnar::version()`.
Separately, `BVNR_SPEC_VERSION_MAJOR` / `BVNR_SPEC_VERSION_MINOR` name the
highest **spec** version the build understands (`1.2`); `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 — with the exceptions this section lists by name and no others:
the corrections made in 1.2, below, each of which withdraws something the text
of 1.0 already excluded or contradicted itself over. This covers the lexical structure, the type families and their
annotations, arrays (including the homogeneity rules of §7.4, as corrected in
1.2 — see below), 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:

- the **unit and currency tables** — new physical units, prefixes, and ISO 4217 /
  crypto currency codes may be added (a document never depends on a code being
  *absent*);
- the **accepted input spellings** of a unit — new aliases, and the compact
  prefix form (`kg` for `k~g`, §11.4). This is the same
  additive direction as a new unit: a spelling is only ever accepted where the
  parser previously raised `error_unit_illegal`, the base symbol is still
  matched as the longest alias suffix so no existing spelling changes meaning,
  and the **canonical output form is unaffected** — `bvn_unit_to_string` and a
  writer handed no spelling keep emitting the separated `k~g`, and
  `bovnar pretty-print --canonical` writes no compact spelling — a prefix is
  either folded into the value or written with its `~` — so a document can be
  made readable to a reader that predates the spelling, except where an
  integer value cannot take the rescale. (A plain re-serialisation keeps the
  spelling the document was written in.);
- **new constructs gated on the version that introduced them** — available only
  to a document whose `#!bovnar` directive declares that version or a newer
  one, and an error in any other, so no existing document can acquire one by
  accident. This is how both minor revisions so far were delivered: 1.1 added
  the `datetime` family, the `\x`/`\u{}` escapes and reference array indexing;
  1.2 added the unit-profile notation (§11.9);
- **new error codes** appended after the current maximum (existing numeric values
  never change). The five the *grammar* of 1.1 required are
  `error_invalid_spec_version` (42), `error_unsupported_spec_version` (43),
  `error_invalid_codepoint` (44), `error_invalid_datetime_literal` (45), and
  `error_datetime_literal_unsupported_epoch` (46) — those carry a `spec 1.1`
  note in §16.10. Codes appended since: 47 and 48 for facilities beside the
  wire format, 49 and 50 for the spec 1.2 unit profiles, 51 for a reader
  policy, and 52 for a lexical rule the grammar always implied (see the
  corrections below). **§16.10 is the list**, and the current
  maximum is wherever it ends — never a number quoted somewhere else, this
  paragraph included;
- the **optional version directive** (§3.4), added in 1.1: it is an ordinary
  comment to any 1.0 reader, so adding one never invalidates a document;
- new optional reader/writer flags and limits whose defaults preserve current
  behaviour.

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.

**Two corrections made in 1.2, stated because they bend the promise in *What 1.0 freezes*.**
Both refuse documents that a 1.1 reader accepted, and both apply to every
document, whatever it declares. They are recorded as corrections of defects
rather than as changes of the format, on these grounds:

- *Whitespace inside a type-annotation parameter* is
  `error_type_param_whitespace` (§5.3). The grammar never derived it — every
  `ws` in an annotation sits beside a separator — but the reference reader
  deleted it, so `<float:64,m in>` was read as `min` and `<uint:6 4>` as 64
  bits. A document relying on that carried a unit or a type its author did not
  write.
- *A bare array is homogeneous in its unit, not merely its dimension* (§7.4).
  The rule as written through 1.1 let `[1 m, 2 ft]`-shaped arrays through
  while the same section promised that a consumer may treat the elements
  identically; a consumer who believed it computed wrong numbers. The promise
  was kept and the rule corrected to make it true.

Neither of the two changes what any accepted document *means*. A producer
affected by either was already emitting data that could not be read correctly.

**Reader defects fixed in the same release.** Beside those two, the reference
reader of library 2.0.0 (which implements 1.2) stopped letting through — or misreading — input the
specification already excluded. They are listed because a document that
depended on one behaves differently. Most of them refuse what was let through.
A few change what an accepted document reads as — the leap second, the zero
mantissa, the element that kept no unit, the path into a nested matrix and the
conversion factors — and each of those entries says so:

- *A `tai` datetime literal spelling a known leap second*
  (`2016-12-31T23:59:60Z`) has its own value, one less than before
  (1861920036, was 1861920037); through 1.1 it collapsed onto the second after
  it. Civil epochs are unchanged.
- *A zero mantissa under an exponent too long to count is zero.*
  `0e999999999999999999999` was delivered as +infinity by the number helpers
  (`bvn_parse_double`, and so `query` and `convert`); it is 0. The document's
  digits are what they were.
- *In an array under a unit annotation, an element annotated without a unit*
  (`<float:64,s>[<float:64> 1, 3]`) no longer strips the unit from the elements
  after it. The `3` is in seconds again — and the document tier then refuses the
  array as mixed.
- *A byte-order mark after leading whitespace* is
  `error_invalid_byte_order_mark` (§3.2 always made offset 0 the only legal
  place; this one was accepted).
- *A version directive ended by end-of-input* is parsed like one ended by a
  newline, so a malformed one is `error_invalid_spec_version`.
- *An array left open at the `;`* (`.a = [1;`) is
  `error_got_incomplete_bvnr_stream`; it was accepted, and absorbed the next
  assignment.
- *A struct inside an array is a scope of its own.* A `,` after a member's
  value (`.f = [{.x = 7, 1;}];`) is `error_unexpected_input_byte`; it was
  accepted, and the member read as `71`. And an array left open at a `;` is
  detected inside a struct as well, after an array nested in it has closed
  (`.p = {.s = [{.a = [1];}; .q = 1;];};`). A `]` is held to the same rule:
  `.a = [{.x = 1];};` closed the array from inside its own element and was
  accepted; it is `error_unexpected_input_byte`.
- *The width of a `/`-row counts each element once* (§7.3). A nested array —
  an element, or an array that is only a member of a struct element — was
  counted twice in the row that holds it. So `[[1]]/[null,null]` (one element
  against two) was accepted and is `error_array_row_size_mismatch`; and
  `[[1],[2]]/[null,null]` and `[{.m = [1];}]/[{.m = null;}]` (equal widths)
  were refused and are valid. This is one of three entries here that also
  *accept* documents a 1.1 reader refused — the others are the nested
  `/`-matrices under homogeneity and the padded version directive. An array in which every row is made of
  arrays, or no row holds one, compared correctly before and is unchanged.
- *`float_fix` and `float_dec` are one keyword each.* Whitespace or a comment
  before the suffix (`<float _fix:32,q8>`) is `error_type_param_whitespace`,
  for the reason given above for whitespace inside a parameter; it was read
  as `float_fix`.
- *A `float_fix` literal too large for any float* (`<float_fix:16,q8> 1e9862`,
  or a 9862-digit integer) is `error_value_out_of_range` (§6.2). Its parse
  overflowed to infinity, and infinity — range-exempt as the keyword `inf`
  (§6.4) — passed the range check, so it was accepted, and read as `inf`.
- *After a reference index only `.`, `[` or the end of the reference may
  follow* (§4.5). `&.a[0]b` is `error_unexpected_input_byte`; it was accepted
  as the path `.a[0]b`, which the grammar does not derive and no lookup
  resolves.
- *A comment that ends at end-of-input inside a multi-byte UTF-8 sequence* is
  `error_invalid_utf8_byte` (§3.1), as the same bytes followed by a newline
  always were.
- *Quoted digits are range-checked whatever they spell.* In base 24 and up
  `n`, `a`, `i` and `f` are digits, and a quoted `"nan"`, `"inf"` or `"ninf"`
  under `uint`/`sint` was exempted from the range check as though it were the
  keyword: `<uint:8,_36> "inf"` (24171) is `error_value_out_of_range`. The
  bare keywords keep the exemption of §6.4.
- *Homogeneity (§7.4) no longer depends on element order.* The document tier
  compared every element with the first non-null one, so a hole in that one
  hid whatever stood at the same position in the others:
  `[[null], [1], ["x"]]` and `[{.x = null;}, {.x = 1;}, {.x = "s";}]` were
  accepted — as was a matrix whose rows carry different units behind a leading
  row of nulls — while the same elements in another order were refused. They
  are `error_array_element_type_mismatch` in every order. In the other
  direction, nested `/`-matrices such as `[[[1,1]/[2,2], null], [null, null]]`
  were refused as ragged through a row count shared between array scopes, and
  are valid. The same shared count made a path into such a matrix resolve to
  the wrong element: in `[[1]/[2]] / [[3]/[4]]`, `[1][0][0][0]` answered 1 and
  answers 3.
- *A version directive may be padded with any number of spaces or tabs*
  (§3.4). One with more than 21 between `#!bovnar` and the version was
  `error_invalid_spec_version`, and is a directive.
- *`utf8` and `bool` take no parameter, a width of zero included* (§5.1).
  `<utf8:0>` and `<bool:0>` were accepted, because the check looked at the
  width's value and not at its presence; they are `error_illegal_value_type`,
  as `<utf8:8>` always was.
- *A struct takes no annotation* (§5.1). One before a struct with members was
  always refused — at the first member's key — which left `<utf8> {}`,
  `<uint:8> {}` and `<uint:8> [{}]` accepted. Every struct under an annotation
  is `error_type_value_mismatch`, reported at its `{`.
- *`max_array_items` limits an array of structs.* Every member assignment used
  to reset the count, so such an array was never limited; the budget is per
  outermost array. And `max_file_size` no longer depends on how the source is
  chunked. Both matter only to a reader that sets those limits.
- *A datetime literal with a fraction whose UTC year falls outside 0000–9999*
  (reachable only through a timezone offset) is
  `error_invalid_datetime_literal`.
- *Eight conversion factors were corrected* (`elle`, `klafter`, `rute`,
  `morgen`, `inHg`, `Da`, `slug`, `ft_lb`): a document's digits are unchanged,
  what they convert to is not.

**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 change what an existing
document's bytes mean is a breaking change and is reserved for a major (2.0)
revision of the *format* — which is not the library's version (above). 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. (The correction of §7.4 recorded above narrowed
homogeneity from dimension to unit; it did not reconsider the rule. It and the
other corrections listed in this section are the whole of what 1.2 changed for
an existing document, and each is there because the alternative was to keep a
rule the specification itself contradicted.)

---

## 18. Security Considerations

A `.bvnr` document is passive data. It carries no scripting, no macros, no
external entity references, and no include directive; a reference (§4.5) is an
inert string that the parser never follows. A conforming consumer therefore
performs no I/O beyond reading the bytes it was handed, and parsing a document
cannot by itself cause code execution or a network fetch.

What follows is what remains after that — the places where a consumer of
untrusted input has to do something the format cannot do for it. The
Internet-Draft in `doc/ietf/` carries the same material at greater length, with
normative keywords.

### 18.1 Resource Exhaustion

The defaults of §12.2 are chosen for trusted pipelines, not for hostile input.
**Total input is unbounded by default** — `max_file_size` of 0 sets no total-byte
cap, so octet-stream payload is unlimited and only the text is capped (at 2³¹ − 1
bytes, `max_text_bytes`). That is right for a telemetry stream and wrong for an
untrusted one. A consumer
at a trust boundary sets every limit in §12.2 explicitly.

Four constructs give an attacker a high ratio of work to input bytes: integer
and float widths reach 32768 bits (`BVN_MAX_INT_WIDTH` for `uint`/`sint`; `float` has the same ceiling, §5.2), and validating one
value at that width is arbitrary-precision arithmetic; a non-decimal base is
written as a quoted string (§5.5), so a 32768-bit value in base 2 is a 32768-character
literal well inside the default `max_string_length`, and base conversion is
superlinear in most implementations; array elements default to 2³¹−1 against the
one-byte-per-element syntax `[,,,,,]`; and adjacent string literals accumulate
toward `max_string_length` across the running total, not per literal.

Nesting defaults to 64 with a hard cap of 255, but the depth is only known after
the bytes are read, so a recursive-descent consumer bounds its own stack rather
than relying on the declared depth. A streaming consumer is less exposed than a
materializing one throughout.

### 18.2 Recovery Mode Discards Data Silently

Recovery mode (§13.2) delivers a *subset* of the document, and nothing in the
data says that a subset is what it is. Where the document is an access-control
list, a security policy, or a set of calibration constants, a dropped assignment
is likely to fail open.

Recovery must not be enabled where the parsed document informs a security
decision. Where it is enabled, check the **recovery count** and treat a non-zero
value as a failure of the document rather than a diagnostic. Watching the
skipped-byte total instead is a trap: a rejected value whose `;` follows
immediately skips *zero* bytes, so the total stays at zero while the assignment
arrives stripped of its value.

### 18.3 Version Leniency

By default a reader accepts a declared version newer than it supports and fails
only on a construct it does not implement (§3.4). A document declaring
`#!bovnar 2.0` but using only 1.0 constructs parses without complaint. A 1.0
reader does not see the directive at all — it is lexically a comment — so a
producer cannot rely on the declaration to cause rejection by an older reader.
For untrusted input, enable `strict_version` so an unsupported version is
rejected at the directive rather than possibly not at all.

### 18.4 Transport Corruption of Octet Streams

Octet-stream chunks are length-prefixed (§9.2). A transport that rewrites a byte
inside a payload — line-ending normalisation above all — shifts every subsequent
length field, and the reader begins interpreting attacker-supplied payload as tag
and length fields. The common outcome is a quick `error_octet_stream_out_of_sync`,
but that is not guaranteed, and a chunk length read out of a corrupted field is
an allocation request — at most 65 536 bytes per chunk (§9.2), so the hazard is
an implementation that pre-allocates or accumulates across chunks. Bound the memory committed to a single chunk independently
of its declared length, and carry documents containing octet streams over
transports that do not transform content.

### 18.5 Reference Resolution

References are stored unresolved and never followed (§4.5), so every hazard lands
in the application that resolves them. Cycles are not detected — `.a = &.b; .b = &.a;`
does not terminate under a naive resolver. Dangling paths are valid documents, so
a resolver must define what a missing target means rather than defaulting to
empty. A reference path is not a URI: it has no scheme, no authority, and no
network semantics, and must never be handed to a resolver that might fetch it.
Index syntax is uninterpreted — `&.a[0][1]` is stored verbatim, digits included,
and must be range-checked against the actual array.

### 18.6 Numeric Interpretation

**Special numbers bypass range validation.** `<uint:8> nan` and `<sint:16> ninf`
are valid (§6.4). A successful parse does not mean the value fits the declared
width, so a consumer mapping values onto fixed-width integers must handle a
non-finite value arriving on an integer channel.

**Width is declared, not proven.** The annotation states how wide the value is;
nothing in the document shows the producer honoured it. The reader's range
validation (§6.2) is the only check, and it covers the integer families and
`float_fix` alone: a `float` or `float_dec` literal is never held to its width,
and a special number is held to nothing (above). Bases 64 and 85 are `uint`-only:
their alphabets use sign characters as digits (Base64 `+`, Ascii85 `+` and `-`),
so permitting a signed value there would introduce an ambiguity the format does
not have (§5.2).

### 18.7 Units Are Validated, Not Verified

The unit system rejects a unit that is malformed, unregistered, or contradicted
by an inline suffix on the same value (§6.5, §12.8). It cannot verify that the
value is a plausible measurement in that unit, that two assignments are
dimensionally consistent with each other, or that the producer measured what it
claims. *It parsed* means the dimension is stated and internally consistent, not
that it is correct.

Read-time unit conversion carries a further trap: asking for a value in some
unit is a **request, not a guarantee**. A target *list* (the unit policy's
`targets`, `--unit`, `--si`) leaves a value it cannot convert — different
dimensions, or two different currencies — in its *original* unit and reports no
error; a per-field rule (`--field`, a policy rule by key path) and the
`want_unit` hook fail with `error_unit_mismatch` instead. Only the first needs the check. A caller that
assumes success then holds a number it believes is in one unit and which is in
another, which is exactly the failure this format exists to prevent. Check
whether the conversion occurred; do not infer it from the absence of an error.

### 18.8 Leap-Second Table Drift

The `tai` epoch requires a leap-second table, which is a static snapshot of an
IERS bulletin. Two builds of differing vintage may convert the same civil literal
to different `tai` values for instants after the older table ends. Where exact
agreement matters, transmit an integer carrier rather than a literal, and do not
assume that timestamps converted by different builds compare correctly.

### 18.9 Confusable and Non-Normalized Keys

Keys admit almost any non-ASCII character — everything but U+0080–U+00BF (§4.2), which leaves the zero-width and formatting characters, U+FEFF among them (§3.2), all available — and are compared as byte sequences with
no Unicode normalisation (§3.1, §4.2). Two keys that are canonically equivalent
under Unicode but differ in bytes are distinct keys and do not collide under the
duplicate-key rule; two keys that are visually identical but differ in code
point — Latin and Cyrillic homoglyphs — are distinct keys that a human reviewer
cannot tell apart. Where keys drive authorization or routing, apply a
normalisation and confusable-detection policy before matching, and prefer ASCII
keys where the application permits.

### 18.10 No Confidentiality, Integrity, or Authenticity

The format defines no encryption, no checksum, no signature, and no notion of an
author. All three properties must come from the transport or an enclosing
envelope. Note also that no canonical form *of a document* is defined — the canonical spelling of a unit (§11.4) and `pretty-print --canonical` normalise units and layout, not everything two equal documents can differ in: a signature over the bytes
is a signature over one *spelling* of the data, and a producer that re-emits the
document invalidates it without changing what it means.

Comments are bytes of the document and not part of its data (§4.1). A producer
that copies a document forward carries its comments forward with it, including
any that were never meant to leave the system that wrote them.

---

## 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_start` … `ev_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 data="float:32,m/s"   (family = vt_float)
ev_type_annotation_type_family_parameter  (width:32)
ev_type_annotation_type_family_parameter  (unit:m/s)
ev_type_annotation_end         data="float:32,m/s"
ev_data                        data="9.81"
```

The three bracketing events — start, family and end — all carry the same
bytes: the whole annotation body as written, or the bare family keyword when
the annotation was synthesised (`"uint"` in A.1). The family itself is in the
event's `value_type.family`. An explicit annotation emits a parameter event for
each parameter it states and no other — `<float:32,m/s>` states no base, so
there is no base event — where a synthesised one emits all three.

### 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 data="float:64,k~g·m/s²"   (family = vt_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_data                        data="2"
ev_array_row_end
ev_array_dim_start
ev_array_row_start
ev_data                        data="3"
ev_data                        data="4"
ev_array_row_end
```

Inside an array a synthesised annotation is emitted only when the element's
synthesised type differs from the type currently in force — at the first
element, and again at each change. It is not repeated per element or per row;
an explicit element annotation is always emitted. So `[1, 2.5, 3]` carries
three annotation blocks (`uint`, `float`, `uint`) and `[1, 2]/[3, 4]` carries
one.

### 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

```c
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

```c
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

```c
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`:

```c
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_identifier_length | 255 | Default applied by the reader when `max_identifier_length` is 0; longer is `error_identifier_too_long` |
| reader default max_string_length | 65 535 | Default applied by the reader when `max_string_length` is 0; longer is `error_string_too_long` |
| reader default max_number_length | 65 535 | Default applied by the reader when `max_number_length` is 0; longer is `error_number_too_long` |
| reader default max_symbol_length | 255 | Default applied by the reader when `max_symbol_length` is 0; longer is `error_symbol_too_long` |
| reader default max_reference_length | 65 535 | Default applied by the reader when `max_reference_length` is 0; longer is `error_reference_too_long` |
| length-limit ceiling | 65 535 | The five `max_*_length` fields are 16-bit, so no setting raises a limit past this. The writer accepts them for symmetry and does not consult them |
| 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 (no total-byte cap) | A `max_file_size` of 0 imposes no total-byte cap; the text is still bounded by `max_text_bytes`. 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` | 32 | Maximum number of unit components in a compound unit |
| type-annotation body | 255 bytes | The bytes between `<` and `>`, whitespace beside separators not counted (§12.2); longer is `error_type_too_long` |
| inline unit suffix | 255 bytes | Longer is `error_unit_too_long` |
| unit exponent | −100 … 100 | `BVN_EXPONENT_MIN` … `BVN_EXPONENT_MAX`, zero excluded (§11.5) |
| unit `(…)` group nesting | 16 | Deeper is `error_unit_illegal` (§11.4) |
| `BVN_MAX_INT_WIDTH` | 32768 | Maximum bit-width for `uint`, `sint` and `datetime`; `float` has the same ceiling (§5.2). The validator and writer reject any declared width exceeding this value with `error_illegal_value_type`. |

---

## See also

- [Tutorial](01_bovnar_tutorial.md) — the same format taught by example, start here if this is your first Bovnar document
- [Unit & Currency Reference](05_bovnar_unit_system.md) — the full registry behind §11, with dimensions and conversion factors
- [Read & Write API](08_bovnar_readwrite_api.md) — the C reader, writer, and DOM that implement §16
- [EBNF Grammar](12_bovnar.ebnf) — the formal grammar referenced by §14
- [FAQ](02_bovnar_faq.md) — the questions this document answers indirectly
- [Conformance Test Tool](13_bovnar_conformance.md) — how an implementation proves it follows this specification

---

*End of Bovnar — Specification (Bovnar spec 1.2).*

<!-- ==================== Unit & currency cheatsheet (https://www.bovnar.io/doc/04_bovnar_unit_cheatsheet.md) ==================== -->

# Bovnar — Units & Currencies Cheat Sheet

> **Spec version:** 1.2
> **Status:** Reference — the symbol tables of the unit and currency registry
> **Scope:** 264 physical units, 166 fiat currencies, 50 cryptocurrencies, and every SI/IEC prefix.

---

## Table of Contents

1. [SI Prefixes](#1-si-prefixes)
2. [IEC Binary Prefixes](#2-iec-binary-prefixes)
3. [Prefix Validity Rules](#3-prefix-validity-rules)
4. [Physical Units](#4-physical-units)
    - 4.1 [SI Base Units](#41-si-base-units)
    - 4.2 [Named SI-Derived Units](#42-named-si-derived-units)
    - 4.3 [Non-SI Units Accepted with SI](#43-non-si-units-accepted-with-si)
    - 4.4 [Imperial & US Customary — Length](#44-imperial--us-customary--length)
    - 4.5 [Imperial & US Customary — Mass](#45-imperial--us-customary--mass)
    - 4.6 [Temperature](#46-temperature)
    - 4.7 [Pressure](#47-pressure)
    - 4.8 [Energy](#48-energy)
    - 4.9 [Power](#49-power)
    - 4.10 [Force](#410-force)
    - 4.11 [Speed & Rotation](#411-speed--rotation)
    - 4.12 [Acceleration](#412-acceleration)
    - 4.13 [Volume — US Liquid](#413-volume--us-liquid)
    - 4.14 [Volume — UK Imperial](#414-volume--uk-imperial)
    - 4.15 [Volume — US Apothecary & Dry](#415-volume--us-apothecary--dry)
    - 4.16 [Area](#416-area)
    - 4.17 [Angle](#417-angle)
    - 4.18 [Digital](#418-digital)
    - 4.19 [CGS Units](#419-cgs-units)
    - 4.20 [Radiation](#420-radiation)
    - 4.21 [Logarithmic](#421-logarithmic)
    - 4.22 [Electrical Power](#422-electrical-power)
    - 4.23 [Textile Linear Density](#423-textile-linear-density)
    - 4.24 [Old German Units](#424-old-german-units)
    - 4.25 [Additional Physical Units](#425-additional-physical-units)
    - 4.26 [Ratio and Proportion](#426-ratio-and-proportion)
    - 4.27 [Named Speeds & Acidity](#427-named-speeds--acidity)
    - 4.28 [Water Hardness](#428-water-hardness)
    - 4.29 [Water-Quality Instrument Scales](#429-water-quality-instrument-scales)
    - 4.30 [Survey, Typographic, Dry-Volume and Trade Units](#430-survey-typographic-dry-volume-and-trade-units)
5. [Currencies](#5-currencies)
    - 5.1 [The Mandatory Currency Sigil](#51-the-mandatory-currency-sigil)
    - 5.2 [ISO 4217 Fiat Currencies](#52-iso-4217-fiat-currencies)
    - 5.3 [Cryptocurrencies](#53-cryptocurrencies)
    - 5.4 [Currency Prefix Rules](#54-currency-prefix-rules)
6. [Symbol Disambiguation](#6-symbol-disambiguation)

- [See also](#see-also)

---

## 1. SI Prefixes

Written as `prefix~base`, or compactly without the separator: `k~m` and `km` are both the kilometre. The canonical output form always keeps the `~`.

| Name   | Symbol | Factor | Enum (`si_prefix_id_t`) |
|--------|--------|--------|--------------------------|
| 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`   |
| *(none)* | —    | 10⁰    | `si_none`   |
| 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` |

> `µ` is U+00B5 MICRO SIGN (UTF-8 `0xC2 0xB5`). U+03BC (Greek small letter mu) is also accepted on input; the canonical output is always U+00B5. ASCII `u` is accepted as an input-only alias for `µ` (e.g. `u~m` = `µ~m`); the canonical output is always `µ`.
> `da` is a two-character prefix: `da~m` = decametre.

**Prefix–base ambiguities** — the base unit is the longest alias suffix, so a bare token is always the unit; `~` selects the prefix reading:

| Bare token | Is a base unit | With `~` becomes prefix |
|------------|----------------|--------------------------|
| `m`  | meter (`bu_meter`)    | milli  |
| `d`  | day (`bu_day`)        | deci   |
| `h`  | hour (`bu_hour`)      | hecto  |
| `T`  | tesla (`bu_tesla`)    | tera   |
| `G`  | gauss (`bu_gauss`)    | giga   |
| `P`  | poise (`bu_poise`)    | peta   |
| `R`  | röntgen (`bu_roentgen`) | ronna |
| `f`  | *(nothing — `error_unit_illegal`; the farad is `F`, uppercase)* | femto  |
| `u`  | dalton (`bu_dalton`)  | micro (ASCII alias for `µ`) |
| `S`  | siemens (`bu_siemens`) | *(not a prefix — `S` has no prefix role)* |

Examples: bare `m` = metre; `m~s` and `ms` = millisecond. Bare `d` = day; `d~s` and `ds` = decisecond. Bare `min` = minute, so milli-inch needs the separator: `m~in`.

A compact spelling is refused when the token means something else in the wild: `usb`, `kt`, `ppt`, `MBtu` and `Mlb` are `error_unit_illegal` (write `u~sb`; for `kt`, `k~t` for the kilotonne or `kn` for the knot; for `ppt`, `pptr`, `‰` or `p~pt`; for US trade's Roman-M thousand, `k~Btu` and `k~lb`, or `M~Btu` and `M~lb` for a million). `pH`, `mph` and `kph` are units in their own right (§3.26–3.27 of the unit-system reference), so they parse as acidity and the two speeds — `p~H`, `m~ph` and `k~ph` are still the picohenry and the milli-/kilophot.

Currencies take the compact prefix too (`k$EUR` = `k~$EUR`, §5.4), but their `$` sigil is never optional.

---

## 2. IEC Binary Prefixes

Used **only** on `b` (bit) and `B` (byte). Written as `prefix~base` or compactly: `Ki~B` and `KiB` are both the kibibyte.

| Name  | Symbol | Factor   | Enum (`iec_prefix_id_t`) |
|-------|--------|----------|--------------------------|
| 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⁸⁰).

---

## 3. Prefix Validity Rules

| Unit category | SI prefixes | IEC prefixes |
|---------------|-------------|--------------|
| Physical units not listed below (default) | All 24 allowed | Forbidden |
| `b` (bit) and `B` (byte) | Only ≥ kilo (`k`, `M`, `G`, …, `Q`) | All 10 allowed |
| `b` and `B` with sub-kilo SI (`d`, `c`, `m`, `µ`, `n`, `p`, `f`, `a`, `z`, `y`, `r`, `q`, `da`, `h`) | **Forbidden** | — |
| Currency codes | All 24 allowed | **Forbidden** |
| Old German units (`bu_pfund` … `bu_scheffel`) | **None** (`si_none` only) | **Forbidden** |
| Ratio units (`%`, `‰`, `‱`, `pcm`, `ppm`, `ppb`, `pptr`, `ppq`) | **None** — `k~%` is `error_unit_illegal` | **Forbidden** |
| Scales that are already a scale: `pH`, `mph`, `kph`, the five water-hardness degrees (`°dH`, `°e`, `°fH`, `°rH`, `°aH`), `gpg`, `CF`, `PSU`, `JTU` | **None** | **Forbidden** |

The turbidity units `NTU`, `FNU`, `FTU` and `FAU` are not in that last row: they take any SI
prefix, because a milli-NTU is a real ultrapure-water measurement. The full rule, with its reasons, is in
[Unit System §4.2](05_bovnar_unit_system.md#42-iec-binary-prefixes).

---

## 4. Physical Units

> **Symbol** = canonical serialized form (produced on output; accepted on input).
> **Long forms** = accepted on input only; never produced on output.
> **Factor** = conversion factor to SI base units unless noted.

### 4.1 SI Base Units

| Symbol | Long forms | Name | Enum |
|--------|-----------|------|------|
| `s`   | `sec`, `second`, `seconds` | second | `bu_second` |
| `m`   | `meter`, `metre`, `meters`, `metres` | metre | `bu_meter` |
| `g`   | `gram`, `grams` | gram | `bu_gram` |
| `A`   | `amp`, `amps`, `ampere`, `amperes` | ampere | `bu_ampere` |
| `K`   | `kelvin`, `kelvins` | kelvin | `bu_kelvin` |
| `mol` | `mole`, `moles` | mole | `bu_mol` |
| `cd`  | `candela`, `candelas` | candela | `bu_candela` |

> `g` (gram) is the base symbol; `k~g` = kilogram.

### 4.2 Named SI-Derived Units

| Symbol | Long forms | Name | Enum | SI definition |
|--------|-----------|------|------|---------------|
| `Hz`  | `hertz` | hertz | `bu_hertz` | s⁻¹ |
| `N`   | `newton`, `newtons` | newton | `bu_newton` | kg·m·s⁻² |
| `Pa`  | `pascal`, `pascals` | pascal | `bu_pascal` | kg·m⁻¹·s⁻² |
| `J`   | `joule`, `joules` | joule | `bu_joule` | kg·m²·s⁻² |
| `W`   | `watt`, `watts` | watt | `bu_watt` | kg·m²·s⁻³ |
| `V`   | `volt`, `volts` | volt | `bu_volt` | kg·m²·A⁻¹·s⁻³ |
| `Ω`   | `ohm`, `ohms`, `Ohm` | ohm | `bu_ohm` | kg·m²·A⁻²·s⁻³ — U+2126 OHM SIGN; U+03A9 (Greek capital omega) also accepted on input, canonical output is always U+2126 |
| `F`   | `farad`, `farads` | farad | `bu_farad` | kg⁻¹·m⁻²·A²·s⁴ |
| `C`   | `coulomb`, `coulombs` | coulomb | `bu_coulomb` | A·s |
| `S`   | `siemens`, `mho`, `mhos`, `℧` | siemens | `bu_siemens` | kg⁻¹·m⁻²·A²·s³ |
| `Wb`  | `weber`, `webers` | weber | `bu_weber` | kg·m²·A⁻¹·s⁻² |
| `T`   | `tesla`, `teslas` | tesla | `bu_tesla` | kg·A⁻¹·s⁻² |
| `H`   | `henry`, `henrys`, `henries` | henry | `bu_henry` | kg·m²·A⁻²·s⁻² |
| `lm`  | `lumen`, `lumens` | lumen | `bu_lumen` | cd·sr |
| `lx`  | `lux` | lux | `bu_lux` | cd·sr·m⁻² |
| `Bq`  | `becquerel`, `becquerels` | becquerel | `bu_becquerel` | s⁻¹ |
| `Gy`  | `gray`, `grays` | gray | `bu_gray` | m²·s⁻² |
| `Sv`  | `sievert`, `sieverts` | sievert | `bu_sievert` | m²·s⁻² |
| `kat` | `katal`, `katals` | katal | `bu_katal` | mol·s⁻¹ |
| `rad` | `radian`, `radians` | radian | `bu_radian` | dimensionless (m/m) |
| `sr`  | `steradian`, `steradians` | steradian | `bu_steradian` | dimensionless (m²/m²) |

### 4.3 Non-SI Units Accepted with SI

| Symbol | Long forms | Name | Enum | Factor / notes |
|--------|-----------|------|------|----------------|
| `L`, `l` | `liter`, `litre`, `liters`, `litres` | litre | `bu_liter` | 10⁻³ m³ |
| `min` | `minute`, `minutes` | minute | `bu_minute` | 60 s |
| `h`   | `hour`, `hours` | hour | `bu_hour` | 3600 s |
| `d`   | `day`, `days` | day | `bu_day` | 86400 s |
| `wk`  | `week`, `weeks` | week | `bu_week` | 604 800 s |
| `mo`  | `month`, `months` | month (Julian) | `bu_month` | 2 629 800 s (= 365.25 d / 12) |
| `fn`  | `fortnight`, `fortnights` | fortnight | `bu_fortnight` | 1 209 600 s (= 14 d) |
| `yr`  | `year`, `years` | year (Julian) | `bu_year` | 31 557 600 s |
| `°`, `deg` | `degr`, `degree`, `degrees` | degree (angle) | `bu_degree` | π/180 rad — U+00B0 |
| `t`   | `tonne`, `tonnes` | tonne | `bu_tonne` | 10³ kg |
| `bar` | `bars` | bar | `bu_bar` | 10⁵ Pa |
| `eV`  | `electronvolt`, `electronvolts` | electronvolt | `bu_electronvolt` | 1.602176634×10⁻¹⁹ J |
| `Da`  | `dalton`, `daltons`, `amu`, `u` | dalton | `bu_dalton` | 1.66053906892×10⁻²⁷ kg (CODATA 2022) |
| `au`  | — | astronomical unit | `bu_astronomical_unit` | 1.495978707×10¹¹ m |
| `ha`  | `hectare`, `hectares` | hectare | `bu_hectare` | 10⁴ m² |

### 4.4 Imperial & US Customary — Length

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `in`   | `inch`, `inches` | inch | `bu_inch` | 0.0254 m (exact) |
| `ft`   | `foot`, `feet` | foot | `bu_foot` | 0.3048 m (exact) |
| `yd`   | `yard`, `yards` | yard | `bu_yard` | 0.9144 m (exact) |
| `mi`   | `mile`, `miles` | statute mile | `bu_mile` | 1609.344 m (exact) |
| `nmi`  | `nautical_mile`, `nautical_miles` | nautical mile | `bu_nautical_mile` | 1852 m (exact) |
| `Å` (U+212B) | `angstrom`, `angstroms`, Å (U+00C5) | ångström | `bu_angstrom` | 10⁻¹⁰ m |
| `ly`   | `light_year`, `light_years` | light-year | `bu_light_year` | 9.4607304725808×10¹⁵ m |
| `pc`   | `parsec`, `parsecs` | parsec | `bu_parsec` | 3.085677581491367×10¹⁶ m |
| `fur`  | `furlong`, `furlongs` | furlong | `bu_furlong` | 201.168 m (exact) |
| `fath` | `fathom`, `fathoms` | fathom | `bu_fathom` | 1.8288 m (exact) |
| `thou` | `thou`, `mil`, `mils` | thou | `bu_thou` | 25.4×10⁻⁶ m (exact) |
| `ch`   | `chain`, `chains` | chain (Gunter's) | `bu_chain` | 20.1168 m (exact) |
| `rd`   | `rod`, `rods` | rod (pole, perch) | `bu_rod` | 5.0292 m (exact) |

> `thou` and `mil` are synonyms. Canonical output is `thou`. `mil` does **not** mean milliradian; milliradians are written `m~rad`.

### 4.5 Imperial & US Customary — Mass

| Symbol  | Long forms | Name | Enum | Factor |
|---------|-----------|------|------|--------|
| `lb`    | `lbs`, `pound`, `pounds` | pound (avoirdupois) | `bu_pound` | 0.45359237 kg (exact) |
| `oz`    | `ounce`, `ounces` | ounce (avoirdupois) | `bu_ounce` | 0.028349523125 kg (exact) |
| `gr`    | `grain`, `grains` | grain | `bu_grain` | 6.479891×10⁻⁵ kg (exact) |
| `st`    | `stone`, `stones` | stone | `bu_stone` | 6.35029318 kg (exact) |
| `tn_sh` | `short_ton`, `short_tons` | short ton (US) | `bu_short_ton` | 907.18474 kg (exact) |
| `tn_l`  | `long_ton`, `long_tons` | long ton (UK) | `bu_long_ton` | 1016.0469088 kg (exact) |
| `oz_t`  | `troy_ounce`, `troy_ounces` | troy ounce | `bu_troy_ounce` | 0.0311034768 kg (exact) |
| `ct`    | `carat`, `carats` | metric carat | `bu_carat` | 2×10⁻⁴ kg (exact) |
| `slug`  | `slugs` | slug | `bu_slug` | 14.593902937206364 kg (= `lb`·`gn`/`ft`) |
| `dr`    | `dram`, `drams` | dram (avoirdupois) | `bu_dram` | 1.7718451953125×10⁻³ kg (exact) |
| `dwt`   | `pennyweight`, `pennyweights` | pennyweight (troy) | `bu_pennyweight` | 1.55517384×10⁻³ kg (exact) |
| `lb_t`  | `troy_pound`, `troy_pounds`, `apothecary_pound` | troy pound (= apothecary pound) | `bu_troy_pound` | 0.3732417216 kg (exact, = 12 `oz_t`) |
| `dr_ap` | `apothecary_dram`, `apothecary_drams` | dram (apothecary) | `bu_apothecary_dram` | 3.8879346×10⁻³ kg (exact, = 3 `sc`) |
| `cwt_l` | `long_hundredweight`, `long_hundredweights` | hundredweight (long/imperial) | `bu_long_hundredweight` | 50.80234544 kg (exact, = 112 `lb`) |

> `dr_ap` is **2.2× the avoirdupois `dr`**, and the short hundredweight is exactly `h~lb`, so it has no unit of its own.

### 4.6 Temperature

| Symbol | Long forms | Name | Enum | Conversion |
|--------|-----------|------|------|------------|
| `°C`, `degC` | `degrC`, `degreeC`, `degreesC`, `celsius` | degree Celsius | `bu_celsius` | K = °C + 273.15 **(affine)** |
| `°F`, `degF` | `degrF`, `degreeF`, `degreesF`, `fahrenheit` | degree Fahrenheit | `bu_fahrenheit` | K = (°F + 459.67) × 5/9 **(affine)** |
| `°Ra`, `degRa`, `Ra` | `degrRa`, `degreeRa`, `degreesRa`, `rankine` | degree Rankine | `bu_rankine` | K = °Ra × 5/9 (linear) |
| `°De`, `degDe` | `degrDe`, `degreeDe`, `degreesDe`, `delisle` | degree Delisle | `bu_delisle` | K = 373.15 − °De × 2/3 **(affine)** |
| `°N`, `degN` | `degrN`, `degreeN`, `degreesN`, `newton_temperature` | degree Newton | `bu_newton_temp` | K = °N × 100/33 + 273.15 **(affine)** |
| `°Re`, `degRe` | `degrRe`, `degreeRe`, `degreesRe`, `reaumur` | degree Réaumur | `bu_reaumur` | K = °Re × 5/4 + 273.15 **(affine)** |
| `°Ro`, `degRo` | `degrRo`, `degreeRo`, `degreesRo`, `romer` | degree Rømer | `bu_romer` | K = (°Ro − 7.5) × 40/21 + 273.15 **(affine)** |

> Kelvin (`K`) is in §4.1. `R` is reserved for röntgen (§4.20). `N` alone is newton (§4.2); use `°N` or `degN` for Newton temperature.

**Temperature differences.** Every scale above is a *scale*: `25 °C` is 298.15 K. A *difference* of 25 degrees is 25 K, and these are the units for it. Each is a ratio scale (no offset) carrying the temperature-interval quantity kind, so `ΔK` never converts to `K` and `°C` never converts to `Δ°C` — those are `error_unit_mismatch`.

| Symbol | Long forms | Name | Enum | Conversion |
|--------|-----------|------|------|------------|
| `ΔK`, `delta_K` | `deltaK`, `delta_kelvin`, `deltakelvin`, `Δ°C`, `delta_degC`, `deltadegC`, `delta_celsius`, `deltacelsius` | kelvin interval | `bu_delta_kelvin` | 1 K exactly. `Δ°C` **is** this unit — the Celsius interval is the kelvin |
| `Δ°F`, `delta_degF` | `deltadegF`, `delta_fahrenheit`, `deltafahrenheit`, `Δ°Ra`, `delta_degRa`, `deltadegRa`, `delta_rankine`, `deltarankine` | Fahrenheit interval | `bu_delta_fahrenheit` | 5/9 K exactly. `Δ°Ra` **is** this unit |
| `Δ°De`, `delta_degDe` | `deltadegDe`, `delta_delisle`, `deltadelisle` | Delisle interval | `bu_delta_delisle` | −2/3 K exactly (Delisle runs backwards) |
| `Δ°N`, `delta_degN` | `deltadegN`, `delta_newton_temperature` | Newton interval | `bu_delta_newton_temp` | 100/33 K exactly |
| `Δ°Re`, `delta_degRe` | `deltadegRe`, `delta_reaumur`, `deltareaumur` | Réaumur interval | `bu_delta_reaumur` | 5/4 K exactly |
| `Δ°Ro`, `delta_degRo` | `deltadegRo`, `delta_romer`, `deltaromer` | Rømer interval | `bu_delta_romer` | 40/21 K exactly |

> `Δ` is U+0394; every unit here has ASCII spellings. There is deliberately no bare `delta_C` or `delta_F` — those read as a delta coulomb and a delta farad. Inside a **compound** the distinction does not arise and is not made: `W/(m²·ΔK)` and `W/(m²·K)` are the same unit, because an affine scale cannot appear in a compound at all, so the `K` there was already an interval.

### 4.7 Pressure

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `atm`  | `atmosphere`, `atmospheres` | standard atmosphere | `bu_atmosphere` | 101 325 Pa (exact) |
| `at`   | `atmosphere_technical` | atmosphere technical | `bu_atmosphere_technical` | 98 066.5 Pa (= 1 kgf/cm²) |
| `mmHg` | — | millimetre of mercury | `bu_mmhg` | 133.322387415 Pa |
| `Torr` | `torr` | torr | `bu_torr` | 101 325/760 Pa |
| `psi`  | — | pound-force per square inch | `bu_psi` | 6894.757293168362 Pa |
| `inHg` | `inch_hg`, `inch_mercury` | inch of mercury | `bu_inch_hg` | 3386.388640341 Pa (= 25.4 mmHg exactly) |
| `mH2O` | `metre_water`, `meter_water` | metre of water column | `bu_meter_water` | 9806.65 Pa (exact, conventional) |

> `at ≠ atm`: 1 at = 98 066.5 Pa; 1 atm = 101 325 Pa.

### 4.8 Energy

| Symbol  | Long forms | Name | Enum | Factor |
|---------|-----------|------|------|--------|
| `cal`   | `calorie`, `calories` | thermochemical calorie | `bu_calorie` | 4.184 J (exact) |
| `Btu`   | `btu`, `BTU` | International Table BTU | `bu_btu` | 1055.05585262 J |
| `erg`   | `ergs` | erg | `bu_erg` | 10⁻⁷ J (exact) |
| `thm`   | `therm`, `therms` | US therm | `bu_therm` | 1.05480400×10⁸ J (exact) |
| `ft_lb` | `foot_pound`, `foot_pounds` | foot-pound | `bu_foot_pound` | 1.3558179483314003 J (= `lbf`·`ft`) |
| `cal_IT` | `calorie_IT` | International Table calorie | `bu_calorie_it` | 4.1868 J (exact) |
| `Btu_th` | `BTU_th`, `btu_th` | thermochemical BTU | `bu_btu_th` | 23 722 880 951/22 500 000 J ≈ 1054.35026449 J |

> `BTU` (all-caps) is a valid alias: with no `$` sigil the token is a physical-unit lookup (the currency table is only consulted for `$`-prefixed tokens), and it matches `bu_btu`. `Btu` and `btu` are also accepted.

### 4.9 Power

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `hp`   | `horsepower` | mechanical horsepower | `bu_horsepower` | 745.6998715822702 W (= 550 `ft_lb`/s) |
| `PS`   | `CV`, `metric_horsepower` | metric horsepower | `bu_metric_horsepower` | 735.49875 W (exact) |

### 4.10 Force

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `lbf`  | `pound_force` | pound-force | `bu_pound_force` | 4.4482216152605 N |
| `dyn`  | `dyne`, `dynes` | dyne | `bu_dyne` | 10⁻⁵ N (exact) |
| `kip`  | `kips` | kip (kilopound-force) | `bu_kip` | 4448.2216152605 N |
| `kgf`  | `kilogram_force` | kilogram-force | `bu_kilogram_force` | 9.80665 N (exact) |

### 4.11 Speed & Rotation

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `kn`   | `knot`, `knots` | knot | `bu_knot` | 1852/3600 m/s |
| `rpm`  | — | revolutions per minute | `bu_rpm` | 1/60 s⁻¹ |

### 4.12 Acceleration

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `gn`   | `standard_gravity` | standard gravity | `bu_standard_gravity` | 9.80665 m·s⁻² (exact) |

### 4.13 Volume — US Liquid

| Symbol  | Long forms | Name | Enum | Factor |
|---------|-----------|------|------|--------|
| `gal`   | `gallon`, `gallons` | US liquid gallon | `bu_gallon` | 3.785411784×10⁻³ m³ (exact) |
| `qt`    | `quart`, `quarts` | US liquid quart | `bu_quart` | 9.46352946×10⁻⁴ m³ |
| `pt`    | `pint`, `pints` | US liquid pint | `bu_pint` | 4.73176473×10⁻⁴ m³ |
| `cup`   | `cups` | US cup | `bu_cup` | 2.365882365×10⁻⁴ m³ |
| `gi`    | `gill`, `gills` | US gill | `bu_gill` | 1.18294118250×10⁻⁴ m³ |
| `fl_oz` | `fluid_ounce`, `fluid_ounces` | US fluid ounce | `bu_fluid_ounce` | 2.95735295625×10⁻⁵ m³ |
| `tbsp`  | `tablespoon`, `tablespoons` | US tablespoon | `bu_tablespoon` | 1.478676478125×10⁻⁵ m³ (exact) |
| `tsp`   | `teaspoon`, `teaspoons` | US teaspoon | `bu_teaspoon` | 4.92892159375×10⁻⁶ m³ |
| `bbl`   | `barrel`, `barrels` | petroleum barrel | `bu_barrel` | 0.158987294928 m³ |

> `cup` (lowercase) = US cup; `CUP` (uppercase) = Cuban Peso. See §6.

### 4.14 Volume — UK Imperial

| Symbol     | Long forms | Name | Enum | Factor |
|------------|-----------|------|------|--------|
| `gal_uk`   | `gallon_uk`, `gallons_uk` | imperial gallon | `bu_gallon_uk` | 4.54609×10⁻³ m³ (exact) |
| `qt_uk`    | `quart_uk`, `quarts_uk` | imperial quart | `bu_quart_uk` | 1136.5225×10⁻⁶ m³ |
| `pt_uk`    | `pint_uk`, `pints_uk` | imperial pint | `bu_pint_uk` | 568.26125×10⁻⁶ m³ |
| `gi_uk`    | `gill_uk`, `gills_uk` | imperial gill | `bu_gill_uk` | 1.420653125×10⁻⁴ m³ (exact) |
| `fl_oz_uk` | `fluid_ounce_uk`, `fluid_ounces_uk` | imperial fluid ounce | `bu_fluid_ounce_uk` | 28.4130625×10⁻⁶ m³ |

### 4.15 Volume — US Apothecary & Dry

| Symbol  | Long forms | Name | Enum | Factor |
|---------|-----------|------|------|--------|
| `fl_dr`, `fl_drams` | `fluid_dram`, `fluid_drams` | US fluid dram | `bu_fluid_dram` | 3.6966911953125×10⁻⁶ m³ |
| `minim` | `minims` | US minim | `bu_minim` | 6.16115199218750×10⁻⁸ m³ |
| `pk`    | `peck`, `pecks` | US dry peck | `bu_peck` | 8.80976754172×10⁻³ m³ |
| `bsh`   | `bushel`, `bushels` | US bushel | `bu_bushel` | 3.523907016688×10⁻² m³ |

> `minim` not `min` (which is the minute).

### 4.16 Area

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `ac`   | `acre`, `acres` | acre | `bu_acre` | 4046.8564224 m² (exact) |
| `barn` | `barns` | barn | `bu_barn` | 10⁻²⁸ m² (exact) |

### 4.17 Angle

| Symbol   | Long forms | Name | Enum | Factor |
|----------|-----------|------|------|--------|
| `arcmin` | `arcminute`, `arcminutes` | arcminute | `bu_arcminute` | π/10800 rad |
| `arcsec` | `arcsecond`, `arcseconds` | arcsecond | `bu_arcsecond` | π/648000 rad |
| `grad`   | `gradian`, `gradians`, `gon` | gradian | `bu_grad` | π/200 rad |
| `rev`    | `turn`, `revolution`, `revolutions`, `turns` | revolution | `bu_revolution` | 2π rad |

### 4.18 Digital

| Symbol | Long forms | Name | Enum |
|--------|-----------|------|------|
| `b`    | `bit`, `bits` | bit | `bu_bit` |
| `B`    | `byte`, `bytes`, `Byte`, `Bytes` | byte | `bu_byte` |

> `b` and `B` are separate quantity kinds: `b` → `B` is **refused**, not
> divided by eight. Prefixes still convert within each (`Ki~B` → `B` is 1024).
> See doc/05 §3.17.

### 4.19 CGS Units

| Symbol | Long forms | Name | Enum | SI equivalent |
|--------|-----------|------|------|---------------|
| `P`    | `poise`, `poises` | poise (dynamic viscosity) | `bu_poise` | 0.1 Pa·s |
| `St`   | `stokes`, `stoke` | stokes (kinematic viscosity) | `bu_stokes` | 10⁻⁴ m²·s⁻¹ |
| `G`    | `gauss` | gauss (magnetic flux density) | `bu_gauss` | 10⁻⁴ T |
| `Mx`   | `maxwell`, `maxwells` | maxwell (magnetic flux) | `bu_maxwell` | 10⁻⁸ Wb |
| `Oe`   | `oersted`, `oersteds` | oersted (magnetic field strength) | `bu_oersted` | 1000/(4π) A/m |
| `sb`   | `stilb`, `stilbs` | stilb (luminance) | `bu_stilb` | 10⁴ cd/m² |
| `ph`   | `phot`, `phots` | phot (illuminance) | `bu_phot` | 10⁴ lx |
| `Gal`  | `galileo`, `galileos` | galileo (acceleration) | `bu_galileo` | 10⁻² m/s² |

### 4.20 Radiation

| Symbol | Long forms | Name | Enum | SI equivalent |
|--------|-----------|------|------|---------------|
| `Ci`   | `curie`, `curies` | curie (radioactivity) | `bu_curie` | 3.7×10¹⁰ Bq |
| `R`    | `roentgen`, `roentgens` | röntgen (radiation exposure) | `bu_roentgen` | 2.58×10⁻⁴ C/kg |
| `rem`  | `rems` | rem (dose equivalent) | `bu_rem` | 10⁻² Sv |

### 4.21 Logarithmic

| Symbol | Long forms | Name | Enum | Notes |
|--------|-----------|------|------|-------|
| `Np`   | `neper`, `nepers` | neper | `bu_neper` | dimensionless; does **not** convert to `dB` |
| `dB`   | `decibel`, `decibels` | decibel | `bu_decibel` | dimensionless; does **not** convert to `Np` |

> Relating two logarithmic scales is a change of base, not a multiplication, so
> the conversion engine cannot express it — and `dB` is written against both the
> power (10·log₁₀) and field (20·log₁₀) conventions without the annotation saying
> which. The library refuses the pair. See doc/05 §3.15.

### 4.22 Electrical Power

| Symbol | Long forms | Name | Enum | Notes |
|--------|-----------|------|------|-------|
| `var`  | `vars` | var (volt-ampere reactive) | `bu_var` | reactive power; same SI dim as W |
| `VA`   | `volt_ampere`, `volt_amperes` | volt-ampere | `bu_volt_ampere` | apparent power; same SI dim as W |

> `W`, `var`, and `VA` all have SI dimension kg·m²·s⁻³. `bvn_units_compatible` returns `true` across them; use `.components[0].base` to distinguish.

### 4.23 Textile Linear Density

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `tex`  | — | tex | `bu_tex` | 1×10⁻⁶ kg/m (ISO 1144) |
| `den`  | `denier`, `deniers` | denier | `bu_denier` | 1/9 000 000 kg/m |

> 9 den = 1 tex.

### 4.24 Old German Units

No Old German unit accepts any SI or IEC prefix (`bvn_prefix_unit_valid` rejects all non-`si_none` prefixes for `bu_pfund` … `bu_scheffel`). Enum values 100133–100145.

#### Mass (metric-compatible)

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `Pfd`  | `pfund`, `pfunds` | Pfund | `bu_pfund` | 0.5 kg (exact) |
| `Ztr`  | `zentner` | Zentner | `bu_zentner` | 50 kg (exact) |
| `dz`   | `doppelzentner` | Doppelzentner | `bu_doppelzentner` | 100 kg (exact) |
| `lot`  | `lots` | Lot | `bu_lot` | 15.625×10⁻³ kg (exact) |

#### Length (historical Prussian)

| Symbol    | Long forms | Name | Enum | Factor |
|-----------|-----------|------|------|--------|
| `prln`    | `prussian_line`, `linie` | Prussian line | `bu_prussian_line` | 313853/144000000 m ≈ 2.1795347×10⁻³ m |
| `prz`     | `prussian_zoll`, `zoll` | Prussian Zoll | `bu_prussian_zoll` | 313853/12000000 m ≈ 2.6154417×10⁻² m |
| `prf`     | `prussian_fuss`, `preussischer_fuss` | Prussian Fuß | `bu_prussian_fuss` | 3.13853×10⁻¹ m |
| `elle`    | `prussian_elle`, `preussische_elle` | Prussian Elle | `bu_prussian_elle` | 6.66937625×10⁻¹ m (exact) |
| `rute`    | `prussian_rute`, `preussische_rute` | Prussian Rute | `bu_prussian_rute` | 3.766236 m (exact) |
| `klafter` | `prussian_klafter` | Klafter | `bu_klafter` | 1.883118 m (exact) |
| `dt_mi`   | `deutsche_meile`, `german_mile` | Geographische Meile | `bu_german_mile` | 7420.44 m |

#### Area (historical Prussian)

| Symbol   | Long forms | Name | Enum | Factor |
|----------|-----------|------|------|--------|
| `morgen` | `prussian_morgen` | Morgen (Prussian) | `bu_morgen` | 2553.21604938528 m² (exact) |

#### Volume (historical Prussian)

| Symbol   | Long forms | Name | Enum | Factor |
|----------|-----------|------|------|--------|
| `schffl` | `scheffel`, `prussian_scheffel` | Scheffel (Prussian) | `bu_scheffel` | 54.961×10⁻³ m³ |

### 4.25 Additional Physical Units

#### Length

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `ftUS` | `survey_foot` | US survey foot | `bu_survey_foot` | 1200/3937 m ≈ 0.30480061 m |
| `lea`  | `league`, `leagues` | League (US statute, 3 mi) | `bu_league` | 4828.032 m |
| `cbl`  | `cable`, `cables` | Cable (international, ¹⁄₁₀ nmi) | `bu_cable` | 185.2 m |
| `hand` | `hands` | Hand (4 in) | `bu_hand` | 0.1016 m |

#### Mass

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `qntl` | `quintal`, `quintals` | Metric quintal | `bu_quintal` | 100 kg |
| `sc`   | `scruple`, `scruples` | Apothecary scruple (20 gr) | `bu_scruple` | 1.2959782×10⁻³ kg |

#### Signal Rate

| Symbol | Long forms | Name | Enum | SI dimension |
|--------|-----------|------|------|--------------|
| `Bd`   | `baud`, `bauds` | Baud (symbol/s) | `bu_baud` | s⁻¹ |

SI prefixes are accepted on all units in this section. IEC prefixes are rejected for all non-digital units.

### 4.26 Ratio and Proportion

Dimensionless scaling factors: `5 %` ≡ `0.05`, `250 ppm` ≡ `0.00025`. These do **not** accept SI or IEC prefixes.

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `%`   | `percent` | per cent | `bu_percent` | 10⁻² |
| `‰`   | `per_mille` | per mille | `bu_per_mille` | 10⁻³ |
| `‱`   | `per_myriad` | per myriad | `bu_per_myriad` | 10⁻⁴ |
| `pcm` | `per_cent_mille` | per cent mille | `bu_per_cent_mille` | 10⁻⁵ |
| `ppm` | — | parts per million | `bu_ppm` | 10⁻⁶ |
| `ppb` | — | parts per billion | `bu_ppb` | 10⁻⁹ |
| `pptr` | `parts_per_trillion`, `pptv` | parts per trillion | `bu_ppt` | 10⁻¹² |
| `ppq` | `parts_per_quadrillion`, `ppqv` | parts per quadrillion | `bu_ppq` | 10⁻¹⁵ |

---

### 4.27 Named Speeds & Acidity

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `mph`  | — | mile per hour | `bu_mile_per_hour` | 0.44704 m·s⁻¹ (exact) |
| `kph`  | `kmh` | kilometre per hour | `bu_kilometer_per_hour` | 5/18 m·s⁻¹ (exact) |
| `pH`   | — | acidity | `bu_ph_scale` | 1 (dimensionless, own logarithmic kind) |

> `mi/h` and `k~m/h` are the same quantities written as compounds; both spellings are valid, but a
> named unit and its compound do not reconcile inside one value. `pH` is not `p~H` (picohenry), and
> `ph` is the phot.

### 4.28 Water Hardness

Six scales for one quantity — the concentration of dissolved alkaline-earth ions. All carry
mol·m⁻³ and convert into each other and into `m~mol/L`. None takes a prefix.

| Symbol | Long form | Enum value | Defined as | 1 mmol/L = |
|--------|-----------|------------|------------|------------|
| `°dH` | `german_hardness` | `bu_german_hardness` | 10 mg CaO / L | 5.6077 |
| `°e`, `°Clark` | `english_hardness`, `clark_degree` | `bu_english_hardness` | 1 grain CaCO₃ / imp. gallon | 7.0217 |
| `°fH` | `french_hardness` | `bu_french_hardness` | 10 mg CaCO₃ / L | 10.0086 |
| `°rH` | `russian_hardness` | `bu_russian_hardness` | 1 mg Ca / L | 40.078 |
| `°aH` | `american_hardness` | `bu_american_hardness` | 1 mg CaCO₃ / L ("ppm") | 100.086 |
| `gpg` | `grains_per_gallon` | `bu_grains_per_gallon` | 1 grain CaCO₃ / US gallon | 5.8468 |
| `val` | `vals`, `eq` | `bu_val` | ½ mol (divalent, water analysis) | 2.000 `mval/L` |

> Keep the degree sign: `dH` without it is the **decihenry**. Water chemistry's "ppm" is `°aH`,
> not Bovnar's dimensionless `ppm`. Millimoles per litre is just the compound `m~mol/L`, and
> `meq/L` is the same unit as `mval/L`. `gpg` (amount concentration) is not `gr/gal` (mass
> concentration).

**Conductivity and dissolved solids need no units of their own:** EC is `µS/cm`, `mS/cm`, `dS/m`
or `S/m` (`µmho/cm` too — `mho`/`℧` are the siemens); TDS is `mg/L`; resistivity is `MΩ·cm`.
"TDS in ppm" means mg/L, while Bovnar's `ppm` is the dimensionless 10⁻⁶.

### 4.29 Water-Quality Instrument Scales

| Symbol | Long form | Enum value | Method / definition | Converts to |
|--------|-----------|------------|---------------------|-------------|
| `NTU` | `nephelometric_turbidity` | `bu_turbidity_ntu` | white light, 90° (EPA 180.1) | itself only |
| `FNU` | `formazin_nephelometric` | `bu_turbidity_fnu` | near-IR 860 nm, 90° (ISO 7027) | itself only |
| `FTU` | `formazin_turbidity` | `bu_turbidity_ftu` | formazin, geometry unstated | itself only |
| `FAU` | `formazin_attenuation` | `bu_turbidity_fau` | attenuation at 0° (ISO 7027) | itself only |
| `JTU` | `jackson_turbidity` | `bu_turbidity_jtu` | visual candle method (historical) | itself only |
| `PSU` | `practical_salinity` | `bu_practical_salinity` | PSS-78 conductivity ratio | itself only |
| `CF` | `conductivity_factor` | `bu_conductivity_factor` | EC in mS/cm × 10 | `µS/cm`, `mS/cm`, `S/m` |

> The five turbidity scales agree on a formazin standard and not on real water — and `FAU` is not
> even the same optical quantity (attenuation, not scatter), while `JTU` is visual and relates to
> formazin only near 40 units. Bovnar refuses every conversion between them; report the method.
> `JTU` and `PSU` take no prefix. PSU is a conductivity ratio, **not** a mass fraction — for
> absolute salinity write `g/k~g`. Case matters: `cF` is the centifarad and `fau` the
> femto-astronomical-unit.

---

### 4.30 Survey, Typographic, Dry-Volume and Trade Units

Every unit here was, until it was added, the sole reason a run of UCUM, UDUNITS-2, QUDT, OM or
UN/ECE codes had to be refused. All are exact; the ones whose value is not a terminating decimal in
SI state a rational rather than a rounded double.

#### US survey lengths

`ftUS` (§4.4) has always been here and nothing was built on it. These are exact rational multiples
of it, and the survey foot is 2 ppm longer than the international one — which is small enough to
ignore and never small enough to be right.

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `inUS` | `survey_inch` | US survey inch | `bu_survey_inch` | 100/3937 m ≈ 0.02540005 m |
| `ydUS` | `survey_yard` | US survey yard | `bu_survey_yard` | 3600/3937 m ≈ 0.91440183 m |
| `fathUS` | `survey_fathom` | US survey fathom | `bu_survey_fathom` | 7200/3937 m ≈ 1.82880366 m |
| `rdUS` | `survey_rod` | US survey rod (pole, perch) | `bu_survey_rod` | 19800/3937 m ≈ 5.02921006 m |
| `chUS` | `survey_chain` | US survey chain (Gunter's) | `bu_survey_chain` | 79200/3937 m ≈ 20.11684023 m |
| `lkUS` | `survey_link` | US survey link | `bu_survey_link` | 792/3937 m ≈ 0.20116840 m |
| `furUS` | `survey_furlong` | US survey furlong | `bu_survey_furlong` | 792000/3937 m ≈ 201.16840234 m |
| `miUS` | `survey_mile`, `survey_miles` | US survey (statute) mile | `bu_survey_mile` | 6336000/3937 m ≈ 1609.34721869 m |

> The survey foot was withdrawn for new work at the end of 2022. That is a reason to **read** it
> carefully, not to refuse it: US land records, state-plane coordinates and a century of drawings
> are written in these. `ac` vs `acUS` differ by 4 ppm — twice the foot's 2 ppm, an area being a
> length squared — which is about ten square metres on a section of land.

#### Typographic lengths

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `pnt` | `point`, `points` | DTP point (¹⁄₇₂ in) | `bu_point` | 127/360000 m ≈ 0.35277778 mm |
| `pca` | `pica`, `picas` | pica (12 points, ⅙ in) | `bu_pica` | 127/30000 m ≈ 4.23333333 mm |
| `lne` | `line`, `lines` | line (¹⁄₁₂ in) | `bu_line` | 127/60000 m ≈ 2.11666667 mm |

> The symbols are not `pt` and `ln`: `pt` is the **pint**. A length answering to `pt` would be the
> same collision as `kt` for the knot, which §6 refuses outright. The **printer's** point
> (0.013837 in) is a different unit and is carried separately, as `pnt_pr` below.

#### US dry volumes and the trade measures

A dry quart is 16 % larger than the liquid `qt`. The peck and bushel here have always been the dry
ones; the gallon, quart and pint were only the liquid ones.

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `gal_dry` | `dry_gallon`, `dry_gallons` | US dry gallon | `bu_dry_gallon` | 268.8025 in³ = 4.40488377086×10⁻³ m³ |
| `qt_dry` | `dry_quart`, `dry_quarts` | US dry quart | `bu_dry_quart` | 1.101220942715×10⁻³ m³ |
| `pt_dry` | `dry_pint`, `dry_pints` | US dry pint | `bu_dry_pint` | 5.506104713575×10⁻⁴ m³ |
| `fbm` | `board_foot`, `board_feet` | board foot (144 in³) | `bu_board_foot` | 2.359737216×10⁻³ m³ |
| `cord` | `cord`, `cords` | cord (128 ft³) | `bu_cord` | 3.624556363776 m³ |
| `ac_ft` | `acre_foot`, `acre_feet` | acre-foot (survey) | `bu_survey_acre_foot` | 1233.4892384681489 m³ |

> `ac_ft` is built on the **survey** acre and foot, which is what UDUNITS, OM and every US water
> agency mean by an acre-foot. The international one is `ac·ft` = 1233.48183754752 m³.

#### And the singles

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `acUS` | `survey_acre`, `survey_acres` | US survey acre | `bu_survey_acre` | 62726400000/15499969 m² ≈ 4046.87261 m² |
| `darcy` | `darcy`, `darcys`, `darcies` | darcy (permeability) | `bu_darcy` | 1/1013250000000 m² ≈ 9.86923267×10⁻¹³ m² |
| `thm_ec` | `therm_EC` | EC therm (10⁵ `Btu`) | `bu_therm_ec` | 1.05505585262×10⁸ J (exact) |
| `ton_ref` | `refrigeration_ton`, `ton_of_refrigeration` | ton of refrigeration (12 000 `Btu`/h) | `bu_refrigeration_ton` | 52752792631/15000000 W ≈ 3516.85284 W |
| `DU` | `dobson`, `dobson_unit`, `dobson_units` | Dobson unit (ozone column) | `bu_dobson` | 4.462×10⁻⁴ mol·m⁻² (exact) |
| `shake` | `shake`, `shakes` | shake | `bu_shake` | 10⁻⁸ s (exact) |

> `darcy` takes prefixes, so the millidarcy every reservoir report is written in is `m~darcy`.
> `thm_ec` is the European gas-billing therm; native `thm` is the US therm, 0.024 % away.

#### Nine more the unit profiles needed

Each was refused across UCUM, UDUNITS-2, QUDT and OM for want of a native unit of the magnitude, and
every factor below is one **two or three publishers state independently**, agreeing to the last digit.

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `pnt_pr` | `printers_point` | printer's point | `bu_printers_point` | 0.0003514598 m (exact, = 0.013837 `in`) |
| `pca_pr` | `printers_pica` | printer's pica | `bu_printers_pica` | 0.0042175176 m (exact, = 12 `pnt_pr`) |
| `hp_E` | `electric_horsepower` | electric horsepower | `bu_horsepower_electric` | 746 W (exact) |
| `hp_B` | `boiler_horsepower` | boiler horsepower | `bu_horsepower_boiler` | 9809.5 W |
| `abV` | `abvolt` | abvolt (CGS-EMU) | `bu_abvolt` | 10⁻⁸ V (exact) |
| `AT` | `assay_ton` | assay ton (short) | `bu_assay_ton` | 175/6000 kg ≈ 0.029166667 kg |
| `bsh_uk` | `bushel_uk` | imperial bushel | `bu_bushel_uk` | 0.03636872 m³ (exact, = 8 `gal_uk`) |
| `clo` | — | clo | `bu_clo` | 0.155 K·m²/W (exact) |
| `debye` | — | debye | `bu_debye` | 1/299792458000000000000000000000 C·m ≈ 3.3356410×10⁻³⁰ C·m |

> Each is a **near neighbour** of a unit already here, which is why it needed a row rather than an
> alias: `pnt_pr` is 0.37 % off `pnt`, `hp_E` 0.04 % off `hp`, `bsh_uk` 3.2 % off `bsh`, and `hp_B`
> thirteen times any horsepower. `abV` needed a unit because no decade prefix reaches 10⁻⁸ and
> prefixes do not stack. `AT` and `debye` are exact rationals — 175/6 g, and 10⁻²¹/c C·m, exact
> since the 1983 definition of the metre fixed c — of which their publishers state 7- and 6-digit roundings.

#### And the thirty-five the same sweep found next

The rest of the refusals **two or more vocabularies define**. Where publishers disagree the exact
definition decides: the CGS-ESU units are built on `c`, exact since 1983, so their five- to
seven-digit decimals are roundings of the rational stated here. The π-based rows carry
`.exact = false`. `yr` is still the Julian year — naming the other calendars is what lets a UDUNITS
or CF document that says `year` be read at all.

**Calendar and sidereal time**

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `yr_trop` | `tropical_year` | tropical year | `bu_year_tropical` | 31 556 925.9747 s (the tropical year UDUNITS and CF carry) |
| `yr_greg` | `gregorian_year` | Gregorian year | `bu_year_gregorian` | 31 556 952 s (exact, = 365.2425 d) |
| `yr_sid` | `sidereal_year` | sidereal year | `bu_year_sidereal` | 31 558 149.7632 s |
| `yr_com` | `common_year` | common year | `bu_year_common` | 31 536 000 s (exact, = 365 d) |
| `mo_syn` | `synodal_month` | synodal month | `bu_month_synodal` | 2 551 442.976 s (the synodal, lunar month) |
| `mo_trop` | `tropical_month` | tropical month | `bu_month_tropical` | 2 360 584.6848 s |
| `mo_greg` | `gregorian_month` | mean Gregorian month | `bu_month_gregorian` | 2 629 746 s |
| `mo_sid` | `sidereal_month` | sidereal month | `bu_month_sidereal` | 2 360 591.5104 s |
| `d_sid` | `sidereal_day` | sidereal day | `bu_day_sidereal` | 86 164.0905 s |
| `h_sid` | `sidereal_hour` | sidereal hour | `bu_hour_sidereal` | `d_sid`/24 = 3590.1704375 s |
| `min_sid` | `sidereal_minute` | sidereal minute | `bu_minute_sidereal` | `d_sid`/1440 |
| `s_sid` | `sidereal_second` | sidereal second | `bu_second_sidereal` | `d_sid`/86400 |

**CGS electrostatic (ESU)**

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `statV` | `statvolt` | statvolt | `bu_statvolt` | 149896229/500000 V = 299.792458 V (exact, = c×10⁻⁶) |
| `statA` | `statampere` | statampere | `bu_statampere` | 1/2997924580 A (exact) |
| `statC` | `statcoulomb`, `franklin` | statcoulomb (franklin) | `bu_statcoulomb` | 1/2997924580 C (exact) |
| `statF` | `statfarad` | statfarad | `bu_statfarad` | 25000/22468879468420441 F (exact) |
| `statΩ` | `statohm` | statohm | `bu_statohm` | 22468879468420441/25000 Ω ≈ 898 755 178 737 Ω (exact) |
| `statH` | `stathenry` | stathenry | `bu_stathenry` | 22468879468420441/25000 H (exact) |
| `statS` | `statsiemens`, `statmho` | statsiemens | `bu_statsiemens` | 25000/22468879468420441 S (exact) |
| `statWb` | `statweber` | statweber | `bu_statweber` | 149896229/500000 Wb = 299.792458 Wb (exact, = statV·s) |
| `statT` | `stattesla` | stattesla | `bu_stattesla` | 149896229/50 T = 2 997 924.58 T (exact, = statV·s/cm²) |

**Luminance**

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `Lmb` | `lambert` | lambert | `bu_lambert` | 10⁴/π cd·m⁻² |
| `apostilb` | `blondel` | apostilb (blondel) | `bu_apostilb` | 1/π cd·m⁻² |
| `footlambert` | `foot_lambert` | foot-lambert | `bu_footlambert` | 1/π cd·ft⁻² |

**Water-vapour permeance**

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `perm_0C` | — | perm (0 °C) | `bu_perm_0c` | 5.72135×10⁻¹¹ kg·Pa⁻¹·s⁻¹·m⁻² (exact) |
| `perm_23C` | — | perm (23 °C) | `bu_perm_23c` | 5.74525×10⁻¹¹ kg·Pa⁻¹·s⁻¹·m⁻² (exact) |
| `perm_m` | `perm_metric` | metric perm | `bu_perm_metric` | 8.68127×10⁻¹¹ kg·Pa⁻¹·s⁻¹·m⁻² (exact) |

**Heat conventions**

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `cal_m` | `mean_calorie` | mean calorie | `bu_calorie_mean` | 4.19002 J (exact) |
| `cal_15` | `calorie_15C` | 15 °C calorie | `bu_calorie_15c` | 4.1858 J (exact) |
| `cal_20` | `calorie_20C` | 20 °C calorie | `bu_calorie_20c` | 4.1819 J (exact) |
| `Btu_59` | `btu_59F` | 59 °F BTU | `bu_btu_59f` | 1054.804 J |
| `Btu_60` | `btu_60F` | 60 °F BTU | `bu_btu_60f` | 1054.68 J (exact) |
| `Btu_m` | `mean_btu` | mean BTU | `bu_btu_mean` | 1055.87 J (exact) |

**And the singles**

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `e` | `elementary_charge` | elementary charge | `bu_elementary_charge` | 1.602176634×10⁻¹⁹ C (exact, the 2019 SI definition) |
| `sph` | `spere`, `spat` | spere (spat) | `bu_spere` | 4π sr |
| `cml` | `circular_mil` | circular mil | `bu_circular_mil` | π/4 `thou`² ≈ 5.0670748×10⁻¹⁰ m² |
| `unit_pole` | `unitpole` | unit pole | `bu_unit_pole` | 4π×10⁻⁸ Wb |
| `hp_W` | `water_horsepower` | water horsepower | `bu_horsepower_water` | 746.043 W |

---

## 5. Currencies

### 5.1 The Mandatory Currency Sigil

As of spec 1.0 a currency code carries a **mandatory `$` sigil**: write `$USD`,
`$BTC`, `k~$EUR` (or compactly `k$EUR`), `$USD/oz_t`. The codes listed in the tables below are the bare
ISO 4217 / crypto identifiers — prefix each with `$` when you use it in a document.
A bare code (no `$`) is **not** a currency; it is matched against the physical-unit
table and raises `error_unit_illegal` if it is not a unit. This removes every
currency/unit namespace collision (e.g. `$CUP` the Cuban Peso vs `cup` the unit).

### 5.2 ISO 4217 Fiat Currencies

166 codes at ids **900000 … 900165**, the front of block 90 of the `value_base_unit_t` id space (`BVN_CURRENCY_FIRST` …). They have no named `bu_*` enumerators — they are resolved from the `$`-sigil code by `bvn_parse_currency_str` and carried as the numeric `base` value; query them with `bvn_unit_is_fiat` / `bvn_currency_info`, both of which read the catalogue row rather than testing the id's range.

> **Min** = minor unit exponent N: 1 major unit = 10^N minor units (e.g. 1 USD = 100 cents, N=2).
> Minor units are **bold** when they differ from 2. `numeric_code` is the ISO 4217 numeric identifier.

| Code | Num | Min | Name |
|------|----:|----:|------|
| `AED` |  784 |   2 | UAE Dirham |
| `AFN` |  971 |   2 | Afghan Afghani |
| `ALL` |    8 |   2 | Albanian Lek |
| `AMD` |   51 |   2 | Armenian Dram |
| `ANG` |  532 |   2 | Netherlands Antillean Guilder |
| `AOA` |  973 |   2 | Angolan Kwanza |
| `ARS` |   32 |   2 | Argentine Peso |
| `AUD` |   36 |   2 | Australian Dollar |
| `AWG` |  533 |   2 | Aruban Florin |
| `AZN` |  944 |   2 | Azerbaijani Manat |
| `BAM` |  977 |   2 | Bosnia-Herzegovina Convertible Mark |
| `BBD` |   52 |   2 | Barbados Dollar |
| `BDT` |   50 |   2 | Bangladeshi Taka |
| `BGN` |  975 |   2 | Bulgarian Lev *(historical; retired 2026-01-01)* |
| `BHD` |   48 | **3** | Bahraini Dinar |
| `BIF` |  108 | **0** | Burundian Franc |
| `BMD` |   60 |   2 | Bermudian Dollar |
| `BND` |   96 |   2 | Brunei Dollar |
| `BOB` |   68 |   2 | Boliviano |
| `BRL` |  986 |   2 | Brazilian Real |
| `BSD` |   44 |   2 | Bahamian Dollar |
| `BTN` |   64 |   2 | Bhutanese Ngultrum |
| `BWP` |   72 |   2 | Botswana Pula |
| `BYN` |  933 |   2 | Belarusian Ruble |
| `BZD` |   84 |   2 | Belize Dollar |
| `CAD` |  124 |   2 | Canadian Dollar |
| `CDF` |  976 |   2 | Congolese Franc |
| `CHF` |  756 |   2 | Swiss Franc |
| `CLF` |  990 | **4** | Unidad de Fomento |
| `CLP` |  152 | **0** | Chilean Peso |
| `CNY` |  156 |   2 | Chinese Yuan |
| `COP` |  170 |   2 | Colombian Peso |
| `CRC` |  188 |   2 | Costa Rican Colon |
| `CUP` |  192 |   2 | Cuban Peso |
| `CVE` |  132 |   2 | Cape Verdean Escudo |
| `CZK` |  203 |   2 | Czech Koruna |
| `DJF` |  262 | **0** | Djiboutian Franc |
| `DKK` |  208 |   2 | Danish Krone |
| `DOP` |  214 |   2 | Dominican Peso |
| `DZD` |   12 |   2 | Algerian Dinar |
| `EGP` |  818 |   2 | Egyptian Pound |
| `ERN` |  232 |   2 | Eritrean Nakfa |
| `ETB` |  230 |   2 | Ethiopian Birr |
| `EUR` |  978 |   2 | Euro |
| `FJD` |  242 |   2 | Fijian Dollar |
| `FKP` |  238 |   2 | Falkland Islands Pound |
| `GBP` |  826 |   2 | Pound Sterling |
| `GEL` |  981 |   2 | Georgian Lari |
| `GHS` |  936 |   2 | Ghanaian Cedi |
| `GIP` |  292 |   2 | Gibraltar Pound |
| `GMD` |  270 |   2 | Gambian Dalasi |
| `GNF` |  324 | **0** | Guinean Franc |
| `GTQ` |  320 |   2 | Guatemalan Quetzal |
| `GYD` |  328 |   2 | Guyanese Dollar |
| `HKD` |  344 |   2 | Hong Kong Dollar |
| `HNL` |  340 |   2 | Honduran Lempira |
| `HRK` |  191 |   2 | Croatian Kuna *(historical; retired 2023-01-01)* |
| `HTG` |  332 |   2 | Haitian Gourde |
| `HUF` |  348 |   2 | Hungarian Forint |
| `IDR` |  360 |   2 | Indonesian Rupiah |
| `ILS` |  376 |   2 | Israeli New Shekel |
| `INR` |  356 |   2 | Indian Rupee |
| `IQD` |  368 | **3** | Iraqi Dinar |
| `IRR` |  364 |   2 | Iranian Rial |
| `ISK` |  352 | **0** | Icelandic Krona |
| `JMD` |  388 |   2 | Jamaican Dollar |
| `JOD` |  400 | **3** | Jordanian Dinar |
| `JPY` |  392 | **0** | Japanese Yen |
| `KES` |  404 |   2 | Kenyan Shilling |
| `KGS` |  417 |   2 | Kyrgyzstani Som |
| `KHR` |  116 |   2 | Cambodian Riel |
| `KMF` |  174 | **0** | Comorian Franc |
| `KPW` |  408 |   2 | North Korean Won |
| `KRW` |  410 | **0** | South Korean Won |
| `KWD` |  414 | **3** | Kuwaiti Dinar |
| `KYD` |  136 |   2 | Cayman Islands Dollar |
| `KZT` |  398 |   2 | Kazakhstani Tenge |
| `LAK` |  418 |   2 | Laotian Kip |
| `LBP` |  422 |   2 | Lebanese Pound |
| `LKR` |  144 |   2 | Sri Lankan Rupee |
| `LRD` |  430 |   2 | Liberian Dollar |
| `LSL` |  426 |   2 | Lesotho Loti |
| `LYD` |  434 | **3** | Libyan Dinar |
| `MAD` |  504 |   2 | Moroccan Dirham |
| `MDL` |  498 |   2 | Moldovan Leu |
| `MGA` |  969 |   2 | Malagasy Ariary |
| `MKD` |  807 |   2 | Macedonian Denar |
| `MMK` |  104 |   2 | Myanmar Kyat |
| `MNT` |  496 |   2 | Mongolian Togrog |
| `MOP` |  446 |   2 | Macanese Pataca |
| `MRU` |  929 |   2 | Mauritanian Ouguiya |
| `MUR` |  480 |   2 | Mauritian Rupee |
| `MVR` |  462 |   2 | Maldivian Rufiyaa |
| `MWK` |  454 |   2 | Malawian Kwacha |
| `MXN` |  484 |   2 | Mexican Peso |
| `MYR` |  458 |   2 | Malaysian Ringgit |
| `MZN` |  943 |   2 | Mozambican Metical |
| `NAD` |  516 |   2 | Namibian Dollar |
| `NGN` |  566 |   2 | Nigerian Naira |
| `NIO` |  558 |   2 | Nicaraguan Cordoba |
| `NOK` |  578 |   2 | Norwegian Krone |
| `NPR` |  524 |   2 | Nepalese Rupee |
| `NZD` |  554 |   2 | New Zealand Dollar |
| `OMR` |  512 | **3** | Omani Rial |
| `PAB` |  590 |   2 | Panamanian Balboa |
| `PEN` |  604 |   2 | Peruvian Sol |
| `PGK` |  598 |   2 | Papua New Guinean Kina |
| `PHP` |  608 |   2 | Philippine Peso |
| `PKR` |  586 |   2 | Pakistani Rupee |
| `PLN` |  985 |   2 | Polish Zloty |
| `PYG` |  600 | **0** | Paraguayan Guarani |
| `QAR` |  634 |   2 | Qatari Riyal |
| `RON` |  946 |   2 | Romanian Leu |
| `RSD` |  941 |   2 | Serbian Dinar |
| `RUB` |  643 |   2 | Russian Ruble |
| `RWF` |  646 | **0** | Rwandan Franc |
| `SAR` |  682 |   2 | Saudi Riyal |
| `SBD` |   90 |   2 | Solomon Islands Dollar |
| `SCR` |  690 |   2 | Seychellois Rupee |
| `SDG` |  938 |   2 | Sudanese Pound |
| `SEK` |  752 |   2 | Swedish Krona |
| `SGD` |  702 |   2 | Singapore Dollar |
| `SHP` |  654 |   2 | Saint Helena Pound |
| `SLE` |  925 |   2 | Sierra Leonean Leone |
| `SLL` |  694 |   2 | Sierra Leonean Leone (old) *(historical; replaced by SLE 2022)* |
| `SOS` |  706 |   2 | Somali Shilling |
| `SSP` |  728 |   2 | South Sudanese Pound |
| `SRD` |  968 |   2 | Surinamese Dollar |
| `STN` |  930 |   2 | Sao Tome and Principe Dobra |
| `SVC` |  222 |   2 | Salvadoran Colon |
| `SYP` |  760 |   2 | Syrian Pound |
| `SZL` |  748 |   2 | Swazi Lilangeni |
| `THB` |  764 |   2 | Thai Baht |
| `TJS` |  972 |   2 | Tajikistani Somoni |
| `TMT` |  934 |   2 | Turkmenistan Manat |
| `TND` |  788 | **3** | Tunisian Dinar |
| `TOP` |  776 |   2 | Tongan Pa'anga |
| `TRY` |  949 |   2 | Turkish Lira |
| `TTD` |  780 |   2 | Trinidad and Tobago Dollar |
| `TWD` |  901 |   2 | New Taiwan Dollar |
| `TZS` |  834 |   2 | Tanzanian Shilling |
| `UAH` |  980 |   2 | Ukrainian Hryvnia |
| `UGX` |  800 | **0** | Ugandan Shilling |
| `USD` |  840 |   2 | US Dollar |
| `UYU` |  858 |   2 | Uruguayan Peso |
| `UZS` |  860 |   2 | Uzbekistani Som |
| `VES` |  928 |   2 | Venezuelan Bolivar Soberano |
| `VND` |  704 | **0** | Vietnamese Dong |
| `VUV` |  548 | **0** | Vanuatu Vatu |
| `WST` |  882 |   2 | Samoan Tala |
| `XAF` |  950 | **0** | CFA Franc BEAC |
| `XAG` |  961 | **0** | Silver |
| `XAU` |  959 | **0** | Gold |
| `XCD` |  951 |   2 | East Caribbean Dollar |
| `XCG` |  532 |   2 | Caribbean Guilder |
| `XDR` |  960 | **0** | Special Drawing Rights |
| `XOF` |  952 | **0** | CFA Franc BCEAO |
| `XPD` |  964 | **0** | Palladium |
| `XPF` |  953 | **0** | CFP Franc |
| `XPT` |  962 | **0** | Platinum |
| `XTS` |  963 | **0** | Test (ISO 4217 reserved) |
| `YER` |  886 |   2 | Yemeni Rial |
| `ZAR` |  710 |   2 | South African Rand |
| `ZMW` |  967 |   2 | Zambian Kwacha |
| `ZWG` |  924 |   2 | Zimbabwe Gold |
| `ZWL` |  932 |   2 | Zimbabwean Dollar *(historical; superseded by ZWG 2024)* |

> `CLF` is the only currency with 4 minor units.
> `XTS` is the ISO 4217 testing code; present in the table but should not appear in production data.

### 5.3 Cryptocurrencies

50 codes at ids **900166 … 900215**, after the fiat codes in the same block 90. Like them they have no named `bu_*` enumerators — resolved by `bvn_parse_currency_str`, queried with `bvn_unit_is_crypto` / `bvn_currency_info`. `numeric_code = 0` for all.

> **Min** = `minor_unit` = on-chain decimal places. E.g. `<uint:64,$BTC>` stores satoshis; divide by 10⁸ to obtain BTC.

| Code   | Min | Subunit | Name |
|--------|----:|---------|------|
| `BTC`  |   8 | satoshi | Bitcoin |
| `ETH`  |  18 | wei | Ethereum |
| `SOL`  |   9 | lamport | Solana |
| `XRP`  |   6 | drop | XRP |
| `BNB`  |  18 | — | BNB |
| `ADA`  |   6 | lovelace | Cardano |
| `LTC`  |   8 | — | Litecoin |
| `DOT`  |  10 | planck | Polkadot |
| `XMR`  |  12 | piconero | Monero |
| `ETC`  |  18 | — | Ethereum Classic |
| `BCH`  |   8 | — | Bitcoin Cash |
| `XLM`  |   7 | stroop | Stellar |
| `FIL`  |  18 | — | Filecoin |
| `ICP`  |   8 | — | Internet Computer |
| `TRX`  |   6 | — | TRON |
| `EOS`  |   4 | — | EOS |
| `VET`  |  18 | — | VeChain |
| `NEO`  |   0 | — | Neo |
| `ZEC`  |   8 | — | Zcash |
| `UNI`  |  18 | — | Uniswap |
| `ARB`  |  18 | — | Arbitrum |
| `SUI`  |   9 | — | Sui |
| `TON`  |   9 | — | Toncoin |
| `INJ`  |  18 | — | Injective |
| `SEI`  |   6 | — | Sei |
| `APT`  |   8 | — | Aptos |
| `TAO`  |   9 | — | Bittensor |
| `WIF`  |   6 | — | dogwifhat |
| `DOGE` |   8 | koinu | Dogecoin |
| `LINK` |  18 | — | Chainlink |
| `USDT` |   6 | — | Tether |
| `USDC` |   6 | — | USD Coin |
| `AVAX` |  18 | — | Avalanche |
| `ATOM` |   6 | — | Cosmos |
| `POL`  |  18 | — | Polygon |
| `NEAR` |  24 | — | NEAR Protocol |
| `ALGO` |   6 | — | Algorand |
| `HBAR` |   8 | — | Hedera |
| `AAVE` |  18 | — | Aave |
| `MKR`  |  18 | — | Maker |
| `DAI`  |  18 | — | Dai |
| `STX`  |   6 | — | Stacks |
| `GRT`  |  18 | — | The Graph |
| `LDO`  |  18 | — | Lido DAO |
| `BONK` |   5 | — | Bonk |
| `PEPE` |  18 | — | Pepe |
| `SHIB` |  18 | — | Shiba Inu |
| `JUP`  |   6 | — | Jupiter |
| `PYTH` |   6 | — | Pyth Network |
| `RUNE` |   8 | — | THORChain |

### 5.4 Currency Prefix Rules

All 24 SI prefixes are allowed on all currency units. IEC binary prefixes are forbidden on all currencies (`error_unit_illegal`). The `~` is optional — the `$` sigil already separates the prefix from the code — but the sigil is not: `kUSD` is `error_unit_illegal`.

| Example | Meaning |
|---------|---------|
| `k~$USD` (or `k$USD`) | thousands of USD (×10³) |
| `M~$EUR` (or `M$EUR`) | millions of EUR (×10⁶) |
| `G~$ETH` (or `G$ETH`) | giga-ETH = Gwei scale (×10⁹) |
| `m~$USD` (or `m$USD`) | milli-USD = one tenth of a cent (×10⁻³) |

---

## 6. Symbol Disambiguation

> The full token-by-token treatment — case traps, look-alike code points, same-dimension quantities, and
> abbreviations that are deliberately not units — is in [`07_bovnar_unit_ambiguities.md`](07_bovnar_unit_ambiguities.md).

As of spec 1.0 a currency is written **only** with the `$` sigil (§5.1), so the bare form is always a physical-unit lookup and the namespaces never collide:

| Token | Bare form (no `$`) | `$`-sigil form |
|-------|--------------------|----------------|
| `cup` / `CUP` | `cup` → US cup (`bu_cup`); `CUP` → `error_unit_illegal` | `$CUP` → Cuban Peso (ISO 4217:192) |
| `BTU`  | `BTU` → BTU (`bu_btu`); `Btu`, `btu` also accepted | *(not ISO 4217)* |
| `SOL`  | `SOL` → `error_unit_illegal` | `$SOL` → Solana (crypto) |
| `BAR`  | `BAR` → `error_unit_illegal`; use lowercase `bar` | *(not ISO 4217)* |
| `ERG`  | `ERG` → `error_unit_illegal`; use lowercase `erg` | *(not ISO 4217)* |
| `CAD`, `XAU` | `error_unit_illegal` (no physical unit) | `$CAD` → Canadian Dollar; `$XAU` → Gold (X-code) |

No bare token is simultaneously a valid physical unit and a currency: currencies live entirely under `$`, physical units entirely without it.

---

## See also

- [Unit & Currency Reference](05_bovnar_unit_system.md) — the full registry: dimensions, factors, prefix policy, and the C API
- [Unit Ambiguities](07_bovnar_unit_ambiguities.md) — which reading wins when a token could mean two things
- [Specification §11 — Units System](03_bovnar_spec.md#11-units-system) — how a unit attaches to a value
- [FAQ §4 — Units](02_bovnar_faq.md#4-units) — the questions these tables raise most often
- [Unit Profiles](11_bovnar_unit_profiles.md) — writing these same units as UCUM, UN/ECE, QUDT, UDUNITS, OM 2 or CF codes, and the profile-only units that exist in no native spelling

---

*The id space is blocked: the leading two digits of an id name its vocabulary. Native units 100000–100263 (264) · UCUM opaque units 200000–200039 (40) · UN/ECE opaque units 300000–300024 (25) · currencies 900000–900215 (166 fiat, 50 crypto). Blocks 40, 50, 60, 70 and 80 are reserved for QUDT, QUDT quantity kinds, UDUNITS, OM 2 and the CF standard names, which contribute no opaque units today.*
*The space is SPARSE — do not index an array by an id. `BVN_UNIT_SLOT_COUNT` = 330 is the row count of the library's dense tables, indexed by `bvni_unit_slot()`, and is not a bound on the enum.*

---

*End of Bovnar — Units & Currencies Cheat Sheet (Bovnar spec 1.2).*

<!-- ==================== Unit system (https://www.bovnar.io/doc/05_bovnar_unit_system.md) ==================== -->

# Bovnar — Unit & Currency Reference

> **Spec version:** 1.2
> **Status:** Normative — the unit and currency registry the parser validates against
> **Scope:** Physical units, currency codes, prefix rules, disambiguation, C/Python APIs, and validation.

---

## Table of Contents

1. [Overview](#1-overview)
    - 1.1 [Design Principles](#11-design-principles)
2. [Syntax — Annotation as a Type Parameter](#2-syntax--annotation-as-a-type-parameter)
    - 2.1 [Parameter Ordering Flexibility](#21-parameter-ordering-flexibility)
    - 2.2 [Inline Unit Suffix](#22-inline-unit-suffix)
    - 2.3 [Applicable Type Families](#23-applicable-type-families)
3. [Physical Base Units](#3-physical-base-units)
    - 3.1 [SI Base Units](#31-si-base-units)
    - 3.2 [Named SI-Derived Units](#32-named-si-derived-units)
    - 3.3 [Non-SI Units Accepted for Use with SI](#33-non-si-units-accepted-for-use-with-si)
    - 3.4 [Imperial and US Customary Units](#34-imperial-and-us-customary-units)
    - 3.5 [Pressure Units](#35-pressure-units)
    - 3.6 [Energy Units](#36-energy-units)
    - 3.7 [Power Units](#37-power-units)
    - 3.8 [Force Units](#38-force-units)
    - 3.9 [Speed and Rotational Frequency Units](#39-speed-and-rotational-frequency-units)
    - 3.10 [Volume Units](#310-volume-units)
    - 3.11 [Area Units](#311-area-units)
    - 3.12 [Angle Units](#312-angle-units)
    - 3.13 [CGS Units](#313-cgs-units)
    - 3.14 [Radiation Units](#314-radiation-units)
    - 3.15 [Logarithmic Units](#315-logarithmic-units)
    - 3.16 [Electrical Power Units](#316-electrical-power-units)
    - 3.17 [Digital Units](#317-digital-units)
    - 3.18 [Textile Linear Density](#318-textile-linear-density)
    - 3.19 [US Apothecary / Dry Volume](#319-us-apothecary--dry-volume)
    - 3.20 [Old German Units](#320-old-german-units)
    - 3.21 [Additional Length Units](#321-additional-length-units)
    - 3.22 [Additional Mass Units](#322-additional-mass-units)
    - 3.23 [Acceleration](#323-acceleration)
    - 3.24 [Signal Rate](#324-signal-rate)
    - 3.25 [Ratio and Proportion Units](#325-ratio-and-proportion-units)
    - 3.26 [Named Speed Units](#326-named-speed-units)
    - 3.27 [Acidity](#327-acidity)
    - 3.28 [Water Hardness](#328-water-hardness)
    - 3.29 [Conductivity and Dissolved Solids](#329-conductivity-and-dissolved-solids)
    - 3.30 [Turbidity and Salinity](#330-turbidity-and-salinity)
    - 3.31 [Sentinel Value](#331-sentinel-value)
    - 3.32 [Units the Unit Profiles Needed](#332-units-the-unit-profiles-needed)
4. [Prefixes](#4-prefixes)
    - 4.1 [SI Prefixes](#41-si-prefixes)
    - 4.2 [IEC Binary Prefixes](#42-iec-binary-prefixes)
    - 4.3 [Compact Prefix Form](#43-compact-prefix-form)
5. [Unit Notation Grammar](#5-unit-notation-grammar)
    - 5.1 [Simple Units](#51-simple-units)
    - 5.2 [Compound Units](#52-compound-units)
    - 5.3 [Separators](#53-separators)
    - 5.4 [Denominator Semantics](#54-denominator-semantics)
6. [Exponents](#6-exponents)
    - 6.1 [Unicode Superscript Form](#61-unicode-superscript-form)
    - 6.2 [ASCII Caret Form](#62-ascii-caret-form)
    - 6.3 [Exponent Edge Cases](#63-exponent-edge-cases)
7. [The `no_unit` Keyword](#7-the-no_unit-keyword)
8. [Constraints and Limits](#8-constraints-and-limits)
9. [Currency Codes](#9-currency-codes)
    - 9.1 [The `$` Sigil Rule](#91-the--sigil-rule)
    - 9.2 [ISO 4217 Fiat Currencies and Precious Metals](#92-iso-4217-fiat-currencies-and-precious-metals)
    - 9.3 [Cryptocurrencies](#93-cryptocurrencies)
    - 9.4 [Prefix Rules for Currency Units](#94-prefix-rules-for-currency-units)
    - 9.5 [Compound Currency Expressions](#95-compound-currency-expressions)
    - 9.6 [Compatibility Rules](#96-compatibility-rules)
    - 9.7 [Type Pairing Recommendations](#97-type-pairing-recommendations)
10. [Symbol Disambiguation](#10-symbol-disambiguation)
    - 10.1 [The Namespace Rule as Disambiguator](#101-the-namespace-rule-as-disambiguator)
    - 10.2 [Exhaustive Conflict Table](#102-exhaustive-conflict-table)
    - 10.3 [The CUP Case in Detail](#103-the-cup-case-in-detail)
    - 10.4 [The Mandatory Currency Sigil](#104-the-mandatory-currency-sigil)
11. [C Data Model](#11-c-data-model)
    - 11.1 [Enumerations](#111-enumerations)
    - 11.2 [Structures](#112-structures)
    - 11.3 [Convenience Macros](#113-convenience-macros)
12. [C API Functions](#12-c-api-functions)
    - 12.1 [Parsing a Unit String](#121-parsing-a-unit-string)
    - 12.2 [Serializing a Unit](#122-serializing-a-unit)
    - 12.3 [Prefix Factor and Exponent Queries](#123-prefix-factor-and-exponent-queries)
    - 12.4 [SI Conversion API](#124-si-conversion-api)
    - 12.5 [Currency API](#125-currency-api)
    - 12.6 [Unit Profile API (spec 1.2)](#126-unit-profile-api-spec-12)
    - 12.7 [Python API](#127-python-api)
13. [Integration with the Parser Event Stream](#13-integration-with-the-parser-event-stream)
    - 13.1 [Full event sequence — physical unit](#131-full-event-sequence--physical-unit)
    - 13.2 [Full event sequence — currency unit](#132-full-event-sequence--currency-unit)
    - 13.3 [Inline unit suffix — event stream view](#133-inline-unit-suffix--event-stream-view)
    - 13.4 [Practical callback](#134-practical-callback)
14. [Validation Errors](#14-validation-errors)
15. [Annotated Examples](#15-annotated-examples)
    - 15.1 [Physical Quantities](#151-physical-quantities)
    - 15.2 [Digital Storage](#152-digital-storage)
    - 15.3 [Compound SI Quantities](#153-compound-si-quantities)
    - 15.4 [Currency Amounts and Rates](#154-currency-amounts-and-rates)
    - 15.5 [Error Cases](#155-error-cases)
- [See also](#see-also)

---

## 1. Overview

The Bovnar quantity annotation system is an **optional, per-value annotation** that attaches a physical unit or currency denomination to any numeric field. It is part of the type annotation (`<family:width,_base,unit>`) and applies to the `uint`, `sint`, `float`, `float_fix`, and `float_dec` type families.

Two distinct namespaces share the annotation slot:

- **Physical units** — 264 named base units covering SI, Imperial, CGS, radiation, surveying, culinary, Old German, and digital storage quantities.
- **Currency codes** — 216 monetary denominations: 166 ISO 4217 alphabetic codes (including precious-metal X-codes; 4 are historical — HRK retired 2023-01-01, SLL replaced by SLE 2022, ZWL superseded by ZWG 2024, BGN retired 2026-01-01 — and `ANG` coexists with its successor `XCG`, which inherited its numeric code 532; see §9.2) and 50 cryptocurrency tickers.

Both namespaces are syntactically unified: the same grammar, the same `~` prefix separator, the same compound-unit operators (`·`, `*`, `/`), and the same `value_unit_t` data model apply to both. They are separated purely by a token-classification rule described in §9.1 and §10.

Annotations are **descriptive**, not prescriptive: the *validator* checks form and type, and never rejects a document because its units do not add up. The library does provide dimensional analysis and unit conversion as an explicit, opt-in service — `bvn_units_compatible`, `bvn_unit_convert_factor`, `bvn_unit_convert_value` and the exact-rational `bvn_unit_convert_rational` (§12.4), plus the reader's `want_unit` hook, which converts at read time (§1.10 of the read/write API). None of that runs unless you ask for it. Exchange-rate arithmetic is the one thing the library genuinely does not do: currencies carry no conversion table, and a cross-currency conversion is always refused rather than guessed (§9.6).

### 1.1 Design Principles

- **SI-first.** All SI base units, all 22 BIPM-2019 named derived units, and all 24 current SI prefixes (quecto … quetta) are supported.
- **Binary-prefix aware.** IEC 80000-13 binary prefixes (kibi … yobi) are supported for digital storage quantities, plus `Ri`/`Qi` (robi, quebi) as a forward-looking extension — those two are a proposal, not part of IEC 80000-13, which stops at yobi.
- **Compound units.** Derived quantities (m/s, kg·m/s², USD/oz_t) are expressed inline without separate schema definitions.
- **Two exponent notations.** Unicode superscript (`m²`, `s⁻²`) and ASCII caret (`m^2`, `s^-2`) are accepted equivalently.
- **Currency as a first-class unit.** ISO 4217 and cryptocurrency codes participate in all unit composition rules — prefixes, compound expressions, and the `value_unit_t` representation — with no special-case parsing.
- **Dimensionless values.** The keyword `no_unit` is the canonical representation of a dimensionless quantity.

---

## 2. Syntax — Annotation as a Type Parameter

The unit or currency code occupies the **third positional parameter class** of a type annotation, after the optional bit-width and optional base. Parameter classes are identified by their content, not by position:

```
type-spec       = param-type [ ":" type-param-list ]
type-param-list = type-param { "," type-param }
type-param      = width-param   (* plain decimal integer, e.g. 32    *)
                | base-param    (* "_" + decimal integer,  e.g. _16  *)
                | unit-param    (* everything else,        e.g. m/s  *)
```

> **A unit may also be written in a foreign notation.** A `name:` namespace hands
> the parameter to a **unit profile**, which translates it into the same
> `value_unit_t` this document describes — so `<float_dec:64,ucum:mm[Hg]>` and
> `<float_dec:64,mmHg>` are the same unit to every part of the library. Seven
> namespaces are defined — `ucum`, `unece`, `qudt`, `qudt-qk`, `udunits`, `om`
> and `cf`; everything in this reference applies to the
> result unchanged. **The notation is spec 1.2**, so a document must opt in with
> a `#!bovnar 1.2` directive. A native unit is unaffected in every version. See
> [Unit Profiles](11_bovnar_unit_profiles.md).

### 2.1 Parameter Ordering Flexibility

```bovnar
.val = <uint:32,_10,no_unit> 42;
#      <uint:_10,no_unit,32>        — identical (parameter order is free)
#      <uint:no_unit,_10,32>        — identical
```

### 2.2 Inline Unit Suffix

A unit may be written directly after a scalar value literal, between the value and the terminating `;`:

```bovnar
.distance  = 1500 m;            # inline physical unit
.speed     = 9.81 m/s;          # compound inline unit
.price     = 19.99 $USD;        # inline currency (mandatory $ sigil)
.gold_rate = 2351.40 $USD/oz_t; # inline compound currency/unit
.ratio     = 3.14 no_unit;      # explicit dimensionless
```

The inline unit uses the **same character set** and **same semantic parser** (`bvn_parse_unit`) as the type-annotation unit parameter. It is terminated by ASCII whitespace, `#` (comment), or `;`.

**A native unit contains no whitespace, in either position.** Inline, whitespace ends the token, so `1.0 k g` is a value with a stray token after it (`error_unexpected_input_byte`). In a type annotation, whitespace is legal only beside a separator — the family `:`, a `,` between parameters, the closing `>` — and inside a native unit parameter it is `error_type_param_whitespace` (spec [§5.3](03_bovnar_spec.md#53-parameter-order)). Whitespace there used to be deleted, so `<float:64,m in>` was accepted as `min` — the minute — which made it the one place in the format where a wrong unit was produced silently instead of refused.

A parameter carrying a **profile namespace** is the exception: there whitespace is kept verbatim and the vocabulary decides what it means, because UDUNITS multiplies with a space and `udunits:kg m-2 s-1` is the commonest spelling of a flux in CF metadata. That is a property of the foreign notation, not of the native one — see doc/11 §13.2.

#### Constraints

| Situation | Result |
|-----------|--------|
| No annotation unit; inline unit present | Inline unit becomes the effective unit |
| Annotation has no unit; inline unit present | Inline unit becomes the effective unit |
| Annotation unit present; no inline unit | Annotation unit is the effective unit |
| Annotation unit **equals** inline unit | Valid; the common unit is used |
| Annotation unit **differs** from inline unit | `error_unit_mismatch` |
| Inline unit inside an array element | `error_unexpected_input_byte` |

When both are present, equality is checked after parsing via `bvn_unit_equal`, a structural comparison of the parsed `value_unit_t` values: the two units must have the same number of 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 reordered spellings such as `N·m` and `m·N` (or `m·s⁻¹` and `m/s`) compare as equal. (It is *not* a raw `memcmp`, which would wrongly reject reordered components.)

<!-- bovnar-example: rejected -->
```bovnar
.v = <float:64,m/s> 9.81 m·s⁻¹;   # OK: both parse to m/s
.v = <float:64,m> 1.0 s;           # ERROR: error_unit_mismatch
```

### 2.3 Applicable Type Families

| Type family | Unit / currency parameter |
|-------------|--------------------------|
| `uint`      | Supported |
| `sint`      | Supported |
| `float`     | Supported (binary floating-point; discouraged for monetary amounts — see §9.7) |
| `float_fix` | Supported (wrong for monetary values — see §9.7) |
| `float_dec` | Supported; **recommended** for monetary amounts |
| `utf8`      | Parameterless: a unit (or any other parameter) is `error_illegal_value_type` |

---

## 3. Physical Base Units

Bovnar supports 264 physical base units. Currency codes are a separate namespace and are covered in §9.

> **Reading this section:** The *Symbol* column gives the canonical serialized form. *Long forms* are accepted on input but never produced on output. *Enum value* is the `value_base_unit_t` constant used in the C API.

### 3.1 SI Base Units

| Symbol | Long forms | Name | Enum value | Notes |
|--------|-----------|------|------------|-------|
| `s`    | `sec`, `second`, `seconds` | second | `bu_second` | SI base unit of time |
| `m`    | `meter`, `metre`, `meters`, `metres` | meter | `bu_meter` | SI base unit of length |
| `g`    | `gram`, `grams` | gram | `bu_gram` | SI base unit of mass is kg; `g` carries the prefix |
| `A`    | `amp`, `amps`, `ampere`, `amperes` | ampere | `bu_ampere` | SI base unit of electric current |
| `K`    | `kelvin`, `kelvins` | kelvin | `bu_kelvin` | SI base unit of thermodynamic temperature |
| `mol`  | `mole`, `moles` | mole | `bu_mol` | SI base unit of amount of substance |
| `cd`   | `candela`, `candelas` | candela | `bu_candela` | SI base unit of luminous intensity |

> **Note on the kilogram:** Bovnar uses `g` (gram) as the base unit symbol so that the `k~` (kilo) prefix can be attached explicitly: `k~g` = kilogram. This is consistent with how the SI formally defines the kilogram as a prefixed gram.

### 3.2 Named SI-Derived Units

| Symbol | Long forms | Name | Enum value | SI Definition |
|--------|-----------|------|------------|---------------|
| `Hz`   | `hertz` | hertz | `bu_hertz` | s⁻¹ |
| `N`    | `newton`, `newtons` | newton | `bu_newton` | kg·m·s⁻² |
| `Pa`   | `pascal`, `pascals` | pascal | `bu_pascal` | kg·m⁻¹·s⁻² |
| `J`    | `joule`, `joules` | joule | `bu_joule` | kg·m²·s⁻² |
| `W`    | `watt`, `watts` | watt | `bu_watt` | kg·m²·s⁻³ |
| `V`    | `volt`, `volts` | volt | `bu_volt` | kg·m²·A⁻¹·s⁻³ |
| `Ω`    | `ohm`, `ohms`, `Ohm` | ohm | `bu_ohm` | kg·m²·A⁻²·s⁻³ — U+2126 OHM SIGN, UTF-8: `0xE2 0x84 0xA6`; U+03A9 (Greek capital omega) also accepted on input, canonical output is always U+2126 |
| `F`    | `farad`, `farads` | farad | `bu_farad` | kg⁻¹·m⁻²·A²·s⁴ |
| `C`    | `coulomb`, `coulombs` | coulomb | `bu_coulomb` | A·s |
| `S`    | `siemens`, `mho`, `mhos`, `℧` | siemens | `bu_siemens` | kg⁻¹·m⁻²·A²·s³ |
| `Wb`   | `weber`, `webers` | weber | `bu_weber` | kg·m²·A⁻¹·s⁻² |
| `T`    | `tesla`, `teslas` | tesla | `bu_tesla` | kg·A⁻¹·s⁻² |
| `H`    | `henry`, `henrys`, `henries` | henry | `bu_henry` | kg·m²·A⁻²·s⁻² |
| `°C`   | `degC`, `degrC`, `degreeC`, `degreesC`, `celsius` | degree Celsius | `bu_celsius` | K = °C + 273.15 (affine); BIPM Table 4 entry 14 |
| `lm`   | `lumen`, `lumens` | lumen | `bu_lumen` | cd·sr |
| `lx`   | `lux` | lux | `bu_lux` | cd·sr·m⁻² |
| `Bq`   | `becquerel`, `becquerels` | becquerel | `bu_becquerel` | s⁻¹ |
| `Gy`   | `gray`, `grays` | gray | `bu_gray` | m²·s⁻² |
| `Sv`   | `sievert`, `sieverts` | sievert | `bu_sievert` | m²·s⁻² |
| `kat`  | `katal`, `katals` | katal | `bu_katal` | mol·s⁻¹ |
| `rad`  | `radian`, `radians` | radian | `bu_radian` | dimensionless (plane angle; m/m) |
| `sr`   | `steradian`, `steradians` | steradian | `bu_steradian` | dimensionless (solid angle; m²/m²) |

> **Photometry: the steradian is carried, not dropped.** `lm`, `lx` and `ph` are
> defined *through* the steradian, so they carry its quantity kind (§3.12 and
> §11 of [Unit Ambiguities](07_bovnar_unit_ambiguities.md)); `cd` and `sb` do not.
> The SI dimension vector cannot tell them apart — every photometric unit reduces
> to candela in base dimensions — so without the kind the library both refused
> `lm ↔ cd·sr` and converted `lm ↔ cd` at factor 1, which is the same claim with
> the `sr` silently dropped. What holds now:
>
> | Converts | Refused |
> |----------|---------|
> | `lm` ↔ `cd·sr`, `lx` ↔ `lm/m²` ↔ `cd·sr/m²`, `ph` ↔ `lx`, `sb` ↔ `cd/m²` | `lm` ↔ `cd` (flux vs intensity), `lx` ↔ `cd/m²` and `ph` ↔ `sb` (illuminance vs luminance) |

### 3.3 Non-SI Units Accepted for Use with SI

| Symbol | Long forms | Name | Enum value | Notes |
|--------|-----------|------|------------|-------|
| `L`, `l` | `liter`, `litre`, `liters`, `litres` | liter | `bu_liter` | 10⁻³ m³ |
| `min`  | `minute`, `minutes` | minute | `bu_minute` | 60 s |
| `h`    | `hour`, `hours` | hour | `bu_hour` | 3600 s |
| `d`    | `day`, `days` | day | `bu_day` | 86400 s |
| `wk`   | `week`, `weeks` | week | `bu_week` | 604800 s |
| `yr`   | `year`, `years` | year | `bu_year` | 31557600 s (Julian year) |
| `mo`   | `month`, `months` | month (Julian) | `bu_month` | 2629800 s (= 365.25 d / 12) |
| `fn`   | `fortnight`, `fortnights` | fortnight | `bu_fortnight` | 1209600 s (= 14 d) |
| `°`, `deg` | `degr`, `degree`, `degrees` | degree (angle) | `bu_degree` | π/180 rad — U+00B0 |
| `t`    | `tonne` | tonne | `bu_tonne` | 10³ kg |
| `bar`  | — | bar | `bu_bar` | 10⁵ Pa |
| `eV`   | `electronvolt` | electronvolt | `bu_electronvolt` | 1.602176634×10⁻¹⁹ J |
| `Da`   | `dalton`, `amu`, `u` | dalton | `bu_dalton` | 1.66053906892×10⁻²⁷ kg (CODATA 2022) |
| `au`   | — | astronomical unit | `bu_astronomical_unit` | 1.495978707×10¹¹ m |
| `ha`   | `hectare` | hectare | `bu_hectare` | 10⁴ m² |

### 3.4 Imperial and US Customary Units

#### Length

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `in`   | `inch`, `inches` | inch | `bu_inch` | 0.0254 m (exact) |
| `ft`   | `foot`, `feet` | foot | `bu_foot` | 0.3048 m (exact) |
| `yd`   | `yard`, `yards` | yard | `bu_yard` | 0.9144 m (exact) |
| `mi`   | `mile`, `miles` | statute mile | `bu_mile` | 1609.344 m (exact) |
| `nmi`  | `nautical_mile`, `nautical_miles` | nautical mile | `bu_nautical_mile` | 1852 m (exact) |
| `Å` (U+212B) | `angstrom`, `angstroms`, Å (U+00C5), Å (U+0041 U+030A) | ångström | `bu_angstrom` | 10⁻¹⁰ m |
| `ly`   | `light_year`, `light_years` | light-year | `bu_light_year` | 9.4607304725808×10¹⁵ m |
| `pc`   | `parsec`, `parsecs` | parsec | `bu_parsec` | 3.085677581491367×10¹⁶ m |
| `fur`  | `furlong`, `furlongs` | furlong | `bu_furlong` | 201.168 m (exact) |
| `fath` | `fathom`, `fathoms` | fathom | `bu_fathom` | 1.8288 m (exact) |
| `thou` | `thou`, `mil`, `mils` | thou | `bu_thou` | 25.4×10⁻⁶ m (exact) |
| `ch`   | `chain`, `chains` | chain (Gunter's) | `bu_chain` | 20.1168 m (exact) |
| `rd`   | `rod`, `rods` | rod (pole, perch) | `bu_rod` | 5.0292 m (exact) |

> **Thou vs mil:** Both `thou` and `mil` are accepted for 1/1000 of an inch (25.4 µm). The canonical output form is `thou`. Note that `mil` does **not** mean milliradian; milliradians are written `m~rad`.

#### Mass

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `lb`   | `lbs`, `pound`, `pounds` | pound (avoirdupois) | `bu_pound` | 0.45359237 kg (exact) |
| `oz`   | `ounce`, `ounces` | ounce (avoirdupois) | `bu_ounce` | 0.028349523125 kg (exact) |
| `gr`   | `grain`, `grains` | grain | `bu_grain` | 6.479891×10⁻⁵ kg (exact) |
| `st`   | `stone`, `stones` | stone | `bu_stone` | 6.35029318 kg (exact) |
| `tn_sh`| `short_ton`, `short_tons` | short ton (US ton) | `bu_short_ton` | 907.18474 kg (exact) |
| `tn_l` | `long_ton`, `long_tons` | long ton (UK ton) | `bu_long_ton` | 1016.0469088 kg (exact) |
| `oz_t` | `troy_ounce`, `troy_ounces` | troy ounce | `bu_troy_ounce` | 0.0311034768 kg (exact) |
| `ct`   | `carat`, `carats` | metric carat | `bu_carat` | 2×10⁻⁴ kg (exact) |
| `slug` | `slugs` | slug | `bu_slug` | 14.593902937206364 kg (= `lb`·`gn`/`ft`) |
| `dr`   | `dram`, `drams` | dram (avoirdupois) | `bu_dram` | 1.7718451953125×10⁻³ kg (exact) |
| `dwt`  | `pennyweight`, `pennyweights` | pennyweight (troy) | `bu_pennyweight` | 1.55517384×10⁻³ kg (exact) |
| `lb_t` | `troy_pound`, `troy_pounds`, `apothecary_pound` | troy pound (= apothecary pound) | `bu_troy_pound` | 0.3732417216 kg (exact, = 12 `oz_t`) |
| `dr_ap`| `apothecary_dram`, `apothecary_drams` | dram (apothecary) | `bu_apothecary_dram` | 3.8879346×10⁻³ kg (exact, = 3 `sc`) |
| `cwt_l`| `long_hundredweight`, `long_hundredweights` | hundredweight (long/imperial) | `bu_long_hundredweight` | 50.80234544 kg (exact, = 112 `lb`) |

> The apothecary dram is **2.2×** the avoirdupois `dr` and the two are a classic near miss; the
> apothecary POUND, by contrast, *is* the troy pound (twelve apothecary ounces, and an apothecary
> ounce is a troy ounce), so both spellings are one unit. The SHORT hundredweight is exactly 100 lb,
> which the prefix mechanism already spells `h~lb`, so it has no unit of its own.

#### Temperature

| Symbol | Long forms | Name | Enum value | Conversion |
|--------|-----------|------|------------|------------|
| `°C`, `degC` | `degrC`, `degreeC`, `degreesC`, `celsius` | degree Celsius | `bu_celsius` | K = °C + 273.15 (affine) — also §3.2 (BIPM named derived unit) |
| `°F`, `degF` | `degrF`, `degreeF`, `degreesF`, `fahrenheit` | degree Fahrenheit | `bu_fahrenheit` | K = (°F + 459.67) × 5/9 (affine) |
| `°Ra`, `degRa` | `degrRa`, `degreeRa`, `degreesRa`, `rankine` | degree Rankine | `bu_rankine` | K = °Ra × 5/9 (linear) |
| `°De`, `degDe` | `degrDe`, `degreeDe`, `degreesDe`, `delisle` | degree Delisle | `bu_delisle` | K = 373.15 − °De × 2/3 (affine) |
| `°N`, `degN` | `degrN`, `degreeN`, `degreesN`, `newton_temperature` | degree Newton | `bu_newton_temp` | K = °N × 100/33 + 273.15 (affine) |
| `°Re`, `degRe` | `degrRe`, `degreeRe`, `degreesRe`, `reaumur` | degree Réaumur | `bu_reaumur` | K = °Re × 5/4 + 273.15 (affine) |
| `°Ro`, `degRo` | `degrRo`, `degreeRo`, `degreesRo`, `romer` | degree Rømer | `bu_romer` | K = (°Ro − 7.5) × 40/21 + 273.15 (affine) |

> Kelvin (`K`) is the SI base unit (§3.1). `Ra` not `R` — `R` is reserved for the röntgen (`bu_roentgen`).

#### Temperature Differences

Every unit above is a **scale**: `25 °C` is 298.15 K, and Bovnar converts it that way. A **difference** of 25 degrees is 25 K, and these are the units that say so.

| Symbol | Long forms | Name | Enum value | Conversion |
|--------|-----------|------|------------|------------|
| `ΔK`, `delta_K` | `deltaK`, `delta_kelvin`, `deltakelvin`, `Δ°C`, `delta_degC`, `deltadegC`, `delta_celsius`, `deltacelsius` | kelvin interval | `bu_delta_kelvin` | 1 K exactly (linear). `Δ°C` **is** this unit |
| `Δ°F`, `delta_degF` | `deltadegF`, `delta_fahrenheit`, `deltafahrenheit`, `Δ°Ra`, `delta_degRa`, `deltadegRa`, `delta_rankine`, `deltarankine` | Fahrenheit interval | `bu_delta_fahrenheit` | 5/9 K exactly (linear). `Δ°Ra` **is** this unit |
| `Δ°De`, `delta_degDe` | `deltadegDe`, `delta_delisle`, `deltadelisle` | Delisle interval | `bu_delta_delisle` | −2/3 K exactly (linear) |
| `Δ°N`, `delta_degN` | `deltadegN`, `delta_newton_temperature` | Newton interval | `bu_delta_newton_temp` | 100/33 K exactly (linear) |
| `Δ°Re`, `delta_degRe` | `deltadegRe`, `delta_reaumur`, `deltareaumur` | Réaumur interval | `bu_delta_reaumur` | 5/4 K exactly (linear) |
| `Δ°Ro`, `delta_degRo` | `deltadegRo`, `delta_romer`, `deltaromer` | Rømer interval | `bu_delta_romer` | 40/21 K exactly (linear) |

**Six rows, eight spellings.** The degree Celsius interval *is* the kelvin (SI Brochure 9th ed. §2.3.1) and the Rankine degree *is* the Fahrenheit degree, so `Δ°C` and `Δ°Ra` are aliases rather than units of their own — two units that had to compare equal and convert by exactly 1 would be a distinction with no content.

**They are ratio scales.** `.affine = false`, `.offset = 0.0`. That is what lets them compose where the scales cannot: `Δ°F/k~m` is a lapse rate and `Δ°Re^-1` an expansion coefficient, while `°F/k~m` and `°Re^-1` have no SI meaning at all (§12.4 — an affine scale is meaningful only alone at exponent 1).

**They carry their own quantity kind**, so:

```
ΔK   → K       error_unit_mismatch      the whole point
°C   → Δ°C     error_unit_mismatch      a reading is not an interval, and only
                                        the author can decide which one it was
Δ°C  → ΔK      factor 1                 the same unit
Δ°F  → ΔK      factor 5/9               exact; lossless only for multiples of 9
Δ°De → ΔK      factor −2/3              Delisle runs backwards
--si on 25 Δ°C                          25 ΔK, not 298.15 K
[<float:64,K> 1.0, <float:64,ΔK> 2.0]   error_array_element_type_mismatch
```

A bare array is homogeneous in its unit (spec [§7.4](03_bovnar_spec.md#74-element-homogeneity)), so the last line needs no rule of its own.

**Inside a compound the distinction does not arise, and is not made.** `W/(m²·ΔK)` and `W/(m²·K)` are the *same unit*, as are `ΔK/k~m` and `K/k~m`, and `ΔK^-1` and `K^-1`. The reason is the one above: an affine scale cannot appear in a compound at all, so a `K` there was already an interval and there is nothing to separate. The quantity kind is therefore significant only for a lone unit at exponent 1 — precisely where the affine offset was the hazard. Every U-value written before these units existed keeps its meaning.

> `Δ` is U+0394 (`0xCE 0x94`); every unit has ASCII spellings, formed as `delta_` plus the scale's own short form. There is deliberately no bare `delta_C` or `delta_F`: those read as a delta coulomb and a delta farad, and the scales themselves do not alias bare `C` or `F` either. `delta_K` is unambiguous because `K` is the kelvin and nothing else.

### 3.5 Pressure Units

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `atm`  | `atmosphere`, `atmospheres` | standard atmosphere | `bu_atmosphere` | 101325 Pa (exact) |
| `at`   | `atmosphere_technical` | atmosphere technical | `bu_atmosphere_technical` | 98066.5 Pa (= 1 kgf/cm²) |
| `mmHg` | — | millimetre of mercury | `bu_mmhg` | 133.322387415 Pa |
| `Torr` | `torr` | torr | `bu_torr` | 101325/760 Pa |
| `psi`  | — | pound-force per square inch | `bu_psi` | 6894.757293168362 Pa |
| `inHg` | `inch_hg`, `inch_mercury` | inch of mercury | `bu_inch_hg` | 3386.388640341 Pa (= 25.4 mmHg exactly) |
| `mH2O` | `metre_water`, `meter_water` | metre of water column | `bu_meter_water` | 9806.65 Pa (exact, conventional) |

> `mH2O` takes prefixes, so the spellings people actually write fall out of it: `c~mH2O` (or the
> compact `cmH2O`) is the centimetre of water a ventilator is set in, and `m~mH2O` the millimetre.
> The value is the conventional column — water at 1000 kg/m³ under standard gravity — the same kind
> of convention that fixes `mmHg` at exactly 133.322387415 Pa. A column measured at a stated
> temperature (UDUNITS' `water_4C`, QUDT's `IN_H2O`) is a *different* unit and is refused, not
> rounded onto this one.

### 3.6 Energy Units

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `cal`  | `calorie`, `calories` | thermochemical calorie | `bu_calorie` | 4.184 J (exact) |
| `Btu`  | `btu` | International Table BTU | `bu_btu` | 1055.05585262 J |
| `erg`  | `ergs` | erg | `bu_erg` | 10⁻⁷ J (exact) |
| `thm`  | `therm`, `therms` | US therm | `bu_therm` | 1.05480400×10⁸ J (exact) |
| `ft_lb` | `foot_pound`, `foot_pounds` | foot-pound | `bu_foot_pound` | 1.3558179483314003 J (= `lbf`·`ft`) |
| `cal_IT` | `calorie_IT` | International Table calorie | `bu_calorie_it` | 4.1868 J (exact) |
| `Btu_th` | `BTU_th`, `btu_th` | thermochemical BTU | `bu_btu_th` | 23722880951/22500000 J ≈ 1054.35026449 J |

> **Two calories and two BTUs, and the pairs cross over.** `cal` is the THERMOCHEMICAL calorie and
> `Btu` the INTERNATIONAL TABLE BTU, which is not an inconsistency but two vocabularies' defaults:
> `cal` follows UCUM, whose unqualified `cal` is thermochemical, and `Btu` follows UDUNITS, whose
> unqualified `BTU` is the IT one (as its `calorie` is). UCUM's unqualified `[Btu]` is
> thermochemical too, so `ucum:[Btu]` maps to `Btu_th`, not to `Btu`. Each pair is 0.067 % apart — dimensionally identical
> and numerically wrong if confused — so both members of both pairs are carried and every profile
> code lands on the one its own vocabulary says it means. Both BTUs are one pound of water raised
> one degree Fahrenheit, i.e. the corresponding calorie × 453.59237 g/lb × 5/9; that terminates for
> the IT calorie and does not for the thermochemical one, which is why `Btu_th` states an exact
> rational rather than a decimal.

> **`BTU` alias note:** `BTU` (all uppercase, three characters) is a valid alias for `bu_btu`. Because currencies require the `$` sigil, the bare token `BTU` is a physical-unit lookup and resolves to `bu_btu`; `Btu` and `btu` are also accepted. See §10.2 for the complete look-alike table.

### 3.7 Power Units

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `hp`   | `horsepower` | mechanical horsepower | `bu_horsepower` | 745.6998715822702 W (= 550 `ft_lb`/s) |
| `PS`   | `CV`, `metric_horsepower` | metric horsepower | `bu_metric_horsepower` | 735.49875 W (exact) |

### 3.8 Force Units

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `lbf`  | `pound_force` | pound-force | `bu_pound_force` | 4.4482216152605 N |
| `dyn`  | `dyne`, `dynes` | dyne | `bu_dyne` | 10⁻⁵ N (exact) |
| `kip`  | `kips` | kip (kilopound-force) | `bu_kip` | 4448.2216152605 N |
| `kgf`  | `kilogram_force` | kilogram-force | `bu_kilogram_force` | 9.80665 N (exact) |

### 3.9 Speed and Rotational Frequency Units

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `kn`   | `knot`, `knots` | knot | `bu_knot` | 1852/3600 m/s |
| `rpm`  | — | revolutions per minute | `bu_rpm` | 1/60 s⁻¹ |

> `kn` has dimension m·s⁻¹. `rpm` has dimension s⁻¹ (rotational frequency, not linear speed); it is grouped here by convention.

### 3.10 Volume Units

#### US Liquid Volume

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `gal`  | `gallon`, `gallons` | US liquid gallon | `bu_gallon` | 3.785411784×10⁻³ m³ (exact) |
| `qt`   | `quart`, `quarts` | US liquid quart | `bu_quart` | 9.46352946×10⁻⁴ m³ |
| `pt`   | `pint`, `pints` | US liquid pint | `bu_pint` | 4.73176473×10⁻⁴ m³ |
| `cup`  | `cups` | US cup | `bu_cup` | 2.365882365×10⁻⁴ m³ |
| `gi`   | `gill`, `gills` | US gill | `bu_gill` | 1.18294118250×10⁻⁴ m³ |
| `fl_oz`| `fluid_ounce`, `fluid_ounces` | US fluid ounce | `bu_fluid_ounce` | 2.95735295625×10⁻⁵ m³ |
| `tbsp` | `tablespoon`, `tablespoons` | US tablespoon | `bu_tablespoon` | 1.478676478125×10⁻⁵ m³ (exact) |
| `tsp`  | `teaspoon`, `teaspoons` | US teaspoon | `bu_teaspoon` | 4.92892159375×10⁻⁶ m³ |
| `bbl`  | `barrel`, `barrels` | petroleum barrel | `bu_barrel` | 0.158987294928 m³ |

> **`cup` disambiguation:** The canonical symbol for the US cup volume unit is `cup` (all lowercase). The Cuban Peso (ISO 4217 code 192) is written with the mandatory currency sigil as `$CUP`; the bare uppercase token `CUP` is `error_unit_illegal`. The two cannot be confused. See §10.3 for full details.

#### UK Imperial Volume

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `gal_uk` | `gallon_uk`, `gallons_uk` | imperial gallon | `bu_gallon_uk` | 4.54609×10⁻³ m³ (exact) |
| `qt_uk`  | `quart_uk`, `quarts_uk` | imperial quart | `bu_quart_uk` | 1136.5225×10⁻⁶ m³ |
| `pt_uk`  | `pint_uk`, `pints_uk` | imperial pint | `bu_pint_uk` | 568.26125×10⁻⁶ m³ |
| `gi_uk`  | `gill_uk`, `gills_uk` | imperial gill | `bu_gill_uk` | 1.420653125×10⁻⁴ m³ (exact) |
| `fl_oz_uk` | `fluid_ounce_uk`, `fluid_ounces_uk` | imperial fluid ounce | `bu_fluid_ounce_uk` | 28.4130625×10⁻⁶ m³ |

### 3.11 Area Units

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `ac`   | `acre`, `acres` | acre | `bu_acre` | 4046.8564224 m² (exact) |
| `barn` | `barns` | barn | `bu_barn` | 10⁻²⁸ m² (exact) |

### 3.12 Angle Units

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `arcmin` | `arcminute`, `arcminutes` | arcminute | `bu_arcminute` | π/10800 rad |
| `arcsec` | `arcsecond`, `arcseconds` | arcsecond | `bu_arcsecond` | π/648000 rad |
| `grad`   | `gradian`, `gradians`, `gon` | gradian | `bu_grad` | π/200 rad |
| `rev`    | `turn`, `revolution`, `revolutions`, `turns` | revolution | `bu_revolution` | 2π rad |

> Angle is one **shared** quantity kind, so `°` → `rad` works and only the factor
> is irrational. What the kind stops is an angle drifting into a plain count:
> `rev/min` is an angular rate and `rpm` a cycle rate, and they differ by exactly
> 2π. `sr` carries the kind at weight 2, because a steradian *is* `rad²` — and so
> do the photometric units built on it (`lm` = cd·sr, `lx` = lm/m², `ph` = lm/cm²;
> see the note under §3.2).

### 3.13 CGS Units

| Symbol | Long forms | Name | Enum value | SI equivalent |
|--------|-----------|------|------------|---------------|
| `P`    | `poise`, `poises` | poise (dynamic viscosity) | `bu_poise` | 0.1 Pa·s |
| `St`   | `stokes`, `stoke` | stokes (kinematic viscosity) | `bu_stokes` | 10⁻⁴ m²·s⁻¹ |
| `G`    | `gauss` | gauss (magnetic flux density) | `bu_gauss` | 10⁻⁴ T |
| `Mx`   | `maxwell`, `maxwells` | maxwell (magnetic flux) | `bu_maxwell` | 10⁻⁸ Wb |
| `Oe`   | `oersted`, `oersteds` | oersted (magnetic field strength) | `bu_oersted` | 1000/(4π) A/m |
| `sb`   | `stilb`, `stilbs` | stilb (luminance) | `bu_stilb` | 10⁴ cd/m² |
| `ph`   | `phot`, `phots` | phot (illuminance) | `bu_phot` | 10⁴ lx |
| `Gal`  | `galileo`, `galileos` | galileo (acceleration) | `bu_galileo` | 10⁻² m/s² |

> **`sb` and `ph` are not the same quantity**, though the SI dimension vector says
> they are: both reduce to cd·m⁻² and both are 10⁴ of their SI counterpart. The
> stilb is a *luminance* (cd/cm²), the phot an *illuminance* (lm/cm²), and a lumen
> is a candela-**steradian**. Bovnar carries that steradian as a quantity kind, so
> `ph` converts with `lx` and `sb` with `cd/m²`, and neither converts with the
> other — see §3.12 and the note under §3.2.

### 3.14 Radiation Units

| Symbol | Long forms | Name | Enum value | SI equivalent |
|--------|-----------|------|------------|---------------|
| `Ci`   | `curie`, `curies` | curie (radioactivity) | `bu_curie` | 3.7×10¹⁰ Bq |
| `R`    | `roentgen`, `roentgens` | röntgen (radiation exposure) | `bu_roentgen` | 2.58×10⁻⁴ C/kg |
| `rem`  | `rems` | rem (dose equivalent) | `bu_rem` | 10⁻² Sv |

### 3.15 Logarithmic Units

| Symbol | Long forms | Name | Enum value | Notes |
|--------|-----------|------|------------|-------|
| `Np`   | `neper`, `nepers` | neper | `bu_neper` | dimensionless logarithmic ratio |
| `dB`   | `decibel`, `decibels` | decibel | `bu_decibel` | dimensionless logarithmic ratio |

> **Neper and decibel do not convert into each other.** `bvn_units_compatible`
> reports `false` for the pair, and every conversion entry point refuses it.
>
> A logarithmic level is not a linear quantity: 20 dB is a ratio of 100, not
> twice the ratio 10 dB names. Relating two logarithmic scales is a change of
> base, which the multiply-by-a-factor model every conversion entry point is
> built on cannot express — so bovnar gives `Np`, `dB` and `pH` a quantity kind
> each and refuses every pair, rather than applying a factor that is only ever
> right by coincidence.
>
> ISO 80000-3 does state 1 Np = 8.685889… dB, for a level referred consistently
> to the same kind of quantity. Two things stop that from being a conversion
> this library can perform. It is a relation between *levels*, so it does not
> compose with the exponents and prefixes a `value_unit_t` carries; and `dB` is
> written in practice against both the power convention (10·log₁₀) and the field
> convention (20·log₁₀), with nothing in the annotation recording which — so the
> number a reader would need is not determined by the unit alone.
>
> The same reasoning keeps a level out of a plain number: `dB` → dimensionless is
> refused too.

### 3.16 Electrical Power Units

| Symbol | Long forms | Name | Enum value | Notes |
|--------|-----------|------|------------|-------|
| `var`  | `vars` | var (volt-ampere reactive) | `bu_var` | reactive power; same SI dimension as W |
| `VA`   | `volt_ampere`, `volt_amperes` | volt-ampere | `bu_volt_ampere` | apparent power; same SI dimension as W |

> **Watt, var, and VA:** All three carry the same SI dimensional signature (kg·m²·s⁻³). `bvn_units_compatible` returns `true` when comparing them. They are kept as distinct base units because they represent distinct AC power interpretations.

### 3.17 Digital Units

| Symbol | Long forms | Name | Enum value |
|--------|-----------|------|------------|
| `b`    | `bit`, `bits` | bit | `bu_bit` |
| `B`    | `byte`, `bytes`, `Byte`, `Bytes` | byte | `bu_byte` |

> **Bit and byte do not convert into each other.** They are separate quantity
> kinds, so `b` → `B` is refused rather than divided by eight. A byte is eight
> bits *on the wire*, but a value annotated in bytes and one annotated in bits
> are usually not the same measurement — a link rate quoted in `M~b/s` and a file
> size in `M~B` — and silently trading one for the other is the class of error
> this format exists to prevent. Convert explicitly in the application if that
> is genuinely what you mean.
>
> Prefixes still work within each: `Ki~B` → `B` is 1024, `M~b` → `k~b` is 1000.
> Both units take IEC binary prefixes as well as SI ones, and SI prefixes on them
> are restricted to kilo and above (§4.2).

### 3.18 Textile Linear Density

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `tex`  | — | tex | `bu_tex` | 1×10⁻⁶ kg/m (ISO 1144) |
| `den`  | `denier`, `deniers` | denier | `bu_denier` | 1/9000000 kg/m |

### 3.19 US Apothecary / Dry Volume

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `fl_dr`  | `fluid_dram`, `fluid_drams` | US fluid dram | `bu_fluid_dram` | 3.6966911953125×10⁻⁶ m³ |
| `minim`  | `minims` | US minim | `bu_minim` | 6.16115199218750×10⁻⁸ m³ |
| `pk`     | `peck`, `pecks` | US dry peck | `bu_peck` | 8.80976754172×10⁻³ m³ |
| `bsh`    | `bushel`, `bushels` | US bushel | `bu_bushel` | 3.523907016688×10⁻² m³ |

> `minim` (not `min`, which is the minute) avoids ambiguity.

### 3.20 Old German Units

Old German units fall into metric-compatible units (still in use in DACH regions) and historical pre-metric Prussian units. The prefix `pr` is reserved for Prussian symbols. No German unit accepts any non-trivial SI or IEC prefix; `bvn_prefix_unit_valid` rejects any non-`si_none`/`iec_none` prefix for every unit from `bu_pfund` through `bu_scheffel`.

#### Metric-Compatible German Units — Mass

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `Pfd`  | `pfund`, `pfunds` | Pfund | `bu_pfund` | 0.5 kg (exact) |
| `Ztr`  | `zentner` | Zentner | `bu_zentner` | 50 kg (exact) |
| `dz`   | `doppelzentner` | Doppelzentner | `bu_doppelzentner` | 100 kg (exact) |
| `lot`  | `lots` | Lot | `bu_lot` | 15.625×10⁻³ kg (exact) |

#### Historical German Units — Length (Prussian)

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `prln` | `prussian_line`, `linie` | Prussian line | `bu_prussian_line` | 313853/144000000 m ≈ 2.1795347×10⁻³ m |
| `prz`  | `prussian_zoll`, `zoll` | Prussian Zoll | `bu_prussian_zoll` | 313853/12000000 m ≈ 2.6154417×10⁻² m |
| `prf`  | `prussian_fuss`, `preussischer_fuss` | Prussian Fuß | `bu_prussian_fuss` | 3.13853×10⁻¹ m |
| `elle` | `prussian_elle`, `preussische_elle` | Prussian Elle | `bu_prussian_elle` | 6.66937625×10⁻¹ m (exact; 25½ Zoll) |
| `rute` | `prussian_rute`, `preussische_rute` | Prussian Rute | `bu_prussian_rute` | 3.766236 m (exact) |
| `klafter` | `prussian_klafter` | Klafter | `bu_klafter` | 1.883118 m (exact) |
| `dt_mi` | `deutsche_meile`, `german_mile` | Geographische Meile | `bu_german_mile` | 7420.44 m |

#### Historical German Units — Area (Prussian)

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `morgen` | `prussian_morgen` | Morgen (Prussian) | `bu_morgen` | 2553.21604938528 m² (exact) |

#### Historical German Units — Volume (Prussian)

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `schffl` | `scheffel`, `prussian_scheffel` | Scheffel (Prussian) | `bu_scheffel` | 54.961×10⁻³ m³ |

> Every Prussian unit here reproduces its historical definition from the 1816 Fuß (0.313853 m):
> Zoll = Fuß/12, Linie = Zoll/12, Rute = 12 Fuß, Klafter = 6 Fuß, Elle = 25½ Zoll, Morgen = 180
> square Ruten. `test_unit_factors_derived.py` checks each of them against that definition.

> The German units carry ids **100133–100145**, inside the native unit block like every other physical unit — see §11.1 for the block layout. They used to sit at 348–360, appended past the whole currency range, because the id space was one flat counter and the currencies had been dropped into the middle of it. The space is blocked now, so a unit's id no longer says anything about when it was added.

### 3.21 Additional Length Units

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `ftUS` | `survey_foot` | US survey foot | `bu_survey_foot` | 1200/3937 m ≈ 0.304800609… m |
| `lea`  | `league`, `leagues` | statute league | `bu_league` | 4828.032 m (= 3 statute miles) |
| `cbl`  | `cable`, `cables` | cable length | `bu_cable` | 185.2 m |
| `hand` | `hands` | hand | `bu_hand` | 0.1016 m (= 4 in, exact) |

> `ftUS` (US survey foot) differs from `ft` (international foot, 0.3048 m exactly) by about 2 ppm. Used in US geodetic surveying.

### 3.22 Additional Mass Units

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `qntl` | `quintal`, `quintals` | quintal | `bu_quintal` | 100 kg (exact) |
| `sc`   | `scruple`, `scruples` | apothecary scruple | `bu_scruple` | 1.2959782×10⁻³ kg (= 20 grains) |

### 3.23 Acceleration

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `gn`   | `standard_gravity` | standard gravity | `bu_standard_gravity` | 9.80665 m·s⁻² (exact, CGPM 1901) |

### 3.24 Signal Rate

| Symbol | Long forms | Name | Enum value | Notes |
|--------|-----------|------|------------|-------|
| `Bd`   | `baud`, `bauds` | baud | `bu_baud` | 1 symbol/s = 1 s⁻¹ (ITU-T V.662) |

### 3.25 Ratio and Proportion Units

Dimensionless scaling factors. A value carrying one of these units reduces to the
numeric value multiplied by the factor in the canonical (dimensionless) base
representation — e.g. `5 %` ≡ `0.05`, `250 ppm` ≡ `0.00025`. Like the other
dimensionless ratios they carry an empty dimension vector, but unlike `rad`/`sr`
they do **not** accept SI or IEC prefixes (a prefixed `%` is meaningless).

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `%`   | `percent` | per cent | `bu_percent` | 10⁻² |
| `‰`   | `per_mille` | per mille | `bu_per_mille` | 10⁻³ |
| `‱`   | `per_myriad` | per myriad | `bu_per_myriad` | 10⁻⁴ |
| `pcm` | `per_cent_mille` | per cent mille | `bu_per_cent_mille` | 10⁻⁵ |
| `ppm` | — | parts per million | `bu_ppm` | 10⁻⁶ |
| `ppb` | — | parts per billion | `bu_ppb` | 10⁻⁹ |
| `pptr` | `parts_per_trillion`, `pptv` | parts per trillion | `bu_ppt` | 10⁻¹² |
| `ppq` | `parts_per_quadrillion`, `ppqv` | parts per quadrillion | `bu_ppq` | 10⁻¹⁵ |

> **Why `pptr` and not `ppt`.** Two independent reasons, and either alone would
> settle it. `ppt` already resolved — as the compact form of `p~pt`, the
> picopint — so claiming it as an alias would have taken a spelling away from the
> unit that held it, which §4.3's guarantee forbids and `gen_units.py` refuses at
> build time. And `ppt` is ambiguous in the field: parts per **thousand** in some
> industries, parts per **trillion** in atmospheric chemistry, a factor of 10⁹
> apart. UCUM splits the same ambiguity the same way — `[ppth]` for per thousand,
> `[pptr]` for per trillion — so the symbol here is borrowed rather than
> invented. The compact token `ppt` is refused outright (§4.3); write `pptr`,
> `‰`, or `p~pt` if the picopint really is what you meant.
>
> `pptv` and `ppqv`, the "by volume" spellings, are accepted as aliases because
> UDUNITS-2 defines them as the same 10⁻¹² and 10⁻¹⁵. Both profiles that name
> these units — UCUM's `[pptr]` and UDUNITS' `ppt`/`pptv`/`ppq`/`ppqv` — were
> refused for want of a native target until these rows existed.

### 3.26 Named Speed Units

Speeds people write as one token. `mi/h` and `k~m/h` express the same quantities
as compounds and remain valid; these are separate base units, not shorthands the
parser expands, so an annotation of `mph` does **not** reconcile with an inline
`mi/h` (the comparison is structural — see §2.2). Neither accepts a prefix: they
already carry one, or have no meaningful prefixed form. `kn` (the knot, §3.9)
belongs to the same family.

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `mph` | — | mile per hour | `bu_mile_per_hour` | 0.44704 m·s⁻¹ (exact) |
| `kph` | `kmh` | kilometre per hour | `bu_kilometer_per_hour` | 5/18 m·s⁻¹ (exact; the decimal does not terminate) |

### 3.27 Acidity

The pH scale: dimensionless by construction, being a negative decimal logarithm
of hydrogen-ion activity. It carries a factor of 1 and accepts **no** prefix — a
milli-pH is not a quantity. It is modelled for the same reason `dB` and `Np` are:
a logarithmic scale is still a statement about what the number means, and `7.2`
alone is not.

Because the scale is logarithmic, the value is a label, not something to sum or
average — the same caveat that applies to `dB` and `Np`.

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `pH` | — | pH (acidity) | `bu_ph_scale` | 1 (dimensionless) |

> **`pH` vs `p~H`:** the two differ by one character and by seven orders of
> dimension. `pH` is the acidity scale; `p~H` is the picohenry. Case matters too:
> `ph` is the phot (§3.13). This is exactly why acidity had to become a unit —
> without it, `pH` resolves as a compact prefixed henry.

### 3.28 Water Hardness

Six scales for one physical quantity: the concentration of dissolved
alkaline-earth ions (Ca²⁺, Mg²⁺). Each is *defined* as a mass of a reference compound per litre,
but the scales use **different** compounds — `°dH` counts CaO, most of the others CaCO₃, `°rH`
counts Ca — so mass concentration is not their common ground. Amount concentration is: every scale
carries mol·m⁻³ (which is exactly mmol·L⁻¹), converts into every other, and into `m~mol/L`.

None of them takes a prefix. **Millimoles per litre needs no unit of its own** — it is the compound
`m~mol/L` (or `mmol/L`).

| Symbol | Long form | Name | Enum value | Defined as | Factor (mol·m⁻³ = mmol·L⁻¹) |
|--------|-----------|------|------------|------------|------------------------------|
| `°dH` | `german_hardness` | German degree | `bu_german_hardness` | 10 mg CaO / L | 0.178326 |
| `°e`, `°Clark` | `english_hardness`, `clark_degree` | English (Clark) degree | `bu_english_hardness` | 1 grain CaCO₃ / imperial gallon | 0.142415 |
| `°fH` | `french_hardness` | French degree | `bu_french_hardness` | 10 mg CaCO₃ / L | 0.099914 |
| `°rH` | `russian_hardness` | Russian degree | `bu_russian_hardness` | 1 mg Ca / L | 0.024951 |
| `°aH` | `american_hardness` | American degree | `bu_american_hardness` | 1 mg CaCO₃ / L | 0.009991 |
| `gpg` | `grains_per_gallon` | grains per US gallon | `bu_grains_per_gallon` | 1 grain CaCO₃ / US gallon | 0.171034 |
| `val` | `vals`, `eq` | equivalent (water analysis) | `bu_val` | ½ mol — see below | 0.5 (as `mol`, not a concentration) |

Reading across, with 1 mmol·L⁻¹ as the reference:

| 1 mmol/L equals | `°dH` | `°e` | `°fH` | `°rH` | `°aH` | `gpg` | `mval/L` |
|-----------------|-------|------|-------|-------|-------|-------|----------|
| | 5.6077 | 7.0217 | 10.0086 | 40.078 | 100.086 | 5.8468 | 2.000 |

> **`gpg` is a unit, `gr/gal` is a compound, and they are not the same thing.** `gpg` is an amount
> concentration and converts with the other hardness scales; `gr/gal` (grain per US gallon) is a
> *mass* concentration and deliberately does not. Both spellings stay valid.

> **`val` is the equivalent as *water analysis* uses it.** The ions counted are divalent, so one
> equivalent is half a mole and `m~val/L` = 0.5 mmol/L, which is what the hardness tables state.
> An equivalent of a *monovalent* species is one mole — Bovnar cannot know the species from the
> unit, so for that case write the amount directly (`m~mol/L`). `eq` is accepted as a spelling of
> `val` (below) and means the same divalent equivalent — it is **not** UCUM's generic equivalent of
> one mole: native `meq/L` is half of `ucum:meq/L`. A clinical `meq/L` of a monovalent ion (Na⁺,
> K⁺, Cl⁻) is one millimole per litre, so write it as `m~mol/L` or as `ucum:meq/L`.
>
> This is settled, not provisional: `eq` stays a spelling of `val` and will not be redefined as a
> mole, because that would silently halve every `meq/L` written in the water-analysis sense.
> Inside a `ucum:` unit, `eq` is UCUM's and means one mole (see
> [`11_bovnar_unit_profiles.md`](11_bovnar_unit_profiles.md)).

> **Exactness.** Every degree is derived from a molar mass (IUPAC 2021: Ca 40.078, C 12.011,
> O 15.999 g·mol⁻¹), so the factors are measurement-derived rather than exact rationals. They carry
> `.exact = false`, which means a **read-time lossless conversion** (`want_unit`, §1.10 of the
> read/write API) refuses them with `error_unit_inexact` (47) rather than inventing precision —
> the same answer it gives for a π-based angle. `bvn_unit_convert_value` and
> `bvn_unit_to_si_factor`, which work in double precision, convert normally.

> **`mval/L` and `meq/L` are one unit.** `eq` is an accepted spelling of `val`, so the German and
> English conventions both work and both carry the divalent caveat above.

> **`dH` is not `°dH`.** Without the degree sign the token is the decihenry, and it stays that way.
> Likewise water chemistry writes the American scale as "ppm"; Bovnar's `ppm` is the dimensionless
> 10⁻⁶ and is *not* interchangeable with `°aH`. See [`07_bovnar_unit_ambiguities.md`](07_bovnar_unit_ambiguities.md).

### 3.29 Conductivity and Dissolved Solids

Two quantities that water data quotes constantly and that need **no unit of their own** — the
existing tables already say them exactly.

| Quantity | Write | Notes |
|----------|-------|-------|
| Electrical conductivity (EC) | `µS/cm`, `uS/cm`, `mS/cm`, `dS/m`, `S/m` | siemens per length; `dS/m` is the soil-salinity convention and equals `mS/cm` |
| … in pre-SI spelling | `µmho/cm`, `mmho/cm` | `mho`, `mhos` and `℧` (U+2127) are accepted spellings of the siemens |
| Total dissolved solids (TDS) | `mg/L`, `µg/L`, `g/L`, `k~g/m³` | mass concentration |
| Resistivity (the reciprocal) | `MΩ·cm`, `M~Ω·c~m` | ultrapure-water convention |

One quantity does get a unit of its own, because the hydroponic scale is a
rescaling nothing else in the table expresses:

| Symbol | Long form | Name | Enum value | Method | Prefixes |
|--------|-----------|------|------------|--------|----------|
| `CF` | `conductivity_factor` | conductivity factor | `bu_conductivity_factor` | EC in mS/cm × 10 | no |

`CF` carries the dimensions of conductivity, so unlike the turbidity scales
below it *does* convert: 1 CF = 0.1 mS/cm = 100 µS/cm, exactly, and into `S/m`
and `dS/m` with it. Only the uppercase spelling is the conductivity factor —
`cF` is the centifarad.

> **"ppm" for TDS.** Water data writes TDS in "ppm", meaning milligrams per litre. Bovnar's `ppm`
> is the dimensionless 10⁻⁶ — for a dilute aqueous solution at 1 kg/L the two are *numerically*
> the same, so writing `ppm` is defensible here in a way it is not for hardness (§3.28), but the
> dimensions differ: `ppm` is a ratio, `mg/L` is a concentration, and they do not convert into one
> another. Pick the one you mean.

> **TDS meter scales.** A conductivity meter reading "TDS ppm" applies a conversion factor to EC
> (the 500/442/700 scales). That factor is a property of the instrument and the water, not of a
> unit — record the EC in `µS/cm` and the factor separately, or record the TDS as `mg/L`.

### 3.30 Turbidity and Salinity

Six scales defined by a **measurement method** rather than by a physical quantity. They are
dimensionless, and each carries its own quantity kind, so none of them converts to another or to a
plain number — the conversion a factor cannot express is refused instead of guessed.

| Symbol | Long form | Name | Enum value | Method | Prefixes |
|--------|-----------|------|------------|--------|----------|
| `NTU` | `nephelometric_turbidity` | nephelometric turbidity unit | `bu_turbidity_ntu` | white light, 90° detector (EPA 180.1) | yes |
| `FNU` | `formazin_nephelometric` | formazin nephelometric unit | `bu_turbidity_fnu` | near-infrared 860 nm, 90° detector (ISO 7027) | yes |
| `FTU` | `formazin_turbidity` | formazin turbidity unit | `bu_turbidity_ftu` | formazin-calibrated, geometry **unstated** (ISO 7027:1984) | yes |
| `FAU` | `formazin_attenuation` | formazin attenuation unit | `bu_turbidity_fau` | attenuation at 0°, in the transmitted beam (ISO 7027) | yes |
| `JTU` | `jackson_turbidity` | Jackson turbidity unit | `bu_turbidity_jtu` | visual candle turbidimeter (historical) | no |
| `PSU` | `practical_salinity` | practical salinity unit | `bu_practical_salinity` | PSS-78 conductivity ratio | no |

None of the five turbidity scales converts to any other:

| | `NTU` | `FNU` | `FTU` | `FAU` | `JTU` |
|---|---|---|---|---|---|
| **`NTU`** | ✓ | — | — | — | — |
| **`FNU`** | — | ✓ | — | — | — |
| **`FTU`** | — | — | ✓ | — | — |
| **`FAU`** | — | — | — | ✓ | — |
| **`JTU`** | — | — | — | — | ✓ |

> **One kind per method, and that is the whole content of the table.** The formazin-calibrated
> scales (`NTU`, `FNU`, `FTU`) are numerically equal *on a formazin standard* — which is exactly the
> trap. On real water white light and near-infrared respond differently to particle size and colour,
> so the number alone does not say what was measured; `FTU` says even less, since its geometry is
> unstated by definition. `FAU` is not the same optical quantity at all: it measures how much light
> the sample removes from the beam, not how much it scatters sideways, and it is the instrument of
> choice above ~40 FNU where nephelometry saturates. `JTU` is the visual candle method, whose
> published equivalence to formazin ("1 JTU ≈ 1 NTU") holds near 40 units and nowhere else, being
> nonlinear and sample-dependent. Bovnar refuses every one of these conversions rather than implying
> a factor exists — report the method.
>
> `NTU`, `FNU`, `FTU` and `FAU` accept prefixes (`m~NTU` is real in ultrapure-water work). `JTU`
> does not: the candle method cannot resolve below roughly 25 JTU, so a milli-JTU is not a
> measurement.
>
> Watch the case. `fau` is the femto-astronomical-unit, `cF` the centifarad — the turbidity and
> conductivity scales are uppercase only. See [`07_bovnar_unit_ambiguities.md`](07_bovnar_unit_ambiguities.md).

> **PSU is not per-mille.** Practical salinity is a conductivity ratio, so it is dimensionless by
> construction and `PSU` is a label rather than a unit — SI-minded texts write *S*_P = 35 with no
> unit at all. It is **not** a mass fraction: *S*_P 35 corresponds to about 35.165 g/kg absolute
> salinity, so equating it with `‰` or `g/kg` is wrong by roughly half a percent. For absolute
> salinity write the mass fraction directly: `g/k~g`. The scale is bounded by construction, so it
> takes no prefix.

### 3.31 Sentinel Value

`bu_none` (value `0`) is the internal representation of "no base unit", used for the `no_unit` keyword and as the default when no unit annotation is present.

---

### 3.32 Units the Unit Profiles Needed

Every unit in this section was, until it was added, the **sole** reason a run of UCUM, UDUNITS-2,
QUDT, OM or UN/ECE codes had to be refused as `error_unit_profile_unsupported`: the publisher
defines the unit, the value is exactly stateable, and there was no native unit to translate onto.
None of them is new physics. All are exact — the ones whose value is not a terminating decimal in
SI state a rational in `units.bvnr` rather than the repr of a double.

#### US survey lengths

`bu_survey_foot` (§3.21) has been in the registry all along and nothing was built on it, so every
survey length above the foot had to be refused. Each below is an exact rational multiple of the
survey foot's 1200/3937 m.

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `inUS` | `survey_inch` | US survey inch | `bu_survey_inch` | 100/3937 m |
| `ydUS` | `survey_yard` | US survey yard | `bu_survey_yard` | 3600/3937 m |
| `fathUS` | `survey_fathom` | US survey fathom | `bu_survey_fathom` | 7200/3937 m |
| `rdUS` | `survey_rod` | US survey rod (pole, perch) | `bu_survey_rod` | 19800/3937 m |
| `chUS` | `survey_chain` | US survey chain (Gunter's) | `bu_survey_chain` | 79200/3937 m |
| `lkUS` | `survey_link` | US survey link | `bu_survey_link` | 792/3937 m |
| `furUS` | `survey_furlong` | US survey furlong | `bu_survey_furlong` | 792000/3937 m |
| `miUS` | `survey_mile`, `survey_miles` | US survey (statute) mile | `bu_survey_mile` | 6336000/3937 m |
| `acUS` | `survey_acre`, `survey_acres` | US survey acre | `bu_survey_acre` | 43 560 `ftUS`² = 62726400000/15499969 m² |

> **The survey foot is 2 ppm longer than the international foot**, and the survey acre 4 ppm larger
> than the international acre — an area being a length squared. Small enough to ignore and never
> small enough to be right: on a section of land the acre difference is about ten square metres.
> The survey foot was withdrawn for new work at the end of 2022, which is a reason to read it
> carefully rather than to refuse it, since US land records, state-plane coordinates and a century
> of engineering drawings are written in it.

#### Typographic lengths

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `pnt` | `point`, `points` | DTP point (1/72 in) | `bu_point` | 127/360000 m |
| `pca` | `pica`, `picas` | pica (12 points) | `bu_pica` | 127/30000 m |
| `lne` | `line`, `lines` | line (1/12 in) | `bu_line` | 127/60000 m |

> The symbols are deliberately **not** `pt` and `ln`. `pt` is the pint and has been since before
> these existed; a length answering to it would change what an existing spelling means, which
> §10 forbids and which `gen_units.py` now refuses at build time. UCUM's own bracketed spellings
> `[pnt]`, `[pca]` and `[lne]` name them unambiguously, so the symbols follow those. The
> **printer's** point (0.013837 in) is a different unit and has a row of its own, `pnt_pr`, at the end of §3.32.

#### US dry volumes, and the trade measures built on feet

A dry quart is **16 per cent larger** than the liquid quart `qt`. The peck (`pk`) and bushel (`bsh`)
in §3.19 have always been the dry ones; the gallon, quart and pint were only the liquid ones, so a
document that meant dry had no way to say so.

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `gal_dry` | `dry_gallon`, `dry_gallons` | US dry gallon | `bu_dry_gallon` | 268.8025 in³ = 4.40488377086×10⁻³ m³ |
| `qt_dry` | `dry_quart`, `dry_quarts` | US dry quart | `bu_dry_quart` | 1.101220942715×10⁻³ m³ |
| `pt_dry` | `dry_pint`, `dry_pints` | US dry pint | `bu_dry_pint` | 5.506104713575×10⁻⁴ m³ |
| `fbm` | `board_foot`, `board_feet` | board foot (144 in³) | `bu_board_foot` | 2.359737216×10⁻³ m³ |
| `cord` | `cord`, `cords` | cord (128 ft³) | `bu_cord` | 3.624556363776 m³ |
| `ac_ft` | `acre_foot`, `acre_feet` | acre-foot (survey) | `bu_survey_acre_foot` | `acUS`·`ftUS` = 1233.4892384681489 m³ |

> `ac_ft` is the acre-foot on the **survey** acre and foot, which is what UDUNITS, OM and every US
> water agency mean by it. The international acre-foot is `ac·ft` = 1233.48183754752 m³ — 5.8 ppm
> away, and a different unit rather than a rounding.

#### Permeability, energy, power, ozone and one very short time

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `darcy` | `darcy`, `darcys`, `darcies` | darcy (permeability) | `bu_darcy` | 1/1013250000000 m² |
| `thm_ec` | `therm_EC` | EC therm (10⁵ `Btu`) | `bu_therm_ec` | 1.05505585262×10⁸ J (exact) |
| `ton_ref` | `refrigeration_ton`, `ton_of_refrigeration` | ton of refrigeration | `bu_refrigeration_ton` | 12 000 `Btu`/h = 52752792631/15000000 W |
| `DU` | `dobson`, `dobson_unit`, `dobson_units` | Dobson unit (ozone column) | `bu_dobson` | 4.462×10⁻⁴ mol·m⁻² (as published; 10 µm of ideal gas at STP is 4.4615×10⁻⁴) |
| `shake` | `shake`, `shakes` | shake | `bu_shake` | 10⁻⁸ s (exact) |

> `darcy` takes prefixes, so the millidarcy every reservoir report is written in is `m~darcy` (or
> compactly `mdarcy`). `thm_ec` is the therm European gas billing uses; native `thm` is the US
> therm, 0.024 per cent away, and no SI prefix reaches 10⁵ — which is why it needed a unit of its
> own rather than a prefixed `Btu`. `shake` exists for the same reason at the other end: there is
> no SI prefix at 10⁻⁸.

---

#### Nine more, from two and three vocabularies at once

Each was refused across UCUM, UDUNITS-2, QUDT and OM for one reason only — no native unit of the
magnitude. One publisher naming a unit is a request; **two or three naming it independently at the
same value** is evidence, and every factor below is one all of its publishers agree on to the last
digit they state.

| Symbol | Long forms | Name | Enum value | Factor |
|--------|-----------|------|------------|--------|
| `pnt_pr` | `printers_point` | printer's point | `bu_printers_point` | 0.0003514598 m (exact, = 0.013837 `in`) |
| `pca_pr` | `printers_pica` | printer's pica | `bu_printers_pica` | 0.0042175176 m (exact, = 12 `pnt_pr`) |
| `hp_E` | `electric_horsepower` | electric horsepower | `bu_horsepower_electric` | 746 W (exact) |
| `hp_B` | `boiler_horsepower` | boiler horsepower | `bu_horsepower_boiler` | 9809.5 W |
| `abV` | `abvolt` | abvolt (CGS-EMU) | `bu_abvolt` | 10⁻⁸ V (exact) |
| `AT` | `assay_ton` | assay ton (short) | `bu_assay_ton` | 175/6000 kg ≈ 0.029166667 kg |
| `bsh_uk` | `bushel_uk` | imperial bushel | `bu_bushel_uk` | 0.03636872 m³ (exact, = 8 `gal_uk`) |
| `clo` | — | clo | `bu_clo` | 0.155 K·m²/W (exact) |
| `debye` | — | debye | `bu_debye` | 1/299792458000000000000000000000 C·m ≈ 3.3356410×10⁻³⁰ C·m |

> **Each is a near neighbour of a unit already here, which is why it needed its own row rather than
> an alias.** `pnt` is the DTP point (¹⁄₇₂ in) and `pnt_pr` the printer's point (0.013837 in), 0.37 %
> apart; `hp` is the mechanical horsepower, `PS` the metric one and `hp_E` the electric 746 W, while
> `hp_B` is thirteen times any of them; `bsh` is the US **dry** bushel and `bsh_uk` the imperial one,
> 3.2 % larger. Mapping any of these onto its neighbour is the error doc/11 §6.3 warns about —
> dimensionally perfect, inside anything a later check would notice, and wrong.
>
> `abV` needed a unit rather than a prefix because there is no decade prefix at 10⁻⁸ and prefixes do
> not stack. `AT` and `debye` are stated as exact rationals: the assay ton is 175/6 g, chosen so that
> milligrams recovered from one assay ton read as troy ounces per short ton, and the debye has been
> exactly 10⁻²¹/c C·m since the 1983 definition of the metre fixed c. Their publishers state 7- and 6-digit roundings of
> those; the rational is what they are rounding.

#### And the thirty-five the same sweep found next

Grouping every profile refusal by (dimension, value) turned up 35 groups **two or more vocabularies
define**. The nine above were the first pass; these are the rest that name a real unit. What is left
out is a *constant* rather than a unit (UCUM's `[pi]`), a value only one publisher states, or a
dimensionless "relative permeability" that is a number.

**Where the publishers disagree, the exact definition decides.** The CGS-ESU units are built on `c`,
exact since the 1983 definition of the metre, so every one is an exact rational and the publishers' five- to seven-digit
decimals are roundings of it — which is why `statΩ` is 22468879468420441/25000 here and
898 755 400 000, 898 755 200 000 and 898 760 000 000 in UDUNITS, OM and QUDT. The sidereal hour,
minute and second are derived from the sidereal **day** rather than taken from the three slightly
inconsistent decimals the publishers state for them, so the four stay consistent with each other.
The π-based rows carry `.exact = false`, as the oersted and the parsec do: a value that is not a
rational cannot be converted losslessly, and saying so is what that flag is for.

**`yr` is still the Julian year and `mo` still a twelfth of it.** Naming the others does not make the
short spellings ambiguous — it gives the other calendars somewhere to go, which is what lets a
UDUNITS or CF document that says `year` be read at all. It was refused outright before.

**Calendar and sidereal time**

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `yr_trop` | `tropical_year` | tropical year | `bu_year_tropical` | 31 556 925.9747 s (the tropical year UDUNITS and CF carry) |
| `yr_greg` | `gregorian_year` | Gregorian year | `bu_year_gregorian` | 31 556 952 s (exact, = 365.2425 d) |
| `yr_sid` | `sidereal_year` | sidereal year | `bu_year_sidereal` | 31 558 149.7632 s |
| `yr_com` | `common_year` | common year | `bu_year_common` | 31 536 000 s (exact, = 365 d) |
| `mo_syn` | `synodal_month` | synodal month | `bu_month_synodal` | 2 551 442.976 s (the synodal, lunar month) |
| `mo_trop` | `tropical_month` | tropical month | `bu_month_tropical` | 2 360 584.6848 s |
| `mo_greg` | `gregorian_month` | mean Gregorian month | `bu_month_gregorian` | 2 629 746 s |
| `mo_sid` | `sidereal_month` | sidereal month | `bu_month_sidereal` | 2 360 591.5104 s |
| `d_sid` | `sidereal_day` | sidereal day | `bu_day_sidereal` | 86 164.0905 s |
| `h_sid` | `sidereal_hour` | sidereal hour | `bu_hour_sidereal` | `d_sid`/24 = 3590.1704375 s |
| `min_sid` | `sidereal_minute` | sidereal minute | `bu_minute_sidereal` | `d_sid`/1440 |
| `s_sid` | `sidereal_second` | sidereal second | `bu_second_sidereal` | `d_sid`/86400 |

**CGS electrostatic (ESU)**

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `statV` | `statvolt` | statvolt | `bu_statvolt` | 149896229/500000 V = 299.792458 V (exact, = c×10⁻⁶) |
| `statA` | `statampere` | statampere | `bu_statampere` | 1/2997924580 A (exact) |
| `statC` | `statcoulomb`, `franklin` | statcoulomb (franklin) | `bu_statcoulomb` | 1/2997924580 C (exact) |
| `statF` | `statfarad` | statfarad | `bu_statfarad` | 25000/22468879468420441 F (exact) |
| `statΩ` | `statohm` | statohm | `bu_statohm` | 22468879468420441/25000 Ω ≈ 898 755 178 737 Ω (exact) |
| `statH` | `stathenry` | stathenry | `bu_stathenry` | 22468879468420441/25000 H (exact) |
| `statS` | `statsiemens`, `statmho` | statsiemens | `bu_statsiemens` | 25000/22468879468420441 S (exact) |
| `statWb` | `statweber` | statweber | `bu_statweber` | 149896229/500000 Wb = 299.792458 Wb (exact, = statV·s) |
| `statT` | `stattesla` | stattesla | `bu_stattesla` | 149896229/50 T = 2 997 924.58 T (exact, = statV·s/cm²) |

**Luminance**

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `Lmb` | `lambert` | lambert | `bu_lambert` | 10⁴/π cd·m⁻² |
| `apostilb` | `blondel` | apostilb (blondel) | `bu_apostilb` | 1/π cd·m⁻² |
| `footlambert` | `foot_lambert` | foot-lambert | `bu_footlambert` | 1/π cd·ft⁻² |

**Water-vapour permeance**

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `perm_0C` | — | perm (0 °C) | `bu_perm_0c` | 5.72135×10⁻¹¹ kg·Pa⁻¹·s⁻¹·m⁻² (exact) |
| `perm_23C` | — | perm (23 °C) | `bu_perm_23c` | 5.74525×10⁻¹¹ kg·Pa⁻¹·s⁻¹·m⁻² (exact) |
| `perm_m` | `perm_metric` | metric perm | `bu_perm_metric` | 8.68127×10⁻¹¹ kg·Pa⁻¹·s⁻¹·m⁻² (as published; 1 g/(m²·d·mmHg) is 8.6812682×10⁻¹¹) |

**Heat conventions**

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `cal_m` | `mean_calorie` | mean calorie | `bu_calorie_mean` | 4.19002 J (exact) |
| `cal_15` | `calorie_15C` | 15 °C calorie | `bu_calorie_15c` | 4.1858 J (exact) |
| `cal_20` | `calorie_20C` | 20 °C calorie | `bu_calorie_20c` | 4.1819 J (exact) |
| `Btu_59` | `btu_59F` | 59 °F BTU | `bu_btu_59f` | 1054.804 J |
| `Btu_60` | `btu_60F` | 60 °F BTU | `bu_btu_60f` | 1054.68 J (exact) |
| `Btu_m` | `mean_btu` | mean BTU | `bu_btu_mean` | 1055.87 J (exact) |

**And the singles**

| Symbol | Long forms | Name | Enum | Factor |
|--------|-----------|------|------|--------|
| `e` | `elementary_charge` | elementary charge | `bu_elementary_charge` | 1.602176634×10⁻¹⁹ C (exact, the 2019 SI definition) |
| `sph` | `spere`, `spat` | spere (spat) | `bu_spere` | 4π sr |
| `cml` | `circular_mil` | circular mil | `bu_circular_mil` | π/4 `thou`² ≈ 5.0670748×10⁻¹⁰ m² |
| `unit_pole` | `unitpole` | unit pole | `bu_unit_pole` | 4π×10⁻⁸ Wb |
| `hp_W` | `water_horsepower` | water horsepower | `bu_horsepower_water` | 746.043 W |

---

## 4. Prefixes

A prefix is attached to a base unit symbol either with the `~` separator — `prefix~baseunit`, the canonical form — or **compactly**, without it: `kg` is `k~g`, `MiB` is `Mi~B`. Both spellings parse to the same `value_unit_t`. `bvn_unit_to_string` — and a writer handed only a `value_unit_t` — always emits the separated form; a document that is *re-serialised* keeps the spelling it was written in. `bovnar pretty-print --canonical` does not keep the spelling either way: it writes every unit in reduced form and rescales the value to match (`1.0 km` becomes `1000.0 m`, `2.5 kg` becomes `2500.0 g`). A prefix on a base unit, a currency or a non-SI unit is folded into the value; a unit that is, or reduces to, a single named derived SI unit keeps its prefix, in the separated form (`kPa` → `k~Pa`, `k~N·m` → `k~J`), so `2.5 k~N` and `2500 N` are not unified. An integer that would stop being an integer, or stop fitting its width, fails with `error_value_out_of_range`; nothing is rounded. See §4.3 for what the compact form does and does not accept.

### 4.1 SI Prefixes

All 24 current SI prefixes are supported, from quecto (10⁻³⁰) to quetta (10³⁰).

| Prefix | Symbol | Factor | Enum value |
|--------|--------|--------|------------|
| 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`   |
| *(none)* | —    | 10⁰    | `si_none`   |
| 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` |

> **Encoding note:** `µ` is U+00B5 (MICRO SIGN), UTF-8: `0xC2 0xB5`. U+03BC (GREEK SMALL LETTER MU) is a distinct code point but is also accepted on input; the canonical output form is always U+00B5. ASCII `u` is accepted as an input-only alias for `µ` (e.g. `u~m` parses as `µ~m`); the canonical output form is always `µ`. Note `u` is also the bare symbol for the dalton, but the two never collide: the base unit is whatever the longest alias suffix says, so `u` is the dalton and the `u` in `um` or `u~m` is the prefix.

#### Prefix–Symbol Ambiguities

Several prefix symbols overlap with base unit symbols. A base unit symbol is matched as the **longest alias suffix** of the component, so a bare unit always wins over a prefixed reading of the same token: `min` is the minute, never milli-inch, and `cd` is the candela, never centi-day. Where that is not the reading you want, the `~` separator states it: `m~in` is milli-inch, `m~` introduces the milli prefix, bare `m` is the meter.

| Symbol | As prefix | As base unit |
|--------|-----------|--------------|
| `m`    | milli     | meter        |
| `d`    | deci      | day          |
| `h`    | hecto     | hour         |
| `T`    | tera      | tesla        |
| `G`    | giga      | gauss (`GG` is giga-gauss) |
| `P`    | peta      | poise        |
| `R`    | ronna     | roentgen     |
| `f`    | femto     | *(none)* — the farad is `F`, uppercase |
| `a`    | atto      | *(none)*     |
| `u`    | micro (ASCII alias for `µ`) | dalton |
| `S`    | *(none)*  | siemens      |

`d~s` = decisecond; `d` alone = day.

### 4.2 IEC Binary Prefixes

IEC 80000-13 binary prefixes are used for digital quantities (`b` and `B` only). The table below also lists `Ri` and `Qi`; the standard stops at `Yi` (2⁸⁰) and those two are an unratified extension.

| Prefix | Symbol | Factor | Enum value |
|--------|--------|--------|------------|
| 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` |

#### Prefix–Unit Validity Constraints

- **IEC prefixes** (`Ki`…`Qi`) are only permitted on `b` and `B`. `Ki~m` → `error_unit_illegal`.
- **SI sub-kilo prefixes** (`d`, `c`, `m`, `µ`, `n`, `p`, `f`, `a`, `z`, `y`, `r`, `q`, `da`, `h`) are forbidden on `b` and `B`.
- **German units** (`bu_pfund` through `bu_scheffel`) accept only `si_none`/`iec_none`.
- **Ratio units** (`%`, `‰`, `‱`, `pcm`, `ppm`, `ppb`, `pptr`, `ppq`) likewise: a prefixed per-cent is meaningless. `k~%` → `error_unit_illegal` (§3.25).
- **Scales that are already a scale** — `pH`, `mph`, `kph`, the five water-hardness degrees, `gpg`, `CF`, `PSU`, `JTU` — take no prefix either. Each carries its reason in §3.26–§3.30; `NTU`, `FNU`, `FTU` and `FAU` *do* take one, because a milli-NTU is a real ultrapure-water measurement.
- **Currency units** accept SI prefixes of any magnitude (see §9.4). IEC prefixes are forbidden on all currency codes.

<!-- bovnar-example: rejected -->
```bovnar
.valid1   = <uint:64,Ki~B>  8;     # OK: IEC prefix on byte
.valid2   = <uint:32,M~b>   100;   # OK: SI mega on bit
.valid3   = <float_dec:64,k~$USD> 250.0;  # OK: kilo on currency
.invalid1 = <uint:64,Ki~m>  1;     # ERROR: IEC prefix on meter
.invalid2 = <uint:32,m~B>   512;   # ERROR: SI milli on byte
.invalid3 = <float_dec:64,Ki~$USD> 1;     # ERROR: IEC prefix on currency
```

### 4.3 Compact Prefix Form

A prefixed physical unit may be written without the `~` separator, in the spelling the rest of the world already uses:

```bovnar
.mass      = <float:64,kg>   72.5;      # same unit as k~g
.distance  = <float:64,km>    1.5;      # k~m
.period    = <float:64,ms>   16.7;      # m~s
.frequency = <float:64,MHz> 2400.0;     # M~Hz
.pressure  = <float:64,hPa> 1013.25;    # h~Pa
.energy    = <float:64,MeV>   13.6;     # M~eV
.amount    = <float:64,mmol>   2.5;     # m~mol
.memory    = <uint:64,MiB>   512;       # Mi~B
.force     = <float:64,kg·m/s²> 9.81;   # compounds, exponents and groups too
```

Both spellings produce the same `value_unit_t`, so they reconcile against each other (`<float:64,k~m> 1.5 km` is valid) and against a stored annotation. **The canonical output form is unchanged**: `bvn_unit_to_string` emits `k~g` for either spelling. What the compact form does survive is a re-serialisation: the reader hands the spelling over beside the unit (`unit_source`, §11.2), and `pretty-print` or a typed Python round trip writes `km` back as `km`. `--canonical` is the mode that does not.

The rules are exactly the rules of the separated form, with one addition:

- **A bare unit alias always wins.** The base symbol is the longest alias suffix, so `min` is the minute (not milli-inch), `cd` the candela (not centi-day), `ft` the foot (not femto-tonne), `at` the technical atmosphere (not atto-tonne), `dB` the decibel (not deci-byte). The separated form reaches the other reading: `m~in`, `c~d`, `f~t`.
- **Where two prefixed readings compete, the longer base symbol wins** — for the same reason. `dat` is deci-`at` (technical atmosphere), not deca-tonne; `dau` is deci-`au`, not deca-dalton. Write `da~t` / `da~u` for those.
- **A prefix cannot be stacked.** `kkg` and `k~kg` are both `error_unit_illegal`.
- **Prefix–unit validity is unchanged.** `Kim` fails for the same reason `Ki~m` does; so do `mB`, `kPfd`, `kppm`.
- **A few compact spellings are refused by name.** A token that is a well-known annotation for something *else* would otherwise become a valid — and quietly wrong — unit, which is the failure mode the format exists to prevent. These stay `error_unit_illegal`:

  | Refused | Would have meant | Usually means | Write instead |
  |---------|------------------|---------------|---------------|
  | `usb`   | microstilb       | the bus       | `u~sb` |
  | `kt`    | kilotonne        | *also* the knot | `k~t` (mass) or `kn` (speed) |
  | `ppt`   | picopint (a volume) | parts per trillion, *or* per thousand | `pptr` (10⁻¹²), `‰` (10⁻³), `p~pt` (the volume) |
  | `MBtu`, `MBTU`, `Mbtu`, `Mlb`, `Mlbs` | mega-Btu, mega-pound (10⁶) | a **thousand** Btu or pounds (US trade's Roman M) | `k~Btu`, `k~lb` (10³) or `M~Btu`, `M~lb` (10⁶) |

  `pH`, `mph` and `kph` were on this list until the quantities they name became
  units of their own (§3.26, §3.27). A bare alias outranks any prefixed reading,
  so they now need no exception — and `p~H`, `m~ph`, `k~ph` still mean what they
  always did.

  The `kt` and `ppt` rows are the harder cases. `kt` is a standard abbreviation for two units Bovnar *does* model — the kilotonne in climate and energy data, the knot in marine and aviation data — and reading a speed as a mass is precisely what this format exists to stop. `ppt` is worse: the compact rule makes it a *volume*, while every real use of the token is a dimensionless ratio, and the two ratios it might mean are 10⁹ apart. A token only the author can resolve is one the author has to resolve. The `MBtu`/`Mlb` row is a factor of a thousand with no error: the US gas, steam and commodity trades write a Roman M for a thousand (`MMBtu` for a million), where the compact rule reads the SI mega. Only the M forms are refused — `kBtu`, `GBtu` and `klb` are unaffected, and `MMBtu` was never a unit.

  Note what is **not** done about `ppt`: the parts-per-trillion unit added in §3.25 does not claim the spelling. A bare alias outranks any prefixed reading (rule 2 above), so claiming it would take `ppt` away from `p~pt` and change what an existing document means — which the rule at the top of `units.bvnr` forbids and `gen_units.py` refuses at build time. The unit is spelled `pptr`, following UCUM's own `[pptr]`, and the ambiguous token is refused instead.

  The list lives in `src/gendata/units.bvnr` (`.compact_exceptions`) and applies to the compact spelling only — `p~H` is still picohenry, `k~t` still the kilotonne, `p~pt` still the picopint, `M~Btu` still a million Btu.

Because a compact spelling is only ever reached where the separated form would have been a parse error, no document that parsed before this existed can parse differently now.

For a token-by-token list of every spelling that could be read two ways — including case traps, look-alike characters and abbreviations that are deliberately not units — see [`07_bovnar_unit_ambiguities.md`](07_bovnar_unit_ambiguities.md).

Currencies take the compact prefix too — `k$EUR` is `k~$EUR`. The `$` sigil already separates the prefix from the code (no prefix symbol and no currency code contains a `$`), so nothing is left for the `~` to resolve. What the sigil rule still requires is the sigil itself: `kUSD` is `error_unit_illegal`, because a bare code is never a currency (§10.4).

---

## 5. Unit Notation Grammar

### 5.1 Simple Units

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

The `~` separator is optional (`km` = `k~m`, `k$USD` = `k~$USD`; §4.3). The currency `$` sigil is not.

```bovnar
.temperature = <float:64,K>      300.0;   # kelvin
.distance    = <float:64,k~m>    1.5;     # kilometer
.frequency   = <float:64,M~Hz>   2400;    # megahertz
.storage     = <uint:64,Ki~B>    1024;    # kibibytes
.altitude    = <float:64,km>     10.5;    # kilometer, compact spelling
.fund_nav    = <float_dec:64,k~$USD> 250.0; # $250,000
```

### 5.2 Compound Units

```ebnf
compound-unit  = "no_unit" | unit-expr

unit-expr      = unit-factor { unit-sep unit-factor }

unit-factor    = unit-component | "(" unit-expr ")"    (* grouping: §5.4 *)

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

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

unit-exponent  = [ exp-sign ] exp-digit { exp-digit }
               | "^" [ "-" | "+" ] ASCII-digit { ASCII-digit }

exp-sign       = "⁺" | "⁻"

exp-digit      = "⁰" | "¹" | "²" | "³" | "⁴" | "⁵"
               | "⁶" | "⁷" | "⁸" | "⁹"
```

An exponent is **one or more** digits in either notation — `m¹⁰⁰` and `m^100`
are units, and this production is what §6 states in prose. `⁰` is a digit here
only in a *multi-digit* exponent: it exists so `m¹⁰⁰` can be written, and a
leading `m⁰` is still no unit (§6). At most three digits are scanned, so
`m^1000` fails as an unrecognised token rather than as an over-large exponent.

Currency codes participate in compound expressions using the same separators:

```bovnar
.gold_spot    = <float_dec:64,$USD/oz_t>   2351.40;  # $/troy oz
.rent         = <float_dec:64,$EUR/m²>       12.50;  # €/m²
.billing_rate = <float_dec:64,$EUR/h>       150.00;  # €/h
.eur_usd      = <float_dec:64,$USD/$EUR>      1.0842; # exchange rate
```

> The sub-grammar is **semantic**, not lexical. The outer lexer captures the type-annotation body as a raw byte sequence; `bvn_parse_unit` parses the unit string portion after the lexer finishes.

### 5.3 Separators

| Character | Code point | UTF-8 bytes | Meaning |
|-----------|-----------|-------------|---------|
| `*`       | U+002A    | `0x2A`      | Multiplication |
| `·`       | U+00B7    | `0xC2 0xB7` | Multiplication (preferred visual form) |
| `/`       | U+002F    | `0x2F`      | Division |

`·` and `*` are semantically identical and may be mixed freely.

### 5.4 Denominator Semantics

The first `/` sets a latching "in-denominator" flag to `true` for all subsequent components. Additional `/` separators do not toggle back to the numerator. `k~g·m/s²` stores:

```
[0]: gram,   exp_linear,     si_kilo  ← numerator
[1]: meter,  exp_linear,     si_none  ← numerator
[2]: second, exp_neg_square, si_none  ← denominator (exponent negated)
```

Both `k~g·m/s²` and `k~g·m·s⁻²` parse to identical `value_unit_t` representations.

#### 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
component exponents as a whole. This makes the readable denominator form work and
compose correctly:

```bovnar
.pressure = <float:64,k~g/(m·s²)> 101325;  # kg·m⁻¹·s⁻² — same as k~g/m·s²
.areal    = <float:64,(k~g/m)·s²> 1.0;     # kg·m⁻¹·s²  — grouping changes the s sign
.rate     = <float:64,m/(s·s)> 1.0;        # dimension m·s⁻², but not the unit m/s²
```

`k~g/(m·s²)` and `k~g/m·s²` therefore parse to the **same** `value_unit_t` (the
canonical, parenless form is what the writer emits). Rules: 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 — write `m²·s²`); parentheses must balance;
empty groups (`()`) are rejected; nesting is bounded at 16. Unmatched or malformed
groups raise `error_unit_illegal`.

---

## 6. Exponents

Exponents are integers in the range **−100 … +100**, with **zero reserved**: a
component raised to zero is malformed, not dimensionless, so `m^0` and `m⁰` are
not units. The bounds are `BVN_EXPONENT_MIN` and `BVN_EXPONENT_MAX` in
`include/bovnar.h`.

The named enumerators of `unit_exponent_t` cover only ±1…±9 and are kept because
that is what most units need and what existing source says. They are **not** the
whole domain — any integer in the range is a valid exponent, so a `switch` over
the type needs a `default` and code must not assume it can enumerate the values.

At most **three digits** are scanned, which is exactly what 100 needs. That is
what makes an over-long exponent fail cleanly: `m^1000` leaves a digit where the
scan expects the `^`, so no exponent is recognised at all and the whole token
fails to resolve as a base symbol. A value that scans but lands outside the
range — `m^200` — is refused on the range check.

### 6.1 Unicode Superscript Form

| Glyph | Code point | UTF-8 bytes      | Maps to |
|-------|-----------|------------------|---------|
| `⁰`   | U+2070    | `0xE2 0x81 0xB0` | digit 0 (multi-digit only) |
| `¹`   | U+00B9    | `0xC2 0xB9`      | `exp_linear` |
| `²`   | U+00B2    | `0xC2 0xB2`      | `exp_square` |
| `³`   | U+00B3    | `0xC2 0xB3`      | `exp_cubic` |
| `⁴`   | U+2074    | `0xE2 0x81 0xB4` | `exp_quartic` |
| `⁵`   | U+2075    | `0xE2 0x81 0xB5` | `exp_quintic` |
| `⁶`   | U+2076    | `0xE2 0x81 0xB6` | `exp_sextic` |
| `⁷`   | U+2077    | `0xE2 0x81 0xB7` | `exp_septic` |
| `⁸`   | U+2078    | `0xE2 0x81 0xB8` | `exp_octic` |
| `⁹`   | U+2079    | `0xE2 0x81 0xB9` | `exp_nonic` |
| `⁺`   | U+207A    | `0xE2 0x81 0xBA` | positive sign (no-op) |
| `⁻`   | U+207B    | `0xE2 0x81 0xBB` | negate exponent |

Digits **combine**, most-significant first, so `m¹⁰⁰` is the metre to the
hundredth and `s⁻¹²` is a reciprocal twelfth power. Note that the glyphs are not
one width — `¹ ² ³` are two UTF-8 bytes and `⁰ ⁴`–`⁹` are three — so `⁻¹⁰⁰` is
eleven bytes, and a parser must consume one digit at a time rather than assume a
stride.

`⁰` is a digit only *within* a multi-digit exponent. A lone `m⁰` is still not a
unit, because zero is reserved.

### 6.2 ASCII Caret Form

| ASCII form | Equivalent Unicode | Parsed as |
|------------|--------------------|-----------|
| `m^2`      | `m²`               | `exp_square` |
| `s^-2`     | `s⁻²`              | `exp_neg_square` |
| `m^+2`     | `m²`               | `exp_square` |
| `kg^1`     | `kg¹`              | `exp_linear` |
| `m^100`    | `m¹⁰⁰`             | exponent 100 |
| `s^-100`   | `s⁻¹⁰⁰`            | exponent −100 |

Up to **three ASCII digits** are permitted after the caret — exactly what
`BVN_EXPONENT_MAX` needs. `m^1000` is not an out-of-range exponent but an
unrecognised token: the scan stops after three digits, finds no `^`, and the
whole string fails to resolve.

### 6.3 Exponent Edge Cases

- **`exp_invalid` (value 0):** Zero-initialized sentinel. API functions that have an error output path reject it and signal an error. The two prefix query functions (`bvn_unit_prefix_factor`, `bvn_unit_prefix_exponent`) silently skip components with `exp_invalid`.
- **`exp_linear` (value 1):** Stored for both an explicit `¹`/`^1` and any component written without an exponent suffix.

---

## 7. The `no_unit` Keyword

The literal `no_unit` declares a value as **explicitly dimensionless**:

```bovnar
.ratio       = <float:64,no_unit> 0.95;
.count       = <uint:32,no_unit>  1000;
```

`bvn_parse_unit` detects `no_unit` via `memcmp` and returns `BVN_UNIT_NONE` (`num_components = 0`).

**Omitting the unit parameter** inside an annotation (e.g. `<float:64>`) yields the same `BVN_UNIT_NONE` (`num_components = 0`). Only a **fully unannotated** value (`1.0`) gets `BVN_UNIT_NO_PREFIX(bu_none)` (`num_components = 1`, `base == bu_none`), from default type synthesis. The two states are semantically equivalent — they compare as compatible via `bvn_units_compatible` and both serialize to `"no_unit"` — but they are structurally distinct.

---

## 8. Constraints and Limits

| Constraint | Value | Error on violation |
|------------|-------|--------------------|
| Maximum components per compound unit | 32 (`BVNR_MAX_UNIT_COMPONENTS`) | `error_unit_illegal` |
| Empty component between separators (e.g. `m//s`) | Not allowed | `error_unit_illegal` |
| Maximum raw unit string length | 255 bytes, but *which* cap fires depends on where the unit is written | `error_type_too_long` in an annotation, `error_unit_too_long` inline — see below |
| Null or empty unit string | Rejected by `bvn_parse_unit` | `ok = false` |

`a/b/c` parses as `a / (b·c)` → components `[a, b⁻¹, c⁻¹]`. The "no toggle back" rule means `/` always adds to the denominator; it never re-enters the numerator.

> **An over-long unit in an *annotation* is `error_type_too_long`, not `error_unit_too_long`.**
> Three 255-byte caps sit in front of the unit parser and the one that fires decides the code. The
> whole type-annotation body has its own cap, and it counts the family name, the width and the
> commas as well — so a unit parameter can never be the only thing over the line, and an annotated
> unit always reaches `error_type_too_long` first. An **inline** unit suffix has a buffer to
> itself and is the path that reaches `error_unit_too_long`. doc/11 §2.5
> tabulates all three caps beside the profile grammar.

---

## 9. Currency Codes

Currency amounts are dimensional quantities in financial computing. `$19.99 USD` carries a denomination dimension just as `9.81 m/s²` carries an acceleration dimension. Bovnar extends the unit system with 216 currency and cryptocurrency codes so that monetary data can be annotated and round-tripped with the same precision guarantees as physical measurements.

### 9.1 The `$` Sigil Rule

As of spec 1.0 a currency is recognised **only** in its `$`-sigil form (`$USD`, `$BTC`, or prefixed `k~$EUR` / `k$EUR`). The sigil — and nothing else — dispatches a component to the currency table; see §10.4 for the full rules and rationale.

Classification happens at the lookup stage, per unit component:

1. A component introduced by `$` (after any SI/IEC prefix and its `~`) is looked up in the **currency table**. If the code is not found there, `error_unit_illegal` is raised — a `$` introduces a currency and nothing else.
2. A component **without** a `$` is looked up only in the **physical unit table**. A bare code such as `USD` or `CUP` is therefore never a currency; if it is not a physical unit it is `error_unit_illegal`.

Because the sigil is mandatory, a bare uppercase code can never collide with a physical-unit symbol — present or future — so the two namespaces are disjoint by construction. The collision cases this resolves are catalogued for reference in §10.

### 9.2 ISO 4217 Fiat Currencies and Precious Metals

166 ISO 4217 alphabetic codes are supported, including precious-metal X-codes. They occupy the front of **block 90** of the `value_base_unit_t` id space (§11.1) — ids **900000 … 900165**, alphabetical from `AED` to `ZWL` — and, unlike physical units, have **no named `bu_*` enumerators**: such a currency is resolved from its `$`-sigil code by `bvn_parse_currency_str` and carried as the numeric `base` value. The catalogue in `bovnar_currency.c` is index-aligned to the block, so a lookup is `base - BVN_CURRENCY_FIRST`.

The alphabetical order is a convenience, not a contract. Whether a currency is fiat or crypto is a **column of the catalogue**, read by `bvn_unit_is_fiat` / `bvn_unit_is_crypto`, not a sub-range of the ids — so a new currency of either kind appends at the end of `currencies.bvnr` and renumbers nothing. Before 2.0 it *was* a sub-range, which is why `ZWG` and `XCG` had to be stranded at 378–379 outside the currency range entirely; the block has 10 000 ids and they are ordinary rows now.

Four codes are historical and retained for compatibility: `HRK` (Croatian Kuna, retired 2023-01-01 when Croatia adopted the Euro), `SLL` (Sierra Leonean Leone (old), replaced by `SLE` in 2022), `ZWL` (Zimbabwean Dollar, superseded by `ZWG` Zimbabwe Gold in 2024), and `BGN` (Bulgarian Lev, retired 2026-01-01 when Bulgaria adopted the Euro); `ANG` (Netherlands Antillean Guilder) likewise coexists with its successor `XCG` (Caribbean Guilder, which inherits ANG's numeric code 532).

The `minor_unit` field carries the exponent N such that 1 major unit = 10^N minor units (e.g. 1 USD = 100 cents, N=2). Applications reading integer-annotated values (e.g. `<uint:64,$KWD>`) should call `bvn_currency_minor_unit` to determine the correct decimal shift. Minor units are **bold** below when they differ from 2. `Num` is the ISO 4217 numeric identifier.

| Code | Num | Min | Name |
|------|----:|----:|------|
| `AED` |  784 |   2 | UAE Dirham |
| `AFN` |  971 |   2 | Afghan Afghani |
| `ALL` |    8 |   2 | Albanian Lek |
| `AMD` |   51 |   2 | Armenian Dram |
| `ANG` |  532 |   2 | Netherlands Antillean Guilder |
| `AOA` |  973 |   2 | Angolan Kwanza |
| `ARS` |   32 |   2 | Argentine Peso |
| `AUD` |   36 |   2 | Australian Dollar |
| `AWG` |  533 |   2 | Aruban Florin |
| `AZN` |  944 |   2 | Azerbaijani Manat |
| `BAM` |  977 |   2 | Bosnia-Herzegovina Convertible Mark |
| `BBD` |   52 |   2 | Barbados Dollar |
| `BDT` |   50 |   2 | Bangladeshi Taka |
| `BGN` |  975 |   2 | Bulgarian Lev *(historical; retired 2026-01-01)* |
| `BHD` |   48 | **3** | Bahraini Dinar |
| `BIF` |  108 | **0** | Burundian Franc |
| `BMD` |   60 |   2 | Bermudian Dollar |
| `BND` |   96 |   2 | Brunei Dollar |
| `BOB` |   68 |   2 | Boliviano |
| `BRL` |  986 |   2 | Brazilian Real |
| `BSD` |   44 |   2 | Bahamian Dollar |
| `BTN` |   64 |   2 | Bhutanese Ngultrum |
| `BWP` |   72 |   2 | Botswana Pula |
| `BYN` |  933 |   2 | Belarusian Ruble |
| `BZD` |   84 |   2 | Belize Dollar |
| `CAD` |  124 |   2 | Canadian Dollar |
| `CDF` |  976 |   2 | Congolese Franc |
| `CHF` |  756 |   2 | Swiss Franc |
| `CLF` |  990 | **4** | Unidad de Fomento |
| `CLP` |  152 | **0** | Chilean Peso |
| `CNY` |  156 |   2 | Chinese Yuan |
| `COP` |  170 |   2 | Colombian Peso |
| `CRC` |  188 |   2 | Costa Rican Colon |
| `CUP` |  192 |   2 | Cuban Peso |
| `CVE` |  132 |   2 | Cape Verdean Escudo |
| `CZK` |  203 |   2 | Czech Koruna |
| `DJF` |  262 | **0** | Djiboutian Franc |
| `DKK` |  208 |   2 | Danish Krone |
| `DOP` |  214 |   2 | Dominican Peso |
| `DZD` |   12 |   2 | Algerian Dinar |
| `EGP` |  818 |   2 | Egyptian Pound |
| `ERN` |  232 |   2 | Eritrean Nakfa |
| `ETB` |  230 |   2 | Ethiopian Birr |
| `EUR` |  978 |   2 | Euro |
| `FJD` |  242 |   2 | Fijian Dollar |
| `FKP` |  238 |   2 | Falkland Islands Pound |
| `GBP` |  826 |   2 | Pound Sterling |
| `GEL` |  981 |   2 | Georgian Lari |
| `GHS` |  936 |   2 | Ghanaian Cedi |
| `GIP` |  292 |   2 | Gibraltar Pound |
| `GMD` |  270 |   2 | Gambian Dalasi |
| `GNF` |  324 | **0** | Guinean Franc |
| `GTQ` |  320 |   2 | Guatemalan Quetzal |
| `GYD` |  328 |   2 | Guyanese Dollar |
| `HKD` |  344 |   2 | Hong Kong Dollar |
| `HNL` |  340 |   2 | Honduran Lempira |
| `HRK` |  191 |   2 | Croatian Kuna *(historical; retired 2023-01-01)* |
| `HTG` |  332 |   2 | Haitian Gourde |
| `HUF` |  348 |   2 | Hungarian Forint |
| `IDR` |  360 |   2 | Indonesian Rupiah |
| `ILS` |  376 |   2 | Israeli New Shekel |
| `INR` |  356 |   2 | Indian Rupee |
| `IQD` |  368 | **3** | Iraqi Dinar |
| `IRR` |  364 |   2 | Iranian Rial |
| `ISK` |  352 | **0** | Icelandic Krona |
| `JMD` |  388 |   2 | Jamaican Dollar |
| `JOD` |  400 | **3** | Jordanian Dinar |
| `JPY` |  392 | **0** | Japanese Yen |
| `KES` |  404 |   2 | Kenyan Shilling |
| `KGS` |  417 |   2 | Kyrgyzstani Som |
| `KHR` |  116 |   2 | Cambodian Riel |
| `KMF` |  174 | **0** | Comorian Franc |
| `KPW` |  408 |   2 | North Korean Won |
| `KRW` |  410 | **0** | South Korean Won |
| `KWD` |  414 | **3** | Kuwaiti Dinar |
| `KYD` |  136 |   2 | Cayman Islands Dollar |
| `KZT` |  398 |   2 | Kazakhstani Tenge |
| `LAK` |  418 |   2 | Laotian Kip |
| `LBP` |  422 |   2 | Lebanese Pound |
| `LKR` |  144 |   2 | Sri Lankan Rupee |
| `LRD` |  430 |   2 | Liberian Dollar |
| `LSL` |  426 |   2 | Lesotho Loti |
| `LYD` |  434 | **3** | Libyan Dinar |
| `MAD` |  504 |   2 | Moroccan Dirham |
| `MDL` |  498 |   2 | Moldovan Leu |
| `MGA` |  969 |   2 | Malagasy Ariary |
| `MKD` |  807 |   2 | Macedonian Denar |
| `MMK` |  104 |   2 | Myanmar Kyat |
| `MNT` |  496 |   2 | Mongolian Togrog |
| `MOP` |  446 |   2 | Macanese Pataca |
| `MRU` |  929 |   2 | Mauritanian Ouguiya |
| `MUR` |  480 |   2 | Mauritian Rupee |
| `MVR` |  462 |   2 | Maldivian Rufiyaa |
| `MWK` |  454 |   2 | Malawian Kwacha |
| `MXN` |  484 |   2 | Mexican Peso |
| `MYR` |  458 |   2 | Malaysian Ringgit |
| `MZN` |  943 |   2 | Mozambican Metical |
| `NAD` |  516 |   2 | Namibian Dollar |
| `NGN` |  566 |   2 | Nigerian Naira |
| `NIO` |  558 |   2 | Nicaraguan Cordoba |
| `NOK` |  578 |   2 | Norwegian Krone |
| `NPR` |  524 |   2 | Nepalese Rupee |
| `NZD` |  554 |   2 | New Zealand Dollar |
| `OMR` |  512 | **3** | Omani Rial |
| `PAB` |  590 |   2 | Panamanian Balboa |
| `PEN` |  604 |   2 | Peruvian Sol |
| `PGK` |  598 |   2 | Papua New Guinean Kina |
| `PHP` |  608 |   2 | Philippine Peso |
| `PKR` |  586 |   2 | Pakistani Rupee |
| `PLN` |  985 |   2 | Polish Zloty |
| `PYG` |  600 | **0** | Paraguayan Guarani |
| `QAR` |  634 |   2 | Qatari Riyal |
| `RON` |  946 |   2 | Romanian Leu |
| `RSD` |  941 |   2 | Serbian Dinar |
| `RUB` |  643 |   2 | Russian Ruble |
| `RWF` |  646 | **0** | Rwandan Franc |
| `SAR` |  682 |   2 | Saudi Riyal |
| `SBD` |   90 |   2 | Solomon Islands Dollar |
| `SCR` |  690 |   2 | Seychellois Rupee |
| `SDG` |  938 |   2 | Sudanese Pound |
| `SEK` |  752 |   2 | Swedish Krona |
| `SGD` |  702 |   2 | Singapore Dollar |
| `SHP` |  654 |   2 | Saint Helena Pound |
| `SLE` |  925 |   2 | Sierra Leonean Leone |
| `SLL` |  694 |   2 | Sierra Leonean Leone (old) *(historical; replaced by SLE 2022)* |
| `SOS` |  706 |   2 | Somali Shilling |
| `SSP` |  728 |   2 | South Sudanese Pound |
| `SRD` |  968 |   2 | Surinamese Dollar |
| `STN` |  930 |   2 | Sao Tome and Principe Dobra |
| `SVC` |  222 |   2 | Salvadoran Colon |
| `SYP` |  760 |   2 | Syrian Pound |
| `SZL` |  748 |   2 | Swazi Lilangeni |
| `THB` |  764 |   2 | Thai Baht |
| `TJS` |  972 |   2 | Tajikistani Somoni |
| `TMT` |  934 |   2 | Turkmenistan Manat |
| `TND` |  788 | **3** | Tunisian Dinar |
| `TOP` |  776 |   2 | Tongan Pa'anga |
| `TRY` |  949 |   2 | Turkish Lira |
| `TTD` |  780 |   2 | Trinidad and Tobago Dollar |
| `TWD` |  901 |   2 | New Taiwan Dollar |
| `TZS` |  834 |   2 | Tanzanian Shilling |
| `UAH` |  980 |   2 | Ukrainian Hryvnia |
| `UGX` |  800 | **0** | Ugandan Shilling |
| `USD` |  840 |   2 | US Dollar |
| `UYU` |  858 |   2 | Uruguayan Peso |
| `UZS` |  860 |   2 | Uzbekistani Som |
| `VES` |  928 |   2 | Venezuelan Bolivar Soberano |
| `VND` |  704 | **0** | Vietnamese Dong |
| `VUV` |  548 | **0** | Vanuatu Vatu |
| `WST` |  882 |   2 | Samoan Tala |
| `XAF` |  950 | **0** | CFA Franc BEAC |
| `XAG` |  961 | **0** | Silver |
| `XAU` |  959 | **0** | Gold |
| `XCD` |  951 |   2 | East Caribbean Dollar |
| `XCG` |  532 |   2 | Caribbean Guilder |
| `XDR` |  960 | **0** | Special Drawing Rights |
| `XOF` |  952 | **0** | CFA Franc BCEAO |
| `XPD` |  964 | **0** | Palladium |
| `XPF` |  953 | **0** | CFP Franc |
| `XPT` |  962 | **0** | Platinum |
| `XTS` |  963 | **0** | Test currency (ISO 4217 reserved; do not use in production) |
| `YER` |  886 |   2 | Yemeni Rial |
| `ZAR` |  710 |   2 | South African Rand |
| `ZMW` |  967 |   2 | Zambian Kwacha |
| `ZWG` |  924 |   2 | Zimbabwe Gold |
| `ZWL` |  932 |   2 | Zimbabwean Dollar *(historical; superseded by ZWG 2024)* |

> `CLF` (Unidad de Fomento) is the only currency with 4 minor units **in this catalogue**. ISO 4217 also defines `UYW` with 4, but the catalogue omits the fund and bond codes (`BOV CHE CHW COU MXV USN UYI UYW`, `XBA`–`XBD`, `XSU`, `XUA`) and the no-currency code `XXX`: they denote accounting units and placeholders rather than money a value can be denominated in. The four historical codes `HRK`, `SLL`, `ZWL`, and `BGN` are retained for compatibility but should not be used for new data.

### 9.3 Cryptocurrencies

50 cryptocurrencies are supported, with 3- or 4-letter uppercase tickers. They occupy ids **900166 … 900215**, after the fiat codes in the same block 90, and like them have **no named `bu_*` enumerators** — they are resolved by `bvn_parse_currency_str` and carried as the numeric `base` value. That they happen to follow the fiat run carries no meaning: `bvn_unit_is_crypto` reads the catalogue row, not the id (§9.2). The `minor_unit` field holds the canonical on-chain decimal places. `numeric_code = 0` for all cryptocurrencies.

> **Min** = `minor_unit` = on-chain decimal places. E.g. `<uint:64,$BTC>` stores satoshis; divide by 10⁸ to obtain whole BTC.

| Code   | Min | Subunit | Name |
|--------|----:|---------|------|
| `BTC`  |   8 | satoshi | Bitcoin |
| `ETH`  |  18 | wei | Ethereum |
| `SOL`  |   9 | lamport | Solana |
| `XRP`  |   6 | drop | XRP |
| `BNB`  |  18 | — | BNB |
| `ADA`  |   6 | lovelace | Cardano |
| `LTC`  |   8 | — | Litecoin |
| `DOT`  |  10 | planck | Polkadot |
| `XMR`  |  12 | piconero | Monero |
| `ETC`  |  18 | — | Ethereum Classic |
| `BCH`  |   8 | — | Bitcoin Cash |
| `XLM`  |   7 | stroop | Stellar |
| `FIL`  |  18 | — | Filecoin |
| `ICP`  |   8 | — | Internet Computer |
| `TRX`  |   6 | — | TRON |
| `EOS`  |   4 | — | EOS |
| `VET`  |  18 | — | VeChain |
| `NEO`  |   0 | — | Neo |
| `ZEC`  |   8 | — | Zcash |
| `UNI`  |  18 | — | Uniswap |
| `ARB`  |  18 | — | Arbitrum |
| `SUI`  |   9 | — | Sui |
| `TON`  |   9 | — | Toncoin |
| `INJ`  |  18 | — | Injective |
| `SEI`  |   6 | — | Sei |
| `APT`  |   8 | — | Aptos |
| `TAO`  |   9 | — | Bittensor |
| `WIF`  |   6 | — | dogwifhat |
| `DOGE` |   8 | koinu | Dogecoin |
| `LINK` |  18 | — | Chainlink |
| `USDT` |   6 | — | Tether |
| `USDC` |   6 | — | USD Coin |
| `AVAX` |  18 | — | Avalanche |
| `ATOM` |   6 | — | Cosmos |
| `POL`  |  18 | — | Polygon |
| `NEAR` |  24 | — | NEAR Protocol |
| `ALGO` |   6 | — | Algorand |
| `HBAR` |   8 | — | Hedera |
| `AAVE` |  18 | — | Aave |
| `MKR`  |  18 | — | Maker |
| `DAI`  |  18 | — | Dai |
| `STX`  |   6 | — | Stacks |
| `GRT`  |  18 | — | The Graph |
| `LDO`  |  18 | — | Lido DAO |
| `BONK` |   5 | — | Bonk |
| `PEPE` |  18 | — | Pepe |
| `SHIB` |  18 | — | Shiba Inu |
| `JUP`  |   6 | — | Jupiter |
| `PYTH` |   6 | — | Pyth Network |
| `RUNE` |   8 | — | THORChain |

### 9.4 Prefix Rules for Currency Units

**All SI prefixes** are permitted on all currency units. `k~$USD` denotes "values in thousands of USD" — a common scale annotation in financial reporting. As with a physical unit (§4.3), the `~` is optional: `k$USD` is the same unit, because the `$` sigil already separates the prefix from the code.

```bovnar
.fund_nav   = <float_dec:64,k~$USD>    250.0;   # $250,000
.gdp        = <float_dec:64,M$EUR>  42800.0;    # €42.8 billion — compact prefix
.eth_gwei   = <float_dec:64,G~$ETH>    35.0;    # 35 Gwei gas price
```

The sigil itself is **not** optional in either spelling: `kUSD` is `error_unit_illegal`, exactly as bare `USD` is.

**IEC binary prefixes** (`Ki~`, `Mi~`, …) are **forbidden** on all currency units, compact spelling included (`Ki$USD` is an error too). `bvn_currency_prefix_valid()` returns `false` for any IEC prefix; the parser raises `error_unit_illegal`.

### 9.5 Compound Currency Expressions

Currency codes participate in compound unit expressions using the existing separators:

```bovnar
.gold_spot    = <float_dec:64,$USD/oz_t>    2351.40;  # $/troy oz
.wheat        = <float_dec:64,$USD/bsh>        5.82;  # $/bushel
.rent         = <float_dec:64,$EUR/m²>        12.50;  # €/m²
.billing_rate = <float_dec:64,$EUR/h>         150.00; # €/h
.eur_usd      = <float_dec:64,$USD/$EUR>        1.0842; # exchange rate
.eth_btc      = <float_dec:64,$BTC/$ETH>       0.05610; # cross-crypto rate
```

Currency × currency compounds (`$USD·$EUR`) are syntactically valid and produce no error. Their financial interpretation is the application's responsibility.

#### Exchange Rate Timestamps

Bovnar annotates denomination; it does not store exchange rates or timestamps. The timestamp belongs in a separate field:

```bovnar
.snapshot = {
    .epoch    = <uint:64,s>              1716400000;
    .eur_usd  = <float_dec:64,$USD/$EUR>        1.0842;
};
```

### 9.6 Compatibility Rules

**`bvn_units_compatible()` returns `false` for every unit containing a currency — including a currency against itself.** It is dimension-based, and a currency deliberately has no SI conversion row, so `bvn_unit_dimension_vector` fails and the compatibility test fails with it. `bvn_units_compatible($USD, $USD)` is `false`.

Do not use it as the gate before converting money: it refuses the identity. Use the conversion functions directly — `bvn_unit_convert_factor`, `bvn_unit_convert_value`, `bvn_unit_convert_rational` — which carry their own currency path and report success or failure themselves.

What that path accepts is **the same currency expression, differing at most in prefixes**:

| From | To | Result |
|------|----|--------|
| `$USD` | `$USD` | 1 (identity) |
| `k~$USD` | `$USD` | 1000 — a prefix delta is an exact power of ten (or two, for IEC), so it stays lossless |
| `$USD·oz_t⁻¹` | `oz_t⁻¹·$USD` | 1 — the match is a multiset, because unit multiplication commutes |
| `$USD` | `$EUR` | refused — two different currency ids, and the library holds no exchange rate |
| `$USD/oz_t` | `$USD/g` | **refused**, though only the denominator changes and no rate is involved. A compound carrying a currency is matched as a whole; the physical part is not converted independently. Convert the physical quantity separately if you need this |
| `$USD` | `m`, `no_unit`, `%` | refused |

The mechanism is `bvn_unit_prefix_only_delta` in `bovnar_si_units.c`, consulted only *after* `bvn_units_compatible` has said no — so a unit that does have an SI row keeps taking the ordinary, fully general path.

### 9.7 Type Pairing Recommendations

| Use case | Recommended annotation | Rationale |
|----------|----------------------|-----------|
| Decimal monetary amount | `<float_dec:64,$USD>` | Exact decimal; 16 significant digits |
| High-precision / actuarial | `<float_dec:128,$USD>` | 34 significant digits |
| Integer minor-unit storage | `<uint:64,$USD>` | Value in cents; app reads `minor_unit()` |
| Negative balances in minor units | `<sint:64,$USD>` | |
| Zero-minor-unit currency | `<uint:64,$JPY>` | Integer is the only correct representation |
| 3-minor-unit currency | `<uint:64,$KWD>` | Value in fils |
| Commodity price | `<float_dec:64,$USD/oz_t>` | $/troy oz |
| Exchange rate | `<float_dec:64,$USD/$EUR>` | USD per EUR |
| On-chain satoshi balance | `<uint:64,$BTC>` | Integer satoshis |
| Human-readable BTC amount | `<float_dec:64,$BTC>` | |

> **`float` (binary floating-point) is discouraged** for monetary amounts. Binary fractions cannot represent 0.10 USD exactly. Use `float_dec` for decimal-exact storage.
>
> **`float_fix` is wrong** for monetary values. Q-format stores values as `integer × 2^(-N)` — a binary fractional resolution. No power of 2 equals a power of 10 (except 2⁰ = 10⁰ = 1), so no Q value exactly represents cents.

---

## 10. Symbol Disambiguation

This section documents how a physical-unit token and a currency token are kept apart. The mandatory `$` currency sigil (§10.4) is the normative rule and resolves every potential collision; the look-alike tables that follow are retained for reference.

### 10.1 The Namespace Rule as Disambiguator

The mandatory `$` sigil (§9.1, normative rule in §10.4) makes the two namespaces disjoint by construction: the sigil — and nothing else — selects the currency table, so a bare token is **always** a physical-unit lookup and can never be mistaken for a currency.

| Written token | Looked up in | Result |
|---|---|---|
| `$USD`, `$BTC`, `k~$EUR`, `k$EUR` | currency table (sigil present) | currency, or `error_unit_illegal` if the code is unknown |
| `m`, `Hz`, `cup`, `BTU`, `k~g` (no `$`) | physical unit table only | physical unit, or `error_unit_illegal` if unknown |
| `USD`, `CUP`, `XYZ` (no `$`) | physical unit table only | `error_unit_illegal` — not physical units, and a bare code is never a currency |

Because the lookup is sigil-driven rather than spelling-driven, **case is no longer load-bearing for disambiguation**: `cup` and `CUP` are both physical-unit lookups (the first matches `bu_cup`, the second is `error_unit_illegal`), and the Cuban Peso is written `$CUP`.

### 10.2 Exhaustive Conflict Table

Before the sigil, several uppercase tokens *looked* like they could be either a physical unit or a currency. The sigil removes the ambiguity outright; the table below is retained as a reference for the codes that previously needed disambiguation. In every row, the bare form is a physical-unit lookup and the currency is only ever the `$`-prefixed form.

| Token | Bare form (no `$`) | `$`-sigil form | Notes |
|-------|--------------------|----------------|-------|
| `cup` / `CUP` | `cup` → US cup (`bu_cup`); `CUP` → `error_unit_illegal` | `$CUP` → Cuban Peso (ISO 4217:192) | the classic look-alike, now fully separated |
| `BTU` | `BTU` → International Table BTU (`bu_btu`); `Btu`, `btu` also accepted | *(not ISO 4217)* | uppercase `BTU` is a physical alias, not a currency |
| `SOL` | `SOL` → `error_unit_illegal` (no physical unit) | `$SOL` → Solana (crypto) | |
| `BAR` | `BAR` → `error_unit_illegal`; use lowercase `bar` | *(not ISO 4217)* | |
| `ERG` | `ERG` → `error_unit_illegal`; use lowercase `erg` | *(not ISO 4217)* | |
| `CAD`, `AUD`, `GBP`, `XAU` | `error_unit_illegal` (no physical unit) | `$CAD`, `$AUD`, `$GBP`, `$XAU` → the respective currencies | |

**Key finding:** with the sigil, no token is simultaneously a valid bare physical-unit symbol and a valid currency — currencies live entirely under `$`, physical units entirely without it.

### 10.3 The CUP Case in Detail

`CUP` is the classic example because the ISO 4217 code for the Cuban Peso shares its letters with the English word for the culinary measure. Under the sigil rule the two are unambiguous:

| Written in BVNR | Resolved as | Enum value | SI factor |
|-----------------|-------------|------------|-----------|
| `cup`           | US cup      | `bu_cup`   | 2.365882365×10⁻⁴ m³ |
| `cups`          | US cup (long form) | `bu_cup` | 2.365882365×10⁻⁴ m³ |
| `CUP`           | *(error)* — bare uppercase is not a physical unit | — | `error_unit_illegal` |
| `$CUP`          | Cuban Peso  | `CUP_` *(currency enum, ISO 4217:192)* | — (monetary, no SI factor) |

```bovnar
.recipe_volume = <float_dec:32,cup>  2.0;    # 2 US cups (volume)
.balance       = <float_dec:64,$CUP> 15.00;  # 15 Cuban Pesos (currency)
# .bad         = <float_dec:64,CUP>  15.00;  # error_unit_illegal: bare 'CUP' is not a unit
```

Calling `bvn_unit_to_si_factor` on a `$CUP` unit returns `*ok = false` because `bvn_find_si_conv` skips currency enum values. Calling `bvn_unit_is_currency` on a `cup` unit returns `false`. The two are completely disjoint in both parsing and the conversion API — and, because the peso *must* be written `$CUP`, a user who writes the bare `CUP` intending the culinary cup gets an immediate `error_unit_illegal` (the correct spelling is lowercase `cup`) rather than a silently-accepted currency.

### 10.4 The Mandatory Currency Sigil

A currency code carries a **mandatory `$` sigil** as of spec 1.0. This is the resolution of the `CUP` (Cuban Peso) vs `cup` (the physical cup) namespace collision: a currency is *only* recognised in its sigil form, so a bare code can never collide with a physical-unit symbol — present or future.

```bovnar
# Currencies — the '$' sigil is required:
.price   = <float_dec:64,$USD>      19.99;   # US Dollar
.btc     = <uint:64,$BTC>        54782000;   # Bitcoin (satoshis)
.fund    = <float_dec:64,k~$EUR>   250.0;    # kilo-Euro (prefix before the sigil)
.aum     = <float_dec:64,M$EUR>    42.8;     # mega-Euro, compact prefix (= M~$EUR)
.spot    = <float_dec:64,$USD/oz_t> 2351.40; # currency / physical-unit compound

# A bare code is no longer a currency:
.volume  = <float_dec:32,cup>        2.0;    # the physical 'cup', unambiguously
# .bad   = <float_dec:64,USD> 1.0;            # error_unit_illegal: bare 'USD' is not a unit (needs '$')
```

The sigil attaches directly before the currency code, after any SI/IEC prefix and its optional `~` (`k~$EUR` or `k$EUR`, `M~$USDT` or `M$USDT`; §4.3). It is accepted in inline units and type annotations alike, and the writer emits it on output so values round-trip. Because this **breaks** any document that used bare currency codes, it was made before the 1.0 freeze — afterwards it would be an incompatible change (see the Versioning & Stability section of the specification).

---

## 11. C Data Model

### 11.1 Enumerations

#### `prefix_system_t`

```c
typedef enum prefix_system_e {
    prefix_si,      /* SI decimal prefixes (or no prefix) */
    prefix_iec      /* IEC binary prefixes                */
} prefix_system_t;
```

#### `si_prefix_id_t`

```c
typedef enum si_prefix_id_e {
    si_none = 0,
    si_quecto, si_ronto, si_yocto, si_zepto, si_atto,
    si_femto,  si_pico,  si_nano,  si_micro, si_milli,
    si_centi,  si_deci,
    si_deca,   si_hecto, si_kilo,  si_mega,  si_giga,
    si_tera,   si_peta,  si_exa,   si_zetta, si_yotta,
    si_ronna,  si_quetta
} si_prefix_id_t;
```

#### `iec_prefix_id_t`

```c
typedef enum iec_prefix_id_e {
    iec_none = 0,
    iec_kibi, iec_mebi, iec_gibi, iec_tebi, iec_pebi,
    iec_exbi, iec_zebi, iec_yobi, iec_robi, iec_quebi
} iec_prefix_id_t;
```

#### `value_base_unit_t`

A base unit's id is **blocked**, not a running counter: the leading two decimal digits name the vocabulary it comes from and the four digits after them its position within that vocabulary. Each block therefore holds 10 000 ids, and a vocabulary that grows shifts nothing outside itself.

| Block | Ids | Vocabulary | Source |
|-------|-----|------------|--------|
| 10 | 100000–109999 | native bovnar units | `src/gendata/units.bvnr` |
| 20 | 200000–209999 | UCUM opaque units | `src/gendata/ucum.bvnr` |
| 30 | 300000–309999 | UN/ECE opaque units | `src/gendata/unece.bvnr` |
| 40 | 400000–409999 | QUDT opaque units | `src/gendata/qudt.bvnr` |
| 50 | 500000–509999 | QUDT quantity kinds | `src/gendata/qudt-qk.bvnr` |
| 60 | 600000–609999 | UDUNITS-2 opaque units | `src/gendata/udunits.bvnr` |
| 70 | 700000–709999 | OM 2 opaque units | `src/gendata/om.bvnr` |
| 80 | 800000–809999 | CF standard names | `src/gendata/cf.bvnr` |
| 90 | 900000–909999 | currencies | `src/gendata/currencies.bvnr` |

`bu_none` is 0 and belongs to no block; every tag between the native units and the currencies is now taken, so a further vocabulary needs one outside 10–90. Blocks 40–80 currently contribute no ids at all: those vocabularies map every code onto a native unit, and the block tag reserves their room rather than filling it. Within block 10 the native units run contiguously from `bu_bit` = 100000 to `bu_stattesla` = 100263, in the order of `units.bvnr`; `BVN_UNIT_NATIVE_FIRST`, `BVN_UNIT_NATIVE_LAST` and `BVN_UNIT_NATIVE_COUNT` are generated beside the enum, so read the bounds from those rather than from this sentence. Currencies run contiguously from 900000 and — unlike every other block — have **no named `bu_*` enumerators** (see §9.2/§9.3): a currency is written by its ISO 4217 code behind a `$` sigil, resolved by `bvn_parse_currency_str` and carried as the numeric `base` value. `bvn_unit_is_currency(base)` is a bounds check over block 90.

Only a vocabulary that contributes units of its *own* takes a block. A unit profile is a spelling for the unit slot, so most of its codes translate to native units and carry native ids; the ones that get a block id of their own are the **opaque** units — codes with no native equivalent and no dimension, such as UCUM's assay-defined `[IU]` or UN/ECE's package types — which need an identity precisely because nothing else can stand in for them.

> **Why blocks.** One flat counter makes every vocabulary's ids a function of every other vocabulary's size, and that was not hypothetical. This space previously had physical units at 1–133 *and again* at 348–396 because the currencies had been dropped in between; two currencies stranded at 378–379, outside their own range, because that range had been frozen; and the profiles' units pinned above all of it. Adding a currency shifted units. The 2.0 renumbering ended that, and made an id self-describing: 200017 is UCUM's, whatever else the build contains.
>
> The price is a **sparse** space, so do not index an array by a base unit id. The library's own tables are dense, one row per defined unit, indexed by `bvni_unit_slot()`; `BVN_UNIT_SLOT_COUNT` is that row count. A bounds check is no longer a membership test either — ask `bvn_unit_valid` on the unit, or `bvn_unit_is_currency` on the base.

```c
typedef enum value_base_unit_e {
    bu_none = 0,            /* dimensionless / no unit; in no block */

    /* Digital — block 10 opens here */
    bu_bit = 100000, bu_byte,

    /* SI base */
    bu_second, bu_meter, bu_gram, bu_ampere, bu_kelvin,
    bu_mol, bu_candela,

    /* Named SI-derived */
    bu_hertz, bu_newton, bu_pascal, bu_joule, bu_watt,
    bu_volt,  bu_ohm,   bu_farad,  bu_coulomb, bu_siemens,
    bu_weber, bu_tesla, bu_henry,  bu_lumen,   bu_lux,
    bu_becquerel, bu_gray, bu_sievert, bu_katal,
    bu_liter, bu_minute, bu_hour, bu_day, bu_degree, bu_celsius,
    bu_radian, bu_steradian,
    bu_tonne, bu_bar,
    bu_electronvolt, bu_dalton, bu_astronomical_unit,
    bu_hectare, bu_week, bu_year,

    /* Imperial/US — length */
    bu_inch, bu_foot, bu_yard, bu_mile, bu_nautical_mile,
    bu_angstrom, bu_light_year, bu_parsec, bu_furlong, bu_fathom,

    /* Imperial/US — mass */
    bu_pound, bu_ounce, bu_grain, bu_stone, bu_short_ton,
    bu_long_ton, bu_troy_ounce, bu_carat,

    /* Temperature */
    bu_fahrenheit,

    /* Pressure */
    bu_atmosphere, bu_mmhg, bu_torr, bu_psi,

    /* Energy */
    bu_calorie, bu_btu, bu_erg, bu_therm,

    /* Power */
    bu_horsepower,

    /* Force */
    bu_pound_force, bu_dyne, bu_kip,

    /* Speed */
    bu_knot,

    /* Volume — US */
    bu_gallon, bu_gallon_uk, bu_quart, bu_pint, bu_cup,
    bu_fluid_ounce, bu_tablespoon, bu_teaspoon, bu_barrel,

    /* Area */
    bu_acre, bu_barn,

    /* Angle */
    bu_arcminute, bu_arcsecond, bu_grad,

    /* CGS */
    bu_poise, bu_stokes, bu_gauss, bu_maxwell, bu_oersted,
    bu_stilb, bu_phot, bu_galileo,

    /* Radiation */
    bu_curie, bu_roentgen, bu_rem,

    /* Logarithmic */
    bu_neper, bu_decibel,

    bu_rankine,             /* Temperature — absolute Fahrenheit scale */
    bu_slug,                /* Imperial mass */
    bu_thou,                /* Imperial length */

    /* Volume — UK imperial */
    bu_pint_uk, bu_fluid_ounce_uk, bu_quart_uk,

    /* Electrical power */
    bu_var, bu_volt_ampere,

    /* Force (additional) */
    bu_kilogram_force,

    /* Pressure (additional) */
    bu_inch_hg,

    /* Rotational frequency */
    bu_rpm,

    /* Energy (additional) */
    bu_foot_pound,

    /* Mass (additional) */
    bu_dram, bu_pennyweight,

    /* Length (additional) */
    bu_chain, bu_rod,

    /* Volume (additional) */
    bu_gill, bu_gill_uk,

    /* Acceleration / gravity */
    bu_standard_gravity,

    /* Power (additional) */
    bu_metric_horsepower,

    /* Angle (additional) */
    bu_revolution,

    /* Time (additional) */
    bu_month, bu_fortnight,

    /* Pressure (additional) */
    bu_atmosphere_technical,

    /* Textile */
    bu_tex, bu_denier,

    /* Apothecary / dry volume */
    bu_fluid_dram, bu_minim, bu_peck, bu_bushel,

    /* Old German */
    bu_pfund, bu_zentner, bu_doppelzentner, bu_lot,
    bu_prussian_line, bu_prussian_zoll, bu_prussian_fuss,
    bu_prussian_elle, bu_prussian_rute, bu_klafter,
    bu_german_mile, bu_morgen, bu_scheffel,

    /* Additional physical units */
    bu_survey_foot, bu_league, bu_cable, bu_hand,
    bu_quintal, bu_scruple, bu_baud,

    /* Historical temperature scales */
    bu_delisle, bu_newton_temp, bu_reaumur, bu_romer,

    /* Dimensionless ratio units */
    bu_percent, bu_per_mille, bu_per_myriad,
    bu_per_cent_mille, bu_ppm, bu_ppb,   /* bu_ppt and bu_ppq close
                                          * block 10 — see the note below */

    /* … the pH scale, the named speed units, the five water-hardness
     * degrees, the concentration units, and the turbidity and salinity
     * scales — then: */

    /* Temperature differences (Δ°C shares bu_delta_kelvin, Δ°Ra shares
     * bu_delta_fahrenheit — the same interval, so an alias not an id) */
    bu_delta_kelvin, bu_delta_fahrenheit, bu_delta_delisle,
    bu_delta_newton_temp, bu_delta_reaumur, bu_delta_romer,

    /* … the six units the profiles needed, the US survey lengths, the
     * typographic lengths, the US dry volumes and the trade measures, and
     * the rest of block 10 up to bu_stattesla = 100263. The whole run is
     * generated into include/bovnar_units.gen.h from
     * src/gendata/units.bvnr, in that file's order — read it there rather
     * than from this abridged listing. */

    /* Block 90 — the currencies — has NO named enumerators: a currency is
     * resolved by string via bvn_parse_currency_str and carried as the
     * numeric base value, and the catalogue in bovnar_currency.c is
     * index-aligned to BVN_CURRENCY_FIRST. */

    /* Blocks 20–80 — the profiles' opaque units — are generated into
     * include/bovnar_profiles.gen.h: bu_ucum_iu = 200000, bu_unece_one =
     * 300000, and so on. Only 20 and 30 contribute any today. */
} value_base_unit_t;

/* Not a member of the enum, and NOT a bound on it: the number of rows in the
 * dense tables the library indexes by bvni_unit_slot(). The id space is
 * sparse, so it has no "one past the end". */
/* #define BVN_UNIT_SLOT_COUNT 330 */
```

#### `unit_exponent_t`

```c
#define BVN_EXPONENT_MIN	(-100)
#define BVN_EXPONENT_MAX	( 100)

typedef enum unit_exponent_e {
    exp_range_min  = BVN_EXPONENT_MIN,
    exp_range_max  = BVN_EXPONENT_MAX,
    exp_invalid    =   0,
    exp_linear     =   1,  exp_square    =  2,  exp_cubic     =  3,
    exp_quartic    =   4,  exp_quintic   =  5,  exp_sextic    =  6,
    exp_septic     =   7,  exp_octic     =  8,  exp_nonic     =  9,
    exp_neg_linear =  -1,  exp_neg_square= -2,  exp_neg_cubic = -3,
    exp_neg_quartic=  -4,  exp_neg_quintic=-5,  exp_neg_sextic= -6,
    exp_neg_septic =  -7,  exp_neg_octic = -8,  exp_neg_nonic = -9,
} unit_exponent_t;
```

**The named enumerators are not the range.** They cover ±1…±9 and are kept because callers use them;
the type carries any integer in `[BVN_EXPONENT_MIN, BVN_EXPONENT_MAX]` = ±100, which is what §6 and
the grammar describe. `exp_range_min`/`exp_range_max` are enumerators so that the underlying type is
wide enough for them on every conforming compiler; neither is a valid exponent to *use*. Do not
switch exhaustively over this enum, and do not assume a value outside the named nine is invalid —
ask `bvn_int_to_exponent`, which returns `exp_invalid` for anything out of range.

`exp_invalid` (value `0`) is the zero-initialization sentinel. Always initialize components before use.

### 11.2 Structures

#### `value_unit_component_t`

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

where:

```c
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;
```

#### `value_unit_t`

```c
#define BVNR_MAX_UNIT_COMPONENTS  32

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

For an explicit `no_unit` annotation, `num_components == 0` (`BVN_UNIT_NONE`), and the same for an absent unit parameter in an explicit annotation (e.g. `<float:64>`). Only a fully unannotated value (`1.0`) has `num_components == 1` with `base == bu_none`, from default type synthesis (§7).

#### `bvnr_data_t` (unit field)

```c
typedef struct bvnr_data_s {
    token_type_t       type;
    value_type_spec_t  value_type;
    value_unit_t       value_unit;   /* parsed physical unit or currency */
    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 */
    const void*        unit_source;        /* library 2.0 — value_unit as the document spelled it */
    uint32_t           unit_source_length; /* library 2.0 — its length, 0 with NULL */
    bool               converted;    /* a read-time conversion restated this value */
    bvnr_converted_t   conv;         /* its result: the unit delivered, and the digits */
} bvnr_data_t;
```

`unit_source` is the unit's own **spelling**, where `value_unit` is its meaning.
The two differ wherever formatting a `value_unit_t` is lossy — `km` and `k~m` are
one unit, and so are `ucum:mL{total}` and `m~L` — and the writer re-emits the
spelling when it is handed back. See
[Unit Profiles §5.2](11_bovnar_unit_profiles.md#52-what-canonical-output-loses-and-on-which-path).

`converted` and `conv` are the unit-facing half of the struct: when a `want_unit`
hook or a [unit policy](06_bovnar_unit_policy.md) restated a value, `conv`
carries the unit it was delivered in and the converted digits, while
`value_unit` still carries the unit the **document** wrote. A consumer that
requested a conversion must read `converted` to know whether it happened — see
[Unit Policy §2.2](06_bovnar_unit_policy.md#22-conversion-targets).

### 11.3 Convenience Macros

```c
/* Dimensionless or single-component without prefix */
#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 }}})

/* Single-component with SI prefix */
#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) }}})

/* Single-component with IEC prefix */
#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) }}})

/* Single-component with SI prefix and explicit exponent */
#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) }}})

/* Empty unit — explicit no_unit / internal sentinel */
#define BVN_UNIT_NONE  ((value_unit_t){ .num_components = 0 })

/* Two-component compound unit, both SI-prefixed */
#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) }}})
```

Usage:

```c
value_unit_t kg  = BVN_UNIT_SI(bu_gram, si_kilo);
value_unit_t gib = BVN_UNIT_IEC(bu_byte, iec_gibi);
value_unit_t m2  = BVN_UNIT_SI_EXP(bu_meter, si_none, exp_square);
value_unit_t usd = BVN_UNIT_NO_PREFIX(            /* a currency: ids come from */
    bvn_parse_currency_str((const uint8_t *)"USD", 3));  /* the catalogue, not an enumerator */
value_unit_t none = BVN_UNIT_NONE;
```

---

## 12. C API Functions

### 12.1 Parsing a Unit String

```c
value_unit_t bvn_parse_unit  (const uint8_t* unit, bool* ok);
value_unit_t bvn_parse_unit_n(const uint8_t* unit, uint32_t len, bool* ok);
```

`bvn_parse_unit` parses a NUL-terminated UTF-8 unit string. `bvn_parse_unit_n` is the length-bounded variant used internally when the unit string is a slice of a larger type-annotation buffer. Both set `*ok = false` on any parse error (unknown prefix, unknown base unit, unknown currency code, too many components, empty component, or NULL input).

Single-pass parsing algorithm:

1. `memcmp` against `"no_unit"` → return `BVN_UNIT_NONE` immediately on match.
2. Scan for separator characters to distinguish simple vs. compound paths.
3. For compound units, split on `0x2A` (`*`), `0x2F` (`/`), `0xC2 0xB7` (`·`); parse each slice as a component; negate denominator exponents.
4. For each component, if it is introduced by the `$` sigil (after any prefix and its optional `~`), look the code up in the currency table; otherwise look it up in the physical unit table only. A bare code is never a currency.

```c
bool ok;
value_unit_t u = bvn_parse_unit((const uint8_t *)"k~g·m/s²", &ok);
/* u.num_components == 3:
   [0]: bu_gram,   exp_linear,     si_kilo
   [1]: bu_meter,  exp_linear,     si_none
   [2]: bu_second, exp_neg_square, si_none */

value_unit_t c = bvn_parse_unit((const uint8_t *)"$USD/oz_t", &ok);
/* c.num_components == 2:
   [0]: the id of USD, exp_linear,  si_none  (currency)
   [1]: bu_troy_ounce, exp_neg_linear, si_none */
```

### 12.2 Serializing a Unit

```c
int32_t bvn_unit_to_string   (value_unit_t u, char* buf, size_t bufsize);
int32_t bvn_unit_to_string_ex(value_unit_t u, char* buf, size_t bufsize,
                               bvn_unit_flags_t flags);
```

Serializes `u` to a canonical UTF-8 string. Returns the number of bytes written (excluding NUL) or `-1` on buffer overflow or invalid component.

| Flag | Effect |
|------|--------|
| `BVN_UNIT_FLAGS_NONE` | Unicode superscript exponents, no reduction |
| `BVN_UNIT_ASCII_EXP` | ASCII caret form (`^N`) for all exponents |
| `BVN_UNIT_REDUCE` | Reduce via `bvn_unit_reduce` before serializing |

> **`BVN_UNIT_REDUCE` changes the unit, and this function does not change the value.**
> Reduction folds every prefix out, so `k~g` serializes as `"g"` — a string that
> denotes a quantity 1000× smaller than what you passed in. The value has to move
> with it, and the **writer** does that for you (5 `k~m` is written as `5000 m`,
> in exact rational arithmetic); nothing else does.
>
> **A direct caller must not use `bvn_unit_reduce`'s `scale` for this.** That is
> the scale to the *fully reduced* unit, and this function does not always emit
> the fully reduced unit: where the reduction lands on a named SI unit the
> formatter re-attaches the prefix, so `k~N` comes back `"k~N"` with nothing to
> rescale, while `bvn_unit_reduce` still reports 1000. Applying it there
> multiplies by a thousand twice over, and the two cases are indistinguishable
> from outside — both are a lone unit carrying a kilo prefix.
>
> The recipe is to convert to the unit that is actually **emitted**, which is
> what `bvnr_writer.c` does:
>
> ```c
> char ubuf[BVNR_UNIT_STRING_MAX];
> if (bvn_unit_to_string_ex(u, ubuf, sizeof ubuf, BVN_UNIT_REDUCE) < 0)
>         return false;                       /* overflowing reduction */
> bool ok = false;
> value_unit_t emitted = bvn_parse_unit((const uint8_t *)ubuf, &ok);
> if (!ok || bvn_unit_equal(emitted, u))
>         return true;                        /* nothing moved */
> /* now convert the value from `u` to `emitted` — exactly, if it must be exact */
> bvn_unit_convert_rational(vnum, vden, u, emitted, out_num, out_den, &exact);
> ```
>
> `bvn_unit_reduce`'s `scale` remains the right answer for its own returned unit;
> it is simply not the unit this function prints.
>
> The collapse never *substitutes* one named unit for another. `Sv` and `Gy` share
> a dimension vector, as do `Bq`, `Bd` and `Hz`, and `W`, `VA` and `var`; each
> reduces to itself. A lone base at an exponent other than 1 is a different case
> and still collapses — `s⁻¹` is what `Hz` names, and `m~s⁻¹` comes back `k~Hz`.
>
> **A reduction that drops a component returns `-1` rather than a unit.** When a
> summed exponent leaves the range the format can spell, or more bases survive
> than a unit may carry, `bvn_unit_reduce` drops a component — so the result is a
> *different* unit, not a shorter spelling of the same one. `m¹⁰⁰·m²` is `m¹⁰²`,
> and such a unit used to serialise as `"no_unit"`. Without `BVN_UNIT_REDUCE`
> these units write normally.
>
> A folded scale that no `double` holds is **not** that case. `y~m²⁰` reduces to
> `m²⁰` with a scale of 10⁻⁴⁸⁰: `bvn_unit_reduce` still raises `*overflow` for it
> and leaves `*scale` at 1.0, because the scale is what it cannot return, but the
> unit is intact and this function writes it. The value is moved by the recipe
> above, which is exact and never passes through a `double`; where the moved
> value has no form in its own type, the writer reports
> `error_value_out_of_range`, about the value. Rewriting an equivalent dose as an absorbed dose in the
> document would be a stronger act than offering the conversion, which §12.4 still
> does when a caller asks.

```c
char buf[64];
value_unit_t u = /* k~g·m/s² */;
bvn_unit_to_string(u, buf, sizeof(buf));
/* buf == "k~g·m/s²" */

bvn_unit_to_string_ex(u, buf, sizeof(buf), BVN_UNIT_ASCII_EXP);
/* buf == "k~g*m/s^2" */
```

#### Validation Predicate

```c
bool bvn_unit_valid(value_unit_t u);
```

Returns `true` if every component has a valid exponent, known base unit (physical or currency), and a legal prefix for that base. Both serialization functions call this before writing.

### 12.3 Prefix Factor and Exponent Queries

```c
double  bvn_unit_prefix_factor  (value_unit_t u);
int32_t bvn_unit_prefix_exponent(value_unit_t u);
```

`bvn_unit_prefix_factor` returns the multiplicative scale contributed by the prefixes, ignoring base-unit identity. For `si_none`/`iec_none` prefixes the factor is 1.0.

`bvn_unit_prefix_exponent` returns the sum of `(prefix_base_exponent × unit_exponent)` across all components — the component exponent **signed**, not its magnitude, so `m/k~m` is `-3` and `k~m⁻¹` is `-3` while `k~m` is `+3`.

> **The sum is only meaningful when one prefix system is in play.** SI exponents are powers of ten and IEC exponents powers of two, and this function adds them into a single integer: `Ki~B/k~s` returns `7`, which is neither 2⁷ nor 10⁷ — the actual scale is 1.024. For a mixed unit use `bvn_unit_prefix_factor`, which multiplies the two systems' factors correctly, or read the components yourself.

### 12.4 SI Conversion API

Functions in `bovnar_si_units.h` provide dimensional analysis, compatibility checking, and value conversion between compatible physical units. **A currency has no SI conversion row at all** — it has a catalogue row in `bovnar_currency.c` instead — so `bvn_unit_to_si_factor` and `bvn_unit_dimension_vector` set `*ok = false` / return `false` for one, and `bvn_units_compatible` reports a currency incompatible even with itself. `bvn_units_convertible`, `bvn_unit_convert_value` and `bvn_unit_convert_rational` still handle currencies, through the prefix-only path described below; that difference is the reason the first of those three exists.

```c
/* Full SI factor (physical units only) */
double bvn_unit_to_si_factor(value_unit_t u,
                              bool   *is_affine,
                              double *affine_offset,
                              bool   *ok);

/* SI dimension vector */
bool bvn_unit_dimension_vector(value_unit_t u,
                                int32_t dims[bvn_si_dim_count]);

/* Dimensional compatibility check */
bool bvn_units_compatible(value_unit_t a, value_unit_t b);

/* The predicate to SCREEN a conversion target with */
bool bvn_units_convertible(value_unit_t a, value_unit_t b);

/* The coherent SI form of a unit, if it has one */
bool bvn_unit_si_normal_form(value_unit_t u, value_unit_t *out);

/* Conversion factor: value_in_b = value_in_a × k */
double bvn_unit_convert_factor(value_unit_t a, value_unit_t b,
                                bool *ok, bool *requires_affine);

/* Unit reduction */
value_unit_t bvn_unit_reduce(value_unit_t u,
                              double *scale, bool *overflow);

/* Prefix validity */
bool bvn_prefix_unit_valid(value_unit_prefix_t prefix,
                            value_base_unit_t base);

/* Convert one value (handles the affine scales) */
bool bvn_unit_convert_value(double value, value_unit_t from,
                             value_unit_t to, double *out);

/* Convert one EXACT rational — the lossless path */
bool bvn_unit_convert_rational(const bvn_int_t *vnum, const bvn_int_t *vden,
                                value_unit_t from, value_unit_t to,
                                bvn_int_t *out_num, bvn_int_t *out_den,
                                bool *exact);

/* Exponent integer conversion */
int32_t        bvn_exponent_to_int (unit_exponent_t e);
unit_exponent_t bvn_int_to_exponent(int32_t n);
```

`bvn_unit_convert_rational` is the engine behind the reader's `want_unit` hook and behind every conversion a unit policy performs: it converts the exact rational `vnum/vden` in arbitrary precision, so a 1056-bit float or a 512-bit integer converts with no loss beyond the library's own declared factor. It sets `*exact = false` when the true factor is irrational (a π-based angle, a parsec, a water-hardness scale) — the result is then only an approximation and a lossless consumer must reject it. It returns `false` in three cases a caller reporting a diagnosis must tell apart: the units are dimensionally incompatible, the unit is structurally invalid, or the exact factor needs more than `BVN_INT_MAX_BITS` (reachable only from deliberately extreme units — `Q~m¹⁰⁰·Q~g¹⁰⁰` to `q~m¹⁰⁰·q~g¹⁰⁰` needs 10^12000 — and **not** a statement that the units disagree). Ask `bvn_units_convertible` to separate the first two from the third. To render the result, see `bvn_rational_to_str` and `bvn_rational_str_bufsize` in §3.4 of the [read/write API](08_bovnar_readwrite_api.md) — the renderer never truncates, so size the buffer with the second before calling the first.

**Every out-parameter above is optional.** `NULL` means "do not report this one"; the function behaves identically otherwise and its return value is unchanged. That makes `bvn_unit_dimension_vector(u, NULL)` the bare predicate "does `u` have a dimension vector at all", and `bvn_unit_si_normal_form(u, NULL)` the predicate "does `u` have an SI form". The rule does not extend to an argument the answer is made *of*: a `NULL` `bvn_int_t` in `bvn_unit_convert_rational`, or a `NULL` buffer handed to a formatter, is a refused call.

**`bvn_units_convertible` is the one to screen a conversion target with**, and it is not `bvn_units_compatible`. That function answers "do these two carry the same physical dimension", and a currency deliberately carries none — so it reports `false` for `k~$USD → $USD` and even for `$USD → $USD`, both of which the conversion entry points perform correctly. `bvn_unit_convert_factor` is not the answer either: it reports `*ok = false` for `°F → °C`, because an affine conversion has no single multiplicative factor, so screening on it drops every temperature in the format. `bvn_units_convertible` is "dimensionally compatible, **or** the same unit apart from its prefixes", which is the set `bvn_unit_convert_value` and `bvn_unit_convert_rational` draw from — a **superset** of what they accept, not the same set.

It is a *screen*, not a guarantee, and the gap is exactly **two** shapes wide. Code that screens with this must still handle a conversion that declines, and the two reasons it can decline are worth telling apart:

- **An affine scale outside "alone, at exponent 1".** `s/°C` is dimensionally compatible with `s/K` and passes here; the conversion entry points refuse it, because an affine scale means nothing at an exponent other than 1 (stated under "An affine unit has an SI value only alone" below, and for the interval units in §3.4; see also [Unit Ambiguities §10](07_bovnar_unit_ambiguities.md)). This one is reachable from ordinary units and is the one a policy has to fence.
- **Capacity.** The exact factor needs more than `BVN_INT_MAX_BITS`, as the paragraph above describes: `Q~m¹⁰⁰·Q~g¹⁰⁰ → q~m¹⁰⁰·q~g¹⁰⁰` is 10¹²⁰⁰⁰ and declines, while remaining a perfectly well-defined conversion between compatible units. (`Q~m¹⁰⁰ → q~m¹⁰⁰` alone is 10⁶⁰⁰⁰, about 19 932 bits, and does *not* decline; only the double-factor case is past the bound.) This is *why* the screen answers `true` there — separating "too large to compute" from "these units disagree" is the question it exists to answer, and it is reachable only from deliberately extreme prefixes and exponents.

Nothing else is in the gap. `python/tests/test_convertible_implies_convertible.py` sweeps the catalogue and its quotients and squares — a domain that cannot reach the capacity bound — and requires every pair that passes the screen and is then refused to be the affine shape; a companion case pins the capacity shape directly.

The reader's unit policy closes the gap on its own side rather than living with it: `bvn_policy_selects` asks `bvni_unit_affine_misplaced` before it asks this, so a whole-document **target** never selects such a pair — leaving the value untouched, as an unmatched target should — while a per-field **rule** or a `require_dimension_of` naming it is `error_unit_mismatch`, because a rule is an assertion the caller made by naming the field. Screening on the dimensional answer alone let `--field .rate=K/h` report OK against a `°C/h` document.

**`bvn_unit_si_normal_form`** writes the coherent SI form of `u` — the product of SI base units carrying the same dimension, prefixes folded out (mass comes back as `k~g`, the SI base unit for mass being the kilogram). It returns `false`, leaving `*out` untouched, when there is no such form to name:

- a **currency**, which has no dimension vector at all;
- any **dimensionless** unit (`%`, `ppm`, `dB`, `pH`, `rad`, `°`, the turbidity scales). Deliberate: normalising a ratio would silently turn `35 %` into a bare `0.35`, and normalising an angle would need the irrational factor between `°` and `rad`;
- a unit whose SI form the conversion engine would then refuse. The dimension vector does not determine a unit: `lm`, `lx` and `ph` carry the steradian's quantity kind, which no dimension vector can express, so rebuilding them from dimensions yields `cd` and `cd/m²` — a different quantity (§3.2). `s/°C` has the dimensions of `s/K` and is unconvertible for the affine reason above.

The form returned is checked against `u` with `bvn_units_convertible` before it is returned, so a form this function gives back is one the conversion entry points accept. A temperature **interval** normalises to `ΔK` and never to `K`, which the dimension vector cannot say — both are Θ¹ — so the quantity kind is consulted (§3.4, *Temperature Differences*). This is the function `bvnr_normalise_si` is built on.

For affine units (`bu_celsius`, `bu_fahrenheit`), `*is_affine` is set to `true` and `*affine_offset` receives the additive offset applied after multiplying by the returned factor.

**An affine unit has an SI value only alone, at exponent 1.** `°C²`, `°C·°F`, `°C/h` and `°C·m` all set `*ok = false`; `bvn_unit_convert_value` and `bvn_unit_convert_rational` refuse them, and the reader turns that into `error_unit_mismatch` (38). The offset is a number of kelvin, and a product whose SI unit is `K·s⁻¹` or `K·m` has nowhere to put it: the earlier code added it unscaled, so `20 °C/h` converted to `K/h` as 983360 and `20 °C·m` to `K·m` as 293.15 whatever the metres did. Both are arithmetic on a quantity that does not exist. `°C/h` still **parses** and is still a legal annotation — a consumer that means a temperature *difference* can read the components itself and apply its own semantics; what the library will not do is hand back a number for it. This matches pint, which forbids an offset unit inside a product outright.

```c
bool ok, affine;
double offset;
value_unit_t u = bvn_parse_unit((const uint8_t *)"°C", &ok);
double f = bvn_unit_to_si_factor(u, &affine, &offset, &ok);
/* f == 1.0, affine == true, offset == 273.15 */
```

### 12.5 Currency API

```c
#include "bovnar_currency.h"

/* Classification */
bool bvn_unit_is_currency(int base);
bool bvn_unit_is_fiat    (int base);
bool bvn_unit_is_crypto  (int base);

/* Minor-unit exponent: 1 major unit = 10^N minor units */
uint8_t bvn_currency_minor_unit(int base, bool *ok);

/* Full currency metadata */
const bvn_currency_info_t *bvn_currency_info(int base);

/* Look up by 3–4 char code string; returns 0 (bu_none) on failure */
int bvn_parse_currency_str(const uint8_t *s, uint32_t len);

/* Prefix validity for a specific currency */
bool bvn_currency_prefix_valid(int base, int prefix_system);
```

The `bvn_currency_info_t` structure:

```c
typedef struct {
    char     code[5];           /* "USD", "BTC", etc.                  */
    uint16_t numeric_code;      /* ISO 4217 numeric code (0 for crypto) */
    uint8_t  minor_unit;        /* decimal places                       */
    bool     is_crypto;         /* true for cryptocurrencies            */
    char     name[48];          /* "US Dollar", "Bitcoin", etc.         */
} bvn_currency_info_t;
```

Examples:

```c
bool ok;
int kwd = bvn_parse_currency_str((const uint8_t *)"KWD", 3);
int jpy = bvn_parse_currency_str((const uint8_t *)"JPY", 3);
int usd = bvn_parse_currency_str((const uint8_t *)"USD", 3);
uint8_t n = bvn_currency_minor_unit(kwd, &ok);     /* n=3, ok=true  */
uint8_t m = bvn_currency_minor_unit(jpy, &ok);     /* m=0, ok=true  */
uint8_t x = bvn_currency_minor_unit(bu_meter, &ok);/* x=0, ok=false */

const bvn_currency_info_t *ci = bvn_currency_info(usd);
/* ci->code="USD", ci->numeric_code=840, ci->minor_unit=2,
   ci->is_crypto=false, ci->name="US Dollar" */

int cv = bvn_parse_currency_str((const uint8_t *)"EUR", 3);  /* EUR's id, non-zero */
int cc = bvn_parse_currency_str((const uint8_t *)"DOGE", 4); /* DOGE's id, non-zero */
int cx = bvn_parse_currency_str((const uint8_t *)"xyz", 3);  /* cx=0: not a code */

/* Distinguishing cup (volume) from $CUP (currency) in code. The sigil is
   mandatory: a bare "CUP" is not a unit (ok=false, error_unit_illegal). */
value_unit_t volume   = bvn_parse_unit((const uint8_t *)"cup", &ok);
value_unit_t currency = bvn_parse_unit((const uint8_t *)"$CUP", &ok);
assert(!bvn_unit_is_currency(volume.components[0].base));   /* true */
assert( bvn_unit_is_currency(currency.components[0].base)); /* true */
```

### 12.6 Unit Profile API (spec 1.2)

Four functions serve the profile notations. `bvn_parse_unit` itself is unchanged and takes every notation; these cover what a caller needs *around* it.

```c
error_code_t bvn_unit_error_code(const uint8_t *unit, uint32_t len);
bool         bvn_unit_is_profile_only(value_unit_t u);
int32_t      bvn_unit_to_profile(const char *ns, value_unit_t u,
                                 char *buf, size_t bufsize);
int32_t      bvn_unit_to_ucum(value_unit_t u, char *buf, size_t bufsize);
```

**`bvn_unit_error_code`** says *why* a unit string `bvn_parse_unit` rejected is not a unit — `error_unit_illegal` for malformed input or an unknown atom, `error_unit_profile_unknown` for an unrecognised namespace, `error_unit_profile_unsupported` for a valid profile expression with no representation here. It re-parses, so it is for the error path only; a string that does parse returns `error_none`.

**`bvn_unit_is_profile_only`** is true when a unit has no native spelling, which is exactly the units carrying an **opaque** base unit: a UCUM arbitrary atom (`[IU]`, `[PFU]`, …) or a UNECE package or count code (`XBX`, `C62`, …). For those, `bvn_unit_to_string` emits the profile form in the namespace that *owns* the unit, and re-parsing that output yields the same unit.

**`bvn_unit_to_profile`** writes a code in the named vocabulary — any of the seven namespaces, without the `<ns>:` prefix, though the read-only `cf` has a code for no unit — returning its length or a negative value. **`bvn_unit_to_ucum`** is the `ucum` case, kept for callers that predate the others. Both are **partial by construction**, in three ways: the Old German units, the water-hardness degrees, the turbidity kinds, `PSU`, `CF`, `kph` and every currency have no code in any of these vocabularies (membership is per unit, not per family: `mph` is `unece:HM` and `qudt:MI-PER-HR`, while `kph` has nothing); an opaque unit belonging to a *different* profile has none either; and a **flat** vocabulary (`unece`, `qudt`, `qudt-qk`, `om`) can spell only a single component — prefixed or not, `k~m` is `unece:KMT` — so `k~m/h` has no UNECE form even though `unece:KMH` parses to exactly it. All three are refused rather than approximated.

```c
char buf[BVNR_UNIT_STRING_MAX];
bool ok = true;
value_unit_t u = bvn_parse_unit((const uint8_t *)"ucum:mm[Hg]", &ok);   /* == mmHg */
bvn_unit_to_string(u, buf, sizeof buf);                                 /* "mmHg"    */
bvn_unit_to_ucum  (u, buf, sizeof buf);                                 /* "mm[Hg]"  */
```

Note that **in a document** the notation is gated on an explicit `#!bovnar 1.2` directive (§11.9 of the specification). These API entry points have no document and therefore no version, so they accept the notation unconditionally.

### 12.7 Python API

```python
from bovnar.enums import BaseUnit
from bovnar.currency import (
    is_currency, is_fiat, is_crypto,
    minor_unit, currency_info, currency_name, from_code,
    all_fiat, all_crypto,
)

assert is_currency(BaseUnit.USD)        # True
assert is_fiat(BaseUnit.XAU)            # True (gold is ISO 4217 X-code)
assert is_crypto(BaseUnit.ETH)          # True
assert not is_fiat(BaseUnit.BTC)        # True (BTC is crypto, not fiat)

assert minor_unit(BaseUnit.USD)  == 2   # cents
assert minor_unit(BaseUnit.JPY)  == 0   # indivisible
assert minor_unit(BaseUnit.KWD)  == 3   # fils
assert minor_unit(BaseUnit.BTC)  == 8   # satoshis
assert minor_unit(BaseUnit.ETH)  == 18  # wei

info = currency_info(BaseUnit.EUR)
assert info.code == "EUR"
assert info.numeric_code == 978
assert info.minor_unit == 2

btc = from_code("BTC")
assert btc == BaseUnit.BTC

fiat_count   = sum(1 for _ in all_fiat())    # 166
crypto_count = sum(1 for _ in all_crypto())  # 50
```

---

## 13. Integration with the Parser Event Stream

Unit information flows into the application through two paths:

1. **Type-annotation unit** — parsed from `<family:…,unit-param>` by the lexer, validated by the validator, delivered in the `ev_type_annotation_type_family_parameter` unit event.
2. **Inline unit suffix** — parsed from the suffix following a scalar value literal before the terminating `;`.

In both cases the effective unit is reported in the `bvnr_data_t.value_unit` field of the `ev_data` event.

### 13.1 Full event sequence — physical unit

```
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  data = "float"
ev_type_annotation_type_family_parameter   ← width=64
ev_type_annotation_type_family_parameter   ← unit:
    value_unit = { num_components=3,
      [0] bu_gram,   exp_linear,     {prefix_si, si_kilo}
      [1] bu_meter,  exp_linear,     {prefix_si, si_none}
      [2] bu_second, exp_neg_square, {prefix_si, si_none} }
ev_type_annotation_end
ev_data   data="9.81"
```

### 13.2 Full event sequence — currency unit

```
Input: .price = <float_dec:64,$USD> 19.99;

ev_assignment_start          data = "price"
ev_type_annotation_start     data = "float_dec:64,$USD"
ev_type_annotation_type_family  data = "float_dec"
ev_type_annotation_type_family_parameter   ← width=64
ev_type_annotation_type_family_parameter   ← unit:
    value_unit = { num_components=1,
      [0] USD, exp_linear, {prefix_si, si_none} }
ev_type_annotation_end
ev_data   data="19.99"
```

For events with an explicit type annotation but no unit parameter, the unit event is **not emitted**. For `no_unit`, the unit event IS emitted with `BVN_UNIT_NONE` (`num_components=0`). For synthesised (default) type annotations, the unit event IS emitted with `BVN_UNIT_NO_PREFIX(bu_none)`.

### 13.3 Inline unit suffix — event stream view

```
Input: .distance = 1500 m;

ev_assignment_start      data = "distance"
ev_type_annotation_start data = "uint"      ← synthesised
ev_type_annotation_type_family  data = "uint"
ev_type_annotation_type_family_parameter  ← width=64  (synthesised)
ev_type_annotation_type_family_parameter  ← base=10   (synthesised)
ev_type_annotation_type_family_parameter  ← unit: BVN_UNIT_NO_PREFIX(bu_none)
ev_type_annotation_end
ev_data   data="1500"
    value_unit = { num_components=1,
      [0] bu_meter, exp_linear, {prefix_si, si_none} }
```

The `value_unit` field of `ev_data` always reflects the final, reconciled unit.

### 13.4 Practical callback

```c
bool my_verified_handler(void* userdata, bvnr_event_t ev, bvnr_data_t* d)
{
    if (ev != ev_type_annotation_type_family_parameter)
        return true;

    value_unit_t u = d->value_unit;
    if (u.num_components == 0 ||
        (u.num_components == 1 && u.components[0].base == bu_none))
        return true;

    if (bvn_unit_is_currency(u.components[0].base)) {
        const bvn_currency_info_t *ci =
            bvn_currency_info(u.components[0].base);
        printf("currency: %s  minor_unit=%u\n",
               ci->code, ci->minor_unit);
        return true;
    }

    char unit_str[128];
    bvn_unit_to_string(u, unit_str, sizeof(unit_str));
    printf("unit: %s  (prefix_factor: %g)\n",
           unit_str, bvn_unit_prefix_factor(u));
    return true;
}
```

---

## 14. Validation Errors

The validator raises the following unit-specific errors:

| Error code | Value | Trigger condition |
|------------|-------|-------------------|
| `error_unit_illegal` | 32 | Unparseable unit string: unknown prefix, unknown base unit, unknown currency code after `$`, a bare token in neither the physical-unit table nor (lacking the `$` sigil) recognised as a currency (e.g. `XYZ`, or bare `USD`), invalid prefix–unit combination (e.g. IEC prefix on a currency, sub-kilo SI prefix on byte), empty component between separators (e.g. `m//s`), or more than `BVNR_MAX_UNIT_COMPONENTS` (32) components |
| `error_unit_too_long` | 22 | An **inline** unit suffix exceeds its 255-byte lexer buffer. A unit written in an *annotation* reaches the type-annotation body's own cap first and raises `error_type_too_long` (21) instead — see §8 |
| `error_unit_mismatch` | 38 | An inline unit suffix and an explicit type-annotation unit are both present, but parse to different `value_unit_t` representations. Also raised at read time by a conversion the consumer asked for: a unit policy's assertions (`require_unit`, `require_dimension_of`, a per-field rule the value cannot be converted to — [Unit Policy §6.2](06_bovnar_unit_policy.md)), and a conversion that a per-field rule or the `want_unit` hook demands of a unit holding an affine scale anywhere but alone at exponent 1 (`°C/h`, §12.4; a whole-document target simply does not select it) |
| `error_unexpected_input_byte` | 15 | An inline unit suffix appears inside an array element |
| `error_unit_inexact` | 47 | A read-time conversion (a unit policy, or the `want_unit` hook) cannot be delivered exactly: the factor is irrational, or the result does not terminate in the requested output base. Never raised for a document read without one |
| `error_unit_profile_unknown` | 49 | A `namespace:code` unit (spec 1.2) whose namespace is not a profile this build supports |
| `error_unit_profile_unsupported` | 50 | A profile code that is valid in its vocabulary but has no representation in this unit system ([Unit Profiles](11_bovnar_unit_profiles.md)) |

The errors that judge the document itself are raised during the `on_unverified` → validator phase; `error_unit_inexact`, and `error_unit_mismatch` from a conversion or a policy, come from the conversion the consumer configured, before the value reaches `on_verified`. In `continue_on_error` mode the parser invokes `on_error` and enters the resync state machine, which resumes at the next `;` at the current nesting depth or at the start of the next assignment, whichever comes first (specification §13.2). `bvnr_reader_get_error`, `…_line`, `…_column`, `…_byte`, and `…_offset` all report the location of the offending token.

---

## 15. Annotated Examples

### 15.1 Physical Quantities

```bovnar
# Thermodynamic temperature
.ambient_temp  = <float:64,K>          293.15;

# Temperature in Celsius (affine: K = °C + 273.15)
.room_temp     = <float:32,°C>          20.0;

# Velocity and acceleration
.wind_speed    = <float:64,m/s>         12.5;
.gravity       = <float:64,m/s²>         9.80665;
.gravity_asc   = <float:64,m/s^2>        9.80665;  # ASCII caret — identical

# Pressure and energy
.tire_pressure = <float:32,k~Pa>       250.0;
.heat_energy   = <float:64,k~J>       5400.0;

# Flow rate (liters per minute)
.pump_flow     = <float:32,L/min>       15.0;

# Angles
.bearing       = <float:64,°>          270.0;
.phase         = <float:64,rad>          1.5708;

# Volume — US culinary
.recipe_water  = <float_dec:32,cup>      2.0;   # "cup" (lowercase) = US cup
.recipe_flour  = <float_dec:32,tbsp>     3.0;

# Duration
.shelf_life    = <uint:32,wk>           52;
.service_life  = <float:64,yr>          10.0;
```

### 15.2 Digital Storage

```bovnar
.packet_size = <uint:32,B>      1500;
.cache_size  = <uint:64,Ki~B>    512;
.ram_size    = <uint:64,Mi~B>   4096;
.disk_size   = <uint:64,Gi~B>    500;
.link_rate   = <uint:32,M~b>    1000;
.nic_speed   = <float:64,G~b/s>   10.0;
```

### 15.3 Compound SI Quantities

```bovnar
# Force: Newton = k~g·m/s²
.force          = <float:64,k~g·m/s²>    9.81;
.force_alt      = <float:64,k~g·m·s⁻²>  9.81;   # identical internal form

# Energy: Joule = k~g·m²/s²
.kinetic_energy = <float:64,k~g·m²/s²> 1000.0;

# Electric field
.field_strength = <float:64,V/m>         150.0;

# Torque
.torque         = <float:64,N·m>          25.0;
```

### 15.4 Currency Amounts and Rates

```bovnar
# ── Fiat scalar amounts ────────────────────────────────────────────────────
.price_usd     = <float_dec:64,$USD>   19.99;
.balance_eur   = <float_dec:64,$EUR>  342.00;
.yen_fee       = <uint:64,$JPY>           500;    # zero minor unit — integer only
.kwd_invoice   = <uint:64,$KWD>          3500;    # 3.500 KWD in fils

# "CUP" (uppercase) is the Cuban Peso, NOT the US cup volume unit:
.cup_balance   = <float_dec:64,$CUP>    25.00;   # 25 Cuban Pesos

# ── Crypto scalar amounts ──────────────────────────────────────────────────
.btc_sat       = <uint:64,$BTC>   54782000;       # on-chain satoshis
.eth_readable  = <float_dec:64,$ETH>    2.5;
.doge_bag      = <float_dec:64,$DOGE> 42000.0;
.usdt_stable   = <float_dec:64,$USDT>  5000.00;

# ── Compound units ─────────────────────────────────────────────────────────
.gold_price    = <float_dec:64,$USD/oz_t>   2351.40;  # $/troy oz
.wheat         = <float_dec:64,$USD/bsh>       5.82;  # $/bushel
.rent          = <float_dec:64,$EUR/m²>       12.50;  # €/m²
.billing_rate  = <float_dec:64,$EUR/h>        150.00; # €/h
.eur_usd       = <float_dec:64,$USD/$EUR>       1.0842; # exchange rate

# ── Reporting scale ────────────────────────────────────────────────────────
.fund_nav      = <float_dec:64,k~$USD>    250.0;     # $250,000
.gdp           = <float_dec:64,M~$EUR> 42800.0;      # €42.8 billion

# ── Exchange rate with timestamp ───────────────────────────────────────────
.snapshot = {
    .epoch    = <uint:64,s>              1716400000;
    .eur_usd  = <float_dec:64,$USD/$EUR>        1.0842;
};

# ── Array of prices ────────────────────────────────────────────────────────
.tier_prices   = <float_dec:64,$USD> [9.99, 19.99, 49.99, 99.99];
```

### 15.5 Error Cases

<!-- bovnar-example: rejected -->
```bovnar
# Empty component → error_unit_illegal
.bad1 = <float:64,m//s>      1.0;

# Too many components (33 > 32) → error_unit_illegal
.bad2 = <float:64,m*s*g*A*K*mol*cd*b*V*Hz*N*Pa*J*W*Ω*F*C*S*Wb*T*H*lm*lx*Bq*Gy*kat*L*min*h*d*bar*eV*Da> 1.0;

# Unknown base unit → error_unit_illegal
.bad3 = <float:64,foobar>    1.0;

# IEC prefix on a currency → error_unit_illegal
.bad4 = <float_dec:64,Ki~$USD> 1.0;

# Annotation unit differs from inline unit → error_unit_mismatch
.bad5 = <float:64,m> 1.0 s;

# Inline unit inside an array → error_unexpected_input_byte
.bad6 = <float:64,m> [1.0 m, 2.0 m];   # ERROR: suffix inside array

# Correct: dimensionless explicit
.ok1  = <uint:32,no_unit>    42;

# Correct: omitted unit (same behaviour as no_unit)
.ok2  = <uint:32>            42;

# Correct: BTU is a valid alias for bu_btu (currency lookup returns 0, physical table matches)
.ok3  = <float:64,BTU>      1.0;    # valid: same as Btu or btu

# Correct: sub-kilo SI prefix on currency is accepted
.ok4  = <float_dec:64,m~$USD> 0.001; # valid: milli-dollar (one tenth of a cent)

# Correct: cup (volume) vs CUP (currency) — both valid, different meaning
.vol  = <float_dec:32,cup>  2.0;    # US cup (236.6 mL)
.bal  = <float_dec:64,$CUP> 25.00;   # Cuban Peso
```

---

## See also

- [Specification §11 — Units System](03_bovnar_spec.md#11-units-system) — how a unit is attached to a value
- [Unit & Currency Cheat Sheet](04_bovnar_unit_cheatsheet.md) — every symbol in this registry, in table form
- [Unit Ambiguities](07_bovnar_unit_ambiguities.md) — every token that could plausibly mean two things
- [Unit Profiles](11_bovnar_unit_profiles.md) — writing UCUM, UN/ECE, QUDT, UDUNITS, OM 2 and CF codes in a unit slot, and where the vocabularies disagree
- [Read & Write API](08_bovnar_readwrite_api.md) — `bvn_parse_unit`, `bvn_unit_to_string`, and read-time conversion
- [Python Bindings](09_bovnar_python_bindings.md) — the same unit model from Python, with the NumPy and pint bridges
- [Rust Bindings](14_bovnar_rust_bindings.md) — the same unit model from Rust

---

*End of Bovnar — Unit & Currency Reference (Bovnar spec 1.2).*

<!-- ==================== Unit policy (https://www.bovnar.io/doc/06_bovnar_unit_policy.md) ==================== -->

# Bovnar — Unit Policy Reference

> **Spec version:** 1.2
> **Status:** Reference — the unit policy carried by `bvnr_unit_policy_t`, on the reader and on the writer.
> **Scope:** What a policy declares, the order its parts are consulted in, what it validates, what it converts, and the errors it raises. Behaviour only; the C declarations live in the read/write API reference.

Companion to [Unit & Currency Reference](05_bovnar_unit_system.md) (the registry a policy names units
from) and [Read/Write API](08_bovnar_readwrite_api.md) (the entry points that install one). Every
conversion and every error shown here was produced by running the reference implementation built
from this tree; the transcripts are output, not illustration.

A unit policy states, as **data**, what a document must contain and what unit the consumer wants
values delivered in. It is the declarative form of the `want_unit` hook: because every unit is
given as text, a binding can drive the whole feature without a per-value callback across an FFI
boundary. Nothing about it weakens the exactness contract — the policy chooses targets, and the
conversion itself is the same exact arbitrary-precision path `want_unit` uses.

## Table of Contents

1. [Overview](#1-overview)
    - 1.1 [What a unit policy is](#11-what-a-unit-policy-is)
    - 1.2 [What the parser enforces without one](#12-what-the-parser-enforces-without-one)
    - 1.3 [A policy and the want_unit hook](#13-a-policy-and-the-want_unit-hook)
2. [The policy object](#2-the-policy-object)
    - 2.1 [Per-field rules](#21-per-field-rules)
    - 2.2 [Conversion targets](#22-conversion-targets)
    - 2.3 [Normalisation](#23-normalisation)
    - 2.4 [Inexact results](#24-inexact-results)
    - 2.5 [Requiring a unit](#25-requiring-a-unit)
    - 2.6 [Requiring a dimension](#26-requiring-a-dimension)
    - 2.7 [Limits and lifetime](#27-limits-and-lifetime)
3. [Resolution order](#3-resolution-order)
    - 3.1 [The ladder](#31-the-ladder)
    - 3.2 [Validation runs on the written unit](#32-validation-runs-on-the-written-unit)
    - 3.3 [First match wins](#33-first-match-wins)
4. [Conversion semantics](#4-conversion-semantics)
    - 4.1 [Compatible is not convertible](#41-compatible-is-not-convertible)
    - 4.2 [A bare number matches only no_unit — and no_unit matches only a bare number](#42-a-bare-number-matches-only-no_unit--and-no_unit-matches-only-a-bare-number)
    - 4.3 [Exact, or not at all](#43-exact-or-not-at-all)
    - 4.4 [Quantity kinds keep the dimensionless units apart](#44-quantity-kinds-keep-the-dimensionless-units-apart)
5. [Writer-side policy](#5-writer-side-policy)
    - 5.1 [Validation only](#51-validation-only)
    - 5.2 [Why the producing side carries the promise](#52-why-the-producing-side-carries-the-promise)
    - 5.3 [Relationship to BVN_UNIT_REDUCE](#53-relationship-to-bvn_unit_reduce)
6. [Errors](#6-errors)
    - 6.1 [Rejected before the parse](#61-rejected-before-the-parse)
    - 6.2 [Raised during the parse](#62-raised-during-the-parse)
7. [Using a policy](#7-using-a-policy)
    - 7.1 [C](#71-c)
    - 7.2 [Python](#72-python)
    - 7.3 [Command line](#73-command-line)
8. [Worked examples](#8-worked-examples)
    - 8.1 [Deliver a document in SI](#81-deliver-a-document-in-si)
    - 8.2 [Assert a schema without converting](#82-assert-a-schema-without-converting)
    - 8.3 [Refuse to write a bare number](#83-refuse-to-write-a-bare-number)
- [See also](#see-also)


---

## 1. Overview

### 1.1 What a unit policy is

One `bvnr_unit_policy_t`, installed on a reader with `bvnr_reader_set_unit_policy` or on a writer
with `bvnr_writer_set_unit_policy`. It carries two independent kinds of statement:

- **Validation** — what the document must contain. These reject a document and never change a
  value: `require_unit`, `require_dimension_of`, and the assertion half of a per-field rule.
- **Conversion** — what unit the consumer wants values in: `rules`, `targets`, `normalise`, with
  `base` and `on_inexact` shaping the result.

A policy describes the **consumer**, not the document. It may be installed before or after the
reader or writer is opened, it survives re-opening on another document, and passing `NULL` clears
it.

### 1.2 What the parser enforces without one

Without any policy the parser still enforces everything the format itself guarantees: a unit
written on a value must exist in the registry, a unit parameter in a type annotation must agree
with an inline unit on the same value, and an exponent must be in range. What it does not do is
have an opinion about which units a *particular consumer* wanted. `.speed = 3 kg;` is a perfectly
valid document; only the application knows it is nonsense.

A policy is where that application knowledge goes.

### 1.3 A policy and the want_unit hook

Both may be set. The hook is more specific and wins: for each value the reader asks `want_unit`
first, falls back to `targets`, and falls back again to `normalise`. Normalising a document while
hand-handling the one field that needs something else is the intended combination.

The two differ in their exactness contract. A `want_unit` hook is strictly all-or-nothing. A
policy may be told to step over what it cannot deliver exactly — see [2.4](#24-inexact-results).

---

## 2. The policy object

### 2.1 Per-field rules

A rule names **one field**, by the dotted key path a value sits at, leading dot included, exactly
as the document writes its keys. It is the most specific thing a policy can say, and is consulted
before everything else in it.

```c
static const bvnr_unit_rule_t rules[] = {
    { ".inlet.temperature", "°C", 0, bvnr_rule_convert },
    { ".inlet.*",           "m",  0, bvnr_rule_require },
};
```

A path ending in `.*` matches every value below that point at any depth: `".inlet.*"` covers
`".inlet.temperature"` and `".inlet.pump.rpm"`, but not `".inlet"` itself. A prefix matches only
at a component boundary, so `".in.*"` does not claim `".inlet.a"`. Array elements sit at the path
of the assignment holding them, so one rule covers every element of an array.

`bvnr_rule_convert` asserts and converts; `bvnr_rule_require` asserts only, delivering the value
exactly as written — "this field is a length, in whatever length unit it chose".

A rule is an **assertion**, unlike a whole-document target: the caller named this field, so a
value that cannot be applied to it is `error_unit_mismatch` rather than a value passed through
quietly. Silence would defeat the point of naming it. A bare number fails a rule too — `.speed is
m/s` is not satisfied by a value with no unit.

**A rule that matches nothing is satisfied.** The assertion is about values the rule *reaches*, so
a path no value sits at makes no claim and the document passes:

```
--require-field .inlet.speed=m/s   on a document with .inlet.speed      asserted
--require-field .inlet.sped=m/s    on the same document                 ACCEPTED, checks nothing
--require-field .nope.*=m          on a document with no .nope          ACCEPTED, checks nothing
```

Read that line beside [§2.7](#27-limits-and-lifetime), which is the case it is most easily confused
with and which behaves the *opposite* way: a value the reader **cannot locate** — one nested past
the recorded path depth or length — is `error_unit_mismatch`, because "I could not check it" is not
"it holds". The distinction is between a rule that *found nothing to check* and one that *could not
check what it found*. Only the second is an error.

The practical consequence is worth stating plainly, because it runs against what the flag name
suggests: **`--require-field` does not require the field to exist.** A typo in the path — or a
rename in the document the policy was not updated for — turns that rule off silently, and the
document still validates. Nothing detects this for you; if a policy is load-bearing, assert that
the paths it names are the ones the document actually uses.

### 2.2 Conversion targets

`targets` applies to the whole document. Each numeric value is converted to the **first** target
it can validly convert to, so order is significant: a list of `{"m", "k~m"}` never selects
`k~m`. Each target may carry its own output base, `0` meaning the value keeps its own.

A value that matches no target is delivered untouched unless `normalise` catches it. A value
*already* in the unit a target names is also left untouched — unless that target asks for an
output base, which makes it a pure base conversion. The `converted` flag therefore means "the
policy restated this value", not "the policy looked at it".

### 2.3 Normalisation

`bvnr_normalise_si` catches whatever the targets did not, delivering it in coherent SI base units
with prefixes folded out. `base` sets the output base for those conversions.

Values with no coherent SI form are left alone rather than reinterpreted: currencies, every
dimensionless unit (`%`, `ppm`, `dB`, `pH`, `rad`, `°`), and every unit carrying the steradian's or
an angle's quantity kind, which no SI base form can express (`k~lx`, `k~lm`, `m~ph`, `W/sr`, `k~rad/s`).

Their prefixes stay as written too: `k~lx` is not delivered as `lx` (doc/05 §12.4).

### 2.4 Inexact results

`bvnr_inexact_error` (the default) aborts the parse with `error_unit_inexact`. That is the right
behaviour when the caller named a target deliberately.

`bvnr_inexact_leave` delivers the value untouched instead, with `converted == false`. It exists
for `bvnr_normalise_si`, where every value *that has a coherent SI form* is a conversion candidate
and a single 5/18 factor would otherwise reject an ordinary document. A consumer using it must
read `converted` to know which values it happened to.

It applies to **every** result the conversion cannot deliver exactly — a rational with no
terminating expansion in the output base, and equally a genuinely **irrational** factor (a parsec,
a water hardness scale, a π-based angle converted to radian).

**"Exactly" is a property of the result, not of the factor**, so a rational factor rejects some
values in a document and not others: km/h → m/s is 5/18, and `90` converts to exactly `25` while
`100` converts to `250/9` and does not. Two speeds in one file, one accepted and one refused, is
the expected behaviour rather than a bug — an irrational factor is the only kind that fails for
every value:

```console
$ bovnar events --si heading.bvnr
… unit_inexact at line 2 col 32          the k~m/h, not the heading

$ bovnar events --si --leave-inexact heading.bvnr
"90.0" <float:64,_10,°>          left — dimensionless, never a candidate (§2.3)
"100.0" <float:64,_10,k~m/h>     left, 250/9 does not terminate
"5.0"  <float:64,_10,m>          already SI
```

**Read that first line carefully: it is the `k~m/h` that fails, not the degrees.** Under
`bvnr_normalise_si` a degree is not an inexact conversion — it is not a conversion at all, because
[§2.3](#23-normalisation) leaves every dimensionless unit alone. The heading validates under the
strict run too. What *does* abort a `--si` run on an irrational factor is a **dimensioned** one: a
parsec or a `°dH` water hardness, both of which have a coherent SI form and so are candidates.

A π-based angle reaches this path only when a target names its SI form outright —
`targets = {"rad"}` on a `°` value is `error_unit_inexact` strictly, and left untouched under
`bvnr_inexact_leave`. That is the same mechanism, reached deliberately rather than by
normalisation.

The tempting distinction — that an irrational factor is special because there is no rational to
hand over — belongs to [`want_unit_allow_nonterminating`](08_bovnar_readwrite_api.md), whose
fallback *is* the rational. This mode's fallback is the **native value**, and that works for an
irrational factor exactly as well as for a non-terminating one. It has to: a document carrying a
speed in km/h is entirely ordinary, and under `bvnr_normalise_si` the strict reading would reject
it — which is the case this mode exists for.

Only a **policy-chosen** target takes this path. A target named by the `want_unit` hook keeps the
strict all-or-nothing contract that hook was documented with, whatever `on_inexact` says
([1.3](#13-a-policy-and-the-want_unit-hook)).

### 2.5 Requiring a unit

`require_unit` demands that every numeric value carry one. A bare number fails, whether it was
written without a unit parameter or with an explicit `no_unit`.

### 2.6 Requiring a dimension

`require_dimension_of` demands that every numeric value be validly convertible to at least one of
the listed units: "this document is lengths and temperatures, in whatever unit it chose to write
them". It never changes a value.

A value with **no** unit satisfies this only if one of the listed units is itself `no_unit`, for
the reason in [4.2](#42-a-bare-number-matches-only-no_unit--and-no_unit-matches-only-a-bare-number).

### 2.7 Limits and lifetime

| Limit | Value | Applies to |
|---|---|---|
| `BVNR_MAX_UNIT_TARGETS` | 8 | `targets`, `require_dimension_of` |
| `BVNR_MAX_UNIT_RULES` | 8 | `rules` |
| `BVNR_MAX_UNIT_PATH` | 96 | a rule's `path`, NUL excluded |

Every unit string is parsed by the setter, so the strings themselves need not outlive the call.

**A rule needs to know where a value sits, and there is a depth past which the reader cannot say.**
A key path is recorded to a bounded depth (32 nested structs) and a bounded length (256 bytes, with
each key at most 64); a document that outruns either leaves the position of everything below it
*unknown*. The path machinery never reports a position it is unsure of — matching the wrong field
is the one failure a per-field rule must not have — so below that point a rule cannot be evaluated,
and **the parse fails with `error_unit_mismatch`**.

Refusing is the point. A rule is an assertion the caller made by naming a field, so "I could not
check it" is not "it holds"; a rule that quietly stopped applying once a document got deep would be
exactly the silence [§2.1](#21-per-field-rules) refuses for a value a rule cannot be applied to.
Nothing else in a policy is affected — `targets`, `normalise`, `require_unit` and
`require_dimension_of` ask about the value and never about where it sits, so a document of any
depth reads normally under those.

---

## 3. Resolution order

### 3.1 The ladder

For each numeric value, in order, stopping at the first that applies:

1. a **per-field rule** whose path matches;
2. the `want_unit` hook, if one is set;
3. the first **target** the value can validly convert to;
4. **normalisation**, if `bvnr_normalise_si` is set;
5. otherwise the value is delivered as written.

Validation is separate and does not participate in this ladder: it runs regardless of which rung
delivered the value.

### 3.2 Validation runs on the written unit

`require_unit` and `require_dimension_of` are evaluated on the unit the **document** wrote, before
any conversion. Validate what you were sent, convert for the consumer. This means they say the
same thing whether or not a conversion was also requested — a document is not made to pass by the
policy that was going to rewrite it anyway.

### 3.3 First match wins

Both `rules` and `targets` are first-match-wins, so order is part of the policy. Put `".a.b"`
before `".a.*"`, or the wildcard swallows the specific case.

---

## 4. Conversion semantics

### 4.1 Compatible is not convertible

Dimensional compatibility is not the test for whether a conversion can be performed. Currencies
carry no dimension vector and therefore fail a compatibility test even against themselves, yet
prefix conversion between them is exact and supported:

```text
5 k~$USD  ->  $USD      =  5000
```

Affine conversions have no single multiplicative factor, so a screen on "is there a conversion
factor" would drop every temperature in the format. Both of these work:

```text
212 °F  ->  °C          =  100
 25 °C  ->  K           =  298.15
```

### 4.2 A bare number matches only no_unit — and no_unit matches only a bare number

The fence runs **both ways**:

- a value with no unit only ever matches a target that is itself `no_unit`;
- a `no_unit` target only ever matches a value that has no unit.

A bare number is dimensionally compatible with `%` and with `ppm`, so without the fence a policy
naming `"%"` would deliver `0.25` as `25`, and one naming `no_unit` would deliver `35 %` as `0.35`
— the same silent factor-of-a-hundred from either side, arrived at through the machinery meant to
prevent it. Under `targets = ["%"]` a bare `0.25` is therefore delivered untouched, with
`converted == false`; under `targets = ["no_unit"]` a `35 %` is delivered untouched too.

```text
--unit %          0.25   (no unit)   delivered untouched
--unit %          5000 ppm       ->  0.5 %
--unit no_unit    0.25   (no unit)   matched; the conversion is the identity
--unit no_unit    25 %             delivered untouched
--unit no_unit    5000 ppm         delivered untouched
```

The second half is the one that surprises, so it is worth stating plainly: **`no_unit` as a target
does not strip units off dimensionless values.** There is no policy setting that turns `35 %` into
a bare number — by design, since that is precisely the rescale §4.2 exists to stop.

The fence is not "never convert dimensionless things": `ppm -> %` is a real and wanted conversion,
and it is unaffected. It is specifically about values that carry no unit at all.

### 4.3 Exact, or not at all

Nothing approximate is ever delivered. An exact **rational** and a terminating **positional
expansion** are different things, and only the second can be handed over as digits:

```text
100 mph   ->  m/s   base 10    44.704
100 km/h  ->  m/s   base 10    error_unit_inexact      (250/9)
212 °F    ->  °C    base 10    100
100 °F    ->  °C    base 10    error_unit_inexact      (340/9)
 12 in    ->  m     base 10    0.3048
  1 m     ->  k~m   base 10    0.001
```

Three things follow. Metric-to-metric is where the trouble is, not customary-to-metric: `km/h ->
m/s` carries a factor of 5/18 and does not terminate, while `mph -> m/s` is exactly `44.704` and
does. Whether a conversion terminates depends on the **value** as well as the units — `212 °F`
converts cleanly and `100 °F` does not, through the same 5/9 slope. And the output base matters
independently: `1 m -> k~m` is `0.001` in base 10 and non-terminating in base 2.

This is what `on_inexact` exists to shape; see [2.4](#24-inexact-results).

### 4.4 Quantity kinds keep the dimensionless units apart

Dimensionless does not collapse into one bucket. The library carries a quantity-kind vector
alongside the dimension vector, so decibels, the pH scale, angles, turbidity and practical
salinity are each their own kind and do not convert into one another:

```text
dB   ->  %          refused
pH   ->  no_unit    refused
B/s  ->  Hz         refused
rad  ->  °          allowed
```

Normalisation needs no hand-maintained skip list to keep these apart. The hazards are narrower
than the registry suggests: currencies ([4.1](#41-compatible-is-not-convertible)) and unitless
values ([4.2](#42-a-bare-number-matches-only-no_unit--and-no_unit-matches-only-a-bare-number)), not the logarithmic scales.

---

## 5. Writer-side policy

### 5.1 Validation only

The same policy object drives `bvnr_writer_set_unit_policy`, but only its validating half is
accepted: `require_unit`, `require_dimension_of`, and per-field rules in `bvnr_rule_require` mode.
A policy carrying `targets`, `normalise`, `base`, `on_inexact` or a `bvnr_rule_convert` rule is
rejected outright rather than half-honoured.

`bvnr_write_event` fails with `error_unit_mismatch`, and the writer latches it like any other
write error — query it with `bvnr_writer_get_error`.

The unit checked is the one the value will carry in the output, whether it was given inline or as
a parameter of its type annotation.

### 5.2 Why the producing side carries the promise

A reader can only reject a document somebody has already written. Only the writer can stop a bare
number reaching a file in the first place, which is what "hand the file to anyone and they have
everything required to interpret it" actually depends on.

### 5.3 Relationship to BVN_UNIT_REDUCE

The writer already has a value-rewriting mode: `BVN_UNIT_REDUCE` in
`bvnr_write_flags_t.unit_flags`, which folds prefixes out and rescales the value exactly. The
writer-side policy deliberately does not add a second one. Two rewriting paths arriving through
different doors, with different rules about exactness, is how two features end up disagreeing
about what a document says.

---

## 6. Errors

### 6.1 Rejected before the parse

The setter parses every unit string, so a malformed unit, an out-of-range count or an unusable
base is reported by a `false` return **before** the parse begins, rather than as a mid-document
error in somebody else's file. A rejected policy leaves the previous one in force.

### 6.2 Raised during the parse

| Error | Raised by |
|---|---|
| `error_unit_mismatch` | `require_unit` on a bare value; `require_dimension_of` matching nothing; a value at a path a rule names that is not convertible to the rule's unit; a value nested too deep for a rule to be evaluated at all ([§2.7](#27-limits-and-lifetime)) |
| `error_unit_inexact` | a conversion that cannot be delivered exactly, under `bvnr_inexact_error` |
| `error_invalid_argument` | a converted value that is negative in the output base 64 or 85, which have no sign character |

---

## 7. Using a policy

### 7.1 C

```c
static const bvnr_unit_target_t targets[] = { { "m/s", 0 }, { "°C", 0 } };
bvnr_unit_policy_t p = {0};
p.targets     = targets;
p.num_targets = 2;
p.normalise   = bvnr_normalise_si;
p.on_inexact  = bvnr_inexact_leave;
p.require_unit = true;

if (!bvnr_reader_set_unit_policy(r, &p)) { /* a unit string was malformed */ }
```

### 7.2 Python

```python
from bovnar import Reader, UnitPolicy

with Reader() as r:
    r.set_unit_policy(UnitPolicy(
        targets=["m/s", "°C"],
        normalise_si=True,
        leave_inexact=True,
        require_unit=True,
    ))
    r.read_file("sensors.bvnr", on_verified=handler)
```

A malformed unit raises `BovnarArgumentError` before a byte is read.

### 7.3 Command line

`validate`, `events` and `query` accept the options below. `pretty-print` and `convert` do not
take a unit policy at all, and they differ in how they say so: `pretty-print` rejects each one by
name — `pretty-print: unknown option --si`, exit 2 — while `convert` **accepts and silently
ignores** every flag in the table, including the ones that take an argument. A `bovnar convert --si`
that appears to work has not normalised anything.

| Flag | Field |
|---|---|
| `--require-unit` | `require_unit` |
| `--require-dimension <unit>` | `require_dimension_of` (repeatable) |
| `--require-field <path>=<unit>` | a `bvnr_rule_require` rule |
| `--unit <unit>` | a target (repeatable, first match wins) |
| `--field <path>=<unit>` | a `bvnr_rule_convert` rule |
| `--si` | `bvnr_normalise_si` |
| `--base <N>` | `base`, and the output base of every `--unit` target and `--field` rule |
| `--leave-inexact` | `bvnr_inexact_leave` |

**`--base` is stamped everywhere a base can apply**, not only on the policy's `base`: the CLI
copies it onto every `--unit` target and every `--field` rule, whatever order the flags came in.
That has a consequence §2.2 predicts: a target that asks for an output base restates a value
*already* in its unit, so with `--unit m --base 16` a value in `m` is converted too — `2.0 m`
becomes `2` in base 16 — where `--unit m` alone leaves it untouched. `--si --base 16`, with no
target, leaves a value already in coherent SI units alone, because normalisation only catches what
it would change.

`query` takes all of them except `--base`, which it refuses by name (exit 2):
it prints the value it found in decimal, and a base would change nothing it
shows.

**A conversion flag on `validate` is not a no-op.** `validate` produces no output,
so it is easy to read `--unit` and `--si` there as flags that could not matter —
and they change the verdict, because a conversion the policy cannot deliver
exactly fails the parse. `bovnar validate --si` on a document holding
`100 k~m/h` reports `unit_inexact at line 1, col 32` where the bare `validate`
reports OK. That makes it a useful question in its own right — *would this
document survive normalisation?* — with `--leave-inexact` as the way to ask the
weaker one. The CLI's own help listed only the `--require-*` flags under
`validate` until this was written, so neither the behaviour nor its remedy was
discoverable from `bovnar --help`.

---

## 8. Worked examples

The document used throughout:

```bovnar
.inlet = {
    .temperature = 212 °F;
    .speed       = 100 mph;
};
.spare = 0.25;
```

### 8.1 Deliver a document in SI

```console
$ bovnar query --unit m/s .inlet.speed sensors.bvnr
44.704
$ bovnar query --unit °C .inlet.temperature sensors.bvnr
100
```

`.spare` carries no unit, so no target claims it and it is delivered as written.

### 8.2 Assert a schema without converting

```console
$ bovnar validate --require-field .inlet.speed=m/s sensors.bvnr
sensors.bvnr: OK

$ bovnar validate --require-unit sensors.bvnr
Validation failed: unit_mismatch at line 5, col 14
```

The first asserts that `.inlet.speed` is a speed without touching the value — `mph` is convertible
to `m/s`, so the assertion holds. The second fails on `.spare`, which is the bare number.

### 8.3 Refuse to write a bare number

Set the same `require_unit` policy on a writer and the bare value never reaches a file:

```c
bvnr_unit_policy_t p = {0};
p.require_unit = true;
bvnr_writer_set_unit_policy(w, &p);
/* bvnr_write_event for a value with no unit now fails with
 * error_unit_mismatch, latched on the writer. */
```

---

## See also

- [Unit & Currency Reference](05_bovnar_unit_system.md) — the unit registry and the notation grammar
- [Unit Ambiguities](07_bovnar_unit_ambiguities.md) — how a unit token is resolved, and the pairs that look interchangeable
- [Read/Write API](08_bovnar_readwrite_api.md) — the C declarations, the `want_unit` hook, and the read flags
- [Python Bindings](09_bovnar_python_bindings.md) — the `UnitPolicy` dataclass and `Reader.set_unit_policy`
- [Rust Bindings](14_bovnar_rust_bindings.md) — the `UnitPolicy` struct and `Reader::set_unit_policy`

---

*End of Bovnar — Unit Policy Reference (Bovnar spec 1.2).*

<!-- ==================== Unit ambiguities (https://www.bovnar.io/doc/07_bovnar_unit_ambiguities.md) ==================== -->

# Bovnar — Unit Ambiguities

> **Spec version:** 1.2
> **Status:** Reference — companion to the unit registry and the cheat sheet
> **Scope:** Every token that could plausibly mean two things — what Bovnar reads it as, and how to write the other meaning.

Companion to [Unit & Currency Reference](05_bovnar_unit_system.md) (the registry) and
[Unit & Currency Cheat Sheet](04_bovnar_unit_cheatsheet.md) (the symbol tables). Every row here was checked
against the reference parser; where a token is refused, it really is `error_unit_illegal`.

---

## Table of Contents

1. [How a token is resolved](#1-how-a-token-is-resolved)
2. [A bare unit outranks a prefixed reading](#2-a-bare-unit-outranks-a-prefixed-reading)
3. [Two prefixed readings compete](#3-two-prefixed-readings-compete)
4. [Refused outright](#4-refused-outright)
5. [Prefix symbol vs. base unit symbol](#5-prefix-symbol-vs-base-unit-symbol)
6. [Case decides](#6-case-decides)
7. [Look-alike characters](#7-look-alike-characters)
8. [Temperature scales vs. electrical units](#8-temperature-scales-vs-electrical-units)
9. [Same dimension, different quantity](#9-same-dimension-different-quantity)
10. [An affine scale inside a compound has no value](#10-an-affine-scale-inside-a-compound-has-no-value)
11. [A named unit is not its compound](#11-a-named-unit-is-not-its-compound)
12. [Unit vs. currency](#12-unit-vs-currency)
13. [Looks like a unit, is not one](#13-looks-like-a-unit-is-not-one)
14. [Water hardness: six scales, one quantity](#14-water-hardness-six-scales-one-quantity)
15. [Same name, different definition](#15-same-name-different-definition)
16. [Quick index: if you mean X, write Y](#16-quick-index-if-you-mean-x-write-y)
17. [The same spelling in another namespace](#17-the-same-spelling-in-another-namespace)

- [See also](#see-also)

---

## 1. How a token is resolved

A unit component is resolved in this order. Knowing the order explains every table below.

| Step | Rule | Consequence |
|------|------|-------------|
| 1 | A `$` anywhere selects the **currency** table | `$USD`, `k$EUR`, `k~$EUR`. A bare code is never a currency: `USD` is `error_unit_illegal` |
| 2 | Otherwise the base symbol is the **longest alias suffix** of the token | `min` matches the alias `min` (minute) before it could match `in` (inch) |
| 3 | Whatever precedes the base is the **prefix**, with `~` optional | `k~g` and `kg` are one unit; the canonical spelling is `k~g` |
| 4 | The **prefix policy** of that unit must allow it | `Ki~m` and `Kim` both fail; IEC prefixes are for `b`/`B` only |
| 5 | A handful of spellings are **refused by name** | `usb`, `kt` — see §4 |

Rule 2 is the load-bearing one: **a token that is itself a unit is always that unit**, never a
prefixed reading of a shorter one. That is what made the compact form (`kg` for `k~g`) safe to
introduce — it can only ever be accepted where the parser previously raised an error, so no
document written before it existed changed meaning.

Rule 3's `~` is never wrong to write. When in doubt, or when a reader of your file might be in
doubt, use it: `m~in` cannot be misread, `min` can be misremembered.

---

## 2. A bare unit outranks a prefixed reading

These 19 tokens are units in their own right. The prefixed reading a reader might expect is
reachable only with the `~`.

| Token | Bovnar reads it as | Might be mistaken for | Write that as |
|-------|--------------------|-----------------------|---------------|
| `min` | minute | milli-inch | `m~in` |
| `cd` | candela | centi-day | `c~d` |
| `ft` | foot | femto-tonne | `f~t` |
| `pt` | pint | pico-tonne | `p~t` |
| `qt` | quart | quecto-tonne | `q~t` |
| `ct` | carat | centi-tonne | `c~t` |
| `yd` | yard | yocto-day | `y~d` |
| `rd` | rod | ronto-day | `r~d` |
| `ch` | chain | centi-hour | `c~h` |
| `at` | technical atmosphere | atto-tonne | `a~t` |
| `au` | astronomical unit | atto-dalton | `a~u` |
| `kat` | katal | kilo-(technical atmosphere) | `k~at` |
| `nmi` | nautical mile | nano-mile | `n~mi` |
| `PS` | metric horsepower (Pferdestärke) | peta-siemens | `P~S` |
| `ph` | phot | pico-hour | `p~h` |
| `pH` | acidity (pH scale) | pico-henry | `p~H` |
| `mph` | mile per hour | milli-phot | `m~ph` |
| `kph` | kilometre per hour | kilo-phot | `k~ph` |
| `dB` | decibel | deci-byte | *(impossible: an SI prefix below kilo is illegal on `B`)* |

> `pH`, `mph` and `kph` are the reason three quantities became units: while they were not in the
> table, the compact form resolved them as the picohenry and the milli-/kilophot. Naming the
> quantity is the durable fix — rule 2 then keeps the prefixed reading out on its own.

---

## 3. Two prefixed readings compete

When a token can be split in two places, rule 2 decides: the **longer base symbol** wins.

| Token | Bovnar reads it as | The other split | Write that as |
|-------|--------------------|-----------------|---------------|
| `dat` | deci-`at` = deci-(technical atmosphere) | deca-tonne | `da~t` |
| `dau` | deci-`au` = deci-(astronomical unit) | deca-dalton | `da~u` (canonical `da~Da`) |

Both are deterministic, not ambiguous — but neither is guessable. Write `d~at` / `da~t` and
`d~au` / `da~u` explicitly; the separated form is unambiguous in both directions.

---

## 4. Refused outright

These tokens are `error_unit_illegal` on purpose, listed in `.compact_exceptions` in
`src/gendata/units.bvnr`. Accepting them would turn a parse error into a quietly wrong unit.

| Token | Would have resolved as | Actually means, in the wild | Write |
|-------|------------------------|-----------------------------|-------|
| `kt` | kilo-tonne | kilotonne **or** knot, depending on the field | `k~t` (mass) or `kn` (speed) |
| `usb` | micro-stilb | the bus | `u~sb` (or `µ~sb`) if you really mean microstilb |
| `ppt` | pico-pint (a **volume**) | parts per trillion **or** parts per thousand | `pptr` (10⁻¹²), `‰` (10⁻³), or `p~pt` if you really mean the picopint |
| `MBtu`, `MBTU`, `Mbtu`, `Mlb`, `Mlbs` | mega-Btu, mega-pound (10⁶) | a **thousand** — US trade's Roman M (`MMBtu` is its million) | `k~Btu`, `k~lb` (10³), or `M~Btu`, `M~lb` (10⁶) |

`kt` is the harder case of the first two: both readings are units Bovnar models, so no table lookup
can settle it. Reading a speed as a mass is exactly the failure this format exists to prevent, so
the author is asked to say which.

`ppt` is the same argument twice over. The compact form resolved it as the picopint, so a
dimensionless ratio read as a volume; and the two things a writer might have meant differ by 10⁹.
Bovnar's parts per trillion is spelled **`pptr`**, following UCUM, which splits the same ambiguity
the same way (`[ppth]` for per thousand, `[pptr]` for per trillion). Note the asymmetry with §2:
`ppt` is *not* claimed by the new unit, because a bare alias would have taken the spelling away
from `p~pt` — see the note in `units.bvnr`, and rule 2's guarantee that no existing document
changes meaning. Making it an error takes nothing away silently.

---

## 5. Prefix symbol vs. base unit symbol

Eight symbols are both a prefix and a unit. Bare, they are always the unit; before another unit
they are always the prefix.

| Symbol | As a bare unit | As a prefix | Example of each |
|--------|----------------|-------------|-----------------|
| `m` | metre | milli | `m` = metre, `ms` / `m~s` = millisecond |
| `d` | day | deci | `d` = day, `ds` / `d~s` = decisecond |
| `h` | hour | hecto | `h` = hour, `hPa` / `h~Pa` = hectopascal |
| `T` | tesla | tera | `T` = tesla, `TB` / `T~B` = terabyte |
| `G` | gauss | giga | `G` = gauss, `GHz` / `G~Hz` = gigahertz |
| `P` | poise | peta | `P` = poise, `PB` / `P~B` = petabyte |
| `R` | roentgen | ronna | `R` = roentgen, `Rm` / `R~m` = ronnametre |
| `u` | dalton (ASCII alias of `Da`) | micro (ASCII alias of `µ`) | `u` = dalton, `us` / `u~s` = microsecond, `uu` = micro-dalton |

`da` (deca) is a two-character prefix: `dam` = decametre, `dag` = decagram, `das` = decasecond.
It is not related to `Da` (dalton) — case separates them (§6).

---

## 6. Case decides

Symbols are matched byte for byte. These pairs differ only in case and mean unrelated things;
several are one keystroke apart in everyday data.

Start with the **bare** symbols, because the prefixed rows below are built from
them and they are the three most common symbols in the whole table:

| Lower | Means | Upper | Means |
|-------|-------|-------|-------|
| `s` | second | `S` | siemens |
| `h` | hour | `H` | henry |
| `g` | gram | `G` | gauss |
| `kn` | knot | `kN` | **kilonewton** — the same trap as `gn`/`GN` and `fn`/`fN` below |
| `grad` | gradian (also `gradian`, `gon`) | `Grad` | **gigaradian** — and German writes *Grad* for the degree, so a German-language source is the likely way to type this by accident. Worse than the others: both are angles, so it *converts*, by a factor of 10⁹ |

Then the prefixed pairs:

| Lower | Means | Upper / mixed | Means |
|-------|-------|---------------|-------|
| `ms` | millisecond | `mS` | millisiemens |
| `pa`* | — | `Pa` / `pA` | pascal / picoampere |
| `kg` | kilogram | `kG` | kilogauss |
| `mt` | millitonne | `Mt` / `MT` | megatonne / megatesla |
| `gt`* | — | `Gt` / `GT` | gigatonne / gigatesla |
| `mb` | milli-bit → **illegal** (sub-kilo prefix on `b`) | `MB` / `Mb` | megabyte / megabit |
| `kb` | kilobit | `kB` | kilobyte |
| `gib` | — | `Gib` / `GiB` | gibibit / gibibyte |
| `gal` | gallon | `Gal` | galileo (`mgal` = milligallon, `mGal` = milligal) |
| `st` | stone | `St` | stokes (`cst` = centistone, `cSt` = centistokes) |
| `ha` | hectare | `hA` | hectoampere |
| `hp` | horsepower | `hP` | hectopoise |
| `gn` | standard gravity | `GN` | giganewton |
| `gr` | grain | `GR` | gigaroentgen |
| `dr` | dram | `dR` | deciroentgen |
| `fn` | fortnight | `fN` | femtonewton |
| `da` | *(prefix deca)* | `Da` / `dA` | dalton / deciampere |
| `np`* | — | `Np` / `nP` | neper / nanopoise |
| `ev`* | — | `eV` / `EV` | electronvolt / exavolt |
| `ph` | phot | `pH` | acidity |
| `ps` | **picosecond** — a valid unit, silently | `PS` / `pS` | metric horsepower / picosiemens |
| `cv`* | — | `CV` / `cV` | metric horsepower (French *chevaux*) / centivolt |

\* not a valid token in that casing — listed because the valid neighbours are easy to mistype.

Common miscasings that are **errors**, not silent variants — `K` is the kelvin, not the kilo prefix:

| Written | Result | Intended | Write |
|---------|--------|----------|-------|
| `dH` | decihenry — a valid unit, silently | German hardness degree | `°dH` |
| `cF` | centifarad — a valid unit, silently | hydroponic conductivity factor | `CF` |
| `fau` | femto-astronomical-unit — a valid unit, silently | formazin attenuation unit | `FAU` |
| `Kg`, `KG` | `error_unit_illegal` | kilogram | `kg` or `k~g` |
| `KB` | `error_unit_illegal` | kilobyte | `kB` or `k~B` |
| `NM` | `error_unit_illegal` | nautical mile | `nmi` |
| `Cal` | `error_unit_illegal` | kilocalorie (food calorie) | `kcal` or `k~cal` |

---

## 7. Look-alike characters

Several symbols exist in more than one Unicode code point. Bovnar accepts the variants on input
and always emits one canonical form.

| Meaning | Accepted spellings | Canonical output |
|---------|--------------------|------------------|
| micro prefix | `µ` U+00B5 · `μ` U+03BC (Greek mu) · `u` (ASCII) | `µ` U+00B5 |
| ohm | `Ω` U+2126 · `Ω` U+03A9 (Greek omega) · `ohm` · `Ohm` | `Ω` U+2126 |
| ångström | `Å` U+212B · `Å` U+00C5 · `Å` U+0041 U+030A (A + combining ring) · `angstrom`, `angstroms` | `Å` U+212B |
| degree | `°` U+00B0 · `deg` · `degr` · `degree` · `degrees` | `°` U+00B0 |

A document that has been through canonical Unicode normalisation (NFC or NFD) still parses: every
variant above maps to the same unit, which the normalisation test sweep enforces over the whole
table. **Compatibility normalisation (NFKC or NFKD) is different: it destroys superscript
exponents.** It folds `²` to `2` and `⁻` to U+2212 MINUS SIGN, so `m/s²` becomes `m/s2`, which is
`error_unit_illegal`. A document that may pass through NFKC or NFKD should write its exponents in
the caret form — `m/s^2`, `m*s^-1` — which is plain ASCII and survives every normalisation form.

> **The canonical output is deliberately not NFC.** U+2126 (ohm) and U+212B (ångström) both have
> *singleton* NFC decompositions — to U+03A9 and U+00C5 — so a document this library writes is not
> Unicode-normalised, and running it through NFC **changes its bytes**. It still parses, and
> re-serialising returns U+2126/U+212B, so the round trip is a semantic fixed point but not a byte
> fixed point. That matters if you hash, sign or byte-diff bovnar output (the conformance suite's
> byte-for-byte comparison included): normalise before hashing, or do not normalise at all — but do
> not assume the two are the same file.

`u` is both the ASCII micro prefix and the bare symbol for the dalton — rule 2 keeps them apart:
`u` alone is the dalton, `us` is a microsecond, `uu` is a micro-dalton.

---

## 8. Temperature scales vs. electrical units

The degree sign is not decoration: without it the token is a different quantity entirely.

| With `°` | Means | Without `°` | Means |
|----------|-------|-------------|-------|
| `°C` | degree Celsius | `C` | coulomb |
| `°F` | degree Fahrenheit | `F` | farad |
| `°N` | degree Newton (historical scale) | `N` | newton |
| `°Ra` | degree Rankine | `Ra` | *(accepted alias of `°Ra`)* |

ASCII aliases avoid the question: `degC`, `degF`, `degRa`, `degN`, `degDe`, `degRe`, `degRo`.
`K` (kelvin) is absolute and needs no degree sign.

The temperature *intervals* (§9) prefix the same tokens with `Δ` (U+0394) and carry the same
warning, resolved the same way — `delta_degC`, `delta_degF`, `delta_degRa`, `delta_degN`,
`delta_degDe`, `delta_degRe`, `delta_degRo`, and `delta_K` for `ΔK`. There is deliberately **no**
`delta_C` or `delta_F`: those read as a delta coulomb and a delta farad, exactly as the table above
warns for `C` and `F`, so they are `error_unit_illegal` rather than aliases. `delta_K` is the one
bare-letter form, and it is unambiguous because `K` is the kelvin and nothing else.

The water-hardness degrees carry the same warning, and two of them are genuinely dangerous:

| With `°` | Means | Without `°` | Means |
|----------|-------|-------------|-------|
| `°dH` | German hardness degree | `dH` | **decihenry** — a valid unit, so this misreads silently |
| `°e` | English (Clark) hardness degree | `e` | **elementary charge** — a valid unit, so this misreads silently too |
| `°fH` | French hardness degree | `fH` | femtohenry |
| `°rH` | Russian hardness degree | `rH` | rontohenry |
| `°aH` | American hardness degree | `aH` | attohenry |

Their ASCII long forms avoid the question entirely: `german_hardness`, `english_hardness`
(or `clark_degree`), `french_hardness`, `russian_hardness`, `american_hardness`.

---

## 9. Same dimension, different quantity

The SI dimension vector cannot tell these apart — they are all dimensionally equal — yet they are
not interchangeable in meaning. Bovnar's compatibility check is dimension-based, so it **accepts**
conversions in the first group; the second group is protected by an explicit quantity-kind rule.

**Dimensionally equal, and Bovnar will convert between them — the distinction is yours to keep:**

| Units | Shared dimension | The distinction Bovnar cannot see |
|-------|------------------|-----------------------------------|
| `J` and `N·m` | kg·m²·s⁻² | energy vs. torque |
| `Gy` and `Sv` | m²·s⁻² | absorbed dose vs. equivalent dose |
| `W`, `VA`, `var` | kg·m²·s⁻³ | active, apparent and reactive power |
| `Hz`, `Bq`, `Bd` | s⁻¹ | frequency, radioactivity, symbol rate |
| `mph`, `kn`, `m/s` | m·s⁻¹ | genuinely interchangeable — just different scales |

**The factor is not always 1, and that is the part to read twice.** Each row above
names the units that happen to be worth *one* of each other, but what the
compatibility check actually accepts is the whole **dimensional family** — every
native unit of that dimension converts to every other, at whatever factor relates
them. Most of those conversions are exactly right: `cal` → `J`, `Ci` → `Bq`,
`hp` → `W`. The ones that cross a quantity boundary Bovnar cannot see are right
about the arithmetic and wrong about the quantity, and then the **number changes
too**, so the result does not look like a relabelling:

| Written | Read as | Bovnar returns | What just happened |
|---------|---------|----------------|--------------------|
| `1 Ci` | `Hz` | 3.7×10¹⁰ | a radioactive source read as a frequency |
| `1 rem` | `Gy` | 0.01 | an equivalent dose read as an absorbed dose |
| `1 hp` | `var` | 745.6998715822702 | a mechanical power read as reactive power |
| `1 cal` | `N·m` | 4.184 | an energy read as a torque |
| `1 rpm` | `Bq` | 1/60 | a rotation rate read as an activity |

The families, in full, are: `Hz` `Bq` `Bd` `Ci` `rpm` (s⁻¹); `Gy` `Sv` `rem`
(m²·s⁻²); `W` `var` `VA` `PS` `hp` `hp_E` `hp_B` `hp_W` `ton_ref` (kg·m²·s⁻³); and the sixteen
energy units `J` `eV` `cal` `Btu` `erg` `thm` `ft_lb` `cal_IT` `Btu_th` `thm_ec` `cal_m`
`cal_15` `cal_20` `Btu_59` `Btu_60` `Btu_m`
(kg·m²·s⁻²), which `N·m` joins as a compound.

> **Why these are not given quantity kinds, when `b`/`B` and `lm`/`cd` are.**
> A kind works where the distinction is carried by a *unit* that appears in the
> definition: `sr` holds the photometric kind, so `cd·sr` inherits it and
> `lm` ↔ `cd·sr` keeps working while `lm` ↔ `cd` is refused. Nothing plays that
> part here — a dose weighting factor and a power factor are dimensionless and
> equal to 1 by convention. So a kind on `Sv` would have to refuse `Sv` ↔ `J/kg`
> as well, and one on `VA` would refuse `VA` ↔ `V·A`, which is what a
> volt-ampere *is*; leaving those accepted instead would make `Gy` ≡ `J/kg` ≡
> `Sv` while `Gy` ≢ `Sv`, and code that screens targets with
> `bvn_units_convertible` would then depend on which spelling it was handed.
> The distinction belongs to a quantity-kind vocabulary rather than to a unit,
> which is where Bovnar carries it: `qudt-qk` maps QUDT's kinds to their coherent
> SI units, and that is what separates `Torque` from `Work`.

> Offering the conversion is not the same as rewriting the annotation.
> `BVN_UNIT_REDUCE` leaves every unit in this table alone: a `Sv` serialises as
> `Sv`, never as `Gy`, and a `Ci` never becomes `3.7e10 Hz`. The collapse folds a
> *compound* into the named unit it spells out (`A·s` → `C`); it never
> substitutes one named unit for another. Crossing one of these lines takes an
> explicit conversion request — a `want_unit` target, a unit policy, or `--unit`
> on the command line — so the risk is in what a *consumer* asks for, not in what
> a document says.

**Dimensionless but *not* interchangeable — Bovnar refuses these conversions:**

| Unit | Kind | Refused against |
|------|------|-----------------|
| `b` | information (bit) | `B` — 8 bits are not silently a byte |
| `B` | information (byte) | `b` |
| `rad`, `°`, `grad`, `rev`, `arcmin`, `arcsec` | angle (one shared kind, so `°` → `rad` works) | plain numbers, `Hz`, `%` |
| `sr` | angle, weight 2 (`sr` = `rad²`) | `rad` at exponent 1 |
| `lm`, `lx`, `ph` | angle, weight 2 — each carries one steradian (`lm` = `cd·sr`) | `cd`, `cd/m²`, `sb`: luminous flux is not luminous intensity, illuminance is not luminance |
| `dB` | logarithmic | `Np`, `%`, plain numbers |
| `Np` | logarithmic | `dB`, `%`, plain numbers |
| `pH` | logarithmic | `%`, `ppm`, plain numbers |
| `NTU` | turbidity, white light 90° | every other turbidity scale, `%`, plain numbers |
| `FNU` | turbidity, near-infrared 90° | ditto |
| `FTU` | turbidity, geometry unstated | ditto |
| `FAU` | turbidity, attenuation 0° | ditto |
| `JTU` | turbidity, visual candle | ditto |
| `PSU` | practical salinity | `‰`, `g/kg`, plain numbers |
| `ΔK`, `Δ°C`, `Δ°F`, `Δ°Ra`, `Δ°De`, `Δ°N`, `Δ°Re`, `Δ°Ro` | temperature **interval** (one shared kind, so `Δ°F` → `ΔK` works at 5/9) | `K`, `°C`, `°F` and every other temperature *scale*: 25 °C is 298.15 K, a *rise* of 25 degrees is 25 K |
| `%`, `‰`, `‱`, `pcm`, `ppm`, `ppb`, `pptr`, `ppq` | pure ratios | *(freely interconvertible, and with a plain number: 1 % → 0.01)* |

Water chemistry calls the American hardness scale "ppm" (milligrams of CaCO₃ per litre). Bovnar's
`ppm` is the **dimensionless** 10⁻⁶ and the hardness scale is `°aH`, an amount concentration — the
two carry different dimensions and do not convert into one another. If your source says "ppm
hardness", write `°aH`.

The temperature-interval kind is the one that is scoped to a **lone unit at exponent 1**, and every
other kind in the table is not. The reason is §10: an offset can only ever be applied to a lone bare
temperature, so that is the only place a difference could have been misread as a reading. Inside a
compound `ΔK` and `K` are the *same unit* — `W/(m²·ΔK)` is `W/(m²·K)`, `ΔK/k~m` is `K/k~m` — because
the `K` there was already an interval.

The logarithmic kinds are separate because no factor can relate them: 20 dB is a ratio of 100, not
twice 10 dB, and a pH one unit lower is a tenfold concentration. `rpm` is a *cycle* rate and
`rev/min` an *angular* rate — they differ by 2π and do not convert into one another either.

---

## 10. An affine scale inside a compound has no value

`°C`, `°F`, `°De`, `°N`, `°Re` and `°Ro` are *offset* scales: 0 is not nothing.
That is fine for a lone temperature and meaningless in a product.

| Written | Parses | Converts | Why |
|---------|--------|----------|-----|
| `°C` | yes | yes — `20 °C` → `293.15 K` | the offset has somewhere to go |
| `°C/h` | yes | **no** — `error_unit_mismatch` | the offset is a number of kelvin; `K·s⁻¹` has nowhere to put it |
| `°C·m` | yes | **no** | ditto |
| `°C²`, `°C·°F` | yes | **no** | an offset scale is meaningful at exponent 1 only |

The annotation stays legal, because it is a reasonable thing to *write* — people
do record a rate of change in °C/h. What it means is a temperature **difference**
per hour, and the difference is what Bovnar cannot infer from the unit: `20 °C/h`
as a rate is `20 K/h`, while `20 °C` as a reading is `293.15 K`. Rather than pick
one, the library refuses to produce a number and leaves the components for a
consumer that knows which was meant. Write `ΔK/h` when you mean the rate — or
`K/h`, which is the same unit (§9) — and the ambiguity disappears.

**The lone case now has a spelling too, and it did not before.** `<float:64,°C> 25.0`
is a reading of 25 °C and converts to 298.15 K; if you meant a *rise* of 25 degrees,
write `<float:64,Δ°C> 25.0`, which converts to 25 ΔK. This was the one place in the
format where a wrong unit produced a wrong number silently — the two documents were
byte-identical and there was no third spelling to disambiguate them. See doc/05 §3.4
and doc/temperature_difference.md.

---

## 11. A named unit is not its compound

A named unit is a single component; the compound that means the same thing is several. Both are
valid and both convert to the same SI value, but annotation-vs-inline reconciliation is
**structural**, so mixing the two spellings in one value is `error_unit_mismatch`.

| Named | Equivalent compound | Same SI factor? | `<float:64,named> 1.0 compound;` |
|-------|---------------------|-----------------|----------------------------------|
| `mph` | `mi/h` | yes (0.44704 m·s⁻¹) | `error_unit_mismatch` |
| `kph` | `k~m/h` | yes (5/18 m·s⁻¹) | `error_unit_mismatch` |
| `kn` | `nmi/h` | yes | `error_unit_mismatch` |
| `Hz` | `s^-1` | yes | `error_unit_mismatch` |
| `Pa` | `k~g/(m·s²)` | yes | `error_unit_mismatch` |

Pick one spelling per document and stay with it. `bvn_units_compatible` and
`bvn_unit_convert_factor` relate them, so a consumer can still convert freely.

---

## 12. Unit vs. currency

A currency is recognised **only** with the `$` sigil, so the two namespaces cannot collide. The
classic cases:

| Token | Bare (no `$`) | With `$` |
|-------|---------------|----------|
| `cup` / `CUP` | `cup` = US cup; `CUP` = `error_unit_illegal` | `$CUP` = Cuban Peso |
| `BTU` | British thermal unit (`Btu`, `btu` too) | *(not an ISO 4217 code)* |
| `SOL` | `error_unit_illegal` | `$SOL` = Solana |
| `BAR` | `error_unit_illegal` (use lowercase `bar`) | *(not an ISO 4217 code)* |
| `USD` | `error_unit_illegal` | `$USD` |

A prefix goes before the sigil, `~` optional: `k~$EUR` and `k$EUR` are both thousands of euro.
`kUSD` is an error — the sigil is not optional.

---

## 13. Looks like a unit, is not one

Common abbreviations that are **not** in the table. All are `error_unit_illegal`, which is the
point: a wrong unit is worse than a refused one.

| Written | Intended | Write instead |
|---------|----------|---------------|
| `kWh` | kilowatt-hour | `kW·h` or `k~W·h` |
| `mAh` | milliampere-hour | `mA·h` or `m~A·h` |
| `mcg` | microgram | `µg`, `ug` or `µ~g` |
| `cc` | cubic centimetre | `cm³`, `cm^3` or `c~m³` |
| `sqft` | square foot | `ft²` or `ft^2` |
| `ksi` | kilopound per square inch | `kpsi` or `k~psi` |
| `kts` | knots | `kn` |
| `hr`, `hrs` | hour | `h` |
| `Cal` | food calorie | `kcal` or `k~cal` |
| `NM` | nautical mile | `nmi` |
| `KB`, `Kg` | kilobyte, kilogram | `kB`, `kg` |
| `kt` | kilotonne or knot | `k~t` or `kn` (§4) |
| `MBtu`, `Mlb` | a thousand Btu or pounds (US trade's Roman M) | `k~Btu`, `k~lb`; a million → `M~Btu`, `M~lb` (§4) |

`M` is mega everywhere in the SI, and a document from the gas, steam or commodity trade
usually means 10³ by it, so `MBtu` and `Mlb` are refused rather than read 1000× too large.

Three that *are* accepted but rarely mean what the writer intended:

| Written | Bovnar reads | If you meant… |
|---------|--------------|---------------|
| `fl` | femtolitre | fluid ounce → `fl_oz` |
| `mt` | millitonne (= 1 kg) | metric ton → `t`; megatonne → `Mt` or `M~t` |
| `rad`, `mrad` | radian, milliradian | the absorbed dose (0.01 Gy, UCUM `RAD`) → `c~Gy`; a millirad → `10 µ~Gy` |

---

## 14. Water hardness: six scales, one quantity

All six measure the concentration of dissolved alkaline-earth ions, and Bovnar converts freely
between them and `m~mol/L`. What differs is the reference compound each scale counts, which is why
their *mass*-per-litre definitions are not comparable and the amount concentration is.

| Scale | Symbol | Defined as | 1 mmol/L equals |
|-------|--------|------------|-----------------|
| German | `°dH` | 10 mg CaO / L | 5.6077 °dH |
| English / Clark | `°e`, `°Clark` | 1 grain CaCO₃ / imperial gallon | 7.0217 °e |
| French | `°fH` | 10 mg CaCO₃ / L | 10.0086 °fH |
| Russian | `°rH` | 1 mg Ca / L | 40.078 °rH |
| American | `°aH` | 1 mg CaCO₃ / L (called "ppm") | 100.086 °aH |
| US grains | `gpg` | 1 grain CaCO₃ / US gallon | 5.8468 gpg |
| Equivalents | `m~val/L`, `m~eq/L` | ½ mmol/L (divalent ions) | 2.000 mval/L |
| Amount | `m~mol/L` | — | 1 |

The traps, in order of how easily they bite:

| Trap | Why | Do this |
|------|-----|---------|
| `dH` instead of `°dH` | `dH` is the decihenry — an accepted unit, so nothing errors | keep the `°`, or write `german_hardness` |
| "ppm" for hardness | Bovnar's `ppm` is the dimensionless 10⁻⁶ | write `°aH` |
| `mval/L` for a monovalent species | `val` here is the *water-analysis* equivalent: 1 val = ½ mol | write `m~mol/L` for the amount directly |
| a clinical `meq/L` (Na⁺, K⁺, Cl⁻) written natively | native `eq` is `val`, so `meq/L` is ½ mmol/L — half of `ucum:meq/L` | write `ucum:meq/L` or `m~mol/L` |
| Expecting an exact conversion | the degrees are derived from molar masses, so they carry `.exact = false` | ordinary conversion works; a lossless one reports `error_unit_inexact` |
| `mmol/l` as a "unit" | it is the compound `m~mol/L`, not a base unit | write `mmol/L`, `m~mol/L` or `mmol/l` — all the same |
| `gr/gal` for `gpg` | `gr/gal` is a *mass* concentration, `gpg` an *amount* concentration; they do not convert | write `gpg` for the hardness scale |

**Turbidity and salinity are instrument scales**, and the trap is that they look interchangeable.

| Token | Means | Not | Because |
|-------|-------|-----|---------|
| `NTU` | white-light turbidity, 90° (EPA 180.1) | `FNU`, `FTU`, `FAU`, `JTU` | equal on a formazin standard, different on real water — the optics respond differently to particle size and colour |
| `FNU` | near-infrared turbidity, 90° (ISO 7027) | the other four | same reason, from the other side |
| `FTU` | formazin turbidity, **geometry unstated** | the other four | it does not say which optics were used; that is its entire content |
| `FAU` | formazin *attenuation*, 0° (ISO 7027) | the nephelometric three | measures light removed from the beam, not scattered sideways — a different optical quantity, used above ~40 FNU |
| `JTU` | Jackson candle turbidimeter (historical) | the formazin four | "1 JTU ≈ 1 NTU" holds near 40 units and nowhere else — nonlinear and sample-dependent |
| `PSU` | practical salinity (PSS-78, a conductivity ratio) | `‰`, `g/kg` | *S*_P 35 is ≈ 35.165 g/kg — equating them is wrong by ~0.5 %; write `g/k~g` for absolute salinity |
| `CF` | hydroponic conductivity factor (EC in mS/cm × 10) | `cF` — that is the **centifarad** | uppercase only |

`JTU` and `PSU` take no prefix (the candle method cannot resolve below ~25 JTU; practical salinity
is bounded by construction). The other turbidity scales do — `m~NTU` is real in ultrapure-water
work.

Bovnar refuses every conversion in the "Not" column rather than implying a factor exists. `CF` is
the exception in the other direction: it really is a conductivity, so `1 CF = 0.1 mS/cm = 100 µS/cm`
converts exactly.

**Conductivity and dissolved solids** need no units of their own, and that is the trap: people look
for one.

| Quantity | Write | Not |
|----------|-------|-----|
| Electrical conductivity | `µS/cm`, `mS/cm`, `dS/m`, `S/m` | a base unit — there isn't one, and none is needed |
| … in the pre-SI spelling | `µmho/cm` (`mho`, `mhos`, `℧` are the siemens) | `mho` was unknown before this; it is an alias now |
| Total dissolved solids | `mg/L` | `ppm` — that is the dimensionless 10⁻⁶ |
| Resistivity | `MΩ·cm` | — |

For TDS the `ppm` question is softer than for hardness: at 1 kg/L, 1 mg/L *is* 1 ppm by mass, so
`ppm` states a real (dimensionless) quantity. It simply is not the same dimension as `mg/L`, so the
two never convert into one another. A meter's "TDS ppm" reading is EC times an instrument factor —
record the EC and the factor, or the TDS as `mg/L`.

---

## 15. Same name, different definition

Not parser ambiguities — real-world ones. Bovnar keeps these distinct, and the distinction is
usually where the money is.

| Family | Members and exact values |
|--------|--------------------------|
| Ton | `t` = 1000 kg · `tn_sh` (short ton) = 907.18474 kg · `tn_l` (long ton) = 1016.0469088 kg |
| Ounce | `oz` (mass) = 28.349523125 g · `oz_t` (troy) = 31.1034768 g · `fl_oz` (volume) = 29.5735295625 mL |
| Gallon | `gal` (US) = 3.785411784 L · `gal_uk` = 4.54609 L (US is 0.8327× the imperial) |
| Pint / quart | `pt`, `qt` (US) vs. `pt_uk`, `qt_uk` — same 0.8327× ratio |
| Pressure ≈ 1 atm | `atm` = 101325 Pa · `at` (technical) = 98066.5 Pa · `bar` = 100000 Pa |
| Millimetre of mercury | `Torr` = 101325/760 Pa ≈ 133.322368 Pa · `mmHg` = 133.322387415 Pa — **not** the same unit (they differ by 1.4×10⁻⁷ relative) |
| Horsepower | `hp` (mechanical) = 745.6998715822702 W · `PS` (metric) = 735.49875 W |
| Calorie | `cal` = 4.184 J (thermochemical) · `cal_IT` = 4.1868 J (international table) — 0.067 % apart; the food "Calorie" is `k~cal` |
| BTU | `Btu` = 1055.05585262 J (international table) · `Btu_th` = 1054.35026449 J (thermochemical) — the same 0.067 %, and note the pairing crosses over: bovnar's unqualified calorie is the thermochemical one and its unqualified BTU the IT one, because that is what UCUM and UDUNITS respectively mean by theirs |
| Dram | `dr` (avoirdupois) = 1.7718451953125 g · `dr_ap` (apothecary) = 3.8879346 g — **2.2× apart**, which is the widest near miss in the registry |
| Pound | `lb` (avoirdupois) = 0.45359237 kg · `lb_t` (troy, = the apothecary pound) = 0.3732417216 kg |
| Hundredweight | `cwt_l` (long/imperial) = 112 lb = 50.80234544 kg · the short one is exactly 100 lb, so write `h~lb` |
| Water column | `mH2O` = 9806.65 Pa is the **conventional** column (water at 1000 kg/m³). A column stated at a temperature — UDUNITS' `water_4C`, QUDT's `IN_H2O` — is a different unit and is refused rather than rounded onto this one |
| Thou | `thou` = `mil` = 25.4 µm — `mil` is **not** a milliradian, which is `mrad` / `m~rad` |

---

## 16. Quick index: if you mean X, write Y

| If you mean | Write | Not |
|-------------|-------|-----|
| millisecond | `ms`, `m~s` | `mS` (millisiemens) |
| millisiemens | `mS`, `m~S` | `ms` |
| kilogram | `kg`, `k~g` | `Kg`, `KG` |
| kilobyte / kilobit | `kB` / `kb` | `KB` |
| kilobyte (binary) | `KiB`, `Ki~B` | `kB` (decimal, 1000 B) |
| megatonne | `Mt`, `M~t` | `MT` (megatesla), `mt` (millitonne) |
| kilotonne | `k~t` | `kt` (refused) |
| knot | `kn` | `kt`, `kts` |
| milli-inch | `m~in` | `min` (minute) |
| milliradian | `mrad`, `m~rad` | `mil` (thou) |
| absorbed dose in rad | `c~Gy` (1 rad = 1 cGy) | `rad` (the radian) |
| thousand Btu (US "MBtu") | `k~Btu` | `MBtu` (refused) |
| million Btu (US "MMBtu") | `M~Btu` | `MMBtu` (refused) |
| picohenry | `p~H` | `pH` (acidity) |
| acidity | `pH` | `p~H` (picohenry) |
| phot | `ph` | `pH` |
| millilitre | `mL`, `m~L` | `ml` is fine too; `fl` is a femtolitre |
| microgram | `µg`, `ug`, `µ~g` | `mcg` |
| kilowatt-hour | `kW·h`, `k~W·h` | `kWh` |
| cubic centimetre | `cm³`, `cm^3` | `cc` |
| miles per hour | `mph` (or `mi/h`) | mixing both in one value |
| kilometres per hour | `kph`, `kmh` (or `k~m/h`) | mixing both in one value |
| degree Celsius | `°C`, `degC` | `C` (coulomb) |
| thousand US dollars | `k$USD`, `k~$USD` | `kUSD` |
| a plain count | omit the unit, or `no_unit` | `%` (that is 10⁻²) |
| German water hardness | `°dH`, `german_hardness` | `dH` (decihenry) |
| hardness "in ppm" | `°aH` | `ppm` (dimensionless 10⁻⁶) |
| hardness in equivalents | `m~val/L`, `mval/l` | `m~mol/L` (that is twice the value) |
| hardness in millimoles | `m~mol/L`, `mmol/L` | — it needs no unit of its own |
| hardness in US grains | `gpg` | `gr/gal` (a mass concentration) |
| conductivity | `µS/cm`, `mS/cm`, `dS/m` | — no base unit exists or is needed |
| conductivity, older US data | `µmho/cm` | — `mho` is the siemens |
| dissolved solids | `mg/L` | `ppm` (dimensionless 10⁻⁶) |
| turbidity, white light 90° | `NTU` | `FNU`, `FTU` |
| turbidity, near-IR 90° | `FNU` | `NTU`, `FTU` |
| turbidity, method unstated | `FTU` | `NTU` or `FNU` — those claim a geometry |
| turbidity, attenuation | `FAU` | `FNU` (a different optical quantity) |
| turbidity, candle method | `JTU` | any formazin scale |
| practical salinity | `PSU` | `‰` or `g/kg` |
| absolute salinity | `g/k~g` | `PSU` |
| hydroponic EC scale | `CF` | `cF` (centifarad) |
| thermochemical calorie | `cal` | `cal_IT` (0.067 % larger) |
| international-table calorie | `cal_IT` | `cal` |
| international-table BTU | `Btu` | `Btu_th` |
| thermochemical BTU | `Btu_th` | `Btu` |
| apothecary dram | `dr_ap` | `dr` (avoirdupois, 2.2× smaller) |
| troy or apothecary pound | `lb_t` | `lb` (avoirdupois) |
| long hundredweight | `cwt_l` | `h~lb` (that is the SHORT one, 100 lb) |
| centimetre of water | `cmH2O`, `c~mH2O` | `c~m~H2O` — the prefix goes on `mH2O`, which is the metre of water |

---

## 17. The same spelling in another namespace

> **Spec 1.2.** The `namespace:code` notation needs a `#!bovnar 1.2` directive; see
> [Unit Profiles](11_bovnar_unit_profiles.md).

Everything above is about one token read two ways *inside Bovnar's own registry*. A unit profile
adds a second axis: the same letters mean different things in different vocabularies, and the
namespace is what keeps them apart. This is the reason the namespace is **mandatory** rather than a
fallback — there is no spelling a parser could try natively first and in UCUM second without
sometimes being wrong.

Every row below was produced by the reference implementation.

| Spelling | Native | In a profile | The trap |
|---|---|---|---|
| `st` | the **stone**, 6.35 kg | `ucum:st` → `m³`, the **stere** | The sharpest collision of the set: same two letters, different *dimension* |
| `B` | the **byte** | `ucum:B` → `da~dB`, the **bel** | UCUM writes the byte `By`. `qudt:BYTE` and `udunits:byte` are the byte |
| `ar` | *(not a unit)* | `ucum:ar` → `c~ha`, the **are** | The hectare is `ucum:har`, not `ucum:ar` — an easy off-by-100 |
| `a` | *(not a unit)* | `ucum:a` → `yr`, the **Julian year** | The one letter that means a **different unit in each profile**: `udunits:a` is the **are**, `c~ha`. In UDUNITS `a` is also the atto prefix, and the atom wins — a whole atom outranks a prefixed reading (§1, §2) |
| `MI` | *(case-sensitive; not a unit)* | `qudt:MI` → `mi`, the **mile** | A flat vocabulary never decomposes a code, so this is not milli-anything |
| `KGM` | *(not a unit)* | `unece:KGM` → `k~g` | Likewise not a `k` prefix on a `GM` that Rec 20 never defined |
| `MTS` | *(not a unit)* | `unece:MTS` → `m/s` | Not a mega-`TS`; and `unece:MTK` one letter away is the square metre |
| `eq` | `val`, the **water-analysis equivalent**, ½ mol | `ucum:eq` → `mol`, the **generic equivalent** | Same dimension, a factor of two apart: 140 native `m~eq/L` is 70 mmol/L, 140 `ucum:meq/L` is 140 mmol/L. A clinical `meq/L` of a monovalent ion is the UCUM one (§14) |

Two rules make this tractable, and both are worth knowing before writing a profile code:

- **A flat vocabulary's code is one whole token.** `unece:`, `qudt:`, `qudt-qk:`, `om:` and `cf:` recognise no
  operators and strip no prefixes, so a code either matches entire or is refused. `unece:KGM/MTR`
  and `qudt:M/SEC` are `error_unit_illegal`, not compounds.
- **A whole atom beats a prefix in an expression vocabulary**, exactly as it does natively.
  `ucum:min` is the minute rather than milli-inch, and `udunits:cal` the calorie rather than
  centi-`al` — the same longest-alias rule §1 and §2 describe for the native registry.

Where two vocabularies genuinely name the same quantity they land on the same unit, and that is
checked: `ucum:kg`, `unece:KGM`, `qudt:KiloGM`, `udunits:kg` and `qudt-qk:Mass` all compare equal.
The cross-vocabulary suite (doc/11 §14) verifies this pairwise across 69 concepts, and pins the
rows above as pairs that must **not** compare equal.

---

## See also

- [Unit & Currency Reference](05_bovnar_unit_system.md) — the registry these readings come from
- [Unit & Currency Cheat Sheet](04_bovnar_unit_cheatsheet.md) — every symbol in one place
- [Specification §11 — Units System](03_bovnar_spec.md#11-units-system) — the normative unit rules
- [FAQ §4 — Units](02_bovnar_faq.md#4-units) — common unit questions
- [Unit Profiles](11_bovnar_unit_profiles.md) — the foreign vocabularies §17 draws on, and the suite that checks them against each other

---

*Every claim in this document was verified against the reference implementation. If you find
a token whose behaviour surprises you and it is not listed here, that is a documentation bug —
please report it.*

---

*End of Bovnar — Unit Ambiguities (Bovnar spec 1.2).*

<!-- ==================== Read & Write C API (https://www.bovnar.io/doc/08_bovnar_readwrite_api.md) ==================== -->

# Bovnar — Read & Write API

> **Spec version:** 1.2
> **Status:** Reference — the C reader, writer, and DOM as implemented
> **Scope:** Every function needed to read and write Bovnar streams, in the order you call them.

The writer uses the same event/data model as the reader — `bvnr_event_t` and `bvnr_data_t` — so the two APIs are deliberately symmetric. Learn one, the other follows.

---

## Table of Contents

1. [Reader](#1-reader)
    - 1.1 [`bvnr_reader_create` / `bvnr_reader_destroy`](#11-bvnr_reader_create--bvnr_reader_destroy)
    - 1.2 [`bvnr_source_from_fd`](#12-bvnr_source_from_fd)
    - 1.3 [`bvnr_source_from_mem`](#13-bvnr_source_from_mem)
    - 1.4 [`bvnr_open_read_source`](#14-bvnr_open_read_source)
    - 1.5 [`bvnr_open_read_mem`](#15-bvnr_open_read_mem)
    - 1.6 [`bvnr_read`](#16-bvnr_read)
    - 1.7 [`bvnr_reader_get_error` and friends](#17-bvnr_reader_get_error-and-friends)
    - 1.8 [Version directive (spec 1.1)](#18-version-directive-spec-11)
    - 1.9 [Datetime family (spec 1.1)](#19-datetime-family-spec-11)
    - 1.10 [Read-time lossless unit / base conversion (`want_unit`)](#110-read-time-lossless-unit--base-conversion-want_unit)
    - 1.11 [`bvn_parse_uint64` / `bvn_parse_int64` / `bvn_parse_double`](#111-bvn_parse_uint64--bvn_parse_int64--bvn_parse_double)
    - 1.12 [Reader-side unit policy (`bvnr_reader_set_unit_policy`)](#112-reader-side-unit-policy-bvnr_reader_set_unit_policy)
    - 1.13 [Refusing the binary half (`text_only`)](#113-refusing-the-binary-half-text_only)
    - 1.14 [`bvnr_source_from_callback`](#114-bvnr_source_from_callback)
    - 1.15 [Push-fed reading (`bvnr_open_read_feed`)](#115-push-fed-reading-bvnr_open_read_feed)
2. [Writer](#2-writer)
    - 2.1 [`bvnr_writer_create` / `bvnr_writer_destroy`](#21-bvnr_writer_create--bvnr_writer_destroy)
    - 2.2 [`bvnr_sink_to_fd`](#22-bvnr_sink_to_fd)
    - 2.3 [`bvnr_sink_to_mem`](#23-bvnr_sink_to_mem)
    - 2.4 [`bvnr_sink_bytes_written`](#24-bvnr_sink_bytes_written)
    - 2.5 [`bvnr_open_write_sink`](#25-bvnr_open_write_sink)
    - 2.6 [`bvnr_open_write_mem`](#26-bvnr_open_write_mem)
    - 2.7 [`bvnr_write_event`](#27-bvnr_write_event)
    - 2.8 [`bvnr_write_version`](#28-bvnr_write_version)
    - 2.9 [`bvnr_write_finish`](#29-bvnr_write_finish)
    - 2.10 [`bvnr_writer_get_error` and friends](#210-bvnr_writer_get_error-and-friends)
    - 2.11 [`bvn_format_uint64` / `bvn_format_int64` / `bvn_format_double`](#211-bvn_format_uint64--bvn_format_int64--bvn_format_double)
    - 2.12 [`bvnr_write_bvnf_base` / `bvnr_write_bvnf_base_unit`](#212-bvnr_write_bvnf_base--bvnr_write_bvnf_base_unit)
    - 2.13 [`bvnr_write_bvni` / `bvnr_write_bvni_unit`](#213-bvnr_write_bvni--bvnr_write_bvni_unit)
    - 2.14 [`BVN_TYPE_FLOAT_BASE`](#214-bvn_type_float_base)
    - 2.15 [Writer-side unit policy (`bvnr_writer_set_unit_policy`)](#215-writer-side-unit-policy-bvnr_writer_set_unit_policy)
    - 2.16 [Value helpers](#216-value-helpers)
    - 2.17 [`bvnr_sink_to_callback`](#217-bvnr_sink_to_callback)
    - 2.18 [`bvnr_write_flush`](#218-bvnr_write_flush)
3. [Shared](#3-shared)
    - 3.1 [`bvnr_write_type_annotation`](#31-bvnr_write_type_annotation)
    - 3.2 [`bvn_parse_unit` / `bvn_parse_unit_n`](#32-bvn_parse_unit--bvn_parse_unit_n)
    - 3.3 [`bvn_unit_to_string` / `bvn_unit_to_string_ex`](#33-bvn_unit_to_string--bvn_unit_to_string_ex)
    - 3.4 [`bvn_unit_convert_value` *(bovnar_si_units.h)*](#34-bvn_unit_convert_value-bovnar_si_unitsh)
    - 3.5 [`bvn_error_to_string`](#35-bvn_error_to_string)
    - 3.6 [Canonicalising observer (`bvnr_canon_observer_*`)](#36-canonicalising-observer-bvnr_canon_observer_)
    - 3.7 [Unit inspection helpers](#37-unit-inspection-helpers)
4. [DOM API (`bovnar_dom.h`)](#4-dom-api-bovnar_domh)
    - 4.1 [Parsing and lifetime](#41-parsing-and-lifetime)
    - 4.2 [Navigation](#42-navigation)
    - 4.3 [Type inspection](#43-type-inspection)
    - 4.4 [Typed value accessors](#44-typed-value-accessors)
    - 4.5 [Building a tree](#45-building-a-tree)
    - 4.6 [Minimal example](#46-minimal-example)
    - 4.7 [Parsing under a unit policy](#47-parsing-under-a-unit-policy)
5. [Complete Read Example](#5-complete-read-example)
6. [Inline Unit Suffix — Reading](#6-inline-unit-suffix--reading)
    - 6.1 [Reading inline unit values](#61-reading-inline-unit-values)
7. [Complete Write Example](#7-complete-write-example)

- [See also](#see-also)

---

## 1. Reader

---

### 1.1 `bvnr_reader_create` / `bvnr_reader_destroy`

```c
bvnr_reader_t *bvnr_reader_create(void);
void           bvnr_reader_destroy(bvnr_reader_t *r);
```

Allocate and free a reader on the heap. `bvnr_reader_create` returns `NULL` if allocation fails. Always pair with `bvnr_reader_destroy` when done.

```c
bvnr_reader_t *r = bvnr_reader_create();
if (!r) { perror("alloc"); exit(1); }

/* ... open, read ... */

bvnr_reader_destroy(r);
```

---

### 1.2 `bvnr_source_from_fd`

```c
void bvnr_source_from_fd(bvnr_source_t *s, int fd);
```

Initialise the source `s` to read from an open, readable POSIX file descriptor. The caller retains ownership of `fd`; the library will not close it.

- `s` — pointer to a caller-allocated `bvnr_source_t` (stack is fine).
- `fd` — any readable descriptor: a file, a pipe, a socket.

```c
bvnr_source_t src;
int fd = open("config.bvnr", O_RDONLY);
if (fd < 0) { perror("open"); exit(1); }

bvnr_source_from_fd(&src, fd);
/* pass &src to bvnr_open_read_source */
```

---

### 1.3 `bvnr_source_from_mem`

```c
void bvnr_source_from_mem(bvnr_source_t *s, const void *buf, uint64_t len);
```

Initialise the source `s` to read from an in-memory buffer. The buffer must remain valid for the duration of `bvnr_read`. No copy is made.

- `buf` — pointer to the Bovnar data.
- `len` — number of bytes in the buffer.

```c
static const char payload[] =
    ".host = \"localhost\";\n"
    ".port = <uint:16> 8080;\n";

bvnr_source_t src;
bvnr_source_from_mem(&src, payload, sizeof(payload) - 1);
```

---

### 1.4 `bvnr_open_read_source`

```c
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);
```

Attach the source to the reader and configure it. Must be called before `bvnr_read`. Returns `false` on invalid arguments (`error_invalid_argument`).

- `r` — reader obtained from `bvnr_reader_create`.
- `src` — source initialised with one of the `bvnr_source_from_*` functions.
- `src_mirror` — optional sink that mirrors the raw input bytes as they are consumed (useful for debugging; pass `NULL` in production).
- `options` — configuration struct. Zero-initialise to get all defaults. The most important fields are:

```c
typedef struct bvnr_read_flags_s {
    void  *userdata;
    bool (*on_unverified)(void *userdata, bvnr_event_t, bvnr_data_t *);
    bool (*on_verified)  (void *userdata, bvnr_event_t, bvnr_data_t *);
    bool   continue_on_error;
    bvnr_on_error_fn on_error;
    uint64_t max_file_size;        /* 0 → no total-byte cap (default; text still bounded by max_text_bytes); set positive to 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 */
    /* ... the remaining size limits, plus strict_version, text_only (§1.13),
     * and the want_unit hook and its two knobs (§1.10) ... */
} bvnr_read_flags_t;
```

The full struct is in `include/bovnar.h`, and the specification lists it in
§16.4.

`on_verified` is the callback you will implement almost always. `on_unverified` fires before semantic validation — use it only for diagnostics or partial inspection. The one exception is read-time unit conversion: `want_unit` (§1.10) runs ahead of **both** callbacks, so with it installed an `on_unverified` consumer also sees a populated `converted`/`conv` on `ev_data`. Both callbacks must return `true` to continue parsing, `false` to abort (sets `error_scanner_callback_failed`).

The `options` pointer is not stored; the struct is read during `bvnr_open_read_source` only, so it may live on the stack.

**`max_text_bytes` is not a total-size cap.** It counts the bytes the *text*
scanner consumes; an octet stream's binary payload does not count towards it. A
212-byte document whose body is a 200-byte octet stream is accepted at
`max_text_bytes = 8`. If you are bounding memory or guarding against a hostile
input, `max_file_size` is the one that counts every byte. Both are exact at their
boundary: a document needing N is accepted at N and refused at N-1.

**Reader default limits.** When a `bvnr_read_flags_t` field is set to `0`, the reader substitutes an internal default. For the nesting fields, the default is **64** (not 255); the hard maximum is 255. For `max_array_items` and `max_text_bytes`, the default is **2 147 483 647** — permissive but finite. **`max_file_size` differs: `0` means no total-byte cap**. That is not endless: the text is still bounded by `max_text_bytes`, so an endless text stream raises that as well; only octet-stream payload runs without bound. Setting `max_file_size` explicitly to `16777216` (16 MiB) is recommended for production.

```c
static bool on_event(void *ud, bvnr_event_t ev, bvnr_data_t *d)
{
    /* handle ev / d ... */
    return true;
}

bvnr_read_flags_t opts = {
    .on_verified   = on_event,
    .userdata      = &my_ctx,
    .max_file_size = 16777216,  /* 16 MiB cap */
    /* max_array_nesting: 0 → 64 internal default; hard cap 255 */
};

if (!bvnr_open_read_source(r, &src, NULL, &opts)) {
    fprintf(stderr, "open failed\n");
    return -1;
}
```

---

### 1.5 `bvnr_open_read_mem`

```c
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);
```

Convenience wrapper that constructs a memory source (and optionally a memory mirror sink) internally. Equivalent to calling `bvnr_source_from_mem` followed by `bvnr_open_read_source`. Pass `NULL` / `0` for `mirror_buf` / `mirror_cap` to skip mirroring.

```c
bvnr_read_flags_t opts = { .on_verified = on_event, .userdata = &ctx };

if (!bvnr_open_read_mem(r, payload, payload_len, NULL, 0, &opts))
    return -1;
```

---

### 1.6 `bvnr_read`

```c
bool bvnr_read(bvnr_reader_t *r);
```

Drive the parser until EOF or a fatal error. Fires the registered callbacks for every event. Returns `true` on clean completion, `false` on any error.

The read is single-threaded and not re-entrant: a callback must not call `bvnr_read`, any other reader entry point, or `bvnr_reader_destroy` on the same reader. The push-fed reader (§1.15) detects such a call and refuses it with `error_invalid_argument`; the pull path does not, so there it is simply unsupported.

This is the only call needed after `bvnr_open_read_*`. The reader does not allocate during this call; all buffering is internal to the reader struct.

```c
if (!bvnr_read(r)) {
    fprintf(stderr, "parse error: %s at line %" PRIu64 " col %" PRIu64 "\n",
            bvn_error_to_string(bvnr_reader_get_error(r)),
            bvnr_reader_get_error_line(r),
            bvnr_reader_get_error_column(r));
    bvnr_reader_destroy(r);
    return -1;
}
```

---

### 1.7 `bvnr_reader_get_error` and friends

```c
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);
uint64_t     bvnr_reader_get_error_offset (const bvnr_reader_t *r);
uint32_t     bvnr_reader_get_error_byte   (const bvnr_reader_t *r);
uint64_t     bvnr_reader_get_recovery_count(const bvnr_reader_t *r);
```

The three position counters measure **different things**, and a tool placing a
caret needs the right one:

| Getter | Unit | Base | Note |
|---|---|---|---|
| `..._line` | lines | 1 | |
| `..._column` | **characters** | 1 | a multi-byte UTF-8 sequence advances it by one; a tab advances to the next multiple of 4 |
| `..._offset` | **bytes** | 0 | use this to index the input buffer |

An error raised at the **end of input** (`error_got_incomplete_bvnr_stream`, a
truncated character in a final comment, a directive with no newline) is placed
where the missing byte would go: the offset is the input length, and the column
is one past the last character on the last line — `1` when the input ends in a
newline. (Inside a cut-short multi-byte character the column stays on that
character.)

In `.x = "café"; .y = @;` the `@` is reported at column 19 and offset 19 — the
19th character, and the 20th byte, because `é` is two bytes: a 1-based character
count and a 0-based byte count that happen to land on the same number. One more
non-ASCII character pulls them apart: in `.x = "çafé"; .y = @;` the `@` is at
column 19 and offset 20. This matters here more than in most formats: unit
symbols are routinely non-ASCII (`µ~m`, `°C`, `Ω`), so the two disagree often.

All five error/location getters above (everything except `bvnr_reader_get_recovery_count`) describe the error that **ended** the read, so they are meaningful when `bvnr_read` returned `false`, and inside an `on_error` callback, where they agree with its arguments. Errors recovered under `continue_on_error` are reported through `on_error` only: after a read that returned `true`, `bvnr_reader_get_error` is `error_none` and the position getters return `0`, however many errors were recovered (spec §13.4). `bvnr_reader_get_error_byte` returns the raw byte value that triggered the error. `bvnr_reader_get_recovery_count` is the exception: it returns how many errors triggered entry into resync mode in `continue_on_error` mode (and so is meaningful even when `bvnr_read` ultimately returned `true`). This count is incremented at error entry, not when resync completes at `";". `bvnr_reader_get_skipped_bytes` is the same kind of thing and answers the question the count cannot: how much of the input recovery threw away. One skipped assignment and a whole discarded struct both report a single recovery, and the skipped bytes were never parsed, so nothing else in the API mentions them — a non-zero total means the document your callbacks saw is not the whole document.

```c
if (!bvnr_read(r)) {
    error_code_t ec = bvnr_reader_get_error(r);
    fprintf(stderr,
            "%s at line %" PRIu64 ", col %" PRIu64
            ", offset %" PRIu64 ", byte 0x%02X\n",
            bvn_error_to_string(ec),
            bvnr_reader_get_error_line(r),
            bvnr_reader_get_error_column(r),
            bvnr_reader_get_error_offset(r),
            bvnr_reader_get_error_byte(r));
}
```

---

### 1.8 Version directive (spec 1.1)

```c
bool        bvnr_reader_get_declared_version(
                const bvnr_reader_t *r, uint16_t *major, uint16_t *minor);
bool        bvnr_peek_version(
                const void *buf, uint64_t len, uint16_t *major, uint16_t *minor);
uint32_t    bvnr_version(void);
const char *bvnr_version_string(void);
void        bvnr_spec_version(uint16_t *major, uint16_t *minor);
```

A document may begin with a `#!bovnar <major>.<minor>` directive (see spec §3.4).
After `bvnr_read`, `bvnr_reader_get_declared_version` returns `true` and fills
`major`/`minor` when the document carried one (either out pointer may be NULL).
Set `bvnr_read_flags_t.strict_version` to reject a version newer than this build
supports with `error_unsupported_spec_version`; by default such a version is
recorded but accepted. A malformed directive is always
`error_invalid_spec_version`.

`bvnr_peek_version` scans a raw buffer for the directive without a full parse
(handy before opening a writer to round-trip it). `bvnr_version` /
`bvnr_version_string` return the library version; `bvnr_spec_version` returns the
highest spec version this build understands (`BVNR_SPEC_VERSION_*`).

```c
uint16_t maj, min;
if (bvnr_reader_get_declared_version(r, &maj, &min))
    printf("document declares bovnar %u.%u\n", maj, min);
```

To emit a directive, call `bvnr_write_version` right after opening the writer
(see §2.5), or set `bvnr_write_flags_t.emit_version` to stamp the current spec
version automatically.

---

### 1.9 Datetime family (spec 1.1)

```c
const char *bvnr_datetime_epoch_name (value_type_spec_t vt);
int32_t     bvnr_datetime_epoch_mjd  (value_type_spec_t vt);
int32_t     bvnr_datetime_epoch_index(const char *name);
bool        bvnr_write_datetime(bvnr_writer_t *w, const char *key,
                                uint32_t width, const char *epoch, int64_t value);
```

A `<datetime:width,epoch>` value (family `vt_datetime`) is a **signed integer
count of seconds since a named epoch** — a timestamp, distinct from a *duration*
(a number with a time unit, e.g. `<float:64,s>`). The carrier is validated like
`sint`; the epoch is stored as a small dense index in `value_type_spec_t.base`
(not a numeric base — the carrier is always decimal). These two helpers recover
the epoch from a spec: `bvnr_datetime_epoch_name` returns its lowercase name
(`"unix"` — the default — `"tai"`, `"gps"`, `"mjd"`, `"ntp"`, `"galileo"`,
`"glonass"`, `"y2000"`, `"beidou"`), and `bvnr_datetime_epoch_mjd` returns its
Modified Julian Day (the `bvn_epoch_t` value from `bvn_datetime.h`). Pass that to
`bvn_dt_epoch_seconds_to_datetime()` to convert to a civil date/time.

```c
/* on a datetime data event: */
int64_t secs;  bvn_parse_int64((const char *)d->data, d->value_type, &secs);
bvn_datetime_t civil;
bvn_dt_epoch_seconds_to_datetime(&civil,
    (bvn_epoch_t)bvnr_datetime_epoch_mjd(d->value_type), secs);
```

To **write** a datetime, use the typed helper `bvnr_write_datetime` — `epoch` is
an epoch name (or `NULL` for unix; an unknown name is `error_invalid_argument`),
`value` is signed seconds. `bvnr_datetime_epoch_index` maps a name to the index
stored in `value_type_spec_t.base` (the inverse of `bvnr_datetime_epoch_name`),
for building a spec by hand. The document must declare `#!bovnar 1.1` to be
re-read (emit the directive with `bvnr_write_version`).

```c
bvnr_write_version(w, 1, 1);
bvnr_write_datetime(w, "created", 64, "gps", 1750000000);  /* <datetime:64,gps> */
```

The family is spec 1.1: it requires a `#!bovnar 1.1` declaration, and in a
1.0/unversioned document a `datetime` annotation is `error_illegal_value_type`.

**ISO-8601 literals and fractional seconds.** A datetime may be written as an
ISO-8601 literal (`2026-06-15T12:00:00.5Z`) instead of a raw integer; the reader
converts it to the whole-second carrier you receive in `d->data`. When the
literal carries a fractional second, the verbatim digits (no leading `.`) are
delivered alongside the carrier in two `bvnr_data_t` fields, `frac_data` and
`frac_length` — `NULL`/`0` for every other value. The carrier is unchanged (the
value floors to the written second), so the fraction is informational, but it
lets a consumer see sub-second precision the integer cannot hold, and the writer
re-emits it: a datetime data event whose `frac_data` is set is serialised back as
an ISO literal so the value round-trips. Like `d->data`, `frac_data` is **not
NUL-terminated** — bound the read by `frac_length`.

```c
/* on a datetime data event: */
if (d->frac_data && d->frac_length) {
    /* d->frac_data[0 .. frac_length) are the sub-second digits, e.g. "5" */
}
```

**The unit's own spelling (`unit_source`, library 2.0).** `d->value_unit` is what a
unit *means*; `d->unit_source` / `d->unit_source_length` are what the document
*wrote*. The two differ wherever formatting a `value_unit_t` is lossy: `km` and
`k~m` are one unit, `ucum:mm[Hg]` and `mmHg` are one unit, and `ucum:mL{total}`
parses to `m~L` with nowhere in the struct for `{total}` to live.

The reader sets the pair on `ev_data` and on the type-annotation events whenever
`value_unit` is a real unit, and leaves it `NULL`/`0` otherwise — no unit,
`no_unit`, a datetime's epoch. It is the annotation's unit parameter, or the
inline suffix when the annotation carried no unit; where a value has both, the
annotation's. Like `d->data` it is reader-owned, valid for the callback only and
**not NUL-terminated**. A `want_unit` conversion leaves it alone: it is the
spelling of `value_unit`, never of `conv.unit`.

```c
/* on a data event: */
if (d->unit_source) {
    /* d->unit_source[0 .. unit_source_length) is e.g. "ucum:mL{total}",
     * while bvn_unit_to_string(d->value_unit, ...) gives "m~L" */
}
```

The writer honours it in the other direction — see
[§3.1](#31-bvnr_write_type_annotation).

---

### 1.10 Read-time lossless unit / base conversion (`want_unit`)

```c
/* bvnr_read_flags_t */
bool (*want_unit)(void *userdata, const bvnr_data_t *data,
                  value_unit_t *want, uint32_t *want_base);

/* bvnr_data_t */
bool             converted;   /* true when this value was converted */
bvnr_converted_t conv;        /* the exact converted value (see below) */

typedef struct bvnr_converted_s {
    value_unit_t     unit;    /* the target unit */
    const char*      text;    /* exact value in `base`, NUL-terminated;
                               * NULL if it does not terminate in `base` */
    uint32_t         length;  /* strlen(text), 0 when text is NULL */
    uint32_t         base;    /* base text is rendered in (2..62, 64, 85) */
    const struct bvn_int_s* num;   /* exact numerator (signed) */
    const struct bvn_int_s* den;   /* exact denominator (> 0) */
} bvnr_converted_t;

/* bvnr_read_flags_t */
bool     want_unit_allow_nonterminating;  /* opt in to rational-only results */
uint32_t max_conversion_length;           /* work limit; 0 = 1024 */
```

By default the reader hands you every numeric value exactly as written and you
convert it yourself (§3.4, `bvn_unit_convert_rational`). Set `want_unit` to have
the reader do it for you — **losslessly**, in exact arbitrary-precision
arithmetic, for a value of any width and any base.

When `want_unit` is non-NULL, the reader calls it for every numeric value (with
or without a unit) after validation and before **either** value callback — it can
abort the parse, and the two views of one value must not disagree. An
`on_unverified` consumer therefore also sees a populated `converted`/`conv` on
`ev_data`, even though everything else about that event is still the
pre-validation view. Inspect `data->value_unit` (native unit),
`data->value_type`, or `data->data` and either:

- fill `*want` with the target unit and `*want_base` with the output base and
  **return `true`**, or
- **return `false`** (or leave `want_unit` NULL) to receive the value untouched.

`*want_base` accepts any base bvnr can write — `2..62` plus `64` (Base64) and
`85` (Ascii85) — or `0` to keep the value's own. Anything else is an error, never
a quiet substitution. Note that `64` and `85` have no sign character, so a
negative result in those is rejected too.

Requesting `*want` equal to the native unit with a different `*want_base`
performs a pure **base conversion** (e.g. a hex integer delivered in decimal).
The choice is usually key-specific; track "the current key" (from the earlier
`ev_assignment_start` event) in your `userdata`.

On a valid request the value arrives with `data->converted == true` and
`data->conv` holding the **exact** result — the value in the requested unit and
base as both a positional string (`conv.text`) and a reduced rational
(`conv.num`/`conv.den`, `bvn_int.h`). Everything in `conv` is reader-owned and
valid only for the callback — copy `text` (or the rational) to retain it.
`data->data` / `data->value_unit` keep the original digits and unit.

**The conversion is lossless.** A 1056-bit binary float or a 512-bit integer
converts with no loss beyond the library's own declared factor — the result
widens as needed rather than rounding into a `double`. Every numeric family is
eligible: `uint`/`sint` of any width and base (including a multiprecision integer
or a non-decimal base written as a string literal like `<uint:32,_16> "FF"`),
`float`, `float_dec`, `float_fix`. A datetime is never offered.

Once the hook has asked for a conversion, the value either arrives converted or
the parse **stops**. Nothing approximate is delivered, and nothing is silently
skipped:

| Condition | Error |
|-----------|-------|
| `*want` dimensionally incompatible with the value's unit (seconds for a length; one currency for another) | `error_unit_mismatch` |
| the true factor is irrational (a π-based angle, e.g. degree → radian) | `error_unit_inexact` |
| the value's unit puts an affine scale inside a compound (`°C/h`, `°C·m`, `°C²`): the offset is a number of kelvin and the product has nowhere to add it | `error_unit_mismatch` |
| the exact result has no terminating expansion in `*want_base` (e.g. `1 m → mile` in base 10; in base 85 any fraction, since `.` is a digit there), and `want_unit_allow_nonterminating` is off | `error_unit_inexact` |
| the literal is finite but too extreme to build an exact rational from (e.g. `1e1000000`) | `error_value_out_of_range` |
| `*want_base` is not a base bvnr writes, or the result is negative in base 64/85, or out of memory | `error_invalid_argument` |
| the exact result would be longer than `max_conversion_length` characters | `error_value_out_of_range` |

Only `nan`/`inf`/`ninf` are handed over untouched (`converted == false`, no
error): they carry no finite value, so no conversion was possible or promised.

#### Bounding the work

Rendering an exact expansion is **quadratic in its digit count**, and the count
follows the *magnitude of the value's exponent*, not the literal's length:
`1e-9800` is seven characters and expands to 9800 digits. Left unbounded, a
one-kilobyte document of such values costs minutes of CPU while looking trivial.

`max_conversion_length` caps the characters a conversion may produce; `0` selects
`BVNR_DEFAULT_MAX_CONVERSION_LENGTH` (1024), generous next to any real
measurement. Anything longer is `error_value_out_of_range`, rejected before the
digits are generated. Raise it if you genuinely need thousands of exact digits.
Like `max_number_length` and `max_array_items`, it is here so a consumer of
untrusted input is not at the mercy of the input's shape.

#### Exact-but-not-writable results

Plenty of everyday conversions are exact as a rational yet have no finite
positional expansion in the output base — `km/h → m/s` is `5/18`, `°F → °C` and
`m → km` in base 2 are the same story. By default those abort with
`error_unit_inexact` rather than round.

Set `want_unit_allow_nonterminating` when your consumer can take a rational. The
value then arrives normally with `conv.num`/`conv.den` **exact** and
`conv.text == NULL` (`conv.length == 0`) — always NULL-check `conv.text` before
printing it. An irrational factor still aborts even with the flag set: there is
no exact rational to hand over in the first place.

```c
static bool want_unit(void *ud, const bvnr_data_t *d,
                      value_unit_t *want, uint32_t *want_base)
{
    /* deliver every length in metres, base 10; leave everything else */
    bool ok;
    value_unit_t metre = bvn_parse_unit((const uint8_t *)"m", &ok);
    if (bvn_units_compatible(d->value_unit, metre)) {
        *want = metre;
        *want_base = 10;
        return true;
    }
    return false;
}

static bool on_event(void *ud, bvnr_event_t ev, bvnr_data_t *d)
{
    if (ev == ev_data && d->converted && d->conv.text)
        printf("= %s m\n", d->conv.text);   /* e.g. "5 k~m" -> "5000" */
    return true;
}

bvnr_read_flags_t opts = { .on_verified = on_event, .want_unit = want_unit };
```

---

### 1.11 `bvn_parse_uint64` / `bvn_parse_int64` / `bvn_parse_double`

```c
bool bvn_parse_uint64(const char *s, value_type_spec_t vt, uint64_t *out);
bool bvn_parse_int64 (const char *s, value_type_spec_t vt, int64_t  *out);
bool bvn_parse_double(const char *s, value_type_spec_t vt, double   *out);
```

Convert the raw token string received in `ev_data` into a C numeric type. The `vt` argument — taken directly from `d->value_type` — supplies the base and bit-width for range checking.

The `data` pointer inside `bvnr_data_t` is **not NUL-terminated**, and for null/empty values `d->length` may be `0`. Always guard the copy by `d->length` and NUL-terminate manually.

- Returns `true` and writes `*out` on success.
- Returns `false` if the string is not representable in the declared type — this
  includes exceeding `vt.width`, so `<uint:8>` rejects `"256"`. A width above 64
  is not checked: the 64-bit out parameter is the binding limit there.
- Returns `false` for anything a value token cannot look like: an empty string,
  leading whitespace, a sign on an unsigned carrier, or — for the integer
  helpers `bvn_parse_uint64` and `bvn_parse_int64` — a leading `+`. (The C
  standard library's `strtoull` accepts all four — the sign by wrapping `-1` into
  `18446744073709551615` — which is why these helpers do not simply forward to
  it.) `bvn_parse_double` does accept a leading `+` (`"+5"` is 5), because a
  quoted float may carry one ([spec §6.3](03_bovnar_spec.md#63-digit-validation)).
- Acceptance matches the reader exactly: anything these accept, a document can
  carry, and vice versa.

```c
static bool on_event(void *ud, bvnr_event_t ev, bvnr_data_t *d)
{
    if (ev != ev_data) return true;

    char buf[256];
    if (d->length >= sizeof(buf)) return false;
    if (d->length) memcpy(buf, d->data, d->length);
    buf[d->length] = '\0';

    switch (d->value_type.family) {
    case vt_uint: {
        uint64_t v;
        if (bvn_parse_uint64(buf, d->value_type, &v))
            printf("uint = %" PRIu64 "\n", v);
        break;
    }
    case vt_sint: {
        int64_t v;
        if (bvn_parse_int64(buf, d->value_type, &v))
            printf("sint = %" PRId64 "\n", v);
        break;
    }
    case vt_float: {
        double v;
        if (bvn_parse_double(buf, d->value_type, &v))
            printf("float = %g\n", v);
        break;
    }
    default:
        break;
    }
    return true;
}
```

### 1.12 Reader-side unit policy (`bvnr_reader_set_unit_policy`)

What `want_unit` does through a callback, stated as data: what the document must
contain, and what unit the consumer wants values delivered in. Everything is
expressed as unit **text**, so a binding can drive it without a function pointer.

```c
#define BVNR_MAX_UNIT_TARGETS 8u
#define BVNR_MAX_UNIT_RULES   8u
#define BVNR_MAX_UNIT_PATH    96u

typedef struct bvnr_unit_target_s {
    const char *unit;   /* "m/s"; parsed when the policy is set */
    uint32_t    base;   /* output base, 0 = the value's own */
} bvnr_unit_target_t;

typedef struct bvnr_unit_rule_s {
    const char            *path;   /* ".inlet.temperature", or ".inlet.*" */
    const char            *unit;
    uint32_t               base;   /* output base for bvnr_rule_convert, 0 = own */
    bvnr_unit_rule_mode_t  mode;   /* bvnr_rule_convert | bvnr_rule_require */
} bvnr_unit_rule_t;

typedef struct bvnr_unit_policy_s {
    /* per-field rules — consulted before everything below */
    const bvnr_unit_rule_t     *rules;
    uint32_t                    num_rules;
    /* conversion */
    const bvnr_unit_target_t   *targets;
    uint32_t                    num_targets;
    uint32_t                    base;         /* output base for `normalise` */
    bvnr_unit_normalise_t       normalise;    /* none | bvnr_normalise_si */
    bvnr_unit_inexact_policy_t  on_inexact;   /* error | leave */
    /* validation — these reject a document and never change a value */
    bool                        require_unit;
    const char * const         *require_dimension_of;
    uint32_t                    num_require_dimension_of;
} bvnr_unit_policy_t;

bool bvnr_reader_set_unit_policy(bvnr_reader_t *r, const bvnr_unit_policy_t *p);
```

Every unit string is parsed by the setter, so a typo is a `false` return before
the parse begins rather than an error inside somebody's document; a rejected
policy leaves the previous one in force. The strings need not outlive the call.
The policy may be installed before or after `bvnr_open_read_*`, and it survives
re-opening the reader on another document — it describes the consumer, not the
document. Passing `NULL` clears it.

#### Per-field rules

A rule names ONE field, by the dotted key path a value sits at — leading dot
included, exactly as the document writes its keys. A path ending in `.*` names
everything below that point at any depth. Rules are consulted **before**
everything else in the policy: they are the most specific thing it can say.

```c
static const bvnr_unit_rule_t rules[] = {
    { ".inlet.temperature", "°C",  0, bvnr_rule_convert },
    { ".inlet.*",           "m",   0, bvnr_rule_require },
};
bvnr_unit_policy_t p = {0};
p.rules = rules; p.num_rules = 2;
```

Unlike a whole-document target, **a rule is an assertion**: the caller named
this field, so a value that cannot be applied to it is `error_unit_mismatch`
rather than a value passed through quietly — silence would defeat the point of
naming it. A bare number fails a rule too: ".speed is m/s" is not satisfied by a
value with no unit. `bvnr_rule_require` asserts without converting.

First match wins, so order matters: put `".a.b"` before `".a.*"`. A prefix only
matches at a component boundary, so `".in.*"` does not claim `".inlet.a"`. Array
elements sit at the path of the assignment holding them, so one rule covers a
whole array. Paths are capped at `BVNR_MAX_UNIT_PATH` characters and 32 levels
of nesting; a value deeper than the tracker can describe is at an **unknown**
path and matches no rule at all, rather than matching the wrong one.

#### Conversion

Each numeric value is converted to the first target it can validly convert to,
so **order is significant**: a list of `{"m", "k~m"}` never selects `k~m`. A
value that matches no target is delivered untouched — that is the normal outcome
for a value no rule mentions, not a failure. A value already in the unit a
target names is also left alone (unless that target asks for an output base,
which makes it a pure base conversion), so `converted` means "the policy
restated this value", not "the policy looked at it".

```c
static const bvnr_unit_target_t targets[] = { { "°C", 0 }, { "m", 0 } };
bvnr_unit_policy_t p = {0};
p.targets = targets; p.num_targets = 2;
bvnr_reader_set_unit_policy(r, &p);
```

| document | delivered |
|----------|-----------|
| `<float:64,°F> 212.0` | `100` in `°C` |
| `<float:64,in> 12.0` | `0.3048` in `m` |
| `<float:64,%> 35.0` | untouched — no target is a ratio |
| `<float:64> 0.25` | untouched — see the fence below |

**A value with no unit only ever matches a target that is itself `no_unit`.** A
bare number is dimensionally compatible with `%` and with `ppm`, so without that
fence a policy naming `"%"` would deliver `0.25` as `25` — the silent rescale the
format exists to prevent, arrived at through the machinery meant to prevent it.
The fence works in both directions: a policy naming `no_unit` will not restate
`35 %` as `0.35`.

Selection uses `bvn_units_convertible`, not `bvn_units_compatible`. That is what
keeps `k~$USD -> $USD` available: a currency carries no dimension by design, so
`bvn_units_compatible` reports it incompatible with itself although the
conversion is exact and well defined.

#### Normalisation

`normalise = bvnr_normalise_si` catches whatever the targets did not, delivering
it in coherent SI base units with prefixes folded out:

| document | delivered |
|----------|-----------|
| `<float:64,in> 12.0` | `0.3048` in `m` |
| `<float:64,°F> 212.0` | `373.15` in `K` |
| `<float:64,g> 5.0` | `0.005` in `k~g` — mass normalises to the kilogram |
| `<float:64,%> 35.0` | untouched |
| `<float:64,$USD> 5.0` | untouched |

Every **dimensionless** unit is left exactly as written — `%`, `ppm`, `dB`, `pH`,
`rad`, `°`, the turbidity scales — along with the currencies, and so is a unit
carrying the steradian's or an angle's quantity kind (`k~lx`, `m~ph`, `W/sr`,
`k~rad/s`), whose SI base form would be a different quantity; prefixes stay too. Normalising a ratio
would silently restate `35 %` as `0.35`, and normalising an angle would need the
irrational factor between `°` and `rad`. See `bvn_unit_si_normal_form`.

#### Exactness

The conversion is the same exact path `want_unit` uses, and nothing approximate
is ever delivered. A blanket mode makes every value a conversion candidate,
though, so a single ordinary factor — `42 km/h` is `35/3 m/s`, exact as a
rational with no terminating base-10 expansion — would otherwise reject a
document nobody had a complaint about. `on_inexact = bvnr_inexact_leave` hands
that value over in its native unit instead, visible as `converted == false` and
never as rounded digits. A genuinely irrational factor still aborts, since there
is nothing exact to hand over either way.

#### Validation

`require_unit` and `require_dimension_of` reject a document; they never change a
value. Both are evaluated on the unit the **document** wrote, before any
conversion — validate what you were sent, convert for the consumer — so they mean
the same thing whether or not a conversion was also requested. A failure is
`error_unit_mismatch`.

```c
static const char *lengths[] = { "m" };
bvnr_unit_policy_t p = {0};
p.require_unit             = true;   /* reject any bare number */
p.require_dimension_of     = lengths;/* every value must be a length */
p.num_require_dimension_of = 1;
```

`require_dimension_of` asks about the dimension, not the unit: `5 k~m`, `12 in`
and `3 mi` all satisfy `{"m"}`. A value must satisfy at least one entry. The same
unitless fence applies — a bare number satisfies a requirement only if one of the
listed units is itself `no_unit`.

#### Where this sits relative to `want_unit`

The hook is more specific and wins. For each value the reader asks the hook
first, then the per-field `rules`, then `targets`, and falls back last to
`normalise`. Setting several is a legitimate combination: normalise the
document, name the two fields that need something else, and hand-handle one more
in the hook.

---

### 1.13 Refusing the binary half (`text_only`)

```c
bvnr_read_flags_t f = {0};
f.text_only = true;              /* an octet stream is error_octet_stream_forbidden */
```

Bovnar is a text/binary hybrid: an octet stream carries arbitrary bytes inside an
otherwise readable document, framed by `0x00` and length-prefixed so a reader can
skip it without inspecting it (spec §9). That design is what makes binary payloads
free of escaping and expansion — and it is also the one part of a document that a
*text-shaped* pipeline destroys silently. Line-ending normalisation rewrites a
`0x0D` inside a chunk payload, the length prefix then points at the wrong byte,
and what arrives looks like a malformed document rather than a mangled one. A
`git` checkout without `-text`, a `sed` filter, or a log aggregator that assumes
lines will each do it.

`text_only` lets a consumer state that this channel carries text, and have the
parser enforce it:

```
$ bovnar validate --text-only telemetry.bvnr
Validation failed: octet_stream_forbidden at line 3, col 6
```

This is the same move as `--require-unit`: the format permits something, and a
consumer that cannot accept it says so where it is checkable rather than hoping.
A producer can use it the same way, to guarantee that a channel it publishes
stays transport-safe.

The refusal fires at the stream's **opening `0x00`**, before any payload is read
— the point is that such a document should not have reached this reader at all,
so the earliest refusal is the useful one, and a large binary region is never
touched. It ends the read even under `continue_on_error` (doc/03 §13.2): recovery
cannot read the payload's framing and would scan the binary bytes as text. The
flag changes nothing else: a document without an octet stream parses
identically with it set.

Available on the CLI for `validate` and `events`. `query` refuses the option
rather than ignoring it, because that command parses through the DOM, which takes
a unit policy but not the read flags — an assertion silently not in force is
worse than none.

---

### 1.14 `bvnr_source_from_callback`

```c
void  bvnr_source_from_callback(bvnr_source_t *s, bvnr_pull_fn pull,
                                void *userdata);
void *bvnr_source_get_userdata(const bvnr_source_t *s);

typedef bool (*bvnr_pull_fn)(bvnr_source_t *s, void *buf,
                             uint32_t want, uint32_t *got);
```

Initialise `s` to read through the caller's own `pull` function — a socket
wrapper, a TLS session, a decompressor, a file-like object in a binding. The
source works wherever an fd or memory source does: `bvnr_open_read_source`,
`bvnr_doc_stream_read`.

`pull` fills `buf` with up to `want` bytes and sets `*got`. A short read is
fine; the reader asks again. `true` with `*got == 0` is **end of input**, and
`false` is an I/O error (`error_reading_from_source_fd`). A pull source has no
way to say "nothing yet": a transport that can run dry before the document is
complete must block inside `pull`, or be read with the push-fed reader (§1.15)
instead.

`userdata` is stored *inside* the 64-byte `bvnr_source_t`. The reader works on
a copy of the source it is given, so the `s` the callback receives is not the
caller's variable; the callback recovers its context with
`bvnr_source_get_userdata(s)`. The getter returns `NULL` for a `NULL` handle and
for fd and memory sources.

```c
typedef struct { int sock; } conn_t;

static bool sock_pull(bvnr_source_t *s, void *buf, uint32_t want, uint32_t *got)
{
    conn_t *c = bvnr_source_get_userdata(s);
    ssize_t n = recv(c->sock, buf, want, 0);
    if (n < 0) return false;          /* error_reading_from_source_fd */
    *got = (uint32_t)n;               /* 0: the peer closed, end of input */
    return true;
}

conn_t conn = { sock };
bvnr_source_t src;
bvnr_source_from_callback(&src, sock_pull, &conn);
bvnr_open_read_source(r, &src, NULL, &flags);
bvnr_read(r);
```

---

### 1.15 Push-fed reading (`bvnr_open_read_feed`)

```c
bool bvnr_open_read_feed(bvnr_reader_t *r, const bvnr_sink_t *src_mirror,
                         bvnr_read_flags_t *options);
bool bvnr_read_feed     (bvnr_reader_t *r, const void *buf, uint64_t len);
bool bvnr_read_finish   (bvnr_reader_t *r);
```

For input that arrives when it arrives — a non-blocking socket, an event loop,
a binding receiving bytes from elsewhere. `bvnr_read` *pulls* until its source
says end of input; a fed reader is *handed* bytes, as many times as you like and
in pieces of any size, and told separately that the document is over.

```c
bvnr_open_read_feed(r, NULL, &flags);
while ((n = next_packet(buf)) > 0)
    if (!bvnr_read_feed(r, buf, n))
        break;                         /* the read failed: see the getters */
bool ok = bvnr_read_finish(r);         /* the result bvnr_read would give */
```

**Equivalence.** The callbacks receive exactly the events, in the same order,
that `bvnr_read` delivers for the concatenation of everything fed, wherever the
pieces were cut — inside a token, a multi-byte character or an octet-stream
chunk — and every error carries the same code, line, column, byte and offset.
The lexer and validator keep all their state between feeds and buffer nothing
beyond what one token or one octet chunk needs, so memory stays flat over an
endless stream. (`tests/bovnar_feed_test.c` and `bvnr_conformance --feed` check
this over the corpus, the examples and the conformance suite, cut at every
byte.)

- `bvnr_open_read_feed` arms the reader as `bvnr_open_read_source` does — it may
  be a reader used before, the unit policy survives — with no source. The
  optional `src_mirror` receives the bytes as they are consumed. `bvnr_read` on
  a fed reader returns `false` with `error_invalid_argument` and ends the read.
- `bvnr_read_feed` lexes the bytes and returns when they are consumed; the
  buffer may be reused at once. The first call delivers `ev_stream_start`. It
  returns `false` only when the read has ended in an error — the same errors at
  the same bytes as `bvnr_read`, a callback's abort, `max_file_size` — and
  `true` otherwise, including after errors recovered under `continue_on_error`.
  **Running out of input is never a feed error.**
- `bvnr_read_finish` declares end of input and returns what `bvnr_read` would:
  `true` for a complete document (`ev_stream_end` delivered),
  `error_got_incomplete_bvnr_stream` for one cut off in a value or a struct,
  `error_read_complete_chunk_failed` inside an octet stream.
- After an error or a finish the read is over: further calls return `false` and
  leave the getters (§1.7) as they were. Re-open to read the next document.
- A feed with a `NULL` buffer and a non-zero length returns `false` with
  `error_invalid_argument` and ends the read.
- **Not re-entrant.** A feed, finish, `bvnr_read` or re-open from inside one of
  the reader's own callbacks while a feed runs returns `false` and ends the
  read with `error_invalid_argument`; the feed running stops after the event
  that made the call and returns `false` too. Do not destroy the reader from
  there.

**A stream with metadata.** A document can carry ordinary values before (and
after) a binary or multiplexed octet stream — `.meta = { .codec = "av1"; };`
then `.media = <octet stream>` — and a fed reader delivers them as it reads
them, before the first chunk of the stream. For a multiplexed stream, the
push-fed demultiplexer (`bvnr_demux_open_feed`, [doc/10
§3.4](10_bovnar_streaming.md#34-push-fed-demultiplexing-with-metadata-before-the-stream)) does this with the reader built in: the
metadata goes to your `on_verified`, or comes out of `bvnr_demux_next` as events
ahead of the messages.

---

## 2. Writer

**The writer enforces the streaming tier only.** Each of its checks judges the
event in front of it — a key, a value against its type and width, a struct or
array closed that was open — as the streaming reader's do. The rules the specification assigns to the *DOM* tier
([spec §12.4–§12.5](03_bovnar_spec.md#125-struct-validation)) compare siblings
the writer emits one at a time, and it does not hold them to compare: a key
repeated within one scope, array elements of mixed kind or unit, ragged sibling
sub-arrays, and struct elements of an array with differing key sets. Those are
the **producer's** responsibility. Calling `bvnr_write_bool(w, "b", true)` twice
writes `.b=true;.b=true;` and both calls succeed, and that output is a document
`bovnar validate` and `bvn_dom_parse` refuse with `error_duplicate_struct_key`.

---

### 2.1 `bvnr_writer_create` / `bvnr_writer_destroy`

```c
bvnr_writer_t *bvnr_writer_create(void);
void           bvnr_writer_destroy(bvnr_writer_t *w);
```

Allocate and free a writer on the heap. Mirrors the reader lifecycle exactly. Returns `NULL` on allocation failure.

```c
bvnr_writer_t *w = bvnr_writer_create();
if (!w) { perror("alloc"); exit(1); }

/* ... open, write events, finish ... */

bvnr_writer_destroy(w);
```

---

### 2.2 `bvnr_sink_to_fd`

```c
void bvnr_sink_to_fd(bvnr_sink_t *s, int fd);
```

Initialise sink `s` to write serialised bytes to an open, writable POSIX file descriptor. The caller retains ownership of `fd`.

```c
bvnr_sink_t sink;
bvnr_sink_to_fd(&sink, STDOUT_FILENO);
/* or: int fd = open("out.bvnr", O_WRONLY|O_CREAT|O_TRUNC, 0644); */
```

---

### 2.3 `bvnr_sink_to_mem`

```c
void bvnr_sink_to_mem(bvnr_sink_t *s, void *buf, uint64_t cap);
```

Initialise sink `s` to write into a caller-provided memory buffer of `cap` bytes. Writing beyond `cap` produces `error_sink_buffer_exhausted`. Use `bvnr_sink_bytes_written` to query how many bytes were actually written — but see the note there: for output produced by a *writer*, `bvnr_writer_bytes_written` is the one that works.

```c
char out[4096];
bvnr_sink_t sink;
bvnr_sink_to_mem(&sink, out, sizeof(out));
```

---

### 2.4 `bvnr_sink_bytes_written`

```c
uint64_t bvnr_sink_bytes_written(const bvnr_sink_t *s);
```

Return the number of bytes pushed into a sink so far: a memory sink, an fd sink,
or a callback sink (for that one, what its push accepted). A `NULL` sink is 0.

**Not for writer output.** `bvnr_open_write_sink` takes a *copy* of the sink, so
everything a writer emits advances the copy and never the caller's struct — this
counter stays 0 for the whole lifetime of the writer. Use it only when you push
into the sink yourself. To size writer output, ask the writer:

```c
bvnr_write_finish(w);
uint64_t n = bvnr_writer_bytes_written(w);   /* NOT bvnr_sink_bytes_written(&sink) */
fwrite(out, 1, n, stdout);
bvnr_writer_destroy(w);                       /* read the count before destroying */
```

---

### 2.5 `bvnr_open_write_sink`

```c
bool bvnr_open_write_sink(bvnr_writer_t        *w,
                            const bvnr_sink_t    *sink,
                            bool                  pretty,
                            bvnr_write_flags_t   *options);
```

Attach `sink` to the writer and configure it. Must be called before any `bvnr_write_event`. Returns `false` on invalid arguments. A writer with no sink — never opened, or whose open failed — refuses `bvnr_write_event`, `bvnr_write_version` and every helper with `error_invalid_argument` on the first call.

- `pretty` — when `true`, the serialiser emits newlines and indentation. When `false`, output is compact (single line per assignment, no extra whitespace).
- `options` — configuration struct. Zero-initialise for defaults.

```c
typedef struct bvnr_write_flags_s {
    /* Regrouped for exposition — see include/bovnar.h for the declaration
       order, and zero-initialise rather than relying on this one. ... */

    /* ── Writer enforces these ─────────────────────────────────── */
    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_event)(void *userdata, bvnr_event_t, bvnr_data_t *);
    bvn_unit_flags_t unit_flags;      /* controls unit annotation format */
    bool     emit_version;            /* emit a leading "#!bovnar M.N" on open */

    /* ── Present for API symmetry with bvnr_read_flags_t;          ─
       the writer does not read or enforce these fields. Set to 0. */
    uint16_t max_identifier_length;
    uint16_t max_string_length;
    uint16_t max_number_length;
    uint16_t max_symbol_length;
    uint16_t max_reference_length;
    uint64_t max_array_items;
    uint64_t max_text_bytes;
    uint64_t max_file_size;
    bool     continue_on_error;       /* no-op in the writer */
    bvnr_on_error_fn on_error;        /* no-op in the writer */

    uint64_t _reserved[4];            /* padding for future fields; leave zero */
} bvnr_write_flags_t;
```

> **Writer limits.** Only `max_struct_nesting`, `max_array_nesting`, `on_event`, `userdata`,
> `unit_flags` and `emit_version` have any effect on the writer. All other fields are present solely to keep
> `bvnr_write_flags_t` structurally parallel to `bvnr_read_flags_t`; they are silently
> ignored. In particular, `continue_on_error`, `on_error`, and all per-token-length fields
> have no effect. The writer never internally limits array items, text bytes, or file size.

`on_event` in the write flags fires for each event as it is serialised — useful for logging or auditing. Pass `NULL` if not needed.

`unit_flags` controls how unit annotations are serialised by the writer. The valid flags are:

| Flag | Value | Effect |
|------|-------|--------|
| `BVN_UNIT_FLAGS_NONE` | `0` | Default: Unicode superscript exponents, no reduction |
| `BVN_UNIT_REDUCE` | `1 << 0` | Reduce compound units to canonical form before serialising |
| `BVN_UNIT_ASCII_EXP` | `1 << 1` | Use `^N` ASCII caret notation instead of Unicode superscripts |
| `BVN_NUM_CANONICAL` | `1 << 2` | Render a *rescaled* float in float shape (`5000.0`, not `5000`), so a value that took the rescale lands on the same spelling as one that did not; and write every annotation in one form — the width written out (`<uint>` as `<uint:64>`), a default base `_10` and `no_unit` left out — so `1.0 km` and `<float:64,k~m> 1.0` produce the same bytes. Two spellings stay apart: a unit with an opaque profile code keeps its profile spelling and leaves the prefixes beside it unfolded (`ucum:[IU]/mL` and `ucum:[IU]/L` are not brought to one form), and an inline unit beside an annotation stays inline (`<uint:64> 5 m` is not rewritten as `<uint:64,m> 5`). What `bovnar pretty-print --canonical` sets beside `BVN_UNIT_REDUCE` |

`BVN_UNIT_REDUCE` folds every prefix out of the unit, and the **writer scales the value to match**: `5` with unit `k~m` is written as `5000 m`, not `5 m`. The rescale runs in exact rational arithmetic, so a value far wider than a double keeps every digit; where the scaled value cannot be written exactly in the value's own base the write fails with `error_value_out_of_range` rather than rounding. The rescaled value must also still satisfy the type it was declared with: an `uint`/`sint` that is no longer integral or no longer fits its width, and a `float_fix` that no longer fits its Q format (`<float_fix:32,q16>` leaves 15 integer bits, so 30000 km in metres does not fit), are refused the same way — the writer must not emit a document this library's own reader rejects. (A direct caller of `bvn_unit_to_string_ex` gets no such help — see the note on that function in §3.3.)

These flags can be OR-combined: `BVN_UNIT_REDUCE | BVN_UNIT_ASCII_EXP`. The flags are fixed at open time. To change serialisation behaviour, destroy the writer and open a new one with the updated `unit_flags`. The getter `bvnr_writer_unit_flags(w)` is used internally by the Python FFI layer to retrieve the live flags before each unit serialisation call; there is no public setter.

```c
bvnr_sink_t sink;
bvnr_sink_to_fd(&sink, fd);

bvnr_write_flags_t opts = { 0 };  /* all defaults */
if (!bvnr_open_write_sink(w, &sink, /*pretty=*/true, &opts))
    return -1;
```

---

### 2.6 `bvnr_open_write_mem`

```c
bool bvnr_open_write_mem(bvnr_writer_t      *w,
                           void               *buf,
                           uint64_t            cap,
                           bool                pretty,
                           bvnr_write_flags_t *options);
```

Convenience wrapper that constructs a memory sink internally and calls `bvnr_open_write_sink`. Equivalent to `bvnr_sink_to_mem` + `bvnr_open_write_sink`. To retrieve the written byte count after finishing, call `bvnr_writer_bytes_written`.

```c
char out[4096];
bvnr_write_flags_t opts = { 0 };
if (!bvnr_open_write_mem(w, out, sizeof(out), false, &opts))
    return -1;
```

---

### 2.7 `bvnr_write_event`

```c
bool bvnr_write_event(bvnr_writer_t *w, bvnr_event_t ev, bvnr_data_t *data);
```

Emit one event to the writer. Every value in the output goes through this function: the helpers in [§2.16](#216-value-helpers) and the multiprecision writers in §2.12–2.13 are sequences of these events, and a caller replaying a reader's stream calls it directly. It serialises the event and the data it describes directly into the configured sink.

Returns `true` on success, `false` on any serialisation error.

The event sequence you must emit for every assignment mirrors exactly what the reader delivers to `on_verified`. At minimum, for a typed scalar value:

```
ev_assignment_start          (data->data = key, data->length = key length)
ev_type_annotation_start     (data->data = annotation text, or NULL for no annotation)
ev_type_annotation_type_family
ev_type_annotation_type_family_parameter   (for each parameter)
ev_type_annotation_end
ev_data                      (data->data = value string, data->value_type/value_unit set)
```

The `BVN_TYPE_*` macros build `value_type_spec_t` literals conveniently:

```c
#define BVN_TYPE_PLAIN          ((value_type_spec_t){ .family = vt_plain })
#define BVN_TYPE_UTF8           ((value_type_spec_t){ .family = vt_utf8  })
#define BVN_TYPE_BOOL           ((value_type_spec_t){ .family = vt_bool  })
#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) })
/* float with explicit numeral base (for base-16 output):        */
#define BVN_TYPE_FLOAT_BASE(w,b) ((value_type_spec_t){ .family = vt_float, .width = (w), .base = (b) })
/* uint/sint with explicit numeral base:                         */
#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) })
```

The maximum bit-width accepted for `uint` and `sint` is `BVN_MAX_INT_WIDTH` (defined as `32768u` in `bovnar.h`). The validator and writer reject any declared `uint`/`sint` width exceeding this limit with `error_illegal_value_type`.

> **Critical:** The writer dispatches `ev_type_annotation_type_family_parameter` events on `d->type`, not on `d->value_type`. For each parameter event, `d.type` must be set to the appropriate `token_type_t` value: `token_is_type_width` for the width parameter, `token_is_type_base` for the base parameter, `token_is_type_q` for the Q (fractional bits) parameter of `float_fix`, and `token_is_unit` for the unit parameter. An unrecognised `d.type` causes the writer to emit nothing for that event — the annotation will be silently incomplete. **Use `bvnr_write_type_annotation` (see §3.1) to avoid this complexity entirely.**

**Example: write `.port = <uint:16> 8080;`**

```c
/* Helper: emit a fully-typed uint16 assignment */
static bool write_uint16(bvnr_writer_t *w,
                          const char *key, uint16_t value)
{
    char valbuf[16];
    snprintf(valbuf, sizeof(valbuf), "%" PRIu16, value);

    value_type_spec_t vt = BVN_TYPE_UINT(16);
    value_unit_t      vu = BVN_UNIT_NONE;

    bvnr_data_t d;

    /* 1. Assignment start — key */
    d = (bvnr_data_t){ .data = (void*)key, .length = (uint32_t)strlen(key) };
    if (!bvnr_write_event(w, ev_assignment_start, &d)) return false;

    /* 2. Type annotation — use bvnr_write_type_annotation for the full sequence */
    if (!bvnr_write_type_annotation(w, vt, vu)) return false;

    /* 3. Value */
    d = (bvnr_data_t){
        .type       = token_is_number,
        .value_type = vt,
        .value_unit = vu,
        .data       = valbuf,
        .length     = (uint32_t)strlen(valbuf),
    };
    return bvnr_write_event(w, ev_data, &d);
}
```

**Example: write a string assignment `.host = "localhost";`**

```c
static bool write_string(bvnr_writer_t *w,
                           const char *key, const char *value)
{
    value_type_spec_t vt = BVN_TYPE_UTF8;
    value_unit_t      vu = BVN_UNIT_NONE;
    bvnr_data_t d;

    d = (bvnr_data_t){ .data = (void*)key, .length = (uint32_t)strlen(key) };
    if (!bvnr_write_event(w, ev_assignment_start, &d)) return false;

    d = (bvnr_data_t){ .value_type = vt };
    if (!bvnr_write_event(w, ev_type_annotation_start, &d)) return false;
    if (!bvnr_write_event(w, ev_type_annotation_type_family, &d)) return false;
    if (!bvnr_write_event(w, ev_type_annotation_end, &d)) return false;

    d = (bvnr_data_t){
        .type       = token_is_string,   /* required: says how the value is spelled */
        .value_type = vt,
        .data       = (void*)value,
        .length     = (uint32_t)strlen(value),
    };
    return bvnr_write_event(w, ev_data, &d);
}
```

The `ev_data` event's `type` is not optional. Left at zero it is
`token_is_identifier`, which is not a value, and the write fails with
`error_type_value_mismatch` after the annotation has already gone out.

**Example: open and close a struct**

```c
static bool write_struct_start(bvnr_writer_t *w, const char *key)
{
    bvnr_data_t d = { .data = (void*)key, .length = (uint32_t)strlen(key) };
    if (!bvnr_write_event(w, ev_assignment_start, &d)) return false;
    return bvnr_write_event(w, ev_struct_start, &(bvnr_data_t){0});
}

static bool write_struct_end(bvnr_writer_t *w)
{
    return bvnr_write_event(w, ev_struct_end, &(bvnr_data_t){0});
}
```

---

### 2.8 `bvnr_write_version`

```c
bool bvnr_write_version(bvnr_writer_t *w, uint16_t major, uint16_t minor);
```

Emit a leading `#!bovnar <major>.<minor>` version directive (spec §3.4). Must be
called immediately after `bvnr_open_write_*` and before any value; calling it
once output has begun is `error_invalid_argument`. Use it to round-trip a
directive read from a source document, or pass `BVNR_SPEC_VERSION_MAJOR` /
`BVNR_SPEC_VERSION_MINOR` to stamp the current spec version. Setting
`bvnr_write_flags_t.emit_version` is equivalent to calling it with the current
spec version right after open.

```c
bvnr_open_write_sink(w, &sink, true, NULL);
bvnr_write_version(w, 1, 1);          /* "#!bovnar 1.1\n" */
bvnr_write_uint(w, "port", 16, 443);
bvnr_write_finish(w);
```

---

### 2.9 `bvnr_write_finish`

```c
bool bvnr_write_finish(bvnr_writer_t *w);
```

Flush any buffered output and finalise the stream. Must be called after all `bvnr_write_event` calls and before `bvnr_writer_destroy`. Returns `false` if any struct or array is still open, or an assignment, a type annotation or a `/` row separator was left with nothing after it (`error_got_incomplete_bvnr_stream`), if writing the trailing semicolon fails, or if flushing the write buffer to the sink fails.

**When it fails** — after an earlier error, or because it finds the document incomplete itself (`error_got_incomplete_bvnr_stream`, or `error_unit_mismatch` for a spelling from `bvnr_writer_set_unit_source` that no value took) — it returns `false` and still hands the sink everything written up to that error: the same bytes a memory sink already holds. So an fd or callback sink ends with the document up to the failing value, or up to where the producer stopped, not with whatever part of it the 64 KiB buffer happened to have pushed; the error code and offset are those a memory sink reports. (A sink that has itself failed, `error_writing_to_sink` or `error_sink_buffer_exhausted`, is not pushed again, and a push that fails here does not replace the error.) That output is not a document: discard it, or keep it only to see where the error fell.

**64 KiB write buffer.** For an fd or a custom sink the writer accumulates output into an internal 64 KiB buffer. Individual `bvnr_write_event` calls do not push bytes to the sink immediately; instead bytes accumulate in the buffer and are forwarded to the sink only when the buffer is full, when `bvnr_write_flush` is called (§2.18), or when `bvnr_write_finish` is called. A memory sink (`bvnr_open_write_mem`) is written through directly — there is no point buffering a buffer — so its bytes are in the caller's buffer as soon as each event returns. This means the sink receives large contiguous writes rather than one tiny push per token, which is important for fd-based sinks. `bvnr_writer_bytes_written` always reflects the true total of bytes handed off to the sink plus bytes still in the buffer, so the count is accurate at any point during writing.

```c
if (!bvnr_write_finish(w)) {
    fprintf(stderr, "write finish failed: %s\n",
            bvn_error_to_string(bvnr_writer_get_error(w)));
}
bvnr_writer_destroy(w);
```

---

### 2.10 `bvnr_writer_get_error` and friends

```c
error_code_t     bvnr_writer_get_error       (const bvnr_writer_t *w);
uint64_t         bvnr_writer_get_error_offset(const bvnr_writer_t *w);
uint64_t         bvnr_writer_bytes_written   (const bvnr_writer_t *w);
bvn_unit_flags_t bvnr_writer_unit_flags      (const bvnr_writer_t *w);
```

The writer error API is smaller than the reader's: there are **no** `bvnr_writer_get_error_line` or `bvnr_writer_get_error_column` functions. The writer has no lexer and therefore cannot track source positions. Use `bvnr_writer_get_error_offset` (byte count into the output stream) and `bvnr_writer_get_error` (error code) for diagnostics. `bvnr_writer_bytes_written` returns the total bytes emitted to the sink so far — available at any point, not only after errors.

`bvnr_writer_unit_flags` returns the `bvn_unit_flags_t` bitmask currently stored in the writer object (set via `bvnr_write_flags_t.unit_flags` at open time). The writer uses these flags whenever it serialises a unit annotation string (via `bvn_unit_to_string_ex`). This function is primarily used by the Python bindings FFI layer to retrieve the live flags before each unit serialisation call.

```c
if (!bvnr_write_event(w, ev_data, &d)) {
    fprintf(stderr, "write error: %s at offset %" PRIu64 "\n",
            bvn_error_to_string(bvnr_writer_get_error(w)),
            bvnr_writer_get_error_offset(w));
    return -1;
}
```

---

### 2.11 `bvn_format_uint64` / `bvn_format_int64` / `bvn_format_double`

```c
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);
```

Produce the value string that goes into `bvnr_data_t.data` when writing numeric values. All three return the number of bytes written (excluding NUL terminator), or `-1` on buffer overflow (and `bvn_format_double` on the cases below).

- `base` — numeric base (2–62, 64, 85). Use `10` for the common case.
- `min_digits` — zero-pad to at least this many digits. Pass `0` for no padding.
- For `bvn_format_double`, the type spec `vt` controls the output precision according to `vt.width`. A `float:16` or `float:32` value is first rounded to IEEE binary16 or binary32 (its significand, exponent range and subnormals), as `bvnr_write_float` writes it: `2049` under `float:16` is `2.048e+3`, `16777217` under `float:32` is `1.6777216e+7`, and a finite value the format cannot hold (`1e5` under `float:16`) returns `-1`. The digits are then those of a 16- or 32-bit significand, so the text reads back as the rounded value even by a reader that does not round it to the declared format (`65504` is `6.5504e+4`). A `float_fix` or `float_dec` value is written as the double it is. Because the input is a C `double`, the effective precision is capped at the 64-bit format (a wider `vt.width` yields no extra digits); render the full precision of a 128/256-bit value with the arbitrary-precision writer `bvnr_write_bvnf_base` instead.

```c
char buf[32];

/* Format 255 as a base-16 value, minimum 2 digits → "ff" */
int32_t n = bvn_format_uint64(buf, sizeof(buf), 255, 16, 2);
/* buf = "ff", n = 2 */

/* Format 9.81 as a 64-bit float */
value_type_spec_t vt = BVN_TYPE_FLOAT(64);
n = bvn_format_double(buf, sizeof(buf), 9.81, vt);
/* buf = "9.81e+0", n = 7 — the writer emits an exponent for every non-zero
   value (zero is written as "0.0" / "-0.0"), so a float always re-reads as a
   float and never as an integer carrier */
```

These strings are then placed into `bvnr_data_t.data` / `.length` before calling `bvnr_write_event(w, ev_data, &d)`.

---

### 2.12 `bvnr_write_bvnf_base` / `bvnr_write_bvnf_base_unit`

```c
bool bvnr_write_bvnf_base(bvnr_writer_t *w, const char *key,
                            const bvn_float_t *f,
                            uint32_t width, uint32_t base);

bool bvnr_write_bvnf_base_unit(bvnr_writer_t *w, const char *key,
                                 const bvn_float_t *f,
                                 uint32_t width, uint32_t base,
                                 value_unit_t unit);
```

Write an arbitrary-precision `bvn_float_t` value at any valid `float` width (0, 16, or any multiple of 32 up to 32768) and in either base 10 or base 16. The float string is generated internally by `bvn_float_to_str` and then validated by the writer before being sent to the sink — the call fails and sets an error if the generated string does not satisfy the writer's type constraints.

`bvnr_write_bvnf_base` is the no-unit variant; `bvnr_write_bvnf_base_unit` attaches a physical unit to the type annotation.

**Base 10** — the value string uses the same decimal format as `bvn_format_double` and is emitted as a bare number token (`token_is_number`). The type annotation is `<float:W>` or `<float:W,_10>`.

**Base 16** — the value string uses a binary-exponent hexadecimal format: `[−]D.DDDDp[+|−]EEE` where `D.DDDD` are lowercase hex mantissa digits and `EEE` is the decimal binary exponent. The string is emitted as a quoted string token (`token_is_string`) because non-decimal literals must be quoted in BVNR. The type annotation is `<float:W,_16>`.

```c
#include "bvn_float.h"

bvn_float_t *f = bvn_float_alloc(1024u);
bvn_float_from_str(f, "3.14159265358979323846", 10);

/* .pi_dec = <float:1024> 3.14... ; */
bvnr_write_bvnf_base(w, "pi_dec", f, 1024u, 10u);

/* .pi_hex = <float:256,_16> "1.921fb54442d18p+1"; */
bvn_float_t *g = bvn_float_alloc(256u);
bvn_float_from_double(g, 3.14159265358979323846);
bvnr_write_bvnf_base(w, "pi_hex", g, 256u, 16u);

bvn_float_free(f);
bvn_float_free(g);
```

`width` 0 uses the precision stored in the `bvn_float_t` itself. An invalid width (not 0, not 16, not a positive multiple of 32, or greater than `BVN_FLOAT_MAX_PREC`) causes the call to return `false` with `error_illegal_value_type`.

These functions supersede calling `bvnr_write_bvnf` / `bvnr_write_bvnf_unit` when base 16 output or widths greater than 128 are needed. `bvnr_write_bvnf` and `bvnr_write_bvnf_unit` remain available as convenience wrappers that always use base 10.

---

### 2.13 `bvnr_write_bvni` / `bvnr_write_bvni_unit`

```c
bool bvnr_write_bvni(bvnr_writer_t *w, const char *key,
                      const bvn_int_t *n,
                      uint32_t width, uint32_t base);

bool bvnr_write_bvni_unit(bvnr_writer_t *w, const char *key,
                            const bvn_int_t *n,
                            uint32_t width, uint32_t base,
                            value_unit_t unit);
```

Write an arbitrary bit-width integer. The type family (`uint` or `sint`) is determined by the `negative` flag of the `bvn_int_t`: negative values produce `<sint:W,...>`, non-negative values produce `<uint:W,...>`. Any numeric base supported by the writer (2–62, 64, 85) may be specified.

The integer string is generated by `bvn_int_to_str` and then validated before reaching the sink. For non-decimal bases the string is emitted as a quoted token (`token_is_string`); for base 10 it is emitted as a bare number token (`token_is_number`).

```c
#include "bvn_int.h"

bvn_int_t *n = bvn_int_alloc();

/* 128-bit unsigned max in decimal */
bvn_int_from_str(n, "340282366920938463463374607431768211455", 10);
bvnr_write_bvni(w, "u128max", n, 128u, 10u);

/* 256-bit value in hex — emitted as <uint:256,_16> "deadbeef..."; */
bvn_int_from_str(n, "deadbeefcafebabe0011223344556677"
                    "8899aabbccddeeff0011223344556677", 16);
bvnr_write_bvni(w, "wide_hex", n, 256u, 16u);

/* signed negative in hex */
bvn_int_from_str(n, "-7fffffff", 16);
bvnr_write_bvni(w, "neg_hex", n, 32u, 16u);

bvn_int_free(n);
```

`width` 0 defaults to 64 bits for range validation. A `width` that cannot hold the value causes the call to return `false` with `error_value_out_of_range`.

---

### 2.14 `BVN_TYPE_FLOAT_BASE`

```c
#define BVN_TYPE_FLOAT_BASE(w, b)  /* value_type_spec_t */
```

Convenience macro that constructs a `value_type_spec_t` for a `float` with explicit width `w` and base `b`. The existing `BVN_TYPE_FLOAT(w)` macro always yields base 0 (equivalent to base 10); `BVN_TYPE_FLOAT_BASE` is needed when base 16 must be encoded in the type spec before being passed to `bvnr_write_type_annotation` or `bvnr_write_event` directly.

```c
value_type_spec_t vt16 = BVN_TYPE_FLOAT_BASE(256u, 16u);
/* → { .family = vt_float, .width = 256, .base = 16 } */

value_type_spec_t vt10 = BVN_TYPE_FLOAT_BASE(64u, 10u);
/* equivalent to BVN_TYPE_FLOAT(64) */
```

### 2.15 Writer-side unit policy (`bvnr_writer_set_unit_policy`)

```c
bool bvnr_writer_set_unit_policy(bvnr_writer_t *w, const bvnr_unit_policy_t *p);
```

The producing half of the contract in 1.12: refuse to **emit** a value a reader
under the same policy would refuse to read.

This is the half the format's promise rests on. A reader can only reject a
document somebody already wrote; only the writer can stop a bare number reaching
a file in the first place, which is what "hand the file to anyone and they have
everything required to interpret it" actually depends on.

```c
static const char *lengths[] = { "m" };
bvnr_unit_policy_t p = {0};
p.require_unit             = true;    /* no bare numeric values */
p.require_dimension_of     = lengths; /* and every one of them a length */
p.num_require_dimension_of = 1;
bvnr_writer_set_unit_policy(w, &p);
```

`bvnr_write_event` (and every `bvnr_write_*` helper above it) then fails with
`error_unit_mismatch`, latched into the writer like any other write error and
readable with `bvnr_writer_get_error`. The unit checked is the one the value
will carry in the output — whether it was given inline on the value or as a
parameter of its type annotation, and a single annotation covers every element
of the array under it.

**Validation only.** `require_unit`, `require_dimension_of` and per-field rules
in `bvnr_rule_require` mode are accepted; a policy carrying `targets`,
`normalise`, `base`, `on_inexact` or a `bvnr_rule_convert` rule is rejected
(`false`) rather than half-honoured. The writer already has a
value-rewriting mode — `BVN_UNIT_REDUCE` in `bvnr_write_flags_t.unit_flags`,
which folds prefixes out and rescales the value exactly — and a second one
arriving through a different door, with different rules about exactness, is how
two features end up disagreeing about what a document says.

Like the reader's, the policy may be set before or after `bvnr_open_write_*`,
survives re-opening the writer on another document, and is cleared with `NULL`.

---

### 2.16 Value helpers

One call per assignment: the key, its type annotation and its value, for the
common scalar types. Each is the event sequence of §2.7 written out for you, and
is what most producers should use.

```c
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);
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);
bool bvnr_write_float(bvnr_writer_t *w, const char *key,
                      uint32_t width, double value);
bool bvnr_write_float_fix(bvnr_writer_t *w, const char *key,
                          uint32_t width, uint32_t q, double value);
bool bvnr_write_float_dec(bvnr_writer_t *w, const char *key,
                          uint32_t width, double value);

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);

bool bvnr_write_struct_start(bvnr_writer_t *w, const char *key);
bool bvnr_write_struct_end  (bvnr_writer_t *w);
```

| Helper | Writes | Notes |
|--------|--------|-------|
| `bvnr_write_string` | `.key = "value";` | `value` is escaped for you. It must be valid UTF-8 and may not hold a C0 control other than `\t` `\n` `\v` `\f` `\r`, nor DEL (`error_unexpected_input_byte`, `error_invalid_utf8_byte`) |
| `bvnr_write_plain` | `.key = value;` | a bare **symbol** — `value` must be a valid symbol, it is not quoted |
| `bvnr_write_null` | `.key = ;` | the null value |
| `bvnr_write_bool` | `.key = true;` / `false` | |
| `bvnr_write_uint` / `_sint` | `.key = <uint:16> 443;` | `width` 0 writes `<uint>` / `<sint>` (64 bits); a value that does not fit `width` is `error_value_out_of_range` |
| `bvnr_write_float` | `.key = <float:64> 1e-1;` | `width` is 0 (writes `<float>`), 16, 32, 64, 128 or 256 — another multiple of 32 the format allows, such as 96, is `error_invalid_argument` here and needs `bvnr_write_bvnf_base`. At 16 and 32 the value is rounded to binary16 or binary32 first, and a finite value that overflows that format is `error_value_out_of_range`, not an `inf`. `nan`, `inf` and `ninf` are written as those words; formatted as by `bvn_format_double` (§2.11) |
| `bvnr_write_float_fix` | `.key = <float_fix:32,q16> 1.5e+0;` | `q` is the number of fractional bits |
| `bvnr_write_float_dec` | `.key = <float_dec:64> 1.25e+0;` | |
| `…_unit` | `.key = <float:64,k~m> 1e+0;` | the same, with `unit` as the annotation's unit parameter; honours `bvnr_writer_set_unit_source` (§3.1) and the writer's unit policy (§2.15) |
| `bvnr_write_struct_start` / `_end` | `.key = {` … `};` | every helper called between the two writes a member |

All return `false` on failure with the reason latched in the writer
(`bvnr_writer_get_error`); once a write has failed, every later one fails too.
A `double` carries 53 significant bits whatever `width` says — for the full
precision of a wider `float`, `float_dec` or `float_fix`, or an integer past 64
bits, use `bvnr_write_bvnf_base` (§2.12) and `bvnr_write_bvni` (§2.13). A
timestamp has its own helper, `bvnr_write_datetime` (§1.9). There is no array
helper: an array is written as events (`ev_array_row_start`, one `ev_data` per
element, `ev_array_row_end`).

```c
bvnr_write_string(w, "host", "localhost");       /* .host = "localhost";      */
bvnr_write_uint  (w, "port", 16, 443);           /* .port = <uint:16> 443;    */
bvnr_write_struct_start(w, "limits");            /* .limits = {               */
bvnr_write_bool  (w, "strict", true);            /*     .strict = true;       */
bvnr_write_null  (w, "owner");                   /*     .owner = ;            */
bvnr_write_struct_end(w);                        /* };                        */
```

---

### 2.17 `bvnr_sink_to_callback`

```c
void  bvnr_sink_to_callback(bvnr_sink_t *s, bvnr_push_fn push,
                            bvnr_flush_fn flush, void *userdata);
void *bvnr_sink_get_userdata(const bvnr_sink_t *s);

typedef bool (*bvnr_push_fn) (bvnr_sink_t *s, const void *buf, uint32_t len);
typedef bool (*bvnr_flush_fn)(bvnr_sink_t *s);
```

The writer's twin of §1.14: output goes to the caller's `push`, which must take
all `len` bytes or return `false`. Usable wherever an fd or memory sink is —
`bvnr_open_write_sink`, the reader's mirror, the canonicalising observer,
`bvnr_frame_write`.

It behaves as an fd sink does, not as a memory sink: the writer batches output
into its 64 KiB buffer and pushes whole blocks, and a refused push is
`error_writing_to_sink` (never `error_sink_buffer_exhausted`).
`bvnr_sink_bytes_written` counts what `push` accepted;
`bvnr_writer_bytes_written` counts that plus what the writer still buffers
(§2.9). `bvnr_write_flush` (§2.18) pushes the buffer early. `flush` may be
`NULL`; when set, `bvnr_write_flush` and `bvnr_write_finish` call it after
their last push, and a `false` from it fails them with `error_writing_to_sink`.

As with the source, `userdata` lives inside the 64-byte handle, the writer
pushes through its own copy, and the callback recovers the pointer with
`bvnr_sink_get_userdata(s)`.

```c
static bool sock_push(bvnr_sink_t *s, const void *buf, uint32_t len)
{
    conn_t *c = bvnr_sink_get_userdata(s);
    return send_all(c->sock, buf, len);
}

bvnr_sink_t sink;
bvnr_sink_to_callback(&sink, sock_push, NULL, &conn);
bvnr_open_write_sink(w, &sink, false, NULL);
```

---

### 2.18 `bvnr_write_flush`

```c
bool bvnr_write_flush(bvnr_writer_t *w);
```

Hand everything written so far to the sink now, then call the sink's flush
callback if it has one. For an fd or callback sink the writer gathers output
in its 64 KiB buffer (§2.9), so a consumer at the other end of a socket would
otherwise see nothing until the buffer fills or the document finishes; a
producer that streams a mux message per `bvnr_mux_send` calls this after each
one to hand it over at once.

It is legal between any two writer calls: inside an open struct or array, and
inside an open mux stream between `bvnr_mux_send` calls. It changes only *when*
the bytes leave, never which bytes: a separator still owed to the next value
(the `;` after a value, the `,` before the next array element) stays owed, so
output flushed at every possible point is byte-identical to output never
flushed.

A value is complete for a reader only with what ends it, so that owed `;`
matters to a live consumer: after `bvnr_write_uint(w, "a", 64, 1)` and a flush
the consumer has `.a=<uint:64>1`, and its fed reader delivers the value when the
next write (or `bvnr_write_finish`) sends the `;`. A stream of records written
as top-level values therefore arrives one record late. A mux message has no
such tail: its chunks are complete when `bvnr_mux_send` returns, so a flush
after each send hands every message over at once.

| Sink | What it does |
|---|---|
| memory (`bvnr_sink_to_mem`, `bvnr_open_write_mem`) | nothing; the bytes are in the buffer already. Returns `true`. |
| fd (`bvnr_sink_to_fd`) | writes the buffer out. |
| callback (`bvnr_sink_to_callback`) | pushes the buffer, then calls `flush` when one was given. |

A failure latches like any writer error (§2.10): a refused push or a `false`
from `flush` is `error_writing_to_sink`, with the error offset at the end of
the output so far, and a writer with no sink attached is
`error_invalid_argument`. After an earlier error it returns `false` at once and
pushes nothing. After `bvnr_write_finish` it returns `true` and does nothing:
the finish has flushed. It allocates nothing.

```c
bvnr_mux_begin(w, "media");
bvnr_write_flush(w);                      /* the preamble and the opener */
while (next_frame(&ch, &frame, &len)) {
    if (!bvnr_mux_send(w, ch, frame, len) || !bvnr_write_flush(w))
        break;                            /* bvnr_writer_get_error(w) */
}
bvnr_mux_end(w);
bvnr_write_finish(w);
```

---

## 3. Shared

---

### 3.1 `bvnr_write_type_annotation`

```c
bool bvnr_write_type_annotation(bvnr_writer_t *w,
                                 value_type_spec_t vt,
                                 value_unit_t vu);
```

Emit a complete type-annotation event sequence (`ev_type_annotation_start`, `ev_type_annotation_type_family`, zero or more `ev_type_annotation_type_family_parameter` events, `ev_type_annotation_end`) in a single call. Returns `false` on any serialisation error.

This is the **preferred** way to write type annotations. Using `bvnr_write_event` directly for parameter events requires setting `d.type` to the appropriate `token_type_t` constant for each parameter; `bvnr_write_type_annotation` handles this correctly and automatically.

The function emits parameters as follows:

- **Width** — emitted for numeric families when `vt.width != 0`. A width of `0` is **not** written to the stream (the absence implies the default width of 64 on the reader side via `bvn_effective_width`). Also emitted for `datetime` when `vt.width != 0`. It is **not** emitted for `utf8` or `bool`, which are parameterless — a width on them is rejected by the type-spec validator (`error_illegal_value_type`).
- **Base** — emitted for `float` when `vt.base` is non-zero and not `10`; emitted for `uint`/`sint` when `vt.base` is non-zero and not `10`.
- **Q** — emitted for `float_fix` when `vt.base` (which stores Q) is non-zero. A Q value of `0` is therefore not written explicitly.
- **Unit** — emitted when `vu.num_components > 0`. `BVN_UNIT_NONE` (num_components == 0) produces no unit parameter; a lone `bu_none` component — `BVN_UNIT_NO_PREFIX(bu_none)`, num_components == 1 — is normalised to `BVN_UNIT_NONE` on entry and likewise produces **no** unit parameter.

```c
value_type_spec_t vt = BVN_TYPE_FLOAT(64);
value_unit_t      vu = BVN_UNIT_COMPOUND2(
                           bu_meter,  si_none, exp_linear,
                           bu_second, si_none, exp_neg_square);

/* Emits: <float:64,m/s²> */
if (!bvnr_write_type_annotation(w, vt, vu)) return false;
```

#### Writing a unit the way it was spelled (`bvnr_writer_set_unit_source`)

```c
bool bvnr_writer_set_unit_source(bvnr_writer_t *w,
                                 const char *text, uint32_t length);
```

`vu` is a `value_unit_t`, and a `value_unit_t` has nowhere to hold a spelling,
so the unit is written in canonical form: a producer holding `ucum:mL{total}`
gets `m~L`. `bvnr_writer_set_unit_source` hands the spelling over separately.
The **next** `bvnr_write_type_annotation` — which every `bvnr_write_*_unit`
helper calls — emits `text` for its unit and then forgets it:

```c
bool ok = false;
value_unit_t mL = bvn_parse_unit((const uint8_t *)"m~L", &ok);

bvnr_write_version(w, 1, 2);                       /* profile notation needs it */
bvnr_writer_set_unit_source(w, "ucum:mL{total}", 14);
bvnr_write_float_unit(w, "volume", 64, 1.5, mL);   /* <float:64,ucum:mL{total}> */
bvnr_write_float_unit(w, "other",  64, 2.5, mL);   /* <float:64,m~L> — one shot */
```

It is one shot, and for the **next value only**. If that value takes no unit — a
string, a bool, a struct, a bare number — the spelling does not travel on to
whichever later value happens to carry one: the write fails with
`error_unit_mismatch` (for a helper that writes no annotation at all, at the
key that follows it, or at `bvnr_write_finish` if there is none). It is also dropped when a write fails, when the writer is
re-opened, and by passing `NULL` or a zero length. The text is copied.

A caller driving `bvnr_write_event` directly needs no setter: set
`bvnr_data_t.unit_source` on the unit-parameter event (annotation slot) or on
the data event (inline suffix). Either way the spelling is **checked before a
byte of it is written**, because a spelling that disagrees with the unit is a
document that says two things:

| The spelling… | Error |
|---|---|
| parses to a different unit than the one given | `error_unit_mismatch` |
| does not parse | whatever `bvn_unit_error_code` says: `error_unit_illegal`, `error_unit_profile_unknown`, `error_unit_profile_unsupported` |
| is longer than `BVNR_UNIT_SOURCE_MAX` (255) bytes, or than what is left of an annotation's 255 after the family and the parameters before it | `error_unit_too_long` |
| is profile notation and no `#!bovnar 1.2` directive was emitted | `error_unsupported_spec_version` |
| would not be read back as itself in that unit slot | `error_unit_illegal` |
| was set for a value that turned out to carry no unit | `error_unit_mismatch` |
| is headed for an inline suffix on an array element | `error_unit_illegal` — the format has no inline unit inside `[…]` |

The unit parameter's `data` text is held to the same table. It is what the
reader fills on that event and what every caller before 2.0 passed, and the
writer emits it when there is no `unit_source` — so it has to name
`value_unit` too, and where both fields are set they must agree. (A datetime's
parameter is its epoch, and there the one text accepted is the epoch's name.)
"`value_unit`" here is the parameter event's own, or — for a caller that sets
the unit only on `ev_type_annotation_start` and nothing but `type` on the
parameter events — the one given there. A unit named only on the start event
is written as the annotation's unit parameter; a parameter event naming a
different one, and a second unit parameter, are refused.

**A value agrees with its annotation.** The serialiser writes the unit the
annotation names and never the value's, so a data event that follows an
annotation naming a unit must carry that unit or none. `<float:64,k~g>`
followed by a value whose `value_unit` is metres is `error_unit_mismatch`; it
used to be written, as kilograms. The annotation governs everything written
under it: each element of an annotated array, in each `/`-joined row, is held
to it in the same way, and `<float:64,no_unit>` refuses a value that has a
unit.

**One document can be read and not written back:** a bare value with an inline
unit of some 243 bytes or more. The reader delivers an inline unit inside a
synthesised annotation (`<float:64,_10,…>`), which is a dozen bytes longer than
the slot the unit came from; where that no longer fits in the annotation's 255,
the replay is refused with `error_unit_too_long`.

The fifth row is the one a unit parser cannot answer, and it is answered by
reading the text back through the real reader. The two unit slots end in
different places: `ucum:mL{a,b}` and `udunits:kg m-2 s-1` are each one
annotation parameter and neither is a legal inline suffix, so the same spelling
is written in one slot and refused in the other.

**A unit flag wins.** With `BVN_UNIT_REDUCE` or `BVN_UNIT_ASCII_EXP` set in
`bvnr_write_flags_t.unit_flags`, `unit_source` is ignored, unchecked, and the
flagged form is written: those flags ask for a particular spelling themselves.
The `data` text is still checked there, in the form the flag produces — it may
be `m` for a `value_unit` of `k~m` under `BVN_UNIT_REDUCE`, and may not be `s`.

---

### 3.2 `bvn_parse_unit` / `bvn_parse_unit_n`

```c
value_unit_t bvn_parse_unit  (const uint8_t *unit, bool *ok);
value_unit_t bvn_parse_unit_n(const uint8_t *unit, uint32_t len, bool *ok);
```

Parse a compound unit string (e.g. `"k~g·m/s²"`) into a `value_unit_t`. Both set `*ok` to `false` and return a zeroed unit on any error.

`bvn_parse_unit` requires a NUL-terminated string. `bvn_parse_unit_n` accepts a length `len` and does **not** require a NUL terminator — use this variant when the unit string is a substring of a larger buffer (as is the case inside the parser itself).

This is useful when reading: after `ev_type_annotation_type_family_parameter`, the unit is already parsed for you in `d->value_unit`. You only need `bvn_parse_unit` / `bvn_parse_unit_n` if you are constructing a unit from a string yourself (e.g. from a config or CLI argument).

The validator also calls `bvn_parse_unit_n` internally when processing an **inline unit suffix** (the optional unit token that may follow a scalar value before its terminating `;`). You do not need to call either function yourself to consume inline units; the parsed result is automatically placed in `d->value_unit` of the `ev_data` event, exactly as for annotation-specified units.

```c
bool ok;
value_unit_t u = bvn_parse_unit((const uint8_t *)"k~g·m/s²", &ok);
if (!ok) {
    fprintf(stderr, "bad unit\n");
    return;
}
/* u now holds { num_components=3, [{bu_gram,exp_linear,si_kilo},
                                    {bu_meter,exp_linear,si_none},
                                    {bu_second,exp_neg_square,si_none}] } */

/* Length-bounded variant — no NUL needed */
const uint8_t *annotation = (const uint8_t *)"float:64,m/s";
value_unit_t u2 = bvn_parse_unit_n(annotation + 9, 3, &ok); /* "m/s" */
```

---

### 3.3 `bvn_unit_to_string` / `bvn_unit_to_string_ex`

```c
int32_t bvn_unit_to_string(value_unit_t u, char *buf, size_t bufsize);

int32_t bvn_unit_to_string_ex(value_unit_t u, char *buf, size_t bufsize,
                               bvn_unit_flags_t flags);
```

Serialise a `value_unit_t` back into its canonical string form. Numerator components are joined by `·`, followed by `/` and denominator components joined by `·`. Returns bytes written (excluding NUL), or `-1` on buffer overflow.

`bvn_unit_to_string` is equivalent to calling `bvn_unit_to_string_ex` with `flags = BVN_UNIT_FLAGS_NONE`.

`bvn_unit_to_string_ex` accepts a `bvn_unit_flags_t` bitmask that controls output format:

| Flag | Effect |
|------|--------|
| `BVN_UNIT_FLAGS_NONE` | Default: Unicode superscript exponents, no reduction |
| `BVN_UNIT_REDUCE` | Reduce compound unit to canonical named SI unit before serialising |
| `BVN_UNIT_ASCII_EXP` | Use `^N` ASCII caret notation instead of Unicode superscripts |

These flags can be OR-combined. The writer uses `bvn_unit_to_string_ex` internally, passing the flags from `bvnr_writer_unit_flags(w)`.

> **`BVN_UNIT_REDUCE` here returns only the reduced UNIT.** `bvn_unit_to_string_ex` discards the scale `bvn_unit_reduce` folded out, so `k~g` serialises as `"g"` — a string denoting a quantity 1000× smaller than the unit passed in. The writer above moves the value with it; nothing else does.
>
> **A direct caller must not reach for `bvn_unit_reduce`'s `scale` to do it.** That scale is to the *fully reduced* unit, and this function does not always emit the fully reduced unit: where the reduction lands on a named SI unit the formatter re-attaches the prefix, so `k~N` comes back `"k~N"` with nothing to rescale while `bvn_unit_reduce` still reports 1000 — and `k~g` comes back `"g"`, where the 1000 must be applied. The two are indistinguishable from outside, both being a lone unit with a kilo prefix, so the scale has to come from the unit that is actually emitted: format with this function, parse the result back, and convert from the original to *that*. The [unit-system reference](05_bovnar_unit_system.md) §12.2 gives the eight lines, which are the ones `bvn_ser_reduced_number` runs. The collapse never substitutes one named unit for another: `Sv` stays `Sv`, `Bq` stays `Bq`. A lone base at an exponent other than 1 still collapses, though — `s⁻¹` becomes `Hz` and `m~s⁻¹` becomes `k~Hz`. And a reduction that **overflows** (a summed exponent outside ±100, too many surviving bases, or a scale out of float range) returns `-1`: it drops a component, so what it produces is a different unit rather than a shorter spelling of the same one.

> **Note on writer usage:** When driving the writer manually via `bvnr_write_event`, do **not** pass a unit string in `bvnr_data_t.data` for the `ev_type_annotation_start` event — the serialiser ignores that field and derives the annotation from `data->value_type` and the subsequent parameter events. Use `bvnr_write_type_annotation` (§3.1) to emit a complete, correct type annotation in one call.

```c
value_unit_t u = BVN_UNIT_COMPOUND2(
    bu_gram,   si_kilo,  exp_linear,
    bu_second, si_none,  exp_neg_square);

char buf[64];
int32_t n = bvn_unit_to_string(u, buf, sizeof(buf));
/* buf = "k~g/s²", n = 7 */

n = bvn_unit_to_string_ex(u, buf, sizeof(buf), BVN_UNIT_ASCII_EXP);
/* buf = "k~g/s^2", n = 7 */
```

---

### 3.4 `bvn_unit_convert_value` *(bovnar_si_units.h)*

```c
bool bvn_unit_convert_value(double value, value_unit_t from,
                            value_unit_t to, double *out);
```

Convert one numeric quantity from unit `from` into unit `to`, writing the result
to `*out`. Handles both the simple multiplicative case (`5 k~m → 5000 m`) and the
affine case (`25 °C → 298.15 K`), routing the latter through SI base units. It
answers the same question the reader's `want_unit` hook (§1.10) does — may this
value be delivered in that unit, and what is it there — in `double`, and is the
C equivalent of the Python `convert_value`. It is **not** the routine the hook
runs: the hook works in exact rationals (`bvn_unit_convert_rational`, below).

Returns `false` — leaving `*out` untouched — when the two units are
dimensionally **incompatible** or have no SI mapping. That boolean is the
"validly convert only" guard; the same verdict from the exact engine is what
the reader reports as `error_unit_mismatch`.

```c
double m;
if (bvn_unit_convert_value(5.0,
        bvn_parse_unit((const uint8_t *)"k~m", &ok),
        bvn_parse_unit((const uint8_t *)"m",   &ok), &m))
    printf("%.0f m\n", m);          /* 5000 m */
```

For the standalone factor (without applying it) or to detect the affine case,
see `bvn_unit_convert_factor` in `bovnar_si_units.h`.

`bvn_unit_convert_value` works in `double`, so it is lossy for wide values. For a
**lossless** conversion — the engine behind the reader's `want_unit` hook (§1.10) —
use the exact-rational pair, also in `bovnar_si_units.h`:

```c
bool    bvn_unit_convert_rational(const bvn_int_t *vnum, const bvn_int_t *vden,
                                  value_unit_t from, value_unit_t to,
                                  bvn_int_t *out_num, bvn_int_t *out_den,
                                  bool *exact);
int32_t bvn_rational_to_str(const bvn_int_t *num, const bvn_int_t *den,
                            uint32_t base, char *buf, size_t bufsize, bool *exact);
size_t  bvn_rational_str_bufsize(const bvn_int_t *num, const bvn_int_t *den,
                                 uint32_t base);
bool    bvn_rational_base_valid(uint32_t base);   /* static inline */
```

`bvn_unit_convert_rational` converts the exact rational `vnum/vden` (parse a wire
value into one with `bvn_float_parse_rational` for floats, or `bvn_int_from_str`
for integers) from `from` into `to`, writing the exact reduced result to
`out_num`/`out_den`. It returns `false` for dimensionally incompatible units, and
sets `*exact = false` when the true factor is irrational (π-based angles) — the
result is then only an approximation and a lossless consumer must reject it.
`bvn_rational_to_str` renders an exact rational in any base bvnr can write —
`2..62` plus `64` and `85` (`bvn_rational_base_valid`) — setting `*exact = true`
and writing the full expansion when it terminates. It distinguishes two
failures:

| Return | Meaning |
|--------|---------|
| `BVN_RATIONAL_NONTERMINATING` (`-2`) | the expansion is infinite in this base, or the value has a fraction and the base is 85 (Ascii85 spends `.` on a digit, so there is no radix point). The rational itself is still exact, so use `num`/`den`. |
| `-1` | bad arguments, an unsupported base, a negative value in the sign-less bases 64/85, out of memory, or a `bufsize` too small. |

The buffer is **never truncated** — half an exact expansion is simply a different
number — so size it with `bvn_rational_str_bufsize`, which upper-bounds the
result from the operands' bit lengths.

Both handle any value width — a 1056-bit float, a 512-bit integer — with no
precision loss. The exactness they promise is bounded by the unit table's own
declared factors: every non-irrational unit carries an exact rational `to_si`
factor (see `src/gendata/units.bvnr`), and `gen_units.py` refuses to generate a
table in which a rounded decimal is passed off as exact.

---

### 3.5 `bvn_error_to_string`

```c
const char *bvn_error_to_string(error_code_t code);
```

Return a short, static, human-readable description of an error code. The returned pointer is valid for the lifetime of the program; do not free it.

```c
fprintf(stderr, "error: %s\n", bvn_error_to_string(bvnr_reader_get_error(r)));
/* e.g. "error: value_out_of_range" */
```

**Unit-related error codes** (for reference):

| Code | Value | String | Trigger |
|------|-------|--------|---------|
| `error_unit_illegal` | 32 | `"unit_illegal"` | Unparseable unit string (unknown base, bad prefix, empty component, more than `BVNR_MAX_UNIT_COMPONENTS` = 32 components) |
| `error_unit_too_long` | 22 | `"unit_too_long"` | An **inline** unit suffix exceeds its 255-byte lexer buffer; a unit in an annotation raises `error_type_too_long` instead |
| `error_unit_mismatch` | 38 | `"unit_mismatch"` | Inline unit suffix present, type-annotation unit also present, and the two differ; or a `want_unit` target dimensionally incompatible with the value's unit (§1.10) |
| `error_unit_inexact` | 47 | `"unit_inexact"` | A `want_unit` conversion could not be delivered exactly: irrational factor, or a non-terminating expansion in the output base without `want_unit_allow_nonterminating` (§1.10) |
| `error_unit_profile_unknown` | 49 | `"unit_profile_unknown"` | A unit written in the `name:` profile notation names a profile this build does not have (spec 1.2) |
| `error_unit_profile_unsupported` | 50 | `"unit_profile_unsupported"` | A valid profile expression over known atoms with no representation in the unit model — a special unit carrying a reference level, a scale factor outside the SI prefix decades, or more components than a unit may hold (spec 1.2) |
| `error_octet_stream_forbidden` | 51 | `"octet_stream_forbidden"` | The document contains an octet stream and the reader was opened with `text_only`. Reported at the stream's opening `0x00`, before its payload is read |
| `error_type_param_whitespace` | 52 | `"type_param_whitespace"` | Whitespace split a type-annotation parameter in two (spec §5.3). Legal beside a separator — the family `:`, a `,` between parameters, before the closing `>` — and an error inside a parameter, reported at the first byte after it. `<float:64,k g>` used to be accepted as `k~g` and `<uint:6 4>` as a 64-bit width |

### 3.6 Canonicalising observer (`bvnr_canon_observer_*`)

An adapter that lets a reader's event stream drive the serializer directly, without
constructing a `bvnr_writer_t`. Installed as a reader callback it re-emits every event
to a sink, producing a canonical — or pretty-printed — copy of the input. This is what
`bovnar events -d` prints its re-serialisation with. (`bovnar pretty-print` is the other
way to do the same job: a `bvnr_writer_t` fed from `on_verified`, which is what lets
`--canonical` set `unit_flags`.)

```c
typedef struct bvnr_canon_observer_s bvnr_canon_observer_t;

bvnr_canon_observer_t *bvnr_canon_observer_create(const bvnr_sink_t *sink, bool pretty);
void bvnr_canon_observer_set_version(bvnr_canon_observer_t *obs,
                                     uint16_t major, uint16_t minor);
bool bvnr_canon_observer_on_event(void *obs, bvnr_event_t ev, bvnr_data_t *data);
bool bvnr_canon_observer_finish(bvnr_canon_observer_t *obs);
void bvnr_canon_observer_destroy(bvnr_canon_observer_t *obs);
```

`pretty` selects the indented form; `false` emits the compact canonical form.
`bvnr_canon_observer_create` returns `NULL` if the sink is missing or has no push
function.

`bvnr_canon_observer_on_event` has the signature of a reader callback — `void *` first
— so the observer handle can be passed as `userdata` and the function used as
`on_verified` directly. Call `bvnr_canon_observer_finish` after `bvnr_read` returns to
flush the trailing state, then `bvnr_canon_observer_destroy`.

**It does not re-validate.** The events already came from a validating reader, so the
observer reproduces faithfully what it is fed rather than checking it a second time.
That includes the gaps of a read under `continue_on_error`: a recovered error leaves
a statement without its value (`.a=[1,2,{.b=@;},3]` delivers the key `.b` and nothing
after it), and the observer writes it as it arrived — `{.b=}` — so its output is not
a readable document when any error was recovered from. Check
`bvnr_reader_get_recovery_count` before using it as one.
For the same reason its array-nesting cap is set to the maximum: the reader has already
enforced its own limit, and a second, stricter cap here would reject a document the
reader accepted.

**Version directives.** A canonical copy of a spec-1.1 or 1.2 document needs the directive its
constructs require in order to re-read. `bvnr_canon_observer_set_version` records one to
prepend; it is emitted lazily, just before the first event. Call it before any event is
fed — typically from the reader's callback once `bvnr_reader_get_declared_version`
resolves (§1.8). A version of `0.0`, or a call made after output has begun, is ignored.

```c
bvnr_sink_t sink;
bvnr_sink_to_fd(&sink, STDOUT_FILENO);

bvnr_canon_observer_t *canon = bvnr_canon_observer_create(&sink, /*pretty=*/true);

bvnr_read_flags_t flags = {0};
flags.userdata    = canon;
flags.on_verified = bvnr_canon_observer_on_event;

bvnr_open_read_source(r, &src, NULL, &flags);
bvnr_read(r);
bvnr_canon_observer_finish(canon);
bvnr_canon_observer_destroy(canon);
```

### 3.7 Unit inspection helpers

Three more entry points from `bovnar_si_units.h`. The Python bindings expose all of
them — see [Extended unit functions](09_bovnar_python_bindings.md#52-extended-unit-functions) —
and they are listed here so the C side is not the poor relation.

```c
bool    bvn_unit_dimension_vector(value_unit_t u, int32_t dims[bvn_si_dim_count]);
bool    bvn_prefix_unit_valid(value_unit_prefix_t prefix, value_base_unit_t base);
int32_t bvn_exponent_to_int(unit_exponent_t e);
unit_exponent_t bvn_int_to_exponent(int32_t n);
```

**`bvn_unit_dimension_vector`** fills the seven SI exponents, indexed by
`bvn_si_dim_idx_t` — `bvn_si_dim_meter`, `_kilogram`, `_second`, `_ampere`, `_kelvin`,
`_mol`, `_candela`, with `bvn_si_dim_count` (7) as the array length. Two units with
the same vector are the same physical quantity however they were spelled:

```text
m/s          ok=1  [ 1, 0, -1, 0, 0, 0, 0]
k~g·m/s²     ok=1  [ 1, 1, -2, 0, 0, 0, 0]
N            ok=1  [ 1, 1, -2, 0, 0, 0, 0]      same vector as the compound above
%            ok=1  [ 0, 0,  0, 0, 0, 0, 0]      dimensionless, but it HAS a vector
$USD         ok=0                               currencies carry no dimension
```

The `false` return for a currency is the same fact §4.1 of the unit policy reference
records: a currency has no dimension vector, so a screen built on dimensions alone
declines conversions the engine can actually perform.

**`bvn_prefix_unit_valid`** answers whether a prefix may sit on a base unit — the IEC
prefixes belong to `bit` and `byte` only:

```c
value_unit_prefix_t p = { .system = prefix_iec, .id.iec = iec_gibi };
bvn_prefix_unit_valid(p, bu_byte);    /* true  */
bvn_prefix_unit_valid(p, bu_meter);   /* false */
```

**`bvn_exponent_to_int` / `bvn_int_to_exponent`** convert between the `unit_exponent_t`
enum and a plain integer: `bvn_exponent_to_int(exp_neg_square)` is `-2`, and
`bvn_int_to_exponent(-2)` is `exp_neg_square`.

---

## 4. DOM API (`bovnar_dom.h`)

The SAX reader above streams events. When you instead want the whole document in
memory for random-access queries — without writing a callback — use the DOM API
in `include/bovnar_dom.h`. It parses a document into a tree of `bvn_dom_node_t`
owned by a `bvn_dom_doc_t`, which you navigate and read with typed accessors.

### 4.1 Parsing and lifetime

```c
bvn_dom_doc_t *bvn_dom_doc_create(void);
void           bvn_dom_doc_destroy(bvn_dom_doc_t *doc);   /* frees the whole tree; NULL-safe */
bvn_dom_doc_t *bvn_dom_parse(const void *data, uint32_t len);
bvn_dom_doc_t *bvn_dom_parse_fd(int fd);
bvn_dom_doc_t *bvn_dom_parse_fd_ex(int fd, uint64_t max_bytes);
error_code_t   bvn_dom_doc_get_parse_error(const bvn_dom_doc_t *doc);
```

`bvn_dom_parse` returns NULL **only** on allocation failure — a *malformed*
document still returns a non-NULL doc, so check `bvn_dom_doc_get_parse_error()`
(`error_none` means it parsed cleanly), not the pointer, to detect a parse error.
The `bvn_dom_parse_fd*` variants instead return NULL on **any** failure
(allocation, I/O, or exceeding the size cap). `bvn_dom_parse_fd_ex` caps the
accumulated input at `max_bytes`, but only *downward*: `0` or any value above the
built-in hard cap of 256 MiB is clamped to that cap — there is no unlimited mode.
(The cap is a build-time constant of the library, `BVN_DOM_FD_MAX_BYTES` in
`src/dom/bovnar_dom_builder.c`; it is not in the public headers.) Free every
returned doc with `bvn_dom_doc_destroy`.

**The DOM reads at the format's nesting limit, not the reader's default.** A
`bvnr_reader_t` opened with zeroed flags stops at 64 levels of struct and 64 of
array nesting (§1.4); every `bvn_dom_parse*` function sets both to the hard
maximum of **255**. A document nested 65 to 255 deep therefore parses through
the DOM and is `error_struct_nesting_too_high` / `error_array_nesting_too_high`
from a default streaming reader — set `max_struct_nesting` and
`max_array_nesting` to 255 on the reader to make the two agree, which is what
the `bovnar` CLI does in every subcommand. The DOM's other limits are the
reader's defaults.

### 4.2 Navigation

```c
bvn_dom_node_t *bvn_dom_lookup(const bvn_dom_doc_t *doc, const char *path); /* dot path, e.g. "server.tls.cert" */
bvn_dom_node_t *bvn_dom_struct_get(const bvn_dom_node_t *node, const char *key);
bvn_dom_node_t *bvn_dom_array_at(const bvn_dom_node_t *node, uint32_t index);
uint32_t        bvn_dom_struct_count(const bvn_dom_node_t *node);
uint32_t        bvn_dom_array_count(const bvn_dom_node_t *node);
uint32_t        bvn_dom_array_dims(const bvn_dom_node_t *node);   /* number of `/`-separated dimensions */
const bvn_dom_entry_t *bvn_dom_struct_entries(const bvn_dom_node_t *node);
const bvn_dom_entry_t *bvn_dom_doc_entries(const bvn_dom_doc_t *doc);
uint32_t               bvn_dom_doc_count(const bvn_dom_doc_t *doc);
```

Each `bvn_dom_entry_t` is `{ char *key; bvn_dom_node_t *value; }`. A missing key
or out-of-range index returns NULL.

### 4.3 Type inspection

```c
typedef enum bvn_dom_type_e {
    BVN_DOM_NULL, BVN_DOM_INT, BVN_DOM_FLOAT, BVN_DOM_STRING, BVN_DOM_SYMBOL,
    BVN_DOM_REFERENCE, BVN_DOM_STRUCT, BVN_DOM_ARRAY, BVN_DOM_OCTET_STREAM, BVN_DOM_BOOL
} bvn_dom_type_t;

bvn_dom_type_t    bvn_dom_node_type(const bvn_dom_node_t *node);
bool              bvn_dom_is_null(const bvn_dom_node_t *node);
value_type_spec_t bvn_dom_get_value_type(const bvn_dom_node_t *node);
value_unit_t      bvn_dom_get_unit(const bvn_dom_node_t *node);
int32_t           bvn_dom_get_unit_string(const bvn_dom_node_t *node, char *buf, size_t bufsize);
double            bvn_dom_get_value_in_base_units(const bvn_dom_node_t *node);
```

`bvn_dom_get_value_in_base_units` scales a numeric node into coherent SI —
`1.5 k~m` → `1500.0`, and for an affine scale it applies the offset too, so
`25 °C` → `298.15`. Two caveats it cannot signal:

- **It returns `0.0` for every failure**, which a caller cannot tell from a
  genuine zero. A non-numeric node, a currency (no SI mapping), a structurally
  invalid unit, and an affine unit inside a compound (`°C/h`) all give `0.0`, as
  does `0.0 m`. When the difference matters, read `bvn_dom_get_unit` and call
  `bvn_unit_to_si_factor` yourself — it has an `ok` out-param.
- **It is `double`.** A value wider than a double is rounded here. The lossless
  route is the reader's `want_unit` hook (§1.10) or `bvn_unit_convert_rational`.

### 4.4 Typed value accessors

Each accessor returns `false` (leaving the out-param **unchanged** — no clamping
or truncation) when the node is NULL, not of the requested kind, or does not fit
the target type. "Kind" is strict with one deliberate exception:
`bvn_dom_get_float` also succeeds on an **integer** node and returns its value
as a `double` (a wide integer is rounded) — a caller asking for "the number"
does not have to know whether the document wrote `5` or `5.0`. The reverse does
not hold: the integer accessors fail on a float node. Pointer results are **borrowed** — valid only until the owning
document is destroyed; do not free them.

```c
bool bvn_dom_get_bool  (const bvn_dom_node_t *node, bool   *out);
bool bvn_dom_get_float (const bvn_dom_node_t *node, double *out);
bool bvn_dom_get_i64/u64/i32/u32/i16/u16/i8/u8(const bvn_dom_node_t *node, /* int type */ *out);
bool bvn_dom_get_string   (const bvn_dom_node_t *node, const char    **out, uint32_t *len); /* NUL-terminated; *len excludes NUL */
bool bvn_dom_get_symbol   (const bvn_dom_node_t *node, const char    **out, uint32_t *len);
bool bvn_dom_get_reference(const bvn_dom_node_t *node, const char    **out, uint32_t *len);
bool bvn_dom_get_octets   (const bvn_dom_node_t *node, const uint8_t **out, uint32_t *len); /* raw bytes, NOT NUL-terminated */
```

Wide integers (> 64 bits) are not stored inline; read them as a borrowed bigint
or render them to a string:

```c
const bvn_int_t *bvn_dom_get_bigint(const bvn_dom_node_t *node); /* NULL unless the int is wider than 64 bits */
char            *bvn_dom_int_to_str(const bvn_dom_node_t *node, uint32_t base); /* caller owns; free with bvn_dom_free_string */
void             bvn_dom_free_string(char *s);
```

For a `datetime` node written as a literal with a fractional second (spec 1.1),
the verbatim sub-second digits are available separately; the node's integer value
is still the whole-second epoch count read via `bvn_dom_get_i64`:

```c
const char *bvn_dom_get_datetime_fraction(const bvn_dom_node_t *node, uint32_t *len_out);
```

A value node also keeps its unit's **spelling** (library 2.0) where that says
something `bvn_dom_get_unit` cannot — `ucum:mL{total}` is the unit `m~L`, and
the annotation survives only here:

```c
const char *bvn_dom_get_unit_source(const bvn_dom_node_t *node, uint32_t *len_out);
```

The string is NUL-terminated and owned by the node. It is `NULL` for a node with
no unit, for one the document already wrote in canonical form (so
`bvn_unit_to_string` of the node's unit *is* the spelling, and a large array in
canonical units stores nothing extra), and for one whose value a unit policy
converted — the document's spelling describes a unit that node no longer has.

### 4.5 Building a tree

A tree can also be assembled by hand, within narrow limits — enough to build a
document of structs, arrays, nulls and integers for code that consumes a DOM, a
test fixture say. `bvn_dom_doc_create` makes an empty document;
`bvn_dom_node_alloc` makes an empty node of a given type, which is a complete
value only for the types that carry no payload (`BVN_DOM_STRUCT`, `BVN_DOM_ARRAY`,
`BVN_DOM_NULL`); the `bvn_dom_node_from_*` constructors make integer nodes, and
`bvn_dom_node_from_bigint` one wider than 64 bits with a type and a unit. The
`*_add`/`*_append` functions attach them.

What the API does **not** have: a constructor or setter for a float, string,
symbol, reference, bool or octet-stream node (a node allocated as one of those
holds no value and cannot be given one), and **any DOM serialiser** — there is no
function that writes a tree out as bovnar text. To produce a document, use the
writer (§2) directly; a built tree can only be navigated and read with §4.2–§4.4.

```c
bvn_dom_doc_t  *bvn_dom_doc_create(void);
bvn_dom_node_t *bvn_dom_node_alloc(bvn_dom_type_t t);
void            bvn_dom_node_destroy(bvn_dom_node_t *n);
bvn_dom_node_t *bvn_dom_node_from_i64/u64/i32/u32/i16/u16/i8/u8(/* value */);
bvn_dom_node_t *bvn_dom_node_from_bigint(bvn_int_t *bigint, value_type_spec_t vt, value_unit_t vu);
bool bvn_dom_struct_add   (bvn_dom_node_t *s, const char *key, uint32_t klen, bvn_dom_node_t *val);
bool bvn_dom_doc_add      (bvn_dom_doc_t  *d, const char *key, uint32_t klen, bvn_dom_node_t *val);
bool bvn_dom_array_append (bvn_dom_node_t *a, bvn_dom_node_t *elem);
char *bvn_dom_strdup(const char *s, uint32_t len);
```

**Ownership.** `bvn_dom_struct_add` / `bvn_dom_doc_add` / `bvn_dom_array_append`
**always** take ownership of the value node: on success the container owns it, and
on **every** failure path the node is destroyed internally — so never destroy it
yourself after the call (doing so double-frees).

A node belongs to one tree, and the tree must stay acyclic: pass a freshly built
node that no container owns yet, and never attach a container (or any node
already in the tree) into one of its own descendants. The direct
`val == container` case is refused, but ancestry is not tracked, so an indirect
cycle or a node owned by two trees makes `bvn_dom_node_destroy` free a node
twice (use-after-free). This is a precondition, not a check the library
performs.

**What they refuse.** Each returns `false` — having destroyed the value — when
the container is NULL or of the wrong kind (`bvn_dom_array_append` on anything
but a `BVN_DOM_ARRAY`, `bvn_dom_struct_add` on anything but a `BVN_DOM_STRUCT`),
when the value is NULL, and, for the two keyed ones, when the scope **already
has that key**: a hand-built tree cannot hold the duplicate a parsed document is
refused for (`error_duplicate_struct_key`). The duplicate test is a linear scan
of the scope. `bvn_dom_node_from_bigint` is the
one exception with *asymmetric* ownership: on success it takes ownership of the
`bvn_int_t`; on failure (NULL return) it does not, and you still own it.

### 4.6 Minimal example

```c
bvn_dom_doc_t *doc = bvn_dom_parse(buf, (uint32_t)len);
if (!doc) { /* out of memory */ }
if (bvn_dom_doc_get_parse_error(doc) != error_none) {
    /* malformed input — inspect the code */
} else {
    bvn_dom_node_t *port = bvn_dom_lookup(doc, "server.port");
    uint16_t p;
    if (port && bvn_dom_get_u16(port, &p)) { /* use p */ }
}
bvn_dom_doc_destroy(doc);
```

### 4.7 Parsing under a unit policy

```c
bvn_dom_doc_t *bvn_dom_parse_policy(const void *data, uint32_t len,
                                    const bvnr_unit_policy_t *p);
bvn_dom_doc_t *bvn_dom_parse_fd_policy(int fd, uint64_t max_bytes,
                                       const bvnr_unit_policy_t *p);
```

The DOM takes the same policy the streaming reader does (1.12), so a
random-access consumer can assert what it expects to find and store the unit it
wants back. `NULL` behaves exactly like the plain forms.

A validation failure lands in `bvn_dom_doc_get_parse_error()` as
`error_unit_mismatch`, like any other parse error. A policy the library refuses —
a malformed unit, a rule path naming nothing — is `error_invalid_argument`, so a
mistake in the **policy** is never mistaken for a fault in the **document**.

A value the policy converted is stored **converted**: its digits, its unit and
its base are the conversion's, because a caller who asked for metres and got the
document's feet back would have no way to notice. An integer that converts to a
fraction — 5 g in kilograms is 0.005 — is stored as a float, since that is what
it now is.

```c
static const bvnr_unit_rule_t rules[] = {
    { ".inlet.temperature", "°C", 0, bvnr_rule_convert },
};
bvnr_unit_policy_t p = {0};
p.rules = rules; p.num_rules = 1;

bvn_dom_doc_t *doc = bvn_dom_parse_policy(buf, len, &p);
bvn_dom_node_t *n  = bvn_dom_lookup(doc, ".inlet.temperature");
double v; bvn_dom_get_float(n, &v);      /* 100.0, from a document in °F */
```

This is also what `bovnar query` uses, so the command line has the same reach:

```bash
bovnar query --field .inlet.temperature=°C .inlet.temperature sensors.bvnr
bovnar query --require-unit .inlet.flow sensors.bvnr
```

---

## 5. Complete Read Example

```c
#include <fcntl.h>
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include "bovnar.h"

typedef struct { char key[256]; } ctx_t;

static bool on_verified(void *ud, bvnr_event_t ev, bvnr_data_t *d)
{
    ctx_t *ctx = ud;

    if (ev == ev_assignment_start) {
        size_t n = d->length < 255 ? d->length : 255;
        memcpy(ctx->key, d->data, n);
        ctx->key[n] = '\0';
        return true;
    }

    if (ev != ev_data || d->length == 0) return true;

    char vbuf[256];
    size_t n = d->length < 255 ? d->length : 255;
    memcpy(vbuf, d->data, n);
    vbuf[n] = '\0';

    switch (d->value_type.family) {
    case vt_uint: {
        uint64_t v;
        bvn_parse_uint64(vbuf, d->value_type, &v);
        printf(".%-20s = %" PRIu64 "  (uint%u)\n",
               ctx->key, v, bvn_effective_width(d->value_type));
        break;
    }
    case vt_float: {
        double v;
        bvn_parse_double(vbuf, d->value_type, &v);
        printf(".%-20s = %g  (float%u)\n",
               ctx->key, v, bvn_effective_width(d->value_type));
        break;
    }
    case vt_utf8:
        printf(".%-20s = \"%s\"\n", ctx->key, vbuf);
        break;
    default:
        printf(".%-20s = %s\n", ctx->key, vbuf);
        break;
    }
    return true;
}

int main(int argc, char **argv)
{
    if (argc < 2) { fprintf(stderr, "usage: %s <file>\n", argv[0]); return 1; }

    int fd = open(argv[1], O_RDONLY);
    if (fd < 0) { perror(argv[1]); return 1; }

    bvnr_reader_t *r = bvnr_reader_create();
    ctx_t ctx = {0};

    bvnr_source_t src;
    bvnr_source_from_fd(&src, fd);

    bvnr_read_flags_t opts = {
        .on_verified   = on_verified,
        .userdata      = &ctx,
        .max_file_size = 16777216,
    };

    if (!bvnr_open_read_source(r, &src, NULL, &opts)) {
        fputs("failed to open reader\n", stderr);
        bvnr_reader_destroy(r);
        close(fd);
        return 1;
    }

    int ret = 0;
    if (!bvnr_read(r)) {
        fprintf(stderr, "%s at line %" PRIu64 " col %" PRIu64 "\n",
                bvn_error_to_string(bvnr_reader_get_error(r)),
                bvnr_reader_get_error_line(r),
                bvnr_reader_get_error_column(r));
        ret = 1;
    }

    bvnr_reader_destroy(r);
    close(fd);
    return ret;
}
```

---

## 6. Inline Unit Suffix — Reading

A scalar value may carry an **inline unit suffix** directly after the literal, before its terminating `;`:

```bovnar
.speed = 9.81 m/s;            # no annotation; inline unit
.mass  = <float:64> 70.5 k~g; # annotation without unit; inline unit adopted
.dist  = <float:64,m> 1.5 m;  # annotation and inline agree — valid
```

From the application's perspective, inline units and annotation units are transparent: both end up in `d->value_unit` of the `ev_data` event. No special handling is needed.

The only behavioral difference occurs when **both** are present and **disagree**: the validator raises `error_unit_mismatch` (38) and parsing fails:

<!-- bovnar-example: rejected -->
```bovnar
.bad = <float:64,m> 1.0 s;    # annotation says m, inline says s → error_unit_mismatch
```

Inline unit suffixes are **illegal inside array elements**. The lexer rejects them with `error_unexpected_input_byte`.

### 6.1 Reading inline unit values

```c
/* The callback below works identically whether the unit came from a
 * type annotation or from an inline suffix — no change needed.        */
static bool on_verified(void *ud, bvnr_event_t ev, bvnr_data_t *d)
{
    if (ev != ev_data) return true;

    char unit_str[128] = "no_unit";
    /* dimensionless: num_components==0 (BVN_UNIT_NONE) or
       num_components==1 with base==bu_none (BVN_UNIT_NO_PREFIX(bu_none)) */
    bool is_dim = (d->value_unit.num_components == 0) ||
                  (d->value_unit.num_components == 1 &&
                   d->value_unit.components[0].base == bu_none);
    if (!is_dim)
        bvn_unit_to_string(d->value_unit, unit_str, sizeof(unit_str));

    char val[256];
    size_t n = d->length < 255 ? d->length : 255;
    memcpy(val, d->data, n);
    val[n] = '\0';

    printf("value=%s  unit=%s\n", val, unit_str);
    return true;
}
```

---

## 7. Complete Write Example

```c
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include "bovnar.h"

/* Emit: .velocity = <float:64,m/s²> 9.81e+0; */
static bool write_velocity(bvnr_writer_t *w)
{
    value_type_spec_t vt = BVN_TYPE_FLOAT(64);
    value_unit_t      vu = BVN_UNIT_COMPOUND2(
                               bu_meter,  si_none, exp_linear,
                               bu_second, si_none, exp_neg_square);

    char valbuf[32];
    bvn_format_double(valbuf, sizeof(valbuf), 9.81, vt);

    bvnr_data_t d;

    d = (bvnr_data_t){ .data = "velocity", .length = 8 };
    if (!bvnr_write_event(w, ev_assignment_start, &d)) return false;

    if (!bvnr_write_type_annotation(w, vt, vu)) return false;

    d = (bvnr_data_t){
        .type       = token_is_number,
        .value_type = vt,
        .value_unit = vu,
        .data       = valbuf,
        .length     = (uint32_t)strlen(valbuf),
    };
    return bvnr_write_event(w, ev_data, &d);
}

int main(void)
{
    bvnr_writer_t *w = bvnr_writer_create();

    bvnr_sink_t sink;
    bvnr_sink_to_fd(&sink, STDOUT_FILENO);

    bvnr_write_flags_t opts = { 0 };
    if (!bvnr_open_write_sink(w, &sink, /*pretty=*/true, &opts)) {
        fputs("failed to open writer\n", stderr);
        bvnr_writer_destroy(w);
        return 1;
    }

    int ret = 0;
    if (!write_velocity(w) || !bvnr_write_finish(w)) {
        fprintf(stderr, "write error: %s\n",
                bvn_error_to_string(bvnr_writer_get_error(w)));
        ret = 1;
    }

    bvnr_writer_destroy(w);
    return ret;
}
/* Output: .velocity = <float:64,m/s²> 9.81e+0;
 * (bvn_format_double writes an exponent on every non-zero float -- see 2.11) */
```

---

## See also

- [Specification §16 — Reference API](03_bovnar_spec.md#16-reference-api) — the normative summary of these entry points
- [Unit & Currency Reference](05_bovnar_unit_system.md) — the unit model behind `bvn_parse_unit` and `want_unit`
- [Streaming, Framing & Multiplexing](10_bovnar_streaming.md) — protocols layered on this event API
- [Python Bindings](09_bovnar_python_bindings.md) — the same API from Python
- [Rust Bindings](14_bovnar_rust_bindings.md) — the same API from Rust
- [FAQ §12 — C API](02_bovnar_faq.md#12-c-api) — common questions about these functions

---

*End of Bovnar — Read & Write API (Bovnar spec 1.2).*

<!-- ==================== Python bindings (https://www.bovnar.io/doc/09_bovnar_python_bindings.md) ==================== -->

# Bovnar — Python Bindings

> **Spec version:** 1.2
> **Status:** Reference — the Python package as implemented
> **Scope:** Installation, the high-level and streaming APIs, the DOM, `Quantity`, and the NumPy and pint bridges.

Pure-`ctypes` Python bindings for the **Bovnar (BVNR)** typed serialisation
library (library 2.1.0, format spec 1.2).

No compiled extension module is needed — the bindings load `libbvnr.so`
through the standard `ctypes.CDLL` machinery the first time something needs
the C library; `import bovnar` itself never loads it.

---

## Table of Contents

1. [Requirements](#1-requirements)
2. [Installation](#2-installation)
3. [Library discovery](#3-library-discovery)
4. [Quick-start](#4-quick-start)
    - 4.1 [High-level API (`loads` / `dumps`)](#41-high-level-api-loads--dumps)
    - 4.2 [SAX-style streaming reader](#42-sax-style-streaming-reader)
    - 4.3 [Generator / iterator interface](#43-generator--iterator-interface)
    - 4.4 [DOM (random-access) API](#44-dom-random-access-api)
    - 4.5 [Low-level writer](#45-low-level-writer)
    - 4.6 [Streaming / framing (`bovnar.stream`)](#46-streaming--framing-bovnarstream)
    - 4.7 [Live streams: feeding in pieces](#47-live-streams-feeding-in-pieces)
    - 4.8 [Live streams: writing a message at a time](#48-live-streams-writing-a-message-at-a-time)
    - 4.9 [Canonical form](#49-canonical-form)
5. [Unit helpers](#5-unit-helpers)
    - 5.1 [The unit-profile notations (spec 1.2)](#51-the-unit-profile-notations-spec-12)
    - 5.2 [Extended unit functions](#52-extended-unit-functions)
    - 5.3 [`UnitFlags`](#53-unitflags)
    - 5.4 [`ValueUnitPrefix`](#54-valueunitprefix)
    - 5.5 [Inline unit suffix](#55-inline-unit-suffix)
    - 5.6 [`UnitPolicy` — validation and conversion without a callback](#56-unitpolicy--validation-and-conversion-without-a-callback)
    - 5.7 [Building a `ValueUnit`](#57-building-a-valueunit)
    - 5.8 [Validators and literal helpers](#58-validators-and-literal-helpers)
6. [`Quantity`](#6-quantity)
    - 6.1 [Construction](#61-construction)
    - 6.2 [Properties and methods](#62-properties-and-methods)
    - 6.3 [Lossless numeric access (`float_dec`, `float_fix`, `float:128`/`256`)](#63-lossless-numeric-access-float_dec-float_fix-float128256)
    - 6.4 [`dumps()` integration](#64-dumps-integration)
    - 6.5 [Civil time and calendars](#65-civil-time-and-calendars)
7. [`write_array` helper](#7-write_array-helper)
8. [pint bridge](#8-pint-bridge)
    - 8.1 [Prefixes ride in the unit, never the magnitude](#81-prefixes-ride-in-the-unit-never-the-magnitude)
    - 8.2 [Affine temperature units](#82-affine-temperature-units)
    - 8.3 [Validation](#83-validation)
    - 8.4 [Registry control: `build_registry`](#84-registry-control-build_registry)
    - 8.5 [Quantity kinds: `isolate_kinds`](#85-quantity-kinds-isolate_kinds)
    - 8.6 [Semantic caveats](#86-semantic-caveats)
9. [NumPy bridge](#9-numpy-bridge)
    - 9.1 [Reading: `to_numpy`](#91-reading-to_numpy)
    - 9.2 [Writing: `from_numpy` / `array_to_bvnr`](#92-writing-from_numpy--array_to_bvnr)
    - 9.3 [pint arrays](#93-pint-arrays)
10. [Currency helpers](#10-currency-helpers)
11. [`Reader` reference](#11-reader-reference)
    - 11.1 [Construction](#111-construction)
    - 11.2 [`read_mem`](#112-read_mem)
    - 11.3 [`read_fd`](#113-read_fd)
    - 11.4 [`read_file`](#114-read_file)
    - 11.5 [`iter_mem(data, *, verified_only, **options)`](#115-iter_memdata--verified_only-options)
    - 11.6 [Error-state properties](#116-error-state-properties)
    - 11.7 [`MAX_FILESIZE_BYTES`](#117-max_filesize_bytes)
    - 11.8 [`read_stream`](#118-read_stream)
12. [`Writer` reference](#12-writer-reference)
    - 12.1 [Construction class methods](#121-construction-class-methods)
    - 12.2 [Output retrieval](#122-output-retrieval)
    - 12.3 [Scalar write methods](#123-scalar-write-methods)
    - 12.4 [Extended integer writers](#124-extended-integer-writers)
    - 12.5 [Struct helpers](#125-struct-helpers)
    - 12.6 [Version directive](#126-version-directive)
    - 12.7 [Array helpers](#127-array-helpers)
    - 12.8 [Low-level `emit`](#128-low-level-emit)
    - 12.9 [Octets, embedded documents and mux streams](#129-octets-embedded-documents-and-mux-streams)
    - 12.10 [Observing the output: `on_event`](#1210-observing-the-output-on_event)
    - 12.11 [Plain values, datetimes and type annotations](#1211-plain-values-datetimes-and-type-annotations)
13. [DOM API](#13-dom-api)
    - 13.1 [`dom_parse(data) -> DomDoc`](#131-dom_parsedata---domdoc)
    - 13.2 [`DomDoc`](#132-domdoc)
    - 13.3 [`DomNode`](#133-domnode)
    - 13.4 [`DomType`](#134-domtype)
    - 13.5 [Building a tree (`NewNode`)](#135-building-a-tree-newnode)
14. [Running the test suite](#14-running-the-test-suite)
15. [Package layout](#15-package-layout)
16. [Error handling](#16-error-handling)
17. [FFI details](#17-ffi-details)
    - 17.1 [`ON_ERROR_FUNC` signature](#171-on_error_func-signature)
    - 17.2 [`BvnrWriteFlags` layout](#172-bvnrwriteflags-layout)
    - 17.3 [`write_string` behaviour](#173-write_string-behaviour)
    - 17.4 [`_resolve_unit` default](#174-_resolve_unit-default)
    - 17.5 [Signals inside a callback](#175-signals-inside-a-callback)
18. [`BaseUnit` enum](#18-baseunit-enum)
19. [Spec 1.1 additions](#19-spec-11-additions)

- [See also](#see-also)

---

## 1. Requirements

| Requirement | Notes |
|---|---|
| Python ≥ 3.10 | `dataclasses`, `enum.IntEnum`, union-type annotations (`X \| Y`) |
| `libbvnr.so` | Runtime only, and bundled inside the wheel — see *Library discovery* below |
| `numpy` ≥ 1.24 | **Optional** — only for the NumPy bridge (`pip install bovnar[numpy]`) |
| `pint` ≥ 0.22 | **Optional** — only for the pint bridge (`pip install bovnar[pint]`) |
| pytest ≥ 7 | Test suite only (`pip install bovnar[dev]`) |

`numpy` and `pint` are imported **lazily, on first use** of their respective
bridge functions — importing `bovnar` never requires either to be installed.

---

## 2. Installation

```bash
# From PyPI. The wheels bundle libbvnr, so there is nothing to build and no
# system dependency to install.
pip install bovnar
```

Optional extras pull in the dependencies for the bridges:

```bash
pip install "bovnar[numpy]"   # NumPy bridge
pip install "bovnar[pint]"    # pint bridge
pip install "bovnar[all]"     # both numpy and pint
```

From a source checkout instead. `pyproject.toml` lives at the **repository
root**, not under `python/` — the pure-Python package there is mapped into the
wheel by `[tool.scikit-build] wheel.packages`, so the editable install runs from
the root:

```bash
cmake -B build . && cmake --build build      # produces build/libbvnr.so
export LIBBOVNAR_PATH="$PWD/build/libbvnr.so"

pip install -e ".[dev]"                      # from the repository root
```

---

## 3. Library discovery

The bindings search for `libbvnr.so` in this order:

1. **`LIBBOVNAR_PATH`** — absolute path to the `.so` file, e.g.
   ```bash
   export LIBBOVNAR_PATH=/opt/bovnar/lib/libbvnr.so
   ```
2. **`LIBBOVNAR_DIR`** — directory that *contains* the `.so`, e.g.
   ```bash
   export LIBBOVNAR_DIR=/opt/bovnar/lib
   ```
3. **Bundled in the package** — the library sitting next to `_ffi.py` inside the
   installed `bovnar` package. This is the normal path after `pip install
   bovnar`: CMake installs `libbvnr.*` into the package directory when the wheel
   is built, so an installed wheel resolves here and needs no environment
   variable at all.
4. **`ctypes.util.find_library('bvnr')`** — standard `ldconfig` / `LD_LIBRARY_PATH` search.
5. **In-tree build paths** — `../../build/`, `../../build/release/`, `../../`,
   `.` (resolved relative to the `_ffi.py` file; useful when building Bovnar
   alongside the bindings from a mono-repo).

The search runs on first use, not at import: `import bovnar` succeeds without
the library, and the first call that needs it (`loads`, `Reader()`,
`parse_unit`, …) raises `BovnarLibraryNotFound` if none of the candidates
loads. The message lists every candidate tried, in order, with why each one
failed (`not found`, or the loader's error).

---

## 4. Quick-start

### 4.1 High-level API (`loads` / `dumps`)

```python
import bovnar

# Serialise a Python dict to BVNR bytes
data = {
    "sensor_id": 42,
    "temperature": -3.7,
    "label": "outdoor",
    "active": True,
    "payload": None,
}
bvnr_bytes = bovnar.dumps(data)

# Deserialise back to a Python dict
recovered = bovnar.loads(bvnr_bytes)
assert recovered["sensor_id"] == 42
```

`nan`, `inf` and `ninf` come back as `float`, under an integer type as well
(`<uint:8> nan`, spec §6.4), as `dom_parse()` gives them.

`loads` and `load` take the read options of `Reader.read_mem` as keywords
(§11.2): `bovnar.loads(data, max_file_size=1 << 20, strict_version=True,
max_string_length=4096)` reads untrusted input under tighter limits than the
defaults.

`loads` accepts an optional `typed` flag that wraps typed values in `Quantity`
objects instead of decoding them to native Python scalars.  This preserves the
original text representation, exact type width, numeral base, and physical unit
for lossless round-trips:

```python
bvnr = b".pressure = <float:32,Pa> 101325.0;"
doc  = bovnar.loads(bvnr, typed=True)
q    = doc["pressure"]     # Quantity('101325.0', FLOAT [Pa])
print(q.raw)               # '101325.0'
print(q.unit_str)        # 'Pa'

# dumps() accepts Quantity values — annotation and raw text are re-emitted as-is
out = bovnar.dumps(doc)
assert bovnar.loads(out, typed=True) == doc
```

Two `Quantity` objects are equal (and hash alike) when their literal, unit and
*effective* type agree: a width of 0 is 64 and a radix of 0 is 10, as the C
library reads them, so `<float:64,_10> 5.7`, `<float:0> 5.7` and a bare `5.7`
are one value. (The base field of `float_fix` and `datetime` is Q and the epoch
index, and is compared as stored.) `dumps()` writes the canonical annotation,
which is why the assertion above holds for a document that spelled the
defaults out. It also holds for a quoted numeric literal (`<float:64> "5.7"`)
and for a typed null (`<float:64> ;`, `[<uint:32>, <sint:64>]`): `dumps()`
writes the annotation either one needs to read back as itself.

`dumps()` starts with a 4 MiB write buffer and doubles it automatically on
overflow, up to 256 MiB.  The `cap` keyword argument that existed in earlier
versions is no longer accepted.

`bovnar.load(f)` and `bovnar.dump(obj, f)` are the two for a socket or a binary
file object, with the same options and the same result: `load` pulls the bytes
as the parse needs them and `dump` pushes the output through the writer's
64 KiB buffer, so neither holds the whole document
([`Reader.read_stream`](#118-read_stream), `Writer.to_stream`). The one
difference is a failure on the way out: what `dump` pushed before a refused
value has already reached the stream.

```python
import io

buf = io.BytesIO()
bovnar.dump({"sensor_id": 42, "label": "outdoor"}, buf)
buf.getvalue() == bovnar.dumps({"sensor_id": 42, "label": "outdoor"})   # → True
bovnar.load(io.BytesIO(buf.getvalue()))["label"]                        # → 'outdoor'
```

`dumps()` takes `None`, `bool`, `int` of any size, `float`, `str`, `Decimal`,
a terminating `Fraction`, `bytes` / `bytearray` / `memoryview` (written as an
octet stream — which is what `loads()` returns as `bytes`), `Quantity`, a
`dict` with `str` keys, and a `list` / `tuple` of those. NumPy scalars are
taken as the Python numbers they are.

> **`dumps()` writes what `loads()` reads — the streaming tier, not the
> document tier.** `loads()` checks the grammar and each value's own type,
> unit and range and stops there; `dumps()` is its inverse and stops in the
> same place, so `dumps(loads(x))` works for every `x` that `loads()` accepts.
> Three shapes are therefore written without complaint and then **refused by
> the document tier** (`dom_parse`, `bovnar validate`), where an array is
> homogeneous and rectangular:
>
> | Python value | Written as | Document tier says |
> |---|---|---|
> | `[1, "a"]`, `[True, 1]` — a list mixing kinds | `[1, "a"]` | `array_element_type_mismatch` |
> | `[[1], [2, 3]]` — a ragged list of lists | `[[1], [2, 3]]` | `array_row_size_mismatch` |
> | `[{"a": 1}, {"b": 2}]` — dicts that do not share their keys | `[{.a = <uint:64> 1;}, {.b = <uint:64> 2;}]` | `struct_shape_mismatch` |
>
> A list mixing `int` and `float`, or holding `None` beside values, is fine at
> both tiers. If the output has to pass `bovnar validate`, run it through
> `dom_parse()` before shipping it.
>
> What `dumps()` does refuse, with `BovnarArgumentError`, is what *neither*
> tier reads: a list whose elements carry different units, an integer wider
> than 64 bits in a list that cannot take the width annotation it needs
> (`[2**70, 1.5]`), a `Decimal` past the range of `float_dec:64`, a key
> that is not a `str`, a key that is not a Bovnar identifier (spec §4.2:
> `"a b"`, `"1a"`, `"a.b"`, `""`), a key longer than 255 bytes in UTF-8 (the
> reader's default `max_identifier_length`, which `loads()`, `dom_parse()`
> and `bovnar validate` read with), and a string — or a key — holding a
> control character other than TAB, LF, VT, FF and CR (`"\x00"`, `"\x7f"`)
> or a lone surrogate. The message names the key.

### 4.2 SAX-style streaming reader

The verified callback receives exactly **two** positional arguments: the event
code and the data payload.  There is no userdata/context argument — capture
external state via closure instead.

```python
from bovnar.reader import Reader
from bovnar import Event

def on_event(ev, data):
    if ev == Event.ASSIGNMENT_START:
        print("key:", data.raw_str())
    elif ev == Event.DATA:
        print("value bytes:", data.raw_bytes())
    return True   # returning False stops parsing (raises BovnarParseError)

with Reader() as r:
    r.read_mem(bvnr_bytes, on_verified=on_event)
```

The `BvnrData` payload passed to a callback exposes:

| Method | Returns | Description |
|---|---|---|
| `data.raw_str(encoding='utf-8')` | `str` | The token bytes decoded as text |
| `data.raw_bytes()` | `bytes` | The raw token bytes |
| `data.converted_str()` | `str \| None` | Exact `want_unit` conversion result as a positional string, or `None` if no conversion / it does not terminate in its base |
| `data.converted_in_base(base)` | `str \| None` | The exact conversion re-rendered in `base` |
| `data.converted_rational()` | `tuple[int, int] \| None` | The exact conversion as `(numerator, denominator)` |
| `data.frac_str()` | `str \| None` | Verbatim ISO sub-second digits of a `datetime` literal (spec 1.1), or `None` |
| `data.unit_source_str()` | `str \| None` | The unit exactly as the document spelled it (library 2.0), or `None` when the value carries no unit |

It also carries the `converted` (bool) and `value_type` / `value_unit` fields directly.

**A payload may outlive its callback.** The `BvnrData` a callback (or a
`want_unit` hook) receives is a copy the binding owns, not a view of the
reader's memory: the token bytes, the sub-second digits, the unit spelling and
the exact conversion (`conv.num`/`conv.den`) are all copied out before the
callback runs. A callback can keep the object, and every method in the table
above returns the same answer after the callback has returned and after the
reader is closed.

### 4.3 Generator / iterator interface

```python
from bovnar.reader import Reader

with Reader() as r:
    for payload in r.iter_mem(bvnr_bytes):
        print(payload.event, payload.text)
```

### 4.4 DOM (random-access) API

```python
import bovnar

dom_bytes = b"""
.sensor_id = <uint:64> 42;
.config = {
    .host = "localhost";
    .port = <uint:16> 8080;
};
.values = [1.5, 2.5, 4.0];
"""
doc = bovnar.dom_parse(dom_bytes)

# Top-level key lookup
node = doc["sensor_id"]
print(node.as_i64())        # → 42
print(node.value_type)      # ValueTypeSpec(family=UINT, width=64, base=0)

# Struct traversal
cfg = doc["config"]
host = cfg["host"].as_str()
host                        # → "localhost"

# Array access
arr = doc["values"]
for i in range(len(arr)):
    print(arr[i].as_float())    # 1.5, 2.5, 4.0

# Convert entire document to a plain Python dict (drops type/unit info)
d = doc.to_dict()
d["config"]["port"]         # → 8080
```

### 4.5 Low-level writer

```python
from bovnar.writer import Writer
from bovnar.enums  import BaseUnit, SIPrefix

with Writer.to_mem() as w:
    w.write_float("velocity", 9.81, width=64,
                  unit_si_base=BaseUnit.METER,
                  unit_si_prefix=SIPrefix.NONE)
    w.write_uint("count", 1024, width=32)

output: bytes = w.get_output()
```

### 4.6 Streaming / framing (`bovnar.stream`)

Bindings for the streaming layer (see
[Streaming, Framing & Multiplexing](10_bovnar_streaming.md) for the full
treatment):

```python
from bovnar import stream

# Multi-document record framing
blob = stream.dump_documents([{"id": 1}, {"id": 2}])   # list[dict] -> bytes
docs = stream.load_documents(blob)                      # bytes -> list[dict]
docs = stream.load_documents(blob, continue_past_failed=True)  # bad docs -> None
docs = stream.load_documents(blob, continue_past_failed=True,
                             return_errors=True)   # bad docs -> their error

# Octet multiplexing: (channel, payload) of any size, interleaved & reassembled
multiplexed = stream.mux_dump([(1, b"hello"), (42, b"world")])
messages    = stream.mux_load(multiplexed)             # [(1, b"hello"), (42, b"world")]

# Document-in-document
outer = stream.embed_document(bovnar.dumps({"v": 1}), key="payload")
inner = stream.parse_embedded(bovnar.loads(outer)["payload"])   # {"v": 1}
```

With `return_errors=True` a document that failed is the `BovnarParseError`
it failed with instead of `None`: its `code` is the document's error code and
its `index` the document's position in the stream (and in the list). The frame
reader reports the code alone, so the error carries no position.
`FrameFeed(parse=True, continue_past_failed=True, return_errors=True)` (§4.7)
hands out the same.

```python
bad = stream.frame_documents([b".a = 1;", b".a = @;", b".b = 2;"])
docs = stream.load_documents(bad, continue_past_failed=True,
                             return_errors=True)
docs[0]                              # → {'a': 1}
docs[1].code.name                    # → 'UNEXPECTED_INPUT_BYTE'
docs[1].index                        # → 1
```

`parse_embedded` raises `BovnarParseError` carrying the inner document's own
error code and position. `mux_dump` takes channels in the unsigned 64-bit
range and raises `BovnarArgumentError` for anything outside it. The `key` of
`mux_dump`, `MuxWriter` and `embed_document` is held to the rule `dumps()`
holds a key to (§4.1), with the same `BovnarArgumentError`; the `key` filter
of `mux_load` and `DemuxFeed` may not hold a NUL.

`mux_dump(..., preamble={...})` writes a dict of ordinary values before the
stream opens — what the stream is, read by a consumer before its first message
(§4.7). `load_documents` and `mux_load` also take a socket or a binary file
object in place of bytes and read it to its end, parsing as they pull;
`frame_documents`, `dump_documents`, `mux_dump` and `embed_document` take a
`file=` to write to instead of returning bytes. The results are those of the
in-memory calls.

`load_documents`, `mux_load` and `parse_embedded` take the read options of
`Reader.read_mem` (`bovnar.READ_OPTIONS`, §11.2) as keywords, and read under
them: `load_documents` each document, with `max_file_size` capping one
document and `max_document_size` one frame; `mux_load` the document around the
stream; `parse_embedded` the embedded document, as a document of its own, with
`max_file_size` capping its bytes. All three read with no total cap by default.
`max_document_size` and `max_message` are held to the rule of the read limits:
an int from 0 to 2**64 − 1, or `BovnarArgumentError`. Bytes handed to these
functions — a payload, a document, the input — are any bytes-like object (an
`mmap` is read whole, not from its position) or a `str`, taken as UTF-8;
anything else is a `BovnarArgumentError`.

To write a mux stream, or an embedded document, inside a document of any
shape — values after it, more than one stream — use the `Writer` (§12.9):
`mux_begin(key)`, `mux_send(channel, payload)`, `mux_end()` and
`embed_document(key, doc)`. `mux_dump` and `MuxWriter` end the document with
their stream.

### 4.7 Live streams: feeding in pieces

The functions above take the whole input, or read a stream to its end. For
input that arrives when it arrives — a non-blocking socket, an event loop, a
queue — three objects in `bovnar.stream` each keep one C reader for the whole
stream and are handed bytes in pieces of any size, cut anywhere:

| Object | Fed | Hands out |
|---|---|---|
| `DocumentFeed` | one document | `DocumentEvent(event, data)` per verified event, as a `Reader` callback receives it; with `values=True`, `Value(key, value)` per top-level value once it is complete as well (`events=False` for the values alone) |
| `DemuxFeed` | a document carrying a mux stream | `Message(channel, payload)` per message; with `values=True` the values before and after the stream, in document order; with `events=True` the document's events |
| `FrameFeed` | a `BVF1` frame stream | each document's bytes; with `parse=True` each document parsed, as `load_documents` parses it |

All three take the read options of `Reader.read_mem` (`bovnar.READ_OPTIONS`,
§11.2): `max_file_size`, the text, array, length and nesting caps,
`continue_on_error`, `strict_version`, `text_only` and the rest. Besides those:
`DocumentFeed` takes `typed`, `want_unit`, `on_error`, `on_unverified` and
`mirror`, and has the `recovery_count`, `skipped_bytes` and `declared_version`
of its reader (§11.6); `DemuxFeed(key=None,
max_message=0, typed=False, view=False)`, whose `recovery_count`,
`skipped_bytes` and `declared_version` are those of its reader, as on
`DocumentFeed`; `FrameFeed(parse=False, typed=False, continue_past_failed=False,
max_document_size=0, view=False, return_errors=False)`, where `typed`,
`continue_past_failed`, `return_errors` (§4.6) and the read options need
`parse=True` (each parsed document is read under them,
`max_file_size` capping one document) and `view` needs `parse=False`
(otherwise `BovnarArgumentError`). `DemuxFeed`'s `key=None` demultiplexes every
octet stream of the document, as `mux_load` does, while `mux_dump` and
`MuxWriter` write under `key="mux"`: a document with any other octet stream —
a `bytes` value in the preamble, say — needs `key=` on the reader.

`feed(data)` returns the object itself, which iterates over what those bytes
completed, and `finish()` says the input is over. Running out of input is never
an error; `finish()` raises `BovnarParseError` for a document or frame cut off
part-way. Every other error carries the code the batch function gives —
`loads`, `mux_load`, `load_documents` (a failed document's own code, a bad
magic `UNEXPECTED_INPUT_BYTE`, a frame over `max_document_size`
`VALUE_OUT_OF_RANGE`, a stream cut inside a frame `GOT_INCOMPLETE_BVNR_STREAM`).
`DocumentFeed` raises from the `feed()` whose bytes hit the error, with the
reader's position, and drops what that feed had completed; `DemuxFeed` and
`FrameFeed` raise from the iteration that reaches it, after the items before
it (an exception from one of your own callbacks comes out of the call it was
raised in). `finish()` raises at once. Each is a context manager that frees
its C object.

A stream that starts with metadata hands it over before the first message:

```python
from bovnar import stream

doc = stream.mux_dump([(1, b"frame-1"), (2, b"audio"), (1, b"frame-2")],
                      key="media", preamble={"meta": {"codec": "av1"}})
doc += b".end = true;\n"

items = []
with stream.DemuxFeed(key="media", values=True) as dm:
    for at in range(0, len(doc), 16):          # as a socket might deliver it
        items.extend(dm.feed(doc[at:at + 16]))
    items.extend(dm.finish())

items[0]                             # → ('meta', {'codec': 'av1'})
items[1]                             # → (1, b'frame-1')
items[-1]                            # → ('end', True)
isinstance(items[1], stream.Message) # → True
```

`Message`, `Value` and `DocumentEvent` are named tuples, so
`channel, payload = item` works, and `isinstance` tells them apart. The key
scope, `max_message` (0 selects `BVNR_MUX_DEFAULT_MAX`), the 4096-channel limit
and the error codes are those of `mux_load`.

A document that never ends is read a value at a time; a value handed over is
not kept, so memory stays flat:

```python
values = stream.DocumentFeed(events=False, values=True)
first  = list(values.feed(b'.reading = 1; .read'))
second = list(values.feed(b'ing = 2; .x = [1, 2];'))
last   = list(values.finish())
first                                # → [('reading', 1)]
second                               # → [('reading', 2)]
last                                 # → [('x', [1, 2])]
```

A scalar, a struct or an octet stream is complete at its end; an array at the
next key or the end of the document, because a further `/[…]` row would extend
it. An octet stream in a `DocumentFeed` is gathered whole before it is handed
over, so an endless one belongs in a `DemuxFeed` or is read by its events. The
text of a fed document is capped as `Reader`'s is, at 2 147 483 647 bytes
(`TEXT_DATA_TOO_LONG`, doc/10 §1), and an array at as many items: pass
`max_text_bytes=stream.BVNR_NO_LIMIT` (and `max_array_items`) for a document
that does not end. `DemuxFeed` and a parsing `FrameFeed` take both as well.
`DocumentFeed` takes no `on_verified`, which the feed's items stand in for;
with `values=True`, `typed=True` gives
`Quantity` values as `loads(typed=True)` does (`typed` without `values`, or
neither `events` nor `values`, is refused). `on_unverified(event, data)` sees
the raw lexer events of the bytes each `feed()` hands over, inside that call:
the events `Reader.read_mem(on_unverified=...)` delivers for the whole
document, wherever it was cut. `mirror` receives the bytes the reader
consumes, each piece once the reader has parsed it (§11.2). A `want_unit`,
`on_error` or `on_unverified` callback runs inside the C call, so it may not
close the feed or feed it again (`BovnarArgumentError`); an exception it
raises, or one the mirror raises, comes out of the `feed()` or `finish()` it
was raised in. One feed reads one document: make a new one for the next.

```python
raw = []
mirrored = bytearray()
feed = stream.DocumentFeed(events=False, values=True, mirror=mirrored,
                           on_unverified=lambda ev, d: raw.append(ev.name))
got = list(feed.feed(b'.a = ')) + list(feed.feed(b'1;')) + list(feed.finish())
got                                  # → [('a', 1)]
bytes(mirrored)                      # → b'.a = 1;'
raw[:2]                              # → ['STREAM_START', 'ASSIGNMENT_START']
```

```python
frames = stream.FrameFeed(parse=True)
blob = stream.dump_documents([{"id": 1}, {"id": 2}])
docs = list(frames.feed(blob[:30])) + list(frames.feed(blob[30:]))
frames.finish()
docs                                 # → [{'id': 1}, {'id': 2}]
frames.count                         # → 2
```

**The fed bytes.** `DemuxFeed` and the unparsed `FrameFeed` are drained with
`bvnr_demux_next` / `bvnr_frame_reader_next`, which point into the fed bytes:
those are kept until the iteration has run to its end, and feeding again
before then raises `BovnarArgumentError`. A `bytearray` is copied when fed, so
the usual `recv_into` loop may refill it at once.

**Memoryview payloads.** By default a payload is `bytes`. With `view=True`
(`DemuxFeed`, unparsed `FrameFeed`) it is a read-only `memoryview` of the
reader's own memory instead — the fed bytes for a message that lay whole in one
chunk, a reassembly buffer otherwise — which saves a copy per message and is
valid **only until the next call on the feed**: the next item, `feed()`,
`finish()` or `close()`. The feed releases the view then, so using it later
raises `ValueError` rather than reading memory that has been reused. Copy what
must be kept with `bytes(view)`. A buffer made from the view —
`memoryview(view)`, a slice of it, `numpy.frombuffer(view)`, a held export — is
not released with it, so while one is alive the next call raises
`BovnarArgumentError` and leaves the stream as it was, as often as it is
repeated; drop the buffer and the call goes ahead. Such a buffer also keeps the
feed's C memory alive: `close()` then closes the feed at once (it hands out
nothing more) but frees that memory only when the buffer goes. A message that
lay whole in the fed bytes is a view of those `bytes`, which a buffer made from
it keeps alive by itself, so that one never makes a call refuse.

```python
dm = stream.DemuxFeed(view=True)
it = dm.feed(stream.mux_dump([(1, b"abc"), (2, b"def")]))
first = next(it)
kept = bytes(first.payload)
second = next(it)                    # first.payload is released here
kept                                 # → b'abc'
try:
    first.payload[0]
except ValueError:
    pass                             # a released view refuses every use
dm.close()
```

**What it costs.** Every item crosses the ctypes boundary, so a message costs
microseconds in Python, not the nanoseconds of the C path (doc/10 §8.4).
`DemuxFeed` and the unparsed `FrameFeed` make one plain C call per item and no
callback. `DocumentFeed` and the parsing `FrameFeed` call back into Python once
per event. The batch `mux_load` does so only when its input is a socket or a
file object; an in-memory document is drained with `bvnr_demux_next`, as
`DemuxFeed` is. Measured with
`python/bench/bench_stream.py` (CPython 3.14, one core of an Intel i7-2700K
pinned with `taskset`, on a machine shared with other work: load average about
2, so take the ratios rather than the digits), per message, median of seven
runs:

| Path | 150 B | 30 KB | 400 KB |
|------|------:|------:|-------:|
| `mux_load` (batch, in memory) | 2.3 µs | 8.5 µs | 91 µs |
| `DemuxFeed`, whole buffer, `bytes` | 2.9 µs | 6.4 µs | 70 µs |
| `DemuxFeed`, whole buffer, `memoryview` | 4.5 µs | 4.7 µs | 51 µs |
| `DemuxFeed`, 1460 B pieces, `bytes` | 3.8 µs | 107 µs | 1.35 ms |
| `DemuxFeed`, 1460 B pieces, `memoryview` | 5.7 µs | 114 µs | 1.32 ms |

A small message costs a few microseconds whichever way it is read. A large one
fed whole is sliced out of the fed bytes, or viewed in place with `view=True`,
instead of copied out of a reassembly buffer. Fed in 1460-byte pieces — one TCP
segment's payload — the cost is the feeds, a few microseconds each, twenty of
them for a 30 KB message; read from a socket, take the largest `recv` the
transport offers. A `memoryview` saves the copy only where there is one to
save, from a few kilobytes up.

### 4.8 Live streams: writing a message at a time

`mux_dump(..., file=)` and `dump_documents(..., file=)` take every message or
document at once. The writer behind `mux_dump` (and `embed_document`) gathers
its output in a 64 KiB buffer, so a reader at the other end of a socket sees
nothing until it fills; `dump_documents` and `frame_documents` write each frame
as it is made and call the object's `flush()` once at the end.
Two objects in `bovnar.stream` write as the messages come instead, each handing
what it writes to the socket or file before it returns:

| Object | Writes | Per call |
|---|---|---|
| `MuxWriter(file, *, key="mux", preamble=None, flush_each=True)` | a document carrying a mux stream, as `mux_dump` writes it | `send(channel, payload)`: one message |
| `FrameWriter(file, *, flush_each=True)` | a `BVF1` frame stream, as `frame_documents` writes it | `write(document_bytes)` or `dump(obj)`: one framed document |

`file` is a socket (`sendall`) or a binary file object (`write`). `MuxWriter`
writes the preamble and opens the stream when it is made (handing them to the
object at once with `flush_each=True`), and with `flush_each=True` it calls
`bvnr_write_flush` after every send: the message goes
to the object, then the object's `flush()` runs when it has one, so a
`DemuxFeed` at the other end has message N before message N+1 is sent. With
`flush_each=False` it batches into the 64 KiB buffer, and `flush()` hands over
what has gathered. `close()` — or leaving the `with` block normally — ends the
stream and the document; the object stays open. The bytes are `mux_dump`'s for
the same messages and preamble, however often they were flushed:

```python
import io
from bovnar import stream

out = io.BytesIO()
with stream.MuxWriter(out, key="media",
                      preamble={"meta": {"codec": "av1"}}) as mux:
    first_len = len(out.getvalue())     # the preamble is out already
    mux.send(1, b"frame-1")
    sent_len = len(out.getvalue())      # and so is the first message
    mux.send(2, b"audio")

first_len > 0                           # → True
sent_len > first_len                    # → True
same = out.getvalue() == stream.mux_dump(
    [(1, b"frame-1"), (2, b"audio")], key="media",
    preamble={"meta": {"codec": "av1"}})
same                                    # → True
```

Over a socket, the reader is a `DemuxFeed` fed whatever `recv` returns
(§4.7):

```python
# check-doc-python: skip
with stream.MuxWriter(sock, key="media", preamble={"meta": meta}) as mux:
    for channel, frame in frames():
        mux.send(channel, frame)        # on the wire when send returns
```

`FrameWriter` frames each document as it is written and hands it to the object
at once, as frames are never batched; its `flush_each` only says whether the
object's `flush()` follows each document. A frame stream has no terminator, so
`close()` only flushes the object. `MuxWriter.bytes_written` and
`FrameWriter.count` say how much has been written:

```python
out = io.BytesIO()
with stream.FrameWriter(out) as frames:
    frames.dump({"id": 1})
    frames.write(b'.id = 2;')
stream.load_documents(out.getvalue())   # → [{'id': 1}, {'id': 2}]
frames.count                            # → 2
```

The object has to be blocking: a non-blocking socket is refused when the
writer is made, since one that took part of a buffer and then raised
`BlockingIOError` would leave the stream cut mid-chunk. (A socket with a
timeout is accepted; a timeout fails the writer as any other exception does.)
An exception the object raises (a closed socket, a full disk) comes out of the
call that pushed — the constructor, `send`, `write`, `flush` or `close` —
unchanged. The writer has failed then: `MuxWriter.close()` only releases it,
and a `FrameWriter`, which may have written half a frame, refuses further
documents. Leaving a `MuxWriter`'s `with` block by an exception does not end the
stream either, so a reader sees it cut off rather than complete. The object's
own `write()` and `flush()` may not call back into the writer that called them —
send, write, flush or close it, or for `Writer.to_stream` write a value: that
raises `BovnarArgumentError`, where the bytes written from there would land
inside those being handed over.
`Writer.to_stream(...).flush()` (§12.2) does the same for a document written
value by value.

**What it costs.** Measured with `python/bench/bench_stream.py` on the machine
of §4.7, into a socket-like object whose `sendall` takes the bytes, per
message, the median over its runs:

| Path | 150 B | 30 KB | 400 KB |
|------|------:|------:|-------:|
| `mux_dump` (batch, to `bytes`) | 7.6 µs | 29 µs | 287 µs |
| `MuxWriter`, `flush_each=False` | 2.2 µs | 10 µs | 114 µs |
| `MuxWriter`, flushed per message | 5.5 µs | 12 µs | 126 µs |

A flush per message costs a ctypes call and a Python call of the object's
`sendall` (with the copy of the bytes it is handed) for every message, against
one per 64 KiB batched: about 3 µs on a small message, which is the price of
the reader having it at once. Large messages are dominated by the copy either
way.

### 4.9 Canonical form

`bovnar.canonicalize(data, *, pretty=False, **options)` re-serialises a
document from its verified events through the C library's canonicalising
observer (`bvnr_canon_observer_*`, doc/08 §3.6) and returns the bytes;
`bovnar.canonicalize_to(data, out, *, pretty=False, **options)` writes them to
`out` — a writable binary file object, a socket or a `bytearray` — as they are
made, in 64 KiB pieces. `data` is bytes, or a socket or binary file object read
to its end as the parse pulls it, so neither the input nor the output need be
held whole.

```python
import bovnar

doc = b'.a = 1 ;  # one\n.b = { .c = "x"; };'
canon = bovnar.canonicalize(doc)
canon                                # → b'.a=<uint:64,_10,no_unit>1;.b={.c=<utf8>"x";};'
bovnar.canonicalize(canon) == canon  # → True
bovnar.canonicalize(b'.b={.c="x";};.a=1; ') == bovnar.canonicalize(b'.a=1;.b={.c="x";};')  # → False
```

It is the form `bovnar events -d` prints (`-p` for `pretty=True`): the
compact one is a single line with no whitespace outside literals, each value
written with the annotation the reader gives it (`1` as
`<uint:64,_10,no_unit>1`). Two documents that differ only in layout, comments
and the spelling of separators canonicalise to the same bytes, and
canonicalising the output again gives it back unchanged; the order of the keys
is kept, so documents that differ in it stay apart. Units are written as the
document spelled them: this is not `bovnar pretty-print --canonical`, which
writes through a writer under `UnitFlags.REDUCE | UnitFlags.NUM_CANONICAL`
and folds them to one form. A leading `#!bovnar M.N` directive is kept — for a
stream it is found in the first bytes pulled, before the reader sees them.

The keywords are the read options of `Reader.read_mem` (`bovnar.READ_OPTIONS`,
§11.2), all `0` / `False` by default. A document the reader refuses raises
`BovnarParseError` with the reader's code and position. Under
`continue_on_error` a document the reader had to recover from raises it too,
with the first error recovered from: the observer writes what it is fed, and
the statement a recovery leaves without its value would make the output
unreadable. An exception the input or `out` raises comes out unchanged; after
an error `out` may hold the start of the output.

The observer's C function is installed as the reader's `on_verified` itself,
with the observer as its userdata, so no Python runs per event: Python is
called for the output a 64 KiB piece at a time, and for the input of a
stream a pull at a time.

---

## 5. Unit helpers

```python
import bovnar

# Parse a unit string into a ValueUnit struct
vu = bovnar.parse_unit("k~g·m/s²")

# Convert a ValueUnit back to its canonical string
s = bovnar.unit_to_str(vu)     # → "k~g·m/s²"

# Scalar SI/IEC PREFIX factor for a unit string — the prefix only, never the
# base unit's own factor. Use units.unit_to_si_factor() for the full SI scale.
f = bovnar.unit_factor("M~Hz") # → 1_000_000.0
f = bovnar.unit_factor("in")   # → 1.0  (NOT 0.0254 — the inch has no prefix)
f = bovnar.unit_factor("h")    # → 1.0  (NOT 3600.0)
```

### 5.1 The unit-profile notations (spec 1.2)

**These notations are spec 1.2**: in a document they need a `#!bovnar 1.2` directive, while the API entry points below have no document and take them unconditionally. `parse_unit` takes a profile notation as readily as the native one, and returns
the same `ValueUnit` either way — so everything else in this chapter works on the
result unchanged. **Seven** namespaces are accepted — `ucum:`, `unece:`, `qudt:`,
`qudt-qk:`, `udunits:`, `om:` and `cf:` — but each is a *compile-time* switch, so
which of them a given build carries is a question to ask rather than assume; see
`available_profiles()` below. Helpers cover what a caller needs around them:

```python
import bovnar

vu = bovnar.parse_unit("ucum:mm[Hg]")
bovnar.unit_to_str(vu)              # → "mmHg"   — the native canonical form
bovnar.unit_to_ucum(vu)             # → "mm[Hg]" — back to a UCUM code
bovnar.units_compatible(vu, bovnar.parse_unit("k~Pa"))   # → True

# Which profiles THIS build carries. Each namespace is a compile-time switch,
# so a wheel may have been built without some of them. parse_unit raises the
# same BovnarArgumentError("Invalid unit string: ...") for an absent namespace
# as for a bad unit; unit_error_code() below is what tells the two apart (49
# against 32).
bovnar.available_profiles()
# → ('ucum', 'unece', 'qudt', 'qudt-qk', 'udunits', 'om', 'cf')

# The same unit in other vocabularies — all one ValueUnit
kg = bovnar.parse_unit("k~g")
bovnar.unit_to_profile("ucum",    kg)   # → "kg"
bovnar.unit_to_profile("unece",   kg)   # → "KGM"
bovnar.unit_to_profile("qudt",    kg)   # → "KiloGM"
bovnar.unit_to_profile("udunits", kg)   # → "kg"

iu = bovnar.parse_unit("ucum:[IU]/mL")
bovnar.unit_is_profile_only(iu)     # → True  — no native spelling exists
bovnar.unit_to_str(iu)              # → "ucum:[IU].mL-1"

box = bovnar.parse_unit("unece:XBX")
bovnar.unit_is_profile_only(box)    # → True  — a countable package
bovnar.unit_to_str(box)             # → "unece:XBX" — in its OWN namespace

bovnar.unit_error_code("ucum:B[SPL]")   # → 50 (ErrorCode.UNIT_PROFILE_UNSUPPORTED)
bovnar.unit_error_code("udunits:days since 1970-01-01")
                                        # → 50 — valid UDUNITS, not carryable
bovnar.unit_error_code("nosuch:m")      # → 49 (ErrorCode.UNIT_PROFILE_UNKNOWN)
bovnar.unit_error_code("m/s")           # → 0  (ErrorCode.NONE — it parses)
```

`unit_to_profile` and `unit_to_ucum` raise rather than inventing a code, in three
situations. A native unit outside that vocabulary's transliteration table has no
form in it — the Old German units, the water-hardness degrees, the turbidity
kinds and every currency. An opaque unit belonging to a *different* profile has
none either: `[IU]` is UCUM's, so `unit_to_profile("unece", …)` refuses it. And a
**flat** vocabulary (`unece`, `qudt`, `qudt-qk`, `om`) can spell only a single
component — no product, quotient or exponent, though a prefixed one is fine
(`k~g` is `unece:KGM`) — because a flat code is one whole token with no operator to
build it out of — so `k~m/h` has no UNECE form even though `unece:KMH` parses to
exactly it.

In a **document** the notation additionally requires a `#!bovnar 1.2` directive;
without it `loads` raises with `unit_illegal`. `parse_unit` has no document and
therefore no declared version, so it accepts the notation unconditionally.

See [Unit Profiles](11_bovnar_unit_profiles.md) for the transliteration tables
and the codes that have no representation.

### 5.2 Extended unit functions

The following functions operate on `ValueUnit` objects and are available both
from the top-level `bovnar` namespace and from `bovnar.units`. Each of them —
and `unit_to_str`, `unit_to_profile`, `unit_to_ucum`, `unit_is_profile_only`,
the pint and NumPy bridges, `write_array(vu=)` and a `want_unit` callback's
return value — takes a `Unit` (what `Quantity.unit` and `Unit.parse()` hand
out) wherever it takes a `ValueUnit`, so `unit_to_str(q.unit)` needs no
`.raw`.

```python
from bovnar import (
    unit_valid, unit_prefix_factor, unit_prefix_exponent,
    prefix_unit_valid,
    unit_to_si_factor, units_compatible, units_equal,
    unit_convert_factor, unit_dimension_vector,
    unit_reduce, unit_to_str_ex,
    exponent_to_int, int_to_exponent,
    convert_value,
    UnitFlags,
)

# Validate a ValueUnit struct
ok = unit_valid(vu)                   # True when vu is structurally valid

# Prefix scale factor (SI or IEC) for a ValueUnit
f = unit_prefix_factor(vu)            # e.g. 1000.0 for k~m, 2**30 for Gi~B

# Prefix exponent (base-10 for SI, base-2 for IEC)
e = unit_prefix_exponent(vu)          # e.g. 3 for kilo, -3 for milli, 30 for gibi

# Validate a prefix for a base unit (IEC prefixes are only valid on bit/byte)
from bovnar import ValueUnitPrefix, IECPrefix, BaseUnit
p = ValueUnitPrefix.make_iec(IECPrefix.GIBI)
ok = prefix_unit_valid(p, BaseUnit.BYTE)   # True
ok = prefix_unit_valid(p, BaseUnit.METER)  # False

# Full SI conversion including affine terms (e.g. Celsius → Kelvin)
conv = unit_to_si_factor(vu)
# conv.factor, conv.is_affine, conv.affine_offset

# Check convertibility: same SI dimension vector AND same quantity kinds
ok = units_compatible(vu_a, vu_b)     # b vs B is False — the dimensions agree,
                                      # the kinds do not

# Conversion factor between two compatible units
c = unit_convert_factor(vu_from, vu_to)
# c.factor, c.requires_affine, c.ok — ok is False (and factor 0.0) for an
# affine conversion whose offsets do not cancel, °C → K: use convert_value

# 7-element SI dimension exponent vector [m, kg, s, A, K, mol, cd]
dims = unit_dimension_vector(vu)       # e.g. [1, 0, -1, 0, 0, 0, 0] for m/s

# Fold every prefix out and combine repeated bases. NOT the named-SI collapse:
# unit_reduce(parse_unit("k~g·m/s²")).unit is "m·g/s²", and it is
# unit_to_str_ex(..., UnitFlags.REDUCE) that gives "N". See the warning in §5.3
# before applying r.scale to a value you are also going to serialise.
r = unit_reduce(vu)                   # r.unit, r.scale

# Convert a scalar value between units (handles affine conversions)
kelvin = convert_value(25.0, vu_celsius, vu_kelvin)

# ... or EXACTLY, as the reader's want_unit does: (Fraction, exact). exact is
# False for an irrational factor (° to rad); a float is refused, pass Fraction(x)
from fractions import Fraction
from bovnar import convert_rational
v, exact = convert_rational(42, parse_unit("k~m/h"), parse_unit("m/s"))
# v == Fraction(35, 3), exact is True

# Serialise with formatting options (see UnitFlags below)
s = unit_to_str_ex(vu, UnitFlags.ASCII_EXP)   # use ^N instead of Unicode superscripts
s = unit_to_str_ex(vu, UnitFlags.REDUCE)      # reduce first — but see the warning below
s = unit_to_str_ex(vu, UnitFlags.REDUCE | UnitFlags.ASCII_EXP)

# Exponent enum ↔ integer conversions
n   = exponent_to_int(Exponent.NEG_SQUARE)  # → -2
exp = int_to_exponent(-2)                    # → Exponent.NEG_SQUARE
```

`SI_DIM_NAMES` is the ordered tuple `('m', 'kg', 's', 'A', 'K', 'mol', 'cd')`
— the index positions used by `unit_dimension_vector`.

### 5.3 `UnitFlags`

```python
from bovnar import UnitFlags   # also from bovnar.units

UnitFlags.NONE      # 0 — no special formatting
UnitFlags.REDUCE    # reduce to a canonical named SI unit before serialising
UnitFlags.ASCII_EXP # use ^N exponent notation instead of Unicode superscripts
UnitFlags.NUM_CANONICAL  # writer only: a float REDUCE rescaled keeps float shape
```

The same flags configure a **writer**: `Writer.to_mem(unit_flags=UnitFlags.REDUCE)`
(and `to_fd` / `to_file`) rewrites each unit as it is written *and* rescales the
value with it — `2.5 k~g` goes out as `<float:64,g> 2500`. `NUM_CANONICAL` is
meaningful only there: it appends `.0` to a rescaled float that came out as an
integer string, and writes each annotation in one form (the width written out,
a default `_10` base and `no_unit` left out), so the canonical document mode
lands on one spelling.

> **`UnitFlags.REDUCE` returns the reduced unit, not a rescaled value.** Reduction
> folds every prefix out, so `unit_to_str_ex(parse_unit("k~g"), UnitFlags.REDUCE)`
> is `"g"` — a string denoting a quantity 1000× smaller than the unit you passed.
> Moving the value with it is your job. (The writer does it for you; nothing else
> does.)
>
> **Do not use `unit_reduce(vu).scale` for that.** It is the scale to the *fully
> reduced* unit, and this function does not always emit the fully reduced unit:
> where the reduction lands on a named SI unit the formatter re-attaches the
> prefix. `k~N` comes back `"k~N"` with nothing to rescale while
> `unit_reduce` still reports `1000.0`, and `k~g` comes back `"g"` where the
> `1000.0` must be applied. The two are indistinguishable from outside — both a
> lone unit carrying a kilo prefix — so take the scale from the unit that is
> actually emitted:
>
> ```python
> emitted_str = unit_to_str_ex(vu, UnitFlags.REDUCE)
> emitted     = parse_unit(emitted_str)
> value      *= convert_value(1.0, vu, emitted)   # 1.0 for k~N, 1000.0 for k~g
> ```
>
> The collapse never substitutes one named unit for another, so a `Sv` stays a
> `Sv` rather than becoming a `Gy`. A lone base at an exponent other than 1 does
> still collapse (`s⁻¹` → `"Hz"`, `m~s⁻¹` → `"k~Hz"`), and a reduction whose summed
> exponent leaves the `±100` range (`BVN_EXPONENT_MIN`/`MAX`) raises
> `BovnarArgumentError` instead of returning a unit that lost a dimension —
> `m⁵⁰·m⁵⁰` reduces to `m¹⁰⁰`, `m⁵⁰·m⁵¹` raises.

`UnitFlags` is an `IntFlag` and its values may be OR-combined:

```python
s = unit_to_str_ex(vu, UnitFlags.REDUCE | UnitFlags.ASCII_EXP)
```

### 5.4 `ValueUnitPrefix`

`ValueUnitPrefix` is the public mirror of the C `value_unit_prefix_t` struct.
It can be constructed with class methods or extracted from a `ValueUnitComponent`:

```python
from bovnar import ValueUnitPrefix, SIPrefix, IECPrefix

p_si  = ValueUnitPrefix.make_si(SIPrefix.KILO)
p_iec = ValueUnitPrefix.make_iec(IECPrefix.GIBI)

vu   = bovnar.parse_unit("Gi~B")
comp = vu.components[0]
p    = comp.prefix   # ValueUnitPrefix extracted from a component
```

### 5.5 Inline unit suffix

In addition to the unit embedded in a type annotation (`<float:64,m/s>`), the
Bovnar format supports an **inline unit suffix** placed directly after a scalar
value, before the terminating `;`:

```bovnar
.speed = 9.81 m/s;           # inline suffix, no annotation
.mass  = <float:64> 70.5 k~g;# annotation without unit, inline adopted
.dist  = <float:64,m> 1.5 m; # annotation and inline both say 'm' — valid
```

From the Python layer, inline and annotation units are **identical**: both
reach the application as `data.value_unit` inside the `Event.DATA` payload.
No extra code is required to consume inline units.

The validator raises `ErrorCode.UNIT_MISMATCH` (38 / `BovnarParseError`) when
an inline suffix is present and a type-annotation unit is also present but the
two do not resolve to the same `value_unit_t`. Inline unit suffixes inside
array elements always raise `ErrorCode.UNEXPECTED_INPUT_BYTE`.

### 5.6 `UnitPolicy` — validation and conversion without a callback

`Reader.set_unit_policy` states what the document must contain and what unit
values should arrive in. Everything is unit **text**, so unlike `want_unit` it
costs no per-value trampoline across the FFI boundary — the C side parses the
strings once, when the policy is set.

```python
from bovnar import Reader, UnitPolicy

with Reader() as r:
    r.set_unit_policy(UnitPolicy(
        targets=["m/s", "°C"],      # convert to the first one that fits
        normalise_si=True,          # ...and everything else to SI base units
        leave_inexact=True,         # step over what cannot be exact
        require_unit=True,          # reject any bare numeric value
    ))
    r.read_file("sensors.bvnr", on_verified=handler)
```

A policy survives re-reading the same reader on another document — it describes
the consumer, not the document — and `set_unit_policy(None)` clears it. Every
unit string is parsed by the call, so a typo raises `BovnarArgumentError`
before a byte is read, and a rejected policy leaves the previous one in force.

**Conversion.** Each numeric value takes the first target it can validly convert
to, so order is significant: `["m", "k~m"]` never selects `k~m`. A value that
matches nothing, or that is already in the unit a target names, arrives
untouched — `data.converted` is what tells the two apart. Each target may carry
its own output base as `("m", 16)`.

`normalise_si` catches whatever the targets did not, delivering it in coherent
SI base units with prefixes folded out. Currencies and every **dimensionless**
unit (`%`, `ppm`, `dB`, `pH`, `rad`, `°`) are left as written rather than
reinterpreted.

**A value with no unit only ever matches a target that is itself `no_unit`.** A
bare number is dimensionally compatible with `%` and `ppm`, so without that
fence a policy naming `"%"` would deliver `0.25` as `25`.

**Exactness.** Nothing approximate is ever delivered. `leave_inexact=True` hands
over a value the conversion cannot deliver exactly — an irrational factor, a
non-terminating expansion (`42 km/h` is `35/3 m/s`), an exponent past the work
limit — in its native unit instead of raising. It applies only to policy-chosen
targets; a `want_unit` hook keeps its strict all-or-nothing contract.

**Validation.** `require_unit` rejects a document containing any bare numeric
value; `require_dimension_of=["m"]` requires every numeric value to be validly
convertible to at least one listed unit. Both are evaluated on the unit the
**document** wrote, before any conversion, and both raise
`ErrorCode.UNIT_MISMATCH`.

**Per-field rules** name one field by its key path, and are consulted before
everything else:

```python
from bovnar import Reader, UnitPolicy, UnitRule, dom_parse

policy = UnitPolicy(rules=[
    UnitRule(".inlet.temperature", "°C"),                  # convert this field
    UnitRule(".inlet.*", "m", convert=False),              # assert the subtree
])
```

A path ending in ``".*"`` names a subtree at any depth, and a prefix only
matches at a component boundary (``".in.*"`` does not claim ``".inlet.a"``).
Unlike a whole-document target, a rule is an **assertion**: a value it cannot be
applied to raises ``UNIT_MISMATCH``, because silence would defeat the point of
naming the field. ``convert=False`` asserts without converting.

**The DOM takes the same policy**, so a random-access consumer gets both halves:

```python
doc = dom_parse(text, UnitPolicy(rules=[UnitRule(".inlet.temperature", "°C")]))
doc.lookup(".inlet.temperature").as_float()    # 100.0, from a document in °F
```

A value the policy converted is STORED converted — digits, unit and base — and
an integer that converts to a fraction (5 g in kilograms is 0.005) is stored as
a float. A policy the library refuses raises `BovnarArgumentError` — the same
error, naming the same entry, as `Reader.set_unit_policy` — before anything is
parsed, rather than a parse error, so a mistake in the policy is never mistaken
for a fault in the document.

**The writer takes the same policy, validation half only.** A reader can only
reject a document somebody already wrote; `Writer.set_unit_policy` is what stops
a bare number reaching the file:

```python
from bovnar import Writer, UnitPolicy

w = Writer.to_mem()
w.set_unit_policy(UnitPolicy(require_unit=True, require_dimension_of=["m"]))
w.write_float("tank_level", 12.0, unit_str="in")   # a length — written
w.write_float("spare", 0.25)                        # BovnarWriteError,
                                                    # code UNIT_MISMATCH
```

A policy carrying `targets`, `normalise_si`, `base` or `leave_inexact` raises
`BovnarArgumentError` there rather than being half-honoured: write-side unit
rewriting is `UnitFlags.REDUCE`'s job — open the writer with
`Writer.to_mem(unit_flags=UnitFlags.REDUCE)` — and two rewriting modes with
different rules about exactness is how a document ends up meaning two things.

Module-level helpers back the same decisions outside the reader:

```python
from bovnar import (units_equal, units_compatible, units_convertible,
                    unit_si_normal_form, parse_unit)

# units_compatible is the wrong screen for a conversion target: a currency
# carries no dimension, so it is reported incompatible with itself.
units_compatible (parse_unit("k~$USD"), parse_unit("$USD"))   # → False
units_convertible(parse_unit("k~$USD"), parse_unit("$USD"))   # → True

unit_si_normal_form(parse_unit("in"))   # → m
unit_si_normal_form(parse_unit("g"))    # → k~g  (the SI base unit for mass)
unit_si_normal_form(parse_unit("%"))    # → None (dimensionless)
unit_si_normal_form(parse_unit("lm"))   # → None (carries the steradian's kind)
```

**Three questions, in decreasing strictness.** `units_equal` asks whether the two
are the *same* unit, `units_compatible` whether their dimensions and quantity
kinds line up, `units_convertible` whether a conversion can actually be
performed. Only the last is the right screen for a conversion target.

```python
units_equal(parse_unit("m"), parse_unit("m"))       # → True
units_equal(parse_unit("m"), parse_unit("k~m"))     # → False — convertible, not equal
units_equal(parse_unit("N"), parse_unit("k~g·m/s²"))  # → False — same SI form
units_equal(parse_unit("mmHg"), parse_unit("ucum:mm[Hg]"))   # → True
```

`units_equal` is also what `==` means between two units, whether they are
`ValueUnit`s or `Unit`s, and the two hash alike so they share a dict:

```python
parse_unit("m") == parse_unit("m")                  # → True
```

The hash follows the equality in the one place the two could part: a
component with **no prefix** is the same component whether its prefix system
says SI or IEC, so `parse_unit("B")` and `make_unit_iec(BaseUnit.BYTE)` are
equal *and* one dict key.

A `Unit` also compares equal to a **string** that parses to it
(`q.unit == "k~m"`). That is a convenience for comparison only and is **not
hash-compatible** — `"km"`, `"k~m"` and `"ucum:km"` are three strings with
three hashes and one unit — so `Unit.parse("m") in {"m"}` is `False`. Key a
dict or set on `Unit` objects (or on `str(unit)`), never on a mix of the two.

`Unit.convert_factor(other)` and `Unit.factor` return a bare number, so they
**raise** `BovnarArgumentError` where there is no such number: an affine pair
(`°C` → `K`) has no multiplicative factor and an affine unit no SI factor.
Use `convert_value()` there, or `unit_to_si_factor()` for the scale and the
offset.

That comparison used to be **False**. `ValueUnit` is a `ctypes.Structure`, which
defines no `__eq__`, so `==` fell back to identity — see the note in
[§5.7](#57-building-a-valueunit) on why the raw struct is worth knowing about at
all. `ValueTypeSpec` carried the same gap and compares by value now too, so
`node.value_type == other.value_type` answers the question it looks like it is
answering.

### 5.7 Building a `ValueUnit`

`parse_unit` builds a unit from text. To build one programmatically — without going
through the notation — use the `make_unit_*` constructors, exported from the top-level
`bovnar` namespace and from `bovnar.structs`.

```python
from bovnar import (make_unit_si, make_unit_iec, make_unit_compound,
                    make_unit_none, make_unit_dimensionless,
                    BaseUnit, SIPrefix, IECPrefix, Exponent, unit_to_str)

make_unit_si(BaseUnit.GRAM, SIPrefix.KILO)              # k~g
make_unit_si(BaseUnit.METER, exp=Exponent.SQUARE)       # m²
make_unit_iec(BaseUnit.BYTE, IECPrefix.GIBI)            # Gi~B

make_unit_compound([                                    # k~g·m/s²
    {"base": BaseUnit.GRAM,   "si_prefix": SIPrefix.KILO, "exp": Exponent.LINEAR},
    {"base": BaseUnit.METER,                             "exp": Exponent.LINEAR},
    {"base": BaseUnit.SECOND,                            "exp": Exponent.NEG_SQUARE},
])
```

`prefix` and `exp` default to none and linear, so a plain base unit is
`make_unit_si(BaseUnit.METER)`. A `make_unit_compound` component accepts the keys
`base`, `exp`, `si_prefix` and `iec_prefix` — note that the compound form spells the
prefix key out, where the scalar constructors take it positionally.

**`make_unit_none()` and `make_unit_dimensionless()` are not the same thing**, even
though both render as `no_unit`. The first is a unit struct with no components at all —
a value carrying no unit. The second is one component whose base is `NONE` — a value
carrying `no_unit` explicitly. That is exactly the pair `UnitPolicy(require_unit=True)`
refuses together (§5.6), and the distinction survives a round trip:

```python
unit_to_str(make_unit_none())            # 'no_unit'
unit_to_str(make_unit_dimensionless())   # 'no_unit'
bytes(make_unit_none()) != bytes(make_unit_dimensionless())   # True
```

### 5.8 Validators and literal helpers

`bovnar.literals` binds the C library's checks and literal helpers
(`bovnar.h`): the tests the writer applies to keys, symbols, references and
numbers, the integer and float parsers and formatters behind it, and the parser
of a type annotation. Each function is the C function of the same name without
its `bvn_` prefix (`escape_repl_table` is `bvn_get_escape_repl_table`), and
answers as it does. Text holding NUL is none of the things they check: a
validator answers `False` for it, a parser raises — except `validate_string`,
which takes bytes and checks only that they are UTF-8, so NUL passes.

**Type annotations.** `parse_type_annotation(text)` parses the text between the
angle brackets, as the reader does, into a frozen `TypeAnnotation` of
`value_type` (a `ValueTypeSpec`), `unit` (a `ValueUnit`, with no components when
there is no unit or it is spelled `no_unit`) and `unit_source` (the unit
parameter as written, `''` when there is none):

```python
from bovnar import literals, BovnarParseError

a = literals.parse_type_annotation("uint:32,_16,k~g")
a.value_type.width, a.value_type.base     # → (32, 16)
a.unit                                    # → "k~g"
a.unit_source                             # → "k~g"
literals.parse_type_annotation("float:64,ucum:mL{total}").unit_source   # → "ucum:mL{total}"

try:
    literals.parse_type_annotation("uint:8,_16,_16")    # a base given twice
except BovnarParseError as e:
    refused = e.code
refused.name                              # → "ILLEGAL_VALUE_TYPE"
```

A refusal is a `BovnarParseError` whose `code` is the one the reader gives the
same annotation in a document, so it tells the C function's three flags apart:

| `code` | C flags | Cause |
|---|---|---|
| `ILLEGAL_VALUE_TYPE` | `type_ok` false | An unknown family, an empty or repeated parameter, a parameter the family does not take (a base on `float_fix`, a width on `bool`), a width or base out of range |
| `UNIT_TOO_LONG` | `unit_ok` false, `unit_too_long` true | A unit parameter over 255 bytes |
| `UNIT_ILLEGAL`, `UNIT_PROFILE_UNKNOWN`, `UNIT_PROFILE_UNSUPPORTED` | `unit_ok` false | A unit parameter that is not a unit this build knows; the code is the one `unit_error_code()` gives it (§5.1) |

The reader's version gates are not applied: `datetime` (spec 1.1) and a profile
unit (spec 1.2) parse here whatever version a document declares. A `datetime`'s
epoch is no unit: its index is in `value_type.base` (`datetime_epoch_index`,
§19), as a `float_fix`'s q is.

**Validators.** Each returns a `bool`:

| Function | `True` for |
|---|---|
| `validate_identifier(s)` | A key: an ASCII letter, `_` or a character from U+00C0 on, then no space, control character, U+0080 to U+00BF, or punctuation spec §4.2 forbids (`-` and `+` are allowed). No length limit: the reader's `max_identifier_length` (255) is separate |
| `validate_symbol(s)` | A symbol, which is spelled as a key is |
| `validate_reference(s)` | A path such as `.a.b[0]`, without the `&`: identifier segments, each with optional `[digits]` indexes |
| `validate_number(s)` | A decimal number: an optional `-`, digits with at most one `.`, an optional `e`/`E` exponent. No `+`, and not `inf` |
| `is_special_number_string(s)` | Exactly `inf`, `ninf` or `nan` |
| `validate_digits_for_base(s, base)` | An integer literal: an optional `-` and digits of `base` (no sign at all in bases 64 and 85, where `+` and `-` are digits) |
| `validate_number_in_base(s, base)` | A quoted float literal: either sign, digits of `base` with at most one `.`, and an exponent marked `e` (bases up to 14) or `p` (the power-of-two bases up to 16) |
| `validate_uint_range(s, width, base=10)` / `validate_sint_range(s, width, base=10)` | A literal that fits `width` bits (0: any width); `inf`, `ninf` and `nan` pass |
| `validate_string(data)` | Bytes that are well-formed UTF-8 |

```python
literals.validate_identifier("max-speed")      # → True
literals.validate_identifier("max.speed")      # → False
literals.validate_reference(".axis[2].unit")   # → True
literals.validate_number("+1")                 # → False
literals.validate_digits_for_base("-ff", 16)   # → True
literals.validate_uint_range("256", 8)         # → False
literals.validate_sint_range("-80", 8, 16)     # → True
```

Two of them are narrower than their names. `validate_digits_for_base` does not
check the base against the ones a type may declare: in base 1 the one digit is
`0`. And `validate_string` is the same test as `data.decode('utf-8')`; NUL and
the other control bytes the writer refuses in a string pass it. The range
checks are meant for text the digit check has passed: they refuse an empty
literal, and in base 10 anything but digits after an optional sign
(`validate_uint_range(" 5", 8)` is `False`), but they take a leading `+`.

**Digits.** `char_to_digit(ch, base)` is the value of one digit, `None` when it
is no digit of `base`: up to base 36 a letter's case does not matter, above it
`A` to `Z` are 36 to 61, and bases 64 and 85 use the Base64 and Ascii85
alphabets. `min_digits_for_type(vt)` is how many digits of the type's base the
largest value of an integer type of up to 64 bits takes; 0 for a width of 0 or
above 64, and a float type is answered as if it were an unsigned integer of its
width.

```python
from bovnar import make_type_spec, ValueTypeFamily

literals.char_to_digit("Z", 62)     # → 61
literals.char_to_digit("g", 16)     # → None
literals.min_digits_for_type(make_type_spec(ValueTypeFamily.UINT, 8, 16))   # → 2
```

**Parsing and formatting.** These are the functions the writer and the DOM use.
Each raises `BovnarArgumentError` where the C one returns `false` or -1:

| Function | Result |
|---|---|
| `parse_int64(s, vt)` / `parse_uint64(s, vt)` | The integer literal in the type's base; refused when it does not fit the type's width (64 when it declares none), and for a `+` or a space before it |
| `parse_double(s, vt)` / `parse_double_in_base(s, base)` | The float literal (base 10 or 16; `inf`, `ninf` and `nan`, not `-inf`) as the nearest double, rounded once from its exact value |
| `format_uint64(value, base=10, min_digits=0)` / `format_int64(...)` | The digits in base 2 to 62, 64 or 85, padded with zero digits to `min_digits` (which the C function caps at 64). A negative value has no form in bases 64 and 85 |
| `format_double(value, vt)` | The literal the writer emits under `vt`: the shortest digits that read back to the same double, in base 10 or 16 (any other base raises). A `float:16` or `float:32` value is first rounded to IEEE binary16 or binary32 (`2049.0` is `2.048e+3` under `float:16`) and printed with the digits of a 16- or 32-bit significand, so it reads back as that value without rounding to the format (`65504.0` is `6.5504e+4`); a finite value the format cannot hold raises |
| `escape_repl_table()` | A `dict` of each escape letter to the character it stands for: `t`, `n`, `v`, `f`, `r`, `"` and `\`. The `\x` and `\u` escapes of spec 1.1 take digits and are not in it |

```python
u8 = make_type_spec(ValueTypeFamily.UINT, 8, 0)
f64 = make_type_spec(ValueTypeFamily.FLOAT, 64, 0)
literals.parse_uint64("255", u8)        # → 255
literals.format_double(0.1, f64)        # → "1e-1"
literals.parse_double("1e-1", f64)      # → 0.1
literals.format_uint64(5, 2, 8)         # → "00000101"
literals.escape_repl_table()["t"]       # → "\t"
```

> **No `0x` in base 16.** The integer parsers take what the reader takes, so
> `parse_uint64("0x10", make_type_spec(ValueTypeFamily.UINT, 64, 16))` raises,
> though `strtoull`, which they read with, would skip the prefix.

`bvn_looks_like_double` is not bound. It answers whether the text contains one
of `.`, `e`, `E`, `p` and `P`, so `deadbeef` looks like a float to it and `inf`
does not: a shortcut for the writer's own output, not a test of text from
anywhere else.

---

## 6. `Quantity`

`Quantity` is a typed, unit-annotated scalar value that preserves the original
text representation, type width, numeral base, and physical unit across a
`loads` / `dumps` round-trip.

```python
from bovnar import Quantity, ValueTypeSpec, ValueUnit
from bovnar.enums import ValueTypeFamily
```

### 6.1 Construction

`Quantity` is normally produced by `loads(..., typed=True)` rather than
constructed by hand, but direct construction is supported:

```python
from bovnar.structs import make_type_spec
from bovnar.enums   import ValueTypeFamily

vt = make_type_spec(ValueTypeFamily.FLOAT, 32)
q  = Quantity('101325.0', vt)          # dimensionless
q2 = Quantity('9.81',     vt, vu)      # vu is a ValueUnit, e.g. from parse_unit
```

For exact numeric input there is also the `Quantity.from_number` constructor,
which stores the value as an exact decimal literal (so writing it with `dumps`
is lossless to the format's precision):

```python
from decimal import Decimal
from fractions import Fraction

Quantity.from_number(Decimal('19.99'))                          # float_dec:64
Quantity.from_number(Decimal('1.1'), family=ValueTypeFamily.FLOAT, width=128)
Quantity.from_number(Fraction(837, 256),
                     family=ValueTypeFamily.FLOAT_FIX, width=32, frac=8)
```

It accepts a `Decimal`, `Fraction` (must be a terminating decimal), `int`,
`str` (a verbatim literal), or `float` (only as precise as the double), and
validates the width for the chosen family. A `str` must be a decimal number
literal in the format's own grammar — digits with an optional leading `-`,
`.` and exponent (`1.5`, `-2e-3`, `.5`), or `nan`, `inf`, `ninf` — so `''`,
`'abc'`, `' 1'` and `'+1'` raise `BovnarArgumentError` here rather than
failing later in `dumps`. For `float_dec` it also refuses a
finite value past the width's range — `Decimal('1e400')` does not fit
`float_dec:64` and would materialise to infinity — and a non-finite `float`
is stored under the format's own spelling (`inf`, `ninf`, `nan`).

### 6.2 Properties and methods

| Name | Type | Description |
|---|---|---|
| `q.raw` | `str \| None` | Original text token as it appeared in the BVNR stream |
| `q.vtype` | `ValueTypeSpec` | Type family, bit width, and numeral base |
| `q.unit` | `Unit` | Physical unit; `str(q.unit)` is its canonical text, `q.unit.raw` the `ValueUnit` struct (`BVN_UNIT_NONE` when dimensionless) |
| `q.value` | property | Decode `raw` to the closest native Python scalar (`int`, `float`, `str`, `bool`) — **lossy** for `float_dec` / `float_fix` / `float:128`+ (goes through a C `double`) |
| `q.unit_str` | `str` | Canonical unit string (e.g. `'m/s²'`), or `''` when dimensionless |
| `q.decimal` | `Decimal` | **Exact** value as `decimal.Decimal` from the verbatim literal — lossless at any width; raises for non-numeric families |
| `q.fraction` | `Fraction` | Exact value as `fractions.Fraction` (for `float_fix`, the exact `mantissa / 2**frac`); `None` for a typed null; raises for `nan`/`inf`/`ninf`, which have no ratio, and for non-numeric families. Its exact ratio holds 10^\|exponent\|, a number with that many digits, so it **refuses** (`BovnarArgumentError`) a literal whose decimal exponent — of its magnitude or of its last digit — exceeds `bovnar.quantity.FRACTION_EXPONENT_CAP` (100 000), or for `float_dec:256` its own range (611 937); `.decimal` is exact at any exponent |
| `q.fixed_point` | `(int, int)` | `(mantissa, frac_bits)` of a `float_fix` value; the value is `mantissa / 2**frac_bits`. `None` for a typed null; raises for `nan`/`inf`/`ninf`, which the reader accepts under `float_fix` but no Q-format datum holds |
| `q.stored_value` | `Decimal` | The value materialised into the declared IEEE/fixed format (round-to-nearest-even) — differs from `decimal` only when the literal carries more precision than the format holds. `None` for a typed null, as `decimal` is; `nan`/`inf`/`ninf` give the non-finite `Decimal` in every float family. A literal past the format's range (`1e-99999999`, `1e99999999`) gives the signed zero or infinity it rounds to, decided from the exponent without materialising the number; `ieee_bits` does the same |
| `q.ieee_bits` | `bytes` | IEEE-754 interchange bytes (binary16…256 for `float`, decimal16…256 for `float_dec`), little-endian word order |
| `q.epoch_name` | `str \| None` | For a `datetime`, the epoch name (`"unix"`, `"tai"`, …); `None` otherwise |
| `q.epoch_mjd` | `int \| None` | For a `datetime`, the epoch's Modified Julian Day; `None` otherwise |
| `q.to_civil()` | `CivilDateTime` | For a `datetime`, its UTC date and time ([§6.5](#65-civil-time-and-calendars)); raises otherwise |
| `q.datetime_fraction` | `str \| None` | For a `datetime` written as a literal with a fractional second, the verbatim sub-second digits (spec 1.1); `None` otherwise |
| `q.unit_source` | `str \| None` | The unit exactly as the document spelled it (library 2.0) — `ucum:mL{total}` where `q.unit` is `m~L`. Set by `loads(typed=True)`, or by the `unit_source=` constructor argument; `dumps()` writes it back, and refuses one that does not parse to `q.unit` — including a spelling on a `Quantity` that has no unit at all. `None` writes the canonical form |

### 6.3 Lossless numeric access (`float_dec`, `float_fix`, `float:128`/`256`)

`q.value` decodes through a C `double`, which loses precision for the
decimal-float, fixed-point, and wide binary-float families. The accessors above
instead use the verbatim literal text (and bovnar's arbitrary-precision
`bvn_float`), so the full precision the format carries is reachable from Python:

```python
q = bovnar.loads(b'.p=<float_dec:64> 3.141592653589793238462643383279503;',
                 typed=True)['p']
q.value          # 3.141592653589793   (lossy C double)
q.decimal      # Decimal('3.141592653589793238462643383279503')  (exact literal)
q.stored_value # Decimal('3.141592653589793')  (the decimal64-rounded value)

f = bovnar.loads(b'.x=<float_fix:32,q8> 3.27;', typed=True)['x']
f.fraction     # Fraction(837, 256)
f.fixed_point  # (837, 8)
```

These materialise over the **full** representable range, including exponents the
C parser cannot otherwise reach (beyond ~1e9865). `decimal` / `fraction`
work at every binary width bovnar allows (16, or a multiple of 32 up to 32768);
the bit-exact `stored_value` / `ieee_bits` apply to the IEEE encodings
(`float:16/32/64/128/256`), which are also exposed directly as `bovnar.BvnFloat`.

### 6.4 `dumps()` integration

`_emit_value` dispatches on `Quantity` before the plain `int` / `float` path,
so any dict that came from `loads(..., typed=True)` can be passed directly to
`dumps()` and will produce identical output:

```python
bvnr  = b".speed = <float:32,m/s> 9.81;"
doc   = bovnar.loads(bvnr, typed=True)
out   = bovnar.dumps(doc)
assert out.strip() == bvnr.strip()
```

The annotation is suppressed when `_needs_annotation` returns `False` — i.e.
when the type is `FLOAT:64` with no unit and no non-decimal base (matching the
C library's own default-annotation omission rules).

`dumps()` also accepts bare `decimal.Decimal` (written as an exact
`float_dec:64` literal) and terminating `fractions.Fraction` values directly —
a non-terminating fraction (e.g. `Fraction(1, 3)`) raises `BovnarArgumentError`,
as does a `Decimal` outside the range of `float_dec:64`. A list whose elements
are all `Decimal` / `Fraction` is written under one `<float_dec:64>`
annotation, so the elements mean what the scalars mean.

**Arrays of `Quantity`.** An array has one annotation. Elements that agree on
type and unit are written under it. Elements that agree on the unit but not
the type — or that carry no unit and differ in type, `[<uint:8> 1, <float:64>
2.5]` — are each written under their own annotation, as they were read.
Elements that carry *different* units have no valid form and raise.

**Octet streams.** `loads()` returns an octet stream as `bytes`, and `dumps()`
writes `bytes` / `bytearray` / `memoryview` back as one, so a document holding
one round-trips.

**What "can be passed directly to `dumps()`" does not promise** is that the
result passes the *document* tier: `dumps()` writes what `loads()` reads, and
`loads()` reads heterogeneous and ragged arrays that `dom_parse` and
`bovnar validate` refuse. See the note in
[§4.1](#41-high-level-api-loads--dumps).


### 6.5 Civil time and calendars

A `datetime` value is a count of seconds since its epoch
([§19](#19-spec-11-additions)). `bovnar.timekeeping` turns such counts into
civil dates and times and back, and carries the rest of the C library's
calendar: `bvn_datetime.h` and `bvn_gregorian_date.h`. Every result is computed
by the C functions, so the binding gives the answers the reader and writer use
when they read and print an ISO literal. Nothing is read from the host's time
zone or clock.

```python
import bovnar
from bovnar.timekeeping import (
    CivilDateTime, Epoch, GregorianDate, easter_sunday, epoch_seconds_to_utc,
    tai_second_is_dls_in_europe, tai_seconds_to_local_time, tai_seconds_to_utc,
    utc_to_tai_seconds,
)

q = bovnar.loads(b'#!bovnar 1.1\n.t = <datetime:64,tai> 1861920036;',
                 typed=True)['t']
str(q.to_civil())                                   # → '2016-12-31T23:59:60'

leap = CivilDateTime(2016, 12, 31, 23, 59, 60)
utc_to_tai_seconds(leap)                            # → 1861920036
str(tai_seconds_to_utc(1861920037))                 # → '2017-01-01T00:00:00'
str(epoch_seconds_to_utc(1750000000, Epoch.UNIX))   # → '2025-06-15T15:06:40'

t = utc_to_tai_seconds(CivilDateTime(2024, 7, 1, 12))
str(tai_seconds_to_local_time(t, 3600, tai_second_is_dls_in_europe(t)))  # → '2024-07-01T14:00:00'

GregorianDate(2000, 1, 1).to_mjd()                  # → 51544
str(GregorianDate.normalize(2024, 2, 30))           # → '2024-03-01'
str(easter_sunday(2025))                            # → '2025-04-20'
GregorianDate(2024, 3, 29).is_federal_holiday_in_germany()      # → True  (Good Friday)
str(GregorianDate(2024, 3, 5).last_banking_day_in_month())      # → '2024-03-28'
```

**The types.** `GregorianDate(year, month, day)` is a date of the proleptic
Gregorian calendar (`bvn_gregorian_date_t`), with astronomical years (1 BC is
year 0) from `YEAR_MIN` to `YEAR_MAX`, about ±2.5·10¹⁶. The constructor takes
only a real date; `GregorianDate.normalize(year, month, day)` carries an
out-of-range month or day into the next field instead, as `mktime` does. Its
`leap`, `wday` (0 = Sunday) and `yday` (0 = 1 January) come from the library.
`CivilDateTime(year, month, day, hour=0, minute=0, second=0)` adds a time of
day (`bvn_datetime_t`) and checks hour 0–23, minute 0–59 and second 0–60;
`.date` is its `GregorianDate` and `.mjd` the date's Modified Julian Day. Both
are frozen and hashable, compare with the C equality functions, order
chronologically, print as ISO 8601 (`2016-12-31T23:59:60`) and convert to
`datetime.date` / `datetime.datetime` with `to_date()` / `to_datetime()` where
those can hold the value (years 1–9999, no leap second). `Epoch` is
`bvn_epoch_t`: each member's value is its epoch's MJD, so `Epoch(q.epoch_mjd)`
is the epoch of a datetime `Quantity`, and an epoch argument may also be given
as that int or as the library's name (`"unix"`, `"y2000"`, …).
`Epoch.from_name(name)` resolves such a name through the library
(`bvnr_datetime_epoch_index`, `bvnr_datetime_epoch_mjd`): `Epoch.Y2000` for
`"y2000"`, `Epoch.UNIX` for `""`, `None` for a name it does not know.

**Seconds and epochs.**

| Function | C function | Result |
|---|---|---|
| `epoch_seconds_to_datetime(seconds, epoch)` | `bvn_dt_epoch_seconds_to_datetime` | The civil time `seconds` after `epoch` on a uniform scale (86400 s a day, no leap seconds); every int64 |
| `datetime_to_epoch_seconds(dt, epoch)` | `bvn_dt_datetime_to_epoch_seconds` | Its inverse; `second == 60` counts as the next minute's first second |
| `convert_epoch_seconds(seconds, epoch_from, epoch_to)` | `bvn_dt_convert_epoch_seconds` | The same count relabelled to another origin; the time scale does not change |
| `utc_to_tai_seconds(dt)` | `bvn_dt_utc_to_tai_seconds` | UTC to TAI seconds with the leap-second table (10 s before 1972, 37 s since 2017) |
| `tai_seconds_to_utc(tai_seconds)` | `bvn_dt_tai_seconds_to_utc` | Its inverse; an inserted leap second is `23:59:60` |
| `tai_seconds_to_local_time(tai_seconds, utc_offset_seconds, dls_active)` | `bvn_dt_tai_seconds_to_local_time` | Wall-clock time at a fixed offset, one hour later when `dls_active`; the offset must be whole minutes |
| `tai_second_is_dls_in_europe(tai_second)` | `bvn_dt_tai_second_is_dls_in_europe` | Whether German/European summer time is in force (none before 1980) |
| `epoch_seconds_from_gps_time(time_of_week_ms, week, epoch)`, `tai_seconds_from_gps_time(time_of_week_ms, week)` | `bvn_dt_epoch_seconds_from_gps_time`, `bvn_dt_tai_seconds_from_gps_time` | GPS week + time of week (ms, rounded to the second) as seconds; GPS time except on `Epoch.TAI`, where the 19 s are added |
| `epoch_seconds_from_galileo_time(…)`, `tai_seconds_from_galileo_time(…)` | `bvn_dt_epoch_seconds_from_galileo_time`, `bvn_dt_tai_seconds_from_galileo_time` | The same for Galileo System Time |
| `epoch_seconds_to_utc(seconds, epoch)`, `utc_to_epoch_seconds(dt, epoch)` | — | A datetime value's UTC time and back, as the reader and writer convert an ISO literal: the leap-second table on tai, the uniform scale on unix, mjd, ntp and y2000; the GNSS epochs raise |
| `Quantity.to_civil()` | — | `epoch_seconds_to_utc` of a `datetime` quantity; the sub-second digits stay in `datetime_fraction` |
| `a == b`, `a.is_date_equal(b)`, `a.is_time_equal(b)` | `bvn_dt_is_equal`, `bvn_dt_is_date_equal`, `bvn_dt_is_time_equal` | Field-wise comparison of two `CivilDateTime` |

GPS and Galileo seconds stay in their own scale: for UTC, take
`tai_seconds_from_gps_time` to `tai_seconds_to_utc`. A datetime on a GNSS epoch
has no civil form in the library, which is why `epoch_seconds_to_utc` and
`to_civil()` refuse it.

**The calendar.**

| Function or method | C function |
|---|---|
| `GregorianDate.from_mjd(mjd)`, `d.to_mjd()`, `GregorianDate.normalize(y, m, d)`, `a == b` | `bvn_gregorian_date_from_mjd`, `_to_mjd`, `_normalize`, `_is_equal` |
| `mjd_to_wday(mjd)`, `mjd_to_yday(mjd)`, `mjd_in_leap_year(mjd)` | `bvn_gregorian_date_mjd_to_wday`, `_mjd_to_yday`, `_mjd_in_leap_year` |
| `easter_sunday_mjd(year)`; `easter_sunday(year)`, `good_friday`, `easter_monday`, `ascension_day`, `whit_monday` | `bvn_gregorian_date_easter_sunday_mjd`; `bvn_gregorian_date_calc_*` |
| `d.is_federal_holiday_in_germany()`, `d.is_banking_day_in_germany()`, `d.is_business_day_in_germany()` | `bvn_gregorian_date_is_*_in_germany` |
| `d.last_banking_day_in_month()`, `penultimate_…`, `mid_…`, `first_…`, `d.last_sunday_in_month()`, `d.last_saturday_in_month()` | `bvn_gregorian_date_get_*_in_month` |
| `d.last_banking_day_in_quarter(q)`, `d.first_banking_day_in_quarter(q)` | `bvn_gregorian_date_get_*_banking_day_in_quarter` |
| `d.is_last_banking_day_in_month()` and the other three; `d.is_last_banking_day_in_quarter(q)`, `d.is_first_banking_day_in_quarter(q)` | `bvn_gregorian_date_is_*_banking_day_in_*` |
| `d.is_daylight_saving_switch_in_germany()`, `d.is_day_before_daylight_saving_switch_in_germany()` | `bvn_gregorian_date_is_*daylight_saving_switch_in_germany` |

The federal holidays are the nine that hold in all of Germany, by the rules in
force that year: 3 October from 1990, 17 June from 1954 to 1989, 1 May from 1933.
A banking day is Monday to Friday, not a holiday and not 24 or 31 December; a
business day is Monday to Saturday and not a holiday. The mid banking day is the
first one on or after the 15th. The summer-time switch days follow the German
rule of each year: none before 1980, the end in September until 1995.

**Errors.** Where a C function signals failure with a sentinel, a `false` or an
untouched output — an overflow, an MJD or year outside the supported range, a
quarter outside 1–4 — the binding raises `BovnarArgumentError`, as it does for an
argument that is not an int or does not fit in int64. The seconds functions mark
an overflow with `BVN_GDATE_OVF`, which is the int64 minimum itself, so a result
of exactly `-2**63` cannot be told from one and raises too. Not bound:
`bvn_gregorian_date_to_mjd_fast` (undefined behaviour out of range; `to_mjd()`
is the checked form) and `bvn_dt_dump` / `bvn_dt_print`, which print to stdout
(`str()` and `repr()` take their place).

---

## 7. `write_array` helper

`write_array` is a high-level helper exported from the top-level `bovnar`
namespace. It handles both flat and multi-dimensional arrays.

```python
from bovnar import write_array, Writer
from bovnar.structs import make_type_spec, make_unit_si
from bovnar.enums import ValueTypeFamily, BaseUnit

with Writer.to_mem() as w:
    # Single-row array
    write_array(w, "primes", [2, 3, 5, 7])

    # Multi-row array (rows separated by /)
    write_array(w, "matrix", [[1, 2, 3], [4, 5, 6]])

    # Typed array: whole-array type annotation
    vt = make_type_spec(ValueTypeFamily.UINT, 16)
    write_array(w, "ports", [80, 443, 8080], vt=vt)
```

Element types accepted per element: `int`, `float`, `str`, `bool`, `None`,
`bytes` (an octet stream), `dict` (written as a struct), or nested
`list`/`tuple` (written as a nested array). When *rows* is a flat sequence it
is treated as a single-row array; when all top-level elements are themselves
`list` or `tuple` it is treated as a multi-row array. Any other iterable (a
generator, a `range`) is materialised first.

**With `vt`, every element is held to it.** An `int` under an integer
annotation is checked against the width and written in the annotation's base —
quoted where the base requires it, so `255` under `<uint:16,_16>` is `"ff"`.
An element the annotation cannot describe — `80800` under `<uint:16>`, a
`float` or a `str` under an integer type, a `dict` under any — raises
`BovnarArgumentError` instead of producing a document nothing reads. `None` is
a null hole and is always allowed; a `Quantity` element carries its own
literal and is written as it is. `vu` takes a `ValueUnit` or a `Unit`.

---

## 8. pint bridge

bovnar units interoperate with [pint](https://pint.readthedocs.io/) through a
hand-verified translation table (`bovnar._pint_units`). pint is an **optional**
dependency, imported lazily on first use; importing `bovnar` never requires it.
Install with `pip install "bovnar[pint]"` (or `bovnar[all]`).

The four bridge functions are exported from the top-level `bovnar` namespace
(and from `bovnar._pint_bridge`):

| Function | Direction | Description |
|---|---|---|
| `to_pint(value, vu, *, ureg=None)` | bovnar → pint | Wrap a scalar/ndarray + `ValueUnit` in a pint `Quantity` |
| `to_pint_unit(vu, *, ureg=None)` | bovnar → pint | `ValueUnit` → pint `Unit` (dimensionless when no real unit) |
| `from_pint(qty, *, ureg=None, validate=True)` | pint → bovnar | `Quantity` → `(magnitude, ValueUnit)` |
| `from_pint_unit(unit, *, ureg=None, validate=True)` | pint → bovnar | `Unit` / `Quantity` / `str` → `ValueUnit` |

```python
import bovnar

vu  = bovnar.parse_unit("k~m")        # kilometre
qty = bovnar.to_pint(5.0, vu)         # <Quantity(5.0, 'kilometer')>

mag, vu2 = bovnar.from_pint(qty)      # (5.0, ValueUnit for km)
vu3      = bovnar.from_pint_unit("newton")     # str/Unit/Quantity all accepted
```

### 8.1 Prefixes ride in the unit, never the magnitude

bovnar's `k~m` maps to pint `kilometer` — the prefix is kept inside the unit
*name*, never folded into the magnitude. A wrapped value is therefore returned
unscaled, so a wrapped numpy array is never silently rescaled.

### 8.2 Affine temperature units

Offset/affine scales (`°C`, `°F`, Réaumur, Delisle, Newton, Rømer) cannot carry
a prefix or exponent — pint forbids it and bovnar never emits it. A prefixed or
exponentiated affine unit raises `BovnarArgumentError` rather than a cryptic
pint error.

`to_pint` / `to_pint_unit` take a `ValueUnit` or a `Unit`, so
`bovnar.to_pint(q.value, q.unit)` works on a typed value as it stands. A unit
pint cannot name raises `BovnarArgumentError`: the profile-only units, and
anything carrying the IEC prefixes `Ri~` / `Qi~`, which pint does not define.

### 8.3 Validation

`from_pint` / `from_pint_unit` validate the resulting `ValueUnit` by default
(`validate=True`); a pint unit that maps to a structurally invalid bovnar unit
(e.g. a prefix not permitted on that base) raises `BovnarArgumentError`. pint
units with more than `MAX_UNIT_COMPONENTS` (32) components, non-integer
exponents, or exponents outside `[EXPONENT_MIN, EXPONENT_MAX]` = `[-100, 100]`
also raise. Those two bounds are imported from `bovnar.structs` and
`bovnar.enums` rather than restated in the bridge: they were once written out as
8 and ±9, the values the C library had when the bridge was first written, and
stayed behind when it grew — so `from_pint` refused units `to_pint` had just
produced, and said the old numbers were bovnar's range.

### 8.4 Registry control: `build_registry`

A module-level default `pint.UnitRegistry` is built on first use. To share a
registry across calls — or to register bovnar's custom units onto your own —
pass `ureg=`:

```python
from bovnar._pint_units import build_registry, is_currency_unit, is_kind_scale

ureg = build_registry()                       # fresh registry with bovnar units
ureg = build_registry(my_existing_registry)   # extend an existing one
ureg = build_registry(with_currencies=False)  # skip the currency dimensions
ureg = build_registry(isolate_kinds=False)    # alias kinds onto pint's natives

qty  = bovnar.to_pint(5.0, vu, ureg=ureg)
```

`build_registry` registers bovnar's custom physical-unit definitions — units
where pint's own definition differs or is missing (e.g. `bvnr_gauss`,
`bvnr_var`, the historical German units) — plus, by default, the ISO 4217 +
crypto currencies as custom dimensions. `is_currency_unit(unit)` reports whether
a pint unit involves a currency dimension (holds for products such as
`USD/year`).

### 8.5 Quantity kinds: `isolate_kinds`

Bovnar tracks **quantity kinds** for units that carry no SI dimension yet are
not interchangeable — bit vs byte, an angle vs a plain count, `dB` vs `Np` vs
`pH`, one kind per turbidity method, and practical salinity. pint has no such
concept: to pint they are all dimensionless, so left to itself it will convert
an `NTU` into an `FNU`, a byte into 8 bits or a `pH` into a percentage —
precisely the answers Bovnar exists to withhold.

`build_registry` therefore gives **each kind its own pint dimension**, the same
device the currency table uses to stop `100 USD` becoming `100 EUR`. pint then
enforces Bovnar's rule natively:

```python
ureg = build_registry()                       # isolate_kinds=True by default
q    = bovnar.to_pint(4.2, bovnar.parse_unit("NTU"), ureg=ureg)

q.to("bvnr_fnu")        # pint.DimensionalityError — as bovnar refuses it
q.to("dimensionless")   # pint.DimensionalityError
q.to("bvnr_ntu")        # 4.2 — a conversion within the kind is fine
```

Conversions *within* a kind keep working, because that is what a shared
dimension means: `m~NTU → NTU`, `° → rad` (1 rad = 57.29578°), `sr = rad²`.
`bovnar._pint_units.is_kind_scale(unit)` reports whether a pint unit carries
one of these dimensions, as `is_currency_unit` does for money.

The cost is interoperability with pint's **native** units: a Bovnar-derived
byte no longer converts to pint's `megabyte`, nor a Bovnar radian to pint's
`degree`. If you need that more than you need the refusals:

```python
ureg = build_registry(isolate_kinds=False)    # alias onto pint's natives
```

which restores both the interop and pint's permissiveness. The default is
fidelity.

### 8.6 Semantic caveats

What survives kind isolation is recorded in
`bovnar._pint_units.SEMANTIC_CAVEATS`: `DECIBEL`, `NEPER` and `PH_SCALE`, whose
labels and refusals now match but whose *arithmetic* does not (pint will add two
decibels; 20 dB is a ratio of 100, not twice 10 dB), and `VAL`, where the
divalent water-analysis convention is a fact no unit system can infer. Consult
that dict when exact round-trip semantics matter.

The mapping is **not 1:1** by construction (bovnar `b`=bit vs pint barn,
`R`=roentgen vs pint's gas constant). The translation table is locked against
silent drift by `TestUnitTableIntegrity`, which re-derives every physical unit's
dimension and magnitude from bovnar and asserts the pint bridge reproduces both,
and by `TestPintAgreesWithBovnarOnWhatConverts`, which asserts that pint refuses
every conversion bovnar refuses — the invariant that was missing when pint was
quietly the more permissive of the two.

---

## 9. NumPy bridge

The NumPy bridge converts between bovnar arrays and `numpy.ndarray`. numpy is an
**optional** dependency, imported lazily on first use. Install with
`pip install "bovnar[numpy]"` (or `bovnar[all]`).

All five functions are exported from the top-level `bovnar` namespace (and from
`bovnar._numpy`):

| Function | Direction | Description |
|---|---|---|
| `to_numpy(src, *, dtype=None, return_unit=False)` | bovnar → numpy | Array → `ndarray` (optionally `(ndarray, unit_str)`) |
| `to_pint_array(src, *, dtype=None, ureg=None)` | bovnar → pint | Array → pint `Quantity` (ndarray data + unit) |
| `from_numpy(writer, key, arr, *, unit=None, float_format=None)` | numpy → bovnar | Write an `ndarray` into a `Writer` |
| `from_pint_array(writer, key, qty)` | pint → bovnar | Write a pint `Quantity` (magnitude + unit) into a `Writer` |
| `array_to_bvnr(key, arr, *, unit=None, pretty=True, float_format=None)` | numpy → bovnar | `ndarray` → bovnar bytes (convenience) |

### 9.1 Reading: `to_numpy`

*src* is either a `DomNode` for an ARRAY (random-access, from `dom_parse`) or
the nested list/tuple that `loads(..., typed=True)` produces. Both
`/`-separated rows and bracket nesting collapse to the same `ndarray` shape.

```python
import bovnar

# from a typed loads() result
doc = bovnar.loads(b'.a=<uint:8>[1,2,3];', typed=True)
arr = bovnar.to_numpy(doc['a'])               # dtype uint8

# with the whole-array unit
arr, unit = bovnar.to_numpy(
    bovnar.loads(b'.a=<float:32,m/s>[1,2,3]/[4,5,6];', typed=True)['a'],
    return_unit=True)                         # unit == 'm/s', arr.shape == (2, 3)

# from a DOM node
arr = bovnar.to_numpy(bovnar.dom_parse(b'.a=<sint:16>[10,-20,30];')['a'])
```

The bovnar `(family, width)` maps directly to the numpy dtype (`uint:8` →
`uint8`, `float:32` → `float32`, …). Pass `dtype=` to coerce.

The families with no exact native numpy dtype — `float_dec`, `float_fix`, and
the wide binary floats `float:128`/`256` (and any width above 64) — decode to
an **object array of exact `decimal.Decimal`** on the typed
(`loads(..., typed=True)`) path, which is lossless; pass `dtype='float64'` for
the lossy native conversion. The DOM (`dom_parse`) path only has a C `double`
for these, so it raises and points you at the typed path or `dtype='float64'`.
Integers wider than 64 bits need `dtype=object`, which gives an exact object
array of Python ints; without it `to_numpy` raises `BovnarArgumentError`.
An **empty** DOM array takes its dtype (and unit) from its annotation —
`<sint:32> []` is an `int32` array of shape `(0,)` — since it has no element to
infer one from; an empty array with no annotation is `float64`, as numpy
infers.

A **`datetime`** array on the **unix** epoch (spec 1.1) maps to/from
`datetime64[s]` — the integer epoch-seconds carrier is unix-relative, so it
round-trips losslessly. A non-unix epoch (`tai`, `gps`, …) is *not*
unix-relative, so `to_numpy` refuses it (mapping it to `datetime64` would
mis-date every value) and points you at `dtype='int64'` for the raw seconds.
On the write side a `datetime64[*]` array is coarsened to whole seconds and
written as `<datetime:64>` (the unix epoch is the default and is not
spelled out); `NaT` has no bovnar representation and is
rejected, and `array_to_bvnr` prepends the `#!bovnar 1.1` directive so its
output re-parses.

**Strict by default:**

* an array that mixes numeric encodings (e.g. `uint:8` and `float:64`) raises
  unless you pass `dtype=` to coerce to one;
* a Bovnar array is rectangular and homogeneous **at the document tier**
  (`dom_parse`, `bovnar validate`), so a `DomNode` always yields a regular
  ndarray. The streaming tier — `loads()`, and therefore `dumps()` — does not
  enforce that (see [§4.1](#41-high-level-api-loads--dumps)): a ragged typed
  list raises here unless you pass `dtype=object`;
* a `null` element cannot fill a dtype with no null slot — integer, `bool`, or
  string (which would otherwise silently become `0`/`False`/`'None'`); pass
  `dtype=float` (→ `NaN`) or `dtype=object` (→ `None`);
* the unit is a whole-array property (numpy has one dtype per array) — mixed
  units raise, as does mixing a dimensioned element with a dimensionless one;
  the unit is returned alongside the data, never baked into elements.

### 9.2 Writing: `from_numpy` / `array_to_bvnr`

```python
import numpy as np
import bovnar
from bovnar import Writer

with Writer.to_mem() as w:
    bovnar.from_numpy(w, "velocity",
                      np.array([1.5, 2.5], dtype=np.float32), unit="m/s")
out = w.get_output()

# one-shot convenience
raw = bovnar.array_to_bvnr("matrix", np.arange(8).reshape(2, 2, 2))

# exact decimal/fixed/wide-binary float arrays via float_format
from decimal import Decimal
prices = np.array([Decimal("19.99"), Decimal("0.01")], dtype=object)
bovnar.array_to_bvnr("p", prices, unit="$USD")            # -> <float_dec:64,$USD>
bovnar.array_to_bvnr("a", prices, float_format=("float", 128))   # binary128
```

* `from_numpy` requires a 1-D-or-higher array; write a 0-D scalar with the
  scalar `Writer` API instead.
* An empty axis is written as empty brackets, which keep no length for any axis
  after it, so only the **last** axis may be zero-length: shape `(2, 0)` is
  `[] / []` and reads back as `(2, 0)`, while `(0, 3)` would be written as `[]`
  and read back as `(0,)` — it raises `BovnarArgumentError` instead.
* *unit* may be a bovnar unit string, a `Unit`, a `ValueUnit`, or a pint
  `Unit`/`Quantity` (anything else raises `BovnarArgumentError`).
* Units apply to **numeric** arrays only — a unit on a `bool` or string array
  raises `BovnarArgumentError`.
* An **object array of `Decimal`/`Fraction`** is written as an exact
  decimal/fixed/wide-binary float. `float_format=(family, width[, frac])` —
  family `'float'`, `'float_dec'` (the default for a Decimal object array), or
  `'float_fix'` — selects the target.
* An **object array of Python `int`s** — what `to_numpy(..., dtype=object)`
  returns for integers wider than 64 bits — is written as integers, at the
  narrowest width that holds them and never under 64 (`<uint:71>[…]`).
* Any other object array is ambiguous and needs `float_format`.
* A **bytes array** (dtype kind `S`) is refused: decode it first
  (`arr.astype('U')`).
* A **masked array** with any masked entry is rejected (it would otherwise
  serialise the underlying value of a masked cell); fill or unmask it first.
* `array_to_bvnr` grows its write buffer like `dumps()` (4 MiB, doubling up to
  256 MiB).

### 9.3 pint arrays

`to_pint_array` and `from_pint_array` bridge straight through to pint
Quantities backed by ndarrays, reusing the unit translation above:

```python
q = bovnar.to_pint_array(
    bovnar.loads(b'.a=<float:64,k~m>[1,2,3];', typed=True)['a'])
# q is a pint Quantity: magnitude ndarray [1, 2, 3], unit 'kilometer'

with Writer.to_mem() as w:
    bovnar.from_pint_array(w, "dist", q)
```

A `datetime` array has no pint unit, so `to_pint_array` rejects it rather than
wrap it as a meaningless dimensionless quantity — use `to_numpy` for the
`datetime64` array (or `dtype='int64'` for the raw epoch seconds).

---

## 10. Currency helpers

The `bovnar.currency` submodule (exposed as `bovnar.currency`) provides
metadata for the ISO 4217 fiat and cryptocurrency `BaseUnit` members. It is pure
Python — no library or optional dependency required.

```python
from bovnar import currency, BaseUnit

currency.is_currency(BaseUnit.USD)   # True
currency.is_fiat(BaseUnit.USD)       # True
currency.is_crypto(BaseUnit.BTC)     # True

info = currency.currency_info(BaseUnit.USD)
info.code           # 'USD'
info.numeric_code   # 840 (ISO 4217 numeric; 0 for crypto)
info.minor_unit     # 2   (decimal places: 1 major = 10^minor minor units)
info.name           # 'US Dollar'

currency.minor_unit(BaseUnit.JPY)    # 0
currency.currency_code(BaseUnit.EUR) # 'EUR'
currency.from_code('GBP')            # BaseUnit.GBP

for ci in currency.all_fiat():       # all_crypto() / all_currencies() also exist
    ...
```

`CurrencyInfo` is a frozen dataclass; `currency_info`, `minor_unit`,
`currency_name`, `currency_code`, and `from_code` raise for non-currency bases
or unknown codes.

---

## 11. `Reader` reference

### 11.1 Construction

```python
with Reader() as r:
    ...
```

`Reader.__init__` calls `bvnr_reader_create` immediately.  Use as a context
manager or call `r.close()` explicitly to release the C object.

A reader is not re-entrant. While `read_mem`, `read_fd`, `read_file`,
`read_stream` or `iter_mem` is running, the reader's own callbacks must not call `close()`,
start another read on it, or `set_unit_policy()`: each raises
`BovnarArgumentError("Reader is in use by its own callback")`, which aborts the
running parse and is re-raised from the read. Use a second `Reader` for a
nested parse, and close the first one after its read returns.

### 11.2 `read_mem`

`read_mem(data, *, on_verified, on_unverified, want_unit, on_error, mirror, **options)`

Parse BVNR from a `bytes`, `bytearray`, or `memoryview` object. The callbacks
are named parameters; every other keyword is a **read option**, one of the
names in `bovnar.READ_OPTIONS`. That table is the one list every reading entry
point takes its keywords from — `read_*`, `iter_mem`, `loads` / `load`, the
feeds (§4.7) and the `bovnar.stream` readers — so an option is never reachable
from one and missing from another. An unknown keyword is a `TypeError`, a value
outside its C field (a negative one, `max_string_length=70000`) a
`BovnarArgumentError`. Every option defaults to `0` / `False`.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `data` | `bytes \| bytearray \| memoryview` | — | Input buffer |
| `on_verified` | `Callable[[Event, BvnrData \| None], bool] \| None` | `None` | Callback for validated events |
| `on_unverified` | `Callable[[Event, BvnrData \| None], bool] \| None` | `None` | Callback for raw pre-validation events |
| `on_error` | `Callable[[BovnarParseError], None] \| None` | `None` | Called for every error the reader reports, as a `BovnarParseError` with its code and position: under `continue_on_error`, each error it recovers from — reported nowhere else — and the error that ends a read, before the read raises it. Its return value is ignored. An exception it raises is kept and re-raised from the read once C returns; it stops the read at the next event only when an `on_verified`, `on_unverified` or `want_unit` callback is installed — with `on_error` alone the read runs to the end first |
| `mirror` | binary file object, socket or `bytearray` \| `None` | `None` | Receives every byte the reader consumes: the input mirror of `bvnr_open_read_source`, a debugging aid (below) |
| `max_file_size` | `int` | `0` (unlimited) | Hard limit on bytes consumed |
| `continue_on_error` | `bool` | `False` | Enable resync mode |
| `strict_version` | `bool` | `False` | Reject a declared spec version newer than this build (`error_unsupported_spec_version`) |
| `text_only` | `bool` | `False` | Refuse a document that contains an octet stream (`error_octet_stream_forbidden`), for a channel that is not byte-transparent |
| `want_unit` | `Callable[[BvnrData], tuple \| None] \| None` | `None` | Optional read-time lossless unit/base conversion hook. Called per numeric value; return `(unit, base)` to request a conversion (the exact result arrives on the payload as `converted` and `converted_str()`, with `converted_rational()` / `converted_in_base()` beside it — see §4.2), or `None` to leave the value untouched. An inexact conversion aborts the parse (`error_unit_inexact`). |
| `want_unit_allow_nonterminating` | `bool` | `False` | Deliver an exact-rational-but-non-terminating conversion (e.g. km/h→m/s) as a rational — `converted_str()` returns `None`, `converted_rational()` the exact pair — instead of aborting with `error_unit_inexact` |
| `max_conversion_length` | `int` | `0` (default 1024) | Longest converted text a conversion may produce, in characters; a longer exact result aborts with `error_value_out_of_range` |
| `max_text_bytes` | `int` | `0` (default 2147483647) | Most bytes of text in the document (octet-stream payloads not counted); past it the read fails with `TEXT_DATA_TOO_LONG`. `stream.BVNR_NO_LIMIT` (2**64 − 1) lifts it |
| `max_array_items` | `int` | `0` (default 2147483647) | Most items in one array; past it the read fails with `TOO_MANY_ARRAY_ITEMS`. `stream.BVNR_NO_LIMIT` lifts it |
| `max_identifier_length` | `int` | `0` (default 255) | Longest key, in bytes (`IDENTIFIER_TOO_LONG`) |
| `max_string_length` | `int` | `0` (default 65535) | Longest string value, in bytes (`STRING_TOO_LONG`) |
| `max_number_length` | `int` | `0` (default 65535) | Longest number literal (`NUMBER_TOO_LONG`) |
| `max_symbol_length` | `int` | `0` (default 255) | Longest symbol (`SYMBOL_TOO_LONG`) |
| `max_reference_length` | `int` | `0` (default 65535) | Longest reference (`REFERENCE_TOO_LONG`) |
| `max_struct_nesting` | `int` | `0` (255) | Deepest struct nesting, at most 255 (`STRUCT_NESTING_TOO_HIGH`). `0` is the format's limit of 255, what the DOM and the CLI read at — not the C reader's own default of 64 |
| `max_array_nesting` | `int` | `0` (255) | Deepest array nesting, as `max_struct_nesting` (`ARRAY_NESTING_TOO_HIGH`) |

**The mirror.** `mirror=` — a writable binary file object (`write`), a socket
(`sendall`) or a `bytearray`, which is appended to — is handed every byte the
reader consumes, the comments and the octet-stream payloads included, so what
it holds after a read that succeeded is the input. The bytes reach it a piece
at a time, each once the reader has parsed it: a buffer pulled (up to 64 KiB),
a piece fed to a `DocumentFeed`, an octet-stream chunk. A read that fails, or a
callback stops, has mirrored the input up to the byte it stopped at. An exception the object raises ends the read and is re-raised from
it, unchanged. `read_fd`, `read_file`, `read_stream`, `iter_mem` and
`DocumentFeed` (§4.7) take it too; the frame readers and the demux do not,
as the C objects behind them take no mirror (their caller holds the bytes it
feeds them).

```python
from bovnar import Reader

seen = bytearray()
Reader().read_mem(b'.a = 1;  # note\n', mirror=seen)
bytes(seen)                          # → b'.a = 1;  # note\n'
```

The five length caps and the two nesting caps are for untrusted input: lower
them to what the consumer can take. The length caps are the width of their C
field (`uint16_t`); a key or symbol longer than 255 bytes is accepted at a
higher cap, but `dumps()` and `bovnar validate` keep to the default.

### 11.3 `read_fd`

`read_fd(fd, *, …)` — the keyword parameters of `read_mem`, unchanged.

Parse BVNR from an open POSIX file descriptor. Parameters identical to
`read_mem` except the first argument is a non-negative `int` fd.

### 11.4 `read_file`

`read_file(path, *, …)` — the keyword parameters of `read_mem`, unchanged.

Convenience wrapper: opens `path` with `os.O_RDONLY`, calls `read_fd`, closes
the fd in a `finally` block. The `max_file_size` default is `MAX_FILESIZE_BYTES`
(16 MiB) rather than unlimited.

### 11.5 `iter_mem(data, *, verified_only, **options)`

Generator that collects all events from `read_mem` and yields
`EventPayload(event, raw, value_type, value_unit)` objects.

| Parameter | Default | Description |
|---|---|---|
| `verified_only` | `True` | When `False`, both callbacks fire and events may appear twice |
| `**options` | — | The read options, forwarded to `read_mem` |

`EventPayload` fields:

| Field | Type | Description |
|---|---|---|
| `event` | `Event` | The event code |
| `raw` | `bytes` | Raw token bytes |
| `value_type` | `ValueTypeSpec \| None` | Type annotation if present |
| `value_unit` | `ValueUnit \| None` | Unit if present |
| `text` | `str` (property) | `raw` decoded as UTF-8 (with `errors='replace'`) |
| `converted` | `bool` | `True` when a `want_unit` conversion was applied to this value |
| `converted_text` | `str \| None` | Exact converted value as a positional string; `None` if the conversion does not terminate in `converted_base` |
| `converted_base` | `int` | Base the converted text is rendered in |

### 11.6 Error-state properties

These properties query the C reader object after a failed parse. They describe
the error that **ended** the read. An error recovered under
`continue_on_error=True` does not appear here: after a read that succeeded,
`error_code` is `ErrorCode.NONE` and the positions are `0`, however many errors
were recovered, and `recovery_count` is how to tell that any were (spec §13.4).

| Property | Type | Description |
|---|---|---|
| `error_code` | `ErrorCode` | The error that ended the read; `NONE` after a successful one |
| `error_line` | `int` | 1-based line number of the error |
| `error_column` | `int` | 1-based column of the error |
| `error_offset` | `int` | Byte offset of the error |
| `error_byte` | `int` | The byte the error was raised on, `0` where it is not one byte's fault |
| `recovery_count` | `int` | Number of times resync was entered (incremented at error entry, not at resync completion) |
| `skipped_bytes` | `int` | Bytes a `continue_on_error` read consumed and discarded while recovering; they were never parsed, so no callback mentions them, and a non-zero value means the callbacks did not see the whole document |
| `declared_version` | `tuple[int, int] \| None` | The `(major, minor)` of a leading `#!bovnar M.N` directive, `None` when the document declared none; valid after a read |

To see the errors a `continue_on_error` read recovered from, pass `on_error`
(§11.2): it is called with each one.

### 11.7 `MAX_FILESIZE_BYTES`

```python
from bovnar import MAX_FILESIZE_BYTES   # 16 * 1024 * 1024  (16 MiB)
```

Default `max_file_size` cap used by `Reader.read_file`.

### 11.8 `read_stream`

`read_stream(stream, *, …)` — the keyword parameters of `read_mem`, unchanged.

Parse a document pulled from a socket (`recv_into`) or a binary file object
(`readinto1`, `readinto` or `read`, in that order of preference; `readinto1`
hands over what a pipe or a socket file has, instead of waiting until the
reader's whole request is filled). A short read is fine, and an empty one is the
end of the document. The bytes are pulled as the parse needs them, so the
document is never held whole. An exception the object raises is re-raised from
`read_stream` unchanged. A text-mode file is refused, and so is a non-blocking
object with nothing to read yet: a pull cannot wait, so such a transport is fed
to a `DocumentFeed` instead (§4.7).

---

## 12. `Writer` reference

### 12.1 Construction class methods

| Method | Description |
|---|---|
| `Writer.to_mem(buf=None, cap=4194304, *, …)` | Write to an in-process buffer. `buf` may be a pre-allocated `bytearray`; when `None` an internal buffer of size `cap` is allocated. |
| `Writer.to_fd(fd, *, …)` | Write to an open POSIX file descriptor. |
| `Writer.to_file(path, *, …)` | Open `path` for writing (`O_WRONLY\|O_CREAT\|O_TRUNC`, mode `0o644`) and write to it; the fd is closed when the writer is finished or destroyed. |
| `Writer.to_stream(stream, *, …)` | Write to a socket (`sendall`) or a binary file object (`write`, repeated until it has taken every byte). Output is pushed a full 64 KiB buffer at a time and the rest at `flush()` or `finish()`, which then call the object's `flush` if it has one. An exception the object raises is re-raised from the write call that pushed. |

Each takes the keywords `pretty=True, unit_flags=0, emit_version=False,
on_event=None, max_struct_nesting=0, max_array_nesting=0`. All four are used
as context managers.  On clean exit (`exc_type is None`) the context manager
calls `finish()` automatically.

`unit_flags` takes [`UnitFlags`](#53-unitflags) — `REDUCE` rewrites each unit
and rescales its value, `ASCII_EXP` writes `^N` exponents, `NUM_CANONICAL`
keeps a rescaled float in float shape and writes annotations in one form. `emit_version=True` writes a leading
`#!bovnar M.N` naming the spec version this build implements, as
`write_version()` would. By default every writer accepts structs and arrays
nested up to **255** deep, the same limit `Reader` and `loads()` read at.
`max_struct_nesting` and `max_array_nesting` (`bvnr_write_flags_t`, 1 to
255; `0` is 255, as for the reader's options) lower it: a struct or an array
one level deeper fails the write with `STRUCT_NESTING_TOO_HIGH` or
`ARRAY_NESTING_TOO_HIGH`, and a value outside 0–255 is a
`BovnarArgumentError`. `on_event` is the observer of §12.10.

```python
from bovnar import Writer, BovnarWriteError

w = Writer.to_mem(max_struct_nesting=2)
w.begin_struct("a")
w.begin_struct("b")
try:
    w.begin_struct("c")
except BovnarWriteError as e:
    code = e.code.name
code                                 # → 'STRUCT_NESTING_TOO_HIGH'
```

### 12.2 Output retrieval

| Method / property | Description |
|---|---|
| `w.get_output() -> bytes` | Return the bytes written so far (mem writers only). |
| `w.bytes_written` | Number of bytes written (all writer modes). |
| `w.flush()` | Hand everything written so far to the sink now (`bvnr_write_flush`) and call the stream's `flush`, between any two writes; the bytes are the same as without it. A no-op for a memory writer and after `finish()`. |
| `w.finish()` | Flush and seal the output. Raises `BovnarWriteError` if any struct is still open. |
| `w.destroy()` | Release the underlying C writer object immediately. |

### 12.3 Scalar write methods

All scalar writers accept keyword-only unit parameters.  Unit resolution
priority: `unit_str` (parsed via `bvn_parse_unit_n`) → `unit_iec_base` →
`unit_si_base` → `BVN_UNIT_NONE` (no annotation emitted).

The unit is written in its canonical spelling: `unit_str="ucum:mL{total}"`
comes out as `m~L`. Each numeric writer here and in §12.4 also takes
`unit_source=`, the spelling to write instead, as `bvnr_writer_set_unit_source`
gives one to the C helpers. The C writer checks that it spells the unit it is
written for (and leaves it out under a `unit_flags` rewrite); a `unit_source`
for a value without a unit is a `BovnarArgumentError`.

```python
w = Writer.to_mem(pretty=False, emit_version=True)   # a profile unit is spec 1.2
w.write_float("vol", 5.0, unit_str="ucum:mL{total}",
              unit_source="ucum:mL{total}")
w.finish()
w.get_output().split(b"\n")[1]       # → b'.vol=<float:64,ucum:mL{total}>5e+0;'
```

#### `write_uint(key, value, *, width=64, base=10, unit_str=None, unit_si_base=None, unit_si_prefix=SIPrefix.NONE, unit_si_exp=Exponent.LINEAR, unit_iec_base=None, unit_iec_prefix=IECPrefix.NONE, unit_source=None)`

Write an unsigned integer. `base` selects the numeral system (10 or 16 for
inline values; any Bovnar-supported base for `write_bvni`).

#### `write_sint(key, value, *, width=64, base=10, unit_str=None, unit_si_base=None, unit_si_prefix=SIPrefix.NONE, unit_si_exp=Exponent.LINEAR, unit_source=None)`

Write a signed integer. `base` 64 and 85 are **unsigned** — `+` and `-` are
digits in those alphabets — and raise `BovnarArgumentError` here and for a
signed `write_bvni`.

#### `write_float(key, value, *, width=64, unit_str=None, unit_si_base=None, unit_si_prefix=SIPrefix.NONE, unit_si_exp=Exponent.LINEAR, unit_source=None)`

Write an IEEE 754 binary float.

#### `write_float_fix(key, value, *, width=64, q=0, unit_str=None, unit_si_base=None, unit_si_prefix=SIPrefix.NONE, unit_si_exp=Exponent.LINEAR, unit_source=None)`

Write a Q-format fixed-point value. `q` is the number of fractional bits
(`0 ≤ q < width`).

#### `write_float_dec(key, value, *, width=64, unit_str=None, unit_si_base=None, unit_si_prefix=SIPrefix.NONE, unit_si_exp=Exponent.LINEAR, unit_source=None)`

Write an IEEE 754-2008 decimal float.

#### `write_string(key, value)`

Write a bare quoted UTF-8 string with no type annotation:
```bovnar
.host = "localhost";
```
To emit an explicit `<utf8>` annotation, use the low-level `emit` API with
`Event.TYPE_ANNOTATION_START` / `TYPE_ANNOTATION_END` before `Event.DATA`.

#### `write_bool(key, value)`

Write a `bool` value (`token_is_bool`) — serialized as the bare keyword
`true` or `false`. On read-back it decodes to a Python `bool`.

#### `write_null(key)`

Write a null value (empty slot).

### 12.4 Extended integer writers

#### `write_bvni(key, value, *, width=64, base=10, signed=None, unit_str=None, unit_si_base=None, unit_si_prefix=SIPrefix.NONE, unit_si_exp=Exponent.LINEAR, unit_source=None)`

Arbitrary-width integer writer that supports all Bovnar numeral bases (**2–62, 64, and 85**). Non-decimal values are formatted using Python's own
big-integer arithmetic and emitted as quoted strings.  `signed` defaults to
`True` when `value < 0`.

#### `write_bvnf_base(key, value_str, *, width=0, base=10, unit_str=None, unit_si_base=None, unit_si_prefix=SIPrefix.NONE, unit_si_exp=Exponent.LINEAR, unit_source=None)`

Write a float from a pre-formatted string in base 10 or 16. `base` must be
10 or 16; any other value raises `BovnarArgumentError`.

### 12.5 Struct helpers

```python
w.begin_struct(key)   # emit ASSIGNMENT_START + STRUCT_START, increment depth
w.end_struct()        # emit STRUCT_END, decrement depth
```

`finish()` verifies that the struct depth is zero; an unclosed struct raises
`BovnarWriteError(GOT_INCOMPLETE_BVNR_STREAM)`.

### 12.6 Version directive

```python
w.write_version()                   # "#!bovnar 1.2": the spec this build implements
w.write_version(major=1, minor=1)   # or declare an older version explicitly
```

Emits a leading `#!bovnar <major>.<minor>` directive. With no arguments it
names the spec version this build implements (`bovnar.spec_version()`, 1.2) —
the same directive `emit_version=True` writes; pass `major` and `minor`
together to declare an older one. Must be called before any value is written;
calling it after output has begun raises `BovnarWriteError(INVALID_ARGUMENT)`.
Use it to self-declare the spec version a document targets (spec-1.1
constructs such as datetime literals need at least 1.1 to re-read, a
profile-notation unit such as `ucum:[IU]` needs 1.2).

### 12.7 Array helpers

```python
w.begin_array_row()   # emit ARRAY_ROW_START
w.end_array_row()     # emit ARRAY_ROW_END
w.new_array_dim()     # emit ARRAY_DIM_START (the / separator between rows)
```

### 12.8 Low-level `emit`

```python
w.emit(event, *, key=None, value=None, vt=None, vu=None)
```

Send an arbitrary event to the C writer. `key` and `value` are encoded as
UTF-8. When both `vt` and `value` are supplied the token type is inferred:
`_TOKEN_IS_STRING` for `ValueTypeFamily.UTF8`, and also for a `vt` whose base
is a radix other than 10 (`uint`, `sint` or `float` with `vt.base` not 0 or 10,
whose literal must be quoted); `_TOKEN_IS_NUMBER` otherwise. The base field of
`float_fix` (Q), `float_dec` and `datetime` (the epoch index) is not a radix and
never makes the value a string.

### 12.9 Octets, embedded documents and mux streams

```python
from bovnar import Writer, stream

w = Writer.to_mem(pretty=False)
w.write_uint("before", 1)
w.mux_begin("media")              # .media = <multiplexed octet stream>
w.mux_send(1, b"frame-1")         # one message on channel 1
w.mux_send(2, b"audio")
w.mux_end()
w.embed_document("inner", b".x = 5;")   # a document as an octet value
w.write_octets("raw", b"\x00\x01")       # any bytes-like object
w.write_uint("after", 2)
w.finish()
doc = w.get_output()
stream.mux_load(doc, key="media")       # → [(1, b'frame-1'), (2, b'audio')]
stream.parse_embedded(bovnar.loads(doc)["inner"])   # → {'x': 5}
```

`mux_begin`, `mux_send` and `mux_end` wrap `bvnr_mux_begin` / `_send` /
`_end`: the stream is one value of the document being written, so values may
come before it (read back before the first message) and after it, and a
document may carry more than one. The bytes of a stream are those `mux_dump`
writes. `embed_document` wraps `bvnr_embed_document`, embedding into the
document being written rather than making a document of one key, as
`stream.embed_document` does. A key is held to the rule of `dumps()`
(`BovnarArgumentError`), that of `write_octets` too, a channel to the unsigned
64-bit range, and a payload or a document must be bytes-like (or a `str`,
written as UTF-8). On a
`to_stream` writer, call `flush()` after `mux_send` for a consumer reading
live (§4.8).

### 12.10 Observing the output: `on_event`

`on_event(event, data)`, given to any constructor (§12.1), is called with each
event once it has been serialised (`bvnr_write_flags_t.on_event`), with what
reached the output: under `UnitFlags.REDUCE` a rescaled value's new digits and
unit, not the ones it was written with. Every event the writer serialises
reaches it — those of the `write_*` methods, `emit`, the struct and array
helpers, the mux and embedding calls. `data` is a `BvnrData` snapshot, as a
reader callback gets (`None` for an event without one), which may be kept.

```python
from bovnar import Writer, UnitFlags

seen = []
w = Writer.to_mem(pretty=False, unit_flags=UnitFlags.REDUCE,
                  on_event=lambda ev, d: seen.append((ev.name, d.raw_bytes())))
w.write_uint("d", 5, unit_str="k~m")
w.finish()
w.get_output()                       # → b'.d=<uint:64,m>5000;'
seen[0]                              # → ('ASSIGNMENT_START', b'd')
seen[-1]                             # → ('DATA', b'5000')
```

Returning `False` (any false value but `None`) fails the write, and the
writer, with `SCANNER_CALLBACK_FAILED`: a veto, say of a key that must not
leave the process. An exception the observer raises fails it the same way and
is re-raised from the write call that ran it, unchanged; a later call raises
the latched `BovnarWriteError`, chained to that exception. The observer runs
inside the C call, so it may not write to, flush, finish, reconfigure
(`set_unit_policy`) or destroy its own writer (`BovnarArgumentError`). It costs
a Python call per event.

### 12.11 Plain values, datetimes and type annotations

```python
from bovnar import Event, ValueTypeFamily, Writer, make_type_spec

w = Writer.to_mem(pretty=False, emit_version=True)
w.write_plain("mode", "auto")                     # a bare symbol
w.write_datetime("t", 1750000000, epoch="tai")
u8 = make_type_spec(ValueTypeFamily.UINT, 8)
w.emit(Event.ASSIGNMENT_START, key="v")
w.write_type_annotation(u8, "m")                  # types the whole array
w.begin_array_row()
w.emit(Event.DATA, value="1", vt=u8)
w.emit(Event.DATA, value="2", vt=u8)
w.end_array_row()
w.finish()
w.get_output().split(b"\n")[1]      # → b'.mode=auto;.t=<datetime:64,tai>1750000000;.v=<uint:8,m>[1,2];'
```

`write_plain(key, value)` writes `value` bare, as a plain symbol
(`bvnr_write_plain`): `.mode = auto;`. The C writer refuses a value that is
not a symbol or is a reserved word (`null`, `true`, `false`, `on`, `off`,
`nan`, `inf`, `ninf`) with `TYPE_VALUE_MISMATCH`, an empty one with
`EMPTY_IDENTIFIER` and one too long with `SYMBOL_TOO_LONG`. A NUL or a lone
surrogate in it is a `BovnarArgumentError`, and so is a key `dumps()` would
refuse, both raised before anything is written.

`write_datetime(key, value, *, width=64, epoch=None)` writes `value`, signed
seconds since `epoch` (`"unix"`, the default, `"tai"`, `"gps"`, `"mjd"`,
`"ntp"`, `"galileo"`, `"glonass"`, `"y2000"`, `"beidou"`), as
`<datetime:width[,epoch]>` (`bvnr_write_datetime`). Datetime is spec 1.1, so the
document must declare it first — `emit_version=True`, or `write_version()`
before any value; without that the write fails with `UNSUPPORTED_SPEC_VERSION`,
the key already in the output and the writer in its error state. An epoch the
library does not know is a `BovnarArgumentError`, raised before anything is
written. A datetime with a sub-second fraction is written by `dumps()` from a
`Quantity` (§6.4).

`write_type_annotation(vt, unit=None, *, unit_source=None)` writes the type
annotation `<family:width[,_base|,qQ|,epoch][,unit]>` for the value written
next (`bvnr_write_type_annotation`): after an assignment's key, with `emit`
sending the value, or before an array's first row, where it types the whole
array. `unit` is a `Unit`, a `ValueUnit` or a unit string, `unit_source` its
spelling as in §12.3. As for the typed writers, only the parameters that say
something are written: no width of 0, no base 10, no unit on a string or a
datetime.

The other per-type C helpers have their counterpart above: `bvnr_write_string`,
`_null` and `_bool` in `write_string`, `write_null` and `write_bool`;
`bvnr_write_uint`, `_sint`, `_float`, `_float_fix`, `_float_dec` and their
`_unit` forms in the methods of §12.3; `bvnr_write_bvnf`, `_bvnf_unit`,
`_bvnf_base`, `_bvnf_base_unit`, `bvnr_write_bvni` and `_bvni_unit` in
`write_bvnf_base` and `write_bvni` (§12.4); `bvnr_write_struct_start` and
`_end` in `begin_struct` and `end_struct` (§12.5); `bvnr_writer_set_unit_source`
in `unit_source=`.

---

## 13. DOM API

The DOM API parses a complete BVNR document into an in-memory tree for
random-access queries without writing a SAX callback.

### 13.1 `dom_parse(data) -> DomDoc`

Top-level convenience function (mirrors `DomDoc.parse`).

```python
import bovnar
doc = bovnar.dom_parse(bvnr_bytes)
```

### 13.2 `DomDoc`

Owning wrapper around `bvn_dom_doc_t`. Destroying the object frees the entire
tree; any `DomNode` derived from it becomes invalid after that point.

| Method / property | Description |
|---|---|
| `DomDoc.parse(data)` | Class method. Parse `bytes \| bytearray \| memoryview`. |
| `DomDoc.parse_fd(fd, policy=None, *, max_bytes=0)` | Class method. Parse from an open file descriptor. A read error raises the matching `OSError` (`EBADF` for a bad descriptor, `IsADirectoryError` for a directory, …), input over the cap `BovnarParseError(FILE_TOO_LONG)`. `max_bytes` lowers the cap of 256 MiB (`bvn_dom_parse_fd_ex`); `0`, or more than 256 MiB, keeps it. |
| `DomDoc.parse_file(path, policy=None, *, max_bytes=0)` | Class method. Open path and parse (fd closed in `finally`), under the cap of `parse_fd`. This ceiling is distinct from `Reader.read_file`'s `MAX_FILESIZE_BYTES` (16 MiB). Raises as `parse_fd` does, with the path on the `OSError`. |
| `doc.parse_error` | `ErrorCode` — `NONE` on success. |
| `doc[key]` | Return a `DomNode` by key; raises `KeyError` when absent. `key` goes to the path lookup, so `doc["server"]` is a top-level entry and a dotted or indexed path reaches below it: `doc["server.tls"]` is `doc["server"]["tls"]`, `doc["matrix[1][0]"]` an element. A key cannot contain `.` or `[`, so the two readings never collide. |
| `key in doc` | `True` when `doc[key]` would succeed — the same path rule. A non-`str` is never in a document. |
| `len(doc)` | Number of top-level entries. |
| `iter(doc)` | Iterate over `(key, DomNode)` pairs. |
| `doc.entries()` | Return all top-level `(key, DomNode)` pairs as a list. |
| `doc.lookup(path)` | Dot-separated path lookup, e.g. `'server.tls.cert'`. Returns `None` when absent. |
| `doc.to_dict()` | Convert entire document to a plain Python dict, dropping type and unit info. |
| `DomDoc.create()` | Class method. An empty document to build in (§13.5). |
| `doc.add(key, node)` | Add a `NewNode` as a top-level entry; returns it as a `DomNode` (§13.5). |

> **File-size limits:** `DomDoc.parse_fd` and `DomDoc.parse_file` apply an internal
> ceiling of `BVN_DOM_FD_MAX_BYTES` (256 MiB, `bovnar.dom.DOM_FD_MAX_BYTES`).
> This is 16× larger than `Reader.read_file`'s `MAX_FILESIZE_BYTES` (16 MiB).
> `max_bytes=` lowers it, as the C library's `bvn_dom_parse_fd_ex` does; there
> is no way to raise it, and no unlimited mode.
>
> **The DOM takes no read options.** The C DOM parse functions read with a
> fixed set (no total cap besides the one above, the 255-level nesting, and the
> reader's defaults for everything else), and neither binding can pass more:
> read with a `Reader` (§11) or `loads` for the limits of §11.2.
>
> **Building a tree.** `DomDoc.create()` and `doc.add(key, node)` are the C
> DOM's builder, for a tree made by hand from `NewNode`s (§13.5). Such a tree
> cannot be written out; build a document with `dumps()` or the `Writer`.
>
> **Errors:** the C fd functions return `NULL` for an allocation failure, a read
> error and input over the cap alike; the binding tells them apart by `errno`.
> A read error is the matching `OSError`, input over the cap is
> `BovnarParseError(FILE_TOO_LONG)`, and only an allocation failure is
> `MemoryError` — the one `DomDoc.parse` can raise, since the in-memory parse
> returns `NULL` for nothing else.

### 13.3 `DomNode`

Non-owning view into a `bvn_dom_node_t`. The parent `DomDoc` must remain alive
for as long as any derived `DomNode` is in use.

| Property / method | Description |
|---|---|
| `node.dom_type` | `DomType` enum value |
| `node.value_type` | `ValueTypeSpec` |
| `node.unit` | `ValueUnit` |
| `node.unit_str` | Unit as a canonical string via `bvn_dom_get_unit_string`, or `''` when the value carries no unit — the same contract as `Quantity.unit_str` (the C formatter's `no_unit` spelling is never returned) |
| `node.is_null()` | `True` for null values |
| `node.value_in_base_units()` | Numeric value scaled to SI base units (`float`); an affine unit gets its offset applied (`25 °C` → `298.15`). Returns `0.0` for *any* failure — non-numeric node, currency, affine-in-compound — which is indistinguishable from a genuine zero, and it rounds to `double`. Use `units.unit_to_si_factor()` (which reports `ok`) or the `want_unit` hook when either matters |
| `node.as_i64()` / `as_u64()` | Signed / unsigned 64-bit integer |
| `node.as_i32()` / `as_u32()` | Signed / unsigned 32-bit integer |
| `node.as_i16()` / `as_u16()` | Signed / unsigned 16-bit integer |
| `node.as_i8()` / `as_u8()` | Signed / unsigned 8-bit integer |
| `node.as_float()` | `float` (64-bit) |
| `node.as_bool()` | `bool` for `BOOL` nodes |
| `node.as_str()` | Python `str` for `STRING`, `SYMBOL`, or `REFERENCE` nodes |
| `node.as_bytes()` | `bytes` for `OCTET_STREAM` nodes |
| `node.as_int_str(base=10)` | Integer value as a string in the given base; result C string is freed before return |
| `node.datetime_fraction` | `str` of the verbatim ISO sub-second digits for a `datetime` written as a literal with a fraction (spec 1.1), else `None`; the carrier value is unchanged and still read via `to_python()` |
| `node.unit_source` | `str` of the unit exactly as the document spelled it (library 2.0), where that differs from the canonical form — `ucum:mL{total}` for a unit of `m~L`; `None` for a node with no unit, one already written canonically, or one a unit policy converted |
| `node[key]` | Child `DomNode` by string key (STRUCT) or integer index (ARRAY) |
| `key in node` | Membership test for STRUCT nodes (a direct member only; a non-`str` is never one) |
| `len(node)` | Element count for STRUCT or ARRAY nodes |
| `iter(node)` | For STRUCT: iterate `(key, DomNode)` pairs; for ARRAY: iterate elements |
| `node.entries()` | List of `(key, DomNode)` pairs (STRUCT nodes only) |
| `node.array_dims()` | Number of `/`-separated dimensions (ARRAY nodes only) |
| `node.to_python()` | Recursively convert to a native Python value (drops type/unit info). A multi-row array comes back as a list of rows, empty rows included (`[]/[]` → `[[], []]`), as `loads()` gives it |

### 13.4 `DomType`

```
NULL=0  INT=1  FLOAT=2  STRING=3  SYMBOL=4
REFERENCE=5  STRUCT=6  ARRAY=7  OCTET_STREAM=8  BOOL=9
```

### 13.5 Building a tree (`NewNode`)

`DomDoc.create()` makes an empty document and `NewNode` the nodes to put in it:
the C DOM's builder (`bvn_dom_doc_create`, `bvn_dom_node_alloc`,
`bvn_dom_node_from_*`, `bvn_dom_struct_add`, `bvn_dom_array_append`,
`bvn_dom_doc_add`, doc/08 §4.5). It is there for code that consumes a DOM, a
test fixture say, and it has the C builder's limits: it makes structs, arrays,
nulls and integers only, and a built tree **cannot be written out**, since the C
library has no DOM serialiser. To produce a document, use `dumps()` or the
`Writer`.

```python
from bovnar import DomDoc, NewNode

server = NewNode.struct()
server.add("port", NewNode.integer(8080, width=16))
server.add("tls", NewNode.null())
ports = NewNode.array()
ports.append(NewNode.integer(80, width=16))
ports.append(NewNode.integer(443, width=16))
server.add("ports", ports)

doc = DomDoc.create()
doc.add("server", server)
big = doc.add("big", NewNode.integer(2**100, width=128, unit="m"))
doc["server.port"].as_u16()     # → 8080
big.unit_str                    # → "m"
doc.to_dict()                   # → {"server": {"port": 8080, "tls": None, "ports": [80, 443]}, "big": 1267650600228229401496703205376}
```

| Constructor / method | Description |
|---|---|
| `DomDoc.create()` | An empty document; its `parse_error` is `NONE` |
| `doc.add(key, node)` | Add `node` as a top-level entry and return it as a `DomNode` of `doc`. A parsed document takes one too |
| `NewNode.null()` / `NewNode.struct()` / `NewNode.array()` | An empty node of that kind (`bvn_dom_node_alloc`) |
| `NewNode.integer(value, *, width=64, signed=None, unit=None)` | An integer node of type `sint:<width>` or `uint:<width>`; `signed=None` is `sint` for a negative value and `uint` otherwise, as for `write_bvni`. A width of 8, 16, 32 or 64 is `bvn_dom_node_from_i8` … `_u64`, which take no unit; a width of 65 to 32768 is `bvn_dom_node_from_bigint`, which takes `unit` (a unit string, `Unit` or `ValueUnit`). A value that does not fit the type, another width, or a unit on a narrow width raises `BovnarArgumentError` |
| `node.add(key, child)` | Add `child` as a member of a struct node (`bvn_dom_struct_add`) |
| `node.append(child)` | Append `child` to an array node (`bvn_dom_array_append`) |
| `node.dom_type` / `len(node)` | The node's `DomType`; the number of members or elements |

**Ownership.** The C attach functions take the node they are given on every
path: on success the container owns it, and on failure they destroy it. A
`NewNode` hands its node over once. After it is attached it is spent, and any
further use of it (adding it again, adding to it, reading it) raises
`BovnarArgumentError`: a node is in one tree at most, and a struct cannot be
filled after it was attached, so a tree is built from its leaves up. A refused
attach raises before anything is handed over and leaves the node with the
caller; it is refused for a key that is not an identifier (§5.8; the C functions
refuse only an empty one, but the path lookups could not reach another), for a
key the struct or document already has (which the C function would refuse by
destroying the node), for a spent node, for a node added to itself, and on the
wrong kind of container. Only an allocation failure inside the C call is a
`MemoryError` with the node gone. A `NewNode` collected before it is attached
frees its subtree.

**Reading it back.** A built tree reads through `DomDoc` and `DomNode` like a
parsed one, with two differences that come from the C builder: an integer node
of up to 64 bits carries no unit, and a built array records no row shape, so its
`array_dims()` is `0` and `to_python()` gives one flat list.

Not bound: `bvn_dom_node_alloc` of the other kinds (a float, string, symbol,
reference, bool or octet-stream node it makes holds no value and cannot be given
one), `bvn_dom_strdup` (a C allocation helper; the attach functions copy the key
themselves), and `bvn_dom_get_bigint` (a borrowed `bvn_int_t`; `as_int_str()`
and `to_python()` already read an integer of any width).

---

## 14. Running the test suite

```bash
# Run everything (tests marked needs_lib are skipped when libbvnr.so cannot be loaded)
pytest

# Run only the pure-Python tests (no library required)
pytest -m "not needs_lib"

# Run only integration tests (requires libbvnr.so)
pytest -m needs_lib

# Verbose with short tracebacks
pytest -v --tb=short
```

`needs_lib` is a registered marker (`pyproject.toml`); `conftest.py` turns it
into a skip when the library cannot be loaded, so a run without `libbvnr.so`
skips those tests rather than failing them. A test module whose every test
calls into the library sets `pytestmark = pytest.mark.needs_lib`.

The NumPy- and pint-bridge tests `pytest.importorskip` their dependency, so they
are skipped automatically when `numpy` / `pint` is not installed. Install
`bovnar[all]` to run the full suite.

---

## 15. Package layout

```
bovnar/
├── __init__.py      # loads() / dumps() / load() / dump() / dom_parse() / unit helpers — public API
├── _ffi.py          # ctypes FFI: library discovery + argtypes/restype
├── _fileio.py       # sockets and binary file objects as callback sources and sinks
├── _bigint.py       # int <-> digit text past CPython's 4300-digit int_max_str_digits limit
├── _bvnfloat.py     # BvnFloat: arbitrary-precision float + IEEE binary/decimal/fixed encoders
├── _numpy.py        # NumPy bridge: to_numpy/from_numpy/to_pint_array/array_to_bvnr (numpy optional)
├── _pint_bridge.py  # pint bridge: to_pint/from_pint/to_pint_unit/from_pint_unit (pint optional)
├── _pint_units.py   # verified bovnar↔pint unit table + build_registry()
├── currency.py      # ISO 4217 / crypto currency metadata (pure Python)
├── dom.py           # DomDoc, DomNode, DomType, NewNode — random-access DOM and its builder
├── enums.py         # Python IntEnum mirrors of C enums
├── exceptions.py    # BovnarError hierarchy
├── literals.py      # the C validators, literal parsers/formatters, parse_type_annotation
├── quantity.py      # Quantity — typed, unit-annotated scalar for lossless round-trips
├── reader.py        # Reader, EventPayload, UnitPolicy, UnitRule, READ_OPTIONS, canonicalize, canonicalize_to
├── stream.py        # framing, octet multiplexing, document-in-document, the feeds, the live writers
├── structs.py       # ctypes Structure/Union definitions + ValueUnitPrefix + make_* helpers
├── timekeeping.py   # civil time and calendars: GregorianDate, CivilDateTime, Epoch (bvn_datetime.h)
├── unit.py          # Unit — a ValueUnit with equality, hashing and its operations
├── units.py         # unit_to_si_factor, unit_convert_factor, etc.
└── writer.py        # Writer class

tests/
├── conftest.py                          # shared fixtures, needs_lib marker
├── test_abi.py                          # ctypes struct sizes/offsets against the C compiler
├── test_enums.py, test_structs.py       # the enum and struct mirrors
├── test_version.py                      # library / spec version reporting
├── test_api_coverage.py                 # every read option at every entry point, on_error, the C API the bindings reach
├── test_plumbing.py                     # the writer's observer and nesting caps, the mirror, canonicalize, write_plain
├── test_reader.py, test_writer.py       # Reader and Writer
├── test_stream.py                       # framing, mux, document-in-document
├── test_stream_io.py                    # sockets and file objects as sources and sinks
├── test_stream_feed.py                  # DocumentFeed, FrameFeed, DemuxFeed
├── test_stream_vectors.py               # the shared wire vectors of doc/10 §9
├── test_stream_live.py                  # Writer.flush, MuxWriter, FrameWriter
├── test_dom.py, test_dom_vs_loads_parity.py   # the DOM, and its agreement with loads()
├── test_literals.py                     # bovnar.literals, the DOM builder, UnitConversion.ok
├── test_write_array.py, test_array_parser.py, test_array_roundtrip_fidelity.py
├── test_roundtrip_property.py, test_writers_agree.py, test_dumps_directive.py
├── test_escapes.py, test_unicode_normalisation.py
├── test_datetime.py, test_datetime_oracle.py
├── test_timekeeping.py                  # bovnar.timekeeping: calendar, leap seconds, GNSS time
├── test_lossless_floats.py, test_hex_float_decode.py, test_decimal_tie_rounding.py
├── test_bigint_digits.py                # integers past the 4300-digit str limit
├── test_units.py, test_unit_object.py, test_unit_physics.py, test_unit_source.py
├── test_unit_policy.py, test_unit_factors_derived.py
├── test_base_rendering_property.py, test_convertible_implies_convertible.py
├── test_currency_units.py, test_currency_table_parity.py
├── test_numpy_bridge.py, test_example_numpy_roundtrip.py      # need numpy
├── test_pint_bridge.py, test_pint_factor_parity.py            # need pint
└── test_example_consistency.py          # the shipped examples

bench/
└── bench_stream.py                      # per-message cost of the fed demux and the live writer, run by hand
```

---

## 16. Error handling

All errors surface as subclasses of `BovnarError`. `bovnar.error_message(code)`
is the library's text for an `ErrorCode` (`bvn_error_to_string`):
`"string_too_long"` for `ErrorCode.STRING_TOO_LONG`.

| Exception | When raised |
|---|---|
| `BovnarLibraryNotFound` | `libbvnr.so` could not be loaded — raised on first use of the C library, not at `import bovnar`; the message lists every path tried |
| `BovnarParseError` | Parse error in `Reader` (carries `code`, `line`, `column`, `offset`, `byte`) |
| `BovnarWriteError` | Write error in `Writer` (carries `code`, `offset`) |
| `BovnarArgumentError` | Invalid argument passed to a helper (e.g. bad unit string, closed reader/writer) |
| `TypeError` | An unknown keyword where read options are taken (`max_string_lenght=`) |
| `OSError` | `DomDoc.parse_fd` / `parse_file` could not read the input (the `errno` subclass: `IsADirectoryError`, …); an over-cap input is `BovnarParseError(FILE_TOO_LONG)` instead |
| `MemoryError` | The C library could not allocate (a reader, writer, or DOM tree) |

**`unit_convert_factor` error semantics:** The C function uses the
`(ok, requires_affine)` pair to signal four distinct outcomes:

| ok | requires_affine | Meaning |
|---|---|---|
| `True` | `False` | Multiplicative conversion; `factor` is ready to use |
| `True` | `True` | Both sides are affine and share their zero (e.g. °C ↔ °Re, Réaumur), so `factor` alone is correct |
| `False` | `True` | Affine conversion the offsets do not cancel (e.g. °C ↔ K). **`factor` is `0.0`** — not a usable multiplier |
| `False` | `False` | Units are dimensionally incompatible → `BovnarArgumentError` |

`unit_convert_factor` raises `BovnarArgumentError` **only** when both
`ok=False` and `requires_affine=False`; the result carries the other three as
`c.ok` and `c.requires_affine`, so check `c.ok` before multiplying by `factor`
(`ok` is a field with a default of `True`, so `UnitConversion(f, affine)` built
by hand still works). When it is `False`, call
`convert_value`, which handles the two-step affine path, or call
`unit_to_si_factor` on both units and convert by hand. The `Unit` method
`convert_factor()` returns the bare number and therefore raises for the
`0.0` row instead of returning it.

**Callbacks returning `False`:** When an `on_verified` or `on_unverified`
callback returns `False`, the C parser stops and `bvnr_read` returns failure.
The Python layer then calls `_raise_error()` and raises `BovnarParseError` with
whatever error code the reader recorded.  `BovnarCallbackAbort` is defined in
`exceptions.py` but is not raised by the current implementation.

**Callbacks raising exceptions:** If the callback itself raises a Python
exception, that exception is captured, the C callback returns `False` to stop
the parse, and the original exception is re-raised from `read_mem` / `read_fd`
after the C call returns.

---

## 17. FFI details

### 17.1 `ON_ERROR_FUNC` signature

The error callback type matches the C `bvnr_on_error_fn` signature exactly:

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

In Python this is declared as:

```python
ON_ERROR_FUNC = ctypes.CFUNCTYPE(
    None,
    ctypes.c_void_p,   # userdata
    ctypes.c_int,      # error_code_t err
    ctypes.c_uint64,   # line
    ctypes.c_uint64,   # column
    ctypes.c_uint32,   # byte
    ctypes.c_uint64,   # offset
)
```

### 17.2 `BvnrWriteFlags` layout

`BvnrWriteFlags` mirrors the C `bvnr_write_flags_s` struct in full, including
its `unit_flags` field (`bvn_unit_flags_t`, a `uint32_t`) and the
`emit_version` flag and `_reserved[4]` words that follow it:

| Flag constant (C) | Value | Effect |
|---|---|---|
| `BVN_UNIT_FLAGS_NONE` | `0` | Default: full Unicode exponent characters |
| `BVN_UNIT_REDUCE` | `1 << 0` | Reduce compound units before serialising |
| `BVN_UNIT_ASCII_EXP` | `1 << 1` | Use `^N` ASCII exponent notation instead of Unicode superscripts |
| `BVN_NUM_CANONICAL` | `1 << 2` | Write a float that `BVN_UNIT_REDUCE` rescaled in float shape (`5000.0`, not `5000`), and every annotation in one form (width written out, default `_10` and `no_unit` left out) |

They are set from Python with the `unit_flags=` keyword of `Writer.to_mem` /
`to_fd` / `to_file` / `to_stream`, as `UnitFlags` values; `emit_version=` sets
the struct's `emit_version` field. `max_struct_nesting=` and
`max_array_nesting=` set the struct's two nesting fields (`0` becomes 255,
1–255 is a lower cap), and `on_event=` sets its `on_event` observer
([§12.10](#1210-observing-the-output-on_event)).

The writer respects the `unit_flags` stored inside the C writer object when
serialising unit annotations. `_emit_annotation` retrieves the live flags via
`bvnr_writer_unit_flags(w)` and passes them to `bvn_unit_to_string_ex`.

### 17.3 `write_string` behaviour

`Writer.write_string` emits a bare quoted string with no type annotation,
matching `bvnr_write_string` in the C library:

```bovnar
.host = "localhost";
```

To write a string with an explicit `<utf8>` annotation, use the low-level
`emit` API with `Event.TYPE_ANNOTATION_START` / `TYPE_ANNOTATION_END` events
before the `Event.DATA` event.

### 17.4 `_resolve_unit` default

When no unit arguments are supplied to `write_uint`, `write_sint`,
`write_float`, etc., the unit resolves to `BVN_UNIT_NONE` (zero components),
matching the C convenience helpers. No unit annotation is emitted in this
case. Passing `unit_si_base` or `unit_iec_base` produces a unit with one
component; the `no_unit` keyword is only produced when the
caller explicitly constructs a dimensionless `ValueUnit` with `BaseUnit.NONE`.

### 17.5 Signals inside a callback

Where C calls back into Python — the event callbacks of `Reader` and its
`on_error` handler, `mux_load`'s per-message callback, `load_documents`, a
parsing `FrameFeed`, the writer's `on_event` observer, a reader's `mirror=`
sink, the sources and sinks of sockets and file objects — every exception the
callback raises is kept, the callback answers false so that C stops, and the
exception is re-raised once the C call has returned. (`on_error` answers
nothing, so its exception stops the read at the next event callback, or waits
for the read to end, §11.2.) One window is beyond the binding's reach: an exception
raised by a signal handler (`KeyboardInterrupt` from Ctrl-C) at the very entry
of a callback, before its body runs. ctypes reports it as "Exception ignored"
and the call goes on as if the callback had succeeded, so the interrupt is lost
and the callback's work for that event (a message, a pushed buffer) is missing.
Where an interrupt must stop the work reliably, use the pull-style feeds
(`DemuxFeed`, an unparsed `FrameFeed`), which make no Python callback per item,
or have the signal handler set a flag the loop checks.

---

## 18. `BaseUnit` enum

The `BaseUnit` enum mirrors the C `value_base_unit_e` for the two blocks of
the id space that have Python names: the native units (block 10) and the
currencies (block 90). See [doc/05 §11.1](05_bovnar_unit_system.md#111-enumerations) for the block layout — an id's leading
two digits name the vocabulary it comes from.

| Range | Members |
|---|---|
| 0 | `NONE` |
| 100000–100001 | `BIT`, `BYTE` |
| 100002–100008 | SI base units (`SECOND` … `CANDELA`) |
| 100009–100027 | Named SI derived units (`HERTZ` … `KATAL`) |
| 100028–100043 | Non-SI accepted units (`LITER` … `YEAR`) |
| 100044–100053 | Imperial/US length (`INCH` … `FATHOM`) |
| 100054–100061 | Imperial/US mass (`POUND` … `CARAT`) |
| 100062 | `FAHRENHEIT` |
| 100063–100066 | Pressure (`ATMOSPHERE` … `PSI`) |
| 100067–100070 | Energy (`CALORIE` … `THERM`) |
| 100071 | `HORSEPOWER` |
| 100072–100074 | Force (`POUND_FORCE`, `DYNE`, `KIP`) |
| 100075 | `KNOT` |
| 100076–100084 | US volume (`GALLON` … `BARREL`) |
| 100085–100086 | Area (`ACRE`, `BARN`) |
| 100087–100089 | Angle (`ARCMINUTE`, `ARCSECOND`, `GRAD`) |
| 100090–100097 | CGS units (`POISE` … `GALILEO`) |
| 100098–100100 | Radiation (`CURIE`, `ROENTGEN`, `REM`) |
| 100101–100102 | Logarithmic (`NEPER`, `DECIBEL`) |
| 100103 | `RANKINE` |
| 100104 | `SLUG` |
| 100105 | `THOU` |
| 100106–100108 | UK imperial volume (`PINT_UK`, `FLUID_OUNCE_UK`, `QUART_UK`) |
| 100109–100110 | Electrical power (`VAR`, `VOLT_AMPERE`) |
| 100111 | `KILOGRAM_FORCE` |
| 100112 | `INCH_HG` |
| 100113 | `RPM` |
| 100114 | `FOOT_POUND` |
| 100115–100116 | Mass additional (`DRAM`, `PENNYWEIGHT`) |
| 100117–100118 | Length additional (`CHAIN`, `ROD`) |
| 100119–100120 | Volume additional (`GILL`, `GILL_UK`) |
| 100121 | `STANDARD_GRAVITY` |
| 100122 | `METRIC_HORSEPOWER` |
| 100123 | `REVOLUTION` |
| 100124–100125 | Time additional (`MONTH`, `FORTNIGHT`) |
| 100126 | `ATMOSPHERE_TECHNICAL` |
| 100127–100128 | Textile linear density (`TEX`, `DENIER`) |
| 100129–100132 | Apothecary/dry volume (`FLUID_DRAM`, `MINIM`, `PECK`, `BUSHEL`) |
| 100133–100145 | Historical German units (`PFUND`, `ZENTNER`, `DOPPELZENTNER`, `LOT`, Prussian line/zoll/fuss/elle/rute, `KLAFTER`, `GERMAN_MILE`, `MORGEN`, `SCHEFFEL`) |
| 100146–100152 | Additional physical units (`SURVEY_FOOT`, `LEAGUE`, `CABLE`, `HAND`, `QUINTAL`, `SCRUPLE`, `BAUD`) |
| 100153–100156 | Temperature scales (`DELISLE`, `NEWTON_TEMP`, `REAUMUR`, `ROMER`) |
| 100157–100162 | Ratio/proportion units (`PERCENT`, `PER_MILLE`, `PER_MYRIAD`, `PER_CENT_MILLE`, `PPM`, `PPB`) |
| 100163 | `PH_SCALE` |
| 100164–100165 | Named speed units (`MILE_PER_HOUR`, `KILOMETER_PER_HOUR`) |
| 100166–100170 | Water hardness (`GERMAN_HARDNESS`, `ENGLISH_HARDNESS`, `FRENCH_HARDNESS`, `RUSSIAN_HARDNESS`, `AMERICAN_HARDNESS`) |
| 100171–100172 | Concentration (`VAL`, `GRAINS_PER_GALLON`) |
| 100173–100179 | Turbidity, salinity and conductivity (`TURBIDITY_NTU`, `TURBIDITY_FNU`, `PRACTICAL_SALINITY`, `CONDUCTIVITY_FACTOR`, `TURBIDITY_FTU`, `TURBIDITY_FAU`, `TURBIDITY_JTU`) |
| 100180–100185 | Temperature *differences* (`DELTA_KELVIN`, `DELTA_FAHRENHEIT`, `DELTA_DELISLE`, `DELTA_NEWTON_TEMP`, `DELTA_REAUMUR`, `DELTA_ROMER`) — `Δ°C` shares `DELTA_KELVIN` and `Δ°Ra` shares `DELTA_FAHRENHEIT` |
| 100186–100191 | The six units the profiles needed (`METER_WATER`, `CALORIE_IT`, `BTU_TH`, `TROY_POUND`, `APOTHECARY_DRAM`, `LONG_HUNDREDWEIGHT`) |
| 100192–100200 | US survey lengths (`SURVEY_INCH` … `SURVEY_ACRE`) |
| 100201–100203 | Typographic lengths (`POINT`, `PICA`, `LINE`) |
| 100204–100209 | US dry volumes and the trade measures (`DRY_GALLON`, `DRY_QUART`, `DRY_PINT`, `BOARD_FOOT`, `CORD`, `SURVEY_ACRE_FOOT`) |
| 100210–100214 | `DARCY`, `THERM_EC`, `REFRIGERATION_TON`, `DOBSON`, `SHAKE` |
| 100215–100216 | The ratios below ppb (`PPT`, `PPQ`) — spelled `pptr` and `ppq`; see "Why `pptr` and not `ppt`" in [doc/05 §3.25](05_bovnar_unit_system.md#325-ratio-and-proportion-units) |
| 100217–100225 | Nine units two or three vocabularies define (`PRINTERS_POINT`, `PRINTERS_PICA`, `HORSEPOWER_ELECTRIC`, `HORSEPOWER_BOILER`, `ABVOLT`, `ASSAY_TON`, `BUSHEL_UK`, `CLO`, `DEBYE`) |
| 100226–100236 | Calendar and sidereal time (`YEAR_TROPICAL`, `YEAR_GREGORIAN`, `YEAR_SIDEREAL`, `YEAR_COMMON`, `MONTH_SYNODAL`, `MONTH_TROPICAL`, `MONTH_SIDEREAL`, `DAY_SIDEREAL`, `HOUR_SIDEREAL`, `MINUTE_SIDEREAL`, `SECOND_SIDEREAL`) |
| 100237–100243 | CGS electrostatic (`STATVOLT`, `STATAMPERE`, `STATCOULOMB`, `STATFARAD`, `STATOHM`, `STATHENRY`, `STATSIEMENS`) |
| 100244–100246 | Luminance (`LAMBERT`, `APOSTILB`, `FOOTLAMBERT`) |
| 100247–100249 | Water-vapour permeance (`PERM_0C`, `PERM_23C`, `PERM_METRIC`) |
| 100250–100255 | Heat conventions (`CALORIE_MEAN`, `CALORIE_15C`, `CALORIE_20C`, `BTU_59F`, `BTU_60F`, `BTU_MEAN`) |
| 100256–100261 | `ELEMENTARY_CHARGE`, `SPERE`, `CIRCULAR_MIL`, `UNIT_POLE`, `HORSEPOWER_WATER`, `MONTH_GREGORIAN` |
| 100262–100263 | CGS electrostatic magnetics (`STATWEBER`, `STATTESLA`) |
| 900000–900165 | ISO 4217 fiat currencies (`AED` … `ZWL`), block 90 — see `CURRENCY_FIRST` |
| 900166–900215 | Cryptocurrencies (`BTC` … `RUNE`), the rest of block 90 — see `CURRENCY_LAST` |

There is **no `_SENTINEL`**. It named one past the highest member, which the C
tables were once indexed and bounded by; those tables are indexed by a dense
slot now, and over a blocked, sparse space a "one past the end" number would
only invite the bounds check it can no longer support. Use `UNIT_NATIVE_FIRST`
/ `UNIT_NATIVE_LAST` / `CURRENCY_FIRST` / `CURRENCY_LAST`, or ask
`bovnar.currency.is_currency`.

> **The profiles' opaque units are members too.** The C `value_base_unit_t`
> has a block per profile for the profile-only units — as shipped, UCUM's
> arbitrary atoms (200000–200039, `UCUM_IU` … `UCUM_IR`) and UN/ECE's package
> and count codes (300000–300024, `UNECE_ONE` … `UNECE_VIAL`), doc/11 §7.1 —
> and a parsed unit reports their ids, so `BaseUnit` names each one after its C
> enumerator, `bu_ucum_iu` → `BaseUnit.UCUM_IU`, as the Rust crate's
> `BaseUnit::UcumIu` does. (Until 2.0.0 it omitted them, and
> `ValueUnitComponent.base_unit` raised `ValueError` on `ucum:[IU]/mL`.) They
> have no native spelling and no dimension: `unit_to_str` returns the profile
> notation (`"unece:XBX"`), `unit_is_profile_only` is true, and pint has no
> form for them.

> **Note on `CUP`:** the Cuban-Peso currency is exposed as **`BaseUnit.CUP_`** (trailing
> underscore), not `BaseUnit.CUP`. The plain name `CUP` is the US-cup volume unit
> (enum value 100080); since Python enum member names must be unique, the currency
> at value 900033 takes the suffixed name. This affects the Python member name only — the
> `.bvnr` wire token is still the bare uppercase `CUP`, and case stays load-bearing
> (`cup` = volume, `CUP` = currency).

---

## 19. Spec 1.1 additions

These bindings target the **Bovnar spec 1.2**; spec 1.0 remains the frozen
baseline, and the package's own version (2.1.0) is a separate number
(`bovnar.version()` against `bovnar.spec_version()`). Spec 1.2's one addition,
the unit-profile notation, is covered in [§5.1](#51-the-unit-profile-notations-spec-12):
it is gated on a `#!bovnar 1.2` directive, which `dumps()` prepends when a
kept spelling or a profile-only unit needs it. The 1.1 features (all gated on a
`#!bovnar 1.1` directive — an unversioned document is treated as 1.0) are
exposed as:

- **Version:** `bovnar.version()` (library version string),
  `bovnar.version_number()` (the same as one int, `major * 10000 + minor * 100
  + patch`: 20100 for 2.1.0), `bovnar.spec_version()`
  → `(major, minor)` of the highest spec understood, and
  `bovnar.peek_version(data)` → the `(major, minor)` a document declares, or
  `None`. `Reader.declared_version` gives the same after a read; every reading
  entry point (`read_*`, `loads`, the feeds and stream readers) accepts
  `strict_version=True` to reject a too-new version
  (`ErrorCode.UNSUPPORTED_SPEC_VERSION`).
- **Richer escapes:** `\u{…}` and `\xHH` in string literals are decoded by the C
  reader, so `loads` returns the resulting text transparently. A surrogate /
  out-of-range `\u` is `ErrorCode.INVALID_CODEPOINT`.
- **Datetime family:** `loads` decodes a `<datetime:width,epoch>` value to a
  plain `int` (signed epoch seconds). With `typed=True` it is a `Quantity` whose
  `.epoch_name` (`"unix"`, `"tai"`, …) and `.epoch_mjd` recover the epoch, and
  `.to_civil()` its UTC date and time ([§6.5](#65-civil-time-and-calendars));
  `bovnar.datetime_epoch_index(name)` goes the other way, to the index a
  datetime `ValueTypeSpec` holds in `base`
  (`make_type_spec(ValueTypeFamily.DATETIME, 64,
  bovnar.datetime_epoch_index("gps"))`), `None` for an unknown name.
  `dumps()` emits the `<datetime:…>` annotation and prepends `#!bovnar 1.1`
  automatically when the object contains a datetime. With `typed=True` the
  sub-second fraction of an ISO-8601 literal (spec 1.1) is preserved on the
  `Quantity` and re-emitted, so a `loads(typed=True)`→`dumps()` round-trip is
  lossless (`…T12:00:00.5Z` survives verbatim). The plain (non-typed) value is
  only the whole-second `int` carrier, so a non-typed `loads`→`dumps` drops the
  fraction; to read it explicitly use the DOM tier (`DomNode.datetime_fraction`)
  or the streaming reader (`bvnr_data_t.frac_data` via a callback).
  `ValueTypeFamily.DATETIME` is the family enum member.
- **Reference array indexing:** `&.matrix[0][1]` paths are stored verbatim and
  resolved by `DomDoc.lookup("matrix[0][1]")` at the DOM layer.

`ErrorCode` adds `INVALID_SPEC_VERSION` (42), `UNSUPPORTED_SPEC_VERSION` (43),
`INVALID_CODEPOINT` (44), `INVALID_DATETIME_LITERAL` (45), and
`DATETIME_LITERAL_UNSUPPORTED_EPOCH` (46).

---

## See also

- [Read & Write API](08_bovnar_readwrite_api.md) — the C reader, writer, and DOM these bindings wrap
- [Specification](03_bovnar_spec.md) — the format the package reads and writes
- [Unit & Currency Reference](05_bovnar_unit_system.md) — the unit model behind `Quantity` and the bridges
- [Tutorial](01_bovnar_tutorial.md) — the document syntax, by example
- [FAQ §13 — Python Bindings](02_bovnar_faq.md#13-python-bindings) — common questions about this package
- [Rust Bindings](14_bovnar_rust_bindings.md) — the same tiers from Rust

---

*End of Bovnar — Python Bindings (Bovnar spec 1.2).*

<!-- ==================== Streaming & framing (https://www.bovnar.io/doc/10_bovnar_streaming.md) ==================== -->

# Bovnar — Streaming, Framing & Multiplexing

> **Spec version:** 1.2
> **Status:** Reference — protocols layered on the event API, not part of the wire format
> **Scope:** Endless streaming, multi-document framing, octet multiplexing, and embedded documents.

This document describes four capabilities layered **on top of** the Bovnar
lexer/validator event API, *beside* the DOM. None of them change the frozen 1.0
grammar or wire format (see the [Specification](03_bovnar_spec.md)); they are
applications built on the SAX-style event pipe and the octet-stream value form.

| # | Capability | Where it lives | Header |
|---|------------|----------------|--------|
| 1 | Endless streaming | core policy knob | `bovnar.h` |
| 2 | Multi-document framing | transport beside the lexer | `bovnar_stream.h` |
| 3 | Octet multiplexing | convention inside one octet stream | `bovnar_stream.h` |
| 4 | Document-in-document | a document embedded as octet payload | `bovnar_stream.h` |

The mental model: the **reader** turns bytes into a `bvnr_event_t` stream and the
**writer** turns events back into bytes. The DOM is one consumer of that pipe;
everything here is another. Octet streams (spec §9) are the binary substrate —
opaque, chunked, UTF-8-validation-suspended byte regions — and three of the four
capabilities are sub-protocols carried in or around them.

---

## Table of Contents

1. [Endless streaming](#1-endless-streaming)
2. [Multi-document record framing](#2-multi-document-record-framing)
    - 2.1 [Wire format](#21-wire-format)
    - 2.2 [API](#22-api)
    - 2.3 [CLI](#23-cli)
    - 2.4 [Push-fed frame reading](#24-push-fed-frame-reading)
3. [Octet multiplexing (out-of-band channels, cross-chunk)](#3-octet-multiplexing-out-of-band-channels-cross-chunk)
    - 3.1 [Wire convention (two layers)](#31-wire-convention-two-layers)
    - 3.2 [API](#32-api)
    - 3.3 [CLI](#33-cli)
    - 3.4 [Push-fed demultiplexing, with metadata before the stream](#34-push-fed-demultiplexing-with-metadata-before-the-stream)
4. [Document-in-document](#4-document-in-document)
5. [Python bindings](#5-python-bindings)
6. [Rust bindings](#6-rust-bindings)
7. [Composition](#7-composition)
8. [Live streams: callback I/O, push-fed reading, flushed writing](#8-live-streams-callback-io-push-fed-reading-flushed-writing)
    - 8.1 [Callback sources and sinks](#81-callback-sources-and-sinks)
    - 8.2 [The push-fed document reader](#82-the-push-fed-document-reader)
    - 8.3 [Draining without a callback per item](#83-draining-without-a-callback-per-item)
    - 8.4 [What it costs](#84-what-it-costs)
    - 8.5 [Writing live: a flush per message](#85-writing-live-a-flush-per-message)
9. [Shared conformance vectors](#9-shared-conformance-vectors)

- [See also](#see-also)

---

## 1. Endless streaming

Every byte/offset/line/column counter in the core is 64-bit, so a single
never-ending document — or a single octet stream of unbounded length — is
limited only by policy knobs on `bvnr_read_flags_t`: `max_file_size` for the
stream as a whole, and — for its *text* — `max_text_bytes`, whose default is
finite. With both left at `0` an octet stream may run without bound, but the
text of a document stops at 2 147 483 647 bytes (`error_text_data_too_long`);
raise `max_text_bytes` as well (to `UINT64_MAX` if need be) for a text stream
longer than 2 GiB — in Rust `ReadOptions::max_text_bytes`, in Python the
`max_text_bytes=` keyword (`stream.BVNR_NO_LIMIT`), and `max_array_items`
beside it for an array as long. (`max_file_size`
exists on `bvnr_write_flags_t` too, for symmetry, and the writer ignores
it: a writer never limits how much it writes.)

```c
#define BVNR_FILESIZE_UNLIMITED  0   /* in bovnar.h */
```

Semantics — **`0` is unlimited and the default**:

| `max_file_size` value | Effect |
|-----------------------|--------|
| `0` (= `BVNR_FILESIZE_UNLIMITED`, the default) | **No cap** — endless, no accumulated size limit |
| any positive N | Cap at N bytes (`error_file_too_long` beyond) |

```c
bvnr_read_flags_t fl = {
    .on_verified   = my_cb,
    .max_file_size = BVNR_FILESIZE_UNLIMITED,   /* no total cap */
    .max_text_bytes = UINT64_MAX,               /* endless text too */
};
```

A long-lived document is processed incrementally: process each value in the
`on_verified`/`on_unverified` callback and retain nothing, and memory stays flat
regardless of stream length. (The DOM builder, by contrast, retains a tree and
is therefore not the tool for an endless stream.)

---

## 2. Multi-document record framing

The grammar has no in-band document separator: a reader ends one document at EOF.
To carry a **sequence of independent documents** over one byte stream (file,
socket, pipe) without closing it, wrap each in a length-prefixed frame. The
framing lives beside the lexer; each payload is a complete, standalone document
parsed by an ordinary reader.

### 2.1 Wire format

```
frame   = "BVF1"            ; 4-byte magic
          payload_len       ; uint64, little-endian
          payload[payload_len]
stream  = { frame }         ; terminated by EOF on a frame boundary
```

A clean EOF *before any header byte* ends the sequence successfully; an EOF
part-way through a header or payload is a truncation error.

### 2.2 API

```c
/* Producer */
bool bvnr_frame_write(bvnr_sink_t* sink, const void* doc, uint64_t doc_len);

/* Consumer */
typedef struct {
    bvnr_read_flags_t* flags;            /* per-document reader callbacks/limits */
    void*              userdata;
    bool (*on_document)(void* ud, uint64_t index, bool ok, error_code_t err);
    bool               continue_past_failed; /* keep going after a failed document */
    uint64_t           max_document_size;    /* 0 => BVNR_DOC_DEFAULT_MAX (256 MiB) */
} bvnr_doc_stream_opts_t;

bool bvnr_doc_stream_read(bvnr_source_t* src,
                          const bvnr_doc_stream_opts_t* opts,
                          uint64_t* out_count);
bool bvnr_doc_stream_read_ex(bvnr_source_t* src,
                             const bvnr_doc_stream_opts_t* opts,
                             uint64_t* out_count, error_code_t* out_error);
```

`bvnr_doc_stream_read` reads frames until EOF, parsing each payload with a fresh
reader (reusing one handle internally) and invoking `on_document` after each. It
returns false on a framing/IO error, or on the first per-document parse failure
unless `continue_past_failed` is set. Each frame payload is buffered in RAM
before parsing (the buffer grows with the bytes that actually arrive, so a
crafted oversized length cannot force a large up-front allocation), bounded by
`max_document_size`. Unlike `max_file_size`, this framing guard stays finite when
`0` (it then selects `BVNR_DOC_DEFAULT_MAX`, 256 MiB); set an explicit huge cap
(e.g. `UINT64_MAX`) to lift it.

`bvnr_doc_stream_read_ex` is the same call, and says why it returned false in
`*out_error` with the codes the push-fed frame reader gives (§2.4): a bad magic
is `error_unexpected_input_byte`, a length over the cap
`error_value_out_of_range`, input that ends inside a header or a payload
`error_got_incomplete_bvnr_stream`, a failing pull
`error_reading_from_source_fd`, an `on_document` veto
`error_scanner_callback_failed`, and a failed document without
`continue_past_failed` that document's own code. The bindings' `load_documents`
reports these codes.

Two **orthogonal** error controls — because each document occupies its own
length-delimited frame, the two are independent:

| Control | Scope | Effect |
|---------|-------|--------|
| `flags->continue_on_error` | *within* a document | resync/recover from errors inside one document |
| `continue_past_failed` | *across* documents | report a failed document and advance to the next frame instead of aborting |

So you can parse each document **strictly** (`continue_on_error = false`) yet
**resiliently** process every frame (`continue_past_failed = true`), reporting the
bad ones via `on_document` — the natural mode for a log of independent records.

### 2.3 CLI

```sh
bovnar frames pack a.bvnr b.bvnr c.bvnr > docs.bvf   # wrap each file in a frame
bovnar frames list docs.bvf                          # list documents + status
bovnar frames list -                                 # read the frame stream from stdin
```

### 2.4 Push-fed frame reading

`bvnr_doc_stream_read` pulls from a source until it ends. For a frame stream
that arrives in pieces — a non-blocking socket, an event loop — the frame
reader is **fed** instead (§8), and keeps one reader for the whole stream:

```c
bvnr_frame_reader_t* bvnr_frame_reader_create(const bvnr_doc_stream_opts_t* opts);
bvnr_frame_reader_t* bvnr_frame_reader_create_pull(uint64_t max_document_size);
void         bvnr_frame_reader_destroy(bvnr_frame_reader_t* fr);
bool         bvnr_frame_reader_feed  (bvnr_frame_reader_t* fr, const void* buf, uint64_t len);
int          bvnr_frame_reader_next  (bvnr_frame_reader_t* fr, const uint8_t** doc, uint64_t* len);
bool         bvnr_frame_reader_finish(bvnr_frame_reader_t* fr);
error_code_t bvnr_frame_reader_error (const bvnr_frame_reader_t* fr);
uint64_t     bvnr_frame_reader_count (const bvnr_frame_reader_t* fr);
```

Two kinds, chosen when it is created:

- **Parsing** (`bvnr_frame_reader_create`): every document is parsed exactly as
  `bvnr_doc_stream_read` parses it with the same `opts` — the same events through
  `opts->flags`, the same `on_document` calls, `continue_past_failed` and
  `max_document_size`. Each payload is streamed into a reused push-fed reader as
  its bytes arrive, so a frame is never buffered whole, however large; the
  reader keeps its buffers from one document to the next, so once warm the
  frame reader allocates nothing per document. The one difference that
  streaming makes: a document whose frame the stream cuts off has had its
  events delivered up to the cut (with no `ev_stream_end` and no `on_document`
  call; `finish` reports `error_got_incomplete_bvnr_stream`), where
  `bvnr_doc_stream_read`, which reads each frame whole first, delivers none.
- **Pull** (`bvnr_frame_reader_create_pull`): the documents are handed out as
  bytes by `bvnr_frame_reader_next`, for the caller to parse as it likes. A
  document whose frame lies whole in the fed buffer is handed out from that
  buffer; one split across feeds is gathered in a buffer the reader owns and
  reuses. Either pointer is valid until the next call.

Feeding never fails for want of input; `bvnr_frame_reader_finish` is where a
stream cut inside a header or a payload is `error_got_incomplete_bvnr_stream`. A
bad magic is `error_unexpected_input_byte`, a declared length over the cap
`error_value_out_of_range`. The frame reader is not re-entrant: a feed or
finish from inside `on_document` or one of the reader's callbacks fails the
stream with `error_invalid_argument`, and the feed running stops after the
event that made the call.


---

## 3. Octet multiplexing (out-of-band channels, cross-chunk)

Many logical channels interleaved inside a **single octet-stream value**. The
channel id and message framing are a convention interpreted *above* `ev_data`,
so to the lexer these are ordinary `0x01` data chunks and `bvnr_data_t` needs no
channel field. The 1.0 wire format is untouched.

### 3.1 Wire convention (two layers)

```
per octet chunk:    varint(channel)  fragment_bytes
per channel:        concat(fragments in order) = the channel's logical stream,
                    a sequence of length-prefixed messages:
                        varint(msg_len)  msg_bytes
```

- `varint` is unsigned LEB128 (1 byte for channels/lengths < 128; up to 10 bytes).
- A message is **length-prefixed**, so it may span any number of chunks, and
  channels may interleave freely at chunk granularity — the demultiplexer
  reassembles each channel independently. Both the message data **and** its
  length varint may straddle chunk boundaries (the demux accumulates a partial
  length varint per channel).
- `BVNR_MUX_MAX_MESSAGE` is a conservative **single-chunk guarantee**, not an
  enforced cap: any message of at most that many bytes is emitted by
  `bvnr_mux_send` in one chunk regardless of channel id or length magnitude
  (it subtracts the maximum 10+10 varint bytes from the 64 KiB chunk). Larger
  messages are simply split; nothing rejects them.
- **The one wire requirement:** the per-chunk **channel** varint — the routing
  prefix the demux needs to attribute a chunk — is whole within its chunk.
  `bvnr_mux_send` always satisfies this (the channel id is ≤ 10 bytes and leads
  every chunk).

### 3.2 API

```c
/* Producer */
bool bvnr_mux_begin(bvnr_writer_t* w, const char* key);
bool bvnr_mux_send (bvnr_writer_t* w, uint64_t channel,
                    const void* data, uint64_t len);   /* split as needed */
bool bvnr_mux_end  (bvnr_writer_t* w);
bool bvnr_write_flush(bvnr_writer_t* w);   /* hand what was sent over now */

/* Consumer */
typedef bool (*bvnr_mux_on_msg_fn)(void* ud, uint64_t channel,
                                   const uint8_t* data, uint64_t len);

bvnr_demux_t* bvnr_demux_create(bvnr_mux_on_msg_fn on_message,
                                void* userdata, uint64_t max_message);
void          bvnr_demux_destroy(bvnr_demux_t* dm);
bool          bvnr_demux_set_key (bvnr_demux_t* dm, const char* key);
bool          bvnr_demux_on_event(void* demux, bvnr_event_t ev, bvnr_data_t* d);
error_code_t  bvnr_demux_error(const bvnr_demux_t* dm);
```

A message sent to an fd or callback sink waits in the writer's 64 KiB buffer
until it fills or the document finishes; a producer feeding a live consumer
calls `bvnr_write_flush` after each `bvnr_mux_send` (§8.5). The bytes are the
same either way.

Install `bvnr_demux_on_event` as `bvnr_read_flags_t.on_verified` with `userdata`
set to the `bvnr_demux_t*`. It peels the channel varint off each chunk,
reassembles per-channel messages (owning a buffer per active channel), and
invokes `on_message` for each complete message. A malformed varint, an oversized
declared length (> `max_message`, default 64 MiB), or an allocation failure
latches `bvnr_demux_error` and aborts the read. If `on_message` returns false the
read is aborted and `bvnr_demux_error` reports `error_scanner_callback_failed`
(distinct from the desync code, so a deliberate stop is distinguishable from a
framing error).

A separate, non-fatal case: if the octet stream *ends* while a channel is still
mid-message (or mid-length-varint), the octet framing itself was well-formed, so
the read still **succeeds** — but the partial message is dropped and
`bvnr_demux_error` latches `error_octet_stream_truncated`. This is deliberately a
different code from the desync `error_octet_stream_out_of_sync`: a truncated
message is a message-layer loss, not a framing error. Always check
`bvnr_demux_error` after the read even when it returned true.

**Key scoping.** By default the demux treats *every* octet stream in the document
as multiplexed. Call `bvnr_demux_set_key(dm, "mux")` so it only demuxes streams
opened under that key — a document can then mix one mux stream with ordinary
binary octet payloads (those under other keys are ignored, not misread as
framing). Pass `NULL` to clear the filter.

**Memory.** The demux keeps one reassembly buffer per channel it has seen; a
message that lies whole in one chunk is handed out from the chunk, so a buffer
grows only to that channel's largest message that spans chunks. It is retained
for reuse until `bvnr_demux_destroy`, so steady-state memory is the sum of
per-channel high-water marks (bounded by 4096 channels). Channels are not
reclaimed individually.

That 4096 is a **hard limit**, not only a memory estimate. A chunk naming a
4097th distinct channel cannot be attributed, so the read aborts and
`bvnr_demux_error` latches `error_octet_stream_out_of_sync` — the demux has lost
track of the stream, even though the wire framing was well formed. Because
channels are never reclaimed, the count is of *distinct channel ids seen over the
whole stream*, not of channels live at any one moment. A push-fed demux opened
again for the next document (`bvnr_demux_open_feed`) starts a new stream: its
ids count afresh, and the buffers are kept as spares.

```c
bvnr_demux_t* dm = bvnr_demux_create(on_message, ctx, 0 /* default cap */);
bvnr_read_flags_t fl = { .on_verified = bvnr_demux_on_event, .userdata = dm };
/* ... open_read_* + bvnr_read ... */
if (bvnr_demux_error(dm) != error_none) { /* desync, oversize, alloc, or a truncated message */ }
bvnr_demux_destroy(dm);
```

### 3.3 CLI

```sh
bovnar mux pack 1:req.bvnr 42:log.bvnr > mux.bvnr   # files onto channels 1 and 42
bovnar mux list mux.bvnr                            # channel <n>: <len> bytes
bovnar mux pack 7:a.bvnr | bovnar mux list -        # pipe producer to consumer
```

### 3.4 Push-fed demultiplexing, with metadata before the stream

A mux stream is one value of an ordinary document, so the document can say what
the stream is before it starts — and go on after it ends. The producer writes
the metadata with the normal writer, then opens the stream:

```c
bvnr_write_struct_start(w, "meta");
bvnr_write_string(w, "codec", "av1");
bvnr_write_float_unit(w, "rate", 32, 30.0, BVN_UNIT_NO_PREFIX(bu_hertz));
bvnr_write_struct_end(w);
bvnr_mux_begin(w, "media");             /* .media = \x00 ... */
bvnr_mux_send(w, 1, frame, frame_len);
/* ... */
bvnr_mux_end(w);                        /* ... \x00; */
```

which is, on the wire,

<!-- bovnar-example: illustrative -->
```bovnar
.meta = { .codec = "av1"; .rate = <float:32,Hz> 30.0; };
.media = \x00 …chunks… \x00;
```

A consumer reading that live hands the bytes to a **push-fed demux**: it owns a
push-fed reader (§8.2), keeps the key scope, `max_message`, the 4096-channel
limit and every error code of `bvnr_demux_on_event`, and the result is the same
as installing that on a reader of the whole buffer.

```c
bool bvnr_demux_open_feed(bvnr_demux_t* dm, const bvnr_read_flags_t* flags,
                          bool deliver_events);
bool bvnr_demux_feed  (bvnr_demux_t* dm, const void* buf, uint64_t len);
int  bvnr_demux_next  (bvnr_demux_t* dm, bvnr_demux_item_t* item);
bool bvnr_demux_finish(bvnr_demux_t* dm);
const bvnr_reader_t* bvnr_demux_get_reader(const bvnr_demux_t* dm);

typedef struct bvnr_demux_item_s {
    uint64_t           channel;   /* BVNR_NEXT_MESSAGE */
    const uint8_t*     data;
    uint64_t           length;
    bvnr_event_t       event;     /* BVNR_NEXT_EVENT */
    const bvnr_data_t* value;
} bvnr_demux_item_t;
```

**With callbacks** (an `on_message` given to `bvnr_demux_create`): every message
a feed completes is delivered before `bvnr_demux_feed` returns, and the caller's
`flags->on_verified` sees every event of the document — the metadata first, then
the stream, then whatever follows — so the metadata is known before the first
message:

```c
bvnr_demux_t* dm = bvnr_demux_create(on_message, ctx, 0);
bvnr_demux_set_key(dm, "media");
bvnr_read_flags_t fl = { .on_verified = on_meta_event, .userdata = ctx };
bvnr_demux_open_feed(dm, &fl, false);
while ((n = recv(sock, buf, sizeof buf, 0)) > 0)
    if (!bvnr_demux_feed(dm, buf, (uint64_t)n)) break;
bvnr_demux_finish(dm);
if (bvnr_demux_error(dm) != error_none) { /* as in §3.2 */ }
```

**Pulled** (no `on_message`): the fed bytes are drained with `bvnr_demux_next`,
one plain call per item and no callback at all (§8.3). Opened with
`deliver_events`, the document's own events come out of the same loop, in
document order and in order with the messages — the metadata values, the mux
stream's `ev_octet_stream_start`, its messages, its `ev_octet_stream_end`, the
values after it:

```c
bvnr_demux_t* dm = bvnr_demux_create(NULL, NULL, 0);
bvnr_demux_set_key(dm, "media");
bvnr_demux_open_feed(dm, NULL, true);
bvnr_demux_item_t it;
while ((n = recv(sock, buf, sizeof buf, 0)) > 0) {
    bvnr_demux_feed(dm, buf, (uint64_t)n);
    int rc;
    while ((rc = bvnr_demux_next(dm, &it)) > 0) {
        if (rc == BVNR_NEXT_EVENT)  on_meta(it.event, it.value);
        else                        on_frame(it.channel, it.data, it.length);
    }
    if (rc == BVNR_NEXT_ERROR) break;          /* bvnr_demux_error(dm) */
}
bvnr_demux_finish(dm);
while (bvnr_demux_next(dm, &it) > 0) { /* the closing events */ }
```

A message that lies whole in the fed buffer is handed out from it without a
copy, one whole in a chunk that arrived split across feeds from the reader's
chunk buffer, and one split across chunks is reassembled in the channel's
buffer. (The reader takes a fed buffer in windows of 1 MiB, and a chunk that
straddles a window's end is gathered like one split across feeds.)
Everything an item points to is valid until the next call on the demux, and the
fed buffer must stay valid until `bvnr_demux_next` has returned
`BVNR_NEXT_NONE` (feeding before that is refused). Truncation is a finish
result, as for any fed reader: `bvnr_demux_finish` returns what `bvnr_read` would
for the document, and a message the stream's end cut short is still the latched,
non-fatal `error_octet_stream_truncated`. The demux is not re-entrant: a feed,
next, finish or re-open from inside `on_message` or one of the reader's
callbacks fails the stream with `error_invalid_argument`, and the call running
stops after the event that made it.


---

## 4. Document-in-document

Embed a complete Bovnar document as the octet-stream payload of a key. The outer
parser treats the inner document as opaque octets; only an application that knows
the key holds a nested document re-feeds those bytes to a sub-reader. This is the
natural recursion primitive — no envelope type, just the octet-stream value form.

```c
/* Producer: emits  .<key> = <octets of doc>;  (64 KiB chunks) */
bool bvnr_embed_document(bvnr_writer_t* w, const char* key,
                         const void* doc, uint64_t doc_len);

/* Consumer: parse the embedded bytes with an ordinary reader (recursion). */
bool bvnr_parse_embedded(const void* doc, uint64_t doc_len,
                         bvnr_read_flags_t* flags);
```

To recurse while reading the outer document, accumulate the octet chunks for the
known key between `ev_octet_stream_start` and `ev_octet_stream_end`, then call
`bvnr_parse_embedded` on the accumulated bytes with the nested callbacks:

```c
case ev_octet_stream_end:
    if (in_payload)
        bvnr_parse_embedded(buf, len, &inner_flags);   /* parses the nested doc */
    break;
```

The inner callbacks may themselves embed/extract further documents, so nesting
is arbitrarily deep (bounded by your own recursion budget).

---

## 5. Python bindings

All four capabilities are exposed through the `bovnar.stream` module (a thin
ctypes layer over the C functions, so framing, varint reassembly and the
strict-vs-resilient document policy behave identically):

```python
import bovnar
from bovnar import stream

# Multi-document framing
blob = stream.dump_documents([{"id": 1}, {"id": 2}])     # list[dict] -> bytes
docs = stream.load_documents(blob)                        # bytes -> list[dict]
docs = stream.load_documents(blob, continue_past_failed=True)  # bad docs -> None

# Octet multiplexing (channel, payload) — any size, interleaved
m   = stream.mux_dump([(1, b"hello"), (42, b"world"), (1, b"x" * 200000)])
out = stream.mux_load(m)                                  # [(1, b"hello"), ...]

# Document-in-document
outer = stream.embed_document(bovnar.dumps({"v": 1}), key="payload")
inner = stream.parse_embedded(bovnar.loads(outer)["payload"])   # {"v": 1}
```

`stream.BVNR_FILESIZE_UNLIMITED` (`== 0`) selects endless mode for
**`max_file_size`** only (the text stays capped by `max_text_bytes`, §1). Note it does **not** lift the framing/mux guards:
for `max_document_size` and `max_message`, `0` selects their finite defaults
(`BVNR_DOC_DEFAULT_MAX` / `BVNR_MUX_DEFAULT_MAX`), so pass an explicit large
value there to raise them. See [Python Bindings](09_bovnar_python_bindings.md)
and `python/tests/test_stream.py`.

**Sockets and file objects.** The loaders (`load_documents`, `mux_load`, and
`bovnar.load` for one document) also take a socket or a binary file object and
read it to its end through a callback source (§8.1), parsing as they pull; the
dumpers take a `file=` and write through a callback sink. `mux_dump(...,
preamble={...})` writes the values that describe the stream before it opens
(§3.4).

**Live streams** (§8) are read with three feed objects over the push-fed C
readers, each handed bytes in pieces of any size: `DocumentFeed` (§8.2: a
document's events, or each top-level value once it is complete), `FrameFeed`
(§2.4: each document's bytes, or each document parsed) and `DemuxFeed` (§3.4:
`(channel, payload)` per message, and with `values=True` the values before and
after the mux stream, in document order). `DemuxFeed` and the unparsed
`FrameFeed` drain with `bvnr_demux_next` / `bvnr_frame_reader_next`, one plain
call per item and no callback (§8.3); a payload is `bytes`, or with
`view=True` a `memoryview` valid until the next call, which the feed then
releases:

```python
from bovnar import stream

with stream.DemuxFeed(key="media", values=True) as dm:
    for chunk in chunks:                          # whatever recv() returned
        for item in dm.feed(chunk):
            if isinstance(item, stream.Value):    # .meta before the first message
                on_meta(item.key, item.value)
            else:
                on_frame(item.channel, item.payload)
    for item in dm.finish():                      # raises if cut off part-way
        if isinstance(item, stream.Value):        # values that end the document
            on_meta(item.key, item.value)
```

The limits and error codes are those of the batch functions. A message costs
microseconds in Python — the interpreter and ctypes, not the C path of §8.4:
about 3 µs for a 150-byte message and 6 µs for a 30 KB one fed whole, against
2 µs and 8 µs through `mux_load`, on the machine of §8.4. The
figures, and what feeding in small pieces costs, are in
[Python Bindings §4.7](09_bovnar_python_bindings.md#47-live-streams-feeding-in-pieces)
(`python/bench/bench_stream.py`).

**Writing live** (§8.5) is `stream.MuxWriter(file, key=, preamble=)`, whose
`send(channel, payload)` hands each message to the socket or file before it
returns, and `stream.FrameWriter(file)`, one framed document per `write` or
`dump`; `Writer.flush()` does the same for a document written value by value.
The bytes are those of `mux_dump` and `dump_documents`:

```python
with stream.MuxWriter(sock, key="media", preamble={"meta": {"codec": "av1"}}) as mux:
    for channel, frame in frames:
        mux.send(channel, frame)                  # flushed: the reader has it now
```

A flushed send costs about 5.5 µs for a 150-byte message against 2.2 µs
batched (`flush_each=False`), the ctypes call and the call of the object's
`sendall`
([Python Bindings §4.8](09_bovnar_python_bindings.md#48-live-streams-writing-a-message-at-a-time)).

---

## 6. Rust bindings

The Rust crate has the same batch functions in `bovnar::stream`
(`frame_documents`, `load_documents`, `mux_dump`, `mux_load`,
`embed_document`, `parse_embedded`), over the C functions above. Like
Python's, they work on whole buffers. Their twins over `std::io::Read` and
`Write` (`load_documents_reader`, `mux_dump_to`, …), like `Reader::read_reader`
and `Writer::to_writer`, stream through the callback sources and sinks of §8.1:
they parse as they read and write as they serialise, with the in-memory
functions' results.

For a stream that never ends, `bovnar::codec` writes and reads the framing of
§2 and the multiplexing of §3 incrementally, in pure Rust: `MuxWriter` and
`MuxReader`, `write_frame` and `FrameReader`. The writer's output is byte for
byte that of `bvnr_mux_send` and `bvnr_frame_write`; the reader is fed bytes in
pieces of any size, keeps the demultiplexer's limits and error codes, hands a
message out without a copy when it lies whole in the input, and can peek at a
message's channel, length and first bytes to skip it or forward it (its
chunks unchanged where they lie consecutive in the input, re-encoded as
`MuxWriter` writes them otherwise). A multiplexed stream may open with a **preamble**, ordinary text
values in the same document before the stream's assignment
(`.meta = { … }; .media = <octet stream>;`). Nothing is allocated per message
once the reader's buffers have grown. See
[Rust Bindings §4.8](14_bovnar_rust_bindings.md#48-live-streams-the-framing-codec-bovnarcodec).

To parse documents as they arrive, `bovnar::live` wraps the push-fed readers of
§8: `DocumentFeed` over `bvnr_open_read_feed` (and `ValueFeed`, which hands out
each top-level assignment as a value once it is complete), `FrameParser` and
`FramePull` over the two kinds of fed frame reader (§2.4), and `Demux` over the
fed demultiplexer (§3.4). `FramePull` and `Demux` are drained one item per
call, like the codec's readers but without advancing the slice (the C reader
keeps its place; [Rust Bindings §4.9](14_bovnar_rust_bindings.md#49-live-documents-through-c-bovnarlive)), borrowed from the input without a copy
where it lies whole there, and the borrow checker keeps an item from outliving the next call.
With `deliver_events` the demux hands out the document's own events in order
with the messages, so the metadata before a stream is there before its first
message. `EmbeddedFeed` reads the documents embedded in a live document (§4)
by feeding their chunks to one reused reader, and a `Writer` takes
`embed_document` and `mux_begin` / `mux_send` / `mux_end` mid-document.

Either writer sends a stream message by message. A `Writer` into a
`std::io::Write` (`Writer::to_writer`) batches into the C writer's 64 KiB buffer,
and `Writer::flush` (`bvnr_write_flush`, §8.5) hands over what it holds and
flushes the destination, so a `flush` after each `mux_send` gives a consumer
every message as it is sent, inside a document that may hold any values before
and after the stream. `codec::MuxWriter` writes the same bytes with no C call,
for a stream that is the whole document after its preamble, and leaves the
flushing to the caller.
`MuxReader` stays the fast path for a stream that is one octet stream; `Demux`
takes a document of any shape — values after the stream, other octet streams
beside it, every reader limit. See
[Rust Bindings §4.9](14_bovnar_rust_bindings.md#49-live-documents-through-c-bovnarlive).

---

## 7. Composition

The four compose cleanly because they live at different layers:

- A **frame stream** (2) can carry documents that each **embed** sub-documents (4).
- A **multiplexed** octet stream (3) is itself just one value in a document, which
  can be one frame in a frame stream (2).
- Any of the above has no total-byte cap (1) by default (`max_file_size` 0 ==
  `BVNR_FILESIZE_UNLIMITED`); set a positive `max_file_size` to impose one.
  Octet streams are then endless; text is capped by `max_text_bytes` (§1).

Because each is a thin application over the public event API, you can also write
your own consumer/producer at the same seam without touching the core.

---

## 8. Live streams: callback I/O, push-fed reading, flushed writing

Everything above reads from a `bvnr_source_t` that is *pulled* until it ends.
That fits a file and a blocking socket. It does not fit a stream that never
ends and arrives when it arrives — a non-blocking socket in an event loop, a
binding that is handed bytes — because a pull that returns nothing *is* end of
input: a short feed would read as a truncated document. This section is the
other way in, and (§8.5) the writing side of it: a producer that hands each
message over as it is sent.

### 8.1 Callback sources and sinks

`bvnr_source_from_callback(s, pull, userdata)` and
`bvnr_sink_to_callback(s, push, flush, userdata)` put the caller's own transport
behind a source or sink, usable everywhere an fd or memory one is
([doc/08 §1.14](08_bovnar_readwrite_api.md#114-bvnr_source_from_callback),
[§2.17](08_bovnar_readwrite_api.md#217-bvnr_sink_to_callback)). The user pointer
lives inside the 64-byte handle, because the reader and writer work on a copy of
it; the callback reads it back with `bvnr_source_get_userdata` /
`bvnr_sink_get_userdata`. A callback sink fails as a transport does
(`error_writing_to_sink`), and the writer batches into its 64 KiB buffer before
pushing, or pushes early at `bvnr_write_flush` (§8.5).

### 8.2 The push-fed document reader

`bvnr_open_read_feed`, `bvnr_read_feed`, `bvnr_read_finish`
([doc/08 §1.15](08_bovnar_readwrite_api.md#115-push-fed-reading-bvnr_open_read_feed)):
the caller hands over bytes in pieces of any size and says when the document is
over. The events are exactly those `bvnr_read` delivers for the whole buffer,
wherever the pieces were cut, and so are the errors and their positions. Running
out of input is never an error until the finish. One reader serves an endless
document with flat memory (its text up to `max_text_bytes`, §1), and the frame reader (§2.4) and the demux (§3.4) are
built on it.

It is the same state machine `bvnr_read` runs, not a second one: the text lexer
already resumed at any byte, and the octet-stream framing — which used to pull
until a chunk was complete — is now a state machine of its own that stops at the
end of any buffer, so both readers share every line of it. A chunk that lies
whole in the input reaches the callbacks straight from it.

### 8.3 Draining without a callback per item

A caller that pays for every callback — a binding calling back through an FFI —
drains a feed in one loop instead: `bvnr_frame_reader_next` hands out documents,
`bvnr_demux_next` messages and, with `deliver_events`, the document's events.
Each returns `BVNR_NEXT_DOCUMENT` / `BVNR_NEXT_MESSAGE` / `BVNR_NEXT_EVENT` for an
item, `BVNR_NEXT_NONE` when the fed bytes are used up, and `BVNR_NEXT_ERROR` when
the stream failed. Items point into the fed buffer where they lie whole in it,
into the reader's own buffers otherwise, and are valid until the next call; the
fed buffer must stay valid until the drain has returned `BVNR_NEXT_NONE`.

### 8.4 What it costs

Measured with `bvnr_stream_bench` (`tests/bovnar_stream_bench.c`), an optimized
build (`RelWithDebInfo`), one core of an Intel i7-2700K (3.5 GHz, 2011); per
message, the 400 KB message spanning seven chunks. Figures move with the machine
and with what else runs on it; the ratios are the point. The rows of the table
were measured together, as the median of five runs, at the 2.1.0 tree. The same
cells are in the CLI: `bovnar bench --stream` runs them after the parse table,
`--stream-only` runs them alone, and `--stream-seconds <S>` sets the wall-clock
budget per cell (0.2 s by default). The CLI also measures a raw octet stream on
its own -- writing a document that holds one and reading it back -- at 1 KiB,
64 KiB, 1 MiB and 8 MiB, where an item is a chunk of up to 64 KiB. Its MB/s is
memory-to-memory: payloads are copied and stay cache-resident, so a row can
exceed disk or network throughput; it is the cost of the API and its copies,
not of I/O. `bvnr_stream_bench` remains the standalone harness for before/after
figures against an older library.

| Path | 150 B | 30 KB | 400 KB |
|------|------:|------:|-------:|
| `bvnr_mux_send` | 84 ns | 3.2 µs | 45 µs |
| `bvnr_read` + `bvnr_demux_on_event` | 63 ns | 0.10 µs | 39 µs |
| push-fed demux, callback, one feed | 65 ns | 0.10 µs | 39 µs |
| push-fed demux, `bvnr_demux_next`, one feed | 74 ns | 0.11 µs | 39 µs |
| push-fed demux, callback, 1460 B feeds | 69 ns | 3.6 µs | 69 µs |
| push-fed demux, `bvnr_demux_next`, 1460 B feeds | 75 ns | 3.7 µs | 69 µs |
| push-fed demux, `bvnr_demux_next`, 64 B feeds | 142 ns | 12.7 µs | 188 µs |
| `bvnr_mux_send` into a callback sink, batched | 92 ns | 4.4 µs | 61 µs |
| `bvnr_mux_send` + `bvnr_write_flush` into a callback sink | 104 ns | 4.2 µs | 61 µs |

The mux writer used to stage every chunk in its 64 KiB buffer before
serialising it, and the demux to find a channel by scanning the ones it had
seen. This round writes the chunks straight from the caller's buffer and looks
a channel up in constant time (direct table below 64, a hash above), and the
table is after both. At the commit before them the same benchmark (one run) put
`bvnr_mux_send` at 110 ns, 4.4 µs and 63 µs; 2.0.2, which also allocated a
chunk buffer per send and copied every demultiplexed message, recorded 262 ns,
8.2 µs and 121 µs for the send and 183 ns, 7.2 µs and 116 µs for the demux.

The callback-sink rows are the same writer against a sink that copies what it
is pushed, as a socket's send does, minus the system call; the memory-sink row
copies once. `bvnr_write_flush` hands each message over at once, so that row's
difference from the batched one is the sink's push of a partly filled block.

A message that fits one chunk and arrives in one feed is handed out without
touching its bytes, which is why the 30 KB column falls by a factor of thirty.
Fed in small pieces, a chunk is gathered before it is passed on, and a message
split over chunks is reassembled: one copy each.

Per framed document, a 43-byte one and a 30 KB one of numbers and strings
(measured together with the table above):

| Path | 43 B | 30 KB |
|------|-----:|------:|
| `bvnr_doc_stream_read` (2.0.2: the reader's buffers and the payload allocated per document) | 5.6 µs | 757 µs |
| `bvnr_doc_stream_read` | 1.9 µs | 584 µs |
| parsing frame reader, one feed | 1.8 µs | 584 µs |
| parsing frame reader, 1460 B feeds | 1.8 µs | 578 µs |

Re-opening a reader for the next document used to free its frame stack and
scratch buffers and allocate them again, about 120 KB zeroed, and to copy the
13 KB unit policy out of the validator and back while clearing it: more than a
small document costs to parse, and the frame reader paid it too (5.5 µs for the
43-byte document). A 30 KB document is dominated by its parse. `bvnr_mux_send`,
`bvnr_demux_next` and `bvnr_frame_reader_next` allocate nothing per message once
warm, nor the parsing frame reader and a re-opened fed demux per document; a
`bvnr_doc_stream_read` call allocates the same whatever the number of documents
(`tests/bovnar_alloc_test.c`). `bvnr_mux_send` also copies nothing beyond what
the sink takes: its chunks are written straight from the caller's buffer. A
writer with an observer is the exception — the observer is shown each chunk
whole, so the chunk is staged in the writer's mux buffer only then.

### 8.5 Writing live: a flush per message

The writer gathers what it writes for an fd or callback sink in a 64 KiB buffer
and pushes it when the buffer is full or at `bvnr_write_finish`, so a consumer
at the other end of a socket sees a stream of small messages in 64 KiB bursts,
or not at all until the producer stops. `bvnr_write_flush` hands everything
written so far to the sink, then calls the sink's flush callback if it has one
([doc/08 §2.18](08_bovnar_readwrite_api.md#218-bvnr_write_flush)). It is legal
between any two writer calls — inside an open mux stream as much as between two
values — and changes only when the bytes leave: a separator owed to the next
value stays owed, so the output is byte-identical however often it is flushed.
For a memory sink it does nothing.

A producer that streams frames to a live consumer writes its metadata, opens
the stream, and flushes after every message:

```c
static bool sock_push(bvnr_sink_t* s, const void* buf, uint32_t len)
{
    return send_all(*(int*)bvnr_sink_get_userdata(s), buf, len);
}

bvnr_sink_t sink;
bvnr_sink_to_callback(&sink, sock_push, NULL, &sock);
bvnr_writer_t* w = bvnr_writer_create();
bvnr_open_write_sink(w, &sink, false, NULL);

bvnr_write_struct_start(w, "meta");               /* .meta = { ... }; */
bvnr_write_string(w, "codec", "av1");
bvnr_write_float_unit(w, "rate", 32, 30.0, BVN_UNIT_NO_PREFIX(bu_hertz));
bvnr_write_struct_end(w);
bvnr_mux_begin(w, "media");                       /* .media = \x00 */
bvnr_write_flush(w);                              /* the consumer has .meta now */

while (next_frame(&channel, &frame, &len)) {
    if (!bvnr_mux_send(w, channel, frame, len) || !bvnr_write_flush(w))
        break;                                    /* error_writing_to_sink */
}

bvnr_mux_end(w);                                  /* \x00; */
if (!bvnr_write_finish(w)) { /* bvnr_writer_get_error(w) */ }
bvnr_writer_destroy(w);
```

At the other end a push-fed demux (§3.4) hands out `.meta` before the first
message and each message as soon as its last byte arrives, so message N is
there before message N+1 is sent (`tests/bovnar_feed_test.c` runs exactly this,
with the writer's sink feeding the demux). A failed push or flush latches like
any writer error. The bindings wrap it: `Writer.flush()` and
`stream.MuxWriter` in Python (§5), `Writer::flush` in Rust (§6).

There is no "flush after every message" option on `bvnr_mux_send`: it would
need a field in `bvnr_write_flags_t` or a second send function, and the call
after the send costs the sink's push of the bytes written so far — a few
nanoseconds to a few tens of them on a 150-byte message, depending on the
sink (the row difference in §8.4) — and allocates nothing.

---

## 9. Shared conformance vectors

`tests/stream_vectors/` holds one set of wire vectors that the C library, the
Rust crate and the Python bindings are all held to, so that the C reference and
the pure-Rust codec (`bovnar::codec`) cannot each agree with their own test code
and disagree with each other. `tests/gen_stream_vectors.py` writes them from the
wire conventions of §2.1 and §3.1 alone, never through the library, and lists
each with its expected outcome in `manifest.bvnr`, a Bovnar document: the
messages (channel, length and 64-bit FNV-1a of each), the preamble text, the
framed documents with the outcome each gives when parsed on its own, and the
error that ends the stream, named as `bvn_error_to_string` names it.

**What the set covers.** Multiplexed streams:

- a stream after a preamble of text values (a struct with a unit, a string) and
  the same stream without it — messages of 150 B, 30 KB and 400 KB, an empty
  one, one that fills a 65536-byte chunk (length field `0x0000`), two channels
  interleaved message by message;
- a stream with no message; channel ids of every varint width up to 2^64−1,
  with, for the widest, a message that fills a chunk exactly (65523 bytes after
  10 + 3 varint bytes) and one that spills a byte into a second chunk;
- several messages packed into one chunk, and a chunk that ends one message,
  holds another whole and starts a third;
- the chunks of two 100 KB messages alternating, with a small message between
  them that completes first;
- a length varint split across chunks, with another channel's chunk between
  its bytes;
- 4096 distinct channels, the most a stream may use, and a 4097th
  (`error_octet_stream_out_of_sync`);
- the closing `0x00` followed by whitespace and comments before and after the
  `;`;
- the ways a stream fails: closed inside a message
  (`error_octet_stream_truncated`), a bad chunk tag, a chunk ending inside its
  channel varint, an eleven-byte length varint and a channel varint past 64
  bits (`error_octet_stream_out_of_sync`), a length over a stated
  `max_message` (`error_value_out_of_range`), and input that ends inside a
  chunk or before the closing `0x00` (`error_read_complete_chunk_failed`);
- three documents the C demultiplexer reads and the codec, which reads one shape
  of document only ([Rust Bindings §4.8](14_bovnar_rust_bindings.md#48-live-streams-the-framing-codec-bovnarcodec)),
  refuses: a statement after the stream, a stream under
  another key, an octet stream before the mux stream. The manifest gives the
  codec's outcome beside the C one.

Frame streams: three documents, one empty; a document that does not parse and
one left incomplete among good ones; a bad magic; a frame over a stated
`max_document_size`; input that ends inside a document or inside a header; no
frames at all.

**Who reads and writes them.** The C demultiplexer, batch and fed, the pull and
the parsing frame reader, and `bvnr_doc_stream_read_ex`
(`tests/bovnar_stream_vectors_test.c`); `codec::MuxReader`,
`codec::FrameReader`, `live::Demux`, `live::FramePull`, `live::FrameParser`
and `stream::load_documents` (`rust/tests/test_stream_vectors.rs`); `mux_load`,
`DemuxFeed`, `FrameFeed` pulled and parsing, and `load_documents`
(`python/tests/test_stream_vectors.py`). The C and Rust readers take every
vector fed whole, a byte at a time, cut in two at every position (sampled
positions for the large ones) and in random pieces, the Python ones whole and in
small pieces; the parsing frame readers, with and without
`continue_past_failed`, report each document's own outcome. Every canonical
vector — what `bvnr_mux_send` writes — comes out byte for byte of
`bvnr_mux_send`, `codec::MuxWriter` (its preamble included), the C-backed
`Writer::mux_send`, `mux_dump` and `MuxWriter`; the frame streams that end
cleanly out of `bvnr_frame_write`, `codec::write_frame`, `frame_documents` and
`FrameWriter`. So each side reads what every other writes. `bovnar mux list` and
`bovnar frames list` read every vector as the manifest says
(`tests/check_stream_vectors_cli.py`), short of the two limits the CLI cannot
set. CTest checks that the committed files are what the generator writes.

---

## See also

- [Specification §9 — Octet Streams](03_bovnar_spec.md#9-octet-streams-binary-mode) — the binary substrate three of these four build on
- [Read & Write API](08_bovnar_readwrite_api.md) — the underlying event interface
- [Python Bindings](09_bovnar_python_bindings.md) — the `bovnar.stream` module described in §5
- [Rust Bindings](14_bovnar_rust_bindings.md) — the `bovnar::stream` module
- `include/bovnar_stream.h` — full declarations and per-function contracts
- `tests/bovnar_stream_test.c` — round-trip tests for all four capabilities
- `tests/bovnar_feed_test.c` — the push-fed readers, callback I/O and the flushed writer against the batch functions
- `tests/stream_vectors/` — the shared conformance vectors of §9, and `tests/gen_stream_vectors.py`, which writes them
- `python/bovnar/stream.py` — Python bindings; `python/tests/test_stream.py`,
  `test_stream_io.py`, `test_stream_feed.py`, `test_stream_live.py`

---

*End of Bovnar — Streaming, Framing & Multiplexing (Bovnar spec 1.2).*

<!-- ==================== Unit profiles (https://www.bovnar.io/doc/11_bovnar_unit_profiles.md) ==================== -->

# Bovnar — Unit Profiles

> **Spec version:** 1.2
> **Status:** Normative — the profile notation and the transliteration tables the parser translates through. The code is in `src/utils/bovnar_profiles.c` and pinned by `tests/bovnar_ucum_test.c`, `tests/bovnar_unece_test.c`, `tests/bovnar_qudt_test.c`, `tests/bovnar_udunits_test.c`, `tests/bovnar_om_test.c`, `tests/bovnar_cf_test.c`, `tests/bovnar_crossvocab_test.c` and `tests/bovnar_unit_source_test.c`. Section 10.4 lists the one part of this document that was not built, and §18.3 the licensing questions that are still open.
> **Scope:** How a foreign vocabulary's code may be written in the unit slot beside Bovnar's native notation, what it translates to, what it refuses, and what the format still guarantees once seven of them are admitted.

**Seven namespaces are defined.** Sections 1–10 specify the profile MECHANISM and, as its worked
example, the `ucum:` profile in full. Sections 11–13 specify the next four, §14 the suite that holds
them to each other and §15 the pass that closed their tables; §§16–17 specify the two vocabularies
admitted after that pass, which is why they sit after it rather than beside their siblings:

| Namespace | Vocabulary | Grammar | Codes carried | Section |
|---|---|---|---|---|
| `ucum:` | UCUM — Unified Code for Units of Measure | expression | 206 mapped + 41 arbitrary, **all 312 UCUM defines** | [2](#2-syntax)–[10](#10-cost-risk-and-what-is-left-out) |
| `unece:` | UN/ECE Recommendation 20 and 21 | flat | 1299 + 25 opaque | [11](#11-the-unece-profile) |
| `qudt:` | QUDT unit local names | flat | 2235, **all 2803 QUDT defines** | [12](#12-the-qudt-profiles) |
| `qudt-qk:` | QUDT quantity kinds | flat | 908, **all 1164 QUDT defines** | [12.3](#123-quantity-kinds-qudt-qk) |
| `udunits:` | UDUNITS-2, the CF/netCDF units syntax | expression | 768 (its regular plurals included), **all 580 UDUNITS defines** | [13](#13-the-udunits-profile) |
| `om:` | OM 2 — Ontology of units of Measure | flat | 1337 + 123 refused, **every unit individual OM states** | [16](#16-the-om-2-profile) |
| `cf:` | CF standard names | flat, read-only | 4454 + 617 refused, **all 5071 names of table v94** | [17](#17-the-cf-standard-name-profile) |

Companion to [Unit & Currency Reference](05_bovnar_unit_system.md) (the native registry and notation
grammar these profiles sit beside), [Unit Ambiguities](07_bovnar_unit_ambiguities.md) (how a unit token is
resolved, and the pairs that look interchangeable), and [Unit Policy](06_bovnar_unit_policy.md) (the
reader- and writer-side unit policies a profile unit has to survive unchanged).

**The four enumerable tables are closed against their publishers.** Every atom `ucum-essence.xml`
defines, every spelling the UDUNITS-2 database defines, and every local name and quantity kind QUDT
defines is now in one of the three lists — mapped, opaque, or refused with a reason. That was not
true before: 83 UCUM atoms, 299 UDUNITS spellings and some 3600 QUDT names came back as
`error_unit_illegal`, which tells a conforming producer their code is not a code of the vocabulary
they wrote it in. `unece` cannot be closed the same way and §11.1 says why. §15 records what the
pass changed and what it cost.

Every acceptance, refusal and conversion factor quoted below was produced by running the
reference implementation built from this tree, and the behavioural claims are pinned by the test
files named above (1483 assertions across the seven profiles, plus 4983 in the cross-vocabulary
suite). The VOCABULARY side — whether each foreign code is *worth* what the table says — is checked
too, but not by the same means and not to the same strength: `check_profile_factors.py` (§9.5)
resolves every mapped code against its publisher's own machine-readable definitions, and `unece`
alone is reached at one remove, through QUDT's cross-reference, because Rec 20 states its factors in
prose. §9.2 says what the generator proves on its own, §9.5 says what the outside check proves and
where its tolerance runs out, §10.2 records what is still uncovered, and §14 is explicit that five
tables wrong in the same way would agree with each other perfectly.

---

## Table of Contents

1. [Overview](#1-overview)
    - 1.1 [What a profile is](#11-what-a-profile-is)
    - 1.2 [Why a notation rather than more native units](#12-why-a-notation-rather-than-more-native-units)
    - 1.3 [The one thing that must not change](#13-the-one-thing-that-must-not-change)
2. [Syntax](#2-syntax)
    - 2.1 [The namespace discriminator](#21-the-namespace-discriminator)
    - 2.2 [Where a profile unit may appear, and in which documents](#22-where-a-profile-unit-may-appear-and-in-which-documents)
    - 2.3 [Five bytes the lexer has to learn](#23-five-bytes-the-lexer-has-to-learn)
    - 2.4 [Commas inside an annotation](#24-commas-inside-an-annotation)
    - 2.5 [Length budget](#25-length-budget)
    - 2.6 [Grammar](#26-grammar)
3. [Translation](#3-translation)
    - 3.1 [Three outcomes, and no fourth](#31-three-outcomes-and-no-fourth)
    - 3.2 [Atoms and prefixes](#32-atoms-and-prefixes)
    - 3.3 [Operators, exponents, grouping](#33-operators-exponents-grouping)
    - 3.4 [Annotations](#34-annotations)
    - 3.5 [Scale factors and the decade fold](#35-scale-factors-and-the-decade-fold)
    - 3.6 [Arbitrary units](#36-arbitrary-units)
    - 3.7 [Special units](#37-special-units)
    - 3.8 [Affine units](#38-affine-units)
4. [Semantics after translation](#4-semantics-after-translation)
    - 4.1 [Equality](#41-equality)
    - 4.2 [Compatibility and conversion](#42-compatibility-and-conversion)
    - 4.3 [Policy, normalisation, and the writer](#43-policy-normalisation-and-the-writer)
    - 4.4 [Invariants the profile preserves](#44-invariants-the-profile-preserves)
5. [Serialisation](#5-serialisation)
    - 5.1 [Canonical output](#51-canonical-output)
    - 5.2 [What canonical output loses, and on which path](#52-what-canonical-output-loses-and-on-which-path)
    - 5.3 [Emitting a profile code from a native unit](#53-emitting-a-profile-code-from-a-native-unit)
6. [The transliteration table](#6-the-transliteration-table)
    - 6.1 [Verified mappings](#61-verified-mappings)
    - 6.2 [Collisions — the same spelling, a different unit](#62-collisions--the-same-spelling-a-different-unit)
    - 6.3 [Traps that are not spelling collisions](#63-traps-that-are-not-spelling-collisions)
    - 6.4 [Codes with no Bovnar representation](#64-codes-with-no-bovnar-representation)
7. [Data model](#7-data-model)
    - 7.1 [The opaque units](#71-the-opaque-units)
    - 7.2 [Incommensurability, via the mechanism currencies already use](#72-incommensurability-via-the-mechanism-currencies-already-use)
    - 7.3 [The spelling on the data event](#73-the-spelling-on-the-data-event)
    - 7.4 [New error codes](#74-new-error-codes)
8. [API](#8-api)
    - 8.1 [C](#81-c)
    - 8.2 [Python](#82-python)
    - 8.3 [CLI](#83-cli)
9. [Build and conformance](#9-build-and-conformance)
    - 9.1 [Where the tables live](#91-where-the-tables-live)
    - 9.2 [What the generator checks, and what it does not](#92-what-the-generator-checks-and-what-it-does-not)
    - 9.3 [Tests](#93-tests)
    - 9.4 [One build switch per vocabulary](#94-one-build-switch-per-vocabulary)
    - 9.5 [The factor proof](#95-the-factor-proof)
    - 9.6 [Synchronisation between the tables](#96-synchronisation-between-the-tables)
10. [Cost, risk, and what is left out](#10-cost-risk-and-what-is-left-out)
    - 10.1 [What it cost](#101-what-it-cost)
    - 10.2 [What can go wrong](#102-what-can-go-wrong)
    - 10.3 [Deliberately not attempted](#103-deliberately-not-attempted)
    - 10.4 [Specified here but not built](#104-specified-here-but-not-built)
11. [The UNECE profile](#11-the-unece-profile)
    - 11.1 [Why this vocabulary](#111-why-this-vocabulary)
    - 11.2 [Flat, and why that is not a simplification](#112-flat-and-why-that-is-not-a-simplification)
    - 11.3 [Rec 21 packages, and the Rec 20 counts, as opaque units](#113-rec-21-packages-and-the-rec-20-counts-as-opaque-units)
    - 11.4 [A reading of the table](#114-a-reading-of-the-table)
12. [The QUDT profiles](#12-the-qudt-profiles)
    - 12.1 [Why this vocabulary](#121-why-this-vocabulary)
    - 12.2 [Local names, not IRIs](#122-local-names-not-iris)
    - 12.3 [Quantity kinds (`qudt-qk:`)](#123-quantity-kinds-qudt-qk)
    - 12.4 [The quantity-kind table: the ISO 80000 core](#124-the-quantity-kind-table-the-iso-80000-core)
13. [The UDUNITS profile](#13-the-udunits-profile)
    - 13.1 [An expression profile, sharing the UCUM parser](#131-an-expression-profile-sharing-the-ucum-parser)
    - 13.2 [Space multiplies, and now it can](#132-space-multiplies-and-now-it-can)
    - 13.3 [The near misses: codes that name a native unit and are not it](#133-the-near-misses-codes-that-name-a-native-unit-and-are-not-it)
    - 13.4 [Reference time is refused, and why](#134-reference-time-is-refused-and-why)
14. [The cross-vocabulary conformance suite](#14-the-cross-vocabulary-conformance-suite)
    - 14.1 [A concept table, checked pairwise](#141-a-concept-table-checked-pairwise)
    - 14.2 [The negative half is not optional](#142-the-negative-half-is-not-optional)
    - 14.3 [What it found, and what it cannot tell you](#143-what-it-found-and-what-it-cannot-tell-you)
15. [Closing the tables](#15-closing-the-tables)
    - 15.1 [What was wrong](#151-what-was-wrong)
    - 15.2 [How the rows were produced](#152-how-the-rows-were-produced)
    - 15.3 [What it cost, and what is still open](#153-what-it-cost-and-what-is-still-open)
16. [The OM 2 profile](#16-the-om-2-profile)
    - 16.1 [Why this vocabulary](#161-why-this-vocabulary)
    - 16.2 [The targets were derived from OM's own structure](#162-the-targets-were-derived-from-oms-own-structure)
    - 16.3 [What the derivation refuses](#163-what-the-derivation-refuses)
    - 16.4 [Which name a unit is written back as](#164-which-name-a-unit-is-written-back-as)
17. [The CF standard-name profile](#17-the-cf-standard-name-profile)
    - 17.1 [Why this vocabulary](#171-why-this-vocabulary)
    - 17.2 [The unit is CF's own `canonical_units`](#172-the-unit-is-cfs-own-canonical_units)
    - 17.3 [Read-only, and why a namespace may be](#173-read-only-and-why-a-namespace-may-be)
    - 17.4 [What is absent, and what it costs](#174-what-is-absent-and-what-it-costs)
18. [Provenance, licensing and attribution](#18-provenance-licensing-and-attribution)
    - 18.1 [What is taken from whom](#181-what-is-taken-from-whom)
    - 18.2 [Every table is an adaptation, and says so](#182-every-table-is-an-adaptation-and-says-so)
    - 18.3 [The two open questions](#183-the-two-open-questions)
- [See also](#see-also)

---

## 1. Overview

### 1.1 What a profile is

A **unit profile** is an alternative *spelling* for the unit slot of a type annotation. It is not a
second unit model. A profile expression is translated, at parse time, into exactly the same
`value_unit_t` a native expression produces, and from that point on nothing downstream can tell
which notation the document used:

```bovnar
#!bovnar 1.2
.systolic_a = <float_dec:64,ucum:mm[Hg]> 120.00;   # profile spelling
.systolic_b = <float_dec:64,mmHg>        120.00;   # native spelling — same value_unit_t
```

`bvn_unit_equal` reports those two units equal. `bvn_units_compatible`, `bvn_unit_convert_factor`,
the reader and writer unit policies, `bvnr_normalise_si`, the DOM and the
`want_unit` hook all keep working with no knowledge that a profile exists. That is the whole design
constraint, and §3.1 exists to hold it: a profile expression either becomes a real
`value_unit_t` or it becomes an error. There is no third state in which a value carries a unit the
rest of the library cannot reason about.

The general form is `namespace:code`, and seven namespaces are defined: `ucum`, `unece`, `qudt`,
`qudt-qk`, `udunits`, `om` and `cf`. An unknown namespace is an error (`error_unit_profile_unknown`), never a
passthrough — a consumer reads that code as "this build has no such profile", which is a different
problem from "that is not a unit".

Everything sections 2–10 say about `ucum:` describes the shared mechanism, because every profile
runs through the same translator, the same three outcomes and the same tables. What differs between
them is exactly two things:

- **The grammar.** An *expression* profile (`ucum`, `udunits`) writes a code as an expression over
  prefixed atoms, with operators, exponents and grouping. A *flat* profile (`unece`, `qudt`,
  `qudt-qk`, `om`, `cf`) has no operators and no prefixes at all: the whole code is one token, matched entire.
  That is not a simplification but a correctness requirement — UNECE's `KGM` is the kilogram, and a
  parser that decomposed it would find a `k` prefix on a `GM` that UNECE never defined, just as it
  would read `MTS` as a mega-`TS` and QUDT's `MI` (the mile) as a milli-anything.
- **The tables**, one set per namespace, generated from one data file per namespace by
  `gen_profiles.py`.

Two smaller switches ride on the registry row rather than on the grammar: which extra bytes multiply
and introduce an exponent (§13.1), and whether the vocabulary has UCUM's `{…}` annotations at all —
only `ucum` does, so `udunits:mL{x}` is `error_unit_illegal` rather than an annotated millilitre
(§3.4).

Adding a vocabulary is therefore a data file and a registry row, not a second parser.

### 1.2 Why a notation rather than more native units

Bovnar's native registry is 264 physical units and 216 currencies, hand-maintained in
`src/gendata/`. UCUM's atom table is larger — a complete clinical, apothecary, troy, avoirdupois
and CGS inventory — and its expression language is unbounded, so the set of valid UCUM codes cannot
be enumerated as a table of units at all.

Absorbing that into the native registry would mean adding several hundred aliases, most of them
spellings no Bovnar document would ever choose, and several of which collide with existing native
symbols (§6.2). A notation costs one table of mappings instead, keeps the two disambiguation
regimes apart, and lets a document produced by a UCUM-speaking system be read without the native
registry growing at all.

### 1.3 The one thing that must not change

The obvious way to get this wrong is to make `ucum:` a hole through which any string reaches a
value uninspected. Then `<float:64,ucum:metre>` parses, the typo survives to the consumer, and a
unit has arrived that the library cannot reason about — no dimension, no compatibility check, no
conversion. Every guarantee the unit system provides is a guarantee about units it understands.

So the rule in §3.1 is absolute, and every later section is written to keep it: a `ucum:`
expression is parsed as UCUM in full, against UCUM's own atom table, and anything that is not a
valid UCUM expression over known atoms is `error_unit_illegal` before translation is even
attempted. Passthrough exists (§3.6) but reaches only atoms UCUM itself defines and classifies as
arbitrary — assay quantities commensurable with nothing. It is never a fallback for something
unrecognised.

---

## 2. Syntax

### 2.1 The namespace discriminator

A profile unit is a namespace name, an ASCII colon, and a code:

```
ucum:mm[Hg]
ucum:10*3/uL
ucum:mL{total}
unece:KGM
qudt-qk:Mass
```

A **namespace name** is lowercase letters and digits, and may contain `-` — but not as its first
byte. The hyphen exists for `qudt-qk`, where one publisher's vocabulary is split into a unit
namespace and a quantity-kind namespace that must not be confused with it (§12.3). Because it may
not lead, `-qk:Mass` is not a namespace at all: it falls through to the native parser, which rejects
it exactly as it did before profiles existed.

```
$ bovnar validate t.bvnr        # .a = <float:64,-qk:Mass> 1.0;
Validation failed: unit_illegal at line 2, col 24
```

The colon is already an accepted byte inside a type-annotation body (state table
`copy_type_byte`, `[0x3a]`), and no native unit alias or currency code contains one, so the
discriminator is unambiguous against the entire existing registry with no lookahead.

It was also already an *error* before the profiles existed, which is what made the extension safe.
A `:` in a unit slot was `error_unit_illegal` then, and still is wherever the profile machinery
declines it as not a unit — in a document that has not opted in (§2.2), and in one that has, for a
code the named namespace does not define:

```
$ bovnar validate t.bvnr        # #!bovnar 1.1 / .a = <float:64,ucum:mmHg> 1.0;
Validation failed: unit_illegal at line 2, col 25
```

No document that parsed before spec 1.2 contained a `ucum:` unit, so no document changed meaning
when one started to parse. Contrast the alternative of a quoted form (`ucum:"mm[Hg]"`), which would put a
string-escape sub-language inside a type body for no gain.

The three refusals are distinguishable once a document *has* opted in, which is the whole point of
the split in §3.1:

```
$ bovnar validate t.bvnr        # #!bovnar 1.2 / .a = <float:64,zz:m> 1.0;
Validation failed: unit_profile_unknown at line 2, col 20

$ bovnar validate t.bvnr        # #!bovnar 1.2 / .a = <float:64,ucum:B[SPL]> 1.0;
Validation failed: unit_profile_unsupported at line 2, col 27

$ bovnar validate t.bvnr        # #!bovnar 1.2 / .a = <float:64,ucum:mmHg> 1.0;
Validation failed: unit_illegal at line 2, col 25
```

The last is worth reading twice: `mmHg` is a perfectly good *native* spelling and not a UCUM atom at
all — UCUM writes the millimetre of mercury `mm[Hg]`. Inside a namespace, only that namespace's
table is consulted.

### 2.2 Where a profile unit may appear, and in which documents

**The notation is gated on the declared spec version.** A profile unit needs a `#!bovnar` directive
declaring 1.2 or later — `#!bovnar 1.2` — exactly as the datetime family and the `\x`/`\u`
escapes need a declared 1.1. Without one it is `error_unit_illegal` — in a 1.1 document `ucum:mm[Hg]` is simply not a unit, the same way
`<datetime:64>` is simply not a value type in a 1.0 document. A document with **no** directive
declares nothing and therefore gets neither surface.

```bovnar
#!bovnar 1.2
.systolic = <float_dec:64,ucum:mm[Hg]> 120.00;   # OK
```

The gate is applied to the type-annotation unit parameter and to the inline unit suffix alike: an
inline unit is the same unit slot reached by another route, and gating one and not the other would
leave a hole exactly one comma wide.

Without the gate a document could carry a unit that every conforming reader of its own declared
version must reject, which is the interoperability hazard the directive exists to prevent. Native
units are unaffected in every version: the bump is additive, and `<float:64,mmHg>` parses under 1.0
as it always did.

The writer enforces the other half. A unit with no native spelling — one carrying an opaque base
unit (§7.1) — can only be emitted in this notation, so writing one without having emitted the opt-in
directive is `error_unsupported_spec_version` rather than a document the library cannot read back. A
*translated* unit needs no such guard when it is written from the unit alone: `ucum:mm[Hg]` parses
to `mmHg`, and `mmHg` is what `bvn_unit_to_string` gives back, which every version accepts. The
guard returns with the *spelling*: a writer handed `ucum:mm[Hg]` as the text to write (§5.2) is
writing profile notation, and needs the directive for it exactly as an opaque unit does.

Otherwise: everywhere a native unit may appear — as the unit parameter of a type annotation, and as
an inline unit suffix. Parameter ordering stays free (doc/05 §2.1) and the annotation/inline agreement rule
(doc/05 §2.2) is unchanged — the comparison is on the parsed `value_unit_t`, so the two spellings
may differ as long as they mean the same thing:

<!-- bovnar-example: rejected -->
```bovnar
#!bovnar 1.2
.a = <float:64,ucum:mm[Hg]> 120.0;
.b = 120.0 ucum:mm[Hg];
.c = <float:64,mmHg> 120.0 ucum:mm[Hg];   # OK — both parse to the same unit
.d = <float:64,m> 1.0 ucum:s;             # error_unit_mismatch, as always
```

A profile unit is a *unit*, so it is confined to the same type families (doc/05 §2.3): `uint`,
`sint`, `float`, `float_fix`, `float_dec`. A `ucum:` parameter on `utf8`, `bool` or `datetime` is
`error_illegal_value_type`, unchanged.

Currencies stay native-only. None of the seven vocabularies yields a monetary unit — QUDT's `USD`, OM's `om:euro` and
`EUR` are refused by name (§12.2) rather than mapped — and the `$` sigil rule (doc/05 §9.1) is
untouched.

### 2.3 Five bytes the lexer has to learn

UCUM needs `[`, `]`, `{`, `}` and `'` — the last for codes like `[arb'U]` and `[Amb'a'1'U]`. All
five were rejected by the type-body and inline-unit byte classes:

```
$ bovnar validate t.bvnr        # .a = <float:64,ucum:mm[Hg]> 1.0;   (before this change)
Validation failed: unexpected_input_byte at line 2, col 18
```

The change is **15 state-table entries** — five bytes in each of three states of
`src/lexer/bovnar_state_table.c`:

| Byte | Char | States extended |
|------|------|-----------------|
| 0x27 | `'`  | `copy_type_byte`, `type_body_outro`, `inline_unit_body` |
| 0x5B | `[`  | `copy_type_byte`, `type_body_outro`, `inline_unit_body` |
| 0x5D | `]`  | `copy_type_byte`, `type_body_outro`, `inline_unit_body` |
| 0x7B | `{`  | `copy_type_byte`, `type_body_outro`, `inline_unit_body` |
| 0x7D | `}`  | `copy_type_byte`, `type_body_outro`, `inline_unit_body` |

`type_body_outro` is extended alongside `copy_type_byte` because the two already carry an identical
unit-byte class; letting them diverge here would be the odd choice, and anything nonsensical that
reaches the type-spec parser through it is still `error_illegal_value_type`.

This is the only change to the unit *byte class* the profile requires — whitespace inside a
namespaced annotation parameter is the other lexer change, and has its own section (§13.2) — and it
is the one place where the profile is
*not* invisible to a native-only document: after it, `<float:64,m[s]>` reaches `bvn_parse_unit`
and fails there (`error_unit_illegal`) instead of failing in the lexer
(`error_unexpected_input_byte`). One error code moves for a family of inputs that were errors
before and are errors after. §9.3 pins that in the conformance corpus rather than leaving it to be
discovered.

Nothing else about the byte class changes. `<`, `>`, `"`, `;` and `#` stay out, so a brace cannot
swallow the rest of the document; whitespace stays out of an inline suffix, and inside an annotation
is admitted only within a parameter that carries a namespace (§13.2). Two consequences, and they land on different sides of
the lexer boundary:

```
$ bovnar validate t.bvnr        # .a = <float:64,ucum:mL{a;b}> 1.0;
Validation failed: unexpected_input_byte at line 2, col 25

$ bovnar validate t.bvnr        # .a = <float:64,ucum:mL{total> 1.0;
Validation failed: unit_illegal at line 2, col 29
```

That fences off one thing UCUM permits. UCUM allows any printable ASCII except braces inside an
annotation; this profile allows only the bytes a type body accepts, because `;` and `#` end a value
and `<` and `>` delimit a type annotation — so `;` is refused by the *lexer*, at the byte, while an
unclosed `{` simply runs to the value terminator and ends as a malformed unit. The alternative —
admitting a byte that terminates the construct it sits inside — is not a trade worth making for an
annotation the unit model ignores anyway.

### 2.4 Commas inside an annotation

A type-parameter list is comma-separated, and a UCUM annotation may contain a comma
(`{cells,total}`). The parameter scanner in `bvn_parse_type_annotation` therefore tracks brace depth: a
`,` at brace and bracket depth 0 ends the parameter, a `,` at either depth ≥ 1 is part of it.
Bracket depth needs no such treatment — `[` … `]` cannot contain a comma in any UCUM atom — but is
tracked anyway so that an unbalanced bracket is diagnosed as a malformed unit rather than as a
malformed parameter list.

```bovnar
#!bovnar 1.2
.a = <float:64,ucum:mL{cells,total}> 1.0;   # one unit parameter, not two
```

Depth is bounded by `BVN_UNIT_GROUP_MAX_DEPTH` (16), the same bound the native parenthesis parser
uses. UCUM annotations do not nest — `{` inside an annotation is not legal UCUM — so any depth
above 1 is already an error; the bound exists so that the *scanner* cannot be driven to recurse by
a hostile document before the parser gets to say so.

### 2.5 Length budget

Three caps sit in front of the profile parser, all of them 255 bytes, all of them pre-existing, and
which one fires decides which error a producer sees:

| Cap | Where | Overflow |
|---|---|---|
| the whole type-annotation body | `BVN_TYPE_BUF_CAP − 1`, in the lexer | `error_type_too_long` |
| one inline unit suffix | `BVN_INLINE_UNIT_BUF_CAP − 1`, in the lexer | `error_unit_too_long` |
| one unit *parameter* | `UINT8_MAX`, in `bvn_parse_type_annotation` | `error_unit_too_long` |

An annotated unit therefore reaches `error_type_too_long` first — the body cap counts the family
name, the width and the commas as well, so a unit parameter can never be the only thing over the
line. An inline unit has a buffer to itself and reaches `error_unit_too_long`:

```
$ bovnar validate t.bvnr        # .a = 1.0 ucum:mL{xxx…260 bytes…};
Validation failed: unit_too_long at line 2, col 265
```

UCUM codes with long annotations can exceed 255 — `mL/min/{1.73_m2}` does not, but a genuinely
chatty annotation will. No new limit and no new error: the profile inherits these, and a producer
that needs more than 255 bytes of unit is telling you the annotation is a field, not a unit.

### 2.6 Grammar

The formal rules live in [the EBNF](12_bovnar.ebnf) beside `unit-param`, which is
where the byte classes of §2.3 are also recorded:

```ebnf
unit-param   = profile-unit | native-unit ;
profile-unit = profile-name , ws , ":" , ws , profile-code ;
profile-name = name-head , {name-tail} ;         (* "ucum", "qudt-qk" *)
name-head    = lower-alpha ;                     (* a digit-led parameter is a width *)
name-tail    = lower-alpha | DIGIT | "-" ;
profile-code = profile-char , {profile-char} ;
profile-char = (* any unit-char, plus "," at brace or bracket depth >= 1 *) ;
```

`profile-code` is deliberately not given a grammar beyond its byte class. The sub-grammar is
**semantic**, as the native unit sub-grammar is (doc/05 §5.2): the lexer captures
bytes, and `bvn_parse_unit` — which dispatches on the namespace — decides whether
they are a valid code in that vocabulary. The normative grammar for what is
inside is each vocabulary's own, and restating them here would create a second
authority for each, to keep in step with the first.

---

## 3. Translation

### 3.1 Three outcomes, and no fourth

Every `ucum:` expression ends in exactly one of three states.

| Outcome | When | Result |
|---|---|---|
| **Translated** | Every atom maps onto the native registry, and the whole expression is representable | A normal `value_unit_t`; indistinguishable from the native spelling |
| **Profile-only** | The expression is valid UCUM and every atom is known, but one or more atoms are UCUM *arbitrary* units (§3.6) | A `value_unit_t` over UCUM's opaque units: comparable, convertible to no other unit — only across a prefix change |
| **Refused** | Anything else | An error code, at parse time, from the parser |

Refusal splits three ways by cause, because a producer needs to know which of these happened:

- not a valid UCUM expression, or an atom UCUM does not define → `error_unit_illegal` (32);
- valid UCUM, known atoms, no Bovnar representation (a special unit §3.7, an unrepresentable scale
  §3.5, more than `BVNR_MAX_UNIT_COMPONENTS` (32) components) → `error_unit_profile_unsupported` (50);
- the namespace before the colon is not a profile this build knows → `error_unit_profile_unknown` (49).

There is no "opaque string" outcome. A unit the library cannot reason about is a unit that has
escaped the enforcement point, and the format's only distinguishing claim is that units do not do
that.

### 3.2 Atoms and prefixes

UCUM separates prefix from atom by its own rule; Bovnar separates it by longest-alias-suffix match
with an optional explicit `~` (doc/05 §4.3). Translation happens **after** UCUM's split, on the
resolved (prefix, atom) pair, never by handing the raw UCUM string to the native parser. That is
what keeps the two disambiguation regimes from contaminating each other, and it is why the
collisions in §6.2 are harmless rather than fatal.

A code is matched as a whole atom first, so a complete atom always outranks a prefixed reading of
it — `mho` is the siemens, not milli-ho, and `cd` is the candela, not centi-day. Only if that fails
is the code split, and then **every** prefix that heads it is tried, longest first. Trying only the
longest is wrong: UCUM's prefixes are not suffix-free (`d` heads `da`), so `dar` — the deciare —
matches `da`, leaves `r`, and would be refused although it is legal UCUM. Verified:

```
ucum:ar    →  c~ha       1 in coherent SI = 100.0
ucum:dar   →  m~ha       1 in coherent SI = 10.0
ucum:har   →  ha         1 in coherent SI = 10000.0
```

A split whose atom is non-metric is not a match either, which is what keeps `k[arb'U]` an error
while the walk continues to a shorter prefix.

A UCUM prefix becomes the corresponding `si_prefix_id_t` on the translated component. Two things
can go wrong, and both are handled by the fold in §3.5 rather than by refusal:

- the native target does not accept prefixes at all (`prefix = none` or `ratio` in
  `src/gendata/units.bvnr` — `%`, `ppm`, `pH`, `PSU`, `mph`, the water-hardness degrees);
- the native target already carries a prefix because the mapping is not one-to-one
  (`kg` → `k~g`, `ar` → `c~ha`, `m[Hg]` → `k~mmHg`).

UCUM's binary prefixes (`Ki`, `Mi`, …) map to `prefix_iec` and are subject to the same
`bvn_prefix_unit_valid` check as natively — an IEC prefix on a byte is fine, on a metre it is not,
in both notations. UCUM's own atom table has no byte spelled `B`, so a binary-prefixed UCUM code
reaches that check through `By`.

### 3.3 Operators, exponents, grouping

| UCUM | Bovnar | Note |
|---|---|---|
| `.` | `·` | multiplication |
| `/` | `/` | division — see below |
| `m2`, `s-1` | `m²`, `s⁻¹` | exponent directly after the atom; range as natively (doc/05 §6) |
| `(` `)` | `(` `)` | grouping, mapped through the native group parser (doc/05 §5.2) |
| `1`, `10*0` | *(nothing)* | the unity atom; contributes no component, so `ucum:1` is `no_unit` |

The division rule is the one real difference and it must not be papered over. UCUM's `/` is a
binary operator evaluated left to right: `a/b/c` is `(a/b)/c` — and so is Bovnar's, whose latching
denominator gives `a·b⁻¹·c⁻¹`, the same thing. But UCUM's `/` applies only to the term that follows
it, whereas Bovnar's latches for the rest of the expression. `a/b.c` is `a·c/b` in UCUM and
`a·b⁻¹·c⁻¹` in Bovnar. Translation therefore works on UCUM's parse tree and emits *explicit signed
exponents*, never a `/`-bearing native string. The canonical form the formatter gives back is what
this table shows, and it is the honest witness — it re-parses to the same unit:

```
ucum:kg.m/s2      →  k~g·m/s²
ucum:kg/m.s2      →  k~g·s²/m         (kg·m⁻¹·s², NOT the kg·m⁻¹·s⁻² a latching read would give)
ucum:/min         →  min⁻¹
```

A leading `/` is UCUM's reciprocal form and needs the unity atom to be dropped: `/min` has no
numerator, and Bovnar has no way to write a bare `1` in a unit expression (a native `1` alone is
`error_unit_illegal`), so the translation is the negative exponent, verified:

```
ucum:/min    →  min⁻¹      1 in coherent SI = 0.016666666666666666
ucum:/uL     →  µ~L⁻¹      1 in coherent SI = 999999999.9999999
```

### 3.4 Annotations

A UCUM annotation `{…}` carries no meaning; UCUM defines `{anything}` standing alone as the unity.
The profile follows exactly, and states the consequence rather than hiding it:

```
ucum:mL{total}     →  m~L                       (equal to ucum:mL, and to native m~L)
ucum:{RBC}/uL      →  µ~L⁻¹
ucum:{cells}/uL    →  µ~L⁻¹                      (equal to the line above)
ucum:{RBC}         →  no_unit
```

`{RBC}/uL` and `{cells}/uL` compare equal because in UCUM they *are* equal. An annotation is a
comment, not a discriminator. If the distinction between red cells and cells matters to the
consumer, it is not a unit and does not belong in the unit slot — it belongs in a sibling field,
where Bovnar can type it and the reader can find it by key path.

The annotation text is preserved for round-trip (§5.2), and is preserved *verbatim*, including case
and spacing. It never affects equality, compatibility, conversion or normalisation.

**Annotations are UCUM's, and only UCUM's.** The registry row carries a flag, and it is false for
the other six namespaces, so `udunits:mL{x}` is `error_unit_illegal` rather than a millilitre with
a comment. Admitting the syntax everywhere would let a UDUNITS code that is simply wrong look like a
valid annotated one.

This is the valve. UCUM's own justification for annotations is that a code system which refuses the
expressiveness its users perceive gets adopted halfway, and halfway is as bad as not at all.
Bovnar has no such valve natively and gains one here — bounded to a notation where it is
semantically inert, rather than added to the native grammar where it would have to mean something.

### 3.5 Scale factors and the decade fold

UCUM writes powers of ten as `10*n` or `10^n` and uses them heavily in clinical counts
(`10*3/uL`). Bovnar has no component for a bare numeric factor. It does have prefixes, which are
exactly a decimal decade attached to a component — so a scale factor is discharged into a prefix.

Let translation produce components *c₁…cₙ* with exponents *eᵢ* and current prefix decades *pᵢ*,
plus a residual decade *D* accumulated from every `10*n`, every `10^n`, and every prefix that its
target refused to carry. Then:

> **Fold rule.** Choose the leftmost *i* such that *eᵢ* divides *D* exactly, and
> *p′ᵢ = pᵢ + D/eᵢ* is a decade with a defined SI prefix, and `bvn_prefix_unit_valid` accepts that
> prefix on *bᵢ*. Set *pᵢ ← p′ᵢ* and *D ← 0*. If no such *i* exists, the expression is
> `error_unit_profile_unsupported`.

The division by *eᵢ* is what makes it correct in a denominator, and getting it wrong is a
nine-orders-of-magnitude error rather than a cosmetic one. Verified against the implementation:

| UCUM | Fold | Bovnar | 1 unit in coherent SI |
|---|---|---|---|
| `10*3/uL` | *D* = +3, *e* = −1, *p* = −6 → *p′* = −9 | `n~L⁻¹` | `999999999999.9999` |
| `10*6/L`  | *D* = +6, *e* = −1, *p* = 0 → *p′* = −6  | `µ~L⁻¹` | `999999999.9999999` |
| `10*9/L`  | *D* = +9, *e* = −1, *p* = 0 → *p′* = −9  | `n~L⁻¹` | `999999999999.9999` |
| `10*12/L` | *D* = +12, *e* = −1, *p* = 0 → *p′* = −12 | `p~L⁻¹` | `999999999999999.9` |
| `10*3.m`  | *D* = +3, *e* = +1, *p* = 0 → *p′* = +3  | `k~m` | `1000.0` |
| `10*-3.g` | *D* = −3, *e* = +1, *p* = 0 → *p′* = −3  | `m~g` | `1e-06` |

(The last digits are the existing `bvni_ipow` rounding, not something the fold introduces; the
exact path is `bvn_unit_convert_rational`, doc/05 §12.4.)

**What the fold cannot do, and why that is stated rather than fixed.** SI prefix decades are
±1, ±2, ±3, and then multiples of three to ±30. There is no prefix for 10⁴, 10⁵, 10⁷ or 10⁸, so
`10*4/L` and `10*5.m` are `error_unit_profile_unsupported`. Two escapes were considered and both
rejected: synthesising a dimensionless factor component out of `%` and `ppm`
(`10*4` ≈ `%⁻²`) abuses the ratio units and produces a unit no human would recognise; and adding a
numeric-factor component to `value_unit_component_t` breaks the ABI for a case the clinical corpus
does not appear to need. A named refusal is better than either.

### 3.6 Arbitrary units

UCUM's arbitrary units — `[IU]`, `[arb'U]`, `[PFU]`, `[CFU]`, and the rest of that table — are
assay-defined. UCUM's own rule is that they are commensurable with nothing, including each other —
with one exception UCUM states itself: `[IU]` is defined as `1 [iU]`, the international unit in
two spellings. Bovnar carries that pair as **one** opaque unit: `ucum:[iU]` reads as `ucum:[IU]`,
the two compare equal, `[iU]/L` converts to `[IU]/mL` like any other volume change, and both are
written back as `ucum:[IU]`. They are also the single largest reason a real clinical code fails to
map (§6.4).

The profile admits all **41** of them, as a contiguous run at the bottom of UCUM's own block of the
`value_base_unit_t` id space (§7.1): **40** ids, one per UCUM arbitrary atom, with `[iU]` listed in
`.opaque_aliases` as a second spelling of `[IU]`'s id rather than an id of its own. An alias takes
its target's prefix rule, so `k[iU]` reads as `k[IU]`. Consequences, all of which fall out of the
existing machinery:

- they are **profile-only**: no native alias, reachable only through `ucum:`, and serialised back
  in profile notation (§5.1). Nothing about the native notation changes;
- they are **mutually incommensurable**, by the range predicate of §7.2 rather than by one quantity
  kind each;
- they take prefixes if and only if UCUM says so. Only `[IU]` and `[iU]` are metric in UCUM, so
  `k[IU]` translates while a prefix on `[arb'U]` is `error_unit_illegal` — UCUM's error, raised
  before translation;
- they never appear in an SI normal form, never produce a conversion factor, and
  `bvnr_normalise_si` leaves them alone, exactly as it already leaves currencies and the
  dimensionless kinds alone.

Note what is *not* in that list. UCUM's `U` is the **enzyme unit**, one micromole per minute, and it
is an ordinary mapped atom (`ucum:U` → `µ~mol/min`), not an arbitrary one. Only the bracketed
`[…'U]` spellings are assay-defined.

The critical thing this is **not**: a single shared "opaque" base unit. Collapsing `[IU]` and
`[PFU]` onto one id would make them compare equal, which is the silent-wrongness this format
exists to refuse. One id each is the only honest encoding, and the ids come from UCUM's table
rather than from anyone's judgement — including the one shared id, which is UCUM's own
`[IU]` = `1 [iU]` and the only alias `.opaque_aliases` admits.

### 3.7 Special units

UCUM classes as *special* the units defined by a function rather than a factor: the temperature
scales, the logarithmic ratios, the prism dioptre, the homeopathic potencies. Bovnar has native
forms for some, and those translate (§6.1): `Cel`, `[degF]`, `[degRe]`, `[degR]`, `[pH]`, `Np`, `B`,
`deg`, `gon`.

The rest are `error_unit_profile_unsupported`. The bel-with-a-reference family is the instructive
case: `B[SPL]`, `B[V]`, `B[W]`, `B[kW]`, `B[mV]`, `B[uV]` and `B[10.nV]` all differ from a plain bel
only in their reference level, and Bovnar's `dB` carries no reference. Translating them to `da~dB`
would discard exactly the information that distinguishes them and would let a sound-pressure level
compare equal to a voltage level. Refusing is the whole point.

`B` → `da~dB` is admitted because 1 bel *is* 10 decibels, exactly, in the logarithmic domain, and
`dB` carries its own quantity kind (`BVNI_KIND_LOG_DECIBEL`) so the result cannot drift into a
plain count. Verified: `da~dB` has factor `10.0`. It is a deliberate call and the one place in the
table where a prefix is used on a logarithmic unit.

### 3.8 Affine units

Nothing new. `Cel` translates to `°C`, and the native affine discipline (doc/05 §3, and the
`.affine`/`.offset` fields in `src/gendata/units.bvnr`) applies unchanged: an affine unit is valid
at exponent 1 only, and a compound containing one parses but yields no conversion value, because
the offset is a kelvin count and a product with signature `K·s⁻¹` has nowhere to put it.

`ucum:Cel/h` therefore behaves exactly as native `°C/h` does. The profile neither improves nor
worsens this, and it does not close the temperature-**difference** gap — UCUM has no delta scale
either, and writes a temperature difference as `K`. See §10.3.

---

## 4. Semantics after translation

### 4.1 Equality

`bvn_unit_equal` is unchanged: a multiset comparison of (base, exponent, prefix) triples. Because
translation produces ordinary components, a profile unit and a native unit that mean the same thing
compare equal, and the annotation/inline agreement check of doc/05 §2.2 works across notations:

```bovnar
#!bovnar 1.2
.v = <float:64,mmHg> 120.0 ucum:mm[Hg];      # OK
.w = <float:64,ucum:kg.m/s2> 1.0 k~g·m·s⁻²;  # OK
```

Two profile units compare through their translations, so `ucum:mL{a}` equals `ucum:mL{b}` equals
`ucum:mL`. §3.4 argues that is correct; §6.3 lists what it costs.

### 4.2 Compatibility and conversion

Unchanged, including the quantity-kind fences of `bvni_kinds_match`. A translated unit is subject
to every one of them:

- `ucum:bit` and `ucum:By` do not convert into each other, because `b` and `B` are separate
  information kinds;
- `ucum:[pH]` does not convert into a plain count, because `pH` has its own logarithmic kind;
- `ucum:rad/s` does not convert into `ucum:Hz`, because the angle kind separates angular frequency
  from frequency;
- arbitrary units convert into no other unit — only across a prefix change of the same
  units, which is how `ucum:[IU]/L` converts to `ucum:[IU]/mL` (§7.2).

What the fences do **not** do is separate two named units that share a dimension. `unece:D13` (the
sievert) and `unece:GRY` (the gray) are dimensionally equal and convert with factor 1, as `Gy` and
`Sv` always have natively (doc/07 §9). The distinction survives in the *spelling*, which is what the
unit slot carries and what `BVN_UNIT_REDUCE` will not rewrite — and that is precisely why a table
row has to be chosen by the publisher's label rather than by its value (§9.5, §9.6).

### 4.3 Policy, normalisation, and the writer

`bvnr_unit_policy_t` takes its targets and rules as unit **strings**, parsed with `bvn_parse_unit`
at the point the policy is set. Those strings therefore accept the profile notation for free:

```c
static const bvnr_unit_rule_t rules[] = {
    { .path = ".patient.systolic", .unit = "ucum:mm[Hg]", .mode = bvnr_rule_require },
};
```

and the assertion is satisfied by a document that writes `mmHg`, or `ucum:mm[Hg]`, or `k~Pa` —
because the rule is dimensional, evaluated on the parsed unit, and none of those three spellings
survives into the comparison. Target lists, `bvnr_normalise_si`, `--require-dimension` and
`--field` behave identically.

The writer's validation half (`bvnr_writer_set_unit_policy`) is likewise unchanged. A producer can
be held to "every pressure is written in mm[Hg]" whether it spells that natively or not.

### 4.4 Invariants the profile preserves

All of the following hold for a profile unit exactly as they hold for a native one:

- the unit is inside the bytes, not in a sidecar attribute or an external table;
- it binds to one value, not to a variable or a file;
- the parser checks it, on every parse, with nothing for the application to call;
- a unit that does not parse stops the document — it does not arrive at the application as a string
  for the application to deal with;
- a unit that parses is dimensionally analysable, unless it is profile-only, in which case it is
  explicitly and detectably not (`bvn_unit_is_profile_only`) rather than silently wrong. §3.1 is
  what buys this, and it is the reason there is no opaque-passthrough outcome.

---

## 5. Serialisation

### 5.1 Canonical output

`bvn_unit_to_string` emits the **native** canonical form whenever every component has one:

```
parse "ucum:mm[Hg]"   → write "mmHg"
parse "ucum:kg.m/s2"  → write "k~g·m/s²"
parse "ucum:10*3/uL"  → write "n~L⁻¹"
parse "unece:KGM"     → write "k~g"
parse "qudt-qk:Mass"  → write "k~g"
```

A unit containing a profile-only component (§3.6, §11.3) has no native form, so the profile prefix
becomes part of its canonical spelling and the whole expression is emitted in profile notation, in
the namespace that owns the opaque base:

```
parse "ucum:[IU]"     → write "ucum:[IU]"
parse "ucum:[IU]/L"   → write "ucum:[IU].L-1"
parse "ucum:k[IU]"    → write "ucum:k[IU]"
parse "unece:XBX"     → write "unece:XBX"
```

The compound case is worth reading closely: the output is the profile's own *canonical* code, not
the input text. `[IU]/L` comes back as `[IU].L-1` because the writer emits explicit signed exponents
for the same reason the reader consumes them (§3.3) — a `/` would re-parse under UCUM's rule and
Bovnar's differently as soon as a third term appeared. Re-parsing either output yields the same
`value_unit_t`, so round-trip is closed in both cases without any state outside the struct.

The guard sits at the head of both formatters (`bvn_unit_to_string_ex` and the plain
`bvn_unit_to_string`), *before* flag handling, because `BVN_UNIT_REDUCE` folds a unit towards named
SI and an opaque unit has no SI form to fold towards. One unit that printed differently through the
two formatters would be a round-trip hole.

A unit that somehow mixed opaque bases from two namespaces has no single spelling and is refused
(negative return) rather than written in whichever namespace came first. No document can produce
one — a code is read in exactly one namespace — but the API can compose one, and the honest answer
there is a failure rather than a string that re-parses as something else.

**Three shapes have no spelling, not one.** The mixed-namespace case above is the one a reader
expects; the other two follow from what a *flat* vocabulary is (§2.2). `unece`, `qudt`, `qudt-qk`,
`om` and `cf` match one whole code entire — no operators, no exponents, no prefixes — so a flat
profile's opaque unit can be written only as a **single unprefixed component at exponent 1**:

| Unit | Spelling | Why |
|------|----------|-----|
| `unece:XBX` | `unece:XBX` | one code, exponent 1 — the only shape a flat vocabulary has |
| `bu_unece_box²` | *none* | UN/ECE has no notation for an exponent |
| `bu_unece_box · m` | *none* | and none for a product, native component or not |
| `ucum:[IU]²` | `ucum:[IU]2` | UCUM is an **expr** profile, so this one is spellable |

All three refusals are `bvn_unit_to_string` returning -1 and the writer reporting
`error_unit_illegal`; none of them can arise from a document, only from an API caller composing a
`value_unit_t` by hand. Such a unit stays **structurally valid** and fully usable in memory — it
compares, and it converts to itself — because a unit with no text is still a unit. `bvn_unit_valid`
answers the structural question and says so in its own contract; spellability is
`bvn_unit_to_string`'s answer, and the two are deliberately distinct.

### 5.2 What canonical output loses, and on which path

Formatting a `value_unit_t` is lossy in exactly one respect: annotations. `bvn_unit_to_string` on
`ucum:mL{total}` gives `m~L`, and `{total}` is gone; `ucum:{RBC}/uL` and `ucum:{cells}/uL` both give
`µ~L⁻¹`. Nothing else is lost — a translated unit's meaning survives exactly, and a profile-only
unit round-trips through §5.1.

**Which path a document takes decides whether it sees that loss.** Re-serialising a document the
library parsed — `bovnar pretty-print`, the reader-to-writer round trip — re-emits the type
annotation from the captured source text, so the producer's spelling survives verbatim, annotations
included:

```
$ cat ann.bvnr
#!bovnar 1.2
.a = <float:64,ucum:mL{total}> 1.0;
.b = 2.0 ucum:{RBC}/uL;

$ bovnar pretty-print ann.bvnr
#!bovnar 1.2
.a = <float:64,ucum:mL{total}> 1.0;
.b = <float:64,_10,ucum:{RBC}/uL> 2.0;
```

`.b` shows both halves of that at once: the inline unit was promoted to an annotation — which is
`pretty-print`'s ordinary behaviour, not the profile's — and the annotation text came through it
verbatim.

Constructing a document through the writer API **was** the path that lost it, and by default
still writes the canonical form: `bvnr_write_float_unit` and its siblings take a `value_unit_t`, and
a `value_unit_t` has nowhere to hold an annotation. What changed is that the spelling now has a way
across. The reader hands it out on every unit-bearing event as `bvnr_data_t.unit_source` (§7.3),
and the writer takes it back by either of two routes:

```c
/* the typed helpers: one shot, for the next value */
bvnr_write_version(w, 1, 2);
bvnr_writer_set_unit_source(w, "ucum:mL{total}", 14);
bvnr_write_float_unit(w, "a", 64, 1.5, mL);     /* .a = <float:64,ucum:mL{total}> 1.5e+0; */

/* bvnr_write_event: the same field the reader filled */
d.unit_source = "ucum:mL{total}";  d.unit_source_length = 14;
```

**The spelling is checked before it is written**, because the alternative is a document that says
two things. It must parse to the very unit it is handed over with (`error_unit_mismatch`
otherwise), it must be something a reader would take back as itself in that unit slot, and — being
profile notation — it needs the `#!bovnar 1.2` directive already emitted, exactly as an opaque unit
does (§2.2). doc/08 §3.1 has the full table. With `BVN_UNIT_REDUCE` or `BVN_UNIT_ASCII_EXP` set the
spelling is ignored: those flags ask for a spelling of their own.

**The two unit slots are not interchangeable**, and this is the one place a consumer meets that.
`ucum:mL{a,b}` and `udunits:kg m-2 s-1` are each a single annotation parameter (§2.4, §13.2) and
neither is a legal inline suffix, where a comma or a space ends the unit. A spelling read from an
annotation is therefore written back into an annotation without question and refused as an inline
suffix when it could not be read from one. Replaying a document's events never meets this — the
annotation events travel with the value — and a consumer forwarding only `ev_data` does.

What is still lost, and on purpose: **a spelling is not part of the unit.** `bvn_unit_equal`,
conversion and the policies never see it, a `value_unit_t` that was stored and re-used carries
none, and a value a unit policy *converted* has no document spelling for the unit it now carries.
The DOM keeps the spelling only where it differs from the canonical form (§7.3), and the Python
bindings keep it on a typed `Quantity` (§8.2).

### 5.3 Emitting a profile code from a native unit

`bvn_unit_to_profile(ns, value_unit_t, char*, size_t)` writes a code in the named vocabulary for a
unit that has one, for producers whose output field is foreign-typed;
`bvn_unit_to_ucum(value_unit_t, char*, size_t)` is the `"ucum"` case, kept for callers that predate
the other namespaces. Both return a negative length when they fail.

The mapping is driven by a **generated reverse table**, one per namespace, not by searching the
forward one. Three things depend on that.

First, the choice is deterministic and canonical. Searching the forward table picked
whichever row its length-sorted order reached first, which got the siemens (`mho`) and the calorie
(`cal_th`) wrong; the reverse table decides once, at build time, by a rule that differs with the
grammar because the competing rows differ with it:

| Grammar | The rows competing for one unit | Rule |
|---|---|---|
| expression (`ucum`, `udunits`) | *spellings of one atom* — `S` and `mho`, `cal` and `cal_th`, `L` and `l`, `mo` and `mo_j` | **shortest wins**, ties alphabetically: the vocabulary's own abbreviation is the short one |
| flat (`unece`, `qudt`, `qudt-qk`, `om`) | *different codes for one unit* — `B37` and `B51`, `85` and `M92`, `TON_SHORT` and `TON` | **first declared wins**: the data file lists the canonical code first and its aliases after |

Applying the expression rule to a flat vocabulary is what made this worth stating. Every Rec 20 code
is two or three bytes, so "shortest, ties alphabetically" degenerates into "alphabetically first" and
picked the filler code every time: a joule came out as `J55` (Rec 20's *watt second*), a pascal as
`C55` (*newton per square metre*) — both of which now spell their definitions out, `W·s` and `N/m^2`,
so the joule and the pascal are written as `JOU` and `PAL` with no competitor — a mole as `C34`, a tonne as `2U` (*megagram*), and QUDT's short ton
as the bare, ambiguous `TON`. Each is worth the right number and none is the code a reader of that
vocabulary expects to be handed.

A row may also carry **`.reverse = false`**, which removes it from the running in either grammar. It
is for the case no ordering can see: a code whose *value* is right and whose *meaning* is a different
quantity. UCUM's `eq` is worth a mole and means an equivalent — and, being a byte shorter than `mol`,
was the code every mole was written as. `[oz_ap]` is worth a troy ounce and says *apothecary* about a
document that never did. Both stay legal to read. A unit whose every row is flagged is a build error:
something this profile can read and cannot write is a hole, not a preference.

Second, each row carries the atom's own decade, which is what lets a base whose code is *itself*
prefixed be written at all. `m[Hg]` is a metre of mercury — bovnar's `mmHg` times 10³ — so writing
plain `mmHg` emits the prefix for `0 − 3` and produces `mm[Hg]`. The hectare is the same shape
(`ar` carries −2, so `ha` becomes `har`). Without the decade those bases had no UCUM form at all.

Third, **the key differs by grammar too**. An expression profile emits a prefix, so one row per base
reaches every decade: `g` alone spells the microgram `ug`. A flat profile has no prefix to emit —
`GRM`, `KGM`, `MGM` and `MC` are four separate Rec 20 codes for the gram — so there the key is
*(base, decade)* and a base contributes as many rows as it has codes. Keying a flat profile by base
alone would leave whichever code happened to be shortest and make every other decade unwritable.

It remains **partial by construction**, and in three different ways:

- *No table is complete.* A native unit outside a transliteration table has nowhere to go — the Old
  German units, the water-hardness degrees, the turbidity kinds, `PSU`, `CF`, `kph`, `rpm`,
  and every currency. Membership here is per-unit and not per-family: `mph` has both a UN/ECE
  and a QUDT code while `kph` has neither.
- *A flat profile can only write a single component.* A flat code names a whole unit, so there is no
  way to compose one out of parts: `unece:MSK` reads back as `m/s²`, but a native `m/s²` has no
  UNECE spelling this function can construct. The expression profiles have no such limit.
- *The dimensionless unit is spellable only in an expression profile.* `1` is a bare integer factor
  of 10⁰ in that grammar, so `ucum:1` and `udunits:1` read back as the dimensionless unit. A flat
  vocabulary has no integer-factor production, and the two that do own a unity code refuse it on
  purpose — QUDT's `UNITLESS` and `NUM` and OM's `one` are all `.unsupported`, reading *"the absence
  of a unit — write no unit at all"*. So the five flat namespaces refuse it, `cf` included, which is
  read-only anyway (§17.3).

| Native | `ucum` | `unece` | `qudt` | `udunits` |
|---|---|---|---|---|
| `k~g` | `kg` | `KGM` | `KiloGM` | `kg` |
| `h~Pa` | `hPa` | `A97` | `HectoPA` | `hPa` |
| `m/s` | `m.s-1` | — | — | `m.s-1` |
| `Mi~B` | — | `E63` | `MebiBYTE` | — |
| `mph` | — | `HM` | `MI-PER-HR` | — |
| `kph` | — | — | — | — |
| *(dimensionless)* | `1` | — | — | `1` |

The asymmetry is worth stating plainly: these profiles are good *readers* and partial *writers*. A
round trip that starts in a vocabulary returns to it; one that starts in Bovnar's native registry
may have nowhere to go.

Sweeping the whole native registry — all 264 physical units, each at the twelve prefixes
`si_none da h k M G T d c m µ n` — **865** combinations survive a native → UCUM → native round trip
unchanged, **1926** have no UCUM code, 377 are prefix/unit pairs `bvn_prefix_unit_valid` rejects
before the question arises, and **none round-trips to a different unit**. The last of those is the
invariant; the two counts move whenever the registry gains a unit, so `test_sweep_round_trip` in
`tests/bovnar_ucum_test.c` pins all three rather than leaving them as prose.

---

## 6. The transliteration table

### 6.1 Verified mappings

The shipped table is `src/gendata/ucum.bvnr`: 206 mapped **atoms**, 41 arbitrary units, 65 known
but refused, and UCUM's 20 prefix spellings. What follows is the whole mapped list, grouped as the
data file groups it — and it is the whole of it, checked row by row against the reference
implementation by `check_doc_profile_atoms.py`, which also fails if a mapped code is missing here. Note that these are *atoms*, which is how the table is organised and not how a
producer meets it — `mg/dL` is three atoms and two prefixes, not a row. §6.4 reads the same table
back in terms of whole codes.

Each Bovnar column was produced by parsing `ucum:<code>` with the reference implementation and
reading back `bvn_unit_to_string` and the coherent-SI factor. The UCUM column is the table author's;
§9.2 says what the generator checks about it and §9.5 what the outside proof does.

**SI base units**

| UCUM | Bovnar | 1 UCUM unit in coherent SI |
|---|---|---|
| `m` | `m` | `1.0` |
| `s` | `s` | `1.0` |
| `g` | `g` | `0.001` |
| `A` | `A` | `1.0` |
| `K` | `K` | `1.0` |
| `mol` | `mol` | `1.0` |
| `cd` | `cd` | `1.0` |
| `rad` | `rad` | `1.0` |
| `sr` | `sr` | `1.0` |

**Named SI-derived units**

| UCUM | Bovnar | 1 UCUM unit in coherent SI |
|---|---|---|
| `Hz` | `Hz` | `1.0` |
| `N` | `N` | `1.0` |
| `Pa` | `Pa` | `1.0` |
| `J` | `J` | `1.0` |
| `W` | `W` | `1.0` |
| `V` | `V` | `1.0` |
| `Ohm` | `Ω` | `1.0` |
| `F` | `F` | `1.0` |
| `C` | `C` | `1.0` |
| `S` | `S` | `1.0` |
| `mho` | `S` | `1.0` |
| `Wb` | `Wb` | `1.0` |
| `T` | `T` | `1.0` |
| `H` | `H` | `1.0` |
| `lm` | `lm` | `1.0` |
| `lx` | `lx` | `1.0` |
| `Bq` | `Bq` | `1.0` |
| `Gy` | `Gy` | `1.0` |
| `Sv` | `Sv` | `1.0` |
| `kat` | `kat` | `1.0` |

**Metric non-SI**

| UCUM | Bovnar | 1 UCUM unit in coherent SI |
|---|---|---|
| `L` | `L` | `0.001` |
| `l` | `L` | `0.001` |
| `t` | `t` | `1000.0` |
| `u` | `Da` | `1.66053906892e-27` |
| `eV` | `eV` | `1.602176634e-19` |
| `ar` | `c~ha` | `100.0` |
| `st` | `m³` | `1.0` |
| `bar` | `bar` | `100000.0` |
| `atm` | `atm` | `101325.0` |
| `Ao` | `Å` | `1e-10` |
| `b` | `barn` | `1e-28` |
| `AU` | `au` | `149597870700.0` |
| `pc` | `pc` | `3.085677581491367e+16` |

**Time**

| UCUM | Bovnar | 1 UCUM unit in coherent SI |
|---|---|---|
| `min` | `min` | `60.0` |
| `h` | `h` | `3600.0` |
| `d` | `d` | `86400.0` |
| `wk` | `wk` | `604800.0` |
| `mo` | `mo` | `2629800.0` |
| `mo_j` | `mo` | `2629800.0` |
| `a` | `yr` | `31557600.0` |
| `a_j` | `yr` | `31557600.0` |
| `a_t` | `yr_trop` | `31556925.9747` |
| `a_g` | `yr_greg` | `31556952.0` |
| `mo_s` | `mo_syn` | `2551442.976` |
| `mo_g` | `mo_greg` | `2629746.0` |

Bovnar's `yr` is 31557600 s = 365.25 d and its `mo` is 2629800 s = 30.4375 d, which are the
**Julian** year and month exactly — and which is what UCUM's unqualified `a` and `mo` are, so all
four spellings map with no loss. The tropical and gregorian variants do not (§6.4).

**Angle and logarithmic**

| UCUM | Bovnar | 1 UCUM unit in coherent SI |
|---|---|---|
| `deg` | `°` | `0.017453292519943295` |
| `gon` | `grad` | `0.015707963267948967` |
| `'` | `arcmin` | `0.0002908882086657216` |
| `''` | `arcsec` | `4.84813681109536e-06` |
| `circ` | `rev` | `6.283185307179586` |
| `Np` | `Np` | `1.0` |
| `B` | `da~dB` | `10.0` |
| `[pH]` | `pH` | `1.0` |
| `sph` | `sph` | `12.566370614359172` |

**Temperature**

| UCUM | Bovnar | 1 UCUM unit in coherent SI |
|---|---|---|
| `Cel` | `°C` | `1.0` |
| `[degF]` | `°F` | `0.5555555555555556` |
| `[degRe]` | `°Re` | `1.25` |
| `[degR]` | `°Ra` | `0.5555555555555556` |

**Length and area, US and Imperial**

| UCUM | Bovnar | 1 UCUM unit in coherent SI |
|---|---|---|
| `[in_i]` | `in` | `0.0254` |
| `[ft_i]` | `ft` | `0.3048` |
| `[yd_i]` | `yd` | `0.9144` |
| `[mi_i]` | `mi` | `1609.344` |
| `[nmi_i]` | `nmi` | `1852.0` |
| `[ft_us]` | `ftUS` | `0.3048006096012192` |
| `[fth_i]` | `fath` | `1.8288` |
| `[ly]` | `ly` | `9460730472580800.0` |
| `[mil_i]` | `thou` | `2.54e-05` |
| `[hd_i]` | `hand` | `0.1016` |
| `[in_us]` | `inUS` | `0.0254000508001016` |
| `[yd_us]` | `ydUS` | `0.9144018288036576` |
| `[fth_us]` | `fathUS` | `1.8288036576073152` |
| `[rd_us]` | `rdUS` | `5.029210058420117` |
| `[ch_us]` | `chUS` | `20.116840233680467` |
| `[lk_us]` | `lkUS` | `0.20116840233680466` |
| `[fur_us]` | `furUS` | `201.16840233680466` |
| `[mi_us]` | `miUS` | `1609.3472186944373` |
| `[mil_us]` | `m~inUS` | `2.54000508001016e-05` |
| `[rch_us]` | `h~ftUS` | `30.480060960121918` |
| `[rlk_us]` | `ftUS` | `0.3048006096012192` |
| `[pnt]` | `pnt` | `0.00035277777777777776` |
| `[pca]` | `pca` | `0.004233333333333334` |
| `[lne]` | `lne` | `0.002116666666666667` |
| `[pnt_pr]` | `pnt_pr` | `0.0003514598` |
| `[pca_pr]` | `pca_pr` | `0.0042175176` |
| `[mesh_i]` | `in⁻¹` | `39.37007874015748` |
| `[sin_i]` | `in²` | `0.00064516` |
| `[sft_i]` | `ft²` | `0.09290304` |
| `[syd_i]` | `yd²` | `0.83612736` |
| `[acr_us]` | `acUS` | `4046.872609874252` |
| `[srd_us]` | `rdUS²` | `25.292953811714074` |
| `[smi_us]` | `miUS²` | `2589998.470319521` |
| `[sct]` | `miUS²` | `2589998.470319521` |

The **whole** survey series maps, onto native units built on the survey foot rather than onto the
international ones they are 2 ppm from — that is what `inUS`, `ydUS`, `rdUS` and the rest above are
for, and §6.3 explains why the distinction is worth a unit each. Ramden's chain and link
(`[rch_us]`, `[rlk_us]`) are the survey foot at a hecto prefix and bare. The **British** series
`[ch_br]`, `[ft_br]`, `[yd_br]` is a different matter and is refused: those sit 7.9e-7 from the
international units of the same name, which §6.3 also covers.

**Mass**

| UCUM | Bovnar | 1 UCUM unit in coherent SI |
|---|---|---|
| `[lb_av]` | `lb` | `0.45359237` |
| `[oz_av]` | `oz` | `0.028349523125` |
| `[dr_av]` | `dr` | `0.0017718451953125` |
| `[gr]` | `gr` | `6.479891e-05` |
| `[stone_av]` | `st` | `6.35029318` |
| `[oz_tr]` | `oz_t` | `0.0311034768` |
| `[pwt_tr]` | `dwt` | `0.00155517384` |
| `[sc_ap]` | `sc` | `0.0012959782` |
| `[car_m]` | `ct` | `0.0002` |
| `[oz_ap]` | `oz_t` | `0.0311034768` |
| `[ston_av]` | `tn_sh` | `907.18474` |
| `[lton_av]` | `tn_l` | `1016.0469088` |
| `[scwt_av]` | `h~lb` | `45.359237` |
| `[lcwt_av]` | `cwt_l` | `50.80234544` |
| `[lb_tr]` | `lb_t` | `0.3732417216` |
| `[lb_ap]` | `lb_t` | `0.3732417216` |
| `[dr_ap]` | `dr_ap` | `0.0038879346` |

`[oz_ap]` is read and never written (`.reverse = false`, §5.3): the apothecary ounce *is* the troy
ounce by value, and writing a troy ounce back as `[oz_ap]` would tell a UCUM reader "apothecary"
about a document that never said it.

**Volume**

| UCUM | Bovnar | 1 UCUM unit in coherent SI |
|---|---|---|
| `[gal_us]` | `gal` | `0.003785411784` |
| `[qt_us]` | `qt` | `0.000946352946` |
| `[pt_us]` | `pt` | `0.000473176473` |
| `[foz_us]` | `fl_oz` | `2.95735295625e-05` |
| `[tbs_us]` | `tbsp` | `1.478676478125e-05` |
| `[tsp_us]` | `tsp` | `4.92892159375e-06` |
| `[cup_us]` | `cup` | `0.0002365882365` |
| `[gil_us]` | `gi` | `0.00011829411825` |
| `[fdr_us]` | `fl_dr` | `3.6966911953125e-06` |
| `[min_us]` | `minim` | `6.1611519921875e-08` |
| `[pk_us]` | `pk` | `0.00880976754172` |
| `[bu_us]` | `bsh` | `0.03523907016688` |
| `[bbl_us]` | `bbl` | `0.158987294928` |
| `[gal_br]` | `gal_uk` | `0.00454609` |
| `[qt_br]` | `qt_uk` | `0.0011365225` |
| `[pt_br]` | `pt_uk` | `0.00056826125` |
| `[foz_br]` | `fl_oz_uk` | `2.84130625e-05` |
| `[gil_br]` | `gi_uk` | `0.0001420653125` |
| `[bu_br]` | `bsh_uk` | `0.03636872` |
| `[cin_i]` | `in³` | `1.6387064e-05` |
| `[cft_i]` | `ft³` | `0.028316846592000004` |
| `[cyd_i]` | `yd³` | `0.764554857984` |
| `[gal_wi]` | `gal_dry` | `0.00440488377086` |
| `[dqt_us]` | `qt_dry` | `0.001101220942715` |
| `[dpt_us]` | `pt_dry` | `0.0005506104713575` |
| `[bf_i]` | `fbm` | `0.002359737216` |
| `[cr_i]` | `cord` | `3.624556363776` |
| `[crd_us]` | `cord` | `3.624556363776` |

**Pressure, energy, force, power**

| UCUM | Bovnar | 1 UCUM unit in coherent SI |
|---|---|---|
| `m[Hg]` | `k~mmHg` | `133322.387415` |
| `[psi]` | `psi` | `6894.757293168362` |
| `tex` | `tex` | `1e-06` |
| `[den]` | `den` | `1.1111111111111111e-07` |
| `att` | `at` | `98066.5` |
| `cal_th` | `cal` | `4.184` |
| `cal` | `cal` | `4.184` |
| `[Cal]` | `k~cal` | `4184.0` |
| `[Btu_IT]` | `Btu` | `1055.05585262` |
| `erg` | `erg` | `1e-07` |
| `[HP]` | `hp` | `745.6998715822702` |
| `[kn_i]` | `kn` | `0.5144444444444445` |
| `dyn` | `dyn` | `1e-05` |
| `gf` | `g·gn` | `0.00980665` |
| `[lbf_av]` | `lbf` | `4.4482216152605` |
| `[g]` | `gn` | `9.80665` |
| `m[H2O]` | `mH2O` | `9806.65` |
| `[in_i'Hg]` | `inHg` | `3386.388640341` |
| `[in_i'H2O]` | `in·mH2O/m` | `249.08890999999997` |
| `[PRU]` | `mmHg·s/m~L` | `133322387.415` |
| `[wood'U]` | `mmHg·min/L` | `7999343.2449` |
| `[Btu]` | `Btu_th` | `1054.3502644888888` |
| `[Btu_th]` | `Btu_th` | `1054.3502644888888` |
| `cal_IT` | `cal_IT` | `4.1868` |
| `cal_m` | `cal_m` | `4.19002` |
| `cal_[15]` | `cal_15` | `4.1858` |
| `cal_[20]` | `cal_20` | `4.1819` |
| `[Btu_59]` | `Btu_59` | `1054.804` (UCUM states `1054.80`) |
| `[Btu_60]` | `Btu_60` | `1054.68` |
| `[Btu_m]` | `Btu_m` | `1055.87` |

`m[Hg]` is the row that makes the decade mechanism visible: it is a **metre** of mercury column, so
its native target carries the kilo that makes `mm[Hg]` come out as plain `mmHg`.

`gf` is one of two rows where a single UCUM atom becomes a two-component Bovnar expression: Bovnar
has no gram-force, but `g·gn` is gram times standard gravity, dimensions `[1,1,-2,0,0,0,0]`, factor
`0.00980665`. (The other is `U` below.) Nothing in the design requires a mapping to be atom-to-atom.

**CGS and radiation**

| UCUM | Bovnar | 1 UCUM unit in coherent SI |
|---|---|---|
| `P` | `P` | `0.1` |
| `St` | `St` | `0.0001` |
| `Gal` | `Gal` | `0.01` |
| `G` | `G` | `0.0001` |
| `Mx` | `Mx` | `1e-08` |
| `Oe` | `Oe` | `79.57747154594767` |
| `Bi` | `da~A` | `10.0` |
| `Ky` | `c~m⁻¹` | `100.0` |
| `sb` | `sb` | `10000.0` |
| `ph` | `ph` | `10000.0` |
| `Ci` | `Ci` | `37000000000.0` |
| `RAD` | `c~Gy` | `0.01` |
| `REM` | `rem` | `0.01` |
| `R` | `R` | `0.000258` |
| `[e]` | `e` | `1.602176634e-19` |
| `Gb` | `Oe·c~m` | `0.7957747154594768` |
| `Lmb` | `Lmb` | `3183.098861837907` |

`RAD` and `REM` are the case-**sensitive** spellings. UCUM's `[RAD]` and `[REM]` belong to its
case-insensitive variant, which this profile does not implement (§10.3), so they are not codes a
conforming producer of the case-sensitive vocabulary can emit and are not in the table.

**Digital and ratio**

| UCUM | Bovnar | 1 UCUM unit in coherent SI |
|---|---|---|
| `bit` | `b` | `1.0` |
| `By` | `B` | `1.0` |
| `Bd` | `Bd` | `1.0` |
| `%` | `%` | `0.01` |
| `[ppth]` | `‰` | `0.001` |
| `[pptr]` | `pptr` | `1e-12` |
| `[ppm]` | `ppm` | `1e-06` |
| `[ppb]` | `ppb` | `1e-09` |

**Chemical**

| UCUM | Bovnar | 1 UCUM unit in coherent SI |
|---|---|---|
| `eq` | `mol` | `1.0` |
| `osm` | `mol` | `1.0` |
| `U` | `µ~mol/min` | `1.6666666666666667e-08` |
| `g%` | `g/d~L` | `10.0` |

**Optics**

| UCUM | Bovnar | 1 UCUM unit in coherent SI |
|---|---|---|
| `[diop]` | `m⁻¹` | `1.0` |

**Whole codes, as a producer meets them.** The same table read through the expression grammar:

| UCUM | Bovnar | 1 UCUM unit in coherent SI |
|---|---|---|
| `m/s` | `m/s` | `1.0` |
| `m/s2` | `m/s²` | `1.0` |
| `kg/m2` | `k~g/m²` | `1.0` |
| `kg.m/s2` | `k~g·m/s²` | `1.0` |
| `/min` | `min⁻¹` | `0.016666666666666666` |
| `/uL` | `µ~L⁻¹` | `999999999.9999999` |
| `10*3/uL` | `n~L⁻¹` | `999999999999.9999` |
| `10*6/L` | `µ~L⁻¹` | `999999999.9999999` |
| `mmol/L` | `m~mol/L` | `1.0` |
| `mg/dL` | `m~g/d~L` | `0.01` |
| `ng/mL` | `n~g/m~L` | `1.0000000000000002e-06` |
| `kat/L` | `kat/L` | `1000.0` |
| `meq/L` | `m~mol/L` | `1.0` |
| `U/L` | `µ~mol/min·L` | `1.6666666666666667e-05` |

### 6.2 Collisions — the same spelling, a different unit

These are the codes where the same bytes name different quantities in the two namespaces. They are
harmless **only** because translation runs on UCUM's resolved atom, never by handing the UCUM
string to `bvn_parse_unit` (§3.2) — every one of them would be a silent misreading if it did.
Verified against the native parser:

| Spelling | Native Bovnar | UCUM | Damage if confused |
|---|---|---|---|
| `st` | stone, `6.35029318` kg, dimension `[0,1,0,…]` | stere, `m³`, dimension `[3,0,0,…]` | mass read as volume |
| `B` | byte, information | bel, `da~dB` | a data size read as a level |
| `b` | bit, information | barn, `1e-28` m² | a data size read as an area |
| `Gb` | gigabit (`G~b`, factor `1e9`) | **gilbert**, `Oe·c~m` ≈ `0.7958` A, magnetomotive force | a data size read as a magnetomotive force |
| `a` | *not a unit* | year (Julian) | — (Bovnar declines the ambiguity; see [Unit Ambiguities](07_bovnar_unit_ambiguities.md)) |
| `ar` | *not a unit* | are, `c~ha` | — (the hectare is `ucum:har`, an easy off-by-100) |
| `AU` | *not a unit* | astronomical unit | — (Bovnar spells it `au`) |
| `gf` | *not a unit* | gram-force | — |
| `Cel` | *not a unit* | degree Celsius | — |

Every row above is asserted in `tests/bovnar_ucum_test.c`, and the cross-vocabulary suite pins the
same shape across the other four (§14.2). [Unit Ambiguities §17](07_bovnar_unit_ambiguities.md) is
the reader-facing index of all of them.

`st` and `Gb` are the dangerous two, because on those rows **both sides parse and the dimensions
differ** — everything else in the table fails on one side and is caught. They are also the argument
for the namespace being mandatory rather than a fallback: there is no reading of a bare `st` that
could be made to serve both populations, and a profile that guessed would be wrong for one of them
every time.

`Gb` earns a second look, because the plausible reading of it is the wrong one. UCUM's `b` is the
barn and is **non-metric**, so `G` + `b` genuinely is not a legal prefixed code — which invites the
conclusion that `ucum:Gb` must therefore be refused. It is not: `Gb` is an atom in its own right,
the gilbert. The code resolves, to a magnetomotive force, while the same bytes read natively are a
gigabit. "This prefix cannot attach to that atom" and "these bytes are not a code" are different
statements, and only the first is true here.

### 6.3 Traps that are not spelling collisions

Rows where the spellings differ but the meanings are close enough to be mapped wrongly by
hand. Each is a factor error, not a syntax error, so nothing would catch it downstream.

**`eq` is not `val`.** Bovnar's `val` is documented in `src/gendata/units.bvnr` as the equivalent
*as used in water analysis*, where the ions counted are divalent: its factor is `0.5`, so
`m~val/L` is 0.5 mmol/L. UCUM's `eq` is the generic equivalent, defined as one mole. Mapping
`eq` → `val` would be **wrong by exactly a factor of two** on every clinical electrolyte value.
The table maps `eq` → `mol` and `meq/L` → `m~mol/L`, and leaves `val` alone as the water-analysis
unit it is. Outside the profile `eq` keeps its native meaning, an alias of `val`, so `meq/L` and
`ucum:meq/L` differ by exactly two; the prefix is what decides
(see [`05_bovnar_unit_system.md`](05_bovnar_unit_system.md), water analysis).

**`osm` sits on exactly that footing, and used to be refused instead.** UCUM defines the osmole and
the equivalent identically — `<value Unit="mol" value="1"/>` for each — so the two rows have to
agree, and they did not: `osm` was refused as a "species-dependent relation to the mole". That is
equally true of the equivalent, which depends on valence, and it is a fact about what an osmolality
*means* rather than about what UCUM says the unit is worth. It was the same judgement made two ways
in one file, and `qudt.bvnr` had already made it a third way, mapping `MilliOSM-PER-KiloGM` straight
onto `m~mol/k~g`. `osm` now maps to `mol`, so `mosm` and `mosm/kg` come with it.

Both carry `.reverse = false`, which is not cosmetic. `eq` is two bytes against `mol`'s three, so
the shortest-wins rule made `eq` the code every mole was *written* as; `osm` and `mol` are both
three, and a tie is the only thing that stood between every amount of substance and being written
as an osmole.

**The calorie and the BTU each need two native units.** Bovnar's `cal` is `4.184` J — the
thermochemical calorie, which is what UCUM's unqualified `cal` is, so `cal` and `cal_th` both map to
it. Bovnar's `Btu` is `1055.05585262` J, the **IT** BTU, and `[Btu_IT]` maps to that.

The trap is the *other* convention in each pair, and it is closed by carrying it rather than by
refusing it. Native `Btu_th` (`1054.3502644888889` J) takes UCUM's `[Btu_th]` **and its unqualified
`[Btu]`**, which is thermochemical; native `cal_IT` (`4.1868` J) takes `cal_IT`. Mapping `[Btu]`
onto `Btu` instead would be wrong by about 0.07 %: small, dimensionally correct, and invisible to
every later check, which is the worst shape an error can have here — so the registry gained the unit
rather than the table gaining a refusal. The rest of the family followed the same way: `[Btu_59]`, `[Btu_60]`,
`[Btu_m]`, `cal_m`, `cal_[15]` and `cal_[20]` now map onto native units of their own, each stated at
the exact decimal UCUM states. Only `[Btu_39]` is still refused — UCUM is the sole vocabulary that
defines it, and one publisher naming a unit is a request rather than the corroboration the others
had. UDUNITS runs the other way and is a trap in the mirror image (§13.3).

**The apothecary dram is not the avoirdupois dram.** `[dr_ap]` is 3.8879346 g and bovnar's `dr` is
1.7718451953125 g — a factor of 2.2 apart. Both map, to *different* native units: `[dr_av]` → `dr`,
`[dr_ap]` → `dr_ap`. The apothecary *ounce* and *pound* are the troy ones, so `[oz_ap]` and
`[lb_ap]` land on `oz_t` and `lb_t` beside `[oz_tr]` and `[lb_tr]` — which is exactly what made the
dram a trap while it had no native unit of its own.

**The survey series is not the international series, at 2 ppm.** `[fur_us]`, `[ch_us]`, `[rd_us]`
and `[acr_us]` are built on the US survey foot (1200/3937 m); native `fur`, `ch`, `rd` and `ac` are
international. They were once mapped onto the international atoms — 2 ppm for the lengths, 4 ppm for
the area, dimensionally perfect, and far inside anything a later check would notice — and were
refused for a while on that account.

The registry now carries the survey series itself, so they map to their own units and the near-miss
is gone rather than merely fenced off: `[ft_us]` → `ftUS`, `[in_us]` → `inUS`, `[mi_us]` → `miUS`,
`[rd_us]` → `rdUS`, `[ch_us]` → `chUS`, `[lk_us]` → `lkUS`, `[fth_us]` → `fathUS`,
`[fur_us]` → `furUS`, `[acr_us]` → `acUS`.

**The British series has no native unit, and is refused by name.** `[ch_br]`, `[ft_br]`, `[yd_br]`
and the rest sit `7.9e-7` from the international units of the same name — a genuinely different
foot, and close enough that the factor proof matched the British chain to the international one
until its tolerance was tightened on the strength of exactly this pair (§9.5). Bovnar has no British
foot, so there is nothing to map them to.

They are `error_unit_profile_unsupported` — *named* refusals, not absences. That is the general
arrangement §15 describes: every code the publisher defines and this build cannot carry is listed,
so a producer who sends `[ft_br]` is told "you wrote it right, and this build cannot carry it"
rather than "that is not a code".

**The annotation is not a discriminator.** `ucum:{RBC}/uL` and `ucum:{cells}/uL` compare equal
(§3.4). That is UCUM's semantics faithfully implemented, and it is still a trap for anyone who
expected the unit slot to carry the analyte.

### 6.4 Codes with no Bovnar representation

| Class | Examples | Outcome |
|---|---|---|
| Arbitrary units (41) | `[IU]`, `[iU]`, `[arb'U]`, `[PFU]`, `[CFU]` | **Profile-only** (§3.6) — parsed, comparable, convertible only across a prefix change |
| Special units with a reference | `B[SPL]`, `B[V]`, `B[W]`, `B[kW]`, `B[mV]`, `B[10.nV]` | `error_unit_profile_unsupported` (§3.7) |
| Other special units | `[p'diop]`, `%[slope]`, `[hp'_X]`, `[m/s2/Hz^(1/2)]` | `error_unit_profile_unsupported` |
| Scale outside a prefix decade | `10*4`, `10*5`, `10*7`, `10*8` | `error_unit_profile_unsupported` (§3.5) |
| Constants as units | `[c]`, `[k]`, `[h]`, `[m_e]`, `[G]` | `error_unit_profile_unsupported` — `[e]` is the exception and maps to `e`, the elementary charge being a unit of charge rather than a dimensionless constant |
| Energy conventions with no native counterpart | `[Btu_39]` | `error_unit_profile_unsupported` (§6.3) — the other six now map |
| The British series | `[ch_br]`, `[ft_br]`, `[yd_br]`, `[in_br]`, `[mi_br]` | `error_unit_profile_unsupported` (§6.3) — bovnar has no British foot |
| Over 32 components | any expression exceeding `BVNR_MAX_UNIT_COMPONENTS` | `error_unit_profile_unsupported` |
| A string that is not a UCUM atom at all | `Cal`, `[zzz]`, `notacode` | `error_unit_illegal` |

`error_unit_illegal` is the last row and *only* the last row. Every atom UCUM actually defines is
either mapped, profile-only or named in the refusal list (§15), so a real code never falls through
as "not a code" — that outcome is reserved for a typo. The survey and apothecary series used to sit
here and no longer do: the registry gained `ftUS`, `chUS`, `acUS`, `dr_ap`, `Btu_th`, `cal_IT` and
their siblings, so those codes map (§6.3).

The middle rows are the honest cost of the design. A clinical corpus will hit
`error_unit_profile_unsupported` on real codes, and the profile does not pretend otherwise: it
names the failure rather than accepting the string and leaving the consumer to discover the
problem. Which of these should become real native units is a registry question this document does
not answer — and the answer has been *yes* twice already, which is why the tropical year and the
survey series are named in the paragraph above as codes that now map rather than here as codes that
do not. Neither of the two this paragraph named last is still waiting: osmolality turned out not to need a
unit at all, only consistency with the equivalent (§6.3), and the mean Gregorian month is now
native as `mo_greg`. What remains here is genuinely open — a reference level for the decibel, an
hour angle, a solar mass.

The last row is the distinction the whole error split exists for. An atom in the `.unsupported` list
is refused as *known and uncarryable*; an atom in none of the three atom lists is refused as *not a
code*.
The first tells a producer "write it another way", the second tells them "check your spelling", and
conflating them is how a legitimate code gets reported as a typo.

---

## 7. Data model

### 7.1 The opaque units

Profile units with no native spelling get ids of their own. Each profile owns a **block** of the
`value_base_unit_t` id space — 10 000 ids whose leading two decimal digits identify the vocabulary —
and its opaque units are numbered from the bottom of that block in declaration order. See
doc/05 §11.1 for the whole layout; `src/utils/bvn_internal_dims.h` holds the compile-time checks
that keep the blocks from overlapping.

**A block per profile, not one shared run.** UCUM's arbitrary atoms are not the only units with no
native spelling: UN/ECE's package and count codes (§11.3) are the same shape, and a later vocabulary
may add more. They used to share a single run appended after the last native unit, which meant a
profile that grew a row renumbered every profile below it — and the whole run moved whenever a
native unit was added. Separate blocks end both. Each is bracketed so §7.2 can test membership with
two comparisons, and the pair also tells the writer which namespace owns a given id. As shipped, in
`include/bovnar_profiles.gen.h`:

```c
#define BVN_PROFILE_UCUM_OPAQUE_FIRST  200000   /* 41 arbitrary atoms, [iU] = [IU] */
#define BVN_PROFILE_UCUM_OPAQUE_LAST   200039
#define BVN_PROFILE_UCUM_OPAQUE_COUNT      40
#define BVN_PROFILE_UNECE_OPAQUE_FIRST 300000   /* 5 counts + 20 packages */
#define BVN_PROFILE_UNECE_OPAQUE_LAST  300024
#define BVN_PROFILE_UNECE_OPAQUE_COUNT     25
#define BVN_PROFILE_QUDT_OPAQUE_FIRST  400000   /* FIRST > LAST: contributes none */
#define BVN_PROFILE_QUDT_OPAQUE_LAST   399999
#define BVN_PROFILE_QUDT_OPAQUE_COUNT       0
/* … one group per profile, plus a BVN_SLOT_<NS> macro each */
```

The empty-profile convention (`FIRST > LAST`) is what lets `qudt`, `qudt-qk`, `udunits`, `om` and
`cf` share one registry row shape with the two that do contribute. `om` is a deliberate case rather
than an accident of coverage: OM has its own arbitrary units, and giving them ids here would make
`om:InternationalUnit` incommensurable with the `ucum:[IU]` that is the same unit (§16.3).

Because the id space is now sparse, the tables the library indexes by base unit are **dense**: one
row per defined unit, indexed by `bvni_unit_slot()` rather than by the id. `BVN_UNIT_SLOT_COUNT` is
that row count — `bu_none`, then every native unit, then every opaque unit — and it is *not* a bound
on the enum. The generated table rows spell their index as `BVN_SLOT_UCUM(bu_ucum_iu)` and so on, so
a row and its enumerator cannot drift apart.

The per-profile pairs are what stop a unit being spelled in the wrong namespace. A single hardcoded
`"ucum:"` in the writer would have printed a UNECE package code as `ucum:XBX`, which re-parses as
nothing at all.

**The ids are assigned by `gen_profiles.py`, not written in the data files.** With one profile a
hand-written `.id` was reviewable; with several it is a renumbering trap, because inserting a row in
an earlier profile shifts every id below it. The generator assigns them in registry order and writes
the result to `include/bovnar_profiles.gen.h`, which is committed — so a shift appears as a diff in
review rather than as a silent ABI change.

Each entry carries the empty dimension vector, factor `1.0`, `.affine = false`, the vocabulary's own
metric flag as its prefix policy, and **no native alias** — the alias table is what makes a unit
reachable from native notation, and leaving it empty is what keeps these profile-only.

### 7.2 Incommensurability, via the mechanism currencies already use

The design sketched a new predicate in `bvni_dims_match` / `bvni_kinds_match` comparing arbitrary
multisets. It was not needed. The library already has a class of units with no SI conversion row —
currencies — and the machinery that handles them gives exactly the right answers here.

Two refusals, one each in the two functions everything else is built on:

- `bvn_unit_to_si_factor` sets `*ok = false` on an opaque component;
- `bvn_unit_dimension_vector` returns false on one.

`bvn_units_compatible` is built on both, so it reports false for anything containing an opaque
unit. `bvn_unit_convert_factor` then falls through to `bvn_unit_prefix_only_delta` — the
same-unit-apart-from-prefixes path that exists for currencies — and that is what produces the rest:

| Pair | Result | Why |
|---|---|---|
| `ucum:[IU]` → `ucum:[IU]` | factor 1 | prefix-only delta of zero |
| `ucum:[IU]` → `ucum:[PFU]` | refused | different bases |
| `ucum:[IU]` → `mol`, `%`, `no_unit` | refused | different bases, and no dimension to fall back on |
| `ucum:[IU]/L` → `ucum:[IU]/mL` | factor 0.001 | same bases, prefix delta 3 |

The last row is the one that matters: `[IU]/L` is a genuine concentration and rescaling its volume
is exact. A flat "anything arbitrary is incomparable" rule would have refused it.

Note the shape of the answer: `bvn_units_compatible` says *false* for `[IU]` against itself, while
`bvn_unit_convert_factor` returns 1. That reads oddly until you see it is exactly how `$USD`
against `$USD` has always behaved — compatibility is a statement about dimension, and neither a
currency nor an assay unit has one.

`bvni_is_opaque` is still a two-comparison range test over the whole block, regardless of how many
profiles contribute to it. The block is contiguous and sits above every native unit, and both facts
are static assertions in `bvn_internal_dims.h` rather than comments: a native unit appended past the
block's first id would otherwise become silently incommensurable with everything.

### 7.3 The spelling on the data event

`bvnr_data_t` carries the unit twice: `value_unit` is what it means, and `unit_source` /
`unit_source_length` are the bytes the producer wrote — following the `frac_data`/`frac_length`
precedent from spec 1.1, which solved the same problem for the one other value whose text says more
than its parsed form.

```c
const void* unit_source;          /* e.g. "ucum:mL{total}"; NULL when there is no unit */
uint32_t    unit_source_length;   /* at most BVNR_UNIT_SOURCE_MAX (255) */
```

**On the way in** the reader sets it on `ev_data` and on the type-annotation events whenever
`value_unit` is a real unit. It is the annotation's unit parameter, or the inline suffix when the
annotation carried no unit; where a value has both — `<float:64,mmHg> 120 ucum:mm[Hg]` — it is the
annotation's, which is the one a re-serialisation writes. It follows the unit through arrays: an
annotation inside an array holds for one element, and the spelling goes back to the array's own
with the unit. A `want_unit` conversion leaves it alone, so it is always the spelling of
`value_unit` and never of `conv.unit`.

**On the way out** the writer emits it in place of the canonical form, after the checks §5.2
describes. This section used to explain why that half was not built: every `bvnr_write_*` entry
point takes a `value_unit_t`, and carrying a string beside it looked like a new parameter on the
whole writer surface. It turned out to need one function. The helpers all reach the unit through
`bvnr_write_type_annotation`, so a spelling set once on the writer
(`bvnr_writer_set_unit_source`) and consumed by the next annotation covers every one of them, and a
caller of `bvnr_write_event` already has the field.

**The struct grew**, which is an ABI change — the same one the read-time conversion fields already
made in this release, under the same SOVERSION bump.

**The DOM** keeps the spelling per node (`bvn_dom_get_unit_source`) only where it differs from
`bvn_unit_to_string` of the node's unit, so a large array written in canonical units stores nothing
extra and `km`, `ucum:mm[Hg]` and `ucum:mL{total}` are each kept. A node whose value a unit policy
converted has none.

### 7.4 New error codes

Two, appended after `error_octet_stream_truncated` (48) and moving `BVN_ERROR_COUNT` with them —
the fuzz harnesses use that count as their bound for "is this a real error code" and trap above it,
so a stale count turns a legitimate new error into a fuzz crash.

| Code | Value | Meaning |
|---|---|---|
| `error_unit_profile_unknown` | 49 | The namespace before the `:` is not a profile this build supports |
| `error_unit_profile_unsupported` | 50 | Valid in its vocabulary over known codes, but no Bovnar representation (§6.4) |

Malformed input, and a code over something the vocabulary does not define, stay `error_unit_illegal`
(32). The split is what a producer needs: "you wrote it wrong" and "you wrote it right and we cannot
carry it" call for different fixes.

`error_octet_stream_forbidden` (51) and `error_type_param_whitespace` (52) were appended after
these two in the same release cycle, so the enum ends at 52 and `BVN_ERROR_COUNT` is 53. The static assertion in
`bvn_internal_dims.h` names the last enumerator explicitly, which is what makes that ordering a
build-time fact rather than a comment.

---

## 8. API

### 8.1 C

```c
/* Unchanged. Dispatches on a "name:" prefix; a native string behaves exactly as
 * it does today. This is the single door — every path that reaches a unit goes
 * through it, so a profile unit cannot arrive by a route that skips a check. */
value_unit_t bvn_parse_unit  (const uint8_t* unit, bool* ok);
value_unit_t bvn_parse_unit_n(const uint8_t* unit, uint32_t len, bool* ok);

/* Unchanged. Emits native form, or profile form when a component is
 * profile-only (section 5.1). */
int32_t bvn_unit_to_string(value_unit_t u, char* buf, size_t bufsize);

/* New. Why a rejected unit string is not a unit: error_unit_illegal,
 * error_unit_profile_unknown or error_unit_profile_unsupported. Re-parses, so it
 * is for the error path; a string that DOES parse reports error_none. */
error_code_t bvn_unit_error_code(const uint8_t* unit, uint32_t len);

/* New. True when a unit has no native spelling — it carries an OPAQUE base
 * unit, a UCUM arbitrary atom or a UNECE package/count code — so
 * bvn_unit_to_string emits profile notation, in the namespace that owns it. */
bool bvn_unit_is_profile_only(value_unit_t u);

/* New. A code in the named vocabulary (without the "<ns>:" prefix); negative on
 * failure (section 5.3). */
int32_t bvn_unit_to_profile(const char* ns, value_unit_t u,
                            char* buf, size_t bufsize);

/* New. bvn_unit_to_profile against "ucum", for callers that predate the other
 * vocabularies. */
int32_t bvn_unit_to_ucum(value_unit_t u, char* buf, size_t bufsize);

/* New. WHICH NAMESPACES THIS BUILD CARRIES (section 9.4). The vocabularies are
 * compiled per-namespace, so "there is no such profile" and "this build has no
 * such profile" are the same error code from the outside; this pair is how a
 * consumer finds out up front rather than inferring it from a failed parse.
 * bvn_unit_profile_name returns NULL past the end, and the ORDER is the
 * library's and not a contract -- the set is. Both are present in every
 * configuration; a build with all seven off answers 0 rather than dropping the
 * symbols, so a caller needs no second way to ask. */
uint32_t    bvn_unit_profile_count(void);
const char* bvn_unit_profile_name(uint32_t index);

/* New. Spell the unit of the NEXT value written through a bvnr_write_*_unit
 * helper the way the producer wrote it (sections 5.2, 7.3). One shot. */
bool bvnr_writer_set_unit_source(bvnr_writer_t* w,
                                 const char* text, uint32_t length);

/* New, in bovnar_dom.h. A node's unit as the document spelled it, where that
 * differs from the canonical form; NULL otherwise (section 7.3). */
const char* bvn_dom_get_unit_source(const bvn_dom_node_t* node,
                                    uint32_t* len_out);
```

`bvn_parse_unit` keeps its signature, which is why the policy strings of §4.3 work with no change
at all: `bvnr_unit_policy_t` parses its targets with it, so the notation arrives for free. One
struct changed for this feature — `bvnr_data_t` gained `unit_source`/`unit_source_length` (§7.3) —
so its ABI movement is that, the two error codes and the new entry points. (The release moved more
than the profiles did; the changelog has the whole list.)

### 8.2 Python

`parse_unit`, `unit_to_str`, `units_compatible` and `UnitPolicy` accept and produce every profile
notation with no signature change. Four additions mirror the C ones:

```python
bovnar.unit_to_profile(ns, vu)   # -> str; ns is one of the seven namespaces.
                                 #    Raises when the unit has no code in that
                                 #    vocabulary, which for the read-only "cf"
                                 #    is every unit (§17.3)
bovnar.unit_to_ucum(vu)          # -> str; the "ucum" case of the above
bovnar.unit_is_profile_only(vu)  # -> bool
bovnar.unit_error_code(s)        # -> int (error_code_t), 0 when s parses
```

A namespace this build was not compiled with (§9.4) raises like one that does not exist;
`bovnar.available_profiles()` lists the ones it has.

**The spelling travels through the typed tier.** `loads(typed=True)` puts it on each `Quantity` as
`unit_source`, and `dumps()` writes it back, so a typed round trip of a document spelled in a
profile is byte-identical — annotations, compact prefixes and space-multiplied UDUNITS codes
included. `dumps()` prepends `#!bovnar 1.2` when a kept spelling (or a profile-only unit) needs it
and not otherwise. An array has one annotation and so keeps one spelling: where its leaves disagree,
or one carries none, the array is written in canonical form.

```python
q = bovnar.loads(src, typed=True)["volume"]
q.unit_source                    # "ucum:mL{total}"
str(q.unit)                      # "m~L"
Quantity("1.5", vt, "m~L", unit_source="ucum:mL{total}")   # set by hand
data.unit_source_str()           # the same text, on a Reader callback payload
node.unit_source                 # and on a DomNode, where it differs from the canonical form
```

The existing `from_pint_unit` / `to_pint_unit` bridge is untouched.

### 8.3 CLI

`bovnar version` prints the compiled-in namespaces on a second line, which is where to look first
when a code that should parse does not:

```
bovnar 2.1.0 (spec 1.2)
unit profiles: ucum unece qudt qudt-qk udunits om cf
```

Otherwise, no new flags. `--unit`, `--field`, `--require-dimension` and `--require-field` take unit strings and
therefore take profile strings:

```
bovnar validate --require-field '.patient.systolic=ucum:mm[Hg]' chart.bvnr
bovnar validate --require-field '.line.qty=unece:XBX'           invoice.bvnr
bovnar events --unit 'ucum:mmol/L'   labs.bvnr
bovnar events --unit 'qudt:M-PER-SEC' telemetry.bvnr
```

`bovnar events` shows both halves of the translation, which is the quickest way to see what a code
became: the `type_param` row carries the source spelling and the `type_family` row the parsed unit.

```
  type_param      unit         "qudt:M-PER-SEC"
  type_end        type          <float:64,_10,m/s>
```

`bovnar pretty-print` re-emits the annotation from the captured source text (§5.2), so a document
that went in spelled `qudt:M-PER-SEC` comes out spelled `qudt:M-PER-SEC`.

---

## 9. Build and conformance

### 9.1 Where the tables live

One hand-edited data file per namespace in `src/gendata/`, in the same shape as `units.bvnr`,
`currencies.bvnr` and `prefixes.bvnr`:

| File | Lists |
|---|---|
| `ucum.bvnr` | 20 prefixes, 206 mapped, 40 opaque, 1 opaque alias, 65 unsupported |
| `unece.bvnr` | 1299 mapped, 25 opaque, 155 unsupported |
| `qudt.bvnr` | 2235 mapped, 573 unsupported |
| `qudt-qk.bvnr` | 900 mapped, 265 unsupported |
| `udunits.bvnr` | 41 prefixes, 768 mapped, 98 unsupported |
| `om.bvnr` | 1337 mapped, 123 unsupported |
| `cf.bvnr` | 4454 mapped, 617 unsupported |

Five list kinds, and a code belongs to exactly one of them: the vocabulary's prefix spellings with
their decades (expression profiles only — a flat profile has no prefix mechanism), the codes that
translate (with their native target expression and the vocabulary's metric flag), the opaque codes
(with no target at all), the opaque aliases (a second code for one of the opaque codes, sharing its
id — only `ucum:[iU]`), and the codes that are known but refused (with the reason).
`gen_profiles.py` generates the C lookup tables on every build, wired into CMake next to the other
generators; the generated files are never edited.

The native target is stored as **source text**, not as a pre-baked `value_unit_t`, and the C side
parses it with the same `bvn_parse_unit` every document goes through. That keeps the generator from
needing a second implementation of the unit grammar in Python, which is how a generated table starts
disagreeing with the parser it feeds.

### 9.2 What the generator checks, and what it does not

At build time `gen_profiles.py` refuses to emit a table when:

- a code appears in more than one of the lists (a code has exactly one outcome);
- a flat profile declares prefixes at all, or a prefix declares a decade with no SI prefix — the
  fold could never discharge it;
- two profiles claim the same block tag, a profile claims the native units' tag or the currencies',
  or a profile has more opaque units than its block holds — any of which would make one id answer to
  two vocabularies;
- an opaque name collides across profiles;
- a `.bovnar` target names a prefix or a unit this build's registry does not have;
- the longest profile code this table could emit would overflow `BVNR_UNIT_STRING_MAX` (1088), which
  is what sizes the stack buffers in the writer and the CLI;
- every row naming one unit carries `.reverse = false`, which would make that unit readable and
  unwritable.

The target check is the useful one day to day: a typo in a target would otherwise surface as
`error_unit_illegal` on a code that is perfectly valid, which is the most confusing failure
these tables can produce.

**What it does not check is the vocabulary side**, and that half now lives in a separate tool rather
than in the generator. `gen_profiles.py` still carries no foreign value: a data file states the
target and not what the publisher says the code is worth. Where a value was uncertain, or certainly
*close but not equal* to a native unit, the code went into `.unsupported` instead of being mapped
onto the nearly-right one. That is where `[Btu]`, `cal_IT` and `[dr_ap]` sat until the registry
gained units of exactly their value; all three map today, and the rule that kept them out is the
rule that still holds for whatever has no exact target.

`check_profile_factors.py` (CTest target `bvnr_profile_factors`) closes it for all seven profiles,
though not for all of them equally. It reads UCUM's `ucum-essence.xml`, the UDUNITS-2 XML database
and QUDT's Turtle vocabularies, resolves every code to a factor and a dimension vector, asks the
**reference library** — not a second Python implementation of the unit grammar — what the mapped
native target is worth, and compares. `unece` is reached at one remove; see §9.5.

The generator also emits the **reverse** tables §5.3 uses, choosing the canonical code for each slot
by the grammar's rule (shortest for an expression profile, first-declared for a flat one), honouring
`.reverse = false`, and recording that code's own decade. Deriving them rather than searching the
forward tables at run time is what makes `bvn_unit_to_profile` deterministic; the 689 round trips
quoted in §5.3 are the check that forward and reverse agree.

### 9.3 Tests

One test file per vocabulary, each pinning the section that specifies it, plus the suite that holds
them to each other:

| Test | Target | Assertions | Specifies |
|---|---|---|---|
| `tests/bovnar_ucum_test.c` | `bvnr_ucum_test` | 217 | §2–§10 |
| `tests/bovnar_unece_test.c` | `bvnr_unece_test` | 576 | §11 |
| `tests/bovnar_qudt_test.c` | `bvnr_qudt_test` | 256 | §12 |
| `tests/bovnar_udunits_test.c` | `bvnr_udunits_test` | 258 | §13 |
| `tests/bovnar_om_test.c` | `bvnr_om_test` | 118 | §16 |
| `tests/bovnar_cf_test.c` | `bvnr_cf_test` | 58 | §17 |
| `tests/bovnar_crossvocab_test.c` | `bvnr_crossvocab_test` | 4983 | §14 |

`bvnr_ucum_test` (labels `unit;si;profile;ucum`) carries the behavioural claims of sections 2–10: the three
outcomes with their exact error codes, equivalence with the native spelling, UCUM's non-latching
`/`, annotation inertness, every worked fold case in §3.5 including the four refused decades, each
collision in §6.2, arbitrary-unit incommensurability including the `[IU]/L` ↔ `[IU]/mL` case,
profile-only round-trip, the registry sweep of §5.3, the version gate, and the partiality of
`bvn_unit_to_ucum`.

`tests/bovnar_unit_ext_test.c` pins the block boundary: the last native unit sits below
its profile's `_OPAQUE_FIRST`, and `BVN_UNIT_SLOT_COUNT` grows by one dense table row.

**The conformance corpus covers the profiles too.** `bvnr_conformance` carries a `unit_profile`
group of **59 cases** (`UPR-001` … `UPR-053`), so a third-party implementation can be held to the
same rules rather than only the reference one being tested against itself. It covers the three
outcomes with their exact error codes, the version gate, annotations, the decade fold, the
error-code move of §2.3 (`<float:64,m[s]>` was `error_unexpected_input_byte` and is now
`error_unit_illegal`), and — for the four vocabularies of §11–§13 — flatness, the opaque counts,
quantity kinds, reference time, and cross-vocabulary agreement.

The agreement cases are written a particular way, and it is worth knowing why: each is an annotation
in one notation against an *inline unit* in another, for example
`<float:64,unece:KGM> 12.5 ucum:kg;`. The parser compares the parsed units, so such a case passes
only if both spellings produced the same `value_unit_t` — which lets the corpus test cross-vocabulary
equality without needing any comparison facility of its own, and without a conforming implementation
having to expose one.

### 9.4 One build switch per vocabulary

**`BVNR_WITH_<NAME>_PROFILE`, seven of them, all `ON` by default.** This section used to say the
profiles were unconditional and that the switch did not exist; §15.3 then measured what that cost —
the binary grew 65 % — and §10.4 carried the switch as specified-and-not-built. It is built.

| | |
|---|---|
| Options | `BVNR_WITH_UCUM_PROFILE`, `..._UNECE_...`, `..._QUDT_...`, `..._QUDT_QK_...`, `..._UDUNITS_...`, `..._OM_...`, `..._CF_...` |
| Default | `ON`, every one. The default build is byte-for-byte the build every consumer already has |
| What comes out | That vocabulary's atom, unsupported and reverse tables, its registry row, and the string literals they point into |
| What it saves | All seven off: **1.96 MB → 524 KB** of `libbvnr.so` (a 73 % reduction), measured Release+LTO on x86-64. `cf` alone is about 150 KB |
| Reported by | `bvn_unit_profile_count()` / `bvn_unit_profile_name(i)`, and `bovnar version` prints the list |

**What a switch does not change, and this is the part that matters.** Not the base-unit id space, not
the dense unit tables, not `value_unit_t`, not `error_code_t`, not one exported signature. The
generators still run whole: every `bu_ucum_*` enumerator keeps the value it has and the opaque blocks
stay where they are (§7.1), so two builds with different switches are ABI-compatible and differ only
in which namespaces they translate. A caller compiled against one can link the other.

**An absent namespace is `error_unit_profile_unknown`** — the same answer as a namespace no build
ever defined. That is deliberate and it is what this section always predicted the code was for: the
document is not wrong for naming `ucum`, and a build without `ucum` cannot read it either way. A
consumer that needs to distinguish the two asks `bvn_unit_profile_count`/`bvn_unit_profile_name`
before parsing rather than inferring it from an error afterwards.

**The cost of an absent namespace is symmetrical and total.** It cannot be read, and it cannot be
written: `bvn_unit_to_profile` and `bvn_unit_to_ucum` return −1 for it, and a unit carrying that
vocabulary's opaque units (§7.1) has no spelling at all, so `bvn_unit_to_string` fails rather than
inventing one. There is no half-way state in which a build carries the ids but not the words. A build
that must read `ucum:[IU]` needs `ucum` compiled in.

**How the reduced configurations are kept working.** The switches are compile-time, so an ordinary
build reaches exactly one of the 128 configurations they describe, and a configuration nothing
reaches is one that stops compiling without anyone noticing. `bvnr_profile_configs` compiles the
*amalgamation* twice under the strict `-Werror` flags an integrator uses, and runs a smoke test
against each.

*All seven at `0`* (`tests/profiles_off_smoke.c`) is where the compile itself is at risk: it pins the
contract above and would fail on the two things that actually break there — a registry array left
empty (C99 has no empty initialiser, which is why the table carries a sentinel row) and a table that
loses its last reference and trips `-Wunused-const-variable`.

*A partial set* (`tests/profiles_partial_smoke.c`, `ucum` and `qudt-qk` on) is where the interesting
mistakes live instead, and none of them is reachable when the answer is uniformly "none": an index
that walks absent profiles rather than skipping them, an id space that compacts when a block is
unused — `bu_unece_one` must stay `300000` with `unece` absent, or two builds would disagree about
what an id means — and an opaque unit whose profile is not compiled in, which stays incommensurable
and has no spelling. It also pins the distinction a mixed build is the only place to observe at
once: an absent namespace is `error_unit_profile_unknown` while a bad code in a *present* one is
`error_unit_illegal`.

Both are registered unconditionally of the switches, since a reduced build is the last place they
should be skipped. The per-vocabulary test suites are the other side of the same
arrangement: each is registered only when its own profile is on, and the whole-corpus gates — the
cross-vocabulary suite, the conformance runs, the Python suites — only when all seven are, because
their totals are single numbers that a partial build would make wrong rather than smaller.

### 9.5 The factor proof

> `check_profile_factors.py`, CTest target `bvnr_profile_factors`, labels
> `python;units;profile;vocab`. The `python` label records "drives libbvnr from a Python
> interpreter", which is what a sanitizer pass excludes with `-LE python`: ASan refuses to `dlopen`
> its own runtime into an uninstrumented interpreter, so the ctypes route below is unavailable
> there. The unsanitized pass still runs it.

Everything else in this document proves the tables are **self-consistent**. §14.3 is blunt that this
is not the same as correct: five tables wrong in the same way would agree with each other perfectly.
This tool is the outside check.

For each mapped row it resolves the publisher's own definition of the code to a factor and a
dimension vector, asks the reference library what the native target is worth, and compares. The
native side goes through `bvn_parse_unit` and `bvn_unit_to_si_factor` via the ctypes bindings, never
through a Python reimplementation of the unit grammar — that is exactly how a table starts
disagreeing with the parser it feeds (§9.1).

As of this tree it compares **10905 rows across the seven vocabularies** and reports **0 mismatches**,
0 dead rows and **0 coverage suggestions** — the tables are closed against their publishers, so
there is nothing left for the coverage half to propose.

**Three outcomes, and only one fails the build.**

| Outcome | Meaning | Fatal |
|---|---|---|
| mismatch | the code exists upstream and means something else — a different dimension, or a factor outside tolerance | **yes** |
| dead | the table accepts a spelling the publisher does not define; unreachable from a conforming producer | no (`--strict-dead`) |
| unmapped-but-exact | the publisher defines a code worth something this build already spells, and the table does not carry it | no — a coverage suggestion, and carrying a unit is editorial |

There are no dead rows. The report used to name ten `udunits` spellings the UDUNITS XML database
does not define as atoms — `gramme`, `pc`, `degree_Fahrenheit`, `deg`, `nmi`, `dB`, `decibel`,
`Np`, `neper`, `pH` — and lumped together one that is right, five that are deliberate and four that
were hazards. `dB` is deci + `B`, a decibel in UDUNITS' own language; `nmi` (a nanomile there),
`pH` (a picohenry), `Np` and `neper` were removed; and `gramme`, `pc`, `degree_Fahrenheit`, `deg`
and `decibel` are accepted on read and never written (`.reverse = false`). A row is dead now only
when it is writable, or when the publisher's prefix reading of it is a different unit.

**What the coverage suggestion is matched against decides what it can find.** It began as an index
of the 180 native *symbols*, which meant a code could only ever be proposed when it was worth a bare
unprefixed atom — and a flat vocabulary spells every prefixed and every compound unit as one whole
token. `unece:A97` is the hectopascal, `KMQ` the kilogram per cubic metre, `MSK` the metre per
square second, `qudt:RAD-PER-SEC` the radian per second: not one of them is a native symbol, so not
one of them could be suggested, and they were exactly the codes a table is most likely to be missing
while its neighbours carry them. The index now also holds every `.bovnar` target any of the five
tables already uses — a target one table has written down is a spelling this build is known to
accept — which is what turned that whole class from invisible into a printed list. At its peak it
printed 1161 suggestions, divided as `qudt-qk` 718, `qudt` 222, `udunits` 113, `unece` 95, `ucum`
13. Every one of them has since been resolved, into a mapped row or a refusal with a reason, and the
count is 0.

**A suggestion asks whether a PRODUCER can write the code, not whether a row exists.** The last two
it printed were `udunits:kg` and `udunits:kilogram`, and neither was missing: an expression profile
reaches them through its prefix mechanism as `k` + `g` and `kilo` + `gram`, with no row and nothing
wrong. Asking the library settles it. A spelling that parses to the *wrong* unit is still reported,
because that is a worse defect than a missing row rather than a lesser one — which is exactly what
`udunits:pt` was (§13.4).

**The two systems are not the same system**, and the corrections are the substance of the UCUM
comparison rather than a detail of it. UCUM's mass base is the gram, so a factor carries 10³ per
mass exponent that bovnar's does not. UCUM has no amount base — the mole is Avogadro's *number* — so
a factor also carries N_A per amount exponent, divided out using UCUM's own value so it cancels
exactly. UCUM's electrical base is charge, which maps to current **and** time, and that is what makes
`A` = `C/s` come out as a bare current. UCUM's plane angle is a base and bovnar's radian is
dimensionless, so that component is dropped.

**The tolerance is measured, and the margin is thin.** A publisher states decimals, and a publisher
states them at whichever CODATA edition it was last revised against — UDUNITS writes the horsepower
as `7.456999e2 W` and the atomic mass unit at the 1986 adjustment, UCUM states the same unit at 2018,
and `units.bvnr` states it at 2022 (the one measured constant in the native registry; its header
names the edition, since an edition nobody wrote down is one nobody can check). So a correct row
still disagrees in the seventh digit. The genuine errors are far above that: the US survey foot
at `2e-6`, the survey acre at `4e-6`, the tropical year at `2.1e-5`, the IT calorie at `6.7e-4`.

What sets the bound is neither of those, but the closest real pair in any of the vocabularies:

| | |
|---|---|
| largest publisher rounding observed | `6.81e-7` — UDUNITS' 1986 CODATA atomic mass unit against bovnar's 2022 one |
| smallest genuine difference observed | `7.87e-7` — UCUM's **British** chain against the international one |

`TOL = 7.5e-7` is the only value that separates them, and it separates them by 10 %. This was found
the hard way: at the `1e-6` the tool originally used, `ucum:[ch_br]` matched bovnar's international
chain and was very nearly added to the table as a coverage gap. The British foot, yard, chain and
rod differ from the international ones only in the seventh digit, which puts a whole family of real
units inside the noise floor of a decimal comparison. They are kept out of `ucum.bvnr` by name, with
the reason written beside the row they would have joined, rather than left to the tolerance (§6.3).

A real disagreement below `6.8e-7` would still pass. That is the honest limit of comparing against
a source that publishes rounded decimals, and no amount of tuning removes it — only tracking each
publisher's stated precision through its whole definition chain would.

**Waivers are printed** rather than silently skipped, because a waiver nobody sees is how a
regression hides behind an old excuse. There are two kinds:

- **Modelling (102 rows, listed under `--verbose`).** bovnar carries bit and byte as two base units of information with no
  factor between them; UCUM and UDUNITS both define the byte as the number 8. QUDT goes further and
  models information as *entropy*, so its bit is `ln 2` and its byte `8·ln 2`, the coherent unit
  being the nat — which is exactly why `qudt-qk.bvnr` already refuses `InformationEntropy`. A
  different model, not a wrong conversion.
- **The publisher is wrong or rounded (35 rows, printed on every run: OM 7, QUDT 13, UCUM 6,
  UDUNITS 2, UNECE 7).** UCUM defines the phot as `1e-4 lx`; a phot is one lumen per square
  centimetre, i.e. `1e4 lx` — the value is inverted. Native `ph` is correct and the profile keeps
  mapping to it. UCUM also rounds the mercury column to `133.3220 kPa` (2.9 ppm) and QUDT the torr
  to `133.322 Pa` (2.8 ppm), both past the tolerance and both against exact conventional values
  bovnar carries. The rest are the same two shapes — a constant stated at five or six digits, or a
  value that is not the publisher's own model (QUDT's pebibyte) — and `unece:MON`, below.

**A waiver is for a row whose label, symbol and cross-references agree and whose number does not.**
Where the publisher contradicts *itself* — the label and the ucumCode say one unit and the multiplier
another — the row is **refused** rather than read by either side, because the producer who wrote the
code may have meant either. `qudt:LA` (labelled Lambert, worth an apostilb), `HP_Brake` (worth a
boiler horsepower), `PT` (labelled Point with ucumCode `[pnt]`, worth the thou), `BTU_MEAN` (ucumCode
`[Btu_m]`, worth the IT BTU), `GRAIN-PER-GAL` (imperial by symbol and ucumCode, US by number) and the
four prefixed katal-per-litre units (`MicroKAT-PER-L` is labelled, symbolled and ucumCoded `ukat/L`
and worth a million times less) are refused for it, and so is the one Rec 20 code each was the only
claimant of (`unece:P30`, `N3`, `J39`). Each used to follow the number; `ucum:ph` follows the name
only because UCUM states nothing *but* the number and the name, and a phot is not in doubt.

**The cross-reference check compares numbers too.** QUDT's ucumCode and udunitsCode for a unit, and
its uneceCommonCode, each claim that two bovnar rows in two different tables are one unit. The check
used to ask only whether the two were convertible — and a row fitted to a wrong multiplier stays
convertible with the code its publisher gives for it. It now asks for the same factor (to `TOL`)
against the UCUM and UDUNITS rows and for the **same unit** (`bvn_unit_equal`) against the UNECE row,
which is the only test here that can see `qudt:KiloVAR` read as `k~VA` beside `unece:KVR` read as
`k~var`: equal in SI, different in meaning. None disagree; the ones waived by name are those
where QUDT's code is the one that is wrong (the crossed `[dr_ap]`/`[dr_av]` of its
two drams, `udunits:t` on the short ton, `udunits:DU` on the millimole per square metre) or where
Rec 20's only code for a unit is its definition (`PS`, *pound-force per square inch*, read as the
native `psi`). Six such definition codes (`A8`, `J55`, `P14`, `M77`, `F90`, `C55`) spell the definition
out — `A·s`, not `C` — as QUDT's rows do, and the named unit keeps its own code (`COU`, `JOU`, …).

**It needs the publications, and a test must not fetch.** The files are cached under
`<build>/vocab/`; populate it once with `python3 check_profile_factors.py --fetch`. Without a cache
the test **skips green**, which is the same rule `bvnr_web_links` follows. CI should fetch and then
pass `--strict`, so that a skip there is a failure rather than a quiet pass.

**QUDT needs no evaluator, and its quantity kinds get a sharper check than the others.** UCUM and
UDUNITS state a unit as an expression over other units, so both need a parser. QUDT states each
unit's own `conversionMultiplier` and its dimension vector as an IRI local name
(`A0E0L1I0M0H0T0D0`), so reading it is a table lookup. A quantity kind has no multiplier at all —
and there the check is the claim §12.3 actually makes, that a kind maps to the **coherent** SI unit
of that kind. Reporting a kind as `(1.0, its dimensions)` turns that into two ordinary assertions —
the dimensions agree, and the native factor is exactly 1 — so `qudt-qk:Length` mapped to `ft` would
fail on the factor although its dimensions are perfect.

**`unece` is checked through a secondary source, and the difference is not cosmetic.** Rec 20 states
its conversion factors in prose, so there is no primary artefact to resolve. QUDT carries a
`qudt:uneceCommonCode` on many of its units, which gives a machine-readable UNECE-code-to-value map
at one remove — QUDT asserting what a Rec 20 code means, not UN/ECE. Three things follow, and the
tool implements all three rather than quietly treating the result as primary:

- **A disagreement is evidence, not proof.** It says one of the two tables is wrong, not which. The
  first one found is a case in point: QUDT attaches `MON` to its own `MO`, a unit its description
  calls the *synodic* month of 29.53059 days. Rec 20's `MON` is a commercial month, so the
  cross-reference is what is wrong there — a trade code list does not mean the lunar cycle — and
  bovnar's Julian month stays. It is carried as a waiver and printed every run.
- **A code QUDT does not mention is not thereby absent from Rec 20.** The dead-row check is
  therefore switched off for this vocabulary entirely, and the rows the cross-reference does not
  reach are counted and named on every run instead. There are 8: `CEL`, `FAH`, `GRY`, `HEN`, `MOL`,
  `Q12`, `Q13` and `TNE`.
- **A code several QUDT units claim is usable only when they agree.** The cross-reference covers
  1488 codes; 81 have more than one claimant. Most are aliases of one unit, but `J62` is claimed by
  both a barrels-per-hour and a barrels-per-second unit, which differ by 3600. 11 codes disagree
  that way and go unchecked — which is why `TNE`, claimed inconsistently, is in the uncovered list
  above.

The cross-reference also **grew the table**, from 100 mapped codes to 201 in one pass. The tool
listed 151 codes whose value is exactly a native unit and which `unece.bvnr` did not carry; 101 were
taken. What decided the other fifty is worth stating, because it is precisely what a value-only
match cannot do: **a suggestion is a claim about a number, not about a meaning.** Dimensions
collapse, so `D13` (the sievert) matched the gray, `D44` (var) and `D46` (volt-ampere) both matched
the watt, `NU` (the newton metre) matched the joule, and `C80` (the rad) matched the rem. Every one
of those pairs is dimensionally equal and freely convertible (doc/07 §9); what distinguishes them is
the *spelling*, which is what a unit slot carries. Accepted on the number alone, each would have put
a document's own vocabulary word for one quantity onto another. Every row was read against QUDT's
label for the unit carrying the code, and the ones left out divide into three groups:

| Left out | Why |
|---|---|
| ratios of two named units — `mg/kg`, `mL/L`, `cm³/m³`, `bar/bar` | each is worth exactly `ppm`, `‰` or 1, but the code says a ratio *of what*, and collapsing it onto the generic unit loses that |
| logarithmic and information scales — `phon`, `sone`, `Erlang`, `Nat`, `baud` | no native form, or a modelling difference: QUDT makes `baud` dimensionless where native `Bd` is a rate |
| a quantity that is not the matched unit — `J/m³` (energy density, matched `Pa`), `J/kg` (specific energy, matched `Gy`), `W/sr` (radiant intensity, matched `W`) | same dimension, different quantity |

`J39` (mean BTU) was left out for the reason §6.3 already gives for its UCUM counterpart: native
`Btu` is the IT one, and a code whose label says otherwise is a factor trap rather than a coverage
gap. It was later mapped to `Btu` through `qudt:BTU_MEAN`, whose multiplier is the IT value, and is
now refused with that row, because the label and the ucumCode `[Btu_m]` say the mean BTU (native
`Btu_m`) and the number does not. `DRI` (UK dram) maps to the avoirdupois `dr`: its one QUDT
claimant, `DRAM_UK`, states the avoirdupois value under the label "Dram (UK)", and only its
ucumCode — crossed with `DRAM_US`'s — points at the apothecary dram.

Arbitrary and special units (`isArbitrary`, `isSpecial`, and any QUDT unit with no
`conversionMultiplier`) have no factor to check — they are exactly the ones carried as opaque or
refused. The standing risk of §10.2 is **reduced everywhere and retired nowhere**: four tables now
rest on their publishers, and the fifth rests on another publisher's reading of its publisher.

### 9.6 Synchronisation between the tables

Checking each table against its own publisher leaves a second question open: whether the five agree
with **each other** about what bovnar's registry contains. §14's concept table asks that of the
concepts it lists; this asks it of every native unit, and it is the same tool — the coverage half of
`check_profile_factors.py`, read across all five outputs at once rather than one profile at a time.

For each native unit, the question is which profiles map it against which vocabularies
*define* something equal to it. A unit that three tables carry and a fourth omits — while that
fourth vocabulary has a perfectly good code for it — is a synchronisation gap, and on the first
sweep there were 65 of them. They were overwhelmingly one-sided:

| Profile | Units its own vocabulary defines that the table omitted |
|---|---|
| `qudt` | 56 |
| `unece` | 9 |
| `ucum` | 8 |
| `udunits` | 5 |

`qudt` was the outlier by an order of magnitude: it had no curie, no dyne, no roentgen, no phot or
stilb, no poise or stokes, none of the imperial volumes, and neither the volt-ampere nor the var
although QUDT defines all of them. Closing it took the table from 158 mapped codes to 244.

**The gap could not be closed by the numbers**, and this is the same lesson §9.5's coverage
suggestion carries. Matching purely on value proposed the lux for a luminance, the rem for the rad,
and the watt for *both* the volt-ampere and the var — because those pairs are equal in SI and differ
only in which word the document uses (doc/07 §9). Every row was read against the publisher's own
label before it was accepted.

**A second pass closed the compound half of the same gap.** The first pass could only see codes
worth a bare native symbol (§9.5), which is why it reported nine for `unece` and none at all of the
kind a flat vocabulary is most likely to be missing. Once the index carried the tables' own targets,
another 71 rows landed — `unece` from 201 to 252 and `qudt` from 244 to 263, the counts of that pass
(§9.1 has today's) — and they divide into two groups that are worth naming, because both are gaps a
flat grammar creates and an expression grammar cannot have:

| Group | Examples | Why the expression profiles never had the gap |
|---|---|---|
| a **prefixed** unit | `unece:A97` hPa, `KVT` kV, `4H` µm, `2Q` kBq, `qudt:KiloCAL_TH` | `ucum` and `udunits` reach every decade by emitting a prefix; a flat vocabulary needs a separate code per decade, and there were 25 missing |
| a **compound** unit | `unece:MSK` m·s⁻², `KGS` kg·s⁻¹, `C65` Pa·s, `JE` J·K⁻¹, `qudt:M2-PER-SEC`, `RAD-PER-SEC` | `ucum:m/s2` is an expression; `unece:MSK` is one token that resembles no native spelling at all |

Most of the compound group is the coherent SI unit of a kind `qudt-qk.bvnr` already maps, which is
the sharpest form the desynchronisation took: the two QUDT namespaces disagreed about their own
publisher, `qudt-qk:KinematicViscosity` translating to `m²/s` while `qudt:M2-PER-SEC` — a unit QUDT
defines — was not a code this build would accept. §14's table now carries a row for each, so the
agreement is pinned rather than asserted.

Reading by label rather than by number mattered again, and in the same direction. The value alone
proposed the **watt** for `KVA` and `MVA` and the **hertz** for all three becquerel codes; apparent
power is not active power and an activity is not a frequency (doc/07 §9).

**It also found a row that was wrong rather than missing, and the shape of it is worth keeping.**
Three vocabularies have a code for the reciprocal minute and a *different* code for the revolution
per minute, and native `rpm` is neither: `rpm` counts revolutions where `rev` is an **angle** of 2π
radians, so `rpm` and `rev/min` differ by 2π and are genuinely held apart by the angle quantity kind
(doc/07 §9). `unece:C94` and `qudt:PER-MIN` — both labelled *reciprocal minute* — were mapped onto
`rpm`, which asserted a rotation the code does not make, while `unece:M46` and `udunits:rpm`, which
do mean the revolution per minute, were not carried at all. `qudt:REV-PER-MIN` had been correct
since it was written, and its own comment says why; the two reciprocal-minute codes were the mirror
error beside it. All four now agree:

| Concept | native | `unece` | `qudt` | `udunits` |
|---|---|---|---|---|
| reciprocal minute | `min⁻¹` | `C94` | `PER-MIN` | — |
| revolution per minute | `rev/min` | `M46` | `REV-PER-MIN` | `rpm` |

The precedent was already in the same file: `unece:C97`, the reciprocal second, maps to `s⁻¹` and
not to the hertz, for exactly this reason. `min⁻¹` is what it looks like beside a unit that has a
name.

What remains unclosed is deliberate: ratios of two named units, logarithmic scales with no native
form, and the British imperial series, whose members sit inside the tolerance of §9.5 and are kept
out by name instead.

---

## 10. Cost, risk, and what is left out

### 10.1 What it cost

| Area | Change |
|---|---|
| Lexer | 15 state-table entries across three states (§2.3); brace/bracket-depth tracking in the type-parameter scanner (§2.4) |
| Unit parser | `src/utils/bovnar_profiles.c` — namespace dispatch, the shared expression parser, the flat matcher, the translator, the fold |
| Registry | Five data files in `src/gendata/` in that pass, seven today (§9.1); `gen_profiles.py` emitting the per-profile and shared tables; one `value_base_unit_t` BLOCK per profile (UCUM 200000–200040, UN/ECE 300000–300024) |
| Version gate | Two checks in the validator — the annotation unit parameter and the inline unit suffix (§2.2); one guard in the writer |
| Compatibility | Two refusals, one each in `bvn_unit_to_si_factor` and `bvn_unit_dimension_vector` (§7.2) |
| Serialisation | One guard at the head of each of the two formatters (§5.1) |
| ABI | Two error codes; three new functions. **No struct changed** *(in that pass — `bvnr_data_t` has since gained `unit_source`, §7.3)* |
| Bindings | Four `ctypes` declarations and four wrappers |
| Tests | 604 assertions across five per-vocabulary files, 3551 in the cross-vocabulary suite, 47 conformance cases; two assertions widened in `bovnar_unit_ext_test.c` (the figures for this pass; §§16–17 added two more files, 137 assertions, 1225 cross-vocabulary assertions and 6 conformance cases) |

The unit parser is the bulk of it. Everything else is small, and — this is what §1.1 buys — the
DOM, the writer, the streaming reader, the policy engine and the CLI needed no work at all, because
a translated unit is an ordinary unit. The two formatter guards, the two compatibility refusals and
the writer's one spec-version check are the entire footprint outside the new file. (That was true
of translation, which is what this table costs out. Keeping a unit's *spelling* came later and did
touch the reader, the writer and the DOM — §7.3.)

### 10.2 What can go wrong

**The tables rest on their authors less than they did, and not nowhere.** §9.5's factor proof now
does confirm that UCUM's `[Btu_IT]` is 1055.05585262 J against `ucum-essence.xml`, and it has found
real errors — the US survey series, the IT calorie, the tropical year, a QUDT rotational speed 2π
out. What it cannot reach is the part of a row that is not a number:

- **What a code MEANS.** A factor comparison confirms that `unece:D13` is worth what native `Sv` is
  worth. It cannot confirm that D13 is the *sievert* rather than the gray, which is the same value
  and a different quantity — and, since the two convert freely (doc/07 §9), a wrong choice there
  survives into the document as a wrong word rather than a wrong number. Every one of those calls
  was made by reading the publisher's label, by hand, and a wrong reading survives every check in
  this repository.
- **The seventh digit.** §9.5's tolerance is measured at `7.5e-7` and the British imperial series
  sits at `7.9e-7`. A real disagreement below `6.8e-7` passes.
- **`unece` at all, primarily.** Rec 20 has no machine-readable factor artefact, so that table rests
  on QUDT's reading of it, on 6 rows the cross-reference does not cover at all, and on 11 codes
  whose QUDT claimants disagree.

The conservative default is what covers the rest: a code whose value was uncertain went into
`.unsupported` or was left out, so the failure mode is a refused code rather than a wrong number.

**Five tables multiply the exposure, and the cross-vocabulary suite does not divide it.** §14 proves
the five agree with each other, which is a real property and catches a whole class of single-table
error — it found the missing ampere on its first run. It cannot catch a *shared* error: five tables
wrong in the same way agree perfectly. That is now the argument for §9.5 rather than a gap beside
it, since the factor proof is the only check in the tree that looks outside the tree at all.

**The tables rot — and something now notices.** UCUM, Rec 20, QUDT and UDUNITS all revise; the data
files do not, and the ordinary build cannot tell, because §9.5's check skips without its cache and a
test must not reach the network. `.github/workflows/vocabulary-drift.yml` runs
`check_profile_factors.py --fetch --strict` weekly, outside the build that gates a pull request, so
a publisher's revision becomes a red scheduled run rather than a silent divergence. It watches what
it can: UDUNITS-2, UCUM and OM are read from their moving branch, QUDT and CF from the release the
tables were built against, and Rec 20 not at all — it has no machine-readable artefact to fetch
(§11.1). A failure is a prompt to review a table by hand; nothing is rewritten automatically.

**~~Whitespace inside a type annotation is accepted and not accumulated~~ — closed.** This section
used to record the one place in the format where a wrong unit was produced silently rather than
refused: a space **between** parameters and a space **inside** a unit were indistinguishable by the
time the parameter was scanned, so `<float:64,k g>` was accepted as `k~g` and
`<float:64,udunits:m s-1>` became `udunits:ms-1` — reciprocal milliseconds — for a value written as
a speed. (In this tree, while the notation was being built: no released reader has the compact form
or a profile, so neither was ever accepted in a release. What 1.1 did accept this way was
`<float:64,m in>`, as the minute.)

The fix is the one this section called for. Whitespace stays ignorable beside a separator (the
family `:`, a `,` between parameters, the closing `>`), and inside a *native* parameter it is
`error_type_param_whitespace`. That half was not a change to the grammar: doc/12 always put every
`ws` in an annotation beside a separator and never derived one in the middle of a parameter, so the
implementation was leniently wrong and now agrees with the normative grammar. It *is* a change to
what parses — a document relying on the old leniency now fails — and that is the point: those
documents carried a unit their author did not write.

**And once the two positions were distinguishable, a third answer became available for the one that
wanted it.** Inside a parameter carrying a profile *namespace*, whitespace is neither deleted nor
refused: it is kept verbatim and handed to the vocabulary. UDUNITS multiplies with a space, so
`udunits:kg m-2 s-1` — CF's commonest spelling — is now the unit it says it is, and `udunits:ms-1` is
still the reciprocal millisecond it has always been. A UCUM annotation keeps its spacing, which makes
§3.4's promise true for the first time. Every other vocabulary refuses a space through its own
grammar as `error_unit_illegal`, which is the right layer: "no such code", not "no whitespace here".
§13.2, which used to explain why the space-separated form could not be supported, now explains how it
is.

**The refusal set is where adopters leave.** §6.4 refuses the referenced bels, the other
function-defined special units, four decades of scale and the British series. A clinical corpus will meet several of those early, and
each is a reason to conclude the profile does not really support UCUM. The counter-argument — that
naming a refusal beats accepting a string you cannot reason about — is correct and will not always
be persuasive. §15 makes this worse before it makes it better: the refusal set is now much larger,
because every code the publisher defines and this build cannot carry is *named* rather than left to
fall through as `error_unit_illegal`. That is the honest arrangement and it is also the one where
an adopter can count what they are not getting.

**Annotation equality will surprise someone.** §3.4 is UCUM's rule faithfully applied, and it still
means two units a clinician reads as different compare as the same.

**One error code moved.** `<float:64,m[s]>` was `error_unexpected_input_byte` and is now
`error_unit_illegal` (§2.3). Both are refusals of the same document, but a consumer switching on the
exact code sees a change.

### 10.3 Deliberately not attempted

- **~~Temperature difference~~ — closed, natively.** `Cel` is still a scale here as in UCUM, and a
  `ucum:Cel` value is still a scale reading. What changed is that Bovnar now has a unit for the
  difference: `ΔK` and its five siblings, in their own quantity kind, so `ΔK → K` is
  `error_unit_mismatch` and `--si` on 25 Δ°C gives 25 ΔK rather than 298.15 K. This entry called it
  "the format's most concrete gap, worth more than this whole profile", and said the fix belonged in
  the *native registry* because importing it through a foreign notation would put it somewhere no
  native document could reach — which is where it went. See doc/temperature_difference.md.

  Two consequences for this document. **One row here was wrong and is corrected**:
  `qudt-qk:TemperatureDifference` mapped to `K`, which made it the same unit as
  `qudt-qk:ThermodynamicTemperature` — the confusion the code's own name rules out. It maps to `ΔK`.
  A quantity kind states a quantity and no unit, so there is no published unit string being diverged
  from. **The CF standard names that are differences by name** (`air_temperature_anomaly` and three
  siblings) are deliberately *not* changed the same way: CF states `canonical_units = "K"` for them,
  and overriding a publisher's stated unit from the sense of its name is a different decision, and one
  that would put `cf:air_temperature_anomaly` in disagreement with `udunits:K` for the same variable.
  CF 1.12's own answer is the `units_metadata` attribute, which is not in the unit slot and so still
  cannot be read by a profile — the converter §2 describes is where that call belongs.
  **The differences CF states in `degree_C` are the exception, and map to `Δ°C`** (the same unit as `ΔK`): the three
  `difference_between_sea_surface_*_temperature` names and `sea_water_added_conservative_temperature`
  / `sea_water_added_potential_temperature` (a tracer that is zero in the control climate). A `K`
  difference read as `K` is harmless, since the two share a zero. A `degree_C` difference read as the
  affine `°C` is not: converting the cool-skin effect of −0.17 to kelvin gave 272.98. CF's own
  description names each of the five a difference, so this follows the publisher rather than
  overriding it. The `degree_C` names that are on-scale temperatures (`perceived_temperature`,
  `wet_bulb_globe_temperature`, the `redistributed` tracers and two more) stay `°C`.
- **Case-insensitive UCUM.** UCUM defines a case-insensitive variant. `ucum:` is the case-sensitive
  one only, which is why `RAD` and `REM` are in the table and UCUM's bracketed `[RAD]`/`[REM]` are
  not (§6.1). `ucum_ci:` is unreserved and undefined; it would need its own atom table and would
  make §6.2's collisions materially worse.
- **~~CF~~.** Superseded: `cf:` is a namespace now (§17), carrying the standard-name table this entry
  said would not fit. What remains not attempted is the other half of what CF is — a reference date
  embedded in the time unit, which still has nowhere to land in a per-value unit slot (§13.4), and
  the `units` strings themselves, which are UDUNITS syntax and reached through `udunits:` (§13).
  `cf:` is read-only for the reason §17 gives: dozens of standard names state the same unit, so
  writing one back would assert a quantity the unit does not know.
- **Exchange rates.** Unchanged and unchangeable: currencies carry no conversion table, and a
  cross-currency conversion is refused rather than guessed (doc/05 §9.6). No vocabulary here yields
  a currency, so the profiles never reach this.

### 10.4 Specified here but not built

One thing this document describes is not in the library, and three that were listed here have since
landed. They are together so the gap is one paragraph to read rather than four sections to
cross-check.

| Not built | Where it is described | Consequence |
|---|---|---|
| ~~Verbatim source preservation (`bvnr_data_t.unit_source`, writer re-emission)~~ | §5.2, §7.3 | **Built.** The reader delivers the spelling on every unit-bearing event, the writer re-emits it from `bvnr_write_event` and — through the one-shot `bvnr_writer_set_unit_source` — from every typed helper, and the DOM and the Python typed tier keep it |
| ~~The generator's factor proof against the publishers' own values~~ | §9.2, §9.5 | **Built** as `check_profile_factors.py`, outside the generator. All seven profiles, six against their own publishers and `unece` at one remove through QUDT |
| ~~`BVNR_WITH_UCUM_PROFILE` and feature reporting~~ | §9.4 | **Built** as seven per-vocabulary switches plus `bvn_unit_profile_count`/`bvn_unit_profile_name`. All seven off takes `libbvnr.so` from 1.96 MB to 524 KB |
| A machine check of `unece` against Rec 20 **itself** | §9.2, §9.5, §10.2 | Rec 20's factors are prose; the table is checked through QUDT's cross-reference instead, which is another publisher's reading |

The factor proof was the one worth building next and is now built (§9.5). It caught four wrong
udunits rows and two wrong UCUM spellings on its first run and found a defect in UCUM's own data;
extending it to QUDT caught six local names QUDT does not define, a month that was the lunar one and
a rotational speed 2π out. `unece` is reached through QUDT's cross-reference, which is a weaker
claim and is reported as one. §14's caveat is therefore reduced rather than lifted: the strongest
statement available for that table is that it agrees with another publisher's reading of Rec 20.
Verbatim source preservation was next and is now built (§7.3). It cost less than the paragraph
that deferred it predicted: one field pair on `bvnr_data_t` and one function, not a parameter on
every writer entry point.

---

## 11. The UNECE profile

> `unece:` — UN/ECE Recommendation 20, *Codes for Units of Measure Used in International Trade*, and
> Recommendation 21, *Codes for Passengers, Types of Cargo, Packages and Packaging Materials*.
> Data file `src/gendata/unece.bvnr` (1299 mapped, 25 opaque, 155 unsupported — every Rec 20 code
> QUDT's cross-reference reaches, which is not the same as every Rec 20 code; §11.1);
> pinned by `tests/bovnar_unece_test.c` (576 assertions).

### 11.1 Why this vocabulary

Rec 20 is, by message volume, the most widely deployed unit code list of the five: it is the unit
vocabulary of UN/EDIFACT, UBL, Peppol and EN 16931, ISO 20022, GS1, and OPC UA's `EUInformation`
structure. A producer whose unit arrives as `KGM` should not have to translate it before it can be
written down, and an industrial-telemetry consumer reading OPC UA payloads should not have to
maintain a second mapping table of its own.

**This is the one table that cannot be closed, and the reason is the vocabulary rather than the
effort.** The other four publish a machine-readable list of everything they define, so "every code
the publisher states is in one of the three lists" is a condition that can be met and checked. Rec
20 states its factors in *prose*. There is no artefact to enumerate, and this profile has always
been reached at one remove, through the `qudt:uneceCommonCode` cross-reference (§9.5). So the table
is now closed against **what that cross-reference reaches** — 1299 mapped and 155 refused, up from
252 and 7 — and a Rec 20 code that no QUDT unit claims is still outside it, because nothing in this
repository can say what it is worth. Where the cross-reference contradicts *itself*, the code is
refused saying so: 81 codes have more than one QUDT claimant, and `J62` is claimed by both a
barrels-per-hour and a barrels-per-second unit, 3600 apart.

Rec 20's Annex II/III sector qualifiers and the Rec 21 codes that name packaging *material* rather
than a countable package remain deliberately absent.

### 11.2 Flat, and why that is not a simplification

Rec 20 is a **flat** profile. A code is one whole token, looked up entire; no prefix is stripped and
no operator is recognised:

```bovnar
#!bovnar 1.2
.mass  = <float:64,unece:KGM> 12.5;      # → k~g
.speed = <float:64,unece:KMH> 88.0;      # → k~m/h
.temp  = <float:64,unece:CEL> 21.0;      # → °C
```

`unece:KGM/MTR`, `unece:MTR2` and `unece:kMTR` are all `error_unit_illegal`. This matters more than
it looks: `KGM` is the kilogram, and a parser that decomposed flat codes would find a `k` prefix on
a `GM` that Rec 20 never defined — and would read `MTS` (metre per second) as a mega-`TS`. A flat
vocabulary spells each prefixed unit with its own separate code, which is why `GRM`, `KGM`, `MGM`
and `MC` are four codes for the gram and why the reverse table (§5.3) is keyed by *(base, decade)*
for a flat profile where an expression profile needs only one row per base.

Case is Rec 20's own and is significant: `unece:kgm` is an error.

### 11.3 Rec 21 packages, and the Rec 20 counts, as opaque units

Rec 21's X-prefixed codes name countable packages, and Rec 20 has a handful of pure count codes.
Both become **opaque** units, through exactly the mechanism UCUM's arbitrary atoms use (§7.1, §7.2):
a `value_base_unit_t` in its profile's own block, no dimension of its own, and no native
spelling, so they serialise back as `unece:<code>`. There are 25: five counts (`C62`, `H87`, `NAR`,
`NPR`, `SET`) and twenty packages (`XBX`, `XPX`, `XCT`, …).

```bovnar
#!bovnar 1.2
.qty = <uint:32,unece:XBX> 12;    # 12 boxes
.pal = <uint:32,unece:XPX> 3;     # 3 pallets
```

| Pair | Result | Why |
|---|---|---|
| `unece:XBX` → `unece:XBX` | factor 1 | prefix-only delta of zero |
| `unece:XBX` → `unece:XPX` | refused | different bases |
| `unece:XBX` → `unece:C62` | refused | a box is not a bare count |
| `unece:XBX` → `k~g` | refused | a box has no mass |
| `unece:XBX` → `%` | refused | a box is not dimensionless — it has no dimension at all |

That is the right answer and not merely a convenient one. Twelve boxes and twelve pallets are not
the same quantity, nothing in the code list says how many of one make the other, and a format whose
whole claim is that a wrong unit fails loudly must not invent a factor here. The same reasoning
refuses `DZN` (dozen), `GRO` (gross), `DPC` and `DZP` as `error_unit_profile_unsupported`: they are
*scaled* counts, and an opaque base admits no multiplier.

Note the shape of the answer, which is the same oddity currencies already have:
`bvn_units_compatible` reports **false** for a box against itself while `bvn_unit_convert_factor`
returns 1. Compatibility is a statement about dimension, and neither a currency nor a package has
one.

### 11.4 A reading of the table

A sample across the shape of the vocabulary — base units, the per-decade codes a flat grammar
needs, the compound codes it needs for the same reason, and the numeric fillers:

| Rec 20 | Bovnar | 1 unit in coherent SI |
|---|---|---|
| `MTR` | `m` | `1.0` |
| `MMT` | `m~m` | `0.001` |
| `KMT` | `k~m` | `1000.0` |
| `4H` | `µ~m` | `1e-06` |
| `GRM` | `g` | `0.001` |
| `KGM` | `k~g` | `1.0` |
| `TNE` | `t` | `1000.0` |
| `LTR` | `L` | `0.001` |
| `MTK` | `m²` | `1.0` |
| `MTQ` | `m³` | `1.0` |
| `SEC` | `s` | `1.0` |
| `HUR` | `h` | `3600.0` |
| `CEL` | `°C` | `1.0` |
| `KEL` | `K` | `1.0` |
| `MTS` | `m/s` | `1.0` |
| `KMH` | `k~m/h` | `0.2777777777777778` |
| `MSK` | `m/s²` | `1.0` |
| `KGS` | `k~g/s` | `1.0` |
| `KMQ` | `k~g/m³` | `1.0` |
| `NEW` | `N` | `1.0` |
| `PAL` | `Pa` | `1.0` |
| `A97` | `h~Pa` | `100.0` |
| `BAR` | `bar` | `100000.0` |
| `C65` | `Pa·s` | `1.0` |
| `JOU` | `J` | `1.0` |
| `JE` | `J/K` | `1.0` |
| `K53` | `k~cal` | `4184.0` |
| `WTT` | `W` | `1.0` |
| `KWH` | `k~W·h` | `3600000.0` |
| `AMP` | `A` | `1.0` |
| `VLT` | `V` | `1.0` |
| `KVT` | `k~V` | `1000.0` |
| `OHM` | `Ω` | `1.0` |
| `HTZ` | `Hz` | `1.0` |
| `MOL` | `mol` | `1.0` |
| `CDL` | `cd` | `1.0` |
| `C81` | `rad` | `1.0` |
| `DD` | `°` | `0.017453292519943295` |
| `P1` | `%` | `0.01` |
| `59` | `ppm` | `1e-06` |
| `2Q` | `k~Bq` | `1000.0` |
| `GRY` | `Gy` | `1.0` |
| `D13` | `Sv` | `1.0` |
| `NU` | `N·m` | `1.0` |
| `D44` | `var` | `1.0` |
| `D46` | `VA` | `1.0` |
| `C97` | `s⁻¹` | `1.0` |
| `C94` | `min⁻¹` | `0.016666666666666666` |
| `M46` | `rev/min` | `0.10471975511965977` |

The last five rows are the ones §9.6 is about: `GRY`/`D13`, `NU`/`JOU` and `D44`/`D46` are pairs
that are equal in SI and different in meaning, and `C94`/`M46` is the pair that was actually wrong.

---

## 12. The QUDT profiles

> `qudt:` — QUDT unit local names (2235 mapped, 573 unsupported — every one of the 2803 local
> names QUDT defines, plus five refusals for names it does not: `USD`, `EUR` and three `DECIBEL_*`).
> `qudt-qk:` — QUDT quantity kinds (908 mapped, 257 unsupported — every one of the 1164 kinds,
> plus a refusal for `LogarithmicRatio`, which QUDT 3.1.0 does not define).
> Data files `src/gendata/qudt.bvnr` and `src/gendata/qudt-qk.bvnr`;
> pinned by `tests/bovnar_qudt_test.c` (256 assertions).

### 12.1 Why this vocabulary

QUDT is the unit ontology of the digital-twin and building-semantics world — DTDL, Brick,
ASHRAE 223P, IOF. It is also the only one of the five that supplies stable IRIs, which is precisely
what Bovnar does not have and does not need inside a document.

### 12.2 Local names, not IRIs

QUDT units are IRIs under `http://qudt.org/vocab/unit/`. This profile spells them by their **local
name** — the part after the last `/` — and rejects the full IRI:

```bovnar
#!bovnar 1.2
.v = <float:64,qudt:M-PER-SEC> 9.81;
.m = <float:64,qudt:KiloGM>    72.5;
.d = <uint:64,qudt:MebiBYTE>   16;
```

`qudt:http://qudt.org/vocab/unit/M` is `error_unit_illegal`. A unit slot is at most 255 bytes and a
`:` inside it already means something here; admitting a URI would put a second scheme separator
inside a namespace separator for no gain, since the local name is the identifying part.

Flat, for the same reason UNECE is (§11.2), and here the temptation is sharper because QUDT's naming
*looks* decomposable: `KiloGM` really is kilo + GM. It is not taken apart, because the naming is
regular enough to tempt a parser and irregular enough that the parser would be wrong — `MI` is the
mile, not a milli-anything.

**And the local name is the identifier, not the symbol.** QUDT's `PCA` carries the symbol `pc`, and
it is the *pica*, a typographic length; the parsec is `PARSEC`. A table built by matching symbols
rather than local names gets a length wrong by nineteen orders of magnitude, which is why the
`bvnr_profile_factors` gate (§9.5) resolves every row against QUDT's own definitions. (`qudt:PCA`
itself is not carried — the pica has no native form — so it is `error_unit_illegal`; the point is
what a symbol-matched table would have *done* with it.)

`MebiBYTE` is the one shape where a flat code names a **binary**-prefixed unit. Since a binary prefix
has no decade, the reverse table carries the IEC prefix identity alongside the decimal decade, and
an expression profile — which reaches a scale by emitting a decimal prefix, and there is none
meaning 2²⁰ — simply has no spelling for such a unit (§5.3).

Eight local names are refused rather than mapped: `UNITLESS` and `NUM` (the absence of a unit, which
Bovnar spells by omitting the slot), `MO` (QUDT's synodic month of 29.53 days, three per cent from
the Julian month native `mo` is), `USD` and `EUR` (currencies, which Bovnar carries natively with
the `$` sigil and minor-unit metadata), and `DECIBEL_M`/`DECIBEL_W`/`DECIBEL_V` (a reference level
`dB` cannot carry, §3.7).

### 12.3 Quantity kinds (`qudt-qk:`)

**A quantity kind is not a unit.** `Length` says what is being measured and nothing about the scale.
This namespace therefore has to decide what `qudt-qk:Length` means in a slot whose whole job is to
fix the scale, and there are only three honest answers:

1. **Refuse it.** Correct, and useless: it makes the namespace an error message.
2. **Carry the dimension without a scale.** Not representable. A `value_unit_t` is a product of
   prefixed base units; there is no way to spell "a length, scale unspecified" in one, and adding a
   fourth state to the unit model would break §1.3, the one thing that must not change.
3. **Translate to the coherent SI unit of the kind.** `Length` → `m`, `Mass` → `k~g`,
   `Velocity` → `m/s`. **This is what the profile does.**

Option 3 is not this table guessing. QUDT relates a `QuantityKind` to exactly one coherent SI unit,
and ISO 80000 defines a kind of quantity together with the coherent unit of its system. Writing
`<float:64,qudt-qk:Length> 3.0` means three metres because the coherent SI unit of length *is* the
metre. §9.5 turns that into a build gate: every mapped kind is checked to have a native factor of
exactly 1, so a kind mapped to a non-coherent unit fails even though its dimensions are perfect.

**Where it can still bite.** A producer who knows only the kind, and whose numbers are not in
coherent SI units, will write a wrong value that parses cleanly. If your lengths are in feet,
`qudt-qk:Length` is the wrong thing to write and `qudt:FT` is the right one. This namespace is for a
producer whose data is already coherent-SI and whose metadata carries a kind rather than a unit —
the common shape of a QUDT- or DTDL-sourced feed, and the only reason the namespace exists.

Two consequences follow from translating to a unit, and both are honest rather than accidental:

- Kinds that share a dimension share a unit. `qudt-qk:Energy` and `qudt-qk:Work` compare **equal**,
  as do `Speed` and `Velocity`, `Voltage` and `ElectricPotential`, `Force` and `Weight`. Bovnar's
  dimension vector cannot tell them apart, and neither can ISO 80000.
- A kind whose coherent unit Bovnar cannot state is **refused**, not approximated:
  `CelsiusTemperature` (an affine scale, not a coherent unit — write `°C`, or `K` for
  `ThermodynamicTemperature`), `LogarithmicRatio` (no reference level), `Dimensionless`,
  `DimensionlessRatio` and `Count` (the *absence* of a unit, which Bovnar spells by omitting the
  slot), `InformationEntropy` (QUDT's coherent unit there is the nat, §9.5), and `Currency`.
  Refusing costs an error message; approximating costs a number.

`qudt-qk` is also why a namespace may contain a hyphen (§2.1). It may not lead: `-qk:Mass` is not a
namespace and falls through to the native parser, which rejects it as it always did.

### 12.4 The quantity-kind table: the ISO 80000 core

The table is no longer short enough to print — 908 of QUDT's 1164 kinds map. What follows is the
ISO 80000 core it started as, which is still the part worth reading, and every row in it was
verified to have a native coherent-SI factor of exactly `1.0`, the claim §12.3 makes. For the rest,
read `src/gendata/qudt-qk.bvnr`.

**How the other 858 were chosen, and why not by dimension.** A kind states no multiplier, so there
is nothing to match on but the dimension vector — and dimensions cannot tell `Torque` from `Work`.
Both are `[2,1,-2]` at factor `1`, and answering "the coherent SI unit of this kind" with `J` for
both would be numerically perfect and would tell a reader of a torque that they had an energy. So
the coherent unit is taken from QUDT's own `qudt:applicableUnit` list instead: the member whose
`conversionMultiplier` is exactly `1` **is** the coherent unit of that kind, and QUDT lists `N-M`
under `Torque` and `J` under `Work`. That unit's `qudt:ucumCode` is then carried to native through
the UCUM table, so a quantity-kind row and a unit row cannot disagree.

**The 254 that are refused are almost all dimensionless**, and refused for a reason no amount of
work would remove: every native ratio has the same dimension vector as every other, so `Absorptance`,
`MassFraction` and `ActivityCoefficient` are each worth exactly what `%`, `ppm`, the radian and the
bit are worth. Nothing in this repository can say which one QUDT means, and QUDT states no
`ucumCode` for them to settle it. Guessing would put a turbidity reading and a percentage in the
same equivalence class.

| QUDT quantity kind | Bovnar | QUDT quantity kind | Bovnar |
|---|---|---|---|
| `Length` | `m` | `ElectricCharge` | `C` |
| `Mass` | `k~g` | `Voltage` | `V` |
| `Time` | `s` | `ElectricPotential` | `V` |
| `ElectricCurrent` | `A` | `Resistance` | `Ω` |
| `ThermodynamicTemperature` | `K` | `ElectricalResistance` | `Ω` |
| `AmountOfSubstance` | `mol` | `Capacitance` | `F` |
| `LuminousIntensity` | `cd` | `Inductance` | `H` |
| `Area` | `m²` | `Conductance` | `S` |
| `Volume` | `m³` | `MagneticFlux` | `Wb` |
| `Angle` | `rad` | `MagneticFluxDensity` | `T` |
| `PlaneAngle` | `rad` | `LuminousFlux` | `lm` |
| `SolidAngle` | `sr` | `Illuminance` | `lx` |
| `Velocity` | `m/s` | `Activity` | `Bq` |
| `Speed` | `m/s` | `AbsorbedDose` | `Gy` |
| `Acceleration` | `m/s²` | `DoseEquivalent` | `Sv` |
| `AngularVelocity` | `rad/s` | `CatalyticActivity` | `kat` |
| `Frequency` | `Hz` | `Force` | `N` |
| `VolumeFlowRate` | `m³/s` | `Weight` | `N` |
| `MassFlowRate` | `k~g/s` | `Pressure` | `Pa` |
| `Density` | `k~g/m³` | `Stress` | `Pa` |
| `MassDensity` | `k~g/m³` | `Energy` | `J` |
| `Momentum` | `k~g·m/s` | `Work` | `J` |
| `DynamicViscosity` | `Pa·s` | `Power` | `W` |
| `KinematicViscosity` | `m²/s` | `Torque` | `N·m` |
| `AmountOfSubstanceConcentration` | `mol/m³` | `HeatCapacity` | `J/K` |
| `SpecificHeatCapacity` | `J/k~g·K` | `ThermalConductivity` | `W/m·K` |

---

## 13. The UDUNITS profile

> `udunits:` — UDUNITS-2, Unidata's unit library, whose string grammar is the de-facto units syntax
> of netCDF and the CF conventions.
> Data file `src/gendata/udunits.bvnr` (41 prefixes, 768 mapped, 98 unsupported — every one of
> the 580 spellings the UDUNITS-2 database defines);
> pinned by `tests/bovnar_udunits_test.c` (258 assertions).

### 13.1 An expression profile, sharing the UCUM parser

UDUNITS is the second **expression** profile, and it runs through the same translator as UCUM with
two syntax differences configured on its registry row:

- `*` multiplies beside `.`;
- `^` introduces an exponent, beside the bare trailing digits both vocabularies allow.

A third difference runs the other way: UDUNITS has no `{…}` annotations, so `udunits:mL{x}` is
`error_unit_illegal` (§3.4).

**What UDUNITS accepts and this profile does not.** UDUNITS' scanner is more liberal than the
syntax above, and the rest of its grammar is refused rather than half-implemented. Every one of
these is `error_unit_illegal` — a refusal, never a different reading:

| UDUNITS accepts | example | here |
|---|---|---|
| `**` as the exponent operator | `m**2` | refused; write `m^2` or `m2` |
| `·` (U+00B7) and `-` as multiplication | `m·s-1`, `kg-m` | refused; write `m*s-1` or `m.s-1` |
| a tab between terms | `kg⇥m-2` | refused; write a space |
| blanks around `*` or `.` | `kg * m` | refused; write `kg*m` or `kg m` |
| an exponent on a parenthesised group | `(m/s)2` | refused; write `m2/s2` |
| unit names in any case | `Kelvin`, `Meter` | refused; the names are case-sensitive here |

A prefix on a unit UDUNITS defines and this table marks non-metric — `kcal`, `kft`, `nmi` — is
the other side of the same line: UDUNITS reads it, and this profile refuses it as
`error_unit_profile_unsupported`, a known spelling it does not carry, because a few of those
readings are traps (`nmi` is a nanomile to UDUNITS and a nautical mile to most of the people who
write it).

The **division rule is identical**, which is worth stating because it is where a units parser most
often goes quietly wrong. In both vocabularies `/` inverts the term that follows it and nothing
else — ordinary left-to-right arithmetic. `kg/m*s` is `(kg/m)*s`, so the second term is positive;
`kg/m/s` is `kg·m⁻¹·s⁻¹`. The suite pins `udunits:kg/m*s` equal to `ucum:kg/m.s` — both formatting
back as `k~g·s/m` — for exactly this reason: getting it backwards turns a viscosity into its
reciprocal and still formats cleanly.

UDUNITS accepts symbols and spelled-out names for units *and* prefixes, which is why the table
carries 768 codes and 41 prefix spellings for a vocabulary no larger than UCUM's: `m`, `meter` and
`metre` are three rows, and `kilo` is a prefix beside `k`. `udunits:km`, `udunits:kilometer` and
`udunits:kilometre` are one unit.

```
udunits:m*s-1       →  m/s
udunits:kg*m-2*s-1  →  k~g/m²·s
udunits:m^2         →  m²
udunits:hPa         →  h~Pa
udunits:nit         →  cd/m²
```

### 13.2 Space multiplies, and now it can

UDUNITS multiplies with a space, and CF's commonest spelling of a flux is `kg m-2 s-1`. **Bovnar
accepts it in a type annotation**, and `' '` is in this profile's multiplication set beside `.` and
`*`.

```
udunits:kg m-2 s-1   →  k~g/m²·s     the same unit as udunits:kg*m-2*s-1
udunits:m s-1        →  m/s          a speed
udunits:ms-1         →  m~s⁻¹        a reciprocal MILLISECOND — also valid UDUNITS,
                                     and a different unit. The space is what
                                     tells the two apart
udunits:kg  m-2      →  k~g/m²       a run of spaces is ONE operator, as in UDUNITS
udunits:m / s        →  m/s          spaces around '/' divide
udunits:m per s      →  m/s          and so does the word per, in either case
udunits:m perch      →  m·rdUS       ...but only as a word
```

**This section used to say the opposite, and the reason it could is the reason it no longer has to.**
While the lexer *deleted* whitespace inside a type annotation, the slot `udunits:m s-1` arrived here
as `udunits:ms-1` — a perfectly good UDUNITS expression meaning reciprocal milliseconds — so listing
space as an operator would not have made the space-separated form work. It would only have made the
wrong reading of it look supported. §10.2 recorded that as the one place in the format where a wrong
unit was produced silently rather than refused.

Closing that hole is what made this possible. Whitespace beside a parameter separator is still
ignorable, whitespace inside a *native* parameter is `error_type_param_whitespace`, and whitespace
inside a **namespaced** parameter is carried in verbatim for the vocabulary to interpret (spec §5.3).
So the space now reaches this parser as a space, and means here what it means in UDUNITS.

Three limits worth stating plainly:

- **A run of spaces is one operator, and the profile — not the lexer — says so.** UDUNITS'
  scanner spells MULTIPLY `{space}+` and DIVIDE `{space}*"/"{space}*` or `{space}+per{space}+`, so
  `kg  m-2`, `m / s` and `m per s` are valid UDUNITS, and the profile reads them as UDUNITS does.
  This section used to call `kg  m-2` "the vocabulary's own error"; it is not one. The lexer still
  does not collapse the run, because that would cost a UCUM annotation the spacing §3.4 promises to
  keep verbatim. A space that leads or trails the code, or an operator with nothing after it, is
  still `error_unit_illegal`.
- **The inline unit form cannot carry a space and never will.** Whitespace is what *terminates* that
  token, so `1.0 udunits:kg m-2` is a value, a unit and a stray token. The space-separated spelling
  is available in a type annotation only.
- **A line break is still an error.** No vocabulary spells a unit across a line.

`udunits:ms-1` is still pinned to `m~s⁻¹` explicitly, and §14.2 still pins `udunits:ms-1` against
`udunits:m*s-1` as a pair that must not compare equal — that pair matters *more* now, not less,
because the space is the only thing distinguishing the speed from the reciprocal millisecond.
Conformance cases UPR-043c…UPR-043f pin the whole boundary, including the annotation/inline agreement
between `udunits:kg m-2 s-1` and `udunits:kg*m-2*s-1`, which passes only if both spellings produce
the same `value_unit_t`.

### 13.3 The near misses: codes that name a native unit and are not it

UDUNITS is the profile with the most spellings that look like a native unit, carry the same
dimension, and differ only in factor. A factor-only error is the one shape nothing downstream can
catch, so none of them may be mapped onto the native unit whose name they borrow — the same
reasoning §6.3 applies to the BTU and the apothecary dram.

They were refused on that account for as long as there was nothing else to send them to. **The
registry now carries the units themselves**, so all but one map to a unit of their own and the
near-miss is gone rather than merely fenced off:

| UDUNITS code | Is | Native unit of that name | Apart by | Now |
|---|---|---|---|---|
| `year`, `yr` | tropical year, `3.15569259747e7` s | `yr`, the Julian year `31557600` s | 674 s — 11 min/yr | → `yr_trop` |
| `month` | a twelfth of the tropical year | `mo`, a twelfth of the Julian year | as above | **refused** |
| `calorie`, `cal`, `IT_calorie` | IT calorie, `4.1868` J | `cal`, thermochemical `4.184` J | 0.067 % | → `cal_IT` |
| `chain`, `rod`, `furlong`, `fathom` | built on the US **survey** foot | international, on the `0.3048` m foot | 2 ppm | → `chUS`, `rdUS`, `furUS`, `fathUS` |
| `acre` | US survey acre, `160` survey rod² | `ac`, international `4046.8564224` m² | 4 ppm | → `acUS` |
| `shake` | `1e-8` s | — | no SI prefix for 10⁻⁸ (§3.5) | → `shake` |

`month` is the one left, and it is left for a reason the others do not share: a twelfth of the
tropical year is not a unit anybody publishes under a name of its own, so there is nothing to add to
the registry that would not be an invention. It stays `error_unit_profile_unsupported`, named.

The spelled-out forms UDUNITS gives for what Bovnar actually means still map, and they remain the
unambiguous way to write these: `Julian_year` → `yr` and `thermochemical_calorie` → `cal`, both
exactly, beside `year` → `yr_trop` and `calorie` → `cal_IT`. The BTU
runs the other way from UCUM: UDUNITS' unqualified `Btu` is the IT BTU, which is exactly what native
`Btu` is, so it maps here although UCUM's unqualified `[Btu]` cannot (§6.3).

Two spelling traps in the same family, neither of them factor errors:

- **`oz` is a volume.** In UDUNITS it is a symbol of the US *fluid* ounce, not the avoirdupois
  ounce, so it translates to `fl_oz`. The mass is spelled `avoirdupois_ounce`. Reading `udunits:oz`
  as a mass was a dimension error, and the two are now not even compatible.
- **`b` is the barn**, not the bit — the collision §6.2 records for UCUM, present here for the same
  reason and resolved the same way. `bit` is the bit and `byte` the byte.

`unified_atomic_mass_unit`, UDUNITS' fourth spelling of the dalton, used to be absent for a reason
worth recording because it has now moved twice. At 24 bytes it pushed this profile's worst-case
emitted string past `BVNR_UNIT_STRING_MAX` and `gen_profiles.py` refused to generate the table
(§9.2); `thermochemical_calorie` was excluded the same way when that constant was 192. Closing the
table against the UDUNITS database brought a longer code still into view —
`astronomical_unit_BIPM_2006`, 27 bytes — and the constant went from 1024 to 1088 on its account.
All four dalton spellings map.

That row was then found **unreachable**, and is refused with a reason that has nothing to do with
its length: this is an *expression* profile, so a code ending in digits cannot be read at all — the
`2006` scans as an exponent (§3.3). Write `au`, `ua` or `astronomical_unit`. The constant has since
stopped depending on it either way: `BVNR_UNIT_STRING_MAX` is recomputed from `src/gendata` by both
generators on every build, and the header deliberately names no symbol as the extreme, because
whichever one it named would be overtaken by the next catalogue growth and the comment would outlive
the fact.

**The one thing an incomplete atom table costs that an error message cannot.** UDUNITS spells the
pint `pt`, and that spelling was not in this table. It did not therefore fail: with no atom row the
expression parser fell back on a *prefixed* reading, `p` + `t`, and `udunits:pt` parsed clean as a
**picotonne**. A document saying 473 mL came back as `1e-9` kg — a mass where a volume was written,
wrong by nine orders of magnitude, in the wrong dimension, with no diagnostic anywhere.

This is the failure mode that makes closing a table worth more than tidiness. Every other missing
code cost a producer a wrong error message; this one cost them a wrong number, and it was reachable
from a perfectly ordinary CF file. A whole atom outranks a prefixed reading (§3.2), so the row is
the entire fix — but nothing would have found it except enumerating the publisher's own list and
asking, for each spelling, not "is there a row" but "what does this parse to". `check_profile_factors.py`
now asks exactly that (§9.5), which is what turns this from a bug that was found once into a class
of bug the build checks for.

The refusals that are not near misses at all are CF constructs that are not units: `count`, and the
coordinate direction markers — `degrees_north`, `degrees_east` and every other spelling UDUNITS
gives them, `degreeN` through `degrees_true`, twenty in all. Before the table was closed the first
six were refused with a reason and their fourteen siblings came back as `error_unit_illegal`, so the
same concept produced two different errors depending on how it was spelled. A direction is a coordinate-system statement; write `°` and record
the direction in a sibling field. Note that UDUNITS' `1`, which CF uses for a dimensionless
variable, needs no row: the shared expression parser resolves a bare power of ten to unity before
any table is consulted, so `udunits:1` yields no unit exactly as `ucum:1` does.

### 13.4 Reference time is refused, and why

`<unit> since <timestamp>` is UDUNITS' most distinctive construct and the one most often asked for.
It is **refused**, as `error_unit_profile_unsupported`, for a structural reason rather than a
scheduling one.

A Bovnar timestamp is `<datetime:width,epoch>`. Two facts make the translation impossible as the
type system stands:

1. **The epoch is not a unit.** It lives in `value_type_spec_t.base` — the numeric-base parameter
   slot — as a small dense index. A unit-slot expression cannot reach it, and cannot select the
   `datetime` family either; the family comes from the annotation's first parameter.
2. **The carrier is defined as seconds.** `vt_datetime` is a signed integer count of *seconds*
   since the epoch. `days since 1970-01-01` would need the carrier rescaled, which no unit slot
   can do.

Either one alone would block it; both together mean this is a type-system change, not a unit
question. Implementing it would require `bvn_parse_unit` to return a family-and-epoch override
alongside the unit, a precedence rule for when an explicit `<datetime:64,unix>` disagrees with a
profile-implied one, and a carrier-scaling rule the datetime family does not currently have.

`since` is one of five spellings of UDUNITS' SHIFT operator — the scanner's
`{space}*(@|after|from|ref|since){space}*`, each keyword in either case — and the same operator
with a number after it is an offset (`K @ 273.15` is the Celsius scale). All five are refused the
same way: `days after 1970-01-01`, `hours SINCE 2000-01-01`, `K from 273.15` and `K @ 273.15` are
`error_unit_profile_unsupported`, because each is valid UDUNITS that bovnar cannot carry. Until this
release only a lower-case `since` was caught, and the rest came back as `error_unit_illegal`, which
told the producer they had made a typo.

The refusal is driven by a check on the registry row (`shift_ops`) rather than by an atom lookup,
because the operand after the keyword is a timestamp or a number, not a unit, and there is nothing
to look up. A keyword counts only as a whole word — `refrigeration_ton` holds `ref` and is a unit —
except that `since` is caught run together too (`dayssince1970-01-01`, conformance case UPR-043b):
that is the construct with its spaces taken out, which is what the lexer used to deliver, and no
UDUNITS name contains it. A row for `since` is kept in `.unsupported` anyway, so that a bare
`udunits:since` refuses identically and so that a maintainer finds the reason where they look for
it. Refusing costs a producer an error message that
says exactly this; accepting it would cost them a silently wrong instant.

---

## 14. The cross-vocabulary conformance suite

> `tests/bovnar_crossvocab_test.c` — 69 concepts, 6 vocabularies, 4983 assertions.

Every other profile test asks *does this vocabulary translate correctly?*. This one asks the
question that only exists once there are five: **do they agree?**

If any two disagree, the format's central promise fails — a document written by a UCUM-speaking
producer and read by a UNECE-speaking consumer would carry a unit that compares unequal to itself.

### 14.1 A concept table, checked pairwise

Each row is one physical concept and the way each vocabulary spells it:

```c
{ "kilogram", { "k~g", "kg", "ucum:kg", "unece:KGM", "qudt:KiloGM",
                "udunits:kg", "qudt-qk:Mass", NULL } },
```

Every spelling is checked against **every other** spelling in its row, not against a designated
reference. A reference-based check would pass if two non-reference spellings agreed with the
reference for different reasons; the pairwise form cannot. Each pair is checked for:

1. that both parse at all;
2. `bvn_unit_equal` — the same components, prefixes and exponents;
3. agreement of the coherent-SI factor, which pins the absolute scale;
4. dimensional compatibility;
5. round-trip: the canonical spelling re-parses to the same unit.

### 14.2 The negative half is not optional

A suite that only checked agreement would be satisfied by a translator that mapped everything onto
the metre. A second table pins the pairs that look interchangeable across vocabularies and are not:

| Pair | Why they must differ |
|---|---|
| `ucum:kg` vs `qudt:GM` | the kilogram is not the gram — the one SI base unit with a prefix in its name, and where a "tidied" table goes wrong by 10³ |
| `ucum:st` vs `unece:STI` | UCUM's `st` is the **stere**, a cubic metre; UNECE's `STI` is the stone |
| `qudt:MI` vs `qudt:MilliM` | QUDT's `MI` is the mile — a flat local name never decomposes |
| `udunits:ms-1` vs `udunits:m*s-1` | reciprocal milliseconds is not metres per second (§13.2) |
| `ucum:B` vs `qudt:BYTE` | UCUM's `B` is the **bel**; UCUM writes the byte `By` |
| `qudt:KiloBYTE` vs `qudt:KibiBYTE` | 10³ bytes is not 2¹⁰ bytes |
| `unece:MTS` vs `unece:MTK` | metre per second against square metre — two Rec 20 codes one letter apart |
| `unece:C94` vs `unece:M46` | the reciprocal minute against the revolution per minute — 2π apart, and held apart by the angle kind (§9.6) |

### 14.3 What it found, and what it cannot tell you

On its first run the suite failed on `ucum:A`: **the ampere was missing from the UCUM table**. One
of the seven SI base units had no UCUM spelling, and no single-vocabulary test had asked, because
each was written against the table it was testing. Asking every unit vocabulary for the same seven
concepts found it immediately. That is the argument for the suite in one line.

**What it cannot find is a concept nobody wrote a row for**, and that is the limit worth stating,
because it is the one this table keeps running into. A vocabulary left out of a row is
indistinguishable here from a vocabulary that has no code — "UNECE has no code for the katal" reads
exactly like
"nobody added `unece:KAT`", and for the tesla, the sievert, the katal, the radian, the steradian and
the newton metre it was the second. Each had been carried in `unece.bvnr` for a whole release and
omitted from the row that would have checked it. §9.6's coverage index is what finds *that* class;
this suite is what pins it once found. The two are not alternatives.

What it also cannot tell you: it proves the six unit vocabularies agree **with each other**, not that
they agree with their publishers — that is §9.5's job, and §9.5 reaches `unece` only at one remove
and reaches `CEL`, `FAH`, `GRY`, `HEN`, `MOL`, `Q12`, `Q13` and `TNE` not at all. **Five tables that are wrong in
the same way agree perfectly.** §10.2 is where what remains uncovered is recorded.

---

## 15. Closing the tables

### 15.1 What was wrong

The tables were **confident subsets**, and §11.1 argued for that: a code not in the table fails
loudly, which costs a producer an error message, while a code in it and wrong costs them a wrong
number. That argument is sound and it justified not *mapping* a code. It never justified what
actually happened to one.

`ucum.bvnr`'s own header states the contract:

> `.unsupported` — Listed so that it fails as `error_unit_profile_unsupported` ("you wrote it right
> and we cannot carry it") rather than `error_unit_illegal` ("that is not a UCUM atom").

An atom in none of the three lists is `error_unit_illegal`. Measured against the publishers, that
was being said about 83 of UCUM's 312 atoms, 299 of UDUNITS' 570 spellings, and some 3600 QUDT
names — a false statement to a conforming producer, indistinguishable from what they get for
`udunits:zzzq`. The refusal list exists precisely to prevent that, and a `.unsupported` row needs no
conversion factor, so closing this gap carried none of the risk the conservative default was
protecting against.

Two consequences were worse than a wrong error message:

- **`udunits:pt` was a picotonne.** §13.3 has the detail. An expression profile falls back on a
  prefixed reading when no atom matches, so a missing atom is not always an error — sometimes it is
  a different unit, silently.
- **The same concept gave two different errors.** `degrees_north` was refused with a reason and its
  fourteen sibling spellings were called illegal.

### 15.2 How the rows were produced

Nothing here was matched on numbers alone, because numbers cannot tell a sievert from a gray. In
order of preference:

1. **The publisher's own definition, transliterated.** UCUM defines `[PRU]` as `mm[Hg].s/ml`; each
   atom is replaced by whatever this table already maps it to, giving `mmHg·s/m~L`. The row is then
   one a reader can check against the publication rather than one that merely computes the same.
2. **The publisher's own cross-reference.** QUDT publishes `qudt:ucumCode` and `qudt:udunitsCode`,
   so a QUDT local name is carried to native through the UCUM table this repository has already
   proved against `ucum-essence.xml` — which is also why §14's suite cannot find these two tables
   disagreeing. UNECE goes through QUDT the same way (§11.1).
3. **A value match, fenced.** Only for a code with a **non-zero** dimension vector. Every
   dimensionless native unit shares one dimension vector, so "worth 1 and dimensionless" matches the
   bit, the radian, the turbidity units and every ratio alike; unfenced it proposed the
   nephelometric turbidity unit as a *bit*, parts-per-trillion as a *picoradian*, and QUDT's
   `GeneralizedMomentum` as an NTU.

**Every proposal was then verified through the reference library** — same factor, same dimension
vector, same tolerance as §9.5 — and anything that did not verify became a `.unsupported` row
instead. `check_profile_factors.py` re-proves all of it from the publishers' files on every run. For the
five tables this section closed that was 4635 rows against 0 mismatches; §16 and §17 have since
added two more vocabularies, and [§9.5](#95-the-factor-proof) carries the current total across all
seven.

### 15.3 What it cost, and what is still open

| | Before | After that pass |
|---|---|---|
| `ucum` | 141 mapped, 32 opaque, 56 refused | 157, 41, 114 — **all 312** |
| `udunits` | 251 mapped, 32 refused | 404, 180 — **all 570** |
| `qudt` | 263 mapped, 8 refused | 2056, 752 — **all 2803** |
| `qudt-qk` | 52 mapped, 7 refused | 910, 255 — **all 1164** |
| `unece` | 252 mapped, 7 refused | 1195, 257 — all QUDT's cross-reference reaches |
| rows `check_profile_factors.py` compares | 899 | 4635 |
| coverage suggestions outstanding | 1161 | 0 |
| `bovnar` binary | 617 KB | 1015 KB |

**Both columns are a record of that pass, not of today.** The tables have grown again since, as the
registry gained units that unblocked codes previously refused for want of a target — the survey-foot
series, the IT calorie, the tropical year. [§9.1](#91-where-the-tables-live) carries the current
figures and is checked against `src/gendata` on every build; this table is left as it was written,
because the point of it is what one pass cost.

**The binary is 65 % larger**, and that was the real price. It is table data — codes, targets and
refusal strings — and the refusal reasons are written as shared literals per family so the compiler
pools them, but a build that wanted only one vocabulary still paid for five.

**The per-profile build switch called for here now exists** (§9.4): seven `BVNR_WITH_<NAME>_PROFILE`
options, all on by default, and a build with every one off takes `libbvnr.so` from 1.96 MB to 524 KB.
The paragraph above is what it answers, and the id-space blocking mentioned two paragraphs down is
what made it cheap — because each vocabulary owns its own block, dropping one moves no other
vocabulary's ids, so the switches change what a build can *translate* and nothing about its ABI.

`BVNR_UNIT_STRING_MAX` went from 1024 to 1088 to admit `astronomical_unit_BIPM_2006` (§13.3), and
the UCUM block from 32 to 41 arbitrary atoms — which, with a block per profile, moved no other vocabulary's ids.

**What closing the tables did not do.** It did not make the rows better evidenced than §9.5 can
make them — the "what a code MEANS" gap in §10.2 is untouched, and it is now spread over four times
as many rows. It did not close `unece`, which has no publication to close against. It did not add a
single native unit: the British imperial series, the pre-metric French lengths, the typographic
points and picas, the water column and the CGS electrostatic units are all refused *by name* now
rather than *by omission*, which is an improvement in the error message and not in the coverage.
Growing the native registry to reach them is a separate decision, and doc/05 is where it belongs.

---

## 16. The OM 2 profile

> `om:` — OM 2, the Ontology of units of Measure (Rijgersberg, van Assem, Top; Wageningen), the
> second unit ontology of the semantic web beside QUDT.
> Data file `src/gendata/om.bvnr` (1337 mapped, 123 refused — every unit individual OM states);
> pinned by `tests/bovnar_om_test.c` (118 assertions); id block 70, contributing no opaque units.

### 16.1 Why this vocabulary

QUDT is the vocabulary of digital twins and building semantics; OM is the one agrifood, food
science, life-cycle assessment and FAIR/ELN data reach for, and it is what Wikidata's unit items
most often align to. A consumer reading either ontology's data had exactly one of the two namespaces
before this, and the missing half is not a small dialect of the first: OM's local names are English
words (`kilogramPerCubicmetre`), QUDT's are abbreviations (`KiloGM-PER-M3`), and nothing maps one to
the other without a table.

It is a **flat** profile for the same reason QUDT is (§12.2): `kilogramPerCubicmetre` is matched
entire. OM's names are regular enough to tempt a parser into decomposing them and irregular enough
that the parser would be wrong — OM writes the cubic metre in a denominator as `Cubicmetre` and in
`kilogramPerCubicDecimetre` as `CubicDecimetre`, and a decomposer would have to decide which of
those is the typo. Neither is: they are both the publisher's spelling, and this table follows the
publisher.

### 16.2 The targets were derived from OM's own structure

**This is the part that is not like QUDT.** QUDT states a `conversionMultiplier` per unit, so its
table is a value read. OM states no multiplier at all. It states how each unit is *built*:

| OM class | states | example |
|---|---|---|
| `PrefixedUnit` | `hasPrefix`, `hasUnit` | `kilogram` = kilo × `gram` |
| `UnitDivision` | `hasNumerator`, `hasDenominator` | `metrePerSecond-Time` |
| `UnitMultiplication` | `hasTerm1`, `hasTerm2` | `newtonMetre` |
| `UnitExponentiation` | `hasBase`, `hasExponent` | `squareMetre` |
| `SingularUnit` | `hasFactor`, `hasUnit` | `inch-International` = 0.0254 × `metre` |
| a named unit | `hasUnit`, no factor | `joule` **is** `newtonMetre` |

So every compound row's `.bovnar` target was **built from that composition, bottom-up**, out of the
atoms mapped by hand — not read off the local name. `gramPerPetalitre` is `g/P~L` because OM states
a numerator, a denominator and a prefix; a table built by reading names in English would have agreed
on most rows and disagreed silently on the rest, which is the failure mode this whole document is
organised against.

Two consequences worth stating, because both look like bugs until you know where they come from:

- **The named SI units come out as themselves.** OM defines the joule as the newton metre and the
  becquerel as the reciprocal second, so a derivation that followed `hasUnit` blindly would spell
  `om:joule` as `N·m`. The atoms are mapped first, so it does not.
- **A prefix on a compound lands on one component.** OM's `attomolar` is atto × `molar`, and `molar`
  is the mole per litre; the prefix multiplies the whole unit, so it may be attached to any
  component standing at exponent 1. The target is `a~mol/L`, worth 10⁻¹⁵ either way.

**Where the composition could not be followed, a fenced value match finished the job** — §15.2's
third preference, and used the same way here. OM builds the hectare as hecto × `are` and bovnar has
no `are`; it builds the micron as a factor on the metre and bovnar spells that `µ~m`, which is not
an atom of any table. In both cases the unit *as a whole* is worth exactly one native (optionally
prefixed) unit, and that match is taken only after the structural attempt fails, only when the
dimensions agree, and only when the native unit it lands on is itself near-coherent. Without that
last fence, 10⁵ metres — OM's `_100Kilometre`, for which no SI prefix exists — matched the
**petaångström**, a spelling that is worth the right number and that nobody would write. It is
refused instead.

The same walk is implemented independently in `check_profile_factors.py` (`class Om`), which
resolves every local name against `om-2.0.rdf` and compares it with what the library says the target
is worth: **1271 rows compared, no mismatch**. The three shapes of resolver this brings the tool to
— expression, table read, composition — are listed in its header.

### 16.3 What the derivation refuses

123 local names are carried as refusals rather than mappings, and four groups are worth naming —
after one that is no longer among them:

- **`om:year` is not refused any more, nor is anything built on it.** OM's year is the
  **Gregorian** 31 556 952 s; native `yr` is the Julian 31 557 600 s, twenty-one parts per million
  apart and dimensionally identical — the same shape as UDUNITS' tropical year (§13.3). It was
  refused for that until the registry gained the Gregorian year `yr_greg`, and it maps there now,
  with `gigayear` (`G~yr_greg`), `reciprocalYear`, `cubicMetrePerYear` and the other compounds OM
  builds on it.
- **Units OM names without stating a magnitude.** `calorie-15C`, `BritishThermalUnit-Mean` and
  `colonyFormingUnit` have a dimension or a symbol and no factor. A unit whose value the publisher
  does not state is not one this table can carry, whatever a reader might guess it to be. The `bel`
  used to be listed here; OM declares it a `SingularUnit`, symbol `B`, with the decibel as deci ×
  bel, so `om:bel` maps to `da~dB` as `ucum:B`, `qudt:B` and `udunits:B` already did (§3.7).
- **The arbitrary units.** `om:InternationalUnit` is UCUM's `[IU]`, which bovnar already carries as
  an opaque base unit with an identity of its own (§7.1). A second, OM-flavoured identity for the
  same assay unit would make the two **incommensurable with each other** — two units that cannot be
  compared although they are the same unit. This is why block 70 contributes no opaque ids.
- **Money, scales and dimension one.** OM models currencies as units (`om:euro`); bovnar carries
  currencies in a system of their own (§7.2). `KelvinScale`, `CelsiusScale` and the other three
  `…Scale` individuals are scales, not units: OM relates each to its unit by a factor *and an
  offset*, so mapping one to a unit would read a Celsius-scale 20 as 20 K.
  `om:one` and `om:dozen` are dimension one, which bovnar spells by omitting the slot — translating
  them would make a count compare equal to a percentage. `om:metrePerMetre` is not refused: OM
  composes it as metre ÷ metre, and it maps to the ratio `m/m`, which keeps the length it is a ratio
  of rather than collapsing to a bare number.

Each is `error_unit_profile_unsupported`, not `error_unit_illegal`: the producer wrote a real OM
unit and is told bovnar cannot carry it, which is the distinction §3.1 exists for.

### 16.4 Which name a unit is written back as

`om:` is written as well as read, and several local names are worth the same native unit, so one of
them has to keep the reverse spelling and the rest carry `.reverse = false`. **Shape decides it
before length does**, which is a rule this vocabulary needed and the others did not:

| native unit | candidates | written back as | why |
|---|---|---|---|
| `da~A` | `abampere`, `biot`, `decaampere` | `decaampere` | the target carries a prefix, so the name that OM builds with a prefix wins over the shorter, stranger one |
| `m` | `metre`, `cubicMetrePerSquareMetre` | `metre` | a composed name is worth a metre and is not a spelling of one |
| `mmHg` | `millimetreOfMercury` | `millimetreOfMercury` | the only candidate, and 2.9 ppm from OM's rounded column — waived by name in `check_profile_factors.py`, exactly as UCUM's `m[Hg]` and QUDT's `TORR` are |

791 native units — counted as (unit, decade) pairs, which is how a flat vocabulary names them — have an OM spelling as a result. A compound unit still has none: a flat vocabulary
spells one whole code, so `bvn_unit_to_profile("om", m/s²)` returns -1 for the reason §5.1 gives.

---

## 17. The CF standard-name profile

> `cf:` — the CF conventions' standard names, the controlled vocabulary of netCDF climate and
> forecast data.
> Data file `src/gendata/cf.bvnr` (4454 mapped, 617 refused — all 5071 names of standard name table
> **v94**, 2026-06-09); pinned by `tests/bovnar_cf_test.c` (58 assertions); id block 80, contributing
> no opaque units. **Read-only** (§17.3).

### 17.1 Why this vocabulary

`udunits:` already speaks the unit half of a CF file. The other half is the `standard_name`
attribute, and the two are not independent: CF makes `units` optional exactly when the standard name
fixes it, so a pipeline holding a name and no units string had nothing to write down. This namespace
is that case — and it is the same slot `qudt-qk:` fills for QUDT-sourced data, which is why it has
the same shape.

### 17.2 The unit is CF's own `canonical_units`

A standard name is **not a unit**. It names what is being measured, so this namespace has to answer
with a unit or refuse the name, and §12.3's three options apply unchanged. It answers with the unit
**CF itself states**: every entry in the table carries a `canonical_units` field — a UDUNITS
expression, because CF's unit syntax is UDUNITS — and that field, not a reading of the name, is what
each row translates.

```bovnar
#!bovnar 1.2
.t   = <float:64,cf:air_temperature> 288.15;      # K, because CF says "K"
.wind = <float:64,cf:northward_wind> -3.4;        # m/s, because CF says "m s-1"
.rain = <float:64,cf:rainfall_flux> 0.00012;      # kg/m²·s
```

5071 names state only **116 distinct** canonical-unit strings, and only those 116 were mapped by
hand; every row then takes the target its own `canonical_units` names. Two standard names that state
the same units therefore cannot drift apart — there is no per-name unit to get wrong — and
`cf:air_temperature` and `cf:sea_water_temperature` are the same `value_unit_t`.

**The bite is `qudt-qk:`'s bite.** A producer whose numbers are not in the canonical units writes a
wrong value that parses cleanly. If your temperatures are in degrees Celsius, `cf:air_temperature`
is the wrong thing to write and `udunits:degC` is the right one — which is what CF's own conventions
already require of the `units` attribute.

The check is unusually strong for a profile of this size, because **both sides are primary**:
`check_profile_factors.py` (`class Cf`) re-evaluates each name's `canonical_units` with the same
UDUNITS evaluator the `udunits` profile is checked against, and compares that against the library's
reading of the target. 4434 rows compared, no mismatch. It is not the one-remove position `unece`
is in (§11.1): CF publishes the name-to-units mapping and Unidata publishes what the units are
worth, and neither is being asked about the other's vocabulary.

### 17.3 Read-only, and why a namespace may be

`bvn_unit_to_profile("cf", u)` returns **-1 for every unit**, and this is the first namespace of
which that is true. It is not a gap in the table:

> Sixty-nine standard names state `K`. Writing a kelvin back as one of them would pick a quantity
> out of a hat and assert it — `cf:air_temperature` for a sea-surface temperature is not a
> formatting choice, it is a false statement.

A unit does not know what quantity it measures, so there is nothing this direction could honestly
return. `gen_profiles.py` therefore marks the profile **unwritable** and emits no reverse rows at
all, rather than singling one row out to be wrong; the C side reaches -1 through the ordinary "no
row names this base" path, with no special case in the writer. A `.reverse` flag in such a data file
is a build error, because it would be describing a choice nothing makes.

The refusal is this namespace's own. The same kelvin still writes as `qudt:K` and `om:kelvin`, and a
`cf:` unit is not "profile only" (§5.1): it translates to an ordinary native unit, and written from
that unit alone it comes back as one. A document that *spelled* it `cf:air_temperature` keeps that
spelling when it is re-serialised (§5.2) — which is the text being preserved, not a unit being
translated into a standard name.

### 17.4 What is absent, and what it costs

| absent | count | why |
|---|---|---|
| deprecated aliases | 599 | CF keeps retired names in the table pointing at their replacements. A document written today should carry the replacement, and an alias fails as `error_unit_illegal`, which says so. |
| `canonical_units` of `1` | 571 | dimension one — spelled in bovnar by omitting the slot. Translating it would put a cloud fraction, a percentage and a bit in one equivalence class. |
| mole fractions | 7 | dimension one under another name, and refused for the same reason. |
| string-valued names | 17 | `region`, `area_type`, `platform_name`: CF states no units because they label a variable rather than measure one. |
| `1e-3`, `1e-6` | 19 | a bare scale factor. CF gives `sea_water_salinity` `1e-3`, which is a magnitude with no unit at all. |
| `dBZ`, °C in a product | 3 | a logarithm against a reference bovnar cannot state, and an affine unit beside another component (§3.8). |

Those are the 617 rows of `.unsupported`; with 4454 mapped they are the 5071 the upstream table
defines. **Per-`year` quantities used to be on this list and are not any more**: the registry gained
`yr_trop`, so `age_of_sea_ice` and `sea_water_age_since_surface_contact` carry it and the two sea
level tendencies carry `m/yr_trop`. That is the same change §13.3 records for UDUNITS, reaching a
second vocabulary.

**The size, measured rather than estimated.** This is by far the largest table in the repository:
5071 codes averaging 54 bytes, against 2808 QUDT rows averaging 12. `BVNR_UNIT_STRING_MAX` did not
have to move — the longest name is 166 bytes and the cap is 1088 — but the binary did:

| | stripped release `bovnar` |
|---|---|
| after §15 closed the five tables | 1015 KB |
| with `om:` and `cf:` | **1798 KB** |

Three quarters of that is `cf`, and nearly all of `cf` is the names themselves: 502 KB of generated
atom rows against OM's 89 KB. This is what finally forced the per-profile build switch §15.3 had been
asking for — it is the difference between a 1 MB binary and a 1.8 MB one for a consumer who reads no
netCDF at all, and `-DBVNR_WITH_CF_PROFILE=OFF` is now how they decline it (§9.4). The decision
recorded here is that completeness won *inside* the vocabulary: a standard name absent from the table
is indistinguishable, to a producer, from one bovnar has never heard of, and a vocabulary carried in
part is a vocabulary whose absences have to be documented one by one. Whether to carry the vocabulary
at all is the integrator's, which is the right place for it.

---

## 18. Provenance, licensing and attribution

### 18.1 What is taken from whom

Seven profiles carry identifier strings from **six external vocabularies**, and those strings are
not this project's to license. The tables map them onto Bovnar's own registry; the mapping, the
targets and the refusal rationales are original work here, and the identifiers are not.

What is taken is **identifiers** — a UCUM atom code, a QUDT or OM local name, a UN/ECE common code,
a UDUNITS spelling, a CF standard name — and two kinds of short text beside them, both worth naming
rather than glossing over. The first is the **conventional name of a unit**, where a row would
otherwise be unreadable: a three-letter Rec 20 code is unintelligible without one, so most rows of
`unece.bvnr` carry `# statute mile` or `# US petroleum barrel`, the refusal strings in `ucum.bvnr`
do the same, and a few of those match UCUM's own `<name>` field word for word — `%[slope]` is
"percent of slope" in both, because that is what the unit is called. The second is CF's
`canonical_units`, which `cf.bvnr` carries on every row and translates into that row's target,
because §17.2 is built on it and it cannot be paraphrased away.

What is *not* taken is any upstream definition, description, annotation, property or explanatory
prose — nor any upstream conversion factor, since every target resolves through the native registry
and §9.5 then compares that against the publisher's value rather than importing it. Where a row
carries reasoning, that reasoning was written here. And no upstream artefact is redistributed at
all: §9.5's factor proof fetches the publishers' own machine-readable definitions into a git-ignored
build directory, verifies the tables against them, and packages none of them.

| Namespace | Vocabulary | Version | Licence |
|---|---|---|---|
| `ucum:` | UCUM | 2.2 (rev. 2024-06-17) | UCUM Copyright Notice and License v1.1 — **restrictive; see §18.3** |
| `qudt:`, `qudt-qk:` | QUDT | 3.1.0 | CC BY 4.0, attribution to QUDT.org |
| `om:` | OM 2 | 2.0 | CC BY 4.0, © Rijgersberg, Willems, Top / Wageningen UR |
| `udunits:` | UDUNITS-2 | master | BSD 3-clause, © UCAR / Unidata |
| `cf:` | CF standard name table | v94 (2026-06-09) | **none stated by the publisher; see §18.3** |
| `unece:` | UN/CEFACT Rec 20 and 21 | verified via QUDT's `uneceCommonCode` | UN/CEFACT IPR Policy; what is verified against QUDT is CC BY 4.0 |

The full notices — copyright lines, licence URIs, the BSD text verbatim, and the modification
statement CC BY 4.0 requires — live in `THIRD_PARTY_NOTICES.md` at the root of the distribution,
and each `src/gendata/*.bvnr` file repeats its own block in its header so that the version and the
licence travel with the data rather than only with the notices file. That file has a second part,
outside the scope of this document, inventorying everything else this project ships that it did not
author — the website's fonts and libraries, the imagery, the toolchain whose output is committed,
and whatever later review adds to it.

### 18.2 Every table is an adaptation, and says so

No table here reproduces a vocabulary, and none could: §3.1's three outcomes mean a code whose value
Bovnar cannot state exactly is refused rather than approximated, so every table is a subset by
construction, and §6.4 is the standing list of what each one leaves out. That is what makes these
adaptations rather than copies, and CC BY 4.0 requires an adaptation to be marked as one — which is
why the notices file states it for QUDT and OM in those words, and why the `.bvnr` headers do too.

It also has a consequence worth stating outside the licence text: **a publisher's endorsement is
never implied.** QUDT.org, the OM authors, UCAR/Unidata and the CF and UN/CEFACT communities have no
involvement in this project. Where a translation is wrong, it is wrong here — which is exactly what
§9.5 exists to catch, and §10.2 is the honest account of what it still cannot.

### 18.3 The two open questions

**Three of the six are settled.** QUDT and OM are CC BY 4.0 and need attribution, which they now
have; UDUNITS-2 is BSD 3-clause and needs its notice carried into binary distributions, which
`pack_artifacts.cmake` now does. **One is low-risk but unconfirmed:** UN/ECE is unresolved on the
face of the UN's own website terms and universally implemented in practice — Rec 20 exists to be
implemented — and written confirmation has been sought rather than assumed. **Two are genuinely
open**, and neither is open because nobody has got round to it.

**UCUM.** Its licence is revocable, and §3(a) conditions the grant on not creating derivative works
of the UCUM table and not adding to, deleting from or modifying its content. `ucum.bvnr` is a subset
mapped onto a different unit model, which is a derivative work on the licence's own definition
whatever its purpose. Two things follow, and both matter. First: Bovnar's native notation was
designed and specified independently of UCUM, is not offered as a replacement for it, and the
`ucum:` namespace exists precisely so that a producer holding a UCUM code need not translate it by
hand — §1.2 is the whole argument for a notation rather than more native units, and it is an
interoperability argument. Second: a UCUM code is never silently reinterpreted, because §3.1 leaves
nowhere for a guess to hide. Written permission for the derived mapping is being sought; until it is
granted, `-DBVNR_WITH_UCUM_PROFILE=OFF` (§9.4) drops the table from the build entirely.

**CF.** The conventions *document* is CC0 1.0, but the standard name table is maintained separately
and carries no licence file and no in-band rights statement. Nothing suggests the CF community means
it to be restricted — a standard name exists to be written into data files worldwide — but there is
no grant to point at, so the table is attributed to the CF community and CEDA and an explicit
licence declaration has been requested. `-DBVNR_WITH_CF_PROFILE=OFF` is the same escape hatch.

Neither question is one this document can close by reasoning about it, and neither is recorded here
as settled. What is recorded is what was taken, from which published version, and under which terms
— so that the answer, when it arrives, lands against a provenance record rather than against a
table nobody can trace.

---

## See also

- [Unit & Currency Reference](05_bovnar_unit_system.md) — the native registry and notation grammar these profiles sit beside
- [Unit Ambiguities](07_bovnar_unit_ambiguities.md) — how a unit token is resolved natively, and the pairs that look interchangeable
- [Unit Ambiguities §17](07_bovnar_unit_ambiguities.md#17-the-same-spelling-in-another-namespace) — the reader-facing index of the cross-namespace collisions §6.2 records
- [Unit Policy](06_bovnar_unit_policy.md) — the reader- and writer-side policies a translated unit passes through unchanged
- [Read/Write API](08_bovnar_readwrite_api.md#112-reader-side-unit-policy-bvnr_reader_set_unit_policy) — the reader-side unit policy, and the `want_unit` hook a profile unit reaches unmodified
- [Conformance Test Tool](13_bovnar_conformance.md) — where the 59-case `unit_profile` group lives
- [Unit Cheatsheet](04_bovnar_unit_cheatsheet.md) — the native spellings the transliteration tables target
- [EBNF](12_bovnar.ebnf) — `unit-param`, `profile-unit` and the byte classes of §2.3

---

*End of Bovnar — Unit Profiles (Bovnar spec 1.2).*

<!-- ==================== EBNF grammar (https://www.bovnar.io/doc/12_bovnar.ebnf) ==================== -->

(* ================================================================== *)
(*  BOVNAR  –  EBNF derived from parser/lexer/validator implementation *)
(*  Version 1.2           Notation: ISO/IEC 14977:1996                *)
(*    { }  = zero or more       [ ]  = optional                       *)
(*    ( )  = grouping           |  = alternation                      *)
(*    ,    = concatenation      ;  = rule terminator                  *)
(*    "…"  = literal terminal   (*…*) = comment                       *)
(*  TWO EXTENSIONS to that notation, used throughout: inside a         *)
(*  terminal "\xNN" denotes the single byte NN, not four characters;   *)
(*  and a rule whose body is only a comment (unit-char, byte, the      *)
(*  UTF-8 classes of §12) is a BYTE CLASS defined by that comment --   *)
(*  the role ISO 14977 gives a ? special sequence ?. It is not the     *)
(*  empty production.                                                  *)
(*  Updated: 2026-11-03 — "[]" derives only as the empty row: a one-  *)
(*    element row needs a filled-elem (present-value split out of      *)
(*    raw-value). No change to what the reader accepts.                *)
(*  Updated: 2026-10-11 — review pass: a struct takes no annotation,  *)
(*    utf8/bool refuse a zero width too, digit alphabets, the default  *)
(*    Q, eight reserved words in the symbol section, 0xC2 in a         *)
(*    comment, ws-mandatory after a comment, the comment exception to  *)
(*    resync, DIGIT in profile-name.                                   *)
(*  Updated: 2026-10-10 — spec 1.2: the unit-profile notation        *)
(*    (profile-unit), whitespace inside a type parameter as an error,  *)
(*    array homogeneity by unit; consistency pass vs the reader -- a   *)
(*    comment may not start directly after a bare token, a bare        *)
(*    literal under a non-decimal base is read in that base, an        *)
(*    annotation before an octet stream is carried unchecked, the BOM  *)
(*    rule before/after the first assignment, epoch-param, and the     *)
(*    two resync boundaries. The entries below are HISTORY: the        *)
(*    counts they quote were true on their dates (see base-unit for    *)
(*    today's 264 units / 745 spellings).                              *)
(*  2026-06-17 — drop the residual "1.1 (draft)" label (spec   *)
(*    1.1 is released); note datetime also accepts a width parameter;    *)
(*    reframe base-unit as a lookup into the full 180-unit / 529-spelling *)
(*    table (NOT the closed SI subset it previously claimed).             *)
(*  2026-06-16 — consistency pass vs the implementation:               *)
(*    allow ws/comments before the assignment ";" and the "/" array    *)
(*    separator; move the inline unit to a value-only scalar-with-unit  *)
(*    rule so array elements are correctly excluded; bound \u{…} to 1–6 *)
(*    hex digits and version-int to no-leading-zero; inline type-spec;  *)
(*    split the array-homogeneity / duplicate-key constraints into the  *)
(*    LEXER/VALIDATOR tier (the streaming reader) vs the DOM-BUILDER     *)
(*    tier (only a DOM parse rejects ragged siblings, mixed kinds, and  *)
(*    duplicate keys; the streaming reader accepts them).               *)
(*  2026-06-15 — spec 1.1 (additive over the frozen 1.0):              *)
(*    version directive (#!bovnar M.N), datetime type family,         *)
(*    \xHH / \u{…} string escapes, reference array indexing.          *)
(*  Earlier (2026-05-29): null/true/false/on/off keywords, bool       *)
(*    type family, special numbers respelled nan / inf / ninf      *)
(* ================================================================== *)


(* ─────────────────────────────────────────────────────────────────  *)
(*  1. Top-level stream                                                *)
(* ─────────────────────────────────────────────────────────────────  *)

(* Valid end-of-stream states (lexer states where EOF is accepted):   *)
(*   undefined       – nothing has been read yet (empty stream)       *)
(*   first_comment_intro – inside a leading comment that runs to EOF  *)
(*                     with no trailing newline (comment-only stream) *)
(*   first_bom       – BOM or first-line comment (its newline seen),  *)
(*                     no assignment yet                              *)
(*   value_outro     – the semicolon of the last assignment consumed  *)
(*   comment_intro / ignore_comment_byte – a trailing comment         *)
(*                   opened after the last ";", ended by EOF          *)
(* In all these cases struct_nesting_level must be zero.              *)

(* Events emitted to the validator callbacks (bvnr_event_t):         *)
(*                                                                    *)
(*   ev_stream_start  – once at the very start of bvn_lex_run   *)
(*   ev_stream_end    – once at the very end of bvn_lex_run,          *)
(*                      after the last value's terminator is consumed *)
(*   ev_assignment_start   – on the identifier token of an assignment *)
(*   ev_struct_start       – on "{"                                   *)
(*   ev_struct_end         – on "}"                                   *)
(*   ev_array_row_start      – on "[" opening a bracket row             *)
(*   ev_array_row_end      – on "]" closing a bracket row             *)
(*   ev_array_dim_start        – on "/" between rows; fired immediately   *)
(*                           when "/" is consumed, before the next    *)
(*                           "[" opens the next row                  *)
(*   ev_octet_stream_start – on the NUL byte that enters binary mode  *)
(*   ev_octet_stream_end   – on the 0x00 tag ending an octet stream   *)
(*                                                                    *)
(*   For every typed value (including values whose type annotation is  *)
(*   synthesised by the validator – see §4) the following sequence    *)
(*   is emitted before ev_data:                                       *)
(*                                                                    *)
(*     ev_type_annotation_start              – once, before params    *)
(*     ev_type_annotation_type_family        – the family name        *)
(*     ev_type_annotation_type_family_parameter  – zero to three      *)
(*                      times, once per present parameter (width,     *)
(*                      base or q, unit), in that order               *)
(*     ev_type_annotation_end                – once, after params     *)
(*     ev_data                               – the value token        *)
(*                                                                    *)
(*   ev_type_annotation_start is emitted to on_unverified first       *)
(*   (raw bytes only), then to on_verified (with value_type and       *)
(*   value_unit filled in).  All other type-annotation events and     *)
(*   ev_data are emitted to both callbacks simultaneously.            *)
(*   For bool keywords (true/false/on/off) the same default type-     *)
(*   annotation sequence is emitted, synthesising <bool> when no       *)
(*   explicit annotation was given (no width/base/unit params).        *)
(*                                                                    *)
(*   ev_data is also emitted for null values, symbols, references,    *)
(*   and octet-stream chunks without a preceding type-annotation       *)
(*   sequence. Struct values do NOT produce ev_data; they are          *)
(*   signaled by ev_struct_start and ev_struct_end.                   *)

(* Only blank characters — NOT comments — may precede the version      *)
(* directive: it is recognised solely as the very first comment, so an  *)
(* earlier comment would demote it to an ordinary one (see below).      *)
stream       = [utf8-bom] , {ws-char} , [version-directive] , ws ,
               {assignment , ws} ;

(* UTF-8 BOM: legal only as the very first three bytes of the stream. *)
utf8-bom     = "\xEF\xBB\xBF" ;

(* Version directive (spec 1.1): an OPTIONAL leading comment of the    *)
(* form "#!bovnar <major>.<minor>" declaring the spec version the      *)
(* document targets. It is recognised only as the very first comment   *)
(* (after the optional BOM and whitespace); a "#!bovnar …" after any    *)
(* other comment or any content is an ordinary comment. Lexically it IS a comment, so a   *)
(* spec-1.0 reader skips it transparently — the declaration is purely   *)
(* additive. A 1.1+ reader parses it, exposes the version, and (in      *)
(* strict mode) rejects a version it does not support. Each component   *)
(* is a decimal integer with no leading zero (a bare "0" excepted).     *)
(* A malformed version after the "!bovnar" prefix is                    *)
(* error_invalid_spec_version -- it is NOT re-read as a comment, though *)
(* the optional below and `comment` would together derive it: once     *)
(* "#!bovnar" and an hspace open the first comment, the line is a       *)
(* directive or an error.                                               *)
(* The directive accepts ONLY horizontal whitespace (HT or SP) around   *)
(* its components — not the LF/CR/VT/FF that "ws-char" also covers.      *)
version-directive = "#" , "!bovnar" , hspace , {hspace} ,
                    version-int , "." , version-int ,
                    {hspace} , ("\x0D" | "\x0A" | (* eof *)) ;
hspace       = "\x09" (* HT *) | "\x20" (* SP *) ;
(* version-int: a decimal integer with no leading zero (a bare "0" is the *)
(* only form starting with "0"); each component must fit in a uint16.     *)
version-int  = "0"
             | ( ("1"|"2"|"3"|"4"|"5"|"6"|"7"|"8"|"9") , {DIGIT} ) ;

(* Whitespace: blank characters and comments, freely intermixed.     *)
(* ONE RESTRICTION this production cannot carry: "#" is not a          *)
(* terminator of a bare token. A comment may not start immediately     *)
(* after a key, a number, a symbol, a reserved word, a reference, a    *)
(* datetime literal or a type-annotation parameter -- there "#" is     *)
(* error_unexpected_input_byte, and at least one ws-char must come     *)
(* first. After a string, "]", "}", ">", "=", ";" or an inline unit,  *)
(* "#" may follow directly.                                           *)
ws           = {ws-char | comment} ;

(* At least one whitespace character, followed by optional further    *)
(* whitespace and comments.  Used as the mandatory separator before   *)
(* an inline unit suffix.  A comment's own line end is a ws-char for   *)
(* this purpose, so after a STRING (where "#" may follow directly) a   *)
(* comment alone separates: "text"# c<LF>m carries the unit m.         *)
ws-mandatory = ( ws-char | comment ) , {ws-char | comment} ;
ws-char      = "\x09"             (* HT  *)
             | "\x0A"             (* LF  *)
             | "\x0B"             (* VT  *)
             | "\x0C"             (* FF  *)
             | "\x0D"             (* CR  *)
             | "\x20" ;           (* SP  *)

(* Comment: "#" through the next LF, CR, or end of input.            *)
(* Control bytes 0x00–0x08, 0x0E–0x1F, and 0x7F are hard errors     *)
(* inside a comment body.                                            *)
comment      = "#" , {comment-char} , ("\x0D" | "\x0A" | (* eof *)) ;
comment-char = "\x09"                               (* HT             *)
             | "\x0B" | "\x0C"                      (* VT, FF         *)
             | printable-byte                        (* 0x20–0x7E      *)
             | high-byte ;                           (* 0x80–0xFF      *)


(* ─────────────────────────────────────────────────────────────────  *)
(*  2. Assignments                                                     *)
(* ─────────────────────────────────────────────────────────────────  *)

(* The leading "." is a syntactic sigil; it is not stored in the key. *)
(* At least one id-start character after "." is required;             *)
(* ".=" is error_empty_identifier.                                    *)
(* Comments are permitted between the key and "=", but only after at   *)
(* least one ws-char: "#" directly after the last key byte is a hard  *)
(* error (see ws, and the id-body-char hard-error list below).        *)

assignment   = "." , key , ws , "=" , ws , value , ws , ";" ;

key          = id-start , {id-body-char} ;

(* First character of an identifier, symbol, or reference segment:   *)
(* A–Z, a–z, "_", and UTF-8 leader bytes 0xC3–0xF4.                 *)
(* 0xC2 is explicitly rejected (ACT_NONE) at all token-start         *)
(* positions, so code points U+0080–U+00BF cannot begin these tokens.*)
id-start     = ALPHA | "_" | utf8-leader ;

(* Body characters of an identifier:                                  *)
(* Accepted: "+", "-", digits, alpha, "_",                            *)
(*   UTF-8 leader bytes 0xC3–0xF4, UTF-8 continuation bytes 0x80–0xBF.*)
(* Hard-error bytes: 0x00–0x08, 0x0E–0x1F, 0x7F, 0xC2, punct:       *)
(*   " # , . / ; < > [ ] { } ! $ % & ' ( ) * : ? @ \ ^ ` | ~      *)
(* "=" is not one of them: it terminates the body (ACT_value_intro).  *)
(* Whitespace terminates the body (ACT_to_identifier_outro), after    *)
(* which only "=" is accepted (with comments permitted between).      *)
id-body-char = "+" | "-"
             | DIGIT
             | ALPHA
             | "_"
             | utf8-leader         (* 0xC3–0xF4 *)
             | utf8-continuation ; (* 0x80–0xBF *)


(* ─────────────────────────────────────────────────────────────────  *)
(*  3. Values                                                          *)
(* ─────────────────────────────────────────────────────────────────  *)

(* A value is the right-hand side of an assignment or an element      *)
(* inside an array row; the optional type annotation applies in both  *)
(* contexts.                                                          *)
(*                                                                    *)
(* Typed null elements: in array context, a type annotation not       *)
(* followed by any raw value token (i.e. the next byte is "," "]"    *)
(* or ";") is a null value carrying the annotated type.  This is     *)
(* handled by ACT_type_null_then_new_array_value,                     *)
(* ACT_type_null_then_array_outro, and ACT_type_null_then_value_outro  *)
(* from the type_outro state.                                         *)
(*                                                                    *)
(* ONE VALUE FORM TAKES NO ANNOTATION: a struct. An annotation in     *)
(* force where its "{" opens -- written before it, or inherited from  *)
(* the array it is an element of -- is error_type_value_mismatch.     *)
(* The production below is wider than that; the constraint is here.   *)

value        = [type-annotation , ws] , ( scalar-with-unit | raw-value ) ;

(* A bare number or string scalar may carry an inline unit suffix — but ONLY  *)
(* at value position, never inside an array element (array-elem uses raw-      *)
(* value, which has no unit option).  The lexer reaches the inline-unit state  *)
(* only from number_outro / string_outro, so no other value form takes one.    *)
scalar-with-unit = ( number | string ) , ws-mandatory , inline-unit ;

raw-value    = null-value | present-value ;

(* present-value: every raw-value that is not null.  Split out so a   *)
(* one-element array row can require a non-empty element (row-content, *)
(* §9) without restating the list.                                     *)
present-value = bool-value
             | special-number
             | datetime-literal               (* spec 1.1 *)
             | number
             | string
             | symbol
             | reference
             | array
             | struct
             | octet-stream ;

(* datetime-literal (spec 1.1): an ISO-8601 UTC timestamp accepted in     *)
(* place of the integer epoch-seconds carrier.  The lexer accumulates the *)
(* shape (dtlit_* states, entered from a digit run followed by "-"); the  *)
(* validator strictly range-checks the fields and converts the UTC civil  *)
(* instant to the integer carrier on the declared epoch (unix when a bare *)
(* literal infers <datetime:64,unix>).  Whole-second carrier: a frac-     *)
(* tional part (any digit count) does not change the value but is kept    *)
(* verbatim and re-emitted so the literal round-trips.  A "+HH:MM" /      *)
(* "-HH:MM" offset is folded to true UTC before conversion.              *)
(* The atomic GNSS epochs (gps/galileo/glonass/beidou) reject a literal.  *)
datetime-literal = year , "-" , month , "-" , day ,
                   [ "T" , hour , ":" , minute , ":" , second ,
                     [ "." , dt-fraction ] , [ dt-zone ] ] ;
dt-fraction  = DIGIT , { DIGIT } ;          (* kept verbatim; carrier is whole seconds *)
dt-zone      = "Z" | ( "+" | "-" ) , hour , ":" , minute ;  (* tz offset *)
year         = DIGIT , DIGIT , DIGIT , DIGIT ;
month        = DIGIT , DIGIT ;   (* 01..12                             *)
day          = DIGIT , DIGIT ;   (* 01..28/29/30/31 per month          *)
hour         = DIGIT , DIGIT ;   (* 00..23                             *)
minute       = DIGIT , DIGIT ;   (* 00..59                             *)
second       = DIGIT , DIGIT ;   (* 00..60 (60 = UTC leap second; on the *)
                                (* tai epoch it is its own instant, on  *)
                                (* the civil ones it folds onto the     *)
                                (* following second -- see the spec)    *)

(* null-value: either the reserved keyword "null" or the empty        *)
(* production.                                                        *)
(* At assignment level:  ".foo = ;" and ".foo = null;" are equivalent. *)
(* In array context: a leading or trailing comma, two consecutive     *)
(* commas, a bare "null", or a type annotation immediately followed   *)
(* by "," / "]" all denote a null element.                            *)
null-value   = "null" | (* empty *) ;

(* bool-value: a reserved keyword in value position.  "on" is an      *)
(* alias for "true" and "off" for "false"; the validator collapses    *)
(* all four spellings to a vt_bool value (canonical text "true" /     *)
(* "false") and synthesises a <bool> type annotation when none is     *)
(* given (see §4).  An explicit <bool> annotation accepts only these  *)
(* keywords as its value.                                             *)
(*                                                                    *)
(* Lexically these keywords — together with "null" — are ordinary     *)
(* symbol tokens (§7); the validator reserves the exact words and     *)
(* reclassifies them.  A longer word that merely starts with one      *)
(* (e.g. "truthy", "nullable") remains a plain symbol.                *)
bool-value   = "true" | "false" | "on" | "off" ;


(* ─────────────────────────────────────────────────────────────────  *)
(*  4. Type annotations                                                *)
(* ─────────────────────────────────────────────────────────────────  *)

(* Lexer: a keyword state machine (tf_* states) recognises the eight  *)
(* family names via six keyword paths (float_fix / float_dec share     *)
(* the "float" prefix; see §4 below).  After the family keyword the    *)
(* raw bytes of the ":…"                                              *)
(* parameter string are accumulated into type_data by copy_type_byte. *)
(*                                                                    *)
(* Validator: bvn_parse_type_annotation parses the accumulated string *)
(* and the validator emits the structured type-annotation event        *)
(* sequence described in §1.                                          *)
(*                                                                    *)
(* Default synthesis: when a number or string value carries no        *)
(* explicit type annotation (value_type is still vt_plain), the       *)
(* validator synthesises a default before emitting ev_data:           *)
(*   strings / special-numbers → <utf8>  /  <float:64,_10,no_unit>   *)
(*   floats (has "." or "e"/"E") → <float:64,_10,no_unit>            *)
(*   negative integers           → <sint:64,_10,no_unit>             *)
(*   plain integers              → <uint:64,_10,no_unit>             *)
(*   bool keywords (true/false/on/off) → <bool>                      *)
(*   any number with an inline currency unit → <float_dec:64,$XXX>   *)
(*     (takes precedence over the float / integer rows)               *)
(*   ISO-8601 datetime literal (spec 1.1) → <datetime:64,unix>       *)
(* The full type-annotation event sequence is emitted for a scalar     *)
(* number or string value whether annotated explicitly or not.        *)
(* Inside an array a synthesised annotation is emitted only when the   *)
(* element's synthesised type differs from the type currently in       *)
(* force -- at the first element, and again at each change. It is not  *)
(* repeated per element or per row; an explicit element annotation is  *)
(* always emitted.                                                     *)

type-annotation = "<" , ws , param-type , ws , ">" ;

(* Types with an optional parameter list after ":".                   *)
(* Parameters are identified by class and may appear in any order;    *)
(* at most one of each class (width, base, q, unit) is permitted.     *)
(*                                                                     *)
(* "utf8" goes to type_body_outro after the keyword; the lexer        *)
(* accepts ":" and the parameter bytes, but utf8 is a parameterless    *)
(* family — any width, base, q, or unit parameter is rejected by        *)
(* bvn_parse_type_annotation as error_illegal_value_type (see bool).   *)
(*                                                                     *)
(* "float_fix" and "float_dec" are not separate keyword paths in the  *)
(* lexer state table.  The lexer fires ACT_tf_float_done after the    *)
(* shared "float" prefix (states tf_f → tf_fl → tf_flo → tf_floa     *)
(* → ACT_tf_float_done), which stores "float" in type_data and        *)
(* transitions to type_body_outro.  The subsequent bytes "_fix" or    *)
(* "_dec" are accumulated via copy_type_byte.  The final string        *)
(* ("float", "float_fix", or "float_dec") is dispatched in           *)
(* bvn_parse_type_annotation.                                         *)
(* "bool" and "utf8" are parameterless families: any width, base, q,  *)
(* or unit parameter is error_illegal_value_type -- a width of 0      *)
(* included; the test is on presence.  bool's only valid               *)
(* values are the bool keywords (see bool-value, §3).                  *)
(* "datetime" (spec 1.1) is a timestamp: a signed integer seconds count    *)
(* validated like sint.  It accepts an optional width parameter (as sint)   *)
(* plus one epoch-name parameter (unix|tai|gps|mjd|ntp|galileo|glonass|     *)
(* y2000|beidou; default unix), e.g. <datetime:64,unix>.  A numeric base,   *)
(* q, or unit parameter is error_illegal_value_type.  Available only when   *)
(* the document declares "#!bovnar 1.1" or newer.                           *)
param-type   = ("uint" | "sint" | "float" | "float_fix" | "float_dec" | "utf8"
                 | "bool" | "datetime")
             , [ws , ":" , ws , type-param-list] ;

(* The parameter list is accumulated byte-by-byte into type_data by   *)
(* the copy_type_byte / type_body_outro states, and later re-parsed   *)
(* by bvn_parse_type_annotation.                                      *)
(*                                                                    *)
(* Bytes accepted in the accumulated string:                          *)
(*   A–Z, a–z, 0–9, "$", "%", "(", ")", "*", "+", ",", ".", "/",     *)
(*   "-", ":", "^", "_", "~", "'", "[", "]", "{", "}"                *)
(*   ("~" joins a prefix to its unit, "$" starts a currency, "%" is    *)
(*   percent, "(" and ")" group a unit expression -- all required by   *)
(*   inline-unit-char below.  "'", "[", "]", "{" and "}" are required *)
(*   by profile-unit: UCUM brackets an atom ("[in_i]"), annotates a    *)
(*   term ("mL{total}") and uses "'" inside a code ("[arb'U]").       *)
(*   ";", "#", "<", ">" and '"' stay OUT, so an unterminated bracket  *)
(*   or annotation cannot consume the rest of the document)           *)
(*   UTF-8 continuation bytes 0x80–0xBF                               *)
(*   UTF-8 leader bytes 0xC2–0xF4  (0xC2 IS accepted here, unlike     *)
(*   identifier / symbol / reference token starts)                    *)
(* WHITESPACE IS ACCEPTED ONLY WHERE THIS GRAMMAR PUTS A `ws`, which  *)
(* is beside a separator: after the family keyword, either side of the *)
(* family ":" or of a "," between parameters, and before the closing   *)
(* ">". It is not accumulated. Three lexer states implement the        *)
(* distinction -- copy_type_byte (mid-parameter), type_body_outro (at  *)
(* a separator boundary, where whitespace and "#" comments are         *)
(* ignorable) and type_body_ws (mid-parameter with whitespace already  *)
(* consumed, where only a separator, a ">" or a comment may follow).   *)
(* A parameter byte reached from type_body_ws is                       *)
(* error_type_param_whitespace.                                       *)
(*                                                                     *)
(* The lexer used to DROP whitespace wherever it fell, which made      *)
(* "<float:64,m in>" the minute and "<uint:6 4>" a 64-bit width, and   *)
(* would have turned the UDUNITS spelling "udunits:m s-1" into a       *)
(* reciprocal millisecond. Nothing in this grammar ever derived those;  *)
(* the implementation was leniently wrong and now matches what is      *)
(* written here.                                                      *)
(*                                                                     *)
(* A "," or ":" nested inside a UCUM annotation ("{…}") or bracketed   *)
(* atom ("[…]") is part of profile-code and NOT a separator, so        *)
(* "ucum:mL{cells,tot}" is one parameter and not two, and a stray      *)
(* comma in "ucum:[IU,]" is a malformed unit (error_unit_illegal), not *)
(* a malformed parameter list.                                         *)
(*                                                                     *)
(* ONE EXCEPTION, AND IT IS A PROFILE'S: inside a parameter that       *)
(* carries a profile NAMESPACE, whitespace is neither deleted nor      *)
(* refused. It is kept VERBATIM and the vocabulary decides -- UDUNITS  *)
(* multiplies with a space ("udunits:kg m-2 s-1", CF's commonest       *)
(* spelling of a flux), a UCUM annotation keeps its spacing, and every *)
(* other vocabulary refuses it through its own grammar as             *)
(* error_unit_illegal. See profile-code below. It is the family ":"    *)
(* that does NOT open a namespace: only a later ":" at bracket depth 0 *)
(* does, which is what keeps "<uint:6 4>" an error. Trailing           *)
(* whitespace is trimmed (it is beside the separator that ends the     *)
(* parameter); a line break inside the code is an error, one after it  *)
(* is trailing whitespace.                                            *)
(* ">" always terminates the annotation.                              *)
type-param-list = type-param , {ws , "," , ws , type-param} ;

type-param   = width-param           (* e.g. 32, 64                        *)
             | base-param            (* e.g. _16, _64, _85                 *)
             | q-param               (* e.g. q8, q16  – float_fix only     *)
             | epoch-param           (* e.g. unix, tai – datetime only     *)
             | unit-param ;          (* e.g. m~s, Gi~B, m^2                *)

(* Epoch (spec 1.1): the epoch name of a datetime. An epoch name and a  *)
(* unit are both bare words; they are told apart by the family -- under *)
(* "datetime" a bare-word parameter is an epoch, under every other      *)
(* family it is a unit.                                                 *)
epoch-param  = "unix" | "tai" | "gps" | "mjd" | "ntp"
             | "galileo" | "glonass" | "y2000" | "beidou" ;

(* Width: one or more decimal digits.  0 selects the default width, 64.*)
(* float:      0, 16, or any multiple of 32 up to BVN_FLOAT_MAX_PREC.*)
(* float_fix,  *)
(* float_dec:  0, 16, 32, 64, 128, 256.                               *)
width-param  = DIGIT , {DIGIT} ;

(* Base: underscore followed by one or more decimal digits.           *)
(* Valid values: 2–62, 64 (standard Base64), 85 (Ascii85).           *)
(* Digits, lowest first: 0-9 a-z for 2–36, case-INSENSITIVE; 0-9 a-z  *)
(*   A-Z for 37–62, case-sensitive (a = 10, A = 36); A-Z a-z 0-9 + /  *)
(*   for 64 (RFC 4648, no padding); "!" (0x21) .. "u" (0x75) for 85.  *)
(* Bases 64 and 85 are uint-only: their alphabets take sign           *)
(*   characters as digits ("+" in both, "-" in 85), so sint+64/85 is   *)
(*   illegal.                                                          *)
(* "float" accepts only base 10 (or absent, → 10) or base 16;        *)
(*   other bases → error_illegal_value_type.                          *)
(* "float_fix" and "float_dec" reject the base parameter entirely:   *)
(*   any base-param → error_illegal_value_type.                       *)
(* A bare number literal under a non-decimal base is valid when every   *)
(* byte of it is a digit of that base, and it is read IN THAT BASE:    *)
(* <uint:_16> 10 is sixteen. In base 15 and above "e"/"E" are digits,  *)
(* so a bare 1e3 under _16 is the integer 0x1E3 and not an exponent    *)
(* form. A value needing a letter the number lexer does not accept     *)
(* ("ff") lexes as a symbol -- error_type_value_mismatch -- and must   *)
(* be a quoted string. Producers SHOULD quote every non-decimal value. *)
base-param   = "_" , DIGIT , {DIGIT} ;

(* Q~parameter: lowercase "q" followed by one or more decimal digits. *)
(* Specifies the number of fractional bits for the float_fix Q-format.*)
(*   Value = raw_signed_integer × 2^(-Q).                             *)
(* Valid only for "float_fix"; rejected for all other families.       *)
(* Valid range: 0 ≤ Q < effective_width (width 0 → effective 64).    *)
(* Omitted, Q is 0: <float_fix:32> is <float_fix:32,q0>.              *)
(* Q is stored in value_type_spec_t.base and retrieved via            *)
(* bvn_effective_q; bvn_effective_base always returns 10 for          *)
(* float_fix and float_dec.                                           *)
q-param      = "q" , DIGIT , {DIGIT} ;

(* Unit parameter: everything up to the next "," or ">".             *)
(* Parsed against the unit table by bvn_parse_unit.              *)
(* "no_unit" is a reserved keyword meaning explicitly dimensionless.  *)
(* Valid for: uint, sint, float, float_fix, float_dec.               *)
unit-param   = profile-unit | native-unit ;
native-unit  = unit-char , {unit-char} ;
unit-char    = (* any byte accepted by copy_type_byte that is not     *)
               (* "," (0x2C) or ">" (0x3E):                          *)
               (* A–Z, a–z, 0–9, "*", "+", ".", "/", "-", ":", "^",  *)
               (* "_", "~", "$", "%", "(", ")", "'", "[", "]", "{",  *)
               (* "}", or a UTF-8 multi-byte byte (0x80–0xBF or       *)
               (* 0xC2–0xF4)                                          *) ;

(* ── Unit profile: a foreign notation in the unit slot ─────────── *)
(*                                                                     *)
(* A "name:" namespace hands the parameter to a unit PROFILE, which    *)
(* translates it into the same value_unit_t a native expression        *)
(* produces.  See doc/11_bovnar_unit_profiles.md.  Seven namespaces    *)
(* are defined -- "ucum", "unece", "qudt", "qudt-qk", "udunits", "om"  *)
(* and "cf"; any other is error_unit_profile_unknown, and a profile    *)
(* expression that is malformed or names a code the profile does not   *)
(* know is error_unit_illegal; one the profile does know and this unit *)
(* system cannot represent is error_unit_profile_unsupported.  There   *)
(* is no further outcome in which the value carries a unit the library *)
(* cannot reason about.                                                *)
(*                                                                     *)
(* SPEC 1.2.  Available only when the document declares "#!bovnar 1.2" *)
(* or newer; in a document that declares less, or nothing, a profile   *)
(* unit is error_unit_illegal -- in that version it is not a unit.     *)
(*                                                                     *)
(* WHICH OF THE SEVEN A GIVEN BUILD CARRIES IS A BUILD OPTION          *)
(* (BVNR_WITH_<NAME>_PROFILE, all on by default). A namespace that was *)
(* compiled out is error_unit_profile_unknown -- the same answer as a  *)
(* namespace that never existed, deliberately: the document is not     *)
(* wrong, and this build cannot read it either way. Nothing else       *)
(* varies with the switches; the base-unit ids and every structure     *)
(* here are identical in all configurations.                           *)
(* bvn_unit_profile_count/bvn_unit_profile_name report the set.        *)
(*                                                                     *)
(* No native unit alias or currency code contains a ":", so the        *)
(* namespace is unambiguous against the whole registry with no         *)
(* lookahead -- and a ":" in a unit was an error before the profile    *)
(* existed, so no document can change meaning.                         *)
(*                                                                     *)
(* The code is NOT given a grammar here.  Like the native unit         *)
(* sub-grammar below it is semantic, enforced by bvn_parse_unit; the   *)
(* normative grammar for what is inside is each vocabulary's own, and  *)
(* restating them here would create a second authority for each, to be *)
(* kept in step with the first.                                        *)
(*                                                                     *)
(* The hyphen in profile-name may not LEAD, which is what keeps "-qk:" *)
(* from being a namespace; it exists for "qudt-qk", where the          *)
(* vocabulary itself is split into a unit namespace and a              *)
(* quantity-kind one.                                                  *)
profile-unit = profile-name , ws , ":" , ws , profile-code ;
profile-name = name-head , {name-tail} ;   (* "ucum", "qudt-qk"       *)
name-head    = lower-alpha ;   (* a digit-led parameter is a width *)
name-tail    = lower-alpha | DIGIT | "-" ;
lower-alpha  = "a"|"b"|"c"|"d"|"e"|"f"|"g"|"h"|"i"|"j"|"k"|"l"|"m"
             | "n"|"o"|"p"|"q"|"r"|"s"|"t"|"u"|"v"|"w"|"x"|"y"|"z" ;
(* NOTE: after a profile-code the ws before "," or ">" may not contain *)
(* a comment -- the reader refuses "#" there.                          *)
profile-code = profile-char , {profile-char} ;
profile-char = (* any unit-char, plus "," at brace or bracket depth    *)
               (* >= 1 ("{" or "[" open) -- a                        *)
               (* UCUM annotation may contain a comma ("{cells,tot}") *)
               (* and the type-parameter scanner tracks brace and     *)
               (* bracket depth so that one does not end the          *)
               (* parameter.  Depth is bounded at 16, as for the      *)
               (* native "(" grouping.                                *)
               (*                                                     *)
               (* PLUS SP (0x20) and HT (0x09), which no unit-char is: *)
               (* a profile code may contain them and they are carried *)
               (* verbatim, because UDUNITS multiplies with a space    *)
               (* ("kg m-2 s-1") and a UCUM annotation keeps its       *)
               (* spacing.  What a space MEANS is each vocabulary's    *)
               (* own business and is not specified here, exactly as   *)
               (* the rest of profile-code is not: "ucum:[in i]" and   *)
               (* "qudt:Kilo GM" are error_unit_illegal because those  *)
               (* vocabularies define no such code, not because a      *)
               (* space is forbidden.  A trailing run is trimmed; LF,  *)
               (* CR, VT and FF stay OUT (no vocabulary spells a unit  *)
               (* across a line).  A native unit-param admits none of  *)
               (* this -- there a space is                             *)
               (* error_type_param_whitespace.                        *) ;

(* ── Unit sub-grammar (semantic, enforced by bvn_parse_unit) ─── *)
(*                                                                     *)
(* resolved-unit = "no_unit" | unit-expr ;                            *)
(* unit-expr     = unit-factor , {unit-sep , unit-factor} ;           *)
(* unit-factor   = unit-component | "(" , unit-expr , ")" ;           *)
(*                                                                     *)
(* unit-sep      = "*"                        (* product – U+002A  *) *)
(*               | "·"                        (* product – U+00B7  *) *)
(*               | "/" ;                      (* division           *) *)
(*   "·" is the middle dot, encoded as 0xC2 0xB7 (UTF-8 for U+00B7). *)
(*   "*" and "·" are semantically equivalent (multiplication).        *)
(*                                                                     *)
(* Semantics of "/": the first "/" switches every subsequent factor    *)
(* into the denominator (its stored exponents are negated). A "(...)"  *)
(* group is a sub-expression evaluated independently; when it falls in  *)
(* the denominator its net exponents are negated as a whole, so        *)
(* "k~g/(m·s²)" = kg·m⁻¹·s⁻² and "(k~g/m)·s²" = kg·m⁻¹·s². Additional   *)
(* "/" separators at one level never toggle back to the numerator.     *)
(* An explicit separator is required before a group ("m·(s)", not      *)
(* "m(s)"); a group is not (yet) followed by its own exponent; paren    *)
(* nesting is bounded at 16.                                           *)
(*                                                                     *)
(* Empty components or groups (e.g. "m//s", "()", "m*·s"), unmatched   *)
(* parentheses, and more than BVNR_MAX_UNIT_COMPONENTS = 32 total      *)
(* components per unit string all produce error_unit_illegal.          *)
(*                                                                     *)
(* unit-component = [prefix ["~"]] base-unit [unit-exponent] ;        *)
(*                                                                     *)
(* A bare base-unit with no prefix requires no separator.             *)
(* A prefix may be written with the "~" separator ("k~g") or          *)
(* compactly, without it ("kg", "MiB").  Both spell the same unit;    *)
(* the separated form is the canonical one, though a re-serialised    *)
(* document keeps the spelling it carried.  base-unit is matched      *)
(* as the LONGEST alias suffix, so a bare unit outranks any prefixed  *)
(* reading of the same token ("min" is the minute, not milli-inch)    *)
(* and no compact spelling can change how an existing document        *)
(* decodes.  A prefix cannot be stacked ("kkg", "k~kg" are both       *)
(* error_unit_illegal), prefix-unit validity is unchanged, and the    *)
(* few compact spellings listed in .compact_exceptions               *)
(* (src/gendata/units.bvnr: "usb", "kt", "ppt", "MBtu", "MBTU",     *)
(* "Mbtu", "Mlb", "Mlbs") stay errors -- they name something else,  *)
(* or two things at once.                                            *)
(* A prefixed CURRENCY behaves the same way ("k~$EUR" = "k$EUR");    *)
(* the '$' sigil separates prefix from code and stays MANDATORY, so  *)
(* "kUSD" is error_unit_illegal just as bare "USD" is.              *)
(*                                                                     *)
(* si-prefix  = "Q"|"R"|"Y"|"Z"|"E"|"P"|"T"|"G"|"M"|"k"|"h"|"da"   *)
(*            | "d"|"c"|"m"|"µ"|"n"|"p"|"f"|"a"|"z"|"y"|"r"|"q" ;  *)
(*   (µ = U+00B5 0xC2 0xB5; Greek μ = U+03BC; ASCII "u" alias)       *)
(*                                                                     *)
(* iec-prefix = "Ki"|"Mi"|"Gi"|"Ti"|"Pi"|"Ei"|"Zi"|"Yi"|"Ri"|"Qi" ; *)
(*   Ri (2^90) and Qi (2^100) are a forward-looking EXTENSION, not     *)
(*   part of IEC 80000-13, which stops at yobi (Yi, 2^80).             *)
(*                                                                     *)
(* base-unit  = a symbol looked up in the fixed unit table by              *)
(*   bvn_parse_unit (src/utils/bovnar_utils.c): 264 named units with        *)
(*   745 accepted spellings (canonical symbols plus long-form and plural    *)
(*   aliases), spanning SI, imperial / US customary, CGS, radiation,        *)
(*   surveying, culinary, Old German and digital-storage quantities.        *)
(*   Doc 2 (the unit-system spec) is the authoritative registry; the SI     *)
(*   coherent / common subset below is REPRESENTATIVE only, NOT a closed    *)
(*   alternation:                                                           *)
(*     b B s m g A K mol cd Hz N Pa J W V Ω F C S Wb T H lm lx Bq Gy Sv     *)
(*     kat rad sr L l min h d wk yr °C ° degC degrC degrees degree degr     *)
(*     deg t bar eV Da au ha                                                *)
(*   Symbols are case-sensitive and a few spellings collide across roles,   *)
(*   resolved by context: "P" = poise (base unit) vs peta (SI prefix),      *)
(*   "G" = gauss vs giga.  (Ω = U+2126: 0xE2 0x84 0xA6; ° = U+00B0: 0xC2    *)
(*   0xB0)                                                                  *)
(*                                                                     *)
(* unit-exponent = [exp-sign] exp-digit , [exp-digit] , [exp-digit]   *)
(*               | "^" ["-"|"+"] DIGIT , [DIGIT] , [DIGIT] ;          *)
(*                                                                     *)
(* exp-sign  = "⁺" (* U+207A: 0xE2 0x81 0xBA, positive / no-op *)    *)
(*           | "⁻" ; (* U+207B: 0xE2 0x81 0xBB, negate exponent *)   *)
(*                                                                     *)
(* exp-digit = "⁰"|"¹"|"²"|"³"|"⁴"|"⁵"|"⁶"|"⁷"|"⁸"|"⁹" ;          *)
(*   (U+2070 / 00B9 / 00B2 / 00B3 / 2074–2079)                       *)
(*                                                                     *)
(* "⁺" is accepted but has no effect (same as absent).               *)
(* The ASCII form "^[+-]?[0-9]{1,3}" maps to the same internal values as *)
(* the Unicode superscript form.                                      *)
(*                                                                     *)
(* An exponent is an INTEGER in [BVN_EXPONENT_MIN, BVN_EXPONENT_MAX]  *)
(* = [-100, 100], with ZERO RESERVED: "m^0" and "m⁰" are not units.  *)
(* At most three digits are scanned, which is exactly what 100 needs, *)
(* so "m^1000" is not an over-large exponent but an unrecognised      *)
(* token — the scan finds no "^" where it expects one, and the whole  *)
(* string fails to resolve as a base symbol. A value that scans but   *)
(* lands outside the range ("m^200") is refused on the range check.   *)
(*                                                                     *)
(* ⁰ is a digit like the others, leading or not ("m⁰⁰⁷" and "m^007"  *)
(* are m⁷); it exists so "m¹⁰⁰" can be written. An exponent that      *)
(* comes to zero, however spelled, is still no unit.                  *)


(* ─────────────────────────────────────────────────────────────────  *)
(*  5. Numbers                                                         *)
(* ─────────────────────────────────────────────────────────────────  *)

(* The lexer is digit-agnostic: any ASCII digit 0–9 is accepted in a  *)
(* bare number literal without base validation.  Digit validity against*)
(* a non-decimal base is checked later by bvn_acc_parse_number.       *)
(* Leading zeros are not rejected by the lexer ("007" is valid).      *)
(* Only "e"/"E" are accepted as exponent markers in bare literals.    *)
(* "p"/"P" (binary exponent) is recognised by the validator inside a   *)
(* quoted-string number value of a base-16 float, e.g.                 *)
(* <float:64,_16> "1.8p+2"; there "e"/"E" are hex mantissa digits, so  *)
(* the binary exponent must use "p"/"P" (see spec §6.3).  It is the     *)
(* only context in which "p"/"P" is reachable.                         *)

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

(* Integer-led: one or more digits with an optional fractional part.  *)
(* A trailing dot ("123.") without fractional digits is valid.        *)
int-led      = DIGIT , {DIGIT} , ["." , {DIGIT}] ;

(* Dot-led: at least one digit after the dot is required.             *)
(* ".5" and "-.5" are valid; a lone "." is a hard error.             *)
dot-led      = "." , DIGIT , {DIGIT} ;

dec-exponent = ("e" | "E") , ["+" | "-"] , DIGIT , {DIGIT} ;

(* Special IEEE-754 values are bare reserved keywords: nan, inf, and   *)
(* ninf (negative infinity).  Like null / true / false / on / off they *)
(* are lexed as ordinary symbols and reclassified to numeric special   *)
(* values by the validator; a bare word that is not one of these exact *)
(* spellings stays an ordinary symbol (e.g. "infinity", "nans").       *)
(* There is no sigil and no inline-unit suffix — a unit is supplied    *)
(* via the type annotation, e.g. <float:64,m/s> inf.                   *)
special-number = "nan" | "inf" | "ninf" ;


(* ─────────────────────────────────────────────────────────────────  *)
(*  6. Strings                                                         *)
(* ─────────────────────────────────────────────────────────────────  *)

(* Two or more adjacent quoted literals with only whitespace/comments  *)
(* between them are concatenated into a single string token by the    *)
(* lexer (string_outro re-enters string_intro on '"').  The combined  *)
(* byte length is bounded by max_string_length (default UINT16_MAX).  *)

string          = string-literal , {ws , string-literal} ;

string-literal  = '"' , {string-char} , '"' ;

string-char  = safe-string-byte
             | escape-seq ;

(* safe-string-byte: whitespace 0x09–0x0D, 0x20–0x7E except '"' (0x22)*)
(* and '\' (0x5C), plus high bytes 0x80–0xFF.                         *)
(* Control bytes 0x00–0x08, 0x0E–0x1F, and DEL (0x7F) are hard       *)
(* errors even inside strings.                                        *)
safe-string-byte = (* byte in [0x09,0x0D] | [0x20,0x21] | [0x23,0x5B] *)
                   (*          | [0x5D,0x7E] | [0x80,0xFF]             *) ;

(* Recognised escape sequences (from bvn_escape_lut):               *)
escape-seq   = "\" , ( "t"   (* \t = 0x09 HT  *)
                     | "n"   (* \n = 0x0A LF  *)
                     | "v"   (* \v = 0x0B VT  *)
                     | "f"   (* \f = 0x0C FF  *)
                     | "r"   (* \r = 0x0D CR  *)
                     | '"'   (* \"            *)
                     | "\"   (* \\            *)
                     | byte-escape       (* spec 1.1, see below *)
                     | unicode-escape ) ; (* spec 1.1, see below *)
(* All other bytes after "\" are error_illegal_escape_sequence.       *)
(* spec 1.1 only — available when the document declares "#!bovnar 1.1"  *)
(* or newer; in a 1.0/unversioned document "x"/"u" after "\" stay       *)
(* error_illegal_escape_sequence.                                       *)
byte-escape    = "x" , hex-digit , hex-digit ;       (* one byte 0xHH    *)
unicode-escape = "u" , "{" , hex-digit ,
                 [hex-digit] , [hex-digit] , [hex-digit] ,
                 [hex-digit] , [hex-digit] , "}" ;
(* 1 to 6 hex digits naming a Unicode scalar.  A 7th hex digit before "}"  *)
(* (or an empty "{}") is error_illegal_escape_sequence — a structural       *)
(* check made before the value is examined.  A value that is a surrogate    *)
(* (D800..DFFF) or above 10FFFF is error_invalid_codepoint; otherwise the   *)
(* scalar is UTF-8 encoded.  A \x byte must leave the whole string valid     *)
(* UTF-8 (else error_invalid_utf8_byte).  A \x or \u{} escape that resolves  *)
(* to a control byte 0x00–0x08, 0x0E–0x1F or 0x7F is                        *)
(* error_unexpected_input_byte, as the raw byte is: neither production      *)
(* excludes those values, this rule does.                                   *)
hex-digit      = DIGIT | "a" | "b" | "c" | "d" | "e" | "f"
               | "A" | "B" | "C" | "D" | "E" | "F" ;


(* ─────────────────────────────────────────────────────────────────  *)
(*  6.5  Inline unit suffix                                           *)
(* ─────────────────────────────────────────────────────────────────  *)

(* An inline-unit may follow a scalar number or string value (but not *)
(* a special-number keyword — give its unit via the type annotation).  *)
(* At least one whitespace character (ws-mandatory) is the sole        *)
(* required separator between the value literal and the unit.          *)
(* Inline units attach only at value position: the value rule has a   *)
(* scalar-with-unit alternative, while an array element (array-elem)   *)
(* uses raw-value, which has no unit option.  At the action level the  *)
(* lexer additionally rejects ACT_inline_unit_intro when the           *)
(* in_array_element flag is set (a redundant guard).                   *)
(*                                                                     *)
(* INVALID (no whitespace before the unit):                           *)
(*   .bad = 9.81m/s;     (* no separator at all — hard error *)       *)
(*                                                                     *)
(* Examples:                                                           *)
(*   .a = 9.81 m/s;          (* space separator *)                    *)
(*   .b = <float:64> 1.5 k~m; (* with type annotation *)             *)
(*   .c = <float:64,s> inf;  (* special-number unit via annotation *) *)
(*   .d = 1.5e3 k~J;          (* number with exponent *)              *)
(*   .e = "FF" m;             (* string literal with unit *)          *)
(*                                                                     *)
(* The inline path does not consult the type family: an inline unit    *)
(* after a utf8 string (<utf8> "text" m) is accepted and carried,      *)
(* although the same unit as an annotation parameter (<utf8,m>) is     *)
(* error_illegal_value_type. Producers SHOULD NOT attach one to text.  *)
(*                                                                     *)
(* State-machine flow:                                                 *)
(*   1. Whitespace from a number-body state fires ACT_to_number_outro *)
(*      → number_outro.  Strings enter string_outro_nosp after the    *)
(*      closing ""; whitespace transitions to string_outro.           *)
(*      Special numbers (nan, inf, ninf) are lexed as symbols and      *)
(*      take no inline unit (give the unit via the type annotation).   *)
(*   2. From number_outro / string_outro (whitespace already seen):   *)
(*        alpha / "_" / "$" / "%" / "(" / UTF-8 leader (0xC2–0xF4)     *)
(*        → ACT_inline_unit_intro                                      *)
(*   3. ACT_inline_unit_intro pushes the first byte and enters        *)
(*      inline_unit_body directly.                                     *)
(*   4. inline_unit_body accumulates unit characters (including ":"  *)
(*      as a valid mid-unit byte) until whitespace (→                 *)
(*      inline_unit_outro) or ";" (→ value_outro via finalize).      *)
(*                                                                     *)
(*   Inside an array element (in_array_element flag set),             *)
(*   ACT_inline_unit_intro immediately rejects with                    *)
(*   error_unexpected_input_byte.                                      *)
(*                                                                     *)
(* Semantic rule (enforced by validator):                              *)
(*   If a type-annotation unit was also given, the inline unit must    *)
(*   parse to the identical value_unit_t; mismatch → error_unit_mismatch. *)

inline-unit       = inline-unit-start , { inline-unit-char } ;

(* A currency component carries a mandatory "$" sigil (spec 1.0): "$USD",      *)
(* "$BTC", or prefixed "<prefix>[~]$EUR".  Bare codes are no longer currencies, *)
(* so a currency code can never collide with a physical-unit symbol.           *)
(* "(" lets an inline unit begin with a parenthesised group, e.g. "(m/s)/s",   *)
(* so the inline form accepts the same unit grammar as a type-annotation unit. *)
inline-unit-start = ALPHA | "_" | "$" | "%" | "(" | unit-lead-byte ;

inline-unit-char  = ALPHA | DIGIT | "_" | "$" | "%" | "+" | "-" | "." | "/" | ":" | "^" | "*" | "~"
                  | "(" | ")" | "'" | "[" | "]" | "{" | "}"
                  | unit-lead-byte | utf8-continuation ;

(* unit-lead-byte: the UTF-8 lead-byte range accepted inside a unit — the    *)
(* full 0xC2-0xF4, BROADER than utf8-leader (0xC3-0xF4).  Unlike identifier, *)
(* symbol, and reference token starts (where 0xC2 is rejected, see §12), a   *)
(* unit may begin with or contain a 0xC2-led code point: the µ prefix        *)
(* (U+00B5 = 0xC2 0xB5), the ° / °C units (U+00B0 = 0xC2 0xB0), and the      *)
(* "·" product separator (U+00B7 = 0xC2 0xB7).  The lexer maps the whole     *)
(* 0xC2-0xF4 range to ACT_inline_unit_intro / ACT_copy_inline_unit_byte,     *)
(* matching copy_type_byte in a type-annotation unit body.                   *)
unit-lead-byte    = (* byte in [0xC2, 0xF4] *) ;


(* ─────────────────────────────────────────────────────────────────  *)
(*  7. Symbols  (unquoted bare-word values in value position)         *)
(* ─────────────────────────────────────────────────────────────────  *)

(* A symbol starts with id-start and continues with sym-body-char.   *)
(* Differences from the identifier body action table:                 *)
(*   "=" is a hard error (not a state transition)                    *)
(*   "," / "]" / ";" terminate the symbol cleanly (not errors)       *)
(*   "." is a hard error (same as in identifier body)                *)

(* Reserved keywords: a symbol token whose text is exactly "null",   *)
(* "true", "false", "on", "off", "nan", "inf" or "ninf" is            *)
(* reclassified by the validator (see null-value / bool-value, §3,    *)
(* and special-number, §5) and is NOT delivered as a symbol.          *)
(* Any other bare word — including one that merely begins with a      *)
(* keyword, like "ontology" or "nullish" — remains a symbol.          *)
symbol        = id-start , {sym-body-char} ;

(* sym-body-char spans the same byte range as id-body-char; the       *)
(* difference is purely in the termination actions fired for          *)
(* "," (new_array_value), "]" (array_outro), and ";" (value_outro).  *)
sym-body-char = id-body-char ;


(* ─────────────────────────────────────────────────────────────────  *)
(*  8. References                                                      *)
(* ─────────────────────────────────────────────────────────────────  *)

(* The "&" sigil introduces a dotted path to another key.            *)
(* The stored string starts with "." and includes all dots:           *)
(* "&.foo.bar" stores ".foo.bar".                                     *)
(* At least one segment is required; "&" alone or "&." without a      *)
(* following id-start character is an error.                          *)
(* A reference is stored UNRESOLVED — the path is a string token; the *)
(* library never dereferences it, so the target need not exist and    *)
(* cycles are not detected. Resolution is the application's job.       *)

reference    = "&" , ref-segment , {ref-segment | ref-index} ;
ref-segment  = "." , id-start , {ref-body-char} ;

(* ref-index (spec 1.1): array indexing in a reference path, e.g.      *)
(* &.matrix[0][1].  The bytes are captured verbatim into the path      *)
(* string (references are stored unresolved); bvn_dom_lookup           *)
(* interprets them at the DOM layer.  Gated on a "#!bovnar 1.1"         *)
(* declaration — in a 1.0/unversioned document "[" in a reference is   *)
(* error_unexpected_input_byte.  The reference_index sub-state          *)
(* distinguishes a "]" that closes an index from one that closes an    *)
(* enclosing array (which ends the reference).                         *)
ref-index    = "[" , DIGIT , {DIGIT} , "]" ;

(* ref-body-char: same byte set as sym-body-char. A "." (0x2E)        *)
(* does NOT extend the body: it is ACT_copy_reference_dot, which      *)
(* starts a NEW ref-segment (advancing to reference_segment_intro,    *)
(* which REQUIRES an id-start next). A "." with no following          *)
(* id-start — a trailing or doubled dot — is                          *)
(* error_unexpected_input_byte.                                       *)
ref-body-char = sym-body-char ;


(* ─────────────────────────────────────────────────────────────────  *)
(*  9. Arrays                                                          *)
(* ─────────────────────────────────────────────────────────────────  *)

(* An array is one or more bracket-enclosed rows separated by "/":   *)
(*   .a = [1, 2, 3]/[4, 5, 6];                                       *)
(* "/" emits ev_array_dim_start; the next "[" opens the next row.         *)
(* Whitespace/comments are permitted on both sides of "/" (between the *)
(* closing "]" and "/", and between "/" and the next "[").            *)
(*                                                                    *)
(* The former adjacent-row syntax "[1,2][3,4]" and the comma-between- *)
(* rows form "[1,2],[3,4]" are hard errors at the assignment level.   *)
(* array_outro no longer accepts "[" directly; the only continuation  *)
(* for a further row is "/".                                          *)
(*                                                                    *)
(* Nested arrays are valid element values inside a row:               *)
(*   [[1,2],[3,4]] is a one-row outer array whose two elements are    *)
(*   the inner arrays [1,2] and [3,4].                                *)
(*                                                                    *)
(* Leading and trailing commas produce null elements:                 *)
(*   [,1,2,]  →  four elements: null, 1, 2, null.                   *)
(*                                                                    *)
(* Semantic constraints — two distinct tiers:                          *)
(*                                                                    *)
(*   a) Enforced by the LEXER/VALIDATOR (the streaming reader): the    *)
(*      "/"-separated dimension rows of a SINGLE array must all have   *)
(*      the same element count; a mismatch is                          *)
(*      error_array_row_size_mismatch (code 2).  The lexer checks each *)
(*      row as it closes, so the error fires at the earliest byte (the *)
(*      overshooting element, or the closing "]" of a short row).      *)
(*      The per-bracket-pair context save/restore mechanism keeps this *)
(*      check scoped to one array instance: a "/"-row width never      *)
(*      leaks across a "," boundary or into a sibling branch.          *)
(*        [1,2,3]/[4,5]   → error (one array's /-rows: 3 vs 2)         *)
(*        [[1,2]/[3,4,5]] → error (inner /-array's rows: 2 vs 3)       *)
(*                                                                    *)
(*   b) Enforced by the DOM BUILDER ONLY (bvn_dom_parse), NOT by the   *)
(*      streaming reader — `loads`/the SAX callbacks accept these and  *)
(*      deliver every element; only a DOM parse rejects them.  Array   *)
(*      elements must be HOMOGENEOUS (spec 1.0): the same kind         *)
(*      throughout -- and, for numbers in a bare array, the same UNIT, *)
(*      not merely the same dimension (corrected in spec 1.2) --       *)
(*      `,`-separated sibling sub-arrays must be                       *)
(*      rectangular (match in length), and sibling structs must share  *)
(*      the same shape.  A ragged sibling set is                       *)
(*      error_array_row_size_mismatch (code 2); a mix of element kinds *)
(*      (e.g. a scalar beside a sub-array) is                          *)
(*      error_array_element_type_mismatch (code 39); sibling structs   *)
(*      with differing shapes is error_struct_shape_mismatch (code 40).*)
(*        [[1,2],[3,4,5]] → DOM error (ragged sibling sub-arrays)      *)
(*        [1,[2,3]]       → DOM error (scalar mixed with a sub-array)  *)
(*        [{.x=1;},{.y=2;}] → DOM error (sibling structs, differing    *)
(*                            shapes)                                  *)
(*        [[1,2]/[3,4], [5,6]/[7,8]] → accepted (same-shape blocks)    *)
(*                                                                    *)
(*   c) Array nesting depth ≤ max_array_nesting (default 64, cap 255).*)

array        = array-row , {ws , "/" , ws , array-row} ;

array-row    = "[" , ws , [row-content] , ws , "]" ;

(* row-content is OPTIONAL: "[]" (nothing between the brackets) is an   *)
(* EMPTY row — zero elements, no ev_data. It is distinct from "[null]"  *)
(* (one null element) and "[,]" (two null elements). When present,      *)
(* row-content has at least one element position; leading / trailing /  *)
(* consecutive commas each expand to a null-value element. An empty row  *)
(* has width 0, a valid row width for /-dimension consistency.          *)
(* The productions, not this comment, settle "[]": a row of ONE element *)
(* needs a filled-elem, so "[]" and "[ ]" cannot also derive as one     *)
(* empty null-value. An element may be empty only beside a comma.       *)
(* A type annotation alone fills the slot: "[<uint:8>]" is one typed    *)
(* null, as "[<uint:8> ,]" is two elements.                              *)
row-content  = filled-elem
             | array-elem , ws , "," , ws , array-elem ,
               {ws , "," , ws , array-elem} ;

filled-elem  = type-annotation , ws , raw-value
             | "null"
             | present-value ;

(* array-elem has the same structure as a top-level value, except that an  *)
(* inline unit suffix is not permitted: it uses raw-value (no unit option), *)
(* not the value rule's scalar-with-unit alternative (see §6.5).            *)
array-elem   = [type-annotation , ws] , raw-value ;


(* ─────────────────────────────────────────────────────────────────  *)
(*  10. Structs  (nested key-value scopes)                            *)
(* ─────────────────────────────────────────────────────────────────  *)

(* A struct "{" … "}" contains zero or more assignments. "{}" is an    *)
(* empty struct (zero members). After "}" the struct behaves like any  *)
(* completed value: followed by ";" at the top level, or "," / "]" in  *)
(* array context.                                                      *)
(*                                                                    *)
(* Key uniqueness is a DOM-BUILDER constraint, NOT a grammar one: the   *)
(* lexer/validator (the streaming reader) accepts a repeated key and    *)
(* delivers BOTH assignments.  A DOM parse (bvn_dom_parse) is stricter — *)
(* a duplicate key within one scope (a struct, or the top-level         *)
(* document) is error_duplicate_struct_key.  The same key in different  *)
(* scopes is always unrelated.                                          *)
(* Nesting depth: struct_nesting_level ≤ max_struct_nesting           *)
(* (default 64 when the flag is 0; hard cap 255).                      *)
(* An unmatched "}" is error_illegal_struct_close.                    *)

struct       = "{" , ws , {assignment , ws} , "}" ;


(* ─────────────────────────────────────────────────────────────────  *)
(*  11. Octet streams  (binary-framed escape from the text layer)     *)
(* ─────────────────────────────────────────────────────────────────  *)

(* A NUL byte (0x00) appearing where a value is expected switches from*)
(* text mode to binary chunk mode.  The UTF-8 stream validator is     *)
(* suspended for the duration.                                        *)
(*                                                                    *)
(* Binary protocol (bvn_read_octet_stream):                           *)
(*   Tag 0x01 → chunk follows (2-byte LE length + data bytes)        *)
(*   Tag 0x00 → end of stream                                         *)
(*   Any other tag → error_octet_stream_out_of_sync                   *)
(*                                                                    *)
(* After ev_octet_stream_end the lexer enters octet_stream_outro,     *)
(* which accepts the same terminators as any other completed value.   *)

octet-stream = "\x00" , {os-chunk} , os-end ;

os-chunk     = "\x01" , os-length , os-data ;
os-end       = "\x00" ;

(* 16-bit little-endian length; 0x0000 encodes 65536 bytes.          *)
os-length    = byte , byte ;    (* little-endian uint16; 0x0000 → 65536 *)

os-data      = {byte} ;         (* exactly os-length bytes; not CF     *)


(* ─────────────────────────────────────────────────────────────────  *)
(*  12. Lexical primitives                                             *)
(* ─────────────────────────────────────────────────────────────────  *)

DIGIT        = "0" | "1" | "2" | "3" | "4"
             | "5" | "6" | "7" | "8" | "9" ;

ALPHA        = "A"|"B"|"C"|"D"|"E"|"F"|"G"|"H"|"I"|"J"|"K"|"L"|"M"
             | "N"|"O"|"P"|"Q"|"R"|"S"|"T"|"U"|"V"|"W"|"X"|"Y"|"Z"
             | "a"|"b"|"c"|"d"|"e"|"f"|"g"|"h"|"i"|"j"|"k"|"l"|"m"
             | "n"|"o"|"p"|"q"|"r"|"s"|"t"|"u"|"v"|"w"|"x"|"y"|"z" ;

(* utf8-leader here denotes the range used at identifier, symbol, and  *)
(* reference token-start and -body positions: 0xC3–0xF4.              *)
(* 0xC2 is the leader for U+0080–U+00BF.  It is explicitly overridden *)
(* to ACT_NONE at every identifier, symbol, and reference boundary,   *)
(* so code points U+0080–U+00BF cannot start or appear in those       *)
(* tokens.  0xC2 IS accepted inside quoted strings and type parameter  *)
(* bodies (copy_type_byte), where the full range 0xC2–0xF4 applies.  *)
utf8-leader      = (* byte in [0xC3, 0xDF] | [0xE0, 0xEF] | [0xF0, 0xF4] *) ;

utf8-continuation = (* byte in [0x80, 0xBF] *) ;

(* Printable ASCII: 0x20–0x7E *)
printable-byte = (* byte in [0x20, 0x7E] *) ;

(* High byte: any byte 0x80–0xFF, 0xC2 included -- a comment may hold *)
(* U+0080–U+00BF ("25 °C") though a key may not.  0xC0, 0xC1 and      *)
(* 0xF5–0xFF are structurally in this range but rejected by the       *)
(* parallel UTF-8 validator (error_invalid_utf8_byte).                *)
high-byte    = (* byte in [0x80, 0xFF] *) ;

byte         = (* any single byte 0x00–0xFF *) ;


(* ─────────────────────────────────────────────────────────────────  *)
(*  13. Constraints not expressible in context-free EBNF              *)
(* ─────────────────────────────────────────────────────────────────  *)

(*
  a) UTF-8 validity
     Every byte in the text layer (outside octet-stream regions) must
     form valid UTF-8 when fed through bvn_utf8_feed.  The UTF-8
     validator runs in parallel with the action state machine; a
     non-UTF-8 byte causes error_invalid_utf8_byte regardless of which
     lexer state the parser is in.  Overlong encodings and surrogate
     halves are rejected.

  b) BOM placement
     The UTF-8 BOM (EF BB BF) is legal only at byte offset 0.
     Before the first assignment:
       - a BOM inside any comment (the first comment line, a version
         directive line, or a later comment line) is
         error_invalid_byte_order_mark;
       - a BOM after leading whitespace, with no comment line before it,
         is error_invalid_byte_order_mark;
       - a BOM at the start of a line that follows a comment line or a
         version directive is error_unexpected_input_byte, as is a
         second BOM directly after the one at offset 0.
     Once the first assignment has begun:
       - EF BB BF inside a comment or a string is valid UTF-8 and is
         accepted as content;
       - EF BB BF between assignments is error_unexpected_input_byte.

  c) 0xC2 at token boundaries
     The byte 0xC2 (UTF-8 leader for U+0080–U+00BF) is mapped to
     ACT_NONE at identifier, symbol, and reference token-start and
     token-body positions, forbidding code points U+0080–U+00BF in
     those tokens.  0xC2 is accepted inside quoted strings and in type
     parameter bodies (copy_type_byte / type_body_outro).

  d) Special-number keywords
     The special floats nan / inf / ninf are bare reserved keywords (no
     sigil).  They are lexed as symbols and reclassified to numeric
     special values by the validator, like null / true / false / on /
     off; any other bare word (e.g. "infinity", "nans") stays a symbol.
     They take no inline unit — a unit is supplied via the annotation.

  e) String concatenation
     Adjacent string literals with only whitespace/comments between
     them are concatenated into a single token by the lexer.  The
     combined byte length is bounded by max_string_length (default
     UINT16_MAX = 65535 bytes).

  f) Number exponent in bare literals vs. quoted strings
     Bare number literals only accept "e"/"E" as exponent markers
     (exp_intro state).

  g) Array row-size consistency (LEXER/VALIDATOR — the streaming reader)
     The "/"-separated dimension rows of a single array must all have
     the same element count; the lexer checks each row as it closes, so
     the violation is reported at the earliest offending byte. The
     per-bracket-pair context save/restore mechanism keeps the /-row
     check scoped to one array instance.
     Violation: error_array_row_size_mismatch
       (e.g. [1,2,3]/[4,5], or [[1,2]/[3,4,5]]).

     Sibling homogeneity (DOM BUILDER ONLY — bvn_dom_parse; the streaming
     reader accepts these). Array elements must be the same kind throughout
     and, for numbers in a bare array, carry the same unit ([1 m, 2 ft]-shaped
     data, annotated element by element, is error_array_element_type_mismatch),
     `,`-separated sibling sub-arrays must be rectangular (match in length,
     so a bare array/matrix is rectangular), and sibling structs must share
     the same shape. A ragged sibling set is error_array_row_size_mismatch
     (e.g. [[1,2],[3,4,5]]); a mix of element kinds is
     error_array_element_type_mismatch (code 39, e.g. [1,[2,3]]); sibling
     structs with differing shapes is error_struct_shape_mismatch (code 40,
     e.g. [{.x=1;},{.y=2;}]). loads()/the SAX callbacks deliver such arrays
     unchanged; only a DOM parse rejects them.

  h) Array and struct nesting limits
     Array nesting (bracket depth): max_array_nesting (default 64 when
       the flag is 0; hard cap 255).
     Struct nesting: max_struct_nesting (default 64 when the flag is 0;
       hard cap 255).

  i) Type-annotation / value compatibility (enforced by validator)
     "utf8" requires a string value.
     "uint", "sint", "float", "float_fix", "float_dec" accept a number or
       string value; a quoted string allows special float notation.
     "utf8" and "bool" are parameterless: the lexer accepts the ":"
       and parameter bytes, but bvn_parse_type_annotation rejects any
       width, base, q, or unit parameter as error_illegal_value_type
       (e.g. "<utf8:8>" and "<utf8:,m>" are both errors).
     "uint" rejects negative number literals (error_value_out_of_range).
     Digit characters in the value must be valid for the declared base
       (error_digit_not_in_base).
     The numeric value must fit in the declared bit-width
       (error_value_out_of_range) -- for uint, sint, datetime and
       float_fix; the bare keywords nan / inf / ninf are exempt under
       every family.
     For "float": accepted widths are 0, 16, or any multiple of 32 up to
       BVN_FLOAT_MAX_PREC (32768); accepted bases are 10 (or absent) and
       16; any other base → error_illegal_value_type.
     For "float_fix": accepted widths are 0, 16, 32, 64, 128, 256;
       the base parameter (_N) is forbidden (error_illegal_value_type);
       the Q parameter (qN) must satisfy 0 ≤ Q < effective_width
       (error_illegal_value_type if violated);
       Q stored in value_type_spec_t.base; retrieved via bvn_effective_q.
     For "float_dec": accepted widths are 0, 16, 32, 64, 128, 256;
       the base parameter (_N) is forbidden (error_illegal_value_type);
       the Q parameter is not valid for float_dec.
     "float_fix" is never synthesised by the validator's default type
       synthesis. "float_dec" is synthesised in exactly one case -- a
       number carrying an inline currency unit, <float_dec:64,$XXX> --
       and otherwise requires an explicit annotation.
     "float" and "float_dec" values are NOT range- or precision-checked:
       a literal with more precision or a larger exponent than the
       declared width can hold is valid; the excess is the consumer's to
       round, and a consumer may deliver +/-infinity.
     An octet-stream value under a type annotation is accepted under
       EVERY family: the annotation is carried on the stream's events and
       is not checked against it (no error_type_value_mismatch).
       Producers SHOULD NOT write one.

  j) Identifier (key) non-empty
     The grammar requires at least one id-start character after ".".
     An empty key (".=") triggers error_empty_identifier.

  k) Struct closing brace without matching open
     A "}" seen when struct_nesting_level == 0 is
     error_illegal_struct_close.

  l) Comma in non-array context
     "," triggers ACT_new_array_value, which checks
     array_nesting_level > 0 and returns error_unexpected_input_byte
     if the condition is not met.  A stray "," at the top level is
     therefore an error.

  m) Octet stream out-of-sync
     Any byte other than 0x01 (chunk) or 0x00 (end) as the tag byte
     inside an octet stream causes error_octet_stream_out_of_sync.

  n) Error recovery (continue_on_error mode)
     When bvnr_read_flags_t.continue_on_error is set, any parse error
     in the text but one INSIDE A COMMENT invokes the on_error callback
     and then
     enters the resync state machine (an invalid UTF-8 sequence or a
     control byte in a comment is reported and counted, and the comment
     runs on to its line end: nothing is unwound and nothing is lost).
     The resync machine (states: resync, resync_dot, resync_string,
     resync_string_escape, resync_comment) skips
     bytes while tracking bracket and brace nesting depths, and resumes
     at whichever comes first: a ";" at the saved nesting depth, or the
     start of the next assignment -- a "." at that depth followed by a
     byte that can begin an identifier.  recovery_count
     (bvnr_reader_get_recovery_count) is incremented immediately when
     an error triggers entry into resync mode, not when resync
     completes.  EOF inside any resync state raises a final
     error_got_incomplete_bvnr_stream (delivered to on_error and
     returned by bvnr_reader_get_error); the original error that
     triggered resync was already delivered to on_error earlier, at
     the point of fault.

  o) Inline unit suffix
     An inline-unit follows a scalar number or string in non-array
     context only.  A special-number keyword (nan, inf, ninf) takes NO
     inline unit — it is lexed as a symbol, and symbols accept no inline
     suffix; "inf m/s" is error_unexpected_input_byte.  Supply a unit
     for a special value through the type annotation (<float:64,m/s> inf).
     At least one whitespace character MUST separate the value literal
     from the unit.  The only accepted form is "value unit"; any other
     form (no separator or colon) is a hard error.

     State-machine flow:
       1. Whitespace from number-body states (zero_intro, copy_number_byte,
          fraction_intro, copy_fraction_byte, copy_exp_byte) fires
          ACT_to_number_outro → number_outro.  Strings enter
          string_outro_nosp after the closing quote; whitespace
          transitions to string_outro.  A comment's terminating newline
          also counts as whitespace (comment_outro promotes string_outro_nosp
          → string_outro).
       2. From number_outro / string_outro (at least one ws consumed):
            alpha / "_" / "$" / "%" / "(" / UTF-8-leader (0xC2-0xF4)
            → ACT_inline_unit_intro
       3. ACT_inline_unit_intro pushes the first byte and enters
          inline_unit_body directly.
       4. inline_unit_body accumulates unit characters (including ":"
          as a valid mid-unit byte) until whitespace (→
          inline_unit_outro) or ";" (→ value_outro via finalize).

     Inside an array element (in_array_element flag set),
     ACT_inline_unit_intro immediately rejects with
     error_unexpected_input_byte.

     The validator's bvn_val_receive calls bvn_parse_unit on the
     inline buffer and compares the result to v->parsed_unit with
     bvn_unit_equal -- the multiset of components, so the comparison
     is order-insensitive and "m*s" agrees with "s*m".  When
     has_annotation_unit is true and the comparison
     fails, the validator returns error_unit_mismatch.
     When no annotation unit was present, the inline unit is silently
     adopted as the effective unit.
*)

<!-- ==================== Conformance (https://www.bovnar.io/doc/13_bovnar_conformance.md) ==================== -->

# Bovnar — Conformance Test Tool

> **Spec version:** 1.2
> **Status:** Normative — conformance protocol `bvnr-conformance-v1`
> **Scope:** The conformance corpus, the IUT protocol, and how to validate a third-party implementation.

---

## Table of Contents

1. [Purpose](#1-purpose)
2. [Quick Start](#2-quick-start)
    - 2.1 [Self-test (verify the reference implementation)](#21-self-test-verify-the-reference-implementation)
    - 2.2 [Testing an external implementation](#22-testing-an-external-implementation)
    - 2.3 [Filtering by group](#23-filtering-by-group)
3. [Architecture](#3-architecture)
    - 3.1 [Validation tiers](#31-validation-tiers)
4. [Building](#4-building)
5. [Running](#5-running)
    - 5.1 [Command-line options](#51-command-line-options)
    - 5.2 [Examples](#52-examples)
    - 5.3 [Test groups](#53-test-groups)
6. [IUT Protocol](#6-iut-protocol)
    - 6.1 [Communication model](#61-communication-model)
    - 6.2 [Success response](#62-success-response)
    - 6.3 [Error response](#63-error-response)
    - 6.4 [Arguments](#64-arguments)
7. [Writing a Compliant IUT Adapter](#7-writing-a-compliant-iut-adapter)
    - 7.1 [Event log format reference](#71-event-log-format-reference)
    - 7.2 [Field details](#72-field-details)
    - 7.3 [TOKEN_TYPE values for DATA lines](#73-token_type-values-for-data-lines)
    - 7.4 [Effective width and base](#74-effective-width-and-base)
    - 7.5 [Example traces](#75-example-traces)
8. [Test Case Corpus](#8-test-case-corpus)
    - 8.1 [Coverage summary](#81-coverage-summary)
9. [Output Format (TAP)](#9-output-format-tap)
10. [Extending the Corpus](#10-extending-the-corpus)
11. [CMake Integration](#11-cmake-integration)

- [See also](#see-also)

---

## 1. Purpose

The Bovnar Conformance Test Tool (`bvnr_conformance`) verifies that any
implementation of the Bovnar serialization format produces correct, spec-
compliant behaviour.  The reference implementation (this repository) is
used both as the test driver and as the oracle against which candidate
implementations are judged.

Three use modes are supported:

| Mode | Description |
|------|-------------|
| **Self-test** (default) | Runs the corpus against the reference libbvnr directly |
| **IUT test** (`--iut`) | Invokes an external binary and compares its output to the reference |
| **Feed equivalence** (`--feed`) | Feeds every case to the push-fed reader (`bvnr_read_feed`) cut at every byte, and byte by byte, and checks it reads exactly as `bvnr_read` reads it whole |

---

## 2. Quick Start

### 2.1 Self-test (verify the reference implementation)

```sh
cd build
cmake --build . --target bvnr_conformance
ctest -R bvnr_conformance_self --output-on-failure
```

### 2.2 Testing an external implementation

```sh
# Build your adapter (see Section 7)
cc -o my_impl_adapter my_adapter.c -lmy_bovnar

# Run the conformance suite against it
./tests/bvnr_conformance --iut ./my_impl_adapter
```

### 2.3 Filtering by group

```sh
./tests/bvnr_conformance --filter units
./tests/bvnr_conformance --iut ./my_impl_adapter --filter arrays
```

---

## 3. Architecture

```
┌───────────────────────────────────────────────────────────────────┐
│                       bvnr_conformance                            │
│                                                                   │
│  Test corpus (470 cases) ──→ for each test case:                │
│                                                                   │
│  Self-test mode:                    IUT mode:                     │
│  ┌─────────────────────┐            ┌──────────────────────────┐  │
│  │ ref_parse()         │            │ ref_parse() → event log  │  │
│  │  (uses libbvnr)     │            │ fork/exec IUT binary     │  │
│  │ verify error codes  │            │ compare IUT stdout to    │  │
│  │ verify key presence │            │ reference event log      │  │
│  └─────────────────────┘            └──────────────────────────┘  │
│                                                                   │
│  Output: TAP v14 stream                                           │
└───────────────────────────────────────────────────────────────────┘
```

The reference implementation is the single authoritative oracle.  An
implementation is conformant when its IUT adapter produces output
byte-for-byte identical to the reference for every test case.

### 3.1 Validation tiers

Most cases exercise the **streaming reader** (`bvnr_read`): the lexer,
validator, and the `on_verified` event stream the IUT protocol mirrors. A
smaller set — the `homogeneity` group — exercises the **materialised-document
(DOM) tier** (`bvn_dom_parse`), because the spec-1.0 array-homogeneity (§7.4),
struct-shape, and duplicate-key (§8.1) rules are enforced *above* the lexer and
are therefore unreachable through the streaming `on_verified` callback. These
DOM-tier cases run in **self-test mode only**; under `--iut` they are reported
as `# SKIP`, since IUT protocol v1 is streaming-only and cannot express a
DOM-tier check. An implementation that targets full spec-1.0 conformance must
still enforce these rules in its document/tree API; the self-test cases pin the
reference behaviour and its frozen error codes (39, 40, 41).

---

## 4. Building

The conformance tool is built automatically as part of the standard CMake
build.  Both the main driver and the reference IUT adapter are compiled:

```sh
mkdir build && cd build
cmake ..
cmake --build .
```

Targets produced:

| Target | Binary | Purpose |
|--------|--------|---------|
| `bvnr_conformance` | `tests/bvnr_conformance` | Main conformance driver |
| `bvnr_conformance_iut` | `tests/bvnr_conformance_iut` | Reference IUT adapter |

The conformance tool links against `bvnr_static`.  No external
dependencies beyond libc and POSIX are required.

---

## 5. Running

### 5.1 Command-line options

```
bvnr_conformance [OPTIONS]

Options:
  --iut <binary>   Path to the IUT binary to test
  --filter <group> Run only cases in the specified group
  --feed           Instead of the expectations, check that every case fed
                   to bvnr_read_feed in pieces (cut at every byte, and byte
                   by byte) reads exactly as bvnr_read reads it whole
  --list           List all test case IDs and descriptions
  --verbose        Print additional diagnostic information
  --help, -h       Show this help and exit
```

### 5.2 Examples

```sh
# Run self-test (reference vs. reference)
./tests/bvnr_conformance

# Run with verbose output
./tests/bvnr_conformance --verbose

# List all test cases
./tests/bvnr_conformance --list

# Run only unit-related tests
./tests/bvnr_conformance --filter units

# Test an external IUT adapter
./tests/bvnr_conformance --iut ./my_adapter

# Test external adapter, units group only
./tests/bvnr_conformance --iut ./my_adapter --filter units
```

### 5.3 Test groups

| Group | Description |
|-------|-------------|
| `encoding` | UTF-8, BOM handling, byte class enforcement, truncated streams |
| `limits` | Lexer token-length caps |
| `version` | `#!bovnar M.N` directive parsing and strictness |
| `identifiers` | Key syntax, length limits |
| `strings` | Escape sequences, concatenation, length limits |
| `datetime` | Timestamp family: epochs, range, version gating, ISO-8601 literals (spec 1.1) |
| `numbers` | Integer and float literals, scientific notation |
| `types` | All type families, widths, bases |
| `default_synthesis` | Automatic type annotation inference |
| `symbols` | Bare-word values |
| `references` | `&.path` syntax |
| `null_values` | Null scalars and null array elements |
| `structs` | Nesting, empty structs, struct arrays |
| `arrays` | 1D, 2D, nested, typed, null elements |
| `octet_streams` | Binary chunk protocol |
| `units` | SI, IEC, compound, inline suffix, mismatch errors |
| `special_numbers` | `nan`, `inf`, `ninf` |
| `roundtrip` | Multi-assignment sequences |
| `recovery` | Error-resync behaviour |
| `comments` | Comment parsing |
| `whitespace` | Whitespace tolerance |
| `homogeneity` | DOM-tier: array homogeneity, struct shape, key uniqueness (self-test only) |
| `unit_profile` | The seven profile notations (spec 1.2): the three outcomes and their error codes, the version gate, annotations, cross-vocabulary agreement |

---

## 6. IUT Protocol

The **IUT (Implementation Under Test) Protocol** version 1
(`bvnr-conformance-v1`) defines the interface between the conformance
driver and a candidate implementation's adapter binary.

### 6.1 Communication model

```
conformance driver                    IUT adapter
       │                                   │
       │  fork, exec IUT binary            │
       │ ─────────────────────────────────→│
       │                                   │
       │  Bovnar text (stdin)              │
       │ ─────────────────────────────────→│
       │                                   │
       │       closes stdin (EOF)          │
       │ ─────────────────────────────────→│
       │                                   │
       │  event log or error (stdout)     │
       │ ←─────────────────────────────── │
       │                                   │
       │  exit(0) or exit(1)              │
       │ ←─────────────────────────────── │
```

### 6.2 Success response

The IUT must:

1. Exit with code **0**.
2. Write the conformance event log to stdout with **no trailing garbage**.

The event log is a sequence of lines, one event per line, in the order
the events were received from the `on_verified` callback. One event has no
line: `ev_stream_end`, which closes every successful parse and so
distinguishes nothing — the exit code already says the stream ended cleanly.

### 6.3 Error response

The IUT must:

1. Exit with code **non-zero** (typically 1).
2. Write exactly one line to stdout: `ERROR <code_name>` followed by `\n`.

Where `<code_name>` is the value returned by `bvn_error_to_string()` for
the first error encountered, e.g. `value_out_of_range`.

### 6.4 Arguments

The Bovnar text arrives on stdin, but a case that overrides a reader limit
passes the override on the adapter's **command line**. Each option is followed
by one decimal value, and is present only when the case overrides that limit:

| Option | Limit (`bvnr_read_flags_t`) |
|--------|-----------------------------|
| `--max-id <N>` | `max_identifier_length` |
| `--max-str <N>` | `max_string_length` |
| `--max-num <N>` | `max_number_length` |
| `--max-sym <N>` | `max_symbol_length` |
| `--max-ref <N>` | `max_reference_length` |
| `--max-snest <N>` | `max_struct_nesting` |
| `--max-anest <N>` | `max_array_nesting` |
| `--max-aitems <N>` | `max_array_items` |

An adapter MUST apply every option it is given and MUST ignore an option it
does not know. An adapter that reads stdin and ignores its arguments fails the
eight cases that pin a limit (`ID-011`, `STR-015`, `NUM-015`, `SYM-006`,
`REF-004`, `STU-007`, `ARR-012`, `ARR-013`). No other state is passed: every
case runs with recovery off and with the default, lenient version handling.

---

## 7. Writing a Compliant IUT Adapter

The file `tests/bvnr_conformance_iut.c` is the reference IUT adapter.
It uses the reference libbvnr and is intended both for self-testing and
as a template for third-party implementors.

A minimal conforming adapter must:

1. **Read all of stdin** into a buffer.
2. **Apply the limit options** on its command line (§6.4).
3. **Parse the buffer** using the implementation under test.
4. **Collect events** from the `on_verified` callback.
5. **Emit the event log** to stdout on success.
6. **Emit** `ERROR <code_name>\n` to stdout and exit non-zero on failure.

### 7.1 Event log format reference

Each event maps to one line.  Text fields use `\xNN` escaping for any
byte outside the printable ASCII range `0x20–0x7E` or for the backslash
character `0x5C`.  The two hex digits are **lowercase** (`\x09`, `\xc3`,
`\x5c`) — the comparison is byte for byte, so `\xC3` does not match.

```
STREAM_START
ASSIGNMENT_START <key>
TYPE_ANN_START <ann>
TYPE_FAMILY <ann>
TYPE_PARAM_WIDTH <N>
TYPE_PARAM_BASE <N>
TYPE_PARAM_Q <N>
TYPE_PARAM_UNIT <unit>
TYPE_ANN_END <ann>
DATA <token_type> <value>
STRUCT_START
STRUCT_END
ARRAY_ROW_START
ARRAY_ROW_END
ARRAY_DIM_START
OCTET_STREAM_START
OCTET_STREAM_END
```

### 7.2 Field details

| Line | Fields | Notes |
|------|--------|-------|
| `STREAM_START` | — | Always first |
| `ASSIGNMENT_START <key>` | key: raw key bytes, safe-escaped | |
| `TYPE_ANN_START <ann>` | ann: the **whole annotation body**, safe-escaped — the text between `<` and `>` verbatim, e.g. `float:64,m/s`, not `float`. It is the bare family keyword only when the annotation was *synthesised* for an untyped value (see below) | |
| `TYPE_FAMILY <ann>` | Same bytes as TYPE_ANN_START | |
| `TYPE_PARAM_WIDTH <N>` | N: effective width (0 → 64) | Emitted when the annotation carries a width, and for every synthesised annotation except `utf8` and `bool` (table below). `<float>` names no width and produces no such line |
| `TYPE_PARAM_BASE <N>` | N: effective base (0 → 10) | Emitted only when the annotation **states** a base (`_16`), or was synthesised for a `uint`, `sint` or `float` — not for a synthesised `datetime` or currency `float_dec`. `<float:64,m/s>` names no base and produces no such line — the events mirror the parameters actually present |
| `TYPE_PARAM_Q <N>` | N: Q parameter | Only for `float_fix` |
| `TYPE_PARAM_UNIT <unit>` | unit: unit string, safe-escaped | Emitted when the annotation carries a unit (a written `no_unit` included), and for a synthesised `uint`, `sint`, `float` or currency `float_dec` (table below). A `datetime`'s **epoch name** travels in this line too (`TYPE_PARAM_UNIT tai`) — but only an epoch other than `unix`. The unix epoch is the implicit one: written (`<datetime:64,unix>`) or defaulted (`<datetime:64>`, or a synthesised datetime) it produces **no** `TYPE_PARAM_UNIT` line. A datetime is not a numeric type; the epoch shares the unit parameter slot because it occupies the same position in the grammar |
| `TYPE_ANN_END <ann>` | Same bytes as TYPE_ANN_START | |
| `DATA <token_type> <value>` | token_type: see below; value: safe-escaped | The single space after the token type is always written, also when the value is empty: a null is the line `DATA null ` — with its trailing space — and an empty string is `DATA string ` |
| `STRUCT_START` | — | |
| `STRUCT_END` | — | |
| `ARRAY_ROW_START` | — | |
| `ARRAY_ROW_END` | — | |
| `ARRAY_DIM_START` | — | Emitted between `/`-separated rows |
| `OCTET_STREAM_START` | — | |
| `OCTET_STREAM_END` | — | |

**Synthesised annotations.** A value written without an annotation gets one
synthesised for it (doc/03 §10). Its `TYPE_ANN_START`, `TYPE_FAMILY` and
`TYPE_ANN_END` lines carry the bare family keyword, and the lines between them
depend on the family:

| Untyped value | Annotation body | Lines between `TYPE_FAMILY` and `TYPE_ANN_END` |
|---------------|-----------------|--------------------------------------------------|
| `42` | `uint` | `TYPE_PARAM_WIDTH 64`, `TYPE_PARAM_BASE 10`, `TYPE_PARAM_UNIT no_unit` |
| `-5` | `sint` | `TYPE_PARAM_WIDTH 64`, `TYPE_PARAM_BASE 10`, `TYPE_PARAM_UNIT no_unit` |
| `2.5` | `float` | `TYPE_PARAM_WIDTH 64`, `TYPE_PARAM_BASE 10`, `TYPE_PARAM_UNIT no_unit` |
| `5 m` (inline unit) | `uint` | `TYPE_PARAM_WIDTH 64`, `TYPE_PARAM_BASE 10`, `TYPE_PARAM_UNIT m` |
| `5 $USD` (inline currency) | `float_dec` | `TYPE_PARAM_WIDTH 64`, `TYPE_PARAM_UNIT $USD` — no base line |
| `2026-06-15` (ISO-8601 literal, spec 1.1) | `datetime` | `TYPE_PARAM_WIDTH 64` only — the unix epoch is implicit |
| `"hello"` | `utf8` | none |
| `true`, `false` | `bool` | none |
| `null`, a symbol, a reference | — | no annotation lines at all: the `DATA` line follows `ASSIGNMENT_START` directly |

### 7.3 TOKEN_TYPE values for DATA lines

| Token type | String |
|------------|--------|
| `token_is_number` | `number` |
| `token_is_string` | `string` |
| `token_is_symbol` | `symbol` |
| `token_is_reference` | `reference` |
| `token_is_array_number` | `array_number` |
| `token_is_array_string` | `array_string` |
| `token_is_null_value` | `null` |
| `token_is_octet_stream` | `octets` |
| `token_is_bool` | `bool` |

For `octets` token type, the value field is `<N> bytes` (decimal byte
count, then a space, then the literal string `bytes`), not the raw
binary data.

### 7.4 Effective width and base

- **Effective width**: if the stored width is 0, emit `64`.
  Use `bvn_effective_width(value_type)`.
- **Effective base**: if the stored base is 0, emit `10`.
  Use `bvn_effective_base(value_type)`.

These rules ensure that untyped values synthesised to `uint:64,_10`
produce `TYPE_PARAM_WIDTH 64` and `TYPE_PARAM_BASE 10`, matching the
reference output exactly.

### 7.5 Example traces

**Input:** `.x = 42;`

```
STREAM_START
ASSIGNMENT_START x
TYPE_ANN_START uint
TYPE_FAMILY uint
TYPE_PARAM_WIDTH 64
TYPE_PARAM_BASE 10
TYPE_PARAM_UNIT no_unit
TYPE_ANN_END uint
DATA number 42
```

**Input:** `.s = "hello";`

```
STREAM_START
ASSIGNMENT_START s
TYPE_ANN_START utf8
TYPE_FAMILY utf8
TYPE_ANN_END utf8
DATA string hello
```

**Input:** `.v = <float:64,m/s> 9.81;`

```
STREAM_START
ASSIGNMENT_START v
TYPE_ANN_START float:64,m/s
TYPE_FAMILY float:64,m/s
TYPE_PARAM_WIDTH 64
TYPE_PARAM_UNIT m/s
TYPE_ANN_END float:64,m/s
DATA number 9.81
```

Note what an **explicit** annotation does differently from the synthesised one
above, because this is where an adapter is most likely to diverge: the three
`TYPE_ANN_*`/`TYPE_FAMILY` lines carry the annotation body verbatim rather than
the family keyword, and there is no `TYPE_PARAM_BASE` line at all — the source
named no base, and the event stream mirrors the parameters that are there.

**Input:** `#!bovnar 1.1` + `.t = <datetime:64,tai> 1000;` (the datetime family is spec-1.1 gated, so the directive is required)

```
STREAM_START
ASSIGNMENT_START t
TYPE_ANN_START datetime:64,tai
TYPE_FAMILY datetime:64,tai
TYPE_PARAM_WIDTH 64
TYPE_PARAM_UNIT tai
TYPE_ANN_END datetime:64,tai
DATA number 1000
```

The epoch arrives as `TYPE_PARAM_UNIT`, not as a parameter of its own.

**Input:** `#!bovnar 1.1` + `.t = <datetime:64,unix> 1000;` (the unix epoch, written out)

```
STREAM_START
ASSIGNMENT_START t
TYPE_ANN_START datetime:64,unix
TYPE_FAMILY datetime:64,unix
TYPE_PARAM_WIDTH 64
TYPE_ANN_END datetime:64,unix
DATA number 1000
```

The unix epoch is the implicit one, so it produces no `TYPE_PARAM_UNIT` line
even when the annotation names it. The annotation body still carries it
verbatim; an adapter that emits `TYPE_PARAM_UNIT unix` here fails the byte
comparison.

**Input:** `.x = ok;` (symbol)

```
STREAM_START
ASSIGNMENT_START x
DATA symbol ok
```

**Input:** `.a = [1, 2];`

```
STREAM_START
ASSIGNMENT_START a
ARRAY_ROW_START
TYPE_ANN_START uint
TYPE_FAMILY uint
TYPE_PARAM_WIDTH 64
TYPE_PARAM_BASE 10
TYPE_PARAM_UNIT no_unit
TYPE_ANN_END uint
DATA array_number 1
DATA array_number 2
ARRAY_ROW_END
```

Inside an array a synthesised annotation is emitted only when the element's
synthesised type differs from the type currently in force — at the first
element, and again at each change. It is not repeated per element or per row
(the second row of `[1, 2]/[3, 4]` carries no annotation lines at all, while
`[1, 2.5, 3]` carries three blocks); an explicit element annotation is always
emitted.

**Input:** `.x = <uint:8> 999;` (error — value out of range)

```
ERROR value_out_of_range
```
(exit code 1)

---

## 8. Test Case Corpus

The corpus is embedded in `tests/bvnr_conformance.c`.  Each case
specifies:

| Field | Description |
|-------|-------------|
| `id` | Unique identifier, e.g. `TYP-019` (`ENC-001`…`ENC-014` for the `encoding` group) |
| `group` | Group name for `--filter` |
| `description` | Human-readable description |
| `input` | Bovnar text (or binary) to parse |
| `expect` | `CF_VALID` or `CF_ERROR` |
| `expected_error` | Error code for `CF_ERROR` cases |
| `continue_on_error` | Whether the reader should resync |
| `max_*` | Limit overrides (0 = use defaults) |
| `expect_key` | Optional: key name expected in event log |

### 8.1 Coverage summary

| Group | Cases | What is tested |
|-------|-------|---------------|
| `encoding` | 14 | UTF-8 validity, BOM placement, byte classes, truncated streams |
| `limits` | 4 | Lexer token-length caps at both sides of the boundary |
| `version` | 13 | `#!bovnar M.N` directive: valid, malformed, strictness (spec 1.1) |
| `identifiers` | 11 | Syntax, body characters, length limits |
| `strings` | 33 | Escapes, concatenation, UTF-8, limits |
| `datetime` | 54 | Timestamp family: epochs, signed range, gating, ISO-8601 literals (spec 1.1) |
| `numbers` | 16 | Integer, float, scientific, special numbers |
| `types` | 71 | All seven type families, widths, bases, errors, and where whitespace may fall inside an annotation |
| `default_synthesis` | 8 | Auto-type inference rules |
| `symbols` | 6 | Bare-word values and limits |
| `references` | 10 | Dotted paths, array indexing (spec 1.1), limits |
| `null_values` | 5 | Null in all positions |
| `structs` | 7 | Nesting, empty, unmatched braces |
| `arrays` | 19 | 1D, 2D, nested, typed, null, limits, /-row size consistency |
| `octet_streams` | 4 | Single/multi-chunk, sync errors |
| `units` | 91 | SI/IEC prefixes, compact prefix form, compound, inline, multi-digit exponents and their bounds, temperature differences, errors |
| `unit_profile` | 59 | The seven profile notations: the three outcomes and their error codes, annotations, the decade fold, the one native unit whose error code the profile moved, and cross-vocabulary agreement — each agreement case is an annotation in one notation against an inline unit in another, so it passes only if both spellings produced the same unit |
| `special_numbers` | 5 | `nan`, `inf`, `ninf` |
| `roundtrip` | 5 | Multi-assignment correctness |
| `recovery` | 2 | Error-resync: valid data after error |
| `comments` | 6 | Comment styles |
| `whitespace` | 4 | Whitespace tolerance |
| `homogeneity` | 23 | DOM-tier: array homogeneity (§7.4 — same UNIT, not merely the same dimension), struct shape, key uniqueness (§8.1) — self-test only |
| **Total** | **470** | |

---

## 9. Output Format (TAP)

The tool emits **TAP version 14** (Test Anything Protocol), which is consumed
natively by CTest and many CI systems. Each case **group** is a TAP 14 *subtest*:
a 4-space-indented child stream of the individual cases, a trailing child plan,
and a leading `# Subtest:` comment, rolled up into one parent test point. The
parent plan therefore counts the groups (currently 23), not the 470 cases.

```
TAP version 14
1..23
# Subtest: encoding
    ok 1 - [ENC-001] empty stream
    ok 2 - [ENC-002] UTF-8 BOM at byte 0
    not ok 3 - [ENC-003] UTF-8 BOM after first comment
      ---
      message: expected error unexpected_input_byte but parse succeeded
      ...
    1..14
not ok 1 - encoding
# Subtest: limits
    ...
ok 2 - limits
# Subtest: version
    ok 1 - [VER-001] directive declaring the current spec version
    ...
    1..13
ok 3 - version
```

The closing summary line (`# All 470 conformance tests passed`, or the count of
failures) goes to standard error, so it is not part of the TAP stream.

A group's parent point is `not ok` if any of its cases failed. Exit code is **0**
when all tests pass, **1** when any test fails. (`--filter <group>` restricts the
run to a single group; the parent plan is then `1..1`.)

---

## 10. Extending the Corpus

To add a new conformance test, add an entry to the `g_cases[]` array in
`tests/bvnr_conformance.c` using one of the provided macros:

```c
/* Valid input — no expected error */
VALID("GRP-NNN", "group_name", "description of the test",
      ".input = value;"),

/* Valid input — verify a specific key and value appear */
VALID_KEY("GRP-NNN", "group_name", "description",
          ".key = value;", "key", "value"),

/* Error — no special limits */
ERROR_CASE("GRP-NNN", "group_name", "description",
           ".bad = <uint:8> 999;",
           error_value_out_of_range),

/* Error — with continue_on_error (resync mode) */
ERROR_CONT("GRP-NNN", "group_name", "description",
           ".a = 1; .bad = <uint:8> 999; .b = 2;",
           error_value_out_of_range),

/* Error — with custom limits (max_id, max_str, max_num,
                               max_struct_nesting, max_array_nesting,
                               max_array_items) */
ERROR_LIM("GRP-NNN", "group_name", "description",
          ".long_name = 1;",
          error_identifier_too_long,
          4 /*max_id*/, 0, 0, 0, 0, 0),

/* Materialised-document (DOM) tier — validated via bvn_dom_parse instead of
   the streaming reader; runs in self-test mode only (skipped under --iut).
   Use for the spec-1.0 homogeneity / struct-shape / duplicate-key rules. */
DOM_VALID("GRP-NNN", "group_name", "description",
          ".a = [1, 2.5, 3];"),
DOM_ERROR("GRP-NNN", "group_name", "description",
          ".a = [1, \"two\"];",
          error_array_element_type_mismatch),
```

After editing, rebuild:

```sh
cmake --build build --target bvnr_conformance
```

---

## 11. CMake Integration

Three CTest tests are registered:

| CTest name | What it runs |
|------------|-------------|
| `bvnr_conformance_self` | Conformance suite against the reference implementation |
| `bvnr_conformance_iut_self` | Conformance suite using the IUT adapter as the external binary |
| `bvnr_conformance_feed` | Feed equivalence (`--feed`): every case through the push-fed reader |

All three are in the `conformance` label group and can be run with:

```sh
ctest -L conformance --output-on-failure
```

The `bvnr_conformance_iut_self` test validates the IUT adapter itself:
it must produce bit-for-bit identical output to the internal reference
path for every valid test case.

To run all tests including conformance:

```sh
ctest --output-on-failure
```

---

## See also

- [Specification](03_bovnar_spec.md) — the behaviour the corpus tests
- [EBNF Grammar](12_bovnar.ebnf) — the grammar an implementation under test must accept
- [Read & Write API](08_bovnar_readwrite_api.md) — the reference reader and writer the adapter drives
- [Unit & Currency Reference](05_bovnar_unit_system.md) — the unit table a conforming implementation needs

---

*End of Bovnar — Conformance Test Tool (Bovnar spec 1.2).*

<!-- ==================== Rust bindings (https://www.bovnar.io/doc/14_bovnar_rust_bindings.md) ==================== -->

# Bovnar — Rust Bindings

> **Spec version:** 1.2
> **Status:** Reference — the Rust crate as implemented
> **Scope:** Installation, the high-level and streaming APIs, the DOM, `Quantity` and the exact numbers, JSON conversion, and the thread rules.

Rust bindings for the **Bovnar (BVNR)** typed serialisation library (library
2.1.0, format spec 1.2).

The crate is a thin wrapper around the C library: `build.rs` compiles the C
sources into the crate, so there is no system library to install and nothing is
looked up at run time. One module is not a wrapper: `bovnar::codec`, the
incremental framing codec for live streams, is pure Rust
([§4.8](#48-live-streams-the-framing-codec-bovnarcodec)). The API stays close to the C library's own shapes:
`parse` / `to_string` over a dynamic `Value`, `Reader` callbacks, `Writer` calls
and a random-access DOM. There is no serde integration, no builder API for
values and no typed deserialisation; the DOM has the C library's node builder
([§12.5](#125-building-a-tree-newnode)). Where Rust's types can say more at
no cost, they do: the C enums are Rust enums, and an error carries the
library's error kind.

---

## Table of Contents

1. [Requirements](#1-requirements)
2. [Installation](#2-installation)
3. [Building the C library](#3-building-the-c-library)
    - 3.1 [WebAssembly (`wasm32-wasip2`)](#31-webassembly-wasm32-wasip2)
    - 3.2 [Unit-profile features](#32-unit-profile-features)
    - 3.3 [How the published crate is made](#33-how-the-published-crate-is-made)
4. [Quick-start](#4-quick-start)
    - 4.1 [High-level API (`parse` / `to_string`)](#41-high-level-api-parse--to_string)
    - 4.2 [SAX-style streaming reader](#42-sax-style-streaming-reader)
    - 4.3 [Collecting events](#43-collecting-events)
    - 4.4 [DOM (random-access) API](#44-dom-random-access-api)
    - 4.5 [Low-level writer](#45-low-level-writer)
    - 4.6 [Streaming / framing (`bovnar::stream`)](#46-streaming--framing-bovnarstream)
    - 4.7 [Files and I/O streams (`std::io::Read` / `Write`)](#47-files-and-io-streams-stdioread--write)
    - 4.8 [Live streams: the framing codec (`bovnar::codec`)](#48-live-streams-the-framing-codec-bovnarcodec)
    - 4.9 [Live documents through C (`bovnar::live`)](#49-live-documents-through-c-bovnarlive)
    - 4.10 [Canonical form](#410-canonical-form)
5. [Unit helpers](#5-unit-helpers)
    - 5.1 [The unit-profile notations (spec 1.2)](#51-the-unit-profile-notations-spec-12)
    - 5.2 [Extended unit functions](#52-extended-unit-functions)
    - 5.3 [`UnitFlags`](#53-unitflags)
    - 5.4 [`Prefix`](#54-prefix)
    - 5.5 [Inline unit suffix](#55-inline-unit-suffix)
    - 5.6 [`UnitPolicy` — validation and conversion without a callback](#56-unitpolicy--validation-and-conversion-without-a-callback)
    - 5.7 [Building a `Unit`](#57-building-a-unit)
    - 5.8 [Validators and literal helpers](#58-validators-and-literal-helpers)
6. [`Quantity`](#6-quantity)
    - 6.1 [Construction](#61-construction)
    - 6.2 [Fields and methods](#62-fields-and-methods)
    - 6.3 [Exact numbers (`Decimal`, `BigRational`, `BvnFloat`)](#63-exact-numbers-decimal-bigrational-bvnfloat)
    - 6.4 [`to_string()` integration](#64-to_string-integration)
    - 6.5 [Civil time and calendars](#65-civil-time-and-calendars)
7. [`write_array` helper](#7-write_array-helper)
8. [JSON conversion](#8-json-conversion)
9. [Currency helpers](#9-currency-helpers)
10. [`Reader` reference](#10-reader-reference)
    - 10.1 [Construction](#101-construction)
    - 10.2 [`read_mem`](#102-read_mem)
    - 10.3 [`ReadOptions`](#103-readoptions)
    - 10.4 [`EventData` and `EventPayload`](#104-eventdata-and-eventpayload)
    - 10.5 [`iter_mem`](#105-iter_mem)
    - 10.6 [Error-state accessors](#106-error-state-accessors)
11. [`Writer` reference](#11-writer-reference)
    - 11.1 [Construction](#111-construction)
    - 11.2 [Output retrieval](#112-output-retrieval)
    - 11.3 [Scalar write methods](#113-scalar-write-methods)
    - 11.4 [Extended integer writers](#114-extended-integer-writers)
    - 11.5 [Struct helpers](#115-struct-helpers)
    - 11.6 [Version directive](#116-version-directive)
    - 11.7 [Array helpers](#117-array-helpers)
    - 11.8 [Low-level `emit`](#118-low-level-emit)
    - 11.9 [Observing the output: `on_event`](#119-observing-the-output-on_event)
    - 11.10 [The C per-type helpers](#1110-the-c-per-type-helpers)
12. [DOM API](#12-dom-api)
    - 12.1 [`DomDoc::parse`](#121-domdocparse)
    - 12.2 [`DomDoc`](#122-domdoc)
    - 12.3 [`DomNode`](#123-domnode)
    - 12.4 [`DomType`](#124-domtype)
    - 12.5 [Building a tree (`NewNode`)](#125-building-a-tree-newnode)
13. [Error handling](#13-error-handling)
14. [Threads](#14-threads)
15. [Running the test suite](#15-running-the-test-suite)
16. [Crate layout](#16-crate-layout)
17. [Enums and `BaseUnit`](#17-enums-and-baseunit)
18. [Spec 1.1 additions](#18-spec-11-additions)
19. [License](#19-license)

- [See also](#see-also)

---

## 1. Requirements

| Requirement | Notes |
|---|---|
| Rust ≥ 1.88 | The crate's `rust-version`; CI builds and tests on it |
| A C compiler | `build.rs` compiles the C library through the `cc` crate |
| Python 3 on `PATH` (only when building from a repository checkout) | In the repository, the build runs the upstream table generator scripts, exactly as CMake does. Set the `PYTHON` environment variable to name a different interpreter. The crate from crates.io carries the generated tables and needs no Python ([§3.3](#33-how-the-published-crate-is-made)) |
| `num-bigint`, `num-rational`, `num-integer`, `num-traits` | Pulled in by Cargo; they carry the exact numbers (arbitrary-width integers, rational numbers and the coefficient of a `Decimal`) |
| [wasi-sdk](https://github.com/WebAssembly/wasi-sdk) | **Only** for the `wasm32-wasip2` target — see [§3](#3-building-the-c-library) |

The only optional features are the unit-profile notations, all on by default
([§3.2](#32-unit-profile-features)). There are no optional dependencies and no
integration with third-party unit or array libraries; see [§16](#16-crate-layout).

---

## 2. Installation

The crate carries the version of the C library it wraps (**2.1.0**: `version` in
`Cargo.toml` is the `project(bovnar VERSION x.y.z)` of the top-level
`CMakeLists.txt`, and CI fails when the two differ). It is published to
crates.io with each release:

```toml
[dependencies]
bovnar = "2.1.0"
```

A repository checkout works too, as a path dependency or straight from Git,
where Cargo finds the crate inside the repository. Built that way it needs
Python 3 at build time ([§1](#1-requirements)):

```toml
[dependencies]
bovnar = { path = "../bovnar/rust" }
# or
bovnar = { git = "https://github.com/sothis/bovnar" }
```

To work on the crate itself:

```bash
cd rust
cargo build
cargo test
```

---

## 3. Building the C library

The crate has no shared library to find: `build.rs` compiles the C library into
the crate. The crate from crates.io carries the C sources and the generated unit
tables in `c/` and compiles those; in a repository checkout `build.rs` runs the
table generators and compiles `../src` and `../include` instead. Either way a
downstream crate needs a C compiler at build time (plus Python 3 in the
checkout case, [§1](#1-requirements)) and nothing at run time.

`build.rs` re-runs when the C sources, the generators, the interpreter override,
the enabled features or `WASI_SDK_PATH` change.

### 3.1 WebAssembly (`wasm32-wasip2`)

A WebAssembly build needs a C compiler for WASI, [wasi-sdk](https://github.com/WebAssembly/wasi-sdk).
Point `WASI_SDK_PATH` at it and build for `wasm32-wasip2`:

```bash
rustup target add wasm32-wasip2
export WASI_SDK_PATH=/opt/wasi-sdk        # where wasi-sdk is unpacked
cargo build --release --target wasm32-wasip2
```

The same applies to a crate that depends on this one and is built for
`wasm32-wasip2`: set `WASI_SDK_PATH` for its build.

The crate's own tests run there under [wasmtime](https://wasmtime.dev), which
must see the checkout (the examples the tests read, and the build's output):

```bash
CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run --dir $(git rev-parse --show-toplevel)" \
    cargo test --target wasm32-wasip2 --lib --tests
```

A non-default `CARGO_TARGET_DIR` outside the checkout needs a `--dir` of its
own. WASI has no threads and a panic aborts, so the one test of each is
ignored there; the doctests run on native targets only.

### 3.2 Unit-profile features

Each unit-profile notation of [doc/11](11_bovnar_unit_profiles.md) is a Cargo
feature, and all seven are on by default:

| Feature | Notation | C switch |
|---|---|---|
| `ucum` | `ucum:` | `BVNR_WITH_UCUM_PROFILE` |
| `unece` | `unece:` | `BVNR_WITH_UNECE_PROFILE` |
| `qudt` | `qudt:` | `BVNR_WITH_QUDT_PROFILE` |
| `qudt-qk` | `qudt-qk:` | `BVNR_WITH_QUDT_QK_PROFILE` |
| `udunits` | `udunits:` | `BVNR_WITH_UDUNITS_PROFILE` |
| `om` | `om:` | `BVNR_WITH_OM_PROFILE` |
| `cf` | `cf:` | `BVNR_WITH_CF_PROFILE` |

Leaving a feature out drops that notation's tables from the library, as
`-DBVNR_WITH_<NAME>_PROFILE=OFF` does for the CMake build, and the reader then
refuses its codes. `THIRD_PARTY_NOTICES.md` says why a distributor may want to
leave out `ucum` or `cf`:

```toml
[dependencies]
bovnar = { version = "2.1.0", default-features = false, features = ["unece", "qudt", "qudt-qk", "udunits", "om"] }
```

### 3.3 How the published crate is made

`build.rs` reaches `../src`, which a package made from `rust/` would not contain,
so `rust/Cargo.toml` keeps `publish = false` and the crate is published from a
staged copy. `rust/package.py` builds that copy: the crate without `tests/` and
`fuzz/` (they read `../examples` and `../src/gendata`), `LICENSE`,
`THIRD_PARTY_NOTICES.md`, and in `c/` the headers, the C sources `build.rs`
compiles and the generators' output. It then runs cargo on the copy:

```bash
python3 rust/package.py                          # cargo package: pack and verify
python3 rust/package.py -- publish --dry-run     # what CI runs on every push
```

CI's `rust_package` job runs that dry run on every push, and `publish_crate`
publishes the same way on a `vX.Y.Z` tag through crates.io Trusted Publishing.

The crate carries `PROVENANCE.json`: the repository, the commit and, on a
release, the tag it was staged from, and for every file in the crate its
SHA-256 and the repository path it was copied from (`"generated"` for the
tables in `c/generated/`). A program that vendors the C library from `c/`
can tie what it compiles, and the notices it ships, to that one revision by
comparing its copies against those hashes. Before cargo runs, `package.py`
checks the staged copy with `check_crate_provenance.py`, which stages the
named commit again and requires the same files byte for byte. `rust_package`
checks the packed `.crate` the same way, and a publish refuses uncommitted
changes and a commit without its `vX.Y.Z` tag. To check a published crate
against the repository:

```bash
python3 check_crate_provenance.py bovnar-X.Y.Z.crate --tag vX.Y.Z
```

---

## 4. Quick-start

### 4.1 High-level API (`parse` / `to_string`)

The dynamic document type is `Value`. `parse` reads BVNR bytes into a `Value`
and `to_string` writes a `Value` out as BVNR bytes:

```rust
use bovnar::{Value, parse, to_string};

fn main() -> Result<(), bovnar::Error> {
    // Serialise a document to BVNR bytes
    let data = Value::Struct(vec![
        ("sensor_id".into(), Value::Int(42)),
        ("temperature".into(), Value::Float(-3.7)),
        ("label".into(), Value::String("outdoor".into())),
        ("active".into(), Value::Bool(true)),
        ("payload".into(), Value::Null),
    ]);
    let bvnr_bytes = to_string(&data, true)?;

    // Deserialise it back
    let recovered = parse(&bvnr_bytes)?;
    assert_eq!(recovered, data);
    let Value::Struct(fields) = recovered else { unreachable!() };
    assert_eq!(fields[0], ("sensor_id".to_string(), Value::Int(42)));
    Ok(())
}
```

`Value` is what `parse` returns and `to_string` takes:

| Variant | Notes |
|---|---|
| `Null` | |
| `Bool(bool)` | |
| `Int(i128)` | Any integer an `i128` holds, every `uint64` and `sint64` included |
| `BigInt(num_bigint::BigInt)` | An integer outside the `i128` range (a `<uint:256>`, say); `to_string` writes it as `<uint:N>`/`<sint:N>`, `N` the smallest power of two from 128 that holds it |
| `Float(f64)` | `nan`, `inf` and `ninf` too, under an integer type as well (spec §6.4) |
| `String(String)` | Symbols and references read back as strings too |
| `Bytes(Vec<u8>)` | An octet stream |
| `Array(Vec<Value>)` | A multi-row array is an array of row arrays |
| `Struct(Vec<(String, Value)>)` | Keys in document order; a struct is a `Vec` of pairs, not a map |
| `Quantity(Box<Quantity>)` | A typed scalar with its literal, type and unit |
| `Decimal(Decimal)` | Written as an exact `float_dec:64` literal |
| `Fraction(BigRational)` | Written as an exact `float_dec:64` literal when it terminates |

`parse_typed` is `parse` with typed values kept: they come back as
`Value::Quantity` instead of being decoded to native scalars. That preserves the
original text representation, exact type width, numeral base and physical unit
for lossless round trips. What only the annotation carries comes back too: a
quoted numeric literal under a default annotation (`<float:64,_10> "5.7"`), and a
typed null, alone (`<float:64> ;`) or as an array element (`[<uint:32>, <sint:64>]`):

```rust
use bovnar::{Value, parse_typed, to_string};

fn main() -> Result<(), bovnar::Error> {
    let bvnr = b".pressure = <float:32,Pa> 101325.0;";
    let doc = parse_typed(bvnr)?;
    let Value::Struct(fields) = &doc else { unreachable!() };
    let Value::Quantity(q) = &fields[0].1 else { unreachable!() };
    assert_eq!(q.raw.as_deref(), Some("101325.0"));
    assert_eq!(q.unit_str(), "Pa");

    // to_string() accepts Quantity values — annotation and raw text are re-emitted as-is
    let out = to_string(&doc, true)?;
    assert_eq!(parse_typed(&out)?, doc);
    Ok(())
}
```

`to_string(value, pretty)` takes a `Value::Struct` — any other top-level value is
an `Error::Argument`. Its write buffer starts at 4 MiB and doubles on overflow
up to 256 MiB; there is no `cap` argument. It returns `Vec<u8>` rather than
`String`, because an octet stream is written raw. `write_to(value, pretty, out)`
does the same into any `std::io::Write`, and `parse_reader(input)` /
`parse_typed_reader(input)` read from any `std::io::Read`
([§4.7](#47-files-and-io-streams-stdioread--write)).

`parse` and its siblings read with the default `ReadOptions`;
`parse_with_options(data, typed, &options)` and
`parse_reader_with_options(input, typed, &options)` take any of them
([§10.3](#103-readoptions)) — a total cap, `strict_version`, `text_only`, the
length and nesting caps — with `typed` choosing between `parse` and
`parse_typed`:

```rust
use bovnar::{ErrorCode, ReadOptions};

fn main() {
    let untrusted = ReadOptions {
        max_file_size: 1 << 20,
        max_string_length: 4096,
        strict_version: true,
        ..ReadOptions::default()
    };
    let err = bovnar::parse_with_options(b"#!bovnar 9.0\n.a = 1;", false, &untrusted)
        .unwrap_err();
    assert_eq!(err.code(), Some(ErrorCode::UnsupportedSpecVersion));
}
```

> **`to_string()` writes what `parse()` reads — the streaming tier, not the
> document tier.** `parse()` checks the grammar and each value's own type, unit
> and range and stops there; `to_string()` is its inverse and stops in the same
> place, so `to_string(&parse(x)?)` works for every `x` that `parse()` accepts.
> Three shapes are therefore written without complaint and then **refused by the
> document tier** (`DomDoc::parse`, `bovnar validate`), where an array is
> homogeneous and rectangular:
>
> | Rust value | Written as | Document tier says |
> |---|---|---|
> | `Array(vec![Int(1), String("a".into())])` — an array mixing kinds | `[1, "a"]` | `array_element_type_mismatch` |
> | a ragged array of arrays — rows of 1 and 2 elements | `[[1], [2, 3]]` | `array_row_size_mismatch` |
> | structs that do not share their keys | `[{.a = <uint:64> 1;}, {.b = <uint:64> 2;}]` | `struct_shape_mismatch` |
>
> An array mixing `Int` and `Float`, or holding `Null` beside values, is fine at
> both tiers. If the output has to pass `bovnar validate`, run it through
> `DomDoc::parse` before shipping it.
>
> What `to_string()` does refuse, with `Error::Argument`, is what *neither* tier
> reads: an array whose elements carry different units, an integer wider than 64
> bits in an array that cannot take the width annotation it needs
> (`[Int(1 << 70), Float(1.5)]`), and a `Decimal` past the range of
> `float_dec:64`. A key that is not a string is not on the list: a struct key is
> a `String`, so the type system has already refused it.

### 4.2 SAX-style streaming reader

The callback receives exactly **one** argument, the `EventData`. There is no
userdata/context argument — capture external state in the closure instead. The
closure returns `true` to go on and `false` to stop the read (which then ends
in an `Error::Parse`).

```rust
use bovnar::{Event, EventData, ReadOptions, Reader};

fn main() -> Result<(), bovnar::Error> {
    let bvnr_bytes = b".a = 1; .b = \"two\";";
    let mut keys = Vec::new();
    let mut values = Vec::new();
    let mut on_event = |d: &EventData<'_>| {
        if d.event == Event::AssignmentStart {
            keys.push(d.text());
        } else if d.event == Event::Data {
            values.push(d.raw.to_vec());
        }
        true // returning false stops parsing (an Error::Parse)
    };

    let mut r = Reader::new()?;
    r.read_mem(bvnr_bytes, &ReadOptions::default(), Some(&mut on_event), None)?;
    assert_eq!(keys, ["a", "b"]);
    assert_eq!(values[0], b"1");
    Ok(())
}
```

`EventData` is the payload passed to a callback:

| Field / method | Type | Description |
|---|---|---|
| `d.event` | `Event` | The event code |
| `d.token_type` | `Option<TokenType>` | How the token was written: number, string, symbol, ... |
| `d.value_type` | `ValueTypeSpec` | The type annotation (family, width, base) |
| `d.value_unit` | `Unit` | The unit the value carries (dimensionless when it has none) |
| `d.raw` | `&[u8]` | The raw token bytes |
| `text()` | `String` | The token bytes as text, invalid UTF-8 replaced |
| `d.frac` | `&[u8]` | Verbatim ISO sub-second digits of a `datetime` literal (spec 1.1); empty when there are none |
| `d.unit_source` | `&[u8]` | The unit exactly as the document spelled it (library 2.0); empty when the value carries no unit |
| `d.converted` | `bool` | `true` when a `want_unit` conversion or a unit policy was applied |
| `d.converted_text` | `&[u8]` | The exact converted value as a positional string; empty if the conversion does not terminate in its base |
| `d.converted_base` | `u32` | The base `converted_text` is rendered in |
| `d.converted_unit` | `Unit` | The unit the converted value is in |
| `d.converted_rational()` | `Option<(String, String)>` | The exact conversion as `(numerator, denominator)` decimal strings — they may exceed any machine integer |
| `d.converted_in_base(base)` | `Result<Option<String>>` | The exact conversion re-rendered in `base` |

**The borrow ends with the callback.** `EventData<'a>` borrows the reader's
memory, so a field cannot be kept past the call; copy what you need (`text()`,
`d.raw.to_vec()`). The owned copy that can outlive its callback is
[`EventPayload`](#104-eventdata-and-eventpayload), which `iter_mem` collects for
you.

### 4.3 Collecting events

`Reader::iter_mem` returns the events as a `Vec` of owned `EventPayload` values
(the whole stream, read to the end):

```rust
use bovnar::Reader;

fn main() -> Result<(), bovnar::Error> {
    let bvnr_bytes = b".a = 1; .b = 2;";
    let mut r = Reader::new()?;
    for payload in r.iter_mem(bvnr_bytes, true, 0)? {
        println!("{:?} {}", payload.event, payload.text());
    }
    Ok(())
}
```

### 4.4 DOM (random-access) API

```rust
use bovnar::{DomDoc, DomType};

fn main() -> Result<(), bovnar::Error> {
    let bvnr_bytes = br#"
        .sensor_id = 42;
        .config = { .host = "example.com"; };
        .values = [1.5, 2.5, 3.5];
    "#;
    let doc = DomDoc::parse(bvnr_bytes)?;

    // Top-level key lookup
    let node = doc.get("sensor_id").expect("present");
    assert_eq!(node.as_i64(), Some(42));
    assert_eq!(node.value_type().width, 64);

    // Struct traversal — by member, or by dotted path
    let cfg = doc.get("config").expect("present");
    assert_eq!(cfg.get("host").and_then(|n| n.as_str()), Some("example.com"));
    assert_eq!(doc.lookup("config.host").and_then(|n| n.as_str()), Some("example.com"));

    // Array access
    let arr = doc.get("values").expect("present");
    assert_eq!(arr.dom_type(), Some(DomType::Array));
    let sum: f64 = (0..arr.len()).filter_map(|i| arr.at(i)?.as_float()).sum();
    assert_eq!(sum, 7.5);

    // Convert the entire document to a plain Value (drops type/unit info)
    let value = doc.to_value()?;
    assert!(matches!(value, bovnar::Value::Struct(_)));
    Ok(())
}
```

A `DomNode` is a `Copy` view that borrows its `DomDoc`, so the compiler — not a
convention — keeps the document alive for as long as a node is in use.

### 4.5 Low-level writer

```rust
use bovnar::{Base, Writer};

fn main() -> Result<(), bovnar::Error> {
    let mut w = Writer::to_mem(4096, true)?;
    w.write_float("velocity", 9.81, 64)?;
    w.write_uint("count", 1024, 32, Base::DECIMAL)?;
    w.finish()?;

    let output: &[u8] = w.get_output();
    assert_eq!(bovnar::parse(output)?, bovnar::parse(b".velocity = 9.81; .count = <uint:32> 1024;")?);
    Ok(())
}
```

The scalar writers take **no unit arguments**: write a value with
a unit through [`write_quantity`](#113-scalar-write-methods) or
[`write_array`](#7-write_array-helper), which take a `Unit`.

### 4.6 Streaming / framing (`bovnar::stream`)

Bindings for the streaming layer (see
[Streaming, Framing & Multiplexing](10_bovnar_streaming.md) for the full
treatment). The functions work on in-memory buffers:

```rust
use bovnar::{Value, parse, stream, to_string};

fn main() -> Result<(), bovnar::Error> {
    let one = Value::Struct(vec![("id".into(), Value::Int(1))]);
    let two = Value::Struct(vec![("id".into(), Value::Int(2))]);

    // Multi-document record framing
    let blob = stream::dump_documents(&[one.clone(), two.clone()], true)?;
    let docs = stream::load_documents(&blob, false, false, 0)?;
    assert_eq!(docs, [Some(one.clone()), Some(two)]);
    // With continue_past_failed = true a document that fails is None and the rest are read
    let docs = stream::load_documents(&blob, false, true, 0)?;
    assert_eq!(docs.len(), 2);

    // Octet multiplexing: (channel, payload) of any size, interleaved & reassembled
    let multiplexed = stream::mux_dump(&[(1, b"hello".as_slice()), (42, b"world".as_slice())], "mux")?;
    let messages = stream::mux_load(&multiplexed, 0, None)?;
    assert_eq!(messages, [(1, b"hello".to_vec()), (42, b"world".to_vec())]);

    // Document-in-document
    let outer = stream::embed_document(&to_string(&one, true)?, "payload", true)?;
    let Value::Struct(fields) = parse(&outer)? else { unreachable!() };
    let Value::Bytes(inner) = &fields[0].1 else { unreachable!() };
    assert_eq!(stream::parse_embedded(inner, false)?, one);
    Ok(())
}
```

All arguments are positional:

| Function | Arguments |
|---|---|
| `frame_documents(docs)` | already-serialised documents, `&[impl AsRef<[u8]>]` |
| `dump_documents(values, pretty)` | `&[Value]` |
| `load_documents(data, typed, continue_past_failed, max_document_size)` | `max_document_size` 0 is the default cap (`BVNR_DOC_DEFAULT_MAX`, 256 MiB) |
| `load_documents_results(data, typed, max_document_size, options)` | every frame read, a failed document `Err` with its error code |
| `mux_dump(messages, key)` | `(u64, payload)` pairs; the conventional key is `"mux"` |
| `mux_load(data, max_message, key)` | `max_message` 0 is the default cap (`BVNR_MUX_DEFAULT_MAX`, 64 MiB); `key` `None` accepts any |
| `embed_document(inner, key, pretty)` | the conventional key is `"payload"` |
| `parse_embedded(inner, typed)` | |

`load_documents` with `continue_past_failed` gives `None` for a document that
failed, and the error is gone. `load_documents_results` (and
`load_documents_reader_results`) keep it: every frame is read, as with
`continue_past_failed`, and the document at index *i* is `Ok(value)` or the
`Err` it failed with — an `Error::Parse` with the document's error code and no
position, which the frame reader does not report. The outer `Err` is the
stream's own failure: the framing, a frame over the cap. `live::FrameParser`
hands each document's index and result to `on_document` already
([§4.9](#49-live-documents-through-c-bovnarlive)).

```rust
use bovnar::{ErrorCode, ReadOptions, Value, stream};

fn main() -> Result<(), bovnar::Error> {
    let frames = stream::frame_documents(&[&b".a = 1;"[..], b".a = @;", b".b = 2;"])?;
    let docs = stream::load_documents_results(&frames, false, 0, &ReadOptions::default())?;
    assert_eq!(docs[0], Ok(Value::Struct(vec![("a".into(), Value::Int(1))])));
    assert_eq!(docs[1].as_ref().unwrap_err().code(), Some(ErrorCode::UnexpectedInputByte));
    assert!(docs[2].is_ok());
    Ok(())
}
```

`parse_embedded` returns an `Error::Parse` carrying the inner document's own
error code and, where the library reported one, its position. `mux_dump`
channels are `u64`, so the channel range is checked by the type.

The readers read with the default `ReadOptions`. Each has a twin that takes
them as its last argument — `load_documents_with_options`,
`mux_load_with_options`, `parse_embedded_with_options`, and the same for the
`*_reader` functions — and reads under them: `load_documents` each document,
with `max_file_size` capping one document and `max_document_size` one frame;
`mux_load` the document around the stream; `parse_embedded` the embedded
document, as a document of its own, with `max_file_size` capping its bytes.

Each function has a twin for `std::io::Read` or `std::io::Write`
([§4.7](#47-files-and-io-streams-stdioread--write)).

The `Reader` and `Writer` types of [§10](#10-reader-reference) and
[§11](#11-writer-reference) are the building blocks underneath: use them
directly when a document is produced or consumed event by event rather than as
one buffer.

### 4.7 Files and I/O streams (`std::io::Read` / `Write`)

Every entry point that reads or writes a whole document has a twin that takes
any `std::io::Read` or `std::io::Write`: a `File`, a pipe, a socket, a `Cursor`.
There is no separate path-based or file-descriptor form; open the `File` and
pass it in.

```rust
use std::io::Cursor;
use bovnar::{Base, DomDoc, ReadOptions, Reader, Writer};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let path = std::env::temp_dir().join(format!("bovnar-doc-{}.bvnr", std::process::id()));

    // Write: the document streams into the file, the rest on finish()
    let mut w = Writer::to_writer(std::fs::File::create(&path)?, false)?;
    w.write_uint("port", 443, 16, Base::DECIMAL)?;
    w.finish()?;
    drop(w);

    // Random access over the file
    let dom = DomDoc::parse_reader(std::fs::File::open(&path)?)?;
    assert_eq!(dom.lookup("port").and_then(|n| n.as_u64()), Some(443));
    std::fs::remove_file(&path)?;

    // Streaming over any reader
    let mut keys = Vec::new();
    let mut on_event = |d: &bovnar::EventData<'_>| {
        if d.event == bovnar::Event::AssignmentStart {
            keys.push(String::from_utf8_lossy(d.raw).into_owned());
        }
        true
    };
    Reader::new()?.read_reader(Cursor::new(b".a = 1;"), &ReadOptions::default(), Some(&mut on_event), None)?;
    assert_eq!(keys, ["a"]);
    Ok(())
}
```

The plain-value and `stream` functions have twins too:

```rust
use std::io::Cursor;
use bovnar::{parse_reader, stream, write_to};

fn main() -> Result<(), bovnar::Error> {
    let value = parse_reader(Cursor::new(b".a = 1;"))?;

    // Serialise into any Write; here a Vec<u8>
    let mut out = Vec::new();
    write_to(&value, false, &mut out)?;
    assert_eq!(bovnar::parse(&out)?, value);

    // Framed documents round-trip through a Write and a Read
    let mut framed = Vec::new();
    stream::dump_documents_to(&[value.clone(), value.clone()], false, &mut framed)?;
    let docs = stream::load_documents_reader(Cursor::new(framed), false, false, 0)?;
    assert_eq!(docs, [Some(value.clone()), Some(value)]);
    Ok(())
}
```

| In-memory entry point | Reader or writer twin |
|---|---|
| `parse(data)` | `parse_reader(input)` |
| `parse_typed(data)` | `parse_typed_reader(input)` |
| `parse_with_options(data, typed, &options)` | `parse_reader_with_options(input, typed, &options)` |
| `to_string(value, pretty)` | `write_to(value, pretty, out)` |
| `Reader::read_mem(data, …)` | `Reader::read_reader(input, &options, on_verified, on_unverified)` |
| `Reader::read_mem_want_unit(data, …)` | `Reader::read_reader_want_unit(input, &options, want_unit, on_verified)` |
| `Reader::read_mem_with(data, &options, handlers)` | `Reader::read_reader_with(input, &options, handlers)` |
| `Reader::read_mem_mirror(data, &options, handlers, mirror)` | `Reader::read_reader_mirror(input, &options, handlers, mirror)` |
| `canonicalize(data, pretty, &options)` | `canonicalize_reader(input, out, pretty, &options)` |
| `DomDoc::parse(data)` | `DomDoc::parse_reader(input)` |
| `DomDoc::parse_with_policy(data, &policy)` | `DomDoc::parse_reader_with_policy(input, &policy)` |
| — | `DomDoc::parse_reader_capped(input, max_bytes)`, a lower cap on what is read ([§12.2](#122-domdoc)) |
| `Writer::to_mem(cap, pretty)` | `Writer::to_writer(out, pretty)` |
| `Writer::to_mem_with(cap, pretty, unit_flags, emit_version)` | `Writer::to_writer_with(out, cap, pretty, unit_flags, emit_version)` |
| `Writer::to_mem_with_options(cap, options)` | `Writer::to_writer_with_options(out, cap, options)` |
| `stream::frame_documents(docs)` | `stream::frame_documents_to(docs, out)` |
| `stream::dump_documents(values, pretty)` | `stream::dump_documents_to(values, pretty, out)` |
| `stream::load_documents(data, typed, continue_past_failed, max_document_size)` | `stream::load_documents_reader(input, typed, continue_past_failed, max_document_size)` |
| `stream::load_documents_with_options(data, typed, continue_past_failed, max_document_size, &options)` | `stream::load_documents_reader_with_options(input, typed, continue_past_failed, max_document_size, &options)` |
| `stream::load_documents_results(data, typed, max_document_size, &options)` | `stream::load_documents_reader_results(input, typed, max_document_size, &options)` |
| `stream::mux_dump(messages, key)` | `stream::mux_dump_to(messages, key, out)` |
| `stream::mux_load(data, max_message, key)` | `stream::mux_load_reader(input, max_message, key)` |
| `stream::mux_load_with_options(data, max_message, key, &options)` | `stream::mux_load_reader_with_options(input, max_message, key, &options)` |
| `stream::embed_document(inner, key, pretty)` | `stream::embed_document_to(inner, key, pretty, out)` |
| `stream::parse_embedded(inner, typed)` | `stream::parse_embedded_reader(input, typed)` |
| `stream::parse_embedded_with_options(inner, typed, &options)` | `stream::parse_embedded_reader_with_options(input, typed, &options)` |

**They stream.** A reader twin hands its `Read` to the C library as a callback
source (`bvnr_source_from_callback`): the library pulls from it as it parses, so
the events of what has arrived are delivered while the rest is still to come,
and the input is never held whole (`Reader::read_reader` keeps memory flat
however long the input is; the twins that return values hold those values). The results are those of the
in-memory twin for the same bytes, `file_too_long` at the same offset for a
document over `ReadOptions::max_file_size` and the `max_document_size` and
`max_message` of `load_documents_reader` and `mux_load_reader` included. The
input is read until it reports its end (`Ok(0)`), so these suit a file, a pipe
or a blocking socket; for input that is handed over when it arrives, use the
fed readers of [§4.9](#49-live-documents-through-c-bovnarlive). Only the DOM
reads its input into memory first, up to `DOM_READ_MAX_BYTES` (256 MiB, the cap
of `bvn_dom_parse_fd`), because it builds the whole document anyway.

A writer twin, and `Writer::to_writer`, push the document into `out` through a
callback sink (`bvnr_sink_to_callback`) as it is serialised: the C writer
batches its output as it does for an fd, so `out` receives 64 KiB blocks as
they fill and the rest on `finish()`, which then flushes `out` once; a
`to_writer` writer also hands over what it holds whenever `flush()` is called
([§11.5](#115-struct-helpers)). The bytes are those of the in-memory twin. Since output leaves as it is made, a value
that cannot be serialised part-way through leaves the blocks before it in
`out` (a value that is not a struct at all leaves nothing), and a `to_writer`
writer dropped unfinished drops what it still holds. `to_writer` has no cap on
the document's size; `to_writer_with(out, cap, …)` keeps `cap` as one, and
output past it fails with `sink_buffer_exhausted`, as with `Writer::to_mem` --
from the call that pushes it into `out` (the write that fills a block, or
`flush()` or `finish()`), not necessarily the write that made it.
`Writer::get_output()` is empty for such a writer. `out` must be
`Send + 'static`; the writer stays `Send` ([§14](#14-threads)).

Short reads and writes are continued and `Interrupted` is retried. A failing
`input` or `out` is an `Error::Io` ([§13](#13-error-handling)) with the code the
C library gives an unreadable fd source or unwritable fd sink
(`reading_from_source_fd`, `writing_to_sink`), after the events of what had
arrived, and a `to_writer` writer reports it from every later call too. A panic
inside `input` or `out` reaches the caller, as one in an event handler does
([§13](#13-error-handling)); it never unwinds through the C library. No file
descriptor is involved, so these work the same on Windows.

### 4.8 Live streams: the framing codec (`bovnar::codec`)

The `stream` functions of [§4.6](#46-streaming--framing-bovnarstream) work on
whole buffers. `bovnar::codec` does the same two wire conventions — octet
multiplexing and `BVF1` frames ([Streaming](10_bovnar_streaming.md) §2 and §3)
— on a live stream: the writer hands out each message's bytes as it is sent,
and the reader is fed bytes in pieces of any size, as they arrive from a
socket or a pipe, and hands out each message as soon as its last byte is in.
The module is pure Rust (`#![forbid(unsafe_code)]`): no message goes through
the C library, and nothing is allocated per message once the reader's buffers
have grown to the largest message.

```rust
use bovnar::codec::{MuxReader, MuxWriter};

fn main() -> Result<(), bovnar::Error> {
    // A stream under the key "media" that opens with ordinary values
    let mux = MuxWriter::with_preamble(b".meta = { .codec = \"av1\"; .rate = 30; };", "media")?;
    let mut wire = Vec::new(); // stands in for a socket
    wire.extend_from_slice(mux.opener());
    let mut head = Vec::new();
    for (channel, frame) in [(1, &b"key frame"[..]), (2, b"audio"), (1, b"delta")] {
        // Only the chunk headers are written, into `head`; the frame is
        // borrowed, ready for a vectored write
        for part in mux.encode(&mut head, channel, frame) {
            wire.extend_from_slice(part);
        }
    }
    wire.extend_from_slice(mux.terminator());

    // The C library reads the same bytes
    assert_eq!(bovnar::stream::mux_load(&wire, 0, Some("media"))?.len(), 3);

    // Fed in pieces, as from a socket
    let mut reader = MuxReader::with_options(0, Some("media"))?;
    let mut received = Vec::new();
    for piece in wire.chunks(7) {
        let mut input = piece;
        while let Some(m) = reader.read(&mut input)? {
            received.push((m.channel, m.payload.to_vec()));
        }
    }
    reader.finish()?; // the input ended where a stream may end
    assert_eq!(received[1], (2, b"audio".to_vec()));
    let meta = reader.preamble_value(false)?.expect("the stream is open");
    assert_eq!(meta, bovnar::parse(b".meta = { .codec = \"av1\"; .rate = 30; };")?);
    Ok(())
}
```

**Writing.** `MuxWriter::new(key)` or `MuxWriter::with_preamble(preamble,
key)` validates the key (1 to 255 bytes, the identifier rules of spec §4.2:
what the C writer accepts and a reader at its defaults reads) and the
preamble, once. A stream is `opener()`, the messages, then `terminator()`;
`begin(&mut vec)` and `finish(&mut vec)` append them to a `Vec`. Each message
is one of:

| Call | What it writes |
|---|---|
| `encode(&mut head, channel, payload)` | only the chunk headers, into `head` (cleared, then reused); returns `Parts`, an iterator over the message's wire bytes in order — header, payload slice, header, … — for a vectored write |
| `write_message(&mut vec, channel, payload)` | the whole message, appended to a `Vec` |
| `write_message_to(&mut out, channel, payload)` | the whole message to any `std::io::Write`, one vectored write per chunk, nothing allocated; `out` is not flushed, and must block (a failure part-way, `WouldBlock` included, leaves the stream broken; for a non-blocking socket write `encode`'s parts yourself) |

A message is cut into chunks exactly as `bvnr_mux_send` cuts it (a message of
at most `stream::BVNR_MUX_MAX_MESSAGE` bytes is one chunk), so the bytes are
those of `stream::mux_dump` for the same key and messages.

**Reading.** `MuxReader::read(&mut input)` takes `input: &mut &[u8]`, the bytes
not yet read, reads from its front and returns the next complete message as
`Message { channel, payload }`. `None` means every byte of `input` was taken
(an unfinished message is kept inside the reader), so the buffer can be
refilled; after a message, call again with the same slice until `None`. A
message that lies whole in `input` is borrowed from it; one that spans pieces
or chunks is gathered in a buffer per channel that is reused. The slice is
valid until the next call. `finish()` checks that the input ended after the
document's `;` (or before its first byte); `reset()` readies the reader for
another stream, keeping the buffers it has grown. The reader reads every valid octet region, not only what
`bvnr_mux_send` writes: several messages packed into one chunk, channels
interleaved chunk by chunk, a length varint split across chunks.

| Option | Default |
|---|---|
| `MuxReader::with_options(max_message, key)` | `max_message` 0 is 64 MiB, as for `mux_load`; with `key`, a stream under another key is an `Error::Argument` naming both (`bvnr_demux_set_key` would ignore it, but this reader reads one stream) |
| `.with_max_preamble(bytes)` | 64 KiB of text before the octet stream, preamble and opening assignment together; more is `text_data_too_long` |

**Peek, skip, forward.** For a router or relay, `next_item(&mut input, n)`
also stops at the start of each message with `Item::Peek`, holding its
channel, its declared length and its first `n` bytes; nothing else of the
message has been read. Drop the peek to receive the message as
`Item::Message`, call `skip()` to have its bytes passed over as they arrive,
or `forward()` to receive it as `Item::Wire`: whole chunks carrying that
message alone, which can be written into any other multiplexed stream between
two messages. When those chunks lie consecutive in the input, `Wire::bytes`
is the input itself, not copied; otherwise the reader gathers the message and
writes it out again as `MuxWriter` would.

```rust
use bovnar::codec::{Item, MuxReader, MuxWriter};

fn main() -> Result<(), bovnar::Error> {
    let mux = MuxWriter::new("mux")?;
    let mut inbound = Vec::new();
    mux.begin(&mut inbound);
    for (channel, msg) in [(1, &b"for the relay"[..]), (2, b"local only"), (1, b"again")] {
        mux.write_message(&mut inbound, channel, msg);
    }
    mux.finish(&mut inbound);

    // Forward channel 1, drop the rest, decided on the peek alone
    let mut reader = MuxReader::new();
    let mut outbound = mux.opener().to_vec();
    let mut input = &inbound[..];
    while let Some(item) = reader.next_item(&mut input, 0)? {
        match item {
            Item::Peek(p) if p.channel == 1 => p.forward(),
            Item::Peek(p) => p.skip(),
            Item::Wire(w) => outbound.extend_from_slice(w.bytes),
            Item::Message(_) => unreachable!("every message was decided"),
        }
    }
    outbound.extend_from_slice(mux.terminator());
    let relayed = bovnar::stream::mux_load(&outbound, 0, None)?;
    assert_eq!(relayed, [(1, b"for the relay".to_vec()), (1, b"again".to_vec())]);
    Ok(())
}
```

**The preamble.** Text before the stream's assignment — complete statements,
a `#!bovnar` directive first if needed — lets a stream carry ordinary values
about itself in the same document. `with_preamble` checks it once with the
full parser; it must be text, so an octet stream in it is `octet_stream_forbidden`
(a reader takes the document's first `0x00` for the stream's opener). The
reader gathers the text up to that `0x00`, requires it to end with the
assignment `.KEY =`, checks the preamble with the full parser (once per
stream; a bare `.KEY=` or `.KEY = ` skips the check), and then reads the octet
stream in pure Rust. `read_preamble(&mut input)` reads only up to the opener
and returns the preamble, so it can be acted on before any message;
`preamble()` returns it afterwards and `preamble_value(typed)` parses it into
a `Value`. A C-written document with values before `bvnr_mux_begin` reads the
same way.

**What the reader reads.** A preamble (possibly empty), one octet stream as
the value of the document's last assignment (`.KEY =`, with no comment between
the `=` and the stream), `;`, then only whitespace and comments, which may also
come before the `;`. A statement after the stream, or any other byte after it, is
`unexpected_input_byte`: the codec reads multiplexed streams, not the
grammar. Parse a general document with `stream::mux_load` or a `Reader`.

**Errors** are `Error::Parse` with the code the C demultiplexer gives
(`mux_load` gives the same code for a desync, an oversized message, the 4097th
channel and a cut-off stream; what only the codec refuses -- bytes after the
stream, a preamble over its cap, a key other than the expected one -- has a code
of its own), and they are latched:

| Condition | Code |
|---|---|
| A chunk tag other than `0x01`/`0x00`, a channel varint not whole in its chunk, a varint over 10 bytes or 64 bits, a 4097th distinct channel | `octet_stream_out_of_sync` |
| A declared length over `max_message` | `value_out_of_range` |
| The octet stream closes inside a message or its length | `octet_stream_truncated` |
| `finish()` inside the octet stream | `read_complete_chunk_failed` |
| `finish()` before the stream opened, or before its `;` | `got_incomplete_bvnr_stream` |
| A comment after the `;` that is not UTF-8, or that the input ends inside a character of (`finish()`) | `invalid_utf8_byte` |

**Frames.** `codec::frame_header(len)`, `write_frame(&mut vec, doc)` and
`write_frame_to(&mut out, doc)` (one vectored write, no copy) write what
`bvnr_frame_write` writes. `FrameReader::read(&mut input)` returns each
complete document's bytes, borrowed when whole in the input, gathered
otherwise; parse each with `parse`, a `Reader` or `DomDoc`.
`FrameReader::with_max_document_size(n)` caps a document (0 is 256 MiB, as in
`bvnr_doc_stream_read`; the buffer only grows with bytes that arrived). A bad
magic is `unexpected_input_byte`, a length over the cap `value_out_of_range`,
and `finish()` inside a frame `got_incomplete_bvnr_stream`.

```rust
use bovnar::codec::{FrameReader, write_frame};

fn main() -> Result<(), bovnar::Error> {
    let mut wire = Vec::new();
    write_frame(&mut wire, b".id = 1;");
    write_frame(&mut wire, b".id = 2;");
    assert_eq!(wire, bovnar::stream::frame_documents(&[".id = 1;", ".id = 2;"])?);

    let mut reader = FrameReader::new();
    let mut ids = Vec::new();
    for piece in wire.chunks(5) {
        let mut input = piece;
        while let Some(doc) = reader.read(&mut input)? {
            ids.push(bovnar::parse(doc)?);
        }
    }
    reader.finish()?;
    assert_eq!(ids.len(), 2);
    Ok(())
}
```

**Cost per message**, from `cargo bench --bench codec`: a release build pinned
to one core (`taskset -c 3`) of an Intel Core i7-2700K (2011), whose clock
scales with load. Each figure is the median of seven runs (each run the median
of nine passes over at least 64 MiB of messages), and in brackets the fastest
run. The stream is a few MiB held in the cache, as the bytes of a socket read
are when the reader sees them; it is opened once and fed the same messages
every pass, as a live stream is fed, so opening it is not counted. A 400 KB
message spans seven chunks.

| Message | write | write + copy | peek + skip | forward | read | `mux_dump` | `mux_load` |
|---|---|---|---|---|---|---|---|
| 150 B | 8.4 ns (8.3) | 26 ns (25) | 12 ns (12) | 13 ns (13) | 8.2 ns (8.1) | 244 ns (242) | 123 ns (122) |
| 30 KB | 8.6 ns (8.6) | 1.5 µs (1.5) | 12 ns (12) | 14 ns (14) | 8.8 ns (8.8) | 8.4 µs (8.2) | 2.7 µs (2.5) |
| 400 KB | 37 ns (37) | 22 µs (21) | 55 ns (55) | 56 ns (56) | 25 µs (23) | 110 µs (110) | 76 µs (70) |

*write* is `encode` (the headers only, for a vectored write); *write + copy* is
`write_message` into a reused `Vec`; *peek + skip* and *forward* are
`next_item`, a look at the channel and the first payload byte, and the
decision, the forwarded chunks borrowed from the input; *read* is `read`, which gathers (copies) a message that spans
chunks, so the 400 KB one; *mux_dump* and *mux_load* are the batch functions
through the C library, for comparison (`mux_load` copies every message into
the `Vec` it returns, which the C demux alone does not: doc/10 §8.4). Counted in instructions, which the
clock does not move, a 150-byte message is about 145 to write, 120 to read and
140 to peek at and skip, the benchmark's loop included. From a stream much
larger than the cache, every chunk header a 400 KB message has is a cache
miss, which puts peek and forward at about 130 ns for it, and small messages
are read as fast as memory delivers them: about 24 ns to read a 150-byte one
and 29 ns to peek at and skip it. The other figures move little.

### 4.9 Live documents through C (`bovnar::live`)

The codec of [§4.8](#48-live-streams-the-framing-codec-bovnarcodec) reads the
framing; it does not parse documents. `bovnar::live` wraps the C library's
push-fed readers ([Streaming](10_bovnar_streaming.md) §8) for that: they are
*handed* bytes in pieces of any size, as they come from a non-blocking socket,
an event loop, a frame or another document's octet stream, and are told
separately when the input is over. Each keeps one C object for its whole life,
so an endless stream costs one setup, and whatever the cuts, the events, values
and errors are exactly those the batch functions give for the whole input
(the frame readers' framing errors aside, below).

| Type | Over | What comes out |
|---|---|---|
| `DocumentFeed` | `bvnr_open_read_feed` | the events of `Reader::read_mem`, from each `feed` and `finish` |
| `ValueFeed` | the same | each top-level assignment as a `Value` once it is complete |
| `ValueBuilder` | — | the same from events you hand it (those of a `Demux`, say) |
| `EmbeddedFeed` | `DocumentFeed` | the documents embedded under a key of another document, as their chunks arrive |
| `FrameParser` | `bvnr_frame_reader_create` | the events and the result of every framed document |
| `FramePull` | `bvnr_frame_reader_create_pull` | each framed document's bytes |
| `Demux` | `bvnr_demux_open_feed`, `bvnr_demux_next` | a multiplexed stream's messages, and the document's own events in order with them |

**Documents.** `DocumentFeed::new(&options)` takes the `ReadOptions` of a
`Reader` (and `set_unit_policy` its policy). `feed(bytes, handler)` delivers the
events those bytes complete before it returns, so the buffer can be reused at
once; running out of input is never an error there. `finish(handler)` declares
the end: a document cut off fails there, with the code and position
`read_mem` reports. `reset()` starts the next document on the same reader. An
error ends the document, and every later `feed` or `finish` returns it until
`reset()`. `ValueFeed` does the same and appends to a `Vec` each assignment the
bytes complete, those before an error included: a scalar, a struct or an octet
stream with its last event, an array (which a `/` may still continue) with the
event after it. A key assigned twice comes twice, and nothing is kept once it is
handed out, so an endless document is read in constant memory. Its *text* is
capped by the library's default of 2 147 483 647 bytes per document
(`error_text_data_too_long`, doc/10 §1), and each array at as many items: set
`ReadOptions::max_text_bytes` (and `max_array_items`) to `u64::MAX` for a
document that does not end, and `max_file_size` where a cap still matters.

```rust
use bovnar::live::ValueFeed;
use bovnar::{ReadOptions, Value};

fn main() -> Result<(), bovnar::Error> {
    // An endless document arriving from a socket, cut anywhere
    let pieces: [&[u8]; 4] = [b".temp = 21", b".5; .unit = \"C\"; .te", b"mp = 22.0;", b" .tag = [1, 2];"];
    let mut feed = ValueFeed::new(false, &ReadOptions::default())?;
    let mut got = Vec::new();
    for piece in pieces {
        feed.feed(piece, &mut got)?;
    }
    assert_eq!(got[0], ("temp".to_string(), Value::Float(21.5)));
    assert_eq!(got.len(), 3, "the array may still go on");
    feed.finish(&mut got)?; // the end of the document completes it
    assert_eq!(got[3].0, "tag");
    Ok(())
}
```

**Every handler.** `feed` and `finish` take the event handler alone.
`feed_with(bytes, handlers)` and `finish_with(handlers)` take a
`ReadHandlers` ([§10.2](#102-read_mem)), as `Reader::read_mem_with` does:
`on_verified`, the raw lexer events of `on_unverified`, the `want_unit` hook
and `on_error` — which sees each error a `continue_on_error` document recovers
from — called inside the call that parses the bytes they concern. `ValueFeed`
takes them through its own `feed_with(bytes, out, handlers)` and
`finish_with(out, handlers)`, its `on_verified` seeing each event after the
value builder has. The C reader is given its callbacks when a document starts,
so the first call of a document decides whether `on_unverified` and
`want_unit` are called for the whole of it: a later call that needs one the
document was not started with is an `Error::Argument` (`reset()` starts the
next document, decided afresh), and a later call may pass fewer. A panic in a
handler reaches the caller of the call, and the document has failed.

```rust
use bovnar::live::DocumentFeed;
use bovnar::{EventData, ReadHandlers, ReadOptions, Unit};

fn main() -> Result<(), bovnar::Error> {
    let mut feed = DocumentFeed::new(&ReadOptions::default())?;
    let (mut tokens, mut metres) = (0, Vec::new());
    for piece in [&b".d = <uint:64,k~m"[..], b"> 3; .e = 4;"] {
        let mut on_raw = |_: &EventData<'_>| { tokens += 1; true };
        let mut want = |d: &EventData<'_>| {
            (!d.value_unit.is_dimensionless()).then(|| (Unit::parse("m").unwrap(), 0))
        };
        let mut on_value = |d: &EventData<'_>| {
            if d.converted { metres.push(String::from_utf8_lossy(d.converted_text).into_owned()); }
            true
        };
        feed.feed_with(piece, ReadHandlers {
            on_verified: Some(&mut on_value),
            on_unverified: Some(&mut on_raw),
            want_unit: Some(&mut want),
            on_error: None,
        })?;
    }
    feed.finish(&mut |_| true)?;
    assert!(tokens > 0);
    assert_eq!(metres, ["3000"]);
    Ok(())
}
```

**The mirror.** `set_mirror(w)` gives the feed any `std::io::Write + Send`
that receives every byte the reader consumes (`src_mirror`, a debugging aid):
each piece fed, once the reader has parsed it, and a document that fails has
mirrored the input up to the byte the error is at. It starts the
next document, as `reset()` does, and stays for every document after;
`take_mirror::<W>()` hands it back as the type it was given (`None`, and the
mirror kept, for another type), and the rest of a document in progress is not
mirrored. A failing mirror fails the document with an `Error::Io` with
`writing_to_sink`; a panic in it reaches the caller. `ValueFeed` has the same
two methods. For a `Reader`, `read_mem_mirror` and `read_reader_mirror` take a
`&mut dyn std::io::Write` for the one read ([§10.2](#102-read_mem)). The frame
readers and the demux take no mirror, as the C objects behind them do not:
their caller holds the bytes it feeds.

```rust
use bovnar::ReadOptions;
use bovnar::live::DocumentFeed;

fn main() -> Result<(), bovnar::Error> {
    let mut feed = DocumentFeed::new(&ReadOptions::default())?;
    feed.set_mirror(Vec::<u8>::new())?;
    for piece in [&b".a = 1;"[..], b"  # note\n.b = 2;"] {
        feed.feed(piece, &mut |_| true)?;
    }
    feed.finish(&mut |_| true)?;
    assert_eq!(feed.take_mirror::<Vec<u8>>().unwrap(), b".a = 1;  # note\n.b = 2;");
    Ok(())
}
```

**Frames.** `FramePull` hands out each framed document's bytes, to be parsed
by a reused `DocumentFeed` or `ValueFeed` (or `parse`); `codec::FrameReader`
does the same in pure Rust. `FrameParser::new(&options, continue_past_failed,
max_document_size)` parses every document as `stream::load_documents` does,
streaming each frame's payload into one reused reader so that a frame is never
held whole: `feed(bytes, on_event, on_document)` calls `on_document(index,
result)` for each document it completes — `Ok(())` or the `Err` with the
document's error code — and its returning false ends the stream with
`scanner_callback_failed`. `feed_with(bytes, handlers, on_document)` takes a
`ReadHandlers` for the documents, as `DocumentFeed::feed_with` does; the frame
reader is given its callbacks when the stream starts, so the first feed
decides whether `on_unverified` and `want_unit` are called. A bad magic is
`unexpected_input_byte`, a frame over the cap `value_out_of_range`, a stream
that does not end on a frame boundary `got_incomplete_bvnr_stream` at `finish`.

```rust
use bovnar::live::{FramePull, ValueFeed};
use bovnar::{ReadOptions, stream};

fn main() -> Result<(), bovnar::Error> {
    let wire = stream::frame_documents(&[&b".id = 1;"[..], b".id = 2;"])?;
    let mut frames = FramePull::new(0)?;
    let mut values = ValueFeed::new(false, &ReadOptions::default())?;
    let mut ids = Vec::new();
    for piece in wire.chunks(5) {
        let mut input = piece;
        while let Some(doc) = frames.next(&mut input)? {
            values.reset()?;
            values.feed(doc, &mut ids)?;
            values.finish(&mut ids)?;
        }
    }
    frames.finish()?;
    assert_eq!(ids.len(), 2);
    Ok(())
}
```

**Multiplexed streams.** `Demux::new(&DemuxOptions { max_message, key,
deliver_events, read })` reads the document that carries the stream, with the
key scope, `max_message`, the 4096-channel limit and every error of
`stream::mux_load`. Each `next` returns a `DemuxItem::Message { channel,
payload }` or, with `deliver_events`, a `DemuxItem::Event` for every event but
the stream's chunks, in document order: the metadata before the stream comes
out before its first message, and a `ValueBuilder` makes values of it (the
demux withholds the stream's chunks but not its start and end, so the builder
also hands out the stream's own key, as an empty `Value::Bytes`, once the
stream ends; Python's `DemuxFeed(values=True)` leaves it out). After
`finish()`, `next` on an empty slice hands out the closing events (more input is
refused with an `Error::Argument`); `reset()`
starts the next document: the channel buffers are kept, the channel ids (and
with them the 4096-channel count) start afresh, as `codec::MuxReader::reset`
does. `recovery_count()`, `skipped_bytes()` and `declared_version()` are those
of the demux's reader, as on `DocumentFeed`. `next_with(input, handlers)` and
`finish_with(handlers)` take a `ReadHandlers` for that reader, which parses
inside `next` and `finish`: its `on_verified` sees every verified event, the
stream's chunks included, before the demux does, and `on_unverified`,
`want_unit` and `on_error` are a `Reader`'s. The first call of a document
decides which of `on_verified`, `on_unverified` and `want_unit` its reader
calls, as for `DocumentFeed`.

```rust
use bovnar::live::{Demux, DemuxItem, DemuxOptions, ValueBuilder};
use bovnar::Writer;

fn main() -> Result<(), bovnar::Error> {
    // Metadata, the stream, and a value after it, as a C writer writes them
    let mut w = Writer::to_mem(1 << 16, false)?;
    w.begin_struct("meta")?;
    w.write_string("codec", "av1")?;
    w.end_struct()?;
    w.mux_begin("media")?;
    w.mux_send(1, b"key frame")?;
    w.mux_send(2, b"audio")?;
    w.mux_end()?;
    w.write_bool("complete", true)?;
    w.finish()?;
    let wire = w.get_output().to_vec();

    let options = DemuxOptions { key: Some("media".into()), deliver_events: true, ..DemuxOptions::default() };
    let mut demux = Demux::new(&options)?;
    let mut meta = ValueBuilder::new(false);
    let mut messages = Vec::new();
    for piece in wire.chunks(6) {
        let mut input = piece;
        while let Some(item) = demux.next(&mut input)? {
            match item {
                DemuxItem::Event(e) => meta.push(&e.data()),
                DemuxItem::Message { channel, payload } => {
                    if messages.is_empty() {
                        // Everything before the stream is here already
                        assert_eq!(meta.drain().next().map(|(k, _)| k).as_deref(), Some("meta"));
                    }
                    messages.push((channel, payload.to_vec()));
                }
            }
        }
    }
    demux.finish()?;
    while let Some(item) = demux.next(&mut &[][..])? {
        if let DemuxItem::Event(e) = item {
            meta.push(&e.data());
        }
    }
    assert_eq!(messages, [(1, b"key frame".to_vec()), (2, b"audio".to_vec())]);
    assert!(meta.drain().any(|(k, _)| k == "complete"));
    Ok(())
}
```

**Draining.** `FramePull::next` and `Demux::next` take `&mut &[u8]` and return
one item per call; unlike the codec's readers, they do not advance the slice past
each item. An item
that lies whole in the input is borrowed from it without a copy; one that spans
pieces or chunks is gathered in a buffer the reader keeps and reuses. Either way
it borrows the reader until the next call, which the borrow checker enforces.
The C object keeps a pointer into the bytes until they are drained, so pass the
same slice to every call until one returns `None` (which empties it): the slice
is not shortened meanwhile, another one is refused with an `Error::Argument`, and
so is `finish()` while bytes are left. A warm `Demux` or `FramePull` allocates
nothing per message or document.

**Embedded documents.** `Writer::embed_document(key, doc)` puts a document into
an open writer ([§11.5](#115-struct-helpers)), one after another into a
`to_writer` stream if you like. To read them while the outer document arrives,
hand its events to an `EmbeddedFeed`: it feeds each embedded document's chunks
to one reused `DocumentFeed` as they come and returns the document's value, or
its error, when its octet stream closes; `stream::parse_embedded` gathers the
bytes and starts a reader for every document instead.

```rust
use bovnar::live::{DocumentFeed, EmbeddedFeed};
use bovnar::{EventData, ReadOptions, Value, Writer};

fn main() -> Result<(), bovnar::Error> {
    let mut w = Writer::to_mem(4096, false)?;
    for n in 1..=3 {
        w.embed_document("payload", format!(".n = {n};").as_bytes())?;
    }
    w.finish()?;
    let outer = w.get_output().to_vec();

    let mut embedded = EmbeddedFeed::new("payload", false)?;
    let mut docs = Vec::new();
    let mut on_event = |d: &EventData<'_>| {
        docs.extend(embedded.push(d));
        true
    };
    let mut feed = DocumentFeed::new(&ReadOptions::default())?;
    for piece in outer.chunks(4) {
        feed.feed(piece, &mut on_event)?;
    }
    feed.finish(&mut on_event)?;
    assert_eq!(docs.len(), 3);
    assert_eq!(docs[2], Ok(Value::Struct(vec![("n".into(), Value::Int(3))])));
    Ok(())
}
```

**`live::Demux` or `codec::MuxReader`?** Both read a multiplexed stream as it
arrives, with the C demultiplexer's limits and error codes.

| | `codec::MuxReader` | `live::Demux` |
|---|---|---|
| Implementation | pure Rust, no C call per message | the C parser over the whole document |
| Document shape | one octet stream, after an optional text preamble, ending the document | any: values before and after the stream, other octet streams beside it (`key`) |
| The values around the stream | the preamble's bytes, parsed on request | every event, in order with the messages |
| Limits and policy | `max_message`, the preamble cap | `max_message` and every `ReadOptions` limit |
| Extras | peek, skip and forward a message without gathering it | — |
| Use it for | a dedicated stream, a router or a relay | documents of any other shape, or when the values around the stream matter |

**Errors** are those of the batch functions for the same document or mux
stream, as `Error::Parse`: with a position when the document failed, the
demultiplexer's code alone (`octet_stream_out_of_sync`, `value_out_of_range`,
`octet_stream_truncated`) when the stream did, as `mux_load` reports them. A
frame reader's errors carry no position, as `load_documents`' do not, and its
framing errors are `load_documents`' too: a bad magic is
`unexpected_input_byte`, a frame over the cap `value_out_of_range` and a stream
cut inside a frame `got_incomplete_bvnr_stream`. A panic in
a handler reaches the caller, as for `Reader` ([§13](#13-error-handling)).

**What it costs**, from `cargo bench --bench live`: a release build pinned to
one core (`taskset -c 4`) of the same i7-2700K as §4.8, load average under 2;
the median of three runs, each the median of nine passes. The input is a few
MiB held in the cache; *pieces* feeds it in 1460-byte pieces, a TCP segment's
payload, so most messages and documents straddle a feed. The `mux_load` and
`MuxReader` columns were measured in the same run as the rest of this table, not
taken from §4.8's, which is a separate run; compare figures within one table.
Per message:

| Message | `Demux` whole | `Demux` pieces | with `deliver_events` | `mux_load` | `MuxReader` whole | `MuxReader` pieces |
|---|---|---|---|---|---|---|
| 150 B | 100 ns | 109 ns | 100 ns | 123 ns | 8 ns | 14 ns |
| 30 KB | 150 ns | 2.4 µs | 182 ns | 2.9 µs | 11 ns | 1.8 µs |
| 400 KB | 26 µs | 50 µs | 26 µs | 68 µs | 23 µs | 28 µs |

A message whole in the fed buffer is borrowed, so its cost does not grow with
its size; one that straddles a feed or spans chunks is gathered, and copying it
is most of the cost, in the codec as in the demux. `mux_load` copies every
message into a `Vec`. The codec is about ten times cheaper per small message:
no C call and no grammar. Per document (a struct of integers and strings per
line, an octet stream every 512 lines), with the throughput in brackets:

| Document | `DocumentFeed` whole | `DocumentFeed` pieces | `Reader::read_mem` | `ValueFeed` | `parse` |
|---|---|---|---|---|---|
| 196 B | 16 µs (12 MB/s) | 16 µs (12 MB/s) | 16 µs (12 MB/s) | 20 µs (10 MB/s) | 27 µs (7 MB/s) |
| 32 KB | 2.0 ms (16 MB/s) | 1.9 ms (16 MB/s) | 2.0 ms (16 MB/s) | 2.4 ms (13 MB/s) | 2.5 ms (13 MB/s) |
| 402 KB | 24 ms (17 MB/s) | 24 ms (17 MB/s) | 24 ms (17 MB/s) | 29 ms (14 MB/s) | 30 ms (13 MB/s) |

Feeding costs what reading the whole buffer costs, wherever the pieces are cut:
the time is the parser's. `ValueFeed` hands each assignment out as it
completes. `parse` builds the whole document, and past 32 entries keeps an
index from key to entry instead of scanning the keys already there — which took
its 402 KB column from 113 ms to 30 ms.

### 4.10 Canonical form

`bovnar::canonicalize(data, pretty, &options)` re-serialises a document from its
verified events through the C library's canonicalising observer
(`bvnr_canon_observer_*`, doc/08 §3.6) and returns the bytes;
`canonicalize_reader(input, out, pretty, &options)` reads any `std::io::Read`
as it parses and writes to any `std::io::Write` as the output is made, in
64 KiB pieces, so neither is held whole.

```rust
use bovnar::{ReadOptions, canonicalize};

fn main() -> Result<(), bovnar::Error> {
    let options = ReadOptions::default();
    let canon = canonicalize(b".a = 1 ;  # one\n.b = { .c = \"x\"; };", false, &options)?;
    assert_eq!(canon, b".a=<uint:64,_10,no_unit>1;.b={.c=<utf8>\"x\";};");
    assert_eq!(canonicalize(&canon, false, &options)?, canon); // idempotent
    assert_eq!(canonicalize(b".a=1;.b={.c=\"x\";};", false, &options)?, canon);
    let mut out = Vec::new();
    bovnar::canonicalize_reader(&b"#!bovnar 1.1\n.a = 1;"[..], &mut out, true, &options)?;
    assert!(out.starts_with(b"#!bovnar 1.1\n"));
    Ok(())
}
```

It is the form `bovnar events -d` prints (`-p` for `pretty`): the compact one
is a single line with no whitespace outside literals, each value written with
the annotation the reader gives it (`1` as `<uint:64,_10,no_unit>1`). Two
documents that differ only in layout, comments and the spelling of separators
canonicalise to the same bytes, and canonicalising the output again gives it
back unchanged; the order of the keys is kept. Units are written as the
document spelled them: this is not `bovnar pretty-print --canonical`, which
writes through a writer under `UnitFlags::REDUCE | UnitFlags::NUM_CANONICAL`
and folds them to one form. A leading `#!bovnar M.N` directive is kept — from
an `std::io::Read` it is found in the first bytes read, before the reader sees
them.

The document is read under `options`, and one the reader refuses is its
`Error::Parse`. Under `continue_on_error` a document the reader had to recover
from is refused too, with the first error it recovered from (code and
position): the observer writes what it is fed, and the statement a recovery
leaves without its value would make the output unreadable. A failing `input`
is an `Error::Io` with `reading_from_source_fd`, a failing `out` one with
`writing_to_sink`, and a panic in either reaches the caller; after an error
`out` may hold the start of the output. The observer's C function is the
reader's `on_verified` itself, with the observer as its userdata, so no Rust
runs per event.

---

## 5. Unit helpers

The unit functions live in the `bovnar::units` module; `Unit` itself is at the
crate root.

```rust
use bovnar::Unit;
use bovnar::units::{unit_factor, unit_prefix_factor, unit_to_str};

fn main() -> Result<(), bovnar::Error> {
    // Parse a unit string into a Unit
    let u = Unit::parse("k~g·m/s²")?;

    // Convert a Unit back to its canonical string
    assert_eq!(unit_to_str(&u)?, "k~g·m/s²");
    assert_eq!(u.to_string(), "k~g·m/s²"); // Display is the canonical spelling too

    // Scalar SI/IEC PREFIX factor of a unit — the prefix only, never the
    // base unit's own factor.
    assert_eq!(unit_prefix_factor(&Unit::parse("M~Hz")?), 1_000_000.0);
    assert_eq!(unit_prefix_factor(&Unit::parse("in")?), 1.0); // NOT 0.0254 — the inch has no prefix
    assert_eq!(unit_prefix_factor(&Unit::parse("h")?), 1.0);  // NOT 3600.0

    // unit_factor() takes the unit as text and is the same prefix factor
    assert_eq!(unit_factor("M~Hz")?, 1_000_000.0);
    assert_eq!(unit_factor("in")?, 1.0);
    assert_eq!(unit_factor("h")?, 1.0);

    // The FULL factor to coherent SI is Unit::factor
    assert_eq!(Unit::parse("in")?.factor()?, 0.0254);
    assert_eq!(Unit::parse("h")?.factor()?, 3600.0);
    Ok(())
}
```

**`unit_factor` is the prefix scale, not the full factor.** It is
`unit_prefix_factor` on a unit given as text, and reports only what the prefixes
contribute: `1000000.0` for `M~Hz`, `1.0` for `in` and `h`. The full factor to
coherent SI is `Unit::factor` on the parsed unit (or `unit_to_si_factor`); it
fails for an affine unit (`°C`) or one with no SI factor.

`Unit` is the one unit type; every function that takes a unit takes a `&Unit`.

### 5.1 The unit-profile notations (spec 1.2)

**These notations are spec 1.2**: in a document they need a `#!bovnar 1.2`
directive, while the API entry points below have no document and take them
unconditionally. `Unit::parse` takes a profile notation as readily as the
native one, and returns the same `Unit` either way — so everything else in this
chapter works on the result unchanged. **Seven** namespaces are accepted —
`ucum:`, `unece:`, `qudt:`, `qudt-qk:`, `udunits:`, `om:` and `cf:` — but each
is a *compile-time* switch of the C library, so which of them a given build
carries is a question to ask rather than assume; see `available_profiles()`
below.

```rust
use bovnar::{ErrorCode, Unit};
use bovnar::units::{
    available_profiles, units_compatible, unit_error_code, unit_is_profile_only, unit_to_profile,
    unit_to_str, unit_to_ucum,
};

fn main() -> Result<(), bovnar::Error> {
    let vu = Unit::parse("ucum:mm[Hg]")?;
    assert_eq!(unit_to_str(&vu)?, "mmHg");               // the native canonical form
    assert_eq!(unit_to_ucum(&vu)?, "mm[Hg]");            // back to a UCUM code
    assert!(units_compatible(&vu, &Unit::parse("k~Pa")?));

    // Which profiles THIS build carries. Unit::parse fails with the same
    // Error::Argument for an absent namespace as for a bad unit;
    // unit_error_code() is what tells the two apart (49 against 32).
    assert_eq!(available_profiles(), ["ucum", "unece", "qudt", "qudt-qk", "udunits", "om", "cf"]);

    // The same unit in other vocabularies — all one Unit
    let kg = Unit::parse("k~g")?;
    assert_eq!(unit_to_profile("ucum", &kg)?, "kg");
    assert_eq!(unit_to_profile("unece", &kg)?, "KGM");
    assert_eq!(unit_to_profile("qudt", &kg)?, "KiloGM");
    assert_eq!(unit_to_profile("udunits", &kg)?, "kg");
    assert_eq!(kg.to_profile("unece")?, "KGM");           // the method form

    let iu = Unit::parse("ucum:[IU]/mL")?;
    assert!(unit_is_profile_only(&iu));                   // no native spelling exists
    assert_eq!(unit_to_str(&iu)?, "ucum:[IU].mL-1");

    let packaging = Unit::parse("unece:XBX")?;
    assert!(packaging.is_profile_only());              // a countable package
    assert_eq!(unit_to_str(&packaging)?, "unece:XBX");    // in its OWN namespace

    assert_eq!(unit_error_code("ucum:B[SPL]"), ErrorCode::UnitProfileUnsupported as i32); // 50
    assert_eq!(unit_error_code("udunits:days since 1970-01-01"), 50); // valid UDUNITS, not carryable
    assert_eq!(unit_error_code("nosuch:m"), ErrorCode::UnitProfileUnknown as i32);        // 49
    assert_eq!(unit_error_code("m/s"), 0);                // it parses
    Ok(())
}
```

`unit_to_profile` and `unit_to_ucum` fail rather than inventing a code, in three
situations. A native unit outside that vocabulary's transliteration table has no
form in it — the Old German units, the water-hardness degrees, the turbidity
kinds and every currency. An opaque unit belonging to a *different* profile has
none either: `[IU]` is UCUM's, so `unit_to_profile("unece", …)` refuses it. And a
**flat** vocabulary (`unece`, `qudt`, `qudt-qk`, `om`) can spell only a single
component — no product, quotient or exponent, though a prefixed one is fine
(`k~g` is `unece:KGM`) — because a flat code is one whole token with no operator
to build it out of — so `k~m/h` has no UNECE form even though `unece:KMH` parses
to exactly it.

In a **document** the notation additionally requires a `#!bovnar 1.2`
directive; without it `parse` fails with `unit_illegal`. `Unit::parse` has no
document and therefore no declared version, so it accepts the notation
unconditionally.

See [Unit Profiles](11_bovnar_unit_profiles.md) for the transliteration tables
and the codes that have no representation.

### 5.2 Extended unit functions

The following functions operate on `Unit` values and live in `bovnar::units`.

```rust
use bovnar::{BaseUnit, Exponent, IECPrefix, Prefix, Unit};
use bovnar::units::{
    convert_value, exponent_to_int, int_to_exponent, prefix_unit_valid, unit_convert_factor,
    unit_dimension_vector, unit_prefix_exponent, unit_prefix_factor, unit_reduce,
    unit_to_si_factor, unit_to_str_ex, unit_valid, units_compatible, UnitFlags,
};

fn main() -> Result<(), bovnar::Error> {
    let km = Unit::parse("k~m")?;
    let gib = Unit::parse("Gi~B")?;

    // Validate a Unit
    assert!(unit_valid(&km));

    // Prefix scale factor (SI or IEC) for a Unit
    assert_eq!(unit_prefix_factor(&km), 1000.0);
    assert_eq!(unit_prefix_factor(&gib), 2f64.powi(30));

    // Prefix exponent (base-10 for SI, base-2 for IEC)
    assert_eq!(unit_prefix_exponent(&km), 3);
    assert_eq!(unit_prefix_exponent(&gib), 30);

    // Validate a prefix for a base unit (IEC prefixes are only valid on bit/byte)
    let p = Prefix::Iec(IECPrefix::Gibi);
    assert!(prefix_unit_valid(p, BaseUnit::Byte));
    assert!(!prefix_unit_valid(p, BaseUnit::Meter));

    // Full SI conversion including affine terms (e.g. Celsius → Kelvin)
    let c = unit_to_si_factor(&Unit::parse("°C")?)?;
    assert!(c.is_affine);
    assert_eq!(c.affine_offset, 273.15);

    // Check convertibility: same SI dimension vector AND same quantity kinds
    assert!(units_compatible(&km, &Unit::parse("m")?));
    assert!(!units_compatible(&Unit::parse("b")?, &Unit::parse("B")?)); // the dimensions agree, the kinds do not

    // Conversion factor between two compatible units
    let conv = unit_convert_factor(&km, &Unit::parse("m")?)?;
    assert_eq!((conv.factor, conv.requires_affine, conv.ok()), (1000.0, false, true));

    // 7-element SI dimension exponent vector [m, kg, s, A, K, mol, cd]
    assert_eq!(unit_dimension_vector(&Unit::parse("m/s")?)?, [1, 0, -1, 0, 0, 0, 0]);

    // Fold every prefix out and combine repeated bases. NOT the named-SI collapse:
    // the reduced unit of k~g·m/s² is "m·g/s²", and it is
    // unit_to_str_ex(.., UnitFlags::REDUCE) that gives "N". See the warning in §5.3
    // before applying r.scale to a value you are also going to serialise.
    let r = unit_reduce(&Unit::parse("k~g·m/s²")?)?;
    assert_eq!(r.unit.to_string(), "m·g/s²");
    assert_eq!(r.scale, 1000.0);

    // Convert a scalar value between units (handles affine conversions)
    let kelvin = convert_value(25.0, &Unit::parse("°C")?, &Unit::parse("K")?)?;
    assert!((kelvin - 298.15).abs() < 1e-9);

    // ... or EXACTLY, as the reader's want_unit does: (BigRational, exact),
    // exact false for an irrational factor (° to rad)
    let v = num_rational::BigRational::from_integer(42.into());
    let (ms, exact) =
        bovnar::units::convert_rational(&v, &Unit::parse("k~m/h")?, &Unit::parse("m/s")?)?;
    assert_eq!(ms, num_rational::BigRational::new(35.into(), 3.into()));
    assert!(exact);

    // Serialise with formatting options (see UnitFlags below)
    let force = Unit::parse("k~g·m/s²")?;
    assert_eq!(unit_to_str_ex(&Unit::parse("m/s²")?, UnitFlags::ASCII_EXP)?, "m/s^2"); // ^N instead of superscripts
    assert_eq!(unit_to_str_ex(&force, UnitFlags::REDUCE)?, "N");                      // reduce first — but see the warning below
    assert_eq!(unit_to_str_ex(&force, UnitFlags::REDUCE | UnitFlags::ASCII_EXP)?, "N");

    // Exponent ↔ integer conversions
    assert_eq!(exponent_to_int(Exponent::NEG_SQUARE), -2);
    assert_eq!(int_to_exponent(-2), Exponent::NEG_SQUARE);
    Ok(())
}
```

`SI_DIM_NAMES` is the array `["m", "kg", "s", "A", "K", "mol", "cd"]` — the index
positions used by `unit_dimension_vector`. Both `SIConversion` (`factor`,
`is_affine`, `affine_offset`) and `UnitConversion` (`factor`,
`requires_affine`, and the method `ok()`, §13) are small `Copy` structs, and `ReducedUnit` is
`{ unit, scale }`.

### 5.3 `UnitFlags`

```rust
use bovnar::units::UnitFlags;

fn main() {
    assert_eq!(UnitFlags::NONE, 0);           // no special formatting
    assert_eq!(UnitFlags::REDUCE, 1);         // reduce to a canonical named SI unit before serialising
    assert_eq!(UnitFlags::ASCII_EXP, 2);      // use ^N exponent notation instead of Unicode superscripts
    assert_eq!(UnitFlags::NUM_CANONICAL, 4);  // writer only: a float REDUCE rescaled keeps float shape
}
```

`UnitFlags` is a namespace of `u32` constants, not an `IntFlag` type: OR them
together and pass the `u32`. The same flags configure a **writer**:
`Writer::to_mem_with(cap, pretty, UnitFlags::REDUCE, false)` rewrites each unit
as it is written *and* rescales the value with it — `2.5 k~g` goes out as
`<float:64,g> 2500`. `NUM_CANONICAL` is meaningful only there: it appends `.0` to
a rescaled float that came out as an integer string, so the canonical document
mode lands on one spelling.

> **`UnitFlags::REDUCE` returns the reduced unit, not a rescaled value.**
> Reduction folds every prefix out, so `unit_to_str_ex(&k~g, UnitFlags::REDUCE)`
> is `"g"` — a string denoting a quantity 1000× smaller than the unit you
> passed. Moving the value with it is your job. (The writer does it for you;
> nothing else does.)
>
> **Do not use `unit_reduce(&u)?.scale` for that.** It is the scale to the *fully
> reduced* unit, and this function does not always emit the fully reduced unit:
> where the reduction lands on a named SI unit the formatter re-attaches the
> prefix. `k~N` comes back `"k~N"` with nothing to rescale while `unit_reduce`
> still reports `1000.0`, and `k~g` comes back `"g"` where the `1000.0` must be
> applied. The two are indistinguishable from outside — both a lone unit
> carrying a kilo prefix — so take the scale from the unit that is actually
> emitted:
>
> ```rust
> use bovnar::Unit;
> use bovnar::units::{convert_value, unit_to_str_ex, UnitFlags};
>
> fn main() -> Result<(), bovnar::Error> {
>     let vu = Unit::parse("k~g")?;
>     let mut value = 2.5;
>     let emitted = Unit::parse(&unit_to_str_ex(&vu, UnitFlags::REDUCE)?)?;
>     value *= convert_value(1.0, &vu, &emitted)?; // 1.0 for k~N, 1000.0 for k~g
>     assert_eq!((emitted.to_string().as_str(), value), ("g", 2500.0));
>     Ok(())
> }
> ```
>
> The collapse never substitutes one named unit for another, so a `Sv` stays a
> `Sv` rather than becoming a `Gy`. A lone base at an exponent other than 1 does
> still collapse (`s⁻¹` → `"Hz"`, `m~s⁻¹` → `"k~Hz"`), and a reduction whose
> summed exponent leaves the `±100` range (`EXPONENT_MIN` / `EXPONENT_MAX`)
> fails with `Error::Argument` instead of returning a unit that lost a
> dimension — `m⁵⁰·m⁵⁰` reduces to `m¹⁰⁰`, `m⁵⁰·m⁵¹` fails.

### 5.4 `Prefix`

`Prefix` is one value for what the C struct `value_unit_prefix_t` splits into a
system and an id: an enum with an SI and an IEC variant. It is what `UnitComponent::prefix` holds:

```rust
use bovnar::{IECPrefix, Prefix, SIPrefix, Unit};

fn main() -> Result<(), bovnar::Error> {
    let p_si = Prefix::Si(SIPrefix::Kilo);
    let p_iec = Prefix::Iec(IECPrefix::Gibi);
    assert!(!p_si.is_none() && !p_iec.is_none());
    assert_eq!(Prefix::default(), Prefix::Si(SIPrefix::None));

    let vu = Unit::parse("Gi~B")?;
    let comp = vu.components()[0];
    assert_eq!(comp.prefix, p_iec);             // the Prefix of a component
    assert_eq!(comp.prefix, IECPrefix::Gibi);   // compares equal to the bare IEC prefix as well
    Ok(())
}
```

`Prefix::default()` is no prefix (`SIPrefix::None`), and an unprefixed component
equals whatever its prefix system.

### 5.5 Inline unit suffix

In addition to the unit embedded in a type annotation (`<float:64,m/s>`), the
Bovnar format supports an **inline unit suffix** placed directly after a scalar
value, before the terminating `;`:

```bovnar
.speed = 9.81 m/s;           # inline suffix, no annotation
.mass  = <float:64> 70.5 k~g;# annotation without unit, inline adopted
.dist  = <float:64,m> 1.5 m; # annotation and inline both say 'm' — valid
```

From the Rust layer, inline and annotation units are **identical**: both reach
the application as `d.value_unit` in the `Event::Data` payload. No extra code is
required to consume inline units.

The validator fails with `ErrorCode::UnitMismatch` (38, an `Error::Parse`) when
an inline suffix is present and a type-annotation unit is also present but the
two do not resolve to the same unit. Inline unit suffixes inside array elements
always fail with `ErrorCode::UnexpectedInputByte`.

### 5.6 `UnitPolicy` — validation and conversion without a callback

`Reader::set_unit_policy` states what the document must contain and what unit
values should arrive in. Everything is unit **text**, so unlike `want_unit` it
costs no per-value trampoline across the FFI boundary — the C side parses the
strings once, when the policy is set.

```rust
use bovnar::{EventData, ReadOptions, Reader, UnitPolicy};

fn main() -> Result<(), bovnar::Error> {
    let mut r = Reader::new()?;
    r.set_unit_policy(Some(&UnitPolicy {
        targets: vec![("m/s".into(), 0), ("°C".into(), 0)], // convert to the first one that fits
        normalise_si: true,    // ...and everything else to SI base units
        leave_inexact: true,   // step over what cannot be exact
        require_unit: true,    // reject any bare numeric value
        ..UnitPolicy::default()
    }))?;

    let mut delivered = Vec::new();
    let mut handler = |d: &EventData<'_>| {
        if d.converted {
            delivered.push(String::from_utf8_lossy(d.converted_text).into_owned());
        }
        true
    };
    r.read_mem(b".v = <float:64,k~m/h> 36.0;", &ReadOptions::default(), Some(&mut handler), None)?;
    assert_eq!(delivered, ["10"]);
    Ok(())
}
```

A policy survives re-reading the same reader on another document — it describes
the consumer, not the document — and `set_unit_policy(None)` clears it. Every
unit string is parsed by the call, so a typo fails with `Error::Argument` before
a byte is read, and a rejected policy leaves the previous one in force. The
struct is plain data: `UnitPolicy { rules, targets, normalise_si, base,
leave_inexact, require_unit, require_dimension_of }`, so
`..UnitPolicy::default()` fills what a policy leaves out. At most
`MAX_UNIT_TARGETS` (8) targets, `MAX_UNIT_RULES` (8) rules, and
`MAX_UNIT_PATH` (96) bytes of path are accepted.

**Conversion.** Each numeric value takes the first target it can validly convert
to, so order is significant: `["m", "k~m"]` never selects `k~m`. A value that
matches nothing, or that is already in the unit a target names, arrives
untouched — `d.converted` is what tells the two apart. Each target carries its
own output base as the second tuple element: `("m".into(), 16)`; `0` is decimal.

`normalise_si` catches whatever the targets did not, delivering it in coherent
SI base units with prefixes folded out. Currencies and every **dimensionless**
unit (`%`, `ppm`, `dB`, `pH`, `rad`, `°`) are left as written rather than
reinterpreted.

**A value with no unit only ever matches a target that is itself `no_unit`.** A
bare number is dimensionally compatible with `%` and `ppm`, so without that
fence a policy naming `"%"` would deliver `0.25` as `25`.

**Exactness.** Nothing approximate is ever delivered. `leave_inexact: true`
hands over a value the conversion cannot deliver exactly — an irrational
factor, a non-terminating expansion (`42 km/h` is `35/3 m/s`), an exponent past
the work limit — in its native unit instead of failing. It applies only to
policy-chosen targets; a `want_unit` hook keeps its strict all-or-nothing
contract.

**Validation.** `require_unit` rejects a document containing any bare numeric
value; `require_dimension_of: vec!["m".into()]` requires every numeric value to
be validly convertible to at least one listed unit. Both are evaluated on the
unit the **document** wrote, before any conversion, and both fail with
`ErrorCode::UnitMismatch`.

**Per-field rules** name one field by its key path, and are consulted before
everything else:

```rust
use bovnar::{UnitPolicy, UnitRule};

fn main() {
    let policy = UnitPolicy {
        rules: vec![
            UnitRule::new(".inlet.temperature", "°C"), // convert this field
            UnitRule { convert: false, ..UnitRule::new(".inlet.*", "m") }, // assert the subtree
        ],
        ..UnitPolicy::default()
    };
    assert_eq!(policy.rules.len(), 2);
}
```

A path ending in `".*"` names a subtree at any depth, and a prefix only
matches at a component boundary (`".in.*"` does not claim `".inlet.a"`). Unlike
a whole-document target, a rule is an **assertion**: a value it cannot be
applied to fails with `UnitMismatch`, because silence would defeat the point of
naming the field. `convert: false` asserts without converting.

**The DOM takes the same policy**, so a random-access consumer gets both halves:

```rust
use bovnar::{DomDoc, UnitPolicy, UnitRule};

fn main() -> Result<(), bovnar::Error> {
    let text = ".inlet = { .temperature = <float:64,°F> 212.0; };".as_bytes();
    let policy = UnitPolicy {
        rules: vec![UnitRule::new(".inlet.temperature", "°C")],
        ..UnitPolicy::default()
    };
    let doc = DomDoc::parse_with_policy(text, &policy)?;
    let t = doc.lookup("inlet.temperature").expect("present");
    assert_eq!(t.as_float(), Some(100.0)); // from a document in °F
    Ok(())
}
```

A value the policy converted is STORED converted — digits, unit and base — and
an integer that converts to a fraction (5 g in kilograms is 0.005) is stored as
a float. A policy the library refuses is an `Error::Argument` — the same error,
naming the same entry, as `Reader::set_unit_policy` — before anything is
parsed, rather than a parse error, so a mistake in the policy is never mistaken
for a fault in the document.

**The writer takes the same policy, validation half only.** A reader can only
reject a document somebody already wrote; `Writer::set_unit_policy` is what
stops a bare number reaching the file:

```rust
use bovnar::{BaseUnit, Exponent, Quantity, SIPrefix, Unit, UnitPolicy, ValueTypeFamily, ValueTypeSpec, Writer};

fn main() -> Result<(), bovnar::Error> {
    let mut w = Writer::to_mem(4096, true)?;
    w.set_unit_policy(Some(&UnitPolicy {
        require_unit: true,
        require_dimension_of: vec!["m".into()],
        ..UnitPolicy::default()
    }))?;

    let float64 = ValueTypeSpec { family: ValueTypeFamily::Float as i32, width: 64, base: 0 };
    let inches = Unit::parse("in")?;
    w.write_quantity("tank_level", &Quantity::new("12.0", float64, inches))?; // a length — written
    let err = w.write_quantity("spare", &Quantity::new("0.25", float64, Unit::default())).unwrap_err();
    assert!(matches!(err, bovnar::Error::Write { .. })); // code UnitMismatch
    let _ = (BaseUnit::Meter, Exponent::LINEAR, SIPrefix::Kilo); // see §5.7 for building units
    Ok(())
}
```

A policy carrying `targets`, `normalise_si`, `base` or `leave_inexact` fails with
`Error::Argument` there rather than being half-honoured: write-side unit
rewriting is `UnitFlags::REDUCE`'s job — open the writer with
`Writer::to_mem_with(cap, pretty, UnitFlags::REDUCE, false)` — and two rewriting
modes with different rules about exactness is how a document ends up meaning
two things.

Module-level helpers back the same decisions outside the reader:

```rust
use bovnar::Unit;
use bovnar::units::{unit_si_normal_form, units_compatible, units_convertible};

fn main() -> Result<(), bovnar::Error> {
    let (kusd, usd) = (Unit::parse("k~$USD")?, Unit::parse("$USD")?);
    // units_compatible is the wrong screen for a conversion target: a currency
    // carries no dimension, so it is reported incompatible with itself.
    assert!(!units_compatible(&kusd, &usd));
    assert!(units_convertible(&kusd, &usd));

    assert_eq!(unit_si_normal_form(&Unit::parse("in")?), Some(Unit::parse("m")?));
    assert_eq!(unit_si_normal_form(&Unit::parse("g")?), Some(Unit::parse("k~g")?)); // the SI base unit for mass
    assert_eq!(unit_si_normal_form(&Unit::parse("%")?), None);                      // dimensionless
    assert_eq!(unit_si_normal_form(&Unit::parse("lm")?), None);                     // carries the steradian's kind
    Ok(())
}
```

**Three questions, in decreasing strictness.** `units_equal` asks whether the
two are the *same* unit, `units_compatible` whether their dimensions and
quantity kinds line up, `units_convertible` whether a conversion can actually be
performed. Only the last is the right screen for a conversion target.

```rust
use bovnar::Unit;
use bovnar::units::units_equal;

fn main() -> Result<(), bovnar::Error> {
    let p = Unit::parse;
    assert!(units_equal(&p("m")?, &p("m")?));
    assert!(!units_equal(&p("m")?, &p("k~m")?));             // convertible, not equal
    assert!(!units_equal(&p("N")?, &p("k~g·m/s²")?));        // same SI form
    assert!(units_equal(&p("mmHg")?, &p("ucum:mm[Hg]")?));
    Ok(())
}
```

`units_equal` is also what `==` means between two `Unit`s, with one addition:
"no unit at all" (a typed value's empty unit) and a dimensionless unit (a bare
literal's) are both the absence of one and compare, and hash, equal, as the
Python binding's do. `Unit` is `Eq` and `Hash` with the two agreeing — equal units are one `HashMap` key regardless
of the order their components were written in. The hash follows the equality in
the one place the two could part: a component with **no prefix** is the same
component whether its prefix system says SI or IEC, so `Unit::parse("B")` and
`Unit::iec(BaseUnit::Byte, IECPrefix::None, Exponent::LINEAR)` are equal *and*
one key.

A `Unit` also compares equal to a **string** that parses to it
(`unit == "k~m"`, via `PartialEq<str>` and `PartialEq<&str>`); a string that does
not parse is simply unequal. That is a convenience for comparison only and is
**not hash-compatible** — `"km"`, `"k~m"` and `"ucum:km"` are three strings with
three hashes and one unit. Key a map or set on `Unit` values (or on
`unit.to_string()`), never on a mix of the two.

`Unit::convert_factor(&other)` and `Unit::factor()` return a bare number, so
they **fail** with `Error::Argument` where there is no such number: an affine
pair (`°C` → `K`) has no multiplicative factor and an affine unit no SI factor.
Use `convert_value()` there, or `unit_to_si_factor()` for the scale and the
offset.

### 5.7 Building a `Unit`

`Unit::parse` builds a unit from text. To build one programmatically — without
going through the notation — use the constructors on `Unit`:

```rust
use bovnar::{BaseUnit, Exponent, IECPrefix, SIPrefix, Unit, UnitComponent};

fn main() -> Result<(), bovnar::Error> {
    let kg = Unit::si(BaseUnit::Gram, SIPrefix::Kilo, Exponent::LINEAR);
    assert_eq!(kg.to_string(), "k~g");
    let m2 = Unit::si(BaseUnit::Meter, SIPrefix::None, Exponent::SQUARE);
    assert_eq!(m2.to_string(), "m²");
    let gib = Unit::iec(BaseUnit::Byte, IECPrefix::Gibi, Exponent::LINEAR);
    assert_eq!(gib.to_string(), "Gi~B");

    let newton = Unit::compound(&[
        UnitComponent::si(BaseUnit::Gram, SIPrefix::Kilo, Exponent::LINEAR),
        UnitComponent::si(BaseUnit::Meter, SIPrefix::None, Exponent::LINEAR),
        UnitComponent::si(BaseUnit::Second, SIPrefix::None, Exponent::NEG_SQUARE),
    ])?;
    assert_eq!(newton.to_string(), "k~g·m/s²");
    Ok(())
}
```

The C constructors `make_unit_si`, `make_unit_iec` and `make_unit_compound` are
`Unit::si`, `Unit::iec` and `Unit::compound`; the prefix and exponent are always
passed (`SIPrefix::None`, `Exponent::LINEAR` for a plain base unit), and
`Unit::compound` fails with `Error::Argument` beyond `MAX_UNIT_COMPONENTS` (32)
components.

**`Unit::default()` and `Unit::dimensionless()` are not the same thing**, even
though both render as `no_unit`. The first (the C `make_unit_none()`) is a
unit with no components at all — a value carrying no unit. The second (the C
`make_unit_dimensionless()`) is one component whose base is `BaseUnit::None` — a
value carrying `no_unit` explicitly. That is exactly the pair
`UnitPolicy { require_unit: true, .. }` refuses together (§5.6):

```rust
use bovnar::Unit;
use bovnar::units::unit_to_str;

fn main() -> Result<(), bovnar::Error> {
    let (none, dimensionless) = (Unit::default(), Unit::dimensionless());
    assert_eq!(unit_to_str(&none)?, "no_unit");
    assert_eq!(unit_to_str(&dimensionless)?, "no_unit");
    assert_eq!((none.num_components(), dimensionless.num_components()), (0, 1));
    assert!(Unit::is_dimensionless(&none) && Unit::is_dimensionless(&dimensionless));
    // Display, unlike unit_to_str, spells a unit-less value as nothing at all
    assert_eq!(none.to_string(), "");
    Ok(())
}
```

`Unit::is_dimensionless()` is true for both of them and for any unit made only of
`none` components; `%` and `ppm` are real units and answer false.

### 5.8 Validators and literal helpers

`bovnar::literals` binds the C library's checks and literal helpers
(`bovnar.h`): the tests the writer applies to keys, symbols, references and
numbers, the integer and float parsers and formatters behind it, and the parser
of a type annotation. Each function is the C function of the same name without
its `bvn_` prefix (`escape_repl_table` is `bvn_get_escape_repl_table`), and
answers as it does. A string holding NUL is none of the things they check: a
validator answers `false` for it, a parser fails — except `validate_string`,
which takes bytes and checks only that they are UTF-8, so NUL passes.

**Type annotations.** `parse_type_annotation(text)` parses the text between the
angle brackets, as the reader does, into a `TypeAnnotation` of `value_type` (a
`ValueTypeSpec`), `unit` (a `Unit`, `Unit::default()` when there is no unit or it
is spelled `no_unit`) and `unit_source` (the unit parameter as written, `""` when
there is none). The struct is `#[non_exhaustive]`, so that a field can be added
to it without breaking anyone.

```rust
use bovnar::literals::parse_type_annotation;
use bovnar::{ErrorCode, ValueTypeFamily, ValueTypeSpec};

fn main() -> Result<(), bovnar::Error> {
    let a = parse_type_annotation("uint:32,_16,k~g")?;
    let spec = ValueTypeSpec { family: ValueTypeFamily::Uint as i32, width: 32, base: 16 };
    assert_eq!(a.value_type, spec);
    assert_eq!(a.unit.to_string(), "k~g");
    assert_eq!(a.unit_source, "k~g");
    let ml = parse_type_annotation("float:64,ucum:mL{total}")?;
    assert_eq!((ml.unit.to_string(), ml.unit_source.as_str()), ("m~L".into(), "ucum:mL{total}"));

    // A base given twice; a unit parameter over 255 bytes.
    let e = parse_type_annotation("uint:8,_16,_16").unwrap_err();
    assert_eq!(e.code(), Some(ErrorCode::IllegalValueType));
    let e = parse_type_annotation(&format!("uint:8,{}", "m".repeat(256))).unwrap_err();
    assert_eq!(e.code(), Some(ErrorCode::UnitTooLong));
    Ok(())
}
```

A refusal is an `Error::Parse` (with no position) whose code is the one the
reader gives the same annotation in a document, so it tells the C function's
three flags apart:

| Code | C flags | Cause |
|---|---|---|
| `IllegalValueType` | `type_ok` false | An unknown family, an empty or repeated parameter, a parameter the family does not take (a base on `float_fix`, a width on `bool`), a width or base out of range |
| `UnitTooLong` | `unit_ok` false, `unit_too_long` true | A unit parameter over 255 bytes |
| `UnitIllegal`, `UnitProfileUnknown`, `UnitProfileUnsupported` | `unit_ok` false | A unit parameter that is not a unit this build knows; the code is the one `units::unit_error_code` gives it |

The reader's version gates are not applied: `datetime` (spec 1.1) and a profile
unit (spec 1.2) parse here whatever version a document declares. A `datetime`'s
epoch is no unit: its index is in `value_type.base` (`datetime_epoch_index`,
§18), as a `float_fix`'s q is.

**Validators.** Each returns a `bool`:

| Function | `true` for |
|---|---|
| `validate_identifier(s)` | A key: an ASCII letter, `_` or a character from U+00C0 on, then no space, control character, U+0080 to U+00BF, or punctuation spec §4.2 forbids (`-` and `+` are allowed). No length limit: the reader's `max_identifier_length` (255) is separate |
| `validate_symbol(s)` | A symbol, which is spelled as a key is |
| `validate_reference(s)` | A path such as `.a.b[0]`, without the `&`: identifier segments, each with optional `[digits]` indexes |
| `validate_number(s)` | A decimal number: an optional `-`, digits with at most one `.`, an optional `e`/`E` exponent. No `+`, and not `inf` |
| `is_special_number_string(s)` | Exactly `inf`, `ninf` or `nan` |
| `validate_digits_for_base(s, base)` | An integer literal: an optional `-` and digits of `base` (no sign at all in bases 64 and 85, where `+` and `-` are digits) |
| `validate_number_in_base(s, base)` | A quoted float literal: either sign, digits of `base` with at most one `.`, and an exponent marked `e` (bases up to 14) or `p` (the power-of-two bases up to 16) |
| `validate_uint_range(s, width, base)` / `validate_sint_range(s, width, base)` | A literal that fits `width` bits (0: any width); `inf`, `ninf` and `nan` pass |
| `validate_string(bytes)` | Bytes that are well-formed UTF-8 |

Two of them are narrower than their names. `validate_digits_for_base` does not
check the base against the ones a type may declare: in base 1 the one digit is
`0`. And `validate_string` is the same test as `std::str::from_utf8`; NUL and
the other control bytes the writer refuses in a string pass it. The range
checks are meant for text the digit check has passed: they refuse an empty
literal, and in base 10 anything but digits after an optional sign
(`validate_uint_range(" 5", 8, 10)` is `false`), but they take a leading `+`.

**Digits.** `char_to_digit(ch, base)` is the value of one digit, `None` when it
is no digit of `base`: up to base 36 a letter's case does not matter, above it
`A` to `Z` are 36 to 61, and bases 64 and 85 use the Base64 and Ascii85
alphabets. `min_digits_for_type(vt)` is how many digits of the type's base the
largest value of an integer type of up to 64 bits takes; 0 for a width of 0 or
above 64, and a float type is answered as if it were an unsigned integer of its
width.

**Parsing and formatting.** These are the functions the writer and the DOM use.
Each returns `Err(Error::Argument)` where the C one returns `false` or -1:

| Function | Result |
|---|---|
| `parse_int64(s, vt)` / `parse_uint64(s, vt)` | The integer literal in the type's base; refused when it does not fit the type's width (64 when it declares none), and for a `+` or a space before it |
| `parse_double(s, vt)` / `parse_double_in_base(s, base)` | The float literal (base 10 or 16; `inf`, `ninf` and `nan`, not `-inf`) as the nearest `f64`, rounded once from its exact value |
| `format_uint64(value, base, min_digits)` / `format_int64(...)` | The digits in base 2 to 62, 64 or 85, padded with zero digits to `min_digits` (which the C function caps at 64). A negative value has no form in bases 64 and 85 |
| `format_double(value, vt)` | The literal the writer emits under `vt`: the shortest digits that read back to the same `f64`, in base 10 or 16 (any other base fails). A `float:16` or `float:32` value is first rounded to IEEE binary16 or binary32 (`2049.0` is `2.048e+3` under `float:16`) and printed with the digits of a 16- or 32-bit significand, so it reads back as that value without rounding to the format (`65504.0` is `6.5504e+4`); a finite value the format cannot hold fails |
| `escape_repl_table()` | A `&'static [u8; 256]`: for each byte, the byte the escape `\` + that byte stands for, 0 for none. The escapes are `t`, `n`, `v`, `f`, `r`, `"` and `\`; the `\x` and `\u` escapes of spec 1.1 take digits and are not in it |

```rust
use bovnar::literals;
use bovnar::{ValueTypeFamily, ValueTypeSpec};

fn main() -> Result<(), bovnar::Error> {
    assert!(literals::validate_identifier("max-speed"));
    assert!(!literals::validate_identifier("max.speed"));
    assert!(literals::validate_reference(".axis[2].unit"));
    assert!(!literals::validate_number("+1"));
    assert!(!literals::validate_uint_range("256", 8, 10));
    assert_eq!(literals::char_to_digit('Z', 62), Some(61));

    let u8t = ValueTypeSpec { family: ValueTypeFamily::Uint as i32, width: 8, base: 0 };
    let f64t = ValueTypeSpec { family: ValueTypeFamily::Float as i32, width: 64, base: 0 };
    assert_eq!(literals::parse_uint64("255", u8t)?, 255);
    assert!(literals::parse_uint64("256", u8t).is_err());
    assert_eq!(literals::format_double(0.1, f64t)?, "1e-1");
    assert_eq!(literals::parse_double("1e-1", f64t)?, 0.1);
    assert_eq!(literals::format_uint64(5, 2, 8)?, "00000101");
    assert_eq!(literals::escape_repl_table()[usize::from(b't')], b'\t');
    Ok(())
}
```

> **No `0x` in base 16.** The integer parsers take what the reader takes, so
> `parse_uint64("0x10", …)` under `uint:64,_16` fails, though `strtoull`, which
> they read with, would skip the prefix.

`bvn_looks_like_double` is not bound. It answers whether the text contains one
of `.`, `e`, `E`, `p` and `P`, so `deadbeef` looks like a float to it and `inf`
does not: a shortcut for the writer's own output, not a test of text from
anywhere else.

---

## 6. `Quantity`

`Quantity` is a typed, unit-annotated scalar value that preserves the original
text representation, type width, numeral base, and physical unit across a
`parse_typed` / `to_string` round trip.

### 6.1 Construction

`Quantity` is normally produced by `parse_typed` rather than constructed by
hand, but direct construction is supported. Its fields are public. The type is a
`ValueTypeSpec` — a plain struct of the family (a `ValueTypeFamily` as its C
integer), the bit width and the numeral base:

```rust
use bovnar::{Quantity, Unit, ValueTypeFamily, ValueTypeSpec};

fn main() -> Result<(), bovnar::Error> {
    let vt = ValueTypeSpec { family: ValueTypeFamily::Float as i32, width: 32, base: 0 };
    let q = Quantity::new("101325.0", vt, Unit::default());       // dimensionless
    let q2 = Quantity::new("9.81", vt, Unit::parse("m/s²")?);     // with a unit
    assert_eq!(q.unit_str(), "");
    assert_eq!(q2.unit_str(), "m/s²");
    Ok(())
}
```

For exact numeric input there is also `Quantity::from_number`, which stores the
value as an exact decimal literal (so writing it with `to_string` is lossless to
the format's precision):

```rust
use bovnar::{Decimal, Quantity, Unit, ValueTypeFamily};
use num_rational::BigRational;

fn main() -> Result<(), bovnar::Error> {
    let price: Decimal = "19.99".parse()?;
    Quantity::from_number(price, ValueTypeFamily::FloatDec, 64, None, Unit::default())?;

    let one_point_one: Decimal = "1.1".parse()?;
    Quantity::from_number(one_point_one, ValueTypeFamily::Float, 128, None, Unit::default())?;

    // A float_fix takes its fractional bits: 837/256 is 3.26953125
    let ratio = BigRational::new(837.into(), 256.into());
    Quantity::from_number(ratio, ValueTypeFamily::FloatFix, 32, Some(8), Unit::default())?;
    Ok(())
}
```

It takes an `impl Into<ExactNumber>`: a literal (`&str` or `String`, used
verbatim), a `Decimal`, a terminating `BigRational`, a `BigInt`, or an `f64`
(only as precise as the double). It validates the width for the chosen family.
For `float_dec` it also refuses a finite value past the width's range — `1e400`
does not fit `float_dec:64` and would materialise to infinity — and a
non-finite `f64` is stored under the format's own spelling (`inf`, `ninf`,
`nan`). A `float_fix` needs its `frac` (`Some(q)`).

### 6.2 Fields and methods

`Quantity` is a plain struct: the properties that merely hold a value are public
fields and the others are methods:

| Name | Type | Description |
|---|---|---|
| `q.raw` | `Option<String>` | Original text token as it appeared in the BVNR stream; `None` for a typed null |
| `q.vtype` | `ValueTypeSpec` | Type family, bit width, and numeral base |
| `q.unit` | `Unit` | Physical unit; `q.unit.to_string()` is its canonical text |
| `q.token_type` | `TokenType` | How the literal was written: number, quoted string, symbol, ... |
| `q.frac` | `Option<String>` | For a `datetime` written as a literal with a fractional second, the verbatim sub-second digits (spec 1.1) |
| `q.unit_source` | `Option<String>` | The unit exactly as the document spelled it (library 2.0) — `ucum:mL{total}` where `q.unit` is `m~L`. Set by `parse_typed`; `to_string()` writes it back, and refuses one that does not parse to `q.unit` — including a spelling on a `Quantity` that has no unit at all. `None` writes the canonical form |
| `value()` | `Value` | Decode `raw` to the closest plain value (`Int`, `Float`, `String`, `Bool`) — **lossy** for `float_dec` / `float_fix` / `float:128`+ (goes through a C `double`) |
| `q.family()` | `Option<ValueTypeFamily>` | The type family, when it is one this binding knows |
| `unit_str()` | `String` | Canonical unit string (e.g. `"m/s²"`), or `""` when dimensionless |
| `decimal()` | `Result<Option<Decimal>>` | **Exact** value as a `Decimal` from the verbatim literal — lossless at any width; an error for non-numeric families, `None` for a typed null |
| `fraction()` | `Result<Option<BigRational>>` | Exact value as a rational (for `float_fix`, the exact `mantissa / 2**frac`). An error for `nan`/`inf`, and for a literal whose decimal exponent is past `bovnar::FRACTION_EXPONENT_CAP` ([§6.3](#63-exact-numbers-decimal-bigrational-bvnfloat)) |
| `fixed_point()` | `Result<(BigInt, u32)>` | `(mantissa, frac_bits)` of a `float_fix` value; the value is `mantissa / 2**frac_bits`. An error for a typed null and for `nan`/`inf`/`ninf`, which a fixed-point datum has no encoding for |
| `stored_value()` | `Result<Option<Decimal>>` | The value materialised into the declared IEEE/fixed format (round-to-nearest-even) — differs from `decimal` only when the literal carries more precision than the format holds. `None` for a typed null, as `decimal` is |
| `ieee_bits()` | `Result<Vec<u8>>` | IEEE-754 interchange bytes (binary16…256 for `float`, decimal16…256 for `float_dec`), little-endian word order |
| `epoch_name()` | `Option<String>` | For a `datetime`, the epoch name (`"unix"`, `"tai"`, …); `None` otherwise |
| `epoch_mjd()` | `Option<i32>` | For a `datetime`, the epoch's Modified Julian Day; `None` otherwise |
| `to_civil()` | `Result<CivilDateTime>` | For a `datetime`, its UTC date and time ([§6.5](#65-civil-time-and-calendars)); an error otherwise |
| `type_key()` | `(i32, u32, u32)` | `(family, width, base)` with the defaults filled in: width 0 is 64, base 0 is 10; a `float_fix` Q and a `datetime` epoch as stored |

Two `Quantity`s are equal when they carry the same literal, sub-second digits,
effective type (`type_key()`) and unit: `<float:0> 2.5`, `<float:64,_10> 2.5`
and a bare `2.5` are one value, and the token form and unit spelling are
presentation — the same rule as the Python binding's `Quantity.__eq__`.

### 6.3 Exact numbers (`Decimal`, `BigRational`, `BvnFloat`)

`value()` decodes through a C `double`, which loses precision for the
decimal-float, fixed-point, and wide binary-float families. The accessors above
instead use the verbatim literal text (and bovnar's arbitrary-precision
`bvn_float`), so the full precision the format carries is reachable from Rust.

An integer is `i128` in a `Value` (`Value::BigInt` past it) and
`num_bigint::BigInt` where a number has no width limit, a rational is `num_rational::BigRational`, and a decimal is
**`bovnar::Decimal`**: an exact decimal — sign, coefficient (`BigUint`) and
exponent, or an infinity or a NaN — that compares numerically and prints in the
General Decimal Arithmetic scientific-string form (`1.50` stays `1.50`). `Decimal` is the exact value
only; it does no arithmetic.

```rust
use bovnar::{Decimal, Value, parse_typed};

fn main() -> Result<(), bovnar::Error> {
    let doc = parse_typed(b".p=<float_dec:64> 3.141592653589793238462643383279503;")?;
    let Value::Struct(fields) = doc else { unreachable!() };
    let Value::Quantity(p) = &fields[0].1 else { unreachable!() };

    // Quantity::value is lossy: a C double
    assert_eq!(p.value(), Value::Float(3.141592653589793));
    // the literal, every digit of it ...
    let literal: Decimal = "3.141592653589793238462643383279503".parse()?;
    assert_eq!(p.decimal()?, Some(literal));
    // ... and the value decimal64 stores: 16 digits, rounded half to even
    assert_eq!(p.stored_value()?.map(|d| d.to_string()), Some("3.141592653589793".into()));
    assert_eq!(p.ieee_bits()?.len(), 8);

    let doc = parse_typed(b".x=<float_fix:32,q8> 3.27;")?;
    let Value::Struct(fields) = doc else { unreachable!() };
    let Value::Quantity(f) = &fields[0].1 else { unreachable!() };
    assert_eq!(f.fraction()?, Some(num_rational::BigRational::new(837.into(), 256.into())));
    assert_eq!(f.fixed_point()?, (837.into(), 8));
    Ok(())
}
```

These materialise over the **full** representable range, including exponents
the C parser cannot otherwise reach (beyond ~1e9865). Overflow and underflow
past the format's range are decided from the decimal exponent, so a 28-byte
`<float:64> 1e-99999999` is `0.0` at once rather than an exact rational with a
hundred-million-digit denominator. `fraction()` has no format to round into: it
refuses a literal whose decimal exponent, of its magnitude or of its last digit,
is past **`bovnar::FRACTION_EXPONENT_CAP`** (100 000; `float_dec:256`, which
reaches 10⁻⁶¹¹⁸⁶⁷, gets 611 937), the same cap as the Python binding's.
`decimal()` is exact at any exponent. `decimal` / `fraction`
work at every binary width bovnar allows (16, or a multiple of 32 up to 32768);
the bit-exact `stored_value` / `ieee_bits` apply to the IEEE encodings
(`float:16/32/64/128/256`), which are also exposed directly as `bovnar::BvnFloat`.

**`BvnFloat` and the `bvnfloat` module** reach the
library's arbitrary-precision float: the IEEE binary and decimal interchange
encodings and the fixed-point datum, plus the exact materialisers used past the
C parser's exponent cap.

```rust
use bovnar::BvnFloat;

fn main() -> Result<(), bovnar::Error> {
    let f = BvnFloat::from_str("0.1", 10, 256)?;     // text, base, precision in bits
    assert!(!f.is_nan() && !f.is_inf() && !f.is_zero());
    assert_eq!(f.to_double(), 0.1);
    let bits = f.to_binary_bits(32)?;                // binary32, little-endian
    assert_eq!(bits.len(), 4);
    assert_eq!(BvnFloat::from_binary_bits(32, &bits)?.to_double(), 0.1f32 as f64);
    Ok(())
}
```

**Exactness.** The `stored_value` of a `float_fix` is exact, never rounded to a
working precision, however wide the datum; the same goes for the literal of a
terminating `Fraction`.

### 6.4 `to_string()` integration

`to_string` dispatches on `Value::Quantity` before the plain `Int` / `Float`
path, so any value that came from `parse_typed` can be passed directly to
`to_string` and will produce identical output:

```rust
use bovnar::{parse_typed, to_string};

fn main() -> Result<(), bovnar::Error> {
    let bvnr = b".speed = <float:32,m/s> 9.81;";
    let doc = parse_typed(bvnr)?;
    let out = to_string(&doc, false)?;
    assert_eq!(out, b".speed=<float:32,m/s>9.81;");
    assert_eq!(parse_typed(&out)?, doc);
    Ok(())
}
```

The annotation is left out where it says nothing the literal does not — i.e.
when the type is `float:64` with no unit and no non-decimal base (matching the C
library's own default-annotation omission rules).

`to_string` also accepts a bare `Value::Decimal` (written as an exact
`float_dec:64` literal) and a terminating `Value::Fraction` — a non-terminating
fraction (e.g. `1/3`) fails with `Error::Argument`, as does a `Decimal` outside
the range of `float_dec:64`. An array whose elements are all `Decimal` /
`Fraction` is written under one `<float_dec:64>` annotation, so the elements
mean what the scalars mean:

```rust
use bovnar::{Value, to_string};
use num_rational::BigRational;

fn main() -> Result<(), bovnar::Error> {
    let half = BigRational::new(1.into(), 2.into());
    let doc = Value::Struct(vec![("h".into(), Value::Fraction(half))]);
    assert_eq!(to_string(&doc, false)?, b".h=<float_dec:64>0.5;");

    let third = BigRational::new(1.into(), 3.into());
    let bad = Value::Struct(vec![("t".into(), Value::Fraction(third))]);
    assert!(matches!(to_string(&bad, false), Err(bovnar::Error::Argument { .. })));
    Ok(())
}
```

**Arrays of `Quantity`.** An array has one annotation. Elements that agree on
type and unit are written under it. Elements that agree on the unit but not
the type — or that carry no unit and differ in type, `[<uint:8> 1, <float:64>
2.5]` — are each written under their own annotation, as they were read.
Elements that carry *different* units have no valid form and fail.

**Octet streams.** `parse()` returns an octet stream as `Value::Bytes`, and
`to_string()` writes `Value::Bytes` back as one, so a document holding one
round-trips.

**What "can be passed directly to `to_string()`" does not promise** is that the
result passes the *document* tier: `to_string()` writes what `parse()` reads,
and `parse()` reads heterogeneous and ragged arrays that `DomDoc::parse` and
`bovnar validate` refuse. See the note in [§4.1](#41-high-level-api-parse--to_string).


### 6.5 Civil time and calendars

A `datetime` value is a count of seconds since its epoch
([§18](#18-spec-11-additions)). `bovnar::timekeeping` turns such counts into
civil dates and times and back, and carries the rest of the C library's
calendar: `bvn_datetime.h` and `bvn_gregorian_date.h`. Every result is computed
by the C functions, so the crate gives the answers the reader and writer use
when they read and print an ISO literal. Nothing is read from the host's time
zone or clock, so the module behaves the same on every target, `wasm32-wasip2`
included.

```rust
use bovnar::timekeeping::{self, CivilDateTime, Epoch, GregorianDate};
use bovnar::{Value, parse_typed};

fn main() -> Result<(), bovnar::Error> {
    let doc = parse_typed(b"#!bovnar 1.1\n.t = <datetime:64,tai> 1861920036;")?;
    let Value::Struct(fields) = doc else { unreachable!() };
    let Value::Quantity(t) = &fields[0].1 else { unreachable!() };
    assert_eq!(t.to_civil()?.to_string(), "2016-12-31T23:59:60");

    let leap = CivilDateTime::new(2016, 12, 31, 23, 59, 60)?;
    assert_eq!(timekeeping::utc_to_tai_seconds(&leap)?, 1_861_920_036);
    let next = timekeeping::tai_seconds_to_utc(1_861_920_037)?;
    assert_eq!(next.to_string(), "2017-01-01T00:00:00");
    let unix = timekeeping::epoch_seconds_to_utc(1_750_000_000, Epoch::Unix)?;
    assert_eq!(unix.to_string(), "2025-06-15T15:06:40");

    // Germany: UTC+1, one hour more in summer
    let t = timekeeping::utc_to_tai_seconds(&CivilDateTime::new(2024, 7, 1, 12, 0, 0)?)?;
    let dst = timekeeping::tai_second_is_dls_in_europe(t);
    let local = timekeeping::tai_seconds_to_local_time(t, 3600, dst)?;
    assert_eq!(local.to_string(), "2024-07-01T14:00:00");

    assert_eq!(GregorianDate::new(2000, 1, 1)?.to_mjd(), 51544);
    assert!(GregorianDate::new(2024, 2, 30).is_err());
    assert_eq!(GregorianDate::normalize(2024, 2, 30)?.to_string(), "2024-03-01");
    assert_eq!(timekeeping::easter_sunday(2025)?.to_string(), "2025-04-20");
    let good_friday = GregorianDate::new(2024, 3, 29)?;
    assert!(good_friday.is_federal_holiday_in_germany());
    let march = GregorianDate::new(2024, 3, 5)?;
    assert_eq!(march.last_banking_day_in_month()?.to_string(), "2024-03-28");
    Ok(())
}
```

**The types.** `GregorianDate` is a date of the proleptic Gregorian calendar
(`bvn_gregorian_date_t`), with astronomical years (1 BC is year 0) from
`YEAR_MIN` to `YEAR_MAX`, about ±2.5·10¹⁶. `GregorianDate::new(year, month,
day)` takes only a real date; `GregorianDate::normalize(year, month, day)`
takes any `i64` fields and carries an out-of-range month or day into the next
field, as `mktime` does. Its accessors are `year()`, `month()`, `day()`,
`leap()`, `wday()` (0 = Sunday) and `yday()` (0 = 1 January), the last three
from the library. `CivilDateTime::new(year, month, day, hour, minute, second)`
adds a time of day (`bvn_datetime_t`) and checks hour 0–23, minute 0–59 and
second 0–60; `date()` is its `GregorianDate` and `mjd()` the date's Modified
Julian Day. Both are `Copy`, `Eq` (through the C equality functions), `Hash`
and `Ord` (chronological), and `Display` as ISO 8601 (`2016-12-31T23:59:60`).
`Epoch` is `bvn_epoch_t`: each discriminant is its epoch's MJD
(`Epoch::mjd()`), `Epoch::from_mjd` of a datetime `Quantity`'s `epoch_mjd()`
is its epoch, and `Epoch::from_name` resolves the library's names (`"unix"`,
`"y2000"`, …).

**Seconds and epochs.** The functions are those of the Python binding
([doc/09 §6.5](09_bovnar_python_bindings.md#65-civil-time-and-calendars)),
under the same names and with the arguments in the same order. Two things
differ: an epoch is an `Epoch` here, where Python also takes its MJD or its
name, and `is_last_banking_day_in_quarter` / `is_first_banking_day_in_quarter`
return a `Result<bool>`, an `Error::Argument` for a quarter outside 1–4, where
Python raises:

| Function | C function | Result |
|---|---|---|
| `epoch_seconds_to_datetime(seconds, epoch)` | `bvn_dt_epoch_seconds_to_datetime` | The civil time `seconds` after `epoch` on a uniform scale (86400 s a day, no leap seconds); every `i64`, so no `Result` |
| `datetime_to_epoch_seconds(&dt, epoch)` | `bvn_dt_datetime_to_epoch_seconds` | Its inverse; `second == 60` counts as the next minute's first second |
| `convert_epoch_seconds(seconds, from, to)` | `bvn_dt_convert_epoch_seconds` | The same count relabelled to another origin; the time scale does not change |
| `utc_to_tai_seconds(&dt)` | `bvn_dt_utc_to_tai_seconds` | UTC to TAI seconds with the leap-second table (10 s before 1972, 37 s since 2017) |
| `tai_seconds_to_utc(tai_seconds)` | `bvn_dt_tai_seconds_to_utc` | Its inverse; an inserted leap second is `23:59:60` |
| `tai_seconds_to_local_time(tai_seconds, utc_offset_seconds, dls_active)` | `bvn_dt_tai_seconds_to_local_time` | Wall-clock time at a fixed offset, one hour later when `dls_active`; the offset must be whole minutes |
| `tai_second_is_dls_in_europe(tai_second)` | `bvn_dt_tai_second_is_dls_in_europe` | Whether German/European summer time is in force (none before 1980) |
| `epoch_seconds_from_gps_time(time_of_week_ms, week, epoch)`, `tai_seconds_from_gps_time(time_of_week_ms, week)` | `bvn_dt_epoch_seconds_from_gps_time`, `bvn_dt_tai_seconds_from_gps_time` | GPS week + time of week (ms, rounded to the second) as seconds; GPS time except on `Epoch::Tai`, where the 19 s are added |
| `epoch_seconds_from_galileo_time(…)`, `tai_seconds_from_galileo_time(…)` | `bvn_dt_epoch_seconds_from_galileo_time`, `bvn_dt_tai_seconds_from_galileo_time` | The same for Galileo System Time |
| `epoch_seconds_to_utc(seconds, epoch)`, `utc_to_epoch_seconds(&dt, epoch)` | — | A datetime value's UTC time and back, as the reader and writer convert an ISO literal: the leap-second table on tai, the uniform scale on unix, mjd, ntp and y2000; the GNSS epochs are an error |
| `Quantity::to_civil()` | — | `epoch_seconds_to_utc` of a `datetime` quantity; the sub-second digits stay in `frac` |
| `a == b`, `a.is_date_equal(&b)`, `a.is_time_equal(&b)` | `bvn_dt_is_equal`, `bvn_dt_is_date_equal`, `bvn_dt_is_time_equal` | Field-wise comparison of two `CivilDateTime` |

GPS and Galileo seconds stay in their own scale: for UTC, take
`tai_seconds_from_gps_time` to `tai_seconds_to_utc`. A datetime on a GNSS epoch
has no civil form in the library, which is why `epoch_seconds_to_utc` and
`to_civil()` refuse it.

**The calendar.** `GregorianDate::from_mjd` / `to_mjd` / `normalize` and `==`
are `bvn_gregorian_date_from_mjd` / `_to_mjd` / `_normalize` / `_is_equal`;
`mjd_to_wday`, `mjd_to_yday` and `mjd_in_leap_year` take a bare MJD;
`easter_sunday_mjd(year)` and `easter_sunday`, `good_friday`, `easter_monday`,
`ascension_day`, `whit_monday` give Easter and the feasts that hang off it. The
German calendar is methods of `GregorianDate`: `is_federal_holiday_in_germany`,
`is_banking_day_in_germany`, `is_business_day_in_germany`;
`last_banking_day_in_month`, `penultimate_…`, `mid_…`, `first_…`,
`last_sunday_in_month`, `last_saturday_in_month`;
`last_banking_day_in_quarter(q)`, `first_banking_day_in_quarter(q)`; the `is_…`
form of each banking-day getter; and `is_daylight_saving_switch_in_germany`,
`is_day_before_daylight_saving_switch_in_germany`. The federal holidays are the
nine that hold in all of Germany, by the rules in force that year (3 October
from 1990, 17 June from 1954 to 1989, 1 May from 1933). A banking day is Monday
to Friday, not a holiday and not 24 or 31 December; a business day is Monday to
Saturday and not a holiday; the mid banking day is the first one on or after
the 15th.

**Errors.** Where a C function signals failure with a sentinel, a `false` or an
untouched output — an overflow, an MJD or year outside the supported range, a
quarter outside 1–4 — the crate returns `Error::Argument`. Not bound:
`bvn_gregorian_date_to_mjd_fast` (undefined behaviour out of range; `to_mjd()`
is the checked form) and `bvn_dt_dump` / `bvn_dt_print`, which print to stdout
(`Display` and `Debug` take their place).

---

## 7. `write_array` helper

`write_array` is a free function at the crate root. It handles both flat and
multi-dimensional arrays, and takes the `Writer` to write into:

```rust
use bovnar::{Value, ValueTypeFamily, ValueTypeSpec, Writer, write_array};

fn main() -> Result<(), bovnar::Error> {
    let ints = |xs: &[i128]| xs.iter().map(|&x| Value::Int(x)).collect::<Vec<_>>();
    let mut w = Writer::to_mem(4096, true)?;

    // Single-row array
    write_array(&mut w, "primes", &ints(&[2, 3, 5, 7]), None, None, None)?;

    // Multi-row array (rows separated by /)
    let matrix = [Value::Array(ints(&[1, 2, 3])), Value::Array(ints(&[4, 5, 6]))];
    write_array(&mut w, "matrix", &matrix, None, None, None)?;

    // Typed array: whole-array type annotation
    let vt = ValueTypeSpec { family: ValueTypeFamily::Uint as i32, width: 16, base: 0 };
    write_array(&mut w, "ports", &ints(&[80, 443, 8080]), Some(vt), None, None)?;

    w.finish()?;
    let Value::Struct(fields) = bovnar::parse(w.get_output())? else { unreachable!() };
    assert_eq!(fields.len(), 3);
    Ok(())
}
```

The signature is `write_array(w, key, rows, vt, unit, unit_source)`: `vt` is the
whole-array annotation (`Option<ValueTypeSpec>`), `unit` an `Option<&Unit>`, and
`unit_source` the unit's spelling if it is to be kept.

Element types accepted per element: `Int`, `Float`, `String`, `Bool`, `Null`,
`Bytes` (an octet stream), `Struct` (written as a struct), `Quantity`, or a
nested `Array` (written as a nested array). When *rows* is a flat slice it is
treated as a single-row array; when all top-level elements are themselves
`Array`s it is treated as a multi-row array.

**With `vt`, every element is held to it.** An `Int` under an integer annotation
is checked against the width and written in the annotation's base — quoted where
the base requires it. An element the annotation cannot describe — `80800` under
`<uint:16>`, a `Float` or a `String` under an integer type, a `Struct` under
any — fails with `Error::Argument` instead of producing a document nothing
reads. `Null` is a null hole and is always allowed; a `Quantity` element carries
its own literal and is written as it is.

---

## 8. JSON conversion

The `bovnar::json` module mirrors the CLI's `bovnar convert` in both directions:

| Function | Description |
|---|---|
| `json::from_json(&str) -> Result<Vec<u8>>` | JSON text to a BVNR document. The root must be an object. An integer wider than 64 bits is written as `<uint:N>`/`<sint:N>` (`N` the smallest power of two from 128 that holds it), byte for byte as `bovnar convert` writes it, and refused inside an array, whose elements share one type |
| `json::to_json(&[u8]) -> Result<String>` | A BVNR document to pretty-printed JSON, as `bovnar convert` prints it |
| `json::detect(&[u8]) -> Format` | Sniffs the format from the first significant byte: `Format::Json`, `Format::Bovnar` or `Format::Unknown`. The CLI goes by file extension; this is the content-based fallback for data without a name |

```rust
use bovnar::json::{self, Format};

fn main() -> Result<(), bovnar::Error> {
    let text = r#"{"name": "example", "ports": [8080, 443]}"#;
    assert_eq!(json::detect(text.as_bytes()), Format::Json);

    let bvnr = json::from_json(text)?;
    assert_eq!(json::detect(&bvnr), Format::Bovnar);
    let back = json::to_json(&bvnr)?;
    assert_eq!(json::from_json(&back)?, bvnr);
    Ok(())
}
```

JSON arrays become a single bracket row with nested arrays as nested rows (never
`/`-separated), as in the CLI. Nesting is limited to 255 levels — the writer's
and the reader's limit — and a document deeper than any reader takes is refused
as JSON rather than by the writer half-way through. NaN and the infinities have
no JSON spelling and are written as `null`.

**Two documented round-trip edge cases.** JSON has one number type, so two
values change on the way back, exactly as with the CLI:

- a float with an integral value becomes an integer — `1e+6` is written
  `1000000` and reads back as `uint`;
- `-0.0` is written `-0`, which reads back as `0`.

```rust
use bovnar::json::{from_json, to_json};

fn main() -> Result<(), bovnar::Error> {
    let first = to_json(&bovnar::to_string(
        &bovnar::Value::Struct(vec![("big".into(), bovnar::Value::Float(1e6))]),
        true,
    )?)?;
    assert!(first.contains("1000000"));            // not 1e+6
    let back = from_json(&first)?;                 // ... and it reads back as an integer
    assert_eq!(to_json(&back)?, first);            // from here on the JSON is stable
    Ok(())
}
```

From the second trip on, the JSON is stable: the first `to_json`/`from_json` pair
normalises, and nothing after it changes.

---

## 9. Currency helpers

The `bovnar::currency` module provides metadata for the ISO 4217 fiat and
cryptocurrency `BaseUnit` members. The catalogue is read from the C library, so
there is no table to keep in step.

```rust
use bovnar::BaseUnit;
use bovnar::currency::{
    all_crypto, all_fiat, currency_code, currency_info, from_code, is_crypto, is_currency, is_fiat,
    minor_unit,
};

fn main() -> Result<(), bovnar::Error> {
    assert!(is_currency(BaseUnit::Usd));
    assert!(is_fiat(BaseUnit::Usd));
    assert!(is_crypto(BaseUnit::Btc));

    let info = currency_info(BaseUnit::Usd)?;
    assert_eq!(info.code, "USD");
    assert_eq!(info.numeric_code, 840);   // ISO 4217 numeric; 0 for crypto
    assert_eq!(info.minor_unit, 2);       // decimal places: 1 major = 10^minor minor units
    assert_eq!(info.name, "US Dollar");

    assert_eq!(minor_unit(BaseUnit::Jpy)?, 0);
    assert_eq!(currency_code(BaseUnit::Eur)?, "EUR");
    assert_eq!(from_code("gbp")?, BaseUnit::Gbp);   // surrounding space and case are ignored

    assert!(all_fiat().any(|ci| ci.code == "CHF")); // all_crypto() / all_currencies() also exist
    assert!(all_crypto().all(|ci| ci.numeric_code == 0));
    Ok(())
}
```

`CurrencyInfo` is a plain struct (`base`, `code`, `numeric_code`, `minor_unit`,
`name`) with an `is_fiat()` method; its `Display` is the code. `currency_info`, `minor_unit`, `currency_name`, `currency_code` and
`from_code` fail with `Error::Argument` for non-currency bases or unknown codes.
`CURRENCY_FIRST`, `CURRENCY_LAST` and `CURRENCY_COUNT` are re-exported from the
module.

---

## 10. `Reader` reference

### 10.1 Construction

```rust
use bovnar::Reader;

fn main() -> Result<(), bovnar::Error> {
    let r = Reader::new()?;
    drop(r); // the C object is released when the Reader is dropped
    Ok(())
}
```

`Reader::new` calls `bvnr_reader_create` immediately. There is no context
manager and no `close()`: the C object is released when the `Reader` is dropped,
and a dropped reader cannot be used again because the compiler has moved it.

### 10.2 `read_mem`

```rust,ignore
read_mem(data: &[u8], options: &ReadOptions,
         on_verified: Option<&mut dyn FnMut(&EventData) -> bool>,
         on_unverified: Option<&mut dyn FnMut(&EventData) -> bool>) -> Result<()>

read_mem_want_unit(data, options, want_unit: &mut dyn FnMut(&EventData) -> Option<(Unit, u32)>,
                   on_verified) -> Result<()>

read_mem_with(data, options, handlers: ReadHandlers) -> Result<()>

read_mem_mirror(data, options, handlers: ReadHandlers,
                mirror: &mut dyn std::io::Write) -> Result<()>
```

Parse BVNR from a byte slice. The options are the `ReadOptions` struct, and the
`want_unit` hook is the separate method `read_mem_want_unit`. `read_mem_with`
(and `read_reader_with` over a `std::io::Read`) takes every handler at once, as
a `ReadHandlers` whose fields are all optional: `on_verified`, `on_unverified`,
`want_unit` — so a hook can sit beside an `on_unverified` callback — and
`on_error`.

| Parameter | Type | Description |
|---|---|---|
| `data` | `&[u8]` | Input buffer |
| `options` | `&ReadOptions` | See [§10.3](#103-readoptions) |
| `on_verified` | `Option<&mut dyn FnMut(&EventData) -> bool>` | Callback for validated events |
| `on_unverified` | `Option<&mut dyn FnMut(&EventData) -> bool>` | Callback for raw pre-validation events |
| `want_unit` | `&mut dyn FnMut(&EventData) -> Option<(Unit, u32)>` | Read-time lossless unit/base conversion hook. Called per numeric value; return `Some((unit, base))` to request a conversion (the exact result arrives on the payload as `converted` and `converted_text`, with `converted_rational()` / `converted_in_base()` beside it — see §4.2), or `None` to leave the value untouched. An inexact conversion aborts the read (`error_unit_inexact`) |
| `on_error` | `&mut dyn FnMut(&Error)` (in `ReadHandlers`) | Called for every error the reader reports, as an `Error::Parse` with its position: under `continue_on_error`, each error it recovers from — reported nowhere else — and the error that ends a read, before the read returns it. A panic in it is parked: the read stops at the next call of an `on_verified`, `on_unverified` or `want_unit` handler, or, with none installed, runs to the end; then the panic resumes in the caller |

```rust
use bovnar::{Error, ReadHandlers, ReadOptions, Reader};

fn main() -> Result<(), Error> {
    let mut recovered = Vec::new();
    let mut on_error = |e: &Error| recovered.push(e.position().unwrap().offset);
    let options = ReadOptions { continue_on_error: true, ..ReadOptions::default() };
    let _ = Reader::new()?.read_mem_with(
        b".a = 1; .b = @; .c = 2;",
        &options,
        ReadHandlers { on_error: Some(&mut on_error), ..ReadHandlers::default() },
    );
    assert_eq!(recovered.first(), Some(&13)); // the '@'
    Ok(())
}
```

`read_mem_mirror` (and `read_reader_mirror` over a `std::io::Read`) is
`read_mem_with` with `mirror` receiving every byte the reader consumes — the
input mirror of `bvnr_open_read_source`, a debugging aid — comments and
octet-stream payloads included, so after a read that succeeded it holds the
input. The bytes reach it a piece at a time, each once the reader has parsed
it: a buffer pulled (up to 64 KiB), a piece fed, an octet-stream chunk. A read
that fails, or that a handler stops, has mirrored the input up to the byte it
stopped at. A failing `mirror` ends the read with an `Error::Io`
with `writing_to_sink`; a panic in it reaches the caller. A `live::DocumentFeed`
takes one with `set_mirror` ([§4.9](#49-live-documents-through-c-bovnarlive)).

```rust
use bovnar::{ReadHandlers, ReadOptions, Reader};

fn main() -> Result<(), bovnar::Error> {
    let mut seen = Vec::new();
    Reader::new()?.read_mem_mirror(b".a = 1;  # note\n", &ReadOptions::default(),
                                   ReadHandlers::default(), &mut seen)?;
    assert_eq!(seen, b".a = 1;  # note\n");
    Ok(())
}
```

A reader opens at the 255-level struct and array nesting cap, in both
directions, like the CLI and the DOM, unless `ReadOptions` lowers it.

The file-descriptor entry points `read_fd` and `read_file` are `read_reader`,
`read_reader_want_unit`, `read_reader_with` and `read_reader_mirror`, over any
`std::io::Read` — see [§4.7](#47-files-and-io-streams-stdioread--write).

### 10.3 `ReadOptions`

`ReadOptions` is plain data with a `Default`; fill what you need and leave the
rest with `..ReadOptions::default()`.

| Field | Type | Default | Description |
|---|---|---|---|
| `max_file_size` | `u64` | `0` (unlimited) | Hard limit on bytes consumed; past it the read fails with `file_too_long` |
| `max_text_bytes` | `u64` | `0` (default 2 147 483 647) | Most bytes of text in a document (octet-stream payloads not counted); past it the read fails with `text_data_too_long`. `u64::MAX` lifts it, for an endless text document |
| `max_array_items` | `u64` | `0` (default 2 147 483 647) | Most items in one array; past it the read fails with `too_many_array_items`. `u64::MAX` lifts it |
| `continue_on_error` | `bool` | `false` | Enable resync mode |
| `strict_version` | `bool` | `false` | Reject a declared spec version newer than this build (`error_unsupported_spec_version`) |
| `text_only` | `bool` | `false` | Refuse a document that contains an octet stream (`error_octet_stream_forbidden`), for a channel that is not byte-transparent |
| `want_unit_allow_nonterminating` | `bool` | `false` | Deliver an exact-rational-but-non-terminating conversion (e.g. km/h→m/s) as a rational — `converted_text` is empty, `converted_rational()` returns the exact pair — instead of aborting with `error_unit_inexact` |
| `max_conversion_length` | `u32` | `0` (default 1024) | Longest converted text a conversion may produce, in characters; a longer exact result aborts with `error_value_out_of_range` |
| `max_identifier_length` | `u16` | `0` (default 255) | Longest key, in bytes (`identifier_too_long`) |
| `max_string_length` | `u16` | `0` (default 65 535) | Longest string value, in bytes (`string_too_long`) |
| `max_number_length` | `u16` | `0` (default 65 535) | Longest number literal (`number_too_long`) |
| `max_symbol_length` | `u16` | `0` (default 255) | Longest symbol (`symbol_too_long`) |
| `max_reference_length` | `u16` | `0` (default 65 535) | Longest reference (`reference_too_long`) |
| `max_struct_nesting` | `u8` | `0` (255) | Deepest struct nesting (`struct_nesting_too_high`). `0` is the format's limit of 255, what the DOM and the CLI read at — not the C reader's own default of 64 |
| `max_array_nesting` | `u8` | `0` (255) | Deepest array nesting, as `max_struct_nesting` (`array_nesting_too_high`) |

Every field of `bvnr_read_flags_t` that is a limit or a switch is here, and
every reading entry point takes the whole struct: `Reader`, the `live` feeds,
`parse_with_options` / `parse_reader_with_options` (§4.1), and the
`*_with_options` twins of the `stream` readers (§4.6). The five length caps and
the two nesting caps are for untrusted input: lower them to what the consumer
can take.

`max_file_size` is 0 by default, which means no cap; set it where a cap matters.
`max_text_bytes` and `max_array_items` are 0 by default too, but 0 selects the
library's finite default there, as in C (`bvnr_read_flags_t`), so an untrusted
document stays bounded unless a caller lifts the cap.

### 10.4 `EventData` and `EventPayload`

`EventData<'a>` — the table in [§4.2](#42-sax-style-streaming-reader) — is the
borrowed view a callback receives. `EventPayload` is the owned copy: the same
fields without the borrow, with `raw` a `Vec<u8>`, `converted_text` an
`Option<String>` (`None` when the conversion does not terminate in
`converted_base`), and a `text()` method.

| Field | Type | Description |
|---|---|---|
| `event` | `Event` | The event code |
| `token_type` | `Option<TokenType>` | How the token was written |
| `value_type` | `ValueTypeSpec` | Type annotation if present |
| `value_unit` | `Unit` | Unit if present |
| `raw` | `Vec<u8>` | Raw token bytes |
| `text()` | `String` | `raw` decoded as UTF-8, invalid bytes replaced |
| `converted` | `bool` | `true` when a `want_unit` conversion was applied to this value |
| `converted_text` | `Option<String>` | Exact converted value as a positional string; `None` if the conversion does not terminate in `converted_base` |
| `converted_base` | `u32` | Base the converted text is rendered in |

### 10.5 `iter_mem`

`iter_mem(data, verified_only, max_file_size) -> Result<Vec<EventPayload>>`
reads `data` and returns all its events, in order. It is a `Vec`, not a lazy
iterator: the read has finished when it returns.

| Parameter | Description |
|---|---|
| `verified_only` | When `false`, both callbacks fire and events may appear twice |
| `max_file_size` | Forwarded to `read_mem`; `0` is unlimited |

For any other option, call `read_mem` with a collecting handler.

### 10.6 Error-state accessors

These query the C reader object after a failed parse. They describe the error
that **ended** the read; the `Error::Parse` that `read_mem` returned carries the
same code and position. An error recovered under `continue_on_error: true` does
not appear here: after a read that succeeded, `error_code()` is
`Some(ErrorCode::None)` and the line is `0`, however many errors were recovered,
and `recovery_count()` is how to tell that any were (spec §13.4).

| Method | Type | Description |
|---|---|---|
| `error_code()` | `Option<ErrorCode>` | The error that ended the read; `ErrorCode::None` after a successful one |
| `error_line()` | `u64` | 1-based line number of the error |
| `error_position()` | `Position` | Line, character column, byte offset and offending byte of the error, all 0 after a clean read |
| `recovery_count()` | `u64` | Number of times resync was entered (incremented at error entry, not at resync completion) |
| `skipped_bytes()` | `u64` | How many input bytes a `continue_on_error` read skipped |
| `declared_version()` | `Option<(u16, u16)>` | The `#!bovnar M.N` directive of the last read, or `None` when the document carried none |

The same position is on the `Error` itself — see
[§13](#13-error-handling) — as `position()`. To see the errors a
`continue_on_error` read recovered from, pass an `on_error` handler
([§10.2](#102-read_mem)).

---

## 11. `Writer` reference

### 11.1 Construction

| Method | Description |
|---|---|
| `Writer::to_mem(cap, pretty)` | Write to an in-process buffer of `cap` bytes; `pretty` indents the output. Writing past `cap` fails with `sink_buffer_exhausted` (`DEFAULT_MEM_CAP` is 4 MiB) |
| `Writer::to_mem_with(cap, pretty, unit_flags, emit_version)` | `to_mem` with two more settings: `unit_flags` ([`UnitFlags`](#53-unitflags)) rewrite units on output, `emit_version` leads the document with `#!bovnar <major>.<minor>` |
| `Writer::to_writer(out, pretty)` | Write to any `std::io::Write`: the document streams into `out` in 64 KiB blocks as it is written, the rest on `flush()` or `finish()`, which flush `out`; no cap on its size |
| `Writer::to_writer_with(out, cap, pretty, unit_flags, emit_version)` | `to_writer` for a document of at most `cap` bytes (`sink_buffer_exhausted` past it, from the call that pushes the output over it), plus `unit_flags` and `emit_version` as in `to_mem_with` |
| `Writer::to_mem_with_options(cap, options: WriteOptions)` | `to_mem` with every write option, below |
| `Writer::to_writer_with_options(out, cap, options: WriteOptions)` | `to_writer_with` with every write option; `cap` `usize::MAX` is no cap |

See [§4.7](#47-files-and-io-streams-stdioread--write) for the I/O writer. By default every writer accepts structs and arrays nested up to **255** deep,
the same limit `Reader` and `parse()` read at. `unit_flags` takes
[`UnitFlags`](#53-unitflags) — `REDUCE` rewrites each unit and rescales its
value, `ASCII_EXP` writes `^N` exponents, `NUM_CANONICAL` keeps a rescaled float
in float shape. `emit_version: true` writes a leading `#!bovnar M.N` naming the
spec version this build implements, as `write_version()` would.

`WriteOptions` is the whole of `bvnr_write_flags_t` that the C writer
consults, with a `Default`: `pretty`, `unit_flags`, `emit_version`, the
nesting caps `max_struct_nesting` and `max_array_nesting` (`0` is 255, as for
`ReadOptions`; a struct or an array one level deeper fails the write with
`struct_nesting_too_high` or `array_nesting_too_high`), and `on_event`, the
observer of [§11.9](#119-observing-the-output-on_event). It holds a boxed
closure, so it is passed by value and is not `Clone`.

```rust
use bovnar::{ErrorCode, WriteOptions, Writer};

fn main() -> Result<(), bovnar::Error> {
    let options = WriteOptions { max_struct_nesting: 2, ..WriteOptions::default() };
    let mut w = Writer::to_mem_with_options(4096, options)?;
    w.begin_struct("a")?;
    w.begin_struct("b")?;
    let err = w.begin_struct("c").unwrap_err();
    assert_eq!(err.code(), Some(ErrorCode::StructNestingTooHigh));
    Ok(())
}
```

The high-level `to_string` needs no buffer size: it retries on a writer twice
the size until the output fits (up to 256 MiB).

### 11.2 Output retrieval

| Method | Description |
|---|---|
| `w.get_output() -> &[u8]` | The document written so far; empty for a `to_writer` writer, whose output has gone to its `std::io::Write` |
| `Writer::bytes_written -> u64` | Number of bytes written |
| `w.flush()` | Hand over what is written so far without ending the document (`bvnr_write_flush`): a `to_writer` writer pushes what it holds to its `std::io::Write` and flushes it once; a `to_mem` writer does nothing. See [§11.5](#115-struct-helpers) |
| `w.finish()` | Flush and seal the output. Fails with `Error::Argument` if any struct is still open. For a `to_writer` writer it also pushes what the writer still holds to its `std::io::Write` and flushes it |

There is no `destroy()`: dropping the `Writer` releases the C object.

### 11.3 Scalar write methods

Every scalar writer takes the key first and returns `Result<()>`. They take no
unit parameters: a value that carries a unit is written with `write_quantity` (or inside [`write_array`](#7-write_array-helper)),
which take a `Unit`.

#### `write_uint(key, value: u64, width: u32, base: Base)`

Write an unsigned integer. `base` selects the numeral system (`Base::DECIMAL` or
`Base::HEX` for inline values; any Bovnar-supported base for `write_bvni`).

#### `write_sint(key, value: i64, width: u32, base: Base)`

Write a signed integer. `base` 64 and 85 are **unsigned** — `+` and `-` are
digits in those alphabets — and fail with `Error::Argument` here and for a
signed `write_bvni`.

#### `write_float(key, value: f64, width: u32)`

Write an IEEE 754 binary float.

#### `write_float_fix(key, value: f64, width: u32, q: u32)`

Write a Q-format fixed-point value. `q` is the number of fractional bits
(`0 ≤ q < width`).

#### `write_float_dec(key, value: f64, width: u32)`

Write an IEEE 754-2008 decimal float.

#### `write_string(key, value: &str)`

Write a bare quoted UTF-8 string with no type annotation:

```bovnar
.host = "localhost";
```

To emit an explicit `<utf8>` annotation, use the low-level `emit` API with
`Event::TypeAnnotationStart` / `TypeAnnotationEnd` before `Event::Data`.

#### `write_bool(key, value: bool)`

Write a `bool` value — serialized as the bare keyword `true` or `false`. On
read-back it decodes to `Value::Bool`.

#### `write_null(key)`

Write a null value (empty slot).

#### `write_octets(key, value: &[u8])`

Write an octet stream, chunked. It reads back as `Value::Bytes`.

#### `write_quantity(key, q: &Quantity)`

Write a typed scalar as `parse_typed` read it: type, unit, unit spelling and
datetime fraction are kept. The annotation is left out where it says nothing
the literal does not.

```rust
use bovnar::{Quantity, Unit, ValueTypeFamily, ValueTypeSpec, Writer};

fn main() -> Result<(), bovnar::Error> {
    let float64 = ValueTypeSpec { family: ValueTypeFamily::Float as i32, width: 64, base: 0 };
    let mut w = Writer::to_mem(4096, false)?;
    w.write_quantity("velocity", &Quantity::new("9.81", float64, Unit::parse("m/s")?))?;
    w.finish()?;
    assert_eq!(w.get_output(), b".velocity=<float:64,m/s>9.81;");
    Ok(())
}
```

### 11.4 Extended integer writers

#### `write_bvni(key, value: i128, width: u32, base: Base, signed: Signedness)`

Arbitrary-width integer writer that supports all Bovnar numeral bases (**2–62,
64, and 85**), for an integer of any width up to 128 bits. Non-decimal values
are formatted with bovnar's own big-integer arithmetic and emitted as quoted
strings. `signed` is a
`Signedness`: `Signed`, `Unsigned`, or `Auto` — `sint` for a negative value,
`uint` otherwise (the default). `format_bigint(value, base)` renders an integer in
any base on its own.

#### `write_big_int(key, value: &BigInt, width, base, signed)`

`write_bvni` for an integer of any size: `value` is a `num_bigint::BigInt`, as
`Value::BigInt` carries one past `i128`, and `width`, `base` (`Base`) and
`signed` (`Signedness`, `Auto` writing a negative value as `sint`) are those of
`write_bvni`. `writer::format_big_int(value, base)` renders it on its own. A
signed value in base 64 or 85 fails with `Error::Argument`, as for
`write_bvni`.

#### `write_bvnf_base(key, literal: &str, width: u32, base: Base)`

Write a float from a pre-formatted string in base 10 or 16. `base` must be
`Base::DECIMAL` or `Base::HEX`; any other value fails with `Error::Argument`.

#### `Base`

`Base` is a checked newtype over the numeral bases bovnar has an alphabet for —
2 to 62, 64 and 85 — rather than an enum of 63 variants. The common ones are
named: `BINARY`, `OCTAL`, `DECIMAL`, `HEX`, `BASE36`, `BASE62`, `BASE64`,
`BASE85`. `Base::new(n)` is `Some` for a base bovnar has an alphabet for and
`None` otherwise; `get()` returns the number.

```rust
use bovnar::{Base, Signedness, Writer};

fn main() -> Result<(), bovnar::Error> {
    assert_eq!(Base::new(16), Some(Base::HEX));
    assert_eq!(Base::new(63), None);

    let mut w = Writer::to_mem(4096, false)?;
    w.write_bvni("big", 1i128 << 100, 128, Base::DECIMAL, Signedness::Auto)?;
    w.write_bvni("neg", -255, 16, Base::HEX, Signedness::Auto)?;
    w.finish()?;
    assert_eq!(bovnar::parse(w.get_output())?, bovnar::parse(
        b".big = <uint:128> 1267650600228229401496703205376; .neg = <sint:16,_16> \"-ff\";",
    )?);
    Ok(())
}
```

### 11.5 Struct helpers

```rust
use bovnar::Writer;

fn main() -> Result<(), bovnar::Error> {
    let mut w = Writer::to_mem(4096, true)?;
    w.begin_struct("server")?;        // emit AssignmentStart + StructStart, increment depth
    w.write_string("host", "example.com")?;
    w.end_struct()?;                  // emit StructEnd, decrement depth
    w.finish()?;
    Ok(())
}
```

`finish()` verifies that the struct depth is zero; an unclosed struct fails with
`Error::Argument` before the C library is asked to finish.

Two more kinds of value go into an open writer, whatever it writes to:

| Method | Writes |
|---|---|
| `w.embed_document(key, doc)` | `.key = <octets>;` holding the document `doc` (`bvnr_embed_document`), for `stream::parse_embedded` or `live::EmbeddedFeed` to read back |
| `w.mux_begin(key)`, `w.mux_send(channel, payload)`, `w.mux_end()` | a multiplexed octet stream under `key` (`bvnr_mux_begin` / `_send` / `_end`), the bytes of `stream::mux_dump`; values written before it are read back before its first message, and the document may go on after it |

On a `to_writer` writer a message reaches `out` when the writer's 64 KiB block
fills, when the document is finished, or at `w.flush()` (`bvnr_write_flush`),
which writes out what the block holds and then calls `out.flush()`, once per
call. A consumer that needs each message as it is sent — a `live::Demux` at the
other end of a socket — gets it by a `flush()` after each `mux_send`. It is
legal between any two writer calls, inside an open struct, array or mux stream
too, and it changes only when the bytes leave, never which: a document flushed
after every call is byte for byte the one written without a flush. It allocates
nothing. A failing `out` is an `Error::Io` with `writing_to_sink`, from the
`flush()` and from every call after it; on a `to_mem` writer `flush()` does
nothing.

```rust
use std::io::Read;
use bovnar::live::{Demux, DemuxItem, DemuxOptions};
use bovnar::Writer;

fn main() -> Result<(), bovnar::Error> {
    let (mut rx, tx) = std::io::pipe().expect("pipe");
    let mut w = Writer::to_writer(tx, false)?;
    w.begin_struct("meta")?;
    w.write_string("codec", "av1")?;
    w.end_struct()?;
    w.mux_begin("media")?;
    w.mux_send(1, b"key frame")?;
    w.flush()?; // the metadata and the first message leave now

    // The other end has the message while the document is still open
    let options = DemuxOptions { key: Some("media".into()), ..DemuxOptions::default() };
    let mut demux = Demux::new(&options)?;
    let mut buf = [0u8; 4096];
    let mut first = None;
    while first.is_none() {
        let n = rx.read(&mut buf).expect("read");
        let mut input = &buf[..n];
        while let Some(item) = demux.next(&mut input)? {
            if let DemuxItem::Message { channel, payload } = item {
                first = Some((channel, payload.to_vec()));
            }
        }
    }
    assert_eq!(first, Some((1, b"key frame".to_vec())));
    w.mux_end()?;
    w.finish()
}
```

Two writers produce a multiplexed stream message by message, byte for byte the
same for the same messages:

| | `Writer` with `mux_send` and `flush` | `codec::MuxWriter` ([§4.8](#48-live-streams-the-framing-codec-bovnarcodec)) |
|---|---|---|
| Implementation | the C writer: a C call per send and per flush, each message copied into the writer's 64 KiB buffer | pure Rust, no C call; the chunk headers can be handed out next to the borrowed payload for a vectored write |
| Document shape | any: the stream is one value, with values of every kind before and after it, each one checked as the writer checks every value | a text preamble, the stream, and nothing after it |
| Destination | a `to_writer` writer's `std::io::Write`, flushed by `flush()` | a `Vec` or any `std::io::Write`, which the caller flushes |
| Use it for | a stream inside a larger document, or when the writer already writes the rest | a dedicated stream, a router or a relay |

### 11.6 Version directive

```rust
use bovnar::Writer;

fn main() -> Result<(), bovnar::Error> {
    let mut w = Writer::to_mem(4096, true)?;
    w.write_version(1, 1)?;           // emit a leading "#!bovnar 1.1" directive
    w.write_uint("n", 1, 8, bovnar::Base::DECIMAL)?;
    w.finish()?;
    assert!(w.get_output().starts_with(b"#!bovnar 1.1"));
    Ok(())
}
```

Emits a leading `#!bovnar <major>.<minor>` directive. Must be called before any
value is written; calling it after output has begun fails with `Error::Write`
(`InvalidArgument`). Use it to self-declare the spec version a document targets
(spec-1.1 constructs such as datetime literals need it to re-read).

### 11.7 Array helpers

```rust
use bovnar::{EmitSpec, Event, ValueTypeSpec, Writer};

fn main() -> Result<(), bovnar::Error> {
    let plain = ValueTypeSpec::default();
    let mut w = Writer::to_mem(4096, false)?;
    w.emit(Event::AssignmentStart, EmitSpec::key("m"))?;
    for (i, row) in [["1", "2"], ["3", "4"]].iter().enumerate() {
        if i > 0 {
            w.new_array_dim()?;     // emit ArrayDimStart (the / separator between rows)
        }
        w.begin_array_row()?;       // emit ArrayRowStart
        for n in row {
            w.emit(Event::Data, EmitSpec::value(n, plain))?;
        }
        w.end_array_row()?;         // emit ArrayRowEnd
    }
    w.finish()?;
    assert_eq!(bovnar::parse(w.get_output())?, bovnar::parse(b".m = [1, 2] / [3, 4];")?);
    Ok(())
}
```

These are the raw events; [`write_array`](#7-write_array-helper) is the helper
that drives them for you.

### 11.8 Low-level `emit`

```rust,ignore
w.emit(event: Event, spec: EmitSpec<'_>) -> Result<()>
```

Send an arbitrary event to the C writer. What goes with it — a key, a value and a type
annotation — is an `EmitSpec`; it carries no unit. When both a `vt` and a `value` are supplied the token type is
inferred: a string token for `ValueTypeFamily::Utf8`, a number otherwise.

| Purpose | Call |
|---|---|
| Start an assignment with a key | `w.emit(Event::AssignmentStart, EmitSpec::key("x"))` |
| Write a typed value | `w.emit(Event::Data, EmitSpec::value("42", spec))` |
| End a struct (nothing attached) | `w.emit(Event::StructEnd, EmitSpec::NONE)` |

```rust
use bovnar::{EmitSpec, Event, ValueTypeFamily, ValueTypeSpec, Writer};

fn main() -> Result<(), bovnar::Error> {
    let spec = ValueTypeSpec { family: ValueTypeFamily::Uint as i32, width: 16, base: 0 };
    let mut w = Writer::to_mem(4096, false)?;
    w.emit(Event::AssignmentStart, EmitSpec::key("x"))?;
    w.emit(Event::Data, EmitSpec::value("42", spec))?;
    w.finish()?;
    assert_eq!(bovnar::parse(w.get_output())?, bovnar::parse(b".x = <uint:16> 42;")?);
    Ok(())
}
```

Two calls go a level lower, for what an `EmitSpec` cannot say:

```rust,ignore
w.event(event: Event, token: TokenType, vt: ValueTypeSpec, data: &[u8]) -> Result<()>
w.annotation(vt: ValueTypeSpec, unit: &Unit, unit_source: Option<&str>) -> Result<()>
```

`event` sends one event with the token type given explicitly
(`bvnr_write_event`): a plain symbol is `Event::Data` with `TokenType::Symbol`,
an array item `TokenType::ArrayNumber` or `ArrayString`. `data` longer than
4 GiB fails with `Error::Argument`. `annotation` writes a whole type
annotation, `<family:width[,_base|,qN|,epoch][,unit]>`, as the events from
`TypeAnnotationStart` to `TypeAnnotationEnd`: the width of a sized family when
it is not 0, the base of an integer or float that is not 10, the `q` of a
fixed-point value, the epoch of a datetime and the unit when it has components
(left out for `datetime` and `utf8`). `unit_source` is the unit's spelling to
write instead of its canonical one, checked by the C writer as for
`bvnr_writer_set_unit_source`; a unit the library cannot serialise fails with
`Error::Argument`. It goes after the key and before the value, or before an
array's first row ([§11.10](#1110-the-c-per-type-helpers) uses both).

### 11.9 Observing the output: `on_event`

`WriteOptions::on_event` is a `Box<dyn FnMut(&EventData<'_>) -> bool + Send>`
(`WriteObserver`), called with each event once it has been serialised
(`bvnr_write_flags_t.on_event`), with what reached the output: under
`UnitFlags::REDUCE` a rescaled value's new digits and unit, not the ones it was
written with. Every event the writer serialises reaches it — those of the
`write_*` methods, `emit`, the struct and array helpers, the mux and
embedding calls. It is owned by the writer, so it shares state with the code
around it through an `Arc` (or a channel); it cannot reach the writer itself.

```rust
use bovnar::units::UnitFlags;
use bovnar::{Event, Quantity, Unit, ValueTypeFamily, ValueTypeSpec, WriteOptions, Writer};
use std::sync::{Arc, Mutex};

fn main() -> Result<(), bovnar::Error> {
    let seen = Arc::new(Mutex::new(Vec::new()));
    let log = Arc::clone(&seen);
    let options = WriteOptions {
        unit_flags: UnitFlags::REDUCE,
        on_event: Some(Box::new(move |d| {
            if d.event == Event::Data {
                log.lock().unwrap().push((d.text(), d.value_unit.to_string()));
            }
            true
        })),
        ..WriteOptions::default()
    };
    let mut w = Writer::to_mem_with_options(4096, options)?;
    let uint = ValueTypeSpec { family: ValueTypeFamily::Uint as i32, width: 64, base: 0 };
    w.write_quantity("d", &Quantity::new("5", uint, Unit::parse("k~m")?))?;
    w.finish()?;
    assert_eq!(w.get_output(), b".d=<uint:64,m>5000;");
    assert_eq!(*seen.lock().unwrap(), [("5000".to_string(), "m".to_string())]);
    Ok(())
}
```

Returning false fails the write, and the writer, with
`scanner_callback_failed`: a veto, say of a key that must not leave the
process. A panic in the observer fails it the same way and reaches the caller
of the write call that ran it; a later call returns the `Error::Write`.

### 11.10 The C per-type helpers

The bindings write every value through `bvnr_write_event`; each of the C
per-type helpers has its counterpart, so none is bound itself:

| C helper | Rust |
|---|---|
| `bvnr_write_string`, `_null`, `_bool` | `write_string`, `write_null`, `write_bool` |
| `bvnr_write_uint`, `_sint`, `_float`, `_float_fix`, `_float_dec` | the methods of [§11.3](#113-scalar-write-methods) |
| their `_unit` forms, `bvnr_writer_set_unit_source` | `write_quantity`, whose `Quantity` carries the unit and its spelling (`unit_source`) |
| `bvnr_write_datetime` | `write_quantity` with a `datetime` type (the epoch in its `base`, `datetime_epoch_index`) |
| `bvnr_write_plain` | `event(Event::Data, TokenType::Symbol, …)` after the key, or `write_quantity` of a symbol |
| `bvnr_write_type_annotation` | `annotation(vt, &unit, unit_source)`, before a value or an array's first row |
| `bvnr_write_bvnf*`, `bvnr_write_bvni*` | `write_bvnf_base`, `write_bvni`, `write_big_int` ([§11.4](#114-extended-integer-writers)) |
| `bvnr_write_struct_start`, `_end` | `begin_struct`, `end_struct` ([§11.5](#115-struct-helpers)) |

```rust
use bovnar::{
    EmitSpec, Event, Quantity, TokenType, Unit, ValueTypeFamily, ValueTypeSpec, Writer,
    datetime_epoch_index,
};

fn main() -> Result<(), bovnar::Error> {
    let mut w = Writer::to_mem_with(4096, false, 0, true)?; // datetime is spec 1.1
    w.emit(Event::AssignmentStart, EmitSpec::key("mode"))?;
    w.event(Event::Data, TokenType::Symbol, ValueTypeSpec::default(), b"auto")?;
    let tai = datetime_epoch_index("tai").unwrap();
    let datetime = ValueTypeSpec { family: ValueTypeFamily::Datetime as i32, width: 64, base: tai };
    w.write_quantity("t", &Quantity::new("1750000000", datetime, Unit::default()))?;
    let u8_ = ValueTypeSpec { family: ValueTypeFamily::Uint as i32, width: 8, base: 0 };
    w.emit(Event::AssignmentStart, EmitSpec::key("v"))?;
    w.annotation(u8_, &Unit::parse("m")?, None)?;
    w.begin_array_row()?;
    w.event(Event::Data, TokenType::ArrayNumber, u8_, b"1")?;
    w.event(Event::Data, TokenType::ArrayNumber, u8_, b"2")?;
    w.end_array_row()?;
    w.finish()?;
    let out = w.get_output();
    let body = &out[out.iter().position(|&b| b == b'\n').unwrap() + 1..];
    assert_eq!(body, b".mode=auto;.t=<datetime:64,tai>1750000000;.v=<uint:8,m>[1,2];");
    Ok(())
}
```

---

## 12. DOM API

The DOM API parses a complete BVNR document into an in-memory tree for
random-access queries without writing a SAX callback.

### 12.1 `DomDoc::parse`

```rust
use bovnar::DomDoc;

fn main() -> Result<(), bovnar::Error> {
    let doc = DomDoc::parse(b".a = 1;")?;
    assert_eq!(doc.len(), 1);
    Ok(())
}
```

A malformed document is an `Error::Parse` carrying the C error code. **It has no
position:** the C DOM reports only the code (`bvn_dom_doc_get_parse_error`), so
`Error::position()` is `None`. To locate the fault, read the same document with
a `Reader`, which does report where.

### 12.2 `DomDoc`

Owning wrapper around `bvn_dom_doc_t`. Dropping it frees the entire tree; the
`DomNode`s derived from it borrow it, so none can outlive it.

| Method | Description |
|---|---|
| `DomDoc::parse(data)` | Parse a byte slice |
| `DomDoc::parse_reader(input)` | `parse` over any `std::io::Read`; see the note below |
| `DomDoc::parse_reader_with_policy(input, &policy)` | `parse_reader` under a policy |
| `DomDoc::parse_reader_capped(input, max_bytes)` / `parse_reader_capped_with_policy(input, max_bytes, &policy)` | `parse_reader` with a lower cap, as `bvn_dom_parse_fd_ex` takes one; see the note below |
| `DomDoc::parse_with_policy(data, &policy)` | Parse under a [`UnitPolicy`](#56-unitpolicy--validation-and-conversion-without-a-callback): values arrive converted, and a document breaking the policy fails with `unit_mismatch` |
| `doc.get(key)` | The top-level value assigned to `key`, as `Option<DomNode>` |
| `doc.lookup(path)` | Dot-separated path lookup, e.g. `"server.tls.cert"`, with indexed elements (`"matrix[1][0]"`). `None` when absent |
| `doc.len()` / `doc.is_empty()` | Number of top-level entries |
| `doc.entries()` | All top-level `(String, DomNode)` pairs, in document order |
| `doc.to_value()` | The whole document as a `Value::Struct`, dropping type and unit info. Fails where a node cannot be read as its kind — see `node.to_value()` |
| `DomDoc::create()` | An empty document to build in (§12.5) |
| `doc.add(key, node)` | Add a `NewNode` as a top-level entry (§12.5) |

> **File-size limits:** `DomDoc::parse_reader` and `parse_reader_with_policy` take
> any `std::io::Read` and apply the same ceiling, `DOM_READ_MAX_BYTES`
> (256 MiB, `BVN_DOM_FD_MAX_BYTES`). A longer input is an `Error::Parse` with
> `file_too_long`, where the C function returns no document. Input over 4 GiB is refused as well (the C API's length is a
> `uint32_t`). `parse_reader_capped` lowers the ceiling to `max_bytes`; 0, or
> anything above 256 MiB, keeps it — there is no unlimited mode, as in C.
>
> **The DOM takes no read options.** The C DOM parse functions read with a
> fixed set (the 255-level nesting, the reader's defaults for everything else),
> so `ReadOptions` cannot reach them; read with a `Reader` or
> `parse_with_options` for those limits.
>
> **Building a tree.** `DomDoc::create()` and `doc.add(key, node)` are the C
> DOM's builder, for a tree made by hand from `NewNode`s (§12.5). Such a tree
> cannot be written out; build a document as a `Value` or with the `Writer`.

Membership is `get(key).is_some()`, and iteration is `entries()`.

### 12.3 `DomNode`

A `Copy` view into a `bvn_dom_node_t` that borrows its `DomDoc`.

| Method | Description |
|---|---|
| `dom_type()` | `Option<DomType>` — `None` for a kind this binding does not know |
| `node.value_type()` | `ValueTypeSpec` |
| `node.unit()` | `Unit`; dimensionless when the value has none |
| `unit_str()` | Unit as a canonical string via `bvn_dom_get_unit_string`, or `""` when the value carries no unit — the same contract as `unit_str()` (the C formatter's `no_unit` spelling is never returned) |
| `node.is_null()` | `true` for null values |
| `node.value_in_base_units()` | Numeric value scaled to SI base units (`f64`); an affine unit gets its offset applied (`25 °C` → `298.15`). Returns `0.0` for *any* failure — non-numeric node, currency, affine-in-compound — which is indistinguishable from a genuine zero, and it rounds to `double`. Use `units::unit_to_si_factor()` (which reports the failure) or the `want_unit` hook when either matters |
| `node.as_i64()` / `as_u64()` | Signed / unsigned 64-bit integer, as an `Option` |
| `node.as_i32()` / `as_u32()` | Signed / unsigned 32-bit integer |
| `node.as_i16()` / `as_u16()` | Signed / unsigned 16-bit integer |
| `node.as_i8()` / `as_u8()` | Signed / unsigned 8-bit integer |
| `node.as_float()` | `Option<f64>` |
| `node.as_bool()` | `Option<bool>` for `Bool` nodes |
| `node.as_str()` | `Option<&str>` for `String`, `Symbol`, or `Reference` nodes |
| `node.as_bytes()` | `Option<&[u8]>` for `OctetStream` nodes |
| `node.as_int_str(base)` | Any integer node, however wide, as digits in the given base |
| `datetime_fraction()` | `Option<&str>` of the verbatim ISO sub-second digits for a `datetime` written as a literal with a fraction (spec 1.1) |
| `node.unit_source()` | `Option<&str>` of the unit exactly as the document spelled it (library 2.0), where that differs from the canonical form — `ucum:mL{total}` for a unit of `m~L`; `None` for a node with no unit, one already written canonically, or one a unit policy converted |
| `node.get(key)` | Child `DomNode` by string key (struct) |
| `node.at(index)` | Child `DomNode` by flat index (array) |
| `node.len()` / `is_empty()` | Element count for struct or array nodes, `0` otherwise |
| `node.entries()` | `(String, DomNode)` pairs (struct nodes) |
| `node.array_dims()` | Number of `/`-separated dimensions (array nodes only) |
| `node.to_value()` | Recursively convert to a plain `Value` (drops type/unit info). A multi-row array comes back as an array of rows, empty rows included (`[]/[]` → `[[], []]`), as `parse()` gives it |

**The `as_*` accessors return `Option`.** A node that is not that kind gives `None`
rather than a zero or a panic, and `as_i64` on a
`float` node is `None` rather than a truncated number.

**`to_value` fails rather than inventing a value.** Text or a key that is not
UTF-8, a float or bool node without its value, or an unknown node kind is an
`Error`, rather than an invented value: two distinct keys must not collapse
into one.

### 12.4 `DomType`

```text
Null=0  Int=1  Float=2  String=3  Symbol=4
Reference=5  Struct=6  Array=7  OctetStream=8  Bool=9
```

`DomType::from_raw(i32)` converts a raw C value.

### 12.5 Building a tree (`NewNode`)

`DomDoc::create()` makes an empty document and `NewNode`, `NewStruct` and
`NewArray` the nodes to put in it: the C DOM's builder (`bvn_dom_doc_create`,
`bvn_dom_node_alloc`, `bvn_dom_node_from_*`, `bvn_dom_struct_add`,
`bvn_dom_array_append`, `bvn_dom_doc_add`, doc/08 §4.5). It is there for code
that consumes a DOM, a test fixture say, and it has the C builder's limits: it
makes structs, arrays, nulls and integers only, and a built tree **cannot be
written out**, since the C library has no DOM serialiser. To produce a document,
use a `Value` or the `Writer`.

```rust
use bovnar::{DomDoc, NewArray, NewNode, NewStruct, Signedness, Unit, Value};
use num_bigint::BigInt;

fn main() -> Result<(), bovnar::Error> {
    let mut server = NewStruct::new()?;
    server.add("port", NewNode::from_u16(8080)?)?;
    server.add("tls", NewNode::null()?)?;
    let mut ports = NewArray::new()?;
    ports.push(NewNode::from_u16(80)?)?;
    ports.push(NewNode::from_u16(443)?)?;
    server.add("ports", ports)?;

    let mut doc = DomDoc::create()?;
    doc.add("server", server)?;
    let m = Unit::parse("m")?;
    let big = BigInt::from(1) << 100;
    doc.add("big", NewNode::from_bigint(&big, 128, Signedness::Auto, Some(&m))?)?;

    assert_eq!(doc.lookup("server.port").and_then(|n| n.as_u16()), Some(8080));
    assert_eq!(doc.get("big").map(|n| n.unit_str()), Some("m".to_string()));
    assert_eq!(
        doc.lookup("server.ports").map(|n| n.to_value()).transpose()?,
        Some(Value::Array(vec![Value::Int(80), Value::Int(443)]))
    );
    Ok(())
}
```

| Constructor / method | Description |
|---|---|
| `DomDoc::create()` | An empty document |
| `doc.add(key, node)` | Add `node` (anything `Into<NewNode>`) as a top-level entry. A parsed document takes one too |
| `NewNode::null()` | A null node (`bvn_dom_node_alloc`) |
| `NewNode::from_i8(v)` … `from_u64(v)` | An integer node of type `sint:8` … `uint:64`, with no unit (`bvn_dom_node_from_i8` … `_u64`) |
| `NewNode::from_bigint(&value, width, signed, unit)` | An integer node of 65 to 32768 bits, with an `Option<&Unit>` (`bvn_dom_node_from_bigint`); `Signedness::Auto` is `sint` for a negative value and `uint` otherwise, as for `write_bvni`. A value that does not fit the type, another width, or a unit that is not structurally valid is `Error::Argument` |
| `NewStruct::new()` / `s.add(key, node)` | A struct node, and a member added to it (`bvn_dom_struct_add`) |
| `NewArray::new()` / `a.push(node)` | An array node, and an element appended to it (`bvn_dom_array_append`) |
| `len()` / `is_empty()` / `dom_type()` | Members or elements of a `NewStruct` / `NewArray`; a `NewNode`'s kind |

Every constructor returns a `Result`, `Error::OutOfMemory` when the C library
cannot allocate, as `Reader::new` and the `Writer` constructors do.

**Ownership is a move.** The C attach functions take the node they are given on
every path: on success the container owns it, and on failure they destroy it.
`add` and `push` take their node by value, so the type system makes that
hand-over the last use: a node cannot be added twice, nor a struct filled after
it was attached (a tree is built from its leaves up), and neither compiles. A
node dropped before it is attached frees its subtree. All node types are `Send`.

```rust,compile_fail
use bovnar::{DomDoc, NewNode};

fn main() -> Result<(), bovnar::Error> {
    let mut doc = DomDoc::create()?;
    let n = NewNode::from_i32(1)?;
    doc.add("a", n)?;
    doc.add("b", n)?; // error[E0382]: use of moved value: `n`
    Ok(())
}
```

`add` fails with `Error::Argument` for a key that is not an identifier (§5.8;
the C functions refuse only an empty one, but the path lookups could not reach
another), and with the code `DuplicateStructKey` for a key the struct or
document already has; the node is dropped then. The kinds are types, so there is
no appending to a struct or adding to an array. Only an allocation failure
inside the C call is `Error::OutOfMemory`.

**Reading it back.** A built tree reads through `DomDoc` and `DomNode` like a
parsed one, with two differences that come from the C builder: an integer node
of up to 64 bits carries no unit, and a built array records no row shape, so its
`array_dims()` is 0 and `to_value()` gives one flat array. `doc.add` takes
`&mut self`, so no `DomNode` is borrowed from the document while it grows.

Not bound: `bvn_dom_node_alloc` of the other kinds (a float, string, symbol,
reference, bool or octet-stream node it makes holds no value and cannot be given
one), `bvn_dom_strdup` (a C allocation helper; the attach functions copy the key
themselves), and `bvn_dom_get_bigint` (a borrowed `bvn_int_t`; `as_int_str()`
and `to_value()` already read an integer of any width).

---

## 13. Error handling

Every fallible call returns `bovnar::Result<T>`, whose error is `bovnar::Error`.
`Error` has one variant per failure class:

| Variant | When raised |
|---|---|
| `Error::Parse { code, position }` | Parse error in `Reader` or `DomDoc` (carries the code and, where the library reported one, the `Position`) |
| `Error::Write { code, offset }` | Write error in `Writer` |
| `Error::Argument { code, message }` | Invalid argument passed to a helper (e.g. bad unit string, refused unit policy) |
| `Error::OutOfMemory(call)` | The library could not allocate; names the call that failed |
| `Error::Io { code, io_kind, message }` | The `std::io::Read` or `Write` behind `read_reader`, `parse_reader`, `write_to`, `to_writer` or a `stream` twin, a reader's `mirror` ([§10.2](#102-read_mem)) or the `input` or `out` of `canonicalize_reader` ([§4.10](#410-canonical-form)) failed. `code` is what the C library reports for an unreadable fd source or unwritable fd sink (`reading_from_source_fd`, `writing_to_sink`); `io_kind` and `message` keep what the I/O error said |

The library is part of the crate, so there is no library-not-found error. `Error`
implements `std::error::Error` and `Display`, so it works with `?` into
`Box<dyn Error>` and `anyhow`.

`kind()` gives the library's error name as an `ErrorKind` (an alias of
`ErrorCode`) wherever the library reported one — an unparsable unit or a refused
policy included — `name()` its C spelling, `code_raw()` the raw integer, and
`position()` the line, column and byte offset where the input was refused:

```rust
use bovnar::{Error, ErrorKind, Unit};

fn main() {
    let err = bovnar::parse(b".a = 1;\n.b = @;").unwrap_err();
    assert_eq!(err.kind(), Some(ErrorKind::UnexpectedInputByte));
    assert_eq!(err.name(), Some("unexpected_input_byte"));
    let pos = err.position().expect("a parse error from the reader has a position");
    assert_eq!(pos.line, 2);
    assert!(pos.column > 0 && pos.offset > 0);
    assert_eq!(pos.byte, b'@' as u32);              // the byte the error was raised on

    match Unit::parse("furlong per fortnight") {
        Err(Error::Argument { code, message }) => {
            assert!(code.is_some(), "the library said why: {message}");
        }
        other => panic!("expected an argument error, got {other:?}"),
    }
}
```

`position()` is `None` for errors the library locates no better than by code: the
DOM parser, and the stream helpers' framing errors. `kind()` is `None` for an
argument error caught before the library and for an allocation failure.
`ErrorCode::name()` is the library's text for a code on its own
(`bvn_error_to_string`): `ErrorCode::StringTooLong.name()` is
`"string_too_long"`.

**`unit_convert_factor` error semantics.** The C function uses the
`(ok, requires_affine)` pair to signal four distinct outcomes:

| ok | requires_affine | Meaning |
|---|---|---|
| `true` | `false` | Multiplicative conversion; `factor` is ready to use |
| `true` | `true` | Both sides are affine and share their zero (e.g. °C ↔ °Re, Réaumur), so `factor` alone is correct |
| `false` | `true` | Affine conversion the offsets do not cancel (e.g. °C ↔ K). **`factor` is `0.0`** — not a usable multiplier |
| `false` | `false` | Units are dimensionally incompatible → `Error::Argument` |

`unit_convert_factor` fails with `Error::Argument` **only** when both `ok=false`
and `requires_affine=false`; for the other three the result's `ok()` method
gives `ok`, so check it before multiplying by `factor`. It is a method rather
than a field because a new public field would break code that builds or
destructures `UnitConversion`; it is exact all the same, since the C function
never succeeds with a factor of 0.0. When it is `false`, call
`convert_value`, which handles the two-step affine path, or call
`unit_to_si_factor` on both units and convert by hand. The `Unit` method
`convert_factor()` returns the bare number and therefore fails for the `0.0`
row instead of returning it.

**Callbacks returning `false`.** When an `on_verified` or `on_unverified`
callback returns `false`, the C parser stops and `read_mem` returns
`Error::Parse` with whatever error code the reader recorded.

**Callbacks that panic.** If the callback panics, the panic is caught at the FFI
boundary, the C callback returns `false` to stop the parse, and the original
panic is resumed from `read_mem` after the C call returns — a Rust panic never
unwinds through C frames.

---

## 14. Threads

`Reader`, `Writer` and `DomDoc` are `Send` but not `Sync`. As `bovnar.h`
(THREADING) allows, each can be moved to another thread, and distinct ones can
be used on distinct threads, but one object must not be shared: a single reader
or writer holds the whole parse or serialise state of one stream.

```rust
use bovnar::{DomDoc, Writer};

fn main() -> Result<(), bovnar::Error> {
    let doc = DomDoc::parse(b".n = 7;")?;
    // A DomDoc moves to another thread; a DomNode borrows it, so it stays with its document.
    let n = std::thread::spawn(move || doc.get("n").and_then(|n| n.as_i64())).join().unwrap();
    assert_eq!(n, Some(7));

    // Distinct writers on distinct threads need no locking
    let outputs: Vec<Vec<u8>> = (0..4u64)
        .map(|i| {
            std::thread::spawn(move || {
                let mut w = Writer::to_mem(4096, false).unwrap();
                w.write_uint("i", i, 8, bovnar::Base::DECIMAL).unwrap();
                w.finish().unwrap();
                w.get_output().to_vec()
            })
        })
        .collect::<Vec<_>>()
        .into_iter()
        .map(|h| h.join().unwrap())
        .collect();
    assert_eq!(outputs.len(), 4);
    Ok(())
}
```

The `live` readers ([§4.9](#49-live-documents-through-c-bovnarlive)) are `Send`
and not `Sync` too: each owns one C object for its stream.

`parse`, `parse_typed`, `to_string`, their `_reader` / `write_to` twins and the `units`, `currency` and `json`
functions are free functions that build their own reader, writer or DOM for each
call, so no two calls share an object and they can run on several threads at
once.

---

## 15. Running the test suite

```bash
cd rust

# Run everything
cargo test

# One test file
cargo test --test test_units

# The documentation examples of rust/README.md
cargo test --doc
```

`cargo test` runs the unit tests in `src/`, the integration tests in `tests/`
(one file per topic — `test_units.rs`, `test_dom.rs`, `test_stream.rs` and so
on — plus `smoke.rs`) and the documentation examples. The ABI check is three
unit tests in `src/ffi.rs`, because the C structs it compares against are
private. `test_codec_alloc.rs` installs a counting global allocator to show that
the codec allocates nothing per message, so it is a test binary of its own;
`test_live_alloc.rs` does the same for the `live` readers and the `std::io`
twins.

`test_writers_agree` compares `to_string` with the C CLI's `pretty-print`, so it
is `#[ignore]`d unless the CLI is built at `<repo>/build/bovnar`:

```bash
cmake -B build . && cmake --build build --target bovnar
cargo test --test test_writers_agree -- --ignored
```

The C library's own suite runs unchanged through CMake: `cmake -B build . &&
cmake --build build && ctest --test-dir build`. That suite also parses every
`bovnar` code block of this document (`ctest -R bvnr_doc`).

`rust/fuzz/` holds [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) targets
(nightly toolchain): `parse` (the streaming parsers and a write-back round
trip), `dom` (parse and walk every node), `json_roundtrip` (`from_json` and
`to_json` until the JSON is stable), `codec` (`MuxReader` and `FrameReader`
fed in pieces, against themselves and against the C library's `mux_load` and
`load_documents`) and `live` (the fed readers of `bovnar::live` in pieces,
against `Reader::read_mem`, `mux_load`, `codec::FrameReader` and
`load_documents`). Instrument the C library too by building it
with clang:

```bash
cd rust/fuzz
CC=clang CFLAGS="-fsanitize=fuzzer-no-link,address" \
    cargo +nightly fuzz run -O parse -- -max_total_time=600
```

---

## 16. Crate layout

```text
rust/
├── Cargo.toml        # version = the C library's version; publish = false (see package.py)
├── build.rs          # compiles the bundled c/, or in the repository ../src after running the generators
├── package.py        # stages the crate with the C sources bundled, for crates.io
├── src/
│   ├── lib.rs        # parse() / parse_typed() / to_string() / write_array() — public API
│   ├── ffi.rs        # the C declarations; the only `unsafe` code
│   ├── bvnfloat.rs   # BvnFloat: arbitrary-precision float + IEEE binary/decimal/fixed encoders
│   ├── codec.rs      # MuxWriter, MuxReader, FrameReader — the pure-Rust framing codec
│   ├── currency.rs   # ISO 4217 / crypto currency metadata (read from the C catalogue)
│   ├── decimal.rs    # Decimal: an exact decimal number (sign, coefficient, exponent)
│   ├── dom.rs        # DomDoc, DomNode, DomType, NewNode — random-access DOM and its builder
│   ├── enums.rs      # Rust enums mirroring the C enums
│   ├── error.rs      # Error, ErrorKind, Position
│   ├── io.rs         # a caller's std::io::Read as a callback source for read_reader / parse_reader
│   ├── json.rs       # from_json / to_json / detect (the CLI's `bovnar convert`)
│   ├── literals.rs   # the C validators, literal parsers/formatters, parse_type_annotation
│   ├── live.rs       # DocumentFeed, ValueFeed, EmbeddedFeed, FrameParser, FramePull, Demux — fed reading through C
│   ├── quantity.rs   # Quantity — typed, unit-annotated scalar for lossless round-trips
│   ├── reader.rs     # Reader, ReadOptions, ReadHandlers, EventData, EventPayload, UnitPolicy, UnitRule, canonicalize
│   ├── stream.rs     # framing, octet multiplexing, document-in-document
│   ├── timekeeping.rs # civil time and calendars: GregorianDate, CivilDateTime, Epoch
│   ├── unit.rs       # Unit, UnitComponent — a unit with equality, hashing and its operations
│   ├── units.rs      # unit_to_si_factor, unit_convert_factor, etc.
│   └── writer.rs     # Writer, WriteOptions, WriteObserver, Base, Signedness, EmitSpec
├── tests/            # one integration-test file per topic, plus smoke.rs
├── benches/          # codec.rs, live.rs: per-message figures of the codec and the fed readers (std only)
└── fuzz/             # cargo-fuzz targets
```

| Module | Contents |
|---|---|
| `src/ffi.rs` | the C declarations and the mirrored C structs |
| `src/reader.rs` | `Reader`, `ReadOptions`, `ReadHandlers`, `EventData`, `EventPayload`, `UnitPolicy`, `UnitRule`, `canonicalize`, `canonicalize_reader` |
| `src/writer.rs` | `Writer`, `WriteOptions`, `WriteObserver`, `Base`, `Signedness`, `EmitSpec` |
| `src/dom.rs` | `DomDoc`, `DomNode`, `DomType`, and the builder's `NewNode`, `NewStruct`, `NewArray` |
| `src/literals.rs` | the C validators and literal helpers, `parse_type_annotation`, `TypeAnnotation` |
| `src/enums.rs` | the Rust enums behind the C enums, and `Exponent` |
| `src/error.rs` | `Error`, `ErrorKind`, `Position` |
| `src/unit.rs` | `Unit`, `UnitComponent` |
| `src/units.rs` | the unit functions: conversion, dimension vectors, profile notations |
| `src/quantity.rs` | `Quantity` |
| `src/bvnfloat.rs` | `BvnFloat` and the IEEE / fixed-point encoders |
| `src/decimal.rs` | `Decimal`, the exact decimal value |
| `src/currency.rs` | currency metadata |
| `src/stream.rs` | framing, multiplexing and document-in-document, with their `Read` / `Write` twins |
| `src/timekeeping.rs` | civil time and calendars: `GregorianDate`, `CivilDateTime`, `Epoch` and the functions of `bvn_datetime.h` / `bvn_gregorian_date.h` |
| `src/codec.rs` | the incremental framing codec in pure Rust: `MuxWriter`, `MuxReader`, `FrameReader`, the frame writers |
| `src/live.rs` | fed reading through the C library: `DocumentFeed`, `ValueFeed`, `ValueBuilder`, `EmbeddedFeed`, `FrameParser`, `FramePull`, `Demux` |
| `src/io.rs` | the `std::io::Read` helpers behind the `_reader` entry points (the sinks of the `_to` ones are in `ffi.rs` and `writer.rs`) |
| `src/json.rs` | JSON ⇄ BVNR, as the CLI's `bovnar convert` does it |
| `src/lib.rs` | `parse`, `parse_typed`, `to_string`, the reader and writer twins, `write_array` and the crate-root re-exports |

**Out of scope.** The crate has no bridges to third-party unit or array
libraries. It takes no raw file descriptors or paths either: the entry points
that read or write a whole document are `std::io::Read` / `std::io::Write` ones
([§4.7](#47-files-and-io-streams-stdioread--write)), and a file is an opened
`std::fs::File` passed to them.

---

## 17. Enums and `BaseUnit`

The C enums are Rust enums with CamelCase variants (the C member `METER` is
`BaseUnit::Meter`): `Event`, `TokenType`,
`ValueTypeFamily`, `ErrorCode` (also `ErrorKind`), `BaseUnit`, `PrefixSystem`,
`SIPrefix` and `IECPrefix`. Raw C values convert with `TryFrom<i32>`, which
refuses an unknown value with an `UnknownValue` error, and back with
`i32::from` (or `as i32`). Each also has `from_raw`, which returns an `Option`.

`Exponent` and `Base` are open ranges, so they are checked newtypes with the
common values named (`Exponent::LINEAR`, `Exponent::NEG_SQUARE`, ...).
`Exponent` runs from `EXPONENT_MIN` (−100) to `EXPONENT_MAX` (100).

```rust
use bovnar::{BaseUnit, Exponent, SIPrefix, Unit};

fn main() -> Result<(), bovnar::Error> {
    let km = Unit::si(BaseUnit::Meter, SIPrefix::Kilo, Exponent::LINEAR);
    assert_eq!(km.to_string(), "k~m");
    assert_eq!(Unit::parse("k~m")?, km);
    let c = km.components()[0];
    assert_eq!((c.base, c.prefix), (BaseUnit::Meter, SIPrefix::Kilo.into()));
    assert_eq!(BaseUnit::try_from(i32::from(BaseUnit::Meter)), Ok(BaseUnit::Meter));
    assert_eq!(BaseUnit::Meter.name(), "METER");
    Ok(())
}
```

The `BaseUnit` enum mirrors the C `value_base_unit_e`. See [doc/05 §11.1](05_bovnar_unit_system.md#111-enumerations) for the
block layout — an id's leading two digits name the vocabulary it comes from. The
native units are 100000–100263 (`UNIT_NATIVE_FIRST` … `UNIT_NATIVE_LAST`) and the
currencies 900000–900215 (`CURRENCY_FIRST` … `CURRENCY_LAST`), with the
CamelCase spelling of each member (`POUND_FORCE` is `PoundForce`, `GERMAN_MILE`
is `GermanMile`):

| Range | Members |
|---|---|
| 0 | `None` |
| 100000–100001 | `Bit`, `Byte` |
| 100002–100008 | SI base units (`Second` … `Candela`) |
| 100009–100027 | Named SI derived units (`Hertz` … `Katal`) |
| 100028–100043 | Non-SI accepted units (`Liter` … `Year`) |
| 100044–100053 | Imperial/US length (`Inch` … `Fathom`) |
| 100054–100061 | Imperial/US mass (`Pound` … `Carat`) |
| 100062 | `Fahrenheit` |
| 100063–100066 | Pressure (`Atmosphere` … `Psi`) |
| 100067–100070 | Energy (`Calorie` … `Therm`) |
| 100071 | `Horsepower` |
| 100072–100074 | Force (`PoundForce`, `Dyne`, `Kip`) |
| 100075 | `Knot` |
| 100076–100084 | US volume (`Gallon` … `Barrel`) |
| 100085–100086 | Area (`Acre`, `Barn`) |
| 100087–100089 | Angle (`Arcminute`, `Arcsecond`, `Grad`) |
| 100090–100097 | CGS units (`Poise` … `Galileo`) |
| 100098–100100 | Radiation (`Curie`, `Roentgen`, `Rem`) |
| 100101–100102 | Logarithmic (`Neper`, `Decibel`) |
| 100103 | `Rankine` |
| 100104 | `Slug` |
| 100105 | `Thou` |
| 100106–100108 | UK imperial volume (`PintUk`, `FluidOunceUk`, `QuartUk`) |
| 100109–100110 | Electrical power (`Var`, `VoltAmpere`) |
| 100111 | `KilogramForce` |
| 100112 | `InchHg` |
| 100113 | `Rpm` |
| 100114 | `FootPound` |
| 100115–100116 | Mass additional (`Dram`, `Pennyweight`) |
| 100117–100118 | Length additional (`Chain`, `Rod`) |
| 100119–100120 | Volume additional (`Gill`, `GillUk`) |
| 100121 | `StandardGravity` |
| 100122 | `MetricHorsepower` |
| 100123 | `Revolution` |
| 100124–100125 | Time additional (`Month`, `Fortnight`) |
| 100126 | `AtmosphereTechnical` |
| 100127–100128 | Textile linear density (`Tex`, `Denier`) |
| 100129–100132 | Apothecary/dry volume (`FluidDram`, `Minim`, `Peck`, `Bushel`) |
| 100133–100145 | Historical German units (`Pfund`, `Zentner`, `Doppelzentner`, `Lot`, Prussian line/zoll/fuss/elle/rute, `Klafter`, `GermanMile`, `Morgen`, `Scheffel`) |
| 100146–100152 | Additional physical units (`SurveyFoot`, `League`, `Cable`, `Hand`, `Quintal`, `Scruple`, `Baud`) |
| 100153–100156 | Temperature scales (`Delisle`, `NewtonTemp`, `Reaumur`, `Romer`) |
| 100157–100162 | Ratio/proportion units (`Percent`, `PerMille`, `PerMyriad`, `PerCentMille`, `Ppm`, `Ppb`) |
| 100163 | `PhScale` |
| 100164–100165 | Named speed units (`MilePerHour`, `KilometerPerHour`) |
| 100166–100170 | Water hardness (`GermanHardness`, `EnglishHardness`, `FrenchHardness`, `RussianHardness`, `AmericanHardness`) |
| 100171–100172 | Concentration (`Val`, `GrainsPerGallon`) |
| 100173–100179 | Turbidity, salinity and conductivity (`TurbidityNtu`, `TurbidityFnu`, `PracticalSalinity`, `ConductivityFactor`, `TurbidityFtu`, `TurbidityFau`, `TurbidityJtu`) |
| 100180–100185 | Temperature *differences* (`DeltaKelvin`, `DeltaFahrenheit`, `DeltaDelisle`, `DeltaNewtonTemp`, `DeltaReaumur`, `DeltaRomer`) — `Δ°C` shares `DeltaKelvin` and `Δ°Ra` shares `DeltaFahrenheit` |
| 100186–100191 | The six units the profiles needed (`MeterWater`, `CalorieIt`, `BtuTh`, `TroyPound`, `ApothecaryDram`, `LongHundredweight`) |
| 100192–100200 | US survey lengths (`SurveyInch` … `SurveyAcre`) |
| 100201–100203 | Typographic lengths (`Point`, `Pica`, `Line`) |
| 100204–100209 | US dry volumes and the trade measures (`DryGallon`, `DryQuart`, `DryPint`, `BoardFoot`, `Cord`, `SurveyAcreFoot`) |
| 100210–100214 | `Darcy`, `ThermEc`, `RefrigerationTon`, `Dobson`, `Shake` |
| 100215–100216 | The ratios below ppb (`Ppt`, `Ppq`) — spelled `pptr` and `ppq` in documents |
| 100217–100225 | Nine units two or three vocabularies define (`PrintersPoint`, `PrintersPica`, `HorsepowerElectric`, `HorsepowerBoiler`, `Abvolt`, `AssayTon`, `BushelUk`, `Clo`, `Debye`) |
| 100226–100236 | Calendar and sidereal time (`YearTropical`, `YearGregorian`, `YearSidereal`, `YearCommon`, `MonthSynodal`, `MonthTropical`, `MonthSidereal`, `DaySidereal`, `HourSidereal`, `MinuteSidereal`, `SecondSidereal`) |
| 100237–100243 | CGS electrostatic (`Statvolt`, `Statampere`, `Statcoulomb`, `Statfarad`, `Statohm`, `Stathenry`, `Statsiemens`) |
| 100244–100246 | Luminance (`Lambert`, `Apostilb`, `Footlambert`) |
| 100247–100249 | Water-vapour permeance (`Perm0c`, `Perm23c`, `PermMetric`) |
| 100250–100255 | Heat conventions (`CalorieMean`, `Calorie15c`, `Calorie20c`, `Btu59f`, `Btu60f`, `BtuMean`) |
| 100256–100261 | `ElementaryCharge`, `Spere`, `CircularMil`, `UnitPole`, `HorsepowerWater`, `MonthGregorian` |
| 100262–100263 | CGS electrostatic magnetics (`Statweber`, `Stattesla`) |
| 900000–900165 | ISO 4217 fiat currencies (`Aed` … `Zwl`), block 90 — starts at `CURRENCY_FIRST` |
| 900166–900215 | Cryptocurrencies (`Btc` … `Rune`), the rest of block 90 — ends at `CURRENCY_LAST` |

There is **no sentinel member** one past the highest id: the id space is blocked
and sparse, so such a number would only invite a bounds check it cannot support.
Use `UNIT_NATIVE_FIRST` / `UNIT_NATIVE_LAST` / `CURRENCY_FIRST` / `CURRENCY_LAST`,
or ask `currency::is_currency`.

> **The profiles' opaque units are in this enum.** The C `value_base_unit_t` has
> a block per profile for the profile-only units, and as shipped two of them have
> members: UCUM's arbitrary atoms (200000–200039, `UcumIu` for `ucum:[IU]`) and
> UN/ECE's package and count codes (300000–300024, `UneceOne` … `UneceVial`; see
> doc/11 §7.1). They have no native spelling, so a unit carrying one renders in
> its profile notation: `unit_to_str` returns `"unece:XBX"`. `BaseUnit::try_from`
> accepts the raw base of a profile-only unit.

> **Note on `Cup_`:** the Cuban-Peso currency is exposed as **`BaseUnit::Cup_`**
> (trailing underscore), not `BaseUnit::Cup`. The plain name `Cup` is the US-cup
> volume unit (enum value 100080); since enum variant names must be unique, the
> currency at value 900033 takes the suffixed name. This affects the variant
> name only — the `.bvnr` wire token is still the bare uppercase `CUP`,
> and case stays load-bearing (`cup` = volume, `CUP` = currency).

---

## 18. Spec 1.1 additions

These bindings target the **Bovnar spec 1.2**; spec 1.0 remains the frozen
baseline, and the crate's own version (2.1.0) is a separate number
(`bovnar::version()` against `bovnar::spec_version()`). Spec 1.2's one addition,
the unit-profile notation, is covered in [§5.1](#51-the-unit-profile-notations-spec-12):
it is gated on a `#!bovnar 1.2` directive, which `to_string()` prepends when a
kept spelling or a profile-only unit needs it. The 1.1 features (all gated on a
`#!bovnar 1.1` directive — an unversioned document is treated as 1.0) are
exposed as:

- **Version:** `bovnar::version()` (library version string),
  `bovnar::version_number()` (the same as one number, `major * 10000 + minor *
  100 + patch`: 20100 for 2.1.0), `bovnar::spec_version()` → `(major, minor)`
  of the highest spec understood, and
  `bovnar::peek_version(data)` → the `(major, minor)` a document declares, or
  `None`. `Reader::declared_version()` gives the same after a read;
  `ReadOptions { strict_version: true, .. }` rejects a too-new version
  (`ErrorCode::UnsupportedSpecVersion`), from a `Reader`, a feed, or
  `parse_with_options`.
- **Richer escapes:** `\u{…}` and `\xHH` in string literals are decoded by the C
  reader, so `parse` returns the resulting text transparently. A surrogate /
  out-of-range `\u` is `ErrorCode::InvalidCodepoint`.
- **Datetime family:** `parse` decodes a `<datetime:width,epoch>` value to a
  plain `Value::Int` (signed epoch seconds). With `parse_typed` it is a
  `Quantity` whose `epoch_name()` (`"unix"`, `"tai"`, …) and `epoch_mjd()`
  recover the epoch, and `to_civil()` its UTC date and time
  ([§6.5](#65-civil-time-and-calendars)); `bovnar::datetime_epoch_index(name)` goes the other way,
  to the index a datetime `ValueTypeSpec` holds in `base`, `None` for an
  unknown name. `to_string()` emits the `<datetime:…>` annotation and
  prepends `#!bovnar 1.1` automatically when the value contains a datetime. With
  `parse_typed` the sub-second fraction of an ISO-8601 literal (spec 1.1) is
  preserved on the `Quantity` (`q.frac`) and re-emitted, so a
  `parse_typed` → `to_string()` round trip is lossless (`…T12:00:00.5Z` survives
  verbatim). The plain (non-typed) value is only the whole-second integer
  carrier, so a non-typed `parse` → `to_string()` drops the fraction; to read it
  explicitly use the DOM tier (`datetime_fraction()`) or the streaming
  reader (`d.frac` in a callback). `ValueTypeFamily::Datetime` is the
  family enum member.
- **Reference array indexing:** `&.matrix[0][1]` paths are stored verbatim and
  resolved by `DomDoc::lookup("matrix[0][1]")` at the DOM layer.

```rust
use bovnar::{Value, parse_typed, peek_version, spec_version, to_string};

fn main() -> Result<(), bovnar::Error> {
    assert_eq!(spec_version(), (1, 2));
    let bvnr = b"#!bovnar 1.1\n.t = <datetime:64,unix> 2026-01-01T12:00:00.5Z;";
    assert_eq!(peek_version(bvnr), Some((1, 1)));

    let doc = parse_typed(bvnr)?;
    let Value::Struct(fields) = &doc else { unreachable!() };
    let Value::Quantity(q) = &fields[0].1 else { unreachable!() };
    assert_eq!(q.epoch_name().as_deref(), Some("unix"));
    assert_eq!(q.frac.as_deref(), Some("5"));
    // the fraction survives a typed round trip
    assert_eq!(parse_typed(&to_string(&doc, true)?)?, doc);
    Ok(())
}
```

`ErrorCode` adds `InvalidSpecVersion` (42), `UnsupportedSpecVersion` (43),
`InvalidCodepoint` (44), `InvalidDatetimeLiteral` (45), and
`DatetimeLiteralUnsupportedEpoch` (46).

---

## 19. License

MIT, like the C library — see
[LICENSE](https://github.com/sothis/bovnar/blob/main/LICENSE).

The crate compiles `libbvnr` into your binary, and that embeds Bovnar's
unit-profile tables, which carry identifier strings from UCUM, QUDT, OM 2,
UDUNITS-2, the CF standard name table and UN/ECE Recommendations 20 and 21.
Those identifiers belong to their publishers and the MIT grant does not extend
to them; the notices are at
[`THIRD_PARTY_NOTICES.md`](https://github.com/sothis/bovnar/blob/main/THIRD_PARTY_NOTICES.md).

---

## See also

- [Read & Write API](08_bovnar_readwrite_api.md) — the C reader, writer, and DOM these bindings wrap
- [Streaming, Framing & Multiplexing](10_bovnar_streaming.md) — the framing, multiplexing and embedding behind `bovnar::stream`
- [Specification](03_bovnar_spec.md) — the format the crate reads and writes
- [Unit & Currency Reference](05_bovnar_unit_system.md) — the unit model behind `Unit` and `Quantity`
- [Tutorial](01_bovnar_tutorial.md) — the document syntax, by example

---

*End of Bovnar — Rust Bindings (Bovnar spec 1.2).*
