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,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Math utilities for GridStamp
|
|
3
|
+
* Vector ops, quaternion math, coordinate transforms
|
|
4
|
+
*/
|
|
5
|
+
import type { Vec3, Quaternion, Mat4, Pose } from '../types/index.js';
|
|
6
|
+
|
|
7
|
+
// ============================================================
|
|
8
|
+
// VECTOR OPERATIONS
|
|
9
|
+
// ============================================================
|
|
10
|
+
|
|
11
|
+
export function vec3Add(a: Vec3, b: Vec3): Vec3 {
|
|
12
|
+
return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function vec3Sub(a: Vec3, b: Vec3): Vec3 {
|
|
16
|
+
return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function vec3Scale(v: Vec3, s: number): Vec3 {
|
|
20
|
+
return { x: v.x * s, y: v.y * s, z: v.z * s };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function vec3Dot(a: Vec3, b: Vec3): number {
|
|
24
|
+
return a.x * b.x + a.y * b.y + a.z * b.z;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function vec3Length(v: Vec3): number {
|
|
28
|
+
return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function vec3Normalize(v: Vec3): Vec3 {
|
|
32
|
+
const len = vec3Length(v);
|
|
33
|
+
if (len < 1e-10) return { x: 0, y: 0, z: 0 };
|
|
34
|
+
return vec3Scale(v, 1 / len);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function vec3Distance(a: Vec3, b: Vec3): number {
|
|
38
|
+
return vec3Length(vec3Sub(a, b));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function vec3Cross(a: Vec3, b: Vec3): Vec3 {
|
|
42
|
+
return {
|
|
43
|
+
x: a.y * b.z - a.z * b.y,
|
|
44
|
+
y: a.z * b.x - a.x * b.z,
|
|
45
|
+
z: a.x * b.y - a.y * b.x,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function vec3Lerp(a: Vec3, b: Vec3, t: number): Vec3 {
|
|
50
|
+
const clamped = Math.max(0, Math.min(1, t));
|
|
51
|
+
return {
|
|
52
|
+
x: a.x + (b.x - a.x) * clamped,
|
|
53
|
+
y: a.y + (b.y - a.y) * clamped,
|
|
54
|
+
z: a.z + (b.z - a.z) * clamped,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ============================================================
|
|
59
|
+
// QUATERNION OPERATIONS
|
|
60
|
+
// ============================================================
|
|
61
|
+
|
|
62
|
+
export const QUAT_IDENTITY: Quaternion = { w: 1, x: 0, y: 0, z: 0 };
|
|
63
|
+
|
|
64
|
+
export function quatMultiply(a: Quaternion, b: Quaternion): Quaternion {
|
|
65
|
+
return {
|
|
66
|
+
w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,
|
|
67
|
+
x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
|
|
68
|
+
y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
|
|
69
|
+
z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function quatConjugate(q: Quaternion): Quaternion {
|
|
74
|
+
return { w: q.w, x: -q.x, y: -q.y, z: -q.z };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function quatNormalize(q: Quaternion): Quaternion {
|
|
78
|
+
const len = Math.sqrt(q.w * q.w + q.x * q.x + q.y * q.y + q.z * q.z);
|
|
79
|
+
if (len < 1e-10) return QUAT_IDENTITY;
|
|
80
|
+
return { w: q.w / len, x: q.x / len, y: q.y / len, z: q.z / len };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Rotate vector by quaternion: q * v * q^-1 */
|
|
84
|
+
export function quatRotateVec3(q: Quaternion, v: Vec3): Vec3 {
|
|
85
|
+
const qv: Quaternion = { w: 0, x: v.x, y: v.y, z: v.z };
|
|
86
|
+
const result = quatMultiply(quatMultiply(q, qv), quatConjugate(q));
|
|
87
|
+
return { x: result.x, y: result.y, z: result.z };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Create quaternion from axis-angle */
|
|
91
|
+
export function quatFromAxisAngle(axis: Vec3, angle: number): Quaternion {
|
|
92
|
+
const halfAngle = angle / 2;
|
|
93
|
+
const s = Math.sin(halfAngle);
|
|
94
|
+
const normalized = vec3Normalize(axis);
|
|
95
|
+
return quatNormalize({
|
|
96
|
+
w: Math.cos(halfAngle),
|
|
97
|
+
x: normalized.x * s,
|
|
98
|
+
y: normalized.y * s,
|
|
99
|
+
z: normalized.z * s,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Spherical linear interpolation */
|
|
104
|
+
export function quatSlerp(a: Quaternion, b: Quaternion, t: number): Quaternion {
|
|
105
|
+
let dot = a.w * b.w + a.x * b.x + a.y * b.y + a.z * b.z;
|
|
106
|
+
let bAdj = b;
|
|
107
|
+
if (dot < 0) {
|
|
108
|
+
dot = -dot;
|
|
109
|
+
bAdj = { w: -b.w, x: -b.x, y: -b.y, z: -b.z };
|
|
110
|
+
}
|
|
111
|
+
if (dot > 0.9995) {
|
|
112
|
+
// Linear interpolation for very close quaternions
|
|
113
|
+
return quatNormalize({
|
|
114
|
+
w: a.w + (bAdj.w - a.w) * t,
|
|
115
|
+
x: a.x + (bAdj.x - a.x) * t,
|
|
116
|
+
y: a.y + (bAdj.y - a.y) * t,
|
|
117
|
+
z: a.z + (bAdj.z - a.z) * t,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const theta = Math.acos(dot);
|
|
121
|
+
const sinTheta = Math.sin(theta);
|
|
122
|
+
const wa = Math.sin((1 - t) * theta) / sinTheta;
|
|
123
|
+
const wb = Math.sin(t * theta) / sinTheta;
|
|
124
|
+
return {
|
|
125
|
+
w: wa * a.w + wb * bAdj.w,
|
|
126
|
+
x: wa * a.x + wb * bAdj.x,
|
|
127
|
+
y: wa * a.y + wb * bAdj.y,
|
|
128
|
+
z: wa * a.z + wb * bAdj.z,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ============================================================
|
|
133
|
+
// COORDINATE TRANSFORMS
|
|
134
|
+
// ============================================================
|
|
135
|
+
|
|
136
|
+
/** Pose to 4x4 transformation matrix */
|
|
137
|
+
export function poseToMat4(pose: Pose): Mat4 {
|
|
138
|
+
const { w, x, y, z } = pose.orientation;
|
|
139
|
+
const { x: tx, y: ty, z: tz } = pose.position;
|
|
140
|
+
// Rotation matrix from quaternion
|
|
141
|
+
const xx = x * x, yy = y * y, zz = z * z;
|
|
142
|
+
const xy = x * y, xz = x * z, yz = y * z;
|
|
143
|
+
const wx = w * x, wy = w * y, wz = w * z;
|
|
144
|
+
return [
|
|
145
|
+
1 - 2 * (yy + zz), 2 * (xy - wz), 2 * (xz + wy), tx,
|
|
146
|
+
2 * (xy + wz), 1 - 2 * (xx + zz), 2 * (yz - wx), ty,
|
|
147
|
+
2 * (xz - wy), 2 * (yz + wx), 1 - 2 * (xx + yy), tz,
|
|
148
|
+
0, 0, 0, 1,
|
|
149
|
+
] as const satisfies Mat4;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Transform point from egocentric (robot) to allocentric (world) frame */
|
|
153
|
+
export function egoToAllo(point: Vec3, robotPose: Pose): Vec3 {
|
|
154
|
+
const rotated = quatRotateVec3(robotPose.orientation, point);
|
|
155
|
+
return vec3Add(rotated, robotPose.position);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Transform point from allocentric (world) to egocentric (robot) frame */
|
|
159
|
+
export function alloToEgo(point: Vec3, robotPose: Pose): Vec3 {
|
|
160
|
+
const relative = vec3Sub(point, robotPose.position);
|
|
161
|
+
return quatRotateVec3(quatConjugate(robotPose.orientation), relative);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Stereo depth from disparity: Z = f * B / d */
|
|
165
|
+
export function stereoDepth(
|
|
166
|
+
focalLength: number,
|
|
167
|
+
baseline: number,
|
|
168
|
+
disparity: number,
|
|
169
|
+
): number {
|
|
170
|
+
if (disparity <= 0) return Infinity;
|
|
171
|
+
return (focalLength * baseline) / disparity;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ============================================================
|
|
175
|
+
// STATISTICAL
|
|
176
|
+
// ============================================================
|
|
177
|
+
|
|
178
|
+
/** Gaussian function: f(x) = peak * exp(-((x-center)^2) / (2*sigma^2)) */
|
|
179
|
+
export function gaussian(x: number, center: number, sigma: number, peak: number = 1): number {
|
|
180
|
+
const diff = x - center;
|
|
181
|
+
return peak * Math.exp(-(diff * diff) / (2 * sigma * sigma));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 3D Gaussian: radial falloff from center point */
|
|
185
|
+
export function gaussian3D(point: Vec3, center: Vec3, sigma: number, peak: number = 1): number {
|
|
186
|
+
const dist = vec3Distance(point, center);
|
|
187
|
+
return peak * Math.exp(-(dist * dist) / (2 * sigma * sigma));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Clamp value to [min, max] */
|
|
191
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
192
|
+
return Math.max(min, Math.min(max, value));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Mean Absolute Error between two arrays */
|
|
196
|
+
export function meanAbsoluteError(a: Float32Array, b: Float32Array): number {
|
|
197
|
+
if (a.length !== b.length) throw new Error('Array length mismatch');
|
|
198
|
+
if (a.length === 0) return 0;
|
|
199
|
+
let sum = 0;
|
|
200
|
+
for (let i = 0; i < a.length; i++) {
|
|
201
|
+
sum += Math.abs(a[i]! - b[i]!);
|
|
202
|
+
}
|
|
203
|
+
return sum / a.length;
|
|
204
|
+
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spatial Payment Verification Engine
|
|
3
|
+
*
|
|
4
|
+
* Core protocol:
|
|
5
|
+
* 1. Robot claims to be at pose P
|
|
6
|
+
* 2. Render expected view from long-term memory at P
|
|
7
|
+
* 3. Capture actual camera view
|
|
8
|
+
* 4. Compare: SSIM + LPIPS + depth MAE → composite score
|
|
9
|
+
* 5. Score > threshold → spatial proof generated → payment proceeds
|
|
10
|
+
*
|
|
11
|
+
* Anti-tamper:
|
|
12
|
+
* - Proof includes Merkle root linking to long-term memory
|
|
13
|
+
* - HMAC-SHA256 signed proof payload
|
|
14
|
+
* - Cryptographic nonce prevents replay
|
|
15
|
+
* - Hardware attestation binds proof to physical device
|
|
16
|
+
*/
|
|
17
|
+
import type {
|
|
18
|
+
CameraFrame,
|
|
19
|
+
RenderedView,
|
|
20
|
+
SpatialMetrics,
|
|
21
|
+
SpatialProof,
|
|
22
|
+
SpatialSettlement,
|
|
23
|
+
SettlementStatus,
|
|
24
|
+
Pose,
|
|
25
|
+
VerificationThresholds,
|
|
26
|
+
} from '../types/index.js';
|
|
27
|
+
import { hmacSign, generateNonce, deriveKey } from '../utils/crypto.js';
|
|
28
|
+
import { meanAbsoluteError } from '../utils/math.js';
|
|
29
|
+
|
|
30
|
+
// ============================================================
|
|
31
|
+
// SSIM — Structural Similarity Index
|
|
32
|
+
// ============================================================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compute SSIM between two grayscale images
|
|
36
|
+
* Based on Wang et al. (2004) — measures structural similarity
|
|
37
|
+
* Range: [-1, 1], where 1 = identical
|
|
38
|
+
*
|
|
39
|
+
* SSIM(x,y) = (2*μx*μy + C1)(2*σxy + C2) / ((μx² + μy² + C1)(σx² + σy² + C2))
|
|
40
|
+
*/
|
|
41
|
+
export function computeSSIM(
|
|
42
|
+
imgA: Uint8Array,
|
|
43
|
+
imgB: Uint8Array,
|
|
44
|
+
width: number,
|
|
45
|
+
height: number,
|
|
46
|
+
windowSize: number = 8,
|
|
47
|
+
): number {
|
|
48
|
+
if (imgA.length !== imgB.length) {
|
|
49
|
+
throw new Error('Image dimensions must match for SSIM');
|
|
50
|
+
}
|
|
51
|
+
if (imgA.length === 0) return 0;
|
|
52
|
+
|
|
53
|
+
// Constants (as per original paper)
|
|
54
|
+
const L = 255; // dynamic range
|
|
55
|
+
const k1 = 0.01, k2 = 0.03;
|
|
56
|
+
const C1 = (k1 * L) ** 2;
|
|
57
|
+
const C2 = (k2 * L) ** 2;
|
|
58
|
+
|
|
59
|
+
let totalSSIM = 0;
|
|
60
|
+
let windowCount = 0;
|
|
61
|
+
|
|
62
|
+
// Slide window across image
|
|
63
|
+
for (let y = 0; y <= height - windowSize; y += windowSize) {
|
|
64
|
+
for (let x = 0; x <= width - windowSize; x += windowSize) {
|
|
65
|
+
let sumA = 0, sumB = 0;
|
|
66
|
+
let sumA2 = 0, sumB2 = 0;
|
|
67
|
+
let sumAB = 0;
|
|
68
|
+
const n = windowSize * windowSize;
|
|
69
|
+
|
|
70
|
+
for (let wy = 0; wy < windowSize; wy++) {
|
|
71
|
+
for (let wx = 0; wx < windowSize; wx++) {
|
|
72
|
+
const idx = (y + wy) * width + (x + wx);
|
|
73
|
+
const a = imgA[idx]!;
|
|
74
|
+
const b = imgB[idx]!;
|
|
75
|
+
sumA += a;
|
|
76
|
+
sumB += b;
|
|
77
|
+
sumA2 += a * a;
|
|
78
|
+
sumB2 += b * b;
|
|
79
|
+
sumAB += a * b;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const muA = sumA / n;
|
|
84
|
+
const muB = sumB / n;
|
|
85
|
+
const sigA2 = sumA2 / n - muA * muA;
|
|
86
|
+
const sigB2 = sumB2 / n - muB * muB;
|
|
87
|
+
const sigAB = sumAB / n - muA * muB;
|
|
88
|
+
|
|
89
|
+
const numerator = (2 * muA * muB + C1) * (2 * sigAB + C2);
|
|
90
|
+
const denominator = (muA * muA + muB * muB + C1) * (sigA2 + sigB2 + C2);
|
|
91
|
+
|
|
92
|
+
totalSSIM += numerator / denominator;
|
|
93
|
+
windowCount++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return windowCount > 0 ? totalSSIM / windowCount : 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Convert RGB image to grayscale for SSIM computation
|
|
102
|
+
* Uses ITU-R BT.601 luma coefficients: 0.299R + 0.587G + 0.114B
|
|
103
|
+
*/
|
|
104
|
+
export function rgbToGrayscale(rgb: Uint8Array, width: number, height: number): Uint8Array {
|
|
105
|
+
const gray = new Uint8Array(width * height);
|
|
106
|
+
for (let i = 0; i < width * height; i++) {
|
|
107
|
+
const r = rgb[i * 3]!;
|
|
108
|
+
const g = rgb[i * 3 + 1]!;
|
|
109
|
+
const b = rgb[i * 3 + 2]!;
|
|
110
|
+
gray[i] = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
|
111
|
+
}
|
|
112
|
+
return gray;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ============================================================
|
|
116
|
+
// LPIPS Approximation (Perceptual Similarity)
|
|
117
|
+
// ============================================================
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Approximate LPIPS using multi-scale edge + texture comparison
|
|
121
|
+
*
|
|
122
|
+
* True LPIPS requires a neural network (VGG/AlexNet). For on-device
|
|
123
|
+
* robotics we use a lightweight approximation:
|
|
124
|
+
* 1. Sobel edge detection at multiple scales
|
|
125
|
+
* 2. Local variance (texture) comparison
|
|
126
|
+
* 3. Histogram distance
|
|
127
|
+
*
|
|
128
|
+
* Range: [0, 1], where 0 = identical (opposite of SSIM direction)
|
|
129
|
+
*/
|
|
130
|
+
export function approximateLPIPS(
|
|
131
|
+
imgA: Uint8Array,
|
|
132
|
+
imgB: Uint8Array,
|
|
133
|
+
width: number,
|
|
134
|
+
height: number,
|
|
135
|
+
): number {
|
|
136
|
+
if (imgA.length !== imgB.length) {
|
|
137
|
+
throw new Error('Image dimensions must match for LPIPS');
|
|
138
|
+
}
|
|
139
|
+
if (imgA.length === 0) return 1;
|
|
140
|
+
|
|
141
|
+
// 1. Edge comparison (Sobel)
|
|
142
|
+
const edgesA = sobelEdges(imgA, width, height);
|
|
143
|
+
const edgesB = sobelEdges(imgB, width, height);
|
|
144
|
+
let edgeDiff = 0;
|
|
145
|
+
for (let i = 0; i < edgesA.length; i++) {
|
|
146
|
+
edgeDiff += Math.abs(edgesA[i]! - edgesB[i]!) / 255;
|
|
147
|
+
}
|
|
148
|
+
const edgeScore = edgeDiff / edgesA.length;
|
|
149
|
+
|
|
150
|
+
// 2. Local variance (texture) comparison
|
|
151
|
+
const varA = localVariance(imgA, width, height, 4);
|
|
152
|
+
const varB = localVariance(imgB, width, height, 4);
|
|
153
|
+
let varDiff = 0;
|
|
154
|
+
for (let i = 0; i < varA.length; i++) {
|
|
155
|
+
varDiff += Math.abs(varA[i]! - varB[i]!);
|
|
156
|
+
}
|
|
157
|
+
const maxVar = Math.max(1, Math.max(...varA, ...varB));
|
|
158
|
+
const textureScore = varDiff / (varA.length * maxVar);
|
|
159
|
+
|
|
160
|
+
// 3. Histogram distance (L1)
|
|
161
|
+
const histA = histogram(imgA);
|
|
162
|
+
const histB = histogram(imgB);
|
|
163
|
+
let histDiff = 0;
|
|
164
|
+
for (let i = 0; i < 256; i++) {
|
|
165
|
+
histDiff += Math.abs(histA[i]! - histB[i]!);
|
|
166
|
+
}
|
|
167
|
+
const histScore = histDiff / (2 * imgA.length); // normalize to [0,1]
|
|
168
|
+
|
|
169
|
+
// Weighted combination
|
|
170
|
+
return Math.min(1, 0.4 * edgeScore + 0.4 * textureScore + 0.2 * histScore);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Sobel edge detection (magnitude) */
|
|
174
|
+
function sobelEdges(img: Uint8Array, width: number, height: number): Float32Array {
|
|
175
|
+
const edges = new Float32Array(width * height);
|
|
176
|
+
for (let y = 1; y < height - 1; y++) {
|
|
177
|
+
for (let x = 1; x < width - 1; x++) {
|
|
178
|
+
const idx = y * width + x;
|
|
179
|
+
// Horizontal Sobel
|
|
180
|
+
const gx =
|
|
181
|
+
-img[(y - 1) * width + (x - 1)]! - 2 * img[y * width + (x - 1)]! - img[(y + 1) * width + (x - 1)]! +
|
|
182
|
+
img[(y - 1) * width + (x + 1)]! + 2 * img[y * width + (x + 1)]! + img[(y + 1) * width + (x + 1)]!;
|
|
183
|
+
// Vertical Sobel
|
|
184
|
+
const gy =
|
|
185
|
+
-img[(y - 1) * width + (x - 1)]! - 2 * img[(y - 1) * width + x]! - img[(y - 1) * width + (x + 1)]! +
|
|
186
|
+
img[(y + 1) * width + (x - 1)]! + 2 * img[(y + 1) * width + x]! + img[(y + 1) * width + (x + 1)]!;
|
|
187
|
+
edges[idx] = Math.sqrt(gx * gx + gy * gy);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return edges;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Local variance in blocks */
|
|
194
|
+
function localVariance(img: Uint8Array, width: number, height: number, blockSize: number): Float32Array {
|
|
195
|
+
const bw = Math.floor(width / blockSize);
|
|
196
|
+
const bh = Math.floor(height / blockSize);
|
|
197
|
+
const result = new Float32Array(bw * bh);
|
|
198
|
+
|
|
199
|
+
for (let by = 0; by < bh; by++) {
|
|
200
|
+
for (let bx = 0; bx < bw; bx++) {
|
|
201
|
+
let sum = 0, sum2 = 0;
|
|
202
|
+
const n = blockSize * blockSize;
|
|
203
|
+
for (let dy = 0; dy < blockSize; dy++) {
|
|
204
|
+
for (let dx = 0; dx < blockSize; dx++) {
|
|
205
|
+
const val = img[(by * blockSize + dy) * width + (bx * blockSize + dx)]!;
|
|
206
|
+
sum += val;
|
|
207
|
+
sum2 += val * val;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const mean = sum / n;
|
|
211
|
+
result[by * bw + bx] = sum2 / n - mean * mean;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 256-bin histogram */
|
|
218
|
+
function histogram(img: Uint8Array): Float32Array {
|
|
219
|
+
const hist = new Float32Array(256);
|
|
220
|
+
for (let i = 0; i < img.length; i++) {
|
|
221
|
+
const val = img[i]!;
|
|
222
|
+
hist[val] = (hist[val] ?? 0) + 1;
|
|
223
|
+
}
|
|
224
|
+
return hist;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ============================================================
|
|
228
|
+
// SPATIAL METRICS COMPUTATION
|
|
229
|
+
// ============================================================
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Compute spatial verification metrics
|
|
233
|
+
* Compares expected render vs actual camera capture
|
|
234
|
+
*/
|
|
235
|
+
export function computeSpatialMetrics(
|
|
236
|
+
expected: RenderedView,
|
|
237
|
+
actual: CameraFrame,
|
|
238
|
+
thresholds: VerificationThresholds,
|
|
239
|
+
): SpatialMetrics {
|
|
240
|
+
// Validate dimensions match
|
|
241
|
+
if (expected.width !== actual.width || expected.height !== actual.height) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`Dimension mismatch: expected ${expected.width}x${expected.height}, ` +
|
|
244
|
+
`actual ${actual.width}x${actual.height}`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Convert to grayscale for SSIM
|
|
249
|
+
const grayExpected = rgbToGrayscale(expected.rgb, expected.width, expected.height);
|
|
250
|
+
const grayActual = rgbToGrayscale(actual.rgb, actual.width, actual.height);
|
|
251
|
+
|
|
252
|
+
// SSIM: structural similarity [0,1]
|
|
253
|
+
const ssim = computeSSIM(grayExpected, grayActual, expected.width, expected.height);
|
|
254
|
+
|
|
255
|
+
// LPIPS approximation: perceptual dissimilarity [0,1]
|
|
256
|
+
const lpips = approximateLPIPS(grayExpected, grayActual, expected.width, expected.height);
|
|
257
|
+
|
|
258
|
+
// Depth MAE (meters) — only if both have depth
|
|
259
|
+
let depthMAE = 0;
|
|
260
|
+
if (expected.depth && actual.depth) {
|
|
261
|
+
depthMAE = meanAbsoluteError(expected.depth, actual.depth);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Composite score: weighted combination
|
|
265
|
+
// SSIM contributes positively, LPIPS and depth MAE contribute negatively
|
|
266
|
+
const ssimNorm = Math.max(0, ssim); // [0,1]
|
|
267
|
+
const lpipsNorm = 1 - Math.min(1, lpips); // invert: [0,1] where 1=good
|
|
268
|
+
const depthNorm = 1 - Math.min(1, depthMAE / thresholds.maxDepthMAE); // [0,1]
|
|
269
|
+
|
|
270
|
+
const composite = 0.4 * ssimNorm + 0.3 * lpipsNorm + 0.3 * depthNorm;
|
|
271
|
+
|
|
272
|
+
return { ssim, lpips, depthMAE, composite };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ============================================================
|
|
276
|
+
// SPATIAL PROOF GENERATION
|
|
277
|
+
// ============================================================
|
|
278
|
+
|
|
279
|
+
const DEFAULT_THRESHOLDS: VerificationThresholds = {
|
|
280
|
+
minSSIM: 0.75,
|
|
281
|
+
maxLPIPS: 0.25,
|
|
282
|
+
maxDepthMAE: 0.5, // meters
|
|
283
|
+
minComposite: 0.75,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Generate a spatial proof — cryptographic proof that robot is at claimed location
|
|
288
|
+
*/
|
|
289
|
+
export function generateSpatialProof(
|
|
290
|
+
robotId: string,
|
|
291
|
+
claimedPose: Pose,
|
|
292
|
+
actualFrame: CameraFrame,
|
|
293
|
+
expectedRender: RenderedView,
|
|
294
|
+
memoryMerkleRoot: string,
|
|
295
|
+
merkleProof: readonly string[],
|
|
296
|
+
hmacSecret: string,
|
|
297
|
+
thresholds: VerificationThresholds = DEFAULT_THRESHOLDS,
|
|
298
|
+
hardwareAttestation?: string,
|
|
299
|
+
): SpatialProof {
|
|
300
|
+
// Validate inputs
|
|
301
|
+
if (!robotId) throw new Error('robotId is required');
|
|
302
|
+
if (!hmacSecret || hmacSecret.length < 32) throw new Error('HMAC secret must be at least 32 chars');
|
|
303
|
+
if (!actualFrame.hmac) throw new Error('Actual frame must be HMAC-signed');
|
|
304
|
+
|
|
305
|
+
// Compute spatial metrics
|
|
306
|
+
const metrics = computeSpatialMetrics(expectedRender, actualFrame, thresholds);
|
|
307
|
+
|
|
308
|
+
// Determine if proof passes
|
|
309
|
+
const passed =
|
|
310
|
+
metrics.ssim >= thresholds.minSSIM &&
|
|
311
|
+
metrics.lpips <= thresholds.maxLPIPS &&
|
|
312
|
+
metrics.depthMAE <= thresholds.maxDepthMAE &&
|
|
313
|
+
metrics.composite >= thresholds.minComposite;
|
|
314
|
+
|
|
315
|
+
// Generate nonce for replay prevention
|
|
316
|
+
const nonce = generateNonce(32);
|
|
317
|
+
|
|
318
|
+
// Build proof payload
|
|
319
|
+
const proofId = generateNonce(16);
|
|
320
|
+
const timestamp = Date.now();
|
|
321
|
+
|
|
322
|
+
// Sign the proof (includes all critical fields to prevent tampering)
|
|
323
|
+
const proofKey = deriveKey(hmacSecret, 'spatial-proof');
|
|
324
|
+
const signatureData = Buffer.from(
|
|
325
|
+
[
|
|
326
|
+
proofId,
|
|
327
|
+
robotId,
|
|
328
|
+
timestamp.toString(),
|
|
329
|
+
nonce,
|
|
330
|
+
passed.toString(),
|
|
331
|
+
metrics.composite.toFixed(6),
|
|
332
|
+
memoryMerkleRoot,
|
|
333
|
+
actualFrame.id,
|
|
334
|
+
].join(':'),
|
|
335
|
+
);
|
|
336
|
+
const signature = hmacSign(signatureData, proofKey);
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
id: proofId,
|
|
340
|
+
robotId,
|
|
341
|
+
claimedPose,
|
|
342
|
+
actualFrame,
|
|
343
|
+
expectedRender,
|
|
344
|
+
metrics,
|
|
345
|
+
passed,
|
|
346
|
+
memoryMerkleRoot,
|
|
347
|
+
merkleProof,
|
|
348
|
+
timestamp,
|
|
349
|
+
signature,
|
|
350
|
+
nonce,
|
|
351
|
+
hardwareAttestation,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Verify a spatial proof's integrity (does NOT re-run image comparison)
|
|
357
|
+
* Checks: HMAC signature, nonce freshness, Merkle root validity
|
|
358
|
+
*/
|
|
359
|
+
export function verifySpatialProofIntegrity(
|
|
360
|
+
proof: SpatialProof,
|
|
361
|
+
hmacSecret: string,
|
|
362
|
+
maxAgeMs: number = 300_000, // 5 minute max proof age
|
|
363
|
+
): { valid: boolean; reason?: string } {
|
|
364
|
+
// Check proof age
|
|
365
|
+
const age = Date.now() - proof.timestamp;
|
|
366
|
+
if (age > maxAgeMs) {
|
|
367
|
+
return { valid: false, reason: `Proof expired: age ${age}ms > max ${maxAgeMs}ms` };
|
|
368
|
+
}
|
|
369
|
+
if (age < 0) {
|
|
370
|
+
return { valid: false, reason: 'Proof timestamp is in the future' };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Verify HMAC signature
|
|
374
|
+
const proofKey = deriveKey(hmacSecret, 'spatial-proof');
|
|
375
|
+
const signatureData = Buffer.from(
|
|
376
|
+
[
|
|
377
|
+
proof.id,
|
|
378
|
+
proof.robotId,
|
|
379
|
+
proof.timestamp.toString(),
|
|
380
|
+
proof.nonce,
|
|
381
|
+
proof.passed.toString(),
|
|
382
|
+
proof.metrics.composite.toFixed(6),
|
|
383
|
+
proof.memoryMerkleRoot,
|
|
384
|
+
proof.actualFrame.id,
|
|
385
|
+
].join(':'),
|
|
386
|
+
);
|
|
387
|
+
const expectedSig = hmacSign(signatureData, proofKey);
|
|
388
|
+
if (expectedSig !== proof.signature) {
|
|
389
|
+
return { valid: false, reason: 'HMAC signature mismatch — proof may be tampered' };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return { valid: true };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ============================================================
|
|
396
|
+
// SETTLEMENT
|
|
397
|
+
// ============================================================
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Create a spatial settlement (payment tied to spatial proof)
|
|
401
|
+
* Atomic: either the proof is valid AND payment succeeds, or both fail
|
|
402
|
+
*/
|
|
403
|
+
export function createSettlement(
|
|
404
|
+
proof: SpatialProof,
|
|
405
|
+
amount: number,
|
|
406
|
+
currency: string,
|
|
407
|
+
payeeId: string,
|
|
408
|
+
hmacSecret: string,
|
|
409
|
+
): SpatialSettlement {
|
|
410
|
+
if (amount <= 0) throw new Error('Settlement amount must be positive');
|
|
411
|
+
if (!currency) throw new Error('Currency is required');
|
|
412
|
+
if (!payeeId) throw new Error('Payee ID is required');
|
|
413
|
+
|
|
414
|
+
// Verify proof integrity first
|
|
415
|
+
const integrity = verifySpatialProofIntegrity(proof, hmacSecret);
|
|
416
|
+
|
|
417
|
+
let status: SettlementStatus;
|
|
418
|
+
let failureReason: string | undefined;
|
|
419
|
+
|
|
420
|
+
if (!integrity.valid) {
|
|
421
|
+
status = 'failed' as SettlementStatus;
|
|
422
|
+
failureReason = `Proof integrity check failed: ${integrity.reason}`;
|
|
423
|
+
} else if (!proof.passed) {
|
|
424
|
+
status = 'failed' as SettlementStatus;
|
|
425
|
+
failureReason = `Spatial verification failed: composite score ${proof.metrics.composite.toFixed(3)} < threshold`;
|
|
426
|
+
} else {
|
|
427
|
+
status = 'verified' as SettlementStatus;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
id: generateNonce(16),
|
|
432
|
+
proof,
|
|
433
|
+
amount,
|
|
434
|
+
currency,
|
|
435
|
+
status,
|
|
436
|
+
payerRobotId: proof.robotId,
|
|
437
|
+
payeeId,
|
|
438
|
+
initiatedAt: Date.now(),
|
|
439
|
+
settledAt: status === 'verified' ? Date.now() : undefined,
|
|
440
|
+
failureReason,
|
|
441
|
+
};
|
|
442
|
+
}
|