@camstack/addon-pipeline 1.2.28 → 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 +518 -203
  4. package/dist/detection-pipeline/index.mjs +517 -202
  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 +149 -8
  10. package/dist/pipeline-runner/index.mjs +148 -8
  11. package/dist/recorder/index.js +1 -1
  12. package/dist/recorder/index.mjs +1 -1
  13. package/dist/{step-definitions-C8A78dBi.mjs → step-definitions-BkY9_IM6.mjs} +13 -2
  14. package/dist/{step-definitions-Bo1hJQQw.js → step-definitions-gC-Ovglc.js} +13 -2
  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-Bo1hJQQw.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();
@@ -2704,19 +3006,25 @@ async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth
2704
3006
  * DROPS detections outright, so it sits between that and a hard cut).
2705
3007
  */
2706
3008
  var FULL_FRAME_MAX_CONFIDENCE = .7;
3009
+ /** A cut only makes sense in (0, 1); anything else is treated as OFF rather
3010
+ * than dropping every detection on a typo. */
3011
+ function isUsableHardAreaCut(ratio) {
3012
+ return Number.isFinite(ratio) && ratio > 0 && ratio < 1;
3013
+ }
2707
3014
  /**
2708
3015
  * True when `bbox` is a near-full-frame LOW-confidence phantom that should be
2709
3016
  * DROPPED from the frame's detections (overlay / tracker / events / motion-wake).
2710
3017
  *
2711
3018
  * Rule (drop ⇔ returns true):
2712
- * (areaRatio ≥ {@link FULL_FRAME_AREA_RATIO}
2713
- * OR width ≥ {@link FULL_FRAME_DIMENSION_RATIO} × frameWidth
2714
- * OR height ≥ {@link FULL_FRAME_DIMENSION_RATIO} × frameHeight)
2715
- * AND score < `maxConfidence`
3019
+ * areaRatio ≥ `hardAreaRatio` ← score-independent, OFF by default
3020
+ * OR ( (areaRatio ≥ {@link FULL_FRAME_AREA_RATIO}
3021
+ * OR width ≥ {@link FULL_FRAME_DIMENSION_RATIO} × frameWidth
3022
+ * OR height {@link FULL_FRAME_DIMENSION_RATIO} × frameHeight)
3023
+ * AND score < `maxConfidence` )
2716
3024
  *
2717
- * A high-confidence detection (`score ≥ maxConfidence`) is trusted as a real
2718
- * subject and always survives. Degenerate frame dimensions (≤0) carry no info
2719
- * to reject on → not a phantom.
3025
+ * Below the hard cut a high-confidence detection (`score ≥ maxConfidence`) is
3026
+ * trusted as a real subject and survives. Degenerate frame dimensions (≤0)
3027
+ * carry no info to reject on → not a phantom.
2720
3028
  *
2721
3029
  * @param bbox absolute-pixel `[x1, y1, x2, y2]` box in source-frame coords.
2722
3030
  * @param frameWidth source frame width in px.
@@ -2725,10 +3033,26 @@ var FULL_FRAME_MAX_CONFIDENCE = .7;
2725
3033
  * @param maxConfidence confidence bar below which a full-frame box is a phantom
2726
3034
  * (defaults to {@link FULL_FRAME_MAX_CONFIDENCE}; overridable by the caller so
2727
3035
  * the bar can be threaded from detection settings without new cap methods).
3036
+ * @param hardAreaRatio area fraction at/above which the box is dropped NO
3037
+ * MATTER the score. Defaults to {@link HARD_AREA_CUT_DISABLED} (off) — it is
3038
+ * armed per camera, because a doorbell close-up legitimately fills the frame
3039
+ * while an overhead courtyard box never does. Threaded from step settings as
3040
+ * `fullFrameGuardHardAreaRatio`.
3041
+ */
3042
+ /**
3043
+ * True when a box trips the guard's GEOMETRY test (near-full-frame, or
3044
+ * edge-to-edge in one axis) but SURVIVES because its score cleared the
3045
+ * confidence bar.
3046
+ *
3047
+ * These are the ones worth counting: on 2026-07-30 device 615 produced six
3048
+ * such boxes in a day, at 0.710-0.787, and they were only found by an offline
3049
+ * sweep of stored events — nothing in production reported them. Arming the
3050
+ * hard-area cut on a camera should be a decision made from this count, not
3051
+ * from one incident.
2728
3052
  */
2729
- function isFullFramePhantomDetection(bbox, frameWidth, frameHeight, score, maxConfidence = FULL_FRAME_MAX_CONFIDENCE) {
3053
+ function isFullFrameSurvivor(bbox, frameWidth, frameHeight, score, maxConfidence = FULL_FRAME_MAX_CONFIDENCE) {
2730
3054
  if (frameWidth <= 0 || frameHeight <= 0) return false;
2731
- if (score >= maxConfidence) return false;
3055
+ if (score < maxConfidence) return false;
2732
3056
  const [x1, y1, x2, y2] = bbox;
2733
3057
  const w = x2 - x1;
2734
3058
  const h = y2 - y1;
@@ -2737,6 +3061,19 @@ function isFullFramePhantomDetection(bbox, frameWidth, frameHeight, score, maxCo
2737
3061
  if (h >= frameHeight * .98) return true;
2738
3062
  return false;
2739
3063
  }
3064
+ function isFullFramePhantomDetection(bbox, frameWidth, frameHeight, score, maxConfidence = FULL_FRAME_MAX_CONFIDENCE, hardAreaRatio = 1) {
3065
+ if (frameWidth <= 0 || frameHeight <= 0) return false;
3066
+ const [x1, y1, x2, y2] = bbox;
3067
+ const w = x2 - x1;
3068
+ const h = y2 - y1;
3069
+ const areaRatio = w * h / (frameWidth * frameHeight);
3070
+ if (isUsableHardAreaCut(hardAreaRatio) && areaRatio >= hardAreaRatio) return true;
3071
+ if (score >= maxConfidence) return false;
3072
+ if (areaRatio >= .9) return true;
3073
+ if (w >= frameWidth * .98) return true;
3074
+ if (h >= frameHeight * .98) return true;
3075
+ return false;
3076
+ }
2740
3077
  //#endregion
2741
3078
  //#region src/detection-pipeline/pipeline/plate-deskew.ts
2742
3079
  /**
@@ -3584,10 +3921,34 @@ var PipelineExecutor = class {
3584
3921
  continue;
3585
3922
  }
3586
3923
  const fullFrameBar = rootStep.settings?.["fullFrameGuardMaxConfidence"];
3587
- if (isFullFramePhantomDetection(mutable.bbox, imageWidth, imageHeight, mutable.score, typeof fullFrameBar === "number" ? fullFrameBar : void 0)) {
3588
- if (debug) console.log(`[executor] drop det macro=${mutable.macroClass} score=${mutable.score} bbox=${JSON.stringify(mutable.bbox)} (full-frame phantom guard)`);
3924
+ const fullFrameHardArea = rootStep.settings?.["fullFrameGuardHardAreaRatio"];
3925
+ if (isFullFramePhantomDetection(mutable.bbox, imageWidth, imageHeight, mutable.score, typeof fullFrameBar === "number" ? fullFrameBar : void 0, typeof fullFrameHardArea === "number" ? fullFrameHardArea : void 0)) {
3926
+ this.opts.logger?.info("full-frame phantom guard: detection dropped", {
3927
+ tags: { deviceId },
3928
+ meta: {
3929
+ macroClass: mutable.macroClass,
3930
+ score: mutable.score,
3931
+ bbox: mutable.bbox,
3932
+ frameWidth: imageWidth,
3933
+ frameHeight: imageHeight,
3934
+ areaRatio: imageWidth > 0 && imageHeight > 0 ? Math.round((mutable.bbox[2] - mutable.bbox[0]) * (mutable.bbox[3] - mutable.bbox[1]) / (imageWidth * imageHeight) * 1e3) / 1e3 : void 0,
3935
+ bar: typeof fullFrameBar === "number" ? fullFrameBar : void 0,
3936
+ hardAreaRatio: typeof fullFrameHardArea === "number" ? fullFrameHardArea : void 0
3937
+ }
3938
+ });
3589
3939
  continue;
3590
3940
  }
3941
+ if (isFullFrameSurvivor(mutable.bbox, imageWidth, imageHeight, mutable.score, typeof fullFrameBar === "number" ? fullFrameBar : void 0)) this.opts.logger?.warn("full-frame box SURVIVED the guard on score", {
3942
+ tags: { deviceId },
3943
+ meta: {
3944
+ macroClass: mutable.macroClass,
3945
+ score: mutable.score,
3946
+ bbox: mutable.bbox,
3947
+ areaRatio: imageWidth > 0 && imageHeight > 0 ? Math.round((mutable.bbox[2] - mutable.bbox[0]) * (mutable.bbox[3] - mutable.bbox[1]) / (imageWidth * imageHeight) * 1e3) / 1e3 : void 0,
3948
+ bar: typeof fullFrameBar === "number" ? fullFrameBar : void 0,
3949
+ hint: "arm fullFrameGuardHardAreaRatio on this camera if these are phantoms"
3950
+ }
3951
+ });
3591
3952
  try {
3592
3953
  await this.executeChildren(rootStep.children, mutable, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, runOpts?.plane, deviceId, nativeCropProvider);
3593
3954
  } catch (err) {
@@ -4902,8 +5263,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4902
5263
  modelsDir: this.modelsDir,
4903
5264
  logger: this.log.child("engine-verify"),
4904
5265
  pythonPath: this.executorOptions.pythonPath,
4905
- concurrency: 1,
4906
- numWorkers: 1
5266
+ provisioning: {
5267
+ concurrency: 1,
5268
+ numWorkers: 1
5269
+ }
4907
5270
  });
4908
5271
  try {
4909
5272
  await probe.initialize([]);
@@ -5659,9 +6022,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5659
6022
  modelsDir: this.modelsDir,
5660
6023
  logger: this.log.child("engine-override"),
5661
6024
  pythonPath: this.executorOptions.pythonPath ?? "",
5662
- concurrency: this.executorOptions.concurrency,
5663
- tuning: this.executorOptions.tuning,
5664
- numWorkers: this.executorOptions.numWorkers
6025
+ provisioning: this.executorOptions.provisioning
5665
6026
  });
5666
6027
  const initPromise = newFactory.initialize([]);
5667
6028
  this.overrideCache = {
@@ -5841,9 +6202,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5841
6202
  modelsDir: this.modelsDir,
5842
6203
  logger: this.log.child("engine-override"),
5843
6204
  pythonPath: this.executorOptions.pythonPath ?? "",
5844
- concurrency: this.executorOptions.concurrency,
5845
- tuning: this.executorOptions.tuning,
5846
- numWorkers: this.executorOptions.numWorkers
6205
+ provisioning: this.executorOptions.provisioning
5847
6206
  });
5848
6207
  const initPromise = newFactory.initialize([]);
5849
6208
  this.overrideCache = {
@@ -6375,13 +6734,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6375
6734
  if (!this.engineFactory) return;
6376
6735
  await this.engineFactory.uncacheFrame(input.frameId);
6377
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
+ */
6378
6742
  async getEffectiveTuning() {
6379
- const t = this.executorOptions.tuning;
6743
+ const p = resolvePoolProvisioning(this.currentEngine, this.executorOptions.provisioning);
6380
6744
  return {
6381
- batchMode: t?.batchMode ?? "none",
6382
- windowMs: t?.windowMs ?? 0,
6383
- maxBatchSize: t?.maxBatchSize ?? 1,
6384
- concurrency: this.executorOptions.concurrency ?? 1
6745
+ batchMode: p.batchMode,
6746
+ windowMs: p.windowMs,
6747
+ maxBatchSize: p.maxBatchSize,
6748
+ concurrency: p.concurrency
6385
6749
  };
6386
6750
  }
6387
6751
  /**
@@ -6399,9 +6763,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6399
6763
  modelsDir: this.modelsDir,
6400
6764
  logger: this.log.child("engine"),
6401
6765
  pythonPath: this.executorOptions.pythonPath ?? "",
6402
- concurrency: this.executorOptions.concurrency,
6403
- tuning: this.executorOptions.tuning,
6404
- numWorkers: this.executorOptions.numWorkers
6766
+ provisioning: this.executorOptions.provisioning
6405
6767
  });
6406
6768
  await this.engineFactory.initialize([]);
6407
6769
  }
@@ -6440,13 +6802,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6440
6802
  modelsDir: this.modelsDir,
6441
6803
  logger: this.log.child(`engine:${deviceKey}`),
6442
6804
  pythonPath: this.executorOptions.pythonPath ?? "",
6443
- concurrency: this.executorOptions.concurrency,
6444
- tuning: this.executorOptions.tuning,
6445
- numWorkers: this.executorOptions.numWorkers
6805
+ provisioning: this.executorOptions.provisioning
6446
6806
  });
6447
6807
  try {
6448
6808
  await factory.initialize([]);
6449
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
+ } });
6450
6817
  await factory.dispose().catch(() => void 0);
6451
6818
  throw err;
6452
6819
  }
@@ -7057,21 +7424,22 @@ function toNodePlatform(value) {
7057
7424
  return value === "darwin" || value === "linux" || value === "win32" ? value : void 0;
7058
7425
  }
7059
7426
  /**
7060
- * Tuning fields that bind into the Python pool at construction. Any
7061
- * change requires a pool respawn to take effect (the executor caches
7062
- * `tuning` at engine-factory build time). `onConfigChanged` watches
7063
- * 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`.
7064
7430
  */
7065
- var POOL_BOUND_KEYS = [
7431
+ var PROFILE_SLIDER_KEYS = [
7066
7432
  "concurrency",
7067
- "batchMode",
7433
+ "numWorkers",
7068
7434
  "windowMs",
7069
7435
  "maxBatchSize",
7070
7436
  "numStreams",
7071
- "intraOpThreads",
7072
- "numWorkers"
7437
+ "intraOpThreads"
7073
7438
  ];
7074
- 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 {
7075
7443
  provider = null;
7076
7444
  /** Last non-null probed hardware PER NODE — reused when the probe transiently
7077
7445
  * returns null so offered backends / device lists don't collapse. Keyed by
@@ -7086,14 +7454,17 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7086
7454
  * ENGINE_METRICS_HEARTBEAT_MS for liveness. */
7087
7455
  lastEmittedEngineSnapshot = null;
7088
7456
  /**
7089
- * Last-applied tuning snapshot, taken at the end of `onInitialize`
7090
- * and refreshed at the end of `onConfigChanged`. Lets the change
7091
- * handler diff the current config against the snapshot and decide
7092
- * whether a pool respawn is necessary — most config edits (engine
7093
- * cascade, audio settings) don't touch the tuning fields and don't
7094
- * 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.
7095
7466
  */
7096
- lastAppliedPoolConfig = {};
7467
+ lastAppliedOverride = {};
7097
7468
  /**
7098
7469
  * Embedded Python path resolved once at boot via
7099
7470
  * `ctx.deps.ensurePython()`. Empty string means the download failed
@@ -7261,17 +7632,16 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7261
7632
  ...section,
7262
7633
  fields: section.fields.map((field) => {
7263
7634
  if (field.type === "select") {
7264
- if (field.key === "batchMode" && tuning["batchMode"] !== void 0) return {
7635
+ if (field.key === "batchMode") return {
7265
7636
  ...field,
7266
- default: tuning["batchMode"]
7637
+ default: tuning.batchMode
7267
7638
  };
7268
7639
  }
7269
7640
  if (field.type === "slider" && "key" in field) {
7270
- const tuned = tuning[field.key];
7271
7641
  const sliderField = field;
7272
- let patchedField = typeof tuned === "number" ? {
7642
+ let patchedField = isProfileSliderKey(sliderField.key) ? {
7273
7643
  ...sliderField,
7274
- default: tuned
7644
+ default: tuning[sliderField.key]
7275
7645
  } : sliderField;
7276
7646
  if (sliderField.key === "concurrency" && backend === "coreml") patchedField = {
7277
7647
  ...patchedField,
@@ -7380,60 +7750,39 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7380
7750
  return normalizeEngineNodeId(this.ctxIfReady?.kernel?.localNodeId ?? "hub");
7381
7751
  }
7382
7752
  /**
7383
- * Resolve the effective pool tuning for the configured backend.
7753
+ * The operator's explicit deviations from the per-runtime registry — and
7754
+ * NOTHING else.
7384
7755
  *
7385
- * Reads the registry's `tuningFor(backend)` and ignores any persisted
7386
- * override for `concurrency / batchMode / windowMs / maxBatchSize /
7387
- * numStreams / intraOpThreads`. Stored values are quietly discarded so an old
7388
- * suboptimal user-override (saved when the UI exposed these knobs)
7389
- * 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.
7390
7763
  *
7391
- * The matrix sweep (`bench-pool-matrix.py` + `bench-coreml-patterns.py`)
7392
- * already chose the best per-backend combo; the operator no longer has
7393
- * 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.
7394
7768
  */
7395
- resolveBackendTuning() {
7396
- const t = tuningFor(this.provider?.resolvedEngineBackend() ?? "onnx");
7397
- const num = (v, dflt) => typeof v === "number" && v > 0 ? v : dflt;
7398
- const batch = (v, dflt) => v === "none" || v === "list" || v === "window" ? v : dflt;
7399
- return {
7400
- concurrency: num(t["concurrency"], 1),
7401
- batchMode: batch(t["batchMode"], "window"),
7402
- windowMs: num(t["windowMs"], 2),
7403
- maxBatchSize: num(t["maxBatchSize"], 8),
7404
- numStreams: num(t["numStreams"], 0),
7405
- intraOpThreads: num(t["intraOpThreads"], 0),
7406
- numWorkers: num(t["numWorkers"], 1)
7407
- };
7769
+ resolveOperatorOverride() {
7770
+ return readOperatorOverride(this.config);
7408
7771
  }
7409
7772
  /**
7410
- * Construct a fresh provider with the given effective tuning. Single source of
7411
- * truth for the provider wiring — shared by `onInitialize`, the post-resolve
7412
- * 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.
7413
7777
  */
7414
- buildProvider(modelsDir, tuning) {
7778
+ buildProvider(modelsDir, override) {
7415
7779
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
7416
7780
  return new DetectionPipelineProvider(this.ctx.settings, modelsDir, this.ctx.logger, this.ctx.eventBus ?? null, () => ({ sections: [] }), {
7417
- concurrency: tuning.concurrency,
7418
- tuning: {
7419
- batchMode: tuning.batchMode,
7420
- windowMs: tuning.windowMs,
7421
- maxBatchSize: tuning.maxBatchSize,
7422
- numStreams: tuning.numStreams,
7423
- intraOpThreads: tuning.intraOpThreads
7424
- },
7425
- numWorkers: tuning.numWorkers,
7781
+ provisioning: override,
7426
7782
  pythonPath: this.pythonPath,
7427
7783
  pythonAddonDir: this.pythonAddonDir
7428
7784
  });
7429
7785
  }
7430
- /**
7431
- * Pool-bound tuning keys of `a` and `b` are equal (used to decide whether a
7432
- * post-resolve / config-change respawn is actually needed).
7433
- */
7434
- static poolTuningEqual(a, b) {
7435
- 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;
7436
- }
7437
7786
  async onInitialize() {
7438
7787
  const modelsDir = await this.resolveModelsDir();
7439
7788
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
@@ -7442,23 +7791,12 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7442
7791
  if (py.ok && py.pythonPath) this.pythonPath = py.pythonPath;
7443
7792
  else this.ctx.logger.warn("Detection engine boot continues without Python — selection will provision on demand", {});
7444
7793
  await this.proactivelyInstallOpenvino();
7445
- const initialTuning = this.resolveBackendTuning();
7446
- this.provider = this.buildProvider(modelsDir, initialTuning);
7794
+ this.provider = this.buildProvider(modelsDir, this.resolveOperatorOverride());
7447
7795
  await this.provider.init();
7448
7796
  await this.provider.setApi(this.ctx);
7449
- const resolvedTuning = this.resolveBackendTuning();
7450
- if (!DetectionPipelineAddon.poolTuningEqual(initialTuning, resolvedTuning)) {
7451
- this.ctx.logger.info("detection-pipeline tuning reconciled to resolved engine", { meta: {
7452
- from: initialTuning,
7453
- to: resolvedTuning
7454
- } });
7455
- this.provider = this.buildProvider(modelsDir, resolvedTuning);
7456
- await this.provider.init();
7457
- await this.provider.setApi(this.ctx);
7458
- }
7459
7797
  await this.provider.warmPool();
7460
7798
  this.engineMetricsTimer = setInterval(() => this.emitEngineMetricsSnapshot(), ENGINE_METRICS_SNAPSHOT_INTERVAL_MS);
7461
- this.lastAppliedPoolConfig = this.snapshotPoolConfig();
7799
+ this.lastAppliedOverride = this.resolveOperatorOverride();
7462
7800
  return [{
7463
7801
  capability: require_dist.pipelineExecutorCapability,
7464
7802
  provider: this.provider
@@ -7544,36 +7882,12 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7544
7882
  }
7545
7883
  }
7546
7884
  /**
7547
- * Snapshot the pool-bound subset of the EFFECTIVE tuning (post-
7548
- * `resolveBackendTuning`). Stored config values for these fields are
7549
- * ignored, so this snapshot only changes when `engineBackend` flips
7550
- * onto a different `tuningFor` row.
7551
- */
7552
- snapshotPoolConfig() {
7553
- const t = this.resolveBackendTuning();
7554
- return {
7555
- concurrency: t.concurrency ?? 0,
7556
- batchMode: t.batchMode,
7557
- windowMs: t.windowMs,
7558
- maxBatchSize: t.maxBatchSize,
7559
- numStreams: t.numStreams,
7560
- intraOpThreads: t.intraOpThreads,
7561
- numWorkers: t.numWorkers
7562
- };
7563
- }
7564
- poolConfigChanged() {
7565
- const next = this.snapshotPoolConfig();
7566
- const prev = this.lastAppliedPoolConfig;
7567
- for (const k of POOL_BOUND_KEYS) if (next[k] !== prev[k]) return true;
7568
- return false;
7569
- }
7570
- /**
7571
7885
  * BaseAddon calls `onConfigChanged` after every settings write. This is the
7572
7886
  * SOLE apply path for tuning changes — none of those fields declare
7573
7887
  * `requiresRestart` anymore (an addon restart re-probes hardware + reloads
7574
- * every model on every set, which is exactly what we want to avoid): pool-bound
7575
- * tuning (`concurrency`/`batchMode`/`windowMs`/…) drives an in-place pool
7576
- * 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
7577
7891
  * SELECTION is no longer operator-facing — the node-default backend is derived
7578
7892
  * once at boot (`setApi` → `resolveAutoEngine`) from static hardware, so there
7579
7893
  * is no per-write engine re-select. The respawn applies optimistically — the
@@ -7581,14 +7895,15 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7581
7895
  * crashed), no addon bounce.
7582
7896
  */
7583
7897
  async onConfigChanged() {
7584
- if (!this.poolConfigChanged()) return;
7898
+ const next = this.resolveOperatorOverride();
7899
+ if (overridesEqual(next, this.lastAppliedOverride)) return;
7585
7900
  if (!this.provider) {
7586
- this.lastAppliedPoolConfig = this.snapshotPoolConfig();
7901
+ this.lastAppliedOverride = next;
7587
7902
  return;
7588
7903
  }
7589
- this.ctx.logger.info("detection-pipeline tuning changed — respawning pool", { meta: {
7590
- prev: this.lastAppliedPoolConfig,
7591
- next: this.snapshotPoolConfig()
7904
+ this.ctx.logger.info("detection-pipeline tuning override changed — respawning pool", { meta: {
7905
+ prev: this.lastAppliedOverride,
7906
+ next
7592
7907
  } });
7593
7908
  try {
7594
7909
  await this.provider.shutdown();
@@ -7596,11 +7911,11 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7596
7911
  this.ctx.logger.warn("provider shutdown failed during tuning respawn", { meta: { error: err instanceof Error ? err.message : String(err) } });
7597
7912
  }
7598
7913
  const modelsDir = await this.resolveModelsDir();
7599
- this.provider = this.buildProvider(modelsDir, this.resolveBackendTuning());
7914
+ this.provider = this.buildProvider(modelsDir, next);
7600
7915
  await this.provider.init();
7601
7916
  await this.provider.setApi(this.ctx);
7602
7917
  await this.provider.warmPool();
7603
- this.lastAppliedPoolConfig = this.snapshotPoolConfig();
7918
+ this.lastAppliedOverride = next;
7604
7919
  this.ctx.logger.info("detection-pipeline pool respawn complete");
7605
7920
  }
7606
7921
  };