Merge pull request #882 from MBombeck/fix/transposed-reference-window-v2

Drop a reference window whose floor sits above its ceiling
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-31 20:35:14 +02:00
commit e6751f0f0c
4 files changed
+256 -3

No files matched your search

+19 -2
View File
@@ -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);
});
});
+45 -1
View File
@@ -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();
});
});