@trillboards/edge-sdk 0.2.40 → 0.2.42
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.
- package/README.md +1 -1
- package/deploy/docker/{Dockerfile.cloud-sense → Dockerfile.connector} +52 -39
- package/deploy/docker/README.md +73 -1
- package/deploy/docker/connector-entrypoint.sh +46 -80
- package/deploy/macos/README.md +1 -1
- package/deploy/macos/test-install-pair.sh +9 -9
- package/deploy/systemd/install.sh +16 -1
- package/deploy/systemd/setup-litertlm.sh +201 -0
- package/deploy/systemd/trillboards-edge.service +8 -4
- package/deploy/systemd/trillboards-litertlm.service +46 -0
- package/deploy/windows/register-task.ps1 +6 -3
- package/deploy/windows/run-connector.ps1 +1 -1
- package/deploy/windows/trillboards-edge-task.vbs +5 -1
- package/dist/CloudSensingManager.d.ts +45 -19
- package/dist/CloudSensingManager.d.ts.map +1 -1
- package/dist/CloudSensingManager.js +89 -46
- package/dist/CloudSensingManager.js.map +1 -1
- package/dist/EdgeAgent.js +1 -1
- package/dist/LiteRtVlmProvider.d.ts +108 -2
- package/dist/LiteRtVlmProvider.d.ts.map +1 -1
- package/dist/LiteRtVlmProvider.js +79 -13
- package/dist/LiteRtVlmProvider.js.map +1 -1
- package/dist/SensingProfileClient.d.ts +9 -0
- package/dist/SensingProfileClient.d.ts.map +1 -1
- package/dist/SensingProfileClient.js +4 -0
- package/dist/SensingProfileClient.js.map +1 -1
- package/dist/SetupSessionClient.d.ts +0 -1
- package/dist/SetupSessionClient.d.ts.map +1 -1
- package/dist/autoSense.d.ts +42 -31
- package/dist/autoSense.d.ts.map +1 -1
- package/dist/autoSense.js +127 -99
- package/dist/autoSense.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +25 -84
- package/dist/cli.js.map +1 -1
- package/dist/combinedSense.d.ts +1 -3
- package/dist/combinedSense.d.ts.map +1 -1
- package/dist/combinedSense.js +0 -1
- package/dist/combinedSense.js.map +1 -1
- package/dist/config.d.ts +17 -64
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -10
- package/dist/config.js.map +1 -1
- package/dist/deployedCadence.d.ts +37 -75
- package/dist/deployedCadence.d.ts.map +1 -1
- package/dist/deployedCadence.js +45 -105
- package/dist/deployedCadence.js.map +1 -1
- package/dist/deployedCadenceSource.d.ts +0 -2
- package/dist/deployedCadenceSource.d.ts.map +1 -1
- package/dist/deployedCadenceSource.js +0 -1
- package/dist/deployedCadenceSource.js.map +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -8
- package/dist/index.js.map +1 -1
- package/dist/localInferenceSchema.d.ts +19 -0
- package/dist/localInferenceSchema.d.ts.map +1 -1
- package/dist/localInferenceSchema.js +23 -0
- package/dist/localInferenceSchema.js.map +1 -1
- package/dist/localSensing.d.ts +48 -107
- package/dist/localSensing.d.ts.map +1 -1
- package/dist/localSensing.js +80 -171
- package/dist/localSensing.js.map +1 -1
- package/dist/localVlmBench.d.ts +36 -0
- package/dist/localVlmBench.d.ts.map +1 -1
- package/dist/localVlmBench.js +48 -0
- package/dist/localVlmBench.js.map +1 -1
- package/dist/sensingCatchRate.d.ts +246 -0
- package/dist/sensingCatchRate.d.ts.map +1 -0
- package/dist/sensingCatchRate.js +514 -0
- package/dist/sensingCatchRate.js.map +1 -0
- package/dist/sensingRunMode.d.ts +14 -11
- package/dist/sensingRunMode.d.ts.map +1 -1
- package/dist/sensingRunMode.js +9 -92
- package/dist/sensingRunMode.js.map +1 -1
- package/dist/setupMode.d.ts +0 -1
- package/dist/setupMode.d.ts.map +1 -1
- package/dist/setupMode.js +5 -3
- package/dist/setupMode.js.map +1 -1
- package/dist/startupDiscovery.d.ts +43 -0
- package/dist/startupDiscovery.d.ts.map +1 -0
- package/dist/startupDiscovery.js +72 -0
- package/dist/startupDiscovery.js.map +1 -0
- package/package.json +1 -1
- package/dist/CloudEscalationPolicy.d.ts +0 -143
- package/dist/CloudEscalationPolicy.d.ts.map +0 -1
- package/dist/CloudEscalationPolicy.js +0 -137
- package/dist/CloudEscalationPolicy.js.map +0 -1
package/dist/localSensing.d.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { SensingProfileClient } from './SensingProfileClient';
|
|
|
5
5
|
import { type ResolvedFeed, type SensingClient, type SensingLoopDeps } from './CloudSensingManager';
|
|
6
6
|
import type { SharedFrameSource } from './MosaicSource';
|
|
7
7
|
import { type LocalSchemaFieldSet } from './localInferenceSchema';
|
|
8
|
-
import { type CloudEscalationPolicyOptions, type CloudEscalationStats } from './CloudEscalationPolicy';
|
|
9
8
|
import type { EdgeAgentConfig } from './config';
|
|
10
9
|
/**
|
|
11
10
|
* Map a deployed profile's SELF-DESCRIBING type to the on-device field set.
|
|
@@ -43,30 +42,6 @@ export interface LocalSensingClientOptions {
|
|
|
43
42
|
* isAlertWorthy(). Default: true.
|
|
44
43
|
*/
|
|
45
44
|
attachEvidenceFrame?: boolean;
|
|
46
|
-
/**
|
|
47
|
-
* Per-frame resilience: when ON-DEVICE inference THROWS for a single frame,
|
|
48
|
-
* POST that frame to the CLOUD VLM (uploadClient.analyze) instead of losing it.
|
|
49
|
-
* Reuses the SAME upload client (no new provider). Opt-in (default false) so
|
|
50
|
-
* the plain local-sense path is unchanged; auto-sense's local branch enables it.
|
|
51
|
-
*/
|
|
52
|
-
cloudFallbackOnError?: boolean;
|
|
53
|
-
/**
|
|
54
|
-
* Confidence-based CLOUD ESCALATION (Phase 6d). When the on-device model
|
|
55
|
-
* flags a CANDIDATE — a frame carrying real signal, judged by the SAME
|
|
56
|
-
* `isAlertWorthy` gate that governs the evidence frame — send THAT ONE frame
|
|
57
|
-
* to the cloud VLM for adjudication instead of shipping the local verdict.
|
|
58
|
-
*
|
|
59
|
-
* Distinct from `cloudFallbackOnError` (a per-frame degrade when local
|
|
60
|
-
* inference THROWS) and from `EdgeAgentConfig.localSensing.allowCloudFallback`
|
|
61
|
-
* (a whole-mode switch when the local runtime is unavailable). Those two reach
|
|
62
|
-
* for the cloud because the local path is BROKEN; this one because the local
|
|
63
|
-
* path WORKED and its judgement is worth confirming.
|
|
64
|
-
*
|
|
65
|
-
* Off unless `enabled: true` — see CloudEscalationPolicy.
|
|
66
|
-
*/
|
|
67
|
-
cloudEscalation?: CloudEscalationPolicyOptions;
|
|
68
|
-
/** Feed label used only in the escalation log line. */
|
|
69
|
-
feedName?: string;
|
|
70
45
|
}
|
|
71
46
|
/**
|
|
72
47
|
* Decide whether an on-device result is potentially ALERT-WORTHY — the gate that
|
|
@@ -90,46 +65,30 @@ export interface LocalSensingClientOptions {
|
|
|
90
65
|
* Non-discriminative keys (source, captured_at, confidence, scene_description)
|
|
91
66
|
* are ignored — see NON_DISCRIMINATIVE_KEYS for why.
|
|
92
67
|
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*/
|
|
100
|
-
export declare function isAlertWorthy(result: Record<string, unknown>): boolean;
|
|
101
|
-
/**
|
|
102
|
-
* A stable fingerprint of the ALERT-BEARING state of one result — "has the
|
|
103
|
-
* operator-visible situation changed?" in one comparable string.
|
|
68
|
+
* ABSENCE (`absenceWatchedFields`) — the empty-shelf half. The rule above answers
|
|
69
|
+
* "is anything HERE?", which is the right question for people and the WRONG one for
|
|
70
|
+
* stock: "the shelf is empty" is a positive finding about a subject that IS in frame,
|
|
71
|
+
* and the operator asked to be told about it. So a field named in `absenceWatchedFields`
|
|
72
|
+
* flips: its EMPTY reading (`shelf_state: 'empty'`, `stock_level: 0`,
|
|
73
|
+
* `product_available: false`, `[]`, `{}`) counts as signal.
|
|
104
74
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* deliberately does NOT bucket numbers into invented bands.
|
|
75
|
+
* That set is not guessed from the field's spelling and is not an allowlist of stock
|
|
76
|
+
* words. The server derives it per screen from the operator's OWN saved route
|
|
77
|
+
* conditions — a field is in it only when an enabled route would actually FIRE on that
|
|
78
|
+
* field's empty reading (`shelf_state eq 'empty'`). So `smoking_activity_detected: false`
|
|
79
|
+
* on an empty scene stays suppressed, because no route targets its absence, and a
|
|
80
|
+
* profile with no absence-targeting route behaves byte-for-byte as before.
|
|
112
81
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
82
|
+
* The privacy FLOOR is unconditional: `DEFAULT_SCENE_FIELD_KEYS` — the presence
|
|
83
|
+
* primitives and the DOOH audience block, i.e. every field whose subject IS the people
|
|
84
|
+
* in front of the camera — can NEVER be unlocked this way, even by a route that
|
|
85
|
+
* explicitly targets them (`person_count lte 0`). Absence of PEOPLE is the blank-room
|
|
86
|
+
* frame the gate exists to prevent; absence of STOCK is the operator's finding. That is
|
|
87
|
+
* the whole distinction, and it is enforced structurally rather than by tuning.
|
|
88
|
+
* Operators who want every frame regardless can still disable the gate
|
|
89
|
+
* (attachEvidenceFrame).
|
|
115
90
|
*/
|
|
116
|
-
export declare function
|
|
117
|
-
/**
|
|
118
|
-
* Is cloud escalation actually ON for this local-sensing configuration?
|
|
119
|
-
*
|
|
120
|
-
* TWO settings decide it, and this is the ONLY place that combines them:
|
|
121
|
-
* `cloudEscalation.enabled` opts in, and `attachEvidenceFrame: false` — the
|
|
122
|
-
* operator's "no frame ever leaves this machine" switch — outranks it, because
|
|
123
|
-
* escalation sends a frame. `LocalSensingClient` enforces the rule and the
|
|
124
|
-
* startup banner reports it; they must read the same expression or the banner
|
|
125
|
-
* will one day claim an egress posture the client is not running.
|
|
126
|
-
*/
|
|
127
|
-
export declare function isCloudEscalationEnabled(options: {
|
|
128
|
-
attachEvidenceFrame?: boolean;
|
|
129
|
-
cloudEscalation?: {
|
|
130
|
-
enabled?: boolean;
|
|
131
|
-
};
|
|
132
|
-
} | null | undefined): boolean;
|
|
91
|
+
export declare function isAlertWorthy(result: Record<string, unknown>, absenceWatchedFields?: ReadonlySet<string> | readonly string[]): boolean;
|
|
133
92
|
/**
|
|
134
93
|
* LocalSensingClient — the SensingClient the cadence loop uses in LOCAL mode.
|
|
135
94
|
* It runs the VLM ON-DEVICE (LiteRtVlmProvider — Gemma 4 on LiteRT-LM via
|
|
@@ -153,33 +112,26 @@ export declare function isCloudEscalationEnabled(options: {
|
|
|
153
112
|
* gating local-inferred data out of the sold bidstream while still firing the
|
|
154
113
|
* operator's Sense-Anything routes.
|
|
155
114
|
*
|
|
156
|
-
* ──
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
115
|
+
* ── THERE IS NO CLOUD PATH ─────────────────────────────────────────────────
|
|
116
|
+
* This client once had three doors to the cloud VLM: `cloudFallbackOnError` (a
|
|
117
|
+
* per-frame degrade when local inference threw), `cloudEscalation` (Phase 6d —
|
|
118
|
+
* local Gemma gates, the cloud adjudicates the candidates it flags), and the
|
|
119
|
+
* whole-mode `allowCloudFallback` in auto-sense. All three are gone, and this
|
|
120
|
+
* supersedes the escalation architecture from #11971 ("local Gemma decides
|
|
121
|
+
* *when* to look, the cloud decides *what* it saw").
|
|
162
122
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* stamp — identical to `cloud-sense` and to the existing
|
|
171
|
-
* `cloudFallbackOnError` frame. Nothing the local model produced enters
|
|
172
|
-
* that record, so `ingest_source='local_connector'` (which exists to keep
|
|
173
|
-
* operator-forgeable structured input out of the sold bidstream) does not
|
|
174
|
-
* apply and is not evaded: there IS no local structured input on this path.
|
|
175
|
-
* 3. THE PRIVACY GATE IS UNCHANGED. Escalation reuses `isAlertWorthy`, so the
|
|
176
|
-
* set of frames that can leave the premises is exactly the set that
|
|
177
|
-
* already leaves as evidence photos — and `attachEvidenceFrame: false`
|
|
178
|
-
* (the operator's "no frame ever leaves" switch) disables escalation
|
|
179
|
-
* outright. An empty scene never escalates.
|
|
123
|
+
* The reason is arithmetic, not architecture. Measured 2026-08-12 over 255,812
|
|
124
|
+
* image-bearing cloud VLM calls: $0.007008 per analyzed frame. At a 30s cadence
|
|
125
|
+
* that is ~$605/camera/month, so a 16-camera store cost ~$9,688/month against a
|
|
126
|
+
* $99-500 subscription — about 10x underwater. Escalation narrowed WHICH frames
|
|
127
|
+
* went, and the earlier plan (plans/cctv-byo-sensing-plan.md:26) narrowed HOW
|
|
128
|
+
* OFTEN; neither changes the sign. A path that meters per frame does not belong
|
|
129
|
+
* in a flat-rate product, so the path is removed rather than rate-limited.
|
|
180
130
|
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
131
|
+
* What replaces it: nothing on the frame path. A frame the on-device runtime
|
|
132
|
+
* cannot analyze is dropped and the fault is surfaced (see the catch in
|
|
133
|
+
* `analyze`), and a connector with no usable runtime WAITS and beacons its
|
|
134
|
+
* `waiting` state rather than silently spending.
|
|
183
135
|
*/
|
|
184
136
|
export declare class LocalSensingClient implements SensingClient {
|
|
185
137
|
private readonly localProvider;
|
|
@@ -190,25 +142,22 @@ export declare class LocalSensingClient implements SensingClient {
|
|
|
190
142
|
private readonly basePrompt;
|
|
191
143
|
private cachedSchema;
|
|
192
144
|
private cachedPrompt;
|
|
145
|
+
/**
|
|
146
|
+
* Fields whose ABSENT reading an operator route on this screen fires on, from the
|
|
147
|
+
* deployed profile (see SensingProfileClient.absenceWatchedFields). Refreshed on the
|
|
148
|
+
* SAME cadence as the schema, because a route saved after the last poll must not be
|
|
149
|
+
* able to change the gate before the model is even emitting its field. Empty until a
|
|
150
|
+
* profile fetch succeeds, so the gate's default is the strict pre-existing behavior.
|
|
151
|
+
*/
|
|
152
|
+
private cachedAbsenceWatchedFields;
|
|
193
153
|
private lastProfileFetchMs;
|
|
194
154
|
/** Send the analyzed JPEG (gated to alert-worthy frames) as evidence. Default true. */
|
|
195
155
|
private readonly attachEvidenceFrame;
|
|
196
|
-
/** On a local-inference throw, POST that one frame to the cloud VLM. Default false. */
|
|
197
|
-
private readonly cloudFallbackOnError;
|
|
198
|
-
/**
|
|
199
|
-
* Rate/novelty bound on cloud escalation for THIS feed. Per-client (not
|
|
200
|
-
* global) on purpose: the budget an operator reasons about is "per camera",
|
|
201
|
-
* and one busy camera must not spend a quiet one's confirmations.
|
|
202
|
-
*/
|
|
203
|
-
private readonly escalationPolicy;
|
|
204
|
-
/** Feed label for the escalation log line. */
|
|
205
|
-
private readonly feedName;
|
|
206
156
|
/**
|
|
207
157
|
* Frames the ON-DEVICE runtime actually produced a result for.
|
|
208
158
|
*
|
|
209
159
|
* Counted at the one place that knows — immediately after `localProvider
|
|
210
|
-
* .analyze()` RETURNS
|
|
211
|
-
* cloud answered) and an escalated frame are both excluded. `auto-sense`
|
|
160
|
+
* .analyze()` RETURNS. `auto-sense`
|
|
212
161
|
* reads this through `getSensingHealth().localInferences` as the only
|
|
213
162
|
* positive proof that a `litert-lm serve` too busy to answer `/v1/models` is
|
|
214
163
|
* busy rather than dead; a counter that a cloud call could advance would make
|
|
@@ -244,14 +193,6 @@ export declare class LocalSensingClient implements SensingClient {
|
|
|
244
193
|
* log-diving. See LiteRtVlmProvider.getAcceleratorHealth.
|
|
245
194
|
*/
|
|
246
195
|
getAcceleratorHealth(): LiteRtAcceleratorHealth;
|
|
247
|
-
/**
|
|
248
|
-
* Cloud-escalation counters for this feed — how many candidate frames were
|
|
249
|
-
* adjudicated by the cloud VLM, and why the rest were not. Read by
|
|
250
|
-
* `CloudSensingManager.getSensingHealth()` so escalation shows up on the
|
|
251
|
-
* operator's periodic capacity line: sending frames off-premises must be
|
|
252
|
-
* VISIBLE, not merely documented.
|
|
253
|
-
*/
|
|
254
|
-
getEscalationStats(): CloudEscalationStats;
|
|
255
196
|
/** Frames the on-device runtime produced a result for. See the field docs. */
|
|
256
197
|
getLocalInferenceStats(): {
|
|
257
198
|
completed: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"localSensing.d.ts","sourceRoot":"","sources":["../src/localSensing.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,EACL,qBAAqB,EACrB,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACpB,KAAK,UAAU,EAChB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AACtF,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAC9D,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,eAAe,EACrB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,
|
|
1
|
+
{"version":3,"file":"localSensing.d.ts","sourceRoot":"","sources":["../src/localSensing.ts"],"names":[],"mappings":"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"}
|
package/dist/localSensing.js
CHANGED
|
@@ -3,15 +3,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.LocalSensingClient = void 0;
|
|
4
4
|
exports.fieldSetForProfileType = fieldSetForProfileType;
|
|
5
5
|
exports.isAlertWorthy = isAlertWorthy;
|
|
6
|
-
exports.alertSignature = alertSignature;
|
|
7
|
-
exports.isCloudEscalationEnabled = isCloudEscalationEnabled;
|
|
8
6
|
exports.buildLocalSensingLoops = buildLocalSensingLoops;
|
|
9
7
|
const AudienceAnalyzeClient_1 = require("./AudienceAnalyzeClient");
|
|
10
8
|
const LiteRtVlmProvider_1 = require("./LiteRtVlmProvider");
|
|
11
9
|
const SensingProfileClient_1 = require("./SensingProfileClient");
|
|
12
10
|
const CloudSensingManager_1 = require("./CloudSensingManager");
|
|
13
11
|
const localInferenceSchema_1 = require("./localInferenceSchema");
|
|
14
|
-
const CloudEscalationPolicy_1 = require("./CloudEscalationPolicy");
|
|
15
12
|
const legacyRecorderProfile_1 = require("./legacyRecorderProfile");
|
|
16
13
|
/** How long a fetched profile is cached before the connector re-checks for a new alert. */
|
|
17
14
|
const DEFAULT_PROFILE_TTL_MS = 5 * 60 * 1000;
|
|
@@ -85,92 +82,80 @@ const NON_DISCRIMINATIVE_KEYS = new Set(['source', 'captured_at', 'confidence',
|
|
|
85
82
|
* Non-discriminative keys (source, captured_at, confidence, scene_description)
|
|
86
83
|
* are ignored — see NON_DISCRIMINATIVE_KEYS for why.
|
|
87
84
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
85
|
+
* ABSENCE (`absenceWatchedFields`) — the empty-shelf half. The rule above answers
|
|
86
|
+
* "is anything HERE?", which is the right question for people and the WRONG one for
|
|
87
|
+
* stock: "the shelf is empty" is a positive finding about a subject that IS in frame,
|
|
88
|
+
* and the operator asked to be told about it. So a field named in `absenceWatchedFields`
|
|
89
|
+
* flips: its EMPTY reading (`shelf_state: 'empty'`, `stock_level: 0`,
|
|
90
|
+
* `product_available: false`, `[]`, `{}`) counts as signal.
|
|
91
|
+
*
|
|
92
|
+
* That set is not guessed from the field's spelling and is not an allowlist of stock
|
|
93
|
+
* words. The server derives it per screen from the operator's OWN saved route
|
|
94
|
+
* conditions — a field is in it only when an enabled route would actually FIRE on that
|
|
95
|
+
* field's empty reading (`shelf_state eq 'empty'`). So `smoking_activity_detected: false`
|
|
96
|
+
* on an empty scene stays suppressed, because no route targets its absence, and a
|
|
97
|
+
* profile with no absence-targeting route behaves byte-for-byte as before.
|
|
98
|
+
*
|
|
99
|
+
* The privacy FLOOR is unconditional: `DEFAULT_SCENE_FIELD_KEYS` — the presence
|
|
100
|
+
* primitives and the DOOH audience block, i.e. every field whose subject IS the people
|
|
101
|
+
* in front of the camera — can NEVER be unlocked this way, even by a route that
|
|
102
|
+
* explicitly targets them (`person_count lte 0`). Absence of PEOPLE is the blank-room
|
|
103
|
+
* frame the gate exists to prevent; absence of STOCK is the operator's finding. That is
|
|
104
|
+
* the whole distinction, and it is enforced structurally rather than by tuning.
|
|
105
|
+
* Operators who want every frame regardless can still disable the gate
|
|
106
|
+
* (attachEvidenceFrame).
|
|
94
107
|
*/
|
|
95
|
-
function isAlertWorthy(result) {
|
|
108
|
+
function isAlertWorthy(result, absenceWatchedFields) {
|
|
96
109
|
if (!result || typeof result !== 'object')
|
|
97
110
|
return false;
|
|
111
|
+
const watched = absenceWatchedFields instanceof Set
|
|
112
|
+
? absenceWatchedFields
|
|
113
|
+
: new Set(Array.isArray(absenceWatchedFields) ? absenceWatchedFields : []);
|
|
98
114
|
for (const [key, value] of Object.entries(result)) {
|
|
99
115
|
if (NON_DISCRIMINATIVE_KEYS.has(key))
|
|
100
116
|
continue;
|
|
117
|
+
// Absence is signal ONLY for an operator-declared subject the operator's own route
|
|
118
|
+
// watches for absence. Never for a scene-occupancy field — see the floor above.
|
|
119
|
+
const absenceIsSignal = watched.has(key) && !localInferenceSchema_1.DEFAULT_SCENE_FIELD_KEYS.has(key);
|
|
101
120
|
if (value === true)
|
|
102
121
|
return true;
|
|
122
|
+
if (value === false) {
|
|
123
|
+
if (absenceIsSignal)
|
|
124
|
+
return true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
103
127
|
if (typeof value === 'number') {
|
|
104
128
|
if (Number.isFinite(value) && value > 0)
|
|
105
129
|
return true;
|
|
130
|
+
if (absenceIsSignal && Number.isFinite(value) && value === 0)
|
|
131
|
+
return true;
|
|
106
132
|
continue;
|
|
107
133
|
}
|
|
108
134
|
if (typeof value === 'string') {
|
|
109
135
|
const v = value.trim().toLowerCase();
|
|
110
136
|
if (v.length > 0 && !EMPTY_SCENE_SENTINELS.has(v))
|
|
111
137
|
return true;
|
|
138
|
+
if (absenceIsSignal)
|
|
139
|
+
return true; // '' / 'empty' / 'none' / 'unknown' IS the finding
|
|
112
140
|
continue;
|
|
113
141
|
}
|
|
114
142
|
if (Array.isArray(value)) {
|
|
115
143
|
if (value.length > 0)
|
|
116
144
|
return true;
|
|
145
|
+
if (absenceIsSignal)
|
|
146
|
+
return true;
|
|
117
147
|
continue;
|
|
118
148
|
}
|
|
119
149
|
if (value && typeof value === 'object') {
|
|
120
150
|
if (Object.keys(value).length > 0)
|
|
121
151
|
return true;
|
|
152
|
+
if (absenceIsSignal)
|
|
153
|
+
return true;
|
|
122
154
|
continue;
|
|
123
155
|
}
|
|
124
156
|
}
|
|
125
157
|
return false;
|
|
126
158
|
}
|
|
127
|
-
/**
|
|
128
|
-
* A stable fingerprint of the ALERT-BEARING state of one result — "has the
|
|
129
|
-
* operator-visible situation changed?" in one comparable string.
|
|
130
|
-
*
|
|
131
|
-
* Built from EXACTLY the keys `isAlertWorthy` judges (NON_DISCRIMINATIVE_KEYS
|
|
132
|
-
* excluded), so the two can never drift: the same fields that decide whether a
|
|
133
|
-
* frame carries signal decide whether that signal is NEW. Keys are sorted so
|
|
134
|
-
* the model's property order cannot manufacture a change, and values are
|
|
135
|
-
* compared as emitted — a `queue_length` of 3 and of 4 are genuinely different
|
|
136
|
-
* facts and an operator route may key on exactly that boundary, so this
|
|
137
|
-
* deliberately does NOT bucket numbers into invented bands.
|
|
138
|
-
*
|
|
139
|
-
* Used by CloudEscalationPolicy's novelty bound: an unchanged signature means a
|
|
140
|
-
* verdict the cloud has already adjudicated, so the frame stays on-premises.
|
|
141
|
-
*/
|
|
142
|
-
function alertSignature(result) {
|
|
143
|
-
if (!result || typeof result !== 'object')
|
|
144
|
-
return '';
|
|
145
|
-
const parts = [];
|
|
146
|
-
for (const key of Object.keys(result).sort()) {
|
|
147
|
-
if (NON_DISCRIMINATIVE_KEYS.has(key))
|
|
148
|
-
continue;
|
|
149
|
-
const value = result[key];
|
|
150
|
-
// Arrays/objects (a `list` / `distribution` custom field) serialize
|
|
151
|
-
// structurally; scalars stringify. Undefined is elided so an absent key and
|
|
152
|
-
// an explicitly-undefined key read the same.
|
|
153
|
-
if (value === undefined)
|
|
154
|
-
continue;
|
|
155
|
-
parts.push(`${key}=${typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value)}`);
|
|
156
|
-
}
|
|
157
|
-
return parts.join('|');
|
|
158
|
-
}
|
|
159
|
-
/**
|
|
160
|
-
* Is cloud escalation actually ON for this local-sensing configuration?
|
|
161
|
-
*
|
|
162
|
-
* TWO settings decide it, and this is the ONLY place that combines them:
|
|
163
|
-
* `cloudEscalation.enabled` opts in, and `attachEvidenceFrame: false` — the
|
|
164
|
-
* operator's "no frame ever leaves this machine" switch — outranks it, because
|
|
165
|
-
* escalation sends a frame. `LocalSensingClient` enforces the rule and the
|
|
166
|
-
* startup banner reports it; they must read the same expression or the banner
|
|
167
|
-
* will one day claim an egress posture the client is not running.
|
|
168
|
-
*/
|
|
169
|
-
function isCloudEscalationEnabled(options) {
|
|
170
|
-
if (!options)
|
|
171
|
-
return false;
|
|
172
|
-
return options.cloudEscalation?.enabled === true && options.attachEvidenceFrame !== false;
|
|
173
|
-
}
|
|
174
159
|
/**
|
|
175
160
|
* LocalSensingClient — the SensingClient the cadence loop uses in LOCAL mode.
|
|
176
161
|
* It runs the VLM ON-DEVICE (LiteRtVlmProvider — Gemma 4 on LiteRT-LM via
|
|
@@ -194,45 +179,45 @@ function isCloudEscalationEnabled(options) {
|
|
|
194
179
|
* gating local-inferred data out of the sold bidstream while still firing the
|
|
195
180
|
* operator's Sense-Anything routes.
|
|
196
181
|
*
|
|
197
|
-
* ──
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
182
|
+
* ── THERE IS NO CLOUD PATH ─────────────────────────────────────────────────
|
|
183
|
+
* This client once had three doors to the cloud VLM: `cloudFallbackOnError` (a
|
|
184
|
+
* per-frame degrade when local inference threw), `cloudEscalation` (Phase 6d —
|
|
185
|
+
* local Gemma gates, the cloud adjudicates the candidates it flags), and the
|
|
186
|
+
* whole-mode `allowCloudFallback` in auto-sense. All three are gone, and this
|
|
187
|
+
* supersedes the escalation architecture from #11971 ("local Gemma decides
|
|
188
|
+
* *when* to look, the cloud decides *what* it saw").
|
|
203
189
|
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* stamp — identical to `cloud-sense` and to the existing
|
|
212
|
-
* `cloudFallbackOnError` frame. Nothing the local model produced enters
|
|
213
|
-
* that record, so `ingest_source='local_connector'` (which exists to keep
|
|
214
|
-
* operator-forgeable structured input out of the sold bidstream) does not
|
|
215
|
-
* apply and is not evaded: there IS no local structured input on this path.
|
|
216
|
-
* 3. THE PRIVACY GATE IS UNCHANGED. Escalation reuses `isAlertWorthy`, so the
|
|
217
|
-
* set of frames that can leave the premises is exactly the set that
|
|
218
|
-
* already leaves as evidence photos — and `attachEvidenceFrame: false`
|
|
219
|
-
* (the operator's "no frame ever leaves" switch) disables escalation
|
|
220
|
-
* outright. An empty scene never escalates.
|
|
190
|
+
* The reason is arithmetic, not architecture. Measured 2026-08-12 over 255,812
|
|
191
|
+
* image-bearing cloud VLM calls: $0.007008 per analyzed frame. At a 30s cadence
|
|
192
|
+
* that is ~$605/camera/month, so a 16-camera store cost ~$9,688/month against a
|
|
193
|
+
* $99-500 subscription — about 10x underwater. Escalation narrowed WHICH frames
|
|
194
|
+
* went, and the earlier plan (plans/cctv-byo-sensing-plan.md:26) narrowed HOW
|
|
195
|
+
* OFTEN; neither changes the sign. A path that meters per frame does not belong
|
|
196
|
+
* in a flat-rate product, so the path is removed rather than rate-limited.
|
|
221
197
|
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
198
|
+
* What replaces it: nothing on the frame path. A frame the on-device runtime
|
|
199
|
+
* cannot analyze is dropped and the fault is surfaced (see the catch in
|
|
200
|
+
* `analyze`), and a connector with no usable runtime WAITS and beacons its
|
|
201
|
+
* `waiting` state rather than silently spending.
|
|
224
202
|
*/
|
|
225
203
|
class LocalSensingClient {
|
|
226
204
|
constructor(localProvider, uploadClient, options = {}) {
|
|
227
205
|
this.localProvider = localProvider;
|
|
228
206
|
this.uploadClient = uploadClient;
|
|
207
|
+
/**
|
|
208
|
+
* Fields whose ABSENT reading an operator route on this screen fires on, from the
|
|
209
|
+
* deployed profile (see SensingProfileClient.absenceWatchedFields). Refreshed on the
|
|
210
|
+
* SAME cadence as the schema, because a route saved after the last poll must not be
|
|
211
|
+
* able to change the gate before the model is even emitting its field. Empty until a
|
|
212
|
+
* profile fetch succeeds, so the gate's default is the strict pre-existing behavior.
|
|
213
|
+
*/
|
|
214
|
+
this.cachedAbsenceWatchedFields = new Set();
|
|
229
215
|
this.lastProfileFetchMs = 0;
|
|
230
216
|
/**
|
|
231
217
|
* Frames the ON-DEVICE runtime actually produced a result for.
|
|
232
218
|
*
|
|
233
219
|
* Counted at the one place that knows — immediately after `localProvider
|
|
234
|
-
* .analyze()` RETURNS
|
|
235
|
-
* cloud answered) and an escalated frame are both excluded. `auto-sense`
|
|
220
|
+
* .analyze()` RETURNS. `auto-sense`
|
|
236
221
|
* reads this through `getSensingHealth().localInferences` as the only
|
|
237
222
|
* positive proof that a `litert-lm serve` too busy to answer `/v1/models` is
|
|
238
223
|
* busy rather than dead; a counter that a cloud call could advance would make
|
|
@@ -254,19 +239,6 @@ class LocalSensingClient {
|
|
|
254
239
|
this.cachedSchema = this.baseSchema;
|
|
255
240
|
this.cachedPrompt = this.basePrompt;
|
|
256
241
|
this.attachEvidenceFrame = options.attachEvidenceFrame !== false;
|
|
257
|
-
this.cloudFallbackOnError = options.cloudFallbackOnError === true;
|
|
258
|
-
this.feedName = options.feedName || 'local-sense';
|
|
259
|
-
// `attachEvidenceFrame: false` is the operator saying NO frame may ever
|
|
260
|
-
// leave this machine, and escalation sends a frame — isCloudEscalationEnabled
|
|
261
|
-
// is the one expression that resolves the pair (the startup banner reads the
|
|
262
|
-
// same one, so the two can never disagree).
|
|
263
|
-
this.escalationPolicy = new CloudEscalationPolicy_1.CloudEscalationPolicy({
|
|
264
|
-
...(options.cloudEscalation || {}),
|
|
265
|
-
enabled: isCloudEscalationEnabled({
|
|
266
|
-
attachEvidenceFrame: this.attachEvidenceFrame,
|
|
267
|
-
cloudEscalation: options.cloudEscalation,
|
|
268
|
-
}),
|
|
269
|
-
});
|
|
270
242
|
}
|
|
271
243
|
getEndpoint() {
|
|
272
244
|
return this.uploadClient.getEndpoint();
|
|
@@ -295,6 +267,7 @@ class LocalSensingClient {
|
|
|
295
267
|
if (profile === null)
|
|
296
268
|
return; // transient error — keep last-good schema
|
|
297
269
|
this.activeProfileName = profile.profileName;
|
|
270
|
+
this.cachedAbsenceWatchedFields = new Set(profile.absenceWatchedFields || []);
|
|
298
271
|
const fieldSet = fieldSetForProfileType(profile.profileType);
|
|
299
272
|
this.activeFieldSet = fieldSet;
|
|
300
273
|
if (fieldSet === 'operator' || profile.fields.length > 0) {
|
|
@@ -317,16 +290,6 @@ class LocalSensingClient {
|
|
|
317
290
|
getAcceleratorHealth() {
|
|
318
291
|
return this.localProvider.getAcceleratorHealth();
|
|
319
292
|
}
|
|
320
|
-
/**
|
|
321
|
-
* Cloud-escalation counters for this feed — how many candidate frames were
|
|
322
|
-
* adjudicated by the cloud VLM, and why the rest were not. Read by
|
|
323
|
-
* `CloudSensingManager.getSensingHealth()` so escalation shows up on the
|
|
324
|
-
* operator's periodic capacity line: sending frames off-premises must be
|
|
325
|
-
* VISIBLE, not merely documented.
|
|
326
|
-
*/
|
|
327
|
-
getEscalationStats() {
|
|
328
|
-
return this.escalationPolicy.getStats();
|
|
329
|
-
}
|
|
330
293
|
/** Frames the on-device runtime produced a result for. See the field docs. */
|
|
331
294
|
getLocalInferenceStats() {
|
|
332
295
|
return { completed: this.localInferencesCompleted };
|
|
@@ -352,25 +315,17 @@ class LocalSensingClient {
|
|
|
352
315
|
});
|
|
353
316
|
}
|
|
354
317
|
catch (err) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
// the
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
console.warn(`[local-sense] on-device inference failed for a frame (${err.message}) — ` +
|
|
363
|
-
'falling back to the cloud VLM for this frame');
|
|
364
|
-
return this.uploadClient.analyze({
|
|
365
|
-
jpeg: request.jpeg,
|
|
366
|
-
capturedAt: Date.now(),
|
|
367
|
-
captureMode: request.captureMode,
|
|
368
|
-
...frameDimensions,
|
|
369
|
-
});
|
|
318
|
+
// LOCAL-INFERENCE ONLY: a frame the on-device runtime could not analyze is
|
|
319
|
+
// DROPPED, not metered. This branch used to POST the frame to the cloud
|
|
320
|
+
// VLM (`cloudFallbackOnError`) at $0.007008 each — the per-frame cost this
|
|
321
|
+
// whole change exists to remove. The capture loop's own cadence is the
|
|
322
|
+
// resilience: the next frame is seconds away, and a runtime that keeps
|
|
323
|
+
// throwing is a fault to surface, not a bill to pay.
|
|
324
|
+
throw err;
|
|
370
325
|
}
|
|
371
326
|
// The on-device runtime returned a result: the ONLY event that proves it is
|
|
372
|
-
// alive. Counted here, past every throw
|
|
373
|
-
//
|
|
327
|
+
// alive. Counted here, past every throw, so nothing but a real on-device
|
|
328
|
+
// inference can advance it.
|
|
374
329
|
this.localInferencesCompleted += 1;
|
|
375
330
|
// Connector-controlled provenance + time (don't trust the model for these).
|
|
376
331
|
const capturedAtMs = Date.now();
|
|
@@ -379,48 +334,7 @@ class LocalSensingClient {
|
|
|
379
334
|
// alert-worthy. Empty/uninteresting scenes send results only — no frame.
|
|
380
335
|
// Evaluate over the raw model output (not the provenance-stamped `result`,
|
|
381
336
|
// though both yield the same gate since source/captured_at are strings).
|
|
382
|
-
const hasSignal = isAlertWorthy(structured);
|
|
383
|
-
// ESCALATION: a candidate the local model flagged is adjudicated by the
|
|
384
|
-
// cloud VLM BEFORE it can fire an alert. Same gate as the evidence frame,
|
|
385
|
-
// so no frame leaves that would not already have left; the policy adds the
|
|
386
|
-
// novelty/pacing/budget bounds. On success this frame's observation is the
|
|
387
|
-
// CLOUD's — the local result is deliberately not also posted (one frame,
|
|
388
|
-
// one observation).
|
|
389
|
-
const canEscalate = this.escalationPolicy.isEnabled() && hasSignal;
|
|
390
|
-
if (this.escalationPolicy.tryEscalate({
|
|
391
|
-
hasSignal,
|
|
392
|
-
// Only computed when the frame could actually escalate. This runs on
|
|
393
|
-
// every frame of every camera and the common case — escalation off, or
|
|
394
|
-
// an empty scene — needs no signature at all (tryEscalate rejects on
|
|
395
|
-
// `disabled`/`no_signal` before it reads one).
|
|
396
|
-
signature: canEscalate ? alertSignature(structured) : '',
|
|
397
|
-
}).escalate) {
|
|
398
|
-
try {
|
|
399
|
-
const escalated = await this.uploadClient.analyze({
|
|
400
|
-
jpeg: request.jpeg,
|
|
401
|
-
capturedAt: capturedAtMs,
|
|
402
|
-
captureMode: request.captureMode,
|
|
403
|
-
...frameDimensions,
|
|
404
|
-
});
|
|
405
|
-
// Rare by construction (bounded per feed per hour), so log every one:
|
|
406
|
-
// an operator must be able to see each frame that left the premises.
|
|
407
|
-
// eslint-disable-next-line no-console
|
|
408
|
-
console.log(`[${this.feedName}] local candidate escalated to the cloud VLM for confirmation ` +
|
|
409
|
-
`→ HTTP ${escalated.statusCode}${escalated.ok ? '' : ' (NOT OK)'}`);
|
|
410
|
-
if (escalated.ok)
|
|
411
|
-
return escalated;
|
|
412
|
-
// A non-2xx cloud verdict is no verdict at all — fall through and ship
|
|
413
|
-
// the local result rather than losing the observation.
|
|
414
|
-
// eslint-disable-next-line no-console
|
|
415
|
-
console.warn(`[${this.feedName}] cloud escalation returned HTTP ${escalated.statusCode} — ` +
|
|
416
|
-
'keeping the on-device verdict for this frame');
|
|
417
|
-
}
|
|
418
|
-
catch (err) {
|
|
419
|
-
// eslint-disable-next-line no-console
|
|
420
|
-
console.warn(`[${this.feedName}] cloud escalation failed (${err.message}) — ` +
|
|
421
|
-
'keeping the on-device verdict for this frame');
|
|
422
|
-
}
|
|
423
|
-
}
|
|
337
|
+
const hasSignal = isAlertWorthy(structured, this.cachedAbsenceWatchedFields);
|
|
424
338
|
const result = {
|
|
425
339
|
...structured,
|
|
426
340
|
source: 'vlm_edge',
|
|
@@ -463,11 +377,9 @@ function buildLocalSensingLoops(config, deps) {
|
|
|
463
377
|
// Per-frame cloud fallback defaults OFF (opt-in) so the plain local-sense path
|
|
464
378
|
// is unchanged. When true, a frame whose on-device inference throws is POSTed
|
|
465
379
|
// to the cloud VLM (same upload client) rather than dropped.
|
|
466
|
-
const cloudFallbackOnError = config.localSensing?.cloudFallbackOnError === true;
|
|
467
380
|
// Cloud escalation defaults OFF. Each feed gets its OWN policy instance so the
|
|
468
381
|
// per-camera budget is per camera (a shared one would let a busy feed spend a
|
|
469
382
|
// quiet feed's confirmations).
|
|
470
|
-
const cloudEscalation = config.localSensing?.cloudEscalation;
|
|
471
383
|
const rtspFactory = deps?.rtspProviderFactory ??
|
|
472
384
|
((feed) => {
|
|
473
385
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
@@ -519,9 +431,6 @@ function buildLocalSensingLoops(config, deps) {
|
|
|
519
431
|
client: new LocalSensingClient(localProviderFactory(feed), uploadClientFactory(feed), {
|
|
520
432
|
profileClient: profileClientFactory(feed),
|
|
521
433
|
attachEvidenceFrame,
|
|
522
|
-
cloudFallbackOnError,
|
|
523
|
-
feedName: feed.name,
|
|
524
|
-
...(cloudEscalation ? { cloudEscalation } : {}),
|
|
525
434
|
}),
|
|
526
435
|
}));
|
|
527
436
|
}
|