feat(analytics): add worker-to-worker comparison mode

Add side-by-side worker comparison analytics to the TUI analytics panel.
Users can now press 'c' to enter comparison mode and view detailed metrics
comparing two workers across performance, error, cost, and efficiency dimensions.

- Add WorkerComparison type with differences, percent differences, and winner per metric
- Add compareWorkers() method to WorkerAnalytics class
- Extend WorkerAnalyticsPanel with comparison view mode
- Add renderComparison() method with formatted comparison rows
- Add keyboard bindings: [c] toggle comparison, [↑/↓] cycle workers, [←/→] swap selection

Related to docs/plan.md Worker Comparison Analytics section.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-28 14:05:00 -04:00
parent eb8ca504de
commit f307524b4d
3 changed files with 580 additions and 4 deletions

View file

@ -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();

View file

@ -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
// ============================================

View file

@ -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 },
};
}
}
/**