@camstack/addon-pipeline 1.2.29 → 1.2.31

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 (30) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +448 -192
  4. package/dist/detection-pipeline/index.mjs +447 -191
  5. package/dist/{dist-DUHlZcWd.js → dist-BvFLZyhW.js} +105 -3
  6. package/dist/{dist-CsKaXmP8.mjs → dist-Du7_SNiR.mjs} +105 -3
  7. package/dist/motion-wasm/index.js +1 -1
  8. package/dist/motion-wasm/index.mjs +1 -1
  9. package/dist/pipeline-runner/index.js +127 -6
  10. package/dist/pipeline-runner/index.mjs +126 -6
  11. package/dist/recorder/index.js +1 -1
  12. package/dist/recorder/index.mjs +1 -1
  13. package/dist/{step-definitions-1GIwU6R8.mjs → step-definitions-BkY9_IM6.mjs} +1 -1
  14. package/dist/{step-definitions-Drb9cWbB.js → step-definitions-gC-Ovglc.js} +1 -1
  15. package/dist/stream-broker/_stub.js +2 -2
  16. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-tHahN-lK.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BUXeX-vb.mjs} +3 -3
  17. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ByHi7ZAe.mjs +26 -0
  18. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CscpvIRR.mjs +26 -0
  19. package/dist/stream-broker/{hostInit-CIwilJUZ.mjs → hostInit-DJ3h-b1X.mjs} +3 -3
  20. package/dist/stream-broker/index.js +3 -3
  21. package/dist/stream-broker/index.mjs +3 -3
  22. package/dist/stream-broker/remoteEntry.js +1 -1
  23. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-B7VdNJlO.js → MaskShapeCanvas-DI4BY7W2-B0DyP46h.js} +1 -1
  24. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-YyB4uLdH.js → MotionZonesSettings-NcxxQN8r-CzGSy_EO.js} +1 -1
  25. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DfqixEad.js → PrivacyMaskSettings-APgPLF7p-CEgMcOOW.js} +1 -1
  26. package/embed-dist/assets/{index-zVLLVdIS.js → index-CHXOWKHF.js} +14 -14
  27. package/embed-dist/index.html +1 -1
  28. package/package.json +1 -1
  29. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D1zJmtUe.mjs +0 -26
  30. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CSWUWZ8c.mjs +0 -26
@@ -3,10 +3,12 @@ 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-DUHlZcWd.js");
7
- const require_step_definitions = require("../step-definitions-Drb9cWbB.js");
6
+ const require_dist = require("../dist-BvFLZyhW.js");
7
+ const require_step_definitions = require("../step-definitions-gC-Ovglc.js");
8
8
  const require_node_topology_platform = require("../node-topology-platform-CFZ7F4xW.js");
9
9
  const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-fsdDExML.js");
10
+ let node_os = require("node:os");
11
+ node_os = require_chunk.__toESM(node_os);
10
12
  let node_child_process = require("node:child_process");
11
13
  let sharp = require("sharp");
12
14
  sharp = require_chunk.__toESM(sharp);
@@ -14,8 +16,6 @@ let node_fs = require("node:fs");
14
16
  node_fs = require_chunk.__toESM(node_fs);
15
17
  let node_path = require("node:path");
16
18
  node_path = require_chunk.__toESM(node_path);
17
- let node_os = require("node:os");
18
- node_os = require_chunk.__toESM(node_os);
19
19
  //#region src/detection-pipeline/runtimes.ts
20
20
  var KNOWN_PLATFORMS = [
21
21
  "darwin",
@@ -81,14 +81,28 @@ function probedToHardwareInfo(hw) {
81
81
  coral: coralInfoFrom(hw)
82
82
  };
83
83
  }
