Decisions
A train can take different junctions on different runs, along tracks its chain declares. A
Switch, Gate or Scale names every track it can send the train down; at run time a
decider answers a typed question about a value in Memory, and the train takes the matching
track. Every track is part of the declaration, so the host's
startup check
verifies all of them before the first run.
protected override Task<Either<Exception, Resolution>> Junctions() =>
Chain<ParseTicket>()
.Switch<Ticket, TicketTrack>(tracks => tracks
.When(TicketTrack.Refund, t => t.Chain<IssueRefund>().Chain<NotifyCustomer>(),
requireConfidence: 0.7)
.When(TicketTrack.Escalate, t => t.Chain<OpenIncident>())
.When(TicketTrack.SelfServe, t => t.Chain<SendHelpArticle>())
.RequireConfidence(0.5)
.Otherwise(t => t.Chain<QueueForHuman>()))
.Chain<CloseTicket>()
.Resolve();The decider can be a typed decision model (one state and typed questions in, calibrated
probabilities out, no generated text), a rule table, a larger language model, or a test double.
The train does not know which. Trax.Docs/adr/0040 records why a chain branches this way and no
other.
Questions and answers
Every decision is one of three kinds of question, each with its own routing step.
| Question | Declared with | Answer | Routed by | Memory holds |
|---|---|---|---|---|
| Which of these options? | Choice<TTrack>(), an enum | the chosen member, a confidence, each member's probability | Switch | ChoiceDecision<TTrack> |
| Where on this ordered scale? | Score<TLevel>(), an enum whose values, read as signed numbers, run lowest to highest | a position from 0 to the top level, a confidence, each level's probability | Scale | ScoreDecision<TLevel> |
| How likely is yes? | YesNo<TQuestion>(), a marker type | the probability of yes | Gate | YesNoDecision<TQuestion> |
A model is meant to judge by the question's words and each option's description, not the type's name. The words go on the type, once:
[Asks("Which team should handle this support ticket?")]
public enum TicketTrack
{
[Description("The customer wants their money back for an order.")]
Refund,
[Description("An outage, a security problem or a legal threat. Page whoever is on call.")]
Escalate,
[Description("A how-to question the help centre already answers.")]
SelfServe,
}
[Asks("Does this post threaten violence against a person?",
Yes = "It threatens, or incites, harm to someone.",
No = "It does not threaten anyone, even if it is rude.")]
public sealed class ContainsThreat;An asking: argument where the decision is declared overrides [Asks]. A decision with no
question at all is refused when the chain is read.
Question keys
Each question is keyed by the name of the type it is about, as QuestionKey.For<T>() returns
it: the type's name after the name of every type it is nested in, joined by dots, with generic
arguments in angle brackets, and no namespace (TicketTrack, Queue.Lane for an enum nested in a
class, Flag<Refund> for a generic marker). A decider finds each answer's question by it, a model
adapter may send it as the question's id, as the System One adapter does (Nimble puts it in its
prompt), recording and replay look answers up by it, and it is part of every asking's
fingerprint. It is not hidden from a model, so do not name a type
anything you would not show one.
Moving the type to another namespace leaves the key as it was. Renaming it, or a type it is nested
in, changes the key, so the next re-queue asks that question afresh rather than replaying an
answer recorded under the old one. [Asks(..., Key = "...")] sets the key explicitly, to keep the
old one across a rename or to give a type a key of its own:
[Asks("Which team should handle this support ticket?", Key = "TicketTrack")]
public enum SupportRoute { ... }An explicit key is 1 to 100 characters, each an ASCII letter or digit, _, - or .. Two
different types that one train asks about under the same key (in one Decide, across steps, or
inside a track) are refused by the startup check, and at run time for a host that skips it,
because their answers could not be told apart; set Key on one of them.
Asking inline, or asking first
A routing step can ask its own question:
.Switch<LoanApplication, Underwriting>(tracks => tracks ...) // asks, then routesOr a Decide step can ask several questions about one state in a single call, and later steps route on the answers without asking again. That is one round trip to a model instead of three:
AddServices(decider)
.Decide<Post>(q => q.YesNo<ContainsThreat>().Choice<Verdict>().Score<Severity>())
.Gate<ContainsThreat>(gate => gate
.Yes(t => t.Chain<TakeDownPost>(), atLeast: 0.7)
.No(t => t.Switch<Verdict>(verdict => verdict
.When(Verdict.Allow, a => a.Chain<PublishPost>())
.When(Verdict.Remove, r => r.Chain<TakeDownPost>())
.When(Verdict.Review, r => r.Scale<Severity>(scale => scale
.AtLeast(Severity.Low, s => s.Chain<QueueForModerator>())
.AtLeast(Severity.High, s => s.Chain<PageTrustAndSafety>())))),
below: 0.3)
.Unsure(t => t.Chain<PageTrustAndSafety>()))
.Resolve();Routing steps nest: a track is a chain like any other, so it can ask and route again. Write each
track on the parameter it is handed (t => t.Chain<X>()), not on the train's own chain methods.
When the answer is not followed
Three things decide whether the train takes the track the decider chose.
| Situation | What happens |
|---|---|
The choice's confidence is below its track's requireConfidence, or below the switch's RequireConfidence when the track sets none | The Otherwise track runs. Without one, the run fails. |
| The decider chose a member the switch has no track for | The Otherwise track runs. Without one, the run fails. |
| A gate's probability falls between its No and Yes bars | The Unsure track runs. Without one, the run fails. |
Set the bars per track. The track with the gravest consequence gets the highest bar and the
safest gets the lowest, often none: in underwriting, Approve and Decline might need 0.95
while ManualReview accepts anything. A switch that declares no Otherwise refuses to guess,
which is what a train whose outcomes all have consequences wants.
A choice's confidence depends on how many options it was offered, so adding a track moves every threshold on that switch. Re-check them against labelled cases after changing a switch's tracks, its question's words, or the model's version.
The track taken goes in Memory as TrackTaken<TKey>, with the reason when the decision was not
followed, so a later junction can record or act on it.
When the run fails instead
An answer Trax cannot trust is never acted on. The run fails and the failure names the step
(Switch<Ticket, TicketTrack>). How it is
classified depends on whose mistake it is.
A decider's answer that does not fit is classified Transient, because a model asked again
usually answers properly:
- an option that is not a member of the enum, including a member given as a number
- a confidence or probability outside 0 to 1, or not a number
- a score below 0 or above the top level
- the wrong kind of answer, or no answer to a question that was asked
Every bad answer in one Decide is named in the failure, not only the first, and each is reported
to the observer's Refused before the step fails.
A fault in the declaration, or a decision the declaration gives nowhere to go, is classified
Permanent, because running it again gets the same refusal:
- a declaration the startup check refuses, for a host that skips the check
- a decision with no track to take: a choice below its bar, or naming a member with no track, on a
switch with no
Otherwise, or a gate's probability in a band with noUnsuretrack
A decider that throws fails the run with its own exception, whatever fallback tracks are
declared: an error is not a decision. The decider's own failure class is kept, so an adapter can
mark a throttled model Transient. A cancelled run stops before it decides, before it tells an
observer, and before it enters a track.
Deciders
IDecider takes a DecisionRequest (the train's name, the state, the questions) and returns one
answer per question. It is found the way a junction input is: in Memory, then the container. One
registration serves every decision in every train; Decide(q => q.DecidedBy<TDecider>()) names a
different one for a single step.
The live decider is handed the object in the train's Memory as DecisionRequest.State, not a
copy, and reads it without changing it. Each shadow is handed a copy of its own (see
Comparing a new decider without trusting it).
A decider that can tell from the declaration alone that it cannot answer a question (too many
options for its model, a question with no words, a state type it cannot send) implements
IVetsQuestions. When a chain is read, each decider a step names, the live one and each shadow, is
looked up as the run finds it, among the services handed to AddServices and then in the
container, and shown the step's questions as DeclaredQuestions (the train, the step, the state's
declared type, the questions). Each problem it names refuses the chain, so the startup check
reports it before the host takes traffic. No decider is asked to decide while the chain is read,
and one the container can only build inside a request is not vetted and refuses at run time
instead. CascadingDecider vets with each of its tiers.
public interface IVetsQuestions
{
IEnumerable<string> Problems(DeclaredQuestions declared);
}| Decider | Package | For |
|---|---|---|
SystemOneDecider, from AddNimbleDecider | Trax.Effect.Decisions.SystemOne | Nimble, Bespoke Labs' open-weights decision model, on a Nimble server you run. The default choice. See Nimble. |
SystemOneDecider, from AddSystemOneDecider | Trax.Effect.Decisions.SystemOne | Another model behind a server that accepts the System One request format, such as Jev. |
RuleDecider | Trax.Core | Policy rather than judgement, written as code. Always certain. |
CascadingDecider | Trax.Core | A fast decider first, and a slower one only for the questions the first was unsure of. |
ScriptedDecider | Trax.Core | Answers written in advance, for tests and for running locally before a model is wired up. |
var policy = new RuleDecider().Choice<LoanApplication, Underwriting>(application =>
application switch
{
{ CreditScore: >= 740, DebtToIncome: <= 0.36m } => Underwriting.Approve,
{ CreditScore: < 580 } or { DebtToIncome: > 0.5m } => Underwriting.Decline,
_ => Underwriting.ManualReview,
});Escalating what the fast decider is unsure of
services.AddSingleton<IDecider>(sp => new CascadingDecider(
first: sp.GetRequiredService<SystemOneDecider>(), // Nimble
then: sp.GetRequiredService<LargeModelDecider>(),
escalateBelow: 0.8));LargeModelDecider stands for an IDecider of your own over a chat model; Trax does not ship
one. A choice or score below escalateBelow, a yes/no whose probability lies strictly between
unsureAbove (0.2) and unsureBelow (0.8), a confidence or probability that is not a number, a
question the first decider did not answer, and an answer that does not fit its question (an option
it was not offered, a score off the levels, a confidence or probability outside 0 to 1, another
kind of answer) are asked again of the second. Only those questions are sent. If the first decider throws, every question goes to the second, since the first tier
being down is what the second is for; cancellation passes straight through. Pass an ILogger as
logger to be told when that happens. The second answer replaces the first; if the second
decider fails, the run fails. The bounds must lie between 0 and 1, with unsureAbove not above
unsureBelow, or the constructor throws. The switch's own bars and fallback tracks still apply to
whatever the cascade returns, which is how a person becomes the third tier.
Comparing a new decider without trusting it
.Decide<Post>(q => q.YesNo<ContainsThreat>().Choice<Verdict>().Shadow<ICandidateDecider>())A shadow is asked every question the live decider is asked, alongside it. Its answers are
recorded with whether each agreed, and never acted on; a shadow that fails, disagrees or is slow
changes nothing. Once the live answer is in, the shadows are waited for at most
WaitForShadows(TimeSpan) (five seconds by default), then cancelled and recorded as not having
answered, whether or not they honour the cancellation. Every run can wait that long for a slow
shadow, so keep it short; zero waits only for shadows that have already answered. A question whose
answer is replayed is not put to the shadows at all.
A Switch, Gate or Scale that asks its own question declares shadows on its tracks:
.Switch<Ticket, TicketTrack>(tracks => tracks
.When(TicketTrack.Refund, t => t.Chain<IssueRefund>())
.Otherwise(t => t.Chain<QueueForHuman>())
.Shadow<ICandidateDecider>()
.WaitForShadows(TimeSpan.FromSeconds(2)))Agreement there means the shadow's answer would have sent the train down the same track, by the
step's own bars and bands. On a plain Decide, whose routing comes later, it means the same
option, the same nearest level, or the same side of one half for a yes/no. A shadow whose answer
does not fit the question never agrees, and neither does any shadow when the live answer takes no
track (a gate's band with no Unsure track, a choice with no track and no Otherwise). A routing
step that only routes on an earlier Decide asks nothing, so shadows declared on it are refused;
shadow the Decide.
Use a shadow to move from a rule table to a model, or from one model version to the next, on real
traffic before switching over. A shadow handed to AddServices is passed as an interface, as every
service there is; from the container, a concrete type does. A shadow from the container is built
in a DI scope of its own, disposed when the shadow ends, so it never shares the run's scoped
services (a DbContext, say) with the live decider or with the junctions that run after the
decision. A shadow that has not finished a second after it was cancelled has its scope disposed
under it, on a timer the run never waits for, and whatever it then fails with is recorded as its
own failure.
Each shadow is handed its own copy of the state: the state is written to JSON once per asking, with the web defaults the System One adapter uses, and read back separately for each shadow, so a shadow that changes its copy changes nothing the run or another shadow sees. A state type JSON cannot write and read back is refused by the startup check when shadows are declared on it and the type alone says so; otherwise a shadow whose copy cannot be made is recorded as not having answered, and the run goes on.
A shadow that neither supplies is a mistake in the host: the startup check reports it,
and a run that reaches it fails, Permanent, as it does for a missing live decider.
What the startup check verifies
Every track is read when the chain is declared, and each is replayed from the Memory at the
routing step. After a routing step the chain can rely only on what every track produces:
a junction after a switch that needs a value only one track makes is refused, naming it. A fault
inside a track is reported at the routing step, as track 'Refund', step 1: ....
The check also refuses a routing step with no decision before it, a decider or shadow that
neither Memory nor the container supplies, a shadow named twice or declared on a step that asks
nothing, a negative or unbounded WaitForShadows, a track declared twice, a track on a value the
enum does not define (When((Lane)7, ...), or the same in AtLeast), a switch with no tracks, a
gate with no Yes or No track or with bars that cross, a scale on an enum with fewer than two
levels, a scale with no track for its lowest level, a Key on [Asks] that is not a valid key,
two types asked about under one key, shadows on a state type JSON cannot copy, and whatever a
decider that implements IVetsQuestions names. Reading the chain asks no decider and runs no
track. A routing step that asks its own question checks its tracks before it asks, so a host
that skips the check still never pays for a decision on a declaration it would refuse.
Observing and replaying
Core reports every answered question and every routing to an IDecisionObserver, and asks an
IDecisionReplay for an earlier answer before it asks a decider. Both are optional and found in
Memory, then the container.
The observer is awaited on the train's path, after the answer is checked and before any track is taken on it:
public interface IDecisionObserver
{
bool Required => false;
Task Decided(DecisionMade decision, CancellationToken cancellationToken);
Task Routed(TrackRouted routing, CancellationToken cancellationToken);
Task Refused(DecisionRefused refusal, CancellationToken cancellationToken) => Task.CompletedTask;
}Refused is told once for each question whose live answer the step will not act on (missing, or
not fitting the question), before the step fails, with the question, occurrence, fingerprint, the
answer (null when there was none), the decider and the reason. A cascade that ended with an answer
that fits is not a refusal, and a shadow's bad answer is reported in its ShadowAnswer.Error
instead. A refusal is never replayed. An observer that cannot record one is logged, and never
replaces the refusal as the reason the step failed, even when it is Required.
By default an observer is best effort: what it throws is logged and ignored, because recording a
decision must never change it. An observer whose record the host depends on, such as one a later
replay reads, returns true from Required, and then a failure to record fails the step before
the train acts on the decision, classified as the exception says or Transient when it says
nothing. An observer that cannot be resolved at all fails the step either way.
DecisionMade carries the question, the answer, the decider, whether it was replayed, the
shadows' answers, Occurrence (how many times the run asked that question before, from 0) and
Fingerprint. The replay is asked by the same coordinates, on the run's path, once per question:
public interface IDecisionReplay
{
Task<RecordedAnswer?> Replay(
string train, string runId, string key, int occurrence, CancellationToken cancellationToken);
}
public sealed record RecordedAnswer(Answer Answer, string Fingerprint);It returns null to ask the decider. A host that replays stores each DecisionMade.Answer with its
Fingerprint and hands both back exactly as stored. Whatever Replay throws fails the step,
classified as the exception says, because a replay that cannot be read cannot be told from a run
with nothing to replay.
The fingerprint identifies one asking of a question as the chain declares it: a SHA-256 over the step, the state's type, the kind of question, its key, its words, and every option or level with its description (or what yes and no mean). It never covers the state's value, which differs from run to run by design. A replayed answer is checked before it is acted on, and is not acted on when:
- its fingerprint differs from the question as it is asked now: the question was reworded, its options, levels or descriptions changed, or the chain changed so that another step now asks it first
- it no longer fits the question: an option renamed or removed from the enum, a scale with fewer levels, another kind of question
Either way the decider is asked afresh, and DecisionMade.ReplayRefused says why. A recorded
choice of a member the switch has no track for is replayed like any other and takes the
Otherwise track again, as it did the first time.
Trax.Effect implements both, to record decisions against the run and to make a re-queued run take the tracks the original took. See Decision Recording and Models.
Testing a train's decisions
var decider = new ScriptedDecider().Choose(Underwriting.Approve, confidence: 0.62);
var result = await new UnderwriteLoan(decider).RunEither(new LoanApplication("a3", 700, 0.4m));
result.IsLeft.Should().BeTrue(); // below Approve's 0.95, and no Otherwise
decider.Requests.Should().ContainSingle(); // what a model would have been shownScriptedDecider answers Choose, Score and YesNo with what the test says, can answer a
question with an answer that does not fit (Answer(key, ...)) or fail every request (Throws),
and keeps every request it was asked. A question it has no answer for is left unanswered, which
fails the run the way a model that skipped one would.
SDK Reference
Decide | Switch | Gate | Scale | AddServices | DeclaredChain | AddNimbleDecider