@kaminos/webgpu-inference-kit 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  - What route is being invoked, such as MoGE depth/normal or SHARP image-to-splat.
32
32
  - Which browser WebGPU adapter, device features, limits, and timestamp capabilities were actually available.
33
33
  - Which kernel/profile variant ran, and which stages are required for a useful runtime profile.
34
- - How a route was scheduled: throughput mode, cooperative/yield posture, phase chunk sizes, submitted-work waits, and unsupported scheduler fields.
34
+ - How a route was scheduled: throughput mode, cooperative/yield posture, breathability spans, yield checkpoints, phase chunk sizes, submitted-work waits, and unsupported scheduler fields.
35
35
  - Which artifacts went in and out, so downstream consumers can join routes without losing identity.
36
36
 
37
37
  The immediate goal is practical composition inside Kaminos: MoGE can become a local geometry/depth route, SHARP can emit splat candidates, Kimodo can emit motion clips, SF3D can emit meshes, and pipeline/commoner code can consume those outputs through one route grammar. The longer-term opportunity is a browser-native inference runtime kit that makes future image generators, 3D generators, and possibly language-model routes easier to seat without rebuilding the same WebGPU plumbing from scratch.
@@ -46,7 +46,7 @@ So the intended stack is:
46
46
 
47
47
  1. **Route boundary:** define callable browser-local inference routes with stable input/output roles.
48
48
  2. **Runtime profile:** preserve adapter/device/kernel/stage identity for the run that actually happened.
49
- 3. **Scheduler/backpressure profile:** expose whether the route is throughput-oriented, cooperative, furnace-class, warm, cached, or frame-tail-sensitive.
49
+ 3. **Scheduler/backpressure profile:** expose whether the route is throughput-oriented, cooperative, furnace-class, warm, cached, frame-tail-sensitive, and where it can honestly yield.
50
50
  4. **Receipt and classification:** reject stale, fallback, partial, mismatched, or invalid route output before another system treats it as authoritative.
51
51
 
52
52
  The fourth layer protects the first three. It should not swallow the whole story.
@@ -63,7 +63,7 @@ The fourth layer protects the first three. It should not swallow the whole story
63
63
  - `createStagedSubmitProfile(input)`, `addStagedSubmitStage(profile, stage)`, `finishStagedSubmitProfile(profile)`, and `validateStagedSubmitProfile(profile)`: describe staged queue-submit timing in a way that can be compared across routes.
64
64
  - `createKernelProfileMetadata(input)` and `createRouteKernelProfileMetadata(input)`: normalize kit version, kernel profile, commit, required stages, and timing-source metadata for route definitions and receipts.
65
65
  - `createWebGpuRuntimeProfileInput(input)`, `createWebGpuRuntimeProfile(input)`, and `validateWebGpuRuntimeProfile(profile)`: combine effective backend identity, kernel metadata, staged profile, and route mode into one producer-side runtime profile object.
66
- - `createWebGpuRouteSchedulerProfile(input)` and `validateWebGpuRouteSchedulerProfile(profile)`: preserve requested versus effective scheduling, including throughput/cooperative mode, route-specific phase chunk sizes, submitted-work waits, yield cadence, and unsupported fields.
66
+ - `createWebGpuRouteSchedulerProfile(input)` and `validateWebGpuRouteSchedulerProfile(profile)`: preserve requested versus effective scheduling, including throughput/cooperative mode, route-specific phase chunk sizes, submitted-work waits, yield cadence, breathability spans, yieldable checkpoints, non-preemptible GPU-submit spans, and unsupported fields.
67
67
  - `createWebGpuRouteBackpressureProfile(input)` and `validateWebGpuRouteBackpressureProfile(profile)`: record visible-wait/furnace pressure, warm/cache posture, memory-sharing posture, and frame-tail impact.
68
68
  - `defineTensorManifest(input)` and `validateTensorManifest(manifest)`: normalize tensor metadata including dtype sizes and byte lengths.
69
69
  - `createWebGpuLocalRouteReceipt(input)`, `createWebGpuRouteReceiptFromArtifacts(input)`, `createRouteReceiptArtifacts(input)`, `finishAndValidateRouteProfile(input)`, `validateRouteReceipt(receipt)`, and `assertAuthoritativeRouteReceipt(receipt)`: shared receipt construction and validation helpers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaminos/webgpu-inference-kit",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Composable browser WebGPU inference route contracts, runtime profiles, and scheduler envelopes.",
@@ -1,4 +1,4 @@
1
- export const WEBGPU_INFERENCE_KIT_VERSION = '0.1.2';
1
+ export const WEBGPU_INFERENCE_KIT_VERSION = '0.1.4';
2
2
  const DEFAULT_KIT_VERSION = WEBGPU_INFERENCE_KIT_VERSION;
