Skip to content

refactor(aetherd): #4070 — typed MeterDef payload + shared FlexKvCarry decode helpers#4075

Merged
ten9876 merged 2 commits into
mainfrom
aetherd-2.3-typed-payloads
Jul 6, 2026
Merged

refactor(aetherd): #4070 — typed MeterDef payload + shared FlexKvCarry decode helpers#4075
ten9876 merged 2 commits into
mainfrom
aetherd-2.3-typed-payloads

Conversation

@ten9876

@ten9876 ten9876 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #4070 — the seam-consistency follow-up from the RFC 2.3 mixed-model series. Behavior-neutral (the three decode tests pin the exact wire→output mapping).

Typed meter payload

MeterDef moves from models/MeterModel.h to core/backends/MeterDef.h (pure data), so IRadioBackend carries it directly: meterDefined(const MeterDef&) instead of the stringly-keyed QVariantMap. FlexBackend::decodeMeterStatus builds the MeterDef; RadioModel hands it straight to defineMeter() — the ~15-line key-string reconstruction is gone, and a field-name typo is now a compile error. MeterModel.h re-includes the core header so its existing users are unaffected.

Shared decode carriers

New core/backends/flex/FlexKvCarry.h single-sources the present-only + fail-closed parse contract (carryStr/Bool/Int/Clamp/Real for optional delta fields; carryInto/IntInto/RealInto for plain struct fields; splitList). The slice (oStr/oInt/…), transmit (tBool/tInt/…), and meter (inline guards) decoders now all route through it — resolving the "3rd/4th re-roll of the same guard" that ten9876 flagged across #4066/#4068/#4071. A future tweak (base-0 parse, whitespace trim) can no longer be applied to one copy and silently skipped in the others. FlexBackend.cpp net-shrinks ~90 lines.

Deliberately NOT done (documented decisions)

  • panState/panWnb stay on the generic extensionStatus(ns, kind, QVariantMap) channel — that channel exists precisely to keep Flex-specific status off the vendor-neutral typed interface; typing them would invert that design.
  • TransmitDelta/SliceDelta kept flat (not per-plane sub-structs — ten9876's feat(aetherd): 2.3 — complete TransmitModel conversion (typed TransmitDelta, last mixed model) #4071 option): the flat structs are verified/tested, and the assign() helper + these shared carriers already address the maintainability concern; a sub-struct rewrite of two just-merged deltas isn't worth the churn. Revisitable if a 6th touchpoint ever copies the pattern.

Gates

  • qRegisterMetaType<MeterDef>() added at startup alongside Slice/TransmitDelta.
  • aetherd_meter_decode_test asserts on the typed MeterDef (incl. the malformed-drop guard → default).
  • 69/69 tests green, engine-boundary --strict green, full app builds clean.

Refs #3849. Concludes the RFC 2.3 mixed-model split seam cleanup — the IRadioBackend seam now carries pan/meter/slice/transmit with compiler-checked typed payloads and one shared decode-helper set.

🤖 Generated with Claude Code

…y decode helpers

Closes the seam-consistency follow-up from the 2.3 series (#4070). Behavior-neutral
(the three decode tests pin the exact wire→output mapping).

Typed meter payload:
- MeterDef moves from models/MeterModel.h to core/backends/MeterDef.h (pure data),
  so IRadioBackend can carry it directly: meterDefined(const MeterDef&) instead of
  the stringly-keyed QVariantMap. FlexBackend::decodeMeterStatus builds the MeterDef;
  RadioModel hands it straight to defineMeter() — the ~15-line key-string
  reconstruction is gone, and a field-name typo is now a compile error.
  MeterModel.h re-includes the core header so its existing users are unaffected.

Shared decode carriers (ten9876's #4066/#4068/#4071 cleanup):
- New core/backends/flex/FlexKvCarry.h single-sources the present-only + fail-closed
  parse contract (carryStr/Bool/Int/Clamp/Real for optional delta fields;
  carryInto/IntInto/RealInto for plain struct fields; splitList). The slice
  (oStr/oInt/…), transmit (tBool/tInt/…), and meter (inline guards) decoders now all
  route through it — a future tweak (base-0 parse, whitespace trim) can't be applied
  to one copy and skipped in the others.

Deliberately NOT done (documented decisions):
- panState/panWnb stay on the generic extensionStatus(ns, kind, QVariantMap) channel
  — that channel exists precisely to keep Flex-specific status OFF the vendor-neutral
  typed interface; typing them would invert that design.
- TransmitDelta/SliceDelta kept flat (not per-plane sub-structs, ten9876's #4071
  option): the flat structs are verified/tested, and the assign() helper + these
  shared carriers already address the maintainability concern; a sub-struct rewrite
  of two just-merged deltas isn't worth the churn.

- qRegisterMetaType<MeterDef>() added alongside Slice/TransmitDelta at startup.
- aetherd_meter_decode_test asserts on the typed MeterDef (incl. the malformed-drop
  guard → default).

69/69 tests green; boundary --strict green; full app builds clean.

Refs #3849. Concludes the RFC 2.3 mixed-model split seam cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ten9876 ten9876 requested review from a team as code owners July 6, 2026 13:07

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code review — high-effort recall pass (8 finder angles → verify)

Verdict: clean, behavior-preserving refactor that lands three earlier review recommendations. meterDefined becomes a typed MeterDef signal (no more stringly-keyed QVariantMap round-trip), the per-decoder carrier lambdas/helpers (oStr/oInt/…, tBool/tInt/…) collapse into one shared FlexKvCarry.h, MeterDef moves to the core layer, and the metatypes are registered at startup. Independent audits confirmed byte-for-byte carrier parity (every guard/clamp/base preserved), the typed MeterDef build is present-or-default identical to the old path, all meterDefined consumers migrated, the MeterDef move resolves cleanly (models→core layering already established), no double-metatype/ODR, and engine-boundary passes. This resolves the #4066 (typed MeterDef), #4071-#3 (shared helpers), and #4071-#2 (qRegisterMetaType at startup) findings.

Four findings inline, all low — quality/test/doc, no runtime bug.

One accuracy note (not inline — the code comment at FlexBackend.cpp:16 correctly scopes the helpers to "slice/transmit/meter"): the PR title/description says the helpers unify pan/slice/meter/transmit, but decodePanState still uses its own carry lambda and did not converge (it carries raw strings into a QVariantMap keyed by the wire key — a map-target shape flexkv doesn't offer). Either add a map-target flexkv::carryRaw and route pan through it, or scope the claim to slice/meter/transmit so the unification boundary is honest — pan's present-only guard remains a 4th independent copy.

Comment thread tests/aetherd_meter_decode_test.cpp Outdated
CHECK(f.contains(QStringLiteral("high"))); // valid field still carried
CHECK(d.name == QStringLiteral("LEVEL"));
CHECK(qFuzzyCompare(d.high, 20.0)); // valid field carried
CHECK(d.low == 0.0); // malformed low dropped → default 0.0

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[test-coverage — low] This no longer verifies the ok-guard it names.

The old test asserted !f.contains("low") / !f.contains("sourceIndex") — a guard-dropped field was literally absent from the QVariantMap, so removing the if (ok) would flip contains() to true and fail the test. The rewrite asserts d.low == 0.0 and d.sourceIndex == 0, but MeterDef's defaults are exactly 0.0/0, and an unguarded "junk".toDouble() / "nope".toInt() also yields 0.0/0 — so the assertion holds whether or not the guard exists. This is the only test covering the meter guard (grep-confirmed), so it's now unpinned: a future deletion of the if (ok) from carryRealInto/carryIntInto ships green. The comment "malformed low dropped → default 0.0" is self-defeating — it asserts the exact value both branches produce.

Root cause: for a plain struct field the fallback (default) equals the parse-failure value, so no plain-field assertion can distinguish guarded from unguarded. To actually pin the contract you'd test at a call site whose default differs from 0 (i.e. the std::optional carriers, where nullopt ≠ 0) — see the companion comment on FlexBackend.cpp about the guard being inert here.

Comment thread src/core/backends/flex/FlexBackend.cpp Outdated
// Build the typed MeterDef directly (#4070). Present-only: a field the
// wire didn't report keeps its MeterDef default. Numeric fields are
// ok-guarded — a malformed present value is dropped, not applied as
// 0/0.0 (a bad low/hi would otherwise collapse a meter's scale).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[doc-accuracy — low] The guard is inert for meter; this comment overstates it.

"a bad low/hi would otherwise collapse a meter's scale" is true for the std::optional slice/transmit carriers (a dropped malformed value leaves the field nullopt, so the model keeps its previous value), but not here: a fresh MeterDef def; is built each iteration with low/high defaulting to 0.0, so a malformed low=junk yields 0.0 with the guard and "junk".toDouble()==0.0 without it — the same collapse either way. The carryRealInto/carryIntInto guard gives real fail-closed protection only at the optional<T> call sites, not at the plain-field MeterDef sites.

Since meter status is a full definition and defineMeter full-replaces, the plain carryInto/unconditional-assign would be equally correct here — consider either dropping the guarded variant's pretense for meter or reworiding the comment to "defensive/consistency only" so a maintainer doesn't trust a protection that isn't there.

Comment thread src/core/backends/flex/FlexKvCarry.h Outdated
}
}

// ---- carriers into plain struct fields (meter's MeterDef) — same present-only

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[cleanup — low] The shared header ships the very duplication its rationale warns against.

Two parallel families with identical present-only + ok-guard bodies, distinguished only by target type: carryStr/carryInt/carryReal/carryBool write std::optional<T>&; carryInto/carryIntInto/carryRealInto write plain T&. The header's own comment says "a future tweak (base-0 parse, whitespace trim) can't be applied to one copy and silently skipped in the others" — yet carryInt and carryIntInto are two copies of the int parse (and carryIntInto already carries a base param carryInt lacks), and likewise for real. Overloading a single carry(kvs, wire, target) on std::optional<T>& vs T& (or one template dispatching to a small assign) collapses both families and makes every call site read uniformly — the Into suffix doesn't signal "plain vs optional" to a reader anyway.

Comment thread src/core/backends/flex/FlexBackend.cpp Outdated
}
return out;
};
// Thin forwarders to the shared flexkv carriers — the present-only + ok-guard

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[cleanup — low] Three call conventions for one helper family in one TU.

