Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -222,4 +222,7 @@ add_subdirectory(src/laige-core)
# ---------------------------------------------------------------------------
if(LAIGE_BUILD_TESTS)
add_subdirectory(tests)
# Dev tools that need the built engine library (gated with tests: a
# library-only build does not need them).
add_subdirectory(tools/fuzz)
endif()
207 changes: 207 additions & 0 deletions docs/api/json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# Bounded JSON parser + serializer (`laige::JsonValue`, `parseJson`)

The engine's declarative-config format and the home of all engine JSON
(M0-CORE-07; ADR 0003, FR-1.5). Public header:
`src/laige-core/include/laige/json.h`; implementation:
`src/laige-core/json.cpp`. Unit suite: `ctest -R config_json`
(`tests/laige-core/config_json_tests.cpp`). Fuzz target: `json_parse`
(`tools/fuzz/laige-fuzz.cpp`; CTest entry `fuzz_json_parse`).

ADR 0003's decision: a hand-rolled, *bounded* parser + serializer in
`laige-core`, **no new dependency** (PRD §11 unchanged). The needed
subset — objects, arrays, strings, numbers, booleans, null — is exactly
what this API covers.

## Quick start

```cpp
#include <laige/json.h>

// Parse a config document (whole view must be consumed).
auto doc = laige::parseJson(text); // Result<JsonValue>
if (doc.isError()) {
// Log the NFR-13.3 line (LOG-002) — the error is always
// ErrorCode::MalformedInput (3); no other code is reachable.
engineLog(laige::ErrorCode::MalformedInput, doc.errorText());
return;
}

const laige::JsonValue& root = doc.value();

// Read a member with a default (findMember is null-safe on any value).
const laige::JsonValue* tick = root.findMember("tick_rate");
double tickRate = (tick != nullptr && tick->isNumber()) ? tick->asNumber()
: 60.0;

// Build a document (API manifest tooling, budget files) and serialize.
laige::JsonValue budgets = laige::JsonValue::makeObject();
budgets.setMember("update", laige::JsonValue::fromNumber(1.5));
const std::string text2 = laige::serializeJson(budgets);
// parse(serialize(v)) == v always (see Round-trip below).
```

## Accepted grammar (RFC 8259, strict)

Whitespace = `SPACE` / `TAB` / `LF` / `CR`, between tokens only. Values =
object / array / string / number / `true` / `false` / `null`. Strings
accept the six two-character escapes, `\/`, and `\uXXXX`. Numbers follow
RFC 8259 §6 exactly: `[ - ] int [ frac ] [ exp ]` with no leading zeros,
no leading `+`, and at least one digit after `.` and after `e`/`E`.

Strictness choices above the RFC floor — each returned as
`ErrorCode::MalformedInput` (never a crash, never silent, CORE-008):

| Rule | Rationale |
|---|---|
| Object member names must be unique (duplicates rejected) | Config hygiene: silent last-wins is a footgun for dev-authored documents. |
| Raw control characters `U+0000..U+001F` in strings rejected | The JSON grammar requires them escaped; `\u0000` escapes are accepted and stored as-is. |
| UTF-8 validated strictly | No overlong encodings, no raw surrogate codepoints, nothing above `U+10FFFF`. |
| Surrogate halves only as `\uD800..\uDBFF` + `\uDC00..\uDFFF` pairs | A lone half has no UTF-8 encoding (ADR 0003: "UTF-8 validated"). |

Anything else — trailing data after the top-level value, unterminated
values, bad escapes, bad numbers, invalid bytes — is `MalformedInput`.
A failed parse produces **no** `JsonValue` (all-or-nothing).

## Bounds (ADR 0003)

`parseJson(input, options)` is bounded by `laige::JsonOptions`:

| Option | Default | Meaning |
|---|---|---|
| `maxDocumentBytes` | `1 MiB` (`1 << 20`) | the raw input must not exceed this (inclusive) |
| `maxDepth` | `32` | maximum nested container depth (`maxDepth <= 0` rejects all containers) |

The parser is recursive descent, but recursion depth is bounded by
`maxDepth`, so **no input causes recursion blowup**. Every failure path
is a bounded return — no crash, no partial document. The bounds are
engine-configurable per call (API-006); the defaults are the documented
ones.

