@gobing-ai/ts-laya-mlx 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/driver.ts ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The local Laya decision driver satisfying {@link DecisionDriver} from `@gobing-ai/ts-ai-runner`.
3
+ * Translates provider-neutral decision requests into the JSON-lines worker format
4
+ * and maps worker answers back onto neutral {@link Answer} types (task 0078).
5
+ */
6
+
7
+ import {
8
+ type Answer,
9
+ DecisionBackendError,
10
+ type DecisionDriver,
11
+ DecisionRequestError,
12
+ type DecisionState,
13
+ type Desc,
14
+ type Question,
15
+ } from '@gobing-ai/ts-ai-runner';
16
+ import { LayaWorkerClient, type LayaWorkerClientOptions, type WorkerQuestion } from './worker-client';
17
+
18
+ /** Configuration options for {@link createLayaDriver}. */
19
+ export interface LayaDriverOptions extends LayaWorkerClientOptions {
20
+ /** Pre-constructed worker client (useful for dependency injection in tests). */
21
+ client?: LayaWorkerClient;
22
+ }
23
+
24
+ /** Format a {@link Desc} field into a string instructions value for the worker. */
25
+ function formatDesc(desc: Desc | undefined): string {
26
+ if (typeof desc === 'string') return desc;
27
+ if (desc === null || desc === undefined) return '';
28
+ return JSON.stringify(desc);
29
+ }
30
+
31
+ /** Convert a neutral {@link Question} into the wire shape consumed by the worker. */
32
+ export function toWorkerQuestion(question: Question): WorkerQuestion {
33
+ switch (question.kind) {
34
+ case 'choice':
35
+ return {
36
+ type: 'choice',
37
+ instructions: formatDesc(question.prompt),
38
+ labels: question.labels,
39
+ criteria: question.labels,
40
+ };
41
+ case 'score':
42
+ return {
43
+ type: 'score',
44
+ instructions: formatDesc(question.prompt),
45
+ rubric: question.rubric,
46
+ // The runtime rejects a map here: score criteria is the rubric as a
47
+ // nonempty list of descriptions (laya_mlx agent.py `_to_internal`).
48
+ criteria: question.rubric.map(formatDesc),
49
+ };
50
+ case 'noul': {
51
+ const outcomes =
52
+ question.yes === undefined && question.no === undefined
53
+ ? undefined
54
+ : { true: question.yes ?? null, false: question.no ?? null };
55
+ return {
56
+ type: 'noul',
57
+ instructions: formatDesc(question.prompt),
58
+ ...(outcomes !== undefined ? { criteria: outcomes } : {}),
59
+ };
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Map a raw worker answer onto the neutral {@link Answer} union (R2, R3, R4, R5).
66
+ * Drops `action.act_probability` and `noul.confidence` deliberately per contract.
67
+ */
68
+ export function mapWorkerAnswer(question: Question, rawAnswer: unknown): Answer {
69
+ if (!rawAnswer || typeof rawAnswer !== 'object' || Array.isArray(rawAnswer)) {
70
+ throw new DecisionBackendError('laya worker returned invalid answer: expected object', undefined);
71
+ }
72
+ const raw = rawAnswer as Record<string, unknown>;
73
+ switch (question.kind) {
74
+ case 'choice': {
75
+ const label = raw.choice ?? raw.label;
76
+ if (typeof label !== 'string' || label.length === 0) {
77
+ throw new DecisionBackendError('laya worker returned invalid choice answer: missing label', undefined);
78
+ }
79
+ const confidence = typeof raw.confidence === 'number' ? raw.confidence : 0;
80
+ const probabilities = (raw.probabilities ?? {}) as Record<string, number>;
81
+ return {
82
+ kind: 'choice',
83
+ label,
84
+ confidence,
85
+ probabilities,
86
+ };
87
+ }
88
+ case 'score': {
89
+ const confidence = typeof raw.confidence === 'number' ? raw.confidence : 0;
90
+ const legend: Record<number, Desc> = Object.fromEntries(question.rubric.map((desc, idx) => [idx, desc]));
91
+ const rawLegend = raw.legend as Record<string | number, Desc> | undefined;
92
+ const normalizedLegend: Record<number, Desc> = {};
93
+ if (rawLegend) {
94
+ for (const [k, v] of Object.entries(rawLegend)) {
95
+ normalizedLegend[Number(k)] = v;
96
+ }
97
+ }
98
+ const rawProbs = (raw.probabilities ?? {}) as Record<string | number, number>;
99
+ const normalizedProbs: Record<number, number> = {};
100
+ for (const [k, v] of Object.entries(rawProbs)) {
101
+ normalizedProbs[Number(k)] = v;
102
+ }
103
+ // The runtime reports score as the probability-weighted expectation
104
+ // (e.g. 1.8451); the neutral contract is the categorical rubric index —
105
+ // argmax over the per-level probabilities (validation.json semantics).
106
+ const probEntries = Object.entries(normalizedProbs);
107
+ let score: number;
108
+ if (probEntries.length > 0) {
109
+ score = Number(probEntries.reduce((best, cur) => (cur[1] > best[1] ? cur : best))[0]);
110
+ } else if (typeof raw.score === 'number' && Number.isFinite(raw.score)) {
111
+ score = Math.round(raw.score);
112
+ } else {
113
+ throw new DecisionBackendError(
114
+ 'laya worker returned invalid score answer: no score or probabilities',
115
+ undefined,
116
+ );
117
+ }
118
+ return {
119
+ kind: 'score',
120
+ score,
121
+ confidence,
122
+ legend: rawLegend ? normalizedLegend : legend,
123
+ probabilities: normalizedProbs,
124
+ };
125
+ }
126
+ case 'noul': {
127
+ // R4: NoulAnswer carries a bare probability by contract; confidence is dropped.
128
+ // R5: action.act_probability is dropped.
129
+ const probability = typeof raw.noul === 'number' ? raw.noul : raw.probability;
130
+ if (typeof probability !== 'number' || !Number.isFinite(probability)) {
131
+ throw new DecisionBackendError(
132
+ 'laya worker returned invalid noul answer: missing probability',
133
+ undefined,
134
+ );
135
+ }
136
+ return {
137
+ kind: 'noul',
138
+ probability,
139
+ };
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Build the local Laya decision driver satisfying {@link DecisionDriver} (R1).
146
+ * Exposes a readonly `name: 'laya-local'` and a single `ask` method with no sugar.
147
+ */
148
+ export function createLayaDriver(options: LayaDriverOptions = {}): DecisionDriver {
149
+ const client = options.client ?? new LayaWorkerClient(options);
150
+
151
+ return {
152
+ name: 'laya-local',
153
+ async ask({
154
+ state,
155
+ questions,
156
+ }: {
157
+ state: DecisionState;
158
+ questions: Record<string, Question>;
159
+ }): Promise<Record<string, Answer>> {
160
+ if (!questions || typeof questions !== 'object' || Object.keys(questions).length === 0) {
161
+ throw new DecisionRequestError('expected a nonempty question map', undefined, undefined);
162
+ }
163
+ const stateStr =
164
+ typeof state === 'string' ? state : state === null || state === undefined ? '' : JSON.stringify(state);
165
+ const workerQuestions: Record<string, WorkerQuestion> = Object.fromEntries(
166
+ Object.entries(questions).map(([name, q]) => [name, toWorkerQuestion(q)]),
167
+ );
168
+ const result = await client.ask(stateStr, workerQuestions);
169
+ if (!result?.answers || typeof result.answers !== 'object' || Array.isArray(result.answers)) {
170
+ throw new DecisionBackendError('laya worker returned invalid result: expected answers map', undefined);
171
+ }
172
+ const answers: Record<string, Answer> = Object.fromEntries(
173
+ Object.entries(questions).map(([name, q]) => {
174
+ const raw = result.answers[name];
175
+ if (raw === undefined) {
176
+ throw new DecisionBackendError(`laya worker missing answer for question '${name}'`, undefined);
177
+ }
178
+ return [name, mapWorkerAnswer(q, raw)];
179
+ }),
180
+ );
181
+ return answers;
182
+ },
183
+ };
184
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @gobing-ai/ts-laya-mlx — Local Laya decision backend driving the vendored MLX runtime.
3
+ * Implements the {@link DecisionDriver} interface for `@gobing-ai/ts-ai-runner` over a JSON-lines process bridge.
4
+ */
5
+
6
+ export { createLayaDriver, type LayaDriverOptions } from './driver';
7
+ export {
8
+ LayaWorkerClient,
9
+ type LayaWorkerClientOptions,
10
+ type LayaWorkerResult,
11
+ translateWorkerError,
12
+ validateHostPrerequisites,
13
+ type WorkerQuestion,
14
+ } from './worker-client';