Loading documentation

Access required

Your account doesn't have access to the documentation. Contact your administrator.

Sign out
Skip to main content

Composite Disability Risk Index

Status: stable Category: composite Added: 2026-04-20 Last reviewed: 2026-09-16

Summary​

Trinity's flagship composite analytics endpoint. One call accepts a flat array of answered question-bank questions and returns a single cross-channel risk readout:

  • A cdri.score (0–100) — the headline disability-related risk indicator, with its seven-level band and the bank version behind it.
  • A phenotypes block: one object per risk channel carrying that channel's level, its own evidence percentage, and its residual net of the subject's overall burden.
  • A breadth measure — 0 is one driver, 1 is evenly diffuse.
  • A phenotype block attaching one of seven recovery phenotypes, with a distribution across all seven and a certainty.
  • A confidence report the caller can use to decide how much weight to put on the result.
  • Optionally, an acuity block — how closely this case needs watching — when the caller also supplies de-identified case facts.

Unlike /score, which rejects any request missing a required item, /cdri/compute is permissive on input: any number of answers is valid, including none. Missing signal lowers evidence; it does not fail the request.

:::info This endpoint and the instruments do not meet The CDRI is scored from a curated question bank. The published assessment instruments are a separate group behind their own /score endpoints, and the two share no identifiers and no inputs. There is no instruments field here, no instrument_id on an answer, and nothing you submit to an instrument affects a CDRI figure. This endpoint also publishes no findings and no recommendations — see What this endpoint does not return. :::

⚠ Not a clinical diagnosis​

The CDRI is a risk indicator, not a diagnosis, prognosis, or benefits-determination verdict. The score and phenotype are statistical summaries intended to support decisions; they do not make those decisions. Integrations built on /cdri/compute are responsible for their own clinical governance and for communicating the indicator's nature to end users.

Endpoint​

FieldValue
MethodPOST
Path/api/cdri/compute
Full URLhttps://engine.tripod-health.com/api/cdri/compute
AuthenticationAPI key (required)
Request bodyapplication/json
Response bodyapplication/json
IdempotentNo — a corpus row is written per call
Rate-limitedYes

There is no ?depth= parameter on this endpoint; it always produces its full response.

Why this endpoint uses POST​

/cdri/compute accepts a structured payload in the request body and writes a de-identified record of the call. GET is unsuitable: query strings have implementation-dependent length limits that cannot accommodate a full answer array, and GET is not designed to carry a JSON body or to be non-idempotent.

Authentication​

Every request must include a valid API key in the apikey header. See authentication.

Request​

Headers​

HeaderRequiredDescription
apikeyYesConsumer API key.
Content-TypeYesapplication/json.

Query parameters​

ParameterTypeDefaultDescription
audienceenumnoneprovider, employee, employer. Selects the stakeholder wording for labels. Does not change any number.

Body​

FieldTypeRequiredDescription
answersarrayNoThe answered bank questions. Defaults to empty.
answers[].item_idstringYesThe question's own id, for example pain_intensity_now.
answers[].valueintegerYesA code that question offers. See The answer ladder.
answers[].answered_days_agointegerNoHow long ago the answer was given. Omit for "today". Feeds acuity, never the index.
demographicsobjectNoDe-identified structural descriptors.
demographics.age_bracketstringNoFor example "35-44". Never a date of birth or a year.
demographics.sexstringNoSelf-reported categorical.
demographics.occupation_classstringNoFor example "manufacturing".
demographics.naics_codestringNoSix-digit NAICS code as a string.
demographics.regionstringNoState or region, never a street address.
roleenumNoptp, case_mgr, frontline, navigator. Ranking metadata only — no risk figure changes with role.
caseobjectNoThe switch for the acuity block. Omit it and there is no acuity.
case.statusenumNoopen (default) or closed. A closed case scores zero acuity.
case.injury_days_agointegerNoDays since the injury. 0–36500.
case.completed_visit_days_agoarray of integerNoDay offsets of completed visits — the full array, not a summary.

