Skip to main content

0005 — An export carries the schema's numbers, not a reader's

  • Status: Accepted, 2026-09-02, with one condition attached at acceptance: every field kind a schema can declare has a stated representation, audio blocks included. §5 is that table. Nothing is skipped silently.
  • Implementation status: Nothing built. The behaviour this record describes exists in TypeScript, in the applications it replaces.
  • Date: 2026-09-02
  • Depends on: 0004 §1 — the converter cannot link a vocabulary, so this record has to say what it does instead.

Context

The applications read numbers out of a sample two ways, and call them tiers.

Tier one is a hand-written reader per schema. Six of them, keyed on schema identifier, in one application's channel table and its two copies elsewhere. Each does some of four things no descriptor states:

It doesExampleKind
Flattens a nested message into columnsacceleration.xaxnaming
Nulls a value when a flag says the sample said nothinggaze x when tracked is falsevalidity
Rescales a unit for a personstorage_free_bytes ÷ 10⁹, labelled GBypresentation
Computes a figure that is not in the sampleaudio RMS and peak from decoded PCManalysis

Tier two is the descriptor. A describedChannels pass in one of those applications: every non-repeated numeric scalar field, with its unit annotation. It reads a schema nobody wrote a reader for. It also skips every message-typed field, by a stated choice — flattening "would need a name for the column … and choosing one here would make the header depend on this file rather than on the schema."

That choice is what makes tier two unusable on its own, and the size of the gap is worth stating rather than gesturing at:

An inertial schema is the worked case. Its five fields, read off the schema as it stood on 2026-09-02, are Vector3 acceleration, Vector3 angular_velocity, Quaternion rotation, Vector3 magnetic_field and string frame. The schema is not in this repository, so the grep that produced that list cannot be run from this checkout.

Every measurement in an inertial sample is inside a nested message. Tier two gives an ImuSample zero columns. A GazeSample gets three — tracked, screen_point_valid, confidence — and none of the gaze.

So the question 0004 §1 leaves is not "generic tier or readers." It is: what does the generic tier have to do so that a converter with no vocabulary produces a file worth having, for every schema — including the ones whose data is a block of bytes?

Decision

An export carries what the schema declares, in the units it declares, under names derived from its structure. Everything a reader adds on top of that is either a declaration the schema should have made, or an analysis that does not belong in an export.

Taking the four kinds in turn, then the table that covers every field kind.

1. Naming — flatten by path, and let the name be overridden

A nested message is walked, and every numeric scalar leaf becomes a column named by its path with the separator replaced: acceleration.xacceleration_x, left.pupil_diameter_mmleft_pupil_diameter_mm. The unit is the leaf's own annotation.

The objection tier two raised — that a name chosen by the converter makes the header depend on the converter — is answered by making the name a default rather than a decision. A column's name is resolved in order: a name the user set, then the path. The first of those is the column-naming feature this work exists to build; the second is mechanical, derivable by anyone from the descriptor, and the same in every language that implements this rule.

A name the user set lives in the recording, in §13.3's Metadata record, never in a file beside it — 0004 §1's rule. Setting one is therefore a rewrite of the container, through ca3 convert, and the next conversion of that file, by anyone, anywhere, resolves the same name with nothing else in hand.

2. Validity — honour validity_for, and name the gap it leaves

A field named by a validity_for annotation is written empty — not zero — when its validity field is false. GazeSample.screen_point_valid gates screen_point; neuro.v1's valid gates microvolts. That is §5.6 of the specification, applied by a converter for the first time.

It does not cover EyeSample.tracked or GazeSample.tracked, and the schema says why in its own comment: the option "names one field; this flag covers six … What is missing is a message-scoped form of the option."

The gaze schema carries one annotated field — screen_point_valid — and three comments explaining why the others are not annotated. It is not in this repository either.

So an export of a gaze stream under this record writes left_tracked as a column and left_origin_x beside it ungated, where the TypeScript reader nulls the position. That is a regression against the application and this record says so rather than hiding it. The fix is not a reader: it is a message-scoped form of the validity option in the grammar, after which the gaze schema annotates tracked, and every converter in every language gates correctly with no code change. Until then the provenance document written beside every export states, per stream, which columns are gated and which carry a validity column the reader must apply.

3. Presentation — none

storage_free_bytes exports as bytes, unit By, because that is what the schema declares. Dividing by 10⁹ is what a screen does for a person, and a file that did it would carry a unit the schema never stated. The applications keep the scaling for display.

4. Analysis — none; blocks — always

Audio RMS and peak are computed from decoded PCM and are not in the sample. A figure derived from a block is an analysis, and the converter is not an analysis tool.

