/**
 * pipeline/attribution.ts
 *
 * Per-document attribution for score changes.
 *
 * Given a ComparisonReport, a list of changed document slugs, and the
 * resolved task mappings, this module classifies each task's score delta
 * as unambiguous (one changed doc), ambiguous (multiple changed docs),
 * or uncorrelated (no changed docs in the task's canonical set).
 *
 * This is Phase 1 of the hybrid attribution approach — zero additional
 * eval cost, purely correlation-based. Phase 2 (targeted LOO) can be
 * layered on top for ambiguous cases when higher precision is needed.
 *
 * @see docs/design-docs/scenario-matrix/per-document-attribution.md
 * @see docs/archive/exec-plans/scenario-matrix-implementation/phase-2-impact-scenarios.md
 */
/** v0 sentinel — no embedding call in v0; v1.2's flip to a real
 *  model name (e.g., "text-embedding-3-small") mechanically forces
 *  ensembleVersion's right segment to change, invalidating cached
 *  weights downstream. */
export const embeddingModel = "none";
/** VER-01 D-02 — co-located ensemble version. Compound semver-ish
 *  shape: "{algorithmVersion}+{embeddingModel}" (per D-02 of Phase
 *  4 CONTEXT.md). Phase 1 landed the constant; Phase 4 wires the
 *  compound shape and the bump-by-calibration discipline
 *  (calibrate-attribution.ts is the ONLY allowed bumper). */
export const ensembleVersion = `0.1.0+${embeddingModel}`;
/** Version tag for the calibration set fixture co-located in the repo.
 *  Bump when the fixture structure changes (e.g. when v1 moves the
 *  calibration set to Content Lake or expands to ~30 stratified rows). */
export const calibrationSetVersion = "v0-fixture";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
 * Attribute score changes to individual documents.
 *
 * For each area in the comparison report, cross-references the task's
 * canonical_docs with the changed slugs to classify the attribution:
 *
 * - **unambiguous**: exactly 1 changed doc in the task's canonical set
 * - **ambiguous**: 2+ changed docs in the task's canonical set
 * - **uncorrelated**: 0 changed docs in the task's canonical set
 *
 * Tasks with deltas within the noise floor are still attributed but
 * flagged as `withinNoiseFloor: true`.
 *
 * @param comparison - The structured comparison between before/after runs
 * @param changedSlugs - Document slugs that changed
 * @param mappings - Resolved task-to-canonical-docs mappings
 * @param noiseThreshold - Deltas within ±this value are marked as noise
 * @returns Attribution report with per-task classifications
 */
export function attributeChanges(comparison, changedSlugs, mappings, noiseThreshold) {
    const changedSet = new Set(changedSlugs);
    const attributions = [];
    // Build a flat list of all tasks from the mappings
    const taskCanonicalDocs = new Map();
    for (const [area, config] of Object.entries(mappings.feature_areas)) {
        for (const task of config.tasks) {
            taskCanonicalDocs.set(task.id, {
                area,
                slugs: task.canonical_docs.map((d) => d.slug),
            });
        }
    }
    // For each area in the comparison, attribute the delta
    for (const areaDelta of comparison.areas) {
        // Find tasks in this area
        const areaTasks = [...taskCanonicalDocs.entries()].filter(([, info]) => info.area === areaDelta.area);
        if (areaTasks.length === 0) {
            // Area exists in comparison but has no tasks in mappings
            // (shouldn't happen in practice, but handle gracefully)
            continue;
        }
        // Classify each task. When no task in this area has a matching
        // changed doc, every per-task classification will fall through to
        // `uncorrelated` here — no separate area-level record is needed.
        for (const [taskId, taskInfo] of areaTasks) {
            const taskMatchingSlugs = taskInfo.slugs.filter((s) => changedSet.has(s));
            const classification = classifyAttribution(taskMatchingSlugs.length);
            attributions.push({
                area: taskInfo.area,
                attributedDocs: taskMatchingSlugs,
                classification,
                delta: areaDelta.delta,
                taskId,
                withinNoiseFloor: Math.abs(areaDelta.delta) <= noiseThreshold,
            });
        }
    }
    // Find untracked documents: changed slugs not in ANY task's canonical docs
    const allTrackedSlugs = new Set([...taskCanonicalDocs.values()].flatMap((info) => info.slugs));
    const untrackedDocs = changedSlugs
        .filter((s) => !allTrackedSlugs.has(s))
        .sort();
    // Compute summary counts
    const summary = {
        ambiguous: attributions.filter((a) => a.classification === "ambiguous")
            .length,
        unambiguous: attributions.filter((a) => a.classification === "unambiguous")
            .length,
        uncorrelated: attributions.filter((a) => a.classification === "uncorrelated").length,
        withinNoise: attributions.filter((a) => a.withinNoiseFloor).length,
    };
    return {
        attributions,
        summary,
        untrackedDocs,
    };
}
// ---------------------------------------------------------------------------
// Formatting
// ---------------------------------------------------------------------------
/**
 * Format an attribution report for console output.
 */
