diff --git a/README.md b/README.md index 480288b..978559f 100644 --- a/README.md +++ b/README.md @@ -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 [--as-of YYYY-MM-DD] [--out file] [--raw] ``` diff --git a/biome.jsonc b/biome.jsonc index 36bfe68..dda37ff 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -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": { diff --git a/contract-lock.json b/contract-lock.json new file mode 100644 index 0000000..dd65cf6 --- /dev/null +++ b/contract-lock.json @@ -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" + } + } +} diff --git a/managed/trial-recruitment-forecaster/contract.test.ts b/managed/trial-recruitment-forecaster/contract.test.ts index d35b4fb..c0d676b 100644 --- a/managed/trial-recruitment-forecaster/contract.test.ts +++ b/managed/trial-recruitment-forecaster/contract.test.ts @@ -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"; @@ -19,13 +20,16 @@ const readJson = (path: string): Record => JSON.parse(readFileSync(path, "utf8")) as Record; 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 = [ @@ -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; + 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); + }); +}); diff --git a/managed/trial-recruitment-forecaster/schema-validation.ts b/managed/trial-recruitment-forecaster/schema-validation.ts new file mode 100644 index 0000000..be8263b --- /dev/null +++ b/managed/trial-recruitment-forecaster/schema-validation.ts @@ -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 => + 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 || ""} ${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) + ); + } +} diff --git a/managed/trial-recruitment-forecaster/simulate.ts b/managed/trial-recruitment-forecaster/simulate.ts index 91dbc8b..f41b475 100644 --- a/managed/trial-recruitment-forecaster/simulate.ts +++ b/managed/trial-recruitment-forecaster/simulate.ts @@ -24,6 +24,11 @@ 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); @@ -31,38 +36,83 @@ 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 [--as-of YYYY-MM-DD] [--out file] [--raw]\nfixtures: ${fixtures.map((f) => f.thesis.id).join(", ")}\n` +async function main(): Promise { + 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 [--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) - : (fixtures.find((f) => f.thesis.id === wanted)?.thesis as - | Record - | 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) + : (fixtures.find((f) => f.thesis.id === wanted)?.thesis as + | Record + | 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`); diff --git a/package.json b/package.json index 6b62235..f810cfe 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/schemas/input.schema.json b/schemas/input.schema.json index b50dff1..e62fc2a 100644 --- a/schemas/input.schema.json +++ b/schemas/input.schema.json @@ -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 `. 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 --out `. The cache key a consumer may rely on is (id, as_of_date).", "type": "object", "required": [ "id", diff --git a/schemas/interpretability.schema.json b/schemas/interpretability.schema.json index 529ba18..da6b5b3 100644 --- a/schemas/interpretability.schema.json +++ b/schemas/interpretability.schema.json @@ -1,8 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/REagent-LABrador/clinical_simulation/schemas/interpretability.schema.json", - "title": "LABrador interpretability contract", - "description": "Shared interpretability block every LABrador module emits with each successful or domain-abstaining run (infrastructure failures that produce no result file are the orchestrator's problem, not this schema's). It exists so a UI can answer, without module-specific code: what was concluded, why, on which evidence and assumptions, how each derived value was calculated, what uncertainty and limitations remain, and what would change the conclusion. Runtime provenance (repo SHA, input/output hashes, LIVE/CACHED/FALLBACK origin, duration, exit status) is added by the orchestrator and is deliberately NOT duplicated here. Contract rules a validator cannot fully express: IDs are stable machine names (never derived from array position) and unique within their collection; every *_id reference must resolve; unknown values are JSON null plus an explaining limitation (never silently 0/false/empty); heuristic scores are labeled heuristic and never called probabilities; interval bounds state whether they are scenarios, percentiles, or confidence intervals; no NaN/Infinity; no HTML. Doc keys beginning with '_' are permitted anywhere.", + "$id": "https://schemas.reagent-labrador.org/interpretability/1.0.0/interpretability.schema.json", + "title": "LABrador shared interpretability contract (unified v1.0.0)", "type": "object", "required": [ "schema_version", @@ -17,43 +16,119 @@ "lineage", "extensions" ], + "additionalProperties": false, "patternProperties": { "^_": {} }, - "additionalProperties": false, "properties": { "schema_version": { - "description": "Version of this interpretability contract (semver).", "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, "headline": { - "description": "The one-glance answer: what did this module conclude?", + "$ref": "#/$defs/headline" + }, + "metrics": { + "type": "array", + "items": { + "$ref": "#/$defs/metric" + } + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/step" + } + }, + "evidence": { + "type": "array", + "items": { + "$ref": "#/$defs/evidence" + } + }, + "assumptions": { + "type": "array", + "items": { + "$ref": "#/$defs/assumption" + } + }, + "uncertainty": { + "$ref": "#/$defs/uncertainty" + }, + "limitations": { + "type": "array", + "items": { + "$ref": "#/$defs/limitation" + } + }, + "counterfactuals": { + "type": "array", + "items": { + "$ref": "#/$defs/counterfactual" + } + }, + "lineage": { + "type": "array", + "items": { + "$ref": "#/$defs/lineage" + } + }, + "extensions": { + "type": "object" + } + }, + "$defs": { + "stable_id": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "json_scalar": { + "type": [ + "number", + "string", + "boolean", + "null" + ] + }, + "json_value": { + "type": [ + "number", + "string", + "boolean", + "null", + "array", + "object" + ] + }, + "headline": { "type": "object", - "required": ["title", "result", "plain_language", "status", "basis"], + "required": [ + "title", + "result", + "plain_language", + "status", + "basis" + ], + "additionalProperties": false, "patternProperties": { "^_": {} }, - "additionalProperties": false, "properties": { "title": { - "description": "Short UI title. No HTML.", "type": "string", "minLength": 1, - "maxLength": 120 + "maxLength": 240 }, "result": { - "description": "Stable machine-readable result token (module-defined vocabulary, e.g. FEASIBLE / FEASIBLE_WITH_RISK / INFEASIBLE_TRIAL_DESIGN for the trial-recruitment forecaster).", "type": "string", - "pattern": "^[A-Z0-9_]+$" + "minLength": 1 }, "plain_language": { - "description": "One-sentence human explanation of the result.", "type": "string", "minLength": 1 }, "status": { - "description": "How solid the module's own conclusion is: SUPPORTED = evidence-backed; QUALIFIED = holds but with material caveats or fallbacks; INCONCLUSIVE = could not conclude; FAILED = the module ran but the domain computation failed; NOT_APPLICABLE = the question does not apply to this input.", "enum": [ "SUPPORTED", "QUALIFIED", @@ -63,513 +138,524 @@ ] }, "basis": { - "description": "What kinds of information the conclusion rests on. OBSERVED = real recorded data; INFERRED = judgement over real data (e.g. an LLM read of source text); MODELED = arithmetic/model output; SYNTHETIC = defaulted or assumed-neutral values were used somewhere load-bearing.", "type": "array", "minItems": 1, "uniqueItems": true, "items": { - "enum": ["OBSERVED", "INFERRED", "MODELED", "SYNTHETIC"] + "enum": [ + "OBSERVED", + "INFERRED", + "MODELED", + "SYNTHETIC" + ] } } } }, - "metrics": { - "description": "The numbers a UI should surface, each with unit, meaning, and links to the evidence/assumptions behind it. An important metric with neither link must be flagged by an UNTAGGED_VALUE limitation.", - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "label", - "value", - "unit", - "display", - "meaning", - "direction", - "evidence_ids", - "assumption_ids" - ], - "patternProperties": { - "^_": {} - }, - "additionalProperties": false, - "properties": { - "id": { - "$ref": "#/$defs/metric_id" - }, - "label": { - "type": "string", - "minLength": 1 - }, - "value": { - "description": "The metric's value at full native precision, or null when genuinely unknown (a limitation must then explain why).", - "type": ["number", "null"] - }, - "unit": { - "$ref": "#/$defs/unit" - }, - "display": { - "description": "Short pre-formatted display string (rounding lives here, never in `value`). No HTML.", - "type": "string", - "minLength": 1, - "maxLength": 120 - }, - "meaning": { - "description": "Why this number matters — and what it must NOT be read as, where that risk exists.", - "type": "string", - "minLength": 1 - }, - "direction": { - "description": "Whether a larger value is favorable (positive), unfavorable (negative), neither (neutral), context-dependent (mixed), or unknown.", - "enum": ["positive", "negative", "neutral", "mixed", "unknown"] - }, - "evidence_ids": { - "type": "array", - "items": { - "$ref": "#/$defs/evidence_id" - } - }, - "assumption_ids": { - "type": "array", - "items": { - "$ref": "#/$defs/assumption_id" - } + "metric": { + "type": "object", + "required": [ + "id", + "label", + "value", + "unit", + "display", + "meaning", + "direction", + "evidence_ids", + "assumption_ids" + ], + "additionalProperties": false, + "patternProperties": { + "^_": {} + }, + "properties": { + "id": { + "$ref": "#/$defs/stable_id" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "value": { + "$ref": "#/$defs/json_scalar" + }, + "unit": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "display": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "meaning": { + "type": "string", + "minLength": 1 + }, + "direction": { + "enum": [ + "positive", + "negative", + "neutral", + "mixed", + "unknown" + ] + }, + "evidence_ids": { + "type": "array", + "items": { + "$ref": "#/$defs/stable_id" } - } - } - }, - "steps": { - "description": "Ordered calculation/decision steps: how the module got from inputs to conclusion. Order is execution order.", - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "label", - "method", - "formula", - "inputs", - "result", - "evidence_ids", - "assumption_ids" - ], - "patternProperties": { - "^_": {} - }, - "additionalProperties": false, - "properties": { - "id": { - "$ref": "#/$defs/step_id" - }, - "label": { - "type": "string", - "minLength": 1 - }, - "method": { - "description": "Named method (e.g. 'median of observed precedent velocities', 'bisection over the months-vs-prevalence curve').", - "type": "string", - "minLength": 1 - }, - "formula": { - "description": "Human-readable formula, or null when the step is not a formula (a search, an LLM judgement, a lookup).", - "type": ["string", "null"] - }, - "inputs": { - "type": "array", - "items": { - "$ref": "#/$defs/valued_ref" - } - }, - "result": { - "$ref": "#/$defs/valued_result" - }, - "evidence_ids": { - "type": "array", - "items": { - "$ref": "#/$defs/evidence_id" - } - }, - "assumption_ids": { - "type": "array", - "items": { - "$ref": "#/$defs/assumption_id" - } + }, + "assumption_ids": { + "type": "array", + "items": { + "$ref": "#/$defs/stable_id" } } - } - }, - "evidence": { - "description": "Sources supporting claims. `synthetic` is true only for placeholder sources, which must also be explained by a limitation.", - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "claim", - "source_type", - "source_id", - "source_url", - "locator", - "quote", - "grade", - "synthetic" - ], - "patternProperties": { - "^_": {} - }, - "additionalProperties": false, + }, + "if": { "properties": { - "id": { - "$ref": "#/$defs/evidence_id" - }, - "claim": { - "description": "The claim this source supports.", - "type": "string", - "minLength": 1 - }, - "source_type": { - "enum": [ - "trial", - "publication", - "database", - "registry", - "simulation", - "other" - ] - }, - "source_id": { - "description": "Real identifier: NCT id, DOI, PMID, accession, or a stated query descriptor for aggregate registry counts.", - "type": "string", - "minLength": 1 - }, - "source_url": { - "description": "Resolvable URL for the source, or null when none exists.", - "type": ["string", "null"], - "pattern": "^https?://" - }, - "locator": { - "description": "Where in the source (figure, section, registry field), or null.", - "type": ["string", "null"] - }, - "quote": { - "description": "Short VERIFIED quote from the source, or null. Never paraphrase into this field.", - "type": ["string", "null"] - }, - "grade": { - "description": "Strength of support for the claim: HIGH = direct primary statement or randomized human data; MODERATE = real data with known imprecision; LOW = weak/indirect; UNSUPPORTED = asserted without a source.", - "enum": ["HIGH", "MODERATE", "LOW", "UNSUPPORTED"] - }, - "synthetic": { - "type": "boolean" + "value": { + "type": "number" } - } - } - }, - "assumptions": { - "description": "Inputs taken as given plus every model constant and threshold, each with the reason it was selected. `synthetic` marks defaulted/assumed-neutral values (which must also raise a limitation).", - "type": "array", - "items": { - "type": "object", - "required": ["id", "path", "value", "unit", "basis", "synthetic"], - "patternProperties": { - "^_": {} }, - "additionalProperties": false, + "required": [ + "value" + ] + }, + "then": { "properties": { - "id": { - "$ref": "#/$defs/assumption_id" - }, - "path": { - "description": "Where the value lives: an input path (input.*), an output path (output.*), or a model constant (model.constants.*).", - "type": "string", - "minLength": 1 - }, - "value": { - "type": ["number", "string", "boolean", "null"] - }, "unit": { - "type": ["string", "null"], - "minLength": 1 - }, - "basis": { - "description": "Why this value was selected — its provenance or rationale.", "type": "string", "minLength": 1 - }, - "synthetic": { - "type": "boolean" } }, - "if": { - "properties": { - "value": { - "type": "number" - } - } - }, - "then": { - "properties": { - "unit": { - "type": "string" - } - } - } + "required": [ + "unit" + ] } }, - "uncertainty": { - "description": "What the module knows about how wrong it could be. `limitations` here must state what the intervals do and do NOT mean.", + "step": { "type": "object", - "required": ["method", "intervals", "seed", "draws", "limitations"], + "required": [ + "id", + "label", + "method", + "formula", + "inputs", + "result", + "evidence_ids", + "assumption_ids" + ], + "additionalProperties": false, "patternProperties": { "^_": {} }, - "additionalProperties": false, "properties": { + "id": { + "$ref": "#/$defs/stable_id" + }, + "label": { + "type": "string", + "minLength": 1 + }, "method": { - "description": "How uncertainty was quantified (Monte Carlo, scenario range, heuristic score, none), stating whether bounds are scenarios, percentiles, or confidence intervals.", "type": "string", "minLength": 1 }, - "intervals": { + "formula": { + "type": [ + "string", + "null" + ] + }, + "inputs": { "type": "array", "items": { "type": "object", "required": [ - "metric_id", - "low", - "central", - "high", - "unit", - "confidence_level" + "path", + "value", + "unit" ], + "additionalProperties": false, "patternProperties": { "^_": {} }, - "additionalProperties": false, "properties": { - "metric_id": { - "$ref": "#/$defs/metric_id" - }, - "low": { - "type": "number" - }, - "central": { - "type": "number" + "path": { + "type": "string", + "minLength": 1 }, - "high": { - "type": "number" + "value": { + "$ref": "#/$defs/json_value" }, "unit": { - "$ref": "#/$defs/unit" - }, - "confidence_level": { - "description": "Confidence level in (0,1] when the bounds are a genuine confidence/credible interval; null for scenario or percentile bounds.", - "type": ["number", "null"], - "exclusiveMinimum": 0, - "maximum": 1 + "type": [ + "string", + "null" + ], + "minLength": 1 } } } }, - "seed": { - "description": "RNG seed when the method draws random numbers; null when no RNG is involved.", - "type": ["number", "string", "null"] - }, - "draws": { - "description": "Monte Carlo draw count; null when the method is not sampling-based.", - "type": ["integer", "null"] + "result": { + "type": "object", + "required": [ + "value", + "unit" + ], + "additionalProperties": false, + "patternProperties": { + "^_": {} + }, + "properties": { + "value": { + "$ref": "#/$defs/json_scalar" + }, + "unit": { + "type": [ + "string", + "null" + ], + "minLength": 1 + } + } }, - "limitations": { + "evidence_ids": { "type": "array", - "minItems": 1, "items": { - "type": "string", - "minLength": 1 + "$ref": "#/$defs/stable_id" } - } - } - }, - "limitations": { - "description": "Structured caveats: everything a careful reader must know before trusting the headline.", - "type": "array", - "items": { - "type": "object", - "required": ["code", "severity", "message", "field_path"], - "patternProperties": { - "^_": {} }, - "additionalProperties": false, - "properties": { - "code": { - "description": "Stable machine code for the limitation class.", - "type": "string", - "pattern": "^[A-Z0-9_]+$" - }, - "severity": { - "enum": ["INFO", "WARNING", "ERROR"] - }, - "message": { - "type": "string", - "minLength": 1 - }, - "field_path": { - "description": "The output field the limitation is about, or null when it applies to the run as a whole.", - "type": ["string", "null"] + "assumption_ids": { + "type": "array", + "items": { + "$ref": "#/$defs/stable_id" } } } }, - "counterfactuals": { - "description": "What would change or falsify the conclusion. Empty only when genuinely not applicable, with a limitation explaining the absence.", - "type": "array", - "items": { - "type": "object", - "required": ["change", "result", "meaning"], - "patternProperties": { - "^_": {} + "evidence": { + "type": "object", + "required": [ + "id", + "claim", + "source_type", + "source_id", + "source_url", + "locator", + "quote", + "grade", + "synthetic" + ], + "additionalProperties": false, + "patternProperties": { + "^_": {} + }, + "properties": { + "id": { + "$ref": "#/$defs/stable_id" }, - "additionalProperties": false, - "properties": { - "change": { - "description": "What input or condition changed.", - "type": "string", - "minLength": 1 - }, - "result": { - "description": "Result of the change.", - "type": "string", - "minLength": 1 - }, - "meaning": { - "description": "Why the change matters.", - "type": "string", - "minLength": 1 - } - } - } - }, - "lineage": { - "description": "Which inputs produced which outputs, and how.", - "type": "array", - "items": { - "type": "object", - "required": ["output_path", "input_paths", "transformation"], - "patternProperties": { - "^_": {} + "claim": { + "type": "string", + "minLength": 1 }, - "additionalProperties": false, - "properties": { - "output_path": { - "type": "string", - "minLength": 1 - }, - "input_paths": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } - }, - "transformation": { - "description": "How the inputs produced the output.", - "type": "string", - "minLength": 1 - } + "source_type": { + "type": "string", + "minLength": 1 + }, + "source_id": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "source_url": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "locator": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "quote": { + "type": [ + "string", + "null" + ] + }, + "grade": { + "enum": [ + "HIGH", + "MODERATE", + "LOW", + "UNSUPPORTED" + ] + }, + "synthetic": { + "type": "boolean" } } }, - "extensions": { - "description": "Module-specific structured data (e.g. engine/data versions). The shared UI must never REQUIRE anything in here.", - "type": "object" - } - }, - "$defs": { - "metric_id": { - "type": "string", - "pattern": "^metric\\.[A-Za-z0-9_]+(\\.[A-Za-z0-9_-]+)*$" - }, - "step_id": { - "type": "string", - "pattern": "^step\\.[A-Za-z0-9_]+(\\.[A-Za-z0-9_-]+)*$" - }, - "evidence_id": { - "type": "string", - "pattern": "^evidence\\.[A-Za-z0-9_]+(\\.[A-Za-z0-9_-]+)*$" - }, - "assumption_id": { - "type": "string", - "pattern": "^assumption\\.[A-Za-z0-9_]+(\\.[A-Za-z0-9_-]+)*$" - }, - "unit": { - "description": "Unit for a numeric value (USD, USD/patient-year, fraction, score, months, patients, trials, points, patients/site-month, ...). Required for every numeric metric.", - "type": "string", - "minLength": 1 - }, - "valued_ref": { - "description": "A named input to a step: where it came from, its value, and its unit (required when the value is numeric; null otherwise allowed).", + "assumption": { "type": "object", - "required": ["path", "value", "unit"], + "required": [ + "id", + "path", + "value", + "unit", + "basis", + "synthetic" + ], + "additionalProperties": false, "patternProperties": { "^_": {} }, - "additionalProperties": false, "properties": { + "id": { + "$ref": "#/$defs/stable_id" + }, "path": { "type": "string", "minLength": 1 }, "value": { - "type": ["number", "string", "boolean", "null"] + "$ref": "#/$defs/json_value" }, "unit": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "minLength": 1 + }, + "basis": { + "type": "string", + "minLength": 1 + }, + "synthetic": { + "type": [ + "boolean", + "null" + ] } + } + }, + "interval": { + "type": "object", + "required": [ + "metric_id", + "low", + "central", + "high", + "unit", + "confidence_level" + ], + "additionalProperties": false, + "patternProperties": { + "^_": {} }, - "if": { - "properties": { - "value": { - "type": "number" - } + "properties": { + "metric_id": { + "$ref": "#/$defs/stable_id" + }, + "low": { + "type": [ + "number", + "null" + ] + }, + "central": { + "type": [ + "number", + "null" + ] + }, + "high": { + "type": [ + "number", + "null" + ] + }, + "unit": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "confidence_level": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "maximum": 1 + }, + "interval_type": { + "enum": [ + "percentile", + "confidence_interval", + "scenario", + "observed_range", + "heuristic_spread" + ] } + } + }, + "uncertainty": { + "type": "object", + "required": [ + "method", + "intervals", + "seed", + "draws", + "limitations" + ], + "additionalProperties": false, + "patternProperties": { + "^_": {} }, - "then": { - "properties": { - "unit": { - "type": "string" + "properties": { + "method": { + "type": "string", + "minLength": 1 + }, + "intervals": { + "type": "array", + "items": { + "$ref": "#/$defs/interval" + } + }, + "seed": { + "type": [ + "integer", + "string", + "null" + ] + }, + "draws": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "limitations": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 } } } }, - "valued_result": { - "description": "A step's result. Numeric results must carry a unit; string results (e.g. a feasibility token) carry null.", + "limitation": { "type": "object", - "required": ["value", "unit"], + "required": [ + "code", + "severity", + "message", + "field_path" + ], + "additionalProperties": false, "patternProperties": { "^_": {} }, - "additionalProperties": false, "properties": { - "value": { - "type": ["number", "string", "boolean", "null"] + "code": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" }, - "unit": { - "type": ["string", "null"], + "severity": { + "enum": [ + "INFO", + "WARNING", + "ERROR" + ] + }, + "message": { + "type": "string", + "minLength": 1 + }, + "field_path": { + "type": [ + "string", + "null" + ], "minLength": 1 } + } + }, + "counterfactual": { + "type": "object", + "required": [ + "change", + "result", + "meaning" + ], + "additionalProperties": false, + "patternProperties": { + "^_": {} }, - "if": { - "properties": { - "value": { - "type": "number" - } + "properties": { + "change": { + "type": "string", + "minLength": 1 + }, + "result": { + "type": "string", + "minLength": 1 + }, + "meaning": { + "type": "string", + "minLength": 1 } + } + }, + "lineage": { + "type": "object", + "required": [ + "output_path", + "input_paths", + "transformation" + ], + "additionalProperties": false, + "patternProperties": { + "^_": {} }, - "then": { - "properties": { - "unit": { - "type": "string" + "properties": { + "output_path": { + "type": "string", + "minLength": 1 + }, + "input_paths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 } + }, + "transformation": { + "type": "string", + "minLength": 1 } } } diff --git a/schemas/output.schema.json b/schemas/output.schema.json index bebbbaf..6fe31e0 100644 --- a/schemas/output.schema.json +++ b/schemas/output.schema.json @@ -54,7 +54,7 @@ }, "interpretability": { "description": "The shared LABrador interpretability contract: what was concluded (headline.result is FEASIBLE / FEASIBLE_WITH_RISK / INFEASIBLE_TRIAL_DESIGN, bucketed from the same 18/48-month thresholds as `score`), why, on which evidence and assumptions, how each value was derived, what uncertainty and limitations remain, and what would change the verdict. Present on every successful and domain-abstaining run.", - "$ref": "./interpretability.schema.json" + "$ref": "https://schemas.reagent-labrador.org/interpretability/1.0.0/interpretability.schema.json" }, "as_of_date": { "description": "Echo of the request's evidence horizon. Absent when the run used today's registry.",