Skip to content

Commit 17c5787

Browse files
authored
Cap false optimal readiness states (#176)
* fix(readiness): cap false optimal recovery states * fix(readiness): address review feedback * fix(readiness): close remaining review gaps
1 parent 59a8d8a commit 17c5787

19 files changed

Lines changed: 1953 additions & 153 deletions

cmd/readiness_probe/main.go

Lines changed: 440 additions & 0 deletions
Large diffs are not rendered by default.

docs/ai-plans/2026-06-04-readiness-false-optimal-hardening.html

Lines changed: 581 additions & 0 deletions
Large diffs are not rendered by default.

internal/ai/blocks.go

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,22 @@ type YesterdayInputs struct {
9696
// design. The RECOVERY prompt itself focuses on autonomic trajectories and
9797
// does not reference EnergyBank.
9898
type RecoveryInputs struct {
99-
LastDate string
100-
HRV []float64
101-
RHR []float64
102-
VO2 []float64
103-
Sleep []float64
104-
Readiness *float64
99+
LastDate string
100+
HRV []float64
101+
RHR []float64
102+
VO2 []float64
103+
Sleep []float64
104+
Context InsightContext
105+
}
106+
107+
type InsightContext struct {
108+
ReadinessScore int `json:"readiness_score,omitempty"`
109+
ReadinessRawScore int `json:"readiness_raw_score,omitempty"`
110+
ReadinessConfidence string `json:"readiness_confidence,omitempty"`
111+
ReadinessCapReason string `json:"readiness_cap_reason,omitempty"`
112+
AIAdviceMode string `json:"ai_advice_mode,omitempty"`
113+
CheckinStatus string `json:"checkin_status,omitempty"`
114+
CheckinAnswer string `json:"checkin_answer,omitempty"`
105115
}
106116

107117
// HashSleep / HashYesterday / HashRecovery extract and hash per-block subsets.
@@ -135,18 +145,18 @@ func HashYesterday(r *health.RawMetrics) string {
135145
})
136146
}
137147

