fix(insights): wire grip / pain / waist / waist-to-height across web and MCP

Grip strength, pain (0–10 NRS), waist circumference, and waist-to-height
existed in the measurement enum, signal registry, and FHIR export but had
no web entry, no detail page, single non-sex-aware bands, and a split-brain
MCP exposure. This closes those gaps.

Web capture: the four signals join the manual measurement form, so a
web-only self-hoster can log them (the iOS client and the measurements API
already could).

Detail pages: flip detailPage on for the four signals and mount each on the
shared HealthKit metric template (chart, stat strip, Coach-read strip,
assessment card) via routed sub-pages and tab-strip pills, the same detail
spine every other metric carries.

Sex-aware bands: grip strength (EWGSOP2 men 27 / women 16 kg) and waist
(WHO men 94 / women 80 cm) now classify by the profile sex through the
age/sex norm resolver the assessment already threads; the resolver also
returns a sex-specific, age-independent band when no date of birth is set.
Fix the waist-to-height boundary so the NICE >= 0.5 rule is correct (the
last in-range value is 0.49).

MCP single source of truth: decouple the registry mcp facet from the Coach
snapshot — a signal can be MCP-readable without being on the Coach surface.
Mark the four physical signals mcp:true, derive the MCP rich-reads clinical
set from the registry flag instead of a duplicate allowlist, and relax the
invariant to coachSnapshot implies mcp (not the reverse). PHQ-9 / GAD-7 and
the environmental signals stay mcp:false and unreachable. compare_metric and
detect_changepoints now advertise clinical-signal support to match
get_metric_baseline.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 18:33:53 +02:00
1 parent 4f39058629
commit a69697b50f
25 files changed
+726 -62

No files matched your search

