@switchlabs/verify-ai-react-native 1.1.0 → 1.1.2

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 (48) hide show
  1. package/README.md +17 -4
  2. package/lib/client/index.d.ts +22 -2
  3. package/lib/client/index.js +132 -19
  4. package/lib/components/VerifyAIScanner.d.ts +7 -4
  5. package/lib/components/VerifyAIScanner.js +235 -18
  6. package/lib/hooks/useVerifyAI.d.ts +32 -10
  7. package/lib/hooks/useVerifyAI.js +246 -14
  8. package/lib/index.d.ts +5 -2
  9. package/lib/index.js +3 -0
  10. package/lib/ml/featureExtractor.d.ts +16 -0
  11. package/lib/ml/featureExtractor.js +123 -0
  12. package/lib/ml/imagePreprocessor.d.ts +2 -0
  13. package/lib/ml/imagePreprocessor.js +48 -0
  14. package/lib/ml/index.d.ts +5 -0
  15. package/lib/ml/index.js +4 -0
  16. package/lib/ml/inferenceEngine.d.ts +24 -0
  17. package/lib/ml/inferenceEngine.js +156 -0
  18. package/lib/ml/modelManager.d.ts +26 -0
  19. package/lib/ml/modelManager.js +207 -0
  20. package/lib/ml/policyEngine.d.ts +14 -0
  21. package/lib/ml/policyEngine.js +161 -0
  22. package/lib/ml/types.d.ts +84 -0
  23. package/lib/ml/types.js +4 -0
  24. package/lib/storage/offlineQueue.js +1 -1
  25. package/lib/telemetry/TelemetryContext.d.ts +4 -0
  26. package/lib/telemetry/TelemetryContext.js +5 -0
  27. package/lib/telemetry/TelemetryReporter.d.ts +24 -0
  28. package/lib/telemetry/TelemetryReporter.js +141 -0
  29. package/lib/types/index.d.ts +18 -0
  30. package/lib/version.d.ts +1 -0
  31. package/lib/version.js +1 -0
  32. package/package.json +23 -2
  33. package/src/client/index.ts +176 -25
  34. package/src/components/VerifyAIScanner.tsx +282 -21
  35. package/src/hooks/useVerifyAI.ts +332 -18
  36. package/src/index.ts +20 -1
  37. package/src/ml/featureExtractor.ts +160 -0
  38. package/src/ml/imagePreprocessor.ts +72 -0
  39. package/src/ml/index.ts +14 -0
  40. package/src/ml/inferenceEngine.ts +200 -0
  41. package/src/ml/modelManager.ts +265 -0
  42. package/src/ml/policyEngine.ts +201 -0
  43. package/src/ml/types.ts +104 -0
  44. package/src/storage/offlineQueue.ts +1 -1
  45. package/src/telemetry/TelemetryContext.tsx +8 -0
  46. package/src/telemetry/TelemetryReporter.ts +184 -0
  47. package/src/types/index.ts +20 -0
  48. package/src/version.ts +1 -0
