/**
 * pipeline/compute-attribution.ts
 *
 * Pure three-signal attribution ensemble helper (v0).
 *
 * Computes per-document attribution scores for a single grader judgment
 * using three signals:
 *   - citation:   the grader's explicit doc citation with a role weight
 *   - canonical:  whether the doc appears in the task's declared context.docs
 *   - retrieved:  whether the doc slug was visited by the agent (agentic mode)
 *
 * No AppContext, no filesystem I/O — this is a pure function importable
 * by calibration scripts and orchestration steps alike.
 *
 * Weights (v0): citation=0.55, canonical=0.30, retrieved=0.15.
 * Embedding model: "none" (v0 — no embedding calls yet).
 *
 * @see docs/decisions/D0049-shared-confidence-contract.md
 * @see docs/decisions/D0050-per-entry-attribution-layout.md
 * @see docs/decisions/D0052-judgment-ref-granularity.md
 * @see docs/design-docs/actionability-ladder/04-per-document-attribution-ensemble.md
 */
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ROLE_WEIGHTS = {
    supports: 1.0,
    contradicts: 0.8,
    missing: 0.6,
    irrelevant: 0.0,
};
/** v0 ensemble weights. Callers may pass custom weights for calibration. */
export const V0_WEIGHTS = {
    citation: 0.55,
    canonical: 0.3,
    retrieved: 0.15,
};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
 * Compute the per-document attribution for a single grader judgment.
 *
 * The function is side-effect free except for the `reliability` accumulator —
 * the caller owns the `GraderReliability`-shaped mutable object and should
 * persist it after iterating over all judgments in a run.
 *
 * Hallucination short-circuit: when a candidate doc has a citation AND
 * `doc.slug` is absent from `judgment.hallucinationCheckedAgainst`,
 * the doc's attribution short-circuits to `score: 0`,
 * `confidence.level: "low"`, `signalsPresent: 0`, and
 * `reliability.hallucinationCount` is incremented. The function does NOT
 * throw — the pipeline continues and the count is persisted by the caller.
 *
 * @param judgment       - The grader judgment being attributed.
 * @param candidates     - DocumentRefs to compute attribution for. Callers
 *                         control the set (contextDocs ∪ cited docs ∪
 *                         retrieved docs).
 * @param contextDocIds  - Set of documentIds declared in task.context.docs
 *                         (the canonical signal source).
 * @param retrievedSlugs - Set of doc slugs visited by the agent, or
 *                         `undefined` when running in baseline mode (the
 *                         retrieved signal is then dropped from the ensemble).
 * @param reliability    - Mutable accumulator; `hallucinationCount` is
 *                         incremented for each hallucinated citation.
 * @param weights        - Ensemble weights; defaults to V0_WEIGHTS.
 */
