arcane-os 0.79.1 → 0.81.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.
@@ -8,32 +8,52 @@ 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.family !== right.family || left.model !== right.model || left.revision !== right.revision || left.dtype !== right.dtype) return false;
14
+ if (left.resourcePaths === right.resourcePaths) return true;
15
+ if (!left.resourcePaths || !right.resourcePaths) return false;
16
+ const entries = Object.entries(left.resourcePaths);
17
+ return entries.length === Object.keys(right.resourcePaths).length
18
+ && entries.every(function sameResource([role, member]) { return right.resourcePaths[role] === member; });
19
+ }
20
+
11
21
  /** App-owned selection; shared typed-decision execution, without a renderer. */
12
22
  export function createNativeDecisionService(configuration = {}, {appRoot = process.cwd()} = {}) {
13
23
  const events = createArcaneEventSource({}, {source: 'core-decisions', eventTypes: ['decisions.state']});
14
24
  const lifetime = new AbortController();
25
+ const loads = new Set();
26
+ const provisionalCleanupFailures = [];
15
27
  let context;
28
+ let modelAssetOwner;
16
29
  let model;
17
30
  let loading;
18
31
  let loadController;
19
32
  let releasing;
20
33
  let closing;
21
34
  let unsubscribe;
22
- let selectedTarget = configuration.executionTarget;
23
- let loadingTarget;
24
- let loadedTarget;
35
+ let selectedActivation = {
36
+ family: configuration.family === undefined ? 'laya' : configuration.family,
37
+ model: configuration.model === undefined ? 'onnx-community/laya-typed-decisions-ONNX' : configuration.model,
38
+ revision: configuration.revision === undefined ? 'main' : configuration.revision,
39
+ dtype: configuration.dtype === undefined ? 'fp32' : configuration.dtype,
40
+ executionTarget: configuration.executionTarget,
41
+ ...(configuration.assetProjectionId == null ? {} : {assetProjectionId: configuration.assetProjectionId, resourcePaths: configuration.resourcePaths && {...configuration.resourcePaths}})
42
+ };
43
+ let loadingActivation;
44
+ let loadedActivation;
25
45
 
26
46
  function current() {
27
47
  const snapshot = model?.current() ?? {
28
- family: 'laya', model: configuration.model ?? 'onnx-community/laya-typed-decisions-ONNX',
48
+ family: configuration.family ?? 'laya', model: configuration.model ?? 'onnx-community/laya-typed-decisions-ONNX',
29
49
  revision: configuration.revision ?? 'main', dtype: configuration.dtype ?? 'fp32',
30
50
  state: closing ? 'disposed' : 'unloaded', loaded: false, busy: Boolean(loading),
31
51
  activeRequests: 0, progress: null, error: null, execution: null
32
52
  };
33
- const pending = Boolean(loading) && (!snapshot.loaded || !sameExecutionTarget(loadedTarget, loadingTarget));
53
+ const pending = Boolean(loading) && (!snapshot.loaded || !sameActivation(loadedActivation, loadingActivation));
34
54
  return {
35
55
  ...snapshot, busy: snapshot.busy || pending,
36
- pendingActivation: pending ? {executionTarget: loadingTarget} : snapshot.pendingActivation ?? null
56
+ pendingActivation: pending ? {...loadingActivation} : snapshot.pendingActivation ?? null
37
57
  };
38
58
  }
39
59
 
@@ -51,57 +71,100 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
51
71
 
52
72
  function operationSignal(signal) { return signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal; }
53
73
 
54
- function load({signal, executionTarget: requestedTarget} = {}) {
74
+ function load({
75
+ family: requestedFamily, model: requestedModel, revision: requestedRevision, dtype: requestedDtype,
76
+ signal, executionTarget: requestedTarget, assetProjectionId: requestedProjection, resourcePaths: requestedPaths
77
+ } = {}) {
55
78
  operationSignal(signal).throwIfAborted();
56
79
  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());
80
+ const projection = requestedProjection === undefined ? selectedActivation.assetProjectionId : requestedProjection;
81
+ const mapping = requestedPaths === undefined ? selectedActivation.resourcePaths : requestedPaths;
82
+ const selection = {
83
+ family: requestedFamily === undefined ? selectedActivation.family : requestedFamily,
84
+ model: requestedModel === undefined ? selectedActivation.model : requestedModel,
85
+ revision: requestedRevision === undefined ? selectedActivation.revision : requestedRevision,
86
+ dtype: requestedDtype === undefined ? selectedActivation.dtype : requestedDtype,
87
+ executionTarget: requestedTarget === undefined ? selectedActivation.executionTarget : requestedTarget,
88
+ ...(projection == null ? {} : {assetProjectionId: projection, resourcePaths: mapping && typeof mapping === 'object' && !Array.isArray(mapping) ? {...mapping} : mapping})
89
+ };
90
+ if (loading && !loadController.signal.aborted && sameActivation(loadingActivation, selection)) return observeLoad(loading, loadController, signal);
91
+ if (!loading && model?.current().loaded && sameActivation(loadedActivation, selection)) return Promise.resolve(current());
60
92
  const previousLoad = loading;
61
93
  const previousController = loadController;
62
94
  const controller = new AbortController();
63
95
  const selectedSignal = AbortSignal.any([lifetime.signal, controller.signal]);
64
96
  const precedingRelease = releasing;
97
+ let retained;
98
+ let retainError;
65
99
  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;
100
+ const replaced = new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision activation request was replaced.'});
101
+ try {
102
+ if (retainError) throw retainError;
103
+ selectedSignal.throwIfAborted();
104
+ if (loading !== task) throw replaced;
105
+ // Only explicit load waits for these owners. A provisional use
106
+ // protects incoming files while the preceding model retires.
107
+ const [localAI, modelAssets] = await Promise.all([
108
+ context.getService('local-ai'),
109
+ configuration.paths && selection.assetProjectionId === undefined ? undefined : context.getService('model-assets')
110
+ ]);
111
+ selectedSignal.throwIfAborted();
112
+ if (loading !== task) throw replaced;
113
+ if (modelAssets) modelAssetOwner = modelAssets;
114
+ if (selection.assetProjectionId !== undefined && !retained) retained = modelAssets.retain(selection.assetProjectionId);
115
+ selectedSignal.throwIfAborted();
116
+ if (loading !== task) throw replaced;
117
+ previousController?.abort(replaced);
118
+ if (precedingRelease) await precedingRelease;
119
+ if (previousLoad) await Promise.allSettled([previousLoad]);
120
+ selectedSignal.throwIfAborted();
121
+ if (loading !== task) throw replaced;
122
+ if (model) await model.dispose();
123
+ selectedSignal.throwIfAborted();
124
+ if (loading !== task) throw replaced;
125
+ unsubscribe?.();
126
+ const paths = configuration.paths && Object.fromEntries(
127
+ Object.entries(configuration.paths).map(function nativePath([name, value]) { return [name, path.resolve(appRoot, value)]; })
128
+ );
129
+ model = createNativeDecisionModel({
130
+ ...configuration, paths, onnx: localAI.getONNXRuntime(), modelAssets,
131
+ family: selection.family, model: selection.model, revision: selection.revision, dtype: selection.dtype,
132
+ executionTarget: selection.executionTarget, assetProjectionId: selection.assetProjectionId,
133
+ resourcePaths: selection.resourcePaths, signal: lifetime.signal
134
+ });
135
+ unsubscribe = model.subscribe(function modelStateChanged() { publish(); });
136
+ const result = await model.load({signal: selectedSignal});
137
+ selectedSignal.throwIfAborted();
138
+ loadedActivation = selection;
139
+ return result;
140
+ } finally {
141
+ previousController?.abort(replaced);
142
+ try { await retained?.release(); }
143
+ catch (error) {
144
+ provisionalCleanupFailures.push(error);
145
+ throw error;
146
+ }
147
+ }
92
148
  });
93
149
  function settled() {
150
+ loads.delete(task);
94
151
  if (loading !== task) return;
95
152
  loading = undefined;
96
153
  loadController = undefined;
97
154
  publish();
98
155
  }
99
156
  task.then(settled, settled).catch(function reportStateFailure(error) { console.error('Native decision state publication failed.', error); });
157
+ loads.add(task);
100
158
  loading = task;
101
159
  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.'}));
160
+ loadingActivation = selection;
161
+ selectedActivation = selection;
162
+ // A pending predecessor may hold the final native use while its workers
163
+ // exit. Take our use before making that predecessor's cleanup runnable.
164
+ if (selection.assetProjectionId !== undefined && modelAssetOwner) {
165
+ try { retained = modelAssetOwner.retain(selection.assetProjectionId); }
166
+ catch (error) { retainError = error; }
167
+ }
105
168
  publish();
106
169
  return observeLoad(task, controller, signal);
107
170
  }
@@ -133,9 +196,12 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
133
196
  // rather than wait for it to download and activate first.
134
197
  loadController?.abort(new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision load was cancelled.'}));
135
198
  const task = model?.unload() ?? Promise.resolve();
136
- const acceptedLoad = loading;
199
+ const acceptedLoads = [...loads];
137
200
  releasing = Promise.resolve().then(async function releaseSelectedModel() {
138
- const results = await Promise.allSettled([task, acceptedLoad]);
201
+ const results = await Promise.allSettled([task, ...acceptedLoads]);
202
+ if (provisionalCleanupFailures.length) throw new AggregateError([
203
+ ...(results[0].status === 'rejected' ? [results[0].reason] : []), ...provisionalCleanupFailures
204
+ ], 'Releasing native decision resources failed.');
139
205
  if (results[0].status === 'rejected') throw results[0].reason;
140
206
  return current();
141
207
  });
@@ -149,7 +215,8 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
149
215
  const reason = new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision service is closing.'});
150
216
  closing = Promise.resolve().then(async function closeDecisionService() {
151
217
  const failures = [];
152
- try { await loading; } catch (error) { if (!lifetime.signal.aborted) failures.push(error); }
218
+ await Promise.allSettled([...loads]);
219
+ failures.push(...provisionalCleanupFailures);
153
220
  try { await model?.dispose(); } catch (error) { failures.push(error); }
154
221
  unsubscribe?.();
155
222
  publish();
@@ -165,7 +232,13 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
165
232
  start(serviceContext) { context = serviceContext; },
166
233
  methods: {
167
234
  'decisions.status': current,
168
- 'decisions.load': function loadRequest(parameters, request) { return load({executionTarget: parameters?.executionTarget, signal: request.signal}); },
235
+ 'decisions.load': function loadRequest(parameters, request) {
236
+ return load({
237
+ family: parameters?.family, model: parameters?.model, revision: parameters?.revision, dtype: parameters?.dtype,
238
+ executionTarget: parameters?.executionTarget, assetProjectionId: parameters?.assetProjectionId,
239
+ resourcePaths: parameters?.resourcePaths, signal: request.signal
240
+ });
241
+ },
169
242
  'decisions.evaluate': async function evaluateRequest({rows, runOptions}, request) {
170
243
  const result = await evaluate(rows, {runOptions, signal: request.signal});
171
244
  // Typed arrays retain their complete values through Core's
@@ -17,7 +17,7 @@ try {
17
17
  };
18
18
  parentPort.on('message', function encodeRequest({id, rows}) {
19
19
  try {
20
- const {encoded, pad} = encodeDecisionRows(interfaceAdapter, rows, 'laya');
20
+ const {encoded, pad} = encodeDecisionRows(interfaceAdapter, rows, workerData.family);
21
21
  const inputs = createDecisionInputs(encoded, pad);
22
22
  parentPort.postMessage({id, result: inputs});
23
23
  } catch (error) {
@@ -6,7 +6,7 @@ function cancelled() {
6
6
  }
7
7
 
8
8
  /** One tokenizer worker owned by one native model activation. */
9
- export function createDecisionTokenizer({tokenizerPath, tokenizerConfigPath, signal, onError}) {
9
+ export function createDecisionTokenizer({family = 'laya', tokenizerPath, tokenizerConfigPath, signal, onError}) {
10
10
  signal?.throwIfAborted();
11
11
  const pending = new Map();
12
12
  let nextId = 0;
@@ -14,7 +14,7 @@ export function createDecisionTokenizer({tokenizerPath, tokenizerConfigPath, sig
14
14
  let terminalError;
15
15
  let exited = false;
16
16
  const worker = new Worker(new URL('./decision-tokenizer-worker.mjs', import.meta.url), {
17
- workerData: {tokenizerPath, tokenizerConfigPath}
17
+ workerData: {family, tokenizerPath, tokenizerConfigPath}
18
18
  });
19
19
  let resolveExit;
20
20
  const exit = new Promise(function tokenizerExit(resolve) { resolveExit = resolve; });
@@ -5,7 +5,13 @@ import {decodeDecisionOutputs} from '../../browser-runtime/ai/decision-runtime.m
5
5
  import {createDecisionTokenizer} from './decision-tokenizer.mjs';
6
6
 
7
7
  const STATE_EVENT = 'ai.decisions.state';
8
- const LAYA_FILES = ['onnx/model.onnx', 'onnx/model.onnx_data', 'tokenizer.json', 'tokenizer_config.json'];
8
+
9
+ function selectedGraph(family, dtype) {
10
+ if (family === 'laya' && dtype === 'fp32') return 'onnx/model.onnx';
11
+ if (family === 'laya' && dtype === 'fp16') return 'onnx/model_fp16.onnx';
12
+ if (family === 'julia' && dtype === 'fp32') return 'model.onnx';
13
+ throw new TypeError('Native decisions support Laya fp32/fp16 and Julia fp32 graph selections.');
14
+ }
9
15
 
10
16
  function cancellation(message) {
11
17
  return new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message});
@@ -17,18 +23,30 @@ function sameExecutionTarget(left, right) {
17
23
  return left === right || (left != null && right != null && left.deviceId !== undefined && left.deviceId === right.deviceId);
18
24
  }
19
25
 
26
+ function sameActivation(left, right) {
27
+ if (left.family !== right.family || left.model !== right.model || left.revision !== right.revision || left.dtype !== right.dtype) return false;
28
+ if (!sameExecutionTarget(left.executionTarget, right.executionTarget) || left.assetProjectionId !== right.assetProjectionId) return false;
29
+ if (left.resourcePaths === right.resourcePaths) return true;
30
+ if (!left.resourcePaths || !right.resourcePaths) return false;
31
+ const entries = Object.entries(left.resourcePaths);
32
+ return entries.length === Object.keys(right.resourcePaths).length
33
+ && entries.every(function sameResource([role, member]) { return right.resourcePaths[role] === member; });
34
+ }
35
+
20
36
  /**
21
- * One explicitly activated Laya FP32 model using an existing native ONNX owner.
37
+ * One explicitly activated typed-decision model using an existing native ONNX owner.
22
38
  * It does not create another inference runtime or load on construction.
23
39
  */
24
40
  export function createNativeDecisionModel({
25
- onnx, modelAssets, workingDirectory, paths,
26
- model = 'onnx-community/laya-typed-decisions-ONNX', revision = 'main',
41
+ onnx, modelAssets, workingDirectory, paths, assetProjectionId, resourcePaths,
42
+ family = 'laya', model = 'onnx-community/laya-typed-decisions-ONNX', revision = 'main',
27
43
  dtype = 'fp32', sessionOptions, executionPreference = 'gpu', executionTarget, signal
28
44
  } = {}) {
29
- if (dtype !== 'fp32') throw new TypeError('This native Laya graph selection requires dtype fp32.');
45
+ selectedGraph(family, dtype);
30
46
  const owner = {};
31
47
  const events = createArcaneEventSource(owner, {source: 'native-decisions', eventTypes: [STATE_EVENT]});
48
+ const loads = new Set();
49
+ const provisionalCleanupFailures = [];
32
50
  let activation;
33
51
  let releaseTask;
34
52
  let disposal;
@@ -37,18 +55,23 @@ export function createNativeDecisionModel({
37
55
  let progress = null;
38
56
  let lastError = null;
39
57
  let execution = null;
40
- let selectedTarget = executionTarget;
58
+ let selectedActivation = {
59
+ family, model, revision, dtype, executionTarget,
60
+ ...(assetProjectionId == null ? {} : {assetProjectionId, resourcePaths: resourcePaths && {...resourcePaths}})
61
+ };
62
+ let activeSelection = selectedActivation;
41
63
  let pendingActivation;
42
64
 
43
65
  function current() {
44
66
  const pending = pendingActivation && (state !== 'ready' || pendingActivation.entry !== activation) ? pendingActivation : null;
45
67
  return {
46
- family: 'laya', model, revision, dtype, state,
68
+ family: activeSelection.family, model: activeSelection.model,
69
+ revision: activeSelection.revision, dtype: activeSelection.dtype, state,
47
70
  loaded: state === 'ready', busy: state === 'loading' || Boolean(pending) || Boolean(activation?.jobs.size),
48
71
  activeRequests: activation?.jobs.size ?? 0, progress,
49
72
  error: lastError ? serializeCoreError(lastError) : null,
50
73
  execution,
51
- pendingActivation: pending ? {executionTarget: pending.executionTarget} : null
74
+ pendingActivation: pending ? {...pending.selection} : null
52
75
  };
53
76
  }
54
77
 
@@ -131,12 +154,32 @@ export function createNativeDecisionModel({
131
154
  }
132
155
 
133
156
  async function prepareFiles(entry) {
157
+ if (entry.selection.assetProjectionId !== undefined) {
158
+ const mapping = entry.selection.resourcePaths;
159
+ if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping)) {
160
+ throw new TypeError('Projected decisions require resourcePaths for model, tokenizer and tokenizerConfig.');
161
+ }
162
+ const files = {};
163
+ for (const role of ['model', 'tokenizer', 'tokenizerConfig']) {
164
+ const member = entry.retained.members.find(function selectedMember(value) { return value.path === mapping[role]; });
165
+ if (!member) throw new CoreError({
166
+ code: 'ARCANE_DECISION_RESOURCE_UNAVAILABLE',
167
+ message: `The retained decision projection has no member ${String(mapping[role])} for ${role}.`
168
+ });
169
+ files[role] = member.nativePath;
170
+ }
171
+ return files;
172
+ }
134
173
  if (paths) return paths;
135
174
  if (!modelAssets) throw new CoreError({code: 'ARCANE_DECISION_ASSETS_UNAVAILABLE', message: 'Native decisions need prepared paths or the Core model-assets owner.'});
136
- const base = `https://huggingface.co/${model}/resolve/${encodeURIComponent(revision)}/`;
175
+ const {family: selectedFamily, model: selectedModel, revision: selectedRevision, dtype: selectedDtype} = entry.selection;
176
+ const graph = selectedGraph(selectedFamily, selectedDtype);
177
+ const companion = selectedFamily === 'laya' ? `${graph}_data` : `${graph}.data`;
178
+ const members = [graph, companion, 'tokenizer.json', 'tokenizer_config.json'];
179
+ const base = `https://huggingface.co/${selectedModel}/resolve/${encodeURIComponent(selectedRevision)}/`;
137
180
  const projection = await modelAssets.prepare({
138
181
  id: entry.id, workingDirectory,
139
- members: LAYA_FILES.map(function selectFile(path) { return {path, url: new URL(path, base).href}; }),
182
+ members: members.map(function selectFile(path) { return {path, url: new URL(path, base).href}; }),
140
183
  signal: entry.controller.signal,
141
184
  onProgress(value) { updateProgress(entry, value); }
142
185
  });
@@ -146,12 +189,18 @@ export function createNativeDecisionModel({
146
189
  await modelAssets.release(entry.id);
147
190
  entry.prepared = false;
148
191
  const files = Object.fromEntries(projection.members.map(function memberFile(member) { return [member.path, member.nativePath]; }));
149
- return {model: files['onnx/model.onnx'], tokenizer: files['tokenizer.json'], tokenizerConfig: files['tokenizer_config.json']};
192
+ return {model: files[graph], tokenizer: files['tokenizer.json'], tokenizerConfig: files['tokenizer_config.json']};
150
193
  }
151
194
 
152
- function beginActivation(target) {
153
- const entry = {id: randomUUID(), controller: new AbortController(), jobs: new Set(), prepared: false, onnxLoadStarted: false, executionTarget: target};
195
+ function beginActivation(request) {
196
+ const entry = {
197
+ id: randomUUID(), controller: new AbortController(), jobs: new Set(), prepared: false, onnxLoadStarted: false,
198
+ selection: request.selection, executionTarget: request.selection.executionTarget, retained: request.retained
199
+ };
200
+ request.retained = undefined;
201
+ request.entry = entry;
154
202
  activation = entry;
203
+ activeSelection = entry.selection;
155
204
  state = 'loading';
156
205
  progress = {phase: 'preparing-model'};
157
206
  lastError = null;
@@ -161,6 +210,7 @@ export function createNativeDecisionModel({
161
210
  entry.controller.signal.throwIfAborted();
162
211
  updateProgress(entry, {phase: 'loading-model'});
163
212
  entry.tokenizer = createDecisionTokenizer({
213
+ family: entry.selection.family,
164
214
  tokenizerPath: files.tokenizer, tokenizerConfigPath: files.tokenizerConfig,
165
215
  signal: entry.controller.signal,
166
216
  onError(error) {
@@ -223,33 +273,57 @@ export function createNativeDecisionModel({
223
273
  }).finally(function detachLoadCaller() { operationSignal?.removeEventListener('abort', abort); });
224
274
  }
225
275
 
226
- async function load({signal: operationSignal, executionTarget: requestedTarget} = {}) {
276
+ async function load({
277
+ family: requestedFamily, model: requestedModel, revision: requestedRevision, dtype: requestedDtype,
278
+ signal: operationSignal, executionTarget: requestedTarget, assetProjectionId: requestedProjection, resourcePaths: requestedPaths
279
+ } = {}) {
227
280
  assertOpen(operationSignal);
228
- const target = requestedTarget === undefined ? selectedTarget : requestedTarget;
229
- if (pendingActivation && !pendingActivation.controller.signal.aborted && sameExecutionTarget(pendingActivation.executionTarget, target)) {
281
+ const projection = requestedProjection === undefined ? selectedActivation.assetProjectionId : requestedProjection;
282
+ const mapping = requestedPaths === undefined ? selectedActivation.resourcePaths : requestedPaths;
283
+ const selection = {
284
+ family: requestedFamily === undefined ? selectedActivation.family : requestedFamily,
285
+ model: requestedModel === undefined ? selectedActivation.model : requestedModel,
286
+ revision: requestedRevision === undefined ? selectedActivation.revision : requestedRevision,
287
+ dtype: requestedDtype === undefined ? selectedActivation.dtype : requestedDtype,
288
+ executionTarget: requestedTarget === undefined ? selectedActivation.executionTarget : requestedTarget,
289
+ ...(projection == null ? {} : {assetProjectionId: projection, resourcePaths: mapping && typeof mapping === 'object' && !Array.isArray(mapping) ? {...mapping} : mapping})
290
+ };
291
+ selectedGraph(selection.family, selection.dtype);
292
+ if (pendingActivation && !pendingActivation.controller.signal.aborted && sameActivation(pendingActivation.selection, selection)) {
230
293
  return observeLoad(pendingActivation, operationSignal);
231
294
  }
232
- if (!pendingActivation && activation && sameExecutionTarget(activation.executionTarget, target)) {
295
+ if (!pendingActivation && activation && sameActivation(activation.selection, selection)) {
233
296
  return awaitActivation(activation, operationSignal);
234
297
  }
235
298
  const previous = pendingActivation;
236
- const request = {executionTarget: target, controller: new AbortController()};
299
+ const request = {selection, controller: new AbortController()};
237
300
  let precedingRelease;
238
301
  // Retain the replacement before synchronous abort/state observers run.
239
302
  // Only this model's retiring activation must finish before its successor.
240
303
  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);
304
+ try {
305
+ if (request.error) throw request.error;
306
+ request.controller.signal.throwIfAborted();
307
+ if (precedingRelease) await precedingRelease;
308
+ assertOpen(request.controller.signal);
309
+ if (pendingActivation !== request) throw cancellation('The decision activation request was replaced.');
310
+ request.entry ??= activation ?? beginActivation(request);
311
+ return await awaitActivation(request.entry, request.controller.signal);
312
+ } finally {
313
+ // A superseded request may own files without ever starting workers.
314
+ try { await request.retained?.release(); }
315
+ catch (error) {
316
+ provisionalCleanupFailures.push(error);
317
+ throw error;
318
+ } finally { request.retained = undefined; }
319
+ }
247
320
  });
248
321
  function settled(error) {
322
+ loads.delete(request.task);
249
323
  request.detach?.();
250
324
  if (pendingActivation !== request) return;
251
325
  pendingActivation = undefined;
252
- if (!request.entry && !activation && !disposed && state === 'loading') {
326
+ if (!request.entry && !activation && !disposed && (state === 'loading' || request.error)) {
253
327
  state = request.controller.signal.aborted ? 'unloaded' : 'error';
254
328
  progress = null;
255
329
  lastError = request.controller.signal.aborted ? null : error;
@@ -257,14 +331,24 @@ export function createNativeDecisionModel({
257
331
  publish();
258
332
  }
259
333
  request.task.then(function loaded() { settled(); }, function loadFailed(error) { settled(error); }).catch(reportCleanupFailure);
334
+ loads.add(request.task);
260
335
  pendingActivation = request;
261
- selectedTarget = target;
262
- const reason = cancellation('The decision execution target was replaced.');
336
+ selectedActivation = selection;
337
+ try {
338
+ if (selection.assetProjectionId !== undefined) {
339
+ if (!modelAssets) throw new CoreError({code: 'ARCANE_DECISION_ASSETS_UNAVAILABLE', message: 'Projected decisions require the Core model-assets owner.'});
340
+ // retain is synchronous at the model-assets public boundary.
341
+ // Acquire before retiring the last native use of these files.
342
+ request.retained = modelAssets.retain(selection.assetProjectionId);
343
+ }
344
+ } catch (error) { request.error = error; }
345
+ const reason = cancellation('The decision activation was replaced.');
346
+ if (pendingActivation !== request) request.controller.abort(reason);
263
347
  previous?.controller.abort(reason);
264
- if (activation && !sameExecutionTarget(activation.executionTarget, target)) stop(activation, reason);
348
+ if (!request.error && !request.controller.signal.aborted && activation && !sameActivation(activation.selection, selection)) stop(activation, reason);
265
349
  precedingRelease = !activation ? releaseTask : undefined;
266
- if (!activation && !precedingRelease) {
267
- request.entry = beginActivation(target);
350
+ if (!request.error && !request.controller.signal.aborted && !activation && !precedingRelease) {
351
+ request.entry = beginActivation(request);
268
352
  request.detach = observeCancellation(request.entry, request.controller.signal);
269
353
  }
270
354
  publish();
@@ -316,8 +400,11 @@ export function createNativeDecisionModel({
316
400
  const pending = pendingActivation;
317
401
  pending?.controller.abort(reason);
318
402
  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) {
403
+ if (!loads.size && !provisionalCleanupFailures.length) return release;
404
+ return Promise.allSettled([release, ...loads]).then(function releasedActivation(results) {
405
+ if (provisionalCleanupFailures.length) throw new AggregateError([
406
+ ...(results[0].status === 'rejected' ? [results[0].reason] : []), ...provisionalCleanupFailures
407
+ ], 'Releasing native decision resources failed.');
321
408
  if (results[0].status === 'rejected') throw results[0].reason;
322
409
  return current();
323
410
  });
@@ -332,7 +419,10 @@ export function createNativeDecisionModel({
332
419
  let release;
333
420
  disposal = Promise.resolve().then(async function disposeNativeModel() {
334
421
  try {
335
- const results = await Promise.allSettled([release, pending?.task]);
422
+ const results = await Promise.allSettled([release, ...loads]);
423
+ if (provisionalCleanupFailures.length) throw new AggregateError([
424
+ ...(results[0].status === 'rejected' ? [results[0].reason] : []), ...provisionalCleanupFailures
425
+ ], 'Releasing native decision resources failed.');
336
426
  if (results[0].status === 'rejected') throw results[0].reason;
337
427
  }
338
428
  finally { state = 'disposed'; publish(); events.dispose(); }