Skip to content

Fix Flex noise-floor pop after dBm scale release - #6242

Merged
ten9876 merged 2 commits into
aethersdr:mainfrom
jensenpat:aether/6238-fft-release-provenance
Oct 9, 2026
Merged

ten9876 merged 2 commits into
aethersdr:mainfrom
jensenpat:aether/6238-fft-release-provenance

Conversation

@jensenpat

@jensenpat jensenpat commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #6241 (merged as ac2f0ce3b), rebased onto main so the diff holds only this repair. It addresses R7 in #6238: the noise floor popping immediately after release of the right dBm scale.

With FFT Floor Auto OFF, MW AM, normal 2D and default averaging/FPS, shifting the entire right-side range by about 37.55 dB could produce a roughly 27 dB false floor excursion after mouse-up. A frame decoded before release could arrive afterward and prematurely complete the correction guard; subsequent old-wire frames then entered averaging under the new decoder scale. Command acceptance did not identify the aperture of an FFT already in flight.

The repair carries immutable decode aperture/generation with each observation through the existing queued/deferred spectrum feed, then reprojects plausible in-flight apertures before smoothing, floor tracking, waterfall fallback and 3D history. It retains at most 32 candidate ranges for the existing two-second window, including rapid reversals and intermediate min/max combinations. Recorded samples matched a temporary old-min/new-max aperture across all 2,696 bins; mixed UDP fragments did not explain that affine error. The endpoint timing is inferred from those samples, not a new firmware guarantee.

Matching replies preserve frame context. Older decode generations cannot complete an A→B→A request. Cancellation, authoritative reconciliation, RF-gain changes and accepted frequency/span changes discard the comparison history so genuine reception changes retain their levels. Absolute-level producers keep an empty context. The new engine/UI payload contains three scalars, no vendor types or commands, and follows the existing route without another thread.

Before / after results

Case and evidence layer Before After / result Coverage and limit
Held right-scale release, live native Cocoa/Metal Two of ten captured separated releases on #6241 showed approximately ±27 dB floor departures, about 116 ms after release. 20 held releases, both directions: maximum rendered-floor departure 1.53 dB in the first second after release. FLEX-8400M 4.2.18.41174; MW AM; Auto Floor OFF; 2D; average 50, weighted OFF, 25 FPS; midpoint −100 dBm; ±37.55 dB shifts. Measured relative to the last pre-release draw.
Same captured failing frames, production C++ replay Smoothed-bin median departures 26.89 / 27.13 dB; guard completed on the first queued old decode. 1.16 / 0.31 dB; completion waits for the current decode generation and target interpretation. Same vectors, ordering and smoothing inputs. Deterministic replay is separate from hardware/render proof.
Intermediate endpoint aperture, captured-frame C++ replay Omitting partial endpoint candidates left median errors 14.11 / 14.32 dB and falsely completed the transition. Both recorded frames reprojected; residual median errors 0.72 / 0.62 dB and transition stayed pending. Both endpoint combinations covered by socket-free helper and real-widget regressions.
Rapid A→B→A, live decoder/widget pipeline Two-aperture assumptions discard older in-flight candidates; queued older A could retire a newer A request. 10 rapid reversal pairs (20 releases): maximum smoothed-bin median departure 1.94 dB in the first second after each pair. Per-frame CPU/widget measurements. Native draw records paused during this portion, so it is not rendered-trace timing proof.
Late old-wire frame after target appears; matching/rounded reply Correction could be discarded by completion/reconciliation. Target completion retains bounded candidates; old frames still reproject; matching replies preserve the guard. Deterministic production-widget tests; explicit cancellation and expiry also covered.
Genuine RF-gain / frequency change during candidate window Retaining old-scene comparisons could incorrectly reinterpret a real level change. History invalidates; injected −104→−72 dBm RF scene remains −72 dBm. Production bridge also observed gain 0→32→0 and center 1.000→1.050→1.000 MHz. Unit test proves preservation of true levels; live readback proves control convergence. Broader band/preamp bounce remains separate.
Raw VITA decode cross-check, live capture Aperture assumptions were previously ambiguous. 5,446 complete decoded FFTs independently reconstructed from raw pixel samples: zero parity discrepancies above 20 µdB. Five pre-release decodes arrived after release; 50 inputs with >10 dB median error were corrected before averaging. Raw data, identifiers and logs remain local; only aggregate results and images are published.
Regression protection Removing the metadata-aware correction reproduces the ordering failure. 8/8 selected offscreen tests passed; disabling the widget metadata branch caused 21 assertions to fail; removing endpoint candidates restored the recorded errors above. The real-widget test is opt-in, enabled locally. No new socket peer or CI gate additions. Hosted CI is pending.

The raw/native capture used temporary record-only probes around the same repair. Those probes are absent from this PR and the production app. The later RF-scene invalidation hooks have direct production-widget tests and a separate uninstrumented RX bridge run. The complete final production source at c7c94dcebad13b1026eb680baba163ca3378ebaa built successfully with cmake --build build -j22; its eight selected tests passed. A read-only merge-tree check against refreshed main 25de97c3 was clean; integration tests on that merged tree have not been run.

Proof

Per-frame native rendered floor, two captured failing releases → 20 held releases with the repair:

Release-pop before and after

Native after screenshot from our RX-only FLEX-8400M run (static appearance; timing proof is the chart above):

MW AM after the range-release repair

Validation and remaining scope

Selected tests: panadapter_dbm_range_test, spectrum_preview_logic_test, dbm_range_plausibility_test, spectrum_confirmed_geometry_test, noise_floor_auto_adjust_gate_test, pan_frame_guard_test, backend_family_switch_test, rtl_model_acceptance_test. Run with QT_QPA_PLATFORM=offscreen ctest --test-dir build -j22 --no-tests=error and the explicit eight-test selection. The opt-in real-widget cases are not part of the default CI graph.

A stream whose range was never written (its status matches the -130/-40 default) mints a decode generation on its first FFT, so the session's first release is guarded as well. panadapter_dbm_range_test pins this: register a stream, decode, assert the scale is valid(), then check that a real write supersedes it. The test fails without the seed.

On the rebased head (c983a788, Linux/Qt 6.12, with -DAETHER_BUILD_SPECTRUM_GESTURE_TEST=ON), 7/7 tests passed: panadapter_dbm_range_test, spectrum_preview_logic_test, dbm_range_plausibility_test, noise_floor_auto_adjust_gate_test, pan_frame_guard_test, client_display_pan_settings_test and spectrum_confirmed_geometry_test. On the demo radio, arrow clicks converged the model and encoder to -130/-50 and then -130/-60, with encoder generations 2 and 3, 1024 finite bins and transmitting: false.

Engine-boundary findings match the merge-base baseline per file (102 tracked findings, no additions); capability count stays 70; raw command-plane count stays 369 across 32 files. Generated touchpoints, frozen CI gate, bridge documentation and whitespace checks passed. No new vendor include, raw command, capability, thread, setting, bridge verb or transmit behavior was added.

The agent automation bridge drove phaseful native gestures, captured snapshots/screenshots and confirmed positive RX control convergence. All sampled MOX/tune/transmitting states were false; touched slice/pan/filter/step and global line-out state were restored, the owned app stopped, and the shared reservation released. No TX testing was performed.

Refs #6238, #6241 and #5525. The six KN7K display reports remain linked in #6241: #5685, #6219, #5204, #4221, #3970, #2371. This follow-up does not claim to resolve every symptom in those reports. R5/R6 band/Go-to/band-stack and preamp bounce remain open investigation cases. Windows, KN7K's radio/firmware, Auto Floor ON timing and TX recovery are not live-proven here.

Future RadioCert gap: existing gestures can reach this path, but sparse tracedebug polling cannot certify an approximately 100 ms non-event. Bounded per-frame VITA/decode-generation/transition/render correlation, recorded-frame replay and capture-gap handling remain future work in #6238; this PR adds deterministic ordering regressions without introducing that testing feature.

Generated with OpenAI Codex (GPT-6 Astra)

@aethersdr-agent

Copy link
Copy Markdown
Contributor

check-windows failure on c7c94dce is CI infrastructure, not your change

Short version: the Windows job never compiled your code. It died at a third-party dependency setup step, 14 steps before Configure. Nothing about this diff is implicated, and no rebase or code change is needed — the job just needs re-running.

What actually failed

From get_check_runs on c7c94dcebad13b1026eb680baba163ca3378ebaa (run 37568103426):

Check Conclusion
check-windows failure
Static checks success
Sanitizer option configures success
build (Linux) in progress at time of writing
check-macos in progress at time of writing

So this is Windows-only — Linux and macOS hadn't finished yet, and both static-check jobs are green.

Inside check-windows, the failed step is:

Setup hidapi (HID encoder support)   → failure
Setup PortAudio                      → skipped
Configure                            → skipped
Build                                → skipped
...
sccache stats                        → failure

Setup hidapi is .\scripts\setup\setup-hidapi.ps1, wired in at .github/workflows/ci.yml:626. It downloads the hidapi 0.14.0 source tarball from GitHub, SHA256-verifies it, and builds it with CMake + MSVC. It is pure build-environment provisioning — no AetherSDR source is involved, and it runs before Configure. Everything after it, including the entire compile, was skipped.

The trailing sccache stats failure is downstream noise, not a second problem: nothing compiled, so sccache had no stats to report.

One honest caveat: the CI log tool gives me per-step conclusions, not the step's stderr, and I don't have API credentials to pull the raw log. So I can't tell you which sub-step of the script failed — the tarball download, the SHA256 check, the CMake configure, or the hidapi build itself. All four are infrastructure, so the verdict holds either way; it only changes who has to act if it recurs (see below).

Why I'm confident it isn't you

  • The parent commit on this same branch was green. 7725ebe2 (Fix Flex FFT headroom and range reply convergence #6241's commit, which this branch sits on) passed check-windows, check-macos, build and both static jobs — run 37553836896. Same branch, same script, one commit earlier, no problem.
  • main was green hours before. 25de97c3 passed check-windows — run 37553887930.
  • Your diff doesn't touch CI. The changed files are src/core/SpectrumDecodeScale.h, PanadapterStream.{h,cpp}, DbmRangeTransition.h, SpectrumPreviewLogic.h, SpectrumWidget.{h,cpp}, MainWindow_{Session,Wiring}.cpp, RadioModel.{h,cpp}, three tests, tests/tests.cmake and four docs files. No workflow, no setup script, no third_party pinning.

What to do

Re-run the failed job (Actions → run 37568103426 → Re-run failed jobs). An empty commit works too, but a plain re-run is cheaper. No rebase, no code edit.

If it fails at the same step again, it stops being transient and becomes a maintainer item — flag it rather than chasing it, since the cause would be in scripts/setup/setup-hidapi.ps1 or the runner image. Worth knowing for that conversation: hidapi and PortAudio are the only two Windows dependencies not behind actions/cache — Qt, FFTW3, DeepFilterNet3 and sccache all have Restore/Save pairs, but hidapi re-downloads and rebuilds from source on every Windows run. That makes it a standing single point of transient failure. (The script also already carries a CMAKE_POLICY_VERSION_MINIMUM=3.5 workaround for CMake 4.x on windows-latest, so it has drifted with the runner image before.)

One thing this does mean

Because the job died before Configure, MSVC has not yet seen this change at all. A green check-windows re-run is required, not just tidiness, before anyone can say the new headers are clean under cl.exe — SpectrumPreviewLogic.h adds <span> and std::span, and DbmRangeTransition.h adds <limits> and new std::numeric_limits use. Both look fine for C++20, and your cmake --build build -j22 on macOS is good signal, but MSVC is the arm that tends to find the surprises in new DSP/geometry headers. Nothing to pre-emptively fix — just don't read the current red as a verdict on the code, and don't read a future red at Build as the same problem as this one.

No Copilot or reviewer comments have been posted on this PR yet, so there's nothing on that side to correlate with the failure.

Thanks for the unusually careful writeup on this one — the per-layer before/after table with the limits called out explicitly (deterministic replay vs. hardware vs. render proof kept separate) makes this much easier to review than it would otherwise be. The infrastructure flake is on us, not you.


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

@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

Partially. The mechanism named in #6238 R7 — "a frame decoded before release could arrive afterward and prematurely complete the correction guard" — is the right root cause, and the design (immutable per-observation aperture + generation, bounded candidate reprojection ahead of smoothing/floor/waterfall/3D) addresses it correctly for a pan whose stream has already taken at least one range write. It does not apply to a pan whose stream has never had setDbmRange() called, which is a reachable startup state on Flex; see Blocker 1. The PR's own evidence configuration (midpoint −100 dBm, ±37.55 dB) never enters that state, which is consistent with the measurements being clean.

The body is accurate about what it does not claim (R5/R6, Auto Floor ON, Windows, KN7K's firmware). One staleness: "Hosted CI is pending" — all five checks are now green on c7c94dce (build, check-macos, check-windows, Static checks, Sanitizer option configures). Note that ci.yml's ctest steps are all -R-filtered to a handful of unrelated tests, so green CI does not mean panadapter_dbm_range_test ran.

Scope

Files / group What it changes Claimed? Verdict
Commit 7725ebe (19 files: SpectrumPreviewLogic.h, automation-bridge.md, tests.cmake, the clippedPeakRecoveryRange/NoiseFloorReacquisition/reply-convergence work, …) #6241 verbatim, carried as the stacked base Yes — body says "contains its existing 7725ebe2 commit", with rebase-after-merge promised In scope as a stacked base; those findings belong to #6241's review, not here
SpectrumDecodeScale.h, PanadapterStream.{h,cpp} (generation map, setDbmRange return, spectrumReady 4th arg) Per-observation aperture + generation on the decode side Yes In scope
DbmRangeTransition.h (FrameGuard) Bounded candidate history + reprojection Yes In scope
SpectrumWidget.{h,cpp}, MainWindow_Session.cpp, MainWindow_Wiring.cpp Carry the scale through the deferred feed; guard consulted before smoothing; RF-gain / geometry invalidation Yes In scope
RadioModel.{h,cpp} qRegisterMetaType, signal 4th arg with a default so the non-Flex 3-arg emit at RadioModel.cpp:6778 still compiles Yes In scope
docs/architecture/vita49-format.md, touchpoint manifest 240→241 One new header (core/SpectrumDecodeScale.h, tagged universal) Yes In scope; Static checks green confirms the generator agrees, so no manifest staleness
tests/panadapter_dbm_range_test.cpp, tests/spectrum_confirmed_geometry_test.cpp New regressions Yes In scope

No new public surface: no bridge verb, no capability field, no settings key, no protocol verb, no CHANGELOG.md entry. No deleted guard — the legacy DbmRangeTransition::evaluate path is retained, not removed (which is itself the subject of Blocker 1). No sibling call site left behind: SpectrumWidget::updateSpectrum has exactly two production callers (MainWindow_Session.cpp:1826 and :1840) and both pass the scale. Nothing in the diff is unexplained by R7 or by the disclosed #6241 base.

Socket-test disclosure: neither new/modified test opens, binds, listens or connects. panadapter_dbm_range_test drives PanadapterStream::decodeFFT directly through a friend struct (PanadapterDbmRangeTestAccess) with a hand-built 48-byte buffer and a Qt::QueuedConnection to a plain QObject; spectrum_confirmed_geometry_test is an offscreen real-widget test with no transport. No fake radio/firmware peer is added, and none is removed.

Blockers

1. src/core/SpectrumDecodeScale.h:18 — a Flex stream that never took a range write produces generation == 0, so valid() is false and the whole repair is inert; the first drag then falls into the superseded path, which prematurely completes and destroys the frame guard.

        return generation != 0 && dbmRangeLooksPlausible(minDbm, maxDbm);

The chain, all in the head checkout:

  • Generations are assigned only inside PanadapterStream::setDbmRange's applyScale(). registerPanStream (PanadapterStream.cpp:420-425) assigns none.
  • RadioModel.cpp:916-923 calls the stream's setDbmRange only when the model actually changed:
          if (pan->setRange(minDbm, maxDbm)) {
              if (m_panStream)
                  m_panStream->setDbmRange(pan->panStreamId(), pan->minDbm(), pan->maxDbm());
          }
    PanadapterModel defaults to m_minDbm{-130.0f} / m_maxDbm{-40.0f} (PanadapterModel.h:279-280) — the same pair decodeFFT uses as its fallback (PanadapterStream.cpp:974). A Flex pan whose stored range is -130/-40 therefore produces no setRange() change, no stream write, and no generation.
  • The other two candidate seeds don't close it: MainWindow_Wiring.cpp:2727 is inside reacquireNoiseFloorLocksAfterProfileLoad() (profile load only), and restoreClientOwnedDbmRange (:3193-3204) is gated on clientOwnsPanDbmRange() and only writes the model.
  • Meanwhile #6241's seed at MainWindow_Wiring.cpp:3698-3700 calls sw->setEncoderDbmRange(pan->minDbm(), pan->maxDbm()) directly, so the widget has a valid encoder range and arms FrameGuard from it while the stream is still at generation 0. The two halves disagree silently.
  • First release in that state: beginDbmRangeTransition arms the guard, setStreamDbmRange(..., true) assigns generation 1 — but the already-decoded, still-queued pre-release frames carry generation 0. At SpectrumWidget.cpp:8511 decodeScale.valid() is false, so control falls to else if (m_dbmReleaseRebaseUntilMs > 0) and the legacy DbmRangeTransition::evaluate runs. Those frames were decoded at the old aperture, so they match m_bins directly: directMedian ≈ 0, rebasedMedian ≈ 37 dB → newEncodingObserved = true → clearDbmReleaseRebase() with the default forgetFrames = true, which calls m_dbmFrameGuard.clear(). Post-release old-wire frames then arrive with generation 1, FrameGuard::evaluate returns early on m_candidates.isEmpty(), and nothing is corrected. That is R7 reproduced on the first scale release of the session.

Neither new test can see this: panadapter_dbm_range_test calls stream.setDbmRange(kStreamId, -165, -35) before its first fft(), and spectrum_confirmed_geometry_test hands the widget generation 1 directly without going through PanadapterStream.

Also worth tightening: the header comment at SpectrumDecodeScale.h:10 ("An empty scale denotes absolute-level samples with no pixel-range encoding") conflates "absolute-level producer" with "Flex stream not yet configured". Only the first is intended; the second is this defect.

Smallest fix — mint on first use in decodeFFT, still under m_streamMutex, replacing PanadapterStream.cpp:974-975:

        dbmRange = m_dbmRanges.value(streamId, {-130.0f, -40.0f});
        quint64& generation = m_dbmRangeGenerations[streamId];
        if (generation == 0) {
            generation = ++m_nextDbmRangeGeneration;
        }
        decodeScale = {dbmRange.first, dbmRange.second, generation};

unregisterPanStream / clearRegisteredStreams already drop the entry, so teardown is unchanged. Seeding in registerPanStream instead would work equally well. Either way please add the regression: registerPanStream → PanadapterDbmRangeTestAccess::fft() → assert the queued scale is valid() and that a later setDbmRange yields a strictly greater generation. That is a deterministic socket-free seam, so the coverage ask is merge-gating rather than a nit.

2. src/gui/MainWindow_Wiring.cpp:3706-3715 — when the stream rejects a range as a stale echo, the caller pairs the rejected endpoints with the accepted range's generation, leaving the widget's encoder cache describing an aperture the decoder never used.

        SpectrumDecodeScale decodeScale;
        if (auto* pan = m_radioModel.panadapter(applet->panId())) {
            sw->setEncoderYPixels(pan->fftYPixels());
            if (pan->panStreamId() && m_radioModel.panStream()) {
                decodeScale = m_radioModel.panStream()->setDbmRange(
                    pan->panStreamId(), minDbm, maxDbm, waitForEcho);
            }
        }
        sw->setEncoderDbmRange(minDbm, maxDbm, decodeScale.generation);

PanadapterStream::setDbmRange's stale-echo branch (PanadapterStream.cpp:533-543) returns currentScale() — the pending range and its generation — without applying minDbm/maxDbm. The new test documents exactly this: staleEcho = stream.setDbmRange(kStreamId, -165, -35) after a pending -127.5/2.5 asserts staleEcho.generation == requested.generation. At the call site above, the returned generation is then paired with the refused minDbm/maxDbm.

Reachable via the levelChanged handler's fallthrough at MainWindow_Wiring.cpp:4107 (setStreamDbmRange(minDbm, maxDbm); on HandshakeAction::Ignore) while the stream still holds a pending echo. Consequence: m_encoderMinDbm/m_encoderMaxDbm describe a range the decoder refused, so the next beginDbmRangeTransition arms FrameGuard with {m_encoderMinDbm, m_encoderMaxDbm} and the actual old wire aperture is never a candidate — the release pop is uncorrectable for that transition. It also runs prepareForFftScaleChange() (fresh settle window, noise-floor reacquisition) for a decoder change that did not happen, and flexInputFloorLooksClipped() / requestFlexRadioHeadroom() compute rowStepDb from the wrong aperture.

The honest shape is to use what the stream reports rather than what was asked for, e.g. only update the widget when the write was adopted:

        if (decodeScale.valid()) {
            sw->setEncoderDbmRange(decodeScale.minDbm, decodeScale.maxDbm,
                                   decodeScale.generation);
        } else {
            sw->setEncoderDbmRange(minDbm, maxDbm);
        }

I'd rather you pick the shape than take that verbatim — the valid() predicate is itself Blocker 1's subject, and the non-Flex / no-stream arm needs to keep working. Either way, please state in the body which of minDbm/maxDbm and decodeScale is authoritative for the widget cache.

Nits (non-blocking)

  • src/gui/SpectrumWidget.h:556-560 — setRfGain and setFrequencyRangeInternal discard the candidate history, but an antenna change does not: reacquireNoiseFloorLock() → prepareForFftScaleChange() touches neither clearDbmReleaseRebase() nor m_dbmFrameGuard. vita49-format.md lists "RF-gain or accepted RF geometry changes"; #6241's bridge doc for noiseFloorReacquiring lists antenna alongside them. Low probability inside the 2 s window, but the invalidation set should be one list.
  • src/gui/SpectrumWidget.cpp:3133 — setEncoderDbmRange(min, max, 0) reaches setTargetScale({min, max, 0}), which can reset an already-correct m_targetGeneration back to 0 when setStreamDbmRange runs with no pan or no stream (teardown, pan removal). Today the only effect is that the transition completes by timeout instead of on the first target-aperture frame, but a decodeGeneration != 0 guard in setTargetScale would make that explicit.
  • src/gui/SpectrumWidget.cpp:7769 — clearDbmReleaseRebase() runs before the small-nudge "already animating toward this exact target" early return at :7838, so a redundant radio echo discards the history for a geometry change already in flight. Harmless (the earlier call already cleared it) but it means the clear is not strictly "accepted changes only" as vita49-format.md says.
  • MainWindow_Session.cpp:1852-1863 — the other panFeedSpectrumReady consumers (incl. the spectrogram/CNN feed) still receive uncorrected bins. R7 is about the visible floor so this is out of scope; naming it so the next reader doesn't assume the correction is global.
  • The widget-side wiring (clearDbmReleaseRebase(false), preserveFrameTransition, the updateSpectrum branch) is covered only by spectrum_confirmed_geometry_test, which is behind option(AETHER_BUILD_SPECTRUM_GESTURE_TEST ... OFF). The cmake comment at tests/tests.cmake:2209-2212 gives a real reason and the body discloses it, so not a blocker — but it does mean the only default-graph coverage of this PR is panadapter_dbm_range_test, and that test's widget-side assertions live in the opt-in file.

What I tried to break

  • Thread safety of the new generation map. m_dbmRangeGenerations is written in setDbmRange (GUI thread) and read in decodeFFT (network thread); both are inside QMutexLocker lock(&m_streamMutex), and the read is bundled with the m_dbmRanges read in the same critical section, so the aperture and its generation cannot be torn apart. m_nextDbmRangeGeneration is only touched under the same lock. Clean. unregisterPanStream and clearRegisteredStreams both drop the new entry, so a recreated stream can't inherit a stale generation, and the counter stays monotone across recreation.
  • Division by zero / NaN in the reprojection. decodeScale.valid() requires dbmRangeLooksPlausible, which enforces rangeDb >= 10, so decodeSpan can never be 0; errorFor guards both operands with std::isfinite. rowStepDb = (max-min)/(m_encoderYPixels-1) in flexInputFloorLooksClipped/requestFlexRadioHeadroom looked like a divide-by-zero until I read setEncoderYPixels (SpectrumWidget.h:292-296), which refuses yPixels <= 1 and defaults to 700. PanadapterStream::setDbmRange clamps minDbm to kMinSpectrumDbm = -180.0f, exactly the plausibility floor, so the clamp cannot produce a range its own predicate rejects.
  • The queued-signal path. Added a 4th argument to a signal crossing a thread boundary; Q_DECLARE_METATYPE(SpectrumDecodeScale) is present and RadioModel registers it at construction (RadioModel.cpp:2003). The default argument on panFeedSpectrumReady keeps the 3-arg non-Flex emit at RadioModel.cpp:6778 compiling and deliberately yields an empty scale, which routes those backends to the unchanged legacy behaviour — that part is correct, and it's also what made Blocker 1 easy to miss.
  • Whether the tests would pass against the unfixed code. The generation comparison in FrameGuard::evaluate is load-bearing for exactly one assertion — the A→B→A case (frames.arm(middle, first, …) with a gen-1 decode against target gen 3, and spectrum_confirmed_geometry_test's "cannot complete using an earlier A decode generation"). The "a queued pre-release decode cannot complete a new range transition" assertion passes without the generation check too, because the range mismatch alone defeats newEncodingObserved. So the reversal case is the one that proves the new mechanism; the rest would survive a weaker implementation. Worth knowing if you mutate further.
  • Sibling surfaces and dead additions. core/SpectrumDecodeScale.h is referenced by PanadapterStream.h, RadioModel.h and DbmRangeTransition.h, and reaches the touchpoint manifest — not a dead file. Both production updateSpectrum callers pass the scale. pan_frame_guard_test already existed on main (tests.cmake:2234), so the PR body's reference to it is to pre-existing coverage, not an addition.
  • Could a candidate win spuriously and rescale a correct frame? errorFor takes a per-bin median over up to 256 samples and kImprovementDb = 0.75 must be beaten, so a 37 dB wrong aperture is never competitive with the true one. For a small requested shift (a few dB) the candidates sit close together and a genuine scene change of similar magnitude could flip the selection — but the resulting error is bounded by the shift itself, so this is a real-but-minor sharp edge rather than a finding.

Everything above is reasoned from the head checkout at /tmp/aetherclaude/pr-6242. I have no build, no test run and no way to drive the GUI, so nothing here was reproduced at runtime; the two blockers are code-path arguments, and Blocker 1 in particular is the one I'd most like you to disprove with a targeted test before dismissing. I could not check the mutation results, the replay figures, the 5,446-FFT parity cross-check, or the native capture — the raw data is local by design, which is fine, but it does mean the live evidence is unverifiable from here.

Recommendation

Request changes, narrowly. The architecture is right and the diff is genuinely careful — the immutable-per-observation aperture is the correct seam, the candidate expansion over intermediate endpoint combinations is a real insight, and the invalidation hooks for RF-gain/geometry are the right instinct. But Blocker 1 means a Flex pan sitting at the shared -130/-40 default gets none of it and still pops on the first release, and Blocker 2 leaves the widget's encoder cache able to describe an aperture the decoder refused — which disarms the guard for the next transition. Both are small, local changes with a deterministic socket-free test seam.

Next step: seed the generation (or drop the generation != 0 sentinel), decide which of the requested range and the returned scale is authoritative at MainWindow_Wiring.cpp:3714, and add the registerPanStream → fft() → valid() assertion. #6241 must land first and this branch rebased, as the body already commits to; commit 7725ebe's findings belong to that review.

Thanks for the unusually honest limits section — "this PR does not claim to resolve every symptom", the explicit separation of evidence layers, and the note that rendered telemetry describes draw submission rather than scanout all made this much faster to review.


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

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Antares Detector — candidate vulnerable file(s)

  • src/core/dBmRangePlausibility.h

The dbmRangeLooksPlausible() function computes rangeDb = maxDbm - minDbm and then checks bounds. If minDbm is near float::max and maxDbm is finite, the subtraction can underflow/wrap, producing an implausible range that bypasses the intended checks (overflow/out-of-bounds in a bounded range check).

Localized by Cisco Foundation AI Antares-1B running locally in the AetherClaude sandbox, seeded by the Cartographer security map. Advisory only — please verify before acting.


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

NF0T
NF0T previously requested changes Oct 7, 2026

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

Independent review of the R7 commit c7c94dce. 7725ebe2 is #6241's commit and is reviewed there, in #6241's CHANGES_REQUESTED. I formed this before comparing against the @aethersdr-agent review, and I note below where I agree and where I don't.

What's right

  • The diagnosis is correct, and the seam is the right one. Command acceptance says nothing about which aperture an in-flight FFT used. Capturing the aperture and generation under m_streamMutex in decodeFFT, in the same critical section as the m_dbmRanges read, makes them untearable. Carrying them with the observation through the queued signal and deferReceivePresentation is exactly how the provenance survives the thread hop. qRegisterMetaType<SpectrumDecodeScale>() is present, and the defaulted fourth argument keeps the non-Flex 3-arg emit routing to the legacy path.
  • Reprojecting before smoothing, the floor tracker, the waterfall fallback and 3D history is the only placement that keeps a bad frame out of the EMA. Correcting after smoothing could not undo it.
  • The intermediate-endpoint candidates are a real insight. {old.min, new.max} and {new.min, old.max} are what the recorded affine error points at. Retaining the original aperture when the 32-entry cap evicts (removeAt(1)) is the right choice.
  • The A→B→A generation check. newEncodingObserved requires decoded.generation == m_targetGeneration, so an older A decode cannot retire a newer A request.
  • Scene invalidation on RF gain and accepted geometry changes stops a genuine level change from being reprojected away.

Inherited from #6241

This branch still carries if (!sendDbmRangeCommand(minDbm, maxDbm)) { return; } at MainWindow_Wiring.cpp:4320 and :4397, so it inherits the Hermes-Lite 2 dBm-range persistence blocker from my #6241 review. Nothing more is needed here: once #6241 is fixed and this branch is rebased as the body plans, it goes away.

Blocker: a Flex stream that never took a range write decodes with generation 0, and the repair is inert on it

I agree with the bot's Blocker 1 and checked it independently against the head:

  • Generations are minted only in PanadapterStream::setDbmRange()'s applyScale().
  • That stream write is reached from RadioModel.cpp:921 only when pan->setRange() reports a change. It is also reached from profile load (MainWindow_Wiring.cpp:2727), the Kiwi→Flex restore (MainWindow_KiwiSdr.cpp:446) and the wiring lambda. PanadapterModel starts at -130/-40, the same pair decodeFFT falls back to.
  • So a pan whose status is -130/-40 never mints a generation. Every queued FFT then carries valid() == false.
  • I checked a wider trigger and ruled it out: panStreamId() is derived from the pan id, so a stream id arriving late is not a second path.

On the first release in that state, the pre-release frames take the legacy evaluate branch, match the old interpretation and call clearDbmReleaseRebase() with forgetFrames = true. That clears m_dbmFrameGuard before the post-release old-wire frames arrive with generation 1, so R7 reproduces on the session's first scale release.

The new test can't see this because it calls setDbmRange(kStreamId, -165, -35) before its first fft(). Suggested fix inline: mint on first decode, still under the mutex. unregisterPanStream/clearRegisteredStreams already drop the entry. A socket-free regression on the existing friend seam would pin it: registerPanStream → fft() → assert the queued scale is valid(), and that a later setDbmRange yields a strictly greater generation.

Questions (non-blocking for me)

1. Should the widget's encoder cache take the stream's answer rather than the request? The bot's Blocker 2 is real, but I'd scope it narrower.

  • Origin: the request-wins cache write is #6241's. Its setStreamDbmRange already set *encoderDbmRange and setEncoderDbmRange(minDbm, maxDbm) regardless of whether the stream adopted the range.
  • What this commit adds: a generation alongside. setTargetScale() already refuses a mismatched range, so the generation can't mis-target the guard; the stale cache is what then feeds FrameGuard::arm.
  • Reachability: the bot's route is not right. observeRadioRange returns ApplyRadioRange, never Ignore, when inactive. The routes I found are narrow ones where a non-echo write meets a live stream pending range:
    • the profile-load-settling auto-floor branch;
    • a rejected reply whose prior range differs from the model.

Using the returned decodeScale for the cache when it is valid() would close it at the root. Your call whether it goes here or into #6241.

2. Is the recovery-axis ordering in the new accepted-reply branch intended? At MainWindow_Wiring.cpp:3839, guard->setDbmRange() runs before the *encoderRecoveryPending check. applyAuthoritativeDbmRange deliberately does the reverse, so that an encoder-only recovery never moves the presentation axis. I believe it is unreachable today: it needs pan->setRange() to report no change on an accepted reply, and a matching status would already have cleared the handshake. Mirroring the existing order would make it unreachable by construction.

3. Windows. The same question as on #6241: all live evidence is from macOS/Metal. check-windows is now green, so MSVC has compiled the new headers (the bot's 03:48 note predates the re-run). Could the release-pop recipe be run on a Windows host before #5525/#6238 R7 are treated as addressed?

Other review comments

  • Antares advisory on dbmRangeLooksPlausible: refuted. Both inputs must pass std::isfinite. IEEE float subtraction overflows to +inf and does not wrap, which the <= 180 bound rejects, and minDbm >= -180 rejects an extreme minimum on its own. It names dBmRangePlausibility.h (wrong case), and this PR doesn't change that file.
  • Bot nits I agree with: antenna changes don't invalidate the candidate history while RF gain does; setTargetScale can reset a correct target generation to 0 on a no-stream write; the new widget wiring is only covered by the opt-in spectrum_confirmed_geometry_test. The PR discloses the last one.
  • Body staleness: "Hosted CI is pending" — all five checks are now green on c7c94dce.

Scope

Group Claimed Verdict
7725ebe2 (#6241) Yes, stacked base Reviewed on #6241
SpectrumDecodeScale.h, PanadapterStream.{h,cpp} Per-observation aperture/generation In scope
DbmRangeTransition.h FrameGuard Bounded candidate reprojection In scope
SpectrumWidget.{h,cpp}, MainWindow_{Session,Wiring}.cpp Carry and consult the scale; invalidation In scope
RadioModel.{h,cpp} Metatype + signal argument In scope; #5554 gate: no family branch, wire text, cast or capability
docs/architecture/* New universal touchpoint (241), VITA notes In scope; Static checks agree
tests/* New regressions In scope; no socket/peer constructs

Preference vs fix: a fix for the reported #6238 R7 pop. No default or UI behaviour change, and nothing new leaves the machine: this commit adds no radio writes. No CHANGELOG.md entry, which is correct.

What I tried to break

  • Generation 0 at startup. Traced every stream setDbmRange caller. Confirmed above; the widget gets a valid encoder range from #6241's direct seed while the stream has none.
  • Stale-echo pairing. Traced reachability through each handshake exit (match, timeout retire/reconcile, cancel for authority, accepted/rejected reply). Only the narrow routes in Question 1 reach it.
  • Thread tearing. Aperture and generation are read in one locked section, and teardown drops both maps.
  • Division/NaN in the reprojection. valid() implies rangeDb >= 10, and errorFor guards both operands.
  • Candidate eviction under rapid reversals. The original aperture is retained, intermediates are deduplicated and implausible ones are refused.
  • Not run. The automation bridge was unavailable this session and I built nothing, so every finding here is reasoned from code at c7c94dce, not reproduced. I couldn't check the replay figures, the 5,446-FFT parity cross-check or the native captures, since the raw data is local by design. I also didn't verify the bot's claim that only the A→B→A assertion depends on the generation check.

CI: all five checks green. Commits are signed and GitHub-verified. All files are Tier 3.

Verdict: request changes, for the generation-0 seed and its regression test, plus the inherited #6241 blocker, which the planned rebase resolves. The design is sound, and the evidence layering in the body (replay vs. pipeline vs. native draw, each with its limits) is the kind of honesty that makes a change like this reviewable.

73,
Ryan NF0T


👨🏼‍💻 Co-authored by Claude Opus 5.5

Comment thread src/core/backends/flex/PanadapterStream.cpp Outdated
@NF0T NF0T removed their assignment Oct 7, 2026
@jensenpat
jensenpat marked this pull request as draft October 8, 2026 00:06
@jensenpat
jensenpat marked this pull request as ready for review October 8, 2026 02:43
@ten9876 ten9876 self-assigned this Oct 8, 2026
ten9876
ten9876 previously requested changes Oct 8, 2026

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

This is the disclosed R7 follow-up to #6238, stacked on #6241. Carrying the decoder aperture/generation corrects the tested queued-frame/reversal cases, but it misses the first scale transition when the producer has never received a changed dBm range. The full head also retains #6241's already-reviewed HL2 regression. Reviewed c7c94dcebad13b1026eb680baba163ca3378ebaa: request changes.

File/group Claimed change Scope verdict
Inherited commit 7725ebe2 #6241 R1–R4 range/reply repair Explicit stack; prior blocker remains
SpectrumDecodeScale.h, PanadapterStream.cpp/.h Aperture/generation attached to each decoded FFT Direct R7 fix; initial-generation blocker
DbmRangeTransition.h, SpectrumPreviewLogic.h Bounded in-flight aperture candidates Direct fix
SpectrumWidget.cpp/.h, MainWindow session/wiring Correct before smoothing/history and preserve guard Direct fix
RadioModel.cpp/.h Propagate typed observation/metatype In scope
Three changed tests, test registration Producer/helper/real-widget replay coverage In scope; real-widget lane is explicit opt-in
VITA/bridge documentation and touchpoint/tag manifests Describe scale ownership and generation In scope; new universal record, no vendor declassification

The stack and limits are disclosed; no unrelated default, dependency, theme, changelog or extra radio-write feature. This is a partial repair, not closure of every report in #6238.

Blockers

  1. src/core/backends/flex/PanadapterStream.cpp:974–975; src/gui/SpectrumWidget.cpp:8521–8542 — the first default-aperture FFT has generation zero, so a queued frame destroys the new guard. Registration does not seed m_dbmRangeGenerations. decodeFFT() uses a fallback −130/−40 aperture but a default generation of zero. SpectrumDecodeScale::valid() rejects it. This is reachable when the pan's status matches its built-in −130/−40 range: RadioModel.cpp:917 updates the stream only if pan->setRange() changes the model, and initial GUI seeding does not seed the stream.

    The actual socket-free producer seam, with a registered stream and no prior setDbmRange(), returned:

    first FFT aperture=-130,-40 generation=0 valid=0
    FAIL: decoded.valid()
    

    A focused replay using the real production SpectrumWidget then reproduced the consequence: seed physical −104 dBm at that default aperture, release to −100/−10, deliver the queued pre-release generation-zero frame, then an old-wire frame decoded under generation one.

    queued generation=0 pending=0
    physical=-104 corrected=-74.000000
    

    The invalid observation takes the legacy branch and calls clearDbmReleaseRebase() with its default forgetFrames=true, so the next old-wire frame enters at a false level. The same replay with initial generation one keeps pending=1 and corrected=-104; its two checks pass. This is a reproduced production-widget input correction failure, not a claimed offscreen GPU/pixel observation.

    Mint a nonzero generation on first decode under the existing mutex. Replace the current decoder metadata assignment with:

         quint64& generation = m_dbmRangeGenerations[streamId];
         if (generation == 0) {
             generation = ++m_nextDbmRangeGeneration;
         }
         decodeScale = {dbmRange.first, dbmRange.second, generation};

    I compiled this replacement against the producer reproducer: it returned generation=1 valid=1 and passed. Source restored afterwards. Add the register→first FFT→release regression without explicitly calling setDbmRange() before the first observation; the current producer/widget tests preseed nonzero generations and miss this case.

  2. src/gui/MainWindow_Wiring.cpp:4320,4397 — the stack still prevents HL2 dBm gestures from adopting/persisting client-owned range. This is inherited unchanged from #6241, not a new allegation about the generation commit. Both handlers return when the Flex command dispatch fails, before adoptClientOwnedDbmRange(). HL2 declares local range persistence but has no Flex command plane; its radio-scale flag does not route it into the fixed-scale arm.

    The exact full head again fails client_display_pan_settings_test:

    FAIL: the send comes before the mark is read
    1 check(s) failed
    

    That source-order assertion is not the whole evidence: our earlier #6241 review on the identical inherited commit independently probed the real unconnected HL2 backend (clientPersistsDbmRange=1, no command plane, dispatch false) and both handler early returns, and ran this test successfully on the same merge base 30882701a. The source pin and actual capability/dispatch behavior agree. No live HL2/restart claim is made here.

    Preserve client-owned adoption independently of the Flex send result. For both three-line early-return blocks, the minimal replacement from that review is:

         sendDbmRangeCommand(minDbm, maxDbm);

    The following ownership-shaped adoption already refuses non-owners/invalid ranges. Resolve this in #6241 and rebase the stack, or carry the fix here; don't merge the full head while it retains the regression.

Non-blocking notes:

  • src/gui/MainWindow_Wiring.cpp:3706–3715: the stream can return its retained scale when refusing stale endpoints, while the caller caches the requested endpoints. Prefer the returned aperture for the encoder cache. I verified the return contract and caller, but did not reproduce a full profile-settling/rejected-reply GUI sequence. The bot's claimed HandshakeAction::Ignore route is incorrect: inactive observeRadioRange() returns ApplyRadioRange. This is a narrowly scoped hardening/completeness note, not a second independently reproduced release-pop blocker.
  • tests/tests.cmake:2206: preserve the documented -DAETHER_BUILD_SPECTRUM_GESTURE_TEST=ON opt-in lane; do not expand the frozen per-PR gate. The default CI does not execute this production-widget replay.
  • docs/architecture/vita49-format.md:36: Windows/8600, firmware-specific status/reply timing, second owned pan, resize/y_pixels and post-TX stress remain unverified locally. Demo does not establish physical Flex encoder convergence or settle those reports.

Verification/attacks: Built exact-head app and four focused targets. panadapter_dbm_range_test, spectrum_preview_logic_test, dbm_range_plausibility_test passed; the client-display test failed as above. Enabled the opt-in registration and compiled the unchanged real-widget test against the exact app's production desktop objects to avoid duplicate desktop compilation; its CTest passed 1/1. The additional focused producer/widget fixtures failed at the first generation and passed with nonzero seeding. Removing the generation equality from the new FrameGuard made the PR's rapid-reversal assertion fail. All temporary scratch source changes were restored; the three core focused tests passed again.

Drove owned pr-6242-review with fresh isolated settings, explicit socket, offscreen and TX disabled; connected only DEMO-0001, tuned 14.1 MHz, dragged the actual right-hand dBm scale 30 pixels, queried model/trace diagnostics, disconnected/reconnected and exited via Quit. The accepted GUI range converged:

{"refLevelDbm":-29.7727279663,"dynamicRangeDb":90,
 "encoderMinDbm":-119.7727279663,"encoderMaxDbm":-29.7727279663}

The demo retained one owned slice/pan, 1024 finite input bins and transmitting:false. Offscreen renderedTrace.valid was false, so no screenshot/scanout claim is made. The first-generation defect was tested at the real decoder and widget seams; Demo cannot supply the Flex encoded-FFT lag that causes it.

Head/base engine, command-plane, capability, registration, settings, timeout and frozen-gate checks passed without per-file growth; manifests reproduce and colour/accessibility counts did not regress. Hosted required checks passed on an older merge of main. Commits are GitHub-verified. No new socket-owning modified test or synthetic firmware peer. Separate automated-review skill unavailable; existing bot/human leads were independently checked, including rejection of the non-finite advisory because the validator checks both inputs with isfinite.

Recommendation: Request changes. Seed the first decoder generation and protect the default-aperture replay; resolve the inherited HL2 adoption regression and rebase the stack before approval. The valid-generation replay coverage is useful, but it does not yet cover the session's first release.

Final CI recheck: required checks still pass and the reviewed head is unchanged. Current main is 14ef3e78841009d73ddf2e7de22b9a5a83d5ee0a (2026-10-08 08:23:34 UTC), newer than this head's earliest pull-request run creation. With non-strict branch protection, the green checks do not prove current-main integration.

@ten9876 ten9876 assigned ten9876 and unassigned ten9876 Oct 8, 2026
jensenpat and others added 2 commits October 9, 2026 11:20
Carry each observation's decoder aperture and generation through the existing
queued spectrum feed. Reproject in-flight wire apertures before smoothing,
including partial endpoint updates and rapid range reversals. Retain a bounded
candidate history after target encoding first appears; command acceptance does
not identify the aperture of an FFT already in flight.

Discard comparisons when RF gain or accepted frequency geometry changes so real
reception changes retain their levels. Matching replies preserve frame context.

Refs aethersdr#6238 (R7). Follow-up dependent on aethersdr#6241.

Validation: full macOS build, eight selected offscreen tests, mutation checks,
recorded-frame C++ replay, and RX-only FLEX-8400M bridge/native capture. Twenty
held releases stayed within 1.53 dB at the native rendered floor; ten rapid
reversal pairs stayed within 1.94 dB at the smoothed-bin median. These are
separate evidence layers. Raw VITA and logs remain local.
…a valid generation. Principle II.

A pan whose status matches the -130/-40 default never reaches
PanadapterStream::setDbmRange(), so its frames decoded with generation 0,
SpectrumDecodeScale::valid() rejected them, and the first scale release
took the legacy path and cleared the release frame guard. decodeFFT() now
mints a generation on first decode, under the same mutex, and
panadapter_dbm_range_test pins it: register, decode, assert valid, then a
real write supersedes it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ten9876
ten9876 force-pushed the aether/6238-fft-release-provenance branch from c7c94dc to c983a78 Compare October 9, 2026 18:33
@ten9876
ten9876 dismissed stale reviews from NF0T and themself October 9, 2026 18:33

Both requested changes are resolved. (1) Generation 0 on a never-written stream: fixed in c983a78 with your suggested seed in decodeFFT() under the mutex. The new panadapter_dbm_range_test regression (register, decode, valid(), then a strictly greater generation after a real write) fails without the seed (first.valid() at line 202) and passes with it. (2) The inherited HL2 blocker: the branch is now Pat's R7 commit replayed onto main after #6241's squash ac2f0ce, which carries the HL2 adopt fix. client_display_pan_settings_test passes on the rebased head. Your questions 1-3 and the bot nits are answered in the PR conversation; under the maintainer's rule they are non-blocking and are not changed here.

@ten9876

ten9876 commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator

Landing pass at c983a7884. The branch is now Pat's R7 commit replayed onto main after #6241 (ac2f0ce3b), plus one fix.

Blockers, both resolved:

  • Generation 0 on a never-written stream. Fixed in c983a7884 and pinned by a regression test in the thread.
  • The inherited HL2 regression. Gone with the replay; client_display_pan_settings_test passes.

The maintainer accepted the new core/SpectrumDecodeScale.h touchpoint (tagged universal, manifest 250 → 251).

Not blocking, so not changed in this pass (the maintainer's rule is that nits don't hold a landing):

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

Approving at c983a7884.

What changed in this pass:

  • 5f3568d5c: Pat's R7 commit replayed onto main after #6241 (ac2f0ce3b), with six one-hunk conflicts resolved by hand (maintainer choice). This also carries #6241's HL2 adopt fix, so the inherited blocker is gone.
  • c983a7884: a stream with no written range mints a decode generation on its first FFT (NF0T and maintainer-review blocker). Pinned by a new panadapter_dbm_range_test block, which fails without the seed.

Maintainer decisions:

  • replay onto main rather than merge;
  • accept the core/SpectrumDecodeScale.h touchpoint;
  • non-blocking findings are answered in the conversation, not fixed.

Verified:

  • 7/7 focused tests on Linux/Qt 6.12, including the opt-in widget test and client_display_pan_settings_test.
  • Engine-boundary and command-plane findings identical to main; capability records at 70; manifest clean.
  • Demo arrow clicks converged the model and encoder (-130/-50, -130/-60; generations 2 and 3), with transmitting: false.
  • Required CI green on this head, built against current main ac2f0ce3b.

Not verified:

  • live Flex release-pop timing (the demo has no encoded-FFT lag);
  • Windows.

tci_server_review_test was not run (socket-owning).

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.

3 participants