From 709fe23d23c707f7de54aa0b7aa37efac55d5851 Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:28:03 +0000 Subject: [PATCH 1/5] Add AGENTS.md: how to work on rootfilespec, for agents and people What the package is and its layout, the design rules from docs/design.md and recent reviews (locators, strings as bytes, keep what is on disk, don't guess), root-io-spec as the format reference, setup with the non-recursive submodule, the checks CI runs (pre-commit's pinned tools, and mypy's minimal environment), how tests are written, the shape of issues, commits and PRs, and the AI-attribution convention. CLAUDE.md imports it, since Claude Code reads CLAUDE.md. Assisted-by: claude-code:claude-opus-5-5 --- AGENTS.md | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 106 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..42f712d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,105 @@ +# AGENTS.md + +Guidance for coding agents (and people) working on rootfilespec. Read +`README.md` and `docs/design.md` first. + +## What this package is + +rootfilespec parses ROOT file binary data into Python dataclasses of primitive +types and numpy arrays. It does no I/O of its own: it takes bytes buffers and +returns objects. The goal is a stable, complete read (and later write) backend +for packages such as uproot. + +- `src/rootfilespec/bootstrap/`: the self-describing part of a file (`TFile`, + `TKey`, `TDirectory`, `TStreamerInfo`, strings, compression) and the RNTuple + anchor. +- `src/rootfilespec/rntuple/`: RNTuple envelopes, schema and page locations. +- `src/rootfilespec/dynamic.py`: generates classes from a file's + `TStreamerInfo`. +- `src/rootfilespec/reader.py`: `FileReader` and `Fetcher`, the I/O side. + +## Design rules + +- **Objects describe where data is; callers fetch.** A locator has an `offset`, + a `size` and `read_from(buffer)`. Parsing code never reads from a file itself. +- **Builtin types where they capture the ROOT type.** Every string is `bytes`, + and its on-disk encoding lives in the annotation + (`Annotated[bytes, ROOTString(...)]`), not in a wrapper class. Bytes are never + decoded on the way in: ROOT strings are uninterpreted bytes. Key names are + `bytes` too. +- **Keep what is on disk.** Keep stored values as stored (a sign that carries a + flag, a checksum, unknown trailing bytes), and derive convenience properties + from them. +- **Don't guess.** When a file has something the parser doesn't understand (an + unknown feature flag, an unknown type), raise a clear error or keep the bytes + uninterpreted, rather than misread it. + +## Format references + +- The reference for every format question is root-io-spec, vendored as a + submodule at `reference/root-io-spec`. Cite its sections (and the ROOT source + lines it cites) in code comments, commit messages and PR descriptions. +- For RNTuple, read `spec/05-rntuple/BinaryFormatSpecification.md` together with + `ERRATA.md` and `NOTES.md` in the same directory: ROOT's document disagrees + with ROOT's code in places, and those files record where. +- Do not infer the format from this parser. It can be wrong, and the fixes go + here. +- `reference/root-io-spec/data/` holds small, byte-documented fixtures. Each + case's `gen/cases//case.toml` pins values at file offsets. + +## Setup + +```sh +git submodule update --init reference/root-io-spec # never --recursive: it nests all of ROOT (~1.5 GB) +python -m venv .venv && source .venv/bin/activate +pip install -e . --group dev # pip >= 25.1 for --group +pre-commit install +``` + +## Checks before pushing + +- `pre-commit run --all-files`: ruff, ruff-format, mypy, prettier, codespell and + more, at the versions pinned in `.pre-commit-config.yaml`. CI's Format job + runs exactly this, so a locally installed ruff or mypy of another version is + not a substitute. +- mypy runs in pre-commit's own environment, which has only `pytest`, `numpy` + and `tomli`. An import of any other package (e.g. `xxhash`) needs + `# type: ignore[import-not-found]`. +- `pytest`: the whole suite. `tests/test_spec_fixtures.py` needs the submodule + and skips without it. + +## Tests + +- Every behaviour change gets a test that fails on `main` and passes with the + change. Say in the PR which tests fail on `main`. +- Prefer real files to synthetic bytes: root-io-spec's fixtures first, then + `scikit-hep-testdata`. When a `case.toml` pins bytes at an offset, assert + those exact values. +- Name a test module after what it tests (`test_page_checksums.py`, not + `test_rntuple.py`). +- `EXPECTED_FAILURES` in `tests/test_spec_fixtures.py` lists every fixture that + fails, with its tracking issue and exact error. A fix that changes what a + fixture does must update its entry: remove it, or move it on to the next error + and issue. + +## Issues, commits and pull requests + +- One issue per problem, with evidence: the file, the offset, the spec section, + the error. Something new found while working on another issue gets its own + issue, not a silent fix. +- One pull request per issue, or per tight group of related issues. A PR built + on another says **Depends on #N** and is rebased when that one merges. +- Commits are small. The message says what changed and why, with spec citations. +- A PR description has: `Fixes #N`; what changed and why, with citations; a + **Tests** section (what each test pins, what fails on `main`, the full-suite + count); and a **Breaking changes** list written for the release notes. +- PRs are squash-merged once the maintainer approves them. + +## Marking AI-generated work + +- Issues, PR descriptions and comments written by an agent start with + `> 🤖 AI generated content`. +- Issue bodies and PR descriptions also end with + `Assisted-by: :`, e.g. + `Assisted-by: claude-code:claude-opus-5-5`. +- Commit messages end with the same `Assisted-by:` line. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 2d9d3b6994b780091d7bb42eefbb948307e9bd1b Mon Sep 17 00:00:00 2001 From: Nick Smith Date: Wed, 30 Sep 2026 18:31:39 -0500 Subject: [PATCH 2/5] AGENTS.md: reconcile the string and on-disk rules, fix setup, add conventions The strings-as-bytes rule and "keep what is on disk" conflict when a string's class (TString vs std::string) is known only from a class tag that plain bytes drop, e.g. a string read through a pointer (#127): the value can then not be written back as read. The rules now say the ROOT type must stay recoverable from the annotation or the enclosing record, or the loss must be documented. Setup: CI installs with `uv sync` from uv.lock, and pre-commit is not in the dev group, so `pre-commit install` failed after the documented install. The pip route stays as the fallback. Also: test_spec_cases.py skips without the submodule too; nox runs both checks; the naming rule is for new test modules; new issues are sub-issues of #66 or #9; #67's direction for dynamic.py; the Uninterpreted default for missing classes (#74) and checksummed model names; and Assisted-by replaces tools' default attribution trailers. Assisted-by: claude-code:claude-opus-5-5 --- AGENTS.md | 50 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 42f712d..5deff68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,24 +15,31 @@ for packages such as uproot. anchor. - `src/rootfilespec/rntuple/`: RNTuple envelopes, schema and page locations. - `src/rootfilespec/dynamic.py`: generates classes from a file's - `TStreamerInfo`. + `TStreamerInfo`. The agreed direction (#67) is for the streamer info to steer + deserialization at runtime, with the generated dataclasses kept only as the + result; don't build more on annotations and inheritance as the serialization + definition. - `src/rootfilespec/reader.py`: `FileReader` and `Fetcher`, the I/O side. ## Design rules - **Objects describe where data is; callers fetch.** A locator has an `offset`, a `size` and `read_from(buffer)`. Parsing code never reads from a file itself. -- **Builtin types where they capture the ROOT type.** Every string is `bytes`, - and its on-disk encoding lives in the annotation - (`Annotated[bytes, ROOTString(...)]`), not in a wrapper class. Bytes are never - decoded on the way in: ROOT strings are uninterpreted bytes. Key names are - `bytes` too. -- **Keep what is on disk.** Keep stored values as stored (a sign that carries a - flag, a checksum, unknown trailing bytes), and derive convenience properties - from them. +- **Builtin types where they capture the ROOT type, as long as the ROOT type + stays recoverable.** Every string (key names included) is `bytes`, never + decoded. Its encoding lives in the annotation + (`Annotated[bytes, ROOTString(...)]`) or in the enclosing record (a key's + `fClassName`), not in a wrapper class. Where neither holds it, keep something + that does, or document that the value cannot be written back as read. +- **Keep what is on disk.** Keep everything a writer would need to write the + bytes back: stored values as stored (a sign that carries a flag, a checksum, + unknown trailing bytes), with convenience properties derived from them. - **Don't guess.** When a file has something the parser doesn't understand (an unknown feature flag, an unknown type), raise a clear error or keep the bytes - uninterpreted, rather than misread it. + uninterpreted, rather than misread it. A class missing from the StreamerInfo + reads as `Uninterpreted`, skipped by its byte count (#74). +- Generated model names carry the StreamerInfo checksum; the class version goes + in the docstring. ## Format references @@ -51,11 +58,14 @@ for packages such as uproot. ```sh git submodule update --init reference/root-io-spec # never --recursive: it nests all of ROOT (~1.5 GB) -python -m venv .venv && source .venv/bin/activate -pip install -e . --group dev # pip >= 25.1 for --group -pre-commit install +uv sync --group dev # as CI does, from uv.lock +uv tool install pre-commit && pre-commit install # pre-commit is not in the dev group ``` +Without uv: `python -m venv .venv && source .venv/bin/activate`, then +`pip install -e . --group dev` (pip >= 25.1 for `--group`) and +`pip install pre-commit`. + ## Checks before pushing - `pre-commit run --all-files`: ruff, ruff-format, mypy, prettier, codespell and @@ -65,8 +75,9 @@ pre-commit install - mypy runs in pre-commit's own environment, which has only `pytest`, `numpy` and `tomli`. An import of any other package (e.g. `xxhash`) needs `# type: ignore[import-not-found]`. -- `pytest`: the whole suite. `tests/test_spec_fixtures.py` needs the submodule - and skips without it. +- `pytest`: the whole suite. `tests/test_spec_fixtures.py` and + `tests/test_spec_cases.py` need the submodule and skip without it. +- `nox` runs both (its `lint` and `tests` sessions). ## Tests @@ -75,8 +86,9 @@ pre-commit install - Prefer real files to synthetic bytes: root-io-spec's fixtures first, then `scikit-hep-testdata`. When a `case.toml` pins bytes at an offset, assert those exact values. -- Name a test module after what it tests (`test_page_checksums.py`, not - `test_rntuple.py`). +- Name a new test module after what it tests (`test_page_checksums.py`, not + `test_rntuple.py`). Older modules such as `test_read.py` predate this rule; + leave their names alone. - `EXPECTED_FAILURES` in `tests/test_spec_fixtures.py` lists every fixture that fails, with its tracking issue and exact error. A fix that changes what a fixture does must update its entry: remove it, or move it on to the next error @@ -87,6 +99,8 @@ pre-commit install - One issue per problem, with evidence: the file, the offset, the spec section, the error. Something new found while working on another issue gets its own issue, not a silent fix. +- File a new issue as a native sub-issue of its tracker: #66 for the bootstrap + review, #9 for files that don't read yet. - One pull request per issue, or per tight group of related issues. A PR built on another says **Depends on #N** and is rebased when that one merges. - Commits are small. The message says what changed and why, with spec citations. @@ -103,3 +117,5 @@ pre-commit install `Assisted-by: :`, e.g. `Assisted-by: claude-code:claude-opus-5-5`. - Commit messages end with the same `Assisted-by:` line. +- `Assisted-by:` replaces any tool's default attribution: no `Co-authored-by` + trailer, and no "Generated with ..." footer. From 8d82f56c2f32386448178f9674bf245ce69a4f21 Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:04:24 -0500 Subject: [PATCH 3/5] AGENTS.md: match the repository as it is today - Setup: there is no uv.lock (it is gitignored), and CI installs the test group (`uv sync --group test`); dev is that group plus mypy. - The Uninterpreted rule (#74) and checksum-named models (#22, under #67) are open issues: today a missing class raises "Unknown type ...", and a generated class has the bare class name. Say they are planned. - Tests: test_compressed_size.py (from #130) also uses root-io-spec fixtures and fails without the submodule instead of skipping; say to check it out first, and that a test that doesn't skip is a bug. - case.toml files are under gen/cases/ and gen/written/. - "No I/O" is about the parsing code; reader.py does read files. - Sub-issues: only where a tracker fits (the RNTuple issues fit neither #66 nor #9). Assisted-by: claude-code:claude-opus-5-5 --- AGENTS.md | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5deff68..913fd56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,9 +6,10 @@ Guidance for coding agents (and people) working on rootfilespec. Read ## What this package is rootfilespec parses ROOT file binary data into Python dataclasses of primitive -types and numpy arrays. It does no I/O of its own: it takes bytes buffers and -returns objects. The goal is a stable, complete read (and later write) backend -for packages such as uproot. +types and numpy arrays. Its parsing code does no I/O: it takes bytes buffers and +returns objects (`reader.py`, below, is the one place that reads files). The +goal is a stable, complete read (and later write) backend for packages such as +uproot. - `src/rootfilespec/bootstrap/`: the self-describing part of a file (`TFile`, `TKey`, `TDirectory`, `TStreamerInfo`, strings, compression) and the RNTuple @@ -37,9 +38,11 @@ for packages such as uproot. - **Don't guess.** When a file has something the parser doesn't understand (an unknown feature flag, an unknown type), raise a clear error or keep the bytes uninterpreted, rather than misread it. A class missing from the StreamerInfo - reads as `Uninterpreted`, skipped by its byte count (#74). -- Generated model names carry the StreamerInfo checksum; the class version goes - in the docstring. + is to read as `Uninterpreted`, skipped by its byte count (#74, still open: + today it raises `Unknown type ...`). +- Generated model names are to carry the StreamerInfo checksum, with the class + version in the docstring (#22, under #67, still open: today a generated class + has the bare class name). ## Format references @@ -52,13 +55,14 @@ for packages such as uproot. - Do not infer the format from this parser. It can be wrong, and the fixes go here. - `reference/root-io-spec/data/` holds small, byte-documented fixtures. Each - case's `gen/cases//case.toml` pins values at file offsets. + case's `case.toml`, under `gen/cases/` or `gen/written/`, pins values at file + offsets. ## Setup ```sh git submodule update --init reference/root-io-spec # never --recursive: it nests all of ROOT (~1.5 GB) -uv sync --group dev # as CI does, from uv.lock +uv sync --group dev # CI's test group, plus mypy uv tool install pre-commit && pre-commit install # pre-commit is not in the dev group ``` @@ -75,8 +79,8 @@ Without uv: `python -m venv .venv && source .venv/bin/activate`, then - mypy runs in pre-commit's own environment, which has only `pytest`, `numpy` and `tomli`. An import of any other package (e.g. `xxhash`) needs `# type: ignore[import-not-found]`. -- `pytest`: the whole suite. `tests/test_spec_fixtures.py` and - `tests/test_spec_cases.py` need the submodule and skip without it. +- `pytest`: the whole suite. Check out the submodule first: the tests that use + its fixtures skip without it, and a test that doesn't is a bug. - `nox` runs both (its `lint` and `tests` sessions). ## Tests @@ -99,8 +103,8 @@ Without uv: `python -m venv .venv && source .venv/bin/activate`, then - One issue per problem, with evidence: the file, the offset, the spec section, the error. Something new found while working on another issue gets its own issue, not a silent fix. -- File a new issue as a native sub-issue of its tracker: #66 for the bootstrap - review, #9 for files that don't read yet. +- File a new issue as a native sub-issue of its tracker where one fits: #66 for + the bootstrap review, #9 for files that don't read yet. - One pull request per issue, or per tight group of related issues. A PR built on another says **Depends on #N** and is rebased when that one merges. - Commits are small. The message says what changed and why, with spec citations. From 131c91a1b03e48aa1c236882d7e1e7bb1b8afe9e Mon Sep 17 00:00:00 2001 From: Nick Smith Date: Thu, 1 Oct 2026 14:32:31 -0500 Subject: [PATCH 4/5] AGENTS.md: state where builtin types are allowed, and require injective decoding Rewrite the builtin-types rule after #127's review: a value may be an annotated builtin only inside a context that records its ROOT type and encoding (a member's annotation, a container's element type, a TKey's fClassName, or a pointee's Ref, which is to keep the stream header per #105). Decoding must be injective on every accepted input, so inputs that would decode to the same value are rejected or kept, not merged. Assisted-by: claude-code:claude-opus-5-5 --- AGENTS.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 913fd56..7605f71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,12 +26,15 @@ uproot. - **Objects describe where data is; callers fetch.** A locator has an `offset`, a `size` and `read_from(buffer)`. Parsing code never reads from a file itself. -- **Builtin types where they capture the ROOT type, as long as the ROOT type - stays recoverable.** Every string (key names included) is `bytes`, never - decoded. Its encoding lives in the annotation - (`Annotated[bytes, ROOTString(...)]`) or in the enclosing record (a key's - `fClassName`), not in a wrapper class. Where neither holds it, keep something - that does, or document that the value cannot be written back as read. +- **Builtin types only where the context holds the ROOT type.** A value may be a + Python builtin (`Annotated[T, fmt]`) only inside a context that records its + ROOT type and encoding: a member (its annotation), a container element (the + container's type), data written with a `TKey` (its `fClassName`), or a pointee + (its `Ref`, which is to keep the stream header: #105, still open, today a + pointee with a class tag comes back bare). Decoding must be injective on every + input the reader accepts: inputs that would decode to the same value are + rejected or kept, not merged. Every string, key names included, is `bytes` + this way, never decoded. - **Keep what is on disk.** Keep everything a writer would need to write the bytes back: stored values as stored (a sign that carries a flag, a checksum, unknown trailing bytes), with convenience properties derived from them. From 66fbf03b44e1f1be8fcc081fd8d981f4d4d9cd76 Mon Sep 17 00:00:00 2001 From: Nick Smith Date: Thu, 1 Oct 2026 15:05:30 -0500 Subject: [PATCH 5/5] Move the design rules into docs/design.md, keep a summary in AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md and docs/design.md described the same rules and had already drifted: design.md sent the pointer case to #135, AGENTS.md to #105. docs/design.md now holds each rule in full, with the reasons and the issues still open against it: - "Annotated builtin types vs. objects" states the context rule (member annotation, container element type, TKey, pointee Ref per #105, survey in #139), that the choice of builtin is ergonomic (TDatime, #123), and the injectivity requirement (kBool 0x99, root-io-spec ElementTypes §2.5; #140). - New sections for keeping what is on disk, content the parser does not understand (#74), and generated classes (#67, #22). AGENTS.md keeps one line per rule, linking its design.md section, so an agent still has the rules in context. Assisted-by: claude-code:claude-opus-5-5 --- AGENTS.md | 44 ++++++++++++++--------------------- docs/design.md | 62 +++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 73 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7605f71..95d7647 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,37 +15,27 @@ uproot. `TKey`, `TDirectory`, `TStreamerInfo`, strings, compression) and the RNTuple anchor. - `src/rootfilespec/rntuple/`: RNTuple envelopes, schema and page locations. -- `src/rootfilespec/dynamic.py`: generates classes from a file's - `TStreamerInfo`. The agreed direction (#67) is for the streamer info to steer - deserialization at runtime, with the generated dataclasses kept only as the - result; don't build more on annotations and inheritance as the serialization - definition. +- `src/rootfilespec/dynamic.py`: generates classes from a file's `TStreamerInfo` + (see Design rules below for its direction, #67). - `src/rootfilespec/reader.py`: `FileReader` and `Fetcher`, the I/O side. ## Design rules -- **Objects describe where data is; callers fetch.** A locator has an `offset`, - a `size` and `read_from(buffer)`. Parsing code never reads from a file itself. -- **Builtin types only where the context holds the ROOT type.** A value may be a - Python builtin (`Annotated[T, fmt]`) only inside a context that records its - ROOT type and encoding: a member (its annotation), a container element (the - container's type), data written with a `TKey` (its `fClassName`), or a pointee - (its `Ref`, which is to keep the stream header: #105, still open, today a - pointee with a class tag comes back bare). Decoding must be injective on every - input the reader accepts: inputs that would decode to the same value are - rejected or kept, not merged. Every string, key names included, is `bytes` - this way, never decoded. -- **Keep what is on disk.** Keep everything a writer would need to write the - bytes back: stored values as stored (a sign that carries a flag, a checksum, - unknown trailing bytes), with convenience properties derived from them. -- **Don't guess.** When a file has something the parser doesn't understand (an - unknown feature flag, an unknown type), raise a clear error or keep the bytes - uninterpreted, rather than misread it. A class missing from the StreamerInfo - is to read as `Uninterpreted`, skipped by its byte count (#74, still open: - today it raises `Unknown type ...`). -- Generated model names are to carry the StreamerInfo checksum, with the class - version in the docstring (#22, under #67, still open: today a generated class - has the bare class name). +`docs/design.md` has each rule in full, with its reasons and the issues still +open against it. In short: + +- **Objects describe where data is; callers fetch.** Parsing code never reads + from a file itself ([locators](docs/design.md#data-fetching-and-locators)). +- **A builtin only where the context records the ROOT type**: a member's + annotation, a container's element type, a `TKey`, or a pointee's `Ref`. Its + decoding must be injective. Every string, key names included, is `bytes`, + never decoded ([builtins](docs/design.md#annotated-builtin-types-vs-objects)). +- **Keep what is on disk**: everything a writer needs to write the bytes back + ([on disk](docs/design.md#keeping-what-is-on-disk)). +- **Don't guess**: raise a clear error or keep the bytes uninterpreted + ([unknown content](docs/design.md#content-the-parser-does-not-understand)). +- **Generated classes**: don't build more on annotations and inheritance (#67) + ([generated classes](docs/design.md#generated-classes)). ## Format references diff --git a/docs/design.md b/docs/design.md index f77eb13..4a9686e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -116,12 +116,62 @@ encoding per length format (`"TString"`, `"charstar"` and `"RNTuple"`), and `std::string` data member. `bootstrap.TString` is the alias `Annotated[bytes, ROOTString("TString")]` for hand-written classes. -A design assumption follows: the bytes do not record which ROOT type or encoding -they came from. A member keeps it in its annotation, a container element in the -container's type, and a record of its own in its key's `fClassName`, so writing -those back needs that context, not just the `bytes`. A string read through a -pointer keeps it nowhere: the stream's class tag is read to find the type and -then dropped, so such a value cannot be written back as read (#135). +A builtin does not record which ROOT type or encoding it came from, so the rule +is: **a value may be a builtin only inside a context that records its ROOT type +and encoding**. Writing it back needs that context, not just the value. The +contexts are: + +- a data member: its annotation; +- a container element (e.g. `vector`): the container's element type; +- data written with a `TKey`: the key's `fClassName`. The caller keeps the key + and the value it located as a pair, so a key of class `TString` reads as + `bytes`; +- an object reached through a pointer: its `Ref`, which keeps the stream header + (the class tag or back-reference, the byte count, the version). Every pointee + is wrapped, so a `TList` reads as a list of `Ref`s. This is #105, still open: + today a pointee with a new class tag comes back bare and its header is + dropped, so a string read through a pointer cannot be written back as read. + +#139 surveys every way an object reaches a file, including those not read yet +(unsplit `TTree` branches, RNTuple streamer fields, `TClonesArray` elements). A +reader for one of them keeps the record of the type next to the values. + +Which builtin to use is a matter of ergonomics: `bytes` for strings, never +decoded; and for example a `datetime` rather than the packed `int` for a +`TDatime` (#123). + +The conversion from the bytes on disk to the builtin must be injective on every +input the reader accepts: inputs that would decode to the same value are +rejected with a clear error, or kept, never merged, because a writer could not +tell which one to put back. For example, a `kBool` byte can be `0x99` in files +ROOT writes (root-io-spec ElementTypes §2.5), so reading it as `bool` loses the +byte; and a `TString` can be read from the long form even when it is short +(#140). + +## Keeping what is on disk + +Writing files is a goal, so a deserialized object keeps everything a writer +would need to write the same bytes back: stored values as stored (a sign that +carries a flag, a checksum, unknown trailing bytes), with convenience properties +derived from them rather than in their place. + +## Content the parser does not understand + +When a file holds something the parser does not understand (an unknown feature +flag, an unknown type), it raises a clear error or keeps the bytes +uninterpreted, rather than guessing. A class missing from the StreamerInfo is to +read as `Uninterpreted`, skipped by its byte count (#74, still open: today it +raises `Unknown type ...`). + +## Generated classes + +`dynamic.py` generates dataclasses from a file's `TStreamerInfo`. The agreed +direction (#67) is for the streamer info to steer deserialization at runtime, +with the generated dataclasses kept only as the result, so new work should not +build more on annotations and inheritance as the serialization definition. +Generated model names are to carry the StreamerInfo checksum, with the class +version in the docstring (#22, still open: today a generated class has the bare +class name). ## Data Fetching and Locators