Nothing else is accepted. An unrecognised key inside answers[], demographics or case is a 400 naming it.

:::caution demographics here, context on /cdri/questions The two endpoints take the same descriptors under different key names: demographics on this endpoint, context on /cdri/questions. Sending context here does not error — unknown top-level body keys are dropped — so the request succeeds and your descriptors are silently ignored. Check that demographics is the key you sent before concluding they had no effect. :::

{
"answers": [
{ "item_id": "pain_intensity_now", "value": 5 },
{ "item_id": "average_pain_3_months", "value": 5 },
{ "item_id": "pain_frequency", "value": 5 },
{ "item_id": "difficulty_walking", "value": 4 },
{ "item_id": "difficulty_lifting", "value": 5 },
{ "item_id": "new_neuro_symptoms", "value": 0 },
{ "item_id": "sitting_one_hour", "value": 3 },
{ "item_id": "standing_one_hour", "value": 4 },
{ "item_id": "modified_duty_available", "value": 6 },
{ "item_id": "expect_usual_work_6_months", "value": 4 }
],
"demographics": { "age_bracket": "35-44", "occupation_class": "medium" },
"case": {
"status": "open",
"injury_days_ago": 96,
"completed_visit_days_ago": [88, 74, 60, 45, 30, 12]
}
}

De-identification requirement​

Every field above is categorical or a day count. The CDRI surface accepts no dates at all — the case history is expressed as offsets precisely so no date has to cross the boundary. A date-shaped value anywhere in the body is a 400. See conventions.

Response​

Success — 200 OK​

The body above, scored:

{
"composite_id": "cdri",
"cdri": {
"score": 67.63,
"band": "bad",
"bank_version": "2026-09-15-standard-ladder"
},
"phenotypes": {
"pain_dominant": { "level": 0.6545, "evidence": 0.9146, "residual": -0.0218 },
"psychosocial_dominant": { "level": 0.6623, "evidence": 0.1739, "residual": -0.0141 },
"fear_avoidance_dominant": { "level": 0.6539, "evidence": 0.3213, "residual": -0.0224 },
"rtw_barrier": { "level": 0.7951, "evidence": 0.5468, "residual": 0.1188 },
"multi_domain_catastrophic": { "level": 0.5612, "evidence": 0.5956, "residual": -0.1152 },
"iatrogenic_escalation": { "level": 0.2353, "evidence": 0.1483, "residual": -0.441 },
"established_chronicity": { "level": 0.7413, "evidence": 0.6932, "residual": 0.065 }
},
"breadth": 0.9177,
"acuity": {
"index": 55.07,
"band": "moderate",
"from_answers": 64.95,
"from_rhythm": 48.32,
"answer_trust": 0.588,
"rhythm_trust": 0.9196,
"bank_version": "2026-09-15-standard-ladder"
},
"phenotype": {
"backend": "deterministic",
"model_version": "2026-09-15-standard-ladder",
"phenotype": "rtw_barrier",
"distribution": {
"pain_dominant": 0.1521,
"psychosocial_dominant": 0.1539,
"fear_avoidance_dominant": 0.152,
"rtw_barrier": 0.1847,
"multi_domain_catastrophic": 0.1304,
"iatrogenic_escalation": 0.0547,
"established_chronicity": 0.1722
},
"model_certainty": 0.1847,
"overall_level": 0.7951,
"trajectory": null,
"similar_cases": null,
"anomalies": null
},
"confidence": {
"overall": 0.4587,
"tier": "medium",
"breakdown": {
"evidence": 0.6332847231817376,
"input_completeness": null,
"imputation_variance": 0.5,
"model_certainty": 0.1847
}
},
"depth": "full",
"catalog_version": "0.36.0",
"request_id": "041314d8-9c74-4786-8ee5-a4da5e2d5166",
"timestamp": "2026-09-16T01:05:17.615321+00:00"
}

Response fields​