The block itself is data, and it is never dropped. This is the condition attached at acceptance. An AudioFrame.samples field is 320 signed 16-bit numbers, and a converter that wrote the sample rate beside it and left the numbers out would have exported the label and not the recording. Three routes exist, in order of preference, and which one a stream took is written into the sidecar:

  1. A declared tensor_form — extension 50010, declared in proto/ca3/options/v1/ — states the element type, byte order, axes and components, and the block is decoded exactly as bindings/python/src/ca3/tensor.py decodes it. AudioFrame deliberately declares none, because its element type "is whichever of two values format carries" and a form cannot name a field as its element type's source.
  2. An element type the user states once, and the file keeps. A block the schema does not describe is a gap in the file, and 0004 §1 says a gap is closed by writing into the file: ca3 meta records the statement — av.v1.AudioFrame.samples = int16le — in the Metadata record, and every later conversion decodes the block under it with nothing else in hand. The provenance document beside each export repeats it as a statement a person made rather than one the schema did. A --block flag on ca3 export may state it for one run; it does not persist unless written in.
  3. Externalised, undecoded, when neither of the above applies: the bytes go to a sidecar <stream>.blocks.bin in exactly the length-prefixed layout ca3 raw already writes, and the row carries samples_offset and samples_len into it. The data is in the export; what it means is deferred to the reader, and the sidecar says so.

Repeated fields are the same case, one level down: a column holds one number per row and a repeated field holds however many the producer wrote. §5's table says what each shape does with one.

5. Every field kind, and its representation

The condition on acceptance is that this table is complete. A field kind absent from it is a bug in this record, not a case the converter may skip.

Declared kindLong (row per value)Wide / per-stream (column per value)Reported in the export's provenance document
numeric scalarone row, valueone column, unit in the name
boolone row, value 0/1one column, 0/1
enumone row, text = the enum's nameone text column, the namethe number→name map, so a reader can invert it
stringone row, textone text column
nested messagewalked; each leaf per its own kind, named by pathsame
oneofthe set member per its kind; the others absentsame, unset members empty
repeated, fixed cardinality = None row per element, index columnN columns, name_0name_{N-1}
repeated, variableone row per element, index columnnot representable; column omittedthe field, and that the long shape carries it
map<K, V>one row per entry, key column, value per its kindnot representable; column omittedthe field, and that the long shape carries it
bytes with a tensor_formone row per element, one index column per axisone column per component × fixed axis length; variable axes as the repeated casethe form's identifier
bytes, element type stated at exportas above, under the stated typeas abovethe user's statement, verbatim
bytes, neitherexternalised — _offset, _len columns into <stream>.blocks.binsamethe sidecar file's name and layout

Two consequences of the table are stated rather than left to be noticed. A wide export can be lossy where a long one is not, and the sidecar names every column it omitted so the loss is visible in the output rather than in the absence of a column nobody expected. bool exports as 0/1, which is what the TypeScript readers write and what lets a validity channel sit in a numeric column; a reader wanting true/false maps one to the other, and a reader wanting a mean of a validity channel does not have to.

What the applications keep

Their tier-one readers, for display. The dashboard gates gaze on tracked and shows storage in gigabytes; the export writes what the file holds. Those are two jobs, not two implementations of one, and the difference between them is stated in the sidecar every export writes.

What they lose is csv.ts, export.ts and the export half of their reader tables — the fork 0004 exists to end.

Alternatives rejected

A reader hook across the ABI. The caller registers a function per schema and the converter calls it per sample. Rejected because it is the pipe again: one boundary crossing per message, which is the cost backend.py measured the subprocess paying, moved rather than removed. It also makes the file's contents depend on which binding produced it.

Readers in Rust, beside the vocabulary, with a binary there that links both. Rejected because it produces two ca3 exports — one that knows no vocabulary and one that does — and the one a user finds first is the lesser. It also puts the C ABI where the container is not, or duplicates it, which 0004 rules out.

Keep the TypeScript export. Rejected; it is the fork.

Skip a block the schema does not describe. This was the first draft of §4 and was rejected at acceptance: an export that carries an audio frame's sample rate and not its samples has exported the label and not the recording.

What would change this

  • The grammar gaining a message-scoped validity option does not change this record; it closes the gap §2 names, and the sentence about tracked is deleted.
  • The grammar gaining a form that can source its element type from a field — §15.3's own reopening condition for AudioFrame — retires §4's second and third routes for audio. Both stay for any schema that has not declared one.
  • A consumer who needs a derived figure in the export — RMS in the CSV, not beside it — is asking for an analysis stage between the reader and the writer. That is a different tool, or a different record, and this one should not grow it.
  • A field kind appearing that §5 does not name is a defect in this record and is fixed here, in place.

What is open

  • The message-scoped validity option, and a description option beside it for the column-tooltip work. Both are grammar changes, both allocate a permanent extension number, and both are proposed rather than accepted. Nothing in the grammar changes until they are.
  • The exact syntax of the element-type statement, and the set of element types it accepts. TensorElementType in the grammar is the obvious list; whether it takes those names or C-style ones is a usability question, not a design one.
  • The Metadata record's own keys — where a user-set column name, a description, a node description and a stated element type each go, and how a rewrite composes with §11.3's attestation and attest.rs. That is record 0006, and it is the one this record cannot be built without.