@gobing-ai/ts-laya-mlx 0.5.21 → 0.5.23

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/README.md CHANGED
@@ -21,7 +21,7 @@ Part of the `@gobing-ai/ts-libs` monorepo and lockstep-versioned with it.
21
21
  > **Notice:** This is the only package in the workspace with specific host architecture constraints.
22
22
 
23
23
  1. **Platform:** macOS on Apple Silicon (`darwin arm64`). Unsupported platforms fail fast at construction time before any process is spawned.
24
- 2. **Python:** Python 3.10+ carrying the `laya-mlx` runtime (resolvable via `pythonPath`, injected `env.LAYA_PYTHON`, or `python3` on `PATH`).
24
+ 2. **Python (core `mlx-python` only):** Python 3.10+ carrying the `laya-mlx` runtime (resolvable via `pythonPath`, injected `env.LAYA_PYTHON`, or `python3` on `PATH`). Core `mlx-native` needs no Python.
25
25
  3. **Supported runtime range:** `laya-mlx 0.1.x` (validated against upstream `0.1.0` @ `fc1df62828a3fedf4d8229fdac1cbd85f1cdf337`).
26
26
  4. **Install command:**
27
27
  ```sh
@@ -32,6 +32,40 @@ Part of the `@gobing-ai/ts-libs` monorepo and lockstep-versioned with it.
32
32
 
33
33
  ---
34
34
 
35
+ ## Execution core and model selection
36
+
37
+ Both cores run Apple MLX on Metal and return identical neutral answers; pick per host.
38
+
39
+ | Option | Env (injected) | Values | Default |
40
+ | --- | --- | --- | --- |
41
+ | `core` | `LAYA_CORE` | `mlx-python` · `mlx-native` · `auto` | `mlx-python` |
42
+ | `modelPath` | `LAYA_MODEL_PATH` | local checkpoint dir (fine-tuned) — wins over `modelId` | — |
43
+ | `modelId` | `LAYA_MODEL_ID` | Hugging Face repo id | `convaiinnovations/laya-multilingual` |
44
+
45
+ - **`mlx-python`** — warm `laya-mlx` Python child process over JSON lines. Lowest latency and memory.
46
+ - **`mlx-native`** — in-process [`@johnhenry/laya`](https://github.com/johnhenry/laya-js) via libmlxc FFI;
47
+ no Python, no child process. Does not support `cacheDir`/`LAYA_CACHE_DIR` (set `HF_HUB_CACHE` for the
48
+ process instead) or `dtype: 'bfloat16'`; both are rejected as `DecisionConfigError`.
49
+ - **`auto`** — loads the native core on first ask; any native load failure falls back to `mlx-python`.
50
+
51
+ Measured on an Apple Silicon Mac (official checkpoint, f16, 3-question batch, 20 warm asks):
52
+
53
+ | Core | Cold (load + first ask) | Warm p50 | Resident memory |
54
+ | --- | --- | --- | --- |
55
+ | `mlx-python` | ~0.8–1.0 s | ~10 ms | ~1.15 GB (78 MB host + ~1.07 GB worker) |
56
+ | `mlx-native` | ~0.8–1.0 s | ~22 ms | ~1.78 GB (all in host process) |
57
+
58
+ ```ts
59
+ createLayaDriver({ core: 'mlx-native', modelPath: '/models/laya-ft-kk' });
60
+ ```
61
+
62
+ The returned driver implements `dispose()`, which closes the client it was built with — the
63
+ `mlx-python` worker is held warm for the client's lifetime, and its open pipes otherwise keep a
64
+ one-shot parent process alive after the last answer. Call it through `maker.dispose()` (from
65
+ `createDecisionMaker`) or `registry.dispose('laya-local')`.
66
+
67
+ ---
68
+
35
69
  ## Backend selection (`ts-ai-runner`)
36
70
 
37
71
  Downstream applications using `createDecisionMaker` from `@gobing-ai/ts-ai-runner`
@@ -70,6 +104,7 @@ Resolution order: `options.driver` (custom driver double) → `options.backend`
70
104
  | Dimension | Hosted TypeSafe (`ts-ai-runner`) | Local Laya (`ts-laya-mlx`) | Notes |
71
105
  |-----------|----------------------------------|---------------------------|-------|
72
106
  | **Factory** | `createTypesafeDriver(config)` | `createLayaDriver(options)` | Both return `DecisionDriver` with single `ask()` method. |
107
+ | **Release** | none (stateless HTTP) | `dispose()` closes the warm worker | A `laya-local` maker must be disposed or a one-shot parent never exits. |
73
108
  | **Driver name** | `'typesafe'` | `'laya-local'` | Available on `driver.name`. |
74
109
  | **Credentials** | `apiKey: string` (`TYPESAFE_API_KEY`) | `env.HF_TOKEN` (optional) | Local inference needs no key; token only for gated HF repos. |
75
110
  | **Model selector** | `model?: string` | `modelId?: string` | Default: `'convaiinnovations/laya-multilingual'`. |
package/dist/driver.d.ts CHANGED
@@ -1,15 +1,38 @@
1
1
  /**
2
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).
3
+ * Translates provider-neutral decision requests into the laya-mlx question format
4
+ * and maps answers back onto neutral {@link Answer} types (task 0078). Two MLX cores
5
+ * serve the same wire shape: the Python worker (`mlx-python`) and the in-process
6
+ * `@johnhenry/laya` port (`mlx-native`).
5
7
  */
6
8
  import { type Answer, type DecisionDriver, type Question } from '@gobing-ai/ts-ai-runner';
7
- import { LayaWorkerClient, type LayaWorkerClientOptions, type WorkerQuestion } from './worker-client';
9
+ import { type LayaNativeLoader } from './native-client';
10
+ import { type LayaWorkerClientOptions, type LayaWorkerResult, type WorkerQuestion } from './worker-client';
11
+ /**
12
+ * Execution core behind `laya-local`. Both run Apple MLX on Metal:
13
+ * - `mlx-python`: warm `laya-mlx` Python worker over JSON lines (default; needs a Python env).
14
+ * - `mlx-native`: in-process `@johnhenry/laya` via libmlxc FFI (no Python).
15
+ * - `auto`: try `mlx-native`, fall back to `mlx-python` when it cannot load.
16
+ */
17
+ export type LayaCore = 'mlx-python' | 'mlx-native' | 'auto';
18
+ /** The `ask` surface both cores implement. */
19
+ export interface LayaClient {
20
+ ask(state: string, questions: Record<string, WorkerQuestion>): Promise<LayaWorkerResult>;
21
+ dispose(): void;
22
+ }
8
23
  /** Configuration options for {@link createLayaDriver}. */
9
24
  export interface LayaDriverOptions extends LayaWorkerClientOptions {
10
- /** Pre-constructed worker client (useful for dependency injection in tests). */
11
- client?: LayaWorkerClient;
25
+ /** Execution core. Default: `env.LAYA_CORE`, else `'mlx-python'`. */
26
+ core?: LayaCore;
27
+ /** Pre-constructed client (dependency injection in tests); bypasses core selection. */
28
+ client?: LayaClient;
29
+ /** Injected native loader (tests); forwarded to {@link LayaNativeClient}. */
30
+ nativeLoader?: LayaNativeLoader;
12
31
  }
32
+ /** Resolve the configured core: explicit option → injected `env.LAYA_CORE` → `'mlx-python'`. */
33
+ export declare function resolveLayaCore(options?: Pick<LayaDriverOptions, 'core' | 'env'>): LayaCore;
34
+ /** Construct the client for the resolved core. */
35
+ export declare function createLayaClient(options?: LayaDriverOptions): LayaClient;
13
36
  /** Convert a neutral {@link Question} into the wire shape consumed by the worker. */
14
37
  export declare function toWorkerQuestion(question: Question): WorkerQuestion;
15
38
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../src/driver.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACH,KAAK,MAAM,EAEX,KAAK,cAAc,EAInB,KAAK,QAAQ,EAChB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,gBAAgB,EAAE,KAAK,uBAAuB,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtG,0DAA0D;AAC1D,MAAM,WAAW,iBAAkB,SAAQ,uBAAuB;IAC9D,gFAAgF;IAChF,MAAM,CAAC,EAAE,gBAAgB,CAAC;CAC7B;AASD,qFAAqF;AACrF,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,GAAG,cAAc,CA8BnE;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,CA0E9E;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,iBAAsB,GAAG,cAAc,CAoChF"}
1
+ {"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../src/driver.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EACH,KAAK,MAAM,EAGX,KAAK,cAAc,EAInB,KAAK,QAAQ,EAChB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAoB,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAC1E,OAAO,EAEH,KAAK,uBAAuB,EAC5B,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACtB,MAAM,iBAAiB,CAAC;AAEzB;;;;;GAKG;AACH,MAAM,MAAM,QAAQ,GAAG,YAAY,GAAG,YAAY,GAAG,MAAM,CAAC;AAI5D,8CAA8C;AAC9C,MAAM,WAAW,UAAU;IACvB,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACzF,OAAO,IAAI,IAAI,CAAC;CACnB;AAED,0DAA0D;AAC1D,MAAM,WAAW,iBAAkB,SAAQ,uBAAuB;IAC9D,qEAAqE;IACrE,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,uFAAuF;IACvF,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,gBAAgB,CAAC;CACnC;AAED,gGAAgG;AAChG,wBAAgB,eAAe,CAAC,OAAO,GAAE,IAAI,CAAC,iBAAiB,EAAE,MAAM,GAAG,KAAK,CAAM,GAAG,QAAQ,CAS/F;AA8CD,kDAAkD;AAClD,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,iBAAsB,GAAG,UAAU,CAS5E;AAuBD,qFAAqF;AACrF,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,GAAG,cAAc,CA8BnE;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,CA2E9E;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,iBAAsB,GAAG,cAAc,CAwChF"}
package/dist/driver.js CHANGED
@@ -1,36 +1,118 @@
1
1
  /**
2
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).
3
+ * Translates provider-neutral decision requests into the laya-mlx question format
4
+ * and maps answers back onto neutral {@link Answer} types (task 0078). Two MLX cores
5
+ * serve the same wire shape: the Python worker (`mlx-python`) and the in-process
6
+ * `@johnhenry/laya` port (`mlx-native`).
5
7
  */
6
- import { DecisionBackendError, DecisionRequestError, } from '@gobing-ai/ts-ai-runner';
7
- import { LayaWorkerClient } from './worker-client.js';
8
+ import { DecisionBackendError, DecisionConfigError, DecisionRequestError, } from '@gobing-ai/ts-ai-runner';
9
+ import { LayaNativeClient } from './native-client.js';
10
+ import { LayaWorkerClient, } from './worker-client.js';
11
+ const LAYA_CORES = ['mlx-python', 'mlx-native', 'auto'];
12
+ /** Resolve the configured core: explicit option → injected `env.LAYA_CORE` → `'mlx-python'`. */
13
+ export function resolveLayaCore(options = {}) {
14
+ const raw = options.core ?? (options.env?.LAYA_CORE || undefined) ?? 'mlx-python';
15
+ if (!LAYA_CORES.includes(raw)) {
16
+ throw new DecisionConfigError(`Unknown laya core '${raw}'; expected one of ${LAYA_CORES.join(', ')}`, 'LAYA_CORE');
17
+ }
18
+ return raw;
19
+ }
20
+ /**
21
+ * `auto` core: the first ask probes `mlx-native` by loading the weights; any load
22
+ * failure (missing libmlxc, unsupported option, bad checkpoint) falls back to the
23
+ * Python worker for the client's lifetime. Request-time errors are not retried.
24
+ */
25
+ class AutoLayaClient {
26
+ options;
27
+ active = null;
28
+ resolved = null;
29
+ disposed = false;
30
+ constructor(options) {
31
+ this.options = options;
32
+ }
33
+ async ask(state, questions) {
34
+ this.active ??= this.select();
35
+ return (await this.active).ask(state, questions);
36
+ }
37
+ dispose() {
38
+ this.disposed = true;
39
+ this.resolved?.dispose();
40
+ }
41
+ async select() {
42
+ let chosen;
43
+ try {
44
+ const native = new LayaNativeClient({ ...this.options, loader: this.options.nativeLoader });
45
+ try {
46
+ await native.warm();
47
+ }
48
+ catch (error) {
49
+ native.dispose();
50
+ throw error;
51
+ }
52
+ chosen = native;
53
+ }
54
+ catch (error) {
55
+ if (!(error instanceof DecisionConfigError))
56
+ throw error;
57
+ chosen = new LayaWorkerClient(this.options);
58
+ }
59
+ this.resolved = chosen;
60
+ // dispose() raced the native probe: the chosen client must not outlive us.
61
+ if (this.disposed)
62
+ chosen.dispose();
63
+ return chosen;
64
+ }
65
+ }
66
+ /** Construct the client for the resolved core. */
67
+ export function createLayaClient(options = {}) {
68
+ switch (resolveLayaCore(options)) {
69
+ case 'mlx-native':
70
+ return new LayaNativeClient({ ...options, loader: options.nativeLoader });
71
+ case 'auto':
72
+ return new AutoLayaClient(options);
73
+ default:
74
+ return new LayaWorkerClient(options);
75
+ }
76
+ }
8
77
  /** Format a {@link Desc} field into a string instructions value for the worker. */
9
- function formatDesc(desc) {
78
+ function formatDesc(desc, fallback = '') {
10
79
  if (typeof desc === 'string')
11
- return desc;
80
+ return desc.trim().length > 0 ? desc : fallback;
12
81
  if (desc === null || desc === undefined)
13
- return '';
82
+ return fallback;
14
83
  return JSON.stringify(desc);
15
84
  }
85
+ /** Rescale a probability distribution to sum to exactly 1.0 when rounding error exceeds 1e-6. */
86
+ function normalizeDistribution(probs) {
87
+ const entries = Object.entries(probs);
88
+ const sum = entries.reduce((s, [, p]) => s + (typeof p === 'number' && Number.isFinite(p) ? p : 0), 0);
89
+ if (sum <= 0 || Math.abs(sum - 1) <= 1e-6) {
90
+ return probs;
91
+ }
92
+ const result = {};
93
+ for (const [k, p] of entries) {
94
+ result[k] = p / sum;
95
+ }
96
+ return result;
97
+ }
16
98
  /** Convert a neutral {@link Question} into the wire shape consumed by the worker. */
17
99
  export function toWorkerQuestion(question) {
18
100
  switch (question.kind) {
19
101
  case 'choice':
20
102
  return {
21
103
  type: 'choice',
22
- instructions: formatDesc(question.prompt),
104
+ instructions: formatDesc(question.prompt, 'Select the best matching option.'),
23
105
  labels: question.labels,
24
106
  criteria: question.labels,
25
107
  };
26
108
  case 'score':
27
109
  return {
28
110
  type: 'score',
29
- instructions: formatDesc(question.prompt),
111
+ instructions: formatDesc(question.prompt, 'Evaluate according to the rubric.'),
30
112
  rubric: question.rubric,
31
113
  // The runtime rejects a map here: score criteria is the rubric as a
32
114
  // nonempty list of descriptions (laya_mlx agent.py `_to_internal`).
33
- criteria: question.rubric.map(formatDesc),
115
+ criteria: question.rubric.map((r) => formatDesc(r, 'Level')),
34
116
  };
35
117
  case 'noul': {
36
118
  const outcomes = question.yes === undefined && question.no === undefined
@@ -38,7 +120,7 @@ export function toWorkerQuestion(question) {
38
120
  : { true: question.yes ?? null, false: question.no ?? null };
39
121
  return {
40
122
  type: 'noul',
41
- instructions: formatDesc(question.prompt),
123
+ instructions: formatDesc(question.prompt, 'Evaluate whether the proposition is true.'),
42
124
  ...(outcomes !== undefined ? { criteria: outcomes } : {}),
43
125
  };
44
126
  }
@@ -60,7 +142,8 @@ export function mapWorkerAnswer(question, rawAnswer) {
60
142
  throw new DecisionBackendError('laya worker returned invalid choice answer: missing label', undefined);
61
143
  }
62
144
  const confidence = typeof raw.confidence === 'number' ? raw.confidence : 0;
63
- const probabilities = (raw.probabilities ?? {});
145
+ const rawProbs = (raw.probabilities ?? {});
146
+ const probabilities = normalizeDistribution(rawProbs);
64
147
  return {
65
148
  kind: 'choice',
66
149
  label,
@@ -102,7 +185,7 @@ export function mapWorkerAnswer(question, rawAnswer) {
102
185
  score,
103
186
  confidence,
104
187
  legend: rawLegend ? normalizedLegend : legend,
105
- probabilities: normalizedProbs,
188
+ probabilities: normalizeDistribution(normalizedProbs),
106
189
  };
107
190
  }
108
191
  case 'noul': {
@@ -124,9 +207,13 @@ export function mapWorkerAnswer(question, rawAnswer) {
124
207
  * Exposes a readonly `name: 'laya-local'` and a single `ask` method with no sugar.
125
208
  */
126
209
  export function createLayaDriver(options = {}) {
127
- const client = options.client ?? new LayaWorkerClient(options);
210
+ const client = options.client ?? createLayaClient(options);
128
211
  return {
129
212
  name: 'laya-local',
213
+ // The client holds a warm Python worker for its lifetime (and the worker's open pipes keep
214
+ // a one-shot parent alive), so the driver exposes the release the client already owns; a
215
+ // consumer that made no call has nothing to close and every dispose is idempotent.
216
+ dispose: () => client.dispose(),
130
217
  async ask({ state, questions, }) {
131
218
  if (!questions || typeof questions !== 'object' || Object.keys(questions).length === 0) {
132
219
  throw new DecisionRequestError('expected a nonempty question map', undefined, undefined);
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
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.
3
+ * Implements the {@link DecisionDriver} interface for `@gobing-ai/ts-ai-runner` on one of two MLX cores:
4
+ * a JSON-lines Python worker (`mlx-python`, default) or in-process `@johnhenry/laya` (`mlx-native`).
4
5
  */
5
- export { createLayaDriver, type LayaDriverOptions } from './driver';
6
- export { LayaWorkerClient, type LayaWorkerClientOptions, type LayaWorkerResult, translateWorkerError, validateHostPrerequisites, type WorkerQuestion, } from './worker-client';
6
+ export { createLayaClient, createLayaDriver, type LayaClient, type LayaCore, type LayaDriverOptions, resolveLayaCore, } from './driver';
7
+ export { LayaNativeClient, type LayaNativeClientOptions, type LayaNativeLoader } from './native-client';
8
+ export { DEFAULT_LAYA_MODEL_ID, LayaWorkerClient, type LayaWorkerClientOptions, type LayaWorkerResult, translateWorkerError, validateHostPrerequisites, type WorkerQuestion, } from './worker-client';
7
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,gBAAgB,EAAE,KAAK,iBAAiB,EAAE,MAAM,UAAU,CAAC;AACpE,OAAO,EACH,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,gBAAgB,EACrB,oBAAoB,EACpB,yBAAyB,EACzB,KAAK,cAAc,GACtB,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACH,gBAAgB,EAChB,gBAAgB,EAChB,KAAK,UAAU,EACf,KAAK,QAAQ,EACb,KAAK,iBAAiB,EACtB,eAAe,GAClB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,gBAAgB,EAAE,KAAK,uBAAuB,EAAE,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACxG,OAAO,EACH,qBAAqB,EACrB,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,gBAAgB,EACrB,oBAAoB,EACpB,yBAAyB,EACzB,KAAK,cAAc,GACtB,MAAM,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
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.
3
+ * Implements the {@link DecisionDriver} interface for `@gobing-ai/ts-ai-runner` on one of two MLX cores:
4
+ * a JSON-lines Python worker (`mlx-python`, default) or in-process `@johnhenry/laya` (`mlx-native`).
4
5
  */
5
- export { createLayaDriver } from './driver.js';
6
- export { LayaWorkerClient, translateWorkerError, validateHostPrerequisites, } from './worker-client.js';
6
+ export { createLayaClient, createLayaDriver, resolveLayaCore, } from './driver.js';
7
+ export { LayaNativeClient } from './native-client.js';
8
+ export { DEFAULT_LAYA_MODEL_ID, LayaWorkerClient, translateWorkerError, validateHostPrerequisites, } from './worker-client.js';
@@ -0,0 +1,61 @@
1
+ /**
2
+ * In-process MLX client over `@johnhenry/laya` (`mlx-native` core).
3
+ * Runs the same Laya checkpoint on Apple Silicon Metal through libmlxc FFI —
4
+ * no Python interpreter, no child process. Exposes the same `ask()` shape as
5
+ * {@link LayaWorkerClient} so the driver maps answers identically for both cores.
6
+ */
7
+ import type { LayaAgent } from '@johnhenry/laya';
8
+ import { type LayaWorkerResult, type WorkerQuestion } from './worker-client';
9
+ /** Loads a checkpoint into a ready agent; the seam the tests substitute. */
10
+ export type LayaNativeLoader = (target: string, opts: Record<string, unknown>) => Promise<LayaAgent>;
11
+ /** Configuration for {@link LayaNativeClient}. */
12
+ export interface LayaNativeClientOptions {
13
+ /** Hugging Face model id. Default: `convaiinnovations/laya-multilingual`. */
14
+ modelId?: string;
15
+ /** Local checkpoint directory (e.g. a fine-tuned model); wins over `modelId`. */
16
+ modelPath?: string;
17
+ /** Checkpoint revision pinned for resolution. */
18
+ revision?: string;
19
+ /**
20
+ * Not supported natively: `@johnhenry/laya` resolves the hub cache from the
21
+ * process environment only. Set → construction fails rather than being silently ignored.
22
+ */
23
+ cacheDir?: string;
24
+ /** Weight precision. `bfloat16` is not supported by the native backend. Default: `'float16'`. */
25
+ dtype?: 'float32' | 'float16' | 'bfloat16';
26
+ /** Questions per forward pass. Default: `16`. */
27
+ batchSize?: number;
28
+ /** Injected environment record — this package never reads the ambient environment (ADR-011). */
29
+ env?: Record<string, string | undefined>;
30
+ platform?: string;
31
+ arch?: string;
32
+ /** Injected loader (tests). Default: dynamic import of `@johnhenry/laya`'s `load`. */
33
+ loader?: LayaNativeLoader;
34
+ }
35
+ /**
36
+ * In-process MLX client over `@johnhenry/laya`: same wire shape and `ask()`
37
+ * contract as {@link LayaWorkerClient}, backed by libmlxc instead of a Python
38
+ * worker. Construction validates the host and the options the native backend
39
+ * cannot honor; the checkpoint is loaded lazily on the first `warm()`/`ask()`.
40
+ */
41
+ export declare class LayaNativeClient {
42
+ private agent;
43
+ private loading;
44
+ private chain;
45
+ private disposed;
46
+ private readonly target;
47
+ private readonly loadOptions;
48
+ private readonly loader;
49
+ constructor(options?: LayaNativeClientOptions);
50
+ /** Checkpoint target actually loaded (model path or hub id). */
51
+ get model(): string;
52
+ /** Eagerly load the weights; `auto` core resolution uses this to probe native availability. */
53
+ warm(): Promise<void>;
54
+ /** Ask one batch of worker-format questions; serialized like the python worker. */
55
+ ask(state: string, questions: Record<string, WorkerQuestion>): Promise<LayaWorkerResult>;
56
+ /** Release Metal buffers; safe to call repeatedly. */
57
+ dispose(): void;
58
+ private dispatch;
59
+ private ensureAgent;
60
+ }
61
+ //# sourceMappingURL=native-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"native-client.d.ts","sourceRoot":"","sources":["../src/native-client.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,SAAS,EAAa,MAAM,iBAAiB,CAAC;AAC5D,OAAO,EAGH,KAAK,gBAAgB,EAErB,KAAK,cAAc,EACtB,MAAM,iBAAiB,CAAC;AAEzB,4EAA4E;AAC5E,MAAM,MAAM,gBAAgB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,SAAS,CAAC,CAAC;AAErG,kDAAkD;AAClD,MAAM,WAAW,uBAAuB;IACpC,6EAA6E;IAC7E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iGAAiG;IACjG,KAAK,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,UAAU,CAAC;IAC3C,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gGAAgG;IAChG,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sFAAsF;IACtF,MAAM,CAAC,EAAE,gBAAgB,CAAC;CAC7B;AAQD;;;;;GAKG;AACH,qBAAa,gBAAgB;IACzB,OAAO,CAAC,KAAK,CAA0B;IACvC,OAAO,CAAC,OAAO,CAAmC;IAClD,OAAO,CAAC,KAAK,CAAuC;IACpD,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA0B;IACtD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmB;gBAE9B,OAAO,GAAE,uBAA4B;IAiCjD,gEAAgE;IAChE,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,+FAA+F;IACzF,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAI3B,mFAAmF;IACnF,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,GAAG,OAAO,CAAC,gBAAgB,CAAC;IASxF,sDAAsD;IACtD,OAAO,IAAI,IAAI;YAMD,QAAQ;IAmBtB,OAAO,CAAC,WAAW;CA2BtB"}
@@ -0,0 +1,111 @@
1
+ /**
2
+ * In-process MLX client over `@johnhenry/laya` (`mlx-native` core).
3
+ * Runs the same Laya checkpoint on Apple Silicon Metal through libmlxc FFI —
4
+ * no Python interpreter, no child process. Exposes the same `ask()` shape as
5
+ * {@link LayaWorkerClient} so the driver maps answers identically for both cores.
6
+ */
7
+ import { DecisionBackendError, DecisionConfigError } from '@gobing-ai/ts-ai-runner';
8
+ import { DEFAULT_LAYA_MODEL_ID, hasNonFiniteValues, validateHostPrerequisites, } from './worker-client.js';
9
+ const defaultLoader = async (target, opts) => {
10
+ // Dynamic import keeps libmlxc/koffi out of the module graph when the python core is used.
11
+ const { load } = await import('@johnhenry/laya');
12
+ return load(target, opts);
13
+ };
14
+ /**
15
+ * In-process MLX client over `@johnhenry/laya`: same wire shape and `ask()`
16
+ * contract as {@link LayaWorkerClient}, backed by libmlxc instead of a Python
17
+ * worker. Construction validates the host and the options the native backend
18
+ * cannot honor; the checkpoint is loaded lazily on the first `warm()`/`ask()`.
19
+ */
20
+ export class LayaNativeClient {
21
+ agent = null;
22
+ loading = null;
23
+ chain = Promise.resolve();
24
+ disposed = false;
25
+ target;
26
+ loadOptions;
27
+ loader;
28
+ constructor(options = {}) {
29
+ validateHostPrerequisites(options.platform, options.arch);
30
+ const cacheDir = options.cacheDir ?? options.env?.LAYA_CACHE_DIR;
31
+ if (cacheDir !== undefined) {
32
+ throw new DecisionConfigError("laya core 'mlx-native' cannot honor cacheDir/LAYA_CACHE_DIR; set HF_HUB_CACHE for the process or use core 'mlx-python'", 'LAYA_CACHE_DIR');
33
+ }
34
+ if (options.dtype === 'bfloat16') {
35
+ throw new DecisionConfigError("laya core 'mlx-native' supports dtype 'float16' or 'float32', not 'bfloat16'", 'LAYA_DTYPE');
36
+ }
37
+ this.target =
38
+ options.modelPath ??
39
+ options.env?.LAYA_MODEL_PATH ??
40
+ options.modelId ??
41
+ options.env?.LAYA_MODEL_ID ??
42
+ DEFAULT_LAYA_MODEL_ID;
43
+ this.loadOptions = {
44
+ backend: 'mlx',
45
+ dtype: options.dtype === 'float32' ? 'f32' : 'f16',
46
+ batchSize: options.batchSize ?? 16,
47
+ ...(options.revision !== undefined ? { revision: options.revision } : {}),
48
+ ...(options.env?.HF_TOKEN ? { token: options.env.HF_TOKEN } : {}),
49
+ // The temperature-clamping notice would otherwise land on the host's console.
50
+ warn: () => { },
51
+ };
52
+ this.loader = options.loader ?? defaultLoader;
53
+ }
54
+ /** Checkpoint target actually loaded (model path or hub id). */
55
+ get model() {
56
+ return this.target;
57
+ }
58
+ /** Eagerly load the weights; `auto` core resolution uses this to probe native availability. */
59
+ async warm() {
60
+ await this.ensureAgent();
61
+ }
62
+ /** Ask one batch of worker-format questions; serialized like the python worker. */
63
+ ask(state, questions) {
64
+ const run = this.chain.then(() => this.dispatch(state, questions));
65
+ this.chain = run.then(() => undefined, () => undefined);
66
+ return run;
67
+ }
68
+ /** Release Metal buffers; safe to call repeatedly. */
69
+ dispose() {
70
+ this.disposed = true;
71
+ this.agent?.dispose();
72
+ this.agent = null;
73
+ }
74
+ async dispatch(state, questions) {
75
+ const agent = await this.ensureAgent();
76
+ let result;
77
+ try {
78
+ // WorkerQuestion is the laya-mlx wire shape, which @johnhenry/laya ports 1:1.
79
+ result = (await agent.predict(state, questions));
80
+ }
81
+ catch (cause) {
82
+ const message = cause instanceof Error ? cause.message : String(cause);
83
+ throw new DecisionBackendError(`laya native inference failed: ${message}`, undefined, { cause });
84
+ }
85
+ if (hasNonFiniteValues(result.answers)) {
86
+ throw new DecisionBackendError("laya native returned non-finite model outputs; retry with dtype='float32'", undefined);
87
+ }
88
+ return result;
89
+ }
90
+ ensureAgent() {
91
+ if (this.disposed) {
92
+ return Promise.reject(new DecisionBackendError('laya native client has been disposed', undefined));
93
+ }
94
+ if (this.agent)
95
+ return Promise.resolve(this.agent);
96
+ this.loading ??= this.loader(this.target, this.loadOptions).then((agent) => {
97
+ this.loading = null;
98
+ if (this.disposed) {
99
+ agent.dispose();
100
+ throw new DecisionBackendError('laya native client has been disposed', undefined);
101
+ }
102
+ this.agent = agent;
103
+ return agent;
104
+ }, (cause) => {
105
+ this.loading = null;
106
+ const message = cause instanceof Error ? cause.message : String(cause);
107
+ throw new DecisionConfigError(`laya core 'mlx-native' failed to load '${this.target}': ${message}`, 'LAYA_CORE', { cause });
108
+ });
109
+ return this.loading;
110
+ }
111
+ }
@@ -12,7 +12,7 @@
12
12
  * boundary. Protocol reference: docs/design/laya-local-decision-backend.md.
13
13
  */
14
14
  import { type DecisionError } from '@gobing-ai/ts-ai-runner';
15
- import { type ProcessExecutor } from '@gobing-ai/ts-runtime';
15
+ import { type FileSystem, type ProcessExecutor } from '@gobing-ai/ts-runtime';
16
16
  /** Configuration options for {@link LayaWorkerClient}. */
17
17
  export interface LayaWorkerClientOptions {
18
18
  /** Interpreter that carries `laya-mlx`. Default: `'python3'`. */
@@ -69,10 +69,16 @@ export interface LayaWorkerResult {
69
69
  * Throws {@link DecisionConfigError} if the platform is not Apple Silicon macOS.
70
70
  */
71
71
  export declare function validateHostPrerequisites(platform?: string, arch?: string): void;
72
+ /** Official Laya checkpoint used when no modelId/modelPath is configured. */
73
+ export declare const DEFAULT_LAYA_MODEL_ID = "convaiinnovations/laya-multilingual";
74
+ /** @internal True when any number nested in `obj` is NaN/Infinity (fp16 overflow). */
75
+ export declare function hasNonFiniteValues(obj: unknown): boolean;
72
76
  /**
73
77
  * Map worker-reported error kinds and messages onto the decision taxonomy (design: Errors table, task 0077).
74
78
  */
75
79
  export declare function translateWorkerError(kind: string | undefined, message: string): DecisionError;
80
+ /** Resolve the Python interpreter to execute: explicit option -> injected env -> candidate discovery -> 'python3'. */
81
+ export declare function resolvePythonPath(options: LayaWorkerClientOptions, packageRoot: string, cwd: string, fileSystem?: FileSystem): string;
76
82
  /**
77
83
  * TypeScript half of the laya process bridge: start the Python worker lazily,
78
84
  * hold it warm for the client's lifetime, and serve serialized asks with
@@ -1 +1 @@
1
- {"version":3,"file":"worker-client.d.ts","sourceRoot":"","sources":["../src/worker-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"worker-client.d.ts","sourceRoot":"","sources":["../src/worker-client.ts"],"names":[],"mappings":"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"}
@@ -12,7 +12,7 @@
12
12
  * boundary. Protocol reference: docs/design/laya-local-decision-backend.md.
13
13
  */
14
14
  import { DecisionAuthError, DecisionBackendError, DecisionConfigError, DecisionConnectionError, DecisionRequestError, DecisionTimeoutError, } from '@gobing-ai/ts-ai-runner';
15
- import { joinPath, nodeBunFactory } from '@gobing-ai/ts-runtime';
15
+ import { getHomeDir, joinPath, nodeBunFactory, } from '@gobing-ai/ts-runtime';
16
16
  /** Environment keys this package may pass to the child process — the full allowlist. */
17
17
  const FORWARDED_ENV_KEYS = ['LAYA_MODEL_ID', 'LAYA_MODEL_PATH', 'LAYA_CACHE_DIR', 'LAYA_PYTHON', 'HF_TOKEN'];
18
18
  /** How many trailing stderr bytes to keep for exit-failure messages. */
@@ -44,7 +44,10 @@ export function validateHostPrerequisites(platform = process.platform, arch = pr
44
44
  throw new DecisionConfigError(`laya-mlx requires macOS on Apple Silicon (darwin arm64); current platform is '${platform} ${arch}'`, 'PLATFORM');
45
45
  }
46
46
  }
47
- function hasNonFiniteValues(obj) {
47
+ /** Official Laya checkpoint used when no modelId/modelPath is configured. */
48
+ export const DEFAULT_LAYA_MODEL_ID = 'convaiinnovations/laya-multilingual';
49
+ /** @internal True when any number nested in `obj` is NaN/Infinity (fp16 overflow). */
50
+ export function hasNonFiniteValues(obj) {
48
51
  if (typeof obj === 'number') {
49
52
  return !Number.isFinite(obj);
50
53
  }
@@ -317,6 +320,27 @@ class WorkerHandle {
317
320
  });
318
321
  }
319
322
  }
323
+ /** Resolve the Python interpreter to execute: explicit option -> injected env -> candidate discovery -> 'python3'. */
324
+ export function resolvePythonPath(options, packageRoot, cwd, fileSystem = nodeBunFactory.createFileSystem()) {
325
+ if (options.pythonPath !== undefined)
326
+ return options.pythonPath;
327
+ if (options.env?.LAYA_PYTHON !== undefined && options.env.LAYA_PYTHON.length > 0) {
328
+ return options.env.LAYA_PYTHON;
329
+ }
330
+ const home = getHomeDir();
331
+ const candidates = [
332
+ home ? joinPath(home, '.local', 'share', 'uv', 'tools', 'laya-mlx', 'bin', 'python') : undefined,
333
+ home ? joinPath(home, '.virtualenvs', 'laya-mlx', 'bin', 'python') : undefined,
334
+ joinPath(packageRoot, '..', '..', 'vendors', 'laya-mlx', '.venv', 'bin', 'python'),
335
+ joinPath(cwd, '.venv', 'bin', 'python'),
336
+ ].filter((p) => p !== undefined);
337
+ for (const candidate of candidates) {
338
+ if (fileSystem.exists(candidate)) {
339
+ return candidate;
340
+ }
341
+ }
342
+ return 'python3';
343
+ }
320
344
  /**
321
345
  * TypeScript half of the laya process bridge: start the Python worker lazily,
322
346
  * hold it warm for the client's lifetime, and serve serialized asks with
@@ -336,8 +360,8 @@ export class LayaWorkerClient {
336
360
  constructor(options = {}, executor = nodeBunFactory.createProcessExecutor()) {
337
361
  validateHostPrerequisites(options.platform, options.arch);
338
362
  const packageRoot = joinPath(import.meta.dir, '..');
339
- this.pythonPath = options.pythonPath ?? options.env?.LAYA_PYTHON ?? 'python3';
340
363
  this.cwd = options.cwd ?? packageRoot;
364
+ this.pythonPath = resolvePythonPath(options, packageRoot, this.cwd);
341
365
  this.startupTimeoutMs = options.startupTimeoutMs ?? 120_000;
342
366
  this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
343
367
  this.forwardedEnv = resolveForwardedEnv(options.env);
@@ -346,7 +370,7 @@ export class LayaWorkerClient {
346
370
  this.args.push('--module', options.module ?? 'laya_mlx');
347
371
  const resolvedModelPath = options.modelPath ?? options.env?.LAYA_MODEL_PATH;
348
372
  const resolvedCacheDir = options.cacheDir ?? options.env?.LAYA_CACHE_DIR;
349
- const resolvedModelId = options.modelId ?? options.env?.LAYA_MODEL_ID ?? 'convaiinnovations/laya-multilingual';
373
+ const resolvedModelId = options.modelId ?? options.env?.LAYA_MODEL_ID ?? DEFAULT_LAYA_MODEL_ID;
350
374
  if (resolvedModelPath !== undefined) {
351
375
  this.args.push('--model-path', resolvedModelPath);
352
376
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-laya-mlx",
3
- "version": "0.5.21",
3
+ "version": "0.5.23",
4
4
  "description": "@gobing-ai/ts-laya-mlx — Local Laya decision backend driving the vendored MLX runtime over a process bridge.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -52,8 +52,12 @@
52
52
  "release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-laya-mlx-v<version> && git push --tags' && exit 1"
53
53
  },
54
54
  "dependencies": {
55
- "@gobing-ai/ts-ai-runner": "^0.5.21",
56
- "@gobing-ai/ts-runtime": "^0.5.21"
55
+ "@gobing-ai/ts-ai-runner": "^0.5.23",
56
+ "@gobing-ai/ts-runtime": "^0.5.23",
57
+ "@johnhenry/laya": "0.3.2"
58
+ },
59
+ "optionalDependencies": {
60
+ "@johnhenry/backend-mlx": "0.5.0"
57
61
  },
58
62
  "devDependencies": {
59
63
  "@types/bun": "1.3.14"
package/src/driver.ts CHANGED
@@ -1,51 +1,162 @@
1
1
  /**
2
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).
3
+ * Translates provider-neutral decision requests into the laya-mlx question format
4
+ * and maps answers back onto neutral {@link Answer} types (task 0078). Two MLX cores
5
+ * serve the same wire shape: the Python worker (`mlx-python`) and the in-process
6
+ * `@johnhenry/laya` port (`mlx-native`).
5
7
  */
6
8
 
7
9
  import {
8
10
  type Answer,
9
11
  DecisionBackendError,
12
+ DecisionConfigError,
10
13
  type DecisionDriver,
11
14
  DecisionRequestError,
12
15
  type DecisionState,
13
16
  type Desc,
14
17
  type Question,
15
18
  } from '@gobing-ai/ts-ai-runner';
16
- import { LayaWorkerClient, type LayaWorkerClientOptions, type WorkerQuestion } from './worker-client';
19
+ import { LayaNativeClient, type LayaNativeLoader } from './native-client';
20
+ import {
21
+ LayaWorkerClient,
22
+ type LayaWorkerClientOptions,
23
+ type LayaWorkerResult,
24
+ type WorkerQuestion,
25
+ } from './worker-client';
26
+
27
+ /**
28
+ * Execution core behind `laya-local`. Both run Apple MLX on Metal:
29
+ * - `mlx-python`: warm `laya-mlx` Python worker over JSON lines (default; needs a Python env).
30
+ * - `mlx-native`: in-process `@johnhenry/laya` via libmlxc FFI (no Python).
31
+ * - `auto`: try `mlx-native`, fall back to `mlx-python` when it cannot load.
32
+ */
33
+ export type LayaCore = 'mlx-python' | 'mlx-native' | 'auto';
34
+
35
+ const LAYA_CORES: readonly LayaCore[] = ['mlx-python', 'mlx-native', 'auto'];
36
+
37
+ /** The `ask` surface both cores implement. */
38
+ export interface LayaClient {
39
+ ask(state: string, questions: Record<string, WorkerQuestion>): Promise<LayaWorkerResult>;
40
+ dispose(): void;
41
+ }
17
42
 
18
43
  /** Configuration options for {@link createLayaDriver}. */
19
44
  export interface LayaDriverOptions extends LayaWorkerClientOptions {
20
- /** Pre-constructed worker client (useful for dependency injection in tests). */
21
- client?: LayaWorkerClient;
45
+ /** Execution core. Default: `env.LAYA_CORE`, else `'mlx-python'`. */
46
+ core?: LayaCore;
47
+ /** Pre-constructed client (dependency injection in tests); bypasses core selection. */
48
+ client?: LayaClient;
49
+ /** Injected native loader (tests); forwarded to {@link LayaNativeClient}. */
50
+ nativeLoader?: LayaNativeLoader;
51
+ }
52
+
53
+ /** Resolve the configured core: explicit option → injected `env.LAYA_CORE` → `'mlx-python'`. */
54
+ export function resolveLayaCore(options: Pick<LayaDriverOptions, 'core' | 'env'> = {}): LayaCore {
55
+ const raw = options.core ?? (options.env?.LAYA_CORE || undefined) ?? 'mlx-python';
56
+ if (!(LAYA_CORES as readonly string[]).includes(raw)) {
57
+ throw new DecisionConfigError(
58
+ `Unknown laya core '${raw}'; expected one of ${LAYA_CORES.join(', ')}`,
59
+ 'LAYA_CORE',
60
+ );
61
+ }
62
+ return raw as LayaCore;
63
+ }
64
+
65
+ /**
66
+ * `auto` core: the first ask probes `mlx-native` by loading the weights; any load
67
+ * failure (missing libmlxc, unsupported option, bad checkpoint) falls back to the
68
+ * Python worker for the client's lifetime. Request-time errors are not retried.
69
+ */
70
+ class AutoLayaClient implements LayaClient {
71
+ private active: Promise<LayaClient> | null = null;
72
+ private resolved: LayaClient | null = null;
73
+ private disposed = false;
74
+
75
+ constructor(private readonly options: LayaDriverOptions) {}
76
+
77
+ async ask(state: string, questions: Record<string, WorkerQuestion>): Promise<LayaWorkerResult> {
78
+ this.active ??= this.select();
79
+ return (await this.active).ask(state, questions);
80
+ }
81
+
82
+ dispose(): void {
83
+ this.disposed = true;
84
+ this.resolved?.dispose();
85
+ }
86
+
87
+ private async select(): Promise<LayaClient> {
88
+ let chosen: LayaClient;
89
+ try {
90
+ const native = new LayaNativeClient({ ...this.options, loader: this.options.nativeLoader });
91
+ try {
92
+ await native.warm();
93
+ } catch (error) {
94
+ native.dispose();
95
+ throw error;
96
+ }
97
+ chosen = native;
98
+ } catch (error) {
99
+ if (!(error instanceof DecisionConfigError)) throw error;
100
+ chosen = new LayaWorkerClient(this.options);
101
+ }
102
+ this.resolved = chosen;
103
+ // dispose() raced the native probe: the chosen client must not outlive us.
104
+ if (this.disposed) chosen.dispose();
105
+ return chosen;
106
+ }
107
+ }
108
+
109
+ /** Construct the client for the resolved core. */
110
+ export function createLayaClient(options: LayaDriverOptions = {}): LayaClient {
111
+ switch (resolveLayaCore(options)) {
112
+ case 'mlx-native':
113
+ return new LayaNativeClient({ ...options, loader: options.nativeLoader });
114
+ case 'auto':
115
+ return new AutoLayaClient(options);
116
+ default:
117
+ return new LayaWorkerClient(options);
118
+ }
22
119
  }
23
120
 
24
121
  /** 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 '';
122
+ function formatDesc(desc: Desc | undefined, fallback = ''): string {
123
+ if (typeof desc === 'string') return desc.trim().length > 0 ? desc : fallback;
124
+ if (desc === null || desc === undefined) return fallback;
28
125
  return JSON.stringify(desc);
29
126
  }
30
127
 
128
+ /** Rescale a probability distribution to sum to exactly 1.0 when rounding error exceeds 1e-6. */
129
+ function normalizeDistribution<K extends string | number>(probs: Record<K, number>): Record<K, number> {
130
+ const entries = Object.entries(probs) as [K, number][];
131
+ const sum = entries.reduce<number>((s, [, p]) => s + (typeof p === 'number' && Number.isFinite(p) ? p : 0), 0);
132
+ if (sum <= 0 || Math.abs(sum - 1) <= 1e-6) {
133
+ return probs;
134
+ }
135
+ const result = {} as Record<K, number>;
136
+ for (const [k, p] of entries) {
137
+ result[k] = p / sum;
138
+ }
139
+ return result;
140
+ }
141
+
31
142
  /** Convert a neutral {@link Question} into the wire shape consumed by the worker. */
32
143
  export function toWorkerQuestion(question: Question): WorkerQuestion {
33
144
  switch (question.kind) {
34
145
  case 'choice':
35
146
  return {
36
147
  type: 'choice',
37
- instructions: formatDesc(question.prompt),
148
+ instructions: formatDesc(question.prompt, 'Select the best matching option.'),
38
149
  labels: question.labels,
39
150
  criteria: question.labels,
40
151
  };
41
152
  case 'score':
42
153
  return {
43
154
  type: 'score',
44
- instructions: formatDesc(question.prompt),
155
+ instructions: formatDesc(question.prompt, 'Evaluate according to the rubric.'),
45
156
  rubric: question.rubric,
46
157
  // The runtime rejects a map here: score criteria is the rubric as a
47
158
  // nonempty list of descriptions (laya_mlx agent.py `_to_internal`).
48
- criteria: question.rubric.map(formatDesc),
159
+ criteria: question.rubric.map((r) => formatDesc(r, 'Level')),
49
160
  };
50
161
  case 'noul': {
51
162
  const outcomes =
@@ -54,7 +165,7 @@ export function toWorkerQuestion(question: Question): WorkerQuestion {
54
165
  : { true: question.yes ?? null, false: question.no ?? null };
55
166
  return {
56
167
  type: 'noul',
57
- instructions: formatDesc(question.prompt),
168
+ instructions: formatDesc(question.prompt, 'Evaluate whether the proposition is true.'),
58
169
  ...(outcomes !== undefined ? { criteria: outcomes } : {}),
59
170
  };
60
171
  }
@@ -77,7 +188,8 @@ export function mapWorkerAnswer(question: Question, rawAnswer: unknown): Answer
77
188
  throw new DecisionBackendError('laya worker returned invalid choice answer: missing label', undefined);
78
189
  }
79
190
  const confidence = typeof raw.confidence === 'number' ? raw.confidence : 0;
80
- const probabilities = (raw.probabilities ?? {}) as Record<string, number>;
191
+ const rawProbs = (raw.probabilities ?? {}) as Record<string, number>;
192
+ const probabilities = normalizeDistribution(rawProbs);
81
193
  return {
82
194
  kind: 'choice',
83
195
  label,
@@ -120,7 +232,7 @@ export function mapWorkerAnswer(question: Question, rawAnswer: unknown): Answer
120
232
  score,
121
233
  confidence,
122
234
  legend: rawLegend ? normalizedLegend : legend,
123
- probabilities: normalizedProbs,
235
+ probabilities: normalizeDistribution(normalizedProbs),
124
236
  };
125
237
  }
126
238
  case 'noul': {
@@ -146,10 +258,14 @@ export function mapWorkerAnswer(question: Question, rawAnswer: unknown): Answer
146
258
  * Exposes a readonly `name: 'laya-local'` and a single `ask` method with no sugar.
147
259
  */
148
260
  export function createLayaDriver(options: LayaDriverOptions = {}): DecisionDriver {
149
- const client = options.client ?? new LayaWorkerClient(options);
261
+ const client = options.client ?? createLayaClient(options);
150
262
 
151
263
  return {
152
264
  name: 'laya-local',
265
+ // The client holds a warm Python worker for its lifetime (and the worker's open pipes keep
266
+ // a one-shot parent alive), so the driver exposes the release the client already owns; a
267
+ // consumer that made no call has nothing to close and every dispose is idempotent.
268
+ dispose: () => client.dispose(),
153
269
  async ask({
154
270
  state,
155
271
  questions,
package/src/index.ts CHANGED
@@ -1,10 +1,20 @@
1
1
  /**
2
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.
3
+ * Implements the {@link DecisionDriver} interface for `@gobing-ai/ts-ai-runner` on one of two MLX cores:
4
+ * a JSON-lines Python worker (`mlx-python`, default) or in-process `@johnhenry/laya` (`mlx-native`).
4
5
  */
5
6
 
6
- export { createLayaDriver, type LayaDriverOptions } from './driver';
7
7
  export {
8
+ createLayaClient,
9
+ createLayaDriver,
10
+ type LayaClient,
11
+ type LayaCore,
12
+ type LayaDriverOptions,
13
+ resolveLayaCore,
14
+ } from './driver';
15
+ export { LayaNativeClient, type LayaNativeClientOptions, type LayaNativeLoader } from './native-client';
16
+ export {
17
+ DEFAULT_LAYA_MODEL_ID,
8
18
  LayaWorkerClient,
9
19
  type LayaWorkerClientOptions,
10
20
  type LayaWorkerResult,
@@ -0,0 +1,173 @@
1
+ /**
2
+ * In-process MLX client over `@johnhenry/laya` (`mlx-native` core).
3
+ * Runs the same Laya checkpoint on Apple Silicon Metal through libmlxc FFI —
4
+ * no Python interpreter, no child process. Exposes the same `ask()` shape as
5
+ * {@link LayaWorkerClient} so the driver maps answers identically for both cores.
6
+ */
7
+
8
+ import { DecisionBackendError, DecisionConfigError } from '@gobing-ai/ts-ai-runner';
9
+ import type { LayaAgent, Questions } from '@johnhenry/laya';
10
+ import {
11
+ DEFAULT_LAYA_MODEL_ID,
12
+ hasNonFiniteValues,
13
+ type LayaWorkerResult,
14
+ validateHostPrerequisites,
15
+ type WorkerQuestion,
16
+ } from './worker-client';
17
+
18
+ /** Loads a checkpoint into a ready agent; the seam the tests substitute. */
19
+ export type LayaNativeLoader = (target: string, opts: Record<string, unknown>) => Promise<LayaAgent>;
20
+
21
+ /** Configuration for {@link LayaNativeClient}. */
22
+ export interface LayaNativeClientOptions {
23
+ /** Hugging Face model id. Default: `convaiinnovations/laya-multilingual`. */
24
+ modelId?: string;
25
+ /** Local checkpoint directory (e.g. a fine-tuned model); wins over `modelId`. */
26
+ modelPath?: string;
27
+ /** Checkpoint revision pinned for resolution. */
28
+ revision?: string;
29
+ /**
30
+ * Not supported natively: `@johnhenry/laya` resolves the hub cache from the
31
+ * process environment only. Set → construction fails rather than being silently ignored.
32
+ */
33
+ cacheDir?: string;
34
+ /** Weight precision. `bfloat16` is not supported by the native backend. Default: `'float16'`. */
35
+ dtype?: 'float32' | 'float16' | 'bfloat16';
36
+ /** Questions per forward pass. Default: `16`. */
37
+ batchSize?: number;
38
+ /** Injected environment record — this package never reads the ambient environment (ADR-011). */
39
+ env?: Record<string, string | undefined>;
40
+ platform?: string;
41
+ arch?: string;
42
+ /** Injected loader (tests). Default: dynamic import of `@johnhenry/laya`'s `load`. */
43
+ loader?: LayaNativeLoader;
44
+ }
45
+
46
+ const defaultLoader: LayaNativeLoader = async (target, opts) => {
47
+ // Dynamic import keeps libmlxc/koffi out of the module graph when the python core is used.
48
+ const { load } = await import('@johnhenry/laya');
49
+ return load(target, opts);
50
+ };
51
+
52
+ /**
53
+ * In-process MLX client over `@johnhenry/laya`: same wire shape and `ask()`
54
+ * contract as {@link LayaWorkerClient}, backed by libmlxc instead of a Python
55
+ * worker. Construction validates the host and the options the native backend
56
+ * cannot honor; the checkpoint is loaded lazily on the first `warm()`/`ask()`.
57
+ */
58
+ export class LayaNativeClient {
59
+ private agent: LayaAgent | null = null;
60
+ private loading: Promise<LayaAgent> | null = null;
61
+ private chain: Promise<unknown> = Promise.resolve();
62
+ private disposed = false;
63
+ private readonly target: string;
64
+ private readonly loadOptions: Record<string, unknown>;
65
+ private readonly loader: LayaNativeLoader;
66
+
67
+ constructor(options: LayaNativeClientOptions = {}) {
68
+ validateHostPrerequisites(options.platform, options.arch);
69
+ const cacheDir = options.cacheDir ?? options.env?.LAYA_CACHE_DIR;
70
+ if (cacheDir !== undefined) {
71
+ throw new DecisionConfigError(
72
+ "laya core 'mlx-native' cannot honor cacheDir/LAYA_CACHE_DIR; set HF_HUB_CACHE for the process or use core 'mlx-python'",
73
+ 'LAYA_CACHE_DIR',
74
+ );
75
+ }
76
+ if (options.dtype === 'bfloat16') {
77
+ throw new DecisionConfigError(
78
+ "laya core 'mlx-native' supports dtype 'float16' or 'float32', not 'bfloat16'",
79
+ 'LAYA_DTYPE',
80
+ );
81
+ }
82
+ this.target =
83
+ options.modelPath ??
84
+ options.env?.LAYA_MODEL_PATH ??
85
+ options.modelId ??
86
+ options.env?.LAYA_MODEL_ID ??
87
+ DEFAULT_LAYA_MODEL_ID;
88
+ this.loadOptions = {
89
+ backend: 'mlx',
90
+ dtype: options.dtype === 'float32' ? 'f32' : 'f16',
91
+ batchSize: options.batchSize ?? 16,
92
+ ...(options.revision !== undefined ? { revision: options.revision } : {}),
93
+ ...(options.env?.HF_TOKEN ? { token: options.env.HF_TOKEN } : {}),
94
+ // The temperature-clamping notice would otherwise land on the host's console.
95
+ warn: () => {},
96
+ };
97
+ this.loader = options.loader ?? defaultLoader;
98
+ }
99
+
100
+ /** Checkpoint target actually loaded (model path or hub id). */
101
+ get model(): string {
102
+ return this.target;
103
+ }
104
+
105
+ /** Eagerly load the weights; `auto` core resolution uses this to probe native availability. */
106
+ async warm(): Promise<void> {
107
+ await this.ensureAgent();
108
+ }
109
+
110
+ /** Ask one batch of worker-format questions; serialized like the python worker. */
111
+ ask(state: string, questions: Record<string, WorkerQuestion>): Promise<LayaWorkerResult> {
112
+ const run = this.chain.then(() => this.dispatch(state, questions));
113
+ this.chain = run.then(
114
+ () => undefined,
115
+ () => undefined,
116
+ );
117
+ return run;
118
+ }
119
+
120
+ /** Release Metal buffers; safe to call repeatedly. */
121
+ dispose(): void {
122
+ this.disposed = true;
123
+ this.agent?.dispose();
124
+ this.agent = null;
125
+ }
126
+
127
+ private async dispatch(state: string, questions: Record<string, WorkerQuestion>): Promise<LayaWorkerResult> {
128
+ const agent = await this.ensureAgent();
129
+ let result: LayaWorkerResult;
130
+ try {
131
+ // WorkerQuestion is the laya-mlx wire shape, which @johnhenry/laya ports 1:1.
132
+ result = (await agent.predict(state, questions as unknown as Questions)) as unknown as LayaWorkerResult;
133
+ } catch (cause) {
134
+ const message = cause instanceof Error ? cause.message : String(cause);
135
+ throw new DecisionBackendError(`laya native inference failed: ${message}`, undefined, { cause });
136
+ }
137
+ if (hasNonFiniteValues(result.answers)) {
138
+ throw new DecisionBackendError(
139
+ "laya native returned non-finite model outputs; retry with dtype='float32'",
140
+ undefined,
141
+ );
142
+ }
143
+ return result;
144
+ }
145
+
146
+ private ensureAgent(): Promise<LayaAgent> {
147
+ if (this.disposed) {
148
+ return Promise.reject(new DecisionBackendError('laya native client has been disposed', undefined));
149
+ }
150
+ if (this.agent) return Promise.resolve(this.agent);
151
+ this.loading ??= this.loader(this.target, this.loadOptions).then(
152
+ (agent) => {
153
+ this.loading = null;
154
+ if (this.disposed) {
155
+ agent.dispose();
156
+ throw new DecisionBackendError('laya native client has been disposed', undefined);
157
+ }
158
+ this.agent = agent;
159
+ return agent;
160
+ },
161
+ (cause: unknown) => {
162
+ this.loading = null;
163
+ const message = cause instanceof Error ? cause.message : String(cause);
164
+ throw new DecisionConfigError(
165
+ `laya core 'mlx-native' failed to load '${this.target}': ${message}`,
166
+ 'LAYA_CORE',
167
+ { cause },
168
+ );
169
+ },
170
+ );
171
+ return this.loading;
172
+ }
173
+ }
@@ -20,7 +20,14 @@ import {
20
20
  DecisionRequestError,
21
21
  DecisionTimeoutError,
22
22
  } from '@gobing-ai/ts-ai-runner';
23
- import { joinPath, nodeBunFactory, type PipeProcess, type ProcessExecutor } from '@gobing-ai/ts-runtime';
23
+ import {
24
+ type FileSystem,
25
+ getHomeDir,
26
+ joinPath,
27
+ nodeBunFactory,
28
+ type PipeProcess,
29
+ type ProcessExecutor,
30
+ } from '@gobing-ai/ts-runtime';
24
31
 
25
32
  /** Environment keys this package may pass to the child process — the full allowlist. */
26
33
  const FORWARDED_ENV_KEYS = ['LAYA_MODEL_ID', 'LAYA_MODEL_PATH', 'LAYA_CACHE_DIR', 'LAYA_PYTHON', 'HF_TOKEN'] as const;
@@ -124,7 +131,11 @@ export function validateHostPrerequisites(platform: string = process.platform, a
124
131
  }
125
132
  }
126
133
 
127
- function hasNonFiniteValues(obj: unknown): boolean {
134
+ /** Official Laya checkpoint used when no modelId/modelPath is configured. */
135
+ export const DEFAULT_LAYA_MODEL_ID = 'convaiinnovations/laya-multilingual';
136
+
137
+ /** @internal True when any number nested in `obj` is NaN/Infinity (fp16 overflow). */
138
+ export function hasNonFiniteValues(obj: unknown): boolean {
128
139
  if (typeof obj === 'number') {
129
140
  return !Number.isFinite(obj);
130
141
  }
@@ -452,6 +463,33 @@ class WorkerHandle {
452
463
  }
453
464
  }
454
465
 
466
+ /** Resolve the Python interpreter to execute: explicit option -> injected env -> candidate discovery -> 'python3'. */
467
+ export function resolvePythonPath(
468
+ options: LayaWorkerClientOptions,
469
+ packageRoot: string,
470
+ cwd: string,
471
+ fileSystem: FileSystem = nodeBunFactory.createFileSystem(),
472
+ ): string {
473
+ if (options.pythonPath !== undefined) return options.pythonPath;
474
+ if (options.env?.LAYA_PYTHON !== undefined && options.env.LAYA_PYTHON.length > 0) {
475
+ return options.env.LAYA_PYTHON;
476
+ }
477
+ const home = getHomeDir();
478
+ const candidates = [
479
+ home ? joinPath(home, '.local', 'share', 'uv', 'tools', 'laya-mlx', 'bin', 'python') : undefined,
480
+ home ? joinPath(home, '.virtualenvs', 'laya-mlx', 'bin', 'python') : undefined,
481
+ joinPath(packageRoot, '..', '..', 'vendors', 'laya-mlx', '.venv', 'bin', 'python'),
482
+ joinPath(cwd, '.venv', 'bin', 'python'),
483
+ ].filter((p): p is string => p !== undefined);
484
+
485
+ for (const candidate of candidates) {
486
+ if (fileSystem.exists(candidate)) {
487
+ return candidate;
488
+ }
489
+ }
490
+ return 'python3';
491
+ }
492
+
455
493
  /**
456
494
  * TypeScript half of the laya process bridge: start the Python worker lazily,
457
495
  * hold it warm for the client's lifetime, and serve serialized asks with
@@ -475,8 +513,8 @@ export class LayaWorkerClient {
475
513
  ) {
476
514
  validateHostPrerequisites(options.platform, options.arch);
477
515
  const packageRoot = joinPath(import.meta.dir, '..');
478
- this.pythonPath = options.pythonPath ?? options.env?.LAYA_PYTHON ?? 'python3';
479
516
  this.cwd = options.cwd ?? packageRoot;
517
+ this.pythonPath = resolvePythonPath(options, packageRoot, this.cwd);
480
518
  this.startupTimeoutMs = options.startupTimeoutMs ?? 120_000;
481
519
  this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
482
520
  this.forwardedEnv = resolveForwardedEnv(options.env);
@@ -485,7 +523,7 @@ export class LayaWorkerClient {
485
523
  this.args.push('--module', options.module ?? 'laya_mlx');
486
524
  const resolvedModelPath = options.modelPath ?? options.env?.LAYA_MODEL_PATH;
487
525
  const resolvedCacheDir = options.cacheDir ?? options.env?.LAYA_CACHE_DIR;
488
- const resolvedModelId = options.modelId ?? options.env?.LAYA_MODEL_ID ?? 'convaiinnovations/laya-multilingual';
526
+ const resolvedModelId = options.modelId ?? options.env?.LAYA_MODEL_ID ?? DEFAULT_LAYA_MODEL_ID;
489
527
 
490
528
  if (resolvedModelPath !== undefined) {
491
529
  this.args.push('--model-path', resolvedModelPath);
@@ -153,8 +153,19 @@ def handle_request(agent, line):
153
153
  if not isinstance(questions, dict) or not questions:
154
154
  return request_error(request_id, "request questions must be a nonempty object")
155
155
 
156
+ # For choice questions, ensure compatibility with laya_mlx >= 0.4.0 where
157
+ # 'labels' is rejected if present alongside 'criteria'.
158
+ sanitized_questions = {}
159
+ for qid, qdef in questions.items():
160
+ if isinstance(qdef, dict) and qdef.get("type") == "choice" and "labels" in qdef:
161
+ q_copy = dict(qdef)
162
+ q_copy.pop("labels", None)
163
+ sanitized_questions[qid] = q_copy
164
+ else:
165
+ sanitized_questions[qid] = qdef
166
+
156
167
  try:
157
- result = agent.predict(state, questions)
168
+ result = agent.predict(state, sanitized_questions)
158
169
  except Exception as exc: # classified and reported, never fatal to the worker
159
170
  return {"id": request_id, "ok": False, "error": {"kind": classify(exc), "message": str(exc)}}
160
171
  return {"id": request_id, "ok": True, "result": result}