1/**
2 * Defines the provider-neutral service for structured decisions.
3 * `decide` encodes one input as JSON, sends its named decisions in one provider
4 * call, and validates the answers. Failures are reported as `AiError` values.
5 * @stability unstable
6 * @since 4.0.0
7 */
8import * as Context from "../Context.ts"
9import * as Effect from "../Effect.ts"
10import * as Predicate from "../Predicate.ts"
11import * as Schema from "../Schema.ts"
12import * as AiError from "./AiError.ts"
13import type * as Decision from "./Decision.ts"
14
15/**
16 * Service key for answering decisions about an input. Create implementations
17 * with `make`.
18 *
19 * @see {@link decide} for answering a definition through the current service
20 *
21 * @stability unstable
22 * @category services
23 * @since 4.0.0
24 */
25export const DecisionModel: Context.Service<DecisionModel, DecisionModel> = Context.Service(
26 "effect/ai/DecisionModel"
27)
28
29/**
30 * Brand type for `DecisionModel`.
31 *
32 * @stability unstable
33 * @category type IDs
34 * @since 4.0.0
35 */
36export type TypeId = "~effect/ai/DecisionModel"
37
38/**
39 * Brand for `DecisionModel` implementations.
40 *
41 * @stability unstable
42 * @category type IDs
43 * @since 4.0.0
44 */
45export const TypeId: TypeId = "~effect/ai/DecisionModel"
46
47/**
48 * Provider-reported token usage. Unreported counts are `undefined`.
49 *
50 * @stability unstable
51 * @category models
52 * @since 4.0.0
53 */
54export class DecisionUsage extends Schema.Class<DecisionUsage>(
55 "effect/ai/DecisionModel/DecisionUsage"
56)({
57 inputTokens: Schema.optional(Schema.Finite),
58 outputTokens: Schema.optional(Schema.Finite)
59}) {}
60
61/**
62 * Options for answering a decision definition.
63 *
64 * @stability unstable
65 * @category options
66 * @since 4.0.0
67 */
68export interface DecideOptions<Input extends Schema.Constraint> {
69 readonly input: Input["Type"]
70}
71
72/**
73 * Answers for every decision in a definition together with usage metadata.
74 *
75 * @see {@link DecisionUsage} for token usage metadata
76 *
77 * @stability unstable
78 * @category models
79 * @since 4.0.0
80 */
81export interface DecideResponse<Decisions extends Record<string, Decision.Any>> {
82 readonly answers: Decision.Answers<Decisions>
83 readonly usage: DecisionUsage
84}
85
86/**
87 * Provider input options for a decision request.
88 * `state` is encoded with `Schema.toCodecJson`, not stringified.
89 * All `decisions` must be answered in one call.
90 *
91 * @stability unstable
92 * @category options
93 * @since 4.0.0
94 */
95export interface ProviderOptions {
96 readonly state: Schema.Json
97 readonly decisions: Record<string, Decision.Any>
98}
99
100/**
101 * Provider answer for a classify decision.
102 *
103 * @stability unstable
104 * @category models
105 * @since 4.0.0
106 */
107export interface ProviderClassifyAnswer {
108 readonly _tag: "Classify"
109 readonly label: string
110 readonly probabilities: Readonly<Record<string, number>>
111 readonly confidence?: number | undefined
112}
113
114/**
115 * Provider answer for a rate decision.
116 * The core derives the label from the highest probability, choosing the
117 * first criteria entry on ties.
118 *
119 * @stability unstable
120 * @category models
121 * @since 4.0.0
122 */
123export interface ProviderRateAnswer {
124 readonly _tag: "Rate"
125 readonly rating: number
126 readonly probabilities: Readonly<Record<string, number>>
127 readonly confidence?: number | undefined
128}
129
130/**
131 * Provider answer for a probability decision.
132 *
133 * @stability unstable
134 * @category models
135 * @since 4.0.0
136 */
137export interface ProviderProbabilityAnswer {
138 readonly _tag: "Probability"
139 readonly probability: number
140}
141
142/**
143 * Provider answer for any decision kind.
144 *
145 * @stability unstable
146 * @category models
147 * @since 4.0.0
148 */
149export type ProviderAnswer = ProviderClassifyAnswer | ProviderRateAnswer | ProviderProbabilityAnswer
150
151/**
152 * Provider response for a decision request.
153 * `answers` is keyed like the requested decisions. Each answer is validated
154 * against its decision before it is returned to the caller.
155 *
156 * @stability unstable
157 * @category models
158 * @since 4.0.0
159 */
160export interface ProviderResponse {
161 readonly answers: Readonly<Record<string, ProviderAnswer>>
162 readonly usage: {
163 readonly inputTokens: number | undefined
164 readonly outputTokens: number | undefined
165 }
166}
167
168/**
169 * Decision operations over a definition.
170 *
171 * @stability unstable
172 * @category models
173 * @since 4.0.0
174 */
175export interface DecisionModel {
176 readonly [TypeId]: TypeId
177 readonly decide: <Input extends Schema.Constraint, Decisions extends Record<string, Decision.Any>>(
178 definition: Decision.Definition<Input, Decisions>,
179 options: DecideOptions<Input>
180 ) => Effect.Effect<DecideResponse<Decisions>, AiError.AiError, Input["EncodingServices"]>
181}
182
183const invalidOutput = (description: string): AiError.AiError =>
184 AiError.make({
185 module: "DecisionModel",
186 method: "decide",
187 reason: new AiError.InvalidOutputError({ description })
188 })
189
190const isFiniteNumber = (value: unknown): value is number => Predicate.isNumber(value) && Number.isFinite(value)
191
192const isUnitInterval = (value: unknown): value is number => isFiniteNumber(value) && value >= 0 && value <= 1
193
194const sumTolerance = 1e-6
195
196/**
197 * `roundingError` is the largest error a provider's rounding adds to a single
198 * probability (half a unit of its last decimal place). Distributions that sum
199 * to 1 within `labels.length * roundingError` are rescaled to sum to 1.
200 */
201const validateDistribution = (
202 key: string,
203 labels: ReadonlyArray<string>,
204 answer: Record<string, unknown>,
205 roundingError: number
206): Record<string, number> | AiError.AiError => {
207 const raw = answer.probabilities
208 if (!Predicate.isObject(raw)) {
209 return invalidOutput(`Provider returned no probabilities for decision "${key}"`)
210 }
211 const probabilities: Record<string, number> = Object.create(null)
212 let total = 0
213 for (const label of labels) {
214 const value = raw[label]
215 if (!isUnitInterval(value)) {
216 return invalidOutput(`Provider returned no probability for label "${label}" of decision "${key}"`)
217 }
218 probabilities[label] = value
219 total += value
220 }
221 const drift = Math.abs(total - 1)
222 if (total === 0 || drift > labels.length * roundingError + sumTolerance) {
223 return invalidOutput(`Provider returned probabilities that do not sum to 1 for decision "${key}"`)
224 }
225 if (drift > sumTolerance) {
226 for (const label of labels) probabilities[label] /= total
227 }
228 return probabilities
229}
230
231const validateConfidence = (
232 key: string,
233 answer: Record<string, unknown>
234): number | undefined | AiError.AiError => {
235 if (answer.confidence === undefined) {
236 return undefined
237 }
238 return isUnitInterval(answer.confidence)
239 ? answer.confidence
240 : invalidOutput(`Provider returned confidence outside [0, 1] for decision "${key}"`)
241}
242
243const validateAnswer = (
244 key: string,
245 decision: Decision.Any,
246 answer: unknown,
247 roundingError: number
248): Decision.Answer<Decision.Any> | AiError.AiError => {
249 if (!Predicate.isObject(answer)) {
250 return invalidOutput(`Provider returned no answer for decision "${key}"`)
251 }
252 if (answer._tag !== decision._tag) {
253 return invalidOutput(
254 `Provider returned a "${String(answer._tag)}" answer for decision "${key}", expected "${decision._tag}"`
255 )
256 }
257 switch (decision._tag) {
258 case "Classify": {
259 const labels = Object.keys(decision.criteria)
260 if (!Predicate.isString(answer.label) || !labels.includes(answer.label)) {
261 return invalidOutput(`Provider returned an unknown label for decision "${key}"`)
262 }
263 const confidence = validateConfidence(key, answer)
264 if (AiError.isAiError(confidence)) {
265 return confidence
266 }
267 const probabilities = validateDistribution(key, labels, answer, roundingError)
268 if (AiError.isAiError(probabilities)) {
269 return probabilities
270 }
271 return { label: answer.label, probabilities, ...(confidence === undefined ? undefined : { confidence }) }
272 }
273 case "Rate": {
274 const levels = decision.criteria
275 if (!isFiniteNumber(answer.rating) || answer.rating < 0 || answer.rating > levels.length - 1) {
276 return invalidOutput(`Provider returned a rating outside [0, ${levels.length - 1}] for decision "${key}"`)
277 }
278 const confidence = validateConfidence(key, answer)
279 if (AiError.isAiError(confidence)) {
280 return confidence
281 }
282 const probabilities = validateDistribution(key, levels, answer, roundingError)
283 if (AiError.isAiError(probabilities)) {
284 return probabilities
285 }
286 let label = levels[0]
287 for (let i = 1; i < levels.length; i++) {
288 if (probabilities[levels[i]] > probabilities[label]) {
289 label = levels[i]
290 }
291 }
292 return { rating: answer.rating, label, probabilities, ...(confidence === undefined ? undefined : { confidence }) }
293 }
294 case "Probability": {
295 if (!isUnitInterval(answer.probability)) {
296 return invalidOutput(`Provider returned a probability outside [0, 1] for decision "${key}"`)
297 }
298 return { probability: answer.probability }
299 }
300 }
301}
302
303const validateAnswers = <Decisions extends Record<string, Decision.Any>>(
304 decisions: Decisions,
305 answers: Readonly<Record<string, ProviderAnswer>>,
306 roundingError: number
307): Effect.Effect<Decision.Answers<Decisions>, AiError.AiError> => {
308 const validated: Record<string, Decision.Answer<Decision.Any>> = Object.create(null)
309 for (const key of Object.keys(decisions)) {
310 const answer = validateAnswer(key, decisions[key], answers[key], roundingError)
311 if (AiError.isAiError(answer)) {
312 return Effect.fail(answer)
313 }
314 validated[key] = answer
315 }
316 return Effect.succeed(validated as Decision.Answers<Decisions>)
317}
318
319/**
320 * Creates a `DecisionModel` that encodes inputs as JSON and validates provider answers.
321 * Answers must cover every decision and use its labels. Distributions must
322 * sum to 1 within `1e-6`, optional confidence must be in `[0, 1]`, and ratings must be
323 * in `[0, criteria.length - 1]`. Invalid answers fail with
324 * `AiError.InvalidOutputError`; encoding failures use `AiError.InvalidUserInputError`.
325 *
326 * **Details**
327 *
328 * Providers that round each probability to `probabilityPrecision` decimal
329 * places may return distributions whose sum drifts from 1 by up to half a unit
330 * of the last place per label. Setting `probabilityPrecision` accepts that
331 * drift and rescales the distribution to sum to 1; larger drift still fails.
332 *
333 * @see {@link DecisionModel} for the service shape returned by this constructor
334 * @see {@link ProviderOptions} for the input passed to the provider implementation
335 * @see {@link ProviderResponse} for the provider response contract consumed by this constructor
336 *
337 * @stability unstable
338 * @category constructors
339 * @since 4.0.0
340 */
341export const make = (params: {
342 readonly decide: (options: ProviderOptions) => Effect.Effect<ProviderResponse, AiError.AiError>
343 readonly probabilityPrecision?: number | undefined
344}): Effect.Effect<DecisionModel> =>
345 Effect.sync(() => {
346 const roundingError = params.probabilityPrecision === undefined ? 0 : 0.5 * 10 ** -params.probabilityPrecision
347 return DecisionModel.of({
348 [TypeId]: TypeId,
349 decide: <Input extends Schema.Constraint, Decisions extends Record<string, Decision.Any>>(
350 definition: Decision.Definition<Input, Decisions>,
351 options: DecideOptions<Input>
352 ): Effect.Effect<DecideResponse<Decisions>, AiError.AiError, Input["EncodingServices"]> =>
353 Schema.encodeEffect(Schema.toCodecJson(definition.input))(options.input).pipe(
354 Effect.mapError((error) =>
355 AiError.make({
356 module: "DecisionModel",
357 method: "decide",
358 reason: new AiError.InvalidUserInputError({ description: error.message })
359 })
360 ),
361 Effect.flatMap((state) => params.decide({ state, decisions: definition.decisions })),
362 Effect.flatMap((response) =>
363 Effect.map(
364 validateAnswers(definition.decisions, response.answers, roundingError),
365 (answers): DecideResponse<Decisions> => ({
366 answers,
367 usage: new DecisionUsage({
368 inputTokens: response.usage.inputTokens,
369 outputTokens: response.usage.outputTokens
370 })
371 })
372 )
373 ),
374 (effect) => Effect.withSpan(effect, "DecisionModel.decide", { captureStackTrace: false })
375 )
376 })
377 })
378
379/**
380 * Answers a decision definition using the current `DecisionModel` service.
381 * Encodes the input with `Schema.toCodecJson`, requiring the schema's encoding services.
382 * Explicit `undefined` fields become `null`; absent fields stay absent.
383 * Custom declarations need a JSON codec annotation or encoding fails.
384 * Returned answers and probability dictionaries have null prototypes.
385 *
386 * **Example** (Triaging a ticket)
387 *
388 * ```ts
389 * import { Effect, Schema } from "effect"
390 * import { Decision, DecisionModel } from "effect/ai"
391 *
392 * const TicketTriage = Decision.make({
393 * input: Schema.String,
394 * decisions: {
395 * urgent: Decision.probability({
396 * instructions: "The message is time-sensitive",
397 * criteria: { false: "No time pressure", true: "Needs action now" }
398 * })
399 * }
400 * })
401 *
402 * const program = Effect.gen(function*() {
403 * const { answers, usage } = yield* DecisionModel.decide(TicketTriage, {
404 * input: "My card was charged twice, please fix this today"
405 * })
406 * return { probability: answers.urgent.probability, usage }
407 * })
408 * ```
409 *
410 * @see {@link DecisionModel} for the service this function requires
411 *
412 * @stability unstable
413 * @category decisions
414 * @since 4.0.0
415 */
416export const decide = <Input extends Schema.Constraint, Decisions extends Record<string, Decision.Any>>(
417 definition: Decision.Definition<Input, Decisions>,
418 options: DecideOptions<Input>
419): Effect.Effect<DecideResponse<Decisions>, AiError.AiError, DecisionModel | Input["EncodingServices"]> =>
420 DecisionModel.use((model) => model.decide(definition, options))