Many application workflows do not need another paragraph from a model. They need a bounded judgment that code can use: which queue should receive a ticket, how severe is the impact, or does this message request a refund?
A chat model can answer those questions, but its native output is generated text. Even structured output still asks a generative model to produce tokens that encode a decision. The application then validates the shape and decides what to do.
TypeSafe takes a narrower approach. Its Jev model accepts
state and typed questions, then returns choices, scores, and probabilities instead
of prose. InstructorPHP exposes that interface through Polyglot's Decision API.
The useful design is larger than one provider:
Let the model make the fuzzy judgment. Keep policy, thresholds, permissions, and side effects in PHP.
The production problem
Consider this support message:
I was charged twice for order A-104. Please refund the duplicate today.
A support system may need three separate judgments:
- Did the customer request a refund?
- Which department should handle the message?
- How frustrated does the customer appear?
A broad prompt such as “analyze and route this ticket” hides those decisions inside one response. It also hides the policy that connects an answer to an action.
The safer shape is explicit. Ask narrow questions, keep every possible answer visible, and compose the results in normal code.
Three primitives instead of generated prose
TypeSafe calls Jev a System One model: a model for focused, structured judgments rather than generated explanations. Its API has three question types.
| Primitive | Question shape | Result used by PHP |
|---|---|---|
Noul |
Is this condition true? | Probability from 0 to 1 |
Choice |
Which option fits best? | Choice, distribution, confidence |
Score |
Which ordered level fits? | Score, distribution, confidence |
The model evaluates independent questions against the same state. Each answer has its own PHP type. No answer is recovered from a sentence or an invented field.
The first runnable example under examples/B06_Decisions/DecisionPrimitives/
asks all three questions in one request:
use Cognesy\Polyglot\Decision\Collections\ChoiceOptions;
use Cognesy\Polyglot\Decision\Collections\Questions;
use Cognesy\Polyglot\Decision\Collections\ScoreLevels;
use Cognesy\Polyglot\Decision\Data\ChoiceOption;
use Cognesy\Polyglot\Decision\Decision;
use Cognesy\Polyglot\Decision\Questions\Choice;
use Cognesy\Polyglot\Decision\Questions\Noul;
use Cognesy\Polyglot\Decision\Questions\Score;
$questions = Questions::of(
new Noul(
id: 'refund_requested',
instructions: 'Does the customer explicitly request a refund?',
),
new Choice(
id: 'department',
options: ChoiceOptions::of(
new ChoiceOption('billing', 'Charges, invoices, and payment problems.'),
new ChoiceOption('technical', 'Bugs, errors, and integration problems.'),
new ChoiceOption('sales', 'Plans, pricing, and account questions.'),
),
instructions: 'Which department should handle this customer message?',
),
new Score(
id: 'frustration',
levels: ScoreLevels::of(
'Calm and neutral.',
'Concerned but civil.',
'Very angry or using strong language.',
),
instructions: 'How frustrated does the customer appear?',
),
);
$answers = Decision::using('typesafe')
->with(
input: 'I was charged twice for order A-104. '
.'Please refund the duplicate today.',
questions: $questions,
)
->get();
$refundProbability = $answers->noul('refund_requested')->probability();
$department = $answers->choice('department')->value();
$frustration = $answers->score('frustration')->value();
The accessors enforce the expected answer kind. Asking for choice('department')
returns the declared choice value and its distribution, not generated prose.
Keep the routing policy in PHP
A model should not decide whether its own confidence is sufficient for an irreversible action. The application knows the stakes and owns that threshold.
The dynamic-choice example builds the allowed routes from application data:
$availableRoutes = [
['id' => 'billing', 'description' => 'Charges, invoices, and refunds.'],
['id' => 'technical', 'description' => 'Product bugs and API errors.'],
['id' => 'account', 'description' => 'Login, profile, and permissions.'],
['id' => 'other', 'description' => 'None of the other routes fits.'],
];
$options = ChoiceOptions::of(...array_map(
static fn (array $route): ChoiceOption => new ChoiceOption(
id: $route['id'],
description: $route['description'],
),
$availableRoutes,
));
$route = Decision::using('typesafe')
->with(
input: 'CSV export returns a 500 error for every project.',
questions: Questions::of(new Choice(
id: 'route',
options: $options,
instructions: 'Which available support route best fits this message?',
)),
)
->get()
->choice('route');
$applicationAction = $route->confidence() >= 0.60
? $route->value()
: 'human-review';
The model can only select an ID supplied by the application. The other option
also gives it a valid destination when the known routes do not fit.
The 0.60 threshold is example policy. The application can choose a different
threshold for each action and change it without changing the question or provider.
Give the decision named evidence
A single string works for a simple ticket. A policy decision often needs several facts and the relationship between them.
The structured-state example sends a ticket, its order, two captured charges, and the refund policy as named JSON fields:
use Cognesy\Polyglot\Decision\Data\JsonContent;
$state = JsonContent::object([
'ticket' => [
'message' => 'I was charged twice. Please reverse the duplicate.',
'customer_tier' => 'business',
],
'order' => [
'id' => 'A-104',
'charges' => [
['amount_usd' => 49, 'status' => 'captured'],
['amount_usd' => 49, 'status' => 'captured'],
],
],
'refund_policy' => 'Duplicate captured charges are eligible for a refund.',
]);
The question can then name the evidence and define each option:
$policyAction = new Choice(
id: 'policy_action',
options: ChoiceOptions::of(
new ChoiceOption('refund', JsonContent::object([
'when' => 'The policy authorizes returning the duplicate charge.',
'excludes' => 'Cases where the charge is only pending.',
])),
new ChoiceOption('investigate', 'Evidence is incomplete or contradictory.'),
new ChoiceOption('deny', 'The policy does not cover the request.'),
),
instructions: JsonContent::object([
'task' => 'Select the action supported by `refund_policy`.',
'evidence' => ['ticket.message', 'order.charges', 'refund_policy'],
]),
);
This structure makes the contract reviewable. A developer can see the evidence paths, allowed actions, and the rule for insufficient evidence.
Keep exact checks in code. PHP should still verify order identity, charge status, amounts, permissions, and idempotency before issuing a refund. The model handles the semantic question that ordinary conditionals cannot answer cleanly.
Treat execution as an application concern
A decision request also has an operational lifecycle. Production code may need request IDs, bounded retries, usage data, and a stable result within one execution.
DecisionRuntime makes that lifecycle explicit:
use Cognesy\Polyglot\Decision\Config\DecisionConfig;
use Cognesy\Polyglot\Decision\Config\DecisionRetryPolicy;
use Cognesy\Polyglot\Decision\Data\DecisionRequest;
use Cognesy\Polyglot\Decision\DecisionRuntime;
$runtime = DecisionRuntime::fromConfig(
DecisionConfig::fromPreset('typesafe'),
);
$request = new DecisionRequest(
input: 'Checkout has returned 503 for every request since 09:10 UTC.',
questions: Questions::of(new Noul(
id: 'production_incident',
instructions: 'Does this describe an active production incident?',
)),
retryPolicy: new DecisionRetryPolicy(
maxAttempts: 3,
baseDelayMs: 250,
maxDelayMs: 2000,
),
);
$pending = $runtime->create($request); // no network request yet
$response = $pending->response(); // executes once
$same = $pending->response(); // returns the memoized response
assert($response === $same);
Retries cover transient transport and provider failures. The Decision execution session stays the single retry owner, so the HTTP layer does not run a second retry policy for the same operation.
InstructorPHP also emits decision and attempt lifecycle events. Default telemetry contains IDs, model and driver names, timing, retry decisions, status, and token counts. It omits the state, instructions, criteria, provider body, credentials, and exception messages.
Four examples to explore
The examples/B06_Decisions/ directory covers the Decision surface in four small
programs:
| Example | What it demonstrates |
|---|---|
DecisionPrimitives |
Ask Noul, Choice, and Score questions together. |
DynamicChoices |
Build the answer space from current application data. |
StructuredState |
Named facts, policy, criteria, and evidence paths. |
DecisionRuntime |
Configuration, retries, IDs, usage, and lifecycle. |
The InstructorPHP repository contains the complete examples and the Polyglot Decision implementation.
What PHP still owns
The Decision API gives the model a focused role inside a larger application. Each result feeds a responsibility that remains visible in code.
| Decision result | PHP responsibility |
|---|---|
NoulAnswer::probability() |
Apply the condition in the workflow. |
ChoiceAnswer::value() |
Map the ID to an application handler. |
| Choice or Score distribution | Choose automatic or reviewed execution. |
ScoreAnswer::value() |
Compare with domain levels or weights. |
| Pending execution | Record IDs and consume one memoized response. |
This split keeps semantic interpretation at the model boundary and keeps execution inside normal PHP services. The model selects from the decision space. The application decides what that selection means for the workflow.
A practical operating model
Use a decision model when the application needs a small semantic judgment and already knows the possible consequences.
- Ask one narrow question per independently useful judgment.
- Describe every option and add a no-match path when the set may be incomplete.
- Use structured state when the decision depends on named facts or policies.
- Keep arithmetic, exact lookups, authorization, and side effects in PHP.
- Choose confidence thresholds for each action instead of one global threshold.
- Store distributions with the decision record when later review needs them.
- Route uncertain, novel, or high-risk cases to a person or a reasoning model.
- Retry transport failures inside one runtime; do not treat retry as validation.
This is different from structured output validation. Structured output turns generated content into validated application data. A structured decision starts with a closed answer space and returns a judgment over that space. Both need application-owned rules after the model call.
The decision rule for adopting this pattern is simple. If the workflow needs prose, code, or an explanation, use a generative model. If it needs a bounded semantic branch, use a decision model and keep execution under explicit PHP control.