Python
Read CA3 recordings in Python, and decode their payloads from the descriptors the recording itself carries.
from ca3 import open_recording
with open_recording("session.ca3") as recording:
for stream in recording.streams:
print(stream.ref, stream.topic, stream.schema_id, stream.message_count)
for sample in recording.samples(streams=["sensor.pointer"]):
pointer = sample.decoded # decoded by the schema the file itself carries
if pointer.position_valid:
print(sample.monotonic_ns, pointer.position.x)
for sample in recording.samples(streams=["sensor.signal"]):
block = sample.array() # (320, 1) int16, from the schema's tensor form
Nothing above imports a schema, and nothing has to: a CA3 recording carries the
definitions of the messages in it. The topics are the ones ca3 mock writes;
your recording's are whatever its producer called them. See
where the schemas come from.
What this is
Two questions, and this package answers both: what do these bytes mean, and what is in this recording.
The first is protobuf, read out of the recording. Every Schema record in
a CA3 file carries a FileDescriptorSet closed under its imports
(spec/container.md §5.1), so the file says what its own messages are — a gaze
observation, an EEG chunk, a body pose, whatever the producer was recording —
and opening it is what gets you the definitions. A reader that instead binds a
vocabulary at build time can read exactly the schemas it shipped: a field
renumbered after the session was recorded leaves it returning the shape it was
compiled against with nothing to say it has stopped matching, and a schema it
never heard of it cannot read at all. Neither applies here.
The second is the ca3 command-line tool, driven as a subprocess. Nothing in
this package reads the container format — no record layout, no byte offsets,
no chunk index. The next section is why.
It touches no transport. Reading a CA3 recording pulls in no streaming
framework: dependencies = [], and nothing here imports one. A consumer that
also wants a live subscriber installs both packages, and neither has to know
the other exists. Placement follows the runtime dependency, and reading a file
needs no runtime — which is why this half sits beside the format.
The reader is a subprocess, and that is the design
ca3.backend.RecordingBackend is the whole surface between the reading API and
whatever produces bytes — four methods, none of which mentions a file offset.
ca3.cli.Ca3Cli implements it by spawning ca3 play, writing commands on its
standard input and reading newline-delimited JSON off its standard output. A
native reader — a PyO3 binding onto the Rust crate that defines the format —
implements the same four methods and changes no calling code.
Two reasons, and both are checkable rather than tasteful.
A second implementation of a moving format is a second thing to keep in step.
The container has already moved under this package once, and the move landed
rather than being proposed: the footer went from 20 bytes to 28 and gained
last_checkpoint_offset, a schema descriptor's length prefix became bytes64,
and the summary index gained an ordering requirement.
grep -n "FOOTER_CONTENT_BYTES: usize" ../../crates/container/src/records.rs # 8 + 8 + 8 + 4
Nothing here names a byte offset, a magic number or a record opcode, so all of that arrived as a newer binary and no diff. A Python reader for the same format would have needed the change made twice, and when two readers drift, the file one accepts and the other refuses is a bug in whichever you believe less.
The subprocess will not be fast enough, and the cost is measured. Every
payload crosses a pipe as base64 inside a JSON object — four bytes of text per
three bytes of payload, plus a JSON parse and an allocation per message. On the
60-second ca3 mock recording, 75,018 messages and 8,602,600 bytes, a full walk
takes 0.64 s: about 117,000 messages a second. Decoding is not what costs.
Parsing all 11,997 pointer payloads out of that walk and reading a field
from each takes 0.009 s, seventy times less than moving them. The ceiling is the
transport, which is exactly what a native backend removes.
0.64 s is ample for inspecting a session and for a test suite. A thousand such recordings is eleven minutes of transport before an array is touched, which is where it stops being ample.
Which verbs, and why not the others
ca3 offers ten. Three of them are machine-readable, and those are the three
used:
playis the only verb that emits payload bytes, one JSON object per line, with the payload base64-encoded.rate 0means "as fast as the pipe accepts", which is what a reader wants and a viewer does not.descriptorswrites everySchemarecord the file carries, with itsFileDescriptorSetbase64-encoded.playnames a stream's schema and stops there, so a reader holding onlyplaycan decode exactly the schemas it was compiled against — the property a self-describing container exists to remove. Opening a recording runs this once, before the player: 30 ms on an 8.2 MB file.manifestwrites the session record as JSON — devices, clock domains, the custom key-value pairs. Nothing else emits them.
cat and info are not used, and it is worth saying why rather than leaving it
to be found. cat prints one aligned line per message — timestamp, topic,
sequence, and the payload's length. It is an index, not the payload; parsing
it would give a reader everything about a message except the message. info is
prose for a person, and prose is not an interface.
Where the schemas come from
The recording, and nowhere else. spec/container.md §5.1 requires every
Schema record to carry a FileDescriptorSet closed under its imports, so
a message resolves from the file alone. Opening a recording reads them and
hands each stream its own:
with open_recording("session.ca3") as recording:
pointer = recording.stream("sensor.pointer")
pointer.schema_id # 'ca3.example.v1.PointerSample', in a ca3 mock file
pointer.descriptor.full_name # the same name, out of the file
[f.name for f in pointer.descriptor.fields]
ca3.SchemaCatalogue is the seam. It takes (schema_id, descriptor_set) pairs
and answers descriptor_for(schema_id); the free functions —
message_class_for, decode, declarations_of, declared_fields,
tensor_form_of, validity_field_for, tensor_form_for — take a descriptor and
answer from it. Either half returns None rather than a guess, because protobuf
will decode bytes as the wrong message and hand back something that resembles
data.
Every schema gets its own descriptor pool, which is the part worth stating rather than leaving to be discovered. Protobuf's default pool holds one definition per fully-qualified name and refuses a second, so a process that registered descriptors there could open one recording and would raise on the next one that renumbered a field — exactly the comparison a self-describing container is for. Private pools make the collision structurally impossible, across recordings and within one. On a five-schema, 8.2 MB reference file building them costs 1.5 ms, against 0.77 ms for a single shared pool.
A stream with no descriptor keeps its bytes. It is what a file declaring no
schema looks like, what a Schema record whose own descriptors do not define it
looks like, and what a non-protobuf encoding looks like. All three give
sample.decoded is None and leave sample.payload intact.
This package ships no schemas and no annotation grammar. A schema resolves out of the descriptor set the recording carries, and an annotation resolves by extension number in that recording's own pool, so there is nothing a compiled-in copy could answer that the file does not answer better.
tests/test_declarations.py decodes a real recording in a fresh interpreter
with both names refused at import, and checks that importlib.util.find_spec
finds neither — which is the stronger claim now available, and the one a
docstring cannot be trusted with.
Tensor forms
A packed signal block is 320 signed 16-bit samples at 50 Hz; a pose sample is
28 joints of 8 numbers each; an EEG chunk is 32 electrodes by 250 time-points.
Protobuf fields say what each quantity means and say nothing about the array,
so every consumer picks an axis order and two consumers pick differently —
[channel, sample] and [sample, channel] are the same numbers and not the
same tensor. spec/container.md §5.8 closes that: a schema declares a tensor
form, and ca3.tensor builds the array it describes.
Install the extra to get this: pip install "ca3[tensor]". Everything else in
the package needs no numpy — see numpy is an extra.
from ca3 import tensor_form_for, to_array
form = tensor_form_for(recording.stream("sensor.signal").descriptor)
form.name # 'example.signal.v1'
form.shape # (320, 1)
form.dtype # dtype('int16')
form.dtype.str # '<i2' — declared, and `.str` spells the order out where `repr` does not
form.component_names # ('amplitude',)
form.components[0].unit # 'uV'
form.axes[0].name # 'sample'
form.nbytes # 640
block = to_array(message) # or sample.array()
Which schemas declare a form is not this package's business to know. The
form is read off whatever descriptor the recording carried; a schema this
package has never seen builds an array if it declares one, and
spec/container.md §15.3 is where a vocabulary's own split is argued.
Two Axis attributes are not spelled the way the proto spells them
The grammar is the authority — proto/ca3/options/v1/options.proto,
message TensorAxis. name, length, from_repeated and length_from are
carried through unchanged. Two are not.
labels is not labelled_by; it holds a different value. labelled_by
carries the type name of an enum — a string such as 'Joint'. This package
resolves that against the descriptor and stores the value names in index
order, so labels[12] is the name of the enum value numbered 12. The
resolution is the part worth having: it checks that the enum's numbers are
exactly 0 … n-1 and refuses otherwise, which is what catches an enum that
drifted into protobuf's unspecified-at-zero convention and would otherwise
label every row off by one.
The trade is one-directional and worth knowing about: labelled_by is read
once and never stored, so the type name cannot be recovered from an Axis.
grep -n "labels = _enum_labels" src/ca3/tensor.py
fields is from renamed, and the rename is not forced. It is the same
tuple of field names in the same order. from is a Python keyword, so
axis.from cannot be written — but that constrains the attribute and not the
name chosen for it: this package already reads the proto field through
getattr(declared, "from"), and from_ was available and is used nowhere. The
plainer evidence is that TensorComponent carries a from too and this package
calls that one source, which is the identical problem one message over,
answered differently. Treat fields as drift rather than as a decision with a
reason behind it; it is recorded here because a name in a released API is worth
more stable than tidy.
# the keyword is handled at the boundary, not by the attribute name
grep -n 'getattr(declared, "from")' src/ca3/tensor.py
# and no trailing-underscore convention exists to have produced `from_`
grep -rnE '\b(from_|class_|type_)\b' src/ | grep -v from_repeated
The two sources
A block form names the bytes field that is the array. It fixes an exact
payload length, so a block that is not the declared length is refused rather
than read as far as it fits — the alternative hands back an array of the right
shape whose last rows are somebody else's numbers, with nothing to say so. The
array is a read-only view onto the payload bytes, which is what keeps a
60 Hz stream from allocating its block twice per sample; call .copy() if you
need to write into it.
A projection form names no block, and every component names the field it is
taken from: six numbers gathered out of an acceleration and an
angular_velocity; an eye axis naming left, right, center with the
components resolving inside each eye; a chunk taking its channel count from a
repeated field and its sample count from samples_per_channel. A bool field
contributes 1 or 0, which is how a validity channel gets into an array of
numbers. There is no buffer to view, so a projection returns a fresh writable
array.
six = to_array(motion_sample) # (6,) ax ay az gx gy gz
eyes = to_array(eye_set) # (3, 8) left, right, center
eeg = to_array(chunk) # (channel, sample), both from the stream
An entry nobody measured is not zero, where the element type can say so. A
block form has one packer, which can write NaN into a row nobody measured: zero
would put an entry at the origin, which is a position a real measurement can
occupy, and an identity quaternion would claim a joint was upright and facing
forward, which is a rotation a real joint can have. A projection is assembled
from fields any producer filled and no packer stands between them — an unset
row comes back as zeros with its validity component at 0.0, and a uint32
form has no NaN to offer at all. The validity component is what a consumer
tests; NaN is what happens to code that does not. spec/container.md §15.2
carries that asymmetry rather than smoothing it over.
A schema that declares no form is giving an answer
§5.8's test is that a form declares what the schema fixes, and a schema that
fixes nothing about its array has none — a component type such as a three-vector,
or a row type whose numbers belong to an array the assembling message already
declares. to_array on one of those raises and names it rather than inventing a
shape, and where this package has something specific to say it says that too:
>>> to_array(sample.decoded)
TensorFormError: ca3.example.v1.PointerSample declares no tensor form. spec/container.md
§5.8: a form declares what the schema fixes, and a schema that fixes nothing
about its array has none; §15.3 is the index, with the condition that reopens
each deferral.
>>> no_form_advice("ca3.canonical.v1.AudioFrame")
'deferred: the element type is whichever of two values `format` holds…'
no_form_advice indexes the sixteen ca3.canonical.v1 schemas §15.3 covers.
That is a table about a vocabulary this repository no longer defines, and it
is kept for one reason: the key is a schema_id read out of a recording, so a
file written against those schemas still gets its next step whether or not
anything ships their bindings. A recording carrying any other vocabulary gets
the refusal, the schema's name, and no further advice.
What that costs is stated rather than hidden. Nothing derives the table.
Deriving it would mean sweeping the vocabulary it describes and asserting every
schema is on exactly one side — a form, or a stated reason — and this package
ships no vocabulary to sweep. tests/test_tensor.py checks that each of the
sixteen lines is present and carries a next step, which is a check on the table
and not on its coverage. Where the table belongs is beside the vocabulary it
describes, which is to say not here.
That gap is worth keeping in mind wherever a split like this is written down: three implementations of §15.3 each kept a hand-written list against a table with one row per argument, and all three disagreed about the count while staying green.
Where the shape comes from
tensor_form is a TensorForm message on extension 50010 of
google.protobuf.MessageOptions, carrying the identifier, the element type,
the byte order, the axes and the components. So the shape arrives inside the
descriptor a CA3 file already embeds (spec/container.md §5.1), structured,
rather than in prose beside it. Earlier releases of this package parsed a
markdown table out of the message's leading comment — roughly two hundred lines
of regex — which is the arrangement §5.8 replaced.
The number is the name. See annotations resolve by extension number: 50010 is what the descriptor carries, and the package the grammar was published under is not something this package has to agree with.
spec/container.md §5.8 states nine invariants a tensor form holds against the
descriptor: the identifier is versioned; an element type is declared and a
block form declares a byte order; every axis has a unique name and states its
length in exactly one of length, from, from_repeated, length_from; a
block names a bytes field whose declared cardinality is the first axis;
the components fill the last axis; an axis labelled_by an enum names one
whose values are exactly the indices 0 … n-1; every component unit is valid
UCUM; every projection component's unit is the unit of the field it is taken
from; and every projection component's field has the form's element type or
is a bool.
The container does not check any of that, and has never parsed a descriptor it stores. §5.8 is where each clause is stated, and whoever declares the schemas is where a checker for them belongs.
This package re-derives all of those except UCUM syntax, and refuses rather than guesses — not because a checker elsewhere is doubted, but because a recording can carry a descriptor older than this code. Checking UCUM would need a grammar and a dependency.
What is not checked, here or anywhere, is that the producer packed the components in the declared order. A transposed axis produces finite, plausible numbers and nothing in a descriptor detects it. CA3's Rust implementation holds its own packer to a byte-exact conformance corpus for that reason; there is no second packer here, because this package only reads.
numpy is an extra
pip install . gets you the container reader, the declarations and
decode/decode_as, and no numpy. Only ca3.tensor builds
arrays, and ca3/__init__.py imports it on first use, so a live subscriber that
decodes payloads and never assembles an array installs neither numpy nor its
wheels. pip install ".[tensor]" adds it. Asking for to_array without the
extra raises an ImportError naming the extra rather than the transitive import.
Installed from a checkout, not from an index. ca3 is published nowhere —
https://pypi.org/pypi/ca3/json answers 404 — so the name in these commands is
a path, not a lookup. That also caps what any other package can say about this
one: an extra can only name something an index resolves, so no published
distribution can offer ca3 as an optional dependency.
tests/test_packaging.py holds import ca3 to that in a fresh interpreter, by
looking at what it left in sys.modules — a property of the import graph is not
something a docstring can be trusted with.
What a schema declares about its numbers
A decoder hands you a number. Whether it is millimetres or metres, and which way its axes point, is what the annotations on the schema declare:
from ca3 import declarations_of, declared_fields, validity_field_for
pointer = recording.stream("sensor.pointer").descriptor
declarations_of(pointer, "position")
# FieldDeclarations(unit='{pixel}', frame='example.screen.v1', ...)
validity_field_for(pointer, "position")
# 'position_valid'
declared_fields(pointer)
# (('position', 1, FieldDeclarations(...)), ('position_valid', 2, ...), ...)
Read out of the descriptor the recording carried, and through that file's own
grammar rather than through extension symbols compiled in here — so a
recording annotated by a definition this package has never compiled arrives
whole rather than with the fields it does not recognise dropped. Field names
need no translation: Python's protobuf bindings keep the .proto spelling, so
validity_for — which names a field as the schema spells it — is directly
usable, which its JavaScript sibling has to convert.
Annotations resolve by extension number, not by package
spec/container.md §5.3 allocates unit = 50001, frame = 50002,
validity_for = 50003 and cardinality = 50004 on
google.protobuf.FieldOptions, and tensor_form = 50010 on
google.protobuf.MessageOptions. Those five numbers are what this package
looks an annotation up by, in the pool that holds the recording's own
descriptors:
grep -n "FindExtensionByNumber" src/ca3/schema.py
The package the grammar is published under is a string in a file this code never
has to agree with, and it has already moved once — from ca3.options.v1 to
ca3.options.v1 — leaving recordings of both kinds in existence. A reader that
asked its pool for "ca3.options.v1.unit" would read one of those and report
every field of the other as declaring nothing. That failure does not raise:
an extension nobody resolved reads as unset, which is exactly what a schema
that declared nothing looks like.
tests/test_declarations.py rewrites a real recording's grammar into a third
package that has never existed and reads the units, frames, validity channels,
cardinalities and tensor form back out of it. tests/vocabulary.py builds its
whole vocabulary under a package name nobody publishes, for the same reason.
tests/test_numbers.py pins the five numbers against the grammar a real
recording carries, without naming its package.
The cost of resolving by number is that a clash is possible — FieldOptions
declares extensions 1000 to max and nothing stops two vocabularies claiming
50001. That is a property of protobuf, protoc announces it at compile time,
and the alternative fails on the ordinary case instead of the rare one.
Three distinctions the API keeps, because each is a fact and not an absence:
unit="1"is dimensionless, and is not the same as no unit at all. The first says the value is a ratio;Nonesays the schema declares nothing. A component of a unit vector is the case: it is a length over a length, and a consumer rescaling metres to millimetres has to multiply anoriginby a thousand and leave adirection's three numbers alone.cardinality=0is a declaration. It says the count is a property of the stream rather than of the schema — an electrode count that varies by device — so it is reported rather than filtered out as falsy.- A frame ending
producer_definedis honest, not missing. It says the geometry has not been established for that device, so two producers naming it have not agreed on anything and no conversion out of it exists.
Some schemas declare nothing at all, on purpose: an event has a type and a bag
of strings, and neither is measured in anything. ca3 mock's Marker is the
case, and declared_fields returns an empty tuple for it rather than raising.
None of this can tell you a field annotated m really carries metres. No
arrangement of options detects a producer that converted wrongly. What the floor
buys is that two laboratories state the same facts in the same place, which
makes a disagreement visible in the descriptor rather than six months later in
an analysis that ran and produced numbers.
Check the schema before you decode
Protobuf will decode bytes as the wrong message and hand back something that
looks like data: the fields whose numbers and wire types happen to align are
populated, the rest take their defaults, and nothing raises. tests/test_recording.py
runs that on a real payload to keep it from being a claim in a docstring.
So decode_as compares the class's protobuf name against the stream's
schema_id first:
pointer = sample.decode_as(PointerSample) # raises SchemaError on any other stream
sample.decoded is the untyped form, for a viewer walking every stream without
knowing what it will find. It returns None — never a substitute — for a schema
this package does not bind, and sample.payload is always there.
PointerSample is the case that makes the whole name matter. This repository's
ca3.example.v1.PointerSample is a screen position, a validity channel and a
pressure; any number of vocabularies declare a message of that name meaning
something else. The package segment is the only thing that tells them apart.
The same check is a free function, for a payload that arrived on a wire rather
than out of a file. A live subscriber holds a schema_id and some bytes and
nothing else, and would otherwise hand-write these three lines or skip them:
from ca3 import decode, decode_as, message_class_for
from your_schemas.pointer_pb2 import PointerSample
pointer = decode_as(schema_id, payload, PointerSample) # checked, and typed
decode_as needs no descriptor and no recording: the class you named carries its
own name and the transport carried the other one, which is the whole of the
check. The untyped form needs the schema, so it takes a descriptor:
whatever = decode(descriptor, payload) # None if nothing resolved it
cls = message_class_for(descriptor) # None, never a substitute
What a recording says about itself
with open_recording("session.ca3") as recording:
recording.truncated # the writer did not finish
recording.session.study # 'PROTO-7'
recording.session.operator_asserted_by # 'configured' — see §11.3
recording.streams # every declared stream, including empty ones
recording.manifest() # devices, clock domains, custom fields
A declared stream holding no messages is reported, not filtered. A recording whose writer did not finish keeps its stream declarations and loses the chunks written after the last checkpoint, so an empty stream is the evidence of what was being recorded. Dropping it would make a truncated session look like a session that never had a signal channel, and those are different things to tell somebody.
schema_conformance is a claim about comparability, not about decodability.
A stream announcing undeclared says the producer is not claiming its schema
can be pooled with another producer's — which is what an honest device says when
its coordinate frames have not been established. Three of the five streams in
the ca3 mock recording are in that state today, and every one of them decodes
perfectly. Reading undeclared as "cannot decode" would throw away almost every
message in that file.
There are two values, not three. §5.7's tier 2 meant "canonical", which is
membership of a registry, and it is retired: a file cannot assert it into being,
and it was the only value in the format a reader could not settle from the bytes
in front of it. A file storing 2 is read as 1. Nothing in this package will
report a stream as canonical, and stream.conformance is well-formed or
undeclared.
Timestamps on Sample and on the start_ns/end_ns window are the recording's
own timeline — the host monotonic clock. sample.source_ns is the device's own,
and it is None when the producer published none. Absent is not zero: a sample
with no device timestamp placed at the device's epoch is a sample somewhere it
never was.
Install
pip install -e ".[dev]"
You also need the ca3 command-line tool to read a recording; decoding payloads
and reading schema declarations need nothing but this package. It is found
through $CA3_BIN first, then ca3 on PATH. There is no sibling-checkout
guessing: a path that resolves on the machine it was written on and nowhere else
is worse than an error naming the two places that were tried.
cargo build --release -p ca3-cli # at the root of this repository
export CA3_BIN=…/ca3/target/release/ca3
One runtime dependency: protobuf, which is what builds a message class out of
a descriptor set a recording carries. numpy is the tensor extra — it is what
a tensor form is for (handing back 640 bytes and a docstring about the layout
would leave every consumer writing the same frombuffer), and nothing else in
the package needs it.
No generated code, and no build step
This package ships no _pb2 modules, no compiled vocabulary, no compiled
grammar and no FileDescriptorSet. A schema resolves out of the descriptor set
the recording carries, and an annotation resolves by extension number in that
recording's own pool.
What that buys, beyond a smaller wheel: there is no generator to run, no
committed artefact that can fall out of step with the .proto files it came
from, and no continuous-integration job to notice when it has. A sync job is
weaker protection than it looks — one that copied a vocabulary but not the
grammar the vocabulary imports let 52 unit, frame and validity declarations land
upstream and arrive nowhere, with every file staying byte-identical and the job
staying green. A reader that binds nothing at build time cannot have that
failure.
proto/ at the repository root is still the authority on what the schemas say.
Nothing under bindings/python reads it.
Develop
pytest
ruff check .
mypy
The tests come in three kinds, because a round trip through one message class is symmetric — it encodes with the field numbers it decodes with, and so agrees with itself about a wrong one.
- Numbers written out as numbers.
tests/test_numbers.pypins every extension number and every enum value by literal, twice: against this checkout's own constants, which needs nothing, and against the grammar a real recording carries, which needs the binary and is the half that can catch this repository agreeing with itself. It exists because its JavaScript sibling did not have it: 76 tests passed while every enum assertion named the generated symbol on both sides, which is true of any renumbering because the renumbering moves both sides together. - A vocabulary and a grammar built in the test suite.
tests/vocabulary.pyassembles aFileDescriptorSetout ofdescriptor_pb2at import time — five schemas that declare a form, three that decline one, and an annotation grammar published under a package name nobody uses. It needs no generated code and no binary, and it can be bent into each of the fourteen shapes §5.8 says to refuse, which no real.protocan.tests/wire.pywrites payloads for it tag by tag from the field numbers, so a renumbering is visible rather than cancelling out. - A recording the Rust implementation wrote.
ca3 mockproduces a 60-second, five-stream file, andtests/test_recording.pychecks the values against the arithmetic the mock is defined by — where the pointer is, what the tone sounds like, how far the device clock has drifted. It names no schema: the streams are reached by topic and decoded by the descriptors the file carries, because a recording being self-describing is the property under test.tests/test_declarations.pyis the same rule applied to the annotations, and it renames the recording's grammar to prove the resolution does not depend on the package. What the mock deliberately gets wrong is as useful as what it gets right: a clock 3 ms behind and drifting, three pointer samples that never arrived, two seconds where tracking is lost, one sample with no device timestamp, one battery reading the device could not take.
The recording fixture is produced, not committed. ca3 mock reads no clock,
so the file is byte-identical on every run and on every machine, and a fixture
generated at test time cannot fall behind the format the way a committed blob
does. The tests that need the binary skip without it, naming where they looked;
the rest — the tensor form and the array it produces, the refusals, the
packaging — run regardless.
What is deliberately not here
- A reader for the
.ca3container. See above. It is not a to-do. - A writer. This package reads. Recording is
crates/container's, and a second packer for a tensor form is precisely the thing whose failure mode is a transposed axis producing plausible numbers. - Views. Drawing a stream, a session or a component set is a JavaScript package's job today, because that is where the applications wanting the pictures are. No Python application needs them yet, and a drawing layer nobody uses is a drawing layer nobody notices going stale.
- A projection payload from a real recording.
ca3 mockwrites pointer, inertial, signal, events and device state. Exactly one of those declares a tensor form and it is a block form, checked against the bytes the Rust encoder wrote; none is a projection. So the projection paths build their payloads by hand and say so, andtest_one_stream_in_the_mock_declares_a_form_and_it_is_a_blockasserts the gap so that a futureca3 mockcarrying a projection stream fails the suite and says to move the case onto real bytes.