Slice keeps these forwarding lambdas (oStr → flexkv::carryStr, …), transmit calls carryBool unqualified via the file-scope using namespace flexkv, and meter qualifies flexkv::carryInto. Transmit already proved the forwarders unnecessary — dropping them (call flexkv::carryStr(kvs, "pan", d.panId) directly) or keeping one convention everywhere would make the contract legible and mean a future carrier-signature change threads through one layer, not two. Relatedly, the local splitList lambda at the slice site is redundant: the using-directive already makes flexkv::splitList callable unqualified, so the lambda is a no-op wrapper that only obscures whether it adds behavior.

@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.

Clean, behavior-neutral refactor — I traced the typed MeterDef path end to end and it holds up: RadioModel is the sole meterDefined consumer so the signal-signature change is safe, defineMeter(const MeterDef&) matches, the qRegisterMetaType<MeterDef>() + Q_DECLARE_METATYPE pair is correct for the queued backend connection, and carryIntInto(..., /*base=*/0) preserves the old base-0 num parse. All seven files are within the stated scope. The shared-carrier consolidation is a genuine improvement and the #4070 "deliberately NOT done" decisions are well reasoned.

I've independently verified the four items you flagged in your own self-review — all four are valid; recap by severity:

Would like fixed before merge

  • tests/aetherd_meter_decode_test.cpp — the malformed-drop test no longer pins the guard it names; a future deletion of if (ok) from the meter path ships green. This is the only meter-guard coverage, and the PR body claims "the three decode tests pin the exact wire→output mapping" — this one now doesn't. Pick a resolution (see inline).

