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:
parent
eb8ca504de
commit
f307524b4d
3 changed files with 580 additions and 4 deletions
|
|
@ -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();
|
||||
|
|
|
|||
110
src/types.ts
110
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
|
||||
// ============================================
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue