From 2f9358c5123dadf3a12f1e34344c07007a0bf647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Sun, 27 Sep 2026 15:41:19 +0200 Subject: [PATCH] fix(stats): refuse a zero-probability sequence in Viterbi Assisted-by: GLM 5.3 Flash --- CHANGELOG.md | 3 +++ stats/hmm.go | 6 ++++++ stats/hmm_test.go | 26 ++++++++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9003018..d063bed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `FitHiddenMarkovModel` fits a single-observation sequence instead of crashing the process: a transition row with no evidence keeps its previous estimate rather than dividing by zero. +- `Viterbi` refuses a sequence of probability zero under the model, + the same refusal `Forward` makes, instead of returning a meaningless + path beside a log probability of -Inf. ## [1.0.0] - 2026-09-03 diff --git a/stats/hmm.go b/stats/hmm.go index 9ebda8d..f3278fc 100644 --- a/stats/hmm.go +++ b/stats/hmm.go @@ -354,6 +354,12 @@ func (m *HiddenMarkovModel) Viterbi(observations []int) (states []int, logProbab arg = k } } + if math.IsInf(best, -1) { + // Every path has probability zero, the structural zero an + // emission or transition carries: the same refusal Forward makes, + // not a meaningless path beside a −Inf log probability. + return nil, 0, base.Errf("%s: the observation sequence has probability zero under the model", name) + } path := make([]int, len(observations)) path[len(observations)-1] = arg for t := len(observations) - 1; t > 0; t-- { diff --git a/stats/hmm_test.go b/stats/hmm_test.go index b3ae976..4a62269 100644 --- a/stats/hmm_test.go +++ b/stats/hmm_test.go @@ -375,3 +375,29 @@ func TestHiddenMarkovFitSingleObservation(t *testing.T) { } } } + +func TestViterbiZeroProbabilitySequence(t *testing.T) { + // Symbol 2 is emitted by no state, so every path through the sequence + // has probability zero. Forward and Smooth refuse such a sequence, and + // Viterbi documents the same refusals: a meaningless path beside a + // −Inf log probability is not an answer. + model, err := NewHiddenMarkovModel( + []float64{0.5, 0.5}, + []float64{0.5, 0.5, 0.5, 0.5}, + []float64{0.5, 0.5, 0, 0.5, 0.5, 0}, + ) + if err != nil { + t.Fatal(err) + } + if _, _, err := model.Viterbi([]int{0, 2, 1}); err == nil { + t.Fatal("a zero-probability sequence was accepted by Viterbi") + } + // The live symbols of the same model still decode. + path, lp, err := model.Viterbi([]int{0, 1}) + if err != nil { + t.Fatal(err) + } + if path[0] != path[1] || lp >= 0 { + t.Fatalf("Viterbi([0, 1]) = (%v, %g), want a coherent path with a negative log probability", path, lp) + } +}