From ed4cfddc6d6e4eea588c5235f8e5b9bfa6443d26 Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Tue, 9 Jun 2026 18:29:44 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20add=20SignalExtractor=20=E2=80=94=20beh?= =?UTF-8?q?avioral=20feature=20computation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extractFeatures() computes FeatureVector from completed session - focus_ratio: typing rhythm signals / expected buckets (capped 1.0) - revision_intensity: (undos + pauses) / duration normalised (capped 1.0) - used_ai_tools: boolean from ai_tool_opened signal presence - relative_velocity: 1.0 MVP constant (Phase 2: 30-day baseline) - Sessions under 5 minutes return focus_ratio: 0 Privacy: all computations use signal counts only. signal_value never read as string — enforced by comment + test. Tests: 9 new tests, 28/28 total passing --- src/processor/signal-extractor.ts | 108 ++++++++++++++ tests/processor/signal-extractor.test.ts | 176 +++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 src/processor/signal-extractor.ts create mode 100644 tests/processor/signal-extractor.test.ts diff --git a/src/processor/signal-extractor.ts b/src/processor/signal-extractor.ts new file mode 100644 index 0000000..ee83679 --- /dev/null +++ b/src/processor/signal-extractor.ts @@ -0,0 +1,108 @@ +/** + * signal-extractor.ts + * + * Computes high-level, aggregate behavioral feature vectors from the raw anonymized signals + * of a completed work session on the user's local device. + * + * What this file does NOT do: + * - It does NOT read, inspect, or process string value content (e.g. signal_value as string). + * - It does NOT perform direct database connections or raw SQLite queries. + * - It does NOT transmit features or session data to any remote servers. + * - It does NOT process unanonymized signal data. + */ + +import type { + CompletedSession, + FeatureVector, + SignalType +} from '../types/index.js'; +import type { WrkmarkDb, RawSignalRow } from '../db/database.js'; + +export class SignalExtractor { + /** + * Constructs a new SignalExtractor instance. + * + * @param db - The database instance injected for signal retrieval. + */ + constructor(private readonly db: WrkmarkDb) {} + + /** + * Extracts a behavioral FeatureVector from raw signals of a completed session. + * + * @param session - The completed work session details. + * @returns The computed behavioral FeatureVector. + */ + extractFeatures(session: CompletedSession): FeatureVector { + const signals = this.db.signals.getBySession(session.id); + const duration_minutes = Math.round((session.duration_ms / 60000) * 100) / 100; + + // Privacy rule check: Confirm that we never read `signal_value` as a string. + // All calculation blocks below only count signal occurrences or check for type existence. + + // Focus Ratio calculation: + // Focus ratio is based on typing rhythm buckets. We divide typing signals by the expected buckets. + // Privacy check: Computes using signal count only. Does not inspect signal_value contents. + let focus_ratio = 0.0; + if (!this.isSessionTooShort(session) && duration_minutes > 0) { + const activeTypingSignals = this.countSignalType(signals, 'typing_rhythm_bucket'); + const expectedBuckets = duration_minutes / 0.5; + focus_ratio = Math.min(1.0, activeTypingSignals / expectedBuckets); + } + + // Revision Intensity calculation: + // Proxy for thoughtful, iterative behavior using pause and undo count. + // Privacy check: Computes using signal counts only. Does not inspect signal_value contents. + let revision_intensity = 0.0; + if (duration_minutes > 0) { + const undoCount = this.countSignalType(signals, 'undo_event'); + const pauseCount = this.countSignalType(signals, 'pause_event'); + revision_intensity = Math.min(1.0, (undoCount + pauseCount) / duration_minutes / 10); + } + + // Used AI Tools calculation: + // Checks presence of any 'ai_tool_opened' signal. + // Privacy check: Only checks the type field, never details or values. + const used_ai_tools = signals.some( + (sig) => sig.signal_type === 'ai_tool_opened' + ); + + // Relative Velocity: + // Placeholder MVP logic (always 1.0) to be compared with baseline in Phase 2. + // TODO: compare against 30-day baseline in Phase 2. + // Privacy check: Constant value. Does not inspect signal_value contents. + const relative_velocity = 1.0; + + return { + session_id: session.id, + computed_at: Date.now(), + duration_minutes, + focus_ratio, + revision_intensity, + used_ai_tools, + relative_velocity, + synced_to_server: false, + }; + } + + /** + * Helper to count signals of a specific type. + * + * @param signals - Array of RawSignalRow records. + * @param type - The signal type to filter and count. + * @returns The count of signals of the specified type. + */ + private countSignalType(signals: RawSignalRow[], type: SignalType): number { + // Privacy check: Counts records matching signal_type without inspecting signal_value. + return signals.filter((sig) => sig.signal_type === type).length; + } + + /** + * Helper to check if a session is too short to extract focus metrics. + * + * @param session - The completed work session. + * @returns True if session duration is less than 5 minutes (300,000 ms), otherwise false. + */ + private isSessionTooShort(session: CompletedSession): boolean { + return session.duration_ms < 300000; + } +} diff --git a/tests/processor/signal-extractor.test.ts b/tests/processor/signal-extractor.test.ts new file mode 100644 index 0000000..e61b477 --- /dev/null +++ b/tests/processor/signal-extractor.test.ts @@ -0,0 +1,176 @@ +/** + * signal-extractor.test.ts + * + * Unit tests for the SignalExtractor class. + * Validates feature extraction calculations (focus_ratio, revision_intensity, etc.), + * boundary conditions (e.g. sessions under 5 minutes), and ensures compliance with + * strict on-device privacy constraints. + * + * What this file does NOT do: + * - It does NOT test telemetry synchronization or remote networking logic. + * - It does NOT write persistent files to the local disk. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { createDatabase } from '../../src/db/database.js'; +import { SignalExtractor } from '../../src/processor/signal-extractor.js'; +import type { WrkmarkDb } from '../../src/db/database.js'; +import type { CompletedSession, AnonymizedSignal } from '../../src/types/index.js'; + +describe('SignalExtractor — Behavioral Feature Vectors Extraction', () => { + let db: WrkmarkDb; + let extractor: SignalExtractor; + + beforeEach(() => { + db = createDatabase(':memory:'); + extractor = new SignalExtractor(db); + }); + + const createTestSession = (overrides: Partial = {}): CompletedSession => { + const session: CompletedSession = { + id: 'session-123', + app_name: 'VS Code', + started_at: 1625097600000, + ended_at: 1625098200000, // 10 minutes default + duration_ms: 600000, // 10 minutes default + signal_count: 0, + ai_tool_opened: false, + synced_to_server: false, + ...overrides, + }; + db.sessions.insert({ + id: session.id, + app_name: session.app_name, + started_at: session.started_at, + signal_count: session.signal_count, + ai_tool_opened: session.ai_tool_opened, + }); + return session; + }; + + const insertSignalsHelper = ( + sessionId: string, + type: 'typing_rhythm_bucket' | 'undo_event' | 'pause_event' | 'ai_tool_opened', + count: number, + numericValue: number | null = 1 + ) => { + for (let i = 0; i < count; i++) { + const signal: AnonymizedSignal = { + timestamp: 1625097600000 + i * 1000, + app_name: 'VS Code', + signal_type: type, + numeric_value: numericValue, + session_id: sessionId, + }; + db.signals.insert(signal, sessionId); + } + }; + + it('extractFeatures() returns correct FeatureVector shape', () => { + const session = createTestSession(); + insertSignalsHelper(session.id, 'typing_rhythm_bucket', 5); + + const vector = extractor.extractFeatures(session); + expect(vector).toBeDefined(); + expect(vector.session_id).toBe(session.id); + expect(typeof vector.computed_at).toBe('number'); + expect(vector.duration_minutes).toBe(10.0); + expect(typeof vector.focus_ratio).toBe('number'); + expect(typeof vector.revision_intensity).toBe('number'); + expect(typeof vector.used_ai_tools).toBe('boolean'); + expect(vector.relative_velocity).toBe(1.0); + expect(vector.synced_to_server).toBe(false); + }); + + it('focus_ratio is 0 for sessions under 5 minutes', () => { + // 4 minutes session (240000 ms) + const session = createTestSession({ + ended_at: 1625097840000, + duration_ms: 240000, + }); + // Even if signals exist, focus_ratio must be 0 + insertSignalsHelper(session.id, 'typing_rhythm_bucket', 10); + + const vector = extractor.extractFeatures(session); + expect(vector.focus_ratio).toBe(0.0); + }); + + it('focus_ratio is capped at 1.0', () => { + const session = createTestSession(); // 10 minutes -> expectedBuckets = 20 + // Insert 25 typing signals (would result in 25/20 = 1.25 focus ratio) + insertSignalsHelper(session.id, 'typing_rhythm_bucket', 25); + + const vector = extractor.extractFeatures(session); + expect(vector.focus_ratio).toBe(1.0); + }); + + it('revision_intensity is capped at 1.0', () => { + const session = createTestSession(); // 10 minutes -> normalization factor is /10 /10 => /100 + // Insert 110 undo/pause events (110 / 10 / 10 = 1.1) + insertSignalsHelper(session.id, 'undo_event', 60); + insertSignalsHelper(session.id, 'pause_event', 50); + + const vector = extractor.extractFeatures(session); + expect(vector.revision_intensity).toBe(1.0); + }); + + it('used_ai_tools is true when ai_tool_opened signal exists', () => { + const session = createTestSession(); + insertSignalsHelper(session.id, 'ai_tool_opened', 1); + + const vector = extractor.extractFeatures(session); + expect(vector.used_ai_tools).toBe(true); + }); + + it('used_ai_tools is false when no ai_tool_opened signal exists', () => { + const session = createTestSession(); + + const vector = extractor.extractFeatures(session); + expect(vector.used_ai_tools).toBe(false); + }); + + it('relative_velocity is always 1.0 at MVP', () => { + const session = createTestSession(); + const vector = extractor.extractFeatures(session); + expect(vector.relative_velocity).toBe(1.0); + }); + + it('duration_minutes is correctly calculated', () => { + // 7 minutes 30 seconds session (450000 ms) -> 7.5 minutes + const session = createTestSession({ + ended_at: 1625098050000, + duration_ms: 450000, + }); + + const vector = extractor.extractFeatures(session); + expect(vector.duration_minutes).toBe(7.5); + }); + + it('Privacy: computations use signal counts not signal values', () => { + const sessionNormal = createTestSession({ id: 'session-normal' }); + const sessionLarge = createTestSession({ id: 'session-large' }); + + // Both sessions get the exact same signal counts (5 typing, 2 undo, 2 pause) + // but the large session uses very large numeric values + insertSignalsHelper(sessionNormal.id, 'typing_rhythm_bucket', 5, 2); + insertSignalsHelper(sessionNormal.id, 'undo_event', 2, 1); + insertSignalsHelper(sessionNormal.id, 'pause_event', 2, 12); + + insertSignalsHelper(sessionLarge.id, 'typing_rhythm_bucket', 5, 50); + insertSignalsHelper(sessionLarge.id, 'undo_event', 2, 1000); + insertSignalsHelper(sessionLarge.id, 'pause_event', 2, 3600); + + const vectorNormal = extractor.extractFeatures(sessionNormal); + const vectorLarge = extractor.extractFeatures(sessionLarge); + + // Assert focus_ratio and revision_intensity are identical + expect(vectorNormal.focus_ratio).toBe(vectorLarge.focus_ratio); + expect(vectorNormal.revision_intensity).toBe(vectorLarge.revision_intensity); + + // Verify focus_ratio and revision_intensity calculations: + // focus_ratio = 5 / (10 / 0.5) = 5 / 20 = 0.25 + expect(vectorNormal.focus_ratio).toBe(0.25); + // revision_intensity = (2 + 2) / 10 / 10 = 4 / 100 = 0.04 + expect(vectorNormal.revision_intensity).toBe(0.04); + }); +});