From 6050fd7a8f5aee6aaa60106fcf785871ea8752dd Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 16 Jun 2026 12:47:23 -0500 Subject: [PATCH] [LOOP-5693] Cover carb history range in sensitivity/override queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recommendManualBolus(potentialCarbEntry:)` appends a synthetic carb entry whose startDate can be up to ~12h in the past (the UI's allowed range). The sensitivity and override timelines are derived from doses, glucose history, and the recommendation effect interval, so they often do not extend back that far. When a past potential carb entry is then fed into the algorithm, `Collection.map(to:carbRatio: insulinSensitivity:...)` calls `closestPrior(to: entry.startDate)` on the sensitivity timeline, gets nil, and trips a `preconditionFailure` inside `CarbStatusBuilder` construction — a hard crash on the main thread (Crashlytics report against `LoopAlgorithm/CarbMath.swift:717`). Widen the sensitivity and override queries to also cover `carbsStart` (= baseTime - 12h - 1min), which already bounds the carb history. Add two tests in LoopDataManagerTests: - `testFetchDataSensitivityCoversCarbHistoryStart` asserts that the returned sensitivity timeline's first entry covers `carbsStart`. - `testRecommendManualBolusWithPastPotentialCarbEntryDoesNotCrash` reproduces the crash (verified to trap on the unpatched code) and confirms the fix lets the recommendation flow complete. --- Loop/Managers/LoopDataManager.swift | 11 +++++-- LoopTests/Managers/LoopDataManagerTests.swift | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 613d3695b1..2e23c08eee 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -389,8 +389,15 @@ final class LoopDataManager: ObservableObject { recommendationEffectInterval: recommendationEffectInterval ) + // Sensitivity and override timelines must also cover the carb history range so that + // any carb entry within that window (including a synthetic `potentialCarbEntry` later + // appended for a manual bolus recommendation) maps to an ISF and any active override. + // Otherwise `closestPrior(to: entry.startDate)` returns nil downstream and the algorithm + // traps in `CarbStatusBuilder` construction. + let scheduleQueryStart = min(neededSensitivityTimeline.start, carbsStart) + let sensitivity = try await settingsProvider.getInsulinSensitivityHistory( - startDate: neededSensitivityTimeline.start, + startDate: scheduleQueryStart, endDate: neededSensitivityTimeline.end ) @@ -404,7 +411,7 @@ final class LoopDataManager: ObservableObject { throw LoopError.configurationError(.maximumBasalRatePerHour) } - var overrides = temporaryPresetsManager.presetHistory.getOverrideHistory(startDate: neededSensitivityTimeline.start, endDate: forecastEndTime) + var overrides = temporaryPresetsManager.presetHistory.getOverrideHistory(startDate: scheduleQueryStart, endDate: forecastEndTime) // For recommendation, we should consider preMeal override to be ending at time of dose if presumePresetEndingNow, diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index 1d40f9ab73..1a2a3ae4bd 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -459,6 +459,39 @@ class LoopDataManagerTests: XCTestCase { } + func testFetchDataSensitivityCoversCarbHistoryStart() async throws { + // A bolus recommendation may append a `potentialCarbEntry` with a startDate up to + // ~12h in the past. The sensitivity and override timelines must extend at least that + // far back; otherwise `closestPrior` on those timelines returns nil for the appended + // entry and `CarbStatusBuilder` construction traps. See LOOP-5693. + let input = try await loopDataManager.fetchData(for: now) + let carbsStart = now.addingTimeInterval(CarbMath.dateAdjustmentPast + .minutes(-1)) + + XCTAssertFalse(input.sensitivity.isEmpty) + XCTAssertLessThanOrEqual(input.sensitivity.first!.startDate, carbsStart) + } + + func testRecommendManualBolusWithPastPotentialCarbEntryDoesNotCrash() async throws { + // Regression for LOOP-5693: a potential carb entry with a startDate older than the + // dose/glucose-derived sensitivity timeline used to trap inside LoopAlgorithm because + // the sensitivity schedule didn't cover the carb entry's startDate. + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 130)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 140)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 150)), + ] + + let pastEntry = NewCarbEntry( + quantity: .carbs(value: 30), + startDate: d(.hours(-11)), + foodType: nil, + absorptionTime: .hours(3) + ) + + let recommendation = try await loopDataManager.recommendManualBolus(potentialCarbEntry: pastEntry) + XCTAssertNotNil(recommendation) + } + func testFetchDataWithHighInsulinNeedsPresetMitigation() async throws { var input = try await loopDataManager.fetchData(for: now) XCTAssertEqual(input.target.count, 1)