export function computeJudgmentAttribution(judgment, candidates, contextDocIds, retrievedSlugs, reliability, weights = V0_WEIGHTS) {
    const resolvableSet = new Set(judgment.hallucinationCheckedAgainst);
    // Build a map of citations by documentId for O(1) lookups (D0052).
    const citationsByDocId = new Map();
    for (const cit of judgment.docCitations) {
        citationsByDocId.set(cit.documentId, cit);
    }
    const attributions = [];
    // Defensive dedup by documentId. Callers may pass candidates that contain
    // duplicate DocumentRef instances sharing a documentId (e.g. when
    // contextSlugs has duplicate slug entries or two manifest slug aliases
    // resolve to the same documentId). Without dedup, the hallucinationCount
    // accumulator and the per-doc attribution array would double-count.
    const seenDocIds = new Set();
    for (const candidate of candidates) {
        if (seenDocIds.has(candidate.documentId))
            continue;
        seenDocIds.add(candidate.documentId);
        const cit = citationsByDocId.get(candidate.documentId);
        // Hallucination short-circuit (Pitfall #11, T-04-01-01).
        //
        // A citation is hallucinated when:
        //   (a) the candidate has a slug and that slug is not in resolvableSet, OR
        //   (b) the candidate has no slug — we cannot verify resolvability, so
        //       treat it conservatively as a hallucination (D0052 keys
        //       resolvableSet by slug; no slug means no positive proof of
        //       resolvability).
        //
        // Score is forced to 0 and reliability.hallucinationCount is incremented
        // so downstream consumers can audit citation grounding without
        // re-deriving the set.
        if (cit && (!candidate.slug || !resolvableSet.has(candidate.slug))) {
            reliability.hallucinationCount += 1;
            attributions.push({
                documentId: candidate.documentId,
                ...(candidate.slug !== undefined ? { slug: candidate.slug } : {}),
                score: 0,
                signals: {},
                confidence: {
                    level: "low",
                    signalsPresent: 0,
                    derivation: "ensemble-stdev",
                },
            });
            continue;
        }
        // Citation signal: role weight when cited; undefined when not cited at all.
        // Do NOT coerce to 0 — undefined signals are dropped from signalsPresent.
        const citation = cit
            ? ROLE_WEIGHTS[cit.role]
            : undefined;
        // Canonical signal: binary presence in task's context.docs (always present).
        const canonical = contextDocIds.has(candidate.documentId) ? 1 : 0;
        // Retrieved signal: slug presence in agent retrieval set, or undefined
        // when running in baseline mode (agentBehavior absent — Pitfall #4).
        const retrieved = retrievedSlugs === undefined
            ? undefined
            : retrievedSlugs.has(candidate.slug ?? "")
                ? 1
                : 0;
        // Collect present (non-undefined) signals for confidence derivation.
        const present = [citation, canonical, retrieved].filter((s) => s !== undefined);
        const score = clamp((citation ?? 0) * weights.citation +
            canonical * weights.canonical +
            (retrieved ?? 0) * weights.retrieved, 0, 1);
        // Build the signals object omitting undefined members so JSON output is clean.
        const signals = { canonical };
        if (citation !== undefined)
            signals.citation = citation;
        if (retrieved !== undefined)
            signals.retrieved = retrieved;
        attributions.push({
            documentId: candidate.documentId,
            ...(candidate.slug !== undefined ? { slug: candidate.slug } : {}),
            score,
            signals,
            confidence: deriveEnsembleConfidence(present),
        });
    }
    const judgmentRef = `${judgment.taskId}--${judgment.modelId}--${judgment.dimension}`;
    return {
        judgmentRef,
        taskId: judgment.taskId,
        modelId: judgment.modelId,
        dimension: judgment.dimension,
        attributions,
        hallucinationCheckedAgainst: judgment.hallucinationCheckedAgainst,
    };
}
/**
 * Derive a `Confidence` triple from the array of present signal values
 * using standard-deviation-based agreement scoring.
 *
 * When `present.length <= 1`, the result is always `"low"` — a single
 * signal cannot speak to agreement.
 *
 * The `0.5` normalization constant is the maximum standard deviation for
 * a 2-or-3-point series whose values are in [0, 1] (achieved by
 * [0, 1] or [0, 0, 1] patterns). Dividing the actual stdev by 0.5
 * normalises agreement to [0, 1].
 *
 * Buckets: agreement > 0.7 → "high", > 0.4 → "medium", else → "low".
 */
export function deriveEnsembleConfidence(present) {
    if (present.length <= 1) {
        return {
            level: "low",
            signalsPresent: present.length,
            derivation: "ensemble-stdev",
        };
    }
    const mean = present.reduce((sum, v) => sum + v, 0) / present.length;
    const variance = present.reduce((sum, v) => sum + (v - mean) ** 2, 0) / present.length;
    const stdev = Math.sqrt(variance);
    // Normalise: max stdev for [0,1]-bounded series ≈ 0.5
    const agreement = 1 - stdev / 0.5;
    const level = agreement > 0.7 ? "high" : agreement > 0.4 ? "medium" : "low";
    return {
        level,
        signalsPresent: present.length,
        derivation: "ensemble-stdev",
    };
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function clamp(value, min, max) {
    return Math.min(max, Math.max(min, value));
}