FieldTypeAlways presentDescription
composite_idstringYesAlways "cdri".
cdri.scorenumber | nullYes0–100 headline index. null when nothing was answered.
cdri.bandstring | nullYesSeven-level label for the score. null when score is null.
cdri.bank_versionstringYesThe question bank version that scored it.
phenotypesobjectYesOne entry per channel. {} when nothing was answered.
phenotypes[channel].levelnumberYes0.0–1.0 severity on that channel.
phenotypes[channel].evidencenumberYes0.0–1.0. How much of that channel's bank mass the answers hold.
phenotypes[channel].residualnumberYesThat channel's level net of the subject's overall burden. Can be negative.
breadthnumber | nullYes0 one driver, 1 evenly diffuse. null when nothing was answered.
acuityobject | nullYesnull unless you sent case. See Acuity.
phenotypeobjectYesThe classification block. See Phenotype block.
confidenceobjectYesSee Confidence.
depthstringYesAlways "full" on this endpoint.
catalog_versionstringYesCatalog version the deploy is serving. Compare with /api/version/get to detect mid-flight deploys.
request_idstringYesCorrelation ID. Quote this in support tickets.
timestampstring (ISO 8601 UTC)YesServer time when the response was emitted.

The band scale​

Seven equal-width bands over 0–100, each spanning 100/7 ≈ 14.29 points. The same vocabulary is used by cdri.band and acuity.band, deliberately — one band word means one thing across the engine.

BandRange
excellent0.00 – 14.29
great14.29 – 28.57
good28.57 – 42.86
moderate42.86 – 57.14
bad57.14 – 71.43
critical71.43 – 85.71
severe85.71 – 100.00

Read band rather than inventing your own cutoffs. The edges are derived from the band count, not written as literals, so they cannot drift apart from the scale.

The score is never adjusted by its own confidence​

Three severe answers and nothing else publish as severe, with a low evidence figure beside them. Uncertainty is reported, not folded into the number.

That is a deliberate change of posture. There is no interval and no action bound on this response, because there is no longer any shrinkage toward a reference prior to build one from. If you previously sorted a queue on action, sort on cdri.score and read confidence and the per-channel evidence alongside it.

Missing data is non-punitive in the other direction too: a channel nobody asked about drops out of the aggregation rather than voting a zero into it.

The headline is a compensatory power mean over the seven channel levels — the worst channel leads, while every other answer still moves the number. Improving any answer lowers the score.

Read level and evidence together​

Each channel reports its level next to its own evidence percentage, and they have to be read as a pair. Evidence compression is deliberately aggressive, so a severe channel resting on one or two answers barely moves the index — and the per-channel figure is the only place that channel is visible at full strength.

In the example above, psychosocial_dominant has a level of 0.6623 on evidence of 0.1739. That is not "this person is moderately psychosocially burdened"; it is "the little we asked about mood came back bad, and we have barely asked."

residual and breadth — the shape, not the level​

cdri.score says how much; residual and breadth say what shape.

  • A channel with a high level but a near-zero residual is high only because this subject's overall burden is high. It is not a distinguishing feature and usually should not drive a channel-specific pathway on its own.
  • A channel with a positive residual is elevated beyond what the subject's overall burden explains. That is the signal worth acting on — rtw_barrier at +0.1188 above.
  • breadth near 0 means one driver carries the picture, and a targeted intervention is plausible. Near 1 means the burden is evenly diffuse, which is the multi-domain presentation that responds to coordinated rather than single-domain care.

Acuity​

Present only when you send case. Acuity answers how closely does this case need watching, which is a different question from how much risk is there.

FieldTypeDescription
acuity.indexnumber0–100. The blended reading.
acuity.bandstringThe same seven-level band vocabulary.
acuity.from_answersnumberWhat the answers alone say.
acuity.from_rhythmnumberWhat the visit rhythm alone says.
acuity.answer_trustnumber0.0–1.0. How much the answer reading is trusted.
acuity.rhythm_trustnumber0.0–1.0. How much the rhythm reading is trusted.
acuity.bank_versionstringThe bank version behind the answer component.

Both components and both trusts are published rather than just the blend, so a caller can see why an index landed where it did. In the example, the answers read hotter (64.95) than the visit rhythm (48.32), and the rhythm is trusted more (0.9196 against 0.588), which is what pulls the blend down to 55.07.

