Skip to content

docs(architecture): radio feature register — where the click ends, per radio. Principle VIII. - #5894

Closed
on8st wants to merge 9 commits into
aethersdr:mainfrom
on8st:docs/radio-feature-register
Closed

on8st wants to merge 9 commits into
aethersdr:mainfrom
on8st:docs/radio-feature-register

Conversation

@on8st

@on8st on8st commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A support matrix normally answers "did the backend declare it" or "is there a
control on screen"
. Neither is the question an operator has, and neither can
tell a working control from a dead one: the widget moves, the model commits, a
readback confirms the value, nothing logs and nothing signals.

This adds the third artefact — 61 operator controls × 6 radio families = 366
cells, scored by where the click ends
— plus the four dispatch traces behind
it (Flex, Icom CI-V, ANAN P2, RTL-SDR), a JSON sidecar, and a checker that
generates the derivable cells and diffs them so the document cannot quietly
stop being true.

file what
docs/architecture/radio-feature-matrix.md (644 lines) the register
docs/architecture/radio-feature-matrix.json (1796 lines) machine-readable sidecar
tools/check_radio_feature_matrix.py (1221 lines) generator + differ, --strict
.github/workflows/static-checks.yml one step
docs/architecture/README.md one bullet, beside radio-capabilities-map.md

No production code changes. No capability field added.

What it says today

W=145 V=7 P=5 R=22 H=0 M=89 D=89 U=9 over 366 cells.

