Skip to main content

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:

  • play is the only verb that emits payload bytes, one JSON object per line, with the payload base64-encoded. rate 0 means "as fast as the pipe accepts", which is what a reader wants and a viewer does not.
  • descriptors writes every Schema record the file carries, with its FileDescriptorSet base64-encoded. play names a stream's schema and stops there, so a reader holding only play can 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.
  • manifest writes 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; None says 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 an origin by a thousand and leave a direction's three numbers alone.
  • cardinality=0 is 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_defined is 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.py pins 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.py assembles a FileDescriptorSet out of descriptor_pb2 at 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 .proto can. tests/wire.py writes 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 mock produces a 60-second, five-stream file, and tests/test_recording.py checks 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.py is 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 .ca3 container. 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 mock writes 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, and test_one_stream_in_the_mock_declares_a_form_and_it_is_a_block asserts the gap so that a future ca3 mock carrying a projection stream fails the suite and says to move the case onto real bytes.