cBioPortal logoDeep dive

Following one clinical attribute all the way into the graph

Take a single column out of a cBioPortal patient staging file:

Following one clinical attribute all the way into the graph

Take a single column out of a cBioPortal patient staging file:

#Age
#Age at diagnosis in years
#NUMBER
#1
AGE

Five cells. By the time CoreModels has finished importing the study, those five cells have become a governed Element with a floating-point value type, a vendor identity that survives renaming, a metadata record holding the literal string NUMBER and the display name Age, an entry in a persisted estate snapshot, and a line item in every future drift audit. This article traces that path for every construct the connector recognizes — what the parser extracts, where each fact lands, which audit codes can fire against it, and exactly where meaning is approximated or dropped.

The parse contract

The connector reads a clinical file as: every leading line that starts with # is a header row; the first non-blank line after them is the attribute-ID row; everything below is data and is discarded. Fewer than four header rows, or no attribute row, and the file is rejected outright with Not a clinical staging file (needs 4 '#' header rows above the attribute-ID row).

Column by column, the mapping is positional:

Header rowBecomes
0 — display namescbio.displayName in the field's meta bag
1 — descriptionsthe field's description
2 — datatypesthe field's native type, upper-cased; empty cell defaults to STRING
3 — prioritiescbio.priority in the field's meta bag
attribute-ID rowthe field's name — the identity everything else keys off

Rows are read defensively. A header row shorter than the attribute row yields empty cells for the columns it does not reach — which is why an attribute with no description is a normal parse outcome that raises a conformance finding rather than an error. Blank attribute IDs are skipped; duplicates are skipped case-insensitively, first occurrence winning. A file yielding no usable attributes is treated as not-a-staging-file. One trap: every leading # line counts as a header row, so a comment line pasted above the block shifts the positional mapping.

Each file becomes one dataset in the neutral estate model: vendor id patient or sample, kind Table, materialization clinical, description cBioPortal clinical {instrument} attributes. The payload is fingerprinted with a truncated SHA-256, so two submissions compare cheaply.

Two structural facts are recognized during the parse. PATIENT_ID in the patient file and SAMPLE_ID in the sample file each gain two normalized checks, Unique and NotNull, both named identity. And the sample file's PATIENT_ID is rewritten into a reference: it keeps its description and datatype, gains a Relationship check, and points at the patient instrument's PATIENT_ID. That rewrite only happens when a patient dataset is present in the same submission — audit a sample file on its own and the column stays a plain string.

The optional meta file contributes three keys and nothing else: cancer_study_identifier becomes the study name (defaulting to cbioportal-study when absent), name becomes a studyName fact, type_of_cancer becomes a cancerType fact. Two more facts are derived: instruments and attributes.

Identity: what the graph keys on

Node ids in CoreModels must be camelCase alphanumerics, so vendor identities are sanitised on the way in and preserved elsewhere. For our study:

Vendor identityGraph node idNode kind
patientpatientType
samplesampleType
patient.AGEpatientAGEElement
patient.PATIENT_IDpatientPATIENTIDElement
sample.cancerTypesampleCancerTypeElement

The id is a convenience. The identity that matters is the mapsTo assertion written on every node — a two-property mixin value whose standard is the vendor key and whose uri is the vendor identity:

{ "transformMapsToStandard": "cbioportal", "transformMapsToUri": "patient.AGE" }

That is the same carrier every other CoreModels import writes, so a vendor identity survives a format export intact. Audits match on it, never on labels — so an SME can relabel AGE to Age at diagnosis and the next audit still recognizes the same governed attribute. It also means a genuine rename in the staging file is real drift, which is correct: renaming an attribute ID breaks every consumer keyed on it.

One consequence deserves a warning label. The dataset identity is the bare instrument name, patient or sample, not something study-scoped — so two studies imported into one project claim the same identities. Govern one study per project. Sanitisation is also collision-checked: two distinct vendor identities that would collapse onto one node id make the import refuse and name both, rather than silently merging two attributes.

Two write paths

Schema shape — Types, Elements, value types, references — flows through a snapshot-to-IR mapper and out through the shared CoreModels writer, the same path every other format import takes. Estate facts the IR deliberately cannot hold are written natively afterwards.

Estate factWhere it lands
clinical fileType node, label = instrument name
clinical attributeElement on that Type
NotNull from an identity checka Required element fact, stashed on the shared element-facts mixin
sample's PATIENT_IDexpected-type relation to the patient Type
vendor identitymapsTo mixin value {cbioportal, <vendor id>}
native type, description, display name, priority, checks, materializationthe cBioPortal Metadata mixin
study id/name/cancer type, counts, fingerprint, timestampthe integration state node
parsed estatethe estate snapshot node (gzip+base64, size-capped)
recorded audit runsthe audit history node (rolling, 50 runs)

The metadata mixin is where the vendor's own vocabulary survives. Its property ids are the vendor key plus the property name, so the value written for patient.AGE reads:

{
  "cbioportalDataType": "NUMBER",
  "cbioportalDescription": "Age at diagnosis in years",
  "cbioportalTags": "",
  "cbioportalMeta": "{\"cbio.displayName\":\"Age\",\"cbio.priority\":\"1\"}",
  "cbioportalChecks": ""
}

And for patient.PATIENT_ID, whose identity checks are normalized:

{
  "cbioportalDataType": "STRING",
  "cbioportalDescription": "Unique patient identifier",
  "cbioportalTags": "",
  "cbioportalMeta": "{\"cbio.displayName\":\"Patient Identifier\",\"cbio.priority\":\"1\"}",
  "cbioportalChecks": "[{\"kind\":\"Unique\",\"name\":\"identity\",\"severity\":null,\"detail\":null},{\"kind\":\"NotNull\",\"name\":\"identity\",\"severity\":null,\"detail\":null}]"
}

The instrument Type carries the dataset-level twin: cbioportalUniqueId (patient), cbioportalResourceKind (table), cbioportalMaterialization (clinical), cbioportalContract (false), and empty strings for the properties cBioPortal has no concept of — cbioportalRelationName, cbioportalAccess. Every property is rewritten on every import, with "" for absent, so a fact that disappears from the estate cannot linger as stale metadata.

That refresh is the one thing an import updates in place, and it is deliberate: mixin values mirror the estate, they are not governed meaning. Governed nodes themselves are never mutated or deleted by an import. Detecting a meaning change is the audit's job; applying it is a human's.

The identity checks deserve one more sentence, because they land in two places. PATIENT_ID's NotNull sets the element's Required fact, which the schema writer persists on a shared element-facts mixin — visible in the product like any other mixin value, but a stash rather than a first-class metamodel slot, so the graph does not enforce it. The authoritative, machine-readable copy of the check is the cbioportalChecks JSON above — the vendor-side record every import refreshes.

Type mapping in both directions

cBioPortal datatypeGoverned value typeApproximated
NUMBERDoubleyes — one type covers integers and decimals alike
BOOLEANBooleanno
STRING, or an empty cellStringno
anything elseStringyes

Both approximations produce a lossiness record naming the path and the original string, and the literal native type is preserved on the metadata mixin so audits compare NUMBER against NUMBER rather than Double against NUMBER.

Generation inverts the map: Integer or Double becomes NUMBER, Boolean becomes BOOLEAN, everything else becomes STRING. The inversion is not symmetric, and that is the honest headline about round-tripping. A generated scaffold takes its display-name row from the governed Element label, not from the cbio.displayName the import captured; it emits priority 1 for every column; and it has no slot for identity checks, because a staging header block has nowhere to put them. Descriptions do survive, read back from the metadata mixin, falling back to the label when absent.

What the audit can and cannot say

The shared engine contributes coverage and drift codes; the connector contributes conformance rules. For a cBioPortal estate the live set is:

  • Coveragedataset-unmapped (Warning): an instrument in the files is not governed. field-unmapped (Info): an attribute exists in the files but not on the governed Type.
  • Driftfield-removed (Error): a governed attribute is gone from a submitted instrument. field-type-drift (Error): the datatype row moved away from what the last import recorded, with the comparison run on the raw strings (governed: NUMBER, estate: STRING), not on the approximated value types.
  • Conformanceattribute-no-description (Info), raised once per instrument with the offending attribute names in the detail; attribute-id-not-upper (Warning), raised per attribute whose ID contains anything other than upper-case letters, digits, and underscores.

Four shared codes cannot meaningfully fire here, and knowing why is knowing the engine. projection-unmapped needs semantic models or exposures, which staging files do not have. contract-drift needs a contract-enforcement flag, which staging files do not have; the connector always records false, so governed and estate always agree.

dataset-removed is the interesting one. The engine scopes removal by identity namespace — everything before the last dot — so a project governing several estates of one vendor never reports another estate's tables as deleted. A cBioPortal instrument identity, patient or sample, has no dot in it, so each identity is its own namespace: an instrument you did not submit is out of scope rather than removed, and the code never fires here. The payoff is that auditing only clinical_patient is safe, which is what you want on a pull request that touched one file.

The enum-* codes need accepted values, and clinical staging files declare none — with one important exception. If a human governs a clinical attribute with a taxonomy (a controlled list of OS_STATUS values, say), every subsequent audit will report enum-constraint-removed (Warning), because the estate side genuinely declares no value set. That is the engine telling the truth about an asymmetry, not a false positive, but it is worth knowing before someone adds the taxonomy.

Limits, and where they bite

  • Clinical files only. Mutation, CNA, and expression files are data, not schema, and are out of scope by design.
  • No live portal connection. The connector declares Import, Audit, and Generate — not LiveSync. CoreModels never holds portal credentials; every verb runs on artifacts you supply.
  • No lineage. A study carries no build DAG, so lineageEdgesAdded is always zero. The patient/sample join is a reference, not a lineage edge.
  • The snapshot cap. The parsed estate is stored gzipped and base64-encoded for re-audit, capped at roughly 1.5 million encoded characters. Past that, the import reports snapshotStored: false with a lossiness record: fresh-artifact audits still work, but re-audit has no baseline.
  • Additive re-import is conservative. When a later import adds an attribute to an instrument that is already governed, the attribute is created — but reference semantics are not auto-created for it. The import says so with a ConstraintRelaxation record explaining that the facts are kept on the metadata mixin and surfaced by the audit, because changing governed meaning is a human act.

None of these are hidden: each surfaces as a lossiness record on the call that hit it, or as a finding on the next audit. The connector reads exactly what a study's header block declares, flags every approximation, and puts each fact where it belongs — which is what gives a drift audit something precise to measure against.

For the artifact contract and the extraction recipe, see the cBioPortal quickstart in the CoreModels integration docs.