Polish (non-blocking)

  • FlexBackend.cpp:394 — the "collapse a meter's scale" comment overstates a guard that is inert at the plain-field MeterDef sites.
  • FlexBackend.cpp:428 — redundant splitList forwarder lambda + three different call conventions for one helper family in one TU.
  • FlexKvCarry.h — the two parallel carrier families are the duplication the header's own rationale warns against; foldable via overload/template. Design opinion, fine to defer.

Nice work threading the seam cleanup to a compiler-checked payload.


🤖 aethersdr-agent · cost: $5.0689 · model: claude-opus-4-8

Comment thread tests/aetherd_meter_decode_test.cpp Outdated
Comment on lines +84 to +85
CHECK(d.low == 0.0); // malformed low dropped → default 0.0
CHECK(d.sourceIndex == 0); // malformed num dropped → default 0

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.

These two assertions no longer distinguish a guarded from an unguarded parse. MeterDef defaults low/sourceIndex to 0.0/0, and an unguarded "junk".toDouble() / "nope".toInt(&ok,0) also yields 0.0/0 — so both branches produce the same value and the test stays green if carryRealInto/carryIntInto lose their if (ok). The // malformed low dropped → default 0.0 comment is self-defeating: it asserts the exact value both paths produce.

For a plain struct field the parse-failure value equals the default, so no meter-site assertion can pin the fail-closed contract. Two honest options:

  1. Move the guard test to an optional<T> call site (slice/transmit), where a dropped malformed value stays nullopt0 — that's where the guard actually protects, per your FlexBackend.cpp:394 note.
  2. Accept the guard is inert here and reword this to a plain "malformed value → field keeps default" check (drop the "dropped"/guard framing), and let (1) carry the real contract.

Comment thread src/core/backends/flex/FlexBackend.cpp Outdated
// Build the typed MeterDef directly (#4070). Present-only: a field the
// wire didn't report keeps its MeterDef default. Numeric fields are
// ok-guarded — a malformed present value is dropped, not applied as
// 0/0.0 (a bad low/hi would otherwise collapse a meter's scale).

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.

Confirmed: this comment is accurate for the optional<T> slice/transmit carriers (a dropped value stays nullopt, model keeps its previous value) but not here — a fresh MeterDef def; defaults low/high to 0.0, so a malformed low=junk collapses to 0.0 with the guard and "junk".toDouble()==0.0 without it. Since defineMeter full-replaces, an unconditional assign would be equally correct at these plain-field sites. Either reword to "defensive/consistency only" so a maintainer doesn't trust protection that isn't there, or drop the guarded variant for the meter path.

Comment thread src/core/backends/flex/FlexBackend.cpp Outdated
const auto oInt = [&](const char* w, std::optional<int>& f) { flexkv::carryInt(kvs, w, f); };
const auto oReal = [&](const char* w, std::optional<double>& f) { flexkv::carryReal(kvs, w, f); };
const auto oBool = [&](const char* w, std::optional<bool>& f) { flexkv::carryBool(kvs, w, f); };
const auto splitList = [](const QString& raw) { return flexkv::splitList(raw); };

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.

The file-scope using namespace flexkv; already makes splitList callable unqualified, so this forwarder lambda is a no-op wrapper — calling flexkv::splitList (or just splitList) directly is equivalent. More broadly, this TU now uses three conventions for one helper family: slice wraps in forwarding lambdas (oStr/oInt/…), transmit calls carryBool unqualified via the using, and meter qualifies flexkv::carryInto. Transmit already proved the forwarders unnecessary; settling on one convention would keep a future carrier-signature change threading through one layer, not two.

