@camstack/addon-pipeline 1.2.296 → 1.2.298

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.
Files changed (33) hide show
  1. package/dist/audio-analyzer/index.js +571 -305
  2. package/dist/audio-analyzer/index.mjs +571 -305
  3. package/dist/{default-detection-model-D1daTtqT.js → default-detection-model-BTJZvSr_.js} +368 -5
  4. package/dist/{default-detection-model-Co578D8C.mjs → default-detection-model-Bim_g_Ui.mjs} +339 -6
  5. package/dist/detection-pipeline/index.js +521 -217
  6. package/dist/detection-pipeline/index.mjs +520 -216
  7. package/dist/{dist-CCd0Q3nr.mjs → dist-DVz_88D_.mjs} +80 -123
  8. package/dist/{dist-8up-f2TX.js → dist-L_HlB0At.js} +91 -140
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-lpQgHes9.js → node-CmzZ_-zB.js} +1 -1
  12. package/dist/{node-DgMSXSWP.mjs → node-Cr8Ch1ZN.mjs} +1 -1
  13. package/dist/pipeline-runner/index.js +213 -203
  14. package/dist/pipeline-runner/index.mjs +213 -203
  15. package/dist/{process-memory-DFC_O5zE.mjs → process-memory-DO7La1yB.mjs} +1 -1
  16. package/dist/{process-memory-CX_92V_r.js → process-memory-FfRDGu11.js} +1 -1
  17. package/dist/recorder/index.js +3 -3
  18. package/dist/recorder/index.mjs +3 -3
  19. package/dist/{segment-demux-js-FZbBuk3F.mjs → segment-demux-js-BuioTwCh.mjs} +1 -1
  20. package/dist/{segment-demux-js-DzBx6NN2.js → segment-demux-js-Cd7btWna.js} +1 -1
  21. package/dist/stream-broker/_stub.js +2 -2
  22. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C_i7oFBl.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CsbrrsyY.mjs} +2 -2
  23. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DisouA2F.mjs +26 -0
  24. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dti8Ex88.mjs +26 -0
  25. package/dist/stream-broker/demux-worker-child.js +1 -1
  26. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  27. package/dist/stream-broker/{hostInit-BPtppL3W.mjs → hostInit-NCsHzij7.mjs} +2 -2
  28. package/dist/stream-broker/index.js +3 -3
  29. package/dist/stream-broker/index.mjs +3 -3
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +1 -1
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DUGQKsKL.mjs +0 -26
  33. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CkbplMHA.mjs +0 -26
@@ -3,11 +3,11 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-8up-f2TX.js");
7
- const require_node = require("../node-lpQgHes9.js");
8
- const require_default_detection_model = require("../default-detection-model-D1daTtqT.js");
6
+ const require_dist = require("../dist-L_HlB0At.js");
7
+ const require_node = require("../node-CmzZ_-zB.js");
8
+ const require_default_detection_model = require("../default-detection-model-BTJZvSr_.js");
9
9
  const require_lazy_sharp = require("../lazy-sharp-BuwKlQLR.js");
10
- const require_process_memory = require("../process-memory-CX_92V_r.js");
10
+ const require_process_memory = require("../process-memory-FfRDGu11.js");
11
11
  let node_os = require("node:os");
12
12
  node_os = require_chunk.__toESM(node_os);
13
13
  let node_fs = require("node:fs");