## Number semantics

- **JSON number → `double`** (ADR 0003). Config values that need exact
integers must lie in `±2^53`, where doubles are exact; beyond that,
integer literals lose low-order bits (e.g. `9007199254740993` stores
`2^53`). That is a documented precision policy, not an error — the
config validation layer (M1) owns range checks.
- **Overflow stores `±inf`** (IEEE 754): a well-formed token that does
not fit a double (e.g. `1e999`) parses to `±inf`. That is a *valid*
parse result; callers consuming or serializing the value must reject
non-finite numbers (see Performance and failure below). NaN is
unreachable from a well-formed document (JSON has no NaN literal).
- **Serializer numbers** are the *shortest correctly rounded decimal*:
the least precision `p` in `1..17` whose `%g` rendering round-trips to
the bit-identical double (`1.5` → `1.5`, `2.0` → `2`, `1e21` →
`1e+21`, `123456.75` → `123456.75`). `-0.0` serializes as `0` (negative
zero is not preserved). The output is deterministic on every platform
(correctly-rounded decimal conversion; round-trip equality is
bit-exact IEEE 754).

## `JsonValue`

A parsed or hand-built document. Value semantics: **copy is deep**
(`O(size)`, allocates), **move is `O(1)`**; a moved-from value is `Null`.
The value always owns exactly the payload its kind names — every kind
change releases the old payload (no stale state).

| Operation | Behavior | Cost |
|---|---|---|
| `kind()` / `isX()` | kind queries | `O(1)` |
| `asBool` / `asNumber` / `asString` / `asArray` / `asObject` | kind accessors; **precondition: matching kind** (debug assert; documented UB in release) | `O(1)` |
| `findMember(name)` | null-safe lookup: `nullptr` when not an object or absent (total on any value) | `O(members)` |
| `hasMember(name)` | `findMember(name) != nullptr` | `O(members)` |
| `setNull` / `setBool` / `setNumber` / `setString` | make this the given value, releasing the old payload | `O(1)` / `O(len)` |
| `append(element)` | array element; **precondition: `isArray()`** | `O(1)` amortized |
| `setMember(name, value)` | replace in place (position preserved) or append; **precondition: `isObject()`** | `O(members)` |
| `operator==` | deep structural equality | `O(size)` |

**Equality** is deep and structural: objects compare **independent of
member order** (`{"a":1,"b":2} == {"b":2,"a":1}` — configs in different
key order are equal), arrays are **order-sensitive**, and a `Number`
holding NaN compares unequal to itself (IEEE 754 `==`).

**Ownership and lifetime (CPP-002, CPP-009, CONC-001).** A `JsonValue`
owns its payload and has exactly one owner thread while mutable — it is
**not thread-safe**. A fully constructed value is safe to read from any
thread (no internal synchronization; the same publish contract as
`Result`/`Status`).

## Serializer

`serializeJson(value)` emits the canonical compact form: no insignificant
whitespace; strings ASCII-safe (`\b \f \n \r \t` for the printable
controls, `\uXXXX` for the rest, and `\uXXXX` — surrogate pairs above
`U+FFFF` — for every codepoint above `0x7F`); numbers as documented
above; objects and arrays in stored order (for parsed documents: document
order). The output is pure ASCII, so it is byte-identical on every
platform and re-parses to the bit-identical value.

## Round-trip

`parse(serialize(v)) == v` for every value whose numbers are finite, and
`serialize(parse(serialize(parse(s)))) == serialize(parse(s))` — the
serializer is idempotent on its own output. The `ConfigJsonRoundTrip`
suite pins both properties on a corpus that includes deep nesting (32),
escaped strings, surrogate pairs, and control characters.

## Performance

**Cold path, O(n).** Parsing and serialization are config-load,
manifest-generation, and budget-file work — **never frame or tick loops**
(PERF-003, PERF-007). Time and space are `O(n)` in document bytes;
object member insertion is `O(members)` per insert (`O(m^2)` per object —
negligible at config scale). One allocation per string value and per
array/object container; the serialized document is one `std::string`.

**Traps:**

- `serializeJson` **recurses** to the value's nesting depth: bounded by
`JsonOptions::maxDepth` for parsed documents, but a hand-built value of
pathological depth risks stack overflow (build documents at normal
depths; the parser cannot produce deeper values than `maxDepth`).
- Serializing a value containing a `Number` holding NaN or `±inf` is a
documented precondition violation (debug assert; UB in release) —
non-finite values are not representable in JSON.
- `fromString`/`setString` with invalid UTF-8 is a documented
precondition violation (debug assert; UB in release). The parser never
produces invalid UTF-8, so parser-built values are always safe to
serialize.

## Determinism

Parsing is a pure function of the input bytes: same input → same value,
same serialization, on every platform (no floating point beyond the
documented `double` number semantics, no platform intrinsics, no
ordering that depends on anything but the input). Object and array
iteration order is document order for parsed values and insertion order
for built values — deterministic in both cases.

## Errors

`parseJson` returns `laige::Result<JsonValue>`; **every failure is
`ErrorCode::MalformedInput` (3)** — the registry entry for code 3
already names the ADR 0003 size/depth bounds, so no new codes exist
(CORE-004). `Status::errorText()` / `errorText(code)` renders the
NFR-13.3 five-field line for logging (LOG-002). There is no other
failure channel: discarding the `Result` is a likely logic bug
(CORE-008).

| Condition | Code |
|---|---|
| Input over `maxDocumentBytes` | `ErrorCode::MalformedInput` (3) |
| Nesting over `maxDepth` | `ErrorCode::MalformedInput` (3) |
| Grammar violation (bad token, escape, number, key) | `ErrorCode::MalformedInput` (3) |
| Duplicate object key | `ErrorCode::MalformedInput` (3) |
| Invalid UTF-8 / lone surrogate / raw control character | `ErrorCode::MalformedInput` (3) |
| Trailing data after the top-level value | `ErrorCode::MalformedInput` (3) |

## Fuzzing (NFR-8.7, TEST-005, SCALE-005)

The parser is a malformed-input surface and is fuzzed in CI: the
`json_parse` target feeds `laige::parseJson` a deterministic,
Prng-seeded stream of mutated, truncated, and random byte inputs
(`tools/fuzz/laige-fuzz.cpp`). The bounded run — `laige-fuzz
json_parse --runs=1000` — is registered as the `fuzz_json_parse` CTest
entry and runs in **every build tree**, instrumented in the ASan tree
(the step's Verify gate; PRD §14: fuzz "every commit (bounded), nightly
(long)" — the nightly long-run lane lands with M0-TEST-01).
19 changes: 14 additions & 5 deletions docs/getting-started/building.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ Notes:

- `Debug` is the canonical `CMAKE_BUILD_TYPE`; `Release` is supported.
- The four rows above the lint row name tools that land in later M0 steps —
`laige-fuzz` (M0-TEST-01), `laige-bench` (M0-CORE-08), `laige-detcheck`
`laige-fuzz` (minimal form from M0-CORE-07: the `json_parse` target and
deterministic bounded runs; M0-TEST-01 extends it with CI lane semantics
and nightly long runs), `laige-bench` (M0-CORE-08), `laige-detcheck`
(M0-TOOL-02), target `laige-api` (M0-TOOL-01). Their command forms are
fixed here now so later steps cannot drift.
- Include-graph lint (M0-CI-03): platform-independent (Python 3 stdlib
Expand Down Expand Up @@ -156,10 +158,7 @@ pinned set and the NaN/Inf policy):
and the fpx16_16 rounding/saturation policy in
[docs/api/sim_math.md](../api/sim_math.md), pinned flags via
`laige_apply_simmath_policy()`), and the memory pools from M0-CORE-05
(`include/laige/pools.h`: `laige::ArenaPool<T>` and `laige::Pool<T>`
with `laige::PoolStats` accounting; API contract in
[docs/api/pools.md](../api/pools.md)).
- `tests/laige-core/laige-core_tests` is a CTest link smoke test (a
(`include/laige/pools.h`: `laige::ArenaPool<T>` and `laige::Pool<T>` with `laige::PoolStats` accounting; API contract in [docs/api/pools.md](../api/pools.md)), and the bounded JSON parser + serializer from M0-CORE-07 (`include/laige/json.h`, `json.cpp`: `laige::JsonValue`, `parseJson`, `serializeJson`, `JsonOptions`; API contract in [docs/api/json.md](../api/json.md)).- `tests/laige-core/laige-core_tests` is a CTest link smoke test (a
GoogleTest suite since M0-DEP-01) that runs in every build tree above: it
verifies the static/shared link and checks the NFR-8.10 policy flags with
`static_assert` (a policy violation fails the build).
Expand Down Expand Up @@ -188,6 +187,16 @@ pinned set and the NaN/Inf policy):
`ctest -R pools` (budget exhaustion, reset semantics, and
generation-checked stale handles; the stale-handle assert runs in a
forked child on the POSIX jobs).
- `config_json` is the M0-CORE-07 CTest entry: a filtered view of the
same `laige-core_tests` executable covering the `ConfigJsonValid`,
`ConfigJsonInvalid`, `ConfigJsonRoundTrip`, `ConfigJsonValue`, and
`ConfigJsonOptions` suites — the step's Verify command is
`ctest -R config_json`.
- `fuzz_json_parse` is the M0-CORE-07 bounded-fuzz CTest entry
(`laige-fuzz json_parse --runs=1000`, registered in `tools/fuzz`): it
runs in every build tree — in the ASan tree it is instrumented and is
the step's sanitizer gate (NFR-8.7; PRD §14: fuzz "every commit
(bounded), nightly (long)").
- Every configure verifies the vendored dependency lock
(`cmake/laige-deps-lock.cmake` against `deps.lock`); a tampered or
unlisted file under `deps/` fails the configure loudly. GoogleTest is the
Expand Down
69 changes: 66 additions & 3 deletions roadmap/M0-foundations.md
Original file line number Diff line number Diff line change
Expand Up @@ -573,16 +573,79 @@ No rendering, no physics, no networking yet — `laige-core` only.
proof — the step's "period sanity" clause — is a full algebraic
proof rather than a sample check, which the GF(2) machinery costs)

