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.

QuestionDeclared withAnswerRouted byMemory holds
Which of these options?Choice<TTrack>(), an enumthe chosen member, a confidence, each member's probabilitySwitchChoiceDecision<TTrack>
Where on this ordered scale?Score<TLevel>(), an enum whose values, read as signed numbers, run lowest to highesta position from 0 to the top level, a confidence, each level's probabilityScaleScoreDecision<TLevel>
How likely is yes?YesNo<TQuestion>(), a marker typethe probability of yesGateYesNoDecision<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 routes

Or 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.

SituationWhat happens
The choice's confidence is below its track's requireConfidence, or below the switch's RequireConfidence when the track sets noneThe Otherwise track runs. Without one, the run fails.
The decider chose a member the switch has no track forThe Otherwise track runs. Without one, the run fails.
A gate's probability falls between its No and Yes barsThe 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 no Unsure track

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);
}
DeciderPackageFor
SystemOneDecider, from AddNimbleDeciderTrax.Effect.Decisions.SystemOneNimble, Bespoke Labs' open-weights decision model, on a Nimble server you run. The default choice. See Nimble.
SystemOneDecider, from AddSystemOneDeciderTrax.Effect.Decisions.SystemOneAnother model behind a server that accepts the System One request format, such as Jev.
RuleDeciderTrax.CorePolicy rather than judgement, written as code. Always certain.
CascadingDeciderTrax.CoreA fast decider first, and a slower one only for the questions the first was unsure of.
ScriptedDeciderTrax.CoreAnswers 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 shown

ScriptedDecider 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