A Frame as a Description — the measurement record¶
The dated record behind the design page: what was
measured, in the order it was measured, with the numbers, the wrong guesses
and what corrected them. The design page states what is; this page is why.
Section letters are shared — an §F.2 cited in an issue, a test or a code
comment is the same entry here and there — and nothing is rewritten after the
fact: a later entry corrects an earlier one in its own words.
F. The Field¶
F.1 How many ways a field was spelled (2026-09-27)¶
A read-only survey of main at 7b51ac4b, taken to answer one question before
designing anything: is the shape wrong, or is only the Field missing? The
primitives were right — wfm_seq_t, wfm_field_t and wfm_frame_desc_t in
native/inc/doppler/wfm/wfm_frame.h, with one transmit compiler,
dp_wfm_source_describe_frame() in wfm_synth_bridge.c — and around them
the same two concepts had been restated many times.
Seven representations of a sequence of bits:
| # | representation | where |
|---|---|---|
| 1 | wfm_seq_t — the canonical one |
wfm_frame.h |
| 2 | a (uint8_t *, len) pair |
ccsds_tm_frame_spec_t, dp_wfm_synth_set_dsss, the four-field DSSS helpers, every receiver's init parameters |
| 3 | twelve flattened arguments per field | dp_frame_create, dp_frame_create_desc, add_field |
| 4 | the loose PN parameters of a source, borrowed by --payload-len |
wfm_compose.h, wfmgen.c |
| 5 | the CLI triple --X / --X-hex / --X-gen |
wfmgen.c option table |
| 6 | a JSON literal string plus a *_gen object; lit / gen inside a carried frame |
wfm_json.c |
| 7 | the kind strings in the generated Frame bindings |
wfm_ext_frame*.c |
Six representations of a frame: wfm_frame_desc_t; the fixed-slot
wfm_frame_t with its own wfm_frame_layout_t; the flat framing fields of a
source; ccsds_tm_frame_spec_t; ccsds_tm_frame_cfg_t; and the receivers'
own parameters (acq_code, data_code, sync, reps, a scalar
frame_syms).
The same work, done more than once:
- Two sequence grammars that disagree. The CLI's
pn:LEN:REG…could not setlfsr; the JSON*_genobject could. - Four bit-string parsers.
wfmgen.crefuses a stray character; the JSON reader skips it; jm'sbit_patterncoercion is a third;Frame's a fourth. A typo reads as a shorter sync word on one face and a refusal on another. - The
reg_bitsrange checked at five sites acrosswfmgen.candwfm_json.c. - Checks that test the pointer, not the length — which a generated
sequence, having no array, fails
(#1592); the same
class
dp_wfm_source_has_frame()had already been fixed for (§C.1).
The answer: the shape is right and the Field is missing. Every one of the seven exists because a field had no value of its own to pass.
F.2 The grammar, prototyped before any C (2026-09-27)¶
A throwaway Python parser and printer for the grammar in §F.1 of the design
page — never committed — was run against every sequence the flag-matrix
golden (native/tests/wfmgen_flag_matrix.json) records.
| measured | result |
|---|---|
| golden sequences converted old spelling → Field | 40 / 40 lossless: same kind, length, parameters and bits |
parse(format(f)) == f and format stable |
40 / 40 |
| by kind | 27 literal, 10 pn, 2 gold, 1 dotted |
malformed specs refused (empty, 0102, 0x, pn:31:65, dotted, 1101*0, a space, …) |
16 / 16 |
| a repetition count other than 1, in the golden | 4, all 2, all on the acquisition code |
old spellings in docs/ and src/doppler/examples/ |
58 — --bits-hex 23, --acq-reps 8, --acq-code-hex 5, --sync-gen 4, --payload-len 4, --payload-gen 4, --data-code-hex 4, --data-code-gen 3, --acq-code-gen 3 |
--acq-reps values in docs and examples |
4 of 4 occurrences with a value are 4 |
What it settled: the grammar expresses everything every face currently
records, with nothing lost; the printed form round-trips; and *N is rare
enough that quoting it is cheap.
F.3 How a refusal names its cause (2026-09-27)¶
The error convention gives an
int function DP_OK or a negative code and no reason, and a CLI that says
only "invalid" about a malformed Field teaches nothing. The wfm module had
already met this: dp_wfm_compose_from_json_why(json, const char **why)
returns a static sentence through an optional out-parameter, because "a
NULL return is the one answer that cannot teach anything"
(wfm_compose.h). dp_wfm_field_parse takes the same shape rather than a
new convention.
F.4 What review changed (2026-09-27)¶
The first draft said every face carries a Field as one string. Review asked whether that meant payload bits had to be strings; it did, and it was wrong. Three corrections followed, each from the owner, in this order:
-
A literal field is data. A payload is an array, a binary file or a byte stream; only a generated field is text by nature. The draft had generalised from sync words — short, written by hand — to payloads, which are neither.
-
Two use cases decide the payload's shape: a finite burst with all data known a priori, and an infinite stream that must be chunked and framed. Neither is served by one block cycled to fill the run, so the payload became a data source consumed a frame at a time (
data:LEN,--data). The decisions that fell out: the last partial chunk is padded with a declared fill (refused without one), a paced stream that runs dry sends idle frames, and the feature is designed now and built after the Field. -
"Delete capability that isn't strictly necessary." Applied, it removed more than it added:
deleted because payload cycling a burst runs once; more is --repeat, which existssix payload flags ( --bits,--bits-hex,--bits-file,--payload,--payload-gen,--payload-len)one question — where the payload comes from — one flag, --datathe Python Fieldobjecta frame's layout already reports its fields _separators in the grammarconvenience syntax, one fewer rule the frame sugar — six CLI coding flags, the flat source framing fields, the by-name compiler, the "both at once" refusal a frame said two ways needs a compiler and a tie-break; said one way, needs neither prbsas a nameit is pn:0:REG[:SEED]in the one grammarKept, each because removing it would lose something a caller needs: both LFSR forms (a published sequence can require Fibonacci),
*REPS(a DSSS preamble is a repeated code),--data none(code-only continuous DSSS), and the fixed-layout CLI field flags for the common frame — which build a description, not a second representation.
Two more followed later in the same review, and both deleted an unknown rather than answering it:
- A receiver is designed in the know. The draft listed "how a receiver
tells an idle frame from data, and where the data stopped" as needing a
general in-band mechanism. It needs none: a receiver is built against the
description it expects, so a length or idle marker is a field at a known
position (
N:M), and the generator does not interpret it. - A repeat is invariant. The draft left "what
--repeatmeans over a data source" open, reading a repeat as possibly drawing new data. It never does: a repeat sends the same bits again, whatever the field is —data:LEN*Nis simple time diversity for resilience — and--repeatover an unending stream is refused.
F.5 The three jm scaffold checks (2026-09-27)¶
The design names three things jm must do before they are relied on (§
Unknowns). Each was measured at the pinned
jm 0.92.2. Checks 1 and 2 used a fresh jm new project (one
no-state object with bits: uint8_t[] and spec: const char *, whose
create() prints what reached C), not doppler, so the result is jm's
behaviour and not doppler's history. Check 3 used doppler's own manifest in
a throwaway worktree.
| check | result |
|---|---|
| 1. an object param taking an array or a string | No. There is no such param type, and coerce = "bit_pattern" on an init-param is warned as "a composer field key" and ignored |
| 2. an omittable param | Yes, both kinds. An array with default = "[]" reaches create() as NULL, length 0 (jm#1003). A const char * with default = "NULL" is str \| None and reaches C as NULL. (optional = true is something else: it dispatches to a second constructor and needs a create_fn.) |
| 3. extra keys survive a re-save | Yes. Seven keys (cli, cli_aliases, json, help, metavar, faces, kind) on a source.fields row, plus a sibling [module.wfm_compose.surface] table, round-trip through a mutating load/save and read back intact. jm apply and jm status --check exit 0 |
What the probes turned up besides the answers:
- A string into a
uint8_t[]param is silently a number.Fld("0101")reaches C as one element,101.Fld(b"\x01\x00")is refused (ValueError: invalid literal for int()), so the one Python type that already is a byte buffer cannot be passed. Both come from one conversion throughint(): jm#1700. - Unknown keys are advisory, never gating.
applyprints one deduplicatedwarning ~:line per unknown key (8 for the probe), withgates=False. Butjm upgradecalls the same manifest "Not up to date: apply will refuse". It does not refuse: jm#1702. - A save that dirties a composer table deletes its comments, but no
command does that. Calling jm's own
load/savewith one field'sdocchanged removed 15 comment lines from that table. That was a probe for "some command rewrites this table", and the premise was wrong: a real mutating command (jm method fir …) writes onlyobjects/fir.tomland leaves the root manifest alone. The defect is reachable only through the API (or, unmeasured, a schema migration): jm#1701. - A composer field is documented twice. All 40
wfm_composefields carry a manifestdockey, which jm reads (_composer.py), andwfm_compose.hcomments the same struct members. Nothing ties the two together, and they already disagree (freq). The header is the primary SSOT, and the manifestdocis a fallback, not a peer. A surface-tablehelpkey would be a third copy: jm#1703. - The design's stated fallback for check 1 no longer exists. It was "an
array goes through
Field.from_bits", and §F.4 above deleted theFieldobject. So a failed check 1 needs a new decision, not a fallback.
Decided (owner, the same day). The first answer was two parameters
per field, <field>= for data and <field>_spec= for text. It was replaced
before landing by something simpler: the C interface takes bits only, one
empty-by-default uint8_t[] per field, and module helpers (field_bits,
cvt.hex_to_bin, cvt.bytes_to_bin) turn every other form into bits. That
needs no dispatch and no jm feature, and removes the "both set" refusal
(design §F.3). The surface keys stay on the jm rows, and the advisory
warnings are accepted.
F.6 The parser, explored (2026-09-29)¶
Phase 7 of the lifecycle, run once on purpose: a parser has no statistical
envelope, so the exploration is a corpus, not a sweep. A scratch harness
compiled wfm_frame.c under ASan and UBSan and drove
dp_wfm_field_parse, dp_wfm_field_format and dp_wfm_field_bits with:
| corpus | size | property |
|---|---|---|
generated valid Fields (bin, hex, pn, gold, dotted, *REPS) |
200,000 | parse → format → parse is the same field; the text is canonical (formatting twice agrees); both texts render the same bits, every one 0 or 1 |
| single-character mutations of those (delete, insert, replace, truncate) | 200,000 | accepted or refused, never a crash; if accepted, the same round trip |
hand edges (2^64-1 and 2^64 lengths, *0, **2, huge *REPS, PN:, signed numbers, a bare 0x) |
26 | as above |
The corpus is reproducible: an xorshift64 generator seeded with
0x9E3779B97F4A7C15 picks one of five shapes — a 1–70-bit binary literal,
a 0x/0X literal of 1–20 digits drawn from both cases, pn (LEN 1–3000,
REG 2–31, five argument shapes including an explicit seed, a hex seed and
zero poly, fibonacci, and one malformed empty-seed form), gold over the
CCSDS preferred pair with LEN 1–2000, and dotted with LEN 1–500 — and
appends *1–*9 to a third of them. The mutations draw from the
alphabet 01:x*X_- +pngoldtd9aF\t.
One finding, in 422 cases, all one class. pn:LEN:1 with no POLY —
spelled 1, 01 or 0x1 — was ACCEPTED by the parser and could never be
built: a 1-bit register has no maximal-length polynomial, so the render
refused it (#1602). The refusal therefore arrived later, on every face, and
on two of them without a reason (wfmgen --bits pn:12:1 and
--data-code pn:7:1 exited 1 with "could not build the waveform spec").
It is now refused where the text is read, naming the remedy — give POLY, or
a wider REG — and pinned in test_wfm_frame.c's refusal table, red before
the fix. A 1-bit register WITH a POLY builds, and is pinned as accepted.
After the fix: 259,037 round trips, 200,000 mutations, 0 findings, no sanitizer report.
A second finding, outside the parser, filed rather than fixed here. A
huge LEN parses (pn:4000000000:5, up to 2^64 - 1) and the face that
holds the bits then ABORTS: wfmgen --bits pn:4000000000:5 exits 134, on
SIGABRT, because the source path allocates the caller's length with the
abort-on-OOM helper. Python's field_bits reports "negative dimensions" for
2^64 - 1. The design states no length limit, and choosing one is a design
decision, so it is #1622.
F.7 The corpus, committed, and the claim inventory (2026-09-29)¶
Phase 8. The scratch harness behind §F.6 is now
native/validation/wfm_field_explore.c: the full corpus under
make validate-c, a --check subset of 1% (2000 + 2000 + the edges, about
30 ms) registered in ctest, and the subset is what the wfmgen certification
report runs.
It reproduces §F.6 exactly on the parser §F.6 ran against — 259,037 round trips, 0 findings — which is the check that the committed harness is the recorded one. Two later refusals move the count, each by a measured amount, each measured against §F.6's parser with that one change:
| parser | round trips | why it moved |
|---|---|---|
| §F.6's | 259,037 | — |
| §F.6's + the Field length bound (#1622) | 258,789 | 248 mutations push LEN * REPS past 261120 (a digit inserted into a LEN) and are refused |
| §F.6's + SEED/POLY within REG (#1624) | 257,563 | the generator reduces each drawn seed into its register; its draws are unchanged |
| both, and the four bound/REG edges added | 257,316 | measured on main after #1625; the one new acceptance is the pn:261120:18 edge, exactly at the bound |
#1624 was found by the claim inventory, not the corpus. Every text the
corpus generated had a seed inside its register or failed to parse for
another reason, so no generated text could show that pn:31:5:32 renders
zeros; the property the corpus holds (the text round-trips and renders the
same bits as its canonical form) is true of a constant field. Reading the
header's REG against the generators' masking is what found it — the
reason phase 8 enumerates claims rather than trusting phase 7's zero.
C. The CCSDS sites¶
C.1 The sites, and how each was settled¶
Moved here from the design page on 2026-09-27, unchanged, when the page was narrowed to state what is.
Of the five the earlier plan listed, site 1 is done —
dp_wfm_source_describe_frame() builds through the by-name builder rather than
dp_ccsds_tm_frame_desc_of(). Three others turned out not to be leaks at all:
frame_core.c, wfm_synth_bridge.c and burst_demod_core.c include
ccsds_tm to compose it, in the acyclic direction the design intends, and
each is the place a caller is meant to meet the standard's kernels.
The fifth was a different kind of thing, and it is closed (#1220):
| site | what it was | how it was settled |
|---|---|---|
| the marker's own translation unit | a CCSDS translation unit compiled into the general wfm_core, under native/src/wfm/ |
deleted. The marker is doppler.ccsds.asm_bits() now, over a ccsds component that delegates to dp_ccsds_tm_asm_bits — the standard beside the general layer, not under it |
A marker one standard picked is a literal field of a preset, not a symbol
in the general namespace. The move needed somewhere to put it, and every
existing module says in its own docstring that it is general — coding's
opening line is "the general channel codes … rather than any standard's
picks" — so doppler.ccsds was created to be the one place a published
literal is at home.
Two things changed with site 1 that the plan did not anticipate:
- A source carries
wfm_seq_tfor its preamble, spreading code and sync word, rather than three pointer/length pairs. Those pairs could only ever describe a LITERAL run, so the four field kinds were reachable from no face at all — the descriptor supported them and every route into it flattened them away (#762). dp_wfm_source_has_frame()tests LENGTH, not the pointer. A generated sequence has no array, so a pointer test read a PN sync as unframed and emitted the payload bare.
C.2 The isolation gate was once too broad¶
An earlier version of make ccsds-isolation-check scanned every component
and allowlisted the four that include a ccsds_tm header, as a ratchet meant
to fall to zero. That was wrong, and worth recording because the mistake is
easy to repeat: a consumer composing the two is the design working.
frame -> ccsds_tm -> wfm_frame is acyclic and deliberate — ccsds_tm has no
Python binding and is not getting one, so frame is where a caller meets the
outer code, the randomiser and the inner code. A ratchet over a rule that
should never reach zero is a slow push toward a refactor nobody wants.