@camstack/addon-post-analysis 1.2.228 → 1.2.229
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.
|
@@ -35511,6 +35511,71 @@ function pairRiders(detections) {
|
|
|
35511
35511
|
}
|
|
35512
35512
|
return pairs;
|
|
35513
35513
|
}
|
|
35514
|
+
//#endregion
|
|
35515
|
+
//#region src/pipeline-analytics/pipeline/track-id.ts
|
|
35516
|
+
/**
|
|
35517
|
+
* A track's identity: short, random, and mintable inside a frame.
|
|
35518
|
+
*
|
|
35519
|
+
* ## Why not a counter, and why not the database
|
|
35520
|
+
*
|
|
35521
|
+
* The operator asked for an incremental id. It cannot be a database
|
|
35522
|
+
* auto-increment: the id is minted by the tracker MID-FRAME and is used
|
|
35523
|
+
* immediately — in the event, in the media key, as the `object-clip` vector's
|
|
35524
|
+
* row id — long before anything is written, so there is no rowid to wait for
|
|
35525
|
+
* and no await to spend on the hot path (D49: a one-shot never gates on a
|
|
35526
|
+
* fallible read).
|
|
35527
|
+
*
|
|
35528
|
+
* A local counter was already tried and reverted. `sort-tracker.ts` still
|
|
35529
|
+
* records what it did: "A bare per-process counter (`track-${n++}`) reset to 1
|
|
35530
|
+
* on every runner restart and collided across restarts AND devices — a
|
|
35531
|
+
* post-reboot track reused an old id and UPSERT-clobbered the old persisted row
|
|
35532
|
+
* (wrong 'now' dates, stale bestEventId thumbnails, cross-session media in the
|
|
35533
|
+
* detail view)."
|
|
35534
|
+
*
|
|
35535
|
+
* A durable counter is possible — the hub hands each node a BLOCK of ids ahead
|
|
35536
|
+
* of need, and the bookmark advances before they are used, so a crash leaves a
|
|
35537
|
+
* gap and never a duplicate. It was designed and NOT built: agents hold no
|
|
35538
|
+
* sqlite of their own (the hub is the only store), so the whole mechanism is a
|
|
35539
|
+
* cluster allocator with a degrade path, for a property nobody reads. The
|
|
35540
|
+
* operator chose the smaller thing.
|
|
35541
|
+
*
|
|
35542
|
+
* So: still random, and only shorter. 36 characters become 10.
|
|
35543
|
+
*
|
|
35544
|
+
* ## Is 10 enough
|
|
35545
|
+
*
|
|
35546
|
+
* Arithmetic, not optimism. The alphabet is 32 characters, so an id is 50 bits
|
|
35547
|
+
* and the space is 32^10 ≈ 1.13e15. The live table held 17 870 track rows under
|
|
35548
|
+
* a 7-day window (measured, `piano-riduzione-database`), so call the population
|
|
35549
|
+
* 20 000 ids that must not collide with each other. Birthday:
|
|
35550
|
+
*
|
|
35551
|
+
* p ≈ n² / 2N = 4e8 / 2.25e15 ≈ 1.8e-7 per retention window
|
|
35552
|
+
*
|
|
35553
|
+
* ~1e-5 per year at the current fleet. A collision is not cosmetic — two tracks
|
|
35554
|
+
* sharing an id UPSERT over each other, which is precisely the old counter's
|
|
35555
|
+
* failure — so the margin is deliberately three orders of magnitude wider than
|
|
35556
|
+
* "probably fine", and it is the reason this is 10 characters and not 6.
|
|
35557
|
+
*
|
|
35558
|
+
* ## The alphabet
|
|
35559
|
+
*
|
|
35560
|
+
* Crockford-style base32, lower case: the digits plus the letters, minus
|
|
35561
|
+
* `i`, `l`, `o` and `u`. The first three are misread as `1`/`1`/`0` by a human
|
|
35562
|
+
* copying an id out of a log; `u` goes with them so the set is exactly 32,
|
|
35563
|
+
* which is what lets five raw bits map to one character with NO modulo bias.
|
|
35564
|
+
* A `% 36` over a byte would make the first 16 characters ~1.5× more likely and
|
|
35565
|
+
* silently shrink the space the arithmetic above depends on.
|
|
35566
|
+
*/
|
|
35567
|
+
/** 32 characters: `0-9a-z` minus `i`, `l`, `o`, `u`. A power of two, on purpose. */
|
|
35568
|
+
var TRACK_ID_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
35569
|
+
/**
|
|
35570
|
+
* A fresh track id. Synchronous, no I/O, no shared state — safe to call from
|
|
35571
|
+
* inside the tracker's per-frame loop, which is the only place that calls it.
|
|
35572
|
+
*/
|
|
35573
|
+
function newTrackId() {
|
|
35574
|
+
const bytes = (0, node_crypto.randomBytes)(10);
|
|
35575
|
+
let out = "";
|
|
35576
|
+
for (let i = 0; i < 10; i += 1) out += TRACK_ID_ALPHABET[(bytes[i] ?? 0) & 31];
|
|
35577
|
+
return out;
|
|
35578
|
+
}
|
|
35514
35579
|
/** Per-(stage, gate) counter + sampling decision. One instance per tracker, so
|
|
35515
35580
|
* the counts are per camera and per detection source. */
|
|
35516
35581
|
var LooseBindSampler = class {
|
|
@@ -36789,7 +36854,7 @@ var SortTracker = class SortTracker {
|
|
|
36789
36854
|
used.add(di);
|
|
36790
36855
|
continue;
|
|
36791
36856
|
}
|
|
36792
|
-
const spawnedId = (
|
|
36857
|
+
const spawnedId = newTrackId();
|
|
36793
36858
|
this.reportSpawnBesideCoasting(spawnedId, det, surviving.filter((t) => t.lastSeen !== timestamp), detections, frameArea, timestamp);
|
|
36794
36859
|
surviving.push({
|
|
36795
36860
|
id: spawnedId,
|
|
@@ -60826,6 +60891,95 @@ async function reconcileGallerySamples(deps, options) {
|
|
|
60826
60891
|
};
|
|
60827
60892
|
}
|
|
60828
60893
|
//#endregion
|
|
60894
|
+
//#region src/pipeline-analytics/summary/summary-membership.ts
|
|
60895
|
+
/**
|
|
60896
|
+
* Who belongs to a summary, and which member is its primary.
|
|
60897
|
+
*
|
|
60898
|
+
* ## What this corrects
|
|
60899
|
+
*
|
|
60900
|
+
* `openOrAbsorb` absorbed ANY track born while a summary was open on the
|
|
60901
|
+
* camera, and `primary` went to whoever opened it. Both were measured wrong on
|
|
60902
|
+
* the live hub on 2026-09-13: synthetic sensor tracks (`pa-synth-…`) sat inside
|
|
60903
|
+
* summaries describing "who was here together", and a dog that walked in ahead
|
|
60904
|
+
* of its owner outranked the person behind it.
|
|
60905
|
+
*
|
|
60906
|
+
* The operator's rule: "persona animale veicolo la priorità. Gli eventi extra
|
|
60907
|
+
* sono sempre separati come eventi sintetici di sensori e pacchi."
|
|
60908
|
+
*
|
|
60909
|
+
* ## Why exclusion and priority live together
|
|
60910
|
+
*
|
|
60911
|
+
* They are the same question asked twice — "what is a subject in a group" —
|
|
60912
|
+
* and answering it in two files is how the two drift. A class that stops being
|
|
60913
|
+
* a member must not keep a rank, and a class that gains a rank must be a
|
|
60914
|
+
* member.
|
|
60915
|
+
*
|
|
60916
|
+
* Pure, so the surprising cases are pinned rather than discovered: the dog that
|
|
60917
|
+
* arrives first, the tie between two people, and the member whose class is not
|
|
60918
|
+
* known yet.
|
|
60919
|
+
*/
|
|
60920
|
+
/** The synthetic marker. `synthetic-track.ts` mints these and nothing parses it
|
|
60921
|
+
* except this — which is the one place it must be parsed. */
|
|
60922
|
+
var SYNTHETIC_TRACK_PREFIX = "pa-synth-";
|
|
60923
|
+
/**
|
|
60924
|
+
* Classes the operator keeps OUT of a group, by name.
|
|
60925
|
+
*
|
|
60926
|
+
* A delivered package is an event about a PLACE, not a member of something that
|
|
60927
|
+
* arrived together — the operator named it alongside the sensors.
|
|
60928
|
+
*/
|
|
60929
|
+
var NON_MEMBER_CLASSES = new Set(["package"]);
|
|
60930
|
+
/**
|
|
60931
|
+
* Is this track a member of a group at all?
|
|
60932
|
+
*
|
|
60933
|
+
* An unknown class is NOT a reason to refuse. The grouping is the point and the
|
|
60934
|
+
* label is decoration, and nothing revisits this decision — a member dropped
|
|
60935
|
+
* here is dropped for the life of the summary.
|
|
60936
|
+
*/
|
|
60937
|
+
function isSummaryEligible(trackId, className) {
|
|
60938
|
+
if (trackId.startsWith(SYNTHETIC_TRACK_PREFIX)) return false;
|
|
60939
|
+
return className === null || className === void 0 || !NON_MEMBER_CLASSES.has(className);
|
|
60940
|
+
}
|
|
60941
|
+
/**
|
|
60942
|
+
* The operator's order: person › animal › vehicle › everything else.
|
|
60943
|
+
*
|
|
60944
|
+
* Higher is more important. Everything unnamed — including a class that has not
|
|
60945
|
+
* resolved yet — sits below all three rather than above them: promoting an
|
|
60946
|
+
* unknown would let a momentary mis-read take the group's identity, and the
|
|
60947
|
+
* group shot with it.
|
|
60948
|
+
*/
|
|
60949
|
+
var CLASS_RANK = new Map([
|
|
60950
|
+
["person", 3],
|
|
60951
|
+
["animal", 2],
|
|
60952
|
+
["vehicle", 1]
|
|
60953
|
+
]);
|
|
60954
|
+
function classPriority(className) {
|
|
60955
|
+
if (className === null || className === void 0) return 0;
|
|
60956
|
+
return CLASS_RANK.get(className) ?? 0;
|
|
60957
|
+
}
|
|
60958
|
+
/**
|
|
60959
|
+
* Which member is the primary: highest class rank, ties broken by EARLIEST
|
|
60960
|
+
* arrival.
|
|
60961
|
+
*
|
|
60962
|
+
* The tie-break matters more than it looks. Two people are equal, so without it
|
|
60963
|
+
* the primary would flip on every join between equals — and the primary is what
|
|
60964
|
+
* a consumer reads to name the group, so it would rename itself for no reason
|
|
60965
|
+
* an operator could see.
|
|
60966
|
+
*
|
|
60967
|
+
* `null` for no members, never a fabricated id.
|
|
60968
|
+
*/
|
|
60969
|
+
function primaryTrackId(members) {
|
|
60970
|
+
let best = null;
|
|
60971
|
+
for (const m of members) {
|
|
60972
|
+
if (best === null) {
|
|
60973
|
+
best = m;
|
|
60974
|
+
continue;
|
|
60975
|
+
}
|
|
60976
|
+
const rank = classPriority(m.className);
|
|
60977
|
+
const bestRank = classPriority(best.className);
|
|
60978
|
+
if (rank > bestRank || rank === bestRank && m.joinedAt < best.joinedAt) best = m;
|
|
60979
|
+
}
|
|
60980
|
+
return best?.trackId ?? null;
|
|
60981
|
+
}
|
|
60982
|
+
//#endregion
|
|
60829
60983
|
//#region src/pipeline-analytics/summary/summary-store.ts
|
|
60830
60984
|
/**
|
|
60831
60985
|
* @durable class=ledger owner=pipeline-analytics
|
|
@@ -61090,7 +61244,18 @@ var SummaryStore = class {
|
|
|
61090
61244
|
async absorb(summary, input) {
|
|
61091
61245
|
const members = await this.membersOf(summary.id);
|
|
61092
61246
|
if (members.some((m) => m.trackId === input.trackId)) return summary;
|
|
61093
|
-
|
|
61247
|
+
const joined = {
|
|
61248
|
+
trackId: input.trackId,
|
|
61249
|
+
className: input.className ?? null,
|
|
61250
|
+
joinedAt: input.at
|
|
61251
|
+
};
|
|
61252
|
+
const primary = primaryTrackId([...members.map((m) => ({
|
|
61253
|
+
trackId: m.trackId,
|
|
61254
|
+
className: m.className,
|
|
61255
|
+
joinedAt: m.joinedAt
|
|
61256
|
+
})), joined]);
|
|
61257
|
+
await this.addMember(summary.id, input, primary === input.trackId ? "primary" : "sibling");
|
|
61258
|
+
if (primary === input.trackId) await this.demoteOtherPrimaries(summary.id, input.trackId, members);
|
|
61094
61259
|
const memberCount = members.length + 1;
|
|
61095
61260
|
const lastActiveAt = Math.max(summary.lastActiveAt, input.at);
|
|
61096
61261
|
await this.store.updateWhere.mutate({
|
|
@@ -61110,6 +61275,31 @@ var SummaryStore = class {
|
|
|
61110
61275
|
lastActiveAt
|
|
61111
61276
|
};
|
|
61112
61277
|
}
|
|
61278
|
+
/**
|
|
61279
|
+
* Put every OTHER member back to `sibling` after a new primary took the role.
|
|
61280
|
+
*
|
|
61281
|
+
* Best-effort per row and loud about a row it could not move: two primaries
|
|
61282
|
+
* is a wrong a consumer can see, but it is smaller than a lost member, so a
|
|
61283
|
+
* failure here never propagates to the join.
|
|
61284
|
+
*/
|
|
61285
|
+
async demoteOtherPrimaries(summaryIdValue, primaryTrack, previous) {
|
|
61286
|
+
for (const m of previous) {
|
|
61287
|
+
if (m.trackId === primaryTrack || m.role !== "primary") continue;
|
|
61288
|
+
try {
|
|
61289
|
+
await this.store.update.mutate({
|
|
61290
|
+
collection: SUMMARY_MEMBERS_COLLECTION,
|
|
61291
|
+
id: memberRowId(summaryIdValue, m.trackId),
|
|
61292
|
+
data: { role: "sibling" }
|
|
61293
|
+
});
|
|
61294
|
+
} catch (err) {
|
|
61295
|
+
this.logger.warn("summary: could not demote the previous primary — two remain", { meta: {
|
|
61296
|
+
summaryId: summaryIdValue,
|
|
61297
|
+
trackId: m.trackId,
|
|
61298
|
+
error: String(err)
|
|
61299
|
+
} });
|
|
61300
|
+
}
|
|
61301
|
+
}
|
|
61302
|
+
}
|
|
61113
61303
|
async addMember(summaryIdValue, input, role) {
|
|
61114
61304
|
await this.store.insertMany.mutate({
|
|
61115
61305
|
collection: SUMMARY_MEMBERS_COLLECTION,
|
|
@@ -68454,7 +68644,7 @@ var SyntheticTrackMaterializer = class {
|
|
|
68454
68644
|
this.deps = deps;
|
|
68455
68645
|
this.debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
68456
68646
|
this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS;
|
|
68457
|
-
this.makeId = deps.makeId ?? (() => `pa-synth-${(
|
|
68647
|
+
this.makeId = deps.makeId ?? (() => `pa-synth-${newTrackId()}`);
|
|
68458
68648
|
this.now = deps.now ?? Date.now;
|
|
68459
68649
|
}
|
|
68460
68650
|
/**
|
|
@@ -71436,6 +71626,16 @@ var SummarySealer = class {
|
|
|
71436
71626
|
}
|
|
71437
71627
|
async onTrackOpened(input) {
|
|
71438
71628
|
try {
|
|
71629
|
+
if (!isSummaryEligible(input.trackId, input.className)) {
|
|
71630
|
+
this.deps.logger.debug("summary: track is not a group member — left standalone", {
|
|
71631
|
+
tags: { deviceId: input.deviceId },
|
|
71632
|
+
meta: {
|
|
71633
|
+
trackId: input.trackId,
|
|
71634
|
+
className: input.className ?? null
|
|
71635
|
+
}
|
|
71636
|
+
});
|
|
71637
|
+
return;
|
|
71638
|
+
}
|
|
71439
71639
|
const row = await this.deps.summaries.openOrAbsorb({
|
|
71440
71640
|
deviceId: input.deviceId,
|
|
71441
71641
|
trackId: input.trackId,
|
|
@@ -4,7 +4,7 @@ import * as fs from "node:fs";
|
|
|
4
4
|
import { promises } from "node:fs";
|
|
5
5
|
import * as path$1 from "node:path";
|
|
6
6
|
import path from "node:path";
|
|
7
|
-
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
8
8
|
import { execFile } from "node:child_process";
|
|
9
9
|
import { startEventLoopStallMonitor } from "@camstack/system/addon-utils";
|
|
10
10
|
import sharp from "sharp";
|
|
@@ -35486,6 +35486,71 @@ function pairRiders(detections) {
|
|
|
35486
35486
|
}
|
|
35487
35487
|
return pairs;
|
|
35488
35488
|
}
|
|
35489
|
+
//#endregion
|
|
35490
|
+
//#region src/pipeline-analytics/pipeline/track-id.ts
|
|
35491
|
+
/**
|
|
35492
|
+
* A track's identity: short, random, and mintable inside a frame.
|
|
35493
|
+
*
|
|
35494
|
+
* ## Why not a counter, and why not the database
|
|
35495
|
+
*
|
|
35496
|
+
* The operator asked for an incremental id. It cannot be a database
|
|
35497
|
+
* auto-increment: the id is minted by the tracker MID-FRAME and is used
|
|
35498
|
+
* immediately — in the event, in the media key, as the `object-clip` vector's
|
|
35499
|
+
* row id — long before anything is written, so there is no rowid to wait for
|
|
35500
|
+
* and no await to spend on the hot path (D49: a one-shot never gates on a
|
|
35501
|
+
* fallible read).
|
|
35502
|
+
*
|
|
35503
|
+
* A local counter was already tried and reverted. `sort-tracker.ts` still
|
|
35504
|
+
* records what it did: "A bare per-process counter (`track-${n++}`) reset to 1
|
|
35505
|
+
* on every runner restart and collided across restarts AND devices — a
|
|
35506
|
+
* post-reboot track reused an old id and UPSERT-clobbered the old persisted row
|
|
35507
|
+
* (wrong 'now' dates, stale bestEventId thumbnails, cross-session media in the
|
|
35508
|
+
* detail view)."
|
|
35509
|
+
*
|
|
35510
|
+
* A durable counter is possible — the hub hands each node a BLOCK of ids ahead
|
|
35511
|
+
* of need, and the bookmark advances before they are used, so a crash leaves a
|
|
35512
|
+
* gap and never a duplicate. It was designed and NOT built: agents hold no
|
|
35513
|
+
* sqlite of their own (the hub is the only store), so the whole mechanism is a
|
|
35514
|
+
* cluster allocator with a degrade path, for a property nobody reads. The
|
|
35515
|
+
* operator chose the smaller thing.
|
|
35516
|
+
*
|
|
35517
|
+
* So: still random, and only shorter. 36 characters become 10.
|
|
35518
|
+
*
|
|
35519
|
+
* ## Is 10 enough
|
|
35520
|
+
*
|
|
35521
|
+
* Arithmetic, not optimism. The alphabet is 32 characters, so an id is 50 bits
|
|
35522
|
+
* and the space is 32^10 ≈ 1.13e15. The live table held 17 870 track rows under
|
|
35523
|
+
* a 7-day window (measured, `piano-riduzione-database`), so call the population
|
|
35524
|
+
* 20 000 ids that must not collide with each other. Birthday:
|
|
35525
|
+
*
|
|
35526
|
+
* p ≈ n² / 2N = 4e8 / 2.25e15 ≈ 1.8e-7 per retention window
|
|
35527
|
+
*
|
|
35528
|
+
* ~1e-5 per year at the current fleet. A collision is not cosmetic — two tracks
|
|
35529
|
+
* sharing an id UPSERT over each other, which is precisely the old counter's
|
|
35530
|
+
* failure — so the margin is deliberately three orders of magnitude wider than
|
|
35531
|
+
* "probably fine", and it is the reason this is 10 characters and not 6.
|
|
35532
|
+
*
|
|
35533
|
+
* ## The alphabet
|
|
35534
|
+
*
|
|
35535
|
+
* Crockford-style base32, lower case: the digits plus the letters, minus
|
|
35536
|
+
* `i`, `l`, `o` and `u`. The first three are misread as `1`/`1`/`0` by a human
|
|
35537
|
+
* copying an id out of a log; `u` goes with them so the set is exactly 32,
|
|
35538
|
+
* which is what lets five raw bits map to one character with NO modulo bias.
|
|
35539
|
+
* A `% 36` over a byte would make the first 16 characters ~1.5× more likely and
|
|
35540
|
+
* silently shrink the space the arithmetic above depends on.
|
|
35541
|
+
*/
|
|
35542
|
+
/** 32 characters: `0-9a-z` minus `i`, `l`, `o`, `u`. A power of two, on purpose. */
|
|
35543
|
+
var TRACK_ID_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
35544
|
+
/**
|
|
35545
|
+
* A fresh track id. Synchronous, no I/O, no shared state — safe to call from
|
|
35546
|
+
* inside the tracker's per-frame loop, which is the only place that calls it.
|
|
35547
|
+
*/
|
|
35548
|
+
function newTrackId() {
|
|
35549
|
+
const bytes = randomBytes(10);
|
|
35550
|
+
let out = "";
|
|
35551
|
+
for (let i = 0; i < 10; i += 1) out += TRACK_ID_ALPHABET[(bytes[i] ?? 0) & 31];
|
|
35552
|
+
return out;
|
|
35553
|
+
}
|
|
35489
35554
|
/** Per-(stage, gate) counter + sampling decision. One instance per tracker, so
|
|
35490
35555
|
* the counts are per camera and per detection source. */
|
|
35491
35556
|
var LooseBindSampler = class {
|
|
@@ -36764,7 +36829,7 @@ var SortTracker = class SortTracker {
|
|
|
36764
36829
|
used.add(di);
|
|
36765
36830
|
continue;
|
|
36766
36831
|
}
|
|
36767
|
-
const spawnedId =
|
|
36832
|
+
const spawnedId = newTrackId();
|
|
36768
36833
|
this.reportSpawnBesideCoasting(spawnedId, det, surviving.filter((t) => t.lastSeen !== timestamp), detections, frameArea, timestamp);
|
|
36769
36834
|
surviving.push({
|
|
36770
36835
|
id: spawnedId,
|
|
@@ -60752,6 +60817,95 @@ async function reconcileGallerySamples(deps, options) {
|
|
|
60752
60817
|
};
|
|
60753
60818
|
}
|
|
60754
60819
|
//#endregion
|
|
60820
|
+
//#region src/pipeline-analytics/summary/summary-membership.ts
|
|
60821
|
+
/**
|
|
60822
|
+
* Who belongs to a summary, and which member is its primary.
|
|
60823
|
+
*
|
|
60824
|
+
* ## What this corrects
|
|
60825
|
+
*
|
|
60826
|
+
* `openOrAbsorb` absorbed ANY track born while a summary was open on the
|
|
60827
|
+
* camera, and `primary` went to whoever opened it. Both were measured wrong on
|
|
60828
|
+
* the live hub on 2026-09-13: synthetic sensor tracks (`pa-synth-…`) sat inside
|
|
60829
|
+
* summaries describing "who was here together", and a dog that walked in ahead
|
|
60830
|
+
* of its owner outranked the person behind it.
|
|
60831
|
+
*
|
|
60832
|
+
* The operator's rule: "persona animale veicolo la priorità. Gli eventi extra
|
|
60833
|
+
* sono sempre separati come eventi sintetici di sensori e pacchi."
|
|
60834
|
+
*
|
|
60835
|
+
* ## Why exclusion and priority live together
|
|
60836
|
+
*
|
|
60837
|
+
* They are the same question asked twice — "what is a subject in a group" —
|
|
60838
|
+
* and answering it in two files is how the two drift. A class that stops being
|
|
60839
|
+
* a member must not keep a rank, and a class that gains a rank must be a
|
|
60840
|
+
* member.
|
|
60841
|
+
*
|
|
60842
|
+
* Pure, so the surprising cases are pinned rather than discovered: the dog that
|
|
60843
|
+
* arrives first, the tie between two people, and the member whose class is not
|
|
60844
|
+
* known yet.
|
|
60845
|
+
*/
|
|
60846
|
+
/** The synthetic marker. `synthetic-track.ts` mints these and nothing parses it
|
|
60847
|
+
* except this — which is the one place it must be parsed. */
|
|
60848
|
+
var SYNTHETIC_TRACK_PREFIX = "pa-synth-";
|
|
60849
|
+
/**
|
|
60850
|
+
* Classes the operator keeps OUT of a group, by name.
|
|
60851
|
+
*
|
|
60852
|
+
* A delivered package is an event about a PLACE, not a member of something that
|
|
60853
|
+
* arrived together — the operator named it alongside the sensors.
|
|
60854
|
+
*/
|
|
60855
|
+
var NON_MEMBER_CLASSES = new Set(["package"]);
|
|
60856
|
+
/**
|
|
60857
|
+
* Is this track a member of a group at all?
|
|
60858
|
+
*
|
|
60859
|
+
* An unknown class is NOT a reason to refuse. The grouping is the point and the
|
|
60860
|
+
* label is decoration, and nothing revisits this decision — a member dropped
|
|
60861
|
+
* here is dropped for the life of the summary.
|
|
60862
|
+
*/
|
|
60863
|
+
function isSummaryEligible(trackId, className) {
|
|
60864
|
+
if (trackId.startsWith(SYNTHETIC_TRACK_PREFIX)) return false;
|
|
60865
|
+
return className === null || className === void 0 || !NON_MEMBER_CLASSES.has(className);
|
|
60866
|
+
}
|
|
60867
|
+
/**
|
|
60868
|
+
* The operator's order: person › animal › vehicle › everything else.
|
|
60869
|
+
*
|
|
60870
|
+
* Higher is more important. Everything unnamed — including a class that has not
|
|
60871
|
+
* resolved yet — sits below all three rather than above them: promoting an
|
|
60872
|
+
* unknown would let a momentary mis-read take the group's identity, and the
|
|
60873
|
+
* group shot with it.
|
|
60874
|
+
*/
|
|
60875
|
+
var CLASS_RANK = new Map([
|
|
60876
|
+
["person", 3],
|
|
60877
|
+
["animal", 2],
|
|
60878
|
+
["vehicle", 1]
|
|
60879
|
+
]);
|
|
60880
|
+
function classPriority(className) {
|
|
60881
|
+
if (className === null || className === void 0) return 0;
|
|
60882
|
+
return CLASS_RANK.get(className) ?? 0;
|
|
60883
|
+
}
|
|
60884
|
+
/**
|
|
60885
|
+
* Which member is the primary: highest class rank, ties broken by EARLIEST
|
|
60886
|
+
* arrival.
|
|
60887
|
+
*
|
|
60888
|
+
* The tie-break matters more than it looks. Two people are equal, so without it
|
|
60889
|
+
* the primary would flip on every join between equals — and the primary is what
|
|
60890
|
+
* a consumer reads to name the group, so it would rename itself for no reason
|
|
60891
|
+
* an operator could see.
|
|
60892
|
+
*
|
|
60893
|
+
* `null` for no members, never a fabricated id.
|
|
60894
|
+
*/
|
|
60895
|
+
function primaryTrackId(members) {
|
|
60896
|
+
let best = null;
|
|
60897
|
+
for (const m of members) {
|
|
60898
|
+
if (best === null) {
|
|
60899
|
+
best = m;
|
|
60900
|
+
continue;
|
|
60901
|
+
}
|
|
60902
|
+
const rank = classPriority(m.className);
|
|
60903
|
+
const bestRank = classPriority(best.className);
|
|
60904
|
+
if (rank > bestRank || rank === bestRank && m.joinedAt < best.joinedAt) best = m;
|
|
60905
|
+
}
|
|
60906
|
+
return best?.trackId ?? null;
|
|
60907
|
+
}
|
|
60908
|
+
//#endregion
|
|
60755
60909
|
//#region src/pipeline-analytics/summary/summary-store.ts
|
|
60756
60910
|
/**
|
|
60757
60911
|
* @durable class=ledger owner=pipeline-analytics
|
|
@@ -61016,7 +61170,18 @@ var SummaryStore = class {
|
|
|
61016
61170
|
async absorb(summary, input) {
|
|
61017
61171
|
const members = await this.membersOf(summary.id);
|
|
61018
61172
|
if (members.some((m) => m.trackId === input.trackId)) return summary;
|
|
61019
|
-
|
|
61173
|
+
const joined = {
|
|
61174
|
+
trackId: input.trackId,
|
|
61175
|
+
className: input.className ?? null,
|
|
61176
|
+
joinedAt: input.at
|
|
61177
|
+
};
|
|
61178
|
+
const primary = primaryTrackId([...members.map((m) => ({
|
|
61179
|
+
trackId: m.trackId,
|
|
61180
|
+
className: m.className,
|
|
61181
|
+
joinedAt: m.joinedAt
|
|
61182
|
+
})), joined]);
|
|
61183
|
+
await this.addMember(summary.id, input, primary === input.trackId ? "primary" : "sibling");
|
|
61184
|
+
if (primary === input.trackId) await this.demoteOtherPrimaries(summary.id, input.trackId, members);
|
|
61020
61185
|
const memberCount = members.length + 1;
|
|
61021
61186
|
const lastActiveAt = Math.max(summary.lastActiveAt, input.at);
|
|
61022
61187
|
await this.store.updateWhere.mutate({
|
|
@@ -61036,6 +61201,31 @@ var SummaryStore = class {
|
|
|
61036
61201
|
lastActiveAt
|
|
61037
61202
|
};
|
|
61038
61203
|
}
|
|
61204
|
+
/**
|
|
61205
|
+
* Put every OTHER member back to `sibling` after a new primary took the role.
|
|
61206
|
+
*
|
|
61207
|
+
* Best-effort per row and loud about a row it could not move: two primaries
|
|
61208
|
+
* is a wrong a consumer can see, but it is smaller than a lost member, so a
|
|
61209
|
+
* failure here never propagates to the join.
|
|
61210
|
+
*/
|
|
61211
|
+
async demoteOtherPrimaries(summaryIdValue, primaryTrack, previous) {
|
|
61212
|
+
for (const m of previous) {
|
|
61213
|
+
if (m.trackId === primaryTrack || m.role !== "primary") continue;
|
|
61214
|
+
try {
|
|
61215
|
+
await this.store.update.mutate({
|
|
61216
|
+
collection: SUMMARY_MEMBERS_COLLECTION,
|
|
61217
|
+
id: memberRowId(summaryIdValue, m.trackId),
|
|
61218
|
+
data: { role: "sibling" }
|
|
61219
|
+
});
|
|
61220
|
+
} catch (err) {
|
|
61221
|
+
this.logger.warn("summary: could not demote the previous primary — two remain", { meta: {
|
|
61222
|
+
summaryId: summaryIdValue,
|
|
61223
|
+
trackId: m.trackId,
|
|
61224
|
+
error: String(err)
|
|
61225
|
+
} });
|
|
61226
|
+
}
|
|
61227
|
+
}
|
|
61228
|
+
}
|
|
61039
61229
|
async addMember(summaryIdValue, input, role) {
|
|
61040
61230
|
await this.store.insertMany.mutate({
|
|
61041
61231
|
collection: SUMMARY_MEMBERS_COLLECTION,
|
|
@@ -68380,7 +68570,7 @@ var SyntheticTrackMaterializer = class {
|
|
|
68380
68570
|
this.deps = deps;
|
|
68381
68571
|
this.debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
68382
68572
|
this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS;
|
|
68383
|
-
this.makeId = deps.makeId ?? (() => `pa-synth-${
|
|
68573
|
+
this.makeId = deps.makeId ?? (() => `pa-synth-${newTrackId()}`);
|
|
68384
68574
|
this.now = deps.now ?? Date.now;
|
|
68385
68575
|
}
|
|
68386
68576
|
/**
|
|
@@ -71362,6 +71552,16 @@ var SummarySealer = class {
|
|
|
71362
71552
|
}
|
|
71363
71553
|
async onTrackOpened(input) {
|
|
71364
71554
|
try {
|
|
71555
|
+
if (!isSummaryEligible(input.trackId, input.className)) {
|
|
71556
|
+
this.deps.logger.debug("summary: track is not a group member — left standalone", {
|
|
71557
|
+
tags: { deviceId: input.deviceId },
|
|
71558
|
+
meta: {
|
|
71559
|
+
trackId: input.trackId,
|
|
71560
|
+
className: input.className ?? null
|
|
71561
|
+
}
|
|
71562
|
+
});
|
|
71563
|
+
return;
|
|
71564
|
+
}
|
|
71365
71565
|
const row = await this.deps.summaries.openOrAbsorb({
|
|
71366
71566
|
deviceId: input.deviceId,
|
|
71367
71567
|
trackId: input.trackId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-post-analysis",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.229",
|
|
4
4
|
"description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|