Composite Disability Risk Index
Status:
stableCategory:compositeAdded:2026-04-20Last 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-levelbandand the bank version behind it. - A
phenotypesblock: one object per risk channel carrying that channel'slevel, its ownevidencepercentage, and itsresidualnet of the subject's overall burden. - A
breadthmeasure —0is one driver,1is evenly diffuse. - A
phenotypeblock attaching one of seven recovery phenotypes, with a distribution across all seven and a certainty. - A
confidencereport the caller can use to decide how much weight to put on the result. - Optionally, an
acuityblock — how closely this case needs watching — when the caller also supplies de-identifiedcasefacts.
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
| Field | Value |
|---|---|
| Method | POST |
| Path | /api/cdri/compute |
| Full URL | https://engine.tripod-health.com/api/cdri/compute |
| Authentication | API key (required) |
| Request body | application/json |
| Response body | application/json |
| Idempotent | No — a corpus row is written per call |
| Rate-limited | Yes |
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
| Header | Required | Description |
|---|---|---|
apikey | Yes | Consumer API key. |
Content-Type | Yes | application/json. |
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
audience | enum | none | provider, employee, employer. Selects the stakeholder wording for labels. Does not change any number. |
Body
| Field | Type | Required | Description |
|---|---|---|---|
answers | array | No | The answered bank questions. Defaults to empty. |
answers[].item_id | string | Yes | The question's own id, for example pain_intensity_now. |
answers[].value | integer | Yes | A code that question offers. See The answer ladder. |
answers[].answered_days_ago | integer | No | How long ago the answer was given. Omit for "today". Feeds acuity, never the index. |
demographics | object | No | De-identified structural descriptors. |
demographics.age_bracket | string | No | For example "35-44". Never a date of birth or a year. |
demographics.sex | string | No | Self-reported categorical. |
demographics.occupation_class | string | No | For example "manufacturing". |
demographics.naics_code | string | No | Six-digit NAICS code as a string. |
demographics.region | string | No | State or region, never a street address. |
role | enum | No | ptp, case_mgr, frontline, navigator. Ranking metadata only — no risk figure changes with role. |
case | object | No | The switch for the acuity block. Omit it and there is no acuity. |
case.status | enum | No | open (default) or closed. A closed case scores zero acuity. |
case.injury_days_ago | integer | No | Days since the injury. 0–36500. |
case.completed_visit_days_ago | array of integer | No | Day 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
| Field | Type | Always present | Description |
|---|---|---|---|
composite_id | string | Yes | Always "cdri". |
cdri.score | number | null | Yes | 0–100 headline index. null when nothing was answered. |
cdri.band | string | null | Yes | Seven-level label for the score. null when score is null. |
cdri.bank_version | string | Yes | The question bank version that scored it. |
phenotypes | object | Yes | One entry per channel. {} when nothing was answered. |
phenotypes[channel].level | number | Yes | 0.0–1.0 severity on that channel. |
phenotypes[channel].evidence | number | Yes | 0.0–1.0. How much of that channel's bank mass the answers hold. |
phenotypes[channel].residual | number | Yes | That channel's level net of the subject's overall burden. Can be negative. |
breadth | number | null | Yes | 0 one driver, 1 evenly diffuse. null when nothing was answered. |
acuity | object | null | Yes | null unless you sent case. See Acuity. |
phenotype | object | Yes | The classification block. See Phenotype block. |
confidence | object | Yes | See Confidence. |
depth | string | Yes | Always "full" on this endpoint. |
catalog_version | string | Yes | Catalog version the deploy is serving. Compare with /api/version/get to detect mid-flight deploys. |
request_id | string | Yes | Correlation ID. Quote this in support tickets. |
timestamp | string (ISO 8601 UTC) | Yes | Server 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.
| Band | Range |
|---|---|
excellent | 0.00 – 14.29 |
great | 14.29 – 28.57 |
good | 28.57 – 42.86 |
moderate | 42.86 – 57.14 |
bad | 57.14 – 71.43 |
critical | 71.43 – 85.71 |
severe | 85.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_barrierat+0.1188above. breadthnear0means one driver carries the picture, and a targeted intervention is plausible. Near1means 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.
| Field | Type | Description |
|---|---|---|
acuity.index | number | 0–100. The blended reading. |
acuity.band | string | The same seven-level band vocabulary. |
acuity.from_answers | number | What the answers alone say. |
acuity.from_rhythm | number | What the visit rhythm alone says. |
acuity.answer_trust | number | 0.0–1.0. How much the answer reading is trusted. |
acuity.rhythm_trust | number | 0.0–1.0. How much the rhythm reading is trusted. |
acuity.bank_version | string | The 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
| Field | Type | Description |
|---|---|---|
phenotype.backend | string | Opaque 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_version | string | null | Opaque version identifier. Do not pin client logic to it. |
phenotype.phenotype | string | null | One of the seven names below, or null if classification did not succeed. |
phenotype.distribution | object | null | A probability across all seven, summing to 1.0 ± 0.01. |
phenotype.model_certainty | number | null | Peak probability from distribution. |
phenotype.overall_level | number | null | The single strongest channel level. A property of phenotypes, not the basis of cdri.score. |
phenotype.trajectory / similar_cases / anomalies | null | Reserved 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)
| Phenotype | One-line meaning |
|---|---|
pain_dominant | Pain intensity is the overriding driver of disability; psychosocial and functional signals are secondary. |
psychosocial_dominant | Mood, anxiety, or cognitive-emotional factors dominate the profile. |
fear_avoidance_dominant | Belief-based activity avoidance (fear of re-injury) drives the clinical picture. |
rtw_barrier | Workplace-specific barriers dominate — low recovery expectation, low work ability, no modified duty. |
multi_domain_catastrophic | High severity across pain, psychosocial, function and RTW — no single driver. |
iatrogenic_escalation | Elevated opioid-risk or treatment-side-effect markers alongside moderate pain and function signals. |
established_chronicity | Pattern 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
| Field | Type | Description |
|---|---|---|
confidence.overall | number | 0.0–1.0, renormalized over whichever terms fired. |
confidence.tier | string | low, medium, or high. |
confidence.breakdown.evidence | number | null | How 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_completeness | null | Always null on the CDRI surface. It belongs to underwriting basis, which measures a different thing under a different name. |
confidence.breakdown.imputation_variance | number | null | The contextualization term. |
confidence.breakdown.model_certainty | number | null | Mirrors 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:
| Gone | Where it lives now |
|---|---|
findings | The /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_flags | Escalations are recommendation items, on /phenotype/classify. |
interval, action | Nothing 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 blocks | Folded into phenotypes, one object per channel carrying level, evidence and residual together. |
matrix_version, loadings_version, provenance, p | Replaced by cdri.bank_version. |
The separate /api/magnitude/compute and /api/acuity/compute endpoints | Here. 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
| Code | Meaning | When |
|---|---|---|
200 | OK | Scored. An all-null result for an empty answer array is a 200. |
400 | ValidationError | An answer naming an unknown question, a code that question does not offer, an unrecognised key, or a malformed case. field names the offender. |
400 | DeIdentificationViolation | The body carried something identifier-shaped, including any precise date. |
401 | Unauthenticated | Missing or invalid API key. |
402 | PaymentRequired | Insufficient credits. See credits. |
429 | RateLimited | Rate limit exceeded. |
503 | ServiceUnavailable | The 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 readconfidencenext to it so a thinly-evidenced subject is not mistaken for a low-risk one. - Watching a case over time. Supply
caseand followacuity.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.
residualandbreadthsay 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.tierand the per-channelevidence. A low score withtier: "low"means "we did not see enough to say much." - Do not treat a channel
levelas a percentage of anything. It is a severity on0.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 isdemographics, andcontextis silently dropped.
Debugging
- Capture
request_idfrom the response body (or, if the response has no body from the engine, theX-Request-IDresponse header). cdri.scoreisnull.answerswas empty, or every answer was rejected. A rejection is a400, so a200with anullscore means the array was empty.- A
400names a field you thought was valid. Readmessage— for a value error it lists that question's legal codes, which are not always0–6. demographicsseem to have no effect. Confirm you sentdemographicsand notcontext; the latter is dropped without an error.acuityisnull. You did not sendcase. It is the switch.phenotype.model_certaintyis low. Expected on thin input. Ask more on the leading channel withPOST /api/cdri/questions?phenotype=<name>&order=voi.- 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. - A
503. The bank did not load. Retry, and report it with therequest_idif 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.
Related
- composite-cdri-questions.md — get the questions whose answers this endpoint scores.
- composite-cdri-extract.md — mine answers from a visit transcript, in the same shape.
- composite-phenotype-classify.md — the pattern and its recommendations, without the scoring envelope.
- patterns-score.md — scoring a published instrument, which is a separate surface from this bank.
- conventions.md — the de-identification boundary and the error envelope.
- credits.md — what this call costs.
Change history
| Date | Change |
|---|---|
| 2026-04-20 | Initial publication. |
| 2026-08-14 | Documented the embedded acuity view and the recommendations modes. |
| 2026-09-16 | Scored 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-14 | The 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. |