diff --git a/README.md b/README.md index db0fadc..d52bfc1 100644 --- a/README.md +++ b/README.md @@ -316,8 +316,8 @@ Available tools: | Tool | Description | |---|---| -| `get_health_briefing` | Daily health briefing: composite readiness score (z-score based), sleep analysis, activity, insights, and alerts. Supports `lang` (en/ru/sr). | -| `get_readiness_history` | Composite readiness scores (0-100) for the last N days. | +| `get_health_briefing` | Daily health briefing with readiness score, server-owned `readiness_serving` freshness/confidence metadata, sleep analysis, activity, insights, and alerts. Supports `lang` (en/ru/sr). | +| `get_readiness_history` | Composite readiness scores (0-100) and server-derived bands for the last N days. Use `get_health_briefing` for today's freshness/confidence contract. | | `list_metrics` | List all available metrics with record counts and date ranges. | | `get_dashboard` | Today's summary with trend vs yesterday. | | `get_metric_data` | Time series for a single metric with minute/hour/day buckets. | @@ -333,6 +333,32 @@ Available tools: | `workout_stats` | Aggregate counters for workouts in a range: count, total duration, distance, energy, avg/max HR, total time-in-HR-zone. | | `sql_query` | Run any read-only SQL SELECT on the PostgreSQL database. | +### Readiness serving contract + +`/api/health-briefing` and MCP `get_health_briefing` expose `readiness_serving` as the canonical contract for clients that need to render score freshness or uncertainty. Prefer this grouped object over recomputing state from dates or metric counts client-side. + +```json +{ + "readiness_serving": { + "status": "fresh | missing | stale | data_accruing | low_coverage | capped", + "confidence": "final | provisional | low", + "reason": "missing_same_day_evidence", + "components": [ + { + "metric": "heart_rate_variability", + "present": true, + "freshness": "ok | missing | stale", + "confidence": "final | provisional | low", + "sample_count": 4, + "missing_reason": "missing_same_day_value" + } + ] + } +} +``` + +The legacy top-level `readiness_confidence`, `readiness_cap_reason`, and `readiness_components` fields remain for compatibility. `readiness_serving.status` is the display-oriented summary: `missing` means a core same-day input is absent, `data_accruing` means same-day data exists but is still too sparse for final confidence, `low_coverage` means a quality/coverage gate fired, and `capped` means a safety or coherence cap changed the displayed score without implying missing data. + ## Telegram Reports When `TELEGRAM_TOKEN` and `TELEGRAM_CHAT_ID` are set, the server sends two daily reports: diff --git a/docs/ai-plans/2026-06-06-issue-166-readiness-serving-freshness-contract.html b/docs/ai-plans/2026-06-06-issue-166-readiness-serving-freshness-contract.html new file mode 100644 index 0000000..0d7f2e7 --- /dev/null +++ b/docs/ai-plans/2026-06-06-issue-166-readiness-serving-freshness-contract.html @@ -0,0 +1,180 @@ + + + + + Implementation Plan: Issue #166 Readiness Serving/Freshness Contract + + + + +
+

Implementation Plan: Issue #166 Readiness Serving/Freshness Contract

+

Approved and implemented

+ +
+

Task Summary

+

+ Define and implement the stable readiness freshness/confidence contract that + dashboard, iOS, MCP, and later confidence UI can consume without duplicating + backend logic. This issue is broader than the already-executed + 2026-05-28-readiness-serving-freshness-closure.html plan: that + plan closed bounded restamp/backfill wiring, while this one should formalize + the API shape and consumer semantics. +

+
+ +
+

Current Behavior

+ +
+ +
+

Desired Behavior

+ +
+ +
+

Assumptions And Unknowns

+ +
+ +
+

Files Likely To Change

+ +
+ +
+

Implementation Steps

+
    +
  1. Write a short contract table before code edits: fields, allowed values, producer, and intended consumer behavior.
  2. +
  3. Audit current readiness evidence outputs against that table for fresh, stale, missing same-day value, low HRV sample count, missing sleep-stage details, and illness/stress cap scenarios.
  4. +
  5. Add focused tests for the current mapping. Prefer tests around pure or near-pure evidence builders before touching handlers.
  6. +
  7. If the current fields cannot express data_accruing cleanly, add a small explicit reason/status field rather than changing existing score semantics.
  8. +
  9. Update MCP descriptions and docs so the contract is discoverable.
  10. +
  11. Run focused tests first, then full Go test/vet before PR.
  12. +
+
+ +
+

Impact

+ +
+ +
+

Test Plan

+ +
+ +
+

Risks And Edge Cases

+ +
+ +
+

Rollback Plan

+

Revert additive metadata and documentation changes. No database rollback should be required. Existing consumers should continue to work throughout because the plan avoids removing current fields.

+
+ +
+

Open Questions

