feat(illness): fold the logged symptom curve into the recovery gap

The recovery-gap engine reasoned only over passive vitals and ignored
the journal's own per-day symptom series. Add the functional-impact
(symptom-burden) return track: read functionalImpact per episode day
(falling back to the strongest linked symptom severity) and treat it as
one more return against the known-constant baseline (healthy = 0).

The symptom track needs none of the MAD-band / contamination machinery
the passive vitals carry — the band is the constant 0 — so it is more
reliable to fold in than any passive vital. It is adverse by
construction, so it contributes to adverseCoverageDays and the headline
gap median, giving the qualifying-days floor a genuinely
illness-relevant contributor where a weight-only episode had none.

Stability is logged-days-only: a sparse journal that logs impact-0 once
and stops withholds the return rather than fabricating an all-clear from
the absence of further logs. gapDriverType names the dominant return
track, with the symptom curve winning ties as the most illness-specific
signal.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 21:06:06 +02:00
1 parent 02ce177ac4
commit fc76c39aea
4 files changed
+435 -4

No files matched your search

@@ -16,10 +16,12 @@ import type { MeasurementType } from "@/generated/prisma/client";
import {
computeIllnessCorrelation,
dayDiff,
FUNCTIONAL_IMPACT_RETURN_KEY,
isAdverseDeviation,
MIN_BASELINE_DAYS,
MIN_EPISODE_COVERAGE_DAYS,
type IllnessCorrelationInput,
type SymptomBurdenPoint,
type VitalDayPoint,
} from "../correlation";
@@ -615,3 +617,196 @@ describe("computeIllnessCorrelation — return anchor", () => {
expect(out.value.recoveryGapDays).toBeGreaterThan(0);
});
});
describe("computeIllnessCorrelation — symptom-burden return track", () => {
// A banded RHR vital present in EVERY case below clears the engine's
// episode-coverage floor (the gate counts banded-vital days, not symptom
// days). Its band stays IN throughout so it produces no adverse signal and
// never competes with the symptom track for the driver — it is scaffolding.
const rhr: MeasurementType = "RESTING_HEART_RATE";
const rhrBaseline = jitteredBaseline(55, 1, 21, "2026-01-02");
const rhrFlat: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 55 },
{ day: "2026-01-11", mean: 55 },
{ day: "2026-01-12", mean: 55 },
{ day: "2026-01-13", mean: 55 },
{ day: "2026-01-14", mean: 55 },
];
it("folds a functional-impact return into the gap with a constant-0 baseline", () => {
// Onset 01-10, felt better 01-14. Logged impact: bedbound early, eases to 0
// on 01-16 and HOLDS for 3 logged days → symptom return 01-16, gap +2.
const symptomBurden: SymptomBurdenPoint[] = [
{ day: "2026-01-10", impact: 3 },
{ day: "2026-01-12", impact: 2 },
{ day: "2026-01-14", impact: 1 }, // felt-better day, still symptomatic
{ day: "2026-01-16", impact: 0 }, // return start
{ day: "2026-01-17", impact: 0 },
{ day: "2026-01-18", impact: 0 }, // 3 logged in-band days → stable
];
const out = computeIllnessCorrelation(
input({
window: {
onsetDay: "2026-01-10",
feltBetterDay: "2026-01-14",
lifecycle: "ACUTE",
},
series: [
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
],
symptomBurden,
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
const sym = out.value.returns.find(
(r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY,
);
expect(sym).toBeDefined();
expect(sym?.returnedDay).toBe("2026-01-16");
expect(sym?.adverse).toBe(true);
// Gap = 01-16 − 01-14 = +2. RHR stayed flat (no gap), so the symptom track
// is the only contributor → headline gap = +2.
expect(sym?.gapDays).toBe(dayDiff("2026-01-14", "2026-01-16"));
expect(out.value.recoveryGapDays).toBe(2);
// It contributes to the adverse-coverage floor (3 logged adverse days).
expect(out.value.adverseCoverageDays).toBeGreaterThanOrEqual(3);
expect(out.value.gapDriverType).toBe(FUNCTIONAL_IMPACT_RETURN_KEY);
});
it("WITHHOLDS the symptom return when logging is too sparse to stabilise", () => {
// The user logs while sick, then logs impact-0 ONCE and stops — the
// 3-logged-day stability run can never fire. The symptom track must
// contribute NO gap (honest withholding), never fabricate recovery from the
// absence of further logs.
const symptomBurden: SymptomBurdenPoint[] = [
{ day: "2026-01-10", impact: 3 },
{ day: "2026-01-12", impact: 2 },
{ day: "2026-01-16", impact: 0 }, // single trailing in-band log
];
const out = computeIllnessCorrelation(
input({
window: {
onsetDay: "2026-01-10",
feltBetterDay: "2026-01-14",
lifecycle: "ACUTE",
},
series: [
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
],
symptomBurden,
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
const sym = out.value.returns.find(
(r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY,
);
expect(sym).toBeDefined();
expect(sym?.returnedDay).toBeNull();
expect(sym?.gapDays).toBeNull();
// No vital returned either → no headline gap fabricated.
expect(out.value.recoveryGapDays).toBeNull();
expect(out.value.gapDriverType).toBeNull();
// The logged adverse days still count toward coverage (honest).
expect(out.value.adverseCoverageDays).toBeGreaterThanOrEqual(2);
});
it("lets the symptom track win the driver over a co-returning vital", () => {
// Both the symptom curve AND an adverse RHR spike return and produce gaps;
// the functional-impact track must win `gapDriverType` on a tie (it is the
// most illness-specific signal).
const rhrSpike: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 75 }, // adverse up
{ day: "2026-01-11", mean: 74 },
{ day: "2026-01-16", mean: 55 }, // back in band
{ day: "2026-01-17", mean: 55 },
{ day: "2026-01-18", mean: 55 }, // 3 stable
];
const symptomBurden: SymptomBurdenPoint[] = [
{ day: "2026-01-10", impact: 3 },
{ day: "2026-01-12", impact: 2 },
{ day: "2026-01-16", impact: 0 }, // same return day → same gap as RHR
{ day: "2026-01-17", impact: 0 },
{ day: "2026-01-18", impact: 0 },
];
const out = computeIllnessCorrelation(
input({
window: {
onsetDay: "2026-01-10",
feltBetterDay: "2026-01-14",
lifecycle: "ACUTE",
},
series: [
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrSpike },
],
symptomBurden,
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
const sym = out.value.returns.find(
(r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY,
);
const rhrReturn = out.value.returns.find((r) => r.type === rhr);
expect(sym?.gapDays).toBe(2);
expect(rhrReturn?.gapDays).toBe(2);
expect(out.value.recoveryGapDays).toBe(2);
// Tie on |gap − median| → functional-impact wins.
expect(out.value.gapDriverType).toBe(FUNCTIONAL_IMPACT_RETURN_KEY);
});
it("ignores a symptom curve with no adverse day (nothing to return from)", () => {
const symptomBurden: SymptomBurdenPoint[] = [
{ day: "2026-01-10", impact: 0 },
{ day: "2026-01-12", impact: 0 },
];
const out = computeIllnessCorrelation(
input({
window: {
onsetDay: "2026-01-10",
feltBetterDay: "2026-01-14",
lifecycle: "ACUTE",
},
series: [
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
],
symptomBurden,
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(
out.value.returns.find((r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY),
).toBeUndefined();
});
it("never produces a symptom return for CHRONIC_ONGOING", () => {
const symptomBurden: SymptomBurdenPoint[] = [
{ day: "2026-01-10", impact: 3 },
{ day: "2026-01-16", impact: 0 },
{ day: "2026-01-17", impact: 0 },
{ day: "2026-01-18", impact: 0 },
];
const out = computeIllnessCorrelation(
input({
window: {
onsetDay: "2026-01-10",
feltBetterDay: "2026-01-14",
lifecycle: "CHRONIC_ONGOING",
},
series: [
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
],
symptomBurden,
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.recoveryGapDays).toBeNull();
expect(out.value.gapDriverType).toBeNull();
expect(
out.value.returns.find((r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY),
).toBeUndefined();
});
});
+57 -1
View File
@@ -39,6 +39,7 @@ import {
computeIllnessCorrelation,
type DayLogFeverPoint,
type IllnessCorrelationValue,
type SymptomBurdenPoint,
type VitalSeries,
} from "./correlation";
@@ -103,7 +104,7 @@ export async function computeEpisodeCorrelation(
// run paired rather than serially (halving the per-type round-trips).
// The day-log fever read shares the same fan-out budget.
const limit = pLimit(VITAL_SCAN_CONCURRENCY);
const [perType, dayLogFever] = await Promise.all([
const [perType, dayLogFever, symptomBurden] = await Promise.all([
Promise.all(
ILLNESS_SCAN_TYPES.map((type) =>
limit(async () => {
@@ -132,6 +133,7 @@ export async function computeEpisodeCorrelation(
),
),
limit(() => readDayLogFever(episode.id, preOnsetStart, end, tz)),
limit(() => readDayLogSymptomBurden(episode.id, preOnsetStart, end, tz)),
]);
const series: VitalSeries[] = [];
@@ -167,6 +169,7 @@ export async function computeEpisodeCorrelation(
window: { onsetDay, feltBetterDay, lifecycle: episode.lifecycle },
series,
dayLogFever,
symptomBurden,
source,
now,
});
@@ -275,3 +278,56 @@ async function readDayLogFever(
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([day, feverC]) => ({ day, feverC }));
}
/**
* Read the illness day-log symptom-burden series over [from, to) — the user's
* own logged daily-impact curve, keyed by the stored `date` (already the
* user-tz day string, so NO tz conversion). Mirrors `readDayLogFever`: one
* `findMany`, present days only, no zero-fill.
*
* Per day the burden is `functionalImpact` (0–3, the deliberate daily slider)
* when logged; else the day's MAX linked `IllnessSymptomLink.severity` (0–3) as
* a fallback corroborator. A day with neither is dropped (absent from the
* series — the engine treats a missing day as "not logged", never an
* implicit 0).
*/
async function readDayLogSymptomBurden(
episodeId: string,
from: Date,
to: Date,
tz: string,
): Promise<SymptomBurdenPoint[]> {
const fromKey = dayKeyForUserTz(from, tz);
const toKey = dayKeyForUserTz(to, tz);
const rows = await prisma.illnessDayLog.findMany({
where: {
episodeId,
deletedAt: null,
date: { gte: fromKey, lte: toKey },
},
select: {
date: true,
functionalImpact: true,
symptomLinks: { select: { severity: true } },
},
});
const points: SymptomBurdenPoint[] = [];
for (const row of rows) {
if (row.functionalImpact != null) {
points.push({ day: row.date, impact: row.functionalImpact });
continue;
}
// Fallback corroborator: the day's strongest linked symptom severity.
let maxSeverity: number | null = null;
for (const link of row.symptomLinks) {
if (link.severity == null) continue;
maxSeverity =
maxSeverity === null
? link.severity
: Math.max(maxSeverity, link.severity);
}
if (maxSeverity != null)
points.push({ day: row.date, impact: maxSeverity });
}
return points.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
}
+179 -1
View File
@@ -66,6 +66,21 @@ export const NOTABLE_SD = 2;
* for the recovery-gap (one lucky in-band day is not a recovery).
*/
export const RETURN_STABILITY_DAYS = 3;
/**
* Sentinel `type` for the functional-impact return track. The symptom curve is
* NOT a `MeasurementType` — it is the user's own logged daily-impact slider —
* so it rides `returns[]` under a stable string key the surface keys copy off
* (the typed `VitalReturnFinding.type` is widened to accept it). Tallied like
* any vital type in `gapReturnTypes` so it can win `gapDriverType`.
*/
export const FUNCTIONAL_IMPACT_RETURN_KEY = "FUNCTIONAL_IMPACT" as const;
/**
* Functional-impact threshold (0–3 slider) at/above which a logged day counts
* as ADVERSE (symptomatic). The baseline is the known constant 0 (healthy), so
* the symptom curve needs none of the MAD-band / contamination machinery the
* passive vitals carry — "return" is simply impact back to below this floor.
*/
export const FUNCTIONAL_IMPACT_ADVERSE_FLOOR = 1;
/** Minimum episode days with ANY vital coverage before the gap is asserted. */
export const MIN_EPISODE_COVERAGE_DAYS = 4;
@@ -153,6 +168,22 @@ export interface DayLogFeverPoint {
feverC: number;
}
/**
* A user-logged symptom-burden reading from the illness day-log, keyed by the
* stored `date` (already the user-tz local day). `impact` is the day's
* `functionalImpact` slider (0–3, 0 = fully functional … 3 = bedbound); when
* NULL it is the day's max linked `IllnessSymptomLink.severity` (0–3) as a
* fallback corroborator, or null when neither was logged. Present days only —
* never zero-filled (mirrors the fever read), so sparse logging WITHHOLDS the
* symptom return rather than fabricating an all-clear from absence of rows.
*/
export interface SymptomBurdenPoint {
/** Local day key, `YYYY-MM-DD`. */
day: string;
/** The day's symptom burden (0–3): functionalImpact, else max severity. */
impact: number;
}
/** The dates the engine reasons over, all `YYYY-MM-DD` local day keys. */
export interface EpisodeWindow {
/** Onset day. */
@@ -174,6 +205,17 @@ export interface IllnessCorrelationInput {
* escalation even with no passive BODY_TEMPERATURE rows. Optional.
*/
dayLogFever?: DayLogFeverPoint[];
/**
* Per-day symptom-burden readings from the illness day-log (`functionalImpact`
* primary, max linked symptom `severity` as fallback). Folded into the
* recovery-gap as ONE more return track against the known-constant baseline
* (healthy = 0): a "symptom return" is the burden back below the adverse floor
* and held for `RETURN_STABILITY_DAYS` LOGGED days. Always `adverse:true` (it
* is illness-relevant by construction), so it contributes to
* `adverseCoverageDays` and can drive the headline gap even on a
* passive-vital-thin episode. Optional.
*/
symptomBurden?: SymptomBurdenPoint[];
/** Provenance source the dominant read resolved against. */
source: "DAY" | "live" | "none";
/** Compute time (injected for deterministic tests). */
@@ -201,7 +243,11 @@ export interface VitalDeviationFinding {
/** A vital's recovery timing within the episode. */
export interface VitalReturnFinding {
type: MeasurementType;
/**
* The metric the return describes — a `MeasurementType` for a passive vital,
* or `FUNCTIONAL_IMPACT_RETURN_KEY` for the user-logged symptom-burden track.
*/
type: MeasurementType | typeof FUNCTIONAL_IMPACT_RETURN_KEY;
/** First day the vital re-entered its band AND stayed in for the window. */
returnedDay: string | null;
/** Days from felt-better to physiological return (signed); null if N/A. */
@@ -255,6 +301,16 @@ export interface IllnessCorrelationValue {
adverseCoverageDays: number;
/** Felt-better marker echoed for the surface. */
feltBetterDay: string | null;
/**
* The metric whose physiological return dominates the headline gap — the
* adverse return-track whose own gap is closest to the median, with the
* functional-impact symptom track winning ties (it is the most
* illness-specific signal). A `MeasurementType`,
* `FUNCTIONAL_IMPACT_RETURN_KEY`, or null when no adverse return drove a gap.
* The card names it ("…before your symptoms eased" vs "…before your resting
* heart rate settled"). Retrospective only.
*/
gapDriverType: string | null;
/**
* Red-flag escalation — a sustained adverse SpO2 drop or sustained fever
* during the episode. Retrospective, but the copy must escalate ("if this
@@ -471,6 +527,21 @@ export function computeIllnessCorrelation(
}
}
// Symptom-burden return track — the user's OWN logged daily-impact curve,
// folded in as one more return against the KNOWN-CONSTANT baseline (healthy
// = 0). No MAD band, no contamination guard, no min-baseline floor: the band
// IS the constant 0, so it sidesteps every reliability caveat the passive
// vitals carry. Always adverse by construction (a logged impact is illness-
// relevant), so it contributes to `adverseCoverageDays` and the headline gap
// even when the passive vitals are thin — the genuine illness-relevant
// contributor the WEIGHT-only floor lacked. Logged-days-only stability:
// sparse logging WITHHOLDS the return rather than fabricating an all-clear.
const symptomReturn = computeSymptomReturn(input.symptomBurden ?? [], window);
if (symptomReturn) {
returns.push(symptomReturn.finding);
for (const day of symptomReturn.adverseDays) adverseDayKeys.add(day);
}
// Red-flag escalation runs DECOUPLED from the banded loop: it scans the raw
// episode days for SpO2 + temperature against ABSOLUTE clinical floors, so a
// rock-steady (MAD=0) vital that drops the band filter still escalates — the
@@ -489,6 +560,31 @@ export function computeIllnessCorrelation(
? null
: Math.round(median(gaps));
// Name the per-episode driver: among the ADVERSE return tracks that produced
// a real gap, the one whose own gap sits closest to the headline median (it
// is the return that "explains" the headline). The functional-impact symptom
// track wins ties — it is the most illness-specific signal the engine has, so
// when it co-explains the gap it gets to name it ("…before your symptoms
// eased"). Null when no adverse return drove a gap.
const gapDriverType =
recoveryGapDays === null
? null
: (returns
.filter(
(r): r is VitalReturnFinding & { gapDays: number } =>
r.gapDays !== null && r.adverse,
)
.sort((a, b) => {
const da = Math.abs(a.gapDays - recoveryGapDays);
const db = Math.abs(b.gapDays - recoveryGapDays);
if (da !== db) return da - db;
// Tie: prefer the functional-impact symptom track, then by name.
const fa = a.type === FUNCTIONAL_IMPACT_RETURN_KEY ? 0 : 1;
const fb = b.type === FUNCTIONAL_IMPACT_RETURN_KEY ? 0 : 1;
if (fa !== fb) return fa - fb;
return String(a.type) < String(b.type) ? -1 : 1;
})[0]?.type ?? null);
const { coverage, confidence } = deriveCoverage({
requiredInputs: 1,
presentInputs: banded.length,
@@ -510,6 +606,7 @@ export function computeIllnessCorrelation(
recoveryGapDays,
adverseCoverageDays: adverseDayKeys.size,
feltBetterDay: window.feltBetterDay,
gapDriverType: gapDriverType === null ? null : String(gapDriverType),
redFlags,
},
coverage,
@@ -544,6 +641,87 @@ function firstStableReturn(
return null;
}
/**
* Compute the functional-impact (symptom-burden) return track for one episode.
*
* The symptom curve's baseline is the KNOWN CONSTANT 0 (healthy), so this needs
* none of the passive vitals' MAD-band / contamination machinery — "adverse" is
* simply `impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR`, "in band" is below it.
*
* - Only LOGGED days (present in `burden`) participate — never zero-filled.
* - The anchor is the first logged adverse day (same role as the vital
* `firstDeviationIndex`): a return search starts AFTER it, so a run-up is
* never read as a return.
* - A "return" is the first logged day at/after the anchor that is in-band AND
* stays in-band for the next `RETURN_STABILITY_DAYS` LOGGED days. LOGGED-days
* stability (not calendar days) is the honest-withholding handler for sparse
* journals: a single trailing impact-0 log never satisfies the run, so the
* track WITHHOLDS rather than fabricating recovery from absence of rows.
* - The gap is `dayDiff(feltBetterDay, returnedDay)`, same signed semantic.
* - Always `adverse:true` (a logged symptom curve is illness-relevant), so it
* feeds `adverseCoverageDays` and can drive the headline gap.
*
* Returns null (contributes nothing) when no adverse day was logged or the
* episode is CHRONIC_ONGOING. `adverseDays` is the set of logged adverse days in
* the active span, for the coverage tally.
*/
function computeSymptomReturn(
burden: SymptomBurdenPoint[],
window: EpisodeWindow,
): { finding: VitalReturnFinding; adverseDays: string[] } | null {
if (window.lifecycle === "CHRONIC_ONGOING") return null;
// Active span only, chronological. (Logged days are sparse and arbitrary, so
// a stable sort by the day key is the canonical order.)
const active = burden
.filter((p) => p.day >= window.onsetDay)
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
if (active.length === 0) return null;
const adverseDays = active
.filter((p) => p.impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR)
.map((p) => p.day);
// No adverse day logged → there is nothing to "return" from.
if (adverseDays.length === 0) return null;
// Anchor: first logged adverse day. The return search starts there so an
// early in-band log (impact 0 before symptoms appeared) is never a return.
const firstAdverseIndex = active.findIndex(
(p) => p.impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR,
);
let returnedDay: string | null = null;
for (let i = firstAdverseIndex; i < active.length; i++) {
if (active[i].impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR) continue;
// Count consecutive in-band LOGGED days from here.
let run = 0;
for (
let j = i;
j < active.length && active[j].impact < FUNCTIONAL_IMPACT_ADVERSE_FLOOR;
j++
) {
run++;
}
if (run >= RETURN_STABILITY_DAYS) {
returnedDay = active[i].day;
break;
}
}
const gapDays =
returnedDay && window.feltBetterDay
? dayDiff(window.feltBetterDay, returnedDay)
: null;
return {
finding: {
type: FUNCTIONAL_IMPACT_RETURN_KEY,
returnedDay,
gapDays,
adverse: true,
},
adverseDays,
};
}
/**
* Detect the retrospective red flags — DECOUPLED from the banded loop and the
* own-baseline band entirely. SpO2 and temperature escalate against ABSOLUTE
+4 -2
View File
@@ -181,11 +181,12 @@ const illnessVitalReturn = z
type: z.string(),
returnedDay: z.string().nullable(),
gapDays: z.number().nullable(),
adverse: z.boolean(),
})
.meta({
id: "IllnessVitalReturn",
description:
"A vital's physiological return: the first day it re-entered its band AND held, and the signed gap (days) from the felt-better marker (positive = the body lagged the feeling).",
"A physiological / symptom return: the first day the metric re-entered its band AND held, the signed gap (days) from the felt-better marker (positive = the body lagged the feeling), and whether the move was illness-adverse (only adverse returns name the driver). `type` is a MeasurementType or `FUNCTIONAL_IMPACT` for the user-logged symptom-burden track (functionalImpact, baseline = constant 0).",
});
const illnessRedFlag = z
@@ -210,12 +211,13 @@ const illnessCorrelationValue = z
recoveryGapDays: z.number().nullable(),
adverseCoverageDays: z.number().int(),
feltBetterDay: z.string().nullable(),
gapDriverType: z.string().nullable(),
redFlags: z.array(illnessRedFlag),
})
.meta({
id: "IllnessCorrelationValue",
description:
"The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns, the headline recovery-gap (median of per-vital gaps), and any red flags.",
"The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns (incl. the user-logged symptom-burden track, `type:FUNCTIONAL_IMPACT`), the headline recovery-gap (median of per-track gaps), the driving metric (`gapDriverType`), and any red flags.",
});
const illnessCorrelationResponse = z