+5 -1
View File
@@ -87,7 +87,11 @@ const layoutSchema = z.object({
}),
)
.min(1)
.max(50)
// Cap at the accepted-id universe (canonical slugs + legacy aliases) so a
// single PUT can cover every tile the layout knows about — the cap grows
// automatically as new sub-page slugs land, rather than tripping a 422
// once the slug count crosses a hard-coded ceiling.
.max(ACCEPTED_INSIGHTS_TILE_IDS.length)
.optional(),
});
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { Dumbbell } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.25 — `/insights/grip-strength`.
*
* Grip-strength sub-page. Rides the generic HealthKit metric template so the
* signal gets the same detail spine as every other metric (chart, stat strip,
* Coach-read strip, assessment card). The assessment threads the sex-aware
* EWGSOP2 cut-off (men 27 / women 16 kg) via the `norms.ts` table.
*/
export default function InsightsGripStrengthPage() {
const { t } = useTranslations();
return (
<HealthKitMetricPage
measurementType="GRIP_STRENGTH"
statusMetric="GRIP_STRENGTH"
insightMetric="GRIP_STRENGTH"
chartKey="gripStrength"
i18nPrefix="insights.gripStrength"
color="#22c55e"
unit="kg"
yAxisUnit="kg"
statIcon={Dumbbell}
emptyStateIcon={<Dumbbell className="size-6" />}
emptyStateCtaType="GRIP_STRENGTH"
coachPrefill={t("insights.gripStrength.coachPrefill")}
/>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { Activity } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.25 — `/insights/pain`.
*
* Pain (0–10 NRS) sub-page. Patient-reported pain intensity on the validated
* numeric rating scale; rides the generic metric template for its detail
* spine + assessment (no-pain / mild band at 0–3).
*/
export default function InsightsPainPage() {
const { t } = useTranslations();
return (
<HealthKitMetricPage
measurementType="PAIN_NRS"
statusMetric="PAIN_NRS"
insightMetric="PAIN_NRS"
chartKey="painNrs"
i18nPrefix="insights.pain"
color="#f97316"
unit=""
yAxisUnit="/10"
statIcon={Activity}
emptyStateIcon={<Activity className="size-6" />}
emptyStateCtaType="PAIN_NRS"
coachPrefill={t("insights.pain.coachPrefill")}
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { Scaling } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.25 — `/insights/waist-to-height`.
*
* Waist-to-height ratio sub-page. The ancestry-robust single number (NICE:
* keep waist under half your height — WHtR ≥ 0.5 flags increased risk). Rides
* the generic metric template for its detail spine + assessment.
*/
export default function InsightsWaistToHeightPage() {
const { t } = useTranslations();
return (
<HealthKitMetricPage
measurementType="WAIST_TO_HEIGHT"
statusMetric="WAIST_TO_HEIGHT"
insightMetric="WAIST_TO_HEIGHT"
chartKey="waistToHeight"
i18nPrefix="insights.waistToHeight"
color="#8b5cf6"
unit=""
statIcon={Scaling}
emptyStateIcon={<Scaling className="size-6" />}
emptyStateCtaType="WAIST_TO_HEIGHT"
coachPrefill={t("insights.waistToHeight.coachPrefill")}
/>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { Ruler } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.25 — `/insights/waist`.
*
* Waist-circumference sub-page. Rides the generic metric template; the
* assessment threads the sex-aware WHO cut-off (men 94 / women 80 cm) via the
* `norms.ts` table.
*/
export default function InsightsWaistPage() {
const { t } = useTranslations();
return (
<HealthKitMetricPage
measurementType="WAIST_CIRCUMFERENCE"
statusMetric="WAIST_CIRCUMFERENCE"
insightMetric="WAIST_CIRCUMFERENCE"
chartKey="waistCircumference"
i18nPrefix="insights.waist"
color="#0ea5e9"
unit="cm"
yAxisUnit="cm"
statIcon={Ruler}
emptyStateIcon={<Ruler className="size-6" />}
emptyStateCtaType="WAIST_CIRCUMFERENCE"
coachPrefill={t("insights.waist.coachPrefill")}
/>
);
}
@@ -185,6 +185,8 @@ export const SUB_PAGE_TABS: Record<
labelKey: "insights.navRespiratoryRate",
metric: "RESPIRATORY_RATE",
},
// v1.25 — pain (0–10 NRS).
pain: { labelKey: "insights.navPainNrs", metric: "PAIN_NRS" },
// ── body composition ──
weight: { labelKey: "insights.navWeight", metric: "WEIGHT" },
bmi: { labelKey: "insights.navBmi", metric: "BMI" },
@@ -207,6 +209,15 @@ export const SUB_PAGE_TABS: Record<
labelKey: "insights.navLeanBodyMass",
metric: "LEAN_BODY_MASS",
},
// v1.25 — waist circumference + waist-to-height ratio.
waist: {
labelKey: "insights.navWaistCircumference",
metric: "WAIST_CIRCUMFERENCE",
},
"waist-to-height": {
labelKey: "insights.navWaistToHeight",
metric: "WAIST_TO_HEIGHT",
},
// ── activity ──
steps: {
labelKey: "insights.navSteps",
@@ -270,6 +281,11 @@ export const SUB_PAGE_TABS: Record<
labelKey: "insights.navStairDescentSpeed",
metric: "STAIR_DESCENT_SPEED",
},
// v1.25 — grip strength.
"grip-strength": {
labelKey: "insights.navGripStrength",
metric: "GRIP_STRENGTH",
},
// ── sleep ──
sleep: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" },
// v1.10.0 — sleep breathing-disturbance index.
@@ -95,6 +95,32 @@ const MEASUREMENT_TYPES = [
unit: "°C",
placeholder: "36.6",
},
// v1.25 — physical / clinical signals. Manual web capture so a web-only
// self-hoster can log them (the iOS client + measurements API already do).
{
value: "WAIST_CIRCUMFERENCE",
labelKey: "measurements.typeWaistCircumference",
unit: "cm",
placeholder: "84",
},
{
value: "WAIST_TO_HEIGHT",
labelKey: "measurements.typeWaistToHeight",
unitKey: "measurements.unitRatio",
placeholder: "0.48",
},
{
value: "GRIP_STRENGTH",
labelKey: "measurements.typeGripStrength",
unit: "kg",
placeholder: "38",
},
{
value: "PAIN_NRS",
labelKey: "measurements.typePainNrs",
unitKey: "measurements.unitScore",
placeholder: "0",
},
] as const;
// v1.4.34 IW-G — single source of truth for the `/measurements?add=<TYPE>`
+6
View File
@@ -280,6 +280,12 @@ export const CHART_OVERLAY_KEYS = [
"averageHeartRate",
"maxHeartRate",
"energyExpenditureKj",
// v1.25 — chart-overlay slots for the physical / clinical signal sub-pages.
// One per chart surface; camel-case keys mirror the existing convention.
"gripStrength",
"painNrs",
"waistCircumference",
"waistToHeight",
] as const;
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
+38 -4
View File
@@ -101,6 +101,31 @@ const RESPIRATORY_RATE_NORMS: NormRow[] = [
{ minAge: 18, maxAge: 120, sex: null, range: { low: 12, high: 18 } },
];
/**
* Grip-strength normal band (kg) by sex — the EWGSOP2 "probable sarcopenia"
* cut-off (Cruz-Jentoft et al., Age Ageing 2019): men < 27 kg, women < 16 kg.
* The band brackets the at-or-above-cut-off "normal" placement; `direction`
* is `higher-better`, so the read flags a value below the low bound. A single
* adult bracket per sex — the cut-off is sex-specific, not age-banded — so the
* resolver returns it whenever the profile sex is known, even with no age.
*/
const GRIP_STRENGTH_NORMS: NormRow[] = [
{ minAge: 0, maxAge: 120, sex: "MALE", range: { low: 27, high: 60 } },
{ minAge: 0, maxAge: 120, sex: "FEMALE", range: { low: 16, high: 60 } },
];
/**
* Waist-circumference normal band (cm) by sex — the WHO European-origin
* increased-risk threshold (WHO 2008/2011; NIH/NHLBI 1998): men > 94 cm,
* women > 80 cm. `direction` is `lower-better`, so the normal band runs from
* 0 up to the sex cut-off. Single adult bracket per sex (sex-specific, not
* age-banded). No ethnicity adjustment — HealthLog does not collect ancestry.
*/
const WAIST_CIRCUMFERENCE_NORMS: NormRow[] = [
{ minAge: 0, maxAge: 120, sex: "MALE", range: { low: 0, high: 94 } },
{ minAge: 0, maxAge: 120, sex: "FEMALE", range: { low: 0, high: 80 } },
];
/**
* The norm tables, keyed by the assessment-registry metric id. A metric
* absent from this map has no age/sex sharpening — the caller keeps the
@@ -110,6 +135,9 @@ const NORM_TABLES: Partial<Record<MetricStatusMetricId, NormRow[]>> = {
VO2_MAX: VO2_MAX_NORMS,
RESTING_HEART_RATE: RESTING_HEART_RATE_NORMS,
RESPIRATORY_RATE: RESPIRATORY_RATE_NORMS,
// v1.25 — sex-specific (age-independent) cut-offs.
GRIP_STRENGTH: GRIP_STRENGTH_NORMS,
WAIST_CIRCUMFERENCE: WAIST_CIRCUMFERENCE_NORMS,
};
/** The age a bracket row is anchored at for interpolation — its centre. */
@@ -161,18 +189,24 @@ export function lookupNormalRange(
ageYears: number | null | undefined,
sex: NormSex,
): NormRange | null {
if (ageYears == null || !Number.isFinite(ageYears) || ageYears < 0) {
return null;
}
const table = NORM_TABLES[metricId];
if (!table) return null;
const rows = resolveSexRows(table, sex);
if (!rows || rows.length === 0) return null;
// Single band — no neighbour to interpolate against.
// Single band — no neighbour to interpolate against. A sex-specific,
// age-independent cut-off (grip strength, waist circumference) resolves
// here without an age, so the sex-aware band still applies when the
// profile carries sex but no date of birth.
if (rows.length === 1) return { ...rows[0].range };
// Multi-bracket tables are age-banded — without a usable age there is no
// honest placement, so the caller keeps the flat registry anchor.
if (ageYears == null || !Number.isFinite(ageYears) || ageYears < 0) {
return null;
}
// Clamp below the youngest centre / above the oldest centre: hold the
// nearest cited band flat rather than extrapolate past the table.
const first = rows[0];
+8 -1
View File
@@ -112,7 +112,14 @@ export type InsightMetric =
| "SIX_MINUTE_WALK_DISTANCE"
| "STAIR_ASCENT_SPEED"
| "STAIR_DESCENT_SPEED"
| "BREATHING_DISTURBANCES";
| "BREATHING_DISTURBANCES"
// v1.25 — physical / clinical signals. Each key equals its MeasurementType,
// so the generic `hasMetricData` fallback gates it on
// `summaries[metric].count > 0` (auto-light-up on the first reading).
| "GRIP_STRENGTH"
| "PAIN_NRS"
| "WAIST_CIRCUMFERENCE"
| "WAIST_TO_HEIGHT";
/**
* Inputs the gating helper consumes. The `summaries` shape mirrors
+56 -1
View File
@@ -140,7 +140,14 @@ export type MetricStatusMetricId =
| "CARDIO_LOAD"
| "AVERAGE_HEART_RATE"
| "MAX_HEART_RATE"
| "ENERGY_EXPENDITURE_KJ";
| "ENERGY_EXPENDITURE_KJ"
// v1.25 — physical / clinical signals promoted to detail pages. Grip
// strength + waist carry a sex-aware band via the `norms.ts` tables the
// generic generator already threads through `lookupNormalRange`.
| "GRIP_STRENGTH"
| "PAIN_NRS"
| "WAIST_CIRCUMFERENCE"
| "WAIST_TO_HEIGHT";
/**
* The registry. Keyed by the HealthKit metric id. Each entry's
@@ -635,6 +642,54 @@ const REGISTRY: Record<MetricStatusMetricId, MetricStatusMeta> = {
direction: "higher-better",
archetype: "activity-fitness",
},
// ── v1.25 — physical / clinical signals ──
// Grip strength — EWGSOP2 strength-limb marker. The flat 16 kg floor is the
// women's cut-off; the sex-aware `norms.ts` table sharpens it (men 27 /
// women 16) when the profile carries sex. Fields mirror the signal-registry
// entry exactly so the registry-invariant projection deep-equals this row.
GRIP_STRENGTH: {
id: "GRIP_STRENGTH",
measurementType: "GRIP_STRENGTH",
displayName: "Grip strength",
unit: "kg",
direction: "higher-better",
normalRange: { low: 16, high: 60 },
archetype: "activity-fitness",
},
// Pain NRS — the validated 0–10 numeric rating; lower is better, the
// no-pain/mild band sits at 0–3.
PAIN_NRS: {
id: "PAIN_NRS",
measurementType: "PAIN_NRS",
displayName: "Pain (0–10 NRS)",
unit: "score",
direction: "lower-better",
normalRange: { low: 0, high: 3 },
archetype: "physiological-vital",
},
// Waist circumference — WHO increased-risk threshold. The flat 94 cm anchor
// is the men's cut-off; the sex-aware `norms.ts` table sharpens it (men 94 /
// women 80) when the profile carries sex.
WAIST_CIRCUMFERENCE: {
id: "WAIST_CIRCUMFERENCE",
measurementType: "WAIST_CIRCUMFERENCE",
displayName: "Waist circumference",
unit: "cm",
direction: "lower-better",
normalRange: { low: 0, high: 94 },
archetype: "body-composition",
},
// Waist-to-height ratio — NICE keep-waist-under-half-your-height rule. WHtR
// ≥ 0.5 flags increased risk, so the last in-range value is 0.49.
WAIST_TO_HEIGHT: {
id: "WAIST_TO_HEIGHT",
measurementType: "WAIST_TO_HEIGHT",
displayName: "Waist-to-height ratio",
unit: "ratio",
direction: "lower-better",
normalRange: { low: 0, high: 0.49 },
archetype: "body-composition",
},
};
/** Closed set of ids the generic route accepts (Zod enum source). */
+16
View File
@@ -53,6 +53,9 @@ const SUB_PAGE_METRIC = {
"body-temperature": ["BODY_TEMPERATURE"],
// v1.7.0 — respiratory rate joins the vitals cluster.
"respiratory-rate": ["RESPIRATORY_RATE"],
// v1.25 — pain (0–10 NRS) joins the vitals cluster as a patient-reported
// signal with its own detail page + assessment.
pain: ["PAIN_NRS"],
// ── body composition ──
weight: ["WEIGHT"],
// BMI is derived from WEIGHT + profile height (no separate series).
@@ -65,6 +68,9 @@ const SUB_PAGE_METRIC = {
"muscle-mass": ["MUSCLE_MASS"],
"visceral-fat": ["VISCERAL_FAT"],
"lean-body-mass": ["LEAN_BODY_MASS"],
// v1.25 — waist circumference + waist-to-height ratio join the body cluster.
waist: ["WAIST_CIRCUMFERENCE"],
"waist-to-height": ["WAIST_TO_HEIGHT"],
// ── activity ──
// v1.12 — Steps gains a routed sub-page. Stored as `ACTIVITY_STEPS`;
// the tile already surfaced on the dashboard but the metric was never
@@ -94,6 +100,9 @@ const SUB_PAGE_METRIC = {
"six-minute-walk": ["SIX_MINUTE_WALK_DISTANCE"],
"stair-ascent-speed": ["STAIR_ASCENT_SPEED"],
"stair-descent-speed": ["STAIR_DESCENT_SPEED"],
// v1.25 — grip strength (EWGSOP2 strength-limb marker) joins the activity
// cluster as a manual / device physical-fitness signal.
"grip-strength": ["GRIP_STRENGTH"],
// ── sleep ──
sleep: ["SLEEP_DURATION"],
// v1.10.0 — sleep breathing-disturbance index joins the sleep cluster.
@@ -204,6 +213,13 @@ export const SUB_PAGE_GROUP: Partial<Record<SubPageSlug, SubPageGroup>> = {
"cardio-recovery": "cardiovascular",
// metabolic cluster (overnight wrist reading)
"wrist-temperature": "metabolic",
// ── v1.25 — physical / clinical signals ──
// Pain rides the vitals popover (patient-reported physiological signal);
// waist + WHtR sit with body composition; grip with activity / fitness.
pain: "vitals",
waist: "body",
"waist-to-height": "body",
"grip-strength": "activity",
};
/**
+2 -1
View File
@@ -95,7 +95,8 @@ describe("resolveRichMetric", () => {
const whtr = resolveRichMetric("waist-to-height ratio");
expect(whtr?.measurementType).toBe("WAIST_TO_HEIGHT");
expect(whtr?.band).toEqual({ low: 0, high: 0.5 });
// WHtR ≥ 0.5 flags increased risk, so the last in-range value is 0.49.
expect(whtr?.band).toEqual({ low: 0, high: 0.49 });
// The exact registry key resolves too.
expect(resolveRichMetric("GRIP_STRENGTH")?.measurementType).toBe(
+34 -25
View File
@@ -43,7 +43,7 @@ import {
getMetricStatusMeta,
METRIC_STATUS_IDS,
} from "@/lib/insights/metric-status-registry";
import { getSignal } from "@/lib/signals/registry";
import { allSignals, getSignal } from "@/lib/signals/registry";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { resolveLabFields } from "@/lib/labs/serialise";
import { sanitizeValueText } from "@/lib/ai/coach/labs-snapshot";
@@ -182,24 +182,21 @@ function fromRegistry(id: string): RichMetric | null {
//
// The v1.25 clinical-signals wave added a set of physical / clinical
// measurements to the signal registry. They sit OFF the Coach snapshot by
// design (`coachSnapshot:false ⇒ surfaces.mcp:false`), so the Coach-driven
// reads (`get_metric_series`, the data inventory) never surface them. The MCP
// layer exposes them here through the rollup-backed rich reads — `compare_metric`,
// `get_metric_baseline`, `detect_changepoints` — and through `search` / `fetch`.
// design (`coachSnapshot:false`), so the Coach-driven reads
// (`get_metric_series`, the data inventory) never surface them — that path
// would report no data for a signal it does not carry. The MCP layer exposes
// them here through the rollup-backed rich reads — `compare_metric`,
// `get_metric_baseline`, `detect_changepoints` — and through `search` /
// `fetch`.
//
// Exposure is an EXPLICIT allowlist owned by this module, not the registry's own
// `surfaces.mcp` flag: the registry keeps these off the Coach surface, while the
// MCP read surface opts them in one signal at a time. The registry remains the
// single source of truth for grounding (the backing `MeasurementType`, unit, and
// population band). Deliberately ABSENT and therefore unreachable over MCP: the
// PHQ-9 / GAD-7 mental-health screeners and every environmental (`ENV_*`) signal
// — they never reach AI / MCP by construction.
export const MCP_CLINICAL_SIGNAL_KEYS = [
"GRIP_STRENGTH",
"PAIN_NRS",
"WAIST_CIRCUMFERENCE",
"WAIST_TO_HEIGHT",
] as const;
// The registry's own `surfaces.mcp` flag is the SINGLE source of truth for
// MCP exposure: a signal surfaces here iff it is a measurement marked
// `mcp:true` while staying off the Coach snapshot (`coachSnapshot:false`).
// The `mcp` and `coachSnapshot` facets are independent — a signal can be
// MCP-readable without being on the Coach surface. Deliberately ABSENT and
// therefore unreachable over MCP: the PHQ-9 / GAD-7 mental-health screeners
// and every environmental (`ENV_*`) signal — they carry `mcp:false` and never
// reach AI / MCP by construction.
/** Build a `RichMetric` from a registry signal key (measurement-kind only). */
function richMetricFromSignal(key: string): RichMetric | null {
@@ -215,11 +212,23 @@ function richMetricFromSignal(key: string): RichMetric | null {
};
}
/** Resolved clinical signals, keyed by their registry key (built once). */
/**
* The MCP-only clinical signals, derived from the registry: every measurement
* signal marked `mcp:true` that stays off the Coach snapshot. This is the one
* place the registry's `surfaces.mcp` flag is consumed for the rich reads —
* no duplicate allowlist to drift against.
*/
const CLINICAL_SIGNAL_BY_KEY = new Map<string, RichMetric>();
for (const key of MCP_CLINICAL_SIGNAL_KEYS) {
const metric = richMetricFromSignal(key);
if (metric) CLINICAL_SIGNAL_BY_KEY.set(key, metric);
for (const sig of allSignals()) {
if (
sig.kind !== "measurement" ||
sig.surfaces.mcp !== true ||
sig.surfaces.coachSnapshot !== false
) {
continue;
}
const metric = richMetricFromSignal(sig.key);
if (metric) CLINICAL_SIGNAL_BY_KEY.set(sig.key, metric);
}
/**
@@ -256,9 +265,9 @@ const CLINICAL_ALIASES: Record<string, string> = {
/**
* Resolve a free-text name to a clinical signal the MCP layer exposes, or
* `null`. Closed by construction to the `MCP_CLINICAL_SIGNAL_KEYS` allowlist, so
* a screener key (PHQ9_SCORE / GAD7_SCORE) or an `ENV_*` key can never resolve
* here even though they exist in the registry.
* `null`. Closed by construction to the registry-derived `mcp:true` measurement
* set, so a screener key (PHQ9_SCORE / GAD7_SCORE) or an `ENV_*` key — all
* `mcp:false` — can never resolve here even though they exist in the registry.
*/
function resolveClinicalSignal(key: string): RichMetric | null {
const aliased = CLINICAL_ALIASES[key];
+2 -2
View File
@@ -889,7 +889,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
name: "compare_metric",
title: "Compare metrics or windows",
description:
"Compare one metric against another over the same horizon, OR a single metric across two horizons. A horizon is either one of the five fixed trailing windows OR an explicit {from,to} date range (use `range`/`rangeB` for before-vs-after-a-date). Returns each side's rollup statistics (count, mean, min, max) with units and reference bands, plus a delta + percent change when both sides share a unit. Returns { present: false } when a side has no data.",
"Compare one metric against another over the same horizon, OR a single metric across two horizons. A horizon is either one of the five fixed trailing windows OR an explicit {from,to} date range (use `range`/`rangeB` for before-vs-after-a-date). Also resolves the clinical-signal metrics (grip strength, pain 0–10 NRS, waist circumference, waist-to-height ratio). Returns each side's rollup statistics (count, mean, min, max) with units and reference bands, plus a delta + percent change when both sides share a unit. Returns { present: false } when a side has no data.",
inputShape: {
metric: z.string().min(1).max(60),
metricB: z.string().min(1).max(60).optional(),
@@ -931,7 +931,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
name: "detect_changepoints",
title: "Detect level shifts in a metric",
description:
"Surface points where a metric's level shifted over a trailing window OR an explicit {from,to} date range (e.g. 'when did my weight trend change?'). Runs a conservative changepoint scan over the rollup tier's bucket means and reports each shift's date, direction, and before/after means with units. High firing bar — returns { present: false } when too little data exists or no shift clears the threshold.",
"Surface points where a metric's level shifted over a trailing window OR an explicit {from,to} date range (e.g. 'when did my weight trend change?'). Also resolves the clinical-signal metrics (grip strength, pain 0–10 NRS, waist circumference, waist-to-height ratio). Runs a conservative changepoint scan over the rollup tier's bucket means and reports each shift's date, direction, and before/after means with units. High firing bar — returns { present: false } when too little data exists or no shift clears the threshold.",
inputShape: {
metric: z.string().min(1).max(60),
window: coachScopeWindowSchema.optional(),
+3 -1
View File
@@ -175,7 +175,9 @@ const insightsLayoutSchema = z
}),
)
.min(1)
.max(50)
// Cap at the accepted-id universe so a PUT can cover every tile the
// layout knows about; grows automatically as new sub-page slugs land.
.max(ACCEPTED_INSIGHTS_TILE_IDS.length)
.optional(),
})
.meta({
@@ -48,12 +48,45 @@ describe("signal registry — structural sanity", () => {
}
});
it("mcp exposure follows Coach-snapshot inclusion", () => {
// The MCP get_metric_series enum is the Coach scope vocabulary, so a signal
// is MCP-readable iff it carries a Coach scope (and vice-versa).
it("every Coach-snapshot signal is also MCP-readable", () => {
// `mcp` and `coachSnapshot` are INDEPENDENT facets: a Coach-scoped signal
// is always reachable over MCP (the Coach `get_metric_series` path), but a
// signal can be MCP-readable WITHOUT a Coach scope (the v1.25 physical
// signals, exposed only through the rollup-backed rich reads). So the
// invariant is one-directional — coachSnapshot ⇒ mcp, never the reverse.
for (const signal of allSignals()) {
const hasScope = signal.surfaces.coachSnapshot !== false;
expect(signal.surfaces.mcp).toBe(hasScope);
if (signal.surfaces.coachSnapshot !== false) {
expect(signal.surfaces.mcp).toBe(true);
}
}
});
it("the physical clinical signals are MCP-readable but off the Coach snapshot", () => {
for (const key of [
"GRIP_STRENGTH",
"PAIN_NRS",
"WAIST_CIRCUMFERENCE",
"WAIST_TO_HEIGHT",
]) {
const signal = getSignal(key);
expect(signal, `${key} missing from registry`).not.toBeNull();
expect(signal!.surfaces.mcp).toBe(true);
expect(signal!.surfaces.coachSnapshot).toBe(false);
}
});
it("the mental-health screeners and environmental signals stay off MCP", () => {
for (const key of ["PHQ9_SCORE", "GAD7_SCORE"]) {
const signal = getSignal(key);
expect(signal, `${key} missing from registry`).not.toBeNull();
expect(signal!.surfaces.mcp).toBe(false);
}
for (const signal of allSignals()) {
if (signal.kind === "environment") {
expect(signal.surfaces.mcp, `${signal.key} must stay off MCP`).toBe(
false,
);
}
}
});
+24 -13
View File
@@ -1280,13 +1280,18 @@ export const SIGNALS: Record<string, SignalDefinition> = {
direction: "higher-better",
archetype: "activity-fitness",
// Coarse population floor; the EWGSOP2 cut-off is sex-specific (men < 27,
// women < 16 kg) and applied at the display edge.
// women < 16 kg) and resolved at the display edge by the sex-aware band
// resolver (`norms.ts` GRIP_STRENGTH table) the detail-page assessment
// threads through `lookupNormalRange`.
normalRange: { low: 16, high: 60 },
surfaces: {
detailPage: false,
detailPage: true,
correlationEligible: false,
// Off the Coach snapshot, but reachable over MCP: the `mcp` facet is
// independent of `coachSnapshot`. The MCP rich reads expose it through
// the rollup-backed baseline / compare / change-point reads.
coachSnapshot: false,
mcp: false,
mcp: true,
},
fhir: {
// TODO(NEEDS-VERIFY): no hand-grip-strength LOINC confirmed this pass —
@@ -1307,10 +1312,11 @@ export const SIGNALS: Record<string, SignalDefinition> = {
archetype: "physiological-vital",
normalRange: { low: 0, high: 3 },
surfaces: {
detailPage: false,
detailPage: true,
correlationEligible: false,
// Off the Coach snapshot, reachable over MCP (independent facets).
coachSnapshot: false,
mcp: false,
mcp: true,
},
fhir: {
loinc: "72514-3",
@@ -1328,14 +1334,16 @@ export const SIGNALS: Record<string, SignalDefinition> = {
unit: "cm",
direction: "lower-better",
archetype: "body-composition",
// WHO European-origin increased-risk threshold (men > 94); ethnicity-aware
// bands are applied at the display edge.
// WHO European-origin increased-risk threshold (men > 94); the sex-aware
// resolver (`norms.ts` WAIST_CIRCUMFERENCE table: men 94 / women 80)
// sharpens it at the display edge via `lookupNormalRange`.
normalRange: { low: 0, high: 94 },
surfaces: {
detailPage: false,
detailPage: true,
correlationEligible: false,
// Off the Coach snapshot, reachable over MCP (independent facets).
coachSnapshot: false,
mcp: false,
mcp: true,
},
fhir: {
loinc: "8280-0",
@@ -1352,13 +1360,16 @@ export const SIGNALS: Record<string, SignalDefinition> = {
unit: "ratio",
direction: "lower-better",
archetype: "body-composition",
// NICE: keep waist under half your height — WHtR ≥ 0.5 flags increased risk.
normalRange: { low: 0, high: 0.5 },
// NICE: keep waist under half your height — WHtR ≥ 0.5 flags increased
// risk, so the last in-range value sits just below the 0.5 threshold
// (`high: 0.49`) and a 0.50 reading classifies as increased, not normal.
normalRange: { low: 0, high: 0.49 },
surfaces: {
detailPage: false,
detailPage: true,
correlationEligible: false,
// Off the Coach snapshot, reachable over MCP (independent facets).
coachSnapshot: false,
mcp: false,
mcp: true,
},
fhir: {
// TODO(NEEDS-VERIFY): WHtR LOINC not confirmed this pass — local text concept.