@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
@@ -1,13 +1,13 @@
1
1
  import { n as __require } from "../chunk-DnnnRqeS.mjs";
2
- import { B as errMsg, E as evaluateZoneRules, F as runtimeDevices$1, G as createEvent, K as hydrateSchema, R as supportedRuntimes$1, T as enumerateInferenceDevices, V as BaseAddon, X as nodePin, Z as parseJsonUnknown, ct as object, dt as union, et as sleep, ft as EventCategory, h as YAMNET_TO_MACRO, j as pipelineExecutorCapability, nt as array, s as DEVICE_BACKEND_TO_FORMAT, t as APPLE_SA_TO_MACRO, ut as string, w as detectionPipelineCapability, x as defaultDeviceFor$1 } from "../dist-CsKaXmP8.mjs";
3
- import { a as getStepDefinition, i as getStep, n as ALL_STEPS, o as resolveModelForFormat, r as getDefaultModelForFormat, t as ALL_PIPELINE_STEPS } from "../step-definitions-1GIwU6R8.mjs";
2
+ import { B as errMsg, E as evaluateZoneRules, F as runtimeDevices$1, G as createEvent, K as hydrateSchema, R as supportedRuntimes$1, T as enumerateInferenceDevices, V as BaseAddon, X as nodePin, Z as parseJsonUnknown, ct as object, dt as union, et as sleep, ft as EventCategory, h as YAMNET_TO_MACRO, j as pipelineExecutorCapability, nt as array, s as DEVICE_BACKEND_TO_FORMAT, t as APPLE_SA_TO_MACRO, ut as string, w as detectionPipelineCapability, x as defaultDeviceFor$1 } from "../dist-Du7_SNiR.mjs";
3
+ import { a as getStepDefinition, i as getStep, n as ALL_STEPS, o as resolveModelForFormat, r as getDefaultModelForFormat, t as ALL_PIPELINE_STEPS } from "../step-definitions-BkY9_IM6.mjs";
4
4
  import { t as pickNodePlatformArch } from "../node-topology-platform-BkR_k6WT.mjs";
5
5
  import { a as ensureModel, o as isModelDownloaded, r as deleteModelFromDisk } from "../model-download-service-Cp9f4dk6-CwUb5v3Y.mjs";
6
+ import * as os from "node:os";
6
7
  import { spawn } from "node:child_process";
7
8
  import sharp from "sharp";
8
9
  import * as fs from "node:fs";
9
10
  import * as path$1 from "node:path";
10
- import * as os from "node:os";
11
11
  //#region src/detection-pipeline/runtimes.ts
12
12
  var KNOWN_PLATFORMS = [
13
13
  "darwin",
@@ -73,14 +73,28 @@ function probedToHardwareInfo(hw) {
73
73
  coral: coralInfoFrom(hw)
74
74
  };
75
75
  }
