Skip to content

Commit a82ca34

Browse files
Add safety guardrails and data-first AI prompt philosophy
Safety guardrails (3 layers of defense against dangerous therapy values): - LoopInsights_SafetyGuardrails struct with clinical bounds mirroring LoopKit (CR 4-28 recommended/2-150 absolute, ISF 16-400/10-500, Basal 0.05-10/0.05-30) - Post-parse validation rejects values outside absolute bounds and >25% changes - AI prompt now includes absolute bounds with clamping instructions - confirmApply() hard-blocks absolute violations - applyEditedSuggestion() validates edited blocks against absolute bounds - autoApplySuggestion() blocks anything outside recommended range (stricter) - SuggestionDetailView shows orange warning banner and color-coded values - DashboardView alert changes to "Safety Warning" with specific warnings - Suggestion cards show orange triangle badge for guardrail warnings Data-first AI prompts (all 4 AI interaction points): - Chat, Analysis, Goals/Patterns, and Trends prompts now require every answer to cite the user's specific numbers — no generic diabetes advice - Added "#1 RULE" blocks emphasizing real data over textbook answers
1 parent c03d6c0 commit a82ca34

8 files changed

Lines changed: 385 additions & 39 deletions

Loop/Models/LoopInsights/LoopInsights_Models.swift

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,112 @@ enum LoopInsightsSettingStatus {
6868
case hasSuggestions // Orange — has pending suggestions
6969
}
7070

71+
// MARK: - Safety Guardrails
72+
73+
/// Absolute and recommended clinical bounds for therapy settings.
74+
/// Mirrors LoopKit's `Guardrail+Settings.swift` as self-contained constants
75+
/// so LoopInsights can validate without importing LoopKit guardrail types.
76+
struct LoopInsights_SafetyGuardrails {
77+
78+
/// Classification of a value relative to guardrail bounds
79+
enum Classification {
80+
case withinRecommended
81+
case belowRecommended
82+
case aboveRecommended
83+
case belowAbsolute
84+
case aboveAbsolute
85+
}
86+
87+
// -- Carb Ratio (g/U) --
88+
static let crRecommendedMin: Double = 4.0
89+
static let crRecommendedMax: Double = 28.0
90+
static let crAbsoluteMin: Double = 2.0
91+
static let crAbsoluteMax: Double = 150.0
92+
93+
// -- Insulin Sensitivity Factor (mg/dL per U) --
94+
static let isfRecommendedMin: Double = 16.0
95+
static let isfRecommendedMax: Double = 400.0
96+
static let isfAbsoluteMin: Double = 10.0
97+
static let isfAbsoluteMax: Double = 500.0
98+
99+
// -- Basal Rate (U/hr) --
100+
static let basalRecommendedMin: Double = 0.05
101+
static let basalRecommendedMax: Double = 10.0
102+
static let basalAbsoluteMin: Double = 0.05
103+
static let basalAbsoluteMax: Double = 30.0
104+
105+
/// Maximum allowed percentage change per analysis step (backstop)
106+
static let maxChangePercent: Double = 25.0
107+
108+
/// Classify a value against the guardrail bounds for a setting type
109+
static func classify(value: Double, settingType: LoopInsightsSettingType) -> Classification {
110+
let (recMin, recMax, absMin, absMax) = bounds(for: settingType)
111+
112+
if value < absMin { return .belowAbsolute }
113+
if value > absMax { return .aboveAbsolute }
114+
if value < recMin { return .belowRecommended }
115+
if value > recMax { return .aboveRecommended }
116+
return .withinRecommended
117+
}
118+
119+
/// Human-readable warning string, or nil if value is within recommended range
120+
static func warningMessage(value: Double, settingType: LoopInsightsSettingType) -> String? {
121+
let classification = classify(value: value, settingType: settingType)
122+
let (recMin, recMax, absMin, absMax) = bounds(for: settingType)
123+
let unit = settingType.unitDescription
124+
let name = settingType.displayName
125+
126+
switch classification {
127+
case .withinRecommended:
128+
return nil
129+
case .belowAbsolute:
130+
return String(
131+
format: NSLocalizedString(
132+
"%@ value %.1f %@ is below the absolute minimum (%.1f %@). This value cannot be applied.",
133+
comment: "LoopInsights guardrail: below absolute"
134+
),
135+
name, value, unit, absMin, unit
136+
)
137+
case .aboveAbsolute:
138+
return String(
139+
format: NSLocalizedString(
140+
"%@ value %.1f %@ exceeds the absolute maximum (%.1f %@). This value cannot be applied.",
141+
comment: "LoopInsights guardrail: above absolute"
142+
),
143+
name, value, unit, absMax, unit
144+
)
145+
case .belowRecommended:
146+
return String(
147+
format: NSLocalizedString(
148+
"%@ value %.1f %@ is below the recommended minimum (%.1f %@). Consult your healthcare provider before applying.",
149+
comment: "LoopInsights guardrail: below recommended"
150+
),
151+
name, value, unit, recMin, unit
152+
)
153+
case .aboveRecommended:
154+
return String(
155+
format: NSLocalizedString(
156+
"%@ value %.1f %@ exceeds the recommended maximum (%.1f %@). Consult your healthcare provider before applying.",
157+
comment: "LoopInsights guardrail: above recommended"
158+
),
159+
name, value, unit, recMax, unit
160+
)
161+
}
162+
}
163+
164+
/// Returns (recommendedMin, recommendedMax, absoluteMin, absoluteMax) for a setting type
165+
private static func bounds(for settingType: LoopInsightsSettingType) -> (Double, Double, Double, Double) {
166+
switch settingType {
167+
case .carbRatio:
168+
return (crRecommendedMin, crRecommendedMax, crAbsoluteMin, crAbsoluteMax)
169+
case .insulinSensitivity:
170+
return (isfRecommendedMin, isfRecommendedMax, isfAbsoluteMin, isfAbsoluteMax)
171+
case .basalRate:
172+
return (basalRecommendedMin, basalRecommendedMax, basalAbsoluteMin, basalAbsoluteMax)
173+
}
174+
}
175+
}
176+
71177
// MARK: - Detected Pattern
72178

