Skip to content

Commit 70fa00a

Browse files
authored
Add readiness serving contract (#177)
* Add readiness serving contract * Address readiness serving review feedback
1 parent 17c5787 commit 70fa00a

8 files changed

Lines changed: 348 additions & 8 deletions

File tree

README.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,8 +316,8 @@ Available tools:
316316

317317
| Tool | Description |
318318
|---|---|
319-
| `get_health_briefing` | Daily health briefing: composite readiness score (z-score based), sleep analysis, activity, insights, and alerts. Supports `lang` (en/ru/sr). |
320-
| `get_readiness_history` | Composite readiness scores (0-100) for the last N days. |
319+
| `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). |
320+
| `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. |
321321
| `list_metrics` | List all available metrics with record counts and date ranges. |
322322
| `get_dashboard` | Today's summary with trend vs yesterday. |
323323
| `get_metric_data` | Time series for a single metric with minute/hour/day buckets. |
@@ -333,6 +333,32 @@ Available tools:
333333
| `workout_stats` | Aggregate counters for workouts in a range: count, total duration, distance, energy, avg/max HR, total time-in-HR-zone. |
334334
| `sql_query` | Run any read-only SQL SELECT on the PostgreSQL database. |
335335

336+
### Readiness serving contract
337+
338+
`/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.
339+
340+
```json
341+
{
342+
"readiness_serving": {
343+
"status": "fresh | missing | stale | data_accruing | low_coverage | capped",
344+
"confidence": "final | provisional | low",
345+
"reason": "missing_same_day_evidence",
346+
"components": [
347+
{
348+
"metric": "heart_rate_variability",
349+
"present": true,
350+
"freshness": "ok | missing | stale",
351+
"confidence": "final | provisional | low",
352+
"sample_count": 4,
353+
"missing_reason": "missing_same_day_value"
354+
}
355+
]
356+
}
357+
}
358+
```
359+
360+
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.
361+
336362
## Telegram Reports
337363

338364
When `TELEGRAM_TOKEN` and `TELEGRAM_CHAT_ID` are set, the server sends two daily reports:
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<title>Implementation Plan: Issue #166 Readiness Serving/Freshness Contract</title>
6+
<meta name="viewport" content="width=device-width, initial-scale=1">
7+
<style>
8+
body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f6f7f9; color: #1f2937; line-height: 1.5; }
9+
main { max-width: 1100px; margin: 0 auto; padding: 32px; }
10+
section { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin: 16px 0; }
11+
h1, h2 { line-height: 1.2; margin-top: 0; }
12+
code { background: #f3f4f6; padding: 2px 5px; border-radius: 4px; }
13+
.badge { display: inline-block; padding: 4px 10px; border-radius: 999px; background: #fee2e2; color: #991b1b; font-size: 12px; font-weight: 700; }
14+
.note { border-left: 4px solid #2563eb; }
15+
.risk { border-left: 4px solid #dc2626; }
16+
.approval { border-left: 4px solid #d97706; }
17+
</style>
18+
</head>
19+
<body>
20+
<main>
21+
<h1>Implementation Plan: Issue #166 Readiness Serving/Freshness Contract</h1>
22+
<p class="badge">Approved and implemented</p>
23+
24+
<section>
25+
<h2>Task Summary</h2>
26+
<p>
27+
Define and implement the stable readiness freshness/confidence contract that
28+
dashboard, iOS, MCP, and later confidence UI can consume without duplicating
29+
backend logic. This issue is broader than the already-executed
30+
<code>2026-05-28-readiness-serving-freshness-closure.html</code> plan: that
31+
plan closed bounded restamp/backfill wiring, while this one should formalize
32+
the API shape and consumer semantics.
33+
</p>
34+
</section>
35+
36+
<section>
37+
<h2>Current Behavior</h2>
38+
<ul>
39+
<li><code>internal/health/types.go</code> already exposes <code>readiness_confidence</code>, <code>readiness_cap_reason</code>, <code>readiness_components</code>, and per-component <code>freshness</code>, <code>confidence</code>, <code>sample_count</code>, and <code>missing_reason</code> on <code>BriefingResponse</code>.</li>
40+
<li><code>internal/storage/briefing.go</code> builds date-aligned readiness evidence from daily cache plus same-day raw metric fallback, including missing and stale states.</li>
41+
<li><code>/api/health-briefing</code> returns the full <code>BriefingResponse</code>; <code>/api/readiness-history</code> returns score and band only.</li>
42+
<li><code>internal/mcpserver/tools.go</code> has <code>get_health_briefing</code> and <code>get_readiness_history</code>, but tool descriptions still describe the older readiness formula and do not call out freshness/confidence metadata.</li>
43+
<li>Dashboard page rendering currently reads confidence and cap reason into the page data, but confidence UI is intentionally not the goal of this issue.</li>
44+
</ul>
45+
</section>
46+
47+
<section>
48+
<h2>Desired Behavior</h2>
49+
<ul>
50+
<li>One documented server-owned readiness state vocabulary: fresh, stale, missing, data-accruing, low-coverage, and capped/provisional states.</li>
51+
<li>API consumers can decide display state from response fields only; no client should infer freshness from dates or recompute coverage.</li>
52+
<li>Existing score values remain behavior-compatible unless tests expose a mismatch between score and metadata.</li>
53+
<li>Dashboard and MCP continue to work, with richer metadata available and documented for future confidence UI.</li>
54+
</ul>
55+
</section>
56+
57+
<section>
58+
<h2>Assumptions And Unknowns</h2>
59+
<ul>
60+
<li>Assumption: the primary contract should be attached to <code>/api/health-briefing</code> first because it already owns <code>BriefingResponse</code>.</li>
61+
<li>Assumption: <code>/api/readiness-history</code> should stay lightweight unless the confidence UI needs historical confidence states.</li>
62+
<li>Unknown: whether <code>data_accruing</code> should be represented as a new readiness freshness enum or mapped through <code>confidence=provisional</code> plus reason.</li>
63+
<li>Unknown: whether MCP needs a separate concise readiness contract tool or simply better descriptions for existing tools.</li>
64+
</ul>
65+
</section>
66+
67+
<section>
68+
<h2>Files Likely To Change</h2>
69+
<ul>
70+
<li><code>internal/health/types.go</code> - add or tighten response structs/enums only if the current fields cannot express the contract cleanly.</li>
71+
<li><code>internal/storage/briefing.go</code> - normalize evidence mapping and reasons if stale/missing/low-coverage cases are inconsistent.</li>
72+
<li><code>internal/storage/readiness_evidence_test.go</code> or related tests - cover fresh, stale, missing, low-coverage, and data-accruing/provisional cases.</li>
73+
<li><code>internal/ui/handler.go</code> and UI tests - only if response wrapping or dashboard API shape needs explicit metadata preservation.</li>
74+
<li><code>internal/mcpserver/tools.go</code> - update readiness tool descriptions and, if approved, expose concise contract metadata.</li>
75+
<li><code>README.md</code>, <code>docs/ARCHITECTURE.md</code>, or a small new API doc - document the contract for dashboard and MCP/debug consumers.</li>
76+
</ul>
77+
</section>
78+
79+
<section>
80+
<h2>Implementation Steps</h2>
81+
<ol>
82+
<li>Write a short contract table before code edits: fields, allowed values, producer, and intended consumer behavior.</li>
83+
<li>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.</li>
84+
<li>Add focused tests for the current mapping. Prefer tests around pure or near-pure evidence builders before touching handlers.</li>
85+
<li>If the current fields cannot express <code>data_accruing</code> cleanly, add a small explicit reason/status field rather than changing existing score semantics.</li>
86+
<li>Update MCP descriptions and docs so the contract is discoverable.</li>
87+
<li>Run focused tests first, then full Go test/vet before PR.</li>
88+
</ol>
89+
</section>
90+
91+
<section>
92+
<h2>Impact</h2>
93+
<ul>
94+
<li><strong>API:</strong> additive metadata only; no removal or rename of existing JSON fields.</li>
95+
<li><strong>Data/migration:</strong> no schema migration expected.</li>
96+
<li><strong>Auth/permissions:</strong> no change.</li>
97+
<li><strong>UX:</strong> no new confidence UI in this issue; it prepares that work.</li>
98+
<li><strong>Deployment:</strong> normal app deploy after tests if code changes are needed.</li>
99+
</ul>
100+
</section>
101+
102+
<section>
103+
<h2>Test Plan</h2>
104+
<ul>
105+
<li><code>go test ./internal/health ./internal/storage -run "Test.*Readiness.*Evidence|Test.*Readiness.*Confidence" -count=1</code></li>
106+
<li><code>go test ./internal/ui ./internal/mcpserver -count=1</code> if handlers or MCP descriptions are touched.</li>
107+
<li><code>go test ./...</code></li>
108+
<li><code>go vet ./...</code></li>
109+
<li>Optional direct DB smoke with <code>~/.health-db</code>: inspect one current <code>/api/health-briefing</code>-equivalent row and confirm metadata matches actual same-day availability.</li>
110+
</ul>
111+
</section>
112+
113+
<section class="risk">
114+
<h2>Risks And Edge Cases</h2>
115+
<ul>
116+
<li>Changing numeric readiness behavior under a metadata issue would blur scope; keep score changes out unless a contract test reveals a bug.</li>
117+
<li>Adding too many enums could make client logic harder; prefer a small stable vocabulary plus reason strings.</li>
118+
<li>Historical readiness points may not have enough evidence to expose detailed metadata without extra queries.</li>
119+
</ul>
120+
</section>
121+
122+
<section>
123+
<h2>Rollback Plan</h2>
124+
<p>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.</p>
125+
</section>
126+
127+
<section>
128+
<h2>Open Questions</h2>
129+
<ul>
130+
<li>Should <code>/api/readiness-history</code> gain historical confidence metadata now, or should that wait for the confidence UI issue?</li>
131+
<li>Should <code>data_accruing</code> be a freshness enum, a cap reason, or a separate serving state?</li>
132+
<li>Which doc should be canonical for API consumers: README, architecture doc, or a dedicated API contract section?</li>
133+
</ul>
134+
</section>
135+
136+
<section>
137+
<h2>Implementation Result</h2>
138+
<p>
139+
Implemented on 2026-06-06. The change is additive: existing
140+
<code>readiness_confidence</code>, <code>readiness_cap_reason</code>, and
141+
<code>readiness_components</code> fields remain in place, and clients can
142+
now prefer the grouped <code>readiness_serving</code> object.
143+
</p>
144+
<ul>
145+
<li>Added <code>ReadinessServingState</code> with <code>status</code>, <code>confidence</code>, <code>reason</code>, and component evidence.</li>
146+
<li>Added serving status values: <code>fresh</code>, <code>missing</code>, <code>stale</code>, <code>data_accruing</code>, <code>low_coverage</code>, and <code>capped</code>.</li>
147+
<li>Kept readiness score behavior unchanged; the new object summarizes existing evidence/cap metadata for consumers.</li>
148+
<li>Kept serving state synchronized when coherence or illness safety caps update readiness metadata after initial scoring.</li>
149+
<li>Updated MCP descriptions and README documentation so consumers know to use <code>readiness_serving</code> instead of guessing.</li>
150+
<li>Added tests for missing evidence priority, sparse-HRV data-accruing status, illness cap status, low-coverage status, and <code>ComputeBriefing</code> response exposure.</li>
151+
</ul>
152+
</section>
153+
154+
<section>
155+
<h2>Verification Run</h2>
156+
<ul>
157+
<li><code>go test ./internal/health ./internal/storage -run "Test.*Readiness.*Evidence|Test.*Readiness.*Serving|TestComputeBriefing_ExposesReadinessServingContract" -count=1</code> passed.</li>
158+
<li><code>go test ./internal/ui ./internal/mcpserver -count=1</code> passed; <code>internal/mcpserver</code> has no test files but compiled.</li>
159+
<li><code>go test ./internal/health ./internal/storage -count=1</code> passed.</li>
160+
<li><code>go test ./...</code> passed.</li>
161+
<li><code>go vet ./...</code> passed.</li>
162+
</ul>
163+
</section>
164+
165+
<section>
166+
<h2>Known Limitations After Implementation</h2>
167+
<ul>
168+
<li><code>/api/readiness-history</code> still returns lightweight score and band data only; historical confidence metadata remains a future confidence UI decision.</li>
169+
<li>No database migration or historical backfill was added; this is a response-contract change over current evidence.</li>
170+
<li>No new visual confidence UI was added in this issue.</li>
171+
</ul>
172+
</section>
173+
174+
<section class="approval">
175+
<h2>Approval Gate</h2>
176+
<p>Approved and implemented. Further confidence UI or historical readiness metadata should use a separate follow-up plan.</p>
177+
</section>
178+
</main>
179+
</body>
180+
</html>

internal/health/headline.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,4 +343,5 @@ func applyReadinessResponseCap(resp *BriefingResponse, maxScore int, confidence,
343343
if readinessCapReasonRank(reason) > readinessCapReasonRank(resp.ReadinessCapReason) {
344344
resp.ReadinessCapReason = reason
345345
}
346+
updateReadinessServing(resp)
346347
}

internal/health/readiness.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ type readinessComputation struct {
269269
Confidence string
270270
CapReason string
271271
Components []ReadinessComponentSummary
272+
Serving *ReadinessServingState
272273
}
273274

274275
func computeReadinessWithEvidence(d RawMetrics) readinessComputation {
@@ -292,6 +293,7 @@ func computeReadinessWithEvidence(d RawMetrics) readinessComputation {
292293
Confidence: ReadinessConfidenceFinal,
293294
}
294295
if e == nil {
296+
out.Serving = readinessServingState(out.Confidence, out.CapReason, out.Components)
295297
return out
296298
}
297299
out.Components = readinessComponentSummaries(*e)
@@ -323,6 +325,7 @@ func computeReadinessWithEvidence(d RawMetrics) readinessComputation {
323325
case IllnessConfidenceModerate:
324326
out.cap(readinessFairCap, ReadinessConfidenceProvisional, "illness_suspicion_moderate")
325327
}
328+
out.Serving = readinessServingState(out.Confidence, out.CapReason, out.Components)
326329
return out
327330
}
328331

@@ -364,6 +367,52 @@ func readinessCapReasonRank(reason string) int {
364367
}
365368
}
366369

370+
func updateReadinessServing(resp *BriefingResponse) {
371+
if resp == nil {
372+
return
373+
}
374+
resp.ReadinessServing = readinessServingState(resp.ReadinessConfidence, resp.ReadinessCapReason, resp.ReadinessComponents)
375+
}
376+
377+
func readinessServingState(confidence, reason string, components []ReadinessComponentSummary) *ReadinessServingState {
378+
if confidence == "" {
379+
confidence = ReadinessConfidenceFinal
380+
}
381+
status := ReadinessServingFresh
382+
if len(components) == 0 && reason == "" {
383+
status = ReadinessServingMissing
384+
} else if hasComponentFreshness(components, ReadinessFreshnessMissing) || reason == "missing_same_day_evidence" {
385+
status = ReadinessServingMissing
386+
} else if hasComponentFreshness(components, ReadinessFreshnessStale) {
387+
status = ReadinessServingStale
388+
} else if reason == "hrv_provisional" || reason == "hrv_sparse" {
389+
status = ReadinessServingDataAccruing
390+
} else if reason == "sleep_quality_low" {
391+
status = ReadinessServingLowCoverage
392+
} else if reason != "" || confidence == ReadinessConfidenceProvisional {
393+
status = ReadinessServingCapped
394+
}
395+
copied := append([]ReadinessComponentSummary(nil), components...)
396+
return &ReadinessServingState{
397+
Status: status,
398+
Confidence: confidence,
399+
Reason: reason,
400+
Components: copied,
401+
}
402+
}
403+
404+
func hasComponentFreshness(components []ReadinessComponentSummary, freshness string) bool {
405+
for _, c := range components {
406+
switch c.Metric {
407+
case "heart_rate_variability", "resting_heart_rate", "sleep_total":
408+
if c.Freshness == freshness {
409+
return true
410+
}
411+
}
412+
}
413+
return false
414+
}
415+
367416
func readinessComponentSummaries(e ReadinessEvidenceInput) []ReadinessComponentSummary {
368417
components := []ReadinessComponentEvidence{
369418
e.HRV,

0 commit comments

Comments
 (0)