Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion app/lab/analysis-sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -252,6 +252,7 @@ export function CohortColumn({ ctx }: { ctx: AnalysisCtx }) {
);
})}
</div>
<AnalysisSources sources={result?.sources} names={studyNames} />
</section>
)}
</div>
Expand Down
33 changes: 32 additions & 1 deletion app/lab/view-chrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -490,6 +490,37 @@ export function CohortStudies({ ids, names }: { ids: string[]; names: Map<string
);
}

/**
* Where an analysis's data came from (the result's `sources`): each database of this deployment and
* each peer reached over MCP, with what the peer reported about itself and when each study's data
* was imported. Said only when there is more than one source or a remote one; one local database
* is the default and goes without saying.
*/
export function AnalysisSources({ sources, names }: { sources?: AnalysisSource[]; names: Map<string, string> }) {
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 (
<p data-tw className="text-xs text-muted-foreground mt-2.5 mb-0" aria-label="where this analysis's data came from">
<span className="font-semibold text-foreground">Data from:</span>{" "}
{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 (
<span key={i} title={s.label + " — " + peer + "\n" + s.studies.map((x) => x.study + ": imported " + (x.imported || "unknown")).join("\n")}>
{i > 0 && " · "}<strong className="font-medium text-foreground">{s.label}</strong>
{" "}({s.remote ? "via MCP" + (s.reportedName && s.reportedName !== s.label ? ", calls itself “" + s.reportedName + "”" : "") : "local"}) — {studies(s)}
</span>
);
})}
</p>
);
}

/**
* 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.
Expand Down
8 changes: 7 additions & 1 deletion lib/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) };
Expand Down Expand Up @@ -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.
Expand Down
50 changes: 32 additions & 18 deletions lib/federation-remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<T = RemoteAggregates>(name: string, args: unknown): Promise<T> {
const r = await mcpCall<T>(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;
Expand All @@ -232,54 +246,54 @@ export class RemoteAggregateResolver implements AggregateCapable {
}
async count(def: CohortDef | null | undefined): Promise<number> {
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<GeneCounts[]> {
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<SurvivalCount[]> {
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<string[]> {
// 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<TimelineSpan[]> {
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<SequenceFlow[]> {
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<CourseCount[]> {
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 || [];
Expand All @@ -304,14 +318,14 @@ export class RemoteAggregateResolver implements AggregateCapable {
async pairCounts(def: CohortDef | null | undefined, pairs: [string, string][]): Promise<PairCounts[]> {
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<AttrStats[]> {
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);
Expand All @@ -320,23 +334,23 @@ export class RemoteAggregateResolver implements AggregateCapable {
async profileMoments(def: CohortDef | null | undefined, genes: string[], profileType: string): Promise<ProfileMoments[]> {
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 || [];
}
async variantCounts(def: CohortDef | null | undefined, genes: string[], minCount = 1): Promise<VariantReport> {
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 };
}
async lollipopCounts(def: CohortDef | null | undefined, genes: string[], minCount = 1): Promise<LollipopReport> {
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
Expand All @@ -347,7 +361,7 @@ export class RemoteAggregateResolver implements AggregateCapable {
async pairMoments(def: CohortDef | null | undefined, pairs: [string, string][], profileType: string): Promise<PairMoments[]> {
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 || [];
Expand Down Expand Up @@ -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<PeerRowsAnswer & { queries?: QueryLogEntry[] | null }>(this.peerUrl, "federated_patient_rows", {
const r = await this.disclosed(this.call<PeerRowsAnswer & { queries?: QueryLogEntry[] | null }>("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 } : {}),
Expand Down Expand Up @@ -432,8 +446,8 @@ export class RemoteTimelineResolver extends RemotePatientResolver {
async patientTimeline(patient: string): Promise<PatientTimeline> {
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);
Expand Down
Loading
Loading