export function formatAttributionConsole(report) {
    const lines = [];
    lines.push("📋 PER-DOCUMENT ATTRIBUTION");
    lines.push("");
    // Unambiguous attributions
    const unambiguous = report.attributions.filter((a) => a.classification === "unambiguous");
    if (unambiguous.length > 0) {
        lines.push(`Unambiguous (${unambiguous.length} tasks):`);
        for (const a of unambiguous) {
            const noiseTag = a.withinNoiseFloor ? " ⚠️ within noise" : "";
            const deltaStr = a.delta > 0 ? `+${Math.round(a.delta)}` : String(Math.round(a.delta));
            lines.push(`  ${a.taskId}: ${deltaStr} → ${a.attributedDocs[0]}${noiseTag}`);
        }
        lines.push("");
    }
    // Ambiguous attributions
    const ambiguous = report.attributions.filter((a) => a.classification === "ambiguous");
    if (ambiguous.length > 0) {
        lines.push(`Ambiguous (${ambiguous.length} tasks):`);
        for (const a of ambiguous) {
            const noiseTag = a.withinNoiseFloor ? " ⚠️ within noise" : "";
            const deltaStr = a.delta > 0 ? `+${Math.round(a.delta)}` : String(Math.round(a.delta));
            lines.push(`  ${a.taskId}: ${deltaStr} → {${a.attributedDocs.join(", ")}}${noiseTag}`);
        }
        lines.push("");
    }
    // Uncorrelated
    const uncorrelated = report.attributions.filter((a) => a.classification === "uncorrelated" && !a.withinNoiseFloor);
    if (uncorrelated.length > 0) {
        lines.push(`Uncorrelated (${uncorrelated.length} tasks):`);
        for (const a of uncorrelated) {
            const deltaStr = a.delta > 0 ? `+${Math.round(a.delta)}` : String(Math.round(a.delta));
            lines.push(`  ${a.taskId}: ${deltaStr} → likely grader variance (no changed docs in canonical set)`);
        }
        lines.push("");
    }
    // Untracked documents
    if (report.untrackedDocs.length > 0) {
        lines.push("Untracked documents:");
        for (const doc of report.untrackedDocs) {
            lines.push(`  ${doc} — not in any task's canonical_docs`);
        }
        lines.push("");
    }
    return lines.join("\n");
}
/**
 * Format an attribution report as markdown for PR comments.
 */
export function formatAttributionMarkdown(report) {
    const lines = [];
    lines.push("### 📋 Per-Document Attribution");
    lines.push("");
    if (report.attributions.length === 0 && report.untrackedDocs.length === 0) {
        lines.push("No attribution data available.");
        lines.push("");
        return lines.join("\n");
    }
    // Summary
    const { ambiguous, unambiguous, uncorrelated, withinNoise } = report.summary;
    const parts = [];
    if (unambiguous > 0)
        parts.push(`${unambiguous} unambiguous`);
    if (ambiguous > 0)
        parts.push(`${ambiguous} ambiguous`);
    if (uncorrelated > 0)
        parts.push(`${uncorrelated} uncorrelated`);
    if (withinNoise > 0)
        parts.push(`${withinNoise} within noise`);
    if (parts.length > 0) {
        lines.push(`**${parts.join(" · ")}**`);
        lines.push("");
    }
    // Attribution table
    const hasAttributions = report.attributions.some((a) => a.delta !== 0);
    if (hasAttributions) {
        lines.push("| Task | Area | Delta | Attribution | Documents |");
        lines.push("|------|------|-------|-------------|-----------|");
        for (const a of report.attributions) {
            if (a.delta === 0 && a.classification === "uncorrelated")
                continue;
            const deltaStr = a.delta > 0 ? `+${Math.round(a.delta)}` : String(Math.round(a.delta));
            const noiseIcon = a.withinNoiseFloor ? " ⚠️" : "";
            const classIcon = a.classification === "unambiguous"
                ? "✅"
                : a.classification === "ambiguous"
                    ? "🟡"
                    : "❓";
            const docs = a.attributedDocs.length > 0
                ? a.attributedDocs.map((d) => `\`${d}\``).join(", ")
                : "—";
            lines.push(`| ${a.taskId} | ${a.area} | ${deltaStr}${noiseIcon} | ${classIcon} ${a.classification} | ${docs} |`);
        }
        lines.push("");
    }
    // Untracked documents
    if (report.untrackedDocs.length > 0) {
        lines.push("**Untracked documents** (not in any task's canonical_docs):");
        lines.push("");
        for (const doc of report.untrackedDocs) {
            lines.push(`- \`${doc}\``);
        }
        lines.push("");
    }
    return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Classify attribution based on the number of matching changed documents */
function classifyAttribution(matchCount) {
    if (matchCount === 0)
        return "uncorrelated";
    if (matchCount === 1)
        return "unambiguous";
    return "ambiguous";
}