84
+ /**
85
+ * THE per-runtime pool provisioning registry — the single source for every
86
+ * pool's concurrency / worker count / batch tuning, keyed by the runtime THAT
87
+ * POOL runs. `engine-factory` used to carry a second, parallel copy of these
88
+ * defaults; it no longer does.
89
+ *
90
+ * `windowMs` / `maxBatchSize` are inert unless `batchMode !== 'none'`, and
91
+ * `numStreams` / `intraOpThreads` are inert at 0 (the runtime picks) — they are
92
+ * still spelled out per row so a reader never has to guess which default a
93
+ * missing key would have hit.
94
+ */
84
95
  var RUNTIME_DETAIL = {
85
96
  onnx: {
86
97
  label: "ONNX Runtime",
87
98
  pythonRequirements: ["requirements.txt", "requirements-onnxruntime.txt"],
88
99
  tuning: {
89
100
  concurrency: 4,
101
+ numWorkers: 1,
90
102
  batchMode: "list",
103
+ windowMs: 2,
91
104
  maxBatchSize: 8,
105
+ numStreams: 0,
92
106
  intraOpThreads: 0
93
107
  }
94
108
  },
@@ -97,8 +111,12 @@ var RUNTIME_DETAIL = {
97
111
  pythonRequirements: ["requirements.txt", "requirements-openvino.txt"],
98
112
  tuning: {
99
113
  concurrency: 1,
114
+ numWorkers: 1,
100
115
  batchMode: "none",
101
- numStreams: 0
116
+ windowMs: 2,
117
+ maxBatchSize: 8,
118
+ numStreams: 0,
119
+ intraOpThreads: 0
102
120
  }
103
121
  },
104
122
  coreml: {
@@ -106,10 +124,12 @@ var RUNTIME_DETAIL = {
106
124
  pythonRequirements: ["requirements.txt", "requirements-coreml.txt"],
107
125
  tuning: {
108
126
  concurrency: 1,
127
+ numWorkers: 1,
109
128
  batchMode: "none",
110
129
  windowMs: 8,
111
130
  maxBatchSize: 8,
112
- numWorkers: 1
131
+ numStreams: 0,
132
+ intraOpThreads: 0
113
133
  }
114
134
  },
115
135
  edgetpu: {
@@ -117,8 +137,12 @@ var RUNTIME_DETAIL = {
117
137
  pythonRequirements: ["requirements.txt", "requirements-edgetpu.txt"],
118
138
  tuning: {
119
139
  concurrency: 1,
140
+ numWorkers: 1,
120
141
  batchMode: "none",
121
- numWorkers: 1
142
+ windowMs: 2,
143
+ maxBatchSize: 8,
144
+ numStreams: 0,
145
+ intraOpThreads: 0
122
146
  }
123
147
  }
124
148
  };
@@ -161,6 +185,12 @@ function modelFormatFor(id) {
161
185
  function pythonRequirementsFor(id) {
162
186
  return RUNTIME_DETAIL[id].pythonRequirements;
163
187
  }
188
+ /**
189
+ * The pool provisioning for ONE runtime. Callers must pass the runtime of the
190
+ * pool they are provisioning — never the node's default runtime. See
191
+ * `engine/pool-provisioning.ts` for the resolver that layers an operator
192
+ * override on top of this.
193
+ */
164
194
  function tuningFor(id) {
165
195
  return RUNTIME_DETAIL[id].tuning;
166
196
  }
@@ -379,6 +409,62 @@ var RAW_FMT_CODE = {
379
409
  * large frame) never trips; override via env for constrained hardware.
380
410
  */
381
411
  var POOL_INFER_TIMEOUT_MS = Math.max(1e3, Number(process.env["CAMSTACK_POOL_INFER_TIMEOUT_MS"]) || 6e4);
412
+ /**
413
+ * Max inference requests outstanding to ONE worker before new ones are SHED.
414
+ *
415
+ * Without this the camera dies permanently the first time the worker gets
416
+ * slow, and it did: device 615 produced nothing for 52 minutes on 2026-07-31.
417
+ * The worker was neither dead nor deadlocked — it was replying at full rate,
418
+ * about a minute behind, and every reply landed after {@link
419
+ * POOL_INFER_TIMEOUT_MS} had already abandoned its request. The proof is in the
420
+ * logs: a steady drip of `Response for unknown request id`, request ids
421
+ * climbing ~13/s, for as long as the outage lasted.
422
+ *
423
+ * That state is self-sustaining and cannot drain, because frames keep arriving
424
+ * while every one of them is written straight to the pipe. `dispatch` had NO
425
+ * bound: a transient slowdown (inference went 40ms → 2500ms across several
426
+ * cameras at 16:19 that day) buys a backlog that is never paid off, and once
427
+ * the backlog exceeds the deadline the loss is total.
428
+ *
429
+ * The queue is therefore capped in TIME, not just in count: at the normal ~40ms
430
+ * per inference this is ~0.3s of work, and even at a 20× slowdown it stays well
431
+ * inside the 60s deadline — so a worker that falls behind sheds, catches up,
432
+ * and recovers on its own. Python already sheds under its own per-model bound
433
+ * and answers `{dropped: true}`; this is the same contract applied one layer
434
+ * earlier, where the unbounded queue actually is.
435
+ */
436
+ var POOL_MAX_IN_FLIGHT = Math.max(1, Number(process.env["CAMSTACK_POOL_MAX_IN_FLIGHT"]) || 8);
437
+ /**
438
+ * Reply deadline for a LIVE inference request (ms), as opposed to the generous
439
+ * {@link POOL_INFER_TIMEOUT_MS} that model loads and commands keep.
440
+ *
441
+ * 60 seconds was never a deadline for live detection — it is three orders of
442
+ * magnitude past the point where the answer is worth having. Frames arrive at
443
+ * ~13/s; a detection that lands 60s late describes a scene that no longer
444
+ * exists, and the only thing the wait accomplished was holding a queue slot
445
+ * while the backlog grew. Operator directive 2026-07-31: "a few seconds are
446
+ * already too many for this scenario".
447
+ *
448
+ * Three seconds is deliberately still loose — normal inference on this fleet is
449
+ * 35-47ms, so this trips only at a ~70× slowdown — but it bounds the damage at
450
+ * seconds instead of a minute, and combined with {@link POOL_MAX_IN_FLIGHT} it
451
+ * makes the backlog mathematically unable to outrun the deadline: the queue can
452
+ * never hold more than `cap × deadline` of work.
453
+ *
454
+ * Commands and model loads keep the long timeout on purpose: a cold model load
455
+ * legitimately takes tens of seconds, and shedding or timing one out
456
+ * desynchronizes the worker's model slots.
457
+ */
458
+ var POOL_LIVE_INFER_TIMEOUT_MS = Math.max(250, Number(process.env["CAMSTACK_POOL_LIVE_INFER_TIMEOUT_MS"]) || 3e3);
459
+ /** Opcodes the in-flight cap may shed. Commands and frame-cache writes are
460
+ * NEVER shed: a dropped command silently desynchronizes model state, and a
461
+ * dropped `cacheFrame` strands the `inferCached` that follows it. */
462
+ var SHEDDABLE_MSG_TYPES = new Set([
463
+ MSG_INFER_JPEG,
464
+ MSG_INFER_RAW,
465
+ MSG_INFER_BATCH,
466
+ MSG_INFER_CACHED
467
+ ]);
382
468
  var PoolHandle = class {
383
469
  pool;
384
470
  modelIndex;
@@ -458,6 +544,13 @@ var PoolWorker = class {
458
544
  process = null;
459
545
  receiveBuffer = Buffer.alloc(0);
460
546
  pending = /* @__PURE__ */ new Map();
547
+ /** True while the in-flight cap is shedding — so the warning fires on the
548
+ * EDGE rather than once per dropped frame. */
549
+ shedding = false;
550
+ /** Inference requests shed by the in-flight cap, for this worker's lifetime.
551
+ * Surfaced so "the camera is quiet" and "the camera is saturated" can be
552
+ * told apart without reading the logs. */
553
+ shedCount = 0;
461
554
  nextRequestId = 1;
462
555
  ready = false;
463
556
  log;
@@ -469,6 +562,15 @@ var PoolWorker = class {
469
562
  getPid() {
470
563
  return this.process?.pid ?? null;
471
564
  }
565
+ /** Requests written and not yet answered. This is the BACKLOG — the number
566
+ * nothing measured while a camera sat at zero frames for 52 minutes. */
567
+ getInFlight() {
568
+ return this.pending.size;
569
+ }
570
+ /** Inference shed by the in-flight cap over this worker's lifetime. */
571
+ getShedCount() {
572
+ return this.shedCount;
573
+ }
472
574
  isReady() {
473
575
  return this.ready;
474
576
  }
@@ -617,10 +719,51 @@ var PoolWorker = class {
617
719
  this.ready = false;
618
720
  await terminateChild(proc, POOL_WORKER_TERM_GRACE_MS);
619
721
  }
722
+ /**
723
+ * Shed an inference request when the worker already has {@link
724
+ * POOL_MAX_IN_FLIGHT} outstanding — the backlog is what turns a slow worker
725
+ * into a dead camera. Returns the shed response, or `null` to proceed.
726
+ *
727
+ * A shed is LOUD the first time and then rate-limited: at frame rate this
728
+ * fires ~13×/s, so logging every one would bury the signal it is meant to
729
+ * raise — but logging none is how a camera went dark for 52 minutes looking
730
+ * like a quiet afternoon.
731
+ */
732
+ shedIfSaturated(msgType) {
733
+ if (!SHEDDABLE_MSG_TYPES.has(msgType)) return null;
734
+ if (this.pending.size < POOL_MAX_IN_FLIGHT) {
735
+ this.shedding = false;
736
+ return null;
737
+ }
738
+ if (!this.shedding) {
739
+ this.shedding = true;
740
+ this.log.warn("worker saturated — shedding inference until it catches up", { meta: {
741
+ worker: this.opts.workerLabel,
742
+ pid: this.getPid(),
743
+ runtime: this.opts.poolRuntime,
744
+ device: this.opts.device ?? "default",
745
+ inFlight: this.pending.size,
746
+ cap: POOL_MAX_IN_FLIGHT,
747
+ deadlineMs: POOL_LIVE_INFER_TIMEOUT_MS
748
+ } });
749
+ }
750
+ this.shedCount++;
751
+ return {
752
+ dropped: true,
753
+ shedReason: "worker-in-flight-cap"
754
+ };
755
+ }
756
+ /** Live inference gets seconds; commands and model loads keep the long
757
+ * timeout — see {@link POOL_LIVE_INFER_TIMEOUT_MS}. */
758
+ deadlineFor(msgType) {
759
+ return SHEDDABLE_MSG_TYPES.has(msgType) ? POOL_LIVE_INFER_TIMEOUT_MS : POOL_INFER_TIMEOUT_MS;
760
+ }
620
761
  dispatch(msgType, payload) {
762
+ const shed = this.shedIfSaturated(msgType);
763
+ if (shed) return Promise.resolve(shed);
621
764
  const reqId = this.allocRequestId();
622
765
  return new Promise((resolve, reject) => {
623
- const timer = this.armRequestTimeout(reqId, reject);
766
+ const timer = this.armRequestTimeout(reqId, reject, this.deadlineFor(msgType));
624
767
  this.pending.set(reqId, {
625
768
  resolve,
626
769
  reject,
@@ -640,24 +783,30 @@ var PoolWorker = class {
640
783
  * `POOL_INFER_TIMEOUT_MS`. `unref` so it never keeps the event loop alive.
641
784
  * The normal response + `rejectAll` paths clear it via `PendingRequest.timer`.
642
785
  */
643
- armRequestTimeout(reqId, reject) {
786
+ armRequestTimeout(reqId, reject, timeoutMs) {
644
787
  const timer = setTimeout(() => {
645
788
  if (this.pending.delete(reqId)) {
646
789
  this.log.error("inference request timed out", { meta: {
647
790
  worker: this.opts.workerLabel,
791
+ pid: this.getPid(),
792
+ runtime: this.opts.poolRuntime,
793
+ device: this.opts.device ?? "default",
794
+ inFlight: this.pending.size,
648
795
  reqId,
649
796
  timeoutMs: POOL_INFER_TIMEOUT_MS
650
797
  } });
651
- reject(/* @__PURE__ */ new Error(`PoolWorker[${this.opts.workerLabel}]: inference request ${reqId} timed out after ${POOL_INFER_TIMEOUT_MS}ms (worker alive, no reply)`));
798
+ reject(/* @__PURE__ */ new Error(`PoolWorker[${this.opts.workerLabel}]: inference request ${reqId} timed out after ${timeoutMs}ms (worker alive, no reply)`));
652
799
  }
653
- }, POOL_INFER_TIMEOUT_MS);
800
+ }, timeoutMs);
654
801
  timer.unref?.();
655
802
  return timer;
656
803
  }
657
804
  dispatchMulti(msgType, payloadLen, parts) {
805
+ const shed = this.shedIfSaturated(msgType);
806
+ if (shed) return Promise.resolve(shed);
658
807
  const reqId = this.allocRequestId();
659
808
  return new Promise((resolve, reject) => {
660
- const timer = this.armRequestTimeout(reqId, reject);
809
+ const timer = this.armRequestTimeout(reqId, reject, this.deadlineFor(msgType));
661
810
  this.pending.set(reqId, {
662
811
  resolve,
663
812
  reject,
@@ -763,6 +912,22 @@ var SharedInferencePool = class {
763
912
  }
764
913
  device;
765
914
  /** Pid of the first worker (for legacy callers). Use `getPids()` for all. */
915
+ /** Summed backlog and shed count across the pool's workers. A rising
916
+ * `inFlight` with a rising `shed` is a worker falling behind; a rising
917
+ * `inFlight` with zero `shed` and no results is the state that used to be
918
+ * invisible. */
919
+ getBacklog() {
920
+ let inFlight = 0;
921
+ let shed = 0;
922
+ for (const w of this.workers) {
923
+ inFlight += w.getInFlight();
924
+ shed += w.getShedCount();
925
+ }
926
+ return {
927
+ inFlight,
928
+ shed
929
+ };
930
+ }
766
931
  getPid() {
767
932
  return this.workers[0]?.getPid() ?? null;
768
933
  }
@@ -796,6 +961,7 @@ var SharedInferencePool = class {
796
961
  loadedCount,
797
962
  startupMs,
798
963
  runtime: this.poolRuntime,
964
+ device: this.device ?? "default",
799
965
  workers: this.numWorkers,
800
966
  pythonWorkers: innerWorkers,
801
967
  pids: this.getPids()
@@ -887,7 +1053,10 @@ var SharedInferencePool = class {
887
1053
  async dispose() {
888
1054
  await Promise.all(this.workers.map((w) => w.dispose()));
889
1055
  this.workers.length = 0;
890
- this.log.info("Pool process(es) terminated");
1056
+ this.log.info("Pool process(es) terminated", { meta: {
1057
+ runtime: this.poolRuntime,
1058
+ device: this.device ?? "default"
1059
+ } });
891
1060
  }
892
1061
  /**
893
1062
  * Recognise a Python overload-shed response (`"dropped": true`) on the
@@ -900,7 +1069,9 @@ var SharedInferencePool = class {
900
1069
  this.droppedResponseCount++;
901
1070
  this.log.debug("Python pool shed frame under overload", { meta: {
902
1071
  modelIndex,
903
- droppedTotal: this.droppedResponseCount
1072
+ droppedTotal: this.droppedResponseCount,
1073
+ runtime: this.poolRuntime,
1074
+ device: this.device ?? "default"
904
1075
  } });
905
1076
  }
906
1077
  return result;
@@ -1236,6 +1407,126 @@ var PipelineModelManager = class {
1236
1407
  }
1237
1408
  };
1238
1409
  //#endregion
1410
+ //#region src/detection-pipeline/engine/resolve-device-engine.ts
1411
+ /**
1412
+ * Resolve a Phase-1 device key (`<backend>:<device>`, or bare `cpu`) into the
1413
+ * {@link PipelineEngineChoice} used to build that device's inference pool.
1414
+ * Throws on an unknown backend token so a bad `deviceKey` fails loud rather
1415
+ * than silently building a wrong pool. Format comes from the shared
1416
+ * {@link DEVICE_BACKEND_TO_FORMAT} SSOT (`@camstack/types`) — no local copy.
1417
+ */
1418
+ function resolveDeviceEngine(deviceKey) {
1419
+ const colon = deviceKey.indexOf(":");
1420
+ const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
1421
+ const device = colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey;
1422
+ const format = require_dist.DEVICE_BACKEND_TO_FORMAT[backend];
1423
+ if (format === void 0) throw new Error(`resolveDeviceEngine: unknown backend "${backend}" in deviceKey "${deviceKey}"`);
1424
+ return {
1425
+ runtime: "python",
1426
+ backend,
1427
+ format,
1428
+ device
1429
+ };
1430
+ }
1431
+ /**
1432
+ * The inverse of {@link resolveDeviceEngine}: the `<backend>:<device>` key that
1433
+ * names the pool an engine belongs to. Every pool-level log line carries this —
1434
+ * without it a default pool logs anonymously and six hours of logs cannot tell
1435
+ * "the NPU is unused" from "the NPU IS the anonymous default pool".
1436
+ * Falls back to the bare backend when the engine carries no device.
1437
+ */
1438
+ function deviceKeyOf(engine) {
1439
+ return engine.device ? `${engine.backend}:${engine.device}` : engine.backend;
1440
+ }
1441
+ //#endregion
1442
+ //#region src/detection-pipeline/engine/pool-provisioning.ts
1443
+ /** The fields a {@link PoolProvisioningOverride} may carry, in table order. */
1444
+ var OVERRIDABLE_KEYS = [
1445
+ "concurrency",
1446
+ "numWorkers",
1447
+ "batchMode",
1448
+ "windowMs",
1449
+ "maxBatchSize",
1450
+ "numStreams",
1451
+ "intraOpThreads"
1452
+ ];
1453
+ /** Backends that are not a runtime of their own and run on onnxruntime. */
1454
+ var BACKEND_TO_RUNTIME = {
1455
+ onnx: "onnx",
1456
+ cpu: "onnx",
1457
+ cuda: "onnx",
1458
+ openvino: "openvino",
1459
+ coreml: "coreml",
1460
+ edgetpu: "edgetpu"
1461
+ };
1462
+ /**
1463
+ * The registry runtime a device-pool backend provisions from. Unknown backends
1464
+ * floor to `onnx` — the same floor `resolveDeviceEngine` applies to the model
1465
+ * format, so a pool never comes up unprovisioned.
1466
+ */
1467
+ function runtimeIdForBackend(backend) {
1468
+ return BACKEND_TO_RUNTIME[backend] ?? "onnx";
1469
+ }
1470
+ /**
1471
+ * Resolve the provisioning for a pool running `engine`, applying the precedence
1472
+ * documented at the top of this module. `engine.device` is not consulted today
1473
+ * (see the module doc) but is part of the signature so a bench-backed
1474
+ * per-device row lands here and nowhere else.
1475
+ */
1476
+ function resolvePoolProvisioning(engine, override = {}) {
1477
+ const runtime = runtimeIdForBackend(engine.backend);
1478
+ const profile = tuningFor(runtime);
1479
+ return {
1480
+ runtime,
1481
+ overriddenFields: OVERRIDABLE_KEYS.filter((k) => override[k] !== void 0),
1482
+ concurrency: Math.max(1, override.concurrency ?? profile.concurrency),
1483
+ numWorkers: Math.max(1, override.numWorkers ?? profile.numWorkers),
1484
+ batchMode: override.batchMode ?? profile.batchMode,
1485
+ windowMs: override.windowMs ?? profile.windowMs,
1486
+ maxBatchSize: override.maxBatchSize ?? profile.maxBatchSize,
1487
+ numStreams: override.numStreams ?? profile.numStreams,
1488
+ intraOpThreads: override.intraOpThreads ?? profile.intraOpThreads
1489
+ };
1490
+ }
1491
+ function positiveNumber(value) {
1492
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
1493
+ }
1494
+ function batchMode(value) {
1495
+ return value === "none" || value === "list" || value === "window" ? value : void 0;
1496
+ }
1497
+ /**
1498
+ * Read the operator override out of the detection addon's settings. Only a
1499
+ * NON-AUTO value counts: the sliders persist `0` and the select persists `''`
1500
+ * for "Auto (backend default)", and those must resolve to the runtime's
1501
+ * registry value, not to a literal 0 worker threads.
1502
+ *
1503
+ * The result is deliberately sparse — an empty object means "nothing
1504
+ * overridden", which is what lets a per-device pool provision from its own
1505
+ * runtime instead of inheriting the node's.
1506
+ */
1507
+ function readOperatorOverride(settings) {
1508
+ const override = {};
1509
+ const concurrency = positiveNumber(settings.concurrency);
1510
+ if (concurrency !== void 0) override.concurrency = concurrency;
1511
+ const numWorkers = positiveNumber(settings.numWorkers);
1512
+ if (numWorkers !== void 0) override.numWorkers = numWorkers;
1513
+ const mode = batchMode(settings.batchMode);
1514
+ if (mode !== void 0) override.batchMode = mode;
1515
+ const windowMs = positiveNumber(settings.windowMs);
1516
+ if (windowMs !== void 0) override.windowMs = windowMs;
1517
+ const maxBatchSize = positiveNumber(settings.maxBatchSize);
1518
+ if (maxBatchSize !== void 0) override.maxBatchSize = maxBatchSize;
1519
+ const numStreams = positiveNumber(settings.numStreams);
1520
+ if (numStreams !== void 0) override.numStreams = numStreams;
1521
+ const intraOpThreads = positiveNumber(settings.intraOpThreads);
1522
+ if (intraOpThreads !== void 0) override.intraOpThreads = intraOpThreads;
1523
+ return override;
1524
+ }
1525
+ /** Two overrides carry the same fields with the same values. */
1526
+ function overridesEqual(a, b) {
1527
+ return OVERRIDABLE_KEYS.every((k) => a[k] === b[k]);
1528
+ }
1529
+ //#endregion
1239
1530
  //#region src/detection-pipeline/engine/engine-factory.ts
1240
1531
  var BACKEND_TO_POOL_RUNTIME = {
1241
1532
  coreml: "coreml",
@@ -1305,9 +1596,31 @@ var EngineFactory = class {
1305
1596
  poolManager = null;
1306
1597
  log;
1307
1598
  opts;
1599
+ /**
1600
+ * `<backend>:<device>` — on EVERY log line this factory writes. A pool that
1601
+ * logs anonymously cannot be told apart from another pool of the same runtime
1602
+ * on a different accelerator.
1603
+ */
1604
+ deviceKey;
1605
+ /**
1606
+ * Resolved at CONSTRUCTION, not at pool spawn, so it is inspectable without
1607
+ * starting Python and so a pool that fails to spawn still logs what it was
1608
+ * trying to be.
1609
+ */
1610
+ resolvedProvisioning;
1308
1611
  constructor(opts) {
1309
1612
  this.opts = opts;
1310
1613
  this.log = opts.logger;
1614
+ this.deviceKey = deviceKeyOf(opts.engine);
1615
+ this.resolvedProvisioning = resolvePoolProvisioning(opts.engine, opts.provisioning);
1616
+ }
1617
+ /**
1618
+ * The provisioning this factory's pool runs with — resolved from the pool's
1619
+ * OWN runtime plus any operator override. Read by the effective-tuning
1620
+ * readout and by the tests that pin the per-runtime behaviour.
1621
+ */
1622
+ get provisioning() {
1623
+ return this.resolvedProvisioning;
1311
1624
  }
1312
1625
  /** Detection always uses the Python pool. */
1313
1626
  get usesPythonPool() {
@@ -1409,40 +1722,51 @@ var EngineFactory = class {
1409
1722
  this.poolManager = null;
1410
1723
  }
1411
1724
  }
1725
+ /**
1726
+ * The one pool-init log line. `deviceKey` / `device` are the reason it
1727
+ * exists: the default pool used to log neither, so an operator reading a
1728
+ * day of logs could not tell whether the NPU was idle or was simply the
1729
+ * anonymous default pool.
1730
+ *
1731
+ * `overriddenFields` replaces the old `source` field. `source` claimed
1732
+ * `'auto-default' | 'user-override'` about the CONCURRENCY TUNING but read as
1733
+ * the provenance of the device/engine choice, and was `'user-override'` on
1734
+ * every pool because the addon always passed explicit tuning. This lists the
1735
+ * fields the operator actually overrode — empty means pure registry.
1736
+ */
1737
+ logProvisioning() {
1738
+ const p = this.resolvedProvisioning;
1739
+ this.log.info("Python inference pool", { meta: {
1740
+ deviceKey: this.deviceKey,
1741
+ backend: this.opts.engine.backend,
1742
+ device: this.opts.engine.device ?? null,
1743
+ runtime: p.runtime,
1744
+ concurrency: p.concurrency,
1745
+ numWorkers: p.numWorkers,
1746
+ overriddenFields: p.overriddenFields,
1747
+ tuning: {
1748
+ batchMode: p.batchMode,
1749
+ windowMs: p.windowMs,
1750
+ maxBatchSize: p.maxBatchSize,
1751
+ numStreams: p.numStreams,
1752
+ intraOpThreads: p.intraOpThreads
1753
+ }
1754
+ } });
1755
+ }
1412
1756
  async initPythonPool(steps) {
1757
+ this.logProvisioning();
1413
1758
  const pythonPath = this.opts.pythonPath;
1414
1759
  if (!pythonPath) throw new Error("EngineFactory: pythonPath is required — the addon must call ctx.deps.ensurePython() and pass the result. The embedded portable Python download likely failed; check the addon logs for the download error.");
1415
1760
  const poolRuntime = BACKEND_TO_POOL_RUNTIME[this.opts.engine.backend];
1416
1761
  if (!poolRuntime) throw new Error(`No pool runtime mapping for backend "${this.opts.engine.backend}"`);
1417
- const concurrency = this.opts.concurrency ?? {
1418
- coreml: 1,
1419
- openvino: 1,
1420
- onnxruntime: 4,
1421
- edgetpu: 1
1422
- }[poolRuntime];
1423
- const isAutoDefault = this.opts.concurrency === void 0;
1424
- const defaultBatchMode = {
1425
- coreml: "window",
1426
- openvino: "none",
1427
- onnxruntime: "list",
1428
- edgetpu: "none"
1429
- };
1430
- const tuning = this.opts.tuning ?? {};
1762
+ const { concurrency, numWorkers } = this.resolvedProvisioning;
1431
1763
  const resolvedTuning = {
1432
- batchMode: tuning.batchMode ?? defaultBatchMode[poolRuntime],
1433
- windowMs: tuning.windowMs ?? 8,
1434
- maxBatchSize: tuning.maxBatchSize ?? 8,
1435
- numStreams: tuning.numStreams ?? 0,
1436
- intraOpThreads: tuning.intraOpThreads ?? 0
1764
+ batchMode: this.resolvedProvisioning.batchMode,
1765
+ windowMs: this.resolvedProvisioning.windowMs,
1766
+ maxBatchSize: this.resolvedProvisioning.maxBatchSize,
1767
+ numStreams: this.resolvedProvisioning.numStreams,
1768
+ intraOpThreads: this.resolvedProvisioning.intraOpThreads
1437
1769
  };
1438
- const numWorkers = Math.max(1, this.opts.numWorkers ?? 1);
1439
- this.log.info("Python inference pool", { meta: {
1440
- runtime: poolRuntime,
1441
- concurrency,
1442
- numWorkers,
1443
- source: isAutoDefault ? "auto-default" : "user-override",
1444
- tuning: resolvedTuning
1445
- } });
1446
1770
  this.pool = new SharedInferencePool(pythonPath, poolRuntime, this.log.child("pool"), {
1447
1771
  concurrency,
1448
1772
  tuning: resolvedTuning,
@@ -1498,28 +1822,6 @@ var EngineFactory = class {
1498
1822
  }
1499
1823
  };
1500
1824
  //#endregion
1501
- //#region src/detection-pipeline/engine/resolve-device-engine.ts
1502
- /**
1503
- * Resolve a Phase-1 device key (`<backend>:<device>`, or bare `cpu`) into the
1504
- * {@link PipelineEngineChoice} used to build that device's inference pool.
1505
- * Throws on an unknown backend token so a bad `deviceKey` fails loud rather
1506
- * than silently building a wrong pool. Format comes from the shared
1507
- * {@link DEVICE_BACKEND_TO_FORMAT} SSOT (`@camstack/types`) — no local copy.
1508
- */
1509
- function resolveDeviceEngine(deviceKey) {
1510
- const colon = deviceKey.indexOf(":");
1511
- const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
1512
- const device = colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey;
1513
- const format = require_dist.DEVICE_BACKEND_TO_FORMAT[backend];
1514
- if (format === void 0) throw new Error(`resolveDeviceEngine: unknown backend "${backend}" in deviceKey "${deviceKey}"`);
1515
- return {
1516
- runtime: "python",
1517
- backend,
1518
- format,
1519
- device
1520
- };
1521
- }
1522
- //#endregion
1523
1825
  //#region src/detection-pipeline/engine/idle-pool-reaper.ts
1524
1826
  var IdlePoolReaper = class {
1525
1827
  lastUsed = /* @__PURE__ */ new Map();
@@ -4961,8 +5263,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4961
5263
  modelsDir: this.modelsDir,
4962
5264
  logger: this.log.child("engine-verify"),
4963
5265
  pythonPath: this.executorOptions.pythonPath,
4964
- concurrency: 1,
4965
- numWorkers: 1
5266
+ provisioning: {
5267
+ concurrency: 1,
5268
+ numWorkers: 1
5269
+ }
4966
5270
  });
4967
5271
  try {
4968
5272
  await probe.initialize([]);
@@ -5718,9 +6022,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5718
6022
  modelsDir: this.modelsDir,
5719
6023
  logger: this.log.child("engine-override"),
5720
6024
  pythonPath: this.executorOptions.pythonPath ?? "",
5721
- concurrency: this.executorOptions.concurrency,
5722
- tuning: this.executorOptions.tuning,
5723
- numWorkers: this.executorOptions.numWorkers
6025
+ provisioning: this.executorOptions.provisioning
5724
6026
  });
5725
6027
  const initPromise = newFactory.initialize([]);
5726
6028
  this.overrideCache = {
@@ -5900,9 +6202,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5900
6202
  modelsDir: this.modelsDir,
5901
6203
  logger: this.log.child("engine-override"),
5902
6204
  pythonPath: this.executorOptions.pythonPath ?? "",
5903
- concurrency: this.executorOptions.concurrency,
5904
- tuning: this.executorOptions.tuning,
5905
- numWorkers: this.executorOptions.numWorkers
6205
+ provisioning: this.executorOptions.provisioning
5906
6206
  });
5907
6207
  const initPromise = newFactory.initialize([]);
5908
6208
  this.overrideCache = {
@@ -6434,13 +6734,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6434
6734
  if (!this.engineFactory) return;
6435
6735
  await this.engineFactory.uncacheFrame(input.frameId);
6436
6736
  }
6737
+ /**
6738
+ * The tuning of the node's DEFAULT pool. Per-device pools each resolve their
6739
+ * own runtime's row, so this is not "the node's tuning" — the per-pool truth
6740
+ * is on each pool's `Python inference pool` log line and in `listLoadedEngines`.
6741
+ */
6437
6742
  async getEffectiveTuning() {
6438
- const t = this.executorOptions.tuning;
6743
+ const p = resolvePoolProvisioning(this.currentEngine, this.executorOptions.provisioning);
6439
6744
  return {
6440
- batchMode: t?.batchMode ?? "none",
6441
- windowMs: t?.windowMs ?? 0,
6442
- maxBatchSize: t?.maxBatchSize ?? 1,
6443
- concurrency: this.executorOptions.concurrency ?? 1
6745
+ batchMode: p.batchMode,
6746
+ windowMs: p.windowMs,
6747
+ maxBatchSize: p.maxBatchSize,
6748
+ concurrency: p.concurrency
6444
6749
  };
6445
6750
  }
6446
6751
  /**
@@ -6458,9 +6763,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6458
6763
  modelsDir: this.modelsDir,
6459
6764
  logger: this.log.child("engine"),
6460
6765
  pythonPath: this.executorOptions.pythonPath ?? "",
6461
- concurrency: this.executorOptions.concurrency,
6462
- tuning: this.executorOptions.tuning,
6463
- numWorkers: this.executorOptions.numWorkers
6766
+ provisioning: this.executorOptions.provisioning
6464
6767
  });
6465
6768
  await this.engineFactory.initialize([]);
6466
6769
  }
@@ -6499,13 +6802,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6499
6802
  modelsDir: this.modelsDir,
6500
6803
  logger: this.log.child(`engine:${deviceKey}`),
6501
6804
  pythonPath: this.executorOptions.pythonPath ?? "",
6502
- concurrency: this.executorOptions.concurrency,
6503
- tuning: this.executorOptions.tuning,
6504
- numWorkers: this.executorOptions.numWorkers
6805
+ provisioning: this.executorOptions.provisioning
6505
6806
  });
6506
6807
  try {
6507
6808
  await factory.initialize([]);
6508
6809
  } catch (err) {
6810
+ this.log.error("Device pool failed to initialize", { meta: {
6811
+ deviceKey,
6812
+ backend: engine.backend,
6813
+ device: engine.device ?? null,
6814
+ provisioning: factory.provisioning,
6815
+ error: err instanceof Error ? err.message : String(err)
6816
+ } });
6509
6817
  await factory.dispose().catch(() => void 0);
6510
6818
  throw err;
6511
6819
  }
@@ -7116,21 +7424,22 @@ function toNodePlatform(value) {
7116
7424
  return value === "darwin" || value === "linux" || value === "win32" ? value : void 0;
7117
7425
  }
7118
7426
  /**
7119
- * Tuning fields that bind into the Python pool at construction. Any
7120
- * change requires a pool respawn to take effect (the executor caches
7121
- * `tuning` at engine-factory build time). `onConfigChanged` watches
7122
- * these and rebuilds the provider when they flip.
7427
+ * Slider keys on the "Inference executor" section that map 1:1 onto a
7428
+ * {@link RuntimePoolProfile} field. Used to inject the node-default runtime's
7429
+ * registry value as the field's suggested default in `getGlobalSettings`.
7123
7430
  */
7124
- var POOL_BOUND_KEYS = [
7431
+ var PROFILE_SLIDER_KEYS = [
7125
7432
  "concurrency",
7126
- "batchMode",
7433
+ "numWorkers",
7127
7434
  "windowMs",
7128
7435
  "maxBatchSize",
7129
7436
  "numStreams",
7130
- "intraOpThreads",
7131
- "numWorkers"
7437
+ "intraOpThreads"
7132
7438
  ];
7133
- var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.BaseAddon {
7439
+ function isProfileSliderKey(key) {
7440
+ return PROFILE_SLIDER_KEYS.some((k) => k === key);
7441
+ }
7442
+ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7134
7443
  provider = null;
7135
7444
  /** Last non-null probed hardware PER NODE — reused when the probe transiently
7136
7445
  * returns null so offered backends / device lists don't collapse. Keyed by
@@ -7145,14 +7454,17 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7145
7454
  * ENGINE_METRICS_HEARTBEAT_MS for liveness. */
7146
7455
  lastEmittedEngineSnapshot = null;
7147
7456
  /**
7148
- * Last-applied tuning snapshot, taken at the end of `onInitialize`
7149
- * and refreshed at the end of `onConfigChanged`. Lets the change
7150
- * handler diff the current config against the snapshot and decide
7151
- * whether a pool respawn is necessary — most config edits (engine
7152
- * cascade, audio settings) don't touch the tuning fields and don't
7153
- * deserve a multi-second model reload.
7457
+ * Last-applied OPERATOR OVERRIDE, taken at the end of `onInitialize` and
7458
+ * refreshed at the end of `onConfigChanged`. Lets the change handler diff the
7459
+ * current config against the snapshot and decide whether a pool respawn is
7460
+ * necessary — most config edits (engine cascade, audio settings) don't touch
7461
+ * the tuning fields and don't deserve a multi-second model reload.
7462
+ *
7463
+ * The per-runtime registry values are compile-time constants resolved inside
7464
+ * each pool, so they can never be the reason for a respawn; only an operator
7465
+ * edit can.
7154
7466
  */
7155
- lastAppliedPoolConfig = {};
7467
+ lastAppliedOverride = {};
7156
7468
  /**
7157
7469
  * Embedded Python path resolved once at boot via
7158
7470
  * `ctx.deps.ensurePython()`. Empty string means the download failed
@@ -7320,17 +7632,16 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7320
7632
  ...section,
7321
7633
  fields: section.fields.map((field) => {
7322
7634
  if (field.type === "select") {
7323
- if (field.key === "batchMode" && tuning["batchMode"] !== void 0) return {
7635
+ if (field.key === "batchMode") return {
7324
7636
  ...field,
7325
- default: tuning["batchMode"]
7637
+ default: tuning.batchMode
7326
7638
  };
7327
7639
  }
7328
7640
  if (field.type === "slider" && "key" in field) {
7329
- const tuned = tuning[field.key];
7330
7641
  const sliderField = field;
7331
- let patchedField = typeof tuned === "number" ? {
7642
+ let patchedField = isProfileSliderKey(sliderField.key) ? {
7332
7643
  ...sliderField,
7333
- default: tuned
7644
+ default: tuning[sliderField.key]
7334
7645
  } : sliderField;
7335
7646
  if (sliderField.key === "concurrency" && backend === "coreml") patchedField = {
7336
7647
  ...patchedField,
@@ -7439,60 +7750,39 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7439
7750
  return normalizeEngineNodeId(this.ctxIfReady?.kernel?.localNodeId ?? "hub");
7440
7751
  }
7441
7752
  /**
7442
- * Resolve the effective pool tuning for the configured backend.
7753
+ * The operator's explicit deviations from the per-runtime registry — and
7754
+ * NOTHING else.
7443
7755
  *
7444
- * Reads the registry's `tuningFor(backend)` and ignores any persisted
7445
- * override for `concurrency / batchMode / windowMs / maxBatchSize /
7446
- * numStreams / intraOpThreads`. Stored values are quietly discarded so an old
7447
- * suboptimal user-override (saved when the UI exposed these knobs)
7448
- * cannot resurrect itself after a restart.
7756
+ * This used to be `resolveBackendTuning()`: it read `tuningFor(<the node's
7757
+ * default backend>)` and handed the whole resolved row to the provider, which
7758
+ * passed it verbatim to every pool it built. That is how an `openvino:npu`
7759
+ * pool on an onnx-default node came up with onnx's `concurrency: 4`. The
7760
+ * per-runtime values now resolve inside each pool
7761
+ * (`engine/pool-provisioning.ts`), keyed by that pool's own runtime, so
7762
+ * nothing backend-shaped may be computed here.
7449
7763
  *
7450
- * The matrix sweep (`bench-pool-matrix.py` + `bench-coreml-patterns.py`)
7451
- * already chose the best per-backend combo; the operator no longer has
7452
- * a reason to disagree.
7764
+ * Precedence, in full: an operator override below wins over the pool's
7765
+ * runtime registry row; an "Auto" field (slider `0`, select `''`) is not an
7766
+ * override. An override is node-wide by design — the operator set it against
7767
+ * a node, not against a runtime.
7453
7768
  */
7454
- resolveBackendTuning() {
7455
- const t = tuningFor(this.provider?.resolvedEngineBackend() ?? "onnx");
7456
- const num = (v, dflt) => typeof v === "number" && v > 0 ? v : dflt;
7457
- const batch = (v, dflt) => v === "none" || v === "list" || v === "window" ? v : dflt;
7458
- return {
7459
- concurrency: num(t["concurrency"], 1),
7460
- batchMode: batch(t["batchMode"], "window"),
7461
- windowMs: num(t["windowMs"], 2),
7462
- maxBatchSize: num(t["maxBatchSize"], 8),
7463
- numStreams: num(t["numStreams"], 0),
7464
- intraOpThreads: num(t["intraOpThreads"], 0),
7465
- numWorkers: num(t["numWorkers"], 1)
7466
- };
7769
+ resolveOperatorOverride() {
7770
+ return readOperatorOverride(this.config);
7467
7771
  }
7468
7772
  /**
7469
- * Construct a fresh provider with the given effective tuning. Single source of
7470
- * truth for the provider wiring — shared by `onInitialize`, the post-resolve
7471
- * tuning reconciliation, and the `onConfigChanged` pool respawn.
7773
+ * Construct a fresh provider carrying the operator override. Single source of
7774
+ * truth for the provider wiring — shared by `onInitialize` and the
7775
+ * `onConfigChanged` pool respawn. NOTHING backend-derived is passed: each
7776
+ * pool resolves its own runtime's registry row.
7472
7777
  */
7473
- buildProvider(modelsDir, tuning) {
7778
+ buildProvider(modelsDir, override) {
7474
7779
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
7475
7780
  return new DetectionPipelineProvider(this.ctx.settings, modelsDir, this.ctx.logger, this.ctx.eventBus ?? null, () => ({ sections: [] }), {
7476
- concurrency: tuning.concurrency,
7477
- tuning: {
7478
- batchMode: tuning.batchMode,
7479
- windowMs: tuning.windowMs,
7480
- maxBatchSize: tuning.maxBatchSize,
7481
- numStreams: tuning.numStreams,
7482
- intraOpThreads: tuning.intraOpThreads
7483
- },
7484
- numWorkers: tuning.numWorkers,
7781
+ provisioning: override,
7485
7782
  pythonPath: this.pythonPath,
7486
7783
  pythonAddonDir: this.pythonAddonDir
7487
7784
  });
7488
7785
  }
7489
- /**
7490
- * Pool-bound tuning keys of `a` and `b` are equal (used to decide whether a
7491
- * post-resolve / config-change respawn is actually needed).
7492
- */
7493
- static poolTuningEqual(a, b) {
7494
- return a.concurrency === b.concurrency && a.batchMode === b.batchMode && a.windowMs === b.windowMs && a.maxBatchSize === b.maxBatchSize && a.numStreams === b.numStreams && a.intraOpThreads === b.intraOpThreads && a.numWorkers === b.numWorkers;
7495
- }
7496
7786
  async onInitialize() {
7497
7787
  const modelsDir = await this.resolveModelsDir();
7498
7788
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
@@ -7501,23 +7791,12 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7501
7791
  if (py.ok && py.pythonPath) this.pythonPath = py.pythonPath;
7502
7792
  else this.ctx.logger.warn("Detection engine boot continues without Python — selection will provision on demand", {});
7503
7793
  await this.proactivelyInstallOpenvino();
7504
- const initialTuning = this.resolveBackendTuning();
7505
- this.provider = this.buildProvider(modelsDir, initialTuning);
7794
+ this.provider = this.buildProvider(modelsDir, this.resolveOperatorOverride());
7506
7795
  await this.provider.init();
7507
7796
  await this.provider.setApi(this.ctx);
7508
- const resolvedTuning = this.resolveBackendTuning();
7509
- if (!DetectionPipelineAddon.poolTuningEqual(initialTuning, resolvedTuning)) {
7510
- this.ctx.logger.info("detection-pipeline tuning reconciled to resolved engine", { meta: {
7511
- from: initialTuning,
7512
- to: resolvedTuning
7513
- } });
7514
- this.provider = this.buildProvider(modelsDir, resolvedTuning);
7515
- await this.provider.init();
7516
- await this.provider.setApi(this.ctx);
7517
- }
7518
7797
  await this.provider.warmPool();
7519
7798
  this.engineMetricsTimer = setInterval(() => this.emitEngineMetricsSnapshot(), ENGINE_METRICS_SNAPSHOT_INTERVAL_MS);
7520
- this.lastAppliedPoolConfig = this.snapshotPoolConfig();
7799
+ this.lastAppliedOverride = this.resolveOperatorOverride();
7521
7800
  return [{
7522
7801
  capability: require_dist.pipelineExecutorCapability,
7523
7802
  provider: this.provider
@@ -7603,36 +7882,12 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7603
7882
  }
7604
7883
  }
7605
7884
  /**
7606
- * Snapshot the pool-bound subset of the EFFECTIVE tuning (post-
7607
- * `resolveBackendTuning`). Stored config values for these fields are
7608
- * ignored, so this snapshot only changes when `engineBackend` flips
7609
- * onto a different `tuningFor` row.
7610
- */
7611
- snapshotPoolConfig() {
7612
- const t = this.resolveBackendTuning();
7613
- return {
7614
- concurrency: t.concurrency ?? 0,
7615
- batchMode: t.batchMode,
7616
- windowMs: t.windowMs,
7617
- maxBatchSize: t.maxBatchSize,
7618
- numStreams: t.numStreams,
7619
- intraOpThreads: t.intraOpThreads,
7620
- numWorkers: t.numWorkers
7621
- };
7622
- }
7623
- poolConfigChanged() {
7624
- const next = this.snapshotPoolConfig();
7625
- const prev = this.lastAppliedPoolConfig;
7626
- for (const k of POOL_BOUND_KEYS) if (next[k] !== prev[k]) return true;
7627
- return false;
7628
- }
7629
- /**
7630
7885
  * BaseAddon calls `onConfigChanged` after every settings write. This is the
7631
7886
  * SOLE apply path for tuning changes — none of those fields declare
7632
7887
  * `requiresRestart` anymore (an addon restart re-probes hardware + reloads
7633
- * every model on every set, which is exactly what we want to avoid): pool-bound
7634
- * tuning (`concurrency`/`batchMode`/`windowMs`/…) drives an in-place pool
7635
- * respawn when the snapshot flips (`poolConfigChanged` below). The engine
7888
+ * every model on every set, which is exactly what we want to avoid): a changed
7889
+ * OPERATOR OVERRIDE drives an in-place pool respawn. The per-runtime registry
7890
+ * values cannot change at runtime, so they can never trigger one. The engine
7636
7891
  * SELECTION is no longer operator-facing — the node-default backend is derived
7637
7892
  * once at boot (`setApi` → `resolveAutoEngine`) from static hardware, so there
7638
7893
  * is no per-write engine re-select. The respawn applies optimistically — the
@@ -7640,14 +7895,15 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7640
7895
  * crashed), no addon bounce.
7641
7896
  */
7642
7897
  async onConfigChanged() {
7643
- if (!this.poolConfigChanged()) return;
7898
+ const next = this.resolveOperatorOverride();
7899
+ if (overridesEqual(next, this.lastAppliedOverride)) return;
7644
7900
  if (!this.provider) {
7645
- this.lastAppliedPoolConfig = this.snapshotPoolConfig();
7901
+ this.lastAppliedOverride = next;
7646
7902
  return;
7647
7903
  }
7648
- this.ctx.logger.info("detection-pipeline tuning changed — respawning pool", { meta: {
7649
- prev: this.lastAppliedPoolConfig,
7650
- next: this.snapshotPoolConfig()
7904
+ this.ctx.logger.info("detection-pipeline tuning override changed — respawning pool", { meta: {
7905
+ prev: this.lastAppliedOverride,
7906
+ next
7651
7907
  } });
7652
7908
  try {
7653
7909
  await this.provider.shutdown();
@@ -7655,11 +7911,11 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7655
7911
  this.ctx.logger.warn("provider shutdown failed during tuning respawn", { meta: { error: err instanceof Error ? err.message : String(err) } });
7656
7912
  }
7657
7913
  const modelsDir = await this.resolveModelsDir();
7658
- this.provider = this.buildProvider(modelsDir, this.resolveBackendTuning());
7914
+ this.provider = this.buildProvider(modelsDir, next);
7659
7915
  await this.provider.init();
7660
7916
  await this.provider.setApi(this.ctx);
7661
7917
  await this.provider.warmPool();
7662
- this.lastAppliedPoolConfig = this.snapshotPoolConfig();
7918
+ this.lastAppliedOverride = next;
7663
7919
  this.ctx.logger.info("detection-pipeline pool respawn complete");
7664
7920
  }
7665
7921
  };