Repository navigation
DeepFist: colour each decoded letter by the model's own confidence #6099
Description
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") andCHANGELOG.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
- No sensitivity filtering for DeepFist.
- No claim for hand-sent code; the measurements are machine-sent W1AW bulletins.
- No change to DeepFist's release status; that is with RFC: DeepCW neural CW decoder as an optional second CW backend #4817.
Relations
- The PR for this will be stacked on fix(cw): lower DeepFist's activity-gate threshold to 3 so weak off-air CW is not gated out (#5950). Principle XI. #5953 (activity threshold, Refs DeepFist drops most of the text on off-air CW: activity gate closed on 66–72 % of ticks (two W1AW bulletins) #5950) and stays a draft until that is decided; the measurements above use threshold 3.
- Add optional DeepFist CW receive backend #5716 added the backend. Whether DeepFist leaves experimental status is a maintainer decision on RFC RFC: DeepCW neural CW decoder as an optional second CW backend #4817, where the model's hosting and licence are the open items; fix(cw): lower DeepFist's activity-gate threshold to 3 so weak off-air CW is not gated out (#5950). Principle XI. #5953 and this issue are the decode-quality work mentioned there.
- No existing issue covers this (searched open and closed for DeepFist confidence / colour / per-letter / per-character).
— authored by agent (Claude Code) on behalf of @skerker
Activity
aethersdr-agent commentedon Oct 2, 2026
Triaged:
maintainer-review+enhancement/CW/GUI. The request is coherent and the code references check out at889bbe8d. 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:
third_party/deepfist/DeepFistCtc.cpp:36-61—greedyCtcFrames()returnsCtcFrames{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.src/core/deepfist/DeepFistStream.cpp:112-120buildsDeepFistCommitter::Token{id, seconds, text}—DeepFistCommitter.h:16has no score field.DeepFistCommitter::process()returns a flatQString(tokens plus the idle word separator) — per-character identity is gone.DeepFistCwModel.cpp:237→emit textDecoded(const QString&).CwRxModel.cpp:50relays it toCwRxBackend::unscoredTextDecoded(const QString&)→MainWindow::appendUnscoredCwText(MainWindow_DigitalModes.cpp:148) →PanadapterApplet::appendUnscoredCwText(:902-914), which paints the whole chunkcolor.text.primary.The non-trivial part: the cross-window average
The app does enable carry (
DeepFistCwModel.cpp:33,carryPending = true; theParametersdefault atDeepFistStream.h:19isfalse), som_pendingis 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:DeepFistCommitter::process, a token that commits on this tick (seconds > m_committed && seconds <= settled) doesoutput += token.textand then hitsif (!carry || token.seconds <= std::max(m_committed, settled) || …) { continue; }— it never runs them_pendingmatch. Its history from the earlier overlapping windows is sitting inm_pendingand is dropped on the floor (not carried intonext, then swept by theerase_if). So the averaging has to be folded in exactly there: matchm_pendingfirst, then publish with(scoreSum + score) / (count + 1).Pending.seenis clamped (std::min(2, …)) so it cannot serve as the sample count —Pendingneeds its ownscoreSum/scoreCount.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
DeepFistStream.cpp, not in the vendored file. It already holdsm_logits,framesandclasses; walk forward fromdecoded.frames[j]while the argmax stays== decoded.ids[j]and take the mean ofexp(row[id]). This is the same reasoning as the existing comment atDeepFistStream.cpp:109-111(the token-table bound check lives there "because it is vendored"), sothird_party/deepfist/and its "faithful port of DeepFist's own decode.py" framing stay untouched.log_probs(DeepFistModel.cpp:157) andDeepFistCtc.hdocuments the buffer as log-probabilities, butinfer()hands it back in a vector calledlogitsand 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.scoretoDeepFistStream::Observation::Token(DeepFistStream.h:38-45) — that is the hook the replay tool needs for acceptance criterion 4.appendCwText. Three blockers, all inPanadapterApplet.cpp: thedeepFistEngineSelected()early return at:826-828; thecost >= m_cwCostThresholdfilter 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 insetCwBackendState(); andemit cwRxTextDisplayed(clean)at:859, which feeds the spotter path wired atMainWindow.cpp:9039.docs/deepfist-cw-backend.md:19states DeepFist output does not feed automatic callsign spotting, and the issue keeps that unchanged. So: factor the four-band table at:837-842into a small helper and keep a separate DeepFist append that uses the helper with no filter and nocwRxTextDisplayed.scoredTextDecoded(QString, QList<float>)or a{text, cost}list) onCwRxBackend/CwRxModelrather than wideningtextDecoded(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-letterQList<float>parallel to the string will mis-align; a list of (text, cost) pairs is the safer payload.Maintainer decisions, not implementation
docs/deepfist-cw-backend.md:19("displayed without inventing confidence") andsrc/models/CwRxModel.h:35("Backends without a calibrated cost useunscoredTextDecodedinstead") 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:111is a historical entry — a new line, not a rewrite.DeepFistCwModel::appParameters()does not exist onmain— 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.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 oftests/deepfist_committer_test.cpp(which already drivesprocess()directly for bothcarryvalues) 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/AetherSDRat origin/main.Unresolved citations (10)
CwRxModel.cpp:50DeepFistCommitter.h:16DeepFistCwModel.cpp:237DeepFistCwModel.cpp:33DeepFistModel.cpp:157DeepFistStream.cpp:109-111DeepFistStream.h:19DeepFistStream.h:38-45MainWindow.cpp:9039MainWindow_DigitalModes.cpp:148aethersdr-agent commentedon Oct 2, 2026
Antares Detector — candidate vulnerable file(s)
src/models/DeepFistCwModel.cppThe 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
fix(cw): a repeated DeepFist letter keeps its own confidence (aethers…
fix(cw): DeepFist letter colours come from theme tokens, readable in …
feat(cw): colour DeepFist letters by the model's per-letter confidence (