76
+ /**
77
+ * THE per-runtime pool provisioning registry — the single source for every
78
+ * pool's concurrency / worker count / batch tuning, keyed by the runtime THAT
79
+ * POOL runs. `engine-factory` used to carry a second, parallel copy of these
80
+ * defaults; it no longer does.
81
+ *
82
+ * `windowMs` / `maxBatchSize` are inert unless `batchMode !== 'none'`, and
83
+ * `numStreams` / `intraOpThreads` are inert at 0 (the runtime picks) — they are
84
+ * still spelled out per row so a reader never has to guess which default a
85
+ * missing key would have hit.
86
+ */
76
87
  var RUNTIME_DETAIL = {
77
88
  onnx: {
78
89
  label: "ONNX Runtime",
79
90
  pythonRequirements: ["requirements.txt", "requirements-onnxruntime.txt"],
80
91
  tuning: {
81
92
  concurrency: 4,
93
+ numWorkers: 1,
82
94
  batchMode: "list",
95
+ windowMs: 2,
83
96
  maxBatchSize: 8,
97
+ numStreams: 0,
84
98
  intraOpThreads: 0
85
99
  }
86
100
  },
@@ -89,8 +103,12 @@ var RUNTIME_DETAIL = {
89
103
  pythonRequirements: ["requirements.txt", "requirements-openvino.txt"],
90
104
  tuning: {
91
105
  concurrency: 1,
106
+ numWorkers: 1,
92
107
  batchMode: "none",
93
- numStreams: 0
108
+ windowMs: 2,
109
+ maxBatchSize: 8,
110
+ numStreams: 0,
111
+ intraOpThreads: 0
94
112
  }
95
113
  },
96
114
  coreml: {
@@ -98,10 +116,12 @@ var RUNTIME_DETAIL = {
98
116
  pythonRequirements: ["requirements.txt", "requirements-coreml.txt"],
99
117
  tuning: {
100
118
  concurrency: 1,
119
+ numWorkers: 1,
101
120
  batchMode: "none",
102
121
  windowMs: 8,
103
122
  maxBatchSize: 8,
104
- numWorkers: 1
123
+ numStreams: 0,
124
+ intraOpThreads: 0
105
125
  }
106
126
  },
107
127
  edgetpu: {
@@ -109,8 +129,12 @@ var RUNTIME_DETAIL = {
109
129
  pythonRequirements: ["requirements.txt", "requirements-edgetpu.txt"],
110
130
  tuning: {
111
131
  concurrency: 1,
132
+ numWorkers: 1,
112
133
  batchMode: "none",
113
- numWorkers: 1
134
+ windowMs: 2,
135
+ maxBatchSize: 8,
136
+ numStreams: 0,
137
+ intraOpThreads: 0
114
138
  }
115
139
  }
116
140
  };
@@ -153,6 +177,12 @@ function modelFormatFor(id) {
153
177
  function pythonRequirementsFor(id) {
154
178
  return RUNTIME_DETAIL[id].pythonRequirements;
155
179
  }
180
+ /**
181
+ * The pool provisioning for ONE runtime. Callers must pass the runtime of the
182
+ * pool they are provisioning — never the node's default runtime. See
183
+ * `engine/pool-provisioning.ts` for the resolver that layers an operator
184
+ * override on top of this.
185
+ */
156
186
  function tuningFor(id) {
157
187
  return RUNTIME_DETAIL[id].tuning;
158
188
  }
@@ -371,6 +401,62 @@ var RAW_FMT_CODE = {
371
401
  * large frame) never trips; override via env for constrained hardware.
372
402
  */
373
403
  var POOL_INFER_TIMEOUT_MS = Math.max(1e3, Number(process.env["CAMSTACK_POOL_INFER_TIMEOUT_MS"]) || 6e4);
404
+ /**
405
+ * Max inference requests outstanding to ONE worker before new ones are SHED.
406
+ *
407
+ * Without this the camera dies permanently the first time the worker gets
408
+ * slow, and it did: device 615 produced nothing for 52 minutes on 2026-07-31.
409
+ * The worker was neither dead nor deadlocked — it was replying at full rate,
410
+ * about a minute behind, and every reply landed after {@link
411
+ * POOL_INFER_TIMEOUT_MS} had already abandoned its request. The proof is in the
412
+ * logs: a steady drip of `Response for unknown request id`, request ids
413
+ * climbing ~13/s, for as long as the outage lasted.
414
+ *
415
+ * That state is self-sustaining and cannot drain, because frames keep arriving
416
+ * while every one of them is written straight to the pipe. `dispatch` had NO
417
+ * bound: a transient slowdown (inference went 40ms → 2500ms across several
418
+ * cameras at 16:19 that day) buys a backlog that is never paid off, and once
419
+ * the backlog exceeds the deadline the loss is total.
420
+ *
421
+ * The queue is therefore capped in TIME, not just in count: at the normal ~40ms
422
+ * per inference this is ~0.3s of work, and even at a 20× slowdown it stays well
423
+ * inside the 60s deadline — so a worker that falls behind sheds, catches up,
424
+ * and recovers on its own. Python already sheds under its own per-model bound
425
+ * and answers `{dropped: true}`; this is the same contract applied one layer
426
+ * earlier, where the unbounded queue actually is.
427
+ */
428
+ var POOL_MAX_IN_FLIGHT = Math.max(1, Number(process.env["CAMSTACK_POOL_MAX_IN_FLIGHT"]) || 8);
429
+ /**
430
+ * Reply deadline for a LIVE inference request (ms), as opposed to the generous
431
+ * {@link POOL_INFER_TIMEOUT_MS} that model loads and commands keep.
432
+ *
433
+ * 60 seconds was never a deadline for live detection — it is three orders of
434
+ * magnitude past the point where the answer is worth having. Frames arrive at
435
+ * ~13/s; a detection that lands 60s late describes a scene that no longer
436
+ * exists, and the only thing the wait accomplished was holding a queue slot
437
+ * while the backlog grew. Operator directive 2026-07-31: "a few seconds are
438
+ * already too many for this scenario".
439
+ *
440
+ * Three seconds is deliberately still loose — normal inference on this fleet is
441
+ * 35-47ms, so this trips only at a ~70× slowdown — but it bounds the damage at
442
+ * seconds instead of a minute, and combined with {@link POOL_MAX_IN_FLIGHT} it
443
+ * makes the backlog mathematically unable to outrun the deadline: the queue can
444
+ * never hold more than `cap × deadline` of work.
445
+ *
446
+ * Commands and model loads keep the long timeout on purpose: a cold model load
447
+ * legitimately takes tens of seconds, and shedding or timing one out
448
+ * desynchronizes the worker's model slots.
449
+ */
450
+ var POOL_LIVE_INFER_TIMEOUT_MS = Math.max(250, Number(process.env["CAMSTACK_POOL_LIVE_INFER_TIMEOUT_MS"]) || 3e3);
451
+ /** Opcodes the in-flight cap may shed. Commands and frame-cache writes are
452
+ * NEVER shed: a dropped command silently desynchronizes model state, and a
453
+ * dropped `cacheFrame` strands the `inferCached` that follows it. */
454
+ var SHEDDABLE_MSG_TYPES = new Set([
455
+ MSG_INFER_JPEG,
456
+ MSG_INFER_RAW,
457
+ MSG_INFER_BATCH,
458
+ MSG_INFER_CACHED
459
+ ]);
374
460
  var PoolHandle = class {
375
461
  pool;
376
462
  modelIndex;
@@ -450,6 +536,13 @@ var PoolWorker = class {
450
536
  process = null;
451
537
  receiveBuffer = Buffer.alloc(0);
452
538
  pending = /* @__PURE__ */ new Map();
539
+ /** True while the in-flight cap is shedding — so the warning fires on the
540
+ * EDGE rather than once per dropped frame. */
541
+ shedding = false;
542
+ /** Inference requests shed by the in-flight cap, for this worker's lifetime.
543
+ * Surfaced so "the camera is quiet" and "the camera is saturated" can be
544
+ * told apart without reading the logs. */
545
+ shedCount = 0;
453
546
  nextRequestId = 1;
454
547
  ready = false;
455
548
  log;
@@ -461,6 +554,15 @@ var PoolWorker = class {
461
554
  getPid() {
462
555
  return this.process?.pid ?? null;
463
556
  }
557
+ /** Requests written and not yet answered. This is the BACKLOG — the number
558
+ * nothing measured while a camera sat at zero frames for 52 minutes. */
559
+ getInFlight() {
560
+ return this.pending.size;
561
+ }
562
+ /** Inference shed by the in-flight cap over this worker's lifetime. */
563
+ getShedCount() {
564
+ return this.shedCount;
565
+ }
464
566
  isReady() {
465
567
  return this.ready;
466
568
  }
@@ -609,10 +711,51 @@ var PoolWorker = class {
609
711
  this.ready = false;
610
712
  await terminateChild(proc, POOL_WORKER_TERM_GRACE_MS);
611
713
  }
714
+ /**
715
+ * Shed an inference request when the worker already has {@link
716
+ * POOL_MAX_IN_FLIGHT} outstanding — the backlog is what turns a slow worker
717
+ * into a dead camera. Returns the shed response, or `null` to proceed.
718
+ *
719
+ * A shed is LOUD the first time and then rate-limited: at frame rate this
720
+ * fires ~13×/s, so logging every one would bury the signal it is meant to
721
+ * raise — but logging none is how a camera went dark for 52 minutes looking
722
+ * like a quiet afternoon.
723
+ */
724
+ shedIfSaturated(msgType) {
725
+ if (!SHEDDABLE_MSG_TYPES.has(msgType)) return null;
726
+ if (this.pending.size < POOL_MAX_IN_FLIGHT) {
727
+ this.shedding = false;
728
+ return null;
729
+ }
730
+ if (!this.shedding) {
731
+ this.shedding = true;
732
+ this.log.warn("worker saturated — shedding inference until it catches up", { meta: {
733
+ worker: this.opts.workerLabel,
734
+ pid: this.getPid(),
735
+ runtime: this.opts.poolRuntime,
736
+ device: this.opts.device ?? "default",
737
+ inFlight: this.pending.size,
738
+ cap: POOL_MAX_IN_FLIGHT,
739
+ deadlineMs: POOL_LIVE_INFER_TIMEOUT_MS
740
+ } });
741
+ }
742
+ this.shedCount++;
743
+ return {
744
+ dropped: true,
745
+ shedReason: "worker-in-flight-cap"
746
+ };
747
+ }
748
+ /** Live inference gets seconds; commands and model loads keep the long
749
+ * timeout — see {@link POOL_LIVE_INFER_TIMEOUT_MS}. */
750
+ deadlineFor(msgType) {
751
+ return SHEDDABLE_MSG_TYPES.has(msgType) ? POOL_LIVE_INFER_TIMEOUT_MS : POOL_INFER_TIMEOUT_MS;
752
+ }
612
753
  dispatch(msgType, payload) {
754
+ const shed = this.shedIfSaturated(msgType);
755
+ if (shed) return Promise.resolve(shed);
613
756
  const reqId = this.allocRequestId();
614
757
  return new Promise((resolve, reject) => {
615
- const timer = this.armRequestTimeout(reqId, reject);
758
+ const timer = this.armRequestTimeout(reqId, reject, this.deadlineFor(msgType));
616
759
  this.pending.set(reqId, {
617
760
  resolve,
618
761
  reject,
@@ -632,24 +775,30 @@ var PoolWorker = class {
632
775
  * `POOL_INFER_TIMEOUT_MS`. `unref` so it never keeps the event loop alive.
633
776
  * The normal response + `rejectAll` paths clear it via `PendingRequest.timer`.
634
777
  */
635
- armRequestTimeout(reqId, reject) {
778
+ armRequestTimeout(reqId, reject, timeoutMs) {
636
779
  const timer = setTimeout(() => {
637
780
  if (this.pending.delete(reqId)) {
638
781
  this.log.error("inference request timed out", { meta: {
639
782
  worker: this.opts.workerLabel,
783
+ pid: this.getPid(),
784
+ runtime: this.opts.poolRuntime,
785
+ device: this.opts.device ?? "default",
786
+ inFlight: this.pending.size,
640
787
  reqId,
641
788
  timeoutMs: POOL_INFER_TIMEOUT_MS
642
789
  } });
643
- reject(/* @__PURE__ */ new Error(`PoolWorker[${this.opts.workerLabel}]: inference request ${reqId} timed out after ${POOL_INFER_TIMEOUT_MS}ms (worker alive, no reply)`));
790
+ reject(/* @__PURE__ */ new Error(`PoolWorker[${this.opts.workerLabel}]: inference request ${reqId} timed out after ${timeoutMs}ms (worker alive, no reply)`));
644
791
  }
645
- }, POOL_INFER_TIMEOUT_MS);
792
+ }, timeoutMs);
646
793
  timer.unref?.();
647
794
  return timer;
648
795
  }
649
796
  dispatchMulti(msgType, payloadLen, parts) {
797
+ const shed = this.shedIfSaturated(msgType);
798
+ if (shed) return Promise.resolve(shed);
650
799
  const reqId = this.allocRequestId();
651
800
  return new Promise((resolve, reject) => {
652
- const timer = this.armRequestTimeout(reqId, reject);
801
+ const timer = this.armRequestTimeout(reqId, reject, this.deadlineFor(msgType));
653
802
  this.pending.set(reqId, {
654
803
  resolve,
655
804
  reject,
@@ -755,6 +904,22 @@ var SharedInferencePool = class {
755
904
  }
756
905
  device;
757
906
  /** Pid of the first worker (for legacy callers). Use `getPids()` for all. */
907
+ /** Summed backlog and shed count across the pool's workers. A rising
908
+ * `inFlight` with a rising `shed` is a worker falling behind; a rising
909
+ * `inFlight` with zero `shed` and no results is the state that used to be
910
+ * invisible. */
911
+ getBacklog() {
912
+ let inFlight = 0;
913
+ let shed = 0;
914
+ for (const w of this.workers) {
915
+ inFlight += w.getInFlight();
916
+ shed += w.getShedCount();
917
+ }
918
+ return {
919
+ inFlight,
920
+ shed
921
+ };
922
+ }
758
923
  getPid() {
759
924
  return this.workers[0]?.getPid() ?? null;
760
925
  }
@@ -788,6 +953,7 @@ var SharedInferencePool = class {
788
953
  loadedCount,
789
954
  startupMs,
790
955
  runtime: this.poolRuntime,
956
+ device: this.device ?? "default",
791
957
  workers: this.numWorkers,
792
958
  pythonWorkers: innerWorkers,
793
959
  pids: this.getPids()
@@ -879,7 +1045,10 @@ var SharedInferencePool = class {
879
1045
  async dispose() {
880
1046
  await Promise.all(this.workers.map((w) => w.dispose()));
881
1047
  this.workers.length = 0;
882
- this.log.info("Pool process(es) terminated");
1048
+ this.log.info("Pool process(es) terminated", { meta: {
1049
+ runtime: this.poolRuntime,
1050
+ device: this.device ?? "default"
1051
+ } });
883
1052
  }
884
1053
  /**
885
1054
  * Recognise a Python overload-shed response (`"dropped": true`) on the
@@ -892,7 +1061,9 @@ var SharedInferencePool = class {
892
1061
  this.droppedResponseCount++;
893
1062
  this.log.debug("Python pool shed frame under overload", { meta: {
894
1063
  modelIndex,
895
- droppedTotal: this.droppedResponseCount
1064
+ droppedTotal: this.droppedResponseCount,
1065
+ runtime: this.poolRuntime,
1066
+ device: this.device ?? "default"
896
1067
  } });
897
1068
  }
898
1069
  return result;
@@ -1228,6 +1399,126 @@ var PipelineModelManager = class {
1228
1399
  }
1229
1400
  };
1230
1401
  //#endregion
1402
+ //#region src/detection-pipeline/engine/resolve-device-engine.ts
1403
+ /**
1404
+ * Resolve a Phase-1 device key (`<backend>:<device>`, or bare `cpu`) into the
1405
+ * {@link PipelineEngineChoice} used to build that device's inference pool.
1406
+ * Throws on an unknown backend token so a bad `deviceKey` fails loud rather
1407
+ * than silently building a wrong pool. Format comes from the shared
1408
+ * {@link DEVICE_BACKEND_TO_FORMAT} SSOT (`@camstack/types`) — no local copy.
1409
+ */
1410
+ function resolveDeviceEngine(deviceKey) {
1411
+ const colon = deviceKey.indexOf(":");
1412
+ const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
1413
+ const device = colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey;
1414
+ const format = DEVICE_BACKEND_TO_FORMAT[backend];
1415
+ if (format === void 0) throw new Error(`resolveDeviceEngine: unknown backend "${backend}" in deviceKey "${deviceKey}"`);
1416
+ return {
1417
+ runtime: "python",
1418
+ backend,
1419
+ format,
1420
+ device
1421
+ };
1422
+ }
1423
+ /**
1424
+ * The inverse of {@link resolveDeviceEngine}: the `<backend>:<device>` key that
1425
+ * names the pool an engine belongs to. Every pool-level log line carries this —
1426
+ * without it a default pool logs anonymously and six hours of logs cannot tell
1427
+ * "the NPU is unused" from "the NPU IS the anonymous default pool".
1428
+ * Falls back to the bare backend when the engine carries no device.
1429
+ */
1430
+ function deviceKeyOf(engine) {
1431
+ return engine.device ? `${engine.backend}:${engine.device}` : engine.backend;
1432
+ }
1433
+ //#endregion
1434
+ //#region src/detection-pipeline/engine/pool-provisioning.ts
1435
+ /** The fields a {@link PoolProvisioningOverride} may carry, in table order. */
1436
+ var OVERRIDABLE_KEYS = [
1437
+ "concurrency",
1438
+ "numWorkers",
1439
+ "batchMode",
1440
+ "windowMs",
1441
+ "maxBatchSize",
1442
+ "numStreams",
1443
+ "intraOpThreads"
1444
+ ];
1445
+ /** Backends that are not a runtime of their own and run on onnxruntime. */
1446
+ var BACKEND_TO_RUNTIME = {
1447
+ onnx: "onnx",
1448
+ cpu: "onnx",
1449
+ cuda: "onnx",
1450
+ openvino: "openvino",
1451
+ coreml: "coreml",
1452
+ edgetpu: "edgetpu"
1453
+ };
1454
+ /**
1455
+ * The registry runtime a device-pool backend provisions from. Unknown backends
1456
+ * floor to `onnx` — the same floor `resolveDeviceEngine` applies to the model
1457
+ * format, so a pool never comes up unprovisioned.
1458
+ */
1459
+ function runtimeIdForBackend(backend) {
1460
+ return BACKEND_TO_RUNTIME[backend] ?? "onnx";
1461
+ }
1462
+ /**
1463
+ * Resolve the provisioning for a pool running `engine`, applying the precedence
1464
+ * documented at the top of this module. `engine.device` is not consulted today
1465
+ * (see the module doc) but is part of the signature so a bench-backed
1466
+ * per-device row lands here and nowhere else.
1467
+ */
1468
+ function resolvePoolProvisioning(engine, override = {}) {
1469
+ const runtime = runtimeIdForBackend(engine.backend);
1470
+ const profile = tuningFor(runtime);
1471
+ return {
1472
+ runtime,
1473
+ overriddenFields: OVERRIDABLE_KEYS.filter((k) => override[k] !== void 0),
1474
+ concurrency: Math.max(1, override.concurrency ?? profile.concurrency),
1475
+ numWorkers: Math.max(1, override.numWorkers ?? profile.numWorkers),
1476
+ batchMode: override.batchMode ?? profile.batchMode,
1477
+ windowMs: override.windowMs ?? profile.windowMs,
1478
+ maxBatchSize: override.maxBatchSize ?? profile.maxBatchSize,
1479
+ numStreams: override.numStreams ?? profile.numStreams,
1480
+ intraOpThreads: override.intraOpThreads ?? profile.intraOpThreads
1481
+ };
1482
+ }
1483
+ function positiveNumber(value) {
1484
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
1485
+ }
1486
+ function batchMode(value) {
1487
+ return value === "none" || value === "list" || value === "window" ? value : void 0;
1488
+ }
1489
+ /**
1490
+ * Read the operator override out of the detection addon's settings. Only a
1491
+ * NON-AUTO value counts: the sliders persist `0` and the select persists `''`
1492
+ * for "Auto (backend default)", and those must resolve to the runtime's
1493
+ * registry value, not to a literal 0 worker threads.
1494
+ *
1495
+ * The result is deliberately sparse — an empty object means "nothing
1496
+ * overridden", which is what lets a per-device pool provision from its own
1497
+ * runtime instead of inheriting the node's.
1498
+ */
1499
+ function readOperatorOverride(settings) {
1500
+ const override = {};
1501
+ const concurrency = positiveNumber(settings.concurrency);
1502
+ if (concurrency !== void 0) override.concurrency = concurrency;
1503
+ const numWorkers = positiveNumber(settings.numWorkers);
1504
+ if (numWorkers !== void 0) override.numWorkers = numWorkers;
1505
+ const mode = batchMode(settings.batchMode);
1506
+ if (mode !== void 0) override.batchMode = mode;
1507
+ const windowMs = positiveNumber(settings.windowMs);
1508
+ if (windowMs !== void 0) override.windowMs = windowMs;
1509
+ const maxBatchSize = positiveNumber(settings.maxBatchSize);
1510
+ if (maxBatchSize !== void 0) override.maxBatchSize = maxBatchSize;
1511
+ const numStreams = positiveNumber(settings.numStreams);
1512
+ if (numStreams !== void 0) override.numStreams = numStreams;
1513
+ const intraOpThreads = positiveNumber(settings.intraOpThreads);
1514
+ if (intraOpThreads !== void 0) override.intraOpThreads = intraOpThreads;
1515
+ return override;
1516
+ }
1517
+ /** Two overrides carry the same fields with the same values. */
1518
+ function overridesEqual(a, b) {
1519
+ return OVERRIDABLE_KEYS.every((k) => a[k] === b[k]);
1520
+ }
1521
+ //#endregion
1231
1522
  //#region src/detection-pipeline/engine/engine-factory.ts
1232
1523
  var BACKEND_TO_POOL_RUNTIME = {
1233
1524
  coreml: "coreml",
@@ -1297,9 +1588,31 @@ var EngineFactory = class {
1297
1588
  poolManager = null;
1298
1589
  log;
1299
1590
  opts;
1591
+ /**
1592
+ * `<backend>:<device>` — on EVERY log line this factory writes. A pool that
1593
+ * logs anonymously cannot be told apart from another pool of the same runtime
1594
+ * on a different accelerator.
1595
+ */
1596
+ deviceKey;
1597
+ /**
1598
+ * Resolved at CONSTRUCTION, not at pool spawn, so it is inspectable without
1599
+ * starting Python and so a pool that fails to spawn still logs what it was
1600
+ * trying to be.
1601
+ */
1602
+ resolvedProvisioning;
1300
1603
  constructor(opts) {
1301
1604
  this.opts = opts;
1302
1605
  this.log = opts.logger;
1606
+ this.deviceKey = deviceKeyOf(opts.engine);
1607
+ this.resolvedProvisioning = resolvePoolProvisioning(opts.engine, opts.provisioning);
1608
+ }
1609
+ /**
1610
+ * The provisioning this factory's pool runs with — resolved from the pool's
1611
+ * OWN runtime plus any operator override. Read by the effective-tuning
1612
+ * readout and by the tests that pin the per-runtime behaviour.
1613
+ */
1614
+ get provisioning() {
1615
+ return this.resolvedProvisioning;
1303
1616
  }
1304
1617
  /** Detection always uses the Python pool. */
1305
1618
  get usesPythonPool() {
@@ -1401,40 +1714,51 @@ var EngineFactory = class {
1401
1714
  this.poolManager = null;
1402
1715
  }
1403
1716
  }
1717
+ /**
1718
+ * The one pool-init log line. `deviceKey` / `device` are the reason it
1719
+ * exists: the default pool used to log neither, so an operator reading a
1720
+ * day of logs could not tell whether the NPU was idle or was simply the
1721
+ * anonymous default pool.
1722
+ *
1723
+ * `overriddenFields` replaces the old `source` field. `source` claimed
1724
+ * `'auto-default' | 'user-override'` about the CONCURRENCY TUNING but read as
1725
+ * the provenance of the device/engine choice, and was `'user-override'` on
1726
+ * every pool because the addon always passed explicit tuning. This lists the
1727
+ * fields the operator actually overrode — empty means pure registry.
1728
+ */
1729
+ logProvisioning() {
1730
+ const p = this.resolvedProvisioning;
1731
+ this.log.info("Python inference pool", { meta: {
1732
+ deviceKey: this.deviceKey,
1733
+ backend: this.opts.engine.backend,
1734
+ device: this.opts.engine.device ?? null,
1735
+ runtime: p.runtime,
1736
+ concurrency: p.concurrency,
1737
+ numWorkers: p.numWorkers,
1738
+ overriddenFields: p.overriddenFields,
1739
+ tuning: {
1740
+ batchMode: p.batchMode,
1741
+ windowMs: p.windowMs,
1742
+ maxBatchSize: p.maxBatchSize,
1743
+ numStreams: p.numStreams,
1744
+ intraOpThreads: p.intraOpThreads
1745
+ }
1746
+ } });
1747
+ }
1404
1748
  async initPythonPool(steps) {
1749
+ this.logProvisioning();
1405
1750
  const pythonPath = this.opts.pythonPath;
1406
1751
  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.");
1407
1752
  const poolRuntime = BACKEND_TO_POOL_RUNTIME[this.opts.engine.backend];
1408
1753
  if (!poolRuntime) throw new Error(`No pool runtime mapping for backend "${this.opts.engine.backend}"`);
1409
- const concurrency = this.opts.concurrency ?? {
1410
- coreml: 1,
1411
- openvino: 1,
1412
- onnxruntime: 4,
1413
- edgetpu: 1
1414
- }[poolRuntime];
1415
- const isAutoDefault = this.opts.concurrency === void 0;
1416
- const defaultBatchMode = {
1417
- coreml: "window",
1418
- openvino: "none",
1419
- onnxruntime: "list",
1420
- edgetpu: "none"
1421
- };
1422
- const tuning = this.opts.tuning ?? {};
1754
+ const { concurrency, numWorkers } = this.resolvedProvisioning;
1423
1755
  const resolvedTuning = {
1424
- batchMode: tuning.batchMode ?? defaultBatchMode[poolRuntime],
1425
- windowMs: tuning.windowMs ?? 8,
1426
- maxBatchSize: tuning.maxBatchSize ?? 8,
1427
- numStreams: tuning.numStreams ?? 0,
1428
- intraOpThreads: tuning.intraOpThreads ?? 0
1756
+ batchMode: this.resolvedProvisioning.batchMode,
1757
+ windowMs: this.resolvedProvisioning.windowMs,
1758
+ maxBatchSize: this.resolvedProvisioning.maxBatchSize,
1759
+ numStreams: this.resolvedProvisioning.numStreams,
1760
+ intraOpThreads: this.resolvedProvisioning.intraOpThreads
1429
1761
  };
1430
- const numWorkers = Math.max(1, this.opts.numWorkers ?? 1);
1431
- this.log.info("Python inference pool", { meta: {
1432
- runtime: poolRuntime,
1433
- concurrency,
1434
- numWorkers,
1435
- source: isAutoDefault ? "auto-default" : "user-override",
1436
- tuning: resolvedTuning
1437
- } });
1438
1762
  this.pool = new SharedInferencePool(pythonPath, poolRuntime, this.log.child("pool"), {
1439
1763
  concurrency,
1440
1764
  tuning: resolvedTuning,
@@ -1490,28 +1814,6 @@ var EngineFactory = class {
1490
1814
  }
1491
1815
  };
1492
1816
  //#endregion
1493
- //#region src/detection-pipeline/engine/resolve-device-engine.ts
1494
- /**
1495
- * Resolve a Phase-1 device key (`<backend>:<device>`, or bare `cpu`) into the
1496
- * {@link PipelineEngineChoice} used to build that device's inference pool.
1497
- * Throws on an unknown backend token so a bad `deviceKey` fails loud rather
1498
- * than silently building a wrong pool. Format comes from the shared
1499
- * {@link DEVICE_BACKEND_TO_FORMAT} SSOT (`@camstack/types`) — no local copy.
1500
- */
1501
- function resolveDeviceEngine(deviceKey) {
1502
- const colon = deviceKey.indexOf(":");
1503
- const backend = colon >= 0 ? deviceKey.slice(0, colon) : deviceKey;
1504
- const device = colon >= 0 ? deviceKey.slice(colon + 1) : deviceKey;
1505
- const format = DEVICE_BACKEND_TO_FORMAT[backend];
1506
- if (format === void 0) throw new Error(`resolveDeviceEngine: unknown backend "${backend}" in deviceKey "${deviceKey}"`);
1507
- return {
1508
- runtime: "python",
1509
- backend,
1510
- format,
1511
- device
1512
- };
1513
- }
1514
- //#endregion
1515
1817
  //#region src/detection-pipeline/engine/idle-pool-reaper.ts
1516
1818
  var IdlePoolReaper = class {
1517
1819
  lastUsed = /* @__PURE__ */ new Map();
@@ -4953,8 +5255,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4953
5255
  modelsDir: this.modelsDir,
4954
5256
  logger: this.log.child("engine-verify"),
4955
5257
  pythonPath: this.executorOptions.pythonPath,
4956
- concurrency: 1,
4957
- numWorkers: 1
5258
+ provisioning: {
5259
+ concurrency: 1,
5260
+ numWorkers: 1
5261
+ }
4958
5262
  });
4959
5263
  try {
4960
5264
  await probe.initialize([]);
@@ -5710,9 +6014,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5710
6014
  modelsDir: this.modelsDir,
5711
6015
  logger: this.log.child("engine-override"),
5712
6016
  pythonPath: this.executorOptions.pythonPath ?? "",
5713
- concurrency: this.executorOptions.concurrency,
5714
- tuning: this.executorOptions.tuning,
5715
- numWorkers: this.executorOptions.numWorkers
6017
+ provisioning: this.executorOptions.provisioning
5716
6018
  });
5717
6019
  const initPromise = newFactory.initialize([]);
5718
6020
  this.overrideCache = {
@@ -5892,9 +6194,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5892
6194
  modelsDir: this.modelsDir,
5893
6195
  logger: this.log.child("engine-override"),
5894
6196
  pythonPath: this.executorOptions.pythonPath ?? "",
5895
- concurrency: this.executorOptions.concurrency,
5896
- tuning: this.executorOptions.tuning,
5897
- numWorkers: this.executorOptions.numWorkers
6197
+ provisioning: this.executorOptions.provisioning
5898
6198
  });
5899
6199
  const initPromise = newFactory.initialize([]);
5900
6200
  this.overrideCache = {
@@ -6426,13 +6726,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6426
6726
  if (!this.engineFactory) return;
6427
6727
  await this.engineFactory.uncacheFrame(input.frameId);
6428
6728
  }
6729
+ /**
6730
+ * The tuning of the node's DEFAULT pool. Per-device pools each resolve their
6731
+ * own runtime's row, so this is not "the node's tuning" — the per-pool truth
6732
+ * is on each pool's `Python inference pool` log line and in `listLoadedEngines`.
6733
+ */
6429
6734
  async getEffectiveTuning() {
6430
- const t = this.executorOptions.tuning;
6735
+ const p = resolvePoolProvisioning(this.currentEngine, this.executorOptions.provisioning);
6431
6736
  return {
6432
- batchMode: t?.batchMode ?? "none",
6433
- windowMs: t?.windowMs ?? 0,
6434
- maxBatchSize: t?.maxBatchSize ?? 1,
6435
- concurrency: this.executorOptions.concurrency ?? 1
6737
+ batchMode: p.batchMode,
6738
+ windowMs: p.windowMs,
6739
+ maxBatchSize: p.maxBatchSize,
6740
+ concurrency: p.concurrency
6436
6741
  };
6437
6742
  }
6438
6743
  /**
@@ -6450,9 +6755,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6450
6755
  modelsDir: this.modelsDir,
6451
6756
  logger: this.log.child("engine"),
6452
6757
  pythonPath: this.executorOptions.pythonPath ?? "",
6453
- concurrency: this.executorOptions.concurrency,
6454
- tuning: this.executorOptions.tuning,
6455
- numWorkers: this.executorOptions.numWorkers
6758
+ provisioning: this.executorOptions.provisioning
6456
6759
  });
6457
6760
  await this.engineFactory.initialize([]);
6458
6761
  }
@@ -6491,13 +6794,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6491
6794
  modelsDir: this.modelsDir,
6492
6795
  logger: this.log.child(`engine:${deviceKey}`),
6493
6796
  pythonPath: this.executorOptions.pythonPath ?? "",
6494
- concurrency: this.executorOptions.concurrency,
6495
- tuning: this.executorOptions.tuning,
6496
- numWorkers: this.executorOptions.numWorkers
6797
+ provisioning: this.executorOptions.provisioning
6497
6798
  });
6498
6799
  try {
6499
6800
  await factory.initialize([]);
6500
6801
  } catch (err) {
6802
+ this.log.error("Device pool failed to initialize", { meta: {
6803
+ deviceKey,
6804
+ backend: engine.backend,
6805
+ device: engine.device ?? null,
6806
+ provisioning: factory.provisioning,
6807
+ error: err instanceof Error ? err.message : String(err)
6808
+ } });
6501
6809
  await factory.dispose().catch(() => void 0);
6502
6810
  throw err;
6503
6811
  }
@@ -7108,21 +7416,22 @@ function toNodePlatform(value) {
7108
7416
  return value === "darwin" || value === "linux" || value === "win32" ? value : void 0;
7109
7417
  }
7110
7418
  /**
7111
- * Tuning fields that bind into the Python pool at construction. Any
7112
- * change requires a pool respawn to take effect (the executor caches
7113
- * `tuning` at engine-factory build time). `onConfigChanged` watches
7114
- * these and rebuilds the provider when they flip.
7419
+ * Slider keys on the "Inference executor" section that map 1:1 onto a
7420
+ * {@link RuntimePoolProfile} field. Used to inject the node-default runtime's
7421
+ * registry value as the field's suggested default in `getGlobalSettings`.
7115
7422
  */
7116
- var POOL_BOUND_KEYS = [
7423
+ var PROFILE_SLIDER_KEYS = [
7117
7424
  "concurrency",
7118
- "batchMode",
7425
+ "numWorkers",
7119
7426
  "windowMs",
7120
7427
  "maxBatchSize",
7121
7428
  "numStreams",
7122
- "intraOpThreads",
7123
- "numWorkers"
7429
+ "intraOpThreads"
7124
7430
  ];
7125
- var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
7431
+ function isProfileSliderKey(key) {
7432
+ return PROFILE_SLIDER_KEYS.some((k) => k === key);
7433
+ }
7434
+ var DetectionPipelineAddon = class extends BaseAddon {
7126
7435
  provider = null;
7127
7436
  /** Last non-null probed hardware PER NODE — reused when the probe transiently
7128
7437
  * returns null so offered backends / device lists don't collapse. Keyed by
@@ -7137,14 +7446,17 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
7137
7446
  * ENGINE_METRICS_HEARTBEAT_MS for liveness. */
7138
7447
  lastEmittedEngineSnapshot = null;
7139
7448
  /**
7140
- * Last-applied tuning snapshot, taken at the end of `onInitialize`
7141
- * and refreshed at the end of `onConfigChanged`. Lets the change
7142
- * handler diff the current config against the snapshot and decide
7143
- * whether a pool respawn is necessary — most config edits (engine
7144
- * cascade, audio settings) don't touch the tuning fields and don't
7145
- * deserve a multi-second model reload.
7449
+ * Last-applied OPERATOR OVERRIDE, taken at the end of `onInitialize` and
7450
+ * refreshed at the end of `onConfigChanged`. Lets the change handler diff the
7451
+ * current config against the snapshot and decide whether a pool respawn is
7452
+ * necessary — most config edits (engine cascade, audio settings) don't touch
7453
+ * the tuning fields and don't deserve a multi-second model reload.
7454
+ *
7455
+ * The per-runtime registry values are compile-time constants resolved inside
7456
+ * each pool, so they can never be the reason for a respawn; only an operator
7457
+ * edit can.
7146
7458
  */
7147
- lastAppliedPoolConfig = {};
7459
+ lastAppliedOverride = {};
7148
7460
  /**
7149
7461
  * Embedded Python path resolved once at boot via
7150
7462
  * `ctx.deps.ensurePython()`. Empty string means the download failed
@@ -7312,17 +7624,16 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
7312
7624
  ...section,
7313
7625
  fields: section.fields.map((field) => {
7314
7626
  if (field.type === "select") {
7315
- if (field.key === "batchMode" && tuning["batchMode"] !== void 0) return {
7627
+ if (field.key === "batchMode") return {
7316
7628
  ...field,
7317
- default: tuning["batchMode"]
7629
+ default: tuning.batchMode
7318
7630
  };
7319
7631
  }
7320
7632
  if (field.type === "slider" && "key" in field) {
7321
- const tuned = tuning[field.key];
7322
7633
  const sliderField = field;
7323
- let patchedField = typeof tuned === "number" ? {
7634
+ let patchedField = isProfileSliderKey(sliderField.key) ? {
7324
7635
  ...sliderField,
7325
- default: tuned
7636
+ default: tuning[sliderField.key]
7326
7637
  } : sliderField;
7327
7638
  if (sliderField.key === "concurrency" && backend === "coreml") patchedField = {
7328
7639
  ...patchedField,
@@ -7431,60 +7742,39 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
7431
7742
  return normalizeEngineNodeId(this.ctxIfReady?.kernel?.localNodeId ?? "hub");
7432
7743
  }
7433
7744
  /**
7434
- * Resolve the effective pool tuning for the configured backend.
7745
+ * The operator's explicit deviations from the per-runtime registry — and
7746
+ * NOTHING else.
7435
7747
  *
7436
- * Reads the registry's `tuningFor(backend)` and ignores any persisted
7437
- * override for `concurrency / batchMode / windowMs / maxBatchSize /
7438
- * numStreams / intraOpThreads`. Stored values are quietly discarded so an old
7439
- * suboptimal user-override (saved when the UI exposed these knobs)
7440
- * cannot resurrect itself after a restart.
7748
+ * This used to be `resolveBackendTuning()`: it read `tuningFor(<the node's
7749
+ * default backend>)` and handed the whole resolved row to the provider, which
7750
+ * passed it verbatim to every pool it built. That is how an `openvino:npu`
7751
+ * pool on an onnx-default node came up with onnx's `concurrency: 4`. The
7752
+ * per-runtime values now resolve inside each pool
7753
+ * (`engine/pool-provisioning.ts`), keyed by that pool's own runtime, so
7754
+ * nothing backend-shaped may be computed here.
7441
7755
  *
7442
- * The matrix sweep (`bench-pool-matrix.py` + `bench-coreml-patterns.py`)
7443
- * already chose the best per-backend combo; the operator no longer has
7444
- * a reason to disagree.
7756
+ * Precedence, in full: an operator override below wins over the pool's
7757
+ * runtime registry row; an "Auto" field (slider `0`, select `''`) is not an
7758
+ * override. An override is node-wide by design — the operator set it against
7759
+ * a node, not against a runtime.
7445
7760
  */
7446
- resolveBackendTuning() {
7447
- const t = tuningFor(this.provider?.resolvedEngineBackend() ?? "onnx");
7448
- const num = (v, dflt) => typeof v === "number" && v > 0 ? v : dflt;
7449
- const batch = (v, dflt) => v === "none" || v === "list" || v === "window" ? v : dflt;
7450
- return {
7451
- concurrency: num(t["concurrency"], 1),
7452
- batchMode: batch(t["batchMode"], "window"),
7453
- windowMs: num(t["windowMs"], 2),
7454
- maxBatchSize: num(t["maxBatchSize"], 8),
7455
- numStreams: num(t["numStreams"], 0),
7456
- intraOpThreads: num(t["intraOpThreads"], 0),
7457
- numWorkers: num(t["numWorkers"], 1)
7458
- };
7761
+ resolveOperatorOverride() {
7762
+ return readOperatorOverride(this.config);
7459
7763
  }
7460
7764
  /**
7461
- * Construct a fresh provider with the given effective tuning. Single source of
7462
- * truth for the provider wiring — shared by `onInitialize`, the post-resolve
7463
- * tuning reconciliation, and the `onConfigChanged` pool respawn.
7765
+ * Construct a fresh provider carrying the operator override. Single source of
7766
+ * truth for the provider wiring — shared by `onInitialize` and the
7767
+ * `onConfigChanged` pool respawn. NOTHING backend-derived is passed: each
7768
+ * pool resolves its own runtime's registry row.
7464
7769
  */
7465
- buildProvider(modelsDir, tuning) {
7770
+ buildProvider(modelsDir, override) {
7466
7771
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
7467
7772
  return new DetectionPipelineProvider(this.ctx.settings, modelsDir, this.ctx.logger, this.ctx.eventBus ?? null, () => ({ sections: [] }), {
7468
- concurrency: tuning.concurrency,
7469
- tuning: {
7470
- batchMode: tuning.batchMode,
7471
- windowMs: tuning.windowMs,
7472
- maxBatchSize: tuning.maxBatchSize,
7473
- numStreams: tuning.numStreams,
7474
- intraOpThreads: tuning.intraOpThreads
7475
- },
7476
- numWorkers: tuning.numWorkers,
7773
+ provisioning: override,
7477
7774
  pythonPath: this.pythonPath,
7478
7775
  pythonAddonDir: this.pythonAddonDir
7479
7776
  });
7480
7777
  }
7481
- /**
7482
- * Pool-bound tuning keys of `a` and `b` are equal (used to decide whether a
7483
- * post-resolve / config-change respawn is actually needed).
7484
- */
7485
- static poolTuningEqual(a, b) {
7486
- 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;
7487
- }
7488
7778
  async onInitialize() {
7489
7779
  const modelsDir = await this.resolveModelsDir();
7490
7780
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
@@ -7493,23 +7783,12 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
7493
7783
  if (py.ok && py.pythonPath) this.pythonPath = py.pythonPath;
7494
7784
  else this.ctx.logger.warn("Detection engine boot continues without Python — selection will provision on demand", {});
7495
7785
  await this.proactivelyInstallOpenvino();
7496
- const initialTuning = this.resolveBackendTuning();
7497
- this.provider = this.buildProvider(modelsDir, initialTuning);
7786
+ this.provider = this.buildProvider(modelsDir, this.resolveOperatorOverride());
7498
7787
  await this.provider.init();
7499
7788
  await this.provider.setApi(this.ctx);
7500
- const resolvedTuning = this.resolveBackendTuning();
7501
- if (!DetectionPipelineAddon.poolTuningEqual(initialTuning, resolvedTuning)) {
7502
- this.ctx.logger.info("detection-pipeline tuning reconciled to resolved engine", { meta: {
7503
- from: initialTuning,
7504
- to: resolvedTuning
7505
- } });
7506
- this.provider = this.buildProvider(modelsDir, resolvedTuning);
7507
- await this.provider.init();
7508
- await this.provider.setApi(this.ctx);
7509
- }
7510
7789
  await this.provider.warmPool();
7511
7790
  this.engineMetricsTimer = setInterval(() => this.emitEngineMetricsSnapshot(), ENGINE_METRICS_SNAPSHOT_INTERVAL_MS);
7512
- this.lastAppliedPoolConfig = this.snapshotPoolConfig();
7791
+ this.lastAppliedOverride = this.resolveOperatorOverride();
7513
7792
  return [{
7514
7793
  capability: pipelineExecutorCapability,
7515
7794
  provider: this.provider
@@ -7595,36 +7874,12 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
7595
7874
  }
7596
7875
  }
7597
7876
  /**
7598
- * Snapshot the pool-bound subset of the EFFECTIVE tuning (post-
7599
- * `resolveBackendTuning`). Stored config values for these fields are
7600
- * ignored, so this snapshot only changes when `engineBackend` flips
7601
- * onto a different `tuningFor` row.
7602
- */
7603
- snapshotPoolConfig() {
7604
- const t = this.resolveBackendTuning();
7605
- return {
7606
- concurrency: t.concurrency ?? 0,
7607
- batchMode: t.batchMode,
7608
- windowMs: t.windowMs,
7609
- maxBatchSize: t.maxBatchSize,
7610
- numStreams: t.numStreams,
7611
- intraOpThreads: t.intraOpThreads,
7612
- numWorkers: t.numWorkers
7613
- };
7614
- }
7615
- poolConfigChanged() {
7616
- const next = this.snapshotPoolConfig();
7617
- const prev = this.lastAppliedPoolConfig;
7618
- for (const k of POOL_BOUND_KEYS) if (next[k] !== prev[k]) return true;
7619
- return false;
7620
- }
7621
- /**
7622
7877
  * BaseAddon calls `onConfigChanged` after every settings write. This is the
7623
7878
  * SOLE apply path for tuning changes — none of those fields declare
7624
7879
  * `requiresRestart` anymore (an addon restart re-probes hardware + reloads
7625
- * every model on every set, which is exactly what we want to avoid): pool-bound
7626
- * tuning (`concurrency`/`batchMode`/`windowMs`/…) drives an in-place pool
7627
- * respawn when the snapshot flips (`poolConfigChanged` below). The engine
7880
+ * every model on every set, which is exactly what we want to avoid): a changed
7881
+ * OPERATOR OVERRIDE drives an in-place pool respawn. The per-runtime registry
7882
+ * values cannot change at runtime, so they can never trigger one. The engine
7628
7883
  * SELECTION is no longer operator-facing — the node-default backend is derived
7629
7884
  * once at boot (`setApi` → `resolveAutoEngine`) from static hardware, so there
7630
7885
  * is no per-write engine re-select. The respawn applies optimistically — the
@@ -7632,14 +7887,15 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
7632
7887
  * crashed), no addon bounce.
7633
7888
  */
7634
7889
  async onConfigChanged() {
7635
- if (!this.poolConfigChanged()) return;
7890
+ const next = this.resolveOperatorOverride();
7891
+ if (overridesEqual(next, this.lastAppliedOverride)) return;
7636
7892
  if (!this.provider) {
7637
- this.lastAppliedPoolConfig = this.snapshotPoolConfig();
7893
+ this.lastAppliedOverride = next;
7638
7894
  return;
7639
7895
  }
7640
- this.ctx.logger.info("detection-pipeline tuning changed — respawning pool", { meta: {
7641
- prev: this.lastAppliedPoolConfig,
7642
- next: this.snapshotPoolConfig()
7896
+ this.ctx.logger.info("detection-pipeline tuning override changed — respawning pool", { meta: {
7897
+ prev: this.lastAppliedOverride,
7898
+ next
7643
7899
  } });
7644
7900
  try {
7645
7901
  await this.provider.shutdown();
@@ -7647,11 +7903,11 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
7647
7903
  this.ctx.logger.warn("provider shutdown failed during tuning respawn", { meta: { error: err instanceof Error ? err.message : String(err) } });
7648
7904
  }
7649
7905
  const modelsDir = await this.resolveModelsDir();
7650
- this.provider = this.buildProvider(modelsDir, this.resolveBackendTuning());
7906
+ this.provider = this.buildProvider(modelsDir, next);
7651
7907
  await this.provider.init();
7652
7908
  await this.provider.setApi(this.ctx);
7653
7909
  await this.provider.warmPool();
7654
- this.lastAppliedPoolConfig = this.snapshotPoolConfig();
7910
+ this.lastAppliedOverride = next;
7655
7911
  this.ctx.logger.info("detection-pipeline pool respawn complete");
7656
7912
  }
7657
7913
  };