From 66ecc10658b6a6f8b22361e6b993f3a0fae57578 Mon Sep 17 00:00:00 2001 From: moveson Date: Mon, 24 Aug 2026 08:47:43 -0600 Subject: [PATCH 1/3] Add failing specs for degenerate pace-factor baselines Reproduces the #2169 incident shape (clean subject-segment pool, poisoned completed-segment pool) plus the latent FloatDomainError when the completed-segment average is exactly zero. Co-Authored-By: Claude Fable 5 --- spec/services/time_predictor_spec.rb | 51 ++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/spec/services/time_predictor_spec.rb b/spec/services/time_predictor_spec.rb index 23186ef44..68497afde 100644 --- a/spec/services/time_predictor_spec.rb +++ b/spec/services/time_predictor_spec.rb @@ -259,4 +259,55 @@ def imputed_pace completed_split_time.time_from_start / completed_typical_time end end + + describe "#data_status with a degenerate stats baseline" do + subject do + described_class.new(segment: segment, effort: effort, lap_splits: lap_splits, + completed_split_time: completed_split_time, calc_model: :stats) + end + + let(:segment) { aid_2_to_aid_5 } + let(:completed_split_time) { subject_split_times.first(5).last } + let(:subject_segment_average) { 10_000.0 } + + before do + allow(SplitTimeQuery).to receive(:typical_segment_time) do |queried_segment, _effort_ids| + average = queried_segment == segment ? subject_segment_average : completed_segment_average + { "effort_count" => 10, "average" => average }.with_indifferent_access + end + end + + context "when the completed-segment pool is poisoned with an absurdly slow average" do + let(:completed_segment_average) { 76_000_000.0 } + + it "clamps the pace factor so a typical time is not flagged bad" do + expect(subject.data_status(1_000)).to eq("good") + end + end + + context "when the completed-segment pool is poisoned with an absurdly fast average" do + let(:completed_segment_average) { 1.0 } + + it "clamps the pace factor so a typical time is not flagged bad" do + expect(subject.data_status(100_000)).to eq("good") + end + end + + context "when the completed-segment average is zero" do + let(:completed_segment_average) { 0.0 } + + it "treats the pace as unmeasurable instead of raising" do + expect { subject.data_status(9_999) }.not_to raise_error + expect(subject.data_status(9_999)).to eq("good") + end + end + + context "when the completed-segment average is negative" do + let(:completed_segment_average) { -100.0 } + + it "treats the pace as unmeasurable" do + expect(subject.data_status(9_999)).to eq("good") + end + end + end end From 1253360ce3a5da6415fe2b4e43ce65187cc2fb35 Mon Sep 17 00:00:00 2001 From: moveson Date: Mon, 24 Aug 2026 08:49:21 -0600 Subject: [PATCH 2/3] Guard and clamp the pace factor against degenerate baselines measurable_pace? now requires positive actual and typical completed times: a typical completed time of exactly 0.0 was truthy, so float division produced Infinity and limits raised FloatDomainError. The imputed pace is also clamped to [0.1, 10] so a corrupt pooled baseline can shrink or stretch the limits band at most tenfold instead of collapsing it to zero width; real pace factors (roughly 0.3..3) are never clamped. Resolves #2238 Co-Authored-By: Claude Fable 5 --- app/services/time_predictor.rb | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/services/time_predictor.rb b/app/services/time_predictor.rb index ced6c7a4b..038db2b43 100644 --- a/app/services/time_predictor.rb +++ b/app/services/time_predictor.rb @@ -1,4 +1,10 @@ class TimePredictor + # Bounds on how far a runner's imputed pace may scale the limits band; + # real pace factors fall roughly within 0.3..3, so the clamp binds only + # when the pooled baseline is corrupt + MIN_PACE_FACTOR = 0.1 + MAX_PACE_FACTOR = 10.0 + def self.segment_time(segment:, effort:, lap_splits: nil, completed_split_time: nil, calc_model: nil, similar_effort_ids: nil, times_container: nil) new( @@ -52,11 +58,18 @@ def limits end def pace_factor - @pace_factor ||= measurable_pace? ? actual_completed_time / typical_completed_time : 1 + @pace_factor ||= + if measurable_pace? + (actual_completed_time / typical_completed_time).clamp(MIN_PACE_FACTOR, MAX_PACE_FACTOR) + else + 1 + end end def measurable_pace? - completed_lap_split.distance_from_start.positive? && actual_completed_time && typical_completed_time + completed_lap_split.distance_from_start.positive? && + actual_completed_time&.positive? && + typical_completed_time&.positive? end def actual_completed_time From 2e47098d8c7831d007aee95c5da73effaaaf5785 Mon Sep 17 00:00:00 2001 From: moveson Date: Mon, 24 Aug 2026 09:28:47 -0600 Subject: [PATCH 3/3] Drop the pace-factor clamp, keeping only the positive-pace guard Per review: without an observability channel, the clamp converts a rare loud failure (mass bad flags, which is how a poisoned baseline announces itself) into a rare silent one. The guard remains: it fixes the FloatDomainError crash on a zero typical completed time and stops a negative actual completed time from producing a nonsense negative limits band. Co-Authored-By: Claude Fable 5 --- app/services/time_predictor.rb | 13 +------------ spec/services/time_predictor_spec.rb | 16 ---------------- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/app/services/time_predictor.rb b/app/services/time_predictor.rb index 038db2b43..66e0d91f3 100644 --- a/app/services/time_predictor.rb +++ b/app/services/time_predictor.rb @@ -1,10 +1,4 @@ class TimePredictor - # Bounds on how far a runner's imputed pace may scale the limits band; - # real pace factors fall roughly within 0.3..3, so the clamp binds only - # when the pooled baseline is corrupt - MIN_PACE_FACTOR = 0.1 - MAX_PACE_FACTOR = 10.0 - def self.segment_time(segment:, effort:, lap_splits: nil, completed_split_time: nil, calc_model: nil, similar_effort_ids: nil, times_container: nil) new( @@ -58,12 +52,7 @@ def limits end def pace_factor - @pace_factor ||= - if measurable_pace? - (actual_completed_time / typical_completed_time).clamp(MIN_PACE_FACTOR, MAX_PACE_FACTOR) - else - 1 - end + @pace_factor ||= measurable_pace? ? actual_completed_time / typical_completed_time : 1 end def measurable_pace? diff --git a/spec/services/time_predictor_spec.rb b/spec/services/time_predictor_spec.rb index 68497afde..b158dc472 100644 --- a/spec/services/time_predictor_spec.rb +++ b/spec/services/time_predictor_spec.rb @@ -277,22 +277,6 @@ def imputed_pace end end - context "when the completed-segment pool is poisoned with an absurdly slow average" do - let(:completed_segment_average) { 76_000_000.0 } - - it "clamps the pace factor so a typical time is not flagged bad" do - expect(subject.data_status(1_000)).to eq("good") - end - end - - context "when the completed-segment pool is poisoned with an absurdly fast average" do - let(:completed_segment_average) { 1.0 } - - it "clamps the pace factor so a typical time is not flagged bad" do - expect(subject.data_status(100_000)).to eq("good") - end - end - context "when the completed-segment average is zero" do let(:completed_segment_average) { 0.0 }