> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.milliseconds.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.milliseconds.ai/_mcp/server.

# Composite scoring

A broad question like "how serious is this ticket?" hides several judgments. The model answers it with one
number that you cannot explain, audit or tune.

Composite scoring splits the question. You send one [rate](/capabilities/rate) call per dimension, each with
its own scale. You keep the weights in your code, where you can read them, test them and change them without
touching the model.

## Design the dimensions

Each dimension is one `rate` call with one ordered scale. Follow three rules.

* **Keep the dimensions independent.** Tone, business impact and time pressure vary on their own. Do not add a
  dimension that restates another one.
* **Describe every level.** The level text is what the model reads. `"Low"` and `"High"` carry no meaning.
  Write `"The whole business is blocked"` instead.
* **Keep scales short.** A scale holds 2 to 10 levels. Four clear levels beat ten vague ones.

More guidance on level wording lives in [writing good statements and labels](/concepts/writing-good-questions).

## Score each dimension

The example text is one inbound support ticket:

```text
We have been down since 09:00. Your API returns 500 on every checkout call. This is our busiest
trading day and we are losing orders. If this is not fixed today we will move to another provider.
```

Three calls run against it, one per dimension. The curl call below scores the impact dimension.

**`curl (impact)`**

```bash title="curl (impact)"
curl -s https://api.milliseconds.ai/v1/decision-machine-1/rate \
  -H "content-type: application/json" \
  -H "authorization: Bearer test_sk-..." \
  -d '{
    "text": "We have been down since 09:00. Your API returns 500 on every checkout call. This is our busiest trading day and we are losing orders. If this is not fixed today we will move to another provider.",
    "scale": ["No work is blocked", "One person is blocked", "A team is blocked", "The whole business is blocked"]
  }'
```

**`Python`**

```python title="Python"
import requests
from concurrent.futures import ThreadPoolExecutor

URL = "https://api.milliseconds.ai/v1/decision-machine-1/rate"

DIMENSIONS = {
    "tone": (0.25, ["Calm", "Annoyed", "Frustrated", "Threatening to leave"]),
    "impact": (0.45, ["No work is blocked", "One person is blocked",
                      "A team is blocked", "The whole business is blocked"]),
    "urgency": (0.30, ["Can wait weeks", "Can wait days",
                       "Needs attention today", "Needs attention within the hour"]),
}

def rate(text, scale):
    r = requests.post(URL, headers={"authorization": "Bearer test_sk-..."}, json={"text": text, "scale": scale}, timeout=30)
    r.raise_for_status()
    return r.json()

def score_all(text):
    with ThreadPoolExecutor(max_workers=len(DIMENSIONS)) as pool:
        jobs = {n: pool.submit(rate, text, s) for n, (_, s) in DIMENSIONS.items()}
    return {n: job.result() for n, job in jobs.items()}
```

**`TypeScript`**

```typescript title="TypeScript"
const URL = "https://api.milliseconds.ai/v1/decision-machine-1/rate";

const DIMENSIONS = {
  tone: { weight: 0.25, scale: ["Calm", "Annoyed", "Frustrated", "Threatening to leave"] },
  impact: { weight: 0.45, scale: ["No work is blocked", "One person is blocked",
                                  "A team is blocked", "The whole business is blocked"] },
  urgency: { weight: 0.30, scale: ["Can wait weeks", "Can wait days",
                                   "Needs attention today", "Needs attention within the hour"] },
};

async function rate(text: string, scale: string[]) {
  const res = await fetch(URL, {
    method: "POST",
    headers: { "content-type": "application/json", authorization: "Bearer test_sk-..." },
    body: JSON.stringify({ text, scale }),
  });
  if (!res.ok) throw new Error(`rate failed: ${res.status}`);
  return res.json() as Promise<{ score: number; level: number; label: string; confidence: number; scores: number[] }>;
}

async function scoreAll(text: string) {
  const names = Object.keys(DIMENSIONS) as (keyof typeof DIMENSIONS)[];
  const results = await Promise.all(names.map((n) => rate(text, DIMENSIONS[n].scale)));
  return Object.fromEntries(names.map((n, i) => [n, results[i]]));
}
```

The three responses, captured live:

**`tone`**

```json title="tone"
{"score":2,"level":2,"label":"Frustrated","confidence":0.208,"scores":[0,0.333,0.333,0.333]}
```

**`impact`**

```json title="impact"
{"score":2.507,"level":3,"label":"The whole business is blocked","confidence":0.498,"scores":[0,0,0.493,0.507]}
```

**`urgency`**

```json title="urgency"
{"score":2.498,"level":2,"label":"Needs attention today","confidence":0.5,"scores":[0,0,0.502,0.498]}
```

Use `score`, not `level`. `score` is the probability-weighted position `Σ p_i · i`, so it carries the
model's uncertainty. `level` is the argmax alone, and it can jump on a 0.001 difference.

## Combine the scores

Normalise each `score` to 0–1 by dividing it by `scale.length - 1`. Then take the weighted sum.

**`Python`**

```python title="Python"
def composite(results):
    parts = [w * results[n]["score"] / (len(s) - 1)
             for n, (w, s) in DIMENSIONS.items()]
    return sum(parts) / sum(w for w, _ in DIMENSIONS.values())

def triage(results):
    value = composite(results)
    floor = min(r["confidence"] for r in results.values())
    if floor < 0.35:
        return "review", value
    if value >= 0.75:
        return "page on-call", value
    if value >= 0.45:
        return "queue today", value
    return "normal queue", value
```

**`TypeScript`**

```typescript title="TypeScript"
type Result = { score: number; confidence: number };

function composite(results: Record<string, Result>) {
  const dims = Object.entries(DIMENSIONS);
  const total = dims.reduce(
    (sum, [n, d]) => sum + d.weight * (results[n].score / (d.scale.length - 1)), 0);
  return total / dims.reduce((sum, [, d]) => sum + d.weight, 0);
}

function triage(results: Record<string, Result>) {
  const value = composite(results);
  const floor = Math.min(...Object.values(results).map((r) => r.confidence));
  if (floor < 0.35) return { action: "review", value };
  if (value >= 0.75) return { action: "page on-call", value };
  if (value >= 0.45) return { action: "queue today", value };
  return { action: "normal queue", value };
}
```

The captured numbers normalise to `0.667`, `0.836` and `0.833`, and give a composite of `0.793`. That clears
the paging band. The tone call reports `confidence` 0.208, so this ticket still routes to a person first.
The model separated impact and urgency cleanly, and stayed unsure about tone.

> **Tip**
>
> Log every dimension score next to the final action. A composite you cannot decompose is as opaque as the
> broad question you replaced.

## Latency and batching

Each dimension is one call. A `rate` call runs near 0.4 s, so three dimensions in series cost near 1.2 s.
Run them together with `Promise.all` or a thread pool. Separate inference slots take the three calls, so they
finish in little more than the time of one.

Batching does not merge dimensions. `texts` sends many texts through **one** scale, so it scores many tickets
on one dimension, not one ticket on many dimensions. To score a queue, batch up to 32 texts inside each
dimension and read the results in input order, as [batching](/capabilities/batching) describes.

> **Warning**
>
> Weights are your product decision, not a model output. Fit them against 50 to 200 labelled examples from a
> [golden set](/evaluate/golden-sets), not against intuition.

## Next

* [Rate](/capabilities/rate) — the full request and response shape for one dimension.
* [Confidence routing](/patterns/confidence-routing) — the act, confirm and escalate bands used above.
* [Tuning thresholds](/evaluate/tuning-thresholds) — pick the weights and the cut-offs from data.