mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Stop narrating rollup buckets the record cannot account for
A rollup band outlives the rows it was folded from. The read-swap falls back to live SQL only when a band is EMPTY, so a bucket that is merely wrong is served as current; `ensureUserRollupsFresh` repairs the DAY tier over the trailing 90 days only, and keys off `Measurement.updatedAt`, which a hard delete does not bump. The WHOOP body sync hard-deletes a WEIGHT row with no invalidation, so a MONTH bucket keeps reporting a value the record no longer holds. The block was handing the model two irreconcilable accounts of the same record and marking neither: the coarse tail said one thing, the all-time extremes read live beside it said another, and `asOf` stamped the whole thing current because it is derived from raw reading age and knows nothing about rollup age. Reconcile them where they are assembled. A bucket mean is an average of real rows, so it cannot fall outside those rows' own all-time extremes — when it does, the band is describing readings the live read cannot see. That is an invariant rather than a heuristic, so the disputed tail is dropped and `asOf.coarseHistoryWithheld` records that it went. Narrating a ghost is worse than narrating less. `currentForTodayClaims` stays keyed to raw recency, which is what it means. The surviving readings really are from today, and suppressing a true present-tense statement to punish a stale history band would trade one dishonesty for another. The bucket on disk is still wrong, and a wide event now says so. The deeper fix is to make the tier notice — compare a bucket's `computedAt` against the type's in-window MAX(`updatedAt`), or invalidate on the hard-delete path — which is a larger change than this repair.
This commit is contained in:
1 parent
251a42bb0a
commit
c1811bc85b
4 files changed
+224
-43
No files matched your search
@@ -16,6 +16,14 @@
|
||||
* directly, so the answer arrives with the data instead of depending on the
|
||||
* reader working it out.
|
||||
*
|
||||
* The same pass reconciles each block's coarse (MONTH / YEAR) timeline, served
|
||||
* from the rollup tier, against the all-time extremes read live beside it. The
|
||||
* read-swap falls back to live SQL only when a band is EMPTY, so a bucket whose
|
||||
* rows were hard-deleted with no invalidation keeps being served as current —
|
||||
* and the block then states two different things about the same record with
|
||||
* nothing saying which is right. A bucket mean cannot lie outside its own rows'
|
||||
* extremes, so when it does the band is dropped rather than narrated.
|
||||
*
|
||||
* Pure and in-place — the snapshot is a plain record by the time it gets here.
|
||||
*/
|
||||
import {
|
||||
@@ -35,6 +43,12 @@ export interface SnapshotAsOf {
|
||||
* the day they were taken on, and must be stated with that day.
|
||||
*/
|
||||
currentForTodayClaims: boolean;
|
||||
/**
|
||||
* Set when the block's coarse (MONTH / YEAR) timeline was dropped because it
|
||||
* could not be reconciled with the live record beside it. Present only in
|
||||
* that case, so its absence is the normal state and costs no prompt budget.
|
||||
*/
|
||||
coarseHistoryWithheld?: true;
|
||||
}
|
||||
|
||||
function readNewestDaysAgo(block: unknown): number | null {
|
||||
@@ -61,27 +75,117 @@ export function asOfFromDaysAgo(daysAgo: number): SnapshotAsOf {
|
||||
};
|
||||
}
|
||||
|
||||
/** Coarse-band bucket rows are `[bucketStart, mean, min, max]`. */
|
||||
type CoarseBucket = [string, number, number, number];
|
||||
|
||||
/** Rounding headroom, so a float artefact is never read as a contradiction. */
|
||||
const RECONCILE_EPSILON = 1e-6;
|
||||
|
||||
function readAllTimeExtremes(
|
||||
block: unknown,
|
||||
): { min: number; max: number } | null {
|
||||
if (typeof block !== "object" || block === null) return null;
|
||||
const aggregate = (block as { aggregate?: unknown }).aggregate;
|
||||
if (typeof aggregate !== "object" || aggregate === null) return null;
|
||||
const min = (aggregate as { allTimeMin?: unknown }).allTimeMin;
|
||||
const max = (aggregate as { allTimeMax?: unknown }).allTimeMax;
|
||||
if (typeof min !== "number" || !Number.isFinite(min)) return null;
|
||||
if (typeof max !== "number" || !Number.isFinite(max)) return null;
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
function readCoarse(block: unknown): Record<string, unknown> | null {
|
||||
if (typeof block !== "object" || block === null) return null;
|
||||
const timeline = (block as { timeline?: unknown }).timeline;
|
||||
if (typeof timeline !== "object" || timeline === null) return null;
|
||||
const coarse = (timeline as { coarse?: unknown }).coarse;
|
||||
if (typeof coarse !== "object" || coarse === null) return null;
|
||||
return coarse as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function bucketsOf(coarse: Record<string, unknown>, band: string): number[] {
|
||||
const rows = coarse[band];
|
||||
if (!Array.isArray(rows)) return [];
|
||||
return (rows as CoarseBucket[])
|
||||
.map((row) => (Array.isArray(row) ? row[1] : Number.NaN))
|
||||
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a block's coarse timeline can be reconciled with the live
|
||||
* record it sits beside.
|
||||
*
|
||||
* The coarse MONTH / YEAR bands are served from the rollup tier; the aggregate's
|
||||
* all-time extremes are read live. A bucket mean is an average of real rows, so
|
||||
* it MUST lie within the all-time min/max of those same rows. When it does not,
|
||||
* the rollup is describing readings the live read cannot see — the bucket
|
||||
* outlived the rows it was folded from.
|
||||
*
|
||||
* This is an invariant, not a heuristic: no arrangement of existing rows can
|
||||
* average to a value outside their own extremes.
|
||||
*/
|
||||
function coarseContradictsRecord(block: unknown): boolean {
|
||||
const extremes = readAllTimeExtremes(block);
|
||||
if (extremes === null) return false;
|
||||
const coarse = readCoarse(block);
|
||||
if (coarse === null) return false;
|
||||
const means = [
|
||||
...bucketsOf(coarse, "monthly"),
|
||||
...bucketsOf(coarse, "yearly"),
|
||||
];
|
||||
if (means.length === 0) return false;
|
||||
return means.some(
|
||||
(mean) =>
|
||||
mean < extremes.min - RECONCILE_EPSILON ||
|
||||
mean > extremes.max + RECONCILE_EPSILON,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach `asOf` to every snapshot block whose aggregate reports a freshest
|
||||
* reading. Blocks with no coverage (narrative memory, plans, the reference
|
||||
* grounding table) are untouched — they carry no measurement to date.
|
||||
*
|
||||
* Returns the metric keys that were stamped stale, so the caller can annotate
|
||||
* them: a briefing narrated off a week-old series is worth seeing in the wide
|
||||
* event, not just in the reader's confusion.
|
||||
* Also drops a coarse timeline that contradicts the live record beside it. The
|
||||
* rollup tier's read-swap falls back to live SQL only when a band is EMPTY, so
|
||||
* a bucket that is merely WRONG — the rows behind it deleted, and no
|
||||
* invalidation fired — is served as though it were current. The block then
|
||||
* carried two irreconcilable accounts of the same record (a coarse tail saying
|
||||
* one thing, all-time extremes saying another) with nothing marking which to
|
||||
* believe, under an `asOf` stamped from raw reading age that knows nothing
|
||||
* about rollup age. Narrating a ghost is worse than narrating less, so the
|
||||
* disputed tail goes and `coarseHistoryWithheld` says it went.
|
||||
*
|
||||
* `currentForTodayClaims` stays keyed to raw recency, which is what it means:
|
||||
* the freshest READING is still as fresh as it was, and suppressing it would
|
||||
* silence a true present-tense statement to punish a stale history band.
|
||||
*
|
||||
* Returns the metric keys stamped stale and the keys whose coarse tail was
|
||||
* withheld, so the caller can annotate both: a briefing narrated off a week-old
|
||||
* series is worth seeing in the wide event, and so is a rollup that has drifted
|
||||
* away from the rows underneath it.
|
||||
*/
|
||||
export function annotateSnapshotFreshness(
|
||||
snapshot: Record<string, unknown>,
|
||||
): string[] {
|
||||
export function annotateSnapshotFreshness(snapshot: Record<string, unknown>): {
|
||||
stale: string[];
|
||||
coarseWithheld: string[];
|
||||
} {
|
||||
const stale: string[] = [];
|
||||
const coarseWithheld: string[] = [];
|
||||
for (const [key, block] of Object.entries(snapshot)) {
|
||||
const daysAgo = readNewestDaysAgo(block);
|
||||
if (daysAgo === null) continue;
|
||||
const asOf = asOfFromDaysAgo(daysAgo);
|
||||
if (coarseContradictsRecord(block)) {
|
||||
const timeline = (block as { timeline?: Record<string, unknown> })
|
||||
.timeline;
|
||||
if (timeline) delete timeline.coarse;
|
||||
asOf.coarseHistoryWithheld = true;
|
||||
coarseWithheld.push(key);
|
||||
}
|
||||
(block as Record<string, unknown>).asOf = asOf;
|
||||
if (!asOf.currentForTodayClaims) stale.push(key);
|
||||
}
|
||||
return stale;
|
||||
return { stale, coarseWithheld };
|
||||
}
|
||||
|
||||
export { TODAY_CLAIM_MAX_AGE_DAYS };
|
||||
@@ -1238,11 +1238,20 @@ async function buildCoachSnapshotImpl(
|
||||
// hero line said "today" about a metric last measured five days earlier. The
|
||||
// stamp sits on the block itself so the age travels with the numbers into
|
||||
// every surface the snapshot feeds — hero, briefing, Coach reply, tools.
|
||||
const staleBlocks = annotateSnapshotFreshness(compactSnapshot);
|
||||
if (staleBlocks.length > 0) {
|
||||
const freshness = annotateSnapshotFreshness(compactSnapshot);
|
||||
if (freshness.stale.length > 0) {
|
||||
annotate({
|
||||
action: { name: "coach.snapshot.stale_blocks" },
|
||||
meta: { blocks: staleBlocks.sort() },
|
||||
meta: { blocks: freshness.stale.sort() },
|
||||
});
|
||||
}
|
||||
// A rollup band that no longer reconciles with the rows underneath it is an
|
||||
// operator-visible defect, not just a narration problem — the bucket is wrong
|
||||
// on disk and stays wrong until something recomputes it.
|
||||
if (freshness.coarseWithheld.length > 0) {
|
||||
annotate({
|
||||
action: { name: "coach.snapshot.coarse_withheld" },
|
||||
meta: { blocks: freshness.coarseWithheld.sort() },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -119,9 +119,11 @@ describe("the Coach snapshot stamp", () => {
|
||||
scope: { sources: ["pulse", "weight"] },
|
||||
};
|
||||
|
||||
const stale = annotateSnapshotFreshness(snapshot);
|
||||
const { stale, coarseWithheld } = annotateSnapshotFreshness(snapshot);
|
||||
|
||||
expect(stale).toEqual(["pulse"]);
|
||||
// Neither block carries a coarse band, so nothing is disputed.
|
||||
expect(coarseWithheld).toEqual([]);
|
||||
expect((snapshot.pulse as { asOf: unknown }).asOf).toEqual({
|
||||
daysAgo: 5,
|
||||
isToday: false,
|
||||
@@ -135,4 +137,63 @@ describe("the Coach snapshot stamp", () => {
|
||||
expect(snapshot.memory).toEqual({ headline: "steady month" });
|
||||
expect(snapshot.scope).toEqual({ sources: ["pulse", "weight"] });
|
||||
});
|
||||
|
||||
/**
|
||||
* A coarse bucket mean is an average of real rows, so it cannot sit outside
|
||||
* those rows' own all-time extremes. When it does, the rollup band is
|
||||
* describing readings the live read cannot see and must not be narrated.
|
||||
*/
|
||||
it("drops a coarse band whose bucket mean the live extremes cannot account for", () => {
|
||||
const snapshot: Record<string, unknown> = {
|
||||
weight: {
|
||||
aggregate: {
|
||||
allTimeMin: 80,
|
||||
allTimeMax: 80,
|
||||
coverage: { count: 6, newestDaysAgo: 0 },
|
||||
},
|
||||
timeline: {
|
||||
recent: [],
|
||||
coarse: { monthly: [["2026-01-01", 95, 95, 95]], yearly: [] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { coarseWithheld } = annotateSnapshotFreshness(snapshot);
|
||||
|
||||
expect(coarseWithheld).toEqual(["weight"]);
|
||||
const weight = snapshot.weight as {
|
||||
timeline: { coarse?: unknown };
|
||||
asOf: { coarseHistoryWithheld?: true; currentForTodayClaims: boolean };
|
||||
};
|
||||
expect(weight.timeline.coarse).toBeUndefined();
|
||||
expect(weight.asOf.coarseHistoryWithheld).toBe(true);
|
||||
// Raw recency is untouched — the surviving readings are still from today.
|
||||
expect(weight.asOf.currentForTodayClaims).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a coarse band that reconciles with the live extremes", () => {
|
||||
const snapshot: Record<string, unknown> = {
|
||||
weight: {
|
||||
aggregate: {
|
||||
allTimeMin: 78,
|
||||
allTimeMax: 96,
|
||||
coverage: { count: 40, newestDaysAgo: 0 },
|
||||
},
|
||||
timeline: {
|
||||
recent: [],
|
||||
coarse: { monthly: [["2026-01-01", 95, 94, 96]], yearly: [] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { coarseWithheld } = annotateSnapshotFreshness(snapshot);
|
||||
|
||||
expect(coarseWithheld).toEqual([]);
|
||||
const weight = snapshot.weight as {
|
||||
timeline: { coarse?: unknown };
|
||||
asOf: { coarseHistoryWithheld?: true };
|
||||
};
|
||||
expect(weight.timeline.coarse).toBeDefined();
|
||||
expect(weight.asOf.coarseHistoryWithheld).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,36 @@
|
||||
/**
|
||||
* CHARACTERISATION — a rollup bucket outlives the rows it was folded from, and
|
||||
* the Coach is shown the result without any marker saying so.
|
||||
* REGRESSION — a rollup bucket must not narrate rows that no longer exist.
|
||||
*
|
||||
* The read-swap in `tiered-context.ts` falls back to live SQL only when a band
|
||||
* is EMPTY (`if (buckets.length > 0) return buckets;`), so a bucket that is
|
||||
* merely wrong is served as though it were current. The one staleness repair,
|
||||
* `ensureUserRollupsFresh`, recomputes the DAY tier over the trailing 90 days
|
||||
* only, and keys off `Measurement.updatedAt` — which a HARD delete does not
|
||||
* bump. `src/lib/whoop/sync-body.ts` performs exactly such a hard
|
||||
* `measurement.deleteMany` on a WEIGHT row with no rollup invalidation, so this
|
||||
* reproduces that shape against a MONTH-band bucket.
|
||||
* is EMPTY, so a bucket that is merely WRONG is served as though it were
|
||||
* current. The one staleness repair, `ensureUserRollupsFresh`, recomputes the
|
||||
* DAY tier over the trailing 90 days only, and keys off `Measurement.updatedAt`
|
||||
* — which a HARD delete does not bump. `src/lib/whoop/sync-body.ts` performs
|
||||
* exactly such a hard `measurement.deleteMany` on a WEIGHT row with no rollup
|
||||
* invalidation, so this reproduces that shape against a MONTH-band bucket.
|
||||
*
|
||||
* What this pins, in the snapshot the Coach narrates from:
|
||||
* - `weight.timeline.coarse.monthly` still carries the deleted value;
|
||||
* - `weight.aggregate.allTimeMin/Max` correctly carry only the surviving
|
||||
* value, so the snapshot contradicts itself and nothing flags which half
|
||||
* is right;
|
||||
* - `weight.asOf.currentForTodayClaims` is true, because `asOf` is derived
|
||||
* from the freshest RAW reading and knows nothing about rollup age.
|
||||
* Before the fix the Coach snapshot carried two irreconcilable accounts of the
|
||||
* same record with nothing marking which to believe: `coarse.monthly` reported
|
||||
* the deleted value while `aggregate.allTimeMin/Max` beside it reported only
|
||||
* the surviving one, under an `asOf` stamped current because it is derived from
|
||||
* raw reading age and knows nothing about rollup age.
|
||||
*
|
||||
* This asserts CURRENT behaviour. Closing the gap — comparing bucket
|
||||
* `computedAt` against the type's in-window `MAX(measurement.updatedAt)`, or
|
||||
* invalidating on the hard delete — will redden it, and the assertions should
|
||||
* then be flipped to the honest expectation.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
* `annotateSnapshotFreshness` now reconciles the two where they are assembled.
|
||||
* A bucket mean is an average of real rows, so it must lie within those rows'
|
||||
* all-time extremes; a mean outside them proves the bucket outlived its rows.
|
||||
* The disputed coarse tail is dropped and `asOf.coarseHistoryWithheld` records
|
||||
* that it was — narrating a ghost is worse than narrating less.
|
||||
*
|
||||
* `currentForTodayClaims` deliberately stays true here. It means "the freshest
|
||||
* READING is recent enough for present tense", and it is: the surviving rows
|
||||
* are from today. Suppressing it would silence a true statement to punish a
|
||||
* stale history band.
|
||||
*
|
||||
* The row on disk is still wrong. The deeper fix is to make the tier notice —
|
||||
* either compare a bucket's `computedAt` against the type's in-window
|
||||
* MAX(`updatedAt`), or invalidate on the hard-delete path — and that is a
|
||||
* larger change than this honesty repair.
|
||||
*/ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
@@ -49,7 +55,7 @@ const LIVE_KG = 80;
|
||||
interface WeightBlock {
|
||||
aggregate: { allTimeMin: number; allTimeMax: number };
|
||||
timeline: { coarse?: { monthly: Array<[string, number, number, number]> } };
|
||||
asOf: { currentForTodayClaims: boolean };
|
||||
asOf: { currentForTodayClaims: boolean; coarseHistoryWithheld?: true };
|
||||
}
|
||||
|
||||
describe("coach snapshot — a rollup bucket that outlived its rows", () => {
|
||||
@@ -57,7 +63,7 @@ describe("coach snapshot — a rollup bucket that outlived its rows", () => {
|
||||
await truncateAllTables(prisma);
|
||||
});
|
||||
|
||||
it("serves the deleted value in the coarse tail while claiming the block is current", async () => {
|
||||
it("withholds the coarse tail whose buckets the live record cannot account for", async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: "stale-rollup@example.test",
|
||||
@@ -117,17 +123,18 @@ describe("coach snapshot — a rollup bucket that outlived its rows", () => {
|
||||
const weight = (snap.sections as Record<string, unknown>)
|
||||
.weight as WeightBlock;
|
||||
|
||||
// The coarse tail the Coach narrates still reports the deleted value.
|
||||
const monthly = weight.timeline.coarse?.monthly ?? [];
|
||||
expect(monthly.length).toBeGreaterThan(0);
|
||||
expect(monthly.every((row) => row[1] === GHOST_KG)).toBe(true);
|
||||
expect(snap.snapshotJson).toContain(String(GHOST_KG));
|
||||
|
||||
// …while the all-time extremes, read live, know only the surviving value.
|
||||
// The all-time extremes, read live, know only the surviving value.
|
||||
expect(weight.aggregate.allTimeMin).toBe(LIVE_KG);
|
||||
expect(weight.aggregate.allTimeMax).toBe(LIVE_KG);
|
||||
|
||||
// …and the block is stamped current, which licenses present-tense prose.
|
||||
// The coarse tail could not be reconciled with them, so it is gone…
|
||||
expect(weight.timeline.coarse).toBeUndefined();
|
||||
expect(weight.asOf.coarseHistoryWithheld).toBe(true);
|
||||
|
||||
// …and the deleted value reaches no part of what the model is shown.
|
||||
expect(snap.snapshotJson).not.toContain(String(GHOST_KG));
|
||||
|
||||
// The freshest reading is still from today, so present tense is still fair.
|
||||
expect(weight.asOf.currentForTodayClaims).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user