/**
 * 4-tier scoring engine — unified scoring across all evaluation modes.
 *
 * Tier 1: Assertion-level (atomic pass/fail + optional numeric score)
 * Tier 2: Dimension-level (aggregated per scoring dimension)
 * Tier 3: Task-level (weighted composite of dimensions)
 * Tier 4: Suite/Area-level (aggregated across tasks)
 *
 * This engine is mode-agnostic — it works for literacy, MCP server,
 * agent harness, knowledge probe, and custom modes.
 *
 * @see docs/design-docs/architecture-overhaul/scoring-rubrics-assertions.md
 */
/**
 * Aggregate assertion scores into dimension scores.
 *
 * Groups assertions by dimension, then applies the configured aggregation
 * strategy (default: weighted-mean).
 */
export function aggregateDimensions(assertions, options) {
    const defaultAgg = options?.defaultAggregation ?? "weighted-mean";
    const labels = options?.dimensionLabels ?? {};
    // Group by dimension
    const groups = new Map();
    for (const a of assertions) {
        const dim = a.dimension || "uncategorized";
        const existing = groups.get(dim);
        if (existing) {
            existing.push(a);
        }
        else {
            groups.set(dim, [a]);
        }
    }
    const dimensions = [];
    for (const [dimId, dimAssertions] of groups) {
        const score = aggregateScores(dimAssertions, defaultAgg);
        dimensions.push({
            dimensionId: dimId,
            label: labels[dimId] ?? dimId,
            score,
            assertionCount: dimAssertions.length,
            passCount: dimAssertions.filter((a) => a.pass).length,
            aggregation: defaultAgg,
            assertions: dimAssertions,
        });
    }
    return dimensions.sort((a, b) => a.dimensionId.localeCompare(b.dimensionId));
}
/**
 * Compute a weighted task score from dimension scores.
 */
export function computeTaskScore(dimensions, options) {
    const { weights, taskId } = options;
    const threshold = options.threshold ?? 0.5;
    const warnings = [];
    // Weighted sum
    let score = 0;
    let totalWeight = 0;
    const matchedDimensions = [];
    for (const dim of dimensions) {
        const weight = weights[dim.dimensionId] ?? 0;
        if (weight > 0)
            matchedDimensions.push(dim.dimensionId);
        score += dim.score * weight;
        totalWeight += weight;
    }
    // Warn when no dimensions match any weight key — likely misconfiguration
    if (totalWeight === 0 && dimensions.length > 0) {
        const dimIds = dimensions.map((d) => d.dimensionId).join(", ");
        const weightKeys = Object.keys(weights).join(", ");
        warnings.push(`Task "${taskId}": no dimensions matched weight keys. ` +
            `Dimensions: [${dimIds}], weights: [${weightKeys}]. Score will be 0.`);
    }
    // Normalize if weights don't sum to 1 (guard against NaN when totalWeight is 0)
    if (totalWeight > 0 && Math.abs(totalWeight - 1.0) > 0.001) {
        score = score / totalWeight;
    }
    else if (totalWeight === 0) {
        score = 0;
    }
    return {
        taskId,
        ...(options.area ? { area: options.area } : {}),
        score,
        dimensions,
        weights,
        weightSource: options.weightSource ?? "default",
        passesThreshold: score >= threshold,
        threshold,
        ...(warnings.length > 0 ? { warnings } : {}),
    };
}
/**
 * Aggregate task scores into area scores.
 */