3
3
  const DEFAULT_TIMING_SOURCE = 'queue-submit-wait';
4
4
 
@@ -8,6 +8,10 @@ import {
8
8
  createRouteReceiptInputArtifact,
9
9
  createWebGpuRouteReceiptFromArtifacts,
10
10
  } from './route-receipt-helper.js';
11
+ import {
12
+ createWebGpuRouteBackpressureProfile,
13
+ createWebGpuRouteSchedulerProfile,
14
+ } from './scheduler-backpressure.js';
11
15
 
12
16
  export const KIMODO_TEXT_TO_MOTION_ROUTE_ID = 'kimodo.text-to-motion.webgpu-local.v0';
13
17
  const KIMODO_MODEL_ID = 'NVIDIA/Kimodo-SOMA-RP-v1.1';
@@ -19,6 +23,100 @@ const OUTPUT_ROLES = [
19
23
  { key: 'filmstrip', role: 'filmstrip', required: false },
20
24
  ];
21
25
 
26
+ function createDefaultKimodoScheduler() {
27
+ return createWebGpuRouteSchedulerProfile({
28
+ requestedScheduler: {
29
+ mode: 'cooperative',
30
+ yieldMs: 4,
31
+ waitForSubmittedWorkDone: true,
32
+ phaseChunkSize: {
33
+ 'text-embedding': 1,
34
+ 'ddim-sampling': 1,
35
+ 'fk-decode': 1,
36
+ 'output-capture': 1,
37
+ },
38
+ },
39
+ effectiveScheduler: {
40
+ mode: 'cooperative',
41
+ yieldMs: 4,
42
+ waitForSubmittedWorkDone: true,
43
+ phaseChunkSize: {
44
+ 'text-embedding': 1,
45
+ 'ddim-sampling': 1,
46
+ 'fk-decode': 1,
47
+ 'output-capture': 1,
48
+ },
49
+ unsupportedFields: [],
50
+ },
51
+ verificationState: 'scheduler-unverified',
52
+ breathability: {
53
+ spans: [
54
+ {
55
+ name: 'text-embedding',
56
+ stage: 'text-embedding',
57
+ kind: 'external-bound',
58
+ interruptible: false,
59
+ canYieldBefore: true,
60
+ canYieldAfter: true,
61
+ },
62
+ {
63
+ name: 'ddim-sampling-loop',
64
+ stage: 'ddim-sampling',
65
+ kind: 'gpu-submit-loop',
66
+ interruptible: false,
67
+ canYieldBefore: true,
68
+ canYieldAfter: true,
69
+ nonInterruptibleReason: 'Each diffusion step submit is non-preemptible; cooperative yielding occurs between steps.',
70
+ metadata: { checkpointCadence: 'per-diffusion-step' },
71
+ },
72
+ {
73
+ name: 'fk-decode',
74
+ stage: 'fk-decode',
75
+ kind: 'cpu-bound',
76
+ interruptible: true,
77
+ canYieldBefore: true,
78
+ canYieldAfter: true,
79
+ },
80
+ {
81
+ name: 'output-capture',
82
+ stage: 'output-capture',
83
+ kind: 'readback-bound',
84
+ interruptible: false,
85
+ canYieldBefore: true,
86
+ canYieldAfter: true,
87
+ },
88
+ ],
89
+ checkpoints: [
90
+ {
91
+ name: 'between-diffusion-steps',
92
+ kind: 'diffusion-step',
93
+ afterStage: 'ddim-sampling',
94
+ yieldable: true,
95
+ waitsForSubmittedWorkDone: true,
96
+ metadata: { cadence: 'per-step' },
97
+ },
98
+ {
99
+ name: 'after-output-capture',
100
+ kind: 'readback',
101
+ afterStage: 'output-capture',
102
+ yieldable: true,
103
+ waitsForSubmittedWorkDone: true,
104
+ },
105
+ ],
106
+ notes: 'Kimodo can expose useful cooperative pressure between diffusion steps; each submitted step remains non-preemptible.',
107
+ },
108
+ });
109
+ }
110
+
111
+ function createDefaultKimodoBackpressure() {
112
+ return createWebGpuRouteBackpressureProfile({
113
+ requestedBudget: 'visible-wait',
114
+ effectiveBudget: 'visible-wait',
115
+ memoryExclusivity: 'shared',
116
+ warmCacheState: 'unknown',
117
+ });
118
+ }
119
+
22
120
  export function createKimodoTextToMotionRouteReceipt(input) {
23
121
  if (!input || typeof input !== 'object') throw new Error('input must be an object');
24
122
  if (!input.input?.artifactId || !input.input?.sha256) {
@@ -75,6 +173,8 @@ export function createKimodoTextToMotionRouteDefinition(input = {}) {
75
173
  requiredFeatures: input.requiredFeatures || [],
76
174
  requiredStages: routeMetadata.requiredStages,
77
175
  timingSource: routeMetadata.timingSource,
176
+ scheduler: input.scheduler || createDefaultKimodoScheduler(),
177
+ backpressure: input.backpressure || createDefaultKimodoBackpressure(),
78
178
  worker: input.worker || {
79
179
  exportName: 'runKimodoTextToMotionRoute',
80
180
  textEmbedding: 'external-llama3-8b',
package/src/moge-route.js CHANGED
@@ -8,6 +8,10 @@ import {
8
8
  createRouteReceiptInputArtifact,
9
9
  createWebGpuRouteReceiptFromArtifacts,
10
10
  } from './route-receipt-helper.js';
11
+ import {
12
+ createWebGpuRouteBackpressureProfile,
13
+ createWebGpuRouteSchedulerProfile,
14
+ } from './scheduler-backpressure.js';
11
15
 
12
16
  export const MOGE_DEPTH_NORMAL_ROUTE_ID = 'moge.depth-normal.webgpu-local.v0';
13
17
  const MOGE_MODEL_ID = 'Ruicheng/moge-2-vitl-normal';
@@ -20,6 +24,80 @@ const OUTPUT_ROLES = [
20
24
  { key: 'mask', role: 'mask', required: false },
21
25
  ];
22
26
 
27
+ function createDefaultMogeScheduler() {
28
+ return createWebGpuRouteSchedulerProfile({
29
+ requestedScheduler: {
30
+ mode: 'cooperative',
31
+ yieldMs: 4,
32
+ waitForSubmittedWorkDone: true,
33
+ phaseChunkSize: {
34
+ backbone: 1,
35
+ 'decoder-heads': 1,
36
+ 'output-readback': 1,
37
+ },
38
+ },
39
+ effectiveScheduler: {
40
+ mode: 'cooperative',
41
+ yieldMs: 4,
42
+ waitForSubmittedWorkDone: true,
43
+ phaseChunkSize: {
44
+ backbone: 1,
45
+ 'decoder-heads': 1,
46
+ 'output-readback': 1,
47
+ },
48
+ unsupportedFields: [],
49
+ },
50
+ verificationState: 'scheduler-unverified',
51
+ breathability: {
52
+ spans: [
53
+ {
54
+ name: 'backbone-submit',
55
+ stage: 'backbone',
56
+ kind: 'gpu-submit-bound',
57
+ interruptible: false,
58
+ canYieldBefore: true,
59
+ canYieldAfter: true,
60
+ nonInterruptibleReason: 'GPU command buffers cannot be preempted after submit',
61
+ },
62
+ {
63
+ name: 'decoder-heads-submit',
64
+ stage: 'decoder-heads',
65
+ kind: 'gpu-submit-bound',
66
+ interruptible: false,
67
+ canYieldBefore: true,
68
+ canYieldAfter: true,
69
+ nonInterruptibleReason: 'GPU command buffers cannot be preempted after submit',
70
+ },
71
+ {
72
+ name: 'output-readback',
73
+ stage: 'output-readback',
74
+ kind: 'readback-bound',
75
+ interruptible: false,
76
+ canYieldBefore: true,
77
+ canYieldAfter: true,
78
+ },
79
+ ],
80
+ checkpoints: REQUIRED_STAGES.map(stage => ({
81
+ name: `after-${stage}`,
82
+ kind: stage === 'output-readback' ? 'readback' : 'stage-boundary',
83
+ afterStage: stage,
84
+ yieldable: true,
85
+ waitsForSubmittedWorkDone: true,
86
+ })),
87
+ notes: 'MoGE can cooperate between staged submits and readback, not inside a submitted GPU pass.',
88
+ },
89
+ });
90
+ }
91
+
92
+ function createDefaultMogeBackpressure() {
93
+ return createWebGpuRouteBackpressureProfile({
94
+ requestedBudget: 'visible-wait',
95
+ effectiveBudget: 'visible-wait',
96
+ memoryExclusivity: 'shared',
97
+ warmCacheState: 'unknown',
98
+ });
99
+ }
100
+
23
101
  export function createMogeDepthNormalRouteReceipt(input) {
24
102
  if (!input || typeof input !== 'object') throw new Error('input must be an object');
25
103
  if (!input.input?.artifactId || !input.input?.sha256) {
@@ -77,6 +155,8 @@ export function createMogeDepthNormalRouteDefinition(input = {}) {
77
155
  requiredFeatures: input.requiredFeatures || [],
78
156
  requiredStages: routeMetadata.requiredStages,
79
157
  timingSource: routeMetadata.timingSource,
158
+ scheduler: input.scheduler || createDefaultMogeScheduler(),
159
+ backpressure: input.backpressure || createDefaultMogeBackpressure(),
80
160
  worker: input.worker || {
81
161
  exportName: 'runMogeDepthNormalRoute',
82
162
  },
@@ -3,6 +3,10 @@ import {
3
3
  assertAuthoritativeRouteReceipt,
4
4
  validateRouteReceipt,
5
5
  } from './route-receipt.js';
6
+ import {
7
+ validateWebGpuRouteBackpressureProfile,
8
+ validateWebGpuRouteSchedulerProfile,
9
+ } from './scheduler-backpressure.js';
6
10
 
7
11
  export const WEBGPU_ROUTE_DEFINITION_SCHEMA = 'kaminos.webgpu-route-definition.v0';
8
12
  export const WEBGPU_ROUTE_REQUEST_SCHEMA = 'kaminos.webgpu-route-request.v0';
@@ -12,6 +16,18 @@ function clone(value) {
12
16
  return value == null ? value : JSON.parse(JSON.stringify(value));
13
17
  }
14
18
 
19
+ function stableJson(value) {
20
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
21
+ if (value && typeof value === 'object') {
22
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
23
+ }
24
+ return JSON.stringify(value);
25
+ }
26
+
27
+ function deepEqual(a, b) {
28
+ return stableJson(a) === stableJson(b);
29
+ }
30
+
15
31
  function isNonEmptyString(value) {
16
32
  return typeof value === 'string' && value.trim().length > 0;
17
33
  }
@@ -95,6 +111,49 @@ function optionalRoleNames(roles) {
95
111
  return roles.filter(role => role.required === false).map(role => role.role);
96
112
  }
97
113
 
114
+ function routeTimingStageNames(timings = {}) {
115
+ const names = new Set();
116
+ const addStageName = stage => {
117
+ if (isNonEmptyString(stage)) names.add(stage);
118
+ if (isNonEmptyString(stage?.name)) names.add(stage.name);
119
+ };
120
+
121
+ if (Array.isArray(timings.stages)) {
122
+ timings.stages.forEach(addStageName);
123
+ }
124
+
125
+ const profile = timings.profile;
126
+ if (profile && typeof profile === 'object') {
127
+ if (Array.isArray(profile.stageNames)) {
128
+ profile.stageNames.forEach(addStageName);
129
+ }
130
+ if (Array.isArray(profile.stages)) {
131
+ profile.stages.forEach(addStageName);
132
+ }
133
+ }
134
+
135
+ return names;
136
+ }
137
+
138
+ function validateRouteTiming(errors, receipt, route) {
139
+ const timings = receipt?.timings;
140
+ if (!timings || !route) return;
141
+
142
+ if (isNonEmptyString(route.timingSource) && timings.source !== route.timingSource) {
143
+ errors.push(`receipt.timings.source must be ${route.timingSource}`);
144
+ }
145
+
146
+ const requiredStages = Array.isArray(route.requiredStages) ? route.requiredStages : [];
147
+ if (requiredStages.length === 0) return;
148
+
149
+ const stageNames = routeTimingStageNames(timings);
150
+ for (const stageName of requiredStages) {
151
+ if (!stageNames.has(stageName)) {
152
+ errors.push(`receipt.timings missing required stage ${stageName}`);
153
+ }
154
+ }
155
+ }
156
+
98
157
  function validateArtifacts(errors, artifacts, roles, path, { requireHash }) {
99
158
  const knownRoles = roleSet(roles);
100
159
  const artifactsByRole = new Map();
@@ -147,6 +206,8 @@ export function defineWebGpuRoute(input) {
147
206
  requiredFeatures: Array.isArray(input.requiredFeatures) ? [...input.requiredFeatures].map(String).sort() : [],
148
207
  requiredStages: Array.isArray(input.requiredStages) ? [...input.requiredStages] : [],
149
208
  timingSource: input.timingSource || 'queue-submit-wait',
209
+ scheduler: clone(input.scheduler || null),
210
+ backpressure: clone(input.backpressure || null),
150
211
  worker: clone(input.worker || null),
151
212
  };
152
213
  }
@@ -194,6 +255,16 @@ export function validateRouteDefinition(route) {
194
255
  }
195
256
  if (!isNonEmptyString(route.timingSource)) errors.push('timingSource must be a non-empty string');
196
257
 
258
+ if (route.scheduler != null) {
259
+ const schedulerResult = validateWebGpuRouteSchedulerProfile(route.scheduler);
260
+ if (!schedulerResult.ok) errors.push(...schedulerResult.errors.map(error => `scheduler.${error}`));
261
+ }
262
+
263
+ if (route.backpressure != null) {
264
+ const backpressureResult = validateWebGpuRouteBackpressureProfile(route.backpressure);
265
+ if (!backpressureResult.ok) errors.push(...backpressureResult.errors.map(error => `backpressure.${error}`));
266
+ }
267
+
197
268
  return { ok: errors.length === 0, errors };
198
269
  }
199
270
 
@@ -250,6 +321,8 @@ export function createRouteInvocationRequest(route, input) {
250
321
  routeConfig: clone(input.routeConfig || {}),
251
322
  model: clone(route.model),
252
323
  kernel: clone(route.kernel),
324
+ scheduler: clone(route.scheduler || null),
325
+ backpressure: clone(route.backpressure || null),
253
326
  createdAt: input.createdAt || new Date().toISOString(),
254
327
  };
255
328
  }
@@ -272,6 +345,30 @@ export function validateRouteInvocationRequest(request, route) {
272
345
  validateArtifacts(errors, request.outputs, route.outputRoles, 'outputs', { requireHash: false });
273
346
  }
274
347
 
348
+ if (request.scheduler != null) {
349
+ const schedulerResult = validateWebGpuRouteSchedulerProfile(request.scheduler);
350
+ if (!schedulerResult.ok) errors.push(...schedulerResult.errors.map(error => `scheduler.${error}`));
351
+ }
352
+ if (route?.scheduler != null) {
353
+ if (request.scheduler == null) {
354
+ errors.push('scheduler must match route definition');
355
+ } else if (!deepEqual(request.scheduler, route.scheduler)) {
356
+ errors.push('scheduler must match route definition');
357
+ }
358
+ }
359
+
360
+ if (request.backpressure != null) {
361
+ const backpressureResult = validateWebGpuRouteBackpressureProfile(request.backpressure);
362
+ if (!backpressureResult.ok) errors.push(...backpressureResult.errors.map(error => `backpressure.${error}`));
363
+ }
364
+ if (route?.backpressure != null) {
365
+ if (request.backpressure == null) {
366
+ errors.push('backpressure must match route definition');
367
+ } else if (!deepEqual(request.backpressure, route.backpressure)) {
368
+ errors.push('backpressure must match route definition');
369
+ }
370
+ }
371
+
275
372
  return { ok: errors.length === 0, errors };
276
373
  }
277
374
 
@@ -322,6 +419,7 @@ export function validateRouteWorkerResult(result, route) {
322
419
  if (result.receipt.effectiveRouteId !== route.routeId) {
323
420
  errors.push('receipt.effectiveRouteId must match route definition');
324
421
  }
422
+ if (routeResult.ok) validateRouteTiming(errors, result.receipt, route);
325
423
  }
326
424
 
327
425
  const backendResult = validateWebGpuBackendIdentity(result.backend);
@@ -6,6 +6,24 @@ const VERIFICATION_STATES = new Set(['verified', 'scheduler-unverified', 'unsupp
6
6
  const BUDGETS = new Set(['interactive', 'visible-wait', 'furnace', 'batch', 'unknown']);
7
7
  const MEMORY_EXCLUSIVITY = new Set(['shared', 'exclusive', 'unknown']);
8
8
  const WARM_CACHE_STATES = new Set(['cold', 'warm', 'hot', 'unknown']);
9
+ const BREATHABILITY_SPAN_KINDS = new Set([
10
+ 'gpu-submit-bound',
11
+ 'gpu-submit-loop',
12
+ 'readback-bound',
13
+ 'js-yieldable',
14
+ 'cpu-bound',
15
+ 'external-bound',
16
+ 'unknown',
17
+ ]);
18
+ const BREATHABILITY_CHECKPOINT_KINDS = new Set([
19
+ 'pre-submit',
20
+ 'post-submit',
21
+ 'stage-boundary',
22
+ 'diffusion-step',
23
+ 'readback',
24
+ 'external-callback',
25
+ 'unknown',
26
+ ]);
9
27
 
10
28
  function clone(value) {
11
29
  return value == null ? value : JSON.parse(JSON.stringify(value));
@@ -53,6 +71,41 @@ function normalizeEffectiveScheduler(input = {}, requestedScheduler) {
53
71
  };
54
72
  }
55
73
 
74
+ function normalizeBreathabilitySpan(input = {}) {
75
+ return {
76
+ name: input.name,
77
+ stage: input.stage || null,
78
+ kind: input.kind || 'unknown',
79
+ interruptible: Boolean(input.interruptible),
80
+ canYieldBefore: Boolean(input.canYieldBefore),
81
+ canYieldAfter: Boolean(input.canYieldAfter),
82
+ nonInterruptibleReason: input.nonInterruptibleReason || null,
83
+ metadata: isPlainObject(input.metadata) ? clone(input.metadata) : {},
84
+ };
85
+ }
86
+
87
+ function normalizeBreathabilityCheckpoint(input = {}) {
88
+ return {
89
+ name: input.name,
90
+ kind: input.kind || 'unknown',
91
+ beforeStage: input.beforeStage || null,
92
+ afterStage: input.afterStage || null,
93
+ yieldable: Boolean(input.yieldable),
94
+ waitsForSubmittedWorkDone: Boolean(input.waitsForSubmittedWorkDone),
95
+ metadata: isPlainObject(input.metadata) ? clone(input.metadata) : {},
96
+ };
97
+ }
98
+
99
+ function normalizeBreathability(input = {}) {
100
+ return {
101
+ spans: Array.isArray(input.spans) ? input.spans.map(normalizeBreathabilitySpan) : [],
102
+ checkpoints: Array.isArray(input.checkpoints)
103
+ ? input.checkpoints.map(normalizeBreathabilityCheckpoint)
104
+ : [],
105
+ notes: input.notes || null,
106
+ };
107
+ }
108
+
56
109
  function validateSchedulerShape(errors, scheduler, label) {
57
110
  if (!isPlainObject(scheduler)) {
58
111
  errors.push(`${label} must be an object`);
@@ -79,6 +132,59 @@ function validateSchedulerShape(errors, scheduler, label) {
79
132
  }
80
133
  }
81
134
 
135
+ function validateBreathability(errors, breathability) {
136
+ if (breathability == null) return;
137
+ if (!isPlainObject(breathability)) {
138
+ errors.push('breathability must be an object');
139
+ return;
140
+ }
141
+ if (!Array.isArray(breathability.spans)) {
142
+ errors.push('breathability.spans must be an array');
143
+ } else {
144
+ breathability.spans.forEach((span, index) => {
145
+ const path = `breathability.spans[${index}]`;
146
+ if (!isNonEmptyString(span?.name)) errors.push(`${path}.name must be a non-empty string`);
147
+ if (span?.stage != null && !isNonEmptyString(span.stage)) errors.push(`${path}.stage must be null or a non-empty string`);
148
+ if (!BREATHABILITY_SPAN_KINDS.has(span?.kind)) errors.push(`${path}.kind has unsupported value`);
149
+ if (typeof span?.interruptible !== 'boolean') errors.push(`${path}.interruptible must be a boolean`);
150
+ if (typeof span?.canYieldBefore !== 'boolean') errors.push(`${path}.canYieldBefore must be a boolean`);
151
+ if (typeof span?.canYieldAfter !== 'boolean') errors.push(`${path}.canYieldAfter must be a boolean`);
152
+ if (span?.nonInterruptibleReason != null && !isNonEmptyString(span.nonInterruptibleReason)) {
153
+ errors.push(`${path}.nonInterruptibleReason must be null or a non-empty string`);
154
+ }
155
+ if (!isPlainObject(span?.metadata)) errors.push(`${path}.metadata must be an object`);
156
+ if ((span?.kind === 'gpu-submit-bound' || span?.kind === 'gpu-submit-loop') && span.interruptible) {
157
+ errors.push(`${path}.${span.kind} cannot be interruptible after GPU submit`);
158
+ }
159
+ });
160
+ }
161
+
162
+ if (!Array.isArray(breathability.checkpoints)) {
163
+ errors.push('breathability.checkpoints must be an array');
164
+ } else {
165
+ breathability.checkpoints.forEach((checkpoint, index) => {
166
+ const path = `breathability.checkpoints[${index}]`;
167
+ if (!isNonEmptyString(checkpoint?.name)) errors.push(`${path}.name must be a non-empty string`);
168
+ if (!BREATHABILITY_CHECKPOINT_KINDS.has(checkpoint?.kind)) errors.push(`${path}.kind has unsupported value`);
169
+ if (checkpoint?.beforeStage != null && !isNonEmptyString(checkpoint.beforeStage)) {
170
+ errors.push(`${path}.beforeStage must be null or a non-empty string`);
171
+ }
172
+ if (checkpoint?.afterStage != null && !isNonEmptyString(checkpoint.afterStage)) {
173
+ errors.push(`${path}.afterStage must be null or a non-empty string`);
174
+ }
175
+ if (typeof checkpoint?.yieldable !== 'boolean') errors.push(`${path}.yieldable must be a boolean`);
176
+ if (typeof checkpoint?.waitsForSubmittedWorkDone !== 'boolean') {
177
+ errors.push(`${path}.waitsForSubmittedWorkDone must be a boolean`);
178
+ }
179
+ if (!isPlainObject(checkpoint?.metadata)) errors.push(`${path}.metadata must be an object`);
180
+ });
181
+ }
182
+
183
+ if (breathability.notes != null && !isNonEmptyString(breathability.notes)) {
184
+ errors.push('breathability.notes must be null or a non-empty string');
185
+ }
186
+ }
187
+
82
188
  function missingUnsupportedField(effectiveScheduler, field) {
83
189
  return !effectiveScheduler.unsupportedFields.includes(field)
84
190
  && !effectiveScheduler.unsupportedFields.includes('phaseChunkSize');
@@ -98,6 +204,7 @@ export function createWebGpuRouteSchedulerProfile(input = {}) {
98
204
  requestedScheduler,
99
205
  effectiveScheduler,
100
206
  verificationState,
207
+ breathability: normalizeBreathability(input.breathability),
101
208
  };
102
209
 
103
210
  const result = validateWebGpuRouteSchedulerProfile(profile);
@@ -115,6 +222,7 @@ export function validateWebGpuRouteSchedulerProfile(profile) {
115
222
  }
116
223
  validateSchedulerShape(errors, profile.requestedScheduler, 'requestedScheduler');
117
224
  validateSchedulerShape(errors, profile.effectiveScheduler, 'effectiveScheduler');
225
+ validateBreathability(errors, profile.breathability);
118
226
 
119
227
  if (!Array.isArray(profile.effectiveScheduler?.unsupportedFields)) {
120
228
  errors.push('effectiveScheduler.unsupportedFields must be an array');
package/src/sf3d-route.js CHANGED
@@ -8,6 +8,10 @@ import {
8
8
  createRouteReceiptInputArtifact,
9
9
  createWebGpuRouteReceiptFromArtifacts,
10
10
  } from './route-receipt-helper.js';
11
+ import {
12
+ createWebGpuRouteBackpressureProfile,
13
+ createWebGpuRouteSchedulerProfile,
14
+ } from './scheduler-backpressure.js';
11
15
 
12
16
  export const SF3D_IMAGE_TO_MESH_ROUTE_ID = 'sf3d.image-to-mesh.webgpu-local.v0';
13
17
  const SF3D_MODEL_ID = 'stabilityai/stable-fast-3d';
@@ -28,6 +32,55 @@ const OUTPUT_ROLES = [
28
32
  { key: 'meshObj', role: 'mesh-obj', required: false },
29
33
  ];
30
34
 
35
+ function createDefaultSf3dScheduler() {
36
+ return createWebGpuRouteSchedulerProfile({
37
+ requestedScheduler: {
38
+ mode: 'throughput',
39
+ yieldMs: 0,
40
+ waitForSubmittedWorkDone: true,
41
+ phaseChunkSize: {},
42
+ },
43
+ effectiveScheduler: {
44
+ mode: 'throughput',
45
+ yieldMs: 0,
46
+ waitForSubmittedWorkDone: true,
47
+ phaseChunkSize: {},
48
+ unsupportedFields: [],
49
+ },
50
+ verificationState: 'scheduler-unverified',
51
+ breathability: {
52
+ spans: REQUIRED_STAGES.map(stage => ({
53
+ name: `${stage}-phase`,
54
+ stage,
55
+ kind: stage === 'glb-export' ? 'cpu-bound' : 'gpu-submit-bound',
56
+ interruptible: false,
57
+ canYieldBefore: true,
58
+ canYieldAfter: true,
59
+ nonInterruptibleReason: stage === 'glb-export'
60
+ ? null
61
+ : 'SF3D browser phases are treated as non-preemptible GPU work until finer receipts prove step boundaries.',
62
+ })),
63
+ checkpoints: REQUIRED_STAGES.map(stage => ({
64
+ name: `after-${stage}`,
65
+ kind: 'stage-boundary',
66
+ afterStage: stage,
67
+ yieldable: true,
68
+ waitsForSubmittedWorkDone: stage !== 'glb-export',
69
+ })),
70
+ notes: 'SF3D remains furnace-class until attention/triplane/marching phases expose finer cooperative boundaries.',
71
+ },
72
+ });
73
+ }
74
+
75
+ function createDefaultSf3dBackpressure() {
76
+ return createWebGpuRouteBackpressureProfile({
77
+ requestedBudget: 'visible-wait',
78
+ effectiveBudget: 'furnace',
79
+ memoryExclusivity: 'exclusive',
80
+ warmCacheState: 'unknown',
81
+ });
82
+ }
83
+
31
84
  export function createSf3dImageToMeshRouteReceipt(input) {
32
85
  if (!input || typeof input !== 'object') throw new Error('input must be an object');
33
86
  if (!input.input?.artifactId || !input.input?.sha256) {
@@ -86,6 +139,8 @@ export function createSf3dImageToMeshRouteDefinition(input = {}) {
86
139
  requiredFeatures: input.requiredFeatures || [],
87
140
  requiredStages: routeMetadata.requiredStages,
88
141
  timingSource: routeMetadata.timingSource,
142
+ scheduler: input.scheduler || createDefaultSf3dScheduler(),
143
+ backpressure: input.backpressure || createDefaultSf3dBackpressure(),
89
144
  worker: input.worker || {
90
145
  exportName: 'runSf3dImageToMeshRoute',
91
146
  meshFormat: 'glb',
@@ -8,6 +8,10 @@ import {
8
8
  createRouteReceiptInputArtifact,
9
9
  createWebGpuRouteReceiptFromArtifacts,
10
10
  } from './route-receipt-helper.js';
11
+ import {
12
+ createWebGpuRouteBackpressureProfile,
13
+ createWebGpuRouteSchedulerProfile,
14
+ } from './scheduler-backpressure.js';
11
15
 
12
16
  export const SHARP_IMAGE_TO_SPLAT_ROUTE_ID = 'sharp.image-to-splat.webgpu-local.v0';
13
17
  const SHARP_MODEL_ID = 'apple/ml-sharp';
@@ -20,6 +24,55 @@ const OUTPUT_ROLES = [
20
24
  { key: 'autoCropEvidence', role: 'splat-autocrop-evidence', required: false },
21
25
  ];
22
26
 
27
+ function createDefaultSharpScheduler() {
28
+ return createWebGpuRouteSchedulerProfile({
29
+ requestedScheduler: {
30
+ mode: 'throughput',
31
+ yieldMs: 0,
32
+ waitForSubmittedWorkDone: true,
33
+ phaseChunkSize: {},
34
+ },
35
+ effectiveScheduler: {
36
+ mode: 'throughput',
37
+ yieldMs: 0,
38
+ waitForSubmittedWorkDone: true,
39
+ phaseChunkSize: {},
40
+ unsupportedFields: [],
41
+ },
42
+ verificationState: 'scheduler-unverified',
43
+ breathability: {
44
+ spans: REQUIRED_STAGES.map(stage => ({
45
+ name: `${stage}-phase`,
46
+ stage,
47
+ kind: stage === 'output-capture' ? 'readback-bound' : 'gpu-submit-bound',
48
+ interruptible: false,
49
+ canYieldBefore: true,
50
+ canYieldAfter: true,
51
+ nonInterruptibleReason: stage === 'output-capture'
52
+ ? null
53
+ : 'Browser WebGPU cannot preempt a submitted SHARP adapter phase.',
54
+ })),
55
+ checkpoints: REQUIRED_STAGES.map(stage => ({
56
+ name: `after-${stage}`,
57
+ kind: stage === 'output-capture' ? 'readback' : 'stage-boundary',
58
+ afterStage: stage,
59
+ yieldable: true,
60
+ waitsForSubmittedWorkDone: true,
61
+ })),
62
+ notes: 'SHARP is furnace-class until finer adapter receipts prove smaller cooperative boundaries.',
63
+ },
64
+ });
65
+ }
66
+
67
+ function createDefaultSharpBackpressure() {
68
+ return createWebGpuRouteBackpressureProfile({
69
+ requestedBudget: 'visible-wait',
70
+ effectiveBudget: 'furnace',
71
+ memoryExclusivity: 'exclusive',
72
+ warmCacheState: 'unknown',
73
+ });
74
+ }
75
+
23
76
  export function createSharpImageToSplatRouteReceipt(input) {
24
77
  if (!input || typeof input !== 'object') throw new Error('input must be an object');
25
78
  if (!input.input?.artifactId || !input.input?.sha256) {
@@ -78,6 +131,8 @@ export function createSharpImageToSplatRouteDefinition(input = {}) {
78
131
  requiredFeatures: input.requiredFeatures || [],
79
132
  requiredStages: routeMetadata.requiredStages,
80
133
  timingSource: routeMetadata.timingSource,
134
+ scheduler: input.scheduler || createDefaultSharpScheduler(),
135
+ backpressure: input.backpressure || createDefaultSharpBackpressure(),
81
136
  worker: input.worker || {
82
137
  exportName: 'runSharpImageToSplatRoute',
83
138
  adapterReportSchema: 'kaminos.sharp-webgpu-adapter-report.v0',