187 lines
5.1 KiB
TypeScript

/**
* Dimension Score Trend Analysis
*
* Track per-dimension score trends over time:
* - Is code quality improving?
* - What dimensions are below threshold?
* - What are the learned minimum thresholds (rejection boundaries)?
*/
import type {
EnrichedFeedbackEvent,
DimensionTrend,
QualityDimension,
} from "../types.js";
/**
* Compute per-dimension trends from feedback events.
*/
export async function computeDimensionTrends(
events: EnrichedFeedbackEvent[]
): Promise<DimensionTrend[]> {
// Collect scores by dimension
const dimensionData = new Map<
QualityDimension,
{
scores: Array<{ score: number; timestamp: string; approved: boolean }>;
}
>();
for (const event of events) {
const isApproved = event.feedback.decision?.decision === "approved";
for (const score of event.feedback.scores ?? []) {
if (!dimensionData.has(score.dimension)) {
dimensionData.set(score.dimension, { scores: [] });
}
dimensionData.get(score.dimension)!.scores.push({
score: score.score,
timestamp: event.timestamp,
approved: isApproved ?? false,
});
}
}
const trends: DimensionTrend[] = [];
for (const [dimension, data] of dimensionData) {
if (data.scores.length === 0) continue;
// Sort by timestamp
const sorted = [...data.scores].sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
// Overall average
const avgScore =
sorted.reduce((sum, s) => sum + s.score, 0) / sorted.length;
// Trend: compare first third to last third
const thirdLen = Math.max(1, Math.floor(sorted.length / 3));
const firstThird = sorted.slice(0, thirdLen);
const lastThird = sorted.slice(-thirdLen);
const firstAvg =
firstThird.reduce((sum, s) => sum + s.score, 0) / firstThird.length;
const lastAvg =
lastThird.reduce((sum, s) => sum + s.score, 0) / lastThird.length;
const trendDelta = lastAvg - firstAvg;
let trend: "improving" | "stable" | "declining";
if (trendDelta > 0.5) trend = "improving";
else if (trendDelta < -0.5) trend = "declining";
else trend = "stable";
// Compute minimum threshold: score below which rejection rate > 80%
const minimumThreshold = computeMinimumThreshold(sorted);
trends.push({
dimension,
avgScore: Math.round(avgScore * 100) / 100,
trend,
trendDelta: Math.round(trendDelta * 100) / 100,
dataPoints: sorted.length,
minimumThreshold,
});
}
// Sort by data points (most data first)
return trends.sort((a, b) => b.dataPoints - a.dataPoints);
}
/**
* Find the score threshold below which Jake almost always rejects.
* Returns undefined if insufficient data.
*/
function computeMinimumThreshold(
scores: Array<{ score: number; approved: boolean }>
): number | undefined {
if (scores.length < 10) return undefined;
// Test each integer score as a potential threshold
for (let threshold = 1; threshold <= 10; threshold++) {
const belowThreshold = scores.filter((s) => s.score < threshold);
if (belowThreshold.length < 3) continue;
const rejectionRate =
belowThreshold.filter((s) => !s.approved).length / belowThreshold.length;
if (rejectionRate >= 0.8) {
return threshold;
}
}
return undefined;
}
/**
* Get the top improving and declining dimensions.
*/
export function getDimensionHighlights(trends: DimensionTrend[]): {
improving: DimensionTrend[];
declining: DimensionTrend[];
belowThreshold: DimensionTrend[];
} {
return {
improving: trends
.filter((t) => t.trend === "improving")
.sort((a, b) => b.trendDelta - a.trendDelta),
declining: trends
.filter((t) => t.trend === "declining")
.sort((a, b) => a.trendDelta - b.trendDelta),
belowThreshold: trends.filter(
(t) =>
t.minimumThreshold !== undefined && t.avgScore < t.minimumThreshold + 1
),
};
}
/**
* Get score distributions per dimension for dashboard display.
*/
export function getDimensionDistributions(
events: EnrichedFeedbackEvent[]
): Record<
QualityDimension,
{ avg: number; min: number; max: number; count: number }
> {
const distributions: Record<
string,
{ scores: number[] }
> = {};
for (const event of events) {
for (const score of event.feedback.scores ?? []) {
if (!distributions[score.dimension]) {
distributions[score.dimension] = { scores: [] };
}
distributions[score.dimension]!.scores.push(score.score);
}
}
const result: Record<
string,
{ avg: number; min: number; max: number; count: number }
> = {};
for (const [dim, data] of Object.entries(distributions)) {
if (data.scores.length === 0) continue;
const sorted = [...data.scores].sort((a, b) => a - b);
result[dim] = {
avg:
Math.round(
(sorted.reduce((a, b) => a + b, 0) / sorted.length) * 100
) / 100,
min: sorted[0]!,
max: sorted[sorted.length - 1]!,
count: sorted.length,
};
}
return result as Record<
QualityDimension,
{ avg: number; min: number; max: number; count: number }
>;
}