@@ -0,0 +1,72 @@
1
+ import { Buffer } from 'buffer';
2
+ import { decode as decodeJpeg } from 'jpeg-js';
3
+
4
+ const DEFAULT_INPUT_SIZE = 640;
5
+
6
+ function stripDataUriPrefix(base64: string): string {
7
+ const marker = 'base64,';
8
+ const markerIndex = base64.indexOf(marker);
9
+ return markerIndex >= 0 ? base64.slice(markerIndex + marker.length) : base64;
10
+ }
11
+
12
+ function isJpeg(bytes: Uint8Array): boolean {
13
+ return bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8;
14
+ }
15
+
16
+ function decodeBase64(base64: string): Uint8Array {
17
+ return Uint8Array.from(Buffer.from(stripDataUriPrefix(base64), 'base64'));
18
+ }
19
+
20
+ function resizeRgbaToChw(
21
+ rgba: Uint8Array,
22
+ sourceWidth: number,
23
+ sourceHeight: number,
24
+ inputSize: number,
25
+ ): Float32Array {
26
+ const totalPixels = inputSize * inputSize;
27
+ const tensor = new Float32Array(3 * totalPixels);
28
+
29
+ for (let y = 0; y < inputSize; y++) {
30
+ const sourceY = Math.min(sourceHeight - 1, Math.floor((y * sourceHeight) / inputSize));
31
+ for (let x = 0; x < inputSize; x++) {
32
+ const sourceX = Math.min(sourceWidth - 1, Math.floor((x * sourceWidth) / inputSize));
33
+ const sourceOffset = (sourceY * sourceWidth + sourceX) * 4;
34
+ const outputIndex = y * inputSize + x;
35
+
36
+ tensor[outputIndex] = rgba[sourceOffset] / 255;
37
+ tensor[totalPixels + outputIndex] = rgba[sourceOffset + 1] / 255;
38
+ tensor[2 * totalPixels + outputIndex] = rgba[sourceOffset + 2] / 255;
39
+ }
40
+ }
41
+
42
+ return tensor;
43
+ }
44
+
45
+ export function preprocessImageBase64(
46
+ base64: string,
47
+ inputSize = DEFAULT_INPUT_SIZE,
48
+ ): Float32Array {
49
+ const bytes = decodeBase64(base64);
50
+ if (!isJpeg(bytes)) {
51
+ throw new Error('On-device inference currently supports JPEG inputs only');
52
+ }
53
+
54
+ const decoded = decodeJpeg(bytes, { useTArray: true, tolerantDecoding: true });
55
+ if (!decoded.width || !decoded.height) {
56
+ throw new Error('Failed to decode JPEG for on-device inference');
57
+ }
58
+
59
+ return resizeRgbaToChw(decoded.data, decoded.width, decoded.height, inputSize);
60
+ }
61
+
62
+ export async function preprocessImageUri(
63
+ imageUri: string,
64
+ inputSize = DEFAULT_INPUT_SIZE,
65
+ ): Promise<Float32Array> {
66
+ const fileSystem = await import('expo-file-system');
67
+ const base64 = await fileSystem.readAsStringAsync(imageUri, {
68
+ encoding: fileSystem.EncodingType.Base64,
69
+ });
70
+
71
+ return preprocessImageBase64(base64, inputSize);
72
+ }
@@ -0,0 +1,14 @@
1
+ export { ModelManager } from './modelManager';
2
+ export { InferenceEngine } from './inferenceEngine';
3
+ export { extractFeatures, ONTOLOGY_CLASS_NAMES } from './featureExtractor';
4
+ export { evaluatePolicy } from './policyEngine';
5
+ export type {
6
+ BundleManifest,
7
+ ModelArtifact,
8
+ FeatureVector,
9
+ Detection,
10
+ ImageQuality,
11
+ PolicyAST,
12
+ PolicyResult,
13
+ RuleResult,
14
+ } from './types';
@@ -0,0 +1,200 @@
1
+ /**
2
+ * On-device inference engine for React Native.
3
+ * Uses react-native-fast-tflite for TFLite inference (uses CoreML delegate on iOS).
4
+ */
5
+
6
+ import { Platform } from 'react-native';
7
+ import type { BundleManifest } from './types';
8
+ import { MODEL_OUTPUT_CLASS_IDS } from './featureExtractor';
9
+
10
+ interface RawDetection {
11
+ classId: number;
12
+ confidence: number;
13
+ bbox: [number, number, number, number];
14
+ }
15
+
16
+ interface TfliteModel {
17
+ runSync(inputs: [Float32Array]): unknown[];
18
+ }
19
+
20
+ type TfliteDelegate = 'core-ml' | 'android-gpu' | 'nnapi';
21
+
22
+ interface TfliteModule {
23
+ loadTensorflowModel(source: { url: string }, delegate?: TfliteDelegate): Promise<TfliteModel>;
24
+ }
25
+
26
+ export class InferenceEngine {
27
+ private model: TfliteModel | null = null;
28
+ private _isLoaded = false;
29
+ private classIds: readonly number[] = MODEL_OUTPUT_CLASS_IDS;
30
+
31
+ get isLoaded(): boolean {
32
+ return this._isLoaded;
33
+ }
34
+
35
+ /** Load the model from a cached bundle artifact. */
36
+ async loadModel(bundle: BundleManifest): Promise<void> {
37
+ const artifact = bundle.artifacts.find((a) => a.format === 'tflite');
38
+
39
+ if (!artifact?.localPath) {
40
+ throw new Error('No TFLite artifact with local path in bundle');
41
+ }
42
+
43
+ try {
44
+ const tfliteModule = await import('react-native-fast-tflite') as TfliteModule;
45
+ const { loadTensorflowModel } = tfliteModule;
46
+ const preferredDelegate: TfliteDelegate | undefined =
47
+ Platform.OS === 'ios' ? 'core-ml' : undefined;
48
+
49
+ try {
50
+ this.model = await loadTensorflowModel({ url: artifact.localPath }, preferredDelegate);
51
+ } catch {
52
+ this.model = await loadTensorflowModel({ url: artifact.localPath });
53
+ }
54
+ this.classIds = bundle.classIds.length > 0 ? bundle.classIds : MODEL_OUTPUT_CLASS_IDS;
55
+
56
+ this._isLoaded = true;
57
+ } catch (err) {
58
+ this._isLoaded = false;
59
+ throw new Error(`Failed to load TFLite model: ${err}`);
60
+ }
61
+ }
62
+
63
+ /** Run inference on preprocessed image data. */
64
+ runInference(input: Float32Array, threshold = 0.3): RawDetection[] {
65
+ if (!this._isLoaded || !this.model) {
66
+ throw new Error('Model not loaded');
67
+ }
68
+
69
+ // Run inference
70
+ const outputs = this.model.runSync([input]);
71
+
72
+ return this.parseDetections(outputs, threshold);
73
+ }
74
+
75
+ private parseDetections(outputs: unknown[], threshold: number): RawDetection[] {
76
+ const detections: RawDetection[] = [];
77
+
78
+ if (!outputs || outputs.length === 0) return detections;
79
+
80
+ if (outputs.length === 1) {
81
+ // Single combined output: [1, N, 4+num_classes]
82
+ const output = extractMatrix(outputs[0]);
83
+ const numDetections = output.length;
84
+
85
+ for (let i = 0; i < numDetections; i++) {
86
+ const det = output[i];
87
+ if (det.length < 5) continue;
88
+
89
+ const bbox = det.slice(0, 4) as [number, number, number, number];
90
+ const scores = det.slice(4);
91
+ let maxScore = 0;
92
+ let classIndex = 0;
93
+
94
+ for (let j = 0; j < scores.length; j++) {
95
+ if (scores[j] > maxScore) {
96
+ maxScore = scores[j];
97
+ classIndex = j;
98
+ }
99
+ }
100
+
101
+ if (maxScore >= threshold) {
102
+ detections.push({
103
+ classId: this.resolveClassId(classIndex),
104
+ confidence: maxScore,
105
+ bbox,
106
+ });
107
+ }
108
+ }
109
+ } else if (outputs.length >= 2) {
110
+ // Separate boxes and scores
111
+ const boxes = extractMatrix(outputs[0]);
112
+ const scoreMatrix = extractMatrix(outputs[1]);
113
+ const scoreVector = extractVector(outputs[1]);
114
+
115
+ for (let i = 0; i < boxes.length; i++) {
116
+ const box = boxes[i] as [number, number, number, number];
117
+
118
+ let maxScore = 0;
119
+ let classIndex = 0;
120
+
121
+ const scoreRow = scoreMatrix[i];
122
+ if (scoreRow) {
123
+ for (let j = 0; j < scoreRow.length; j++) {
124
+ if (scoreRow[j] > maxScore) {
125
+ maxScore = scoreRow[j];
126
+ classIndex = j;
127
+ }
128
+ }
129
+ } else {
130
+ const scalarScore = scoreVector[i] ?? null;
131
+ if (scalarScore === null) continue;
132
+ maxScore = scalarScore;
133
+ }
134
+
135
+ if (maxScore >= threshold) {
136
+ detections.push({
137
+ classId: this.resolveClassId(classIndex),
138
+ confidence: maxScore,
139
+ bbox: box,
140
+ });
141
+ }
142
+ }
143
+ }
144
+
145
+ return detections;
146
+ }
147
+
148
+ private resolveClassId(classIndex: number): number {
149
+ return this.classIds[classIndex] ?? classIndex;
150
+ }
151
+
152
+ dispose(): void {
153
+ this.model = null;
154
+ this._isLoaded = false;
155
+ this.classIds = MODEL_OUTPUT_CLASS_IDS;
156
+ }
157
+ }
158
+
159
+ function extractMatrix(value: unknown): number[][] {
160
+ if (!Array.isArray(value)) {
161
+ return [];
162
+ }
163
+
164
+ if (value.length === 0) {
165
+ return [];
166
+ }
167
+
168
+ const first = value[0];
169
+ if (Array.isArray(first) && (first.length === 0 || typeof first[0] === 'number')) {
170
+ return value
171
+ .filter(Array.isArray)
172
+ .map((row) => (row as unknown[]).map((entry) => Number(entry)));
173
+ }
174
+
175
+ if (Array.isArray(first)) {
176
+ return extractMatrix(first);
177
+ }
178
+
179
+ return [];
180
+ }
181
+
182
+ function extractVector(value: unknown): number[] {
183
+ if (!Array.isArray(value)) {
184
+ return [];
185
+ }
186
+
187
+ if (value.length === 0) {
188
+ return [];
189
+ }
190
+
191
+ if (typeof value[0] === 'number') {
192
+ return value.map((entry) => Number(entry));
193
+ }
194
+
195
+ if (Array.isArray(value[0])) {
196
+ return extractVector(value[0]);
197
+ }
198
+
199
+ return [];
200
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Model Manager for React Native SDK.
3
+ * Downloads, caches, and version-checks model bundles.
4
+ */
5
+
6
+ import { Buffer } from 'buffer';
7
+ import { sha256 } from '@noble/hashes/sha2.js';
8
+ import type { BundleManifest, ModelArtifact } from './types';
9
+
10
+ export type { BundleManifest, ModelArtifact };
11
+
12
+ type ExpoFileSystemModule = typeof import('expo-file-system');
13
+ type BundleManifestResponse = {
14
+ bundle_version: number;
15
+ ontology_version: string;
16
+ schema_version: string;
17
+ policy_id: string;
18
+ class_ids?: number[];
19
+ artifacts?: Array<{
20
+ role: ModelArtifact['role'];
21
+ architecture: string;
22
+ version: number;
23
+ format: ModelArtifact['format'];
24
+ storage_path: string;
25
+ size_bytes: number;
26
+ sha256: string;
27
+ download_url?: string;
28
+ }>;
29
+ policy_ast: BundleManifest['policyAst'];
30
+ };
31
+
32
+ async function loadFileSystem(): Promise<ExpoFileSystemModule | null> {
33
+ try {
34
+ return await import('expo-file-system');
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function bytesToSha256Hex(bytes: Uint8Array): string {
41
+ return Buffer.from(sha256(bytes)).toString('hex');
42
+ }
43
+
44
+ interface ModelManagerConfig {
45
+ apiKey: string;
46
+ baseUrl?: string;
47
+ }
48
+
49
+ export class ModelManager {
50
+ private apiKey: string;
51
+ private baseUrl: string;
52
+ private cachedETag: string | null = null;
53
+ private _currentBundle: BundleManifest | null = null;
54
+
55
+ constructor(config: ModelManagerConfig) {
56
+ this.apiKey = config.apiKey;
57
+ this.baseUrl = config.baseUrl || 'https://verify.switchlabs.dev/api/v1';
58
+ }
59
+
60
+ get currentBundle(): BundleManifest | null {
61
+ return this._currentBundle;
62
+ }
63
+
64
+ /** Check for bundle updates from the server. */
65
+ async checkForUpdates(policyId: string, platform: 'ios' | 'android'): Promise<BundleManifest | null> {
66
+ try {
67
+ const headers: Record<string, string> = {
68
+ 'X-API-Key': this.apiKey,
69
+ 'Accept': 'application/json',
70
+ };
71
+
72
+ if (this.cachedETag) {
73
+ headers['If-None-Match'] = this.cachedETag;
74
+ }
75
+
76
+ const url = `${this.baseUrl}/models/latest?policy=${policyId}&platform=${platform}`;
77
+ const response = await fetch(url, { headers });
78
+
79
+ if (response.status === 304) {
80
+ return this._currentBundle;
81
+ }
82
+
83
+ if (!response.ok) {
84
+ return this._currentBundle;
85
+ }
86
+
87
+ this.cachedETag = response.headers.get('etag');
88
+ const body = (await response.json()) as { bundles?: BundleManifestResponse[] };
89
+ const bundles = body.bundles;
90
+
91
+ if (!bundles || bundles.length === 0) {
92
+ return null;
93
+ }
94
+
95
+ const bundleJson = bundles.find((bundle) => bundle.policy_id === policyId) || bundles[0];
96
+ const manifest = parseBundleManifest(bundleJson);
97
+
98
+ if (this._currentBundle?.bundleVersion === manifest.bundleVersion) {
99
+ return this._currentBundle;
100
+ }
101
+
102
+ // Download artifacts
103
+ await this.downloadArtifacts(manifest, policyId);
104
+ await this.saveBundleManifest(policyId, manifest);
105
+ this._currentBundle = manifest;
106
+ return manifest;
107
+ } catch {
108
+ return this._currentBundle;
109
+ }
110
+ }
111
+
112
+ /** Download model artifacts and verify integrity. */
113
+ private async downloadArtifacts(manifest: BundleManifest, policyId: string): Promise<void> {
114
+ const fileSystem = await loadFileSystem();
115
+ if (!fileSystem || !fileSystem.documentDirectory) {
116
+ console.warn('[VerifyAI] expo-file-system not available, skipping download');
117
+ return;
118
+ }
119
+
120
+ const cacheDir = `${fileSystem.documentDirectory}verify-ai/bundles/${policyId}/`;
121
+ await fileSystem.makeDirectoryAsync(cacheDir, { intermediates: true }).catch(() => {});
122
+
123
+ for (const artifact of manifest.artifacts) {
124
+ if (!artifact.downloadUrl) continue;
125
+
126
+ const ext = artifact.format === 'coreml' ? 'mlpackage.zip' : artifact.format;
127
+ const localPath = `${cacheDir}${artifact.format}_v${artifact.version}.${ext}`;
128
+
129
+ // Check if already downloaded and intact
130
+ const info = await fileSystem.getInfoAsync(localPath).catch(() => ({ exists: false }));
131
+ if (info.exists) {
132
+ const existingHash = await this.readFileSha256(fileSystem, localPath);
133
+ if (existingHash === artifact.sha256) {
134
+ artifact.localPath = localPath;
135
+ continue;
136
+ }
137
+ await fileSystem.deleteAsync(localPath, { idempotent: true }).catch(() => {});
138
+ }
139
+
140
+ // Download
141
+ try {
142
+ const download = await fileSystem.downloadAsync(artifact.downloadUrl, localPath);
143
+ if (download.status === 200) {
144
+ const downloadedHash = await this.readFileSha256(fileSystem, localPath);
145
+ if (downloadedHash === artifact.sha256) {
146
+ artifact.localPath = localPath;
147
+ } else {
148
+ await fileSystem.deleteAsync(localPath, { idempotent: true }).catch(() => {});
149
+ }
150
+ } else {
151
+ await fileSystem.deleteAsync(localPath, { idempotent: true }).catch(() => {});
152
+ }
153
+ } catch {
154
+ await fileSystem.deleteAsync(localPath, { idempotent: true }).catch(() => {});
155
+ }
156
+ }
157
+ }
158
+
159
+ private async readFileSha256(
160
+ fileSystem: ExpoFileSystemModule,
161
+ localPath: string,
162
+ ): Promise<string | null> {
163
+ try {
164
+ const base64 = await fileSystem.readAsStringAsync(localPath, {
165
+ encoding: fileSystem.EncodingType.Base64,
166
+ });
167
+ return bytesToSha256Hex(Uint8Array.from(Buffer.from(base64, 'base64')));
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ private async saveBundleManifest(policyId: string, manifest: BundleManifest): Promise<void> {
174
+ const fileSystem = await loadFileSystem();
175
+ if (!fileSystem || !fileSystem.documentDirectory) {
176
+ return;
177
+ }
178
+
179
+ const cacheDir = `${fileSystem.documentDirectory}verify-ai/bundles/${policyId}/`;
180
+ const manifestPath = `${cacheDir}manifest.json`;
181
+ await fileSystem.makeDirectoryAsync(cacheDir, { intermediates: true }).catch(() => {});
182
+ await fileSystem.writeAsStringAsync(manifestPath, JSON.stringify(serializeBundleManifest(manifest)));
183
+ }
184
+
185
+ /** Load a cached bundle from disk on startup. */
186
+ async loadCachedBundle(policyId: string): Promise<BundleManifest | null> {
187
+ const fileSystem = await loadFileSystem();
188
+ if (!fileSystem || !fileSystem.documentDirectory) {
189
+ return null;
190
+ }
191
+
192
+ try {
193
+ const cacheDir = `${fileSystem.documentDirectory}verify-ai/bundles/${policyId}/`;
194
+ const manifestPath = `${cacheDir}manifest.json`;
195
+
196
+ const info = await fileSystem.getInfoAsync(manifestPath);
197
+ if (!info.exists) return null;
198
+
199
+ const content = await fileSystem.readAsStringAsync(manifestPath);
200
+ const manifest = parseBundleManifest(JSON.parse(content) as BundleManifestResponse);
201
+
202
+ // Verify local paths exist
203
+ for (const artifact of manifest.artifacts) {
204
+ const ext = artifact.format === 'coreml' ? 'mlpackage.zip' : artifact.format;
205
+ const localPath = `${cacheDir}${artifact.format}_v${artifact.version}.${ext}`;
206
+ const fileInfo = await fileSystem.getInfoAsync(localPath).catch(() => ({ exists: false }));
207
+ if (fileInfo.exists) {
208
+ artifact.localPath = localPath;
209
+ }
210
+ }
211
+
212
+ if (manifest.artifacts.some((a) => a.localPath)) {
213
+ this._currentBundle = manifest;
214
+ return manifest;
215
+ }
216
+
217
+ return null;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+ }
223
+
224
+ function parseBundleManifest(json: BundleManifestResponse): BundleManifest {
225
+ return {
226
+ bundleVersion: json.bundle_version,
227
+ ontologyVersion: json.ontology_version,
228
+ schemaVersion: json.schema_version,
229
+ policyId: json.policy_id,
230
+ classIds: json.class_ids || [],
231
+ artifacts: (json.artifacts || []).map((artifact) => ({
232
+ role: artifact.role,
233
+ architecture: artifact.architecture,
234
+ version: artifact.version,
235
+ format: artifact.format,
236
+ storagePath: artifact.storage_path,
237
+ sizeBytes: artifact.size_bytes,
238
+ sha256: artifact.sha256,
239
+ downloadUrl: artifact.download_url,
240
+ localPath: undefined,
241
+ })),
242
+ policyAst: json.policy_ast,
243
+ };
244
+ }
245
+
246
+ function serializeBundleManifest(manifest: BundleManifest): BundleManifestResponse {
247
+ return {
248
+ bundle_version: manifest.bundleVersion,
249
+ ontology_version: manifest.ontologyVersion,
250
+ schema_version: manifest.schemaVersion,
251
+ policy_id: manifest.policyId,
252
+ class_ids: manifest.classIds,
253
+ artifacts: manifest.artifacts.map((artifact) => ({
254
+ role: artifact.role,
255
+ architecture: artifact.architecture,
256
+ version: artifact.version,
257
+ format: artifact.format,
258
+ storage_path: artifact.storagePath,
259
+ size_bytes: artifact.sizeBytes,
260
+ sha256: artifact.sha256,
261
+ download_url: artifact.downloadUrl,
262
+ })),
263
+ policy_ast: manifest.policyAst,
264
+ };
265
+ }