Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ The station's wire contract lives in [`schemas/`](./schemas) (JSON Schema
real run** — the schemas are cross-checked against these instances, so they
describe reality, not intention.

The public snake_case thesis and interpretability schemas are exact vendored
copies from `platform-contracts`; [`contract-lock.json`](./contract-lock.json)
records the source commit and SHA-256 used by tests and runtime validation.

```bash
bun run simulate <fixture-id | request.json> [--as-of YYYY-MM-DD] [--out file] [--raw]
```
Expand Down
7 changes: 7 additions & 0 deletions biome.jsonc
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["ultracite/biome/core"],
"files": {
// Vendored contract bytes are hash-pinned to platform-contracts.
"includes": [
"!schemas/input.schema.json",
"!schemas/interpretability.schema.json"
]
},
"linter": {
"rules": {
"suspicious": {
Expand Down
14 changes: 14 additions & 0 deletions contract-lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"repository": "https://github.com/REagent-LABrador/platform-contracts",
"commit": "755499b42ab65d3b01f959b11624dd4e61bdd561",
"schemas": {
"schemas/input.schema.json": {
"source": "schemas/indication-thesis.schema.json",
"sha256": "ea580e9a17eb5e25818a8f089a9f0b1fa38ffc7959e2966a2c2328d124ff7b7c"
},
"schemas/interpretability.schema.json": {
"source": "schemas/interpretability.schema.json",
"sha256": "ac7b27908688851b4fc3de5e3d31642a6e9d4422b422f57161f2c9ab42c3d6bb"
}
}
}
37 changes: 37 additions & 0 deletions managed/trial-recruitment-forecaster/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { describe, expect, test } from "bun:test";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { Ajv2020 } from "ajv/dist/2020.js";
Expand All @@ -19,13 +20,16 @@ const readJson = (path: string): Record<string, unknown> =>
JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;

const outputSchema = readJson(join(schemasDir, "output.schema.json"));
const inputSchema = readJson(join(schemasDir, "input.schema.json"));
const interpretabilitySchema = readJson(
join(schemasDir, "interpretability.schema.json")
);
const contractLock = readJson(join(schemasDir, "..", "contract-lock.json"));

const ajv = new Ajv2020({ allErrors: true, strict: false });
ajv.addSchema(interpretabilitySchema);
const validateOutput = ajv.compile(outputSchema);
const validateInput = ajv.compile(inputSchema);
const validateInterpretability = ajv.compile(interpretabilitySchema);

const exampleNames = [
Expand Down Expand Up @@ -78,3 +82,36 @@ describe("output schema requires interpretability", () => {
});
}
});

describe("shared platform contract pin", () => {
test("vendored schemas match their recorded SHA-256", () => {
const entries = contractLock.schemas as Record<string, { sha256: string }>;
for (const [localPath, pinned] of Object.entries(entries)) {
const bytes = readFileSync(join(schemasDir, "..", localPath));
expect(createHash("sha256").update(bytes).digest("hex")).toBe(
pinned.sha256
);
}
});

for (const name of [
"dupi-eoe-2018-hindcast.request.json",
"irak4-ra-sourced.request.json",
]) {
test(`${name} validates against canonical snake_case thesis`, () => {
const request = readJson(join(schemasDir, "examples", name));
expect(validateInput(request), formatErrors(validateInput.errors)).toBe(
true
);
});
}

test("missing biomarker population is rejected", () => {
const request = readJson(
join(schemasDir, "examples", "irak4-ra-sourced.request.json")
);
// biome-ignore lint/performance/noDelete: contract absence is under test.
delete request.biomarker_population;
expect(validateInput(request)).toBe(false);
});
});
53 changes: 53 additions & 0 deletions managed/trial-recruitment-forecaster/schema-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { Ajv2020 } from "ajv/dist/2020.js";

const schemasDir = join(import.meta.dir, "../../schemas");
const readSchema = (name: string): Record<string, unknown> =>
JSON.parse(readFileSync(join(schemasDir, name), "utf8")) as Record<
string,
unknown
>;

const ajv = new Ajv2020({ allErrors: true, strict: false });
const validateThesis = ajv.compile(readSchema("input.schema.json"));
const validateInterpretability = ajv.compile(
readSchema("interpretability.schema.json")
);

export class PublicContractError extends Error {
readonly reasonCode: "INPUT_SCHEMA_INVALID" | "OUTPUT_SCHEMA_INVALID";

constructor(
reasonCode: "INPUT_SCHEMA_INVALID" | "OUTPUT_SCHEMA_INVALID",
details: string
) {
super(details);
this.name = "PublicContractError";
this.reasonCode = reasonCode;
}
}

const errorSummary = (errors: typeof validateThesis.errors): string =>
(errors ?? [])
.slice(0, 5)
.map((error) => `${error.instancePath || "<root>"} ${error.message}`)
.join("; ");

export function assertPublicIndicationThesis(value: unknown): void {
if (!validateThesis(value)) {
throw new PublicContractError(
"INPUT_SCHEMA_INVALID",
errorSummary(validateThesis.errors)
);
}
}

export function assertSharedInterpretability(value: unknown): void {
if (!validateInterpretability(value)) {
throw new PublicContractError(
"OUTPUT_SCHEMA_INVALID",
errorSummary(validateInterpretability.errors)
);
}
}
108 changes: 79 additions & 29 deletions managed/trial-recruitment-forecaster/simulate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,45 +24,95 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { echoInput, fromOrgRequest, toOrgOutput } from "./boundary.ts";
import fixtures from "./fixtures/theses.json" with { type: "json" };
import { assessRecruitability } from "./recruitability.ts";
import {
assertPublicIndicationThesis,
assertSharedInterpretability,
PublicContractError,
} from "./schema-validation.ts";
import { IndicationThesis } from "./thesis.ts";

const args = process.argv.slice(2);
const flagValue = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const raw = args.includes("--raw");
const positional = args.filter(
(a, i) =>
!a.startsWith("--") && args[i - 1] !== "--as-of" && args[i - 1] !== "--out"
);
const [wanted] = positional;
const terminalReason = (error: unknown): string => {
if (error instanceof PublicContractError) {
return error.reasonCode;
}
if (error instanceof SyntaxError) {
return "INPUT_JSON_INVALID";
}
const message = error instanceof Error ? error.message : String(error);
if (message.includes("ANTHROPIC_API_KEY")) {
return "CREDENTIAL_MISSING";
}
if (
message.includes("ClinicalTrials.gov") ||
message.includes("fetch failed")
) {
return "REGISTRY_UNAVAILABLE";
}
if (message.toLowerCase().includes("anthropic")) {
return "PROVIDER_UNAVAILABLE";
}
return "CLINICAL_EXECUTION_FAILED";
};

if (!wanted) {
process.stderr.write(
`usage: bun run simulate <fixture-id | request.json> [--as-of YYYY-MM-DD] [--out file] [--raw]\nfixtures: ${fixtures.map((f) => f.thesis.id).join(", ")}\n`
async function main(): Promise<void> {
const raw = args.includes("--raw");
const positional = args.filter(
(a, i) =>
!a.startsWith("--") &&
args[i - 1] !== "--as-of" &&
args[i - 1] !== "--out"
);
process.exit(1);
}
const [wanted] = positional;

if (!wanted) {
throw new Error(
`usage: bun run simulate <fixture-id | request.json> [--as-of YYYY-MM-DD] [--out file] [--raw]; fixtures: ${fixtures.map((f) => f.thesis.id).join(", ")}`
);
}

const body = existsSync(wanted)
? (JSON.parse(readFileSync(wanted, "utf8")) as Record<string, unknown>)
: (fixtures.find((f) => f.thesis.id === wanted)?.thesis as
| Record<string, unknown>
| undefined);
if (!body) {
process.stderr.write(`no fixture or file named "${wanted}"\n`);
process.exit(1);
const fromFile = existsSync(wanted);
const body = fromFile
? (JSON.parse(readFileSync(wanted, "utf8")) as Record<string, unknown>)
: (fixtures.find((f) => f.thesis.id === wanted)?.thesis as
| Record<string, unknown>
| undefined);
if (!body) {
throw new Error(`no fixture or file named "${wanted}"`);
}

const req = fromOrgRequest(body);
const asOf = flagValue("--as-of") ?? req.asOf;
const publicInput =
"biomarker_population" in body ? body : echoInput(req.thesis, asOf);
assertPublicIndicationThesis(publicInput);

const thesis = IndicationThesis.parse(req.thesis);
const result = await assessRecruitability(thesis, { asOf });
const payload = raw
? result
: toOrgOutput(result, echoInput(req.thesis, asOf));
if ("interpretability" in payload) {
assertSharedInterpretability(payload.interpretability);
}
const json = JSON.stringify(payload, null, 2);
const out = flagValue("--out");
if (out) {
writeFileSync(out, `${json}\n`);
}
process.stdout.write(`${json}\n`);
}

const req = fromOrgRequest(body);
const asOf = flagValue("--as-of") ?? req.asOf;
const thesis = IndicationThesis.parse(req.thesis);
const result = await assessRecruitability(thesis, { asOf });
const payload = raw ? result : toOrgOutput(result, echoInput(req.thesis, asOf));
const json = JSON.stringify(payload, null, 2);
const out = flagValue("--out");
if (out) {
writeFileSync(out, `${json}\n`);
try {
await main();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(
`${JSON.stringify({ message, reason_code: terminalReason(error), status: "CANNOT_COMPLETE" })}\n`
);
process.exitCode = 1;
}
process.stdout.write(`${json}\n`);
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.112.4",
"@dotenvx/dotenvx": "^2.17.4",
"ajv": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/bun": "^1.3.14",
"@types/node": "^26.1.1",
"ajv": "^8.20.0",
"typescript": "^7.0.2",
"ultracite": "7.9.4"
}
Expand Down
6 changes: 3 additions & 3 deletions schemas/input.schema.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/REagent-LABrador/clinical_simulation/schemas/input.schema.json",
"title": "IndicationThesis (clinical-simulation request)",
"description": "The request a caller (the hypothesis station or an orchestrator) sends to the clinical-simulation station: one computable indication thesis. Field naming follows the organization dialect (snake_case, as in the simulation station's schemas); the station's boundary (simulate.ts) translates to its internal camelCase zod contract (managed/trial-recruitment-forecaster/thesis.ts), which remains authoritative for semantics. NOTHING here is ever inferred by the station: an unsupplied uniprot_accession stays unsupplied, an unsupplied as_of_date means 'evidence as of today' and is never back-filled. Invocation: `bun run simulate <request.json>`. The cache key a consumer may rely on is (id, as_of_date).",
"$id": "https://schemas.reagent-labrador.org/clinical/1.0.0/indication-thesis.schema.json",
"title": "LABrador IndicationThesis (snake_case v1.0.0)",
"description": "Canonical public request shared by hypothesis, orchestration, clinical, tractability, and comparison stations for one computable indication thesis. The wire format is snake_case. NOTHING is inferred to fill an absent field: an unsupplied uniprot_accession remains absent and a null/absent as_of_date means evidence as of today. Clinical invocation: `bun run simulate <request.json> --out <result.json>`. The cache key a consumer may rely on is (id, as_of_date).",
"type": "object",
"required": [
"id",
Expand Down
Loading