Routing selectors and signal sources
Look up each built-in routing selector and signal source, including its configuration, missing-data behavior, and decision output.
On this page
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:
frombuilds the candidate list.whereremoves candidates that do not meet your conditions.bynames the selector that chooses one remaining candidate.
This example keeps healthy candidates, then chooses the one with the lowest recently observed latency:
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. Use the 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 |
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.
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.
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.
weighted
Use weighted to distribute traffic across a pool in fixed proportions.
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.
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.
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.
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.
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.
cheapest_meeting
Use cheapest_meeting to choose the least expensive candidate among those that passed
where.
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.
Cost formula, terms, and ties
The score is the estimated cost in dollars, so lower wins:
score = prompt_tokens × price.input_per_tok + est_output × price.output_per_tokThe 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.
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.
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.
Normalization, exclusions, and ties
The score is:
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.
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.
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.
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.
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.
$ 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.
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.
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.
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.
Check the decision. Confirm that the expected signal.semantic.<topic> values are present,
then compare them with the threshold in the matched rule.
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.
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.
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.
Estimated-cost formula and pricing lookup
signal.est_cost = (prompt_bytes / chars_per_token) × input_rate
+ est_output_tokens × output_rateRates 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.
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:
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.
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.
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.
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.
What this page does not cover
This page covers selection mechanisms and the signal producers shipped with routing. Use Routing inputs for every value a policy can read and its absence conditions. Use the policy language for node grammar and terminal targets.
The calibration process that produces candidate.quality.lcb is covered under
Calibration and evals.