diff --git a/src/tui/components/WorkerAnalyticsPanel.ts b/src/tui/components/WorkerAnalyticsPanel.ts index aa8180f..ab70321 100644 --- a/src/tui/components/WorkerAnalyticsPanel.ts +++ b/src/tui/components/WorkerAnalyticsPanel.ts @@ -6,7 +6,7 @@ */ import blessed from 'blessed'; -import { WorkerMetrics, AggregatedAnalytics, MetricsDataPoint } from '../../types.js'; +import { WorkerMetrics, AggregatedAnalytics, MetricsDataPoint, WorkerComparison } from '../../types.js'; /** Inline trend type from WorkerMetrics */ type InlineTrend = { @@ -106,6 +106,47 @@ function getStatusColor(errorRate: number): string { return 'red'; } +/** + * Render a comparison row with values, difference, and winner indicator + */ +function renderComparisonRow( + label: string, + value1: string | number, + value2: string | number, + diff: number, + percentDiff: number, + better: 'worker1' | 'worker2' | 'tie', + lowerIsBetter: boolean +): string { + const v1Str = String(value1).padStart(12); + const v2Str = String(value2).padStart(12); + + // Format difference + let diffStr = ''; + if (Math.abs(diff) < 0.001) { + diffStr = ' 0'; + } else { + const sign = diff > 0 ? '+' : ''; + const color = (lowerIsBetter ? diff < 0 : diff > 0) ? 'green' : 'red'; + diffStr = `{${color}-fg}${sign}${diff.toFixed(2)}{/}`; + } + + // Format percentage difference + let percentStr = ''; + if (Math.abs(percentDiff) < 0.1) { + percentStr = ' 0.0%'; + } else { + const sign = percentDiff > 0 ? '+' : ''; + const color = (lowerIsBetter ? percentDiff < 0 : percentDiff > 0) ? 'green' : 'red'; + percentStr = `{${color}-fg}${sign}${percentDiff.toFixed(1)}%{/}`; + } + + // Winner indicator + const winner = better === 'worker1' ? '{green-fg}←{/}' : better === 'worker2' ? '{green-fg}→{/}' : ' '; + + return ` ${label.padEnd(15)} ${v1Str} ${v2Str} ${diffStr} ${percentStr} ${winner}`; +} + /** * WorkerAnalyticsPanel displays worker performance metrics */ @@ -116,10 +157,12 @@ export class WorkerAnalyticsPanel { private metrics: WorkerMetrics[] = []; private aggregated: AggregatedAnalytics | null = null; private selectedIndex = 0; - private viewMode: 'list' | 'detail' | 'aggregated' = 'list'; + private secondSelectedIndex = 1; // For comparison mode + private viewMode: 'list' | 'detail' | 'aggregated' | 'comparison' = 'list'; private sortMode: 'beads' | 'errorRate' | 'cost' | 'efficiency' = 'beads'; private onSelect?: (workerId: string) => void; private analyticsManager: WorkerAnalytics; + private comparisonResult: WorkerComparison | null = null; constructor(options: WorkerAnalyticsPanelOptions) { this.onSelect = options.onSelect; @@ -188,11 +231,35 @@ export class WorkerAnalyticsPanel { */ private bindKeys(): void { this.list.key(['up', 'k'], () => { - this.selectPrevious(); + if (this.viewMode === 'comparison') { + this.selectPreviousComparison(); + } else { + this.selectPrevious(); + } }); this.list.key(['down', 'j'], () => { - this.selectNext(); + if (this.viewMode === 'comparison') { + this.selectNextComparison(); + } else { + this.selectNext(); + } + }); + + this.list.key(['left', 'h'], () => { + if (this.viewMode === 'comparison') { + // Move to first worker selection + this.selectedIndexChanged(this.selectedIndex); + this.render(); + } + }); + + this.list.key(['right', 'l'], () => { + if (this.viewMode === 'comparison') { + // Move to second worker selection + this.secondSelectedIndexChanged(this.secondSelectedIndex); + this.render(); + } }); this.list.key(['enter', 'space'], () => { @@ -203,6 +270,10 @@ export class WorkerAnalyticsPanel { this.toggleAggregated(); }); + this.list.key(['c'], () => { + this.toggleComparison(); + }); + this.list.key(['s'], () => { this.cycleSortMode(); }); @@ -314,6 +385,99 @@ export class WorkerAnalyticsPanel { this.render(); } + /** + * Toggle comparison view + */ + toggleComparison(): void { + if (this.metrics.length < 2) { + // Need at least 2 workers to compare + return; + } + + if (this.viewMode === 'comparison') { + this.viewMode = 'list'; + } else { + this.viewMode = 'comparison'; + // Ensure both indices are valid + if (this.secondSelectedIndex >= this.metrics.length) { + this.secondSelectedIndex = (this.selectedIndex + 1) % this.metrics.length; + } + // Update comparison result + this.updateComparisonResult(); + } + this.render(); + } + + /** + * Select next worker in comparison mode (cycles both selections together) + */ + selectNextComparison(): void { + if (this.metrics.length === 0) return; + this.selectedIndex = (this.selectedIndex + 1) % this.metrics.length; + this.secondSelectedIndex = (this.secondSelectedIndex + 1) % this.metrics.length; + this.updateComparisonResult(); + this.render(); + } + + /** + * Select previous worker in comparison mode + */ + selectPreviousComparison(): void { + if (this.metrics.length === 0) return; + this.selectedIndex = this.selectedIndex === 0 + ? this.metrics.length - 1 + : this.selectedIndex - 1; + this.secondSelectedIndex = this.secondSelectedIndex === 0 + ? this.metrics.length - 1 + : this.secondSelectedIndex - 1; + this.updateComparisonResult(); + this.render(); + } + + /** + * Change primary selection index + */ + selectedIndexChanged(newIndex: number): void { + if (this.metrics.length === 0) return; + this.selectedIndex = newIndex; + this.updateComparisonResult(); + this.render(); + } + + /** + * Change secondary selection index + */ + secondSelectedIndexChanged(newIndex: number): void { + if (this.metrics.length === 0) return; + this.secondSelectedIndex = newIndex; + this.updateComparisonResult(); + this.render(); + } + + /** + * Update the comparison result based on current selections + */ + private updateComparisonResult(): void { + if (this.metrics.length < 2) { + this.comparisonResult = null; + return; + } + + const worker1 = this.metrics[this.selectedIndex]; + const worker2 = this.metrics[this.secondSelectedIndex]; + + if (!worker1 || !worker2) { + this.comparisonResult = null; + return; + } + + // Use the analytics manager's compareWorkers method + this.comparisonResult = this.analyticsManager.compareWorkers( + worker1.workerId, + worker2.workerId + ); + } + /** * Refresh metrics */ @@ -494,6 +658,152 @@ export class WorkerAnalyticsPanel { this.detailBox.setContent(lines.join('\n')); } + /** + * Render comparison view + */ + private renderComparison(): void { + if (!this.comparisonResult || this.metrics.length < 2) { + this.detailBox.setContent('{gray-fg}Need at least 2 workers to compare{/}'); + this.list.hide(); + this.detailBox.top = 0; + this.detailBox.height = '100%-2'; + return; + } + + const c = this.comparisonResult; + const w1 = c.worker1; + const w2 = c.worker2; + const lines: string[] = []; + + lines.push('{bold}=== WORKER COMPARISON ==={/}'); + lines.push(''); + + // Header with worker IDs + const w1Short = w1.workerId.slice(0, 15); + const w2Short = w2.workerId.slice(0, 15); + const winnerIndicator = c.overallWinner === 'worker1' ? '{green-fg}★{/}' : c.overallWinner === 'worker2' ? '{green-fg} ★{/}' : ' ='; + + lines.push(`{bold}Worker 1:${/} {cyan-fg}${w1Short.padEnd(15)}{/} {bold}Worker 2:{/} {cyan-fg}${w2Short.padEnd(15)}{/} ${winnerIndicator}`); + lines.push('{bold}' + '-'.repeat(60) + '{/}'); + lines.push(''); + + // Performance metrics + lines.push('{bold}Performance Metrics:{/}'); + lines.push(renderComparisonRow( + 'Beads Completed', + w1.beadsCompleted, + w2.beadsCompleted, + c.differences.beadsCompleted, + c.percentDifferences.beadsCompleted, + c.betterWorker.beadsCompleted, + false // higher is better + )); + lines.push(renderComparisonRow( + 'Beads/Hour', + w1.beadsPerHour.toFixed(2), + w2.beadsPerHour.toFixed(2), + c.differences.beadsPerHour, + c.percentDifferences.beadsPerHour, + c.betterWorker.beadsPerHour, + false // higher is better + )); + lines.push(renderComparisonRow( + 'Avg Completion', + formatDuration(w1.avgCompletionTimeMs), + formatDuration(w2.avgCompletionTimeMs), + c.differences.avgCompletionTimeMs, + c.percentDifferences.avgCompletionTimeMs, + c.betterWorker.avgCompletionTimeMs, + true // lower is better + )); + lines.push(''); + + // Error and cost metrics + lines.push('{bold}Error & Cost Metrics:{/}'); + lines.push(renderComparisonRow( + 'Error Rate', + formatPercent(w1.errorRate), + formatPercent(w2.errorRate), + c.differences.errorRate, + c.percentDifferences.errorRate, + c.betterWorker.errorRate, + true // lower is better + )); + lines.push(renderComparisonRow( + 'Cost Per Bead', + formatCost(w1.costPerBead), + formatCost(w2.costPerBead), + c.differences.costPerBead, + c.percentDifferences.costPerBead, + c.betterWorker.costPerBead, + true // lower is better + )); + lines.push(renderComparisonRow( + 'Total Cost', + formatCost(w1.totalCostUsd), + formatCost(w2.totalCostUsd), + w1.totalCostUsd - w2.totalCostUsd, + (w1.totalCostUsd - w2.totalCostUsd) / (w2.totalCostUsd || 0.01) * 100, + w1.totalCostUsd < w2.totalCostUsd ? 'worker1' : w1.totalCostUsd > w2.totalCostUsd ? 'worker2' : 'tie', + true // lower is better + )); + lines.push(''); + + // Efficiency metrics + lines.push('{bold}Efficiency Metrics:{/}'); + lines.push(renderComparisonRow( + 'Efficiency Score', + formatPercent(w1.efficiencyScore), + formatPercent(w2.efficiencyScore), + c.differences.efficiencyScore, + c.percentDifferences.efficiencyScore, + c.betterWorker.efficiencyScore, + false // higher is better + )); + lines.push(renderComparisonRow( + 'Active Time', + formatDuration(w1.activeTimeMs), + formatDuration(w2.activeTimeMs), + w1.activeTimeMs - w2.activeTimeMs, + (w1.activeTimeMs - w2.activeTimeMs) / (w2.activeTimeMs || 1) * 100, + w1.activeTimeMs > w2.activeTimeMs ? 'worker1' : w1.activeTimeMs < w2.activeTimeMs ? 'worker2' : 'tie', + false // higher is better (more active time) + )); + lines.push(renderComparisonRow( + 'Idle Percentage', + formatPercent(w1.idlePercentage), + formatPercent(w2.idlePercentage), + w1.idlePercentage - w2.idlePercentage, + (w1.idlePercentage - w2.idlePercentage) / (w2.idlePercentage || 0.01) * 100, + w1.idlePercentage < w2.idlePercentage ? 'worker1' : w1.idlePercentage > w2.idlePercentage ? 'worker2' : 'tie', + true // lower is better + )); + lines.push(''); + + // Overall score + lines.push('{bold}Overall Score:{/}'); + lines.push(` Worker 1: {cyan-fg}${c.score.worker1}{/} metrics won`); + lines.push(` Worker 2: {cyan-fg}${c.score.worker2}{/} metrics won`); + lines.push(''); + + const overallText = c.overallWinner === 'worker1' + ? '{green-fg}Worker 1 wins overall{/}' + : c.overallWinner === 'worker2' + ? '{green-fg}Worker 2 wins overall{/}' + : '{yellow-fg}Overall tie{/}'; + lines.push(` Result: ${overallText}`); + + lines.push(''); + lines.push('{gray-fg}[↑/↓] Next pair [←/→] Swap workers [Esc] Back{/}'); + + // Hide list in comparison view + this.list.hide(); + this.detailBox.top = 0; + this.detailBox.height = '100%-2'; + + this.detailBox.setContent(lines.join('\n')); + } + /** * Render the component */ @@ -504,6 +814,8 @@ export class WorkerAnalyticsPanel { if (this.viewMode === 'aggregated') { this.renderAggregated(); + } else if (this.viewMode === 'comparison') { + this.renderComparison(); } else { // Show list and detail side by side this.list.show(); diff --git a/src/types.ts b/src/types.ts index c32e96b..ef24b62 100644 --- a/src/types.ts +++ b/src/types.ts @@ -971,6 +971,69 @@ export interface FileHeatmapStats { avgModificationsPerFile: number; } +/** + * Heatmap snapshot at a specific point in time + */ +export interface HeatmapSnapshot { + /** Timestamp of this snapshot */ + timestamp: number; + + /** Heatmap entries at this point in time */ + entries: FileHeatmapEntry[]; + + /** Statistics at this point in time */ + stats: FileHeatmapStats; +} + +/** + * Time-series heatmap data for animation + */ +export interface HeatmapTimelapse { + /** Start timestamp of the timelapse */ + startTimestamp: number; + + /** End timestamp of the timelapse */ + endTimestamp: number; + + /** Time interval between snapshots (ms) */ + interval: number; + + /** Total number of snapshots */ + totalSnapshots: number; + + /** Array of heatmap snapshots */ + snapshots: HeatmapSnapshot[]; +} + +/** + * Options for generating timelapse data + */ +export interface TimelapseOptions { + /** Start timestamp (defaults to oldest modification) */ + startTimestamp?: number; + + /** End timestamp (defaults to now) */ + endTimestamp?: number; + + /** Number of snapshots to generate (default 30) */ + snapshotCount?: number; + + /** Minimum modifications to be included */ + minModifications?: number; + + /** Maximum entries per snapshot */ + maxEntries?: number; + + /** Sort mode for snapshots */ + sortBy?: 'modifications' | 'recent' | 'workers' | 'collisions'; + + /** Filter by directory prefix */ + directoryFilter?: string; + + /** Only show files with collisions */ + collisionsOnly?: boolean; +} + // ============================================ // File Anomaly Detection Types // ============================================ @@ -2323,6 +2386,53 @@ export interface WorkerAnalyticsStore { getSummary(options?: WorkerAnalyticsOptions): string; } +/** + * Worker comparison result - side-by-side comparison of two workers + */ +export interface WorkerComparison { + /** First worker metrics */ + worker1: WorkerMetrics; + + /** Second worker metrics */ + worker2: WorkerMetrics; + + /** Metric differences (worker1 - worker2, positive means worker1 is higher) */ + differences: { + beadsCompleted: number; + beadsPerHour: number; + avgCompletionTimeMs: number; + errorRate: number; + costPerBead: number; + efficiencyScore: number; + }; + + /** Percentage differences (worker1 relative to worker2) */ + percentDifferences: { + beadsCompleted: number; + beadsPerHour: number; + avgCompletionTimeMs: number; + errorRate: number; + costPerBead: number; + efficiencyScore: number; + }; + + /** Which worker is better for each metric */ + betterWorker: { + beadsCompleted: 'worker1' | 'worker2' | 'tie'; + beadsPerHour: 'worker1' | 'worker2' | 'tie'; + avgCompletionTimeMs: 'worker1' | 'worker2' | 'tie'; + errorRate: 'worker1' | 'worker2' | 'tie'; + costPerBead: 'worker1' | 'worker2' | 'tie'; + efficiencyScore: 'worker1' | 'worker2' | 'tie'; + }; + + /** Overall winner based on majority of metrics */ + overallWinner: 'worker1' | 'worker2' | 'tie'; + + /** Score tally for determining overall winner */ + score: { worker1: number; worker2: number }; +} + // ============================================ // Semantic Narrative Types // ============================================ diff --git a/src/workerAnalytics.ts b/src/workerAnalytics.ts index 021a8a1..4916203 100644 --- a/src/workerAnalytics.ts +++ b/src/workerAnalytics.ts @@ -19,6 +19,7 @@ import { WorkerAnalyticsOptions, WorkerAnalyticsStore, TimeWindow, + WorkerComparison, } from './types.js'; import { CostTracker } from './tui/utils/costTracking.js'; import { getHistoricalStore, HistoricalStore, WorkerComparisonMetrics } from './historicalStore.js'; @@ -227,6 +228,12 @@ const MAX_ERROR_TIMESTAMPS = 500; const MAX_ACTIVITY_PERIODS = 500; const MAX_BEAD_COMPLETION_TIMES = 500; +/** Maximum number of workers to track in analytics (LRU eviction). */ +const MAX_WORKERS = 1000; + +/** Maximum age (ms) for inactive workers before pruning from analytics. */ +const STALE_WORKER_MAX_AGE_MS = 3_600_000; // 1 hour + /** * Internal tracking data for a worker */ @@ -266,6 +273,8 @@ export class WorkerAnalytics implements WorkerAnalyticsStore { private timeSeriesInterval: number; private lastSnapshotTime: number = 0; private metricAccumulator: MetricAccumulator; + private workerLRU: string[] = []; // Least recently used at front + private eventCountForCleanup: number = 0; constructor(costTracker?: CostTracker, timeSeriesInterval: number = 3600000) { this.costTracker = costTracker || new CostTracker(); @@ -283,8 +292,16 @@ export class WorkerAnalytics implements WorkerAnalyticsStore { // Get or create worker tracking data let worker = this.workers.get(event.worker); if (!worker) { + // Enforce worker cap with LRU eviction + if (this.workers.size >= MAX_WORKERS) { + this.evictLRUWorker(); + } worker = this.createWorkerTrackingData(event.worker, event.ts); this.workers.set(event.worker, worker); + this.workerLRU.push(event.worker); + } else { + // Update LRU order (move to end) + this.touchWorkerLRU(event.worker); } // Update activity tracking @@ -322,6 +339,13 @@ export class WorkerAnalytics implements WorkerAnalyticsStore { this.metricAccumulator.processEvent(event); } + // Periodic cleanup of stale workers (every 1000 events) + this.eventCountForCleanup++; + if (this.eventCountForCleanup >= 1000) { + this.cleanupStaleWorkers(); + this.eventCountForCleanup = 0; + } + // Periodic time-series snapshot this.maybeCreateSnapshot(event.ts); } @@ -599,6 +623,45 @@ export class WorkerAnalytics implements WorkerAnalyticsStore { return lines.join('\n'); } + /** + * Update worker LRU order (move to end when accessed). + */ + private touchWorkerLRU(workerId: string): void { + const idx = this.workerLRU.indexOf(workerId); + if (idx !== -1) { + this.workerLRU.splice(idx, 1); + } + this.workerLRU.push(workerId); + } + + /** + * Evict least recently used worker when over cap. + */ + private evictLRUWorker(): void { + const lruWorkerId = this.workerLRU.shift(); + if (lruWorkerId) { + this.workers.delete(lruWorkerId); + } + } + + /** + * Periodic cleanup of stale workers. + */ + private cleanupStaleWorkers(): void { + const now = Date.now(); + const staleWorkerCutoff = now - STALE_WORKER_MAX_AGE_MS; + + for (const [workerId, worker] of this.workers) { + if (worker.lastActivity < staleWorkerCutoff) { + this.workers.delete(workerId); + const lruIdx = this.workerLRU.indexOf(workerId); + if (lruIdx !== -1) { + this.workerLRU.splice(lruIdx, 1); + } + } + } + } + // ============================================ // Private Helper Methods // ============================================ @@ -925,6 +988,97 @@ export class WorkerAnalytics implements WorkerAnalyticsStore { } { return getHistoricalStore().getStats(); } + + /** + * Compare two workers side-by-side + */ + compareWorkers(worker1Id: string, worker2Id: string, options: WorkerAnalyticsOptions = {}): WorkerComparison | null { + const worker1 = this.getWorkerMetrics(worker1Id, options); + const worker2 = this.getWorkerMetrics(worker2Id, options); + + if (!worker1 || !worker2) { + return null; + } + + // Calculate raw differences (worker1 - worker2) + const differences = { + beadsCompleted: worker1.beadsCompleted - worker2.beadsCompleted, + beadsPerHour: worker1.beadsPerHour - worker2.beadsPerHour, + avgCompletionTimeMs: worker1.avgCompletionTimeMs - worker2.avgCompletionTimeMs, + errorRate: worker1.errorRate - worker2.errorRate, + costPerBead: worker1.costPerBead - worker2.costPerBead, + efficiencyScore: worker1.efficiencyScore - worker2.efficiencyScore, + }; + + // Calculate percentage differences + const percentDifferences = { + beadsCompleted: worker2.beadsCompleted > 0 + ? ((worker1.beadsCompleted - worker2.beadsCompleted) / worker2.beadsCompleted) * 100 + : (worker1.beadsCompleted > 0 ? 100 : 0), + beadsPerHour: worker2.beadsPerHour > 0 + ? ((worker1.beadsPerHour - worker2.beadsPerHour) / worker2.beadsPerHour) * 100 + : 0, + avgCompletionTimeMs: worker2.avgCompletionTimeMs > 0 + ? ((worker1.avgCompletionTimeMs - worker2.avgCompletionTimeMs) / worker2.avgCompletionTimeMs) * 100 + : 0, + errorRate: worker2.errorRate > 0 + ? ((worker1.errorRate - worker2.errorRate) / worker2.errorRate) * 100 + : 0, + costPerBead: worker2.costPerBead > 0 + ? ((worker1.costPerBead - worker2.costPerBead) / worker2.costPerBead) * 100 + : 0, + efficiencyScore: worker2.efficiencyScore > 0 + ? ((worker1.efficiencyScore - worker2.efficiencyScore) / worker2.efficiencyScore) * 100 + : 0, + }; + + // Determine which worker is better for each metric + const EPSILON = 0.0001; // Small value for floating point comparison + const betterWorker = { + beadsCompleted: Math.abs(differences.beadsCompleted) < EPSILON + ? 'tie' as const + : (differences.beadsCompleted > 0 ? 'worker1' as const : 'worker2' as const), + beadsPerHour: Math.abs(differences.beadsPerHour) < EPSILON + ? 'tie' as const + : (differences.beadsPerHour > 0 ? 'worker1' as const : 'worker2' as const), + avgCompletionTimeMs: Math.abs(differences.avgCompletionTimeMs) < EPSILON + ? 'tie' as const + : (differences.avgCompletionTimeMs < 0 ? 'worker1' as const : 'worker2' as const), // Lower is better + errorRate: Math.abs(differences.errorRate) < EPSILON + ? 'tie' as const + : (differences.errorRate < 0 ? 'worker1' as const : 'worker2' as const), // Lower is better + costPerBead: Math.abs(differences.costPerBead) < EPSILON + ? 'tie' as const + : (differences.costPerBead < 0 ? 'worker1' as const : 'worker2' as const), // Lower is better + efficiencyScore: Math.abs(differences.efficiencyScore) < EPSILON + ? 'tie' as const + : (differences.efficiencyScore > 0 ? 'worker1' as const : 'worker2' as const), + }; + + // Calculate score tally + let worker1Score = 0; + let worker2Score = 0; + + Object.values(betterWorker).forEach(winner => { + if (winner === 'worker1') worker1Score++; + else if (winner === 'worker2') worker2Score++; + }); + + // Determine overall winner + const overallWinner: 'worker1' | 'worker2' | 'tie' = + worker1Score > worker2Score ? 'worker1' : + worker2Score > worker1Score ? 'worker2' : 'tie'; + + return { + worker1, + worker2, + differences, + percentDifferences, + betterWorker, + overallWinner, + score: { worker1: worker1Score, worker2: worker2Score }, + }; + } } /**