73179
/// A glucose/insulin pattern detected from aggregated data
@@ -577,6 +683,31 @@ struct LoopInsightsSuggestion: Codable, Identifiable, Equatable {
577683
}
578684
}
579685

686+
// MARK: - Guardrail Computed Properties
687+
688+
/// Warning strings for any proposed values outside recommended bounds
689+
var guardrailWarnings: [String] {
690+
timeBlocks.compactMap { block in
691+
LoopInsights_SafetyGuardrails.warningMessage(value: block.proposedValue, settingType: settingType)
692+
}
693+
}
694+
695+
/// True if any proposed value falls outside the recommended range
696+
var hasGuardrailWarning: Bool {
697+
timeBlocks.contains { block in
698+
let c = LoopInsights_SafetyGuardrails.classify(value: block.proposedValue, settingType: settingType)
699+
return c != .withinRecommended
700+
}
701+
}
702+
703+
/// True if any proposed value falls outside the absolute bounds (hard block)
704+
var hasAbsoluteViolation: Bool {
705+
timeBlocks.contains { block in
706+
let c = LoopInsights_SafetyGuardrails.classify(value: block.proposedValue, settingType: settingType)
707+
return c == .belowAbsolute || c == .aboveAbsolute
708+
}
709+
}
710+
580711
static func == (lhs: LoopInsightsSuggestion, rhs: LoopInsightsSuggestion) -> Bool {
581712
return lhs.id == rhs.id
582713
}