export function aggregateAreas(tasks, previousScores) {
    // Group tasks by area (from explicit metadata, falling back to taskId prefix)
    const groups = new Map();
    for (const task of tasks) {
        const area = task.area ?? extractArea(task.taskId);
        const existing = groups.get(area);
        if (existing) {
            existing.push(task);
        }
        else {
            groups.set(area, [task]);
        }
    }
    const areas = [];
    for (const [areaId, areaTasks] of groups) {
        const score = areaTasks.length > 0
            ? areaTasks.reduce((sum, t) => sum + t.score, 0) / areaTasks.length
            : 0;
        const previousScore = previousScores?.[areaId] ?? null;
        areas.push({
            areaId,
            score,
            taskCount: areaTasks.length,
            passingTaskCount: areaTasks.filter((t) => t.passesThreshold).length,
            tasks: areaTasks,
            delta: previousScore !== null ? score - previousScore : null,
        });
    }
    return areas.sort((a, b) => a.areaId.localeCompare(b.areaId));
}
// ---------------------------------------------------------------------------
// Score normalization
// ---------------------------------------------------------------------------
/**
 * Normalize an assertion score to [0, 1] range.
 *
 * Different assertion types produce scores in different ranges:
 * - Boolean (contains, equals, regex): 0 or 1
 * - LLM rubric: 0-100 (needs /100)
 * - similar: 0-1 (already normalized)
 * - javascript/python: user-defined (assumed 0-1)
 */
export function normalizeScore(rawScore, assertionType) {
    switch (assertionType) {
        case "g-eval":
        case "llm-rubric":
        case "model-graded-closedqa":
        case "model-graded-factuality":
            // LLM rubrics typically return 0-100
            return rawScore > 1 ? rawScore / 100 : rawScore;
        case "similar":
            // Similarity score is already 0-1
            return Math.max(0, Math.min(1, rawScore));
        case "contains":
        case "contains-all":
        case "contains-any":
        case "equals":
        case "is-json":
        case "regex":
            // Boolean assertions: 0 or 1
            return rawScore > 0 ? 1 : 0;
        default:
            // Custom assertions: clamp to [0, 1]
            return Math.max(0, Math.min(1, rawScore));
    }
}
/**
 * Compute ensemble score from multiple grader outputs.
 */
export function computeEnsembleScore(scores, aggregation = "mean") {
    if (scores.length === 0)
        return { score: 0, agreement: 0 };
    if (scores.length === 1)
        return { score: scores[0], agreement: 1 };
    let score;
    switch (aggregation) {
        case "mean":
            score = scores.reduce((a, b) => a + b, 0) / scores.length;
            break;
        case "median": {
            const sorted = [...scores].sort((a, b) => a - b);
            const mid = Math.floor(sorted.length / 2);
            score =
                sorted.length % 2 === 0
                    ? (sorted[mid - 1] + sorted[mid]) / 2
                    : sorted[mid];
            break;
        }
        case "max":
            score = Math.max(...scores);
            break;
    }
    // Agreement: 1 - normalized standard deviation
    const mean = scores.reduce((a, b) => a + b, 0) / scores.length;
    const variance = scores.reduce((sum, s) => sum + (s - mean) ** 2, 0) / scores.length;
    const stdDev = Math.sqrt(variance);
    const agreement = Math.max(0, 1 - stdDev);
    return { score, agreement };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function aggregateScores(assertions, strategy) {
    // Pre-filter to assertions with numeric scores. After this filter,
    // every element's .score is guaranteed non-null — the cast is safe.
    const scored = assertions.filter((a) => a.score !== null);
    if (scored.length === 0) {
        // Fall back to pass rate
        return assertions.length > 0
            ? assertions.filter((a) => a.pass).length / assertions.length
            : 0;
    }
    switch (strategy) {
        case "mean":
            return scored.reduce((sum, a) => sum + a.score, 0) / scored.length;
        case "weighted-mean": {
            const totalWeight = scored.reduce((sum, a) => sum + a.weight, 0);
            if (totalWeight === 0) {
                return scored.reduce((sum, a) => sum + a.score, 0) / scored.length;
            }
            return (scored.reduce((sum, a) => sum + a.score * a.weight, 0) / totalWeight);
        }
        case "min":
            return Math.min(...scored.map((a) => a.score));
        case "max":
            return Math.max(...scored.map((a) => a.score));
    }
}
/**
 * Extract the area name from a task ID.
 *
 * Uses the first segment before the first hyphen (e.g., "groq-blog-queries" → "groq").
 * This works for single-word areas but fails for multi-word areas
 * (e.g., "content-lake-queries" → "content" instead of "content-lake").
 *
 * TODO: Use explicit area metadata from task definitions instead of parsing taskId.
 */
function extractArea(taskId) {
    const parts = taskId.split("-");
    return parts[0] || "general";
}