@@ -367,12 +367,14 @@ function normalizeEngineNodeId(rawNodeId) {
367
367
  * builders eventually reaches ten of them, and the cameras attached by the
368
368
  * forgotten one quietly write a foreign feature space into the shared index.
369
369
  *
370
- * ## Why the failure mode keeps the last value
370
+ * ## Why the failure mode keeps the last value — and refuses before the first
371
371
  *
372
372
  * A failed read must never revert to the registry default MID-PASS: that would
373
373
  * silently change what every remaining vector of that dispatch means, which is
374
374
  * the exact class of harm the scope exists to prevent. So a failure keeps the
375
- * last known row and says so, loudly, once per failure.
375
+ * last known row and says so, once per change. Before the FIRST answer there is
376
+ * no row to keep: the step is refused by name (D658) — a default run then made
377
+ * the default model the pool's active variant for the process lifetime.
376
378
  */
377
379
  /**
378
380
  * The addon whose GLOBAL settings own the cluster row. Same owner as the crop
@@ -381,35 +383,58 @@ function normalizeEngineNodeId(rawNodeId) {
381
383
  * enforces it live — neither should own a value the other depends on).
382
384
  */
383
385
  var CLUSTER_MODEL_OWNER_ADDON_ID = "pipeline-orchestrator";
384
- /** TTL-cached read of the cluster model row. */
386
+ /**
387
+ * TTL-cached read of the cluster model row.
388
+ *
389
+ * A step's model is KNOWN only once the owner has answered for it (`stored` or
390
+ * `absent`). Until then it is UNKNOWN, and a cluster-scoped step is refused by
391
+ * name rather than run with the registry default (D49, D658): a default run
392
+ * during the owner's boot window made that model the pool's active variant for
393
+ * the whole process, and the face default was ArcFace until D651. Once known,
394
+ * an unanswered read keeps the LAST KNOWN model — never reverts to a default.
395
+ */
385
396
  var ClusterModelSource = class {
386
- models = require_dist.DEFAULT_CLUSTER_STEP_MODELS;
397
+ models = {};
398
+ unknown = new Map(require_dist.CLUSTER_MODEL_SCOPED_STEPS.map((s) => [s.stepId, "not-yet-asked"]));
387
399
  settings = require_dist.DEFAULT_CLUSTER_STEP_SETTINGS;
388
400
  lastReadAtMs = Number.NEGATIVE_INFINITY;
389
401
  inFlight = null;
390
402
  logger;
391
403
  now;
392
404
  ttlMs;
405
+ unknownRetryMs;
406
+ /** The last "not known" state logged, so it is said once per change. */
407
+ lastUnknownLogKey = "";
393
408
  constructor(options) {
394
409
  this.logger = options.logger;
395
410
  this.now = options.now ?? (() => Date.now());
396
411
  this.ttlMs = options.ttlMs ?? 6e4;
412
+ this.unknownRetryMs = options.unknownRetryMs ?? 5e3;
397
413
  }
398
- /** The row in force right now — the registry defaults until a read lands. */
414
+ /**
415
+ * The KNOWN models, `stepId → modelId`. A step absent here is unknown (see
416
+ * {@link unknownSteps}) and must be refused, never defaulted.
417
+ */
399
418
  current() {
400
419
  return this.models;
401
420
  }
421
+ /** Cluster-scoped steps whose model the owner has not answered yet, with why. */
422
+ unknownSteps() {
423
+ return this.unknown;
424
+ }
402
425
  /** Cluster-scoped step knobs in force right now (admission floors, …). */
403
426
  currentSettings() {
404
427
  return this.settings;
405
428
  }
406
429
  /**
407
- * Re-read if the cached row has expired. Concurrent callers share one
408
- * in-flight read; a failure keeps the LAST KNOWN row.
430
+ * Re-read if the cached row has expired (sooner while a step is unknown).
431
+ * Concurrent callers share one in-flight read; a failure keeps the LAST
432
+ * KNOWN row.
409
433
  */
410
434
  async refresh(api) {
411
435
  if (api === void 0) return this.models;
412
- if (this.now() - this.lastReadAtMs < this.ttlMs) return this.models;
436
+ const ttl = this.unknown.size > 0 ? Math.min(this.ttlMs, this.unknownRetryMs) : this.ttlMs;
437
+ if (this.now() - this.lastReadAtMs < ttl) return this.models;
413
438
  this.inFlight ??= this.read(api).finally(() => {
414
439
  this.inFlight = null;
415
440
  });
@@ -417,44 +442,186 @@ var ClusterModelSource = class {
417
442
  return this.models;
418
443
  }
419
444
  async read(api) {
420
- try {
421
- const view = await api.addonSettings.getGlobalSettings.query({ addonId: CLUSTER_MODEL_OWNER_ADDON_ID });
422
- const next = require_dist.pickClusterStepModels(view);
423
- const nextSettings = require_dist.pickClusterStepSettings(view);
424
- this.lastReadAtMs = this.now();
425
- const invalid = require_dist.pickInvalidClusterStepModels(view);
426
- if (invalid.length > 0) this.logger.warn("cluster step model row holds a value that is not a model id — running the catalog default for that step; no re-embed pass will run on it", { meta: {
427
- owner: CLUSTER_MODEL_OWNER_ADDON_ID,
428
- invalid,
429
- inUse: Object.fromEntries(invalid.map((e) => [e.stepId, next[e.stepId]]))
430
- } });
431
- const changed = Object.keys(next).filter((stepId) => next[stepId] !== this.models[stepId]);
432
- const settingsChanged = Object.keys(nextSettings).some((stepId) => {
433
- const from = this.settings[stepId] ?? {};
434
- const to = nextSettings[stepId] ?? {};
435
- return [...new Set([...Object.keys(from), ...Object.keys(to)])].some((key) => from[key] !== to[key]);
436
- });
437
- if (changed.length > 0) {
438
- this.logger.info("cluster step model changed — vectors already stored by the previous model need a re-embed pass", { meta: { changed: changed.map((stepId) => ({
439
- stepId,
440
- from: this.models[stepId],
441
- to: next[stepId]
442
- })) } });
443
- this.models = next;
444
- }
445
- if (settingsChanged) {
446
- this.logger.info("cluster step settings changed", { meta: {
447
- from: { ...this.settings },
448
- to: { ...nextSettings }
449
- } });
450
- this.settings = nextSettings;
445
+ this.lastReadAtMs = this.now();
446
+ await Promise.all([this.readModels(api), this.readSettings(api)]);
447
+ }
448
+ async readModels(api) {
449
+ const answers = await Promise.all(require_dist.CLUSTER_MODEL_SCOPED_STEPS.map(async (step) => ({
450
+ stepId: step.stepId,
451
+ answer: await askOwner(api, step.stepId)
452
+ })));
453
+ const nextModels = { ...this.models };
454
+ const nextUnknown = /* @__PURE__ */ new Map();
455
+ const unanswered = [];
456
+ for (const { stepId, answer } of answers) {
457
+ if (answer.kind === "model") {
458
+ nextModels[stepId] = answer.modelId;
459
+ continue;
451
460
  }
461
+ const keeping = this.models[stepId] ?? null;
462
+ unanswered.push({
463
+ stepId,
464
+ reason: answer.reason,
465
+ keeping
466
+ });
467
+ if (keeping === null) nextUnknown.set(stepId, answer.reason);
468
+ }
469
+ const changed = Object.keys(nextModels).filter((stepId) => this.models[stepId] !== void 0 && nextModels[stepId] !== this.models[stepId]);
470
+ if (changed.length > 0) this.logger.info("cluster step model changed — vectors already stored by the previous model need a re-embed pass", { meta: { changed: changed.map((stepId) => ({
471
+ stepId,
472
+ from: this.models[stepId],
473
+ to: nextModels[stepId]
474
+ })) } });
475
+ const learned = Object.keys(nextModels).filter((stepId) => this.models[stepId] === void 0);
476
+ if (learned.length > 0) this.logger.info("cluster step model known — cluster-scoped steps run it", { meta: { known: Object.fromEntries(learned.map((id) => [id, nextModels[id]])) } });
477
+ this.models = nextModels;
478
+ this.unknown = nextUnknown;
479
+ this.reportUnanswered(unanswered);
480
+ }
481
+ /** Once per change of the unanswered set — never once per retry. */
482
+ reportUnanswered(unanswered) {
483
+ const key = JSON.stringify(unanswered);
484
+ if (key === this.lastUnknownLogKey) return;
485
+ this.lastUnknownLogKey = key;
486
+ if (unanswered.length === 0) return;
487
+ this.logger.warn("cluster step model not answered by its owner — a step never known is REFUSED, a known one keeps its last model", { meta: {
488
+ owner: CLUSTER_MODEL_OWNER_ADDON_ID,
489
+ unanswered: unanswered.map((u) => ({
490
+ stepId: u.stepId,
491
+ reason: u.reason,
492
+ ...u.keeping !== null ? { keeping: u.keeping } : { refused: true }
493
+ }))
494
+ } });
495
+ }
496
+ async readSettings(api) {
497
+ let view;
498
+ try {
499
+ view = await api.addonSettings.getGlobalSettings.query({ addonId: CLUSTER_MODEL_OWNER_ADDON_ID });
452
500
  } catch (err) {
453
- this.logger.warn("cluster step model read failed — keeping the last known row", { meta: {
501
+ this.logger.warn("cluster step settings read failed — keeping the last known knobs", { meta: {
454
502
  owner: CLUSTER_MODEL_OWNER_ADDON_ID,
455
- inUse: { ...this.models },
456
503
  error: err instanceof Error ? err.message : String(err)
457
504
  } });
505
+ return;
506
+ }
507
+ if (view === null) return;
508
+ const nextSettings = require_dist.pickClusterStepSettings(view);
509
+ if (Object.keys(nextSettings).some((stepId) => {
510
+ const from = this.settings[stepId] ?? {};
511
+ const to = nextSettings[stepId] ?? {};
512
+ return [...new Set([...Object.keys(from), ...Object.keys(to)])].some((key) => from[key] !== to[key]);
513
+ })) {
514
+ this.logger.info("cluster step settings changed", { meta: {
515
+ from: { ...this.settings },
516
+ to: { ...nextSettings }
517
+ } });
518
+ this.settings = nextSettings;
519
+ }
520
+ }
521
+ };
522
+ /** One step's model from the owner's three-way answer; a throw is no answer. */
523
+ async function askOwner(api, stepId) {
524
+ try {
525
+ const choice = await api.pipelineOrchestrator.getClusterModelChoice.query({ stepId });
526
+ switch (choice.kind) {
527
+ case "stored": return {
528
+ kind: "model",
529
+ modelId: choice.modelId
530
+ };
531
+ case "absent": return {
532
+ kind: "model",
533
+ modelId: choice.defaultModelId
534
+ };
535
+ case "unreadable": return {
536
+ kind: "none",
537
+ reason: choice.reason
538
+ };
539
+ }
540
+ } catch (err) {
541
+ return {
542
+ kind: "none",
543
+ reason: `read-failed: ${err instanceof Error ? err.message : String(err)}`
544
+ };
545
+ }
546
+ }
547
+ //#endregion
548
+ //#region src/detection-pipeline/cluster-model-switch-log.ts
549
+ function* clusterScopedSteps(steps) {
550
+ for (const step of steps) {
551
+ if (step.definition.modelScope === "cluster") yield step;
552
+ yield* clusterScopedSteps(step.children);
553
+ }
554
+ }
555
+ var ClusterModelSwitchLog = class {
556
+ logger;
557
+ /** `deviceId|stepId` → the model that step last ran for that camera. */
558
+ lastRun = /* @__PURE__ */ new Map();
559
+ constructor(logger) {
560
+ this.logger = logger;
561
+ }
562
+ /** Record what `tree` runs for `deviceId`; say so when a cluster step changed model. */
563
+ note(deviceId, tree) {
564
+ if (deviceId <= 0) return;
565
+ for (const step of clusterScopedSteps(tree.roots)) {
566
+ const key = `${String(deviceId)}|${step.stepId}`;
567
+ const previous = this.lastRun.get(key);
568
+ if (previous === step.modelId) continue;
569
+ this.lastRun.set(key, step.modelId);
570
+ if (previous === void 0) continue;
571
+ this.logger.info("cluster-scoped step re-provisioned for this camera — now runs the new model", {
572
+ tags: { deviceId },
573
+ meta: {
574
+ stepId: step.stepId,
575
+ from: previous,
576
+ to: step.modelId
577
+ }
578
+ });
579
+ }
580
+ }
581
+ };
582
+ function* enabledStepIds(steps) {
583
+ for (const step of steps) {
584
+ if (!step.enabled) continue;
585
+ yield step.addonId;
586
+ if (step.children) yield* enabledStepIds(step.children);
587
+ }
588
+ }
589
+ /** The enabled steps of `steps` (any depth) whose cluster model is unknown. */
590
+ function unknownStepsNamed(steps, unknown) {
591
+ if (unknown.size === 0) return [];
592
+ return [...enabledStepIds(steps)].filter((id) => unknown.has(id));
593
+ }
594
+ /**
595
+ * One line per (camera, step) while a cluster-scoped step is REFUSED because
596
+ * the owner has not answered which model the cluster runs (D658). Said once per
597
+ * episode: when every step becomes known the memory is cleared, so the next
598
+ * episode is said again.
599
+ */
600
+ var ClusterUnknownRefusalLog = class {
601
+ logger;
602
+ said = /* @__PURE__ */ new Set();
603
+ constructor(logger) {
604
+ this.logger = logger;
605
+ }
606
+ note(deviceId, steps, unknown) {
607
+ if (unknown.size === 0) {
608
+ if (this.said.size > 0) this.said = /* @__PURE__ */ new Set();
609
+ return;
610
+ }
611
+ if (deviceId <= 0) return;
612
+ for (const stepId of enabledStepIds(steps)) {
613
+ const reason = unknown.get(stepId);
614
+ if (reason === void 0) continue;
615
+ const key = `${String(deviceId)}|${stepId}`;
616
+ if (this.said.has(key)) continue;
617
+ this.said = new Set(this.said).add(key);
618
+ this.logger.warn("cluster-scoped step refused for this camera — the owner has not answered which model the cluster runs", {
619
+ tags: { deviceId },
620
+ meta: {
621
+ stepId,
622
+ reason
623
+ }
624
+ });
458
625
  }
459
626
  }
460
627
  };
@@ -2154,7 +2321,8 @@ function collectFramePlaneSteps(steps, isDetailStep) {
2154
2321
  }
2155
2322
  /**
2156
2323
  * Immutably remove from a frame-plane dispatch tree every enabled DETAIL
2157
- * subtree (at any depth below the top level) whose top step `isUnrunnable`.
2324
+ * subtree (at any depth below the top level) that `isUnrunnable` — judged
2325
+ * WHOLE at its top, because its children ride its call (D657).
2158
2326
  * Those steps can never load into the dispatch device's pool — leaving them
2159
2327
  * in the tree makes `ensureModelsForSteps` fail the whole call on a format
2160
2328
  * build that does not exist. They are NOT dropped work: each runs later as
@@ -2166,6 +2334,11 @@ function collectFramePlaneSteps(steps, isDetailStep) {
2166
2334
  */
2167
2335
  function pruneUnrunnableDetailSteps(steps, isDetailStep, isUnrunnable) {
2168
2336
  const prunedAddonIds = [];
2337
+ const removedAddonIds = [];
2338
+ const collect = (step) => {
2339
+ removedAddonIds.push(step.addonId);
2340
+ for (const child of step.children ?? []) collect(child);
2341
+ };
2169
2342
  const walk = (nodes, topLevel) => {
2170
2343
  const kept = [];
2171
2344
  for (const step of nodes) {
@@ -2173,8 +2346,9 @@ function pruneUnrunnableDetailSteps(steps, isDetailStep, isUnrunnable) {
2173
2346
  kept.push(step);
2174
2347
  continue;
2175
2348
  }
2176
- if (!topLevel && isDetailStep(step.addonId) && isUnrunnable(step.addonId)) {
2349
+ if (!topLevel && isDetailStep(step.addonId) && isUnrunnable(step)) {
2177
2350
  prunedAddonIds.push(step.addonId);
2351
+ collect(step);
2178
2352
  continue;
2179
2353
  }
2180
2354
  kept.push(step.children?.length ? {
@@ -2186,7 +2360,8 @@ function pruneUnrunnableDetailSteps(steps, isDetailStep, isUnrunnable) {
2186
2360
  };
2187
2361
  return {
2188
2362
  steps: walk(steps, true),
2189
- prunedAddonIds
2363
+ prunedAddonIds,
2364
+ removedAddonIds
2190
2365
  };
2191
2366
  }
2192
2367
  //#endregion
@@ -2459,16 +2634,26 @@ var PipelineModelManager = class {
2459
2634
  for (const { entry, step } of diff.unchanged) await this.reconcileDecode(entry, step.settings);
2460
2635
  }
2461
2636
  /**
2462
- * Get an `IInferenceEngine` handle. Without `modelId`, returns the
2463
- * active model for the step (camera-runtime hot path). Pass
2464
- * `modelId` to grab a specific warm variant (benchmark path).
2465
- * Touches the LRU clock so the looked-up model survives eviction
2466
- * pressure from concurrent loads.
2467
- */
2468
- getHandle(stepId, modelId) {
2469
- const entry = this.resolveOrThrow(stepId, modelId);
2637
+ * The warm variant that runs `(stepId, modelId)` — its handle AND the model
2638
+ * loaded at that pool index, which is the only honest answer to "which model
2639
+ * produced this output" (D658).
2640
+ *
2641
+ * No active-variant fallback, deliberately. The step-only lookup this
2642
+ * replaces (`getHandle(stepId)`, deleted) answered the step's ACTIVE
2643
+ * variant — the first one loaded after boot — and a tree that paired that
2644
+ * handle with the step's REQUESTED id stamped SigLIP2 on MobileCLIP vectors
2645
+ * for twenty minutes on 2026-09-27. A pair that is not resident throws,
2646
+ * naming both.
2647
+ */
2648
+ getVariant(stepId, modelId) {
2649
+ const entry = this.loaded.get(stepId)?.get(modelId);
2650
+ if (entry === void 0) throw new Error(`Step "${stepId}" has no warm variant for model "${modelId}" in the inference pool — refusing rather than running another variant`);
2470
2651
  this.touch(entry);
2471
- return this.pool.getHandle(entry.poolIndex);
2652
+ return {
2653
+ engine: this.pool.getHandle(entry.poolIndex),
2654
+ modelId: entry.modelId,
2655
+ poolIndex: entry.poolIndex
2656
+ };
2472
2657
  }
2473
2658
  /** True iff the step has any model loaded. */
2474
2659
  isLoaded(stepId) {
@@ -2499,13 +2684,14 @@ var PipelineModelManager = class {
2499
2684
  return this.activeByStep.get(stepId);
2500
2685
  }
2501
2686
  /**
2502
- * Pool index for a given (stepId, modelId). Without `modelId`,
2503
- * returns the active model's index. Used by the inference fast
2504
- * paths that bypass `getHandle` and call `pool.inferBatch` directly.
2687
+ * Pool index for exactly `(stepId, modelId)`, or `null` when that pair is not
2688
+ * warm. Used by the inference fast paths that bypass `getVariant` and call
2689
+ * `pool.inferBatch` directly. The model is REQUIRED: the active-variant
2690
+ * fallback this had timed a benchmark on whichever variant was active (D658).
2505
2691
  */
2506
2692
  getPoolIndex(stepId, modelId) {
2507
- const entry = this.resolve(stepId, modelId);
2508
- if (!entry) return null;
2693
+ const entry = this.loaded.get(stepId)?.get(modelId);
2694
+ if (entry === void 0) return null;
2509
2695
  this.touch(entry);
2510
2696
  return entry.poolIndex;
2511
2697
  }
@@ -2725,18 +2911,6 @@ var PipelineModelManager = class {
2725
2911
  await this.unloadEntry(evicted);
2726
2912
  }
2727
2913
  }
2728
- resolve(stepId, modelId) {
2729
- const perStep = this.loaded.get(stepId);
2730
- if (!perStep) return null;
2731
- const targetModelId = modelId ?? this.activeByStep.get(stepId);
2732
- if (!targetModelId) return null;
2733
- return perStep.get(targetModelId) ?? null;
2734
- }
2735
- resolveOrThrow(stepId, modelId) {
2736
- const entry = this.resolve(stepId, modelId);
2737
- if (!entry) throw new Error(`Step "${stepId}"${modelId ? ` (model "${modelId}")` : ""} is not loaded in the inference pool`);
2738
- return entry;
2739
- }
2740
2914
  touch(entry) {
2741
2915
  entry.lruTick = ++this.lruClock;
2742
2916
  }
@@ -2932,16 +3106,15 @@ var EngineFactory = class {
2932
3106
  await this.poolManager.applyConfig(steps);
2933
3107
  }
2934
3108
  /**
2935
- * Get an IInferenceEngine for a pipeline step. Without `modelId`,
2936
- * resolves to the active model (camera-runtime hot path); pass
2937
- * `modelId` to grab a specific warm variant (benchmark / override
2938
- * paths picking a non-runtime model).
3109
+ * The resident variant for exactly `(stepId, modelId)`, with the model it
3110
+ * runs — never the step's active variant in its place (D658). The executable
3111
+ * tree is built from this, and a result's model stamp comes from it.
2939
3112
  *
2940
- * @throws if step not loaded with the requested (or active) model.
3113
+ * @throws if that exact pair is not warm.
2941
3114
  */
2942
- getEngine(stepId, modelId) {
3115
+ getStepVariant(stepId, modelId) {
2943
3116
  if (!this.poolManager) throw new Error("EngineFactory not initialized");
2944
- return this.poolManager.getHandle(stepId, modelId);
3117
+ return this.poolManager.getVariant(stepId, modelId);
2945
3118
  }
2946
3119
  /** Check if a step is loaded (any variant). */
2947
3120
  isLoaded(stepId) {
@@ -3045,7 +3218,7 @@ var EngineFactory = class {
3045
3218
  async batchInferRaw(stepId, items, modelId, frameId = 0) {
3046
3219
  if (!this.poolManager) throw new Error("EngineFactory.batchInferRaw: pool not initialized");
3047
3220
  const idx = this.poolManager.getPoolIndex(stepId, modelId);
3048
- if (idx === null) throw new Error(`EngineFactory.batchInferRaw: step "${stepId}"${modelId ? ` (model "${modelId}")` : ""} is not loaded`);
3221
+ if (idx === null) throw new Error(`EngineFactory.batchInferRaw: step "${stepId}" (model "${modelId}") is not loaded`);
3049
3222
  return this.poolManager.getPool().inferBatch(idx, items, frameId);
3050
3223
  }
3051
3224
  async cacheFrameInPool(raw, width, height, format) {
@@ -3055,7 +3228,7 @@ var EngineFactory = class {
3055
3228
  async inferCached(stepId, frameId, modelId) {
3056
3229
  if (!this.poolManager) throw new Error("inferCached: pool not available");
3057
3230
  const idx = this.poolManager.getPoolIndex(stepId, modelId);
3058
- if (idx === null) throw new Error(`inferCached: step "${stepId}"${modelId ? ` (model "${modelId}")` : ""} not loaded`);
3231
+ if (idx === null) throw new Error(`inferCached: step "${stepId}" (model "${modelId}") not loaded`);
3059
3232
  return this.poolManager.getPool().inferCached(idx, frameId);
3060
3233
  }
3061
3234
  async uncacheFrame(frameId) {
@@ -3950,13 +4123,16 @@ function checkReplayPin(input) {
3950
4123
  }
3951
4124
  var PRIMARY_ROOT_STEP = "object-detection";
3952
4125
  /**
3953
- * The root model a frame result reports: `object-detection` when it runs (it
3954
- * is the detector the tracks come from), else the first enabled video root.
3955
- * `undefined` when no root runs — never a fabricated id.
4126
+ * The same rule over an EXECUTABLE tree's roots (D658): their model ids are the
4127
+ * pool variants that will run — after resolution, per-step loads and drops —
4128
+ * so this is what a live `runPipeline` stamps. The tree holds only enabled
4129
+ * video roots already.
3956
4130
  */
3957
- function resolveRootModelId(roots) {
3958
- const enabled = roots.filter((s) => s.enabled && s.slot !== "audio-classifier");
3959
- return (enabled.find((s) => s.addonId === PRIMARY_ROOT_STEP) ?? enabled[0])?.modelId;
4131
+ function resolveTreeRootModelId(roots) {
4132
+ return primaryRootModelId(roots);
4133
+ }
4134
+ function primaryRootModelId(roots) {
4135
+ return (roots.find((s) => s.stepId === PRIMARY_ROOT_STEP) ?? roots[0])?.modelId;
3960
4136
  }
3961
4137
  //#endregion
3962
4138
  //#region src/detection-pipeline/engine-provisioner.ts
@@ -6749,26 +6925,47 @@ function resolveEffectiveClassMap(definition, modelId, resolveModel) {
6749
6925
  * Build an executable tree from user config.
6750
6926
  *
6751
6927
  * @param steps - User-configured pipeline steps (from PipelineDefaultStep[])
6752
- * @param getEngine - Function that returns an IInferenceEngine for a step ID.
6753
- * Throws if step not loaded.
6928
+ * @param getEngine - Returns the engine that runs exactly `(stepId, modelId)`
6929
+ * and the model it runs. Throws if that pair is not loaded.
6754
6930
  */
6755
- function buildExecutableTree(steps, getEngine, resolveModel) {
6756
- return { roots: steps.filter((s) => s.enabled).filter((s) => s.slot !== "audio-classifier").map((s) => buildNode(s, getEngine, resolveModel)) };
6931
+ function buildExecutableTree(steps, getEngine, resolveModel, onMissingVariant) {
6932
+ return { roots: steps.filter((s) => s.enabled).filter((s) => s.slot !== "audio-classifier").map((s) => buildNode(s, getEngine, resolveModel, onMissingVariant)) };
6933
+ }
6934
+ function subtreeIds(step) {
6935
+ return [step.addonId, ...(step.children ?? []).filter((c) => c.enabled).flatMap((c) => subtreeIds(c))];
6936
+ }
6937
+ function buildChild(child, parentStepId, getEngine, resolveModel, onMissingVariant) {
6938
+ if (onMissingVariant === void 0) return buildNode(child, getEngine, resolveModel, onMissingVariant);
6939
+ let variant;
6940
+ try {
6941
+ variant = getEngine(child.addonId, child.modelId);
6942
+ } catch (err) {
6943
+ onMissingVariant({
6944
+ step: child,
6945
+ stepId: child.addonId,
6946
+ modelId: child.modelId,
6947
+ parentStepId,
6948
+ droppedSubtree: subtreeIds(child),
6949
+ error: err instanceof Error ? err.message : String(err)
6950
+ });
6951
+ return null;
6952
+ }
6953
+ return buildNode(child, getEngine, resolveModel, onMissingVariant, variant);
6757
6954
  }
6758
- function buildNode(step, getEngine, resolveModel) {
6955
+ function buildNode(step, getEngine, resolveModel, onMissingVariant, resolved) {
6759
6956
  const definition = require_default_detection_model.getStepDefinition(step.addonId);
6760
- const engine = getEngine(step.addonId);
6761
- const children = (step.children ?? []).filter((c) => c.enabled).map((c) => buildNode(c, getEngine, resolveModel));
6957
+ const variant = resolved ?? getEngine(step.addonId, step.modelId);
6958
+ const children = (step.children ?? []).filter((c) => c.enabled).map((c) => buildChild(c, step.addonId, getEngine, resolveModel, onMissingVariant)).filter((c) => c !== null);
6762
6959
  const mergedSettings = {
6763
6960
  ...collectSchemaDefaults(step.addonId),
6764
6961
  ...step.settings
6765
6962
  };
6766
- const effectiveClassMap = resolveEffectiveClassMap(definition, step.modelId, resolveModel);
6963
+ const effectiveClassMap = resolveEffectiveClassMap(definition, variant.modelId, resolveModel);
6767
6964
  return {
6768
6965
  stepId: step.addonId,
6769
6966
  definition,
6770
- engine,
6771
- modelId: step.modelId,
6967
+ engine: variant.engine,
6968
+ modelId: variant.modelId,
6772
6969
  inputClasses: definition.inputClasses ?? [],
6773
6970
  enabled: step.enabled,
6774
6971
  children,
@@ -6798,43 +6995,6 @@ function walkFieldsForDefaults(fields, out) {
6798
6995
  }
6799
6996
  }
6800
6997
  //#endregion
6801
- //#region src/detection-pipeline/registry/custom-models.ts
6802
- /**
6803
- * Group a flat list of custom-model descriptors (as returned by the
6804
- * `custom-model-registry` collection cap) into a `stepId → entries` map for
6805
- * the picker / resolution union. Pure; order within a step preserved.
6806
- */
6807
- function groupCustomModelsByStep(descriptors) {
6808
- const byStep = /* @__PURE__ */ new Map();
6809
- for (const d of descriptors) {
6810
- const arr = byStep.get(d.stepId) ?? [];
6811
- arr.push(d.entry);
6812
- byStep.set(d.stepId, arr);
6813
- }
6814
- return byStep;
6815
- }
6816
- /**
6817
- * Union a step's static catalog models with operator-registered custom
6818
- * models. On an `id` collision the static catalog entry wins — a custom
6819
- * model can never shadow a built-in one.
6820
- *
6821
- * Pure + side-effect-free so it can be unit-tested in isolation and called
6822
- * from the (free) `buildSchemaSlots` builder without any addon context.
6823
- */
6824
- function mergeCustomModels(staticModels, customModels) {
6825
- const seen = new Set(staticModels.map((m) => m.id));
6826
- const merged = [...staticModels];
6827
- for (const m of customModels) {
6828
- if (seen.has(m.id)) continue;
6829
- seen.add(m.id);
6830
- merged.push({
6831
- ...m,
6832
- provider: require_dist.inferModelProvider(m)
6833
- });
6834
- }
6835
- return merged;
6836
- }
6837
- //#endregion
6838
6998
  //#region src/detection-pipeline/cluster-model-resolution.ts
6839
6999
  /**
6840
7000
  * The cluster model rule, enforced on the node that has to obey it.
@@ -6939,8 +7099,10 @@ function collectSubstitutions(steps, format) {
6939
7099
  return result;
6940
7100
  }
6941
7101
  /**
6942
- * Return one {@link ZeroBuildIssue} per step in `steps` whose catalog entry
6943
- * (via `getStepDef`) has ZERO models with a build for `format`. `steps` is
7102
+ * Return one {@link ZeroBuildIssue} per step in `steps` that a pool of `format`
7103
+ * cannot run: neither the model it names nor any NON-legacy model of its
7104
+ * catalog (via `getStepDef`) ships a build for `format`
7105
+ * (`stepModelRunsOnFormat`, D657). `steps` is
6944
7106
  * expected to already be flattened + filtered to the set the caller cares
6945
7107
  * about (e.g. enabled video steps) — this does not recurse into children or
6946
7108
  * filter by `enabled`, unlike {@link collectSubstitutions}.
@@ -6961,7 +7123,7 @@ function collectZeroBuildIssues(steps, format, getStepDef = require_default_dete
6961
7123
  } catch {
6962
7124
  continue;
6963
7125
  }
6964
- if (!def.models.some((m) => m.formats[format])) result.push({
7126
+ if (!require_dist.stepModelRunsOnFormat(def.models, step.modelId, format)) result.push({
6965
7127
  addonId: step.addonId,
6966
7128
  format
6967
7129
  });
@@ -7011,11 +7173,20 @@ function collectUnknownAddonIssues(steps, getStepDef = require_default_detection
7011
7173
  * `diagnostics.substitutions` instead of happening silently. The caller
7012
7174
  * (the provider) owns logging + dedup for both.
7013
7175
  */
7014
- function resolveInputSteps(steps, format, engine, clusterModels, clusterSettings) {
7176
+ function resolveInputSteps(steps, format, engine, clusterModels, clusterSettings, unknownClusterSteps) {
7015
7177
  const resolvedSteps = [];
7016
7178
  const unknownAddonIds = [];
7017
7179
  const substitutions = [];
7018
7180
  const clusterRefusals = [];
7181
+ const clusterUnknown = [];
7182
+ const recurse = (children) => {
7183
+ const r = resolveInputSteps(children, format, engine, clusterModels, clusterSettings, unknownClusterSteps);
7184
+ unknownAddonIds.push(...r.diagnostics.unknownAddonIds);
7185
+ substitutions.push(...r.diagnostics.substitutions);
7186
+ clusterRefusals.push(...r.diagnostics.clusterRefusals);
7187
+ clusterUnknown.push(...r.diagnostics.clusterUnknown);
7188
+ return r;
7189
+ };
7019
7190
  for (const s of steps) {
7020
7191
  let def;
7021
7192
  try {
@@ -7024,6 +7195,10 @@ function resolveInputSteps(steps, format, engine, clusterModels, clusterSettings
7024
7195
  unknownAddonIds.push(s.addonId);
7025
7196
  continue;
7026
7197
  }
7198
+ if (def.modelScope === "cluster" && unknownClusterSteps?.has(s.addonId) === true) {
7199
+ clusterUnknown.push(s.addonId);
7200
+ continue;
7201
+ }
7027
7202
  if (clusterModels !== void 0) {
7028
7203
  const verdict = resolveClusterStepModel(s.addonId, clusterModels, format, () => def);
7029
7204
  if (verdict.kind !== "node-scoped") {
@@ -7033,12 +7208,7 @@ function resolveInputSteps(steps, format, engine, clusterModels, clusterSettings
7033
7208
  format: verdict.format,
7034
7209
  formatsShipped: verdict.formatsShipped
7035
7210
  });
7036
- const childResult = s.children ? resolveInputSteps(s.children, format, engine, clusterModels, clusterSettings) : null;
7037
- if (childResult) {
7038
- unknownAddonIds.push(...childResult.diagnostics.unknownAddonIds);
7039
- substitutions.push(...childResult.diagnostics.substitutions);
7040
- clusterRefusals.push(...childResult.diagnostics.clusterRefusals);
7041
- }
7211
+ const childResult = s.children ? recurse(s.children) : null;
7042
7212
  const settings = require_dist.overlayClusterStepSettings(s.addonId, s.settings, clusterSettings ?? {});
7043
7213
  resolvedSteps.push({
7044
7214
  addonId: s.addonId,
@@ -7062,12 +7232,7 @@ function resolveInputSteps(steps, format, engine, clusterModels, clusterSettings
7062
7232
  running: runningModelId,
7063
7233
  format
7064
7234
  });
7065
- const childResult = s.children ? resolveInputSteps(s.children, format, engine, clusterModels, clusterSettings) : null;
7066
- if (childResult) {
7067
- unknownAddonIds.push(...childResult.diagnostics.unknownAddonIds);
7068
- substitutions.push(...childResult.diagnostics.substitutions);
7069
- clusterRefusals.push(...childResult.diagnostics.clusterRefusals);
7070
- }
7235
+ const childResult = s.children ? recurse(s.children) : null;
7071
7236
  const settings = require_dist.overlayClusterStepSettings(s.addonId, s.settings, clusterSettings ?? {});
7072
7237
  resolvedSteps.push({
7073
7238
  addonId: s.addonId,
@@ -7086,7 +7251,8 @@ function resolveInputSteps(steps, format, engine, clusterModels, clusterSettings
7086
7251
  diagnostics: {
7087
7252
  unknownAddonIds,
7088
7253
  substitutions,
7089
- clusterRefusals
7254
+ clusterRefusals,
7255
+ clusterUnknown
7090
7256
  }
7091
7257
  };
7092
7258
  }
@@ -7329,6 +7495,62 @@ function stepModelKey(step) {
7329
7495
  function stateOf(verdict) {
7330
7496
  return verdict.kind === "refused" ? "refused" : `failed:${verdict.generation}`;
7331
7497
  }
7498
+ /** The load policy of a `runPipeline` call — see {@link DispatchLoadPolicy}. */
7499
+ function dispatchLoadPolicyOf(input) {
7500
+ return input.deviceId !== void 0 && input.replay !== true ? "partial" : "all-or-nothing";
7501
+ }
7502
+ /**
7503
+ * An all-or-nothing call lost steps to failed loads (D657). Names every one,
7504
+ * so a benchmark never reports timings for a tree it did not run.
7505
+ */
7506
+ var DispatchStepsNotLoadedError = class extends Error {
7507
+ failedSteps;
7508
+ constructor(failures) {
7509
+ const named = failures.map((f) => `${f.step.addonId}/${f.step.modelId} (${f.error instanceof Error ? f.error.message : String(f.error)})`);
7510
+ super(`pipeline steps failed to load — the call runs all of them or none: ${named.join("; ")}`, { cause: failures[0]?.error });
7511
+ this.name = "DispatchStepsNotLoadedError";
7512
+ this.failedSteps = failures.map((f) => `${f.step.addonId}/${f.step.modelId}`);
7513
+ }
7514
+ };
7515
+ /**
7516
+ * A step's model artifact cannot be made present for the pool's format — no
7517
+ * build for that format, a custom model missing from this node, a download
7518
+ * that failed. Named by step and model so the failure is charged to THAT
7519
+ * model only (D657): a plain `Error` was charged to every model of the set,
7520
+ * and one face model with no tflite build backed the Coral's root detector
7521
+ * off for minutes.
7522
+ */
7523
+ var StepModelArtifactError = class extends Error {
7524
+ stepId;
7525
+ modelId;
7526
+ constructor(stepId, modelId, cause) {
7527
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
7528
+ this.name = "StepModelArtifactError";
7529
+ this.stepId = stepId;
7530
+ this.modelId = modelId;
7531
+ }
7532
+ };
7533
+ /** The one model a failed load is charged to, when the error names it. */
7534
+ function failedModelOf(err) {
7535
+ if (err instanceof StepVariantLoadError || err instanceof StepModelArtifactError) return `${err.stepId}/${err.modelId}`;
7536
+ return null;
7537
+ }
7538
+ /**
7539
+ * `steps` without the subtrees of the steps whose model did not load (D657).
7540
+ * A step's children consume its output, so they leave with it. Matched by
7541
+ * INSTANCE: two steps of one addon in a tree are two steps.
7542
+ */
7543
+ function withoutFailedSteps(steps, failed) {
7544
+ const kept = [];
7545
+ for (const step of steps) {
7546
+ if (failed.has(step)) continue;
7547
+ kept.push(step.children && step.children.length > 0 ? {
7548
+ ...step,
7549
+ children: withoutFailedSteps(step.children, failed)
7550
+ } : step);
7551
+ }
7552
+ return kept;
7553
+ }
7332
7554
  /**
7333
7555
  * The reason a condemned pool is charged against its device's restart budget.
7334
7556
  * A pool recycled because it HUNG (D653) names the hang — `inference device
@@ -7436,18 +7658,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7436
7658
  * paths don't issue a cap round-trip on every call. Empty map = no provider
7437
7659
  * (or a query failure) → behaviour identical to the static-catalog-only path.
7438
7660
  */
7439
- customModelsCache = null;
7661
+ customModels = new require_default_detection_model.CustomModelCatalog({ warn: (message, extras) => this.log.warn(message, extras) });
7440
7662
  /**
7441
7663
  * SYNC custom-model lookup handed to every live {@link EngineFactory} so
7442
7664
  * `buildPoolModelConfigForStep` can resolve an operator-registered model at
7443
- * pool-load time. Reads the {@link customModelsCache} snapshot — every load
7665
+ * pool-load time. Reads the {@link customModels} snapshot — every load
7444
7666
  * path (`ensureModelsForSteps`, `ensureModelsForCurrentSteps`) refreshes the
7445
7667
  * cache via `resolveModelEntry` BEFORE `loadAdditional`/`applyConfig`, so
7446
7668
  * the snapshot is fresh when the pool asks. Without this seam a selected
7447
7669
  * custom model failed the WHOLE pool applyConfig with
7448
7670
  * `Model "X" not found in step "Y" catalog`.
7449
7671
  */
7450
- customModelResolver = (stepId, modelId) => this.customModelsCache?.byStep.get(stepId)?.find((m) => m.id === modelId);
7672
+ customModelResolver = (stepId, modelId) => this.customModels.current().get(stepId)?.find((m) => m.id === modelId);
7451
7673
  /**
7452
7674
  * Per-device {@link DeviceProxy} cache used for zone gating at the
7453
7675
  * runtime path. Reads `state.zones.value` + `state.zoneRules.value`
@@ -7470,6 +7692,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7470
7692
  info: (message, extras) => this.log.info(message, extras),
7471
7693
  warn: (message, extras) => this.log.warn(message, extras)
7472
7694
  } });
7695
+ /** One line per camera when a cluster-scoped step starts running another model (D658). */
7696
+ clusterModelSwitchLog = new ClusterModelSwitchLog({ info: (message, extras) => this.log.info(message, extras) });
7697
+ /** One line per (camera, step) while a cluster step is refused as unknown (D658). */
7698
+ clusterUnknownLog = new ClusterUnknownRefusalLog({ warn: (message, extras) => this.log.warn(message, extras) });
7473
7699
  /**
7474
7700
  * Last logged "step configured" signature — used to dedupe the
7475
7701
  * per-step debug trail so it only fires on actual config CHANGE
@@ -7936,7 +8162,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7936
8162
  if (!steps) return;
7937
8163
  const format = this.currentEngine.format;
7938
8164
  const substitutionIssues = this.getActiveModelSubstitutions().map((s) => `${s.addonId}: chose ${s.chosen}, running ${s.running} (${s.format})`);
7939
- const zeroBuildIssues = collectZeroBuildIssues(flattenSteps(steps), format).map((i) => `${i.addonId}: no model has a ${i.format} build`);
8165
+ const zeroBuildIssues = collectZeroBuildIssues(flattenSteps(steps), format, this.mergedStepDefOrThrow).map((i) => `${i.addonId}: no model has a ${i.format} build`);
7940
8166
  this.configIssues = [...substitutionIssues, ...zeroBuildIssues];
7941
8167
  } catch (err) {
7942
8168
  this.configIssues = [];
@@ -8070,24 +8296,21 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
8070
8296
  * a warning — callers then behave exactly like the static-catalog path.
8071
8297
  */
8072
8298
  async getCustomModels() {
8073
- const now = Date.now();
8074
- if (this.customModelsCache && now - this.customModelsCache.at < 5e3) return this.customModelsCache.byStep;
8075
- let byStep = /* @__PURE__ */ new Map();
8076
- try {
8077
- const api = this.addonCtx?.api;
8078
- if (api) {
8079
- if ((await api.addons.listCapabilityProviders.query({ capName: "custom-model-registry" })).some((p) => p.isActive)) byStep = groupCustomModelsByStep(await api.customModelRegistry.listModels.query());
8080
- }
8081
- } catch (err) {
8082
- this.log.warn("custom-model-registry query failed — using static catalog only", { meta: { error: require_dist.errMsg(err) } });
8083
- }
8084
- this.customModelsCache = {
8085
- at: now,
8086
- byStep
8087
- };
8088
- return byStep;
8299
+ return this.customModels.refresh(this.addonCtx?.api);
8089
8300
  }
8090
8301
  /**
8302
+ * A step's definition MERGED with its custom models, synchronously, from
8303
+ * the last custom-model read (D657 round 2). Every placement gate here
8304
+ * judges this catalog — the one the orchestrator's schema carries.
8305
+ */
8306
+ mergedStepDef = (addonId) => this.customModels.stepDefinition(addonId);
8307
+ /** {@link mergedStepDef} as the throwing lookup `collectZeroBuildIssues` takes. */
8308
+ mergedStepDefOrThrow = (addonId) => {
8309
+ const def = this.mergedStepDef(addonId);
8310
+ if (def === null) throw new Error(`Unknown pipeline step: "${addonId}"`);
8311
+ return def;
8312
+ };
8313
+ /**
8091
8314
  * Resolve a model id within a step to a catalog entry — static catalog
8092
8315
  * first, then the custom registry. Returns undefined if neither has it.
8093
8316
  */
@@ -8278,7 +8501,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
8278
8501
  const enabledSteps = this.pruneToEnabledSteps(steps);
8279
8502
  const substitutions = collectSubstitutions(enabledSteps, format);
8280
8503
  const unknownAddonIssues = collectUnknownAddonIssues(enabledSteps);
8281
- const zeroBuildIssues = collectZeroBuildIssues(this.flattenEnabledVideoStepInputs(steps), format);
8504
+ const zeroBuildIssues = collectZeroBuildIssues(this.flattenEnabledVideoStepInputs(steps), format, this.mergedStepDefOrThrow);
8282
8505
  const issues = [...unknownAddonIssues.map((u) => ({
8283
8506
  addonId: u.addonId,
8284
8507
  kind: "unknown-addon",
@@ -8595,6 +8818,12 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
8595
8818
  }
8596
8819
  async runPipeline(input, onProgress) {
8597
8820
  await this.clusterModels.refresh(this.addonCtx?.api);
8821
+ this.clusterUnknownLog.note(input.deviceId ?? 0, input.steps, this.clusterModels.unknownSteps());
8822
+ if (dispatchLoadPolicyOf(input) === "all-or-nothing") {
8823
+ const unknown = unknownStepsNamed(input.steps, this.clusterModels.unknownSteps());
8824
+ if (unknown.length > 0) throw new Error(`cluster-model-unknown: the owner has not answered which model the cluster runs for ${unknown.join(", ")} — the call runs all of its steps or none`);
8825
+ }
8826
+ await this.getCustomModels();
8598
8827
  const nodeId = this.addonCtx?.kernel?.localNodeId ?? "hub";
8599
8828
  const sessionId = input.sessionId ?? `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
8600
8829
  const isRuntime = Boolean(input.frame || input.frameRef);
@@ -8759,11 +8988,6 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
8759
8988
  }
8760
8989
  const enabledSteps = flattenSteps(benchmarkSteps);
8761
8990
  emit(`Pipeline: ${enabledSteps.length} step(s) — ${enabledSteps.map((s) => s.addonId).join(" → ")}`);
8762
- const rootModelId = resolveRootModelId(benchmarkSteps);
8763
- const stampRoot = (r) => rootModelId === void 0 ? r : {
8764
- ...r,
8765
- rootModelId
8766
- };
8767
8991
  if (input.replay === true) {
8768
8992
  const verdict = checkReplayPin({
8769
8993
  requested: input.steps,
@@ -8805,24 +9029,51 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
8805
9029
  const dispatchEngine = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey) : runEngine;
8806
9030
  const dispatchFactory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
8807
9031
  const needed = enabledSteps.filter((s) => dispatchFactory.needsPoolUpdate(s));
9032
+ let runSteps = benchmarkSteps;
9033
+ const loadPolicy = dispatchLoadPolicyOf(input);
8808
9034
  if (needed.length > 0) {
8809
- this.log.info("Benchmark: models to load", { meta: {
8810
- count: needed.length,
8811
- models: needed.map((s) => `${s.addonId}/${s.modelId}`)
9035
+ const dispatchDeviceKeyLabel = deviceKeyOf(dispatchEngine);
9036
+ const toIssue = needed.filter((s) => this.loadGovernor.check(dispatchFactory, dispatchDeviceKeyLabel, [stepModelKey(s)]).kind === "go");
9037
+ if (toIssue.length > 0) this.log.info("Benchmark: models to load", { meta: {
9038
+ count: toIssue.length,
9039
+ models: toIssue.map((s) => `${s.addonId}/${s.modelId}`)
8812
9040
  } });
8813
- for (const s of needed) emit(`Loading model: ${s.addonName} (${s.modelId})...`, {
9041
+ for (const s of toIssue) emit(`Loading model: ${s.addonName} (${s.modelId})...`, {
8814
9042
  step: s.addonId,
8815
9043
  addonId: s.addonId,
8816
9044
  modelId: s.modelId
8817
9045
  });
8818
- await this.ensureModelsForSteps(benchmarkSteps, dispatchFactory, dispatchEngine.format, {
9046
+ const failures = await this.loadDispatchModels(benchmarkSteps, dispatchFactory, dispatchEngine.format, {
8819
9047
  ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
8820
- deviceKey: deviceKeyOf(dispatchEngine)
8821
- });
8822
- emit(`All models loaded`);
9048
+ deviceKey: dispatchDeviceKeyLabel
9049
+ }, loadPolicy);
9050
+ runSteps = stepsSurvivingLoad(benchmarkSteps, failures, loadPolicy);
9051
+ emit(failures.length === 0 ? `All models loaded` : `Models loaded (${failures.length} failed)`);
8823
9052
  }
8824
9053
  emit("Running inference...");
8825
- const tree = buildExecutableTree(benchmarkSteps, (stepId) => dispatchFactory.getEngine(stepId), this.customModelResolver);
9054
+ const notWarm = [];
9055
+ const tree = buildExecutableTree(runSteps, (stepId, modelId) => dispatchFactory.getStepVariant(stepId, modelId), this.customModelResolver, (drop) => notWarm.push({
9056
+ step: drop.step,
9057
+ error: new Error(drop.error)
9058
+ }));
9059
+ if (notWarm.length > 0) {
9060
+ if (loadPolicy === "all-or-nothing") throw new DispatchStepsNotLoadedError(notWarm);
9061
+ const dispatchDeviceKeyLabel = deviceKeyOf(dispatchEngine);
9062
+ for (const failure of notWarm) {
9063
+ const model = stepModelKey(failure.step);
9064
+ this.reportDispatchLoadLoss({
9065
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
9066
+ deviceKey: dispatchDeviceKeyLabel,
9067
+ outcome: "step-dropped"
9068
+ }, dispatchDeviceKeyLabel, [model], model, "not-warm", require_dist.errMsg(failure.error));
9069
+ }
9070
+ }
9071
+ const rootModelId = resolveTreeRootModelId(tree.roots);
9072
+ const stampRoot = (r) => rootModelId === void 0 ? r : {
9073
+ ...r,
9074
+ rootModelId
9075
+ };
9076
+ if (input.replay !== true) this.clusterModelSwitchLog.note(input.deviceId ?? 0, tree);
8826
9077
  setupMs = performance.now() - wallT0 - decodeMs;
8827
9078
  const effectiveDeviceId = input.deviceId ?? 0;
8828
9079
  const deviceOverrides = effectiveDeviceId > 0 ? await this.deviceOverrides.resolve(effectiveDeviceId) : {};
@@ -8928,7 +9179,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
8928
9179
  backend: this.currentEngine.backend,
8929
9180
  device: this.currentEngine.device ?? null
8930
9181
  }, clusterRefusalsOut) {
8931
- const { steps: resolvedSteps, diagnostics } = resolveInputSteps(steps, format, engine, this.clusterModels.current(), this.clusterModels.currentSettings());
9182
+ const { steps: resolvedSteps, diagnostics } = resolveInputSteps(steps, format, engine, this.clusterModels.current(), this.clusterModels.currentSettings(), this.clusterModels.unknownSteps());
8932
9183
  if (clusterRefusalsOut) clusterRefusalsOut.push(...diagnostics.clusterRefusals);
8933
9184
  for (const addonId of diagnostics.unknownAddonIds) this.logLiveDispatchIssueOnce(`unknown:${addonId}`, () => this.log.warn("Live pipeline step references an unknown addon — dropping step", { meta: { addonId } }));
8934
9185
  for (const sub of diagnostics.substitutions) this.logLiveDispatchIssueOnce(`${sub.addonId}|${sub.chosen}|${sub.running}|${sub.format}`, () => this.log.info("Live pipeline step model substituted for engine format (node-local)", { meta: {
@@ -9041,7 +9292,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9041
9292
  const deviceRefusals = [];
9042
9293
  const steps = this.inputStepsToPipelineSteps(args.steps, deviceFormat, deviceStepEngine, deviceRefusals);
9043
9294
  const framePlane = args.plane === "frame";
9044
- const zeroBuild = collectZeroBuildIssues(framePlane ? collectFramePlaneSteps(steps, isDetailPlaneStep) : flattenSteps(steps), deviceFormat);
9295
+ const zeroBuild = collectZeroBuildIssues(framePlane ? collectFramePlaneSteps(steps, isDetailPlaneStep) : flattenSteps(steps), deviceFormat, this.mergedStepDefOrThrow);
9045
9296
  if (zeroBuild.length === 0) {
9046
9297
  if (!framePlane) {
9047
9298
  this.logClusterRefusals(deviceRefusals, deviceId, `${deviceKey} (${deviceFormat})`);
@@ -9050,7 +9301,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9050
9301
  steps
9051
9302
  };
9052
9303
  }
9053
- const pruneResult = pruneUnrunnableDetailSteps(steps, isDetailPlaneStep, (addonId) => stepHasZeroBuildsFor(addonId, deviceFormat));
9304
+ const pruneResult = pruneUnrunnableDetailSteps(steps, isDetailPlaneStep, (subtree) => !require_default_detection_model.buildSubtreeCanRun(subtree, this.mergedStepDef)(deviceFormat));
9054
9305
  if (pruneResult.prunedAddonIds.length > 0) {
9055
9306
  const prunedList = pruneResult.prunedAddonIds.join(",");
9056
9307
  this.logLiveDispatchIssueOnce(`detail-defer:${deviceKey}:${deviceFormat}:${prunedList}`, () => this.log.info("Detail steps have no model build for the dispatch device format — deferred to the detail-plane device jump", {
@@ -9062,7 +9313,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9062
9313
  }
9063
9314
  }));
9064
9315
  }
9065
- this.logClusterRefusals(deviceRefusals.filter((r) => !pruneResult.prunedAddonIds.includes(r.stepId)), deviceId, `${deviceKey} (${deviceFormat})`);
9316
+ this.logClusterRefusals(deviceRefusals.filter((r) => !pruneResult.removedAddonIds.includes(r.stepId)), deviceId, `${deviceKey} (${deviceFormat})`);
9066
9317
  return {
9067
9318
  deviceKey,
9068
9319
  steps: pruneResult.steps
@@ -9096,7 +9347,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9096
9347
  * microseconds instead of a 3-retry download backoff).
9097
9348
  */
9098
9349
  assertStepsRunnable(steps, format, deviceId, enginesTried) {
9099
- const zeroBuild = collectZeroBuildIssues(flattenSteps(steps), format);
9350
+ const zeroBuild = collectZeroBuildIssues(flattenSteps(steps), format, this.mergedStepDefOrThrow);
9100
9351
  if (zeroBuild.length === 0) return;
9101
9352
  const detail = zeroBuild.map((issue) => {
9102
9353
  const step = flattenSteps(steps).find((s) => s.addonId === issue.addonId);
@@ -9130,6 +9381,40 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9130
9381
  nullPoolKey = {};
9131
9382
  /** Negative cache, per-model compile-timeout refusals, per-camera reporting (D653). */
9132
9383
  loadGovernor = new ModelLoadGovernor();
9384
+ /**
9385
+ * Load a dispatch's models ONE STEP AT A TIME (D657), and answer which steps
9386
+ * did not load. Never throws: each step is its own load, joined, backed off
9387
+ * and charged on its own model, so a step whose model cannot load on this
9388
+ * pool — no build for its format, a compile that failed — costs that step,
9389
+ * never the root or a sibling. The caller decides what the dispatch runs
9390
+ * without it ({@link stepsSurvivingLoad}).
9391
+ */
9392
+ async loadDispatchModels(steps, factory, format, context, policy) {
9393
+ const failures = [];
9394
+ const walk = async (nodes, root) => {
9395
+ for (const step of nodes) {
9396
+ if (!step.enabled || step.slot === "audio-classifier") continue;
9397
+ if (factory === null || factory.needsPoolUpdate(step)) try {
9398
+ await this.ensureModelsForSteps([{
9399
+ ...step,
9400
+ children: []
9401
+ }], factory, format, {
9402
+ ...context,
9403
+ outcome: policy === "all-or-nothing" ? "call-failed" : root ? "root-lost" : "step-dropped"
9404
+ });
9405
+ } catch (err) {
9406
+ failures.push({
9407
+ step,
9408
+ error: err
9409
+ });
9410
+ continue;
9411
+ }
9412
+ await walk(step.children ?? [], false);
9413
+ }
9414
+ };
9415
+ await walk(steps, true);
9416
+ return failures;
9417
+ }
9133
9418
  /** Ensure all models needed by steps are downloaded and loaded in the engine pool.
9134
9419
  * `factory`/`format` default to the node's engine; a per-device dispatch passes
9135
9420
  * that device's factory + format (Phase 2 multi-device). `context` names the
@@ -9159,7 +9444,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9159
9444
  this.reportDispatchLoadLoss(context, deviceKey, models, err.model, err.state, err.message);
9160
9445
  throw err;
9161
9446
  }
9162
- const failed = err instanceof StepVariantLoadError ? `${err.stepId}/${err.modelId}` : models[0] ?? "";
9447
+ const failed = failedModelOf(err) ?? models[0] ?? "";
9163
9448
  this.reportDispatchLoadLoss(context, deviceKey, models, failed, `failed:${flight.generation ?? "unknown"}`, require_dist.errMsg(err));
9164
9449
  throw err;
9165
9450
  }
@@ -9251,7 +9536,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9251
9536
  }
9252
9537
  /** Charge a failed load to the model that failed; say so once if it is now refused. */
9253
9538
  recordModelLoadFailure(pool, deviceKey, models, err) {
9254
- const failedModels = err instanceof StepVariantLoadError ? [`${err.stepId}/${err.modelId}`] : models;
9539
+ const named = failedModelOf(err);
9540
+ const failedModels = named !== null ? [named] : models;
9255
9541
  const compileTimeout = err instanceof StepVariantLoadError && err.reason === "compile-timeout";
9256
9542
  const poolModel = err instanceof StepVariantLoadError ? err.poolModel : null;
9257
9543
  let generation = 0;
@@ -9284,24 +9570,31 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9284
9570
  models,
9285
9571
  model,
9286
9572
  state,
9287
- error
9573
+ error,
9574
+ ...context.outcome !== void 0 ? { outcome: context.outcome } : {}
9288
9575
  }
9289
9576
  });
9290
9577
  }
9291
9578
  /** Download any model of `needed` missing in `format` (the device's). */
9292
9579
  async downloadNeededModels(needed, format) {
9293
- for (const step of needed) {
9294
- const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
9295
- if (modelEntry && !(0, _camstack_system_addon_utils.isModelDownloaded)(this.modelsDir, modelEntry, format)) {
9296
- if (modelEntry.formats[format] === void 0) throw new Error(`Model "${modelEntry.id}" has no ${format} format build (available: ${Object.keys(modelEntry.formats).join(", ") || "none"}) — not retrying a permanent format mismatch`);
9297
- if (modelEntry.formats[format]?.url.startsWith("camstack-local://") === true) throw new Error(`Custom model "${modelEntry.id}" (${format}) is not present in this node's models directory — distribute it to this node from Model Studio before selecting it here`);
9298
- this.log.info("Downloading model for step", { meta: {
9299
- modelId: step.modelId,
9300
- format,
9301
- step: step.addonId
9302
- } });
9303
- await this.downloadWithRetry(modelEntry, format, 3);
9304
- }
9580
+ for (const step of needed) try {
9581
+ await this.ensureStepModelArtifact(step, format);
9582
+ } catch (err) {
9583
+ throw new StepModelArtifactError(step.addonId, step.modelId, err);
9584
+ }
9585
+ }
9586
+ /** Make one step's model artifact present on disk for `format`, or say why it cannot be. */
9587
+ async ensureStepModelArtifact(step, format) {
9588
+ const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
9589
+ if (modelEntry && !(0, _camstack_system_addon_utils.isModelDownloaded)(this.modelsDir, modelEntry, format)) {
9590
+ if (modelEntry.formats[format] === void 0) throw new Error(`Model "${modelEntry.id}" has no ${format} format build (available: ${Object.keys(modelEntry.formats).join(", ") || "none"}) — not retrying a permanent format mismatch`);
9591
+ if (modelEntry.formats[format]?.url.startsWith("camstack-local://") === true) throw new Error(`Custom model "${modelEntry.id}" (${format}) is not present in this node's models directory — distribute it to this node from Model Studio before selecting it here`);
9592
+ this.log.info("Downloading model for step", { meta: {
9593
+ modelId: step.modelId,
9594
+ format,
9595
+ step: step.addonId
9596
+ } });
9597
+ await this.downloadWithRetry(modelEntry, format, 3);
9305
9598
  }
9306
9599
  }
9307
9600
  /** Download a model with retry + exponential backoff */
@@ -9399,6 +9692,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9399
9692
  }
9400
9693
  async runPipelineBatchImpl(input) {
9401
9694
  if (input.frames.length === 0) return { results: [] };
9695
+ await this.getCustomModels();
9402
9696
  const dispatchResolution = this.resolveStepsForDispatch({
9403
9697
  steps: input.steps,
9404
9698
  deviceKey: input.deviceKey,
@@ -9641,9 +9935,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9641
9935
  return this.engineFactory.cacheFrameInPool(buf, input.width, input.height, input.format);
9642
9936
  }
9643
9937
  async inferCached(input) {
9938
+ if (typeof input.modelId !== "string" || input.modelId === "") throw new Error(`inferCached: no modelId for step "${input.stepId}" — refusing rather than running the step's active variant`);
9644
9939
  await this.ensureEngineFactory();
9645
9940
  if (!this.engineFactory) throw new Error("inferCached: factory not initialized");
9646
- return this.engineFactory.inferCached(input.stepId, input.frameId);
9941
+ return this.engineFactory.inferCached(input.stepId, input.frameId, input.modelId);
9647
9942
  }
9648
9943
  async uncacheFrame(input) {
9649
9944
  if (!this.engineFactory) return;
@@ -10283,7 +10578,7 @@ function buildSchemaSlots(formats, modelsDir, customByStep) {
10283
10578
  const formatList = [...formats];
10284
10579
  for (const pipelineStep of require_default_detection_model.ALL_PIPELINE_STEPS) {
10285
10580
  const step = pipelineStep.definition;
10286
- const availableModels = mergeCustomModels(step.models, customByStep?.get(step.id) ?? []).filter((m) => m.legacy !== true && formatList.some((f) => m.formats[f]));
10581
+ const availableModels = require_default_detection_model.mergeCustomModels(step.models, customByStep?.get(step.id) ?? []).filter((m) => m.legacy !== true && formatList.some((f) => m.formats[f]));
10287
10582
  if (availableModels.length === 0) continue;
10288
10583
  const slot = step.slot;
10289
10584
  if (!slotMap.has(slot)) slotMap.set(slot, []);
@@ -10447,13 +10742,22 @@ function isDetailPlaneStep(addonId) {
10447
10742
  }
10448
10743
  }
10449
10744
  /**
10450
- * True when the step's FULL catalog ships no model with a build for
10451
- * `format` — the single-step form of `collectZeroBuildIssues`. Unknown/custom
10452
- * addonIds (no catalog to check) are never zero-build, mirroring the
10453
- * collector's skip.
10454
- */
10455
- function stepHasZeroBuildsFor(addonId, format) {
10456
- return collectZeroBuildIssues([{ addonId }], format).length > 0;
10745
+ * What a dispatch runs after its per-step loads (D657).
10746
+ *
10747
+ * An ALL-OR-NOTHING call (a benchmark, a replay — D56 — a capacity ramp) fails
10748
+ * on any failure, naming every step it lost ({@link DispatchStepsNotLoadedError}):
10749
+ * it measures or reproduces exactly what it names. A camera's LIVE dispatch
10750
+ * runs the tree without the steps whose model did not load, and fails — with
10751
+ * the first root's own named error — only when no root survives: a dispatch
10752
+ * with no root has nothing to produce.
10753
+ */
10754
+ function stepsSurvivingLoad(steps, failures, policy) {
10755
+ if (failures.length === 0) return steps;
10756
+ if (policy === "all-or-nothing") throw new DispatchStepsNotLoadedError(failures);
10757
+ const kept = withoutFailedSteps(steps, new Set(failures.map((f) => f.step)));
10758
+ if (kept.some((s) => s.enabled)) return kept;
10759
+ const roots = new Set(steps);
10760
+ throw (failures.find((f) => roots.has(f.step)) ?? failures[0]).error;
10457
10761
  }
10458
10762
  /**
10459
10763
  * The union of model formats a step's catalog ships ANY build for — the