Files
wifi-densepose/vendor/ruvector/examples/edge/pkg/plaid-local-learner.ts

756 lines
21 KiB
TypeScript

/**
* Plaid Local Learning System
*
* A privacy-preserving financial learning system that runs entirely in the browser.
* No financial data, learning patterns, or AI models ever leave the client device.
*
* ## Architecture
*
* ```
* ┌─────────────────────────────────────────────────────────────────────┐
* │ BROWSER (All Data Stays Here) │
* │ │
* │ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │
* │ │ Plaid Link │────▶│ Transaction │────▶│ Local Learning │ │
* │ │ (OAuth) │ │ Processor │ │ Engine (WASM) │ │
* │ └─────────────┘ └──────────────┘ └───────────────────┘ │
* │ │ │ │ │
* │ ▼ ▼ ▼ │
* │ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │
* │ │ IndexedDB │ │ IndexedDB │ │ IndexedDB │ │
* │ │ (Tokens) │ │ (Embeddings) │ │ (Q-Values) │ │
* │ └─────────────┘ └──────────────┘ └───────────────────┘ │
* │ │
* │ ┌─────────────────────────────────────────────────────────────┐ │
* │ │ RuVector WASM Engine │ │
* │ │ • HNSW Vector Index (150x faster similarity search) │ │
* │ │ • Spiking Neural Network (temporal pattern learning) │ │
* │ │ • Q-Learning (spending optimization) │ │
* │ │ • LSH (semantic categorization) │ │
* │ └─────────────────────────────────────────────────────────────┘ │
* └─────────────────────────────────────────────────────────────────────┘
* ```
*
* ## Privacy Guarantees
*
* 1. Financial data NEVER leaves the browser
* 2. Learning happens 100% client-side in WASM
* 3. Optional encryption for IndexedDB storage
* 4. No analytics, telemetry, or tracking
* 5. User can delete all data instantly
*
* @example
* ```typescript
* import { PlaidLocalLearner } from './plaid-local-learner';
*
* const learner = new PlaidLocalLearner();
* await learner.init();
*
* // Process transactions (stays in browser)
* const insights = await learner.processTransactions(transactions);
*
* // Get predictions (computed locally)
* const category = await learner.predictCategory(newTransaction);
* const anomaly = await learner.detectAnomaly(newTransaction);
*
* // All data persisted to IndexedDB
* await learner.save();
* ```
*/
import init, {
PlaidLocalLearner as WasmLearner,
WasmHnswIndex,
WasmCrypto,
WasmSpikingNetwork,
} from './ruvector_edge';
// Database constants
const DB_NAME = 'plaid_local_learning';
const DB_VERSION = 1;
const STORES = {
STATE: 'learning_state',
TOKENS: 'plaid_tokens',
TRANSACTIONS: 'transactions',
INSIGHTS: 'insights',
};
/**
* Transaction from Plaid API
*/
export interface Transaction {
transaction_id: string;
account_id: string;
amount: number;
date: string;
name: string;
merchant_name?: string;
category: string[];
pending: boolean;
payment_channel: string;
}
/**
* Spending pattern learned from transactions
*/
export interface SpendingPattern {
pattern_id: string;
category: string;
avg_amount: number;
frequency_days: number;
confidence: number;
last_seen: number;
}
/**
* Category prediction result
*/
export interface CategoryPrediction {
category: string;
confidence: number;
similar_transactions: string[];
}
/**
* Anomaly detection result
*/
export interface AnomalyResult {
is_anomaly: boolean;
anomaly_score: number;
reason: string;
expected_amount: number;
}
/**
* Budget recommendation
*/
export interface BudgetRecommendation {
category: string;
recommended_limit: number;
current_avg: number;
trend: 'increasing' | 'stable' | 'decreasing';
confidence: number;
}
/**
* Processing insights from batch
*/
export interface ProcessingInsights {
transactions_processed: number;
total_amount: number;
patterns_learned: number;
state_version: number;
}
/**
* Learning statistics
*/
export interface LearningStats {
version: number;
patterns_count: number;
q_values_count: number;
embeddings_count: number;
index_size: number;
}
/**
* Temporal spending heatmap
*/
export interface TemporalHeatmap {
day_of_week: number[]; // 7 values (Sun-Sat)
day_of_month: number[]; // 31 values
}
/**
* Plaid Link configuration
*/
export interface PlaidConfig {
clientId?: string;
environment: 'sandbox' | 'development' | 'production';
products: string[];
countryCodes: string[];
language: string;
}
/**
* Browser-local financial learning engine
*
* All data processing happens in the browser using WebAssembly.
* Financial data is never transmitted to any server.
*/
export class PlaidLocalLearner {
private wasmLearner: WasmLearner | null = null;
private db: IDBDatabase | null = null;
private initialized = false;
private encryptionKey: CryptoKey | null = null;
/**
* Initialize the local learner
*
* - Loads WASM module
* - Opens IndexedDB
* - Restores previous learning state
*/
async init(encryptionPassword?: string): Promise<void> {
if (this.initialized) return;
// Initialize WASM
await init();
// Create WASM learner
this.wasmLearner = new WasmLearner();
// Open IndexedDB
this.db = await this.openDatabase();
// Setup encryption if password provided
if (encryptionPassword) {
this.encryptionKey = await this.deriveKey(encryptionPassword);
}
// Load previous state
await this.load();
this.initialized = true;
console.log('🧠 PlaidLocalLearner initialized (100% browser-local)');
}
/**
* Open IndexedDB database
*/
private openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create object stores
if (!db.objectStoreNames.contains(STORES.STATE)) {
db.createObjectStore(STORES.STATE);
}
if (!db.objectStoreNames.contains(STORES.TOKENS)) {
db.createObjectStore(STORES.TOKENS);
}
if (!db.objectStoreNames.contains(STORES.TRANSACTIONS)) {
const store = db.createObjectStore(STORES.TRANSACTIONS, {
keyPath: 'transaction_id',
});
store.createIndex('date', 'date');
store.createIndex('category', 'category', { multiEntry: true });
}
if (!db.objectStoreNames.contains(STORES.INSIGHTS)) {
db.createObjectStore(STORES.INSIGHTS);
}
};
});
}
/**
* Derive encryption key from password
*
* Uses a unique salt per installation stored in IndexedDB.
* This prevents rainbow table attacks across different users.
*/
private async deriveKey(password: string): Promise<CryptoKey> {
const encoder = new TextEncoder();
// Get or create unique salt for this installation
const salt = await this.getOrCreateSalt();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: 100000,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
/**
* Get or create a unique salt for this installation
*
* Salt is stored in IndexedDB and persists across sessions.
* Each browser/device gets a unique salt.
*/
private async getOrCreateSalt(): Promise<Uint8Array> {
const SALT_KEY = '_encryption_salt';
return new Promise(async (resolve, reject) => {
const transaction = this.db!.transaction([STORES.STATE], 'readwrite');
const store = transaction.objectStore(STORES.STATE);
// Try to get existing salt
const getRequest = store.get(SALT_KEY);
getRequest.onsuccess = () => {
if (getRequest.result) {
// Use existing salt
resolve(new Uint8Array(getRequest.result));
} else {
// Generate new random salt (32 bytes)
const newSalt = crypto.getRandomValues(new Uint8Array(32));
// Store it for future use
const putRequest = store.put(newSalt.buffer, SALT_KEY);
putRequest.onsuccess = () => resolve(newSalt);
putRequest.onerror = () => reject(putRequest.error);
}
};
getRequest.onerror = () => reject(getRequest.error);
});
}
/**
* Encrypt data for storage
*/
private async encrypt(data: string): Promise<ArrayBuffer> {
if (!this.encryptionKey) {
return new TextEncoder().encode(data);
}
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
this.encryptionKey,
new TextEncoder().encode(data)
);
// Prepend IV to encrypted data
const result = new Uint8Array(iv.length + encrypted.byteLength);
result.set(iv);
result.set(new Uint8Array(encrypted), iv.length);
return result.buffer;
}
/**
* Decrypt data from storage
*/
private async decrypt(data: ArrayBuffer): Promise<string> {
if (!this.encryptionKey) {
return new TextDecoder().decode(data);
}
const dataArray = new Uint8Array(data);
const iv = dataArray.slice(0, 12);
const encrypted = dataArray.slice(12);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
this.encryptionKey,
encrypted
);
return new TextDecoder().decode(decrypted);
}
/**
* Save learning state to IndexedDB
*/
async save(): Promise<void> {
this.ensureInitialized();
const stateJson = this.wasmLearner!.saveState();
const encrypted = await this.encrypt(stateJson);
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([STORES.STATE], 'readwrite');
const store = transaction.objectStore(STORES.STATE);
const request = store.put(encrypted, 'main');
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
/**
* Load learning state from IndexedDB
*/
async load(): Promise<void> {
this.ensureInitialized();
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([STORES.STATE], 'readonly');
const store = transaction.objectStore(STORES.STATE);
const request = store.get('main');
request.onerror = () => reject(request.error);
request.onsuccess = async () => {
if (request.result) {
try {
const stateJson = await this.decrypt(request.result);
this.wasmLearner!.loadState(stateJson);
} catch (e) {
console.warn('Failed to load state, starting fresh:', e);
}
}
resolve();
};
});
}
/**
* Process a batch of transactions
*
* All processing happens locally in WASM. No data is transmitted.
*/
async processTransactions(transactions: Transaction[]): Promise<ProcessingInsights> {
this.ensureInitialized();
// Store transactions locally
await this.storeTransactions(transactions);
// Process in WASM
const insights = this.wasmLearner!.processTransactions(
JSON.stringify(transactions)
) as ProcessingInsights;
// Auto-save state
await this.save();
return insights;
}
/**
* Store transactions in IndexedDB
*/
private async storeTransactions(transactions: Transaction[]): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([STORES.TRANSACTIONS], 'readwrite');
const store = transaction.objectStore(STORES.TRANSACTIONS);
transactions.forEach((tx) => {
store.put(tx);
});
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
/**
* Predict category for a transaction
*/
predictCategory(transaction: Transaction): CategoryPrediction {
this.ensureInitialized();
return this.wasmLearner!.predictCategory(
JSON.stringify(transaction)
) as CategoryPrediction;
}
/**
* Detect if a transaction is anomalous
*/
detectAnomaly(transaction: Transaction): AnomalyResult {
this.ensureInitialized();
return this.wasmLearner!.detectAnomaly(
JSON.stringify(transaction)
) as AnomalyResult;
}
/**
* Get budget recommendation for a category
*/
getBudgetRecommendation(
category: string,
currentSpending: number,
budget: number
): BudgetRecommendation {
this.ensureInitialized();
return this.wasmLearner!.getBudgetRecommendation(
category,
currentSpending,
budget
) as BudgetRecommendation;
}
/**
* Record spending outcome for Q-learning
*
* @param category - Spending category
* @param action - 'under_budget', 'at_budget', or 'over_budget'
* @param reward - Reward value (-1 to 1)
*/
recordOutcome(
category: string,
action: 'under_budget' | 'at_budget' | 'over_budget',
reward: number
): void {
this.ensureInitialized();
this.wasmLearner!.recordOutcome(category, action, reward);
}
/**
* Get all learned spending patterns
*/
getPatterns(): SpendingPattern[] {
this.ensureInitialized();
return this.wasmLearner!.getPatternsSummary() as SpendingPattern[];
}
/**
* Get temporal spending heatmap
*/
getTemporalHeatmap(): TemporalHeatmap {
this.ensureInitialized();
return this.wasmLearner!.getTemporalHeatmap() as TemporalHeatmap;
}
/**
* Find similar transactions
*/
findSimilar(transaction: Transaction, k: number = 5): { id: string; distance: number }[] {
this.ensureInitialized();
return this.wasmLearner!.findSimilarTransactions(
JSON.stringify(transaction),
k
) as { id: string; distance: number }[];
}
/**
* Get learning statistics
*/
getStats(): LearningStats {
this.ensureInitialized();
return this.wasmLearner!.getStats() as LearningStats;
}
/**
* Clear all learned data
*
* Privacy feature: completely wipes all local learning data.
*/
async clearAllData(): Promise<void> {
this.ensureInitialized();
// Clear WASM state
this.wasmLearner!.clear();
// Clear IndexedDB
const stores = [STORES.STATE, STORES.TRANSACTIONS, STORES.INSIGHTS];
for (const storeName of stores) {
await new Promise<void>((resolve, reject) => {
const transaction = this.db!.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
console.log('🗑️ All local learning data cleared');
}
/**
* Get stored transactions from IndexedDB
*/
async getStoredTransactions(
options: {
startDate?: string;
endDate?: string;
category?: string;
limit?: number;
} = {}
): Promise<Transaction[]> {
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([STORES.TRANSACTIONS], 'readonly');
const store = transaction.objectStore(STORES.TRANSACTIONS);
let request: IDBRequest;
if (options.startDate && options.endDate) {
const index = store.index('date');
request = index.getAll(IDBKeyRange.bound(options.startDate, options.endDate));
} else if (options.category) {
const index = store.index('category');
request = index.getAll(options.category);
} else {
request = store.getAll();
}
request.onerror = () => reject(request.error);
request.onsuccess = () => {
let results = request.result as Transaction[];
if (options.limit) {
results = results.slice(0, options.limit);
}
resolve(results);
};
});
}
/**
* Export all data for backup
*
* Returns encrypted data that can be imported later.
*/
async exportData(): Promise<ArrayBuffer> {
this.ensureInitialized();
const exportData = {
state: this.wasmLearner!.saveState(),
transactions: await this.getStoredTransactions(),
exportedAt: new Date().toISOString(),
version: 1,
};
return this.encrypt(JSON.stringify(exportData));
}
/**
* Import data from backup
*/
async importData(encryptedData: ArrayBuffer): Promise<void> {
this.ensureInitialized();
const json = await this.decrypt(encryptedData);
const importData = JSON.parse(json);
// Load state
this.wasmLearner!.loadState(importData.state);
// Store transactions
if (importData.transactions) {
await this.storeTransactions(importData.transactions);
}
await this.save();
}
/**
* Ensure learner is initialized
*/
private ensureInitialized(): void {
if (!this.initialized || !this.wasmLearner || !this.db) {
throw new Error('PlaidLocalLearner not initialized. Call init() first.');
}
}
/**
* Close database connection
*/
close(): void {
if (this.db) {
this.db.close();
this.db = null;
}
this.initialized = false;
}
}
/**
* Plaid Link integration helper
*
* Handles Plaid Link flow while keeping tokens local.
*/
export class PlaidLinkHandler {
private db: IDBDatabase | null = null;
constructor(private config: PlaidConfig) {}
/**
* Initialize handler
*/
async init(): Promise<void> {
this.db = await this.openDatabase();
}
private openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}
/**
* Store access token locally
*
* Token never leaves the browser.
*/
async storeToken(itemId: string, accessToken: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([STORES.TOKENS], 'readwrite');
const store = transaction.objectStore(STORES.TOKENS);
// Store encrypted (in production, use proper encryption)
const request = store.put(
{
accessToken,
storedAt: Date.now(),
},
itemId
);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
/**
* Get stored token
*/
async getToken(itemId: string): Promise<string | null> {
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([STORES.TOKENS], 'readonly');
const store = transaction.objectStore(STORES.TOKENS);
const request = store.get(itemId);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
resolve(request.result?.accessToken ?? null);
};
});
}
/**
* Delete token
*/
async deleteToken(itemId: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([STORES.TOKENS], 'readwrite');
const store = transaction.objectStore(STORES.TOKENS);
const request = store.delete(itemId);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
/**
* List all stored item IDs
*/
async listItems(): Promise<string[]> {
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([STORES.TOKENS], 'readonly');
const store = transaction.objectStore(STORES.TOKENS);
const request = store.getAllKeys();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result as string[]);
});
}
}
// Export default instance
export default PlaidLocalLearner;