Loop/Services/LoopInsights/LoopInsights_AIAnalysis.swift

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,12 @@ final class LoopInsights_AIAnalysis {
6666
6767
\(personality.promptInstruction)
6868
69-
YOUR MANDATE: Be analytically rigorous. Every recommendation must be backed by specific numbers \
70-
from the data. If the data does not justify a change, return zero suggestions — that is the \
71-
correct response when settings are working. You are not here to impress or people-please. \
72-
You are here to find real problems and propose precise fixes.
69+
YOUR MANDATE: Be analytically rigorous. You have this person's REAL data — their actual \
70+
glucose readings, insulin delivery, carb logs, and pump settings. Every recommendation must \
71+
cite specific numbers from THEIR data, not generic clinical wisdom. If the data does not \
72+
justify a change, return zero suggestions — that is the correct response when settings are \
73+
working. You are not here to impress or people-please. You are here to find real problems \
74+
in THIS person's data and propose precise fixes grounded in THEIR numbers.
7375
7476
CLINICAL REASONING FRAMEWORK — How AID settings interact:
7577
- BASAL RATE: Controls glucose during fasting periods. Analyze overnight (12AM-6AM) and \
@@ -99,7 +101,7 @@ final class LoopInsights_AIAnalysis {
99101
always means basal rate is too low. >70% basal may mean basal is too high.
100102
4. GLUCOSE TRENDS: Look at the slope of hourly averages. A consistent rise over 3+ hours \
101103
during fasting = basal too low. A consistent drop = basal too high.
102-
5. HIGH TIR DOES NOT MEAN PERFECT SETTINGS: If TIR is 90% but the algorithm is issuing 7 \
104+
5. HIGH TIR DOES NOT MEAN PERFECT SETTINGS: If TIR is 90% but the algorithm is issuing 10 \
103105
corrections/day to achieve that, the settings are suboptimal — the algorithm is doing \
104106
heavy lifting to compensate. Better settings = same TIR with fewer corrections.
105107
@@ -128,10 +130,18 @@ final class LoopInsights_AIAnalysis {
128130
Only skip recommendations when TIR is good AND corrections are low AND basal/bolus is balanced.
129131
130132
SAFETY RULES:
131-
1. Never suggest changes larger than 20% from current values.
133+
1. Never suggest changes larger than 20% from current values in a single step.
132134
2. Conservative changes only — under-adjust rather than over-adjust.
133135
3. If time below range is >4%, prioritize safety (raise ISF or lower basal before anything else).
134136
4. Suggestions are advisory only — the user and their healthcare provider make final decisions.
137+
5. ABSOLUTE CLINICAL BOUNDS — proposed values MUST stay within these ranges. Clamp to bound if needed:
138+
- Carb Ratio: 2.0–150.0 g/U (recommended 4.0–28.0)
139+
- ISF: 10.0–500.0 mg/dL/U (recommended 16.0–400.0)
140+
- Basal Rate: 0.05–30.0 U/hr (recommended 0.05–10.0)
141+
Values outside the recommended range should only be proposed with LOW confidence and explicit justification.
142+
6. CUMULATIVE CHANGE AWARENESS: If recent settings changes are listed above, do NOT stack \
143+
additional changes on top. Settings changes need time (3-7 days minimum) to show effect in the data. \
144+
If the data predates a recent change, recommend waiting for new data before adjusting further.
135145
136146
BIOMETRIC CONTEXT — When biometric data is provided:
137147
- HEART RATE: Elevated resting HR or HR spikes can indicate stress, illness, caffeine, or \
@@ -485,10 +495,46 @@ final class LoopInsights_AIAnalysis {
485495

486496
guard !timeBlocks.isEmpty else { continue }
487497

498+
// Post-parse safety validation: reject blocks outside absolute bounds
499+
// and enforce max change percentage as a code-level backstop
500+
let validatedBlocks = timeBlocks.filter { block in
501+
let classification = LoopInsights_SafetyGuardrails.classify(
502+
value: block.proposedValue, settingType: settingType
503+
)
504+
505+
// Hard reject: values outside absolute bounds
506+
if classification == .belowAbsolute || classification == .aboveAbsolute {
507+
LoopInsights_FeatureFlags.log.error(
508+
"Guardrail REJECTED: \(settingType.displayName) proposed \(block.proposedValue) at \(block.startTimeFormatted) — outside absolute bounds"
509+
)
510+
return false
511+
}
512+
513+
// Warn (but pass through): values outside recommended bounds
514+
if classification != .withinRecommended {
515+
LoopInsights_FeatureFlags.log.default(
516+
"Guardrail WARNING: \(settingType.displayName) proposed \(block.proposedValue) at \(block.startTimeFormatted) — outside recommended range"
517+
)
518+
}
519+
520+
// Backstop: reject blocks with >25% change from current
521+
let changePercent = abs(block.changePercent)
522+
if changePercent > LoopInsights_SafetyGuardrails.maxChangePercent {
523+
LoopInsights_FeatureFlags.log.error(
524+
"Guardrail REJECTED: \(settingType.displayName) proposed \(String(format: "%.1f", block.proposedValue)) at \(block.startTimeFormatted)\(String(format: "%.0f", changePercent))%% change exceeds \(String(format: "%.0f", LoopInsights_SafetyGuardrails.maxChangePercent))%% limit"
525+
)
526+
return false
527+
}
528+
529+
return true
530+
}
531+
532+
guard !validatedBlocks.isEmpty else { continue }
533+
488534
let suggestion = LoopInsightsSuggestion(
489535
id: UUID(),
490536
settingType: settingType,
491-
timeBlocks: timeBlocks,
537+
timeBlocks: validatedBlocks,
492538
reasoning: reasoning,
493539
confidence: confidence,
494540
analysisPeriod: period,

Loop/View Models/LoopInsights/LoopInsights_ChatViewModel.swift

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,20 +115,41 @@ final class LoopInsights_ChatViewModel: ObservableObject {
115115
return """
116116
You are an expert diabetes and automated insulin delivery (AID) advisor embedded \
117117
in the Loop app. The user is wearing an insulin pump managed by Loop's closed-loop \
118-
algorithm. You have access to their real therapy settings and recent glucose/insulin/carb \
119-
statistics provided below.
118+
algorithm. You have access to their REAL therapy settings, glucose data, insulin \
119+
delivery data, carb logs, and biometrics — all provided below. This is not hypothetical. \
120+
These are this specific person's actual numbers from their actual pump and CGM.
120121
121122
\(personality.promptInstruction)
122123
124+
YOUR #1 RULE — ALWAYS ANSWER FROM THEIR DATA:
125+
The entire value of this conversation is that you can see this person's real numbers. \
126+
Every answer you give MUST reference their specific data. Do NOT give generic diabetes \
127+
advice that could apply to anyone. The user can Google generic advice — they came here \
128+
because you can see their TIR, their hourly glucose patterns, their basal/bolus split, \
129+
their correction counts, their actual settings schedules. USE THEM.
130+
131+
When the user asks "why am I high overnight?", don't explain what causes overnight highs \
132+
in general — look at THEIR hourly averages from 12AM-6AM, THEIR basal rate during those \
133+
hours, THEIR overnight trend, and tell them what's happening in THEIR data specifically.
134+
135+
When they ask "should I change my carb ratio?", don't explain what a carb ratio does — \
136+
look at THEIR post-meal glucose patterns, THEIR current CR schedule, THEIR carb stats, \
137+
and give them a specific assessment with specific numbers.
138+
123139
GUIDELINES:
124-
- Answer questions about diabetes management, glucose patterns, therapy settings, and Loop.
125-
- Reference the user's actual data when relevant — don't give generic advice when you have specifics.
126-
- If asked about changing settings, explain the expected impact and always recommend conservative changes.
127-
- You may suggest specific therapy setting adjustments. Frame them clearly as suggestions, not commands.
128-
- Always remind users that significant therapy changes should be discussed with their healthcare provider.
140+
- Ground every answer in their actual data. Cite specific numbers: "Your average glucose \
141+
between 12AM-6AM is 162 mg/dL with your basal at 0.8 U/hr" — not "overnight highs can \
142+
be caused by insufficient basal."
143+
- When their data tells a clear story, say so directly. When the data is ambiguous or \
144+
insufficient, say that too — but explain exactly what's missing and why it matters.
145+
- If asked about settings changes, reference their current value, explain what the data \
146+
suggests, and propose a specific adjustment with expected impact.
147+
- Frame suggestions as suggestions, not commands. Significant therapy changes should be \
148+
discussed with their healthcare provider.
129149
- Keep responses concise but thorough. Use bullet points for multi-part answers.
130-
- If you don't have enough data to answer confidently, say so.
131150
- Never fabricate data or statistics — only reference what's provided in the context below.
151+
- If the data context says "No therapy data currently available", tell the user you don't \
152+
have their data loaded yet and suggest they run an analysis first.
132153
133154
CURRENT DATA CONTEXT:
134155
\(therapyContext)

Loop/View Models/LoopInsights/LoopInsights_DashboardViewModel.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,14 @@ final class LoopInsights_DashboardViewModel: ObservableObject {
326326
func confirmApply() {
327327
guard let record = recordToApply else { return }
328328

329+
// Hard block: cannot apply if any proposed value is outside absolute bounds
330+
if record.suggestion.hasAbsoluteViolation {
331+
LoopInsights_FeatureFlags.log.error("confirmApply BLOCKED: suggestion has absolute guardrail violation")
332+
recordToApply = nil
333+
showingApplyConfirmation = false
334+
return
335+
}
336+
329337
let snapshotBefore = try? coordinator.captureCurrentSnapshot()
330338

331339
// Write the therapy settings changes to Loop
@@ -356,6 +364,20 @@ final class LoopInsights_DashboardViewModel: ObservableObject {
356364
func applyEditedSuggestion(editedBlocks: [LoopInsightsTimeBlock]) {
357365
guard let record = recordToApply else { return }
358366

367+
// Validate edited blocks against absolute bounds
368+
let settingType = record.suggestion.settingType
369+
for block in editedBlocks {
370+
let classification = LoopInsights_SafetyGuardrails.classify(
371+
value: block.proposedValue, settingType: settingType
372+
)
373+
if classification == .belowAbsolute || classification == .aboveAbsolute {
374+
LoopInsights_FeatureFlags.log.error("applyEditedSuggestion BLOCKED: edited value \(block.proposedValue) outside absolute bounds for \(settingType.displayName)")
375+
recordToApply = nil
376+
showingPreFillEditor = false
377+
return
378+
}
379+
}
380+
359381
let snapshotBefore = try? coordinator.captureCurrentSnapshot()
360382

361383
// Build a modified suggestion with the user's edited values
@@ -495,6 +517,12 @@ final class LoopInsights_DashboardViewModel: ObservableObject {
495517
// MARK: - Private
496518

497519
private func autoApplySuggestion(_ suggestion: LoopInsightsSuggestion) async {
520+
// Stricter for automated changes: block if ANY value is outside recommended range
521+
if suggestion.hasGuardrailWarning {
522+
LoopInsights_FeatureFlags.log.error("autoApply BLOCKED: suggestion for \(suggestion.settingType.displayName) has guardrail warning — requires manual review")
523+
return
524+
}
525+
498526
let snapshotBefore = try? coordinator.captureCurrentSnapshot()
499527

500528
coordinator.applyTherapyChanges(suggestion: suggestion)

0 commit comments

Comments
 (0)