The request
The canonical decision block, sent toPOST /decide beside state (and questions, which become optional):
Beside the block, a request may carry:
Unknown actions in a rule, a time that is not
HH:MM, an unknown zone, a chance outside 0 to 1 or a missing
chance are answered 422 with a sentence that names the field.
How the decision step works
The code reads the same:
lab/rl/decision_step.py has one function per stage (chance, baseline, score,
rules, rank, review, explore, explain, record); recommend runs all nine.
Scores. expected_value: p x value_sar - cost_sar. uplift: (p - p_0) x value_sar - cost_sar, and 0 for the
baseline itself. p_success: p. Every candidate is ranked, the forbidden ones too (in their score’s place, with
allowed: false); the best allowed action is the first allowed row, and equal scores keep the order the
candidates were given in.
The recommendation
The block above, at 22:30 Riyadh time, with chances 34%, 29%, 21% and 15%:The rules
Quiet hours are checked against the decision time in the rule’s zone (Asia/Riyadh unless tz names
another). A window whose start is after its end crosses midnight: 22:00 to 09:00 holds from 22:00 up to, not
including, 09:00. The start is inside, the end is outside; equal start and end is no window.
Max count is counted from the journey’s own engine action events within window_days before the decision
time (an event at or after it never counts). An engine action event is either
{"source": "engine", "type": "action", "action": "call_again", "time": "..."} or any event or plain line whose
text says Engine chose call_again, which is what the step itself writes. An engine event with no time counts: a
cap limits contact, and an event of unknown time cannot be shown to be outside the window. Contacts the engine
did not choose (a customer’s call, a rep’s own message) are not engine actions; to cap every contact, count them
yourself and rule the actions out with exclude.
Exclude rules actions out by name. When several rules forbid one action, excluded_by names the first of
exclude, quiet hours, max count.
The baseline: what happens if we do nothing
A lead may convert anyway. An action is worth taking only for what it adds over doing nothing, so the step keeps the counterfactual: the baseline candidate,no_action by default when it is among the candidates, or the one
decision.baseline names. It is a candidate like any other (value and cost usually 0), and its chance p_0 comes
the same way: the model’s success_if_no_action, or the request’s chances.
With a baseline, every ranked row carries uplift = p_a - p_0 and uplift_value_sar = uplift x value_sar, and
the recommendation names the baseline. The uplift objective scores by it:
uplift the baseline’s own score is 0, so an action whose uplift does not pay for its cost loses to doing
nothing. Under the other objectives the why still says the uplift, and the step asks for a review when the best
action’s uplift is not above 0: its chance is no better than doing nothing, and the honest pick is the baseline.
objective: "uplift" without a baseline is a 422.
Exploration and the propensity
With exploration e and n allowed actions, the step takes the best action, except with probability e it draws one of the n allowed actions uniformly (it may land on the best). The propensity of the action taken is (1 - e) + e/n for the best and e/n for any other, so the allowed actions’ propensities sum to 1; with e = 0, or one allowed action, it is 1. A forbidden action is never drawn. Exploration is what keeps every action’s effect learnable: history only shows the outcome of the action actually taken, and an action never tried has no outcomes. The logged propensity is what makes those logs usable: a training example is weighted by 1/propensity, and a new policy can be evaluated offline on the old logs. The draw uses the operating system’s randomness;exploration is 0 unless you set it.
Review
review is true, with a reason each, when:
- no action is allowed;
- the top two allowed scores are within
marginof the best (relative: 0.02 is 2%); - the best action’s chance is below
min_p; - there is a baseline and the best action’s uplift over it is not above 0;
- the best action’s chance is the served model’s answer for a candidate it was never trained on
(
chance_source“model”,trainedfalse), or the baseline’s is. Such a chance is a guess, not a weak estimate: asked live with one untrained candidate beside four trained ones, the untrained one got between 60% and 92% on average, a harmful message included, and won the pick on most leads. Give the candidate’s chance yourself inchances, or expect the review.
Writing it back: the ledger
The decision step writes nothing anywhere: the caller appendsledger_event to the lead’s journey, as history.
Fin reads only its text, its time, source and type; the propensity and the decision id are for training and
evaluation, so the policy stays out of the model’s inputs.
One line, or the complete decision. The event’s text is one line by default, “Engine chose send_offer
(considered: …)”: the name of what was done and what else was allowed. When a rule shut a move, the line says so
after it, with the rule’s own name: ; not allowed: call_again (quiet_hours). That is how Fin’s training
journeys write a decision, and Fin reads the difference: asked live on 400 decisions of its validation set, with
the earlier decisions of each journey written without what a rule shut, its picks were worth SAR 291 less a
decision (95% interval 39 to 612); with it, nothing that can be told from zero. The event also carries the whole
decision as data, in record:
Send
"ledger": "complete" in the decision block and the text says all of it after the line, so the next request’s
journey holds the earlier decision whole and not only its name:
Read: interest_now warm (62%), live_objection price; a reading the model was sure of (85% or more) is written without its percentage. Both forms
start with the same words, so max_count counts either. record is the same whichever form the text has, so a
caller can write the sentence later with its own words.
Then write what you did. The ledger event says what was decided. When you carry the decision out, append one
more event for what was done, a minute or so later: the offer as it went out, the follow-up call as placed, the
hand-over as a task for a rep. Fin’s training journeys always have that row after a decision that does something,
and none after a decision to do nothing:
Most systems log these by themselves: the message that was sent, the call the dialer placed, the task in the CRM.
What matters is that they reach the journey you send. Without them Fin counts fewer contacts than there were and
acts where it should wait: on the same 400 decisions, journeys with no such row and no shut moves in the line cost
its picks SAR 550 a decision (95% interval 212 to 923), one pick in ten changed. With both written as above the
picks are worth what they are on its own validation journeys (a difference of SAR 27, interval -381 to +308).
An endpoint deployed before this returns the one line and no
record; redeploy it to get them. The engine
hands the decision_id to whatever carries the action out (a call, a message, a CRM task), which echoes it on
the events it causes. The outcome event that later carries the same decision_id closes the loop: the history up
to the decision, the action, and whether it succeeded become one training example for that action’s
success_if_ field, weighted by 1/propensity. An outcome that never arrives within the window is unknown
(censored), not a failure.
Where the chances come from
For every candidate the request gave no chance for, the served model is asked one binary question with the idsuccess_if_<key>:
description as what you would do (“send a written price offer”), since it is read in that sentence.
A chance is "trained": true only when the served model was trained on that field id (for a Fin release, the
fields of its fin_config.json); a Laya or Kev model is trained on no success_if_ field. These answers are in
answers too. When every chance comes with the request, the model is asked nothing and latency_ms is 0.
A release with a causal head answers by the move’s name. The Fin release served now has one output per move it
was trained on (send_offer, call_again, hand_to_rep, no_action), and the chance of a candidate with one of
those keys comes from that output: the question’s words and the description never reach the network. Asked live
with the description reworded, with its own trained question sent in questions, and with no other question at
all, the four chances were the same to four decimals. So what matters for such a release is the key. A candidate
under another key (the lab’s own wait_3_days where the model knows no_action) has no output of its own: it is
answered from the question’s words, comes back "trained": false, and is flagged for review.
GET /health on the endpoint says which keys and fields those are, by name: actions (the moves the release has
an output for) and trained_fields (every field id of its schemas). Build the candidates and the questions from
them and not from a list of your own, which goes stale the day another release is served.
Call it
On the endpoint itself (/decide/batch takes states and returns a recommendation per state, each with its own
journey and decision time):
POST /api/rl/recommend takes state, decision, and optionally chances, questions, model, journey and
decision_time. It answers 409 when no Decision API is deployed, and 502 with a sentence when the deployed
endpoint predates the decision step (redeploy it from Lab → Decide, or levers9 decide serve). A request
without a decision block is answered exactly as before.
In the dashboard
The causality labs on Lab → Decide use it for the Next-best action: the lab sends the simulated world’s actions with their value and cost, its do-nothing action as the baseline, its quiet hours, and the chances its estimator learned from simulated logs, and the Decision API’s decision step makes the pick. The card says Decided by the Decision API’s decision step, shows the propensity, the review reasons and the server’s why, and gives each action its uplift over doing nothing. The lab’s contact cap counts every contact in the simulated ledger, so the lab applies it itself and sends the result asexclude. When the API cannot be reached
(none deployed, stopped, or deployed before the decision step), the browser’s own step decides and the card
says in-browser step. The lab’s Decide step, with its sliders, always decides in the browser.