- [ ] **M0-CORE-07 · Config JSON: value type + parser**
- [x] **M0-CORE-07 · Config JSON: value type + parser**
- **Refs:** FR-1.5 (M1 consumes it), M0-DEC-03; PRD §8.2 (NFR-8.7 fuzz), AGENTS TEST-005
- **Depends:** M0-CORE-01, M0-DEC-03
- **Scope:**
- `laige::JsonValue` + parser per D-JSON decision: bounded (depth, size limits documented), no recursion blowup, malformed input → `Status` error (never crash, never silent).
- Serializer for round-trip of simple structures.
- Fuzz target `json_parse` registered with the fuzz runner.
- Unit tests: valid/invalid/malformed corpus (nested depth limit, huge number, truncated input, encoding edge cases).
- **Verify:** `ctest -R config_json` green; `laige-fuzz json_parse --runs=1000` clean under ASan.
- **Size:** ~400 lines + tests (split here if the parser exceeds budget: `M0-CORE-07a` parser, `M0-CORE-07b` serializer+fuzz)
- **Decision (2026-09-11):** hand-rolled bounded parser in
`laige-core` — no dependency (ADR 0003). Recursive descent with an
RAII depth guard; bounds from `JsonOptions` (defaults: 1 MiB document,
depth 32, both inclusive; `maxDepth <= 0` rejects every container).
**Every** parse failure — grammar, bad escapes, duplicate object
keys, raw control characters, invalid UTF-8 (overlong / raw
surrogate / > U+10FFFF), lone surrogate halves in `\uXXXX`, over
size/depth — is `ErrorCode::MalformedInput` (3): no new codes,
because the registry entry for code 3 already names the ADR 0003
bounds (CORE-004). Numbers: JSON number → `double` via
`std::strtod`; a well-formed overflow token (e.g. `1e999`) stores
±inf — a *valid parse result*, callers must reject non-finite
(config validation, M1); integer literals beyond ±2^53 lose
precision (documented policy). `JsonValue`: plain members; deep
copy (O(size)), O(1) move with moved-from == Null (documented);
the "owns exactly the payload its kind names" invariant is kept by
`clearPayload()` on every kind change and every assignment.
Equality: deep; objects order-insensitive, arrays order-sensitive,
NaN != NaN. Serializer: canonical compact ASCII (control chars and
every codepoint > 0x7F as `\uXXXX`, surrogate pairs above U+FFFF;
numbers the shortest correctly rounded decimal via a `%.*g`
search over p = 1..17 — deliberately *not* `std::to_chars`, which
AppleClang 15 (macos-14 lane) lacks for floats; `-0.0` → `0`);
`parse(serialize(v)) == v` for all finite values; serializing a
non-finite number is a documented precondition violation.
`laige-fuzz`: the *minimal* deterministic fuzz runner lands in this
step because the Verify gate requires it (Prng-seeded, default
seed `0x1F055EED`, `--runs`/`--seed`, three input modes — mutate /
truncate / random bytes — over a 19-document ASCII corpus; any
Status is acceptable, only a crash fails); target `json_parse`
registered. M0-TEST-01 extends it (CI lane semantics, nightly long
runs, seed documentation). API contract: `docs/api/json.md`.
- **Verify:** `ctest -R config_json` green — 25 GTest cases across
`ConfigJsonValid` (all six kinds; DBL_MAX / denorm_min / ±inf
overflow / 2^53+1 rounding; escapes, surrogate pairs, strict
UTF-8, DEL; containers, document order, depth-32 and 1 MiB
boundary documents), `ConfigJsonInvalid` (empty/trailing data,
truncation, bad numbers, bad escapes, lone surrogates, raw
controls, invalid UTF-8, duplicate keys, depth 33, 1 MiB+2 —
every case asserts `MalformedInput`, not merely an error),
`ConfigJsonRoundTrip` (parse→serialize→parse value stability and
serializer idempotence over a corpus incl. 32-deep nesting and
escaped strings; canonical forms pinned), `ConfigJsonValue`
(factories, deep copy, moved-from Null, in-place replacement, kind
transitions, deep equality incl. NaN != NaN and object
order-insensitivity, total `findMember`, churn), and
`ConfigJsonOptions` (maxDepth 1/2; maxDocumentBytes inclusive bound
and 0). `laige-fuzz json_parse --runs=1000` clean under ASan: the
instrumented `fuzz_json_parse` CTest entry passed in the ASan tree
and a direct `ASAN_OPTIONS=detect_leaks=1:halt_on_error=1` run is
clean (1000 deterministic Prng-driven runs, no crash, no sanitizer
report). Verified locally 2026-09-11: full 14/14 ctest on
GCC 16.2.1 (`build` static, `build-shared` shared, `build-asan`
ASan+UBSan fatal, `build-tsan` TSan `halt_on_error=1`) and a
Clang 22.1.8 tree — zero warnings under the NFR-8.10 policy;
CI will additionally prove MSVC (windows lane) and AppleClang
(macos lane) compilation of the new sources.
- **Size:** 270 lines header (`json.h` — full AGENTS §9 contracts
next to the code) + 826 lines implementation + 620 lines tests +
208 lines fuzz runner + ~22 lines fuzz CMake + ~12 lines CMake
wiring (over the ~400-line estimate; kept cohesive rather than
split into `M0-CORE-07a/07b`, same pattern as
M0-CORE-01…06: the header carries the API contract and the tests
prove the step's Verify clauses — depth/size bounds, the malformed
corpus, round-trip, value semantics — in one suite)

- [ ] **M0-CORE-08 · Budget harness (histogram + budget checks)**
- **Refs:** PRD §8.1, CORE-001; AGENTS §12 (benchmark report requirements)
Expand Down
4 changes: 2 additions & 2 deletions src/laige-core/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@

if(LAIGE_BUILD_SHARED)
add_library(laige-core SHARED version.cpp errors.cpp logging.cpp
sim_math.cpp sim_math_fixed.cpp)
sim_math.cpp sim_math_fixed.cpp json.cpp)
else()
add_library(laige-core STATIC version.cpp errors.cpp logging.cpp
sim_math.cpp sim_math_fixed.cpp)
sim_math.cpp sim_math_fixed.cpp json.cpp)
endif()

laige_apply_engine_policy(laige-core)
Expand Down
Loading
Loading