From 1d1e1b0bfa9aa27a4e7cb7d4d44de47d3f8d13b1 Mon Sep 17 00:00:00 2001 From: moveson Date: Sat, 22 Aug 2026 12:32:54 -0600 Subject: [PATCH 1/3] Gate typical_segment_time on use_for_projections The data-status statistics pool drew from every split time on a course with no event scoping, so fabricated test times on a shared course corrupted the pace-factor baseline for real events. Join the begin-side subquery through efforts to events and require use_for_projections, matching the predicate Projection.sql already uses. Also discard negative segment pairs from the pool. Co-Authored-By: Claude Fable 5 --- app/queries/split_time_query.rb | 7 ++++- spec/queries/split_time_query_spec.rb | 42 +++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/app/queries/split_time_query.rb b/app/queries/split_time_query.rb index 850e6211d..e4ad845a5 100644 --- a/app/queries/split_time_query.rb +++ b/app/queries/split_time_query.rb @@ -16,9 +16,12 @@ def self.typical_segment_time(segment, effort_ids) (select extract(epoch from(st2.absolute_time - st1.absolute_time)) as seconds from (select st.effort_id, st.absolute_time from split_times st + inner join efforts ef on ef.id = st.effort_id + inner join events ev on ev.id = ef.event_id where st.lap = #{begin_lap} and st.split_id = #{begin_id} and st.sub_split_bitkey = #{begin_bitkey} + and ev.use_for_projections is true and (st.data_status in (#{valid_statuses_list}) or st.data_status is null)) as st1, (select st.effort_id, st.absolute_time @@ -28,7 +31,9 @@ def self.typical_segment_time(segment, effort_ids) and st.sub_split_bitkey = #{end_bitkey} and (st.data_status in (#{valid_statuses_list}) or st.data_status is null)) as st2 - where st1.effort_id = st2.effort_id and #{focus_clause}), + where st1.effort_id = st2.effort_id + and st2.absolute_time >= st1.absolute_time + and #{focus_clause}), quartiles as (select percentile_cont(0.25) within group (order by seconds) as q1, diff --git a/spec/queries/split_time_query_spec.rb b/spec/queries/split_time_query_spec.rb index fc3e4501a..9f199e3fe 100644 --- a/spec/queries/split_time_query_spec.rb +++ b/spec/queries/split_time_query_spec.rb @@ -67,5 +67,47 @@ def execute_query expect(time).to be_within(100).of(300) end end + + context "when the event is not used for projections" do + before { events(:hardrock_2015).update_column(:use_for_projections, false) } + + context "when effort_ids are not provided" do + let(:segment) { start_to_cunningham_in } + let(:effort_ids) { nil } + + it "excludes the event's efforts from the pool" do + expect(count).to eq(0) + expect(time).to be_nil + end + end + + context "when effort_ids are provided" do + let(:event) { events(:hardrock_2015) } + let(:segment) { in_aid_sherman } + let(:effort_ids) { event.efforts.order(:bib_number).ids.first(2) } + + it "excludes the event's efforts even when focused" do + expect(count).to eq(0) + expect(time).to be_nil + end + end + end + + context "when a segment time is negative" do + let(:segment) { in_aid_sherman } + let(:effort_ids) { [effort.id] } + let(:effort) { events(:hardrock_2015).efforts.order(:bib_number).first } + + before do + in_time = effort.split_times.find_by(split: sherman_split, bitkey: in_bitkey) + out_time = effort.split_times.find_by(split: sherman_split, bitkey: out_bitkey) + out_time.update_column(:absolute_time, in_time.absolute_time - 1.minute) + end + + it "excludes the negative pair from the pool" do + expect(count).to eq(0) + expect(time).to be_nil + end + end end end From ad5c69abe2f40bc7566759a2c36e609b4c12327e Mon Sep 17 00:00:00 2001 From: moveson Date: Sat, 22 Aug 2026 12:32:55 -0600 Subject: [PATCH 2/3] Exclude simulated event groups from projections and statistics SimulateInProgressEventGroup now sets use_for_projections false on the events it creates, so fabricated times cannot feed projections or the data-status pool even on a shared course. DuplicateEventGroup is deliberately unchanged: UI duplicates are next year's real races, whose live times must feed the stream; a regression spec pins that. Co-Authored-By: Claude Fable 5 --- app/services/simulate_in_progress_event_group.rb | 4 +++- spec/models/duplicate_event_group_spec.rb | 6 ++++++ spec/services/simulate_in_progress_event_group_spec.rb | 4 ++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/services/simulate_in_progress_event_group.rb b/app/services/simulate_in_progress_event_group.rb index f3ed2d9b3..eec6b6ed8 100644 --- a/app/services/simulate_in_progress_event_group.rb +++ b/app/services/simulate_in_progress_event_group.rb @@ -52,7 +52,9 @@ def build_event_group end shift = start_time - new_event_group.scheduled_start_time - new_event_group.events.each { |event| event.update!(scheduled_start_time: event.scheduled_start_time + shift) } + new_event_group.events.each do |event| + event.update!(scheduled_start_time: event.scheduled_start_time + shift, use_for_projections: false) + end new_event_group.update!(available_live: true) end diff --git a/spec/models/duplicate_event_group_spec.rb b/spec/models/duplicate_event_group_spec.rb index 1f005713d..bc93b4225 100644 --- a/spec/models/duplicate_event_group_spec.rb +++ b/spec/models/duplicate_event_group_spec.rb @@ -32,6 +32,12 @@ it "sets created_by to the provided user" do expect(subject.new_event_group.created_by).to eq(user.id) end + + it "keeps use_for_projections on the duplicated events" do + # UI duplicates are next year's real races; their live times must + # feed projections and statistics when the race runs + expect(subject.new_event_group.events).to all(have_attributes(use_for_projections: true)) + end end context "when the source event group has a webhook token" do diff --git a/spec/services/simulate_in_progress_event_group_spec.rb b/spec/services/simulate_in_progress_event_group_spec.rb index cd2a2a259..7cdc327a7 100644 --- a/spec/services/simulate_in_progress_event_group_spec.rb +++ b/spec/services/simulate_in_progress_event_group_spec.rb @@ -18,6 +18,10 @@ expect(result.new_event_group.name).to include("Simulated") end + it "excludes the simulated events from projections and statistics" do + expect(result.new_event_group.events).to all(have_attributes(use_for_projections: false)) + end + it "places the group start at the requested start time" do expect(result.new_event_group.events.map(&:scheduled_start_time).min).to be_within(1.second).of(start_time) end From 887f21d7ca585c5ba28d1bba1e7b742567df0291 Mon Sep 17 00:00:00 2001 From: moveson Date: Sat, 22 Aug 2026 12:32:57 -0600 Subject: [PATCH 3/3] Degenerate typical time yields unknown status, not all-bad A zero or negative pooled average previously produced an all-zero limits band, against which every real time evaluated as bad. Return empty limits for non-positive typical times on scaling limit types so statuses degrade to unknown; zero_start and in_aid types keep their intentional fixed bands. Co-Authored-By: Claude Fable 5 --- app/models/segment_times_container.rb | 14 +++++++++++++- spec/models/segment_times_container_spec.rb | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/models/segment_times_container.rb b/app/models/segment_times_container.rb index fbccd0cce..130e63152 100644 --- a/app/models/segment_times_container.rb +++ b/app/models/segment_times_container.rb @@ -21,8 +21,13 @@ def segment_time(segment) def limits(segment) return @limits_hashes[segment] if @limits_hashes.key?(segment) + typical_time = segment_time(segment) @limits_hashes[segment] = - segment_time(segment) ? DataStatus.limits(segment_time(segment), limits_type(segment)) : {} + if typical_time.nil? || (!typical_time.positive? && scaling_limits_type?(segment)) + {} + else + DataStatus.limits(typical_time, limits_type(segment)) + end end def data_status(segment, seconds) @@ -37,6 +42,13 @@ def limits_type(segment) segment.special_limits_type || calc_model end + # zero_start bands are intentionally all-zero, and in_aid adds a fixed + # positive allowance, so a zero typical time still yields a usable band + # for those types; all others scale purely with the typical time + def scaling_limits_type?(segment) + !limits_type(segment).to_s.in?(%w[zero_start in_aid]) + end + def validate_setup if calc_model == :focused && effort_ids.nil? raise ArgumentError, diff --git a/spec/models/segment_times_container_spec.rb b/spec/models/segment_times_container_spec.rb index ad7c96671..660cd4945 100644 --- a/spec/models/segment_times_container_spec.rb +++ b/spec/models/segment_times_container_spec.rb @@ -143,6 +143,27 @@ def validate_segment_time(segment, calc_model, expected, effort_ids = nil) expect(container.limits(segment)).to eq({}) end + it "returns an empty hash instead of a degenerate band when a scaling typical time is zero" do + segment = lap_1_start_to_lap_2_finish + container = described_class.new(calc_model: :stats) + allow(container).to receive(:segment_time).and_return(0.0) + expect(container.limits(segment)).to eq({}) + end + + it "returns an empty hash when a scaling typical time is negative" do + segment = lap_1_start_to_lap_2_finish + container = described_class.new(calc_model: :stats) + allow(container).to receive(:segment_time).and_return(-100.0) + expect(container.limits(segment)).to eq({}) + end + + it "still returns a usable band for an in-aid segment with a zero typical time" do + segment = lap_1_in_aid_1 + container = described_class.new(calc_model: :stats) + allow(container).to receive(:segment_time).and_return(0.0) + expect(container.limits(segment)[:high_bad]).to be_positive + end + def validate_limits(segment, expected_time, expected_limits_type, calc_model) allow(DataStatus).to receive(:limits) container = SegmentTimesContainer.new(calc_model: calc_model, effort_ids: [1, 2, 3])