(Updated 2026-09-26 at 727a5685, after @jensenpat's review: origin/main merged in as b72f53c4, and the hidden cells split per #5262 M3a. H is now only a whole applet hidden, which M3a permits; M is one control gated away where docs/style/theme-style-guide.md §4a says it must stay visible and dim with an announced reason, i.e. pending M3b. All 89 formerly-H cells trace to individual-control gates, so H=0; each M record names the file and function of its gate, and --strict fails if that entry is missing, contradicted or stale.)

(Rebased onto main 2859fdef on 2026-09-24. Two ANAN cells moved with upstream: rx/rf-gain D->W via P2Client::setStepAttenuationDb from #5820, and dsp/noise-blanker H->W from #5824. The tallies quoted further down in the #5892, #5907 and Corrections from review sections are the figures those experiments actually produced at base 7c8fdf84, and are left as they were run — the conclusion in each case is that no cell moved, which still holds.)

W reachable · V verified on hardware · P phantom (reports success, reads
back cached state) · R refuses visibly · H a whole applet hidden on a capability (permitted by
M3a) · M an individual control hidden where §4a requires it visible and dimmed
with a reason (pending M3b migration, not finished) · D dead · U the
generator declined to decide.

The 89 D cells are the point — live widgets whose intent dies silently —
and they concentrate in three families:

  • Squelch and RIT/XIT, which have no capability field at all (∅). There is
    nothing to gate on, so the controls are simply live everywhere. The SQL button
    is a radio-side DSP control that never joined
    VfoWidget::applyRadioSideDspVisibility(), the one owner of radio-side DSP
    visibility.
  • Transmit setpoints on receive-only radios. The canTransmit refusal is
    applied at the keying sites only. RF power, mic gain, TX monitor, TX audio
    monitor, speech processor and VOX run straight into base no-ops on three
    families, and hostModulates = true makes ANAN worse by pushing setTxPower
    and setMicGain once on every connect. One guard, already written, applied in
    more places turns six D cells into R cells on three radios.
  • Slice audio mute and pan on backends that demodulate on this host.

Two tables, two derivations — and that split is the finding

The GUI matrix and the headless matrix are built by different methods. The
test that decides which is honest is one question: does anything refuse when
the declaration is absent?

  • In the GUI, nothing does. Defaults are permissive (hasFmRepeaterOffset
    is declared = true), the gates are hand-written and optional, and
    ControlAvailabilityRegistry — the one mechanism built to enforce them — has
    no production consumer (a registered unit test and nothing else). So GUI
    cells are derived from
    reachability, and a declaration is only a cross-check.
  • In aetherd, something does, and it is written into the target.
    ModelReceiveControlTarget opens every admission with
    caps.<record> && known(authority) and returns the typed error
    capability.unavailable; ModelSliceFrequencyTarget::validCoverage does the
    same with a range. There the declaration is the contract, so the headless
    cells are derived from the record.

Deriving the headless cells the GUI way would print a phantom W for every
verb the daemon never exposes — the override exists and nothing headless calls
it. So a feature with no aetherd method gets no headless cell at all; it is
named in prose instead of being scored.

The case that proved the precaution earns its keep. validCoverage()
refuses on minimumHz <= 0, and both FlexBackend and AnanBackend declare
sliceFrequencyControl as {…, 0, 0} (Authority::Radio and
Authority::Engine respectively). Headless tuning is therefore refused on
both
— on the family whose GUI column is otherwise the fullest. Reading the
authority alone, the GUI way, would have printed W.

Why the check cannot be satisfied by a token edit

A gate that says "edit the matrix when you edit a capability" enforces a
change, not the right change, and a counter can be paid off with a comment.
This one generates the derivable cells from the source and diffs them against
the committed record and its sidecar. A diff cannot be paid off.

It derives the four questions a parser can settle — is the capability declared
and to what (three answers matter: true, false, and never assigned), is
the virtual overridden, is the base Q_UNUSED, is the control gated above the
seam — plus two with teeth: a runtime-published label list, and whether a stored
value is ever read back.

D against P is decided by PROVENANCE, not by any field's value. The
question is did this backend ask for this control? It assigned the gating
capability itself and did not implement the verb — a bug in that backend, D.
It never mentions the field and a permissive default offered the control on its
behalf — a gap in the default, one assignment repairs it, P. PERMISSIVE is
reachable only from DEFAULT_TRUE, which is returned only when capabilities()
carries no assignment at all, so an explicit assignment can never produce a P.
Two adjacent FM rows show why the value cannot be the discriminator:
FmTonePresentation defaults to Hidden and Legacy turns the tone controls
on, so the HL2 — which assigns Legacy — asked, and its tone cells are D;
hasFmRepeaterOffset defaults to true and the HL2 never mentions it, so the
repeater rows one line below are P.

P itself has two grounds and they are different repairs, so the checker
labels each and prints the split: six inherited-default cells
(fm/repeater-* on HL2, ANAN, Demo — c.hasFmRepeaterOffset = false; fixes
each) and one cached-readback cell (rx/filter on RTL, where the backend did
ask, stores the edges, echoes a confirming SliceDelta, and RtlSdrDdc never
reads them — real work).

V is never generated

No static read proves a radio obeyed. The checker asserts only that a V
carries a run citation
, and that the generator independently derives W for
the same cell — a V on a cell the source calls dead is either over-claimed or
the generator is broken, and both need a person.

All seven V cells are HL2, each citing a bench run that answered from the
radio's own MAC: rx/frequency (d114), rx/filter (d111), rx/pan-bandwidth
(d101), rx/rf-gain (d103), tx/keying (d126), tx/power (d99), tx/filter
(d110).

d77 and d87 produce no V, however good their numbers are, because they ran
against hpsdrsim on loopback. A simulator agreeing with the software that
drives it is not evidence that a radio obeyed.

The nine U cells, and why an honest "undecided" is the point

Both reasons are the same shape — the base class forwards the intent somewhere
the generator will not follow.

cells why
rx/xit-offset on Icom, HL2, ANAN, RTL, Demo The base setXitOffset is an alias into setRitOffset. The intent is reachable, but not necessarily this control: on Icom the alias is correct because the radio has one shift register; on a two-register family it would silently write the other one. W would launder that, D would be false on Icom.
mem/recall on HL2, ANAN, RTL, Demo The base applyMemoryRecallDetails calls setSliceFmRepeater, which fans out into four FM setters that are themselves no-ops on those families, and then returns true. A caller checking the return value is told the recall succeeded. Whether the operator notices depends on which memory fields were set, which the generator cannot know.

U is a legitimate committed value, accepted only where the generator emitted
U
— committing U over a decided cell is a diff and fails.

Relation to #5892

@rfoust's #5892 (aetherd: seed backend receive contracts and correct Icom
capacity
, implementing #5890) adds
docs/architecture/backend-receive-contract-tests.md in this same directory,
and separates "daemon capability declarations, actual request dispatch/
configuration, and independently injected radio observations"
— the same axis
this register scores, in different words. They are complementary, and they
agree where they overlap.

  • This register is derived from source and guarded by CI. It is
    exhaustive over 61 controls × 6 families and finds gaps nobody has tested
    yet
    — a D cell is a control no test exists for, because a test would have
    caught it.
  • aetherd: seed backend receive contracts and correct Icom capacity #5892 exercises the implementations. It proves what actually happens on a
    handful of operations, through injected transport and state seams.

Two independent derivations landing on the same answer is the useful part.
#5892's doc records that "Icom and ANAN mode/filter remain unadvertised to
daemon clients; that does not mean the desktop implementations are absent"
, and
that Demo's stored filter state does not justify advertising a daemon filter
operation. This register, derived separately from the capability records, scores
headless rx/mode and rx/filter as R for Icom and ANAN and headless
rx/filter as R for Demo, while the GUI table scores Icom mode and filter
W. Same conclusion, two methods.

On the Icom capacity correction: nothing here contradicts it, and I checked
rather than assumed.
This register asserts no capacity numbers —
pan/create and pan/remove are reachability cells, maxPanadapters appears
only as the named gating field, and the generator reads a numeric capability
only as > 0. Applying #5892's c.maxPanadapters = m.hasScope ? 1 : 0; to a
local tree and re-running --strict gives an identical matrix: 366 cells, W=143 D=94 P=7 R=22 H=84 U=9 V=7, 0 disagreements with the source. The
Icom pan/* cells are R for a reason independent of the number — the base
returns a refusal the caller can see and Icom does not override it.

Where #5892 could feed this register, stated precisely. Its contract tests
are the natural future source for automating what V does by hand. One
qualification worth making rather than glossing: V here means a real radio
obeyed
, and #5892 explicitly disclaims firmware convergence — its own table
says each family's tests do not establish "firmware acceptance" or "live Metis
delivery". So a passing contract test earns a class between W and V —
dispatch-verified, stronger than reachability, weaker than a radio moving — not
V itself. If that class is wanted, #5892's suite is where its citations should
come from, and this checker already has the shape to carry it: V is the one
column it refuses to generate and only validates.

Run against a live migration before merge — what it caught, and what it did not

A register that is only ever run against the tree it was written from proves
nothing about drift. So before merging it, --strict was run against #5907
(@rfoust, aetherd: migrate receive controls behind the backend seam) — the
first bounded receive-command migration, and exactly the kind of change this
document is supposed to survive. Both halves of the result belong here.

What it did NOT catch: zero cells moved, and that is correct. --print is
byte-identical between #5907's head and origin/main (both
md5 bc8987b9ff0316870f303a959f25f561), and the committed matrix derives
unchanged on his tree: 366 cells, W=143 D=94 P=7 R=22 H=84 U=9 V=7. The
migration is additive by construction — SliceModel gains intent signals,
FlexBackend gains requestSliceTune / requestSliceFilter /
requestSliceAgc, and the seam verbs the register actually scores
(setSliceFilter, setSliceAgc) were already overridden on main. Nothing
about where the click ends changed, so the cell derivation was silent. A
register that reported movement here would have been wrong.

What it DID catch: a real relocation, through the documentary side-check.
Exit 1 on two feature-matrix-stale-alt-path errors — rx/filter/Flex and
rx/agc-threshold/Flex. Both are literally true. On main,
SliceModel::setFilterWidth ends in sendCommand(QString("filt %1 %2 %3")),
SmartSDR wire text built above the seam; on his branch that body notifies an
intent and the string lives in FlexBackend::setSliceFilter, below it.
'filt ' no longer appears in SliceModel.cpp at all.

And the parse GREW, which is the strongest evidence here that the derivation
tracks the source rather than agreeing with a still tree.
Against his head the
generator finds 82 seam virtuals where main has 79, and 24 FlexBackend
overrides where main has 21. Nothing was lost, no backend's override set
shrank, and no vacuity floor was approached. The quiet-degradation failure mode —
a parser that falls over and derives every cell the same way — is ruled out by
measurement rather than by argument.

The defect that field test exposed, and the fix

The two errors were right and the check was wrong. Every alt_path in this
register documents an above-seam bypass — forty of them, all Flex — and that
work-stream exists to delete exactly those.
So a boolean "does the file still
contain both"
raises feature-matrix-stale-alt-path on every success of
it. After the third green migration someone learns to wave the error through,
and it is then worthless on the day it finds real drift.

Fixing that by downgrading it to a warning would have been the same disarming by
a different route: a check that cannot fail is the thing this register exists to
replace. So the boolean became a classification. The question is not "did the
triple break"
— it breaks identically either way — but did the evidence get
weaker, or did a weaker piece of evidence stop being needed?

what happened the check
ROTTED the claim is false and nothing stronger replaced it fails, and now names which of four conditions refused
RETIRED the bypass was deliberately deleted because the control moved behind the seam reported as progress, never fails

RETIRED requires all four, and each rules out one way the cheerful reading
could be wrong:

  1. the wire token is gone from the declared file — a file that still emits it
    has the bypass and merely names it wrong, which is an unrecorded rename;
  2. the backend overrides the row's seam verb, with a body that is not a
    Q_UNUSED no-op — a compiler-checked fact, not another text match, and the
    reason derive() never consulted the record in the first place;
  3. the cell still derives W — the alt path argued reachability, and if the
    control stopped being reachable then something was lost, not retired;
  4. the wire token is still emitted from that backend's own source — the
    positive half, which catches a "migration" that moved the call and forgot to
    send anything.

The notice is an edit request, not a status line: it names the row, the
family and the two edits (drop the alt_path from the sidecar, drop the *
from the document) and repeats every run until someone makes them.

One case it cannot separate, said out loud rather than glossed. Where a
backend already overrode the verb and already carried the same wire token
below the seam — three records today, rx/filter, rx/agc-threshold and
rx/pan-center on Flex — mangling the above-seam literal yields a tree
indistinguishable from the migration, because in every respect this register
scores it is the same tree: the cell is still true and the bypass no longer
carries that text. The other 37 of the 40 records are load-bearing — the
derivation rests on them — and breaking one of those moves the cell, so
feature-matrix-drift fires beside the alt-path error. That is why perturbation
6 below is aimed at a load-bearing record.

After the change: origin/main gives 366 cells, W=143 D=94 P=7 R=22 H=84 U=9 V=7, 0 alt paths retired, 0 disagreements, exit 0 — identical to before.
#5907's head gives the same 366 cells, 2 alt paths retired, 0 disagreements, exit 0, with two feature-matrix-alt-path-retired notices
naming the two records to delete.

I have not commented on #5907 and have not contacted @rfoust. Whether and
how to raise any of this with him is ON8ST's call.

Two smaller repairs in the same tool

  • The U-provenance breakdown was gated on if reasons and not errors, so
    it vanished exactly when a run was failing and you most wanted it — an
    unexplained U is very often the same parse failure that produced the error
    above it. Ungated; it now prints on every run.
  • LIVE is now read comment-free. The old predicate searched raw text, so a
    wire token surviving only in a // FlexAPI: "…" comment kept the claim alive.
    All forty records pass either reading today, so nothing moved — but the
    comment-only case is now caught (perturbation 6e).

Verification

python tools/check_radio_feature_matrix.py --strict passes on the committed
matrix, rebased onto current main. The other nine tools/check_*.py --strict
also pass, before and after that rebase.

It fails on each of eight deliberate perturbations, all re-run on the
rebased tree:

# perturbation result
1 a cell flipped in the sidecar (dsp/squelch/HL2 D→W) feature-matrix-drift
2 the same flip in the document only feature-matrix-doc-drift
3 an uncited V (rx/frequency/HL2) feature-matrix-uncited-v
4 a well-cited V on a cell the source calls dead feature-matrix-impossible-v
5 an override removed from Hl2Backend.h feature-matrix-drift
6 an alternate path whose wire literal no longer appears in its file — aimed at a load-bearing record (tx/power/Flex, rfpower=) feature-matrix-stale-alt-path and feature-matrix-drift
7 hasSquelch added to RadioCapabilities.h — the ∅ probe feature-matrix-absent-probe
8 the matrix shrunk below the cell floor, and IRadioBackend.h emptied feature-matrix-vacuity

Perturbation 6 now has five more shapes, because the rot/retirement split is
the part of the checker most easily turned into a rubber stamp and it has to be
shown failing where it should and passing where it should:

# perturbation result
6a load-bearing record, wire literal mangled above the seam (tx/power/Flex) exit 1 — stale-alt-path classified ROT ("FlexBackend does not override setTxPower, so this record was the only evidence the control reaches the radio at all") and drift (W → D)
6b non-load-bearing record, wire literal mangled above the seam, override untouched (rx/filter/Flex) exit 0 — RETIRED notice. This is the case named above as indistinguishable from the migration, and it is the same tree
6c symbol renamed above the seam, wire text left in place (setFilterWidth → setFilterEdges) exit 1 — ROT: "SliceModel.cpp still emits 'filt ', so the bypass is still there and only the symbol is wrong — a rename nobody recorded"
6d a "migration" that deletes the wire text on both sides and keeps the override exit 1 — ROT: "emitted from neither SliceModel.cpp nor FlexBackend's own source — the wire text was not moved below the seam, it was lost"
6e wire token survives only in a comment (tx/power/Flex) exit 1 — ROT. The old predicate passed this, because it searched raw text
6f the migration lands but the new override body is a Q_UNUSED no-op exit 1 — ROT ("not an override with a body this checker can read") and drift (W → D)

Only 6b is green, and 6b is the retirement this change exists to recognise.

Perturbation 7 is the one worth naming: ∅ is a claim about absence, which
is exactly the claim that rots silently when someone adds the field. The probe
makes it fail loudly instead. Perturbation 8's floors return 1 regardless of
--strict
— a parser that finds nothing derives every cell the same way, and
the diff would then pass on a matrix that had stopped meaning anything.

Two further checks pin the provenance rule specifically. Making AnanBackend
assign hasFmRepeaterOffset = true — the same permissive value it was already
inheriting
— flips both cells P → D on provenance alone; assigning
fmTonePresentation = Legacy flips the four tone cells H → D. Neither test
changes a value the code did not already have, so neither could pass under a
rule that keyed on the value.

Corrections from review

@aethersdr-agent's review (approve with nits) found one false statement about
current code
, in the half of the document the checker cannot see — exactly the
decay class this register exists to stop:

  • ANAN (P2) is not "one live setter". P2Client exposes a second
    Q_INVOKABLE live setter, setDdcRateLive, and, since feat(anan): drive G2 attenuation from RF Gain. Principle V. #5820, a third,
    setStepAttenuationDb
    (@jensenpat's correction on the rebase — it is what
    moved rx/rf-gain/ANAN from D to W). setDdcRateLive which returns false without
    sending unless the session is running and otherwise resends the whole
    DDC-Specific packet; AnanBackend::finishRateChange invokes it three times
    across the settle window, and startP2ClientSession is now reached from the
    connect path alone. The rate no longer costs a stop/reconfigure/restart. The
    section is rewritten. No cell moves — rx/pan-bandwidth/ANAN stays W
    either way.
  • rx/dial-lock's note overstated. hasRadioDialLock is read in exactly
    one file above the seam, RadioModel.cpp — which is the read the D
    cells rest on. IcomCivBackend.cpp reads it below the seam in four places,
    on the one family whose cell is W. The note now says so.
  • ControlAvailabilityRegistry is not "referenced by nothing but itself":
    it has a registered unit test. The claim that survives is no production
    consumer
    , and that is now what both this body and the document say.
  • MIN_CELLS's own comment said 62 rows; there are 61.

--strict after all four: 61 features × 6 backends = 366 cells,
W=143 D=94 P=7 R=22 H=84 U=9 V=7, 0 disagreements
— identical to before, so
no cell moved on prose changes. All ten tools/check_*.py --strict pass.

What the checker cannot see

Stated so a green run is not read as more than it is: whether the radio obeyed
(only V claims that); whether a control reaches its signal, since the
widget-to-model wiring is Qt lambdas in constructor bodies; whether a model gate
refuses visibly or drops silently (the visible flag is authored, not derived);
and whether an override that reaches the wire writes the right register.
The * mark is the weakest evidence in the document by design — an override is
a compiler-checked fact, an alternate path is a file, a symbol and a wire
literal that all have to still be there.

What the check does NOT guard — the roster

Stated here as plainly as in the document, because it is the one way a green run
can be read as more than it is.

main() iterates the committed record — for rid, row in matrix.items() —
so every check is a question asked about a row that already exists. Nothing
iterates the other way: nothing enumerates IRadioBackend's seam verbs or the
controls the GUI builds and asks whether each has a row. Two consequences, both
silent:

  • A feature deleted from both files passes green. @aethersdr-agent removed
    rx/agc from the JSON and its line from the table during review: --strict
    reported 60 feature(s) … 0 disagreement(s) and exited 0.
  • A new seam verb arriving with a GUI control and no row is invisible too.

MIN_CELLS = 200 is a vacuity floor against a broken parser, not a roster
check — over six backends it does not fire until the matrix falls below 34 rows,
so 27 of today's 61 rows could be deleted before it says anything.

So: the register proves the cells are true. It does not prove the roster is
complete.
That limit is now written into the document itself, under "What a
green check does not prove — the roster is not guarded"
, and cross-referenced
from "Keeping this true".

The repair is a roster anchor — derive the feature list from the seam and
the GUI control inventory and diff it the way the cells are diffed, so an
addition or a deletion fails instead of passing quietly. It is a generator of a
different shape from this one and it is deliberately not in this PR; it is
offered as a named follow-up.

No capability field was added

tools/check_capability_records.py stays at its frozen 71 and this work does
not touch RadioCapabilities.h. The ∅ rows are recorded as absences with the
probe above, and the document says what shape can land instead: an
std::optional<…Control> record whose absence means no support, in the style of
ReceiveAudioControl — or an interface through the seam, the way
AutoRfGainControl.h did when auto-RF-gain hit the same wall.

Constitution principle honored

Principle VIII — Evidence Over Assertion. The register replaces the
assertion "this radio supports X" with a cell derived from the source and a
CI step that fails when the source and the cell disagree. The one column that
cannot be derived, V, is the one the checker refuses to generate and only
validates against a citation.

Defects found, not filed

The four dispatch traces turned up ten defects, recorded in the document's own
table and not filed as issues here — each is a separate decision. They
include the canTransmit guard applied only at the keying sites (#4 in that
table), IcomCivBackend::hasRadioSideCwKeyer answering two different questions
(#5), and RxApplet offering "ANT1"/"ANT2" on radios that never claimed
them (#7) — an invented control rather than a dead one.

Test plan

Documentation, one CI step and one Python checker; no production code path is
touched and no build is required for correctness. tools/check_radio_feature_matrix.py
is pure-Python source analysis with no build dependency.

  • All ten tools/check_*.py --strict pass on the rebased branch.
  • The eight perturbations above each fail, with the error identifiers listed,
    and so do the five further alt-path shapes (6a, 6c, 6d, 6e, 6f). 6b is green
    by design and is the retirement.
  • --strict was run against a live migration (aetherd: migrate receive controls behind the backend seam #5907) before merge, and against
    origin/main after the change: same 366 cells, 0 disagreements, exit 0 on
    both.
  • The two provenance perturbations each flip exactly the expected cells.
  • No radio was keyed and no hardware was accessed for this PR; the V
    citations reference bench runs recorded before it.

Checklist

  • Signed commits, verified locally
  • No new flat settings keys, credentials, protocol methods or TX paths
  • No production behavior change
  • Clean-room work
  • Documentation updated; no changelog or release-file change
  • Frozen CI gate unchanged; the new step is a static-checks.yml strict check

🤖 Generated with Claude Code

@on8st
on8st requested review from a team as code owners September 21, 2026 06:10

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue fit

No linked issue, and none is needed: GOVERNANCE.md's "does NOT require an RFC" list covers documentation, and this adds no production code, no protocol surface and no settings key. Reviewed against the PR's own stated intent — a per-control, per-radio register of where the click ends, generated from the source and diffed in CI. It delivers that, and the generator is the real deliverable: I tried four ways to make it pass on a matrix that had stopped being true and it caught all four (details under "What I tried to break").

Scope

file what it changes claimed by the title/body? verdict
docs/architecture/radio-feature-matrix.md new — the register, vocabulary, two matrices, per-family derivations, defects section yes in scope
docs/architecture/radio-feature-matrix.json new — machine-readable sidecar, 61 records × 6 backends yes in scope
tools/check_radio_feature_matrix.py new — source-derived generator + differ, stdlib-only yes in scope
.github/workflows/static-checks.yml one --strict step, plus its rationale comment yes in scope — but it is a new blocking step on a required check (see Nits)
docs/architecture/README.md one index entry yes in scope

Nothing in the diff is unexplained; no production source, no test target, no CHANGELOG.md entry, no deletions of any kind (the whole diff is +3443/-0). No socket test or fake peer is added, removed or modified.

Blockers

None.

Nits (all non-blocking)

  1. The ANAN section is stale in two adjacent sentences (radio-feature-matrix.md:361 and :368) — inline comment. P2Client::setDdcRateLive (P2Client.h:146, body at P2Client.cpp:175) is a second Q_INVOKABLE live setter that sends on a running session, and AnanBackend::beginRateChange now writes the rate live and resends it three times (AnanBackend.cpp:1103-1110) instead of a stop/reconfigure/restart. No cell changes — rx/pan-bandwidth/ANAN stays W either way — but this is precisely the decay class the register exists to stop, in the half of the document the checker cannot see.
  2. A whole feature row can be deleted from both files and CI stays green. I removed rx/agc from the JSON and its line from the table: --strict reported 60 feature(s) … 0 disagreement(s) and exited 0. Cell drift is guarded; roster shrink is not, and neither is a new seam verb arriving with a GUI control and no row. MIN_CELLS = 200 only catches a wholesale collapse. Worth a line in the doc saying so, or a future roster anchor.
  3. rx/dial-lock's note overstates (radio-feature-matrix.json:535) — inline comment. IcomCivBackend.cpp reads capabilities().hasRadioDialLock at 1198, 2444, 4693 and 7017. The claim holds above the seam; it needs those three words.
  4. "H is a good cell … read H as done" (radio-feature-matrix.md:98) sits against the #5262 M3a maintainer ruling quoted in radio-capabilities-map.md:382-391: "individual controls are never hidden per radio — they dim with a reason", with the remaining hide sites scheduled as M3b. 84 cells currently read as finished against a ruling that calls them pending migration. A maintainer framing call rather than a defect — inline comment.
  5. ControlAvailabilityRegistry … referenced by nothing but itself (:31) — it also has a registered unit test (tests/control_availability_registry_test.cpp). "No production consumer" is the claim that survives.

What I tried to break

Everything below I ran against the PR head checkout; the checks are empirical, the code claims about ANAN/Icom/RTL are read from source, not observed running.

  • Source mutation. Made RtlSdrBackend::setSliceAgc store its arguments instead of Q_UNUSED-ing them. --strict failed with rx/agc/rtl is committed as D and the source derives W, exit 1. The generator genuinely parses the tree; it is not re-reading its own sidecar.
  • Document-only mutation. Flipped one cell in the markdown table, leaving the JSON alone: feature-matrix-doc-drift, exit 1 under --strict, exit 0 without it — matching the documented flag contract.
  • Weakest-evidence rot. Renamed the audio_pan= wire token in SliceModel.cpp; the * cell's alt-path assertion fired (feature-matrix-stale-alt-path). The file/symbol/wire triple really is all three.
  • Can V launder a dead control? No — check_radio_feature_matrix.py:955-971 requires both a run citation and derived == "W", so a hand-added V cannot sit on top of a D. This was the hole I most expected to find.
  • Can a broken parser pass everything? No — Source.vacuity() floors (60 seam virtuals, 90 caps fields, 8 overrides/backend, 20 caps assignments) return 1 regardless of --strict, with a comment forbidding lowering them to match.
  • Workflow placement. The step sits in the same static-checks job as its five siblings, after checkout + setup-python@3.12, with if: ${{ !cancelled() }}; static-checks.yml carries no paths: filter, so it runs on every PR. Stdlib-only, path-anchored on __file__ (verified by running it from /tmp), 1.3 s. All five checks are green on 03e27c07.
  • Spot-checked the prose against the tree, since the checker only guards cells: m_nbModeOk && (m_hasRadioSideDsp || m_hasHostNoiseBlanker) at VfoWidget.cpp:3322, refuseKeyOnTransmitIncapableBackend at RadioModel.cpp:4859, RtlReceiverRegistry's only non-self reference being a comment in WdspChannel.cpp:1091 — all hold. The ANAN paragraph is the one that did not.
  • Could not check: whether the 7 V cells' cited runs (d99, d101, d103, d110, d111, d114, d126) exist — the run records are not in this repo, so the citation requirement is a format check here, not a provenance one.

Recommendation

Approve with nits. The generator is the part that had to be right and it survived every attack I could construct, including the two shapes that would have made the document decorative — a V masking a dead control and a broken parser passing everything. The five nits are prose and framing; only #1 is a false statement about current code, and it changes no cell. I would ask for #1 and #3 as one-line edits before merge, and leave #2 and #4 to the maintainer as follow-ups (the roster anchor, and whether 84 H cells should read as done or as M3b backlog).


🤖 aethersdr-agent · cost: $9.8696 · model: claude-opus-5

Comment thread docs/architecture/radio-feature-matrix.md Outdated
Comment thread docs/architecture/radio-feature-matrix.json Outdated
at a fraction of the cost of turning it into a `W`. A vocabulary that scored
them alike would make the cheap fix invisible.

**`H` is a good cell, not a gap.** On a receive-only radio a hidden transmit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagging for a maintainer rather than as a defect: radio-capabilities-map.md:382-391 records the #5262 M3a ruling as "individual controls are never hidden per radio — they dim with a reason", with the remaining hide sites scheduled as M3b, on the grounds that a hidden control is not announced to a screen reader at all.

So the 84 H cells are, per that ruling, pending migration rather than finished — and "read H as done" is the one sentence in the vocabulary a reader could act on in the wrong direction. Either a pointer to M3a here, or a maintainer confirming that H is the intended end state for these rows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change here, deliberately: whether H is the intended end state or a pending-M3b defect class changes the meaning of 84 cells, and that is a maintainer's ruling rather than something a vocabulary section should settle on its own authority. The consequence is on the record in the conversation on this PR under "#4", including the two sub-questions that fall out either way — whether H should split into hidden correctly versus hidden pending migration, and whether a receive-only radio's transmit controls are inside the ruling's scope at all. Leaving this thread open until someone rules; the vocabulary in radio-feature-matrix.md is unchanged until then.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed in 727a568. The line now reads "H and M are both hidden, and neither means done". It points to a new "Hidden cells: H and M" section, which quotes §4a and the M3b line from radio-capabilities-map.md. H now means only an applet hidden wholesale, which M3a permits. M means a single control that is gated away (hidden, disabled without an announced reason, or relabelled) where §4a wants it dimmed with a reason, so it is pending M3b. All 89 hidden cells are M and none is H: no row in the register is hidden by hiding its applet. Each record now carries a hide block naming the gate's file, symbol and observed form, and the checker enforces it. Four rows didn't fit a form §4a names: rx/filter-preset, tx/freq-check, rx/antenna and dsp/notch-enable. They are marked M, with the reason in their notes, rather than scored as compliant.

@on8st

on8st commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — this is the review I wanted. You attacked the generator instead of reading it, and the two shapes you went after are the two that would have made the whole document decorative: a V masking a dead control, and a broken parser passing everything.

Four of the five are addressed in 5dc6130. #4 I am deliberately not answering — it is a maintainer's call and answering it myself would be the wrong kind of helpful.

1 — the ANAN section was false against the tree I generated from. Fixed, and the sting is the point.

Verified in the source before touching it, and you are right.

P2Client exposes a second Q_INVOKABLE live setter, setDdcRateLive. It returns false without sending unless the session is running, and otherwise resends the whole DDC-Specific packet — live by construction. Its own declaration comment says it answers the question AnanBackend::beginRateChange's comment left open: "whether the radio would accept a live rate change without a session restart at all is a separate, unverified protocol question". And the rate is now written on the running session, three times across the settle window (kRateChangeResendMs, kRateChangeLiveSettleMs) because the protocol offers no ack. startP2ClientSession is reached from the connect path alone; there is no stop/reconfigure/restart on the rate path any more.

One correction to your framing, since precision is the whole subject here: the live write is in AnanBackend::finishRateChange, not beginRateChange. beginRateChange now only refreshes the pending config and hands the WDSP rebuild to a background thread before anything live is touched. More usefully — beginRateChange's own leading comment still describes the old stop/reconfigure/restart, so the source currently asserts both things. The register was faithfully reporting a comment that had stopped being true. I have said that in the section rather than quietly picking the true half, and have not filed it; that is a separate decision.

No cell moves — rx/pan-bandwidth/ANAN is W either way, and --strict confirms an identical census after the edit.

Your sentence about this — "precisely the decay class the register exists to stop, in the half of the document the checker cannot see" — is the honest verdict and I am not going to soften it. The register guards 366 cells and cannot guard the prose that explains them. A dispatch trace written against a file that changed underneath it is exactly how a document goes decorative while its CI step stays green. The only mitigation in the artefact today is that the prose cites file and symbol so it can be re-checked by hand — which is what you just did, and it took a reviewer to do it.

2 — roster shrink is not guarded. Documented here; the anchor is offered as a follow-up.

Reproduced, and the mechanism is worse than the symptom.

main() iterates the committed record — for rid, row in matrix.items() — so every check it runs is a question asked about a row that already exists. Nothing iterates the other way: nothing enumerates IRadioBackend's seam verbs, or the controls the GUI builds, and asks whether each one has a row. The single place the parsed seam set is consulted for content is the stale-seam check, and that runs JSON→source (does this row's verb still exist?), never source→JSON. So both of your cases hold:

  • delete a feature from the JSON and its line from the table, and the run is green — your rx/agc result reproduces exactly;
  • add a new seam verb with a GUI control and no row, and nothing notices either.

And MIN_CELLS = 200 is weaker than "only catches a wholesale collapse": over six backends it does not fire until the matrix falls below 34 rows, so 27 of today's 61 rows could be deleted in silence. Its own comment also claimed 62 rows; there are 61. Both corrected.

This is the same shape this project has been closing all week — the check guards what it was pointed at, and is silent about what was never listed — so the limit should be written down rather than implied. New section in the document, "What a green check does not prove — the roster is not guarded", cross-referenced from "Keeping this true", and a matching section in the PR body. It says plainly: a green run means every cell committed here agrees with the source; it does not mean every control an operator can reach is in this table.

The repair, named so it is not lost: a roster anchor — derive the feature list from the seam verbs and the GUI control inventory, and diff it the way the cells are diffed, so an addition or a deletion fails instead of passing quietly. That is a generator of a different shape from this one, and I have deliberately not built it in this PR; it deserves its own change where it can be attacked the way you attacked this one. I will file it as a follow-up issue on a maintainer's word.

3 — rx/dial-lock's note overstated. Fixed.

Confirmed: IcomCivBackend.cpp reads capabilities().hasRadioDialLock in four places. The claim the D cells actually rest on is the one above the seam, and the note now says so — plus a clause naming the Icom reads below it, on the one family whose cell is W, so the next reader is not ambushed by them. Your three words, and the reason for them.

4 — H as done: not mine to answer, and here is what it costs to leave open.

Leaving this to the maintainer, unanswered. What I will do is put the consequence on the record, because it is larger than a wording choice.

The vocabulary says "H is a good cell, not a gap … read H as done". radio-capabilities-map.md records the #5262 M3a ruling under a "Superseded" banner: "individual controls are never hidden per radio — they dim with a reason, and hiding survives only at applet granularity for a cohesive radio-specific cluster. A hidden control is not announced to a screen reader, so it tells a blind operator nothing at all", with "the migration of the remaining hide sites is M3b, scheduled after M4's conversion so gates and visuals change once."

If that ruling stands as written, H's definition changes and 84 cells change meaning with it — from finished to pending M3b, i.e. from a correct state into a defect class. That is 23 % of the matrix, and it is worth settling before the register is cited in an argument rather than after. Two sub-questions that fall out either way, for whoever takes it:

  • should H split — hidden correctly versus hidden pending migration — so the two are not one cell;
  • are a receive-only radio's transmit controls inside or outside the ruling's scope? A permanently dim TX control on an RX-only radio is the case the ruling's own reasoning is least obviously aimed at, and it is where most of the 84 live.

One thing that may help whoever decides: the same section of radio-capabilities-map.md still carries its pre-ruling prose under the superseded banner — "They are hidden, not disabled" about CWX/DVK/FDX. So the map is currently ambiguous on exactly this point, and the register inherited the ambiguity rather than inventing it.

5 — ControlAvailabilityRegistry. Fixed.

Correct, and it is a registered ctest target (add_test(NAME control_availability_registry_test …) in tests/tests.cmake), not merely a file sitting in the tree. The claim that survives is no production consumer — no file under src/ outside the class itself names it — and that is what both the document and the PR body now say.

On what you could not check

Confirming rather than glossing: you are right, and it is structural. The run records behind the seven V citations (d99, d101, d103, d110, d111, d114, d126) are not in this repository and cannot be — the provenance lives in a bench lab this project has no view of. So the citation requirement is a format check here, not a provenance one.

That is exactly why V is hand-added and never generated. What a reviewer in this repo can verify is the whole of what the checker claims: that a V carries a citation, and that the generator independently derives W for the same cell, so a V cannot sit on top of a D. The rest rests on whoever added it. If that is not good enough for a given citation, the right response is to stop trusting the cell — not to trust the checker harder.


--strict after all four edits: 61 feature(s) x 6 backend(s) = 366 cell(s); W=143 D=94 P=7 R=22 H=84 U=9 V=7; 0 disagreement(s) with the source, exit 0 — identical to the pre-edit census, so no cell moved on prose changes. All ten tools/check_*.py --strict pass. Commit is signed and verified.

🤖 Generated with Claude Code

@on8st

on8st commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

#5894 and #5879 collide: six HL2 cells in the feature matrix, and the gate that should catch it cannot

Found in a local integration build of our open PRs. Neither PR is wrong on its own, and neither can carry the fix alone — so this is a note, not a change request.

The interaction

truthy() in the checker lists ::Hidden among NEGATIVE_ENUMERATORS, so once both land, both gating capabilities read false for the HL2, gate_state() returns HIDDEN, and six HL2 cells derive H where #5894's committed record says D or P.

There is no escape hatch in the record — the matrix has no "expected after" field, and the checker has no write mode (--strict / --print only).

What I measured

Four standalone trees, python3 tools/check_radio_feature_matrix.py --strict:

tree committed HL2 FM cells exit
origin/main checker and matrix do not exist n/a
#5879 alone checker and matrix do not exist n/a
#5894 alone as committed (D D D D P P) 0
#5894 alone, cells changed to H H x6 1 — 6 disagreements, mirror direction
#5894 + #5879 as committed 1 — 6 disagreements
#5894 + #5879, cells changed to H H x6 0

The fourth row is the one that settles placement: putting the record update on #5894's branch today turns a currently-green PR red, because on its own base the source still derives D/P and the checker reports the same six cells in the opposite direction.

Summary line, before and after, in the combined tree:

W=143 D=94 P=7 R=22 H=84 U=9 V=7; 6 disagreement(s) with the source
W=143 D=90 P=5 R=22 H=90 U=9 V=7; 0 disagreement(s) with the source

How this surfaces — and the part that is worse than "the second one goes red"

tools/check_radio_feature_matrix.py --strict runs in exactly one place: a step in static-checks.yml. That workflow is pull_request-only — its own concurrency comment says "PR-only workflow ... ref is always refs/pull/N/merge" — and no push: main lane runs the checker (ci.yml, full-suite.yml, system-libs-canary.yml and codeql.yml are the push-to-main workflows; none of them invoke it).

ci.yml's header also records that "we dropped the 'Require branches up to date before merging' branch-protection bit". I could not read branch protection directly to re-confirm that, so both paths are worth stating:

  • If branches need not be up to date — the likely case — both PRs are already green on merge refs computed before the other existed. The second one merges on that stale green, nothing re-runs the checker on main, and main goes red silently. The failure then appears on the next unrelated PR, whose merge ref inherits the broken main — a Static checks red attributed to a PR that did not cause it.
  • If the second PR gets any new push after the first merges (a rebase, a review fixup), its Static checks re-runs against the new merge ref and goes red there, which is the visible, blocking case.

Either way the record update belongs on whichever PR merges second, applied after its branch carries the other's change:

Applied earlier than that, on either branch, it fails that branch's own --strict. There is no ordering in which it can be staged in advance.

The six cells

feature HL2 now HL2 after gating capability
fm/tone-mode D H fmTonePresentation
fm/tone-tx D H fmTonePresentation
fm/tone-rx D H fmTonePresentation
fm/dtcs D H fmTonePresentation
fm/repeater-dir P H hasFmRepeaterOffset
fm/repeater-offset P H hasFmRepeaterOffset

Note that #5894's own matrix prescribes the change that breaks it: the P-provenance row's repair column reads "One assignment per backend: c.hasFmRepeaterOffset = false;". The code is the honest side here; the record is what goes stale.

H is what the source derives — not a ratified classification

To be explicit, because these are two different claims: H is measured, the output of gate_state() on the combined source. Whether H is the right vocabulary for "the backend declared the control off" on rows that previously read D and P is a judgement, and I am not asserting it. If a reviewer thinks these six should be recorded some other way, that changes the record, not the measurement — say so and I will rework it.

Specifically worth a second opinion: fm/repeater-dir and fm/repeater-offset move P → H, which removes the HL2 from the inherited-default class rather than repairing it in place. That is exactly what the repair column asked for, but it does mean the class the document teaches from loses its worked example (see the prose edits below).

Exact replacement values, so nobody re-derives them

docs/architecture/radio-feature-matrix.json — in each of the six feature objects, cells.hl2:

"fm/tone-mode":       "D" -> "H"
"fm/tone-tx":         "D" -> "H"
"fm/tone-rx":         "D" -> "H"
"fm/dtcs":            "D" -> "H"
"fm/repeater-dir":    "P" -> "H"
"fm/repeater-offset": "P" -> "H"

docs/architecture/radio-feature-matrix.md — the "FM repeater and tone" table (column order Flex | Icom | HL2 | ANAN | RTL | Demo); the HL2 column is the only one that moves:

| `fm/tone-mode` | W* | W | H | H | H | D | CTCSS / DTCS tone mode |
| `fm/tone-tx` | W* | W | H | H | H | D | TX tone frequency |
| `fm/tone-rx` | D | W | H | H | H | D | RX tone frequency |
| `fm/dtcs` | D | W | H | H | H | D | DTCS code and reverse flags |
| `fm/repeater-dir` | W* | W | H | P | H | P | repeater shift direction |
| `fm/repeater-offset` | W* | W | H | P | H | P | repeater offset (Hz) |

The MD/JSON agreement is mechanically enforced (feature-matrix-doc-drift), so both files move together or the gate stays red.

Four prose spots are not mechanically checked and go stale silently. Proposed wording:

  1. Heading — change seven to five in:

    ## The seven `P` cells, by provenance
    

    The checker's own split confirms it: P/inherited-default drops from 6 to 4, leaving fm/repeater-dir and fm/repeater-offset on ANAN and Demo; P/cached-readback (rx/filter/rtl) is unchanged at 1.

  2. Provenance table, inherited default row, cells column — `fm/repeater-dir` and `fm/repeater-offset` on HL2, ANAN and Demo (6) → ...on ANAN and Demo (4). And in the same row's "what it is" column, replace the HL2 sentence:

    Nobody decided to offer it. On the ANAN it is a repeater duplex control on a radio with no transmitter. The HL2 carried the same two cells until it made the assignment this column prescribes; both are now H.

  3. The substantive one. The paragraph under "Reading a field's value as the discriminator..." uses the HL2 as its worked example of the D/P split — Legacy assigned explicitly so the tone verbs are D, hasFmRepeaterOffset never mentioned so the repeater rows are P, "two adjacent rows, opposite verdicts". After fix(hl2): withdraw the FM repeater-duplex and CTCSS-encode controls the HL2 has no verb for, and declare the DSB/CWU/CWL its demodulator already serves #5879 the HL2 has neither half and the example is simply false. It transplants to the Demo backend without any other change: SimBackend::capabilities assigns fmTonePresentation = FmTonePresentation::Legacy explicitly and never mentions hasFmRepeaterOffset, so Demo still reads D on the tone rows and P on the repeater rows. Proposed:

    turns the tone controls on; the Demo backend assigns Legacy explicitly, so it asked, and its unimplemented tone verbs are D. hasFmRepeaterOffset defaults to true and the Demo backend never mentions it, so it never asked, and the repeater rows one line below are P. Two adjacent rows, opposite verdicts, and only provenance separates them. The HL2 held both halves of this example until it declared Hidden and false outright; all six of its FM rows are now H.

  4. The unfiled-findings table, row 8 (AnanBackend) — "One line fixes it; the same shape holds on HL2 and Demo." → "One line fixes it; the same shape still holds on Demo."

With all of the above applied to the combined tree, --strict exits 0 and reports 0 disagreement(s) with the source. Verified locally; no build was run, this is Python and docs only.

Separately, and not for this PR: a gate whose only lane is pull_request cannot see a two-PR interaction until a third PR pays for it. If check_radio_feature_matrix.py --strict is worth blocking merges on, it is probably worth a push: main lane too. Happy to open that as its own issue if you want it.

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue fit

No linked issue. This is a documentation register plus a source-derived checker: no production code, no protocol surface, no settings key. Reviewed against the PR's own intent — where an operator click ends, per control per radio, generated from the tree and diffed in CI.

Scope

file change claimed? verdict
docs/architecture/radio-feature-matrix.md the register yes in scope
docs/architecture/radio-feature-matrix.json sidecar the checker diffs yes in scope
tools/check_radio_feature_matrix.py generator + --strict differ yes in scope
.github/workflows/static-checks.yml one step on the existing job yes in scope
docs/architecture/README.md one index bullet yes in scope

No production edits, no CHANGELOG.md entry, no new test target, no socket peer. The whole diff is additions.

Blocker

The head is consistent with its base 7c8fdf84. It is not consistent with current main (9f81dc00, 14 commits ahead of that base, clean textual merge).

I merged origin/main into this head locally and ran tools/check_radio_feature_matrix.py --strict. Exit 1, two drifts, nothing else:

  1. rx/rf-gain / ANAN is committed D and derives W. AnanBackend::setPanRfGain (landed in #5820) drives the G2 step attenuator through P2Client::setStepAttenuationDb.
  2. dsp/noise-blanker / ANAN is committed H and derives W. capabilities() now sets hasHostNoiseBlanker = true, and setSliceNoiseBlanker forwards into AnanRxDsp::setNoiseBlanker (#5824). The row's gate is the OR of hasRadioSideDsp and hasHostNoiseBlanker, so the hide rung no longer applies.

setStepAttenuationDb is a third Q_INVOKABLE live setter on P2Client. The ANAN section's "two live setters, and everything else at connect time" is false on today's tree, and RF gain is one of the things that is no longer connect-time-only.

This PR adds that checker as a step of the required Static checks job. Squash-merging it onto current main would put a register on main that fails its own gate. Rebase onto current main, re-run --strict, and commit the cells it derives in both the markdown table and the JSON sidecar. Update the ANAN dispatch paragraph in the same pass so it names the noise blanker on the DSP path and the step attenuator on the live wire path.

What held on the reviewed head

--strict on 694472bf is clean: 61 features × 6 backends = 366 cells, W=143 D=94 P=7 R=22 H=84 U=9 V=7, matching the PR body. P split is 6 inherited-default repeater cells and 1 cached-readback (rx/filter / RTL).

Scratch mutations, then restored (HEAD still 694472bf, --strict green again):

  • Replacing the only audio_pan= token in SliceModel.cpp failed with feature-matrix-stale-alt-path and a drift of rx/audio-pan / Flex from W to D. Flex does not override setSliceAudioPan, so the new RETIRED path correctly called this rot.
  • Removing filt from SliceModel.cpp while FlexBackend::setSliceFilter still sends it produced one feature-matrix-alt-path-retired notice and exit 0. That is the split added in 694472bf, and it behaved as the comment describes.

Spot checks the checker does not own, read from this head and still true on origin/main where I re-read them: setXitOffset aliases setRitOffset; applyMemoryRecallDetails calls setSliceFmRepeater and returns true; validCoverage() refuses minimumHz <= 0 and Flex and ANAN declare {…, 0, 0}; ModelRadioConnectionTarget::supports() admits sim, rtl, and LAN flex/hl2/anan; RTL m_filterLowHz / m_filterHighHz are stored and never loaded; hasFmRepeaterOffset defaults to true. Parsed override counts match the prose: Flex 21, Icom 55, RTL 20, ANAN 15. finishRateChange still sends setDdcRateLive three times (immediate, then 60 ms and 140 ms).

CI on 694472bf is success for build, check-macos, check-windows, Static checks, and Sanitizer option configures. All four commits are GitHub-verified.

Nits, not this review's blocker

The open thread on "Read H as done" versus the #5262 M3a ruling (hide sites are M3b backlog) is a framing call. It does not fail the checker. The roster hole is already written down in the document; I am not asking for that generator in this PR.

The seven V citations are format-checked only. The bench records are not in this repo, so I did not confirm the runs exist.

Recommendation

Request changes. Do not merge until the two ANAN cells, and the dispatch paragraph that still says only two live setters, are regenerated against current main.

| `rx/pan-center` | W* | W | W | W | W | W | drag the spectrum or waterfall (pan centre) |
| `rx/pan-bandwidth` | W* | W | V | W | W | D | zoom / span |
| `rx/pan-framerate` | W* | D | W | W | W | D | waterfall / spectrum frame rate |
| `rx/rf-gain` | W* | W | V | D | W | D | RF gain slider (ANT panel) |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On current main this ANAN cell is W, not D. AnanBackend::setPanRfGain (AnanBackend.cpp on 9f81dc00) writes the G2 step attenuator through P2Client::setStepAttenuationDb (#5820). A local merge of origin/main into this head makes --strict fail with rx/rf-gain/anan is committed as D and the source derives W.

The sidecar has the same cell at radio-feature-matrix.json ("anan": "D" under rx/rf-gain). Both have to move together; the checker diffs them.

Suggested change
| `rx/rf-gain` | W* | W | V | D | W | D | RF gain slider (ANT panel) |
| `rx/rf-gain` | W* | W | V | W | W | D | RF gain slider (ANT panel) |

| feature | Flex | Icom | HL2 | ANAN | RTL | Demo | control |
|---|:--:|:--:|:--:|:--:|:--:|:--:|---|
| `dsp/noise-reduction` | W* | W | H | H | H | H | NR button and level |
| `dsp/noise-blanker` | W* | W | W | H | H | H | NB button and level |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On current main this ANAN cell is W, not H. AnanBackend::capabilities() sets hasHostNoiseBlanker = true, and setSliceNoiseBlanker forwards into AnanRxDsp::setNoiseBlanker (#5824). This row gates on hasRadioSideDsp || hasHostNoiseBlanker, so the hide rung no longer applies and the override scores W. --strict after merging origin/main reports exactly that drift.

Update the JSON cell ("anan": "H" under dsp/noise-blanker) in the same edit. The note on that record still correctly describes the OR; it does not need to keep ANAN hidden.

Suggested change
| `dsp/noise-blanker` | W* | W | W | H | H | H | NB button and level |
| `dsp/noise-blanker` | W* | W | W | W | H | H | NB button and level |


### ANAN (P2) — two live setters, and everything else at connect time

`P2Client` exposes **two** `Q_INVOKABLE` live setters, `setDdc0FrequencyHz` and

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This count is stale against the tree the two cell fixes have to be regenerated on. P2Client on origin/main has a third Q_INVOKABLE live setter, setStepAttenuationDb (P2Client.h), and setPanRfGain calls it on the running session. "Everything else the radio is told is a connect-time P2Client::Params field" is no longer true for RF gain, and the DSP bullet below omits the noise blanker, which now reaches AnanRxDsp::setNoiseBlanker.

setDdcRateLive from finishRateChange (immediate plus the 60 ms and 140 ms resends) is still accurate. Please extend this paragraph when you rebase, rather than leaving the heading at two setters.

on8st and others added 5 commits September 24, 2026 08:17
…r radio

A support matrix normally answers "did the backend declare it" or "is there a
control on screen". Neither is the question an operator has, and neither can
tell a working control from a dead one: the widget moves, the model commits, a
readback confirms the value, nothing logs and nothing signals.

This adds the third artefact — 61 operator controls x 6 radio families, scored
by WHERE THE CLICK ENDS — plus the four dispatch traces behind it (Flex, Icom
CI-V, ANAN P2, RTL-SDR), a JSON sidecar, and a checker that keeps it true.

TWO TABLES, TWO DERIVATIONS, and that split is the substantive finding. The
test that decides is one question: does anything refuse when the declaration is
absent?

  * In the GUI, nothing does. Defaults are permissive (hasFmRepeaterOffset is
    declared = true), the gates are hand-written and optional, and the one
    registry built to enforce them has no callers. So GUI cells are derived
    from REACHABILITY and a declaration is only a cross-check.
  * In aetherd, something does, and it is written into the target:
    ModelReceiveControlTarget opens every admission with `caps.<record> &&
    known(authority)` and answers capability.unavailable. There the declaration
    IS the contract, so the headless cells are derived from the RECORD.

Deriving the headless cells the GUI way would print a phantom W for every verb
the daemon never exposes, so a feature with no aetherd method gets no headless
cell at all — it is named in prose instead of being scored.

THE CHECKER GENERATES AND DIFFS; it is deliberately not a touch-counter. A gate
that says "edit the matrix when you edit a capability" enforces A change, not
the RIGHT one — the shape tools/audit_colours.py already has, where the
observed behaviour is reasoning about satisfying the counter. A diff cannot be
paid off with a comment. It derives the four questions a parser can settle (is
the capability declared and to what, is the virtual overridden, is the base
Q_UNUSED, is the control gated above the seam) plus two more with teeth: a
runtime-published label list, and whether a stored value is ever read back.

V — verified on real hardware — is NEVER generated. The checker asserts only
that a V carries a run citation and that the generator independently derives W
for the same cell; a V on a cell the source calls dead is either over-claimed
or the generator is broken. Seven V cells, all HL2, all citing bench runs that
answered from the radio's own MAC. d77 and d87 ran against hpsdrsim on loopback
and produce no V however good their numbers are.

U is a legitimate committed value, accepted only where the GENERATOR emitted U.
Nine cells are U, in two honest classes: the setXitOffset alias (correct on an
Icom, which has one shift register; silently wrong on a two-register family),
and applyMemoryRecallDetails, whose base returns TRUE after calling four
no-ops.

WHAT THE MATRIX SAYS TODAY: W=143 V=7 P=7 R=22 H=84 D=94 U=9 over 366 cells.
The 94 D cells are the point of the exercise — live widgets whose intent dies
silently — and they concentrate in three families: squelch and RIT/XIT, which
have no capability field at all; the transmit setpoints on receive-only radios,
where the canTransmit refusal was applied to the keying paths and not to the
setpoints; and slice audio mute/pan on backends that demodulate on this host.

No capability field was added. tools/check_capability_records.py stays at its
frozen 71 and this work does not touch RadioCapabilities.h.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…P grounds

A review of the register's FM rows agreed with the verdicts and corrected the
reasoning behind them, which is the half that would have gone wrong next.

WHAT WAS WRONG. The prose explained HL2's dead FM tone cells by the VALUE of
fmTonePresentation, as though `Legacy` were doing the gating. It is not:
`Hidden` is the safe default and `Legacy` is what turns those controls ON. The
discriminator between D and P is not a field's value at all — it is PROVENANCE:

  * the backend ASKED (it assigned the gating capability itself) and did not
    implement the verb -> a bug in that backend -> D;
  * the backend NEVER ASKED (it does not mention the field) and a permissive
    default offered the control on its behalf -> a gap in the default, one
    assignment repairs it -> P;
  * the gate is effectively false either way -> the operator sees nothing ->
    H, and it does not matter who wrote the false.

The generator already implemented this and only this — PERMISSIVE is reachable
solely from DEFAULT_TRUE, which effective() returns only when capabilities()
carries no assignment for the field. Two adversarial checks now pin it: making
AnanBackend assign `hasFmRepeaterOffset = true`, the SAME permissive value it
was already inheriting, flips both cells P -> D on provenance alone; assigning
`fmTonePresentation = Legacy` flips the four tone cells H -> D. Neither test
changes a value the code did not already have.

So gate_state() now states the rule and names the trap, the vocabulary section
asks the contributor's question outright ("did this backend ask for this
control?"), and the FM row notes in the sidecar carry the same correction.

AND THE SPLIT THAT WAS BEING LOST. P has TWO grounds and they are different
repairs, so a single tally hid the more expensive one:

  * inherited default (6 cells) — fm/repeater-{dir,offset} on HL2, ANAN, Demo.
    Nobody decided to offer these; `c.hasFmRepeaterOffset = false;` repairs each.
  * cached readback (1 cell) — rx/filter on RTL. The backend DID ask: it
    validates, stores, echoes a SliceDelta, and the DDC stores into two atomics
    nothing reads. Real work repairs it, and this is the ground ON8ST's
    "reads back cached state" clause exists for.

The checker now labels each P with its ground and prints the breakdown, and a
new document section names every P cell, its ground and its repair.

ANAN's four FM TONE cells were checked against the source and need no change:
AnanBackend never mentions fmTonePresentation anywhere, its default is Hidden,
and a default the backend never requested is only a phantom when it is
PERMISSIVE. An off default hides the control, the app and the radio agree, and
H is the honest cell. ANAN's P cells are the two repeater rows, which the lab's
own matrix merges with the tone rows into one line — which is why splitting them
changes the answer.

No cell value moved. 366 cells, W=143 V=7 P=7 R=22 H=84 D=94 U=9, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…es not guard

Review feedback on the radio feature register. No cell moves: the checker
reports the same 366 cells, W=143 D=94 P=7 R=22 H=84 U=9 V=7, 0 disagreements.

- ANAN (P2) is no longer "one live setter". P2Client exposes a second
  Q_INVOKABLE live setter, setDdcRateLive, which early-returns unless the
  session is running and otherwise resends the whole DDC-Specific packet;
  AnanBackend::finishRateChange invokes it three times across the settle
  window, and startP2ClientSession is now reached from the connect path
  alone. The rate no longer costs a stop/reconfigure/restart. This is the
  decay class the register exists to stop, in the half of the document the
  checker cannot see.
- rx/dial-lock's note said hasRadioDialLock is read in exactly one file.
  It is read in exactly one file ABOVE THE SEAM, RadioModel.cpp, which is
  the read the D cells rest on; IcomCivBackend.cpp reads it below the seam
  in four places, on the one family whose cell is W.
- ControlAvailabilityRegistry is not "referenced by nothing but itself".
  It has a registered unit test. The claim that survives is no production
  consumer.
- New section: the check guards CELLS, not the ROSTER. main() iterates the
  committed record, so a feature deleted from both the JSON and the table
  passes green, and a new seam verb arriving with a GUI control and no row
  is equally invisible. MIN_CELLS is a vacuity floor, not a roster check:
  27 of today's 61 rows could go before it fires. A roster anchor is named
  as the repair and deliberately not built here.
- MIN_CELLS's own comment said 62 rows; there are 61.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…le VIII.

Found by running the register's own checker against a live migration.
`tools/check_radio_feature_matrix.py --strict` against aethersdr#5907 (aetherd:
migrate receive controls behind the backend seam) exits 1 on two
`feature-matrix-stale-alt-path` errors — `rx/filter`/Flex and
`rx/agc-threshold`/Flex — and both are literally correct: the `'filt '`
and `'agc_threshold='` wire text left `SliceModel::setFilterWidth` and
`SliceModel::setAgcThreshold` for `FlexBackend::setSliceFilter` and
`FlexBackend::setSliceAgc`, below the seam.

The defect is not the two entries. EVERY `alt_path` in this register
documents an above-seam bypass, and that work-stream exists to delete
them — so the check alarms on every SUCCESS of it. After the third time
someone learns to ignore the error, and it is then worthless on the day
it finds real drift.

So the boolean becomes a classification. The question is not "did the
triple break" — it breaks identically either way — but *did the evidence
get weaker, or did a weaker piece of evidence stop being needed?*

  ROTTED   the claim is false and nothing stronger replaced it. Fails,
           and now names which of four conditions refused.
  RETIRED  the bypass was deliberately deleted because the control moved
           BEHIND the seam. Reported as progress; never fails.

RETIRED requires all four: the wire token is gone from the declared file
(a file that still emits it has the bypass and merely names it wrong —
an unrecorded rename); the backend overrides the row's seam verb with a
body that is not a Q_UNUSED no-op; the cell still derives W; and the
token is still emitted from that backend's own source, so the bypass was
absorbed rather than lost. The notice is an edit request and repeats
until the record is deleted and the `*` dropped from the document.

`LIVE` is also now read comment-free, which is a small strengthening the
old predicate did not have: a wire token surviving only in a FlexAPI
comment no longer keeps the claim alive. All forty records pass either
reading today.

One case this cannot separate, and it says so rather than pretending:
where a backend ALREADY overrode the verb and ALREADY carried the same
wire token below the seam — three records, rx/filter, rx/agc-threshold
and rx/pan-center on Flex — mangling the above-seam literal yields a
tree indistinguishable from the migration, because in every respect this
register scores it IS the same tree. The other 37 of 40 records are
load-bearing: breaking one moves the cell and `feature-matrix-drift`
fires beside the alt-path error.

Second, smaller defect in the same tool: the U-provenance breakdown was
gated on `if reasons and not errors`, so it disappeared exactly when a
run was failing and you most wanted it — an unexplained U is often the
same parse failure that produced the error above it. Ungated.

Also fixed on the way: condition (d) first used `publishes()`, which
anchors on `\b` for an identifier. A wire literal is not an identifier
— `"filt "` ends in a space, `"agc_threshold="` in an equals sign, and
the next character in the source is `%` — so `\b` never matched and
every relocation was reported as a loss. `emits_wire()` is a plain
comment-free substring.

Against origin/main: 366 cells, W=143 D=94 P=7 R=22 H=84 U=9 V=7, 0 alt
paths retired, 0 disagreements. Against aethersdr#5907's head: identical cells, 2
alt paths retired, 0 disagreements, exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
Rebased onto current `main` (`2859fdef`). The head was clean on its own
base `7c8fdf84`; on today's tree the register's own checker exits 1 with
two drifts, both ANAN, both re-derived from source here rather than
taken from the review:

  rx/rf-gain/anan        D -> W
  dsp/noise-blanker/anan H -> W

`rx/rf-gain` moves because `AnanBackend::setPanRfGain`
(`AnanBackend.cpp:1214`) is now a real override that reaches the wire:
it clamps the slider into -31..0 dB, negates it into 0..31 dB of
attenuation, mirrors it into `m_pendingParams`, and invokes
`P2Client::setStepAttenuationDb` (`P2Client.h:130`, body at
`P2Client.cpp:180`), which resends the High Priority packet immediately
when the session is running. Landed in `22389dbb` (aethersdr#5820).

`dsp/noise-blanker` moves because `AnanBackend::capabilities()` now sets
`hasHostNoiseBlanker = true` (`AnanBackend.cpp:491`) with
`hasRadioSideDsp` still false, and `setSliceNoiseBlanker`
(`AnanBackend.cpp:919`) forwards into `AnanRxDsp::setNoiseBlanker`. The
button's gate is the OR of those two fields
(`VfoWidget.cpp:3334`), so the hide rung no longer applies and the
override scores W. Landed in `41acb26a` (aethersdr#5824).

Both cells are moved in the markdown table and in the JSON sidecar,
which the checker diffs against each other.

The ANAN dispatch section was the half the checker cannot see, and it
had gone false in the same two places. `P2Client` now exposes THREE
`Q_INVOKABLE` live setters, not two — `setStepAttenuationDb` joins
`setDdc0FrequencyHz` and `setDdcRateLive` — so the heading, the count
and the "everything else is a connect-time `P2Client::Params` field"
sentence are corrected, the wire bullet gains RF gain, and the DSP
bullet gains the noise blanker. The `dsp/noise-blanker` note named only
the HL2 for a mechanism the ANAN now shares; it names both.

Against `2859fdef`: 366 cells, W=145 D=93 P=7 R=22 H=83 U=9 V=7, 0 alt
paths retired, 0 disagreements, `--strict` exit 0. The other nine
python gates in the Static checks job are green on this tree too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtHEsQsghFwhUQEZdwVKUz
@on8st
on8st force-pushed the docs/radio-feature-register branch from 694472b to d481e6e Compare September 24, 2026 06:21
@on8st

on8st commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

@jensenpat — rebased and all five parts applied in d481e6ea. --strict is green on current main and the Static checks job passes on the new head.

One thing to flag before the detail: main moved again. You reviewed against 9f81dc00; it is 2859fdef now. So I re-derived both cells from source rather than taking the review's description of them, which is the right habit here anyway.

cell was → is what caused it
rx/rf-gain / ANAN D → W AnanBackend::setPanRfGain clamps −31…0 dB, negates to 0…31 dB of attenuation and invokes P2Client::setStepAttenuationDb, which resends the High Priority packet when m_running && m_socket — #5820
dsp/noise-blanker / ANAN H → W capabilities() sets hasHostNoiseBlanker = true with hasRadioSideDsp = false; setSliceNoiseBlanker forwards to AnanRxDsp::setNoiseBlanker — #5824

Both markdown edits are byte-identical to your suggestion blocks, and both JSON cells moved in the same commit — you are right that they have to move together, the checker diffs them. I also updated the dsp/noise-blanker JSON note, which named only the HL2 for a mechanism the ANAN now shares.

The paragraph at :397 is extended rather than patched: the heading no longer says two setters, the "everything else is a connect-time P2Client::Params field" sentence is gone because RF gain is no longer one, the wire-path bullet names the step attenuator and the DSP bullet names the noise blanker.

--strict: exit 0 on the old base; exit 1 after rebasing, with exactly your two drifts and nothing else; exit 0 after the fix — 366 cells, W=145 D=93 P=7 R=22 H=83 U=9 V=7, 0 disagreements. All ten gates in the job this PR modifies pass locally.

I rebased rather than merged, as you asked. Worth recording why it was safe: upstream touched neither shared file (static-checks.yml and docs/architecture/README.md have zero upstream diff since the base), so the "take ours deletes what upstream added" hazard could not fire. I sha-compared all five files before and after the rebase — identical — and confirmed the branch still adds only those five files against the new base.

I have also corrected the body, which had drifted further than the cells: the tally appeared in six places and the two-setter statement was still there, now crediting your correction. The historical #5892 / #5907 / Corrections from review sections keep the figures those experiments actually produced at base 7c8fdf84, marked as such — their conclusion in each case was no cell moved, and that still holds. Two file line counts in the header table were also stale and are fixed.


A cross-PR hazard you should see, tested rather than reasoned about.

#5879 (fix(hl2): withdraw FM repeater-duplex and CTCSS controls) and this PR are each green on their own and collide on six cells. I merged #5879's head 2b86b040 onto my rebased head in a throwaway worktree — clean textual merge — and ran --strict: exit 1, six drifts, all HL2, all FM.

cell committed → derives
fm/tone-mode / HL2 D → H
fm/tone-tx / HL2 D → H
fm/tone-rx / HL2 D → H
fm/dtcs / HL2 D → H
fm/repeater-dir / HL2 P → H
fm/repeater-offset / HL2 P → H

The cause is #5879 doing exactly what its title says — withdrawing the FM controls the HL2 has no verb for — which makes every gating capability false, so those rows score H (hidden) rather than dead or phantom. That is the register working correctly; the problem is only that whichever of the two merges second leaves main with a register failing its own required gate, and the gate runs on pull_request only, so no push-to-main run catches it.

The fix is one regeneration and it can only be written on the second one to land. I am not proposing to pre-empt it here — say which order you want and I will put it on that PR. (This is the same gap as #5914.)

Not addressed, deliberately: the H-as-done question against the #5262 M3a ruling, which you classed as not this review's blocker and which is waiting on a maintainer; and the roster generator, which you said you were not asking for in this PR. The seven V citations are format-checked only — the bench records are not in this repository, so there is nothing here to verify them against.

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed d481e6ea387b659a38976bce86c6561341921613. The two ANAN cells and dispatch paragraph requested in my prior review are corrected against the new base; the scope remains the stated documentation register and required checker.

File Scope Verdict
radio-feature-matrix.md and .json 61-control register and sidecar In scope; one policy-framing blocker below
check_radio_feature_matrix.py source-derived checker In scope
static-checks.yml and architecture index CI registration and link In scope

Blocker: the vocabulary says H is a good cell and readers should treat every hidden control as done. The maintainer's #5262 M3a rule in docs/style/theme-style-guide.md §4a says individual controls must remain visible in an unavailable state with an announced reason; docs/architecture/radio-capabilities-map.md calls remaining hide sites M3b migration work. The register may accurately record today's hidden state, but it must not present every such site as the completed design. Please distinguish observed H from policy-compliant applet hiding or pending individual-control migration, and update the introductory explanation accordingly. This is the unresolved thread on line 98.

The current GitHub snapshot reports five successful checks on this head. I inspected the complete changed-file inventory and relevant diff; I did not rerun the checker locally after establishing the policy blocker. The seven V run citations are format checked by CI; their external hardware records were not verified here. No new test target or socket peer is added.

I cannot recommend merge while this contradiction, the unresolved thread, and the active change-request gate remain.

Non-blocking follow-up: the checker does not pin the feature roster, so deleting a row from both documents can still pass; a roster assertion would make omissions visible. This is not a merge prerequisite.

on8st and others added 3 commits September 26, 2026 13:52
Main moved 20 commits under the register, including aethersdr#5879, which gave the
HL2 explicit FM capabilities. The six HL2 FM cells it changes are corrected
in the next commit, against the generator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ved. Principle VIII.

aethersdr#5879 declared FmTonePresentation::Hidden and hasFmRepeaterOffset = false
on the HL2, so its four tone rows go D -> H and its two repeater rows P -> H.
The provenance example that used the HL2 now uses the Demo backend, which
still holds both halves, and the inherited-default P count drops to four.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g-M3b M. Principle VI.

The vocabulary said `H` is a good cell and to read it as done. The aethersdr#5262
M3a ruling (theme-style-guide.md §4a) keeps hiding only for a whole applet;
an individual control stays visible, dimmed, with its reason announced, and
radio-capabilities-map.md schedules the remaining hide sites as M3b.

`H` now means an applet hidden wholesale, and `M` a single control gated
away where §4a wants it dimmed with a reason. Every one of the 89 hidden
cells is `M`; no row is hidden by hiding its applet.

The split is authored, because widgets do not yield to a parser. Each
record carries a `hide` block with the granularity, the observed form
(hidden 60, disabled 10, relabelled 11, not-closed 8) and the gate's file
and symbol. The checker fails on a hidden cell with no block, a code that
contradicts its granularity, a site whose symbol has left its file, and a
block left on a row that no longer hides anything. Where the form is not
one §4a names, the cell is `M` and the note says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st

on8st commented Sep 26, 2026

Copy link
Copy Markdown
Contributor Author

@jensenpat, this addresses your 06:24Z review. The head is now 727a5685.

Two caveats first. The PR description still has the old vocabulary line ("H … a good cell") and the old counts. The document is current and the description is not. Also, for two rows the checker's "hidden" is the capability's verdict, not what the operator sees. rx/antenna keeps its selector on screen with invented ANT1/ANT2 (defect 7). dsp/notch-enable keeps the status-bar TNF toggle, which is ungated on purpose. Both are marked M with that reason in the note. I did not rescore them by hand.

Your requests, one by one:

  1. Tell observed H apart from compliant applet hiding and pending migration. Done. H now means an applet hidden wholesale, which Tracking: capability-driven multi-radio migration — landing order M0–M6 #5262 M3a permits. The new code M means one control gated away where §4a wants it visible and dimmed with an announced reason, so it is pending M3b. All 89 hidden cells are M and 0 are H. I traced every gate, and none of the 24 rows is hidden by hiding its applet. By observed form, the 89 are: 60 hidden, 10 disabled with the reason in a tooltip at most, 11 relabelled (the filter row, and XFC wearing REV), and 8 not closed. Each record has an authored hide block with the granularity, the form, the gate's file and symbol, and a reason.
  2. Update the introduction. Done. The intro and vocabulary no longer say hidden means done. They quote §4a and the M3b line from radio-capabilities-map.md. A new section, "Hidden cells: H and M", explains the split. It also says that receive-only transmit controls are M too, because §4a names no transmit exception.
  3. The thread on line 98. Answered in the thread.
  4. Roster assertion (non-blocking). Not taken here. It needs a generator of a different shape, and it is tracked in radio feature register: the roster is not guarded — a deleted or missing feature row passes --strict silently #5901.

check_radio_feature_matrix.py knows the new code. --strict now fails in four cases: a hidden cell with no hide block, a cell code that contradicts its granularity, a site whose symbol has left its file (a symbol that survives only in a comment doesn't count), and a hide block left on a row that no longer hides anything. I checked each rule by mutation: all eight scratch mutations failed with the expected error, and the tree was restored afterwards.

Main had moved 20 commits, so I merged it first. #5879 changes six HL2 FM cells: four go from D to H and two from P to H, and all six are now M. That is in its own commit, ec288f56. After that, --strict gives 366 cells, W=145 D=89 P=5 R=22 H=0 M=89 U=9 V=7, with 0 disagreements. All thirteen stdlib steps of static-checks.yml pass locally. No src/ or test target is touched.

🤖 Generated with Claude Code

…o. Principle VIII.

rx/rf-gain, tx/keying, tx/power and tx/filter were verified on an HL2 that had a
hardware fault (bad joint at AD9866 pin 1, the Tx[5] line: RX gain bit 5 and TX DAC
bits 11/5 stuck high; see the correction comments on aethersdr#5354 and aethersdr#5943). Re-measured
after the repair on 2026-09-27, into a dummy load:

- rx/rf-gain: codes 0..60 rise monotonically; the d103 'fold' was the defect.
- tx/keying: USB, LSB and CW keyed; the tone sits on opposite sides for USB and LSB.
- tx/power: forward power monotone across 0..100 % drive.
- tx/filter: a 200 Hz tone passes at 150/3000 and is stopped at 300/2700.

Cells unchanged (all four stay V); only the citations move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@jensenpat jensenpat left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 95812884a0e9e95a02ae42f55efa3eb22c93283b against current main 68cdf661031fcc3358c768faf259478f416a7c0b.

Issue fit

No closing issue is linked. Documentation additions do not require an RFC under GOVERNANCE.md. The change delivers the claimed 61-control register, JSON sidecar and source checker, but is not ready to merge: the clean current-main merge fails its new CI step, and the checker already misclassifies a supported Icom control on the PR head. The earlier H-as-done objection is addressed by the new H/M distinction; that is not this review's blocker.

Scope

File/group Change Claimed? Verdict
docs/architecture/radio-feature-matrix.md and .json GUI/headless register, evidence and migration classifications Yes In scope; correctness findings below
tools/check_radio_feature_matrix.py Source parsing, derivation and strict drift check Yes In scope; parser finding below
.github/workflows/static-checks.yml New strict checker step Yes In scope; current-main integration fails
docs/architecture/README.md Register index link Yes In scope

No unexplained commit or file, production edit, new capability/settings/protocol surface, CHANGELOG entry or changed test target. No socket-owning test or synthetic peer is introduced. The body's scope checklist holds.

Blockers

  1. [P1] Regenerate the ANAN audio cells against current main before adding the required gate. docs/architecture/radio-feature-matrix.json:501, :512, :540, :551, :579; corresponding Markdown rows at radio-feature-matrix.md:255-257 and :352-353; CI invocation at .github/workflows/static-checks.yml:292.

    The head alone passes --strict with 366 cells and zero disagreements. git merge-tree --write-tree upstream/main HEAD is clean, but an actual scratch merge onto 68cdf661 followed by the same command exits 1, reporting exactly:

    rx/audio-mute/anan: GUI D -> W
    rx/audio-gain/anan: GUI D -> W
    rx/audio-pan/anan: GUI D -> W
    rx/audio-mute/anan: headless R -> W
    rx/audio-gain/anan: headless R -> W
    

    Main's #5992 (0817cec5) implements all three seam verbs and declares engine-owned receiveAudioControl (AnanBackend.cpp:415, :853-884 on the merged tree). This is documented source drift, not an app/build failure. Merge current main, regenerate those five cells in both documents, update the affected ANAN trace/counts and rerun --strict on the exact integration result. Merging the present head would install a failing step in Static checks. The five successful hosted checks were created September 27, before this current-main state; they do not validate this merge.

  2. [P2] Do not treat nested capability construction as an absent capability. tools/check_radio_feature_matrix.py:345-347, docs/architecture/radio-feature-matrix.json:135, docs/architecture/radio-feature-matrix.md:245.

    capability_assignments() only recognizes direct c.FIELD = ... assignments. Icom constructs c.rxFilterControl through subfield assignments and presets.append(...) (IcomCivBackend.cpp:582-594 on the reviewed head), so the parser misses every write. Empirically, the unmodified head reports:

    Icom rxFilterControl assignment: None
    rxFilterControl default: ''
    setSliceFilterPreset override: True
    derive(rx/filter-preset, icom): ('H', 'every gating capability is false for this backend')
    

    That false absence becomes committed M. For known/scope Icom models, the backend publishes FIL1/FIL2/FIL3; RxApplet.cpp:3230-3251 uses those identities and forwards clicks through RadioModel.cpp:4507-4519 to setSliceFilterPreset. Thus the record's explanation that the preset meaning was withdrawn does not describe the profiled Icom column. Recognize nested construction/mutations, evaluate the relevant record shape, and correct the Icom cell with its existing model-profile qualification. Add a socket-free Python regression proving this populated record is not parsed as absent. This is reproduced parser output plus source tracing; no Icom GUI/hardware session was run.

Nits — explicitly non-blocking

  • tools/check_radio_feature_matrix.py:875-881: range extraction drops negative signs. Both {Authority::Engine, -1, 100} and {Authority::Engine, 1, -100} return ENGAGED in a scratch parser probe, although ModelSliceFrequencyTarget rejects them. Pan-range derivation also lacks the ceiling enforced by ModelReceiveControlTarget::validRange. Current cells do not contain those malformed ranges; harden parsing or return unknown for unsupported expressions.
  • tools/check_radio_feature_matrix.py:477-484: the worked provenance example still says HL2 assigns Legacy and inherits repeater support. This head explicitly withdraws those features; the document correctly moved the example to Demo. Update the checker comment too.

What I tried to break

  • Local Python 3.14: exact-head strict check passed, clean current-main integration strict check failed as above; git diff --check passed. The macOS system Python 3.9 cannot evaluate this tool's Python 3.10+ annotations; CI explicitly supplies Python 3.12, so that interpreter mismatch is not a CI defect.
  • Six scratch mutations all failed with their expected diagnostics: uncited V, cited V over a dead cell, missing hide classification, stale hide symbol, H/M mismatch, and Markdown-only cell drift. Restored the scratch files and confirmed the head passes again.
  • Engine-boundary, touchpoint-manifest, capability-record and command-plane gates each passed on both merge base and PR head, with byte-identical output. No baseline or touchpoint manifest grows in this PR.
  • Source-reviewed the H/M policy correction and traced the Icom preset path. No app build, CTest or bridge session: this changes a documentation-analysis tool; demo cannot validate an Icom-specific capability declaration. No hardware was accessed. The seven external V bench records remain unverified here. A separate model-invocable code-review skill was unavailable; an independent parallel source audit was cross-checked against the parser and call sites.

Recommendation

Request changes. Correct the nested-record derivation and refresh the register on current main, then rerun the checker and hosted checks at the final head. The older open threads and active change-request decision also remain merge gates; do not bypass them.

@jensenpat

Copy link
Copy Markdown
Collaborator

Closing as "not filed" at the request of the operator. The register has gone stale three times across ANAN and Icom since the initial PR, and is expected to drift again soon after merging. This approach is withdrawn instead of landing the new required gate. No merge.

@jensenpat jensenpat closed this Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants