diff --git a/CLAUDE.md b/CLAUDE.md index f838845f..5b076572 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -322,6 +322,9 @@ ClickHouse SQL; clients never send SQL. Live at https://cbiolab.vercel.app (UI a shareable `?doc=`/`?spec=` link. Two levels: the document panel shows each cohort's compiled SQL, and every view shows the aggregate queries that produced its numbers (resolvers log what they run; a peer returns its own log, marked as having executed there). OncoKB annotations are version-pinned (pin echoed in results). + Every result lists its `sources` (`lib/provenance.ts`): each database and MCP peer, with what a + peer reports about itself in every answer (name, build, each study's import date), and each + view's participation points into that list. 3. **Compilers are single-study; federation happens at the analysis layer** with study-qualified ids. Cross-study: overlap is deliberately NOT deduplicated, vocabularies NOT harmonized — and views/captions must say so. diff --git a/README.md b/README.md index c8d34ec9..562c2ae7 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,13 @@ Every federated call is logged at both ends (`lib/federation-log.ts`), in the se [federation ←] federated_patient_timeline study=nsclc_public_genie_bpc (patient-level) caller=outside 461ms HTTP 200 REFUSED ``` -`→` is this app asking a peer, `←` its `/api/mcp` answering; `caller=outside` is anyone else. An +`→` is this app asking a peer, `←` its `/api/mcp` answering; `caller=outside` is anyone else. + +The answer says so too. Every analysis result lists its `sources` — each database of this +deployment and each peer, with the peer's address, the name and build it reports about itself, when +it answered, and when each study's data was imported there — and each view's `participation` +points into that list. The Analysis tab shows it under the cohorts ("Data from: …"), and MCP's +`run_analysis` text says it as well. An analysis answered from the result cache makes no calls, so it logs none. A `[boundary]` line means a query reached the simulated peer's database directly and was refused (`lib/ch.ts`) — a bug. diff --git a/app/lab/analysis-sections.tsx b/app/lab/analysis-sections.tsx index 2dcc23bc..5e7a3971 100644 --- a/app/lab/analysis-sections.tsx +++ b/app/lab/analysis-sections.tsx @@ -20,7 +20,7 @@ import { PartitionPanel, SplitForm, type SplitSpec } from "./partitions.tsx"; import type { DocUpgrade } from "../../lib/doc-version.ts"; import type { OnCustomData } from "./pills"; import { requestLogin, referencedDatasets, unresolvedUploads } from "./custom-data-client.ts"; -import { CohortStudies, CohortEditMenu, SeriesSwatch, ViewHead } from "./view-chrome.tsx"; +import { AnalysisSources, CohortStudies, CohortEditMenu, SeriesSwatch, ViewHead } from "./view-chrome.tsx"; import { cohortHash, shortHash } from "../../lib/cohort-hash.ts"; import { sameJson } from "../../lib/same-json.ts"; import { viewIsEmpty } from "../../lib/partition-arms.ts"; @@ -252,6 +252,7 @@ export function CohortColumn({ ctx }: { ctx: AnalysisCtx }) { ); })} + )} diff --git a/app/lab/view-chrome.tsx b/app/lab/view-chrome.tsx index a0c3204e..a8fceaa5 100644 --- a/app/lab/view-chrome.tsx +++ b/app/lab/view-chrome.tsx @@ -4,7 +4,7 @@ // so React keeps their state across the analysis page's renders. import { useEffect, useRef, useState } from "react"; import { attributeLabel } from "../../lib/custom-data-names.ts"; -import type { ViewParticipation } from "../../lib/types.ts"; +import type { AnalysisSource, ViewParticipation } from "../../lib/types.ts"; import { VIEW_HELP } from "../../lib/view-help.ts"; import { SERIES, seriesStyle } from "../../lib/chart/style.ts"; import { HIGHLIGHT_CAP, HIGHLIGHT_COLORS } from "../../lib/plot-highlight.ts"; @@ -490,6 +490,37 @@ export function CohortStudies({ ids, names }: { ids: string[]; names: Map }) { + if (!sources || !(sources.length > 1 || sources.some((s) => s.remote))) return null; + const day = (t: string | null) => (t ? t.slice(0, 10) : "import date unknown"); + const studies = (s: AnalysisSource) => s.studies.map((x) => (names.get(x.study) || x.study) + ", imported " + day(x.imported)).join("; "); + return ( +

+ Data from:{" "} + {sources.map((s, i) => { + const peer = s.remote ? [ + "over MCP at " + (s.url || "an unknown address"), + s.reportedName ? "which calls itself “" + s.reportedName + "”" : "which has not described itself", + s.build ? "build " + s.build : null, + s.answeredAt ? "first answered " + new Date(s.answeredAt).toLocaleString() : null + ].filter(Boolean).join(", ") : "this deployment's own database" + (s.build ? ", build " + s.build : ""); + return ( + x.study + ": imported " + (x.imported || "unknown")).join("\n")}> + {i > 0 && " · "}{s.label} + {" "}({s.remote ? "via MCP" + (s.reportedName && s.reportedName !== s.label ? ", calls itself “" + s.reportedName + "”" : "") : "local"}) — {studies(s)} + + ); + })} +

+ ); +} + /** * Every view's header, with one anatomy: the title and what it is, then the actions that * belong to this view, then its status chips, then the controls that reorder and remove it. diff --git a/lib/analysis.ts b/lib/analysis.ts index b1b5d8ea..dc84cf83 100644 --- a/lib/analysis.ts +++ b/lib/analysis.ts @@ -13,6 +13,7 @@ import type { AnalysisDoc, AnalysisResult, CohortDef, PartitionDef, ViewResult, import { resolveSplitBy, type AttributeDistribution } from "./cohort-splitby.ts"; import { resolverFor, PatientLevelResolver, AggregateResolver, studyRouting, restrictedStudies, studyAccess, type Resolver } from "./federation.ts"; import { cantDraw } from "./view-coverage.ts"; +import { describeSources, sourceIndex } from "./provenance.ts"; import { resolveParams } from "./params.ts"; import { resolveExtends } from "./cohort-extends.ts"; import { resolvePartitions, comparableChildren, assertPartitionChildrenFit } from "./cohort-partitions.ts"; @@ -322,14 +323,18 @@ export async function runAnalysis(template: AnalysisDoc | TemplateDoc, opts: Run // ---- views -------------------------------------------------------------- // Every view records which studies answered it and in what mode, so a reader // never has to infer from prose whether patient-level data was involved. + // each names its source in the result's `sources` (lib/provenance.ts) + const sourceOf = sourceIndex(involved, resolvers); const participationFor = (studies: string[], requiresPatientLevel: boolean): ViewParticipation => { const per = studies.map((s) => { const inst = resolvers[s] ? resolvers[s].instance : null; + const source = sourceOf.get(s); return { study: s, mode: (inst ? inst.mode : "patient-level") as "patient-level" | "aggregate-only", instance: inst ? inst.label : "unknown", - ...(inst && inst.id === "peer" ? { remote: true } : {}) + ...(inst && inst.id === "peer" ? { remote: true } : {}), + ...(source != null ? { source } : {}) }; }); return { studies: per, requiresPatientLevel, aggregateOnly: per.filter((x) => x.mode === "aggregate-only").map((x) => x.study) }; @@ -471,6 +476,7 @@ export async function runAnalysis(template: AnalysisDoc | TemplateDoc, opts: Run return { study: docStudy, involvedStudies: involved, oncokbPin: pin, + sources: await describeSources(involved, resolvers), // Echoed so a reader can see WHICH values produced these numbers, the same // reason the OncoKB pin is echoed. `unusedParams` catches a rename that // missed a reference: the run succeeds, so nothing else would say so. diff --git a/lib/federation-remote.ts b/lib/federation-remote.ts index ce70bb7d..cc5797af 100644 --- a/lib/federation-remote.ts +++ b/lib/federation-remote.ts @@ -19,7 +19,7 @@ import { loopbackOnlyConn, noteFailedQuery } from "./ch.ts"; import { logOutbound } from "./federation-log.ts"; import { PEER_TABLES, type RemotePatientRows, type PeerRowsRequest, type PeerRowsAnswer } from "./federation.ts"; import { acceptedFromPeers, allowedKinds, PATIENT_KIND_NAMES, type PatientKind } from "./disclosure.ts"; -import type { PatientTimeline, PatientEvent, PatientSample } from "./types.ts"; +import type { PatientTimeline, PatientEvent, PatientSample, PeerSource } from "./types.ts"; import type { StudyRow } from "./study-list.ts"; import type { StudyCatalog } from "./study-catalog.ts"; @@ -207,11 +207,25 @@ export class RemoteAggregateResolver implements AggregateCapable { * aggregate SQL, marked as having executed on the peer rather than here. */ readonly queries: QueryLogEntry[] = []; + /** what the peer said about itself in its first answer, and when (lib/provenance.ts) */ + private reported: PeerSource | null = null; + private answeredAt: string | null = null; constructor(study: string, url: string, instance: Instance) { this.study = study; this.url = url; this.instance = instance; } + /** Every call to the peer goes through here, so its first self-report is kept. */ + protected async call(name: string, args: unknown): Promise { + const r = await mcpCall(this.url, name, args); + const src = (r as { source?: PeerSource } | null)?.source; + if (src && !this.reported) { this.reported = src; this.answeredAt = new Date().toISOString(); } + return r; + } + /** Where this study's peer is, and what it reported about itself (null before it answered). */ + provenance(): { url: string; reported: PeerSource | null; answeredAt: string | null } { + return { url: this.url, reported: this.reported, answeredAt: this.answeredAt }; + } private remember(r: { cohortSql?: string | null; queries?: QueryLogEntry[] | null }): void { if (!r) return; if (typeof r.cohortSql === "string" && r.cohortSql.trim()) this.peerSql = r.cohortSql; @@ -232,54 +246,54 @@ export class RemoteAggregateResolver implements AggregateCapable { } async count(def: CohortDef | null | undefined): Promise { this.guard(def); - const r = await mcpCall(this.url, "federated_aggregates", { study: this.study, cohort: def || {} }); + const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {} }); this.remember(r); return r.count; } async geneCounts(def: CohortDef | null | undefined, genes: string[]): Promise { this.guard(def); if (!genes.length) return []; - const r = await mcpCall(this.url, "federated_aggregates", { study: this.study, cohort: def || {}, genes }); + const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {}, genes }); this.remember(r); return r.geneCounts; } async survivalCounts(def: CohortDef | null | undefined, endpoint = "OS", anchor?: SurvivalAnchor | null, columns?: EndpointColumns): Promise { this.guard(def); - const r = await mcpCall(this.url, "federated_aggregates", + const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {}, survival: true, endpoint, ...(anchor ? { anchor } : {}), ...(columns ? { endpointColumns: columns } : {}) }); this.remember(r); return r.survivalCounts; } async survivalEndpoints(): Promise { // the peer reports what IT can answer; asking costs one call with no cohort - const r = await mcpCall(this.url, "federated_aggregates", { study: this.study, cohort: {}, survivalEndpoints: true }) + const r = await this.call("federated_aggregates", { study: this.study, cohort: {}, survivalEndpoints: true }) .catch(() => ({ survivalEndpoints: [] as string[] })); return (r as { survivalEndpoints?: string[] }).survivalEndpoints || []; } async timelineSpan(def: CohortDef | null | undefined): Promise { this.guard(def); - const r = await mcpCall(this.url, "federated_aggregates", + const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {}, timelineSpan: true }); this.remember(r); return r.timelineSpan || []; } async treatmentSequence(def: CohortDef | null | undefined, opts: SequenceOpts): Promise { this.guard(def); - const r = await mcpCall(this.url, "federated_aggregates", + const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {}, sequence: opts }); this.remember(r); return r.treatmentSequence || []; } async sequenceKeys(def: CohortDef | null | undefined, eventType: string): Promise<{ key: string; label: string; patients: number; values: number }[]> { this.guard(def); - const r = await mcpCall(this.url, "federated_aggregates", + const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {}, sequenceKeys: eventType }); this.remember(r); return r.sequenceKeys || []; } async eventCourse(def: CohortDef | null | undefined, opts: CourseOpts): Promise { this.guard(def); - const r = await mcpCall(this.url, "federated_aggregates", + const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {}, course: opts }); this.remember(r); return r.eventCourse || []; @@ -304,14 +318,14 @@ export class RemoteAggregateResolver implements AggregateCapable { async pairCounts(def: CohortDef | null | undefined, pairs: [string, string][]): Promise { this.guard(def); if (!pairs.length) return []; - const r = await mcpCall(this.url, "federated_aggregates", { study: this.study, cohort: def || {}, pairs }); + const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {}, pairs }); this.remember(r); return r.pairCounts || []; } async attributeStats(def: CohortDef | null | undefined, attributes: string[], opts?: { histograms?: boolean; topValues?: number }): Promise { this.guard(def); if (!attributes.length) return []; - const r = await mcpCall(this.url, "federated_aggregates", + const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {}, attributes, ...(opts?.histograms ? { attributeHistograms: true } : {}), ...(opts?.topValues != null ? { attributeTopValues: opts.topValues } : {}) }); this.remember(r); @@ -320,7 +334,7 @@ export class RemoteAggregateResolver implements AggregateCapable { async profileMoments(def: CohortDef | null | undefined, genes: string[], profileType: string): Promise { this.guard(def); if (!genes.length || !profileType) return []; - const r = await mcpCall(this.url, "federated_aggregates", + const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {}, momentGenes: genes, profileType }); this.remember(r); return r.profileMoments || []; @@ -328,7 +342,7 @@ export class RemoteAggregateResolver implements AggregateCapable { async variantCounts(def: CohortDef | null | undefined, genes: string[], minCount = 1): Promise { this.guard(def); if (!genes.length) return { variants: [], totals: [], minCount }; - const r = await mcpCall(this.url, "federated_aggregates", + const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {}, variantGenes: genes, minCount }); this.remember(r); return r.variantCounts || { variants: [], totals: [], minCount }; @@ -336,7 +350,7 @@ export class RemoteAggregateResolver implements AggregateCapable { async lollipopCounts(def: CohortDef | null | undefined, genes: string[], minCount = 1): Promise { this.guard(def); if (!genes.length) return { changes: [], types: [], totals: [], cohortSamples: 0, minCount, annotated: false }; - const r = await mcpCall(this.url, "federated_aggregates", + const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {}, lollipopGenes: genes, minCount }); this.remember(r); // a peer on an older build answers without these; refuse by name rather than draw nothing @@ -347,7 +361,7 @@ export class RemoteAggregateResolver implements AggregateCapable { async pairMoments(def: CohortDef | null | undefined, pairs: [string, string][], profileType: string): Promise { this.guard(def); if (!pairs.length || !profileType) return []; - const r = await mcpCall(this.url, "federated_aggregates", + const r = await this.call("federated_aggregates", { study: this.study, cohort: def || {}, pairs, profileType }); this.remember(r); return r.pairMoments || []; @@ -401,7 +415,7 @@ export class RemotePatientResolver extends RemoteAggregateResolver implements Re if (!this.patientKinds.has(kind)) throw new Error("this instance does not accept " + PATIENT_KIND_NAMES[kind] + " from the peer serving " + this.study + " (PEER_MCP_PATIENT_LEVEL)"); this.refuseIds(req.parts.map((p) => p.def)); - const r = await this.disclosed(mcpCall(this.peerUrl, "federated_patient_rows", { + const r = await this.disclosed(this.call("federated_patient_rows", { study: this.study, table: req.table, parts: req.parts.map((p) => ({ cohort: p.cohort, cohortDef: p.def || {} })), source: this.instance.label, query: req.query, defaults: req.defaults, mode: req.mode, ...(req.limit != null ? { limit: req.limit } : {}), ...(req.offset != null ? { offset: req.offset } : {}), ...(req.column ? { column: req.column } : {}), @@ -432,8 +446,8 @@ export class RemoteTimelineResolver extends RemotePatientResolver { async patientTimeline(patient: string): Promise { if (!this.patientKinds.has("timeline")) throw new Error("this instance does not accept patient timelines from the peer serving " + this.study + " (PEER_MCP_PATIENT_LEVEL)"); - const r = await this.disclosed(mcpCall<{ study: string; patient: string; events: unknown[]; samples: unknown[]; queries?: QueryLogEntry[] | null }>( - this.peerUrl, "federated_patient_timeline", { study: this.study, patient }), "timeline"); + const r = await this.disclosed(this.call<{ study: string; patient: string; events: unknown[]; samples: unknown[]; queries?: QueryLogEntry[] | null }>( + "federated_patient_timeline", { study: this.study, patient }), "timeline"); if (r.study !== this.study || r.patient !== patient) throw new Error("peer answered for " + r.study + "/" + r.patient + ", not " + this.study + "/" + patient); if (Array.isArray(r.queries)) for (const q of r.queries) this.queries.push({ label: q.label, sql: q.sql, ms: q.ms, remote: true }); const events = (Array.isArray(r.events) ? r.events : []).map(asEvent); diff --git a/lib/provenance.ts b/lib/provenance.ts new file mode 100644 index 00000000..b36975e2 --- /dev/null +++ b/lib/provenance.ts @@ -0,0 +1,82 @@ +// Where an analysis's data came from. A peer says who it is in every federated answer (`source`: +// its own name, the commit it runs, and when the study it answered for was last imported there); +// the coordinator keeps the first such report per study (lib/federation-remote.ts) and lists every +// source of a run, local or remote, in the result's `sources` (lib/analysis.ts). +import { chQuery, isAbortError, type ChConn } from "./ch.ts"; +import { sq } from "./compiler.ts"; +import { BUILD_INFO } from "./build-info.ts"; +import { studyRouting, type Resolver } from "./federation.ts"; +import type { AnalysisSource, PeerSource } from "./types.ts"; + +const IMPORTED_TTL = 5 * 60_000; +const imported = new Map }>(); + +/** When `study`'s data was last imported into the database `conn` reaches, or null if it can't tell. */ +export function studyImported(study: string, conn: ChConn): Promise { + const key = conn.id + "|" + conn.db + "|" + study; + const hit = imported.get(key); + if (hit && Date.now() - hit.at < IMPORTED_TTL) return hit.value; + const value = chQuery<{ d: string }>("SELECT toString(max(import_date)) AS d FROM " + conn.db + ".cancer_study WHERE cancer_study_identifier = " + sq(study), conn) + .then((r) => (r.data[0]?.d || "").replace(/\.0+$/, "") || null) + .catch((e) => { if (isAbortError(e)) throw e; return null; }); + imported.set(key, { at: Date.now(), value }); + value.catch(() => { if (imported.get(key)?.value === value) imported.delete(key); }); + return value; +} + +/** What this instance reports about itself when it answers as a peer, for `study` if one is named. */ +export async function peerSource(study?: string): Promise { + const conn = study ? (await studyRouting()).get(study) : undefined; + const base = { name: conn?.label || process.env.CLICKHOUSE_LABEL || "primary", build: BUILD_INFO.label }; + return conn && study ? { ...base, study, imported: await studyImported(study, conn) } : base; +} + +/** A remote resolver's view of its peer: where it is, and what the peer reported. */ +interface PeerProvenance { url: string; reported: PeerSource | null; answeredAt: string | null } +const peerProvenance = (r: Resolver): PeerProvenance | null => + typeof (r as unknown as { provenance?: unknown }).provenance === "function" + ? (r as unknown as { provenance(): PeerProvenance }).provenance() : null; + +/** The source a study's resolver stands for: one per database here, one per peer endpoint. */ +export function sourceKey(r: Resolver): string { + const peer = peerProvenance(r); + return peer ? "peer " + peer.url : "local " + r.instance.id; +} + +/** Each study's place in describeSources' list, for the views' participation records to point at. */ +export function sourceIndex(studies: string[], resolvers: Record): Map { + const keys: string[] = [], out = new Map(); + for (const study of studies) { + const r = resolvers[study]; + if (!r) continue; + const key = sourceKey(r); + if (!keys.includes(key)) keys.push(key); + out.set(study, keys.indexOf(key)); + } + return out; +} + +/** Every source of a run, in the order its studies first appear, each with the studies it answered for. */ +export async function describeSources(studies: string[], resolvers: Record): Promise { + const out: AnalysisSource[] = [], byKey = new Map(); + for (const study of studies) { + const r = resolvers[study]; + if (!r) continue; + const key = sourceKey(r), peer = peerProvenance(r); + let src = byKey.get(key); + if (!src) { + // a peer's label also says what it shares; the participation records say that per view + const label = r.instance.label.replace(/ \(shares [^)]*\)$/, "").replace(/ \(aggregate-only\)$/, ""); + src = peer + ? { label, remote: true, url: peer.url, ...(peer.reported ? { reportedName: peer.reported.name, build: peer.reported.build } : {}), + ...(peer.answeredAt ? { answeredAt: peer.answeredAt } : {}), studies: [] } + : { label, remote: false, build: BUILD_INFO.label, studies: [] }; + byKey.set(key, src); + out.push(src); + } + const when = peer ? (peer.reported?.study === study ? peer.reported.imported ?? null : null) + : await studyImported(study, r.instance.conn); + src.studies.push({ study, imported: when }); + } + return out; +} diff --git a/lib/result-text.ts b/lib/result-text.ts index 43d23745..5eee2165 100644 --- a/lib/result-text.ts +++ b/lib/result-text.ts @@ -40,6 +40,11 @@ export function analysisText(document: AnalysisDoc, r: AnalysisResult): string[] out.push(`NOTE (tell the user): this document was written ${r.upgraded.from === "unversioned" ? "before documents carried a version" : "for DSL " + r.upgraded.from} and was upgraded to ${r.upgraded.to} to run` + (r.upgraded.changes.length ? ": " + r.upgraded.changes.join("; ") : "") + ". Saving it writes the new version."); out.push(`Analysis over ${r.involvedStudies.join(" + ")} — OncoKB pin ${r.oncokbPin || "none"} — ${r.stats.ms}ms.`); + // where the data came from, when a peer or more than one database answered + if (r.sources && (r.sources.length > 1 || r.sources.some((x) => x.remote))) + out.push("Data from: " + r.sources.map((x) => x.label + (x.remote ? " (over MCP at " + (x.url || "?") + (x.reportedName ? ", calls itself " + x.reportedName : "") + + (x.build ? ", build " + x.build : "") + ")" : " (local)") + " — " + + x.studies.map((st) => st.study + " imported " + (st.imported || "unknown")).join(", ")).join("; ") + "."); // The document's own title and notes are the author's framing of the whole // workspace. They were stored and rendered in the app but never reached a // model over MCP, so a caller could not read back what it had written. diff --git a/lib/types.ts b/lib/types.ts index 555baae0..141d5b72 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1621,8 +1621,39 @@ export interface ClinicalRow { * on: an aggregate-only participant returns counts and never an identifier, so a * view built entirely from such participants moved no patient-level data at all. */ +/** + * One place an analysis's data came from: a database of this deployment, or a peer it reached + * over MCP, with the studies it answered for and what it reported about itself (lib/provenance.ts). + */ +export interface AnalysisSource { + /** as this deployment names it */ + label: string; + /** a peer reached over MCP (the simulated one included) */ + remote: boolean; + /** that peer's MCP endpoint */ + url?: string; + /** what the peer calls itself, from its own answers — not this deployment's label for it */ + reportedName?: string; + /** the commit the answering code runs, as it reports it */ + build?: string; + /** when the peer first answered during this run */ + answeredAt?: string; + /** each study it answered for, with when that study's data was last imported */ + studies: { study: string; imported: string | null }[]; +} + +/** What a peer reports about itself in every federated answer (lib/provenance.ts). */ +export interface PeerSource { + name: string; + build: string; + /** the study answered for, and when its data was last imported there */ + study?: string; + imported?: string | null; +} + export interface ViewParticipation { - studies: { study: string; mode: "patient-level" | "aggregate-only"; instance: string; remote?: boolean }[]; + /** `source` indexes the result's `sources` */ + studies: { study: string; mode: "patient-level" | "aggregate-only"; instance: string; remote?: boolean; source?: number }[]; /** true only for views whose output IS patients (the oncoprint's columns) */ requiresPatientLevel: boolean; /** the studies that answered with aggregates only */ @@ -1798,6 +1829,8 @@ export interface CoursePoint { } export interface AnalysisResult { study: string; involvedStudies: string[]; oncokbPin: string | null; + /** where the data came from: each database or MCP peer, and the studies it answered for */ + sources?: AnalysisSource[]; /** the parameters this run resolved, and any declared but never referenced */ params?: Record; unusedParams?: string[]; diff --git a/mcp/tools.ts b/mcp/tools.ts index 09411ee5..70991ac1 100644 --- a/mcp/tools.ts +++ b/mcp/tools.ts @@ -22,6 +22,7 @@ import { allowedKinds, disclosedByThisInstance, PATIENT_KIND_NAMES, type Patient import { checkQuery, cleanQuery, type QueryColumn, type RowSource, type TableQuery } from "../lib/table-query.ts"; import { buildExplore } from "../lib/explore.ts"; import { oncokbPin } from "../lib/annotation.ts"; +import { peerSource } from "../lib/provenance.ts"; import { connections, directConnections } from "../lib/ch.ts"; import { studiesFor } from "../lib/study-list.ts"; import { catalogFor } from "../lib/study-catalog.ts"; @@ -206,7 +207,7 @@ export function registerTools(server: any, engine: ToolEngine = DIRECT): void { (survivalCounts.length ? `, ${survivalCounts.length} survival strata` : "") + (pairCounts.length ? `, ${pairCounts.length} gene-pair tables` : "") + (attributeStats.length ? `, ${attributeStats.length} clinical attributes` : "") + " (no patient identifiers returned)" }], - structuredContent: payload + structuredContent: { ...payload, source: await peerSource(s) } }; } catch (e) { return { content: [{ type: "text" as const, text: "REJECTED: " + String((e as Error)?.message || e) }], isError: true }; @@ -310,7 +311,7 @@ export function registerTools(server: any, engine: ToolEngine = DIRECT): void { const hideSql = process.env.FEDERATION_HIDE_SQL === "1"; return { content: [{ type: "text" as const, text: `${table} rows for ${s} (${mode}) — patient-level, disclosed under MCP_DISCLOSE_PATIENTS (${PATIENT_KIND_NAMES[PEER_TABLES[table]]})` }], - structuredContent: { table, ...out, ...(annotated ? { oncokbPin: pin } : {}), queries: hideSql ? null : res.queries } + structuredContent: { table, ...out, ...(annotated ? { oncokbPin: pin } : {}), queries: hideSql ? null : res.queries, source: await peerSource(s) } }; } catch (e) { return rejected(e); } }); @@ -331,7 +332,7 @@ export function registerTools(server: any, engine: ToolEngine = DIRECT): void { const hideSql = process.env.FEDERATION_HIDE_SQL === "1"; return { content: [{ type: "text" as const, text: `timeline of ${patient} (${s}): ${t.events.length} events, ${t.samples.length} samples — patient-level, disclosed under MCP_DISCLOSE_PATIENTS` }], - structuredContent: { study: s, patient, events: t.events, samples: t.samples, queries: hideSql ? null : res.queries } + structuredContent: { study: s, patient, events: t.events, samples: t.samples, queries: hideSql ? null : res.queries, source: await peerSource(s) } }; } catch (e) { return rejected(e); } }); @@ -365,7 +366,7 @@ export function registerTools(server: any, engine: ToolEngine = DIRECT): void { return { content: [{ type: "text" as const, text: `facets for ${s}: ${facets.clinical.length} clinical attributes, ` + `${facets.genes.length} genes, ${facets.events.length} event types (aggregates only)` }], - structuredContent: facets as unknown as Record + structuredContent: { ...facets, source: await peerSource(s) } as unknown as Record }; } catch (e) { return { content: [{ type: "text" as const, text: "REJECTED: " + String((e as Error)?.message || e) }], isError: true }; @@ -389,7 +390,7 @@ export function registerTools(server: any, engine: ToolEngine = DIRECT): void { const studies = (await Promise.all(conns.map((c) => studiesFor(c)))).flat(); return { content: [{ type: "text" as const, text: `${studies.length} studies held by this instance (counts only)` }], - structuredContent: { studies } + structuredContent: { studies, source: await peerSource() } }; } catch (e) { return { content: [{ type: "text" as const, text: "REJECTED: " + String((e as Error)?.message || e) }], isError: true }; @@ -409,7 +410,7 @@ export function registerTools(server: any, engine: ToolEngine = DIRECT): void { const cat = await catalogFor(s); return { content: [{ type: "text" as const, text: `catalog for ${s}: ${cat.types.length} event types, ${cat.totalPatients} patients` }], - structuredContent: cat as unknown as Record + structuredContent: { ...cat, source: await peerSource(s) } as unknown as Record }; } catch (e) { return { content: [{ type: "text" as const, text: "REJECTED: " + String((e as Error)?.message || e) }], isError: true }; diff --git a/test/provenance.test.ts b/test/provenance.test.ts new file mode 100644 index 00000000..6125c686 --- /dev/null +++ b/test/provenance.test.ts @@ -0,0 +1,58 @@ +// Where an analysis's data came from (lib/provenance.ts): one source per database here and per +// peer endpoint, each with the studies it answered for, and what a peer said about itself kept +// apart from what this deployment calls it. +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { describeSources, peerSource, sourceIndex } from "../lib/provenance.ts"; +import type { Resolver } from "../lib/federation.ts"; + +const local = (id: string, label: string) => ({ + instance: { id, label, mode: "patient-level", conn: { id, label, db: "nodb", url: "", user: "", password: "" } } +}) as unknown as Resolver; +const remote = (url: string, label: string, study: string, reported: Record | null) => ({ + instance: { id: "peer", label, mode: "aggregate-only" }, + provenance: () => ({ url, reported: reported && { ...reported, study }, answeredAt: reported ? "2026-10-18T00:00:00.000Z" : null }) +}) as unknown as Resolver; + +describe("an analysis's sources", () => { + const resolvers = { + s1: local("a", "primary"), + s2: remote("http://peer/api/mcp", "GENIE (via MCP) (shares patient timelines)", "s2", { name: "GENIE", build: "abc1234", imported: "2026-07-27 21:21:00" }), + s3: remote("http://peer/api/mcp", "GENIE (via MCP)", "s3", { name: "GENIE", build: "abc1234", imported: null }), + s4: remote("http://other/api/mcp", "Other site", "s4", null), + }; + const studies = ["s1", "s2", "s3", "s4"]; + + test("one source per database and per peer endpoint, in the order the studies first appear", async () => { + const src = await describeSources(studies, resolvers); + assert.deepEqual(src.map((s) => s.label), ["primary", "GENIE (via MCP)", "Other site"]); + assert.deepEqual(src.map((s) => s.studies.map((x) => x.study)), [["s1"], ["s2", "s3"], ["s4"]]); + assert.equal(src[0].remote, false); + assert.equal(src[0].studies[0].imported, null, "an import date that cannot be read is unknown, not invented"); + }); + + test("a peer's own report is kept apart from this deployment's label for it", async () => { + const [, genie, other] = await describeSources(studies, resolvers); + assert.equal(genie.label, "GENIE (via MCP)", "the label, without what the peer shares (participation says that)"); + assert.equal(genie.reportedName, "GENIE"); + assert.equal(genie.url, "http://peer/api/mcp"); + assert.equal(genie.build, "abc1234"); + assert.equal(genie.answeredAt, "2026-10-18T00:00:00.000Z"); + assert.deepEqual(genie.studies, [{ study: "s2", imported: "2026-07-27 21:21:00" }, { study: "s3", imported: null }]); + assert.equal(other.reportedName, undefined, "a peer that has not answered has not described itself"); + assert.equal(other.remote, true); + }); + + test("each view's participation points at its study's source", async () => { + const src = await describeSources(studies, resolvers), idx = sourceIndex(studies, resolvers); + for (const s of studies) assert.ok(src[idx.get(s)!].studies.some((x) => x.study === s), s); + assert.deepEqual([...idx.values()], [0, 1, 1, 2]); + }); + + test("a peer describes itself: its name and its build", async () => { + const me = await peerSource(); + assert.equal(typeof me.name, "string"); + assert.ok(me.name.length > 0); + assert.equal(typeof me.build, "string"); + }); +});