mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Drop a reference window whose floor sits above its ceiling
A lab report's reference range reaches the record by four paths. Three of them validate that the floor does not exceed the ceiling; the document path did not, and the catalog store writes what it is handed. An integration run against a real Postgres confirmed the consequence: a 100/30 pair landed on the row and on the freshly minted biomarker, so every later reading of that analyte inherited a window that cannot be true and a high value came back reassuring. Dropping beats swapping. A transposed pair says the transcription is wrong, not which of the two numbers is, and inventing an order would state something the report never printed. The verbatim string survives either way, so nothing the document actually said is lost. Three points, answering according to what each one is: - the extraction schema drops the pair, because every field there is catch-guarded so one bad value cannot sink a 120-fact document - the review-screen edit refuses with the wording the lab schemas already use, because a person can see both numbers and fix them - the commit path drops it as the last gate, which is what covers facts staged before this rule existed and confirmed afterwards parseReferenceRange has answered a transposed PRINTED window this way since it was written, and the commit path already counts a window it could not read at all. This extends both to the case that was readable and cannot be true.
This commit is contained in:
1 parent
a852860654
commit
963f61cf0f
4 files changed
+256
-3
No files matched your search
@@ -83,8 +83,16 @@ async function commitObservation(
|
||||
// existed has no text, and the numeric bounds the model reported are then
|
||||
// the whole of what it stated.
|
||||
const printed = parseReferenceRange(data.referenceText, data.unit);
|
||||
const sourceLow = printed?.low ?? data.referenceLow;
|
||||
const sourceHigh = printed?.high ?? data.referenceHigh;
|
||||
const rawLow = printed?.low ?? data.referenceLow;
|
||||
const rawHigh = printed?.high ?? data.referenceHigh;
|
||||
// Last gate on an impossible window, and the one that matters for facts
|
||||
// already sitting in the table: a document staged before the extraction
|
||||
// schema learned this rule still carries whatever the model reported, and it
|
||||
// arrives here on confirmation. Dropping beats swapping — the pair says the
|
||||
// transcription is wrong, not which of the two numbers is.
|
||||
const transposed = rawLow !== null && rawHigh !== null && rawLow > rawHigh;
|
||||
const sourceLow = transposed ? null : rawLow;
|
||||
const sourceHigh = transposed ? null : rawHigh;
|
||||
const sourceText = printed?.text ?? null;
|
||||
// A window the report printed and the parser could not read is the case that
|
||||
// used to pass without a trace: the reading is then judged against the
|
||||
@@ -99,6 +107,15 @@ async function commitObservation(
|
||||
meta: { surface: "document.commit", length: printed?.text.length ?? 0 },
|
||||
});
|
||||
}
|
||||
// The same reasoning one step further along: a window that WAS readable and
|
||||
// cannot be true. Counted rather than merely dropped, because a rising count
|
||||
// says something about the extraction that no individual row does.
|
||||
if (transposed) {
|
||||
annotate({
|
||||
action: { name: "labs.referenceRange.transposed" },
|
||||
meta: { surface: "document.commit" },
|
||||
});
|
||||
}
|
||||
|
||||
const biomarker = await resolveOrMintBiomarker(userId, {
|
||||
analyte: data.label,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* The reference window a document states, at the two points it enters: the
|
||||
* model's extraction and a person's edit on the review screen.
|
||||
*
|
||||
* The two answer a transposed pair differently on purpose. Extraction cannot
|
||||
* reject — every field in that schema is `.catch()`ed so one bad value does
|
||||
* not sink a 120-fact document — so it drops the pair and keeps the verbatim
|
||||
* string. An edit is a person looking at both numbers, so it refuses and says
|
||||
* why, exactly as the three lab schemas do.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
extractedFactRawSchema,
|
||||
inboundFactEditSchema,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
const baseRaw = {
|
||||
type: "OBSERVATION" as const,
|
||||
label: "Ferritin",
|
||||
code: null,
|
||||
codeSystem: null,
|
||||
clinicalStatus: null,
|
||||
verificationStatus: null,
|
||||
value: 150,
|
||||
valueText: null,
|
||||
unit: "ng/mL",
|
||||
referenceText: null,
|
||||
dose: null,
|
||||
medicationStatus: null,
|
||||
effectiveDate: "2026-07-01",
|
||||
sourceText: "Ferritin 150 ng/mL",
|
||||
page: 0,
|
||||
confidence: 0.9,
|
||||
};
|
||||
|
||||
const baseEdit = {
|
||||
factType: "OBSERVATION" as const,
|
||||
label: "Ferritin",
|
||||
value: 150,
|
||||
unit: "ng/mL",
|
||||
};
|
||||
|
||||
describe("a stated reference window, on the way in", () => {
|
||||
it("keeps an ordered pair as stated", () => {
|
||||
const parsed = extractedFactRawSchema.parse({
|
||||
...baseRaw,
|
||||
referenceLow: 30,
|
||||
referenceHigh: 100,
|
||||
});
|
||||
expect(parsed.referenceLow).toBe(30);
|
||||
expect(parsed.referenceHigh).toBe(100);
|
||||
});
|
||||
|
||||
it("drops a transposed pair rather than swapping it", () => {
|
||||
const parsed = extractedFactRawSchema.parse({
|
||||
...baseRaw,
|
||||
referenceLow: 100,
|
||||
referenceHigh: 30,
|
||||
referenceText: "30 - 100",
|
||||
});
|
||||
expect(parsed.referenceLow).toBeNull();
|
||||
expect(parsed.referenceHigh).toBeNull();
|
||||
// What the report printed still survives for a person to read.
|
||||
expect(parsed.referenceText).toBe("30 - 100");
|
||||
// And the reading itself is untouched — the window is the only casualty.
|
||||
expect(parsed.value).toBe(150);
|
||||
});
|
||||
|
||||
it("leaves a one-sided window alone", () => {
|
||||
const floorOnly = extractedFactRawSchema.parse({
|
||||
...baseRaw,
|
||||
referenceLow: 30,
|
||||
referenceHigh: null,
|
||||
});
|
||||
expect(floorOnly.referenceLow).toBe(30);
|
||||
const ceilingOnly = extractedFactRawSchema.parse({
|
||||
...baseRaw,
|
||||
referenceLow: null,
|
||||
referenceHigh: 100,
|
||||
});
|
||||
expect(ceilingOnly.referenceHigh).toBe(100);
|
||||
});
|
||||
|
||||
it("treats an equal pair as a real window", () => {
|
||||
const parsed = extractedFactRawSchema.parse({
|
||||
...baseRaw,
|
||||
referenceLow: 30,
|
||||
referenceHigh: 30,
|
||||
});
|
||||
expect(parsed.referenceLow).toBe(30);
|
||||
expect(parsed.referenceHigh).toBe(30);
|
||||
});
|
||||
|
||||
it("refuses a transposed pair from the review screen", () => {
|
||||
const result = inboundFactEditSchema.safeParse({
|
||||
...baseEdit,
|
||||
referenceLow: 100,
|
||||
referenceHigh: 30,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues.map((i) => i.message)).toContain(
|
||||
"referenceLow must not exceed referenceHigh",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts an ordered edit", () => {
|
||||
const result = inboundFactEditSchema.safeParse({
|
||||
...baseEdit,
|
||||
referenceLow: 30,
|
||||
referenceHigh: 100,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -179,7 +179,7 @@ const nullableText = (max: number) =>
|
||||
* per-type staged payload. Everything is `.catch`-guarded so a malformed field
|
||||
* degrades to null + low confidence rather than 422-ing the whole extraction.
|
||||
*/
|
||||
export const extractedFactRawSchema = z.object({
|
||||
const extractedFactRawFields = z.object({
|
||||
type: z.enum(INBOUND_FACT_TYPES),
|
||||
/** Verbatim label / diagnosis text / medication name as written. */
|
||||
label: z.string().trim().min(1).max(300).catch(""),
|
||||
@@ -217,6 +217,34 @@ export const extractedFactRawSchema = z.object({
|
||||
confidence: confidenceScore.default(0),
|
||||
});
|
||||
|
||||
/**
|
||||
* A window whose floor sits above its ceiling cannot be true, so it is dropped
|
||||
* rather than repaired. Swapping the pair would invent a range the report never
|
||||
* printed, and this schema cannot reject the fact the way `labs.ts` does —
|
||||
* every field above is `.catch()`ed precisely so one bad value does not sink a
|
||||
* whole extraction. `referenceText` survives either way, so nothing the
|
||||
* document actually stated is lost: the verbatim string is still there for a
|
||||
* person to read on the review screen.
|
||||
*
|
||||
* `parseReferenceRange` already answers a transposed PRINTED window this way.
|
||||
* These are the numbers the model reported instead, and they travelled
|
||||
* unchecked: an integration run against a real Postgres wrote a 100/30 pair
|
||||
* onto the row AND onto the freshly minted catalog marker, which then handed
|
||||
* the impossible window to every later reading of that analyte.
|
||||
*/
|
||||
export const extractedFactRawSchema = extractedFactRawFields.transform(
|
||||
(fact) => {
|
||||
if (
|
||||
fact.referenceLow !== null &&
|
||||
fact.referenceHigh !== null &&
|
||||
fact.referenceLow > fact.referenceHigh
|
||||
) {
|
||||
return { ...fact, referenceLow: null, referenceHigh: null };
|
||||
}
|
||||
return fact;
|
||||
},
|
||||
);
|
||||
|
||||
export type ExtractedFactRaw = z.infer<typeof extractedFactRawSchema>;
|
||||
|
||||
/** The full JSON envelope the provider returns. */
|
||||
@@ -468,6 +496,22 @@ const observationEditSchema = z
|
||||
message: "Provide a numeric value OR qualitative text, not both",
|
||||
path: ["value"],
|
||||
},
|
||||
)
|
||||
// The same ordering rule the three lab schemas enforce. This is a person
|
||||
// typing on the review screen, not a model guessing, so it refuses rather
|
||||
// than dropping the pair silently — they can see the two numbers and fix
|
||||
// which one is which.
|
||||
.refine(
|
||||
(d) =>
|
||||
d.referenceLow === undefined ||
|
||||
d.referenceLow === null ||
|
||||
d.referenceHigh === undefined ||
|
||||
d.referenceHigh === null ||
|
||||
d.referenceLow <= d.referenceHigh,
|
||||
{
|
||||
message: "referenceLow must not exceed referenceHigh",
|
||||
path: ["referenceLow"],
|
||||
},
|
||||
);
|
||||
|
||||
const medicationEditSchema = z.object({
|
||||
|
||||
@@ -295,4 +295,81 @@ describe("a printed reference range, end to end", () => {
|
||||
sourceReferenceText: PRINTED,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A window whose floor sits above its ceiling. The document path is the one
|
||||
* place these numbers arrive unvalidated: the three lab schemas enforce the
|
||||
* ordering, the extraction schema did not, and the catalog store takes what
|
||||
* it is handed. Before this guard the same run wrote 100/30 onto the row AND
|
||||
* onto the freshly minted marker, so every later reading of that analyte
|
||||
* inherited a range that cannot be true.
|
||||
*
|
||||
* The marker is deliberately absent so it gets minted from this fact — the
|
||||
* mint is the half that outlives the single row.
|
||||
*/
|
||||
it("drops a transposed window instead of storing it", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { commitApprovedFact } = await import("@/lib/documents/commit");
|
||||
const { encryptFactData, encryptFactProvenance } =
|
||||
await import("@/lib/documents/store");
|
||||
const doc = await prisma.inboundDocument.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
filename: "befund.pdf",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 1024,
|
||||
contentEncrypted: Buffer.from("stand-in for the stored bytes"),
|
||||
status: "EXTRACTED",
|
||||
},
|
||||
});
|
||||
const fact = await prisma.extractedFact.create({
|
||||
data: {
|
||||
documentId: doc.id,
|
||||
userId: USER_ID,
|
||||
factType: "OBSERVATION",
|
||||
status: "APPROVED",
|
||||
confidence: 0.9,
|
||||
needsReview: false,
|
||||
// No printed text, so the parser never sees the window and these
|
||||
// numbers are the whole of what the fact states. They are transposed.
|
||||
dataEncrypted: encryptFactData({
|
||||
label: "Ferritin",
|
||||
code: null,
|
||||
codeSystem: null,
|
||||
value: 150,
|
||||
valueText: null,
|
||||
unit: "ng/mL",
|
||||
referenceLow: 100,
|
||||
referenceHigh: 30,
|
||||
referenceText: null,
|
||||
effectiveDate: "2026-07-01",
|
||||
}),
|
||||
provenanceEncrypted: encryptFactProvenance({
|
||||
sourceText: "Ferritin 150 ng/mL 30 - 100",
|
||||
anchored: true,
|
||||
sourceOffset: 0,
|
||||
page: 0,
|
||||
confidence: 0.9,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// The reading itself still lands — the value is the information, the
|
||||
// window is not, so an impossible window costs the range and nothing else.
|
||||
const ref = await commitApprovedFact(USER_ID, fact);
|
||||
const row = await prisma.labResult.findUniqueOrThrow({
|
||||
where: { id: ref.recordId },
|
||||
});
|
||||
expect(row.value).toBe(150);
|
||||
expect(row.sourceReferenceLow).toBeNull();
|
||||
expect(row.sourceReferenceHigh).toBeNull();
|
||||
expect(row.referenceLow).toBeNull();
|
||||
expect(row.referenceHigh).toBeNull();
|
||||
|
||||
const minted = await prisma.biomarker.findFirstOrThrow({
|
||||
where: { userId: USER_ID, name: "Ferritin" },
|
||||
});
|
||||
expect(minted.lowerBound).toBeNull();
|
||||
expect(minted.upperBound).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user