Comment thread src/core/backends/flex/FlexKvCarry.h Outdated
}
}

// ---- carriers into plain struct fields (meter's MeterDef) — same present-only

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.

Non-blocking design note: carryInt/carryIntInto (and carryReal/carryRealInto) are two copies of the same parse distinguished only by optional<T>& vs T& — the exact duplication this header's rationale warns against, and carryIntInto already carries a base param carryInt lacks. Overloading a single carry(kvs, wire, target) on the two target types (or one template dispatching to a small assign) would collapse both families; the Into suffix doesn't signal "plain vs optional" to a reader anyway. Fine to defer, but worth a note before a 5th touchpoint copies it.

…verged, honest meter-guard docs

Resolves ten9876's #4075 findings.

- Consolidate the two carrier families into a single overloaded carry() dispatched
  on target type (std::optional<T>&, plain T&, QVariantMap&), with the guarded
  numeric parse single-sourced in parseInt/parseReal — so the int/real parse is no
  longer two copies (the very duplication the header warned against). carryClamp +
  splitList unchanged.
- Converge pan: decodePanState now routes through the new QVariantMap-target carry()
  instead of its own carry lambda — so the shared helpers genuinely cover
  pan/slice/meter/transmit (the PR's original claim), removing the 4th copy.
- One call convention: dropped slice's forwarder lambdas and its redundant splitList
  lambda; every decoder now calls carry()/splitList() directly (in scope via the
  file-scope `using namespace flexkv`).
- Honesty: reworded the meter decode comment + test to state the ok-guard is
  defensive/consistency-only for a plain MeterDef field (default 0/0.0 == unguarded
  malformed-parse result, full-replace semantics); the guard's real fail-closed
  effect is pinned at the std::optional carry() sites, where the slice/transmit
  decode tests already assert malformed → disengaged.

Behavior-neutral: 69/69 tests green (all four decode tests + pan pass), boundary
--strict green, full app builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ten9876

ten9876 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Review response — pushed 7b070d1

Thanks @ten9876 — all four inline findings + the pan-claim accuracy note resolved:

Carrier duplication (FlexKvCarry.h:60) — the two families collapse into a single overloaded carry() dispatched on target type (std::optional<T>& / plain T& / QVariantMap&), with the guarded numeric parse single-sourced in parseInt/parseReal. The int/real parse is now one copy, not two, and carryIntInto's base param lives on the plain carry(int&, base=10) overload. carryClamp (transmit ranges) + splitList unchanged.

Three call conventions (FlexBackend.cpp:422) — unified. Dropped slice's forwarder lambdas and its redundant splitList lambda; every decoder now calls carry()/splitList() directly via the file-scope using namespace flexkv.

Pan didn't converge (accuracy note) — fixed for real rather than by scoping the claim: decodePanState now routes through the new QVariantMap-target carry() overload, so the shared helpers genuinely cover pan/slice/meter/transmit and pan's present-only guard is no longer a 4th independent copy.

Meter guard inert + self-defeating test (FlexBackend.cpp:394 / test:84) — you're right that for a plain MeterDef field the default 0/0.0 equals the unguarded malformed-parse result (and defineMeter full-replaces), so the guard has no fail-closed effect there and the value assertion couldn't distinguish guarded from unguarded. Reworded both to say defensive/consistency-only for meter, and pointed the actual guard contract at the std::optional carry() sites — where nullopt ≠ 0 and aetherd_slice_decode_test/aetherd_transmit_decode_test already assert malformed → disengaged.

69/69 tests green, boundary --strict green, full app builds clean.

@ten9876 ten9876 merged commit bf49bf4 into main Jul 6, 2026
6 checks passed
@ten9876 ten9876 deleted the aetherd-2.3-typed-payloads branch July 6, 2026 14:29
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.

aetherd 2.3: typed seam payloads + shared decode helpers (pan/meter parity with slice)

1 participant