completed_visit_days_ago is the full array of offsets rather than a mean interval and a days-since-last, because those two summaries throw away the shape of the history and the shape is where the signal is. Thirty visits across thirty consecutive days is a real pattern, and a mean interval cannot distinguish it from thirty visits clustered in one week followed by three weeks of silence.

Send completed visits only — not scheduled, not missed, not cancelled. An intent to be seen is not being seen, and the caller owns the schedule.

A case.status of closed scores zero acuity: a closed case is one nobody needs to see again.

Phenotype block​

FieldTypeDescription
phenotype.backendstringOpaque identifier for the backend that produced the block. "none" means classification could not be produced; treat any other value as "succeeded" and do not branch on the specific string.
phenotype.model_versionstring | nullOpaque version identifier. Do not pin client logic to it.
phenotype.phenotypestring | nullOne of the seven names below, or null if classification did not succeed.
phenotype.distributionobject | nullA probability across all seven, summing to 1.0 ± 0.01.
phenotype.model_certaintynumber | nullPeak probability from distribution.
phenotype.overall_levelnumber | nullThe single strongest channel level. A property of phenotypes, not the basis of cdri.score.
phenotype.trajectory / similar_cases / anomaliesnullReserved for future releases. Always null today.

The distribution is the seven channel levels normalized, scored in the same pass as the index — so the pattern read and the index can never disagree about the same subject.

Phenotype taxonomy (seven classes)​

PhenotypeOne-line meaning
pain_dominantPain intensity is the overriding driver of disability; psychosocial and functional signals are secondary.
psychosocial_dominantMood, anxiety, or cognitive-emotional factors dominate the profile.
fear_avoidance_dominantBelief-based activity avoidance (fear of re-injury) drives the clinical picture.
rtw_barrierWorkplace-specific barriers dominate — low recovery expectation, low work ability, no modified duty.
multi_domain_catastrophicHigh severity across pain, psychosocial, function and RTW — no single driver.
iatrogenic_escalationElevated opioid-risk or treatment-side-effect markers alongside moderate pain and function signals.
established_chronicityPattern consistent with long-duration established chronic pain.

The same seven names are the keys of phenotypes and the legal values of the phenotype filter on /cdri/questions.

:::caution A low model_certainty is common and means what it says Seven classes over a thinly-evidenced subject sit close together — a certainty near 0.14 is the classifier telling you the pattern is not separable yet, not a defect. When it matters, ask more questions on the leading channel: POST /api/cdri/questions?phenotype=<name>&order=voi. /phenotype/classify emits exactly that recommendation when the top two classes are close. :::

Confidence​

FieldTypeDescription
confidence.overallnumber0.0–1.0, renormalized over whichever terms fired.
confidence.tierstringlow, medium, or high.
confidence.breakdown.evidencenumber | nullHow much of the bank's mass the answers hold, rolled up by each channel's realized contribution. null when nothing was answered.
confidence.breakdown.input_completenessnullAlways null on the CDRI surface. It belongs to underwriting basis, which measures a different thing under a different name.
confidence.breakdown.imputation_variancenumber | nullThe contextualization term.
confidence.breakdown.model_certaintynumber | nullMirrors phenotype.model_certainty.

A term that did not run is null, and its weight is dropped rather than filled. The remaining weights renormalize to sum to one, so overall is always a statement about what actually ran — never a partial formula reported as a whole one.

evidence replaced a completeness term, and the replacement is not a rename. The old term measured how much of one instrument arrived; evidence measures how much of the whole bank's mass the answers hold. It reads lower on a single complete form, which is the honest reading — one filled-in questionnaire is a small share of what the bank asks about.

:::note This is not the same number /cdri/questions returns The risk_snapshot on /cdri/questions publishes an evidence-only confidence, because the planning path runs no AI. For identical answers the breakdown.evidence values match to the digit while the overall figures differ. Compare breakdown.evidence, never overall. :::

What this endpoint does not return​