138-
func HashRecovery(r *health.RawMetrics, eb *health.EnergyBank, readiness *float64) string {
148+
func HashRecovery(r *health.RawMetrics, eb *health.EnergyBank, ctx InsightContext) string {
139149
if r == nil {
140150
return ""
141151
}
142152
_ = eb // see RecoveryInputs doc — intra-day fields would defeat the cache
143153
return hashInputs(RecoveryInputs{
144-
LastDate: r.LastDate,
145-
HRV: r.HRV,
146-
RHR: r.RHR,
147-
VO2: r.VO2,
148-
Sleep: r.Sleep,
149-
Readiness: readiness,
154+
LastDate: r.LastDate,
155+
HRV: r.HRV,
156+
RHR: r.RHR,
157+
VO2: r.VO2,
158+
Sleep: r.Sleep,
159+
Context: ctx,
150160
})
151161
}
152162

@@ -163,7 +173,7 @@ func HashRecovery(r *health.RawMetrics, eb *health.EnergyBank, readiness *float6
163173
// EnergyBank fields. Passing the sequence lets RECOMMENDATION pick up
164174
// patterns like "3 rest days in a row" without re-hitting Gemini just
165175
// because today's verdict is unchanged.
166-
func HashRecommendation(sleepText, yesterdayText, recoveryText string, eb *health.EnergyBank, verdictHistory []string) string {
176+
func HashRecommendation(sleepText, yesterdayText, recoveryText string, eb *health.EnergyBank, verdictHistory []string, ctx InsightContext) string {
167177
verdict := ""
168178
var flags []string
169179
if eb != nil {
@@ -180,8 +190,9 @@ func HashRecommendation(sleepText, yesterdayText, recoveryText string, eb *healt
180190
// to surface the rest guidance, even if leaf texts and the
181191
// verdict label are unchanged.
182192
StressFlags []string
193+
Context InsightContext
183194
}
184-
return hashInputs(rec{sleepText, yesterdayText, recoveryText, verdict, verdictHistory, flags})
195+
return hashInputs(rec{sleepText, yesterdayText, recoveryText, verdict, verdictHistory, flags, ctx})
185196
}
186197

187198
// ─── orchestrator ─────────────────────────────────────────────────────────
@@ -200,7 +211,7 @@ type BlockResult struct {
200211
//
201212
// hashes carry the inputs_hash for each block so callers can compare against
202213
// the cache and skip Gemini when nothing has changed since last generation.
203-
func GenerateLeafBlocks(apiKey, model string, maxTokens int, rawMetricsJSON []byte, lang string,
214+
func GenerateLeafBlocks(apiKey, model string, maxTokens int, payloadForBlock func(block string) []byte, lang string,
204215
skipBlock func(block string) bool) []BlockResult {
205216

206217
results := make([]BlockResult, 0, len(LeafBlocks))
@@ -214,7 +225,11 @@ func GenerateLeafBlocks(apiKey, model string, maxTokens int, rawMetricsJSON []by
214225
go func(block string) {
215226
defer wg.Done()
216227
prompt := BuildBlockPrompt(block)
217-
text, _, err := generateWithPrompt(apiKey, model, maxTokens, prompt, rawMetricsJSON, lang)
228+
payload := []byte(nil)
229+
if payloadForBlock != nil {
230+
payload = payloadForBlock(block)
231+
}
232+
text, _, err := generateWithPrompt(apiKey, model, maxTokens, prompt, payload, lang)
218233
resultsMu.Lock()
219234
results = append(results, BlockResult{Block: block, Text: text, Err: err})
220235
resultsMu.Unlock()
@@ -236,15 +251,35 @@ func GenerateLeafBlocks(apiKey, model string, maxTokens int, rawMetricsJSON []by
236251
// proper rest" instead of treating each day in isolation. Empty slice
237252
// is fine — the line is simply omitted from the prompt context.
238253
func GenerateRecommendation(apiKey, model string, maxTokens int, rawMetricsJSON []byte, lang string,
239-
sleepText, yesterdayText, recoveryText string, verdictHistory []string, stressFlags []string) (string, error) {
254+
sleepText, yesterdayText, recoveryText string, verdictHistory []string, stressFlags []string, ctx InsightContext) (string, error) {
240255
prompt := BuildBlockPrompt(BlockRecommendation)
256+
context := BuildRecommendationContext(sleepText, yesterdayText, recoveryText, verdictHistory, stressFlags, ctx)
257+
text, _, err := generateWithPrompt(apiKey, model, maxTokens, prompt, append(rawMetricsJSON, []byte(context)...), lang)
258+
return text, err
259+
}
260+
261+
func BuildRecommendationContext(sleepText, yesterdayText, recoveryText string, verdictHistory []string, stressFlags []string, ctx InsightContext) string {
241262
leafSummary := fmt.Sprintf(
242263
"\n\nLEAF BLOCKS (already generated for the user):\n\nSLEEP\n%s\n\nYESTERDAY\n%s\n\nRECOVERY\n%s",
243264
sleepText, yesterdayText, recoveryText)
244265
if len(verdictHistory) > 0 {
245266
leafSummary += "\n\nENERGYBANK_VERDICT_HISTORY (oldest→newest, last 7 EOD snapshots): " +
246267
strings.Join(verdictHistory, ", ")
247268
}
269+
if ctx.AIAdviceMode != "" {
270+
leafSummary += "\n\nREADINESS_EVIDENCE_CONTEXT: advice_mode=" + ctx.AIAdviceMode +
271+
fmt.Sprintf(", readiness=%d, raw_readiness=%d, confidence=%s, cap_reason=%s",
272+
ctx.ReadinessScore, ctx.ReadinessRawScore, ctx.ReadinessConfidence, ctx.ReadinessCapReason)
273+
if ctx.CheckinStatus != "" || ctx.CheckinAnswer != "" {
274+
leafSummary += fmt.Sprintf(", checkin_status=%s, checkin_answer=%s", ctx.CheckinStatus, ctx.CheckinAnswer)
275+
}
276+
}
277+
switch ctx.AIAdviceMode {
278+
case "withheld":
279+
leafSummary += "\nINSTRUCTION: Do not provide a training or recovery recommendation. Explain briefly that the system is waiting for today's recovery data."
280+
case "provisional_explanation_only", "needs_regeneration_after_sync":
281+
leafSummary += "\nINSTRUCTION: Treat today's readiness as provisional. Explain what is known, what is missing, and how the user's check-in aligns or conflicts with objective signals. Do NOT give confident push-hard advice."
282+
}
248283
// v2.2 §4.3 hard guard for the recommendation prose — the model
249284
// has a known failure mode where verdict=active_recovery but the
250285
// prose still says "you can push if you feel up to it". When
@@ -264,6 +299,5 @@ func GenerateRecommendation(apiKey, model string, maxTokens int, rawMetricsJSON
264299
leafSummary += "\n\nSTRESS_FLAGS (active multi-channel signals): " +
265300
strings.Join(stressFlags, ", ")
266301
}
267-
text, _, err := generateWithPrompt(apiKey, model, maxTokens, prompt, append(rawMetricsJSON, []byte(leafSummary)...), lang)
268-
return text, err
302+
return leafSummary
269303
}

internal/ai/blocks_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package ai
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"health-receiver/internal/health"
8+
)
9+
10+
func TestHashRecoveryIncludesReadinessContextAndCheckin(t *testing.T) {
11+
raw := &health.RawMetrics{
12+
LastDate: "2026-06-04",
13+
HRV: []float64{40, 41, 42},
14+
RHR: []float64{60, 61, 62},
15+
Sleep: []float64{7, 7.5, 8},
16+
}
17+
base := InsightContext{
18+
ReadinessScore: 65,
19+
ReadinessRawScore: 95,
20+
ReadinessConfidence: health.ReadinessConfidenceProvisional,
21+
ReadinessCapReason: "missing_same_day_evidence",
22+
AIAdviceMode: "needs_regeneration_after_sync",
23+
CheckinStatus: "answered",
24+
CheckinAnswer: "meh",
25+
}
26+
changed := base
27+
changed.CheckinAnswer = "sick"
28+
29+
if got, wantNot := HashRecovery(raw, nil, base), HashRecovery(raw, nil, changed); got == wantNot {
30+
t.Fatalf("HashRecovery did not change after check-in answer changed")
31+
}
32+
}
33+
34+
func TestHashRecommendationIncludesReadinessContextAndCheckin(t *testing.T) {
35+
base := InsightContext{AIAdviceMode: "confident_advice_allowed", CheckinStatus: "answered", CheckinAnswer: "ok"}
36+
changed := base
37+
changed.AIAdviceMode = "provisional_explanation_only"
38+
39+
got := HashRecommendation("sleep", "yesterday", "recovery", nil, []string{"moderate"}, base)
40+
wantNot := HashRecommendation("sleep", "yesterday", "recovery", nil, []string{"moderate"}, changed)
41+
if got == wantNot {
42+
t.Fatalf("HashRecommendation did not change after AI advice mode changed")
43+
}
44+
}
45+
46+
func TestGenerateRecommendationAddsProvisionalInstruction(t *testing.T) {
47+
ctx := InsightContext{
48+
ReadinessScore: 65,
49+
ReadinessRawScore: 95,
50+
ReadinessConfidence: health.ReadinessConfidenceProvisional,
51+
ReadinessCapReason: "missing_same_day_evidence",
52+
AIAdviceMode: "provisional_explanation_only",
53+
CheckinStatus: "answered",
54+
CheckinAnswer: "sick",
55+
}
56+
got := BuildRecommendationContext("sleep", "yesterday", "recovery", nil, nil, ctx)
57+
for _, want := range []string{
58+
"READINESS_EVIDENCE_CONTEXT",
59+
"advice_mode=provisional_explanation_only",
60+
"checkin_answer=sick",
61+
"Treat today's readiness as provisional",
62+
"Do NOT give confident push-hard advice",
63+
} {
64+
if !strings.Contains(got, want) {
65+
t.Fatalf("recommendation context missing %q:\n%s", want, got)
66+
}
67+
}
68+
}

internal/health/headline.go

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,32 @@ import (
88
// Cross-metric stress thresholds — chosen with explicit research anchors.
99
//
1010
// rhrEvolveAbsBpm (5 bpm) — Wearable HF decompensation 2025 (MDPI):
11-
// nocturnal RHR rise > 5 bpm doubled CV hospitalisation/mortality risk.
12-
// Below this delta the signal is dominated by device noise (Dial 2025).
11+
//
12+
// nocturnal RHR rise > 5 bpm doubled CV hospitalisation/mortality risk.
13+
// Below this delta the signal is dominated by device noise (Dial 2025).
1314
//
1415
// sleepDebtThresholdH (6.5 h) — sits below the AASM ≥ 7 h recommendation
15-
// (Watson 2015) but above the U-curve worst-case (< 5 h, Li 2025), which
16-
// gives a "functional debt" zone where additional autonomic stress
17-
// should not be ignored.
16+
//
17+
// (Watson 2015) but above the U-curve worst-case (< 5 h, Li 2025), which
18+
// gives a "functional debt" zone where additional autonomic stress
19+
// should not be ignored.
1820
//
1921
// hrvDepressedZ (-1.0) — 1 SD below personal baseline, matching the
20-
// dynamic threshold approach already used in scoreRecovery (and
21-
// recommended by Beattie 2024, Plews 2014).
22+
//
23+
// dynamic threshold approach already used in scoreRecovery (and
24+
// recommended by Beattie 2024, Plews 2014).
2225
//
2326
// awakeFragmentedH (0.5 h) — same threshold used by scoreSleep for the
24-
// "consolidated sleep" bonus; > 30 min of nightly wake is the line
25-
// above which sleep stops being restorative.
27+
//
28+
// "consolidated sleep" bonus; > 30 min of nightly wake is the line
29+
// above which sleep stops being restorative.
2630
//
2731
// stressSignalsRequired (2) — multi-signal converging evidence rule
28-
// (Meeusen 2013, Plews 2014). One signal alone has too many benign
29-
// explanations (illness, alcohol, late workout); two or more
30-
// simultaneously is the recognised overreaching/early-illness pattern
31-
// (Mishra 2024, Persistent COVID changes 2025).
32+
//
33+
// (Meeusen 2013, Plews 2014). One signal alone has too many benign
34+
// explanations (illness, alcohol, late workout); two or more
35+
// simultaneously is the recognised overreaching/early-illness pattern
36+
// (Mishra 2024, Persistent COVID changes 2025).
3237
const (
3338
rhrElevateAbsBpm = 5.0
3439
sleepDebtThresholdH = 6.5
@@ -304,27 +309,38 @@ func applyCoherencePass(resp *BriefingResponse, ls LangStrings) {
304309
// converging stress markers contradict an "Optimal" verdict even if
305310
// the numeric score from any single component looks fine.
306311
if resp.ReadinessScore > 65 {
307-
resp.ReadinessScore = 65
308-
resp.ReadinessBand = ReadinessBand(65)
309-
resp.ReadinessLabel = ls["readiness_fair"]
310-
resp.ReadinessTip = ls["tip_fair"]
311-
resp.RecoveryPct = 65
312-
resp.ReadinessToday = 65
313-
resp.ReadinessTodayBand = ReadinessBand(65)
314-
resp.ReadinessTodayLabel = ls["readiness_fair"]
312+
applyReadinessResponseCap(resp, 65, ReadinessConfidenceProvisional, "stress_headline", ls)
315313
}
316314
case "sleep_debt":
317315
// Sleep < 6.5h alone shouldn't push readiness over 65 (Watson 2015,
318316
// Walker 2017): one good HRV/RHR night doesn't pay off slept-debt.
319317
if resp.ReadinessScore > 65 {
320-
resp.ReadinessScore = 65
321-
resp.ReadinessBand = ReadinessBand(65)
322-
resp.ReadinessLabel = ls["readiness_fair"]
323-
resp.ReadinessTip = ls["tip_fair"]
324-
resp.RecoveryPct = 65
325-
resp.ReadinessToday = 65
326-
resp.ReadinessTodayBand = ReadinessBand(65)
327-
resp.ReadinessTodayLabel = ls["readiness_fair"]
318+
applyReadinessResponseCap(resp, 65, ReadinessConfidenceProvisional, "sleep_debt_headline", ls)
328319
}
329320
}
330321
}
322+
323+
func applyReadinessResponseCap(resp *BriefingResponse, maxScore int, confidence, reason string, ls LangStrings) {
324+
if resp == nil {
325+
return
326+
}
327+
if resp.ReadinessRawScore == 0 {
328+
resp.ReadinessRawScore = resp.ReadinessScore
329+
}
330+
if resp.ReadinessScore > maxScore {
331+
resp.ReadinessScore = maxScore
332+
}
333+
resp.ReadinessDisplayScore = resp.ReadinessScore
334+
resp.ReadinessBand = ReadinessBand(resp.ReadinessScore)
335+
resp.ReadinessLabel, resp.ReadinessTip = readinessLabelTip(resp.ReadinessScore, ls)
336+
resp.RecoveryPct = resp.ReadinessScore
337+
resp.ReadinessToday = resp.ReadinessScore
338+
resp.ReadinessTodayBand = resp.ReadinessBand
339+
resp.ReadinessTodayLabel = resp.ReadinessLabel
340+
if readinessConfidenceRank(confidence) > readinessConfidenceRank(resp.ReadinessConfidence) {
341+
resp.ReadinessConfidence = confidence
342+
}
343+
if readinessCapReasonRank(reason) > readinessCapReasonRank(resp.ReadinessCapReason) {
344+
resp.ReadinessCapReason = reason
345+
}
346+
}

internal/health/illness.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,17 +206,19 @@ func ComputeIllnessSuspicion(in IllnessEvidenceInput) *IllnessSuspicion {
206206
// caps the prescriptive EnergyBank action when illness evidence says a hard
207207
// recommendation would be unsafe.
208208
func ApplyIllnessSafetyCap(resp *BriefingResponse, ls LangStrings) {
209-
if resp == nil || resp.EnergyBank == nil || resp.IllnessSuspicion == nil {
209+
if resp == nil || resp.IllnessSuspicion == nil {
210210
return
211211
}
212212
switch resp.IllnessSuspicion.Confidence {
213213
case IllnessConfidenceHigh:
214-
if verdictRank(resp.EnergyBank.ActionVerdict) > verdictRank("rest") {
214+
applyReadinessResponseCap(resp, readinessLowCap, ReadinessConfidenceLow, "illness_suspicion_high", ls)
215+
if resp.EnergyBank != nil && verdictRank(resp.EnergyBank.ActionVerdict) > verdictRank("rest") {
215216
resp.EnergyBank.ActionVerdict = "rest"
216217
resp.EnergyBank.VerdictReason = ls["energy_reason_illness_suspicion_high"]
217218
}
218219
case IllnessConfidenceModerate:
219-
if verdictRank(resp.EnergyBank.ActionVerdict) > verdictRank("active_recovery") {
220+
applyReadinessResponseCap(resp, readinessFairCap, ReadinessConfidenceProvisional, "illness_suspicion_moderate", ls)
221+
if resp.EnergyBank != nil && verdictRank(resp.EnergyBank.ActionVerdict) > verdictRank("active_recovery") {
220222
resp.EnergyBank.ActionVerdict = "active_recovery"
221223
if resp.IllnessSuspicion.Pattern == IllnessPatternAutonomicProdrome {
222224
resp.EnergyBank.VerdictReason = ls["energy_reason_autonomic_prodrome_moderate"]

0 commit comments

Comments
 (0)