Write a decider
Source IDecider.cs, RuleDecider.cs, CascadingDecider.cs, IVetsQuestions.cs
A decider answers the questions a Decide, Switch, Gate or Scale asks. Trax ships a
System One adapter for typed decision models (see Decision Recording and Models),
RuleDecider for policy, CascadingDecider to combine two, and ScriptedDecider for tests.
Write your own when the answer comes from somewhere else. Decisions
explains how a chain asks and routes.
Implement IDecider
Every type is in Trax.Core.Decisions, in the Trax.Core package, which Trax.Effect brings in:
public interface IDecider
{
Task<DecisionResult> Decide(DecisionRequest request, CancellationToken cancellationToken);
}
public sealed record DecisionRequest(string Train, object State, IReadOnlyList<Question> Questions);
public abstract record Question(string Key, string Instructions);
public sealed record ChoiceQuestion(string Key, string Instructions, IReadOnlyList<Criterion> Options) : Question(Key, Instructions);
public sealed record ScoreQuestion(string Key, string Instructions, IReadOnlyList<Criterion> Levels) : Question(Key, Instructions);
public sealed record YesNoQuestion(string Key, string Instructions, string? Yes, string? No) : Question(Key, Instructions);
public sealed record Criterion(string Name, string? Description);
public sealed record DecisionResult(IReadOnlyDictionary<string, Answer> Answers);
public abstract record Answer { public string? Model { get; init; } }
public sealed record ChoiceAnswer(string Choice, double Confidence = 1.0, IReadOnlyDictionary<string, double>? Probabilities = null) : Answer;
public sealed record ScoreAnswer(double Score, double Confidence = 1.0, IReadOnlyList<double>? Probabilities = null) : Answer;
public sealed record YesNoAnswer(double Probability) : Answer;Return one answer per question, keyed by Question.Key, of the kind the question asks for:
| Question | Answer |
|---|---|
ChoiceQuestion | A ChoiceAnswer naming one of the options' Criterion.Name (the enum member's name), with a confidence from 0 to 1. |
ScoreQuestion | A ScoreAnswer from 0 (the first level) to one less than the number of levels. |
YesNoQuestion | A YesNoAnswer with the probability of yes. |
Set Model to the model and version that answered, or leave it null for a decider that is not a
model. State is the object in Memory, typed as object: match on its type, and do not change it.
using Trax.Core.Decisions;
public sealed class TicketRules : IDecider
{
public Task<DecisionResult> Decide(DecisionRequest request, CancellationToken cancellationToken)
{
var ticket = (Ticket)request.State;
var refund = ticket.Message.Contains("refund", StringComparison.OrdinalIgnoreCase);
var answers = request.Questions.ToDictionary(
q => q.Key,
q => q switch
{
ChoiceQuestion => (Answer)new ChoiceAnswer(refund ? "Refund" : "SelfServe", 0.9),
YesNoQuestion => new YesNoAnswer(refund ? 0.8 : 0.1),
_ => throw new InvalidOperationException($"Cannot answer {q.Key}."),
});
return Task.FromResult(new DecisionResult(answers));
}
}An answer that does not fit, or a question left unanswered, fails the run (When the run fails instead); throwing fails it with your exception.
For runs that should replay, answer from the state alone. Replay checks the hash of the state and nothing else, so an answer that depended on something the decider looked up elsewhere is replayed even after that changed.
Register it
One registration serves every decision in every train, at any lifetime:
using Trax.Core.Decisions;
services.AddSingleton<IDecider, TicketRules>();To use a different decider for one step, register it and name it on that step with
Decide(q => q.DecidedBy<TDecider>()), or hand an instance to the chain with
AddServices.
Write policy as rules
When the decision is policy rather than judgement, write it as a RuleDecider. Each rule maps the
state to an enum member (Choice, Score) or a bool (YesNo), and its answers are always
certain:
using Trax.Core.Decisions;
services.AddSingleton<IDecider>(
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,
}
)
);Escalate to a second decider
CascadingDecider asks the first decider every question and the second only the ones the first
was unsure of. Here the first is a Nimble model registered under a name, and the second is
LargeModelDecider, an IDecider of your own over a chat model (Trax does not ship one):
using Trax.Core.Decisions;
using Trax.Effect.Decisions.SystemOne;
using Trax.Effect.Decisions.SystemOne.Extensions;
using Trax.Effect.Extensions;
services.AddTrax(trax =>
trax.AddEffects(effects =>
effects.AddNimbleDecider(
"nimble",
o => o.Endpoint = new Uri("http://localhost:8000/v1/systemone")
)
)
);
services.AddSingleton<LargeModelDecider>();
services.AddSingleton<IDecider>(sp => new CascadingDecider(
first: sp.GetRequiredKeyedService<SystemOneDecider>("nimble"),
then: sp.GetRequiredService<LargeModelDecider>(),
escalateBelow: 0.8
));| Parameter | Default | Meaning |
|---|---|---|
escalateBelow | 0.8 | A choice or score whose confidence is below it is escalated. |
unsureAbove, unsureBelow | 0.2, 0.8 | A yes/no whose probability lies strictly between the two is escalated. |
logger | null | An ILogger told when the first decider throws. |
Also escalated, whatever the bounds: 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). Only those questions are sent to the second decider, and its answer replaces the first.
If the first decider throws, every question goes to the second; cancellation passes straight
through. If the second fails, the run fails. Each bound must lie between 0 and 1, with
unsureAbove not above unsureBelow, or the constructor throws ArgumentException.
Refuse questions at startup
A decider that can tell from the declaration alone that it cannot answer a question implements
IVetsQuestions. When a chain is read, each decider a step names (the live one and each shadow)
is looked up as the run would find it, among the services handed to AddServices and then in the
container, and shown the step's questions as DeclaredQuestions (Train, Step, the state's
declared type as State, and Questions). Each problem it returns refuses the chain, so the
startup check
reports it before the host takes traffic:
using Trax.Core.Decisions;
public sealed class SmallModelDecider : IDecider, IVetsQuestions
{
private const int MaxOptions = 8;
public IEnumerable<string> Problems(DeclaredQuestions declared) =>
declared
.Questions.OfType<ChoiceQuestion>()
.Where(q => q.Options.Count > MaxOptions)
.Select(q => $"'{q.Key}' offers {q.Options.Count} options; this model takes {MaxOptions}.");
public Task<DecisionResult> Decide(DecisionRequest request, CancellationToken cancellationToken) =>
throw new NotImplementedException();
}No decider is asked to decide while the chain is read. A decider the container can build only
inside a request scope is not vetted, and refuses at run time instead. CascadingDecider vets
with each of its tiers.
SDK Reference
Decide | AddServices | AddNimbleDecider | AddSystemOneDecider