Named explicitly, because each of these used to be here:

GoneWhere it lives now
findingsThe /score endpoints. A finding is an instrument reading its own items against its own band table; the bank has no instruments and no per-question bands.
recommendations, and the ?recommend= parameter/phenotype/classify. The CDRI publishes a measurement and nothing derived from it.
red_flagsEscalations are recommendation items, on /phenotype/classify.
interval, actionNothing replaces them. The score is no longer shrunk toward a prior, so there is no interval to bound — read confidence and per-channel evidence instead.
magnitudes, residuals as top-level blocksFolded into phenotypes, one object per channel carrying level, evidence and residual together.
matrix_version, loadings_version, provenance, pReplaced by cdri.bank_version.
The separate /api/magnitude/compute and /api/acuity/compute endpointsHere. The channel levels and the acuity block are produced in the pass that produces the index, so one call now does strictly less work than three did.

Sending ?recommend= is ignored rather than rejected.

Examples​

curl​

curl -sS -X POST "https://engine.tripod-health.com/api/cdri/compute" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"answers": [{"item_id": "pain_intensity_now", "value": 5}, {"item_id": "difficulty_walking", "value": 4}]}'

Python (requests)​

# Uses the call() helper from quickstart.md.
result = call("POST", "/api/cdri/compute", {
"answers": answers, # accumulated, unchanged
"demographics": {"age_bracket": "35-44"},
"case": {
"status": "open",
"injury_days_ago": 96,
"completed_visit_days_ago": [88, 74, 60, 45, 30, 12],
},
})

print(result["cdri"]["score"], result["cdri"]["band"])

# Read each channel's level next to its own evidence.
for channel, reading in result["phenotypes"].items():
print(f"{channel:26} {reading['level']:.2f} evidence {reading['evidence']:.0%}")

if result["acuity"]:
print("acuity", result["acuity"]["index"], result["acuity"]["band"])

Node.js (native fetch)​

const response = await fetch(
"https://engine.tripod-health.com/api/cdri/compute?audience=employee",
{
method: "POST",
headers: {
apikey: process.env.ENGINE_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ answers }),
},
);
const result = await response.json();

TypeScript response interface​

type Channel =
| "pain_dominant"
| "psychosocial_dominant"
| "fear_avoidance_dominant"
| "rtw_barrier"
| "multi_domain_catastrophic"
| "iatrogenic_escalation"
| "established_chronicity";

type Band =
| "excellent" | "great" | "good" | "moderate"
| "bad" | "critical" | "severe";

interface ChannelReading {
level: number;
evidence: number;
residual: number;
}

interface CdriResponse {
composite_id: "cdri";
cdri: { score: number | null; band: Band | null; bank_version: string };
phenotypes: Partial<Record<Channel, ChannelReading>>;
breadth: number | null;
acuity: {
index: number;
band: Band;
from_answers: number;
from_rhythm: number;
answer_trust: number;
rhythm_trust: number;
bank_version: string;
} | null;
phenotype: {
backend: string;
model_version: string | null;
phenotype: Channel | null;
distribution: Record<Channel, number> | null;
model_certainty: number | null;
overall_level: number | null;
trajectory: null;
similar_cases: null;
anomalies: null;
};
confidence: {
overall: number;
tier: "low" | "medium" | "high";
breakdown: {
evidence: number | null;
input_completeness: null;
imputation_variance: number | null;
model_certainty: number | null;
};
};
depth: "full";
catalog_version: string;
request_id: string;
timestamp: string;
}

The empty body​

curl -sS -X POST "https://engine.tripod-health.com/api/cdri/compute" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"answers": []}'

Returns 200 with cdri.score: null, band: null, phenotypes: {} and breadth: null. Nothing was measured, and the response says so rather than reporting a number computed from nothing.

Error scenarios​

A question the bank does not have​

{
"timestamp": "2026-09-16T00:38:33.141Z",
"error": "ValidationError",
"message": "'nope' is not a question in the current bank",
"field": "answers.nope",
"request_id": "27092ef7-c118-41cd-9e65-281dc5547967"
}

A code that question does not offer​

{
"timestamp": "2026-09-16T00:38:33.087Z",
"error": "ValidationError",
"message": "99 is not one of 'pain_intensity_now's option codes (0, 1, 2, 3, 4, 5, 6)",
"field": "answers.pain_intensity_now",
"request_id": "52b745d6-ec81-4c90-bb56-f6dacb457d0d"
}

A value is never silently clamped. A wrong severity inverts or inflates a channel and nothing downstream can detect it, so the request fails and names the item.

Status codes​

CodeMeaningWhen
200OKScored. An all-null result for an empty answer array is a 200.
400ValidationErrorAn answer naming an unknown question, a code that question does not offer, an unrecognised key, or a malformed case. field names the offender.
400DeIdentificationViolationThe body carried something identifier-shaped, including any precise date.
401UnauthenticatedMissing or invalid API key.
402PaymentRequiredInsufficient credits. See credits.
429RateLimitedRate limit exceeded.
503ServiceUnavailableThe question bank could not be loaded. Retry; nothing about your request caused it.

Intended use​

  • A cross-channel risk readout from whatever has been answered so far, at any point in a conversation.
  • Triage and queueing. Sort on cdri.score, and read confidence next to it so a thinly-evidenced subject is not mistaken for a low-risk one.
  • Watching a case over time. Supply case and follow acuity.index; the two component readings show whether a change came from what the person says or from how often they are being seen.
  • Choosing a pathway. residual and breadth say whether one driver carries the picture or the burden is diffuse.

Mistaken use​

  • Do not read a low score under thin evidence as low risk. Read confidence.tier and the per-channel evidence. A low score with tier: "low" means "we did not see enough to say much."
  • Do not treat a channel level as a percentage of anything. It is a severity on 0.0–1.0.
  • Do not build your own margin on top of the score. There is no interval because there is no shrinkage; adding a private margin re-introduces a correction the engine deliberately removed.
  • Do not expect recommendations here. Use /phenotype/classify.
  • Do not submit an instrument's responses. They are a different surface entirely — see the note at the top.
  • Do not branch on role. It changes no number on this endpoint.
  • Do not send context — the key is demographics, and context is silently dropped.

Debugging​

  1. Capture request_id from the response body (or, if the response has no body from the engine, the X-Request-ID response header).
  2. cdri.score is null. answers was empty, or every answer was rejected. A rejection is a 400, so a 200 with a null score means the array was empty.
  3. A 400 names a field you thought was valid. Read message — for a value error it lists that question's legal codes, which are not always 0–6.
  4. demographics seem to have no effect. Confirm you sent demographics and not context; the latter is dropped without an error.
  5. acuity is null. You did not send case. It is the switch.
  6. phenotype.model_certainty is low. Expected on thin input. Ask more on the leading channel with POST /api/cdri/questions?phenotype=<name>&order=voi.
  7. The score moved and no answer changed. Check cdri.bank_version. A new bank version can re-weight the same answers; figures are only comparable within one version.
  8. A 503. The bank did not load. Retry, and report it with the request_id if it persists.

Performance and caching​

Deterministic apart from the phenotype backend, but not cacheable by request-body hash — every call writes a de-identified corpus row, and a cached response is a call that did not happen. Cache on your side if you need to, knowing that is what you are trading away.

Rate limits​

See rate-limits.md.

Change history​

DateChange
2026-04-20Initial publication.
2026-08-14Documented the embedded acuity view and the recommendations modes.
2026-09-16Scored from the question bank. Request takes a flat answers array; instruments is gone. Response replaces cdri_score/magnitudes/residuals with cdri and phenotypes, adds per-channel evidence, and replaces the acuity view with an acuity block driven by case. Removes findings, red_flags, recommendations, the ?recommend= parameter, interval and action. /api/magnitude/compute and /api/acuity/compute are retired into this endpoint.
2026-10-14The engine assigns every request ID; an X-Request-ID request header is not used. The X-Request-ID response header is the gateway's trace ID for the request and does not match request_id; quote request_id to support.