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.
- package/.cursorrules +74 -0
- package/CLAUDE.md +61 -0
- package/LICENSE +190 -0
- package/README.md +107 -0
- package/dist/index.js +194 -0
- package/package.json +84 -0
- package/src/antispoofing/detector.ts +509 -0
- package/src/antispoofing/index.ts +7 -0
- package/src/gamification/badges.ts +429 -0
- package/src/gamification/fleet-leaderboard.ts +293 -0
- package/src/gamification/index.ts +44 -0
- package/src/gamification/streaks.ts +243 -0
- package/src/gamification/trust-tiers.ts +393 -0
- package/src/gamification/zone-mastery.ts +256 -0
- package/src/index.ts +341 -0
- package/src/memory/index.ts +9 -0
- package/src/memory/place-cells.ts +279 -0
- package/src/memory/spatial-memory.ts +375 -0
- package/src/navigation/index.ts +1 -0
- package/src/navigation/pathfinding.ts +403 -0
- package/src/perception/camera.ts +249 -0
- package/src/perception/index.ts +2 -0
- package/src/types/index.ts +416 -0
- package/src/utils/crypto.ts +94 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/math.ts +204 -0
- package/src/verification/index.ts +9 -0
- package/src/verification/spatial-proof.ts +442 -0
- package/tests/antispoofing/detector.test.ts +196 -0
- package/tests/gamification/badges.test.ts +163 -0
- package/tests/gamification/fleet-leaderboard.test.ts +181 -0
- package/tests/gamification/streaks.test.ts +158 -0
- package/tests/gamification/trust-tiers.test.ts +165 -0
- package/tests/gamification/zone-mastery.test.ts +143 -0
- package/tests/memory/place-cells.test.ts +128 -0
- package/tests/stress/load.test.ts +499 -0
- package/tests/stress/security.test.ts +378 -0
- package/tests/stress/simulation.test.ts +361 -0
- package/tests/utils/crypto.test.ts +115 -0
- package/tests/utils/math.test.ts +195 -0
- package/tests/verification/spatial-proof.test.ts +299 -0
- 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
|
+
}
|