+ +
+ +
+

Implementation Result

+

+ Implemented on 2026-06-06. The change is additive: existing + readiness_confidence, readiness_cap_reason, and + readiness_components fields remain in place, and clients can + now prefer the grouped readiness_serving object. +

+ +
+ +
+

Verification Run

+ +
+ +
+

Known Limitations After Implementation

+ +
+ +
+

Approval Gate

+

Approved and implemented. Further confidence UI or historical readiness metadata should use a separate follow-up plan.

+
+
+ + diff --git a/internal/health/headline.go b/internal/health/headline.go index 8a355bc..c7fd47a 100644 --- a/internal/health/headline.go +++ b/internal/health/headline.go @@ -343,4 +343,5 @@ func applyReadinessResponseCap(resp *BriefingResponse, maxScore int, confidence, if readinessCapReasonRank(reason) > readinessCapReasonRank(resp.ReadinessCapReason) { resp.ReadinessCapReason = reason } + updateReadinessServing(resp) } diff --git a/internal/health/readiness.go b/internal/health/readiness.go index 8a572e1..ce96715 100644 --- a/internal/health/readiness.go +++ b/internal/health/readiness.go @@ -269,6 +269,7 @@ type readinessComputation struct { Confidence string CapReason string Components []ReadinessComponentSummary + Serving *ReadinessServingState } func computeReadinessWithEvidence(d RawMetrics) readinessComputation { @@ -292,6 +293,7 @@ func computeReadinessWithEvidence(d RawMetrics) readinessComputation { Confidence: ReadinessConfidenceFinal, } if e == nil { + out.Serving = readinessServingState(out.Confidence, out.CapReason, out.Components) return out } out.Components = readinessComponentSummaries(*e) @@ -323,6 +325,7 @@ func computeReadinessWithEvidence(d RawMetrics) readinessComputation { case IllnessConfidenceModerate: out.cap(readinessFairCap, ReadinessConfidenceProvisional, "illness_suspicion_moderate") } + out.Serving = readinessServingState(out.Confidence, out.CapReason, out.Components) return out } @@ -364,6 +367,52 @@ func readinessCapReasonRank(reason string) int { } } +func updateReadinessServing(resp *BriefingResponse) { + if resp == nil { + return + } + resp.ReadinessServing = readinessServingState(resp.ReadinessConfidence, resp.ReadinessCapReason, resp.ReadinessComponents) +} + +func readinessServingState(confidence, reason string, components []ReadinessComponentSummary) *ReadinessServingState { + if confidence == "" { + confidence = ReadinessConfidenceFinal + } + status := ReadinessServingFresh + if len(components) == 0 && reason == "" { + status = ReadinessServingMissing + } else if hasComponentFreshness(components, ReadinessFreshnessMissing) || reason == "missing_same_day_evidence" { + status = ReadinessServingMissing + } else if hasComponentFreshness(components, ReadinessFreshnessStale) { + status = ReadinessServingStale + } else if reason == "hrv_provisional" || reason == "hrv_sparse" { + status = ReadinessServingDataAccruing + } else if reason == "sleep_quality_low" { + status = ReadinessServingLowCoverage + } else if reason != "" || confidence == ReadinessConfidenceProvisional { + status = ReadinessServingCapped + } + copied := append([]ReadinessComponentSummary(nil), components...) + return &ReadinessServingState{ + Status: status, + Confidence: confidence, + Reason: reason, + Components: copied, + } +} + +func hasComponentFreshness(components []ReadinessComponentSummary, freshness string) bool { + for _, c := range components { + switch c.Metric { + case "heart_rate_variability", "resting_heart_rate", "sleep_total": + if c.Freshness == freshness { + return true + } + } + } + return false +} + func readinessComponentSummaries(e ReadinessEvidenceInput) []ReadinessComponentSummary { components := []ReadinessComponentEvidence{ e.HRV, diff --git a/internal/health/readiness_evidence_test.go b/internal/health/readiness_evidence_test.go index 3cfdd4e..4c8c48c 100644 --- a/internal/health/readiness_evidence_test.go +++ b/internal/health/readiness_evidence_test.go @@ -51,6 +51,9 @@ func TestReadinessEvidence_SparseHRVCapsConfidence(t *testing.T) { if got.CapReason != "hrv_provisional" { t.Fatalf("cap reason = %q", got.CapReason) } + if got.Serving == nil || got.Serving.Status != ReadinessServingDataAccruing { + t.Fatalf("serving = %+v, want data_accruing", got.Serving) + } } func TestReadinessEvidence_IllnessCapsDisplayNotRaw(t *testing.T) { @@ -80,6 +83,9 @@ func TestReadinessEvidence_IllnessCapsDisplayNotRaw(t *testing.T) { if got.CapReason != "illness_suspicion_high" { t.Fatalf("cap reason = %q", got.CapReason) } + if got.Serving == nil || got.Serving.Status != ReadinessServingCapped { + t.Fatalf("serving = %+v, want capped", got.Serving) + } } func TestApplyIllnessSafetyCap_UpdatesMetadataAtBoundary(t *testing.T) { @@ -104,6 +110,9 @@ func TestApplyIllnessSafetyCap_UpdatesMetadataAtBoundary(t *testing.T) { if resp.ReadinessCapReason != "illness_suspicion_moderate" { t.Fatalf("cap reason = %q", resp.ReadinessCapReason) } + if resp.ReadinessServing == nil || resp.ReadinessServing.Status != ReadinessServingCapped { + t.Fatalf("serving = %+v, want capped", resp.ReadinessServing) + } } func TestApplyIllnessSafetyCap_HighIllnessReasonWinsOverSleepQuality(t *testing.T) { @@ -150,6 +159,60 @@ func TestReadinessEvidence_LowSleepQualityPenalizesAndCaps(t *testing.T) { if got.CapReason != "sleep_quality_low" { t.Fatalf("cap reason = %q", got.CapReason) } + if got.Serving == nil || got.Serving.Status != ReadinessServingLowCoverage { + t.Fatalf("serving = %+v, want low_coverage", got.Serving) + } +} + +func TestReadinessServingState_MissingCoreComponentWins(t *testing.T) { + state := readinessServingState(ReadinessConfidenceProvisional, "missing_same_day_evidence", []ReadinessComponentSummary{ + {Metric: "heart_rate_variability", Present: true, Freshness: ReadinessFreshnessOK, Confidence: ReadinessConfidenceFinal}, + {Metric: "resting_heart_rate", Present: false, Freshness: ReadinessFreshnessMissing, Confidence: ReadinessConfidenceFinal, MissingReason: "missing_same_day_value"}, + {Metric: "sleep_total", Present: true, Freshness: ReadinessFreshnessOK, Confidence: ReadinessConfidenceFinal}, + }) + if state.Status != ReadinessServingMissing { + t.Fatalf("status = %q, want missing", state.Status) + } + if state.Confidence != ReadinessConfidenceProvisional || state.Reason != "missing_same_day_evidence" { + t.Fatalf("state = %+v, want provisional missing_same_day_evidence", state) + } +} + +func TestReadinessServingState_EvidenceFreeInputIsMissing(t *testing.T) { + state := readinessServingState(ReadinessConfidenceFinal, "", nil) + if state.Status != ReadinessServingMissing { + t.Fatalf("status = %q, want missing", state.Status) + } + if state.Confidence != ReadinessConfidenceFinal { + t.Fatalf("confidence = %q, want final", state.Confidence) + } +} + +func TestComputeBriefing_ExposesReadinessServingContract(t *testing.T) { + d := RawMetrics{ + LastDate: "2026-06-04", + HRV: append([]float64{90}, repeatFloat(40, 20)...), + RHR: repeatFloat(55, 21), + Sleep: repeatFloat(7.5, 21), + ReadinessEvidence: &ReadinessEvidenceInput{ + Date: "2026-06-04", + HRV: presentReadinessComponent("heart_rate_variability", 90, 2), + RHR: presentReadinessComponent("resting_heart_rate", 55, 1), + SleepDuration: presentReadinessComponent("sleep_total", 7.5, 0), + }, + } + d.ReadinessEvidence.HRV.Confidence = ReadinessConfidenceProvisional + + resp := ComputeBriefing(d, "en") + if resp.ReadinessServing == nil { + t.Fatal("readiness serving contract missing") + } + if resp.ReadinessServing.Status != ReadinessServingDataAccruing { + t.Fatalf("serving status = %q, want data_accruing", resp.ReadinessServing.Status) + } + if len(resp.ReadinessServing.Components) == 0 { + t.Fatal("serving components missing") + } } func presentReadinessComponent(metric string, value float64, samples int) ReadinessComponentEvidence { diff --git a/internal/health/scoring.go b/internal/health/scoring.go index 2c674c6..1153274 100644 --- a/internal/health/scoring.go +++ b/internal/health/scoring.go @@ -47,6 +47,7 @@ func ComputeBriefing(d RawMetrics, lang string) *BriefingResponse { ReadinessConfidence: readiness.Confidence, ReadinessCapReason: readiness.CapReason, ReadinessComponents: readiness.Components, + ReadinessServing: readiness.Serving, RecoverySource: ReadinessRecoverySourceLegacyAlias, RecoveryPct: readinessScore, ReadinessToday: readinessScore, @@ -64,6 +65,7 @@ func ComputeBriefing(d RawMetrics, lang string) *BriefingResponse { // "all good" in section cards (converging-evidence rule, Meeusen 2013). applyCoherencePass(resp, ls) resp.Overall = overallStatus(resp.Sections) // re-derive after coherence + updateReadinessServing(resp) // Energy Bank runs *after* the coherence pass so a stress-capped readiness // flows in as morning capacity, and so the verdict can downgrade diff --git a/internal/health/types.go b/internal/health/types.go index 24ff019..84f27f5 100644 --- a/internal/health/types.go +++ b/internal/health/types.go @@ -443,9 +443,15 @@ type BriefingResponse struct { ReadinessConfidence string `json:"readiness_confidence,omitempty"` ReadinessCapReason string `json:"readiness_cap_reason,omitempty"` ReadinessComponents []ReadinessComponentSummary `json:"readiness_components,omitempty"` - RecoverySource string `json:"recovery_source,omitempty"` - RecoveryPct int `json:"recovery_pct"` - ReadinessToday int `json:"readiness_today"` // today only vs baseline + // ReadinessServing is the canonical serving/freshness contract for + // dashboards, MCP clients, and future confidence UI. The older + // top-level readiness_confidence/readiness_cap_reason/components fields + // remain for compatibility, but clients should prefer this grouped + // object when they need to render freshness or uncertainty states. + ReadinessServing *ReadinessServingState `json:"readiness_serving,omitempty"` + RecoverySource string `json:"recovery_source,omitempty"` + RecoveryPct int `json:"recovery_pct"` + ReadinessToday int `json:"readiness_today"` // today only vs baseline // ReadinessTodayBand mirrors ReadinessBand for the today-only score. ReadinessTodayBand string `json:"readiness_today_band"` ReadinessTodayLabel string `json:"readiness_today_label"` @@ -477,6 +483,13 @@ const ( ReadinessFreshnessMissing = "missing" ReadinessFreshnessStale = "stale" + ReadinessServingFresh = "fresh" + ReadinessServingMissing = "missing" + ReadinessServingStale = "stale" + ReadinessServingDataAccruing = "data_accruing" + ReadinessServingLowCoverage = "low_coverage" + ReadinessServingCapped = "capped" + ReadinessRecoverySourceLegacyAlias = "readiness_legacy_alias" ) @@ -516,6 +529,13 @@ type ReadinessComponentSummary struct { MissingReason string `json:"missing_reason,omitempty"` } +type ReadinessServingState struct { + Status string `json:"status"` + Confidence string `json:"confidence"` + Reason string `json:"reason,omitempty"` + Components []ReadinessComponentSummary `json:"components,omitempty"` +} + // SubjectiveCheckinSummary is the read-shape rendered into the // dashboard hero. nil when no check-in row exists for today. type SubjectiveCheckinSummary struct { diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go index f120084..944ca48 100644 --- a/internal/mcpserver/tools.go +++ b/internal/mcpserver/tools.go @@ -15,7 +15,7 @@ import ( func registerMetricTools(s *server.MCPServer, _ DBResolver) { s.AddTool(mcp.NewTool("get_health_briefing", - mcp.WithDescription("Get a full daily health briefing: composite readiness score (z-score based: today 60% + 7-day trend 40%, components HRV 40% + RHR 25% + Sleep 35% vs 30-day personal baseline; 70 = your norm, 85 = good, 55 = rough day), sleep analysis with per-source breakdown, recovery, activity, cardio sections, insights, and health alerts. Best starting point."), + mcp.WithDescription("Get a full daily health briefing with readiness score, server-owned readiness_serving freshness/confidence metadata, sleep analysis, recovery, activity, cardio sections, EnergyBank, insights, and health alerts. Best starting point for consumers that must not guess missing/stale/low-coverage states."), mcp.WithString("lang", mcp.Description("Response language: en, ru, sr (default: en)")), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { lang := req.GetString("lang", "en") @@ -41,7 +41,7 @@ func registerMetricTools(s *server.MCPServer, _ DBResolver) { }) s.AddTool(mcp.NewTool("get_readiness_history", - mcp.WithDescription("Get daily composite readiness scores (0–100) for the last N days. Z-score based: today 60% + 7-day trend 40%. Components: HRV 40%, RHR 25%, Sleep 35% (duration + consistency). Score 70 = personal baseline, 85 = 1 SD above, 55 = 1 SD below."), + mcp.WithDescription("Get daily composite readiness scores (0–100) and server-derived readiness bands for the last N days. For today's freshness/confidence contract, use get_health_briefing and read readiness_serving."), mcp.WithNumber("days", mcp.Description("Number of recent days (default: 30)")), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { days := req.GetInt("days", 30) @@ -52,7 +52,6 @@ func registerMetricTools(s *server.MCPServer, _ DBResolver) { return jsonResult(map[string]any{"days": days, "points": pts}) }) - s.AddTool(mcp.NewTool("list_metrics", mcp.WithDescription("List all available health metrics with record counts and date ranges. Call this first to discover what data is available."), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {