arcane-os 0.79.0 → 0.80.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.80.0
4
+
5
+ - Add `assetProjectionId` and `resourcePaths` to native decision factories and
6
+ direct, service and Core `decisions.load` calls. A supplied ready projection
7
+ uses complete already-prepared model/tokenizer files through the existing
8
+ model-assets owner, without upstream preparation or another download.
9
+ - Retain projected files through actual native ONNX and tokenizer exit, including
10
+ failure and cancellation. Acquire incoming ownership before retiring a prior
11
+ activation; coalesce only equal source, complete mapping and device selections.
12
+ Surface provisional cleanup failures through load, unload and disposal.
13
+ - Preserve caller preparation ownership, original companion-file paths, complete
14
+ rows and outputs, configured native paths and ordinary explicit upstream
15
+ loading. Explicit `assetProjectionId:null` returns to that configured source.
16
+ Missing projections or mapped members report their actual errors.
17
+ - Keep native Laya FP32 selection and existing runtime dependencies unchanged.
18
+ This release adds no model variant, GGUF route, driver, model download on
19
+ startup or automatic preference change.
20
+
21
+ ## 0.79.1
22
+
23
+ - Classify generic speech-model progress callbacks as loading, reserving
24
+ download status for the resource owner that actually transfers a resource.
25
+ Preserve initialization status, complete progress details and file counts.
26
+ - Keep cancellation, cached-resource reporting, model selection and speech
27
+ behavior unchanged. This corrects a progress label; it does not establish or
28
+ repair a repeated network transfer.
29
+
3
30
  ## 0.79.0
4
31
 
5
32
  - Add public `createExecutionDeviceCatalog()` and `createExecutionDeviceService()`
package/README.md CHANGED
@@ -20,7 +20,7 @@ selected SDK projection or an explicit live SDK source mount. Both profiles
20
20
  share the theme, packaging, event, cancellation,
21
21
  and browser run contracts while retaining their selected application layout.
22
22
 
23
- This checkout defines the `0.79.0` SDK contract. Development applications declare
23
+ This checkout defines the `0.80.0` SDK contract. Development applications declare
24
24
  `arcane-os` as `latest` and retain their lockfile. Exact SDK pins begin when Roshi
25
25
  designates a production build. Follow [Consumer updates](docs/consumer-updates.md)
26
26
  to refresh and compare the installed SDK and lockfile with the published channel;
@@ -1817,7 +1817,7 @@ export function createSpeechWorkerRuntime({ role, scope = globalThis, send } = {
1817
1817
  : 'Loading model file';
1818
1818
  publishProgress(
1819
1819
  {
1820
- phase: initializing ? 'initialize' : 'download',
1820
+ phase: initializing ? 'initialize' : 'load',
1821
1821
  stage: 'model',
1822
1822
  message,
1823
1823
  file,
@@ -1866,7 +1866,13 @@ callback and the shared sticky AI runtime state. Records include `phase`,
1866
1866
  `stage`, `message`, the current `file` when available, and `elapsedMs`.
1867
1867
  Known artifact inventories report `completed`, `total`, and `unit:'files'`.
1868
1868
  Upstream model files are discovered during loading, so their total remains
1869
- `null` while completed files are counted. Remaining TTS pool initialization
1869
+ `null` while completed files are counted. Generic upstream model callbacks use
1870
+ `phase:'load'`; upstream readiness or completion of the current file set uses
1871
+ `phase:'initialize'`. These callbacks do not establish a network transfer or a
1872
+ cache miss. Resource-fetch progress owns `phase:'download'`, while an existing
1873
+ DBOPFS resource reports `phase:'load'` with `cached:true`. A later generic model
1874
+ callback reports model loading without reclassifying that stored read as a
1875
+ download. Remaining TTS pool initialization
1870
1876
  reports completed model sessions. Consumers use an indeterminate progress bar
1871
1877
  when no final total is known; transfer quantities do not determine this display.
1872
1878
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "sdkVersion": "0.78.0",
3
+ "sdkVersion": "0.79.0",
4
4
  "environment": {
5
5
  "runtime": "Node.js for Node entrypoints; browser for browser-only entrypoints",
6
6
  "minimumVersion": "22.23.2 for Node entrypoints",
@@ -45,14 +45,14 @@
45
45
  "name": "createNativeDecisionModel",
46
46
  "displayName": "createNativeDecisionModel()",
47
47
  "kind": "function",
48
- "signature": "createNativeDecisionModel({onnx,modelAssets,workingDirectory,paths,model='onnx-community/laya-typed-decisions-ONNX',revision='main',dtype='fp32',sessionOptions,executionPreference='gpu',executionTarget,signal}={})",
48
+ "signature": "createNativeDecisionModel({onnx,modelAssets,workingDirectory,paths,assetProjectionId,resourcePaths,model='onnx-community/laya-typed-decisions-ONNX',revision='main',dtype='fp32',sessionOptions,executionPreference='gpu',executionTarget,signal}={})",
49
49
  "entrypoints": ["arcane-os/local-ai/decisions"],
50
50
  "primaryImport": "arcane-os/local-ai/decisions",
51
51
  "group": "Native typed decisions",
52
52
  "summary": "Explicit native Laya FP32 option scoring through an existing ONNX owner and a tokenizer Worker.",
53
53
  "availability": "Node on Windows/Linux/macOS with selected ONNX Runtime and model files; Android needs host adaptation",
54
54
  "protocol": "Shared browser/native decision encoding with native named tensor results",
55
- "normalization": "Returns load/evaluate/classify/current/status/subscribe/unload/dispose; preserves complete separate state/question/options/type and original rows, raw scores and every graph output. Explicit load prepares selected upstream files or uses caller-owned paths. load({executionTarget,signal}) retains the latest selected target when omitted or undefined, initially the factory selection; null requests automatic resolution, while {deviceId} forwards the saved physical selection to the existing ONNX owner. With no target selection, GPU preference still attempts advertised providers then CPU and explicit session providers retain precedence. Same-target loads share activation; a changed target cancels the earlier request and joins its actual ONNX/tokenizer cleanup before replacement. pendingActivation reports the requested replacement separately; current().execution remains the complete ONNX load record, with requested/resolved/configured/observed target diagnostics in current().execution.execution. Configuration alone does not prove physical execution. Cancellation retires the shared activation; files remain retained through actual ONNX and tokenizer Worker exit. No preference writes, chat history or duplicate inference engine."
55
+ "normalization": "Returns load/evaluate/classify/current/status/subscribe/unload/dispose; preserves complete separate state/question/options/type and original rows, raw scores and every graph output. load({assetProjectionId,resourcePaths,executionTarget,signal}) retains omitted or undefined source, mapping and target fields from the latest selection, initially the factory configuration. A supplied ready projection takes precedence over configured paths and upstream settings; the existing modelAssets owner retains it directly without upstream preparation or fetching. resourcePaths maps model, tokenizer and tokenizerConfig to exact projection member.path values; complete companion files remain at their original relative paths. Missing projections or mapped members report their actual failure. assetProjectionId:null returns to configured paths or existing upstream preparation when no paths were configured. Separately, executionTarget:null requests automatic resolution, while {deviceId} forwards the saved physical selection to the existing ONNX owner. With no target selection, GPU preference still attempts advertised providers then CPU and explicit session providers retain precedence. Equal source, complete mapping and target selections share activation; a changed selection cancels the earlier request and joins its actual ONNX/tokenizer cleanup before replacement. Incoming projection use is retained before retiring the previous activation; caller preparation ownership is independent, and native files remain retained through actual ONNX and tokenizer Worker exit. Unload ends native use; loading again after all ownership is released requires a fresh projection. pendingActivation reports the requested replacement and projected source/mapping separately; current().execution remains the complete ONNX load record, with requested/resolved/configured/observed target diagnostics in current().execution.execution. Configuration alone does not prove physical execution. Cancellation retires the shared activation. No preference writes, chat history or duplicate inference engine."
56
56
  },
57
57
  {
58
58
  "id": "core-decisions:createNativeDecisionService",
@@ -66,7 +66,7 @@
66
66
  "summary": "Renderer-independent native Laya service reusing the registered local-AI and model-assets owners.",
67
67
  "availability": "Node Core with selected native ONNX and model files",
68
68
  "protocol": "arcane/1 decisions.status/load/evaluate/unload and decisions.state",
69
- "normalization": "Registration starts no model or tokenizer. Explicit load obtains dependency-ready same-process owners and reacquires getONNXRuntime after recovery. Factory configuration accepts executionTarget; direct load({executionTarget,signal}) and Core decisions.load({executionTarget}) preserve omitted/undefined selection and explicit null automatic selection. Same-target loads coalesce; changed targets cancel pending work and join the prior model's disposal before replacement. pendingActivation remains separate from the full activation execution record and its nested execution diagnostics. Native load/evaluate/classify/current/subscribe/unload/dispose preserve complete results; RPC uses the existing tensor codec. Unload has accepted service lifetime; host shutdown cancels request-lifetime load/evaluation."
69
+ "normalization": "Registration starts no model or tokenizer. Explicit load obtains dependency-ready same-process owners and reacquires getONNXRuntime after recovery. Factory configuration accepts assetProjectionId, resourcePaths and executionTarget; direct load({assetProjectionId,resourcePaths,executionTarget,signal}) and Core decisions.load({assetProjectionId,resourcePaths,executionTarget}) preserve omitted/undefined selections, with the Core request signal forwarded separately. resourcePaths maps model, tokenizer and tokenizerConfig to exact ready projection member.path values; complete companion members retain their relative paths. Supplied projections take precedence over configured paths/upstream settings, are retained through the existing model-assets owner, and never invoke upstream preparation or fetching; unavailable projections or mapped members fail directly. assetProjectionId:null returns to configured paths or existing upstream preparation, independently of executionTarget:null requesting automatic device selection. Equal source, complete mapping and target selections coalesce; changed selections cancel pending work and join the prior model's disposal before replacement. Incoming retained ownership protects projection files while the previous activation retires; caller preparation ownership remains independent, and native use retains files through actual ONNX/tokenizer exit. pendingActivation includes a projected source/mapping separately from the full activation execution record and its nested execution diagnostics. Native load/evaluate/classify/current/subscribe/unload/dispose preserve complete results; RPC uses the existing tensor codec. Unload has accepted service lifetime; host shutdown cancels request-lifetime load/evaluation."
70
70
  },
71
71
  {
72
72
  "id": "core-decisions:default",
@@ -168,6 +168,39 @@ Closing a browser accessor does not by itself establish native unload.
168
168
  Explicitly release unused prepared projections. Core disposal also releases
169
169
  preparation ownership and cleans up after retained engine uses end.
170
170
 
171
+ ### Native Laya decisions
172
+
173
+ The existing native decision owner also consumes ready projections through
174
+ `decisions.load({assetProjectionId, resourcePaths, executionTarget?})` over Core,
175
+ or the direct/service `load` method with an optional `signal`. Supply all three
176
+ resource roles with exact projection member paths:
177
+
178
+ ```js
179
+ try {
180
+ await client.invoke('decisions.load', {
181
+ assetProjectionId: projection.id,
182
+ resourcePaths: {
183
+ model: 'onnx/model.onnx',
184
+ tokenizer: 'tokenizer.json',
185
+ tokenizerConfig: 'tokenizer_config.json'
186
+ }
187
+ }, {signal, timeoutMs: 0});
188
+ } finally {
189
+ await projection.release();
190
+ }
191
+ ```
192
+
193
+ The complete projection must also retain the graph's companion files, including
194
+ `onnx/model.onnx_data` for the selected Laya FP32 graph. The native owner resolves
195
+ the three roles from `retain(id).members` and uses those files directly without
196
+ calling `prepare` or downloading again. It releases its native use only after
197
+ actual ONNX and tokenizer exit; it never releases the caller's preparation
198
+ ownership. Replacement acquires incoming ownership before retiring the prior
199
+ activation, including when only its device changes. Unavailable or incomplete
200
+ selections report their errors without an upstream fallback. See
201
+ [native decisions](native-decisions.md#load-complete-files-already-stored-in-dbopfs)
202
+ for source selection, cancellation and complete row/output contracts.
203
+
171
204
  `current()` returns `{closing,projections}`. Each projection reports its `id`,
172
205
  `directory`, `state`, ordered `members`, `preparationOwned`, number of native
173
206
  `uses`, and `error`. Core emits `modelAssets.state` for lifecycle changes.
@@ -58,7 +58,7 @@ name to `decisions`. It exposes `load`, `evaluate`, `classify` (an alias for
58
58
  | Method | Parameters and result |
59
59
  | --- | --- |
60
60
  | `decisions.status` | Current lifecycle snapshot; does not load anything. |
61
- | `decisions.load` | `{executionTarget?}`, where the target is `{deviceId:string}` or `null`; explicit activation or target replacement, returning the ready lifecycle snapshot. The request signal cancels activation. |
61
+ | `decisions.load` | `{assetProjectionId?, resourcePaths?, executionTarget?}`; explicit activation or source/device replacement, returning the ready lifecycle snapshot. The request signal cancels activation. A target is `{deviceId:string}` or `null`. |
62
62
  | `decisions.evaluate` | `{rows, runOptions?}`; returns `{decisions, outputs}`. The request signal cancels the active model operation. |
63
63
  | `decisions.unload` | Releases the activation. Service-lifetime cleanup continues after the caller disconnects. |
64
64
 
@@ -100,6 +100,76 @@ caller-owned files. For SDK-prepared files, the default selected upstream is
100
100
  - `onnx/model.onnx` and `onnx/model.onnx_data`;
101
101
  - `tokenizer.json` and `tokenizer_config.json`.
102
102
 
103
+ ## Load complete files already stored in DBOPFS
104
+
105
+ Use the existing [model-assets projection](model-assets.md) for complete files
106
+ already obtained through the application's DBOPFS model store. Pass every
107
+ original member, including `onnx/model.onnx_data`, to `prepareCoreModelAssets`.
108
+ The original relative paths and complete content remain unchanged:
109
+
110
+ ```js
111
+ import {prepareCoreModelAssets} from 'arcane-os/ai/core-model-assets';
112
+
113
+ const projection = await prepareCoreModelAssets({
114
+ client,
115
+ workingDirectory: selectedWorkingDirectory,
116
+ members: [
117
+ {path: 'onnx/model.onnx', file: storedGraph},
118
+ {path: 'onnx/model.onnx_data', file: storedGraphData},
119
+ {path: 'tokenizer.json', file: storedTokenizer},
120
+ {path: 'tokenizer_config.json', file: storedTokenizerConfig}
121
+ ],
122
+ signal
123
+ });
124
+ try {
125
+ await client.invoke('decisions.load', {
126
+ assetProjectionId: projection.id,
127
+ resourcePaths: {
128
+ model: 'onnx/model.onnx',
129
+ tokenizer: 'tokenizer.json',
130
+ tokenizerConfig: 'tokenizer_config.json'
131
+ },
132
+ executionTarget: {deviceId: 'cpu'}
133
+ }, {signal, timeoutMs: 0});
134
+ } finally {
135
+ // The native activation owns a separate retain handle after loading.
136
+ await projection.release();
137
+ }
138
+ ```
139
+
140
+ Here the four `stored*` values are the complete original Blob/File members
141
+ from the application's existing store. This projects those files; it performs
142
+ no model download. The decision service must already be registered as above.
143
+ After loading, invoke `decisions.evaluate` with the complete `rows`, then
144
+ `decisions.unload` when that native activation is no longer needed.
145
+
146
+ Direct model and service calls accept
147
+ `load({assetProjectionId, resourcePaths, executionTarget, signal})`; their
148
+ factory configuration accepts the same source selection. A direct model needs
149
+ the existing `modelAssets` owner when selecting a projection. `resourcePaths`
150
+ maps all three roles (`model`, `tokenizer`, `tokenizerConfig`) to exact
151
+ `member.path` values in a ready projection. Additional companion members stay
152
+ in its retained directory; the graph finds its external data under the exact
153
+ relative filename it references. The SDK does not rename or reconstruct them.
154
+
155
+ A supplied projection is retained and used directly. It takes precedence over
156
+ configured `paths` and upstream settings and never invokes upstream preparation
157
+ or retries with downloaded files. Missing, unfinished or released projections,
158
+ and missing mapped resources, report their actual failure.
159
+
160
+ Omitted or `undefined` source fields retain the selected source and mappings.
161
+ `assetProjectionId: null` returns to the factory's configured `paths`, or its
162
+ existing upstream preparation when no paths were configured. Selecting another
163
+ projection or changing any resource mapping replaces the activation even when
164
+ the execution target stays the same. Equal source, mapping and device selections
165
+ coalesce. The incoming retain is acquired before the prior activation retires,
166
+ so device replacement can reuse a projection after the caller has released its
167
+ preparation ownership. Superseded pending retains are released as their cleanup
168
+ settles. Unload ends the native use; if no preparation or other use remains,
169
+ create a fresh projection from the stored originals before loading again.
170
+
171
+ ## Existing upstream preparation
172
+
103
173
  `model` selects the upstream Hugging Face repository and `revision` its
104
174
  revision. Native preparation performs normal upstream fetches only during
105
175
  explicit `load()`. It streams complete members through the existing working
@@ -160,13 +230,14 @@ which device executed graph nodes.
160
230
 
161
231
  `pendingActivation` is separately `null` or `{executionTarget}` while a load or
162
232
  replacement is pending; its `executionTarget` property is omitted at JSON
163
- transport when the selection is `undefined`. A pending target never overwrites the previous
233
+ transport when the selection is `undefined`. A projected selection also includes
234
+ `assetProjectionId` and its complete `resourcePaths` mapping. A pending target never overwrites the previous
164
235
  activation's `execution` record. The old record remains attributable to the
165
236
  retiring activation until its cleanup completes, then the new activation owns
166
237
  its own record. A request is not a claim that the requested device is running.
167
238
 
168
- Concurrent loads for the same target share their pending activation. A
169
- different target supersedes the earlier pending request, which rejects with
239
+ Concurrent loads for the same source, resource mapping and target share their
240
+ pending activation. A different selection supersedes the earlier pending request, which rejects with
170
241
  cancellation instead of returning another target's ready result. Replacement
171
242
  joins this model's actual tokenizer/native cleanup before creating its
172
243
  successor. Independent model owners retain their independent workers and
@@ -209,7 +280,7 @@ progress includes model preparation, tokenization and evaluation; downloaded
209
280
  members report files, not byte progress. Complete technical errors belong in
210
281
  developer diagnostics, while the application owns its user-facing status.
211
282
 
212
- Concurrent callers selecting the same target share one explicit activation.
283
+ Concurrent callers selecting the same source, mapping and target share one explicit activation.
213
284
  Cancelling a load or evaluation cancels that activation and its outstanding operations, because the
214
285
  native session and tokenizer have one owner. Use separately owned models when
215
286
  independent cancellation is required. The construction `signal` governs the
@@ -219,7 +290,8 @@ cleanup; `dispose()` is terminal. Readiness is revoked as disposal begins.
219
290
  Retained model files remain owned until the ONNX and tokenizer Workers really
220
291
  exit. A rejected load, inference or termination request alone does not permit
221
292
  file deletion. Cleanup joins active operations and native output delivery,
222
- preserves cleanup errors, then releases only its working projection. It never
293
+ preserves cleanup errors, then releases its native-use handle. Preparation
294
+ ownership of a supplied projection remains with its caller. It never
223
295
  deletes supplied `paths`. A failed ordinary row does not destroy a healthy
224
296
  loaded session or cancel sibling evaluations. The ONNX owner's `stopping:true`
225
297
  retires decision readiness immediately on terminal Worker failure or shutdown,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.79.0",
3
+ "version": "0.80.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -8,20 +8,35 @@ function sameExecutionTarget(left, right) {
8
8
  return left === right || (left != null && right != null && left.deviceId !== undefined && left.deviceId === right.deviceId);
9
9
  }
10
10
 
11
+ function sameActivation(left, right) {
12
+ if (!left || !right || !sameExecutionTarget(left.executionTarget, right.executionTarget) || left.assetProjectionId !== right.assetProjectionId) return false;
13
+ if (left.resourcePaths === right.resourcePaths) return true;
14
+ if (!left.resourcePaths || !right.resourcePaths) return false;
15
+ const entries = Object.entries(left.resourcePaths);
16
+ return entries.length === Object.keys(right.resourcePaths).length
17
+ && entries.every(function sameResource([role, member]) { return right.resourcePaths[role] === member; });
18
+ }
19
+
11
20
  /** App-owned selection; shared typed-decision execution, without a renderer. */
12
21
  export function createNativeDecisionService(configuration = {}, {appRoot = process.cwd()} = {}) {
13
22
  const events = createArcaneEventSource({}, {source: 'core-decisions', eventTypes: ['decisions.state']});
14
23
  const lifetime = new AbortController();
24
+ const loads = new Set();
25
+ const provisionalCleanupFailures = [];
15
26
  let context;
27
+ let modelAssetOwner;
16
28
  let model;
17
29
  let loading;
18
30
  let loadController;
19
31
  let releasing;
20
32
  let closing;
21
33
  let unsubscribe;
22
- let selectedTarget = configuration.executionTarget;
23
- let loadingTarget;
24
- let loadedTarget;
34
+ let selectedActivation = {
35
+ executionTarget: configuration.executionTarget,
36
+ ...(configuration.assetProjectionId == null ? {} : {assetProjectionId: configuration.assetProjectionId, resourcePaths: configuration.resourcePaths && {...configuration.resourcePaths}})
37
+ };
38
+ let loadingActivation;
39
+ let loadedActivation;
25
40
 
26
41
  function current() {
27
42
  const snapshot = model?.current() ?? {
@@ -30,10 +45,10 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
30
45
  state: closing ? 'disposed' : 'unloaded', loaded: false, busy: Boolean(loading),
31
46
  activeRequests: 0, progress: null, error: null, execution: null
32
47
  };
33
- const pending = Boolean(loading) && (!snapshot.loaded || !sameExecutionTarget(loadedTarget, loadingTarget));
48
+ const pending = Boolean(loading) && (!snapshot.loaded || !sameActivation(loadedActivation, loadingActivation));
34
49
  return {
35
50
  ...snapshot, busy: snapshot.busy || pending,
36
- pendingActivation: pending ? {executionTarget: loadingTarget} : snapshot.pendingActivation ?? null
51
+ pendingActivation: pending ? {...loadingActivation} : snapshot.pendingActivation ?? null
37
52
  };
38
53
  }
39
54
 
@@ -51,57 +66,92 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
51
66
 
52
67
  function operationSignal(signal) { return signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal; }
53
68
 
54
- function load({signal, executionTarget: requestedTarget} = {}) {
69
+ function load({signal, executionTarget: requestedTarget, assetProjectionId: requestedProjection, resourcePaths: requestedPaths} = {}) {
55
70
  operationSignal(signal).throwIfAborted();
56
71
  if (!context) throw new CoreError({code: 'CORE_NOT_READY', message: 'Register and start the decision service with Core before loading.'});
57
- const target = requestedTarget === undefined ? selectedTarget : requestedTarget;
58
- if (loading && !loadController.signal.aborted && sameExecutionTarget(loadingTarget, target)) return observeLoad(loading, loadController, signal);
59
- if (!loading && model?.current().loaded && sameExecutionTarget(loadedTarget, target)) return Promise.resolve(current());
72
+ const projection = requestedProjection === undefined ? selectedActivation.assetProjectionId : requestedProjection;
73
+ const mapping = requestedPaths === undefined ? selectedActivation.resourcePaths : requestedPaths;
74
+ const selection = {
75
+ executionTarget: requestedTarget === undefined ? selectedActivation.executionTarget : requestedTarget,
76
+ ...(projection == null ? {} : {assetProjectionId: projection, resourcePaths: mapping && typeof mapping === 'object' && !Array.isArray(mapping) ? {...mapping} : mapping})
77
+ };
78
+ if (loading && !loadController.signal.aborted && sameActivation(loadingActivation, selection)) return observeLoad(loading, loadController, signal);
79
+ if (!loading && model?.current().loaded && sameActivation(loadedActivation, selection)) return Promise.resolve(current());
60
80
  const previousLoad = loading;
61
81
  const previousController = loadController;
62
82
  const controller = new AbortController();
63
83
  const selectedSignal = AbortSignal.any([lifetime.signal, controller.signal]);
64
84
  const precedingRelease = releasing;
85
+ let retained;
86
+ let retainError;
65
87
  const task = Promise.resolve().then(async function loadSelectedModel() {
66
- selectedSignal.throwIfAborted();
67
- if (precedingRelease) await precedingRelease;
68
- if (previousLoad) await Promise.allSettled([previousLoad]);
69
- selectedSignal.throwIfAborted();
70
- if (model) await model.dispose();
71
- selectedSignal.throwIfAborted();
72
- unsubscribe?.();
73
- // Only the explicit load waits for its native dependencies. Merely
74
- // registering this service does not download a model or delay UI.
75
- const [localAI, modelAssets] = await Promise.all([
76
- context.getService('local-ai'),
77
- configuration.paths ? undefined : context.getService('model-assets')
78
- ]);
79
- selectedSignal.throwIfAborted();
80
- const paths = configuration.paths && Object.fromEntries(
81
- Object.entries(configuration.paths).map(function nativePath([name, value]) { return [name, path.resolve(appRoot, value)]; })
82
- );
83
- model = createNativeDecisionModel({
84
- ...configuration, paths, onnx: localAI.getONNXRuntime(), modelAssets, executionTarget: target,
85
- signal: lifetime.signal
86
- });
87
- unsubscribe = model.subscribe(function modelStateChanged() { publish(); });
88
- const result = await model.load({signal: selectedSignal});
89
- selectedSignal.throwIfAborted();
90
- loadedTarget = target;
91
- return result;
88
+ const replaced = new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision activation request was replaced.'});
89
+ try {
90
+ if (retainError) throw retainError;
91
+ selectedSignal.throwIfAborted();
92
+ if (loading !== task) throw replaced;
93
+ // Only explicit load waits for these owners. A provisional use
94
+ // protects incoming files while the preceding model retires.
95
+ const [localAI, modelAssets] = await Promise.all([
96
+ context.getService('local-ai'),
97
+ configuration.paths && selection.assetProjectionId === undefined ? undefined : context.getService('model-assets')
98
+ ]);
99
+ selectedSignal.throwIfAborted();
100
+ if (loading !== task) throw replaced;
101
+ if (modelAssets) modelAssetOwner = modelAssets;
102
+ if (selection.assetProjectionId !== undefined && !retained) retained = modelAssets.retain(selection.assetProjectionId);
103
+ selectedSignal.throwIfAborted();
104
+ if (loading !== task) throw replaced;
105
+ previousController?.abort(replaced);
106
+ if (precedingRelease) await precedingRelease;
107
+ if (previousLoad) await Promise.allSettled([previousLoad]);
108
+ selectedSignal.throwIfAborted();
109
+ if (loading !== task) throw replaced;
110
+ if (model) await model.dispose();
111
+ selectedSignal.throwIfAborted();
112
+ if (loading !== task) throw replaced;
113
+ unsubscribe?.();
114
+ const paths = configuration.paths && Object.fromEntries(
115
+ Object.entries(configuration.paths).map(function nativePath([name, value]) { return [name, path.resolve(appRoot, value)]; })
116
+ );
117
+ model = createNativeDecisionModel({
118
+ ...configuration, paths, onnx: localAI.getONNXRuntime(), modelAssets,
119
+ executionTarget: selection.executionTarget, assetProjectionId: selection.assetProjectionId,
120
+ resourcePaths: selection.resourcePaths, signal: lifetime.signal
121
+ });
122
+ unsubscribe = model.subscribe(function modelStateChanged() { publish(); });
123
+ const result = await model.load({signal: selectedSignal});
124
+ selectedSignal.throwIfAborted();
125
+ loadedActivation = selection;
126
+ return result;
127
+ } finally {
128
+ previousController?.abort(replaced);
129
+ try { await retained?.release(); }
130
+ catch (error) {
131
+ provisionalCleanupFailures.push(error);
132
+ throw error;
133
+ }
134
+ }
92
135
  });
93
136
  function settled() {
137
+ loads.delete(task);
94
138
  if (loading !== task) return;
95
139
  loading = undefined;
96
140
  loadController = undefined;
97
141
  publish();
98
142
  }
99
143
  task.then(settled, settled).catch(function reportStateFailure(error) { console.error('Native decision state publication failed.', error); });
144
+ loads.add(task);
100
145
  loading = task;
101
146
  loadController = controller;
102
- loadingTarget = target;
103
- selectedTarget = target;
104
- previousController?.abort(new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision activation request was replaced.'}));
147
+ loadingActivation = selection;
148
+ selectedActivation = selection;
149
+ // A pending predecessor may hold the final native use while its workers
150
+ // exit. Take our use before making that predecessor's cleanup runnable.
151
+ if (selection.assetProjectionId !== undefined && modelAssetOwner) {
152
+ try { retained = modelAssetOwner.retain(selection.assetProjectionId); }
153
+ catch (error) { retainError = error; }
154
+ }
105
155
  publish();
106
156
  return observeLoad(task, controller, signal);
107
157
  }
@@ -133,9 +183,12 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
133
183
  // rather than wait for it to download and activate first.
134
184
  loadController?.abort(new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision load was cancelled.'}));
135
185
  const task = model?.unload() ?? Promise.resolve();
136
- const acceptedLoad = loading;
186
+ const acceptedLoads = [...loads];
137
187
  releasing = Promise.resolve().then(async function releaseSelectedModel() {
138
- const results = await Promise.allSettled([task, acceptedLoad]);
188
+ const results = await Promise.allSettled([task, ...acceptedLoads]);
189
+ if (provisionalCleanupFailures.length) throw new AggregateError([
190
+ ...(results[0].status === 'rejected' ? [results[0].reason] : []), ...provisionalCleanupFailures
191
+ ], 'Releasing native decision resources failed.');
139
192
  if (results[0].status === 'rejected') throw results[0].reason;
140
193
  return current();
141
194
  });
@@ -149,7 +202,8 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
149
202
  const reason = new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision service is closing.'});
150
203
  closing = Promise.resolve().then(async function closeDecisionService() {
151
204
  const failures = [];
152
- try { await loading; } catch (error) { if (!lifetime.signal.aborted) failures.push(error); }
205
+ await Promise.allSettled([...loads]);
206
+ failures.push(...provisionalCleanupFailures);
153
207
  try { await model?.dispose(); } catch (error) { failures.push(error); }
154
208
  unsubscribe?.();
155
209
  publish();
@@ -165,7 +219,12 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
165
219
  start(serviceContext) { context = serviceContext; },
166
220
  methods: {
167
221
  'decisions.status': current,
168
- 'decisions.load': function loadRequest(parameters, request) { return load({executionTarget: parameters?.executionTarget, signal: request.signal}); },
222
+ 'decisions.load': function loadRequest(parameters, request) {
223
+ return load({
224
+ executionTarget: parameters?.executionTarget, assetProjectionId: parameters?.assetProjectionId,
225
+ resourcePaths: parameters?.resourcePaths, signal: request.signal
226
+ });
227
+ },
169
228
  'decisions.evaluate': async function evaluateRequest({rows, runOptions}, request) {
170
229
  const result = await evaluate(rows, {runOptions, signal: request.signal});
171
230
  // Typed arrays retain their complete values through Core's
@@ -17,18 +17,29 @@ function sameExecutionTarget(left, right) {
17
17
  return left === right || (left != null && right != null && left.deviceId !== undefined && left.deviceId === right.deviceId);
18
18
  }
19
19
 
20
+ function sameActivation(left, right) {
21
+ if (!sameExecutionTarget(left.executionTarget, right.executionTarget) || left.assetProjectionId !== right.assetProjectionId) return false;
22
+ if (left.resourcePaths === right.resourcePaths) return true;
23
+ if (!left.resourcePaths || !right.resourcePaths) return false;
24
+ const entries = Object.entries(left.resourcePaths);
25
+ return entries.length === Object.keys(right.resourcePaths).length
26
+ && entries.every(function sameResource([role, member]) { return right.resourcePaths[role] === member; });
27
+ }
28
+
20
29
  /**
21
30
  * One explicitly activated Laya FP32 model using an existing native ONNX owner.
22
31
  * It does not create another inference runtime or load on construction.
23
32
  */
24
33
  export function createNativeDecisionModel({
25
- onnx, modelAssets, workingDirectory, paths,
34
+ onnx, modelAssets, workingDirectory, paths, assetProjectionId, resourcePaths,
26
35
  model = 'onnx-community/laya-typed-decisions-ONNX', revision = 'main',
27
36
  dtype = 'fp32', sessionOptions, executionPreference = 'gpu', executionTarget, signal
28
37
  } = {}) {
29
38
  if (dtype !== 'fp32') throw new TypeError('This native Laya graph selection requires dtype fp32.');
30
39
  const owner = {};
31
40
  const events = createArcaneEventSource(owner, {source: 'native-decisions', eventTypes: [STATE_EVENT]});
41
+ const loads = new Set();
42
+ const provisionalCleanupFailures = [];
32
43
  let activation;
33
44
  let releaseTask;
34
45
  let disposal;
@@ -37,7 +48,10 @@ export function createNativeDecisionModel({
37
48
  let progress = null;
38
49
  let lastError = null;
39
50
  let execution = null;
40
- let selectedTarget = executionTarget;
51
+ let selectedActivation = {
52
+ executionTarget,
53
+ ...(assetProjectionId == null ? {} : {assetProjectionId, resourcePaths: resourcePaths && {...resourcePaths}})
54
+ };
41
55
  let pendingActivation;
42
56
 
43
57
  function current() {
@@ -48,7 +62,7 @@ export function createNativeDecisionModel({
48
62
  activeRequests: activation?.jobs.size ?? 0, progress,
49
63
  error: lastError ? serializeCoreError(lastError) : null,
50
64
  execution,
51
- pendingActivation: pending ? {executionTarget: pending.executionTarget} : null
65
+ pendingActivation: pending ? {...pending.selection} : null
52
66
  };
53
67
  }
54
68
 
@@ -131,6 +145,22 @@ export function createNativeDecisionModel({
131
145
  }
132
146
 
133
147
  async function prepareFiles(entry) {
148
+ if (entry.selection.assetProjectionId !== undefined) {
149
+ const mapping = entry.selection.resourcePaths;
150
+ if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping)) {
151
+ throw new TypeError('Projected decisions require resourcePaths for model, tokenizer and tokenizerConfig.');
152
+ }
153
+ const files = {};
154
+ for (const role of ['model', 'tokenizer', 'tokenizerConfig']) {
155
+ const member = entry.retained.members.find(function selectedMember(value) { return value.path === mapping[role]; });
156
+ if (!member) throw new CoreError({
157
+ code: 'ARCANE_DECISION_RESOURCE_UNAVAILABLE',
158
+ message: `The retained decision projection has no member ${String(mapping[role])} for ${role}.`
159
+ });
160
+ files[role] = member.nativePath;
161
+ }
162
+ return files;
163
+ }
134
164
  if (paths) return paths;
135
165
  if (!modelAssets) throw new CoreError({code: 'ARCANE_DECISION_ASSETS_UNAVAILABLE', message: 'Native decisions need prepared paths or the Core model-assets owner.'});
136
166
  const base = `https://huggingface.co/${model}/resolve/${encodeURIComponent(revision)}/`;
@@ -149,8 +179,13 @@ export function createNativeDecisionModel({
149
179
  return {model: files['onnx/model.onnx'], tokenizer: files['tokenizer.json'], tokenizerConfig: files['tokenizer_config.json']};
150
180
  }
151
181
 
152
- function beginActivation(target) {
153
- const entry = {id: randomUUID(), controller: new AbortController(), jobs: new Set(), prepared: false, onnxLoadStarted: false, executionTarget: target};
182
+ function beginActivation(request) {
183
+ const entry = {
184
+ id: randomUUID(), controller: new AbortController(), jobs: new Set(), prepared: false, onnxLoadStarted: false,
185
+ selection: request.selection, executionTarget: request.selection.executionTarget, retained: request.retained
186
+ };
187
+ request.retained = undefined;
188
+ request.entry = entry;
154
189
  activation = entry;
155
190
  state = 'loading';
156
191
  progress = {phase: 'preparing-model'};
@@ -223,33 +258,49 @@ export function createNativeDecisionModel({
223
258
  }).finally(function detachLoadCaller() { operationSignal?.removeEventListener('abort', abort); });
224
259
  }
225
260
 
226
- async function load({signal: operationSignal, executionTarget: requestedTarget} = {}) {
261
+ async function load({signal: operationSignal, executionTarget: requestedTarget, assetProjectionId: requestedProjection, resourcePaths: requestedPaths} = {}) {
227
262
  assertOpen(operationSignal);
228
- const target = requestedTarget === undefined ? selectedTarget : requestedTarget;
229
- if (pendingActivation && !pendingActivation.controller.signal.aborted && sameExecutionTarget(pendingActivation.executionTarget, target)) {
263
+ const projection = requestedProjection === undefined ? selectedActivation.assetProjectionId : requestedProjection;
264
+ const mapping = requestedPaths === undefined ? selectedActivation.resourcePaths : requestedPaths;
265
+ const selection = {
266
+ executionTarget: requestedTarget === undefined ? selectedActivation.executionTarget : requestedTarget,
267
+ ...(projection == null ? {} : {assetProjectionId: projection, resourcePaths: mapping && typeof mapping === 'object' && !Array.isArray(mapping) ? {...mapping} : mapping})
268
+ };
269
+ if (pendingActivation && !pendingActivation.controller.signal.aborted && sameActivation(pendingActivation.selection, selection)) {
230
270
  return observeLoad(pendingActivation, operationSignal);
231
271
  }
232
- if (!pendingActivation && activation && sameExecutionTarget(activation.executionTarget, target)) {
272
+ if (!pendingActivation && activation && sameActivation(activation.selection, selection)) {
233
273
  return awaitActivation(activation, operationSignal);
234
274
  }
235
275
  const previous = pendingActivation;
236
- const request = {executionTarget: target, controller: new AbortController()};
276
+ const request = {selection, controller: new AbortController()};
237
277
  let precedingRelease;
238
278
  // Retain the replacement before synchronous abort/state observers run.
239
279
  // Only this model's retiring activation must finish before its successor.
240
280
  request.task = Promise.resolve().then(async function loadSelectedTarget() {
241
- request.controller.signal.throwIfAborted();
242
- if (precedingRelease) await precedingRelease;
243
- assertOpen(request.controller.signal);
244
- if (pendingActivation !== request) throw cancellation('The decision activation request was replaced.');
245
- request.entry ??= activation ?? beginActivation(request.executionTarget);
246
- return awaitActivation(request.entry, request.controller.signal);
281
+ try {
282
+ if (request.error) throw request.error;
283
+ request.controller.signal.throwIfAborted();
284
+ if (precedingRelease) await precedingRelease;
285
+ assertOpen(request.controller.signal);
286
+ if (pendingActivation !== request) throw cancellation('The decision activation request was replaced.');
287
+ request.entry ??= activation ?? beginActivation(request);
288
+ return await awaitActivation(request.entry, request.controller.signal);
289
+ } finally {
290
+ // A superseded request may own files without ever starting workers.
291
+ try { await request.retained?.release(); }
292
+ catch (error) {
293
+ provisionalCleanupFailures.push(error);
294
+ throw error;
295
+ } finally { request.retained = undefined; }
296
+ }
247
297
  });
248
298
  function settled(error) {
299
+ loads.delete(request.task);
249
300
  request.detach?.();
250
301
  if (pendingActivation !== request) return;
251
302
  pendingActivation = undefined;
252
- if (!request.entry && !activation && !disposed && state === 'loading') {
303
+ if (!request.entry && !activation && !disposed && (state === 'loading' || request.error)) {
253
304
  state = request.controller.signal.aborted ? 'unloaded' : 'error';
254
305
  progress = null;
255
306
  lastError = request.controller.signal.aborted ? null : error;
@@ -257,14 +308,24 @@ export function createNativeDecisionModel({
257
308
  publish();
258
309
  }
259
310
  request.task.then(function loaded() { settled(); }, function loadFailed(error) { settled(error); }).catch(reportCleanupFailure);
311
+ loads.add(request.task);
260
312
  pendingActivation = request;
261
- selectedTarget = target;
262
- const reason = cancellation('The decision execution target was replaced.');
313
+ selectedActivation = selection;
314
+ try {
315
+ if (selection.assetProjectionId !== undefined) {
316
+ if (!modelAssets) throw new CoreError({code: 'ARCANE_DECISION_ASSETS_UNAVAILABLE', message: 'Projected decisions require the Core model-assets owner.'});
317
+ // retain is synchronous at the model-assets public boundary.
318
+ // Acquire before retiring the last native use of these files.
319
+ request.retained = modelAssets.retain(selection.assetProjectionId);
320
+ }
321
+ } catch (error) { request.error = error; }
322
+ const reason = cancellation('The decision activation was replaced.');
323
+ if (pendingActivation !== request) request.controller.abort(reason);
263
324
  previous?.controller.abort(reason);
264
- if (activation && !sameExecutionTarget(activation.executionTarget, target)) stop(activation, reason);
325
+ if (!request.error && !request.controller.signal.aborted && activation && !sameActivation(activation.selection, selection)) stop(activation, reason);
265
326
  precedingRelease = !activation ? releaseTask : undefined;
266
- if (!activation && !precedingRelease) {
267
- request.entry = beginActivation(target);
327
+ if (!request.error && !request.controller.signal.aborted && !activation && !precedingRelease) {
328
+ request.entry = beginActivation(request);
268
329
  request.detach = observeCancellation(request.entry, request.controller.signal);
269
330
  }
270
331
  publish();
@@ -316,8 +377,11 @@ export function createNativeDecisionModel({
316
377
  const pending = pendingActivation;
317
378
  pending?.controller.abort(reason);
318
379
  const release = activation ? stop(activation, reason) : releaseTask ?? Promise.resolve(current());
319
- if (!pending) return release;
320
- return Promise.allSettled([release, pending.task]).then(function releasedActivation(results) {
380
+ if (!loads.size && !provisionalCleanupFailures.length) return release;
381
+ return Promise.allSettled([release, ...loads]).then(function releasedActivation(results) {
382
+ if (provisionalCleanupFailures.length) throw new AggregateError([
383
+ ...(results[0].status === 'rejected' ? [results[0].reason] : []), ...provisionalCleanupFailures
384
+ ], 'Releasing native decision resources failed.');
321
385
  if (results[0].status === 'rejected') throw results[0].reason;
322
386
  return current();
323
387
  });
@@ -332,7 +396,10 @@ export function createNativeDecisionModel({
332
396
  let release;
333
397
  disposal = Promise.resolve().then(async function disposeNativeModel() {
334
398
  try {
335
- const results = await Promise.allSettled([release, pending?.task]);
399
+ const results = await Promise.allSettled([release, ...loads]);
400
+ if (provisionalCleanupFailures.length) throw new AggregateError([
401
+ ...(results[0].status === 'rejected' ? [results[0].reason] : []), ...provisionalCleanupFailures
402
+ ], 'Releasing native decision resources failed.');
336
403
  if (results[0].status === 'rejected') throw results[0].reason;
337
404
  }
338
405
  finally { state = 'disposed'; publish(); events.dispose(); }