@camstack/addon-pipeline 1.2.296 → 1.2.297

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 (32) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-D1daTtqT.js → default-detection-model-B2n_SFXt.js} +368 -5
  4. package/dist/{default-detection-model-Co578D8C.mjs → default-detection-model-D6DypCkI.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-CxHIpsQs.mjs} +53 -123
  8. package/dist/{dist-8up-f2TX.js → dist-DUqr1zyq.js} +64 -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-Dx6DLQ1j.js} +1 -1
  12. package/dist/{node-DgMSXSWP.mjs → node-DyeWu78a.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-BVR4592X.mjs} +1 -1
  16. package/dist/{process-memory-CX_92V_r.js → process-memory-CMJ3NY-s.js} +1 -1
  17. package/dist/recorder/index.js +2 -2
  18. package/dist/recorder/index.mjs +2 -2
  19. package/dist/{segment-demux-js-DzBx6NN2.js → segment-demux-js-CmGIF_uK.js} +1 -1
  20. package/dist/{segment-demux-js-FZbBuk3F.mjs → segment-demux-js-Dzga-XgE.mjs} +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-P71ze8cu.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-DUGQKsKL.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CZkpFU-J.mjs} +1 -1
  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-CRlStbz4.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_ui_mf_2_library__loadShare__.js-CkbplMHA.mjs +0 -26
@@ -1,9 +1,9 @@
1
1
  import { n as __require } from "../chunk-DnnnRqeS.mjs";
2
- import { At as pickClusterStepSettings, Cn as parseJsonUnknown, Dt as overlayClusterStepSettings, En as sleep, Gt as resolvePoolMemoryPolicy, H as YAMNET_TO_MACRO, Ln as string, Mt as pickInvalidClusterStepModels, On as array, Pn as object, Pt as pipelineExecutorCapability, Qt as supportedRuntimes$1, Rn as union, Sn as nodePin, Vt as resolveClusterStepModelId, cn as BaseAddon, et as defaultDeviceFor$1, gt as inferModelProvider, it as detectionPipelineCapability, j as PoolMemoryWatchdog, kt as pickClusterStepModels, lt as evaluateZoneRules, m as DEFAULT_CLUSTER_STEP_SETTINGS, mn as createEvent, p as DEFAULT_CLUSTER_STEP_MODELS, qt as runtimeDevices$1, st as enumerateInferenceDevices, t as APPLE_SA_TO_MACRO, tn as errMsg, vn as hydrateSchema, vt as loadContributionCapability, y as DEVICE_BACKEND_TO_FORMAT, zn as EventCategory } from "../dist-CCd0Q3nr.mjs";
3
- import { s as readProcessCost } from "../node-DgMSXSWP.mjs";
4
- import { a as localFrameRegistry, c as ALL_STEPS, d as getStepDefinition, f as resolveModelForFormat, l as getDefaultModelForFormat, m as landmarkPrecisionVerdict, n as resolveDefaultDetectionModel, r as macroGateVerdict, s as ALL_PIPELINE_STEPS, u as getStep } from "../default-detection-model-Co578D8C.mjs";
2
+ import { Dn as array, Dt as overlayClusterStepSettings, Gt as runtimeDevices$1, H as YAMNET_TO_MACRO, In as string, Kt as stepModelRunsOnFormat, Ln as union, Mt as pipelineExecutorCapability, Nn as object, Rn as EventCategory, Sn as parseJsonUnknown, Tn as sleep, Ut as resolvePoolMemoryPolicy, Zt as supportedRuntimes$1, _n as hydrateSchema, en as errMsg, et as defaultDeviceFor$1, it as detectionPipelineCapability, j as PoolMemoryWatchdog, kt as pickClusterStepSettings, lt as evaluateZoneRules, m as DEFAULT_CLUSTER_STEP_SETTINGS, pn as createEvent, sn as BaseAddon, st as enumerateInferenceDevices, t as APPLE_SA_TO_MACRO, u as CLUSTER_MODEL_SCOPED_STEPS, vt as loadContributionCapability, xn as nodePin, y as DEVICE_BACKEND_TO_FORMAT, zt as resolveClusterStepModelId } from "../dist-CxHIpsQs.mjs";
3
+ import { s as readProcessCost } from "../node-DyeWu78a.mjs";
4
+ import { _ as resolveModelForFormat, a as buildSubtreeCanRun, c as macroGateVerdict, f as ALL_PIPELINE_STEPS, g as getStepDefinition, h as getStep, i as mergeCustomModels, m as getDefaultModelForFormat, n as resolveDefaultDetectionModel, p as ALL_STEPS, r as CustomModelCatalog, u as localFrameRegistry, y as landmarkPrecisionVerdict } from "../default-detection-model-D6DypCkI.mjs";
5
5
  import { t as getSharp } from "../lazy-sharp-DXsqwpph.mjs";
6
- import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-DFC_O5zE.mjs";
6
+ import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-BVR4592X.mjs";
7
7
  import * as os from "node:os";
8
8
  import * as fs from "node:fs";
9
9
  import * as path$1 from "node:path";
@@ -360,12 +360,14 @@ function normalizeEngineNodeId(rawNodeId) {
360
360
  * builders eventually reaches ten of them, and the cameras attached by the
361
361
  * forgotten one quietly write a foreign feature space into the shared index.
362
362
  *
363
- * ## Why the failure mode keeps the last value
363
+ * ## Why the failure mode keeps the last value — and refuses before the first
364
364
  *
365
365
  * A failed read must never revert to the registry default MID-PASS: that would
366
366
  * silently change what every remaining vector of that dispatch means, which is
367
367
  * the exact class of harm the scope exists to prevent. So a failure keeps the
368
- * last known row and says so, loudly, once per failure.
368
+ * last known row and says so, once per change. Before the FIRST answer there is
369
+ * no row to keep: the step is refused by name (D658) — a default run then made
370
+ * the default model the pool's active variant for the process lifetime.
369
371
  */
370
372
  /**
371
373
  * The addon whose GLOBAL settings own the cluster row. Same owner as the crop
@@ -374,35 +376,58 @@ function normalizeEngineNodeId(rawNodeId) {
374
376
  * enforces it live — neither should own a value the other depends on).
375
377
  */
376
378
  var CLUSTER_MODEL_OWNER_ADDON_ID = "pipeline-orchestrator";
377
- /** TTL-cached read of the cluster model row. */
379
+ /**
380
+ * TTL-cached read of the cluster model row.
381
+ *
382
+ * A step's model is KNOWN only once the owner has answered for it (`stored` or
383
+ * `absent`). Until then it is UNKNOWN, and a cluster-scoped step is refused by
384
+ * name rather than run with the registry default (D49, D658): a default run
385
+ * during the owner's boot window made that model the pool's active variant for
386
+ * the whole process, and the face default was ArcFace until D651. Once known,
387
+ * an unanswered read keeps the LAST KNOWN model — never reverts to a default.
388
+ */
378
389
  var ClusterModelSource = class {
379
- models = DEFAULT_CLUSTER_STEP_MODELS;
390
+ models = {};
391
+ unknown = new Map(CLUSTER_MODEL_SCOPED_STEPS.map((s) => [s.stepId, "not-yet-asked"]));
380
392
  settings = DEFAULT_CLUSTER_STEP_SETTINGS;
381
393
  lastReadAtMs = Number.NEGATIVE_INFINITY;
382
394
  inFlight = null;
383
395
  logger;
384
396
  now;
385
397
  ttlMs;
398
+ unknownRetryMs;
399
+ /** The last "not known" state logged, so it is said once per change. */
400
+ lastUnknownLogKey = "";
386
401
  constructor(options) {
387
402
  this.logger = options.logger;
388
403
  this.now = options.now ?? (() => Date.now());
389
404
  this.ttlMs = options.ttlMs ?? 6e4;
405
+ this.unknownRetryMs = options.unknownRetryMs ?? 5e3;
390
406
  }
391
- /** The row in force right now — the registry defaults until a read lands. */
407
+ /**
408
+ * The KNOWN models, `stepId → modelId`. A step absent here is unknown (see
409
+ * {@link unknownSteps}) and must be refused, never defaulted.
410
+ */
392
411
  current() {
393
412
  return this.models;
394
413
  }
414
+ /** Cluster-scoped steps whose model the owner has not answered yet, with why. */
415
+ unknownSteps() {
416
+ return this.unknown;
417
+ }
395
418
  /** Cluster-scoped step knobs in force right now (admission floors, …). */
396
419
  currentSettings() {
397
420
  return this.settings;
398
421
  }
399
422
  /**
400
- * Re-read if the cached row has expired. Concurrent callers share one
401
- * in-flight read; a failure keeps the LAST KNOWN row.
423
+ * Re-read if the cached row has expired (sooner while a step is unknown).
424
+ * Concurrent callers share one in-flight read; a failure keeps the LAST
425
+ * KNOWN row.
402
426
  */
403
427
  async refresh(api) {
404
428
  if (api === void 0) return this.models;
405
- if (this.now() - this.lastReadAtMs < this.ttlMs) return this.models;
429
+ const ttl = this.unknown.size > 0 ? Math.min(this.ttlMs, this.unknownRetryMs) : this.ttlMs;
430
+ if (this.now() - this.lastReadAtMs < ttl) return this.models;
406
431
  this.inFlight ??= this.read(api).finally(() => {
407
432
  this.inFlight = null;
408
433
  });
@@ -410,44 +435,186 @@ var ClusterModelSource = class {
410
435
  return this.models;
411
436
  }
412
437
  async read(api) {
413
- try {
414
- const view = await api.addonSettings.getGlobalSettings.query({ addonId: CLUSTER_MODEL_OWNER_ADDON_ID });
415
- const next = pickClusterStepModels(view);
416
- const nextSettings = pickClusterStepSettings(view);
417
- this.lastReadAtMs = this.now();
418
- const invalid = pickInvalidClusterStepModels(view);
419
- 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: {
420
- owner: CLUSTER_MODEL_OWNER_ADDON_ID,
421
- invalid,
422
- inUse: Object.fromEntries(invalid.map((e) => [e.stepId, next[e.stepId]]))
423
- } });
424
- const changed = Object.keys(next).filter((stepId) => next[stepId] !== this.models[stepId]);
425
- const settingsChanged = Object.keys(nextSettings).some((stepId) => {
426
- const from = this.settings[stepId] ?? {};
427
- const to = nextSettings[stepId] ?? {};
428
- return [...new Set([...Object.keys(from), ...Object.keys(to)])].some((key) => from[key] !== to[key]);
429
- });
430
- if (changed.length > 0) {
431
- this.logger.info("cluster step model changed — vectors already stored by the previous model need a re-embed pass", { meta: { changed: changed.map((stepId) => ({
432
- stepId,
433
- from: this.models[stepId],
434
- to: next[stepId]
435
- })) } });
436
- this.models = next;
437
- }
438
- if (settingsChanged) {
439
- this.logger.info("cluster step settings changed", { meta: {
440
- from: { ...this.settings },
441
- to: { ...nextSettings }
442
- } });
443
- this.settings = nextSettings;
438
+ this.lastReadAtMs = this.now();
439
+ await Promise.all([this.readModels(api), this.readSettings(api)]);
440
+ }
441
+ async readModels(api) {
442
+ const answers = await Promise.all(CLUSTER_MODEL_SCOPED_STEPS.map(async (step) => ({
443
+ stepId: step.stepId,
444
+ answer: await askOwner(api, step.stepId)
445
+ })));
446
+ const nextModels = { ...this.models };
447
+ const nextUnknown = /* @__PURE__ */ new Map();
448
+ const unanswered = [];
449
+ for (const { stepId, answer } of answers) {
450
+ if (answer.kind === "model") {
451
+ nextModels[stepId] = answer.modelId;
452
+ continue;
444
453
  }
454
+ const keeping = this.models[stepId] ?? null;
455
+ unanswered.push({
456
+ stepId,
457
+ reason: answer.reason,
458
+ keeping
459
+ });
460
+ if (keeping === null) nextUnknown.set(stepId, answer.reason);
461
+ }
462
+ const changed = Object.keys(nextModels).filter((stepId) => this.models[stepId] !== void 0 && nextModels[stepId] !== this.models[stepId]);
463
+ 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) => ({
464
+ stepId,
465
+ from: this.models[stepId],
466
+ to: nextModels[stepId]
467
+ })) } });
468
+ const learned = Object.keys(nextModels).filter((stepId) => this.models[stepId] === void 0);
469
+ 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]])) } });
470
+ this.models = nextModels;
471
+ this.unknown = nextUnknown;
472
+ this.reportUnanswered(unanswered);
473
+ }
474
+ /** Once per change of the unanswered set — never once per retry. */
475
+ reportUnanswered(unanswered) {
476
+ const key = JSON.stringify(unanswered);
477
+ if (key === this.lastUnknownLogKey) return;
478
+ this.lastUnknownLogKey = key;
479
+ if (unanswered.length === 0) return;
480
+ 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: {
481
+ owner: CLUSTER_MODEL_OWNER_ADDON_ID,
482
+ unanswered: unanswered.map((u) => ({
483
+ stepId: u.stepId,
484
+ reason: u.reason,
485
+ ...u.keeping !== null ? { keeping: u.keeping } : { refused: true }
486
+ }))
487
+ } });
488
+ }
489
+ async readSettings(api) {
490
+ let view;
491
+ try {
492
+ view = await api.addonSettings.getGlobalSettings.query({ addonId: CLUSTER_MODEL_OWNER_ADDON_ID });
445
493
  } catch (err) {
446
- this.logger.warn("cluster step model read failed — keeping the last known row", { meta: {
494
+ this.logger.warn("cluster step settings read failed — keeping the last known knobs", { meta: {
447
495
  owner: CLUSTER_MODEL_OWNER_ADDON_ID,
448
- inUse: { ...this.models },
449
496
  error: err instanceof Error ? err.message : String(err)
450
497
  } });
498
+ return;
499
+ }
500
+ if (view === null) return;
501
+ const nextSettings = pickClusterStepSettings(view);
502
+ if (Object.keys(nextSettings).some((stepId) => {
503
+ const from = this.settings[stepId] ?? {};
504
+ const to = nextSettings[stepId] ?? {};
505
+ return [...new Set([...Object.keys(from), ...Object.keys(to)])].some((key) => from[key] !== to[key]);
506
+ })) {
507
+ this.logger.info("cluster step settings changed", { meta: {
508
+ from: { ...this.settings },
509
+ to: { ...nextSettings }
510
+ } });
511
+ this.settings = nextSettings;
512
+ }
513
+ }
514
+ };
515
+ /** One step's model from the owner's three-way answer; a throw is no answer. */
516
+ async function askOwner(api, stepId) {
517
+ try {
518
+ const choice = await api.pipelineOrchestrator.getClusterModelChoice.query({ stepId });
519
+ switch (choice.kind) {
520
+ case "stored": return {
521
+ kind: "model",
522
+ modelId: choice.modelId
523
+ };
524
+ case "absent": return {
525
+ kind: "model",
526
+ modelId: choice.defaultModelId
527
+ };
528
+ case "unreadable": return {
529
+ kind: "none",
530
+ reason: choice.reason
531
+ };
532
+ }
533
+ } catch (err) {
534
+ return {
535
+ kind: "none",
536
+ reason: `read-failed: ${err instanceof Error ? err.message : String(err)}`
537
+ };
538
+ }
539
+ }
540
+ //#endregion
541
+ //#region src/detection-pipeline/cluster-model-switch-log.ts
542
+ function* clusterScopedSteps(steps) {
543
+ for (const step of steps) {
544
+ if (step.definition.modelScope === "cluster") yield step;
545
+ yield* clusterScopedSteps(step.children);
546
+ }
547
+ }
548
+ var ClusterModelSwitchLog = class {
549
+ logger;
550
+ /** `deviceId|stepId` → the model that step last ran for that camera. */
551
+ lastRun = /* @__PURE__ */ new Map();
552
+ constructor(logger) {
553
+ this.logger = logger;
554
+ }
555
+ /** Record what `tree` runs for `deviceId`; say so when a cluster step changed model. */
556
+ note(deviceId, tree) {
557
+ if (deviceId <= 0) return;
558
+ for (const step of clusterScopedSteps(tree.roots)) {
559
+ const key = `${String(deviceId)}|${step.stepId}`;
560
+ const previous = this.lastRun.get(key);
561
+ if (previous === step.modelId) continue;
562
+ this.lastRun.set(key, step.modelId);
563
+ if (previous === void 0) continue;
564
+ this.logger.info("cluster-scoped step re-provisioned for this camera — now runs the new model", {
565
+ tags: { deviceId },
566
+ meta: {
567
+ stepId: step.stepId,
568
+ from: previous,
569
+ to: step.modelId
570
+ }
571
+ });
572
+ }
573
+ }
574
+ };
575
+ function* enabledStepIds(steps) {
576
+ for (const step of steps) {
577
+ if (!step.enabled) continue;
578
+ yield step.addonId;
579
+ if (step.children) yield* enabledStepIds(step.children);
580
+ }
581
+ }
582
+ /** The enabled steps of `steps` (any depth) whose cluster model is unknown. */
583
+ function unknownStepsNamed(steps, unknown) {
584
+ if (unknown.size === 0) return [];
585
+ return [...enabledStepIds(steps)].filter((id) => unknown.has(id));
586
+ }
587
+ /**
588
+ * One line per (camera, step) while a cluster-scoped step is REFUSED because
589
+ * the owner has not answered which model the cluster runs (D658). Said once per
590
+ * episode: when every step becomes known the memory is cleared, so the next
591
+ * episode is said again.
592
+ */
593
+ var ClusterUnknownRefusalLog = class {
594
+ logger;
595
+ said = /* @__PURE__ */ new Set();
596
+ constructor(logger) {
597
+ this.logger = logger;
598
+ }
599
+ note(deviceId, steps, unknown) {
600
+ if (unknown.size === 0) {
601
+ if (this.said.size > 0) this.said = /* @__PURE__ */ new Set();
602
+ return;
603
+ }
604
+ if (deviceId <= 0) return;
605
+ for (const stepId of enabledStepIds(steps)) {
606
+ const reason = unknown.get(stepId);
607
+ if (reason === void 0) continue;
608
+ const key = `${String(deviceId)}|${stepId}`;
609
+ if (this.said.has(key)) continue;
610
+ this.said = new Set(this.said).add(key);
611
+ this.logger.warn("cluster-scoped step refused for this camera — the owner has not answered which model the cluster runs", {
612
+ tags: { deviceId },
613
+ meta: {
614
+ stepId,
615
+ reason
616
+ }
617
+ });
451
618
  }
452
619
  }
453
620
  };
@@ -2147,7 +2314,8 @@ function collectFramePlaneSteps(steps, isDetailStep) {
2147
2314
  }
2148
2315
  /**
2149
2316
  * Immutably remove from a frame-plane dispatch tree every enabled DETAIL
2150
- * subtree (at any depth below the top level) whose top step `isUnrunnable`.
2317
+ * subtree (at any depth below the top level) that `isUnrunnable` — judged
2318
+ * WHOLE at its top, because its children ride its call (D657).
2151
2319
  * Those steps can never load into the dispatch device's pool — leaving them
2152
2320
  * in the tree makes `ensureModelsForSteps` fail the whole call on a format
2153
2321
  * build that does not exist. They are NOT dropped work: each runs later as
@@ -2159,6 +2327,11 @@ function collectFramePlaneSteps(steps, isDetailStep) {
2159
2327
  */
2160
2328
  function pruneUnrunnableDetailSteps(steps, isDetailStep, isUnrunnable) {
2161
2329
  const prunedAddonIds = [];
2330
+ const removedAddonIds = [];
2331
+ const collect = (step) => {
2332
+ removedAddonIds.push(step.addonId);
2333
+ for (const child of step.children ?? []) collect(child);
2334
+ };
2162
2335
  const walk = (nodes, topLevel) => {
2163
2336
  const kept = [];
2164
2337
  for (const step of nodes) {
@@ -2166,8 +2339,9 @@ function pruneUnrunnableDetailSteps(steps, isDetailStep, isUnrunnable) {
2166
2339
  kept.push(step);
2167
2340
  continue;
2168
2341
  }
2169
- if (!topLevel && isDetailStep(step.addonId) && isUnrunnable(step.addonId)) {
2342
+ if (!topLevel && isDetailStep(step.addonId) && isUnrunnable(step)) {
2170
2343
  prunedAddonIds.push(step.addonId);
2344
+ collect(step);
2171
2345
  continue;
2172
2346
  }
2173
2347
  kept.push(step.children?.length ? {
@@ -2179,7 +2353,8 @@ function pruneUnrunnableDetailSteps(steps, isDetailStep, isUnrunnable) {
2179
2353
  };
2180
2354
  return {
2181
2355
  steps: walk(steps, true),
2182
- prunedAddonIds
2356
+ prunedAddonIds,
2357
+ removedAddonIds
2183
2358
  };
2184
2359
  }
2185
2360
  //#endregion
@@ -2452,16 +2627,26 @@ var PipelineModelManager = class {
2452
2627
  for (const { entry, step } of diff.unchanged) await this.reconcileDecode(entry, step.settings);
2453
2628
  }
2454
2629
  /**
2455
- * Get an `IInferenceEngine` handle. Without `modelId`, returns the
2456
- * active model for the step (camera-runtime hot path). Pass
2457
- * `modelId` to grab a specific warm variant (benchmark path).
2458
- * Touches the LRU clock so the looked-up model survives eviction
2459
- * pressure from concurrent loads.
2460
- */
2461
- getHandle(stepId, modelId) {
2462
- const entry = this.resolveOrThrow(stepId, modelId);
2630
+ * The warm variant that runs `(stepId, modelId)` — its handle AND the model
2631
+ * loaded at that pool index, which is the only honest answer to "which model
2632
+ * produced this output" (D658).
2633
+ *
2634
+ * No active-variant fallback, deliberately. The step-only lookup this
2635
+ * replaces (`getHandle(stepId)`, deleted) answered the step's ACTIVE
2636
+ * variant — the first one loaded after boot — and a tree that paired that
2637
+ * handle with the step's REQUESTED id stamped SigLIP2 on MobileCLIP vectors
2638
+ * for twenty minutes on 2026-09-27. A pair that is not resident throws,
2639
+ * naming both.
2640
+ */
2641
+ getVariant(stepId, modelId) {
2642
+ const entry = this.loaded.get(stepId)?.get(modelId);
2643
+ 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`);
2463
2644
  this.touch(entry);
2464
- return this.pool.getHandle(entry.poolIndex);
2645
+ return {
2646
+ engine: this.pool.getHandle(entry.poolIndex),
2647
+ modelId: entry.modelId,
2648
+ poolIndex: entry.poolIndex
2649
+ };
2465
2650
  }
2466
2651
  /** True iff the step has any model loaded. */
2467
2652
  isLoaded(stepId) {
@@ -2492,13 +2677,14 @@ var PipelineModelManager = class {
2492
2677
  return this.activeByStep.get(stepId);
2493
2678
  }
2494
2679
  /**
2495
- * Pool index for a given (stepId, modelId). Without `modelId`,
2496
- * returns the active model's index. Used by the inference fast
2497
- * paths that bypass `getHandle` and call `pool.inferBatch` directly.
2680
+ * Pool index for exactly `(stepId, modelId)`, or `null` when that pair is not
2681
+ * warm. Used by the inference fast paths that bypass `getVariant` and call
2682
+ * `pool.inferBatch` directly. The model is REQUIRED: the active-variant
2683
+ * fallback this had timed a benchmark on whichever variant was active (D658).
2498
2684
  */
2499
2685
  getPoolIndex(stepId, modelId) {
2500
- const entry = this.resolve(stepId, modelId);
2501
- if (!entry) return null;
2686
+ const entry = this.loaded.get(stepId)?.get(modelId);
2687
+ if (entry === void 0) return null;
2502
2688
  this.touch(entry);
2503
2689
  return entry.poolIndex;
2504
2690
  }
@@ -2718,18 +2904,6 @@ var PipelineModelManager = class {
2718
2904
  await this.unloadEntry(evicted);
2719
2905
  }
2720
2906
  }
2721
- resolve(stepId, modelId) {
2722
- const perStep = this.loaded.get(stepId);
2723
- if (!perStep) return null;
2724
- const targetModelId = modelId ?? this.activeByStep.get(stepId);
2725
- if (!targetModelId) return null;
2726
- return perStep.get(targetModelId) ?? null;
2727
- }
2728
- resolveOrThrow(stepId, modelId) {
2729
- const entry = this.resolve(stepId, modelId);
2730
- if (!entry) throw new Error(`Step "${stepId}"${modelId ? ` (model "${modelId}")` : ""} is not loaded in the inference pool`);
2731
- return entry;
2732
- }
2733
2907
  touch(entry) {
2734
2908
  entry.lruTick = ++this.lruClock;
2735
2909
  }
@@ -2925,16 +3099,15 @@ var EngineFactory = class {
2925
3099
  await this.poolManager.applyConfig(steps);
2926
3100
  }
2927
3101
  /**
2928
- * Get an IInferenceEngine for a pipeline step. Without `modelId`,
2929
- * resolves to the active model (camera-runtime hot path); pass
2930
- * `modelId` to grab a specific warm variant (benchmark / override
2931
- * paths picking a non-runtime model).
3102
+ * The resident variant for exactly `(stepId, modelId)`, with the model it
3103
+ * runs — never the step's active variant in its place (D658). The executable
3104
+ * tree is built from this, and a result's model stamp comes from it.
2932
3105
  *
2933
- * @throws if step not loaded with the requested (or active) model.
3106
+ * @throws if that exact pair is not warm.
2934
3107
  */
2935
- getEngine(stepId, modelId) {
3108
+ getStepVariant(stepId, modelId) {
2936
3109
  if (!this.poolManager) throw new Error("EngineFactory not initialized");
2937
- return this.poolManager.getHandle(stepId, modelId);
3110
+ return this.poolManager.getVariant(stepId, modelId);
2938
3111
  }
2939
3112
  /** Check if a step is loaded (any variant). */
2940
3113
  isLoaded(stepId) {
@@ -3038,7 +3211,7 @@ var EngineFactory = class {
3038
3211
  async batchInferRaw(stepId, items, modelId, frameId = 0) {
3039
3212
  if (!this.poolManager) throw new Error("EngineFactory.batchInferRaw: pool not initialized");
3040
3213
  const idx = this.poolManager.getPoolIndex(stepId, modelId);
3041
- if (idx === null) throw new Error(`EngineFactory.batchInferRaw: step "${stepId}"${modelId ? ` (model "${modelId}")` : ""} is not loaded`);
3214
+ if (idx === null) throw new Error(`EngineFactory.batchInferRaw: step "${stepId}" (model "${modelId}") is not loaded`);
3042
3215
  return this.poolManager.getPool().inferBatch(idx, items, frameId);
3043
3216
  }
3044
3217
  async cacheFrameInPool(raw, width, height, format) {
@@ -3048,7 +3221,7 @@ var EngineFactory = class {
3048
3221
  async inferCached(stepId, frameId, modelId) {
3049
3222
  if (!this.poolManager) throw new Error("inferCached: pool not available");
3050
3223
  const idx = this.poolManager.getPoolIndex(stepId, modelId);
3051
- if (idx === null) throw new Error(`inferCached: step "${stepId}"${modelId ? ` (model "${modelId}")` : ""} not loaded`);
3224
+ if (idx === null) throw new Error(`inferCached: step "${stepId}" (model "${modelId}") not loaded`);
3052
3225
  return this.poolManager.getPool().inferCached(idx, frameId);
3053
3226
  }
3054
3227
  async uncacheFrame(frameId) {
@@ -3943,13 +4116,16 @@ function checkReplayPin(input) {
3943
4116
  }
3944
4117
  var PRIMARY_ROOT_STEP = "object-detection";
3945
4118
  /**
3946
- * The root model a frame result reports: `object-detection` when it runs (it
3947
- * is the detector the tracks come from), else the first enabled video root.
3948
- * `undefined` when no root runs — never a fabricated id.
4119
+ * The same rule over an EXECUTABLE tree's roots (D658): their model ids are the
4120
+ * pool variants that will run — after resolution, per-step loads and drops —
4121
+ * so this is what a live `runPipeline` stamps. The tree holds only enabled
4122
+ * video roots already.
3949
4123
  */
3950
- function resolveRootModelId(roots) {
3951
- const enabled = roots.filter((s) => s.enabled && s.slot !== "audio-classifier");
3952
- return (enabled.find((s) => s.addonId === PRIMARY_ROOT_STEP) ?? enabled[0])?.modelId;
4124
+ function resolveTreeRootModelId(roots) {
4125
+ return primaryRootModelId(roots);
4126
+ }
4127
+ function primaryRootModelId(roots) {
4128
+ return (roots.find((s) => s.stepId === PRIMARY_ROOT_STEP) ?? roots[0])?.modelId;
3953
4129
  }
3954
4130
  //#endregion
3955
4131
  //#region src/detection-pipeline/engine-provisioner.ts
@@ -6742,26 +6918,47 @@ function resolveEffectiveClassMap(definition, modelId, resolveModel) {
6742
6918
  * Build an executable tree from user config.
6743
6919
  *
6744
6920
  * @param steps - User-configured pipeline steps (from PipelineDefaultStep[])
6745
- * @param getEngine - Function that returns an IInferenceEngine for a step ID.
6746
- * Throws if step not loaded.
6921
+ * @param getEngine - Returns the engine that runs exactly `(stepId, modelId)`
6922
+ * and the model it runs. Throws if that pair is not loaded.
6747
6923
  */
6748
- function buildExecutableTree(steps, getEngine, resolveModel) {
6749
- return { roots: steps.filter((s) => s.enabled).filter((s) => s.slot !== "audio-classifier").map((s) => buildNode(s, getEngine, resolveModel)) };
6924
+ function buildExecutableTree(steps, getEngine, resolveModel, onMissingVariant) {
6925
+ return { roots: steps.filter((s) => s.enabled).filter((s) => s.slot !== "audio-classifier").map((s) => buildNode(s, getEngine, resolveModel, onMissingVariant)) };
6926
+ }
6927
+ function subtreeIds(step) {
6928
+ return [step.addonId, ...(step.children ?? []).filter((c) => c.enabled).flatMap((c) => subtreeIds(c))];
6929
+ }
6930
+ function buildChild(child, parentStepId, getEngine, resolveModel, onMissingVariant) {
6931
+ if (onMissingVariant === void 0) return buildNode(child, getEngine, resolveModel, onMissingVariant);
6932
+ let variant;
6933
+ try {
6934
+ variant = getEngine(child.addonId, child.modelId);
6935
+ } catch (err) {
6936
+ onMissingVariant({
6937
+ step: child,
6938
+ stepId: child.addonId,
6939
+ modelId: child.modelId,
6940
+ parentStepId,
6941
+ droppedSubtree: subtreeIds(child),
6942
+ error: err instanceof Error ? err.message : String(err)
6943
+ });
6944
+ return null;
6945
+ }
6946
+ return buildNode(child, getEngine, resolveModel, onMissingVariant, variant);
6750
6947
  }
6751
- function buildNode(step, getEngine, resolveModel) {
6948
+ function buildNode(step, getEngine, resolveModel, onMissingVariant, resolved) {
6752
6949
  const definition = getStepDefinition(step.addonId);
6753
- const engine = getEngine(step.addonId);
6754
- const children = (step.children ?? []).filter((c) => c.enabled).map((c) => buildNode(c, getEngine, resolveModel));
6950
+ const variant = resolved ?? getEngine(step.addonId, step.modelId);
6951
+ const children = (step.children ?? []).filter((c) => c.enabled).map((c) => buildChild(c, step.addonId, getEngine, resolveModel, onMissingVariant)).filter((c) => c !== null);
6755
6952
  const mergedSettings = {
6756
6953
  ...collectSchemaDefaults(step.addonId),
6757
6954
  ...step.settings
6758
6955
  };
6759
- const effectiveClassMap = resolveEffectiveClassMap(definition, step.modelId, resolveModel);
6956
+ const effectiveClassMap = resolveEffectiveClassMap(definition, variant.modelId, resolveModel);
6760
6957
  return {
6761
6958
  stepId: step.addonId,
6762
6959
  definition,
6763
- engine,
6764
- modelId: step.modelId,
6960
+ engine: variant.engine,
6961
+ modelId: variant.modelId,
6765
6962
  inputClasses: definition.inputClasses ?? [],
6766
6963
  enabled: step.enabled,
6767
6964
  children,
@@ -6791,43 +6988,6 @@ function walkFieldsForDefaults(fields, out) {
6791
6988
  }
6792
6989
  }
6793
6990
  //#endregion
6794
- //#region src/detection-pipeline/registry/custom-models.ts
6795
- /**
6796
- * Group a flat list of custom-model descriptors (as returned by the
6797
- * `custom-model-registry` collection cap) into a `stepId → entries` map for
6798
- * the picker / resolution union. Pure; order within a step preserved.
6799
- */
6800
- function groupCustomModelsByStep(descriptors) {
6801
- const byStep = /* @__PURE__ */ new Map();
6802
- for (const d of descriptors) {
6803
- const arr = byStep.get(d.stepId) ?? [];
6804
- arr.push(d.entry);
6805
- byStep.set(d.stepId, arr);
6806
- }
6807
- return byStep;
6808
- }
6809
- /**
6810
- * Union a step's static catalog models with operator-registered custom
6811
- * models. On an `id` collision the static catalog entry wins — a custom
6812
- * model can never shadow a built-in one.
6813
- *
6814
- * Pure + side-effect-free so it can be unit-tested in isolation and called
6815
- * from the (free) `buildSchemaSlots` builder without any addon context.
6816
- */
6817
- function mergeCustomModels(staticModels, customModels) {
6818
- const seen = new Set(staticModels.map((m) => m.id));
6819
- const merged = [...staticModels];
6820
- for (const m of customModels) {
6821
- if (seen.has(m.id)) continue;
6822
- seen.add(m.id);
6823
- merged.push({
6824
- ...m,
6825
- provider: inferModelProvider(m)
6826
- });
6827
- }
6828
- return merged;
6829
- }
6830
- //#endregion
6831
6991
  //#region src/detection-pipeline/cluster-model-resolution.ts
6832
6992
  /**
6833
6993
  * The cluster model rule, enforced on the node that has to obey it.
@@ -6932,8 +7092,10 @@ function collectSubstitutions(steps, format) {
6932
7092
  return result;
6933
7093
  }
6934
7094
  /**
6935
- * Return one {@link ZeroBuildIssue} per step in `steps` whose catalog entry
6936
- * (via `getStepDef`) has ZERO models with a build for `format`. `steps` is
7095
+ * Return one {@link ZeroBuildIssue} per step in `steps` that a pool of `format`
7096
+ * cannot run: neither the model it names nor any NON-legacy model of its
7097
+ * catalog (via `getStepDef`) ships a build for `format`
7098
+ * (`stepModelRunsOnFormat`, D657). `steps` is
6937
7099
  * expected to already be flattened + filtered to the set the caller cares
6938
7100
  * about (e.g. enabled video steps) — this does not recurse into children or
6939
7101
  * filter by `enabled`, unlike {@link collectSubstitutions}.
@@ -6954,7 +7116,7 @@ function collectZeroBuildIssues(steps, format, getStepDef = getStepDefinition) {
6954
7116
  } catch {
6955
7117
  continue;
6956
7118
  }
6957
- if (!def.models.some((m) => m.formats[format])) result.push({
7119
+ if (!stepModelRunsOnFormat(def.models, step.modelId, format)) result.push({
6958
7120
  addonId: step.addonId,
6959
7121
  format
6960
7122
  });
@@ -7004,11 +7166,20 @@ function collectUnknownAddonIssues(steps, getStepDef = getStepDefinition) {
7004
7166
  * `diagnostics.substitutions` instead of happening silently. The caller
7005
7167
  * (the provider) owns logging + dedup for both.
7006
7168
  */
7007
- function resolveInputSteps(steps, format, engine, clusterModels, clusterSettings) {
7169
+ function resolveInputSteps(steps, format, engine, clusterModels, clusterSettings, unknownClusterSteps) {
7008
7170
  const resolvedSteps = [];
7009
7171
  const unknownAddonIds = [];
7010
7172
  const substitutions = [];
7011
7173
  const clusterRefusals = [];
7174
+ const clusterUnknown = [];
7175
+ const recurse = (children) => {
7176
+ const r = resolveInputSteps(children, format, engine, clusterModels, clusterSettings, unknownClusterSteps);
7177
+ unknownAddonIds.push(...r.diagnostics.unknownAddonIds);
7178
+ substitutions.push(...r.diagnostics.substitutions);
7179
+ clusterRefusals.push(...r.diagnostics.clusterRefusals);
7180
+ clusterUnknown.push(...r.diagnostics.clusterUnknown);
7181
+ return r;
7182
+ };
7012
7183
  for (const s of steps) {
7013
7184
  let def;
7014
7185
  try {
@@ -7017,6 +7188,10 @@ function resolveInputSteps(steps, format, engine, clusterModels, clusterSettings
7017
7188
  unknownAddonIds.push(s.addonId);
7018
7189
  continue;
7019
7190
  }
7191
+ if (def.modelScope === "cluster" && unknownClusterSteps?.has(s.addonId) === true) {
7192
+ clusterUnknown.push(s.addonId);
7193
+ continue;
7194
+ }
7020
7195
  if (clusterModels !== void 0) {
7021
7196
  const verdict = resolveClusterStepModel(s.addonId, clusterModels, format, () => def);
7022
7197
  if (verdict.kind !== "node-scoped") {
@@ -7026,12 +7201,7 @@ function resolveInputSteps(steps, format, engine, clusterModels, clusterSettings
7026
7201
  format: verdict.format,
7027
7202
  formatsShipped: verdict.formatsShipped
7028
7203
  });
7029
- const childResult = s.children ? resolveInputSteps(s.children, format, engine, clusterModels, clusterSettings) : null;
7030
- if (childResult) {
7031
- unknownAddonIds.push(...childResult.diagnostics.unknownAddonIds);
7032
- substitutions.push(...childResult.diagnostics.substitutions);
7033
- clusterRefusals.push(...childResult.diagnostics.clusterRefusals);
7034
- }
7204
+ const childResult = s.children ? recurse(s.children) : null;
7035
7205
  const settings = overlayClusterStepSettings(s.addonId, s.settings, clusterSettings ?? {});
7036
7206
  resolvedSteps.push({
7037
7207
  addonId: s.addonId,
@@ -7055,12 +7225,7 @@ function resolveInputSteps(steps, format, engine, clusterModels, clusterSettings
7055
7225
  running: runningModelId,
7056
7226
  format
7057
7227
  });
7058
- const childResult = s.children ? resolveInputSteps(s.children, format, engine, clusterModels, clusterSettings) : null;
7059
- if (childResult) {
7060
- unknownAddonIds.push(...childResult.diagnostics.unknownAddonIds);
7061
- substitutions.push(...childResult.diagnostics.substitutions);
7062
- clusterRefusals.push(...childResult.diagnostics.clusterRefusals);
7063
- }
7228
+ const childResult = s.children ? recurse(s.children) : null;
7064
7229
  const settings = overlayClusterStepSettings(s.addonId, s.settings, clusterSettings ?? {});
7065
7230
  resolvedSteps.push({
7066
7231
  addonId: s.addonId,
@@ -7079,7 +7244,8 @@ function resolveInputSteps(steps, format, engine, clusterModels, clusterSettings
7079
7244
  diagnostics: {
7080
7245
  unknownAddonIds,
7081
7246
  substitutions,
7082
- clusterRefusals
7247
+ clusterRefusals,
7248
+ clusterUnknown
7083
7249
  }
7084
7250
  };
7085
7251
  }
@@ -7322,6 +7488,62 @@ function stepModelKey(step) {
7322
7488
  function stateOf(verdict) {
7323
7489
  return verdict.kind === "refused" ? "refused" : `failed:${verdict.generation}`;
7324
7490
  }
7491
+ /** The load policy of a `runPipeline` call — see {@link DispatchLoadPolicy}. */
7492
+ function dispatchLoadPolicyOf(input) {
7493
+ return input.deviceId !== void 0 && input.replay !== true ? "partial" : "all-or-nothing";
7494
+ }
7495
+ /**
7496
+ * An all-or-nothing call lost steps to failed loads (D657). Names every one,
7497
+ * so a benchmark never reports timings for a tree it did not run.
7498
+ */
7499
+ var DispatchStepsNotLoadedError = class extends Error {
7500
+ failedSteps;
7501
+ constructor(failures) {
7502
+ const named = failures.map((f) => `${f.step.addonId}/${f.step.modelId} (${f.error instanceof Error ? f.error.message : String(f.error)})`);
7503
+ super(`pipeline steps failed to load — the call runs all of them or none: ${named.join("; ")}`, { cause: failures[0]?.error });
7504
+ this.name = "DispatchStepsNotLoadedError";
7505
+ this.failedSteps = failures.map((f) => `${f.step.addonId}/${f.step.modelId}`);
7506
+ }
7507
+ };
7508
+ /**
7509
+ * A step's model artifact cannot be made present for the pool's format — no
7510
+ * build for that format, a custom model missing from this node, a download
7511
+ * that failed. Named by step and model so the failure is charged to THAT
7512
+ * model only (D657): a plain `Error` was charged to every model of the set,
7513
+ * and one face model with no tflite build backed the Coral's root detector
7514
+ * off for minutes.
7515
+ */
7516
+ var StepModelArtifactError = class extends Error {
7517
+ stepId;
7518
+ modelId;
7519
+ constructor(stepId, modelId, cause) {
7520
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
7521
+ this.name = "StepModelArtifactError";
7522
+ this.stepId = stepId;
7523
+ this.modelId = modelId;
7524
+ }
7525
+ };
7526
+ /** The one model a failed load is charged to, when the error names it. */
7527
+ function failedModelOf(err) {
7528
+ if (err instanceof StepVariantLoadError || err instanceof StepModelArtifactError) return `${err.stepId}/${err.modelId}`;
7529
+ return null;
7530
+ }
7531
+ /**
7532
+ * `steps` without the subtrees of the steps whose model did not load (D657).
7533
+ * A step's children consume its output, so they leave with it. Matched by
7534
+ * INSTANCE: two steps of one addon in a tree are two steps.
7535
+ */
7536
+ function withoutFailedSteps(steps, failed) {
7537
+ const kept = [];
7538
+ for (const step of steps) {
7539
+ if (failed.has(step)) continue;
7540
+ kept.push(step.children && step.children.length > 0 ? {
7541
+ ...step,
7542
+ children: withoutFailedSteps(step.children, failed)
7543
+ } : step);
7544
+ }
7545
+ return kept;
7546
+ }
7325
7547
  /**
7326
7548
  * The reason a condemned pool is charged against its device's restart budget.
7327
7549
  * A pool recycled because it HUNG (D653) names the hang — `inference device
@@ -7429,18 +7651,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7429
7651
  * paths don't issue a cap round-trip on every call. Empty map = no provider
7430
7652
  * (or a query failure) → behaviour identical to the static-catalog-only path.
7431
7653
  */
7432
- customModelsCache = null;
7654
+ customModels = new CustomModelCatalog({ warn: (message, extras) => this.log.warn(message, extras) });
7433
7655
  /**
7434
7656
  * SYNC custom-model lookup handed to every live {@link EngineFactory} so
7435
7657
  * `buildPoolModelConfigForStep` can resolve an operator-registered model at
7436
- * pool-load time. Reads the {@link customModelsCache} snapshot — every load
7658
+ * pool-load time. Reads the {@link customModels} snapshot — every load
7437
7659
  * path (`ensureModelsForSteps`, `ensureModelsForCurrentSteps`) refreshes the
7438
7660
  * cache via `resolveModelEntry` BEFORE `loadAdditional`/`applyConfig`, so
7439
7661
  * the snapshot is fresh when the pool asks. Without this seam a selected
7440
7662
  * custom model failed the WHOLE pool applyConfig with
7441
7663
  * `Model "X" not found in step "Y" catalog`.
7442
7664
  */
7443
- customModelResolver = (stepId, modelId) => this.customModelsCache?.byStep.get(stepId)?.find((m) => m.id === modelId);
7665
+ customModelResolver = (stepId, modelId) => this.customModels.current().get(stepId)?.find((m) => m.id === modelId);
7444
7666
  /**
7445
7667
  * Per-device {@link DeviceProxy} cache used for zone gating at the
7446
7668
  * runtime path. Reads `state.zones.value` + `state.zoneRules.value`
@@ -7463,6 +7685,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7463
7685
  info: (message, extras) => this.log.info(message, extras),
7464
7686
  warn: (message, extras) => this.log.warn(message, extras)
7465
7687
  } });
7688
+ /** One line per camera when a cluster-scoped step starts running another model (D658). */
7689
+ clusterModelSwitchLog = new ClusterModelSwitchLog({ info: (message, extras) => this.log.info(message, extras) });
7690
+ /** One line per (camera, step) while a cluster step is refused as unknown (D658). */
7691
+ clusterUnknownLog = new ClusterUnknownRefusalLog({ warn: (message, extras) => this.log.warn(message, extras) });
7466
7692
  /**
7467
7693
  * Last logged "step configured" signature — used to dedupe the
7468
7694
  * per-step debug trail so it only fires on actual config CHANGE
@@ -7929,7 +8155,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7929
8155
  if (!steps) return;
7930
8156
  const format = this.currentEngine.format;
7931
8157
  const substitutionIssues = this.getActiveModelSubstitutions().map((s) => `${s.addonId}: chose ${s.chosen}, running ${s.running} (${s.format})`);
7932
- const zeroBuildIssues = collectZeroBuildIssues(flattenSteps(steps), format).map((i) => `${i.addonId}: no model has a ${i.format} build`);
8158
+ const zeroBuildIssues = collectZeroBuildIssues(flattenSteps(steps), format, this.mergedStepDefOrThrow).map((i) => `${i.addonId}: no model has a ${i.format} build`);
7933
8159
  this.configIssues = [...substitutionIssues, ...zeroBuildIssues];
7934
8160
  } catch (err) {
7935
8161
  this.configIssues = [];
@@ -8063,24 +8289,21 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
8063
8289
  * a warning — callers then behave exactly like the static-catalog path.
8064
8290
  */
8065
8291
  async getCustomModels() {
8066
- const now = Date.now();
8067
- if (this.customModelsCache && now - this.customModelsCache.at < 5e3) return this.customModelsCache.byStep;
8068
- let byStep = /* @__PURE__ */ new Map();
8069
- try {
8070
- const api = this.addonCtx?.api;
8071
- if (api) {
8072
- if ((await api.addons.listCapabilityProviders.query({ capName: "custom-model-registry" })).some((p) => p.isActive)) byStep = groupCustomModelsByStep(await api.customModelRegistry.listModels.query());
8073
- }
8074
- } catch (err) {
8075
- this.log.warn("custom-model-registry query failed — using static catalog only", { meta: { error: errMsg(err) } });
8076
- }
8077
- this.customModelsCache = {
8078
- at: now,
8079
- byStep
8080
- };
8081
- return byStep;
8292
+ return this.customModels.refresh(this.addonCtx?.api);
8082
8293
  }
8083
8294
  /**
8295
+ * A step's definition MERGED with its custom models, synchronously, from
8296
+ * the last custom-model read (D657 round 2). Every placement gate here
8297
+ * judges this catalog — the one the orchestrator's schema carries.
8298
+ */
8299
+ mergedStepDef = (addonId) => this.customModels.stepDefinition(addonId);
8300
+ /** {@link mergedStepDef} as the throwing lookup `collectZeroBuildIssues` takes. */
8301
+ mergedStepDefOrThrow = (addonId) => {
8302
+ const def = this.mergedStepDef(addonId);
8303
+ if (def === null) throw new Error(`Unknown pipeline step: "${addonId}"`);
8304
+ return def;
8305
+ };
8306
+ /**
8084
8307
  * Resolve a model id within a step to a catalog entry — static catalog
8085
8308
  * first, then the custom registry. Returns undefined if neither has it.
8086
8309
  */
@@ -8271,7 +8494,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
8271
8494
  const enabledSteps = this.pruneToEnabledSteps(steps);
8272
8495
  const substitutions = collectSubstitutions(enabledSteps, format);
8273
8496
  const unknownAddonIssues = collectUnknownAddonIssues(enabledSteps);
8274
- const zeroBuildIssues = collectZeroBuildIssues(this.flattenEnabledVideoStepInputs(steps), format);
8497
+ const zeroBuildIssues = collectZeroBuildIssues(this.flattenEnabledVideoStepInputs(steps), format, this.mergedStepDefOrThrow);
8275
8498
  const issues = [...unknownAddonIssues.map((u) => ({
8276
8499
  addonId: u.addonId,
8277
8500
  kind: "unknown-addon",
@@ -8588,6 +8811,12 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
8588
8811
  }
8589
8812
  async runPipeline(input, onProgress) {
8590
8813
  await this.clusterModels.refresh(this.addonCtx?.api);
8814
+ this.clusterUnknownLog.note(input.deviceId ?? 0, input.steps, this.clusterModels.unknownSteps());
8815
+ if (dispatchLoadPolicyOf(input) === "all-or-nothing") {
8816
+ const unknown = unknownStepsNamed(input.steps, this.clusterModels.unknownSteps());
8817
+ 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`);
8818
+ }
8819
+ await this.getCustomModels();
8591
8820
  const nodeId = this.addonCtx?.kernel?.localNodeId ?? "hub";
8592
8821
  const sessionId = input.sessionId ?? `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
8593
8822
  const isRuntime = Boolean(input.frame || input.frameRef);
@@ -8752,11 +8981,6 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
8752
8981
  }
8753
8982
  const enabledSteps = flattenSteps(benchmarkSteps);
8754
8983
  emit(`Pipeline: ${enabledSteps.length} step(s) — ${enabledSteps.map((s) => s.addonId).join(" → ")}`);
8755
- const rootModelId = resolveRootModelId(benchmarkSteps);
8756
- const stampRoot = (r) => rootModelId === void 0 ? r : {
8757
- ...r,
8758
- rootModelId
8759
- };
8760
8984
  if (input.replay === true) {
8761
8985
  const verdict = checkReplayPin({
8762
8986
  requested: input.steps,
@@ -8798,24 +9022,51 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
8798
9022
  const dispatchEngine = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey) : runEngine;
8799
9023
  const dispatchFactory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
8800
9024
  const needed = enabledSteps.filter((s) => dispatchFactory.needsPoolUpdate(s));
9025
+ let runSteps = benchmarkSteps;
9026
+ const loadPolicy = dispatchLoadPolicyOf(input);
8801
9027
  if (needed.length > 0) {
8802
- this.log.info("Benchmark: models to load", { meta: {
8803
- count: needed.length,
8804
- models: needed.map((s) => `${s.addonId}/${s.modelId}`)
9028
+ const dispatchDeviceKeyLabel = deviceKeyOf(dispatchEngine);
9029
+ const toIssue = needed.filter((s) => this.loadGovernor.check(dispatchFactory, dispatchDeviceKeyLabel, [stepModelKey(s)]).kind === "go");
9030
+ if (toIssue.length > 0) this.log.info("Benchmark: models to load", { meta: {
9031
+ count: toIssue.length,
9032
+ models: toIssue.map((s) => `${s.addonId}/${s.modelId}`)
8805
9033
  } });
8806
- for (const s of needed) emit(`Loading model: ${s.addonName} (${s.modelId})...`, {
9034
+ for (const s of toIssue) emit(`Loading model: ${s.addonName} (${s.modelId})...`, {
8807
9035
  step: s.addonId,
8808
9036
  addonId: s.addonId,
8809
9037
  modelId: s.modelId
8810
9038
  });
8811
- await this.ensureModelsForSteps(benchmarkSteps, dispatchFactory, dispatchEngine.format, {
9039
+ const failures = await this.loadDispatchModels(benchmarkSteps, dispatchFactory, dispatchEngine.format, {
8812
9040
  ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
8813
- deviceKey: deviceKeyOf(dispatchEngine)
8814
- });
8815
- emit(`All models loaded`);
9041
+ deviceKey: dispatchDeviceKeyLabel
9042
+ }, loadPolicy);
9043
+ runSteps = stepsSurvivingLoad(benchmarkSteps, failures, loadPolicy);
9044
+ emit(failures.length === 0 ? `All models loaded` : `Models loaded (${failures.length} failed)`);
8816
9045
  }
8817
9046
  emit("Running inference...");
8818
- const tree = buildExecutableTree(benchmarkSteps, (stepId) => dispatchFactory.getEngine(stepId), this.customModelResolver);
9047
+ const notWarm = [];
9048
+ const tree = buildExecutableTree(runSteps, (stepId, modelId) => dispatchFactory.getStepVariant(stepId, modelId), this.customModelResolver, (drop) => notWarm.push({
9049
+ step: drop.step,
9050
+ error: new Error(drop.error)
9051
+ }));
9052
+ if (notWarm.length > 0) {
9053
+ if (loadPolicy === "all-or-nothing") throw new DispatchStepsNotLoadedError(notWarm);
9054
+ const dispatchDeviceKeyLabel = deviceKeyOf(dispatchEngine);
9055
+ for (const failure of notWarm) {
9056
+ const model = stepModelKey(failure.step);
9057
+ this.reportDispatchLoadLoss({
9058
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
9059
+ deviceKey: dispatchDeviceKeyLabel,
9060
+ outcome: "step-dropped"
9061
+ }, dispatchDeviceKeyLabel, [model], model, "not-warm", errMsg(failure.error));
9062
+ }
9063
+ }
9064
+ const rootModelId = resolveTreeRootModelId(tree.roots);
9065
+ const stampRoot = (r) => rootModelId === void 0 ? r : {
9066
+ ...r,
9067
+ rootModelId
9068
+ };
9069
+ if (input.replay !== true) this.clusterModelSwitchLog.note(input.deviceId ?? 0, tree);
8819
9070
  setupMs = performance.now() - wallT0 - decodeMs;
8820
9071
  const effectiveDeviceId = input.deviceId ?? 0;
8821
9072
  const deviceOverrides = effectiveDeviceId > 0 ? await this.deviceOverrides.resolve(effectiveDeviceId) : {};
@@ -8921,7 +9172,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
8921
9172
  backend: this.currentEngine.backend,
8922
9173
  device: this.currentEngine.device ?? null
8923
9174
  }, clusterRefusalsOut) {
8924
- const { steps: resolvedSteps, diagnostics } = resolveInputSteps(steps, format, engine, this.clusterModels.current(), this.clusterModels.currentSettings());
9175
+ const { steps: resolvedSteps, diagnostics } = resolveInputSteps(steps, format, engine, this.clusterModels.current(), this.clusterModels.currentSettings(), this.clusterModels.unknownSteps());
8925
9176
  if (clusterRefusalsOut) clusterRefusalsOut.push(...diagnostics.clusterRefusals);
8926
9177
  for (const addonId of diagnostics.unknownAddonIds) this.logLiveDispatchIssueOnce(`unknown:${addonId}`, () => this.log.warn("Live pipeline step references an unknown addon — dropping step", { meta: { addonId } }));
8927
9178
  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: {
@@ -9034,7 +9285,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9034
9285
  const deviceRefusals = [];
9035
9286
  const steps = this.inputStepsToPipelineSteps(args.steps, deviceFormat, deviceStepEngine, deviceRefusals);
9036
9287
  const framePlane = args.plane === "frame";
9037
- const zeroBuild = collectZeroBuildIssues(framePlane ? collectFramePlaneSteps(steps, isDetailPlaneStep) : flattenSteps(steps), deviceFormat);
9288
+ const zeroBuild = collectZeroBuildIssues(framePlane ? collectFramePlaneSteps(steps, isDetailPlaneStep) : flattenSteps(steps), deviceFormat, this.mergedStepDefOrThrow);
9038
9289
  if (zeroBuild.length === 0) {
9039
9290
  if (!framePlane) {
9040
9291
  this.logClusterRefusals(deviceRefusals, deviceId, `${deviceKey} (${deviceFormat})`);
@@ -9043,7 +9294,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9043
9294
  steps
9044
9295
  };
9045
9296
  }
9046
- const pruneResult = pruneUnrunnableDetailSteps(steps, isDetailPlaneStep, (addonId) => stepHasZeroBuildsFor(addonId, deviceFormat));
9297
+ const pruneResult = pruneUnrunnableDetailSteps(steps, isDetailPlaneStep, (subtree) => !buildSubtreeCanRun(subtree, this.mergedStepDef)(deviceFormat));
9047
9298
  if (pruneResult.prunedAddonIds.length > 0) {
9048
9299
  const prunedList = pruneResult.prunedAddonIds.join(",");
9049
9300
  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", {
@@ -9055,7 +9306,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9055
9306
  }
9056
9307
  }));
9057
9308
  }
9058
- this.logClusterRefusals(deviceRefusals.filter((r) => !pruneResult.prunedAddonIds.includes(r.stepId)), deviceId, `${deviceKey} (${deviceFormat})`);
9309
+ this.logClusterRefusals(deviceRefusals.filter((r) => !pruneResult.removedAddonIds.includes(r.stepId)), deviceId, `${deviceKey} (${deviceFormat})`);
9059
9310
  return {
9060
9311
  deviceKey,
9061
9312
  steps: pruneResult.steps
@@ -9089,7 +9340,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9089
9340
  * microseconds instead of a 3-retry download backoff).
9090
9341
  */
9091
9342
  assertStepsRunnable(steps, format, deviceId, enginesTried) {
9092
- const zeroBuild = collectZeroBuildIssues(flattenSteps(steps), format);
9343
+ const zeroBuild = collectZeroBuildIssues(flattenSteps(steps), format, this.mergedStepDefOrThrow);
9093
9344
  if (zeroBuild.length === 0) return;
9094
9345
  const detail = zeroBuild.map((issue) => {
9095
9346
  const step = flattenSteps(steps).find((s) => s.addonId === issue.addonId);
@@ -9123,6 +9374,40 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9123
9374
  nullPoolKey = {};
9124
9375
  /** Negative cache, per-model compile-timeout refusals, per-camera reporting (D653). */
9125
9376
  loadGovernor = new ModelLoadGovernor();
9377
+ /**
9378
+ * Load a dispatch's models ONE STEP AT A TIME (D657), and answer which steps
9379
+ * did not load. Never throws: each step is its own load, joined, backed off
9380
+ * and charged on its own model, so a step whose model cannot load on this
9381
+ * pool — no build for its format, a compile that failed — costs that step,
9382
+ * never the root or a sibling. The caller decides what the dispatch runs
9383
+ * without it ({@link stepsSurvivingLoad}).
9384
+ */
9385
+ async loadDispatchModels(steps, factory, format, context, policy) {
9386
+ const failures = [];
9387
+ const walk = async (nodes, root) => {
9388
+ for (const step of nodes) {
9389
+ if (!step.enabled || step.slot === "audio-classifier") continue;
9390
+ if (factory === null || factory.needsPoolUpdate(step)) try {
9391
+ await this.ensureModelsForSteps([{
9392
+ ...step,
9393
+ children: []
9394
+ }], factory, format, {
9395
+ ...context,
9396
+ outcome: policy === "all-or-nothing" ? "call-failed" : root ? "root-lost" : "step-dropped"
9397
+ });
9398
+ } catch (err) {
9399
+ failures.push({
9400
+ step,
9401
+ error: err
9402
+ });
9403
+ continue;
9404
+ }
9405
+ await walk(step.children ?? [], false);
9406
+ }
9407
+ };
9408
+ await walk(steps, true);
9409
+ return failures;
9410
+ }
9126
9411
  /** Ensure all models needed by steps are downloaded and loaded in the engine pool.
9127
9412
  * `factory`/`format` default to the node's engine; a per-device dispatch passes
9128
9413
  * that device's factory + format (Phase 2 multi-device). `context` names the
@@ -9152,7 +9437,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9152
9437
  this.reportDispatchLoadLoss(context, deviceKey, models, err.model, err.state, err.message);
9153
9438
  throw err;
9154
9439
  }
9155
- const failed = err instanceof StepVariantLoadError ? `${err.stepId}/${err.modelId}` : models[0] ?? "";
9440
+ const failed = failedModelOf(err) ?? models[0] ?? "";
9156
9441
  this.reportDispatchLoadLoss(context, deviceKey, models, failed, `failed:${flight.generation ?? "unknown"}`, errMsg(err));
9157
9442
  throw err;
9158
9443
  }
@@ -9244,7 +9529,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9244
9529
  }
9245
9530
  /** Charge a failed load to the model that failed; say so once if it is now refused. */
9246
9531
  recordModelLoadFailure(pool, deviceKey, models, err) {
9247
- const failedModels = err instanceof StepVariantLoadError ? [`${err.stepId}/${err.modelId}`] : models;
9532
+ const named = failedModelOf(err);
9533
+ const failedModels = named !== null ? [named] : models;
9248
9534
  const compileTimeout = err instanceof StepVariantLoadError && err.reason === "compile-timeout";
9249
9535
  const poolModel = err instanceof StepVariantLoadError ? err.poolModel : null;
9250
9536
  let generation = 0;
@@ -9277,24 +9563,31 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9277
9563
  models,
9278
9564
  model,
9279
9565
  state,
9280
- error
9566
+ error,
9567
+ ...context.outcome !== void 0 ? { outcome: context.outcome } : {}
9281
9568
  }
9282
9569
  });
9283
9570
  }
9284
9571
  /** Download any model of `needed` missing in `format` (the device's). */
9285
9572
  async downloadNeededModels(needed, format) {
9286
- for (const step of needed) {
9287
- const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
9288
- if (modelEntry && !isModelDownloaded(this.modelsDir, modelEntry, format)) {
9289
- 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`);
9290
- 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`);
9291
- this.log.info("Downloading model for step", { meta: {
9292
- modelId: step.modelId,
9293
- format,
9294
- step: step.addonId
9295
- } });
9296
- await this.downloadWithRetry(modelEntry, format, 3);
9297
- }
9573
+ for (const step of needed) try {
9574
+ await this.ensureStepModelArtifact(step, format);
9575
+ } catch (err) {
9576
+ throw new StepModelArtifactError(step.addonId, step.modelId, err);
9577
+ }
9578
+ }
9579
+ /** Make one step's model artifact present on disk for `format`, or say why it cannot be. */
9580
+ async ensureStepModelArtifact(step, format) {
9581
+ const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
9582
+ if (modelEntry && !isModelDownloaded(this.modelsDir, modelEntry, format)) {
9583
+ 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`);
9584
+ 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`);
9585
+ this.log.info("Downloading model for step", { meta: {
9586
+ modelId: step.modelId,
9587
+ format,
9588
+ step: step.addonId
9589
+ } });
9590
+ await this.downloadWithRetry(modelEntry, format, 3);
9298
9591
  }
9299
9592
  }
9300
9593
  /** Download a model with retry + exponential backoff */
@@ -9392,6 +9685,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9392
9685
  }
9393
9686
  async runPipelineBatchImpl(input) {
9394
9687
  if (input.frames.length === 0) return { results: [] };
9688
+ await this.getCustomModels();
9395
9689
  const dispatchResolution = this.resolveStepsForDispatch({
9396
9690
  steps: input.steps,
9397
9691
  deviceKey: input.deviceKey,
@@ -9634,9 +9928,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
9634
9928
  return this.engineFactory.cacheFrameInPool(buf, input.width, input.height, input.format);
9635
9929
  }
9636
9930
  async inferCached(input) {
9931
+ 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`);
9637
9932
  await this.ensureEngineFactory();
9638
9933
  if (!this.engineFactory) throw new Error("inferCached: factory not initialized");
9639
- return this.engineFactory.inferCached(input.stepId, input.frameId);
9934
+ return this.engineFactory.inferCached(input.stepId, input.frameId, input.modelId);
9640
9935
  }
9641
9936
  async uncacheFrame(input) {
9642
9937
  if (!this.engineFactory) return;
@@ -10440,13 +10735,22 @@ function isDetailPlaneStep(addonId) {
10440
10735
  }
10441
10736
  }
10442
10737
  /**
10443
- * True when the step's FULL catalog ships no model with a build for
10444
- * `format` — the single-step form of `collectZeroBuildIssues`. Unknown/custom
10445
- * addonIds (no catalog to check) are never zero-build, mirroring the
10446
- * collector's skip.
10447
- */
10448
- function stepHasZeroBuildsFor(addonId, format) {
10449
- return collectZeroBuildIssues([{ addonId }], format).length > 0;
10738
+ * What a dispatch runs after its per-step loads (D657).
10739
+ *
10740
+ * An ALL-OR-NOTHING call (a benchmark, a replay — D56 — a capacity ramp) fails
10741
+ * on any failure, naming every step it lost ({@link DispatchStepsNotLoadedError}):
10742
+ * it measures or reproduces exactly what it names. A camera's LIVE dispatch
10743
+ * runs the tree without the steps whose model did not load, and fails — with
10744
+ * the first root's own named error — only when no root survives: a dispatch
10745
+ * with no root has nothing to produce.
10746
+ */
10747
+ function stepsSurvivingLoad(steps, failures, policy) {
10748
+ if (failures.length === 0) return steps;
10749
+ if (policy === "all-or-nothing") throw new DispatchStepsNotLoadedError(failures);
10750
+ const kept = withoutFailedSteps(steps, new Set(failures.map((f) => f.step)));
10751
+ if (kept.some((s) => s.enabled)) return kept;
10752
+ const roots = new Set(steps);
10753
+ throw (failures.find((f) => roots.has(f.step)) ?? failures[0]).error;
10450
10754
  }
10451
10755
  /**
10452
10756
  * The union of model formats a step's catalog ships ANY build for — the