diff --git a/docs/deepfist-cw-backend.md b/docs/deepfist-cw-backend.md index 09b63f4ef2..7359d2284f 100644 --- a/docs/deepfist-cw-backend.md +++ b/docs/deepfist-cw-backend.md @@ -18,7 +18,9 @@ there is no second normalized decoder in the UI. It also lowers the activity-gate threshold from the stream default 12 to 3 so weak off-air CW is not gated out (#5950); that value is what every user of this backend runs. Output has no calibrated -ggmorse cost and is displayed without inventing confidence, pitch or speed. +ggmorse cost. Each letter is coloured by the model's own posterior (cost = +1 − posterior, the four ggmorse colour bands) and is never filtered by the +sensitivity slider; pitch and speed are not invented. DeepFist output does not feed automatic callsign spotting. Other monitored slices and speaker gain/mute do not alter the selected decoder input. This is an audio slice tap, not an RF separation claim. diff --git a/docs/theming/canonical-tokens.md b/docs/theming/canonical-tokens.md index a3a5422c8f..85f7d77808 100644 --- a/docs/theming/canonical-tokens.md +++ b/docs/theming/canonical-tokens.md @@ -140,6 +140,18 @@ themes can preserve contrast without bypassing the theme system. | `color.meter.gainReduction` | `#f2c14e` | GR meter bar | | `color.meter.bar.fill` | `#405060` | meter bar inactive fill (~31 refs) | +### CW decoder confidence (specialised — paint code only) + +DeepFist letters, by cost = 1 − the model's posterior (< 0.15 / < 0.35 / < 0.60 / else). Light values are at least +4.5 : 1 on `color.background.0`. + +| Token | Dark | Light | +|---|---|---| +| `color.cw.confidence.high` | `#00ff88` | `{color.green.500}` (`#1a8040`) | +| `color.cw.confidence.medium` | `#e0e040` | `#737313` | +| `color.cw.confidence.fair` | `#ff9020` | `#ae5700` | +| `color.cw.confidence.low` | `#ff4040` | `{color.red.500}` (`#c02020`) | + ### Spectrum / waterfall (specialised — paint code only) | Token | Canonical | Notes | diff --git a/resources/themes/default-dark.json b/resources/themes/default-dark.json index d4b6e0efc1..0d45f74598 100644 --- a/resources/themes/default-dark.json +++ b/resources/themes/default-dark.json @@ -137,6 +137,12 @@ "bloom": { "blue": "#293aa7ff", "teal": "#1a7bf2dc" }, "grid": "#0978aad2" }, + "cw": { + "confidence.high": "#00ff88", + "confidence.medium": "#e0e040", + "confidence.fair": "#ff9020", + "confidence.low": "#ff4040" + }, "meter": { "crst": "{color.red.500}", "rms": "{color.blue.500}", diff --git a/resources/themes/default-light.json b/resources/themes/default-light.json index fca8735b65..2c0b4bd7a0 100644 --- a/resources/themes/default-light.json +++ b/resources/themes/default-light.json @@ -138,6 +138,12 @@ "bloom": { "blue": "#140b6bc4", "teal": "#0f12a08c" }, "grid": "#0a1a2a3a" }, + "cw": { + "confidence.high": "{color.green.500}", + "confidence.medium": "#737313", + "confidence.fair": "#ae5700", + "confidence.low": "{color.red.500}" + }, "meter": { "crst": "{color.red.500}", "rms": "{color.blue.500}", diff --git a/src/core/ThemeSeedGenerated.cpp b/src/core/ThemeSeedGenerated.cpp index f65d948665..74f76dc0ad 100644 --- a/src/core/ThemeSeedGenerated.cpp +++ b/src/core/ThemeSeedGenerated.cpp @@ -101,6 +101,10 @@ void ThemeManager::seedGeneratedDefaults() m_tokens.insert("color.canvas.dots", QString("#50e6f0fa")); m_tokens.insert("color.control.inactive", QString("#8ea8c0")); m_tokens.insert("color.control.unavailable", QString("#3a4a5a")); + m_tokens.insert("color.cw.confidence.fair", QString("#ff9020")); + m_tokens.insert("color.cw.confidence.high", QString("#00ff88")); + m_tokens.insert("color.cw.confidence.low", QString("#ff4040")); + m_tokens.insert("color.cw.confidence.medium", QString("#e0e040")); m_tokens.insert("color.highlight.fg", QString("#000000")); m_tokens.insert("color.highlight.message", QString("#e58be5")); m_tokens.insert("color.highlight.rx", QString("#379baf")); diff --git a/src/core/deepfist/DeepFistCommitter.h b/src/core/deepfist/DeepFistCommitter.h index 5535d92a8e..f65a8c048d 100644 --- a/src/core/deepfist/DeepFistCommitter.h +++ b/src/core/deepfist/DeepFistCommitter.h @@ -6,35 +6,99 @@ namespace AetherSDR { // Worker-owned text finalization. Only the optional carry strategy retains state -// beyond the existing time watermark; no model output or audio is kept here. +// beyond the existing time watermark; no model output or audio is kept here, +// except each token's confidence from the last few seconds of windows. class DeepFistCommitter { public: - struct Token { int id; double seconds; QString text; }; + // confidence: the model's posterior for this token in one window, 0..1. + struct Token { int id; double seconds; QString text; float confidence = 1.f; }; + // One published run of text and the confidence it is shown with. + struct Piece { QString text; float confidence; }; double committed() const { return m_committed; } std::size_t pendingCount() const { return m_pending.size(); } QString process(const std::vector& tokens, bool gated, double end, - double settled, bool carry) + double settled, bool carry, std::vector* pieces = nullptr) { QString output; + // A letter is re-decoded in every window it stays in (~3 before it + // settles). Its confidence is the mean over those sightings, one per + // earlier window. Same-token letters chained within 0.12 s in one window + // pair with an earlier window's sightings by order when the counts match; + // otherwise each takes the closest sighting within 0.12 s. + const auto publish = [&](const Token& token, bool current, std::vector own) { + output += token.text; + if (!pieces) { return; } + std::sort(own.begin(), own.end()); + auto first = std::find(own.begin(), own.end(), token.seconds); + if (first == own.end()) { own = {token.seconds}; first = own.begin(); } + const std::size_t rank = static_cast(first - own.begin()); + auto last = first; + while (first != own.begin() && *first - *(first - 1) <= 0.12) { --first; } + while (last + 1 != own.end() && *(last + 1) - *last <= 0.12) { ++last; } + const std::size_t run = static_cast(last - first) + 1; + const std::size_t position = rank - static_cast(first - own.begin()); + double sum = current ? token.confidence : 0.0; + int count = current ? 1 : 0; + for (std::size_t i = 0; i < m_history.size();) { + const double window = m_history[i].end; + std::vector near; + for (; i < m_history.size() && m_history[i].end == window; ++i) { + const Sighting& sighting = m_history[i]; + if (sighting.id == token.id && sighting.seconds >= *first - 0.12 + && sighting.seconds <= *last + 0.12) { near.push_back(&sighting); } + } + std::sort(near.begin(), near.end(), + [](const Sighting* a, const Sighting* b) { return a->seconds < b->seconds; }); + const Sighting* match = nullptr; + if (near.size() == run) { + match = near[position]; + } else { + for (const Sighting* sighting : near) { + const double distance = std::abs(sighting->seconds - token.seconds); + if (distance <= 0.12 + && (!match || distance < std::abs(match->seconds - token.seconds))) { match = sighting; } + } + } + if (match) { sum += match->confidence; ++count; } + } + pieces->push_back({token.text, count ? static_cast(sum / count) : token.confidence}); + }; + // The times of one token id in the current window, or in an earlier window by its end. + const auto times = [&](int id) { + std::vector result; + for (const Token& token : tokens) { if (token.id == id) { result.push_back(token.seconds); } } + return result; + }; + const auto earlierTimes = [&](int id, double window) { + std::vector result; + for (const Sighting& sighting : m_history) { + if (sighting.id == id && sighting.end == window) { result.push_back(sighting.seconds); } + } + return result; + }; + const auto separate = [&] { + output += QLatin1Char(' '); + if (pieces) { pieces->push_back({QStringLiteral(" "), 1.f}); } + }; if (gated) { bool pendingTail = false; if (carry) { for (const Pending& token : m_pending) { if (token.seen >= 2 && token.seconds > m_committed && end - token.last <= 1.21) { - if (token.seconds <= settled) { output += token.text; } + if (token.seconds <= settled) { publish(token, false, earlierTimes(token.id, token.last)); } else { pendingTail = true; } } } } // Close the word only after its confirmed tail has settled or // expired; a separator cannot be taken back after publication. - if (!pendingTail && !m_idle) { output += QLatin1Char(' '); m_idle = true; } + if (!pendingTail && !m_idle) { separate(); m_idle = true; } } else { m_idle = false; std::vector next; for (const Token& token : tokens) { - if (token.seconds > m_committed && token.seconds <= settled) { output += token.text; } + if (token.seconds > m_committed && token.seconds <= settled) { publish(token, true, times(token.id)); } if (!carry || token.seconds <= std::max(m_committed, settled) || next.size() >= 128) { continue; } auto match = m_pending.end(); double distance = 0.16; @@ -46,12 +110,14 @@ class DeepFistCommitter { } } const int seen = match == m_pending.end() ? 1 : std::min(2, match->seen + 1); - next.push_back({{token.id, token.seconds, token.text}, seen, end}); + next.push_back({token, seen, end}); // One-to-one matching preserves repeated characters as separate events. if (match != m_pending.end()) { m_pending.erase(match); } } m_pending = std::move(next); + for (const Token& token : tokens) { m_history.push_back({token.id, token.seconds, token.confidence, end}); } } + std::erase_if(m_history, [&](const Sighting& sighting) { return end - sighting.end > 8.0; }); m_committed = std::max(m_committed, settled); std::erase_if(m_pending, [&](const Pending& token) { return token.seconds <= m_committed || end - token.last > 1.21; @@ -60,9 +126,11 @@ class DeepFistCommitter { } private: struct Pending : Token { int seen; double last; }; + struct Sighting { int id; double seconds; float confidence; double end; }; double m_committed = 0; // Starts idle: a committer that has published nothing owes no separator. bool m_idle = true; std::vector m_pending; + std::vector m_history; }; } diff --git a/src/core/deepfist/DeepFistStream.cpp b/src/core/deepfist/DeepFistStream.cpp index 804cd945db..1e72a81e4c 100644 --- a/src/core/deepfist/DeepFistStream.cpp +++ b/src/core/deepfist/DeepFistStream.cpp @@ -46,8 +46,27 @@ bool DeepFistStream::hasCompletedMark(const float* samples, int count) } return false; } +float DeepFistStream::spanConfidence(const float* logProbs, int frames, int classes, int frame, int id) +{ + if (!logProbs || frames <= 0 || classes <= 0 || frame < 0 || frame >= frames || id < 0 || id >= classes) { + return 0.f; + } + double sum = 0; + int count = 0; + for (int t = frame; t < frames; ++t) { + const float* row = logProbs + static_cast(t) * classes; + const float* best = std::max_element(row, row + classes); + if (t > frame && best - row != id) { break; } + double total = 0; + for (int c = 0; c < classes; ++c) { total += std::exp(double(row[c]) - *best); } + sum += std::exp(double(row[id]) - *best) / total; + ++count; + } + return static_cast(sum / count); +} QString DeepFistStream::process(const float* samples, int count, lyra::dsp::DeepFistModel& model, - std::vector* observations) + std::vector* observations, + std::vector* pieces) { if (m_failed) { return {}; } QString result; @@ -117,7 +136,8 @@ QString DeepFistStream::process(const float* samples, int count, lyra::dsp::Deep } const double time = end - 6.0 + decoded.frames[j] * 6.0 / frames; const QString token = QString::fromStdString(model.tokens()[decoded.ids[j]]); - tokens.push_back({decoded.ids[j], time, token}); + tokens.push_back({decoded.ids[j], time, token, + spanConfidence(m_logits.data(), frames, classes, decoded.frames[j], decoded.ids[j])}); if (observations) { const QString decision = gated ? QStringLiteral("activity_gate") : time <= m_committer.committed() ? QStringLiteral("past_boundary") @@ -126,7 +146,7 @@ QString DeepFistStream::process(const float* samples, int count, lyra::dsp::Deep } } } - const QString publication = m_committer.process(tokens, gated, end, settled, m_parameters.carryPending); + const QString publication = m_committer.process(tokens, gated, end, settled, m_parameters.carryPending, pieces); result += publication; if (observations) { observation.publication = publication; diff --git a/src/core/deepfist/DeepFistStream.h b/src/core/deepfist/DeepFistStream.h index fc357b03ba..9432f06a9e 100644 --- a/src/core/deepfist/DeepFistStream.h +++ b/src/core/deepfist/DeepFistStream.h @@ -44,8 +44,15 @@ class DeepFistStream { }; std::vector tokens; }; + // pieces, when given, receives the published text split into runs with + // their confidence (DeepFistCommitter::Piece); the returned text is unchanged. QString process(const float* samples, int count, lyra::dsp::DeepFistModel& model, - std::vector* observations = nullptr); + std::vector* observations = nullptr, + std::vector* pieces = nullptr); + // Mean posterior of class `id` over the frames from `frame` while it stays + // the argmax. `logProbs` is row-major [frames][classes]; each row is + // normalized here, so raw logits and log-probabilities give the same value. + static float spanConfidence(const float* logProbs, int frames, int classes, int frame, int id); static bool hasCompletedMark(const float* samples, int count); bool failed() const { return m_failed; } private: diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 48246d3b45..b858fec226 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -1290,6 +1290,7 @@ private slots: void cwRxModelAction(); void refreshCwRxStatus(); void appendUnscoredCwText(const QString& text); + void appendColoredCwText(const QString& text, float cost); void refreshCwRxBackend(); #endif CwRxModel m_cwDecoder; diff --git a/src/gui/MainWindow_DigitalModes.cpp b/src/gui/MainWindow_DigitalModes.cpp index e7f06511ad..7e49d7b1b0 100644 --- a/src/gui/MainWindow_DigitalModes.cpp +++ b/src/gui/MainWindow_DigitalModes.cpp @@ -151,11 +151,19 @@ void MainWindow::appendUnscoredCwText(const QString& text) m_cwDecoderApplet->appendUnscoredCwText(text); } } +void MainWindow::appendColoredCwText(const QString& text, float cost) +{ + if (m_cwDecoderApplet && m_cwDecoder.isRunning()) { + m_cwDecoderApplet->appendColoredCwText(text, cost); + } +} void MainWindow::refreshCwRxBackend() { m_cwDecoder.selectBackend(CwDecodeSettings::backend()); connect(&m_cwDecoder, &CwRxModel::unscoredTextDecoded, this, &MainWindow::appendUnscoredCwText, Qt::UniqueConnection); + connect(&m_cwDecoder, &CwRxModel::coloredTextDecoded, + this, &MainWindow::appendColoredCwText, Qt::UniqueConnection); connect(&m_cwDecoder, &CwRxModel::statusChanged, this, &MainWindow::refreshCwRxStatus, Qt::UniqueConnection); if (m_cwDecoderApplet) { diff --git a/src/gui/PanadapterApplet.cpp b/src/gui/PanadapterApplet.cpp index 23cb4fdf8e..4c80a4c76f 100644 --- a/src/gui/PanadapterApplet.cpp +++ b/src/gui/PanadapterApplet.cpp @@ -822,6 +822,19 @@ int PanadapterApplet::pitchRangeHigh() const return m_pitchRangeSlider ? m_pitchRangeSlider->high() : 700; } +QString PanadapterApplet::cwCostColor(float cost) +{ + // Color by confidence: lower cost = higher confidence + // < 0.15 green (high confidence) + // < 0.35 yellow (medium) + // < 0.60 orange (meh) + // >= 0.60 red (low confidence) + if (cost < 0.15f) { return QStringLiteral("#00ff88"); } + if (cost < 0.35f) { return QStringLiteral("#e0e040"); } + if (cost < 0.60f) { return QStringLiteral("#ff9020"); } + return QStringLiteral("#ff4040"); +} + void PanadapterApplet::appendCwText(const QString& text, float cost) { #ifdef HAVE_DEEPFIST @@ -835,16 +848,7 @@ void PanadapterApplet::appendCwText(const QString& text, float cost) QString clean = text; clean.replace('\n', ' '); - // Color by confidence: lower cost = higher confidence - // < 0.15 green (high confidence) - // < 0.35 yellow (medium) - // < 0.60 orange (meh) - // >= 0.60 red (low confidence) - QString color; - if (cost < 0.15f) color = "#00ff88"; - else if (cost < 0.35f) color = "#e0e040"; - else if (cost < 0.60f) color = "#ff9020"; - else color = "#ff4040"; + const QString color = cwCostColor(cost); m_cwText->moveCursor(QTextCursor::End); // Switching back from TX → RX inserts a separator space so the [TX] @@ -911,7 +915,27 @@ void PanadapterApplet::appendUnscoredCwText(const QString& text) QTextCursor cursor = m_cwText->textCursor(); cursor.insertText(clean, format); m_cwText->moveCursor(QTextCursor::End); - // DeepFist does not provide a calibrated ggmorse confidence score. + // For a backend that reports no per-letter score. +} +void PanadapterApplet::appendColoredCwText(const QString& text, float cost) +{ + // The cost is 1 - the backend's own per-letter posterior. It picks the + // color (ggmorse's four bands, as theme tokens) and is never compared + // with the Sens threshold, which is on ggmorse's scale. + QString clean = text; + clean.replace('\n', ' '); + m_cwText->moveCursor(QTextCursor::End); + if (m_lastCwTextSource == CwTextSource::Tx) { m_cwText->insertPlainText(" "); } + m_lastCwTextSource = CwTextSource::Rx; + QTextCharFormat format; + const QString band = cost < 0.15f ? QStringLiteral("high") + : cost < 0.35f ? QStringLiteral("medium") + : cost < 0.60f ? QStringLiteral("fair") : QStringLiteral("low"); + format.setForeground(AetherSDR::ThemeManager::instance().color( + m_cwText, QStringLiteral("color.cw.confidence.") + band)); + QTextCursor cursor = m_cwText->textCursor(); + cursor.insertText(clean, format); + m_cwText->moveCursor(QTextCursor::End); } #endif diff --git a/src/gui/PanadapterApplet.h b/src/gui/PanadapterApplet.h index daa9fa4ead..3fa27616f3 100644 --- a/src/gui/PanadapterApplet.h +++ b/src/gui/PanadapterApplet.h @@ -66,6 +66,8 @@ class PanadapterApplet : public QWidget { // CW decode panel void setCwPanelVisible(bool visible); + // The four CW confidence colors, lowest cost (green) to highest (red). + static QString cwCostColor(float cost); void appendCwText(const QString& text, float cost = 0.0f); void appendCwTextTx(const QString& text, float cost = 0.0f); void setCwStats(float pitchHz, float speedWpm); @@ -76,6 +78,8 @@ class PanadapterApplet : public QWidget { void setCwBackendState(const QString& key, bool tuning, const QString& status, bool preparing, bool canRetry, const QString& detail); void appendUnscoredCwText(const QString& text); + // Colored like appendCwText, but never dropped by the Sens threshold. + void appendColoredCwText(const QString& text, float cost); #endif QPushButton* lockPitchButton() const { return m_lockPitchBtn; } QPushButton* lockSpeedButton() const { return m_lockSpeedBtn; } diff --git a/src/models/CwRxModel.cpp b/src/models/CwRxModel.cpp index b5cdb50d5b..a6a3bd8eb7 100644 --- a/src/models/CwRxModel.cpp +++ b/src/models/CwRxModel.cpp @@ -47,7 +47,7 @@ class GgmorseRxBackend final : public CwRxBackend { class DeepFistRxBackend final : public CwRxBackend { public: DeepFistRxBackend() { - connect(&m_decoder, &DeepFistCwModel::textDecoded, this, &CwRxBackend::unscoredTextDecoded); + connect(&m_decoder, &DeepFistCwModel::scoredTextDecoded, this, &CwRxBackend::coloredTextDecoded); connect(&m_decoder, &DeepFistCwModel::statusChanged, this, &CwRxBackend::statusChanged); } ~DeepFistRxBackend() override { stop(); } @@ -137,6 +137,10 @@ void CwRxModel::bind() [this, generation](const QString& text) { if (generation == m_generation && isRunning()) { emit unscoredTextDecoded(text); } }); + connect(m_backend.get(), &CwRxBackend::coloredTextDecoded, this, + [this, generation](const QString& text, float cost) { + if (generation == m_generation && isRunning()) { emit coloredTextDecoded(text, cost); } + }); connect(m_backend.get(), &CwRxBackend::statsUpdated, this, [this, generation](float pitch, float speed) { if (generation == m_generation && isRunning()) { emit statsUpdated(pitch, speed); } diff --git a/src/models/CwRxModel.h b/src/models/CwRxModel.h index 02357ca94e..4cf34d4c61 100644 --- a/src/models/CwRxModel.h +++ b/src/models/CwRxModel.h @@ -35,6 +35,9 @@ class CwRxBackend : public QObject { // Backends without a calibrated cost use unscoredTextDecoded instead. void textDecoded(const QString& text, float cost); void unscoredTextDecoded(const QString& text); + // Colored by cost on the same scale, never hidden by the Sens threshold: + // the cost ranks this backend's own letters, it is not ggmorse's cost. + void coloredTextDecoded(const QString& text, float cost); void statsUpdated(float pitch, float speed); void statusChanged(); }; @@ -70,6 +73,7 @@ class CwRxModel final : public QObject { signals: void textDecoded(const QString& text, float cost); void unscoredTextDecoded(const QString& text); + void coloredTextDecoded(const QString& text, float cost); void statsUpdated(float pitch, float speed); void statusChanged(); private: diff --git a/src/models/DeepFistCwModel.cpp b/src/models/DeepFistCwModel.cpp index 27ddb103ed..e194b8be76 100644 --- a/src/models/DeepFistCwModel.cpp +++ b/src/models/DeepFistCwModel.cpp @@ -222,6 +222,7 @@ void DeepFistCwModel::run(quint64 runId, const QString& directory) } const int channels = item.frame.stream().format.channels(); QString output; + std::vector pieces; for (qsizetype offset = 0; offset < item.frame.frameCount(); offset += 4096) { if (m_stopping.load() || generation != m_generation.load()) { break; } const int count = static_cast(std::min(4096, item.frame.frameCount() - offset)); @@ -232,7 +233,7 @@ void DeepFistCwModel::run(quint64 runId, const QString& directory) std::vector mono(converted.size() / sizeof(float)); std::memcpy(mono.data(), converted.constData(), converted.size()); m_processing = true; - output += stream.process(mono.data(), static_cast(mono.size()), model); + output += stream.process(mono.data(), static_cast(mono.size()), model, nullptr, &pieces); m_processing = false; if (stream.failed()) { m_acceptAudio = false; @@ -242,9 +243,12 @@ void DeepFistCwModel::run(quint64 runId, const QString& directory) } if (output.isEmpty()) { continue; } const PcmFrame frame = item.frame; - QMetaObject::invokeMethod(this, [this, generation, frame, output] { + QMetaObject::invokeMethod(this, [this, generation, frame, output, pieces] { if (m_running && generation == m_generation.load() && frame.current()) { emit textDecoded(output); + for (const DeepFistCommitter::Piece& piece : pieces) { + emit scoredTextDecoded(piece.text, 1.f - piece.confidence); + } } }, Qt::QueuedConnection); } diff --git a/src/models/DeepFistCwModel.h b/src/models/DeepFistCwModel.h index f47a79acc6..a69703e179 100644 --- a/src/models/DeepFistCwModel.h +++ b/src/models/DeepFistCwModel.h @@ -42,6 +42,9 @@ class DeepFistCwModel final : public QObject { static DeepFistStream::Parameters appParameters(); signals: void textDecoded(const QString& text); + // The same text in runs, each with the cost it is coloured by + // (1 - the letter's confidence; lower is better, as for ggmorse). + void scoredTextDecoded(const QString& text, float cost); void statusChanged(const QString& status); private: struct Item { PcmFrame frame; quint64 generation; }; diff --git a/tests/deepfist_committer_test.cpp b/tests/deepfist_committer_test.cpp index 8bfe224e19..128f06b126 100644 --- a/tests/deepfist_committer_test.cpp +++ b/tests/deepfist_committer_test.cpp @@ -1,4 +1,5 @@ #include "core/deepfist/DeepFistCommitter.h" +#include #include using AetherSDR::DeepFistCommitter; namespace { @@ -91,5 +92,48 @@ int main() if (!delayedExpiry.process({}, true, 6.8, 4.6, true).isEmpty()) { return 19; } expiry += delayedExpiry.process({{6, 5.0, "E"}}, false, 7.2, 5.0, true); if (!expectText(expiry, "A E", "active word after expiry")) { return 20; } - std::puts("committer: carry mutation, expiry, repeated tokens, reset, delayed separators passed"); + // Confidence (CONSTRUCTED values, arithmetic only): a published letter + // carries the mean over its sightings, one per window; the separator is + // shown as certain; a carried letter averages the sightings it had. + using Piece = DeepFistCommitter::Piece; + const auto near = [](float a, float b) { return std::abs(a - b) < 1e-4f; }; + DeepFistCommitter scored; + std::vector pieces; + scored.process({{2, 2.8, "A", 0.9f}, {21, 3.60, "T", 0.2f}}, false, 4.0, 2.7, true, &pieces); + scored.process({{2, 2.81, "A", 0.6f}, {21, 3.61, "T", 0.4f}}, false, 4.4, 2.7, true, &pieces); + if (!pieces.empty()) { return 21; } + scored.process({{2, 2.8, "A", 0.3f}, {21, 3.62, "T", 0.6f}}, false, 4.8, 3.0, true, &pieces); + if (pieces.size() != 1 || pieces[0].text != "A" || !near(pieces[0].confidence, 0.6f)) { return 22; } + pieces.clear(); + scored.process({}, true, 5.2, 3.9, true, &pieces); + if (pieces.size() != 2 || pieces[0].text != "T" || !near(pieces[0].confidence, 0.4f) + || pieces[1].text != " " || !near(pieces[1].confidence, 1.f)) { return 23; } + // A repeated letter inside the 0.12 s tolerance keeps its own score (CONSTRUCTED, + // the review's case): two E 80 ms apart, seen identically in two windows. + DeepFistCommitter closePair; + pieces.clear(); + closePair.process({{6, 2.80, "E", 0.9f}, {6, 2.88, "E", 0.1f}}, false, 4.0, 2.0, false, &pieces); + closePair.process({{6, 2.80, "E", 0.9f}, {6, 2.88, "E", 0.1f}}, false, 4.4, 3.0, false, &pieces); + if (pieces.size() != 2 || !near(pieces[0].confidence, 0.9f) || !near(pieces[1].confidence, 0.1f)) { + return 24; + } + // The same pair with the later window's times shifted 50 ms: matched by order, + // since the closest earlier sighting of the first E is now the second E's. + DeepFistCommitter shiftedPair; + pieces.clear(); + shiftedPair.process({{6, 2.80, "E", 0.9f}, {6, 2.88, "E", 0.1f}}, false, 4.0, 2.0, false, &pieces); + shiftedPair.process({{6, 2.85, "E", 0.9f}, {6, 2.93, "E", 0.1f}}, false, 4.4, 3.0, false, &pieces); + if (pieces.size() != 2 || !near(pieces[0].confidence, 0.9f) || !near(pieces[1].confidence, 0.1f)) { + return 25; + } + // Counts differ (the earlier window saw one E): each letter takes the closest + // sighting, so both average in the earlier 0.9. + DeepFistCommitter unevenPair; + pieces.clear(); + unevenPair.process({{6, 2.80, "E", 0.9f}}, false, 4.0, 2.0, false, &pieces); + unevenPair.process({{6, 2.80, "E", 0.9f}, {6, 2.88, "E", 0.1f}}, false, 4.4, 3.0, false, &pieces); + if (pieces.size() != 2 || !near(pieces[0].confidence, 0.9f) || !near(pieces[1].confidence, 0.5f)) { + return 26; + } + std::puts("committer: carry mutation, expiry, repeated tokens, reset, delayed separators, confidence passed"); }