arcane-os 0.79.1 → 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 +18 -0
- package/README.md +1 -1
- package/docs/reference/inventory/package-api.json +3 -3
- package/docs/reference/model-assets.md +33 -0
- package/docs/reference/native-decisions.md +78 -6
- package/package.json +1 -1
- package/src/core/services/decisions.mjs +101 -42
- package/src/local-ai/decisions.mjs +92 -25
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
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
|
+
|
|
3
21
|
## 0.79.1
|
|
4
22
|
|
|
5
23
|
- Classify generic speech-model progress callbacks as loading, reserving
|
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.
|
|
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;
|
|
@@ -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.
|
|
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
|
|
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?}
|
|
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
|
|
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
|
|
169
|
-
different
|
|
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
|
|
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
|
@@ -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
|
|
23
|
-
|
|
24
|
-
|
|
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 || !
|
|
48
|
+
const pending = Boolean(loading) && (!snapshot.loaded || !sameActivation(loadedActivation, loadingActivation));
|
|
34
49
|
return {
|
|
35
50
|
...snapshot, busy: snapshot.busy || pending,
|
|
36
|
-
pendingActivation: pending ? {
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
186
|
+
const acceptedLoads = [...loads];
|
|
137
187
|
releasing = Promise.resolve().then(async function releaseSelectedModel() {
|
|
138
|
-
const results = await Promise.allSettled([task,
|
|
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
|
-
|
|
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) {
|
|
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
|
|
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 ? {
|
|
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(
|
|
153
|
-
const entry = {
|
|
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
|
|
229
|
-
|
|
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 &&
|
|
272
|
+
if (!pendingActivation && activation && sameActivation(activation.selection, selection)) {
|
|
233
273
|
return awaitActivation(activation, operationSignal);
|
|
234
274
|
}
|
|
235
275
|
const previous = pendingActivation;
|
|
236
|
-
const request = {
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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 && !
|
|
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(
|
|
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 (!
|
|
320
|
-
return Promise.allSettled([release,
|
|
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,
|
|
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(); }
|