Skip to content

DeepFist: colour each decoded letter by the model's own confidence #6099

Closed

Description

@skerker

What

DeepFist text in the CW decode panel is one colour. The model already produces a per-frame probability for every letter it emits; the app discards it. This asks for each DeepFist letter to be coloured by that probability, using the four colours the panel already uses for ggmorse (green / yellow / orange / red).

DeepFist is off by default (ENABLE_DEEPFIST_EXPERIMENT), so this changes nothing in a default build.

Why

On weak or fading CW DeepFist prints wrong letters among the right ones, and the operator cannot tell which is which. That is the trade-off behind the threshold decision in #5953: a lower activity threshold lets weak signals through and more wrong letters with them. Marking the doubtful letters makes that trade-off visible instead of hiding text.

Measured on two off-air W1AW bulletin recordings (machine-sent code, FLEX-8400, 3396 labelled letters), share of letters that were correct, per colour:

Colour (cost = 1 − confidence) Strong take (CER 18.4 %) Weak take (CER 49.8 %)
green (< 0.15) 94.7 % 96.7 %
yellow (< 0.35) 64.1 % 75.6 %
orange (< 0.60) 42.0 % 54.3 %
red (≥ 0.60) 14.8 % 32.6 %

These figures come from a local build and a local replay tool that runs the app's own decoder code over the recordings; they are not from a stock build. The same two recordings were used to choose the scoring method, so a recording not used for that choice is still needed and is planned.

Code references (at main 889bbe8d)

  • src/gui/PanadapterApplet.cpp:902-914 — appendUnscoredCwText(), the DeepFist path, with the comment "DeepFist does not provide a calibrated ggmorse confidence score."
  • src/gui/PanadapterApplet.cpp:825-846 — appendCwText(text, cost), the ggmorse path: the four colour bands on cost.
  • src/models/CwRxModel.h:35 — "Backends without a calibrated cost use unscoredTextDecoded instead."
  • third_party/deepfist/DeepFistCtc.cpp:36 — greedyCtcFrames() reads the per-frame log-probabilities the confidence comes from.
  • docs/deepfist-cw-backend.md:18 ("displayed without inventing confidence") and CHANGELOG.md:111 ("DeepFist invents no confidence"); the doc sentence would be updated.

Proposed change

  • Score per letter = the model's mean probability over that letter's frames, averaged over the overlapping decode windows that saw it before it settles. Cost = 1 − score.
  • Colour with the existing four bands. The sensitivity slider stays disabled for DeepFist: nothing is hidden, only coloured.
  • ggmorse display, callsign spotting and MQTT output are unchanged.

Acceptance criteria

  • DeepFist letters appear in the four existing colours; ggmorse output is unchanged.
  • A default build (experiment off) is unchanged.
  • A socket-free test pins the per-letter score through the committer.
  • The colour table above is reproduced on a recording not used to choose the method.

Non-goals

Relations

— authored by agent (Claude Code) on behalf of @skerker

Activity

  1. self-assigned this
    on Oct 2, 2026
  2. added
    CWCW keying, decode, and operation
    enhancementImprovement to existing feature
    GUIUser interface
    maintainer-reviewRequires maintainer review before any action is taken
    on Oct 2, 2026
  3. aethersdr-agent commented on Oct 2, 2026

    @aethersdr-agent
    Contributor

    Triaged: maintainer-review + enhancement / CW / GUI. The request is coherent and the code references check out at 889bbe8d. Below is what I verified in the tree, the one place in the pipeline where the proposal is harder than it reads, and the parts that are a maintainer decision rather than an implementation detail.

    Where the score is dropped today

    There is no score to colour with — it is discarded at the first hop and there is no slot for it in any of the five hops after that:

    1. third_party/deepfist/DeepFistCtc.cpp:36-61 — greedyCtcFrames() returns CtcFrames{ids, frames, T}. frames[j] is the emission frame (the first frame of the argmax run, because repeats are collapsed at :54), not the run extent, and no probability is kept. So "that letter's frames" does not exist in the return value; only the onset does.
    2. src/core/deepfist/DeepFistStream.cpp:112-120 builds DeepFistCommitter::Token{id, seconds, text} — DeepFistCommitter.h:16 has no score field.
    3. DeepFistCommitter::process() returns a flat QString (tokens plus the idle word separator) — per-character identity is gone.
    4. DeepFistCwModel.cpp:237 → emit textDecoded(const QString&).
    5. CwRxModel.cpp:50 relays it to CwRxBackend::unscoredTextDecoded(const QString&) → MainWindow::appendUnscoredCwText (MainWindow_DigitalModes.cpp:148) → PanadapterApplet::appendUnscoredCwText (:902-914), which paints the whole chunk color.text.primary.

    The non-trivial part: the cross-window average

    The app does enable carry (DeepFistCwModel.cpp:33, carryPending = true; the Parameters default at DeepFistStream.h:19 is false), so m_pending is live and "averaged over the overlapping decode windows that saw it" has a mechanism. But it does not currently reach the tokens you want to colour:

    • In the ungated branch of DeepFistCommitter::process, a token that commits on this tick (seconds > m_committed && seconds <= settled) does output += token.text and then hits if (!carry || token.seconds <= std::max(m_committed, settled) || …) { continue; } — it never runs the m_pending match. Its history from the earlier overlapping windows is sitting in m_pending and is dropped on the floor (not carried into next, then swept by the erase_if). So the averaging has to be folded in exactly there: match m_pending first, then publish with (scoreSum + score) / (count + 1).
    • Pending.seen is clamped (std::min(2, …)) so it cannot serve as the sample count — Pending needs its own scoreSum / scoreCount.
    • The gated-carry publish branch (seen >= 2 && seconds > m_committed && end - last <= 1.21) needs the same accumulated score.

    Without that, the score on every committed letter is from one window only, which is a different (and weaker) quantity than the one the measurements were taken with — worth confirming the replay tool scored it the same way.

    Proposed shape

    • Compute the score in DeepFistStream.cpp, not in the vendored file. It already holds m_logits, frames and classes; walk forward from decoded.frames[j] while the argmax stays == decoded.ids[j] and take the mean of exp(row[id]). This is the same reasoning as the existing comment at DeepFistStream.cpp:109-111 (the token-table bound check lives there "because it is vendored"), so third_party/deepfist/ and its "faithful port of DeepFist's own decode.py" framing stay untouched.
    • One caution on the probability claim: the ONNX output is named log_probs (DeepFistModel.cpp:157) and DeepFistCtc.h documents the buffer as log-probabilities, but infer() hands it back in a vector called logits and nothing normalises it. exp() of it is a probability assuming log-softmax; a test should pin that a frame row sums to ≈1 so a future model export can't silently turn the cost into garbage rather than quietly mis-colouring.
    • Also add score to DeepFistStream::Observation::Token (DeepFistStream.h:38-45) — that is the hook the replay tool needs for acceptance criterion 4.
    • Don't route DeepFist through appendCwText. Three blockers, all in PanadapterApplet.cpp: the deepFistEngineSelected() early return at :826-828; the cost >= m_cwCostThreshold filter at :830, which would hide letters (contrary to the issue's non-goal) and would do it against a stale threshold, since the sensitivity slider is disabled for DeepFist in setCwBackendState(); and emit cwRxTextDisplayed(clean) at :859, which feeds the spotter path wired at MainWindow.cpp:9039. docs/deepfist-cw-backend.md:19 states DeepFist output does not feed automatic callsign spotting, and the issue keeps that unchanged. So: factor the four-band table at :837-842 into a small helper and keep a separate DeepFist append that uses the helper with no filter and no cwRxTextDisplayed.
    • Signal plumbing: add a new per-segment signal (e.g. scoredTextDecoded(QString, QList<float>) or a {text, cost} list) on CwRxBackend/CwRxModel rather than widening textDecoded(text, cost) — that signal's cost is per-chunk and is contractually threshold-filtered on the ggmorse path. Note tokens are not always one character (<SK>) and the committer's word separator carries no score, so a per-letter QList<float> parallel to the string will mis-align; a list of (text, cost) pairs is the safer payload.

    Maintainer decisions, not implementation

    • This reverses a documented stance. docs/deepfist-cw-backend.md:19 ("displayed without inventing confidence") and src/models/CwRxModel.h:35 ("Backends without a calibrated cost use unscoredTextDecoded instead") both say DeepFist deliberately shows no confidence. The issue's table is a reasonable empirical argument that a softmax mean does separate right from wrong letters, but reusing the ggmorse band edges (0.15 / 0.35 / 0.60) means two different quantities share one colour legend. That is the call to make here. CHANGELOG.md:111 is a historical entry — a new line, not a rewrite.
    • Stacking is real and blocking. fix(cw): lower DeepFist's activity-gate threshold to 3 so weak off-air CW is not gated out (#5950). Principle XI. #5953 is still open and unmerged (assigned to @rfoust), and DeepFistCwModel::appParameters() does not exist on main — it is introduced by that PR, so any test written against it only builds on that branch. The measurements here also assume threshold 3, which is fix(cw): lower DeepFist's activity-gate threshold to 3 so weak off-air CW is not gated out (#5950). Principle XI. #5953's value.
    • Verification is local-only. No CI workflow sets ENABLE_DEEPFIST_EXPERIMENT=ON, so neither the changed files nor the DeepFist tests are compiled or run in CI, and they skip without the model present. Acceptance criteria 3 and 4 can only be attested from a local build; criterion 3's socket-free test is a natural extension of tests/deepfist_committer_test.cpp (which already drives process() directly for both carry values) and should pin the averaged score across two overlapping windows including the commit-tick case above.

    No questions for the reporter — the report is specific and self-consistent, and the only gap it names (a recording not used to choose the method) it already names itself.

    🤖 Generated with Claude Code


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


    📍 Citation health check (Foundry Constitution Principle I): 5 resolved, 10 unresolved against /Users/aetherclaude/workspace/AetherSDR at origin/main.

    Unresolved citations (10)
    Citation Reason
    CwRxModel.cpp:50 file not found in workspace
    DeepFistCommitter.h:16 file not found in workspace
    DeepFistCwModel.cpp:237 file not found in workspace
    DeepFistCwModel.cpp:33 file not found in workspace
    DeepFistModel.cpp:157 file not found in workspace
    DeepFistStream.cpp:109-111 file not found in workspace
    DeepFistStream.h:19 file not found in workspace
    DeepFistStream.h:38-45 file not found in workspace
    MainWindow.cpp:9039 file not found in workspace
    MainWindow_DigitalModes.cpp:148 file not found in workspace
  4. aethersdr-agent commented on Oct 2, 2026

    @aethersdr-agent
    Contributor

    Antares Detector — candidate vulnerable file(s)

    • src/models/DeepFistCwModel.cpp

    The DeepFist decoder implementation decodes a QByteArray and returns a QByteArray (DeepFistCwModel::decode). This is the vulnerable sink for untrusted decoded input (CW codes) and requires validation/sanitization before use. The implementation is responsible for ensuring decoded input is safe to process.

    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: $2.9784 · model: claude-opus-5

  5. removed their assignment
    on Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    CWCW keying, decode, and operationGUIUser interfaceenhancementImprovement to existing featuremaintainer-reviewRequires maintainer review before any action is taken

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions