gridstamp 1.0.0

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 (42) hide show
  1. package/.cursorrules +74 -0
  2. package/CLAUDE.md +61 -0
  3. package/LICENSE +190 -0
  4. package/README.md +107 -0
  5. package/dist/index.js +194 -0
  6. package/package.json +84 -0
  7. package/src/antispoofing/detector.ts +509 -0
  8. package/src/antispoofing/index.ts +7 -0
  9. package/src/gamification/badges.ts +429 -0
  10. package/src/gamification/fleet-leaderboard.ts +293 -0
  11. package/src/gamification/index.ts +44 -0
  12. package/src/gamification/streaks.ts +243 -0
  13. package/src/gamification/trust-tiers.ts +393 -0
  14. package/src/gamification/zone-mastery.ts +256 -0
  15. package/src/index.ts +341 -0
  16. package/src/memory/index.ts +9 -0
  17. package/src/memory/place-cells.ts +279 -0
  18. package/src/memory/spatial-memory.ts +375 -0
  19. package/src/navigation/index.ts +1 -0
  20. package/src/navigation/pathfinding.ts +403 -0
  21. package/src/perception/camera.ts +249 -0
  22. package/src/perception/index.ts +2 -0
  23. package/src/types/index.ts +416 -0
  24. package/src/utils/crypto.ts +94 -0
  25. package/src/utils/index.ts +2 -0
  26. package/src/utils/math.ts +204 -0
  27. package/src/verification/index.ts +9 -0
  28. package/src/verification/spatial-proof.ts +442 -0
  29. package/tests/antispoofing/detector.test.ts +196 -0
  30. package/tests/gamification/badges.test.ts +163 -0
  31. package/tests/gamification/fleet-leaderboard.test.ts +181 -0
  32. package/tests/gamification/streaks.test.ts +158 -0
  33. package/tests/gamification/trust-tiers.test.ts +165 -0
  34. package/tests/gamification/zone-mastery.test.ts +143 -0
  35. package/tests/memory/place-cells.test.ts +128 -0
  36. package/tests/stress/load.test.ts +499 -0
  37. package/tests/stress/security.test.ts +378 -0
  38. package/tests/stress/simulation.test.ts +361 -0
  39. package/tests/utils/crypto.test.ts +115 -0
  40. package/tests/utils/math.test.ts +195 -0
  41. package/tests/verification/spatial-proof.test.ts +299 -0
  42. package/tsconfig.json +26 -0
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Trust Tier System — Progressive robot trust based on verified operations
3
+ *
4
+ * Maps WeMeetWeMet's 5-level progression to robot fleet management:
5
+ * Untrusted → Probation → Verified → Trusted → Elite → Autonomous
6
+ *
7
+ * Each tier controls: fee multiplier, tx limits, verification frequency, autonomy level.
8
+ * Tier changes are HMAC-signed to prevent forgery.
9
+ */
10
+
11
+ import { hmacSign, hmacVerify, generateNonce } from '../utils/crypto.js';
12
+
13
+ export enum TrustTier {
14
+ UNTRUSTED = 0,
15
+ PROBATION = 1,
16
+ VERIFIED = 2,
17
+ TRUSTED = 3,
18
+ ELITE = 4,
19
+ AUTONOMOUS = 5,
20
+ }
21
+
22
+ export interface TierConfig {
23
+ readonly name: string;
24
+ readonly minPoints: number;
25
+ readonly feeMultiplier: number; // 1.0 = base fee, lower = discount
26
+ readonly maxTransactionAmount: number; // max single tx
27
+ readonly verificationFrequency: number; // every N operations must verify
28
+ readonly autonomyLevel: number; // 0-100, how much unsupervised action
29
+ readonly minStreakDays: number; // minimum consecutive days for promotion
30
+ readonly minBadges: number; // minimum badges required
31
+ readonly minZoneMastery: number; // minimum zone mastery score [0,1]
32
+ }
33
+
34
+ export const TIER_CONFIGS: Readonly<Record<TrustTier, TierConfig>> = {
35
+ [TrustTier.UNTRUSTED]: {
36
+ name: 'Untrusted',
37
+ minPoints: 0,
38
+ feeMultiplier: 2.5,
39
+ maxTransactionAmount: 10,
40
+ verificationFrequency: 1, // verify EVERY operation
41
+ autonomyLevel: 0,
42
+ minStreakDays: 0,
43
+ minBadges: 0,
44
+ minZoneMastery: 0,
45
+ },
46
+ [TrustTier.PROBATION]: {
47
+ name: 'Probation',
48
+ minPoints: 100,
49
+ feeMultiplier: 2.0,
50
+ maxTransactionAmount: 50,
51
+ verificationFrequency: 1,
52
+ autonomyLevel: 10,
53
+ minStreakDays: 3,
54
+ minBadges: 1,
55
+ minZoneMastery: 0.1,
56
+ },
57
+ [TrustTier.VERIFIED]: {
58
+ name: 'Verified',
59
+ minPoints: 500,
60
+ feeMultiplier: 1.5,
61
+ maxTransactionAmount: 200,
62
+ verificationFrequency: 3, // every 3rd operation
63
+ autonomyLevel: 40,
64
+ minStreakDays: 7,
65
+ minBadges: 3,
66
+ minZoneMastery: 0.3,
67
+ },
68
+ [TrustTier.TRUSTED]: {
69
+ name: 'Trusted',
70
+ minPoints: 2000,
71
+ feeMultiplier: 1.2,
72
+ maxTransactionAmount: 1000,
73
+ verificationFrequency: 5,
74
+ autonomyLevel: 70,
75
+ minStreakDays: 14,
76
+ minBadges: 8,
77
+ minZoneMastery: 0.5,
78
+ },
79
+ [TrustTier.ELITE]: {
80
+ name: 'Elite',
81
+ minPoints: 5000,
82
+ feeMultiplier: 1.0,
83
+ maxTransactionAmount: 5000,
84
+ verificationFrequency: 10,
85
+ autonomyLevel: 90,
86
+ minStreakDays: 30,
87
+ minBadges: 15,
88
+ minZoneMastery: 0.7,
89
+ },
90
+ [TrustTier.AUTONOMOUS]: {
91
+ name: 'Autonomous',
92
+ minPoints: 10000,
93
+ feeMultiplier: 0.8, // discount for top-tier
94
+ maxTransactionAmount: 25000,
95
+ verificationFrequency: 20, // spot checks only
96
+ autonomyLevel: 100,
97
+ minStreakDays: 60,
98
+ minBadges: 20,
99
+ minZoneMastery: 0.85,
100
+ },
101
+ };
102
+
103
+ export interface TierChangeEvent {
104
+ readonly id: string;
105
+ readonly robotId: string;
106
+ readonly previousTier: TrustTier;
107
+ readonly newTier: TrustTier;
108
+ readonly reason: string;
109
+ readonly points: number;
110
+ readonly timestamp: number;
111
+ readonly signature: string; // HMAC-signed to prevent forgery
112
+ }
113
+
114
+ export interface RobotTrustProfile {
115
+ readonly robotId: string;
116
+ readonly currentTier: TrustTier;
117
+ readonly points: number;
118
+ readonly totalVerifications: number;
119
+ readonly successfulVerifications: number;
120
+ readonly failedVerifications: number;
121
+ readonly spoofingIncidents: number;
122
+ readonly consecutiveSuccesses: number;
123
+ readonly history: readonly TierChangeEvent[];
124
+ readonly createdAt: number;
125
+ readonly lastActivityAt: number;
126
+ }
127
+
128
+ /**
129
+ * Sign a tier change event to prevent forgery
130
+ */
131
+ function signTierChange(
132
+ robotId: string,
133
+ previousTier: TrustTier,
134
+ newTier: TrustTier,
135
+ points: number,
136
+ timestamp: number,
137
+ secret: string,
138
+ ): string {
139
+ const payload = `tier-change:${robotId}:${previousTier}:${newTier}:${points}:${timestamp}`;
140
+ return hmacSign(Buffer.from(payload), secret);
141
+ }
142
+
143
+ /**
144
+ * Verify a tier change event signature
145
+ */
146
+ export function verifyTierChange(event: TierChangeEvent, secret: string): boolean {
147
+ const payload = `tier-change:${event.robotId}:${event.previousTier}:${event.newTier}:${event.points}:${event.timestamp}`;
148
+ return hmacVerify(Buffer.from(payload), event.signature, secret);
149
+ }
150
+
151
+ export class TrustTierSystem {
152
+ private readonly profiles = new Map<string, RobotTrustProfile>();
153
+ private readonly secret: string;
154
+
155
+ constructor(secret: string) {
156
+ if (!secret || secret.length < 32) {
157
+ throw new Error('TrustTierSystem requires a secret of at least 32 chars');
158
+ }
159
+ this.secret = secret;
160
+ }
161
+
162
+ /**
163
+ * Register a new robot (starts at Untrusted)
164
+ */
165
+ register(robotId: string): RobotTrustProfile {
166
+ if (!robotId || robotId.trim().length === 0) {
167
+ throw new Error('robotId is required');
168
+ }
169
+ if (this.profiles.has(robotId)) {
170
+ throw new Error(`Robot ${robotId} already registered`);
171
+ }
172
+ const now = Date.now();
173
+ const profile: RobotTrustProfile = {
174
+ robotId,
175
+ currentTier: TrustTier.UNTRUSTED,
176
+ points: 0,
177
+ totalVerifications: 0,
178
+ successfulVerifications: 0,
179
+ failedVerifications: 0,
180
+ spoofingIncidents: 0,
181
+ consecutiveSuccesses: 0,
182
+ history: [],
183
+ createdAt: now,
184
+ lastActivityAt: now,
185
+ };
186
+ this.profiles.set(robotId, profile);
187
+ return profile;
188
+ }
189
+
190
+ /**
191
+ * Get a robot's current trust profile
192
+ */
193
+ getProfile(robotId: string): RobotTrustProfile | undefined {
194
+ return this.profiles.get(robotId);
195
+ }
196
+
197
+ /**
198
+ * Record a successful spatial verification and award points
199
+ */
200
+ recordSuccess(robotId: string, pointsEarned: number): RobotTrustProfile {
201
+ const profile = this.profiles.get(robotId);
202
+ if (!profile) throw new Error(`Robot ${robotId} not registered`);
203
+ if (pointsEarned < 0) throw new Error('Points earned must be non-negative');
204
+
205
+ const updated: RobotTrustProfile = {
206
+ ...profile,
207
+ points: profile.points + pointsEarned,
208
+ totalVerifications: profile.totalVerifications + 1,
209
+ successfulVerifications: profile.successfulVerifications + 1,
210
+ consecutiveSuccesses: profile.consecutiveSuccesses + 1,
211
+ lastActivityAt: Date.now(),
212
+ };
213
+ this.profiles.set(robotId, updated);
214
+ return this.checkPromotion(robotId);
215
+ }
216
+
217
+ /**
218
+ * Record a failed verification — penalty + possible demotion
219
+ */
220
+ recordFailure(robotId: string, isSpoofingAttempt: boolean = false): RobotTrustProfile {
221
+ const profile = this.profiles.get(robotId);
222
+ if (!profile) throw new Error(`Robot ${robotId} not registered`);
223
+
224
+ // Penalty: lose 10% of points on failure, 25% on spoofing
225
+ const penalty = isSpoofingAttempt
226
+ ? Math.floor(profile.points * 0.25)
227
+ : Math.floor(profile.points * 0.10);
228
+
229
+ const updated: RobotTrustProfile = {
230
+ ...profile,
231
+ points: Math.max(0, profile.points - penalty),
232
+ totalVerifications: profile.totalVerifications + 1,
233
+ failedVerifications: profile.failedVerifications + 1,
234
+ spoofingIncidents: profile.spoofingIncidents + (isSpoofingAttempt ? 1 : 0),
235
+ consecutiveSuccesses: 0, // reset streak
236
+ lastActivityAt: Date.now(),
237
+ };
238
+ this.profiles.set(robotId, updated);
239
+ return this.checkDemotion(robotId, isSpoofingAttempt);
240
+ }
241
+
242
+ /**
243
+ * Check if robot qualifies for tier promotion
244
+ */
245
+ private checkPromotion(robotId: string): RobotTrustProfile {
246
+ const profile = this.profiles.get(robotId)!;
247
+ const currentTier = profile.currentTier;
248
+
249
+ // Can't promote beyond Autonomous
250
+ if (currentTier >= TrustTier.AUTONOMOUS) return profile;
251
+
252
+ const nextTier = (currentTier + 1) as TrustTier;
253
+ const nextConfig = TIER_CONFIGS[nextTier];
254
+
255
+ // Check all promotion criteria
256
+ if (profile.points < nextConfig.minPoints) return profile;
257
+
258
+ // Success rate must be > 90% for promotion
259
+ if (profile.totalVerifications > 0) {
260
+ const successRate = profile.successfulVerifications / profile.totalVerifications;
261
+ if (successRate < 0.9) return profile;
262
+ }
263
+
264
+ // No spoofing incidents allowed for Trusted+
265
+ if (nextTier >= TrustTier.TRUSTED && profile.spoofingIncidents > 0) return profile;
266
+
267
+ return this.changeTier(robotId, nextTier, 'Promotion: met all tier requirements');
268
+ }
269
+
270
+ /**
271
+ * Check if robot should be demoted
272
+ */
273
+ private checkDemotion(robotId: string, wasSpoofing: boolean): RobotTrustProfile {
274
+ const profile = this.profiles.get(robotId)!;
275
+ const currentTier = profile.currentTier;
276
+
277
+ // Can't demote below Untrusted
278
+ if (currentTier <= TrustTier.UNTRUSTED) return profile;
279
+
280
+ // Immediate demotion on spoofing if Trusted or above
281
+ if (wasSpoofing && currentTier >= TrustTier.TRUSTED) {
282
+ // Drop TWO tiers for spoofing at high trust
283
+ const newTier = Math.max(TrustTier.UNTRUSTED, currentTier - 2) as TrustTier;
284
+ return this.changeTier(robotId, newTier, 'Demotion: spoofing detected at high trust level');
285
+ }
286
+
287
+ // Demote if points dropped below current tier minimum
288
+ const currentConfig = TIER_CONFIGS[currentTier];
289
+ if (profile.points < currentConfig.minPoints) {
290
+ const newTier = (currentTier - 1) as TrustTier;
291
+ return this.changeTier(robotId, newTier, 'Demotion: points below tier minimum');
292
+ }
293
+
294
+ // Demote if success rate drops below 80%
295
+ if (profile.totalVerifications >= 10) {
296
+ const successRate = profile.successfulVerifications / profile.totalVerifications;
297
+ if (successRate < 0.8) {
298
+ const newTier = (currentTier - 1) as TrustTier;
299
+ return this.changeTier(robotId, newTier, 'Demotion: success rate below 80%');
300
+ }
301
+ }
302
+
303
+ return profile;
304
+ }
305
+
306
+ /**
307
+ * Execute tier change with HMAC-signed event
308
+ */
309
+ private changeTier(robotId: string, newTier: TrustTier, reason: string): RobotTrustProfile {
310
+ const profile = this.profiles.get(robotId)!;
311
+ if (profile.currentTier === newTier) return profile;
312
+
313
+ const now = Date.now();
314
+ const signature = signTierChange(
315
+ robotId,
316
+ profile.currentTier,
317
+ newTier,
318
+ profile.points,
319
+ now,
320
+ this.secret,
321
+ );
322
+
323
+ const event: TierChangeEvent = {
324
+ id: generateNonce(16),
325
+ robotId,
326
+ previousTier: profile.currentTier,
327
+ newTier,
328
+ reason,
329
+ points: profile.points,
330
+ timestamp: now,
331
+ signature,
332
+ };
333
+
334
+ const updated: RobotTrustProfile = {
335
+ ...profile,
336
+ currentTier: newTier,
337
+ history: [...profile.history, event],
338
+ };
339
+ this.profiles.set(robotId, updated);
340
+ return updated;
341
+ }
342
+
343
+ /**
344
+ * Get the fee multiplier for a robot
345
+ */
346
+ getFeeMultiplier(robotId: string): number {
347
+ const profile = this.profiles.get(robotId);
348
+ if (!profile) return TIER_CONFIGS[TrustTier.UNTRUSTED].feeMultiplier;
349
+ return TIER_CONFIGS[profile.currentTier].feeMultiplier;
350
+ }
351
+
352
+ /**
353
+ * Check if a transaction amount is within tier limits
354
+ */
355
+ isWithinLimits(robotId: string, amount: number): boolean {
356
+ const profile = this.profiles.get(robotId);
357
+ if (!profile) return amount <= TIER_CONFIGS[TrustTier.UNTRUSTED].maxTransactionAmount;
358
+ return amount <= TIER_CONFIGS[profile.currentTier].maxTransactionAmount;
359
+ }
360
+
361
+ /**
362
+ * Check if verification is required for this operation
363
+ */
364
+ requiresVerification(robotId: string, operationIndex: number): boolean {
365
+ const profile = this.profiles.get(robotId);
366
+ if (!profile) return true; // unknown robots always verify
367
+ const freq = TIER_CONFIGS[profile.currentTier].verificationFrequency;
368
+ return operationIndex % freq === 0;
369
+ }
370
+
371
+ /**
372
+ * Get all registered robots count
373
+ */
374
+ get robotCount(): number {
375
+ return this.profiles.size;
376
+ }
377
+
378
+ /**
379
+ * Verify integrity of a robot's entire tier history
380
+ */
381
+ verifyHistory(robotId: string): { valid: boolean; invalidEvents: number[] } {
382
+ const profile = this.profiles.get(robotId);
383
+ if (!profile) return { valid: false, invalidEvents: [] };
384
+
385
+ const invalidEvents: number[] = [];
386
+ for (let i = 0; i < profile.history.length; i++) {
387
+ if (!verifyTierChange(profile.history[i]!, this.secret)) {
388
+ invalidEvents.push(i);
389
+ }
390
+ }
391
+ return { valid: invalidEvents.length === 0, invalidEvents };
392
+ }
393
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Zone Mastery System — Geographic expertise tracking
3
+ *
4
+ * Maps WeMeetWeMet's venue collection to robot spatial knowledge:
5
+ * Zone = AABB region of space
6
+ * Mastery = coverage × success_rate × time_factor
7
+ * Higher mastery = more trusted operations in that zone
8
+ */
9
+
10
+ import type { Vec3, AABB } from '../types/index.js';
11
+ import { hmacSign, generateNonce } from '../utils/crypto.js';
12
+
13
+ export interface Zone {
14
+ readonly id: string;
15
+ readonly name: string;
16
+ readonly bounds: AABB;
17
+ readonly createdAt: number;
18
+ }
19
+
20
+ export interface ZoneMasteryRecord {
21
+ readonly zoneId: string;
22
+ readonly robotId: string;
23
+ readonly visitCount: number;
24
+ readonly successCount: number;
25
+ readonly failureCount: number;
26
+ readonly totalPointsInZone: number;
27
+ readonly uniquePositions: number; // distinct locations visited (discretized)
28
+ readonly firstVisit: number;
29
+ readonly lastVisit: number;
30
+ }
31
+
32
+ export interface ZoneMasteryScore {
33
+ readonly zoneId: string;
34
+ readonly coverage: number; // 0-1: how much of zone explored
35
+ readonly successRate: number; // 0-1: verification success rate
36
+ readonly timeFactor: number; // 0-1: recency + duration
37
+ readonly composite: number; // weighted combination
38
+ }
39
+
40
+ function positionKey(pos: Vec3, gridSize: number): string {
41
+ const gx = Math.floor(pos.x / gridSize);
42
+ const gy = Math.floor(pos.y / gridSize);
43
+ const gz = Math.floor(pos.z / gridSize);
44
+ return `${gx}:${gy}:${gz}`;
45
+ }
46
+
47
+ function isInBounds(pos: Vec3, bounds: AABB): boolean {
48
+ return (
49
+ pos.x >= bounds.min.x && pos.x <= bounds.max.x &&
50
+ pos.y >= bounds.min.y && pos.y <= bounds.max.y &&
51
+ pos.z >= bounds.min.z && pos.z <= bounds.max.z
52
+ );
53
+ }
54
+
55
+ function zoneVolume(bounds: AABB): number {
56
+ return (
57
+ (bounds.max.x - bounds.min.x) *
58
+ (bounds.max.y - bounds.min.y) *
59
+ Math.max(1, bounds.max.z - bounds.min.z) // avoid zero for 2D zones
60
+ );
61
+ }
62
+
63
+ export class ZoneMasterySystem {
64
+ private readonly zones = new Map<string, Zone>();
65
+ private readonly mastery = new Map<string, ZoneMasteryRecord>(); // "robotId:zoneId" → record
66
+ private readonly visitedPositions = new Map<string, Set<string>>(); // "robotId:zoneId" → position keys
67
+ private readonly secret: string;
68
+ private readonly gridSize: number; // meters per cell for discretization
69
+
70
+ constructor(secret: string, gridSize: number = 2.0) {
71
+ if (!secret || secret.length < 32) {
72
+ throw new Error('ZoneMasterySystem requires a secret of at least 32 chars');
73
+ }
74
+ this.secret = secret;
75
+ this.gridSize = gridSize;
76
+ }
77
+
78
+ /**
79
+ * Define a new zone
80
+ */
81
+ defineZone(name: string, bounds: AABB): Zone {
82
+ if (!name) throw new Error('Zone name is required');
83
+ if (bounds.min.x >= bounds.max.x || bounds.min.y >= bounds.max.y) {
84
+ throw new Error('Invalid zone bounds: min must be less than max');
85
+ }
86
+ const zone: Zone = {
87
+ id: generateNonce(8),
88
+ name,
89
+ bounds,
90
+ createdAt: Date.now(),
91
+ };
92
+ this.zones.set(zone.id, zone);
93
+ return zone;
94
+ }
95
+
96
+ /**
97
+ * Find which zone a position belongs to (first match)
98
+ */
99
+ findZone(position: Vec3): Zone | undefined {
100
+ for (const zone of this.zones.values()) {
101
+ if (isInBounds(position, zone.bounds)) {
102
+ return zone;
103
+ }
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ /**
109
+ * Find all zones a position belongs to
110
+ */
111
+ findAllZones(position: Vec3): Zone[] {
112
+ const result: Zone[] = [];
113
+ for (const zone of this.zones.values()) {
114
+ if (isInBounds(position, zone.bounds)) {
115
+ result.push(zone);
116
+ }
117
+ }
118
+ return result;
119
+ }
120
+
121
+ /**
122
+ * Record a robot visit to a position (auto-discovers zone)
123
+ */
124
+ recordVisit(
125
+ robotId: string,
126
+ position: Vec3,
127
+ success: boolean,
128
+ pointsEarned: number = 0,
129
+ ): { zoneId: string | null; mastery: ZoneMasteryScore | null } {
130
+ if (!robotId) throw new Error('robotId is required');
131
+
132
+ const zone = this.findZone(position);
133
+ if (!zone) return { zoneId: null, mastery: null };
134
+
135
+ const key = `${robotId}:${zone.id}`;
136
+ const existing = this.mastery.get(key);
137
+ const posKey = positionKey(position, this.gridSize);
138
+
139
+ // Track unique positions
140
+ if (!this.visitedPositions.has(key)) {
141
+ this.visitedPositions.set(key, new Set());
142
+ }
143
+ this.visitedPositions.get(key)!.add(posKey);
144
+ const uniquePositions = this.visitedPositions.get(key)!.size;
145
+
146
+ const now = Date.now();
147
+ const record: ZoneMasteryRecord = {
148
+ zoneId: zone.id,
149
+ robotId,
150
+ visitCount: (existing?.visitCount ?? 0) + 1,
151
+ successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
152
+ failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
153
+ totalPointsInZone: (existing?.totalPointsInZone ?? 0) + pointsEarned,
154
+ uniquePositions,
155
+ firstVisit: existing?.firstVisit ?? now,
156
+ lastVisit: now,
157
+ };
158
+ this.mastery.set(key, record);
159
+
160
+ return { zoneId: zone.id, mastery: this.computeMastery(record, zone) };
161
+ }
162
+
163
+ /**
164
+ * Compute mastery score for a robot in a zone
165
+ */
166
+ private computeMastery(record: ZoneMasteryRecord, zone: Zone): ZoneMasteryScore {
167
+ // Coverage: unique cells / estimated total cells in zone
168
+ const vol = zoneVolume(zone.bounds);
169
+ const cellVol = this.gridSize * this.gridSize * Math.max(this.gridSize, 1);
170
+ const estimatedCells = Math.max(1, Math.ceil(vol / cellVol));
171
+ const coverage = Math.min(1, record.uniquePositions / estimatedCells);
172
+
173
+ // Success rate
174
+ const total = record.visitCount;
175
+ const successRate = total > 0 ? record.successCount / total : 0;
176
+
177
+ // Time factor: recency (exponential decay) + duration bonus
178
+ const now = Date.now();
179
+ const hoursSinceLastVisit = (now - record.lastVisit) / (1000 * 60 * 60);
180
+ const recency = Math.exp(-hoursSinceLastVisit / 168); // half-life ~1 week
181
+ const durationDays = (record.lastVisit - record.firstVisit) / (1000 * 60 * 60 * 24);
182
+ const durationBonus = Math.min(0.5, durationDays / 60); // max 0.5 after 60 days
183
+ const timeFactor = Math.min(1, recency * 0.6 + durationBonus + 0.1); // 0.1 base
184
+
185
+ // Composite: coverage 40%, success rate 35%, time 25%
186
+ const composite = coverage * 0.4 + successRate * 0.35 + timeFactor * 0.25;
187
+
188
+ return {
189
+ zoneId: record.zoneId,
190
+ coverage,
191
+ successRate,
192
+ timeFactor,
193
+ composite,
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Get mastery score for a specific robot+zone
199
+ */
200
+ getMastery(robotId: string, zoneId: string): ZoneMasteryScore | undefined {
201
+ const key = `${robotId}:${zoneId}`;
202
+ const record = this.mastery.get(key);
203
+ if (!record) return undefined;
204
+ const zone = this.zones.get(zoneId);
205
+ if (!zone) return undefined;
206
+ return this.computeMastery(record, zone);
207
+ }
208
+
209
+ /**
210
+ * Get all mastery scores for a robot
211
+ */
212
+ getAllMastery(robotId: string): ZoneMasteryScore[] {
213
+ const scores: ZoneMasteryScore[] = [];
214
+ for (const [key, record] of this.mastery.entries()) {
215
+ if (!key.startsWith(`${robotId}:`)) continue;
216
+ const zone = this.zones.get(record.zoneId);
217
+ if (!zone) continue;
218
+ scores.push(this.computeMastery(record, zone));
219
+ }
220
+ return scores;
221
+ }
222
+
223
+ /**
224
+ * Get max mastery score across all zones for a robot
225
+ */
226
+ getMaxMastery(robotId: string): number {
227
+ const scores = this.getAllMastery(robotId);
228
+ if (scores.length === 0) return 0;
229
+ return Math.max(...scores.map(s => s.composite));
230
+ }
231
+
232
+ /**
233
+ * Get number of zones a robot has visited
234
+ */
235
+ getZoneCount(robotId: string): number {
236
+ let count = 0;
237
+ for (const key of this.mastery.keys()) {
238
+ if (key.startsWith(`${robotId}:`)) count++;
239
+ }
240
+ return count;
241
+ }
242
+
243
+ /**
244
+ * Sign a mastery snapshot for external verification
245
+ */
246
+ signMasterySnapshot(robotId: string): { scores: ZoneMasteryScore[]; signature: string } {
247
+ const scores = this.getAllMastery(robotId);
248
+ const payload = `mastery:${robotId}:${JSON.stringify(scores)}`;
249
+ const signature = hmacSign(Buffer.from(payload), this.secret);
250
+ return { scores, signature };
251
+ }
252
+
253
+ get totalZones(): number {
254
+ return this.zones.size;
255
+ }
256
+ }