# Routing selectors and signal sources

Look up each built-in routing selector and signal source, including its configuration, missing-data behavior, and decision output.

Source: https://tokentriage.com/docs/build/routing-selectors

---
import Callout from "../../../components/Callout.astro";
import Details from "../../../components/Details.astro";
import NextSteps from "../../../components/NextSteps.astro";

Use this reference when you need to configure a `select` node or understand the values in a
routing decision. A **tier** is a model option you configured. A **candidate** is a tier still
being considered for the current request.

A `select` node makes its choice in three steps:

1. `from` builds the candidate list.
2. `where` removes candidates that do not meet your conditions.
3. `by` names the selector that chooses one remaining candidate.

This example keeps healthy candidates, then chooses the one with the lowest recently observed
latency:

```yaml
select:
  from: { tiers: all }
  where: "candidate.healthy"
  by: { selector: least_latency, params: { stat: p50 } }
  on_empty: { tier: frontier }
```

The selector never sees candidates removed by `where`. If it returns a choice that is missing
from its score table, TokenTriage treats the result as a routing fault.

If you are still deciding between rules, selection, splits, and cascades, start with
[Choose a routing strategy](/docs/build/routing-strategies). Use
[the policy language](/docs/build/policy-language) for the surrounding YAML grammar.

## Choose a selector

TokenTriage includes six selectors. Start from the job you need the router to do.

| Selector             | Use it when                                                   | How it chooses                                             |
| -------------------- | ------------------------------------------------------------- | ---------------------------------------------------------- |
| `first_eligible`     | candidates have a deliberate priority order                   | takes the first candidate                                  |
| `weighted`           | you want a fixed traffic distribution                         | draws in proportion to pool weights                        |
| `least_latency`      | recent response time matters most                             | takes the lowest observed latency                          |
| `cheapest_meeting`   | price matters after required conditions are met               | takes the lowest estimated cost                            |
| `weighted_objective` | you need to balance cost, latency, and quality                | combines the axes with weights you set                     |
| `epsilon_greedy`     | you have graded outcomes and can reserve traffic for learning | usually takes the best learned tier and sometimes explores |

The selectors compare existing candidate data before the provider request is sent. They do not
add another model call.

`weighted` and `epsilon_greedy` use seeded randomness. The other four selectors are
deterministic for the same candidate data.

### When selector data is missing

Missing data does not have one universal meaning. Check this behavior before you enable a
selector on live traffic.

| Selector             | What happens                                                                                  |
| -------------------- | --------------------------------------------------------------------------------------------- |
| `first_eligible`     | reads no selector-specific measurements, so it excludes nothing                               |
| `weighted`           | treats a missing weight or `weight: 0` as `1`                                                 |
| `least_latency`      | marks an unmeasured candidate `cold_start` and keeps it eligible                              |
| `cheapest_meeting`   | excludes an unpriced candidate; a quality floor also excludes missing or insufficient quality |
| `weighted_objective` | excludes a candidate missing any axis whose weight is above zero                              |
| `epsilon_greedy`     | keeps an unlearned tier available only for exploration; a quality floor can exclude it        |

<Callout type="warning">

For `weighted`, an explicit `weight: 0` does not disable a pool member. It becomes `1`. Remove
the member or filter it out when it should receive no traffic.

</Callout>

## Selector reference

Each entry shows the smallest valid `by` block, the data the selector needs, and what to inspect
on a recorded decision. Open the scoring details when you need formulas, terms, or tie-breaking.

### `first_eligible`

Use `first_eligible` when the candidate order expresses your preference. It takes the first
candidate that passed `where`.

```yaml
by: { selector: first_eligible }
```

**Parameters.** None. Adding a parameter prevents the configuration from loading.

**Data and missing values.** The selector reads only the order of the candidates it receives.
It does not exclude candidates or depend on runtime measurements.

**Check the decision.** Look for `selector=first_eligible` and a `position` term beside each
candidate. The selected candidate should be the first one that was not filtered.

Use another mechanism when you want to retry after an upstream request fails.
`first_eligible` chooses a tier before the request is sent; it is not a retry policy.

<Details summary="Scoring and state">

For candidate `i` in a list of `n` candidates, the score is `(n − i) / n`. Higher wins. The
scores exist so the recorded decision shows the comparison.

This selector stamps no `ctx_ver` because it reads no changing runtime state.

</Details>

### `weighted`

Use `weighted` to distribute traffic across a pool in fixed proportions.

```yaml
by: { selector: weighted }
```

**Parameters.** None.

**Data and missing values.** The selector reads each pool member's weight. A missing weight or
an explicit `weight: 0` becomes `1`. Candidates built from a tier list have no
`candidate.pool_weight` value.

**Check the decision.** Look for `selector=weighted`, then compare each candidate's `weight`
and `probability` terms with the pool configuration.

Use `first_eligible` for a priority order or `least_latency` when measurements should decide
the winner.

<Details summary="Scoring and randomness">

Each candidate's score is `weight / total`. Higher probability means a larger share of
requests, not a guarantee about a small sample.

The selector makes one seeded draw, keyed to the trace id, and records it on the decision. It
stamps no `ctx_ver` because pool weights do not change while a request is being evaluated.

</Details>

### `least_latency`

Use `least_latency` when recent response time matters more than a fixed priority or traffic
share. An **upstream** is a configured connection to a model provider.

```yaml
by: { selector: least_latency, params: { stat: p50 } }
```

| Parameter | Type   | Default | Notes                                             |
| --------- | ------ | ------- | ------------------------------------------------- |
| `stat`    | string | `p50`   | `p50` or `p95`                                    |
| `window`  | string | none    | must parse as a duration; currently has no effect |

**Data and missing values.** TokenTriage records response-time observations after returning a
response to your application. A candidate with no observations stays eligible and carries a
`cold_start` term. If every candidate is cold, the first one wins.

Warm every candidate with representative traffic before relying on this selector. One
practical path is to start with `weighted`, wait until every upstream has observations, and
then switch to `least_latency`.

**Check the decision.** Look for `latency_p50_ms` or `latency_p95_ms` beside every measured
candidate and `cold_start` beside candidates without observations.

Use another selector when price or answer quality matters. `least_latency` considers neither.

<Details summary="Measurement, scoring, and state">

The selector uses nearest-rank latency over the last 256 observations for each upstream. The
raw percentile in milliseconds is the score, so lower wins.

A `p95` selector sees candidates measured only at `p50` as cold. Ties go to the lowest tier
rank, then declaration order.

The decision's `ctx_ver` identifies the latency snapshot used for the choice.

</Details>

### `cheapest_meeting`

Use `cheapest_meeting` to choose the least expensive candidate among those that passed
`where`.

```yaml
by: { selector: cheapest_meeting }
```

| Parameter               | Type   | Default                     | Notes                                                        |
| ----------------------- | ------ | --------------------------- | ------------------------------------------------------------ |
| `default_output_tokens` | number | mirrors the prompt estimate | must be above zero when set                                  |
| `min_quality_lcb`       | number | none                        | in `[0,1]`; a calibrated floor                               |
| `quality_metric`        | string | none                        | metric read by the quality floor; requires `min_quality_lcb` |

LCB means **lower confidence bound**: a conservative quality estimate produced by calibration.

**Data and missing values.** The selector needs input and output prices for each candidate. It
excludes an unpriced candidate and records `unpriced`. A tier you declared free has a genuine
`$0` estimate, remains eligible, and can win.

Prompt tokens come from `signal.token_est`. When that value is missing or not positive, the
selector estimates `1000` prompt tokens. The output estimate mirrors the prompt estimate unless
you set `default_output_tokens`.

When every candidate is unpriced, the selector chooses nothing and the node follows
`on_empty`. A configured quality floor also excludes candidates whose measured lower bound is
missing or below the floor.

**Check the decision.** Look for `input_cost` and `output_cost` on every priced candidate, plus
`quality_lcb` when you configured a floor.

Use `weighted_objective` when cost is one part of a trade-off rather than the sole score.

<Details summary="Cost formula, terms, and ties">

The score is the estimated cost in dollars, so lower wins:

```text
score = prompt_tokens × price.input_per_tok + est_output × price.output_per_tok
```

The score terms are `input_cost` and `output_cost`, plus `quality_lcb` when a floor is
configured. Ties go to the lowest tier rank, then declaration order.

The selector stamps a `ctx_ver` only when it reads a calibrated quality floor.

</Details>

### `weighted_objective`

Use `weighted_objective` when no single measurement should decide the route. It combines up to
three **axes**—cost, latency, and quality—with weights you set.

```yaml
by:
  selector: weighted_objective
  params:
    weights: { cost: 0.5, quality: 0.5 }
    quality_prior: { cheap: 0.6, frontier: 0.9 }
```

| Parameter        | Type   | Default  | Notes                                                                         |
| ---------------- | ------ | -------- | ----------------------------------------------------------------------------- |
| `weights`        | map    | required | any of `cost`, `latency`, or `quality`; at least one must be above zero       |
| `quality_prior`  | map    | none     | a quality value you supply for each tier                                      |
| `quality_source` | string | `prior`  | `prior` or `calibrated`; `calibrated` cannot be combined with `quality_prior` |
| `quality_metric` | string | none     | metric read when `quality_source` is `calibrated`                             |
| `normalize`      | string | `minmax` | the only accepted value                                                       |

**How it chooses.** The selector converts each active axis to a value from `0` to `1`. It
reverses cost and latency so that a better result is always higher, applies your weights, and
adds the contributions. The highest total wins.

**Data and missing values.** The cost axis needs prices and a prompt-token estimate. The
latency axis reads `candidate.latency.p95_ms`. Quality comes from your `quality_prior` or the
calibrated `candidate.quality.lcb` value.

A candidate missing any axis with a weight above zero is excluded. A weight of zero removes
that axis completely, including as a reason to exclude a candidate.

**Check the decision.** Look for `cost`, `latency`, and `quality` terms. They are the weighted
contributions, so the visible terms add up to the final score.

Use `cheapest_meeting` when price alone should decide. It avoids supplying and maintaining
another measurement.

<Details summary="Normalization, exclusions, and ties">

The score is:

```text
score = Σ weight(axis) × normalized(axis)
```

Cost uses the same estimate as `cheapest_meeting`, except the output estimate always mirrors
the prompt estimate. Latency uses p95 only.

An excluded candidate carries `excluded` and a `<axis>_absent` term naming the first missing
axis in alphabetical order. If every candidate has the same value for an axis, that axis
normalizes to `1` for everyone and does not break the tie.

Ties go to the lowest tier rank, then declaration order. The decision stamps a `ctx_ver` when
the selector reads latency or calibrated quality.

</Details>

### `epsilon_greedy`

Use `epsilon_greedy` when you have graded outcomes and want the router to keep learning. An
**arm** is one eligible tier. The selector usually chooses the learned tier with the highest
pass rate and reserves some requests for exploration.

```yaml
by:
  selector: epsilon_greedy
  params: { epsilon: 0.05, min_samples: 50, reward: shadow_grade }
```

| Parameter         | Type   | Default | Notes                                                                |
| ----------------- | ------ | ------- | -------------------------------------------------------------------- |
| `epsilon`         | number | `0.1`   | in `[0,1]`; the exploration share                                    |
| `min_samples`     | number | `1`     | graded observations required before a tier can be exploited          |
| `reward`          | string | none    | accepts `shadow_grade` only; does not change scoring in this version |
| `min_quality_lcb` | number | none    | in `[0,1]`; optional calibrated admission floor                      |
| `quality_metric`  | string | none    | metric read by the quality floor; requires `min_quality_lcb`         |

**How it chooses.** An arm is _learned_ when it has a mean and its count reaches
`min_samples`. If at least one arm is learned, a seeded draw against `epsilon` decides whether
to exploit or explore. Exploitation takes the highest learned pass rate. Exploration draws
uniformly from all eligible arms.

If no arm is learned, every request explores regardless of `epsilon`.

**Data and missing values.** Reward means come from graded shadow-duplication rows for each
tier. A tier with no mean, or too few samples, is marked `cold` and remains available only
through exploration. An absent `reward_count` reads as zero. A configured quality floor can
exclude a tier before this choice.

**Check the decision.** Look for `reward_mean` and `count` on each tier. The chosen row carries
`exploit` or `explore`, plus the reason exploration happened.

Use a fixed selector when you cannot tolerate exploratory traffic or do not yet have a grading
pipeline.

<Details summary="Randomness, score terms, and state">

The score is `candidate.reward_mean`, a pass rate from `0` to `1`. Higher wins among learned
arms.

The selector makes up to two seeded draws with separate salts: one for the exploration decision
on every request and another for the arm only when the request explores. Each draw is recorded.

The decision stamps the reward snapshot version as `ctx_ver`. The `reward` parameter currently
accepts only `shadow_grade` and does not change the calculation.

</Details>

## Read the decision fields

Run `policy explain` with a request id to see the selector, every candidate score, and the
reason a candidate was filtered or excluded.

```console
$ tokentriage policy explain --req-id tt_route
```

The selector entries above name the fields that matter for a first check. Open the complete
table when you need to interpret a less common term.

<Details summary="All selector score-table fields">

| Term                                                | Value        | From                                    | Meaning                                      |
| --------------------------------------------------- | ------------ | --------------------------------------- | -------------------------------------------- |
| `position`                                          | index        | `first_eligible`                        | position in the candidate list               |
| `weight`                                            | the weight   | `weighted`                              | the pool weight used, defaulted to `1`       |
| `probability`                                       | share        | `weighted`                              | that candidate's chance of being drawn       |
| `latency_p50_ms` / `latency_p95_ms`                 | ms           | `least_latency`                         | the observed percentile                      |
| `cold_start`                                        | `1`          | `least_latency`                         | no observations for that upstream            |
| `input_cost` / `output_cost`                        | dollars      | `cheapest_meeting`                      | the two halves of the estimate               |
| `unpriced`                                          | `1`          | `cheapest_meeting`                      | the catalog cannot price that tier           |
| `cost` / `latency` / `quality`                      | contribution | `weighted_objective`                    | the weighted, normalised contribution        |
| `cost_absent` / `latency_absent` / `quality_absent` | `1`          | `weighted_objective`                    | which axis had no value                      |
| `reward_mean` / `count`                             | rate, count  | `epsilon_greedy`                        | the graded pass rate and its evidence        |
| `cold`                                              | `1`          | `epsilon_greedy`                        | not learned: no mean, or below `min_samples` |
| `exploit` / `explore`                               | `1`          | `epsilon_greedy`                        | how this pick was made                       |
| `explore_epsilon` / `explore_under_min_samples`     | `1`          | `epsilon_greedy`                        | why it explored                              |
| `excluded`                                          | `1`          | `weighted_objective` or a quality floor | not a candidate for the choice               |
| `quality_lcb`                                       | the bound    | any with a quality floor                | the admitted lower bound                     |
| `quality_below_min_lcb`                             | the floor    | any with a quality floor                | measured, and short of it                    |
| `quality_absent:<reason>`                           | `1`          | any with a quality floor                | not measurable, with the reason              |

`quality_absent` and `quality_below_min_lcb` are separate because they describe different
problems: no measurement was available, or a measurement was available and fell below the
floor.

</Details>

## Request scorers

Selectors choose a candidate. **Request scorers** do a different job: they inspect the request
before routing and emit `signal.*` values that rules and filters can read. Configure them under
`routing.policy.scorers`. TokenTriage includes `semantic` and `est_cost`; any other name
prevents the configuration from loading.

### `semantic`

Use `semantic` to compare a prompt with example phrases for topics you define. It emits one
`signal.semantic.<topic>` value from `0` to `1` for each topic.

```yaml
scorers:
  semantic:
    budget_ms: 200
    topics:
      legal:
        exemplars: ["draft a contract clause", "review this NDA"]
```

| Key                       | Default  | Notes                                                                 |
| ------------------------- | -------- | --------------------------------------------------------------------- |
| `topics.<name>.exemplars` | required | at least one topic with at least one phrase containing scorable words |
| `budget_ms`               | `1`      | per-request time budget for the whole scorer                          |

Put the threshold in the routing condition, not in the scorer configuration. For example,
`signal.semantic.legal >= 0.7`.

<Callout type="warning">

If scoring exceeds `budget_ms`, all `signal.semantic.*` values are absent for that request and
conditions that depend on them evaluate false. The `1` ms default can be exceeded under load,
so set the budget explicitly and test it with representative traffic before routing on the
signal.

</Callout>

**Check the decision.** Confirm that the expected `signal.semantic.<topic>` values are present,
then compare them with the threshold in the matched rule.

<Details summary="How semantic matching works">

For each topic, the scorer takes the best cosine similarity between the prompt and any
configured exemplar. It uses term frequency–inverse document frequency (TF-IDF), a local text
comparison that gives less weight to common words. Common English words are removed first.

Words are split on ASCII word characters, so non-ASCII letters act as separators. This can
reduce matching quality for prompts and exemplars written in other scripts.

</Details>

### `est_cost`

Use `est_cost` when a routing rule needs an approximate request cost before choosing a tier.
It emits `signal.est_cost` in dollars for the reference tier you name.

```yaml
scorers:
  est_cost:
    reference_tier: frontier
```

| Key                 | Default  | Notes                                                   |
| ------------------- | -------- | ------------------------------------------------------- |
| `reference_tier`    | required | must name a configured tier                             |
| `est_output_tokens` | `256`    | the assumed response length                             |
| `chars_per_token`   | `4`      | divisor used to estimate prompt tokens from prompt size |

The signal is absent when TokenTriage cannot price the reference tier. A `reference_tier` that
does not name a configured tier prevents the configuration from loading.

**Check the decision.** Look for `signal.est_cost` and confirm that the reference tier's catalog
entry matches the provider and region you expect.

<Details summary="Estimated-cost formula and pricing lookup">

```text
signal.est_cost = (prompt_bytes / chars_per_token) × input_rate
                + est_output_tokens × output_rate
```

Rates resolve through the reference tier's catalog prefix and region, not its wire dialect.
An Azure or Bedrock upstream therefore uses its own pricing entry.

</Details>

## Cascade outcome scorer

An outcome scorer inspects a model response inside a cascade. TokenTriage supplies
`outcome.refusal_markers`, which is available only in a stage's `accept_when` condition.

Use `= 0` when a stage should accept only responses with no recognized decline phrase:

```yaml
accept_when: "outcome.status = 200 and outcome.refusal_markers = 0"
```

The value counts distinct matching patterns, not the number of refusals. A condition such as
`outcome.refusal_markers >= 2` means that at least two different patterns matched; one strongly
worded response can satisfy it.

<Details summary="How refusal markers are counted">

The scorer checks the first 8 KiB of the response case-insensitively against a fixed phrase
list, including forms such as "I cannot", "unable to assist", and "I must decline". Curly
apostrophes are normalized, so "I can't" and "I can’t" both match.

The list omits openers such as "as an AI" because they can begin helpful responses. An empty
response produces `0` rather than an absent value.

</Details>

## Context providers

Context providers supply `ctx.*` values about the current request, time, budget, or governance
state. TokenTriage registers them automatically when the required feature is configured.

| Provider         | Supplies                                           | Registered when                               |
| ---------------- | -------------------------------------------------- | --------------------------------------------- |
| `clock`          | `ctx.time.hour_utc`, `.weekday`, `.unix`           | always                                        |
| `budgetstate`    | `ctx.budget.<id>.*` per configured budget          | you configure at least one budget             |
| `governstate`    | `ctx.limit.<id>.*` per global or node-pinned limit | governance is enabled and such a limit exists |
| `principalstate` | `ctx.principal.*` for this request's own chain     | governance is enabled                         |

If budget or governance state is unavailable, the corresponding provider emits no values. A
routing condition that depends on an absent value evaluates false. `clock` reads the request's
arrival time and is always available.

<Callout type="note">

Context values steer routing; they do not enforce a budget or rate limit. Governance gates
continue to enforce those limits in every mode, regardless of what the routing policy reads.
See [Governance](/docs/build/governance).

</Callout>

<Details summary="Clock replay and state versions">

The clock is derived from the request's recorded arrival time. Replaying a logged decision
therefore reproduces the time-based branch it originally took.

The clock is omitted from the decision's state stamp. Including a per-request timestamp would
change that stamp on every request.

</Details>

## What this page does not cover

This page covers selection mechanisms and the signal producers shipped with routing. Use
[Routing inputs](/docs/build/routing-inputs) for every value a policy can read and its absence
conditions. Use [the policy language](/docs/build/policy-language) for node grammar and
[terminal targets](/docs/build/policy-language#terminal-targets).

The calibration process that produces `candidate.quality.lcb` is covered under
[Calibration and evals](/docs/calibration/overview).

<NextSteps
  items={[
    {
      title: "Pick the cheapest candidate that qualifies",
      href: "/docs/build/routing-cheapest",
      description:
        "The two cost-aware selectors in use, with a worked score table and a quality floor.",
    },
    {
      title: "Spread traffic and fail over",
      href: "/docs/build/routing-pools",
      description:
        "The three availability selectors in use, plus the cooldown that feeds candidate.healthy.",
    },
    {
      title: "Routing inputs",
      href: "/docs/build/routing-inputs",
      description:
        "Every value these primitives produce and consume, with the exact absence conditions.",
    },
    {
      title: "Routing explanation fields",
      href: "/docs/build/routing-explanation-fields",
      description:
        "See where score tables, filtered candidates, and context versions appear on a decision.",
    },
  ]}
/>
