// ─── ADR-111 §5 Gate-1 train/test split — frozen, ancestry-ordered ──────────
//
// The mining/control split over the frozen corpus `selectionRule(asOfCommit)`
// (ADR-110 §6). Committed as a lock BEFORE extraction; `trainPrs` = the OLDER
// ancestry segment (mining), `heldOutPrs` = the NEWER held-out segment
// (control) — forward-chronological, never a random shuffle (reverse-causality
// leakage) and never commit-date (rebase-rewritable; the same `--topo-order`
// invariant as #2197). The split is a THREE-WAY DISJOINT COVER of the corpus:
// `trainPrs ⊎ heldOutPrs ⊎ excludedPrs == selectionRule(asOfCommit)`, with the
// positive/negative controls as designated tags WITHIN `heldOutPrs` (never a
// separate cover bucket). Total accounting (FM (d)/(g)): a corpus PR in no slice
// is a silent drop; a slice PR outside the corpus is an out-of-corpus member.
//
// This module is the pure schema + the deterministic cover validator (the §8
// split-ledger check) + the forward-ancestry-cut producer. IO (git enumeration
// → PrMeta) lives in the cli layer, as with `selection-rule.ts`.
import { z } from 'zod';
import { diffPrSets } from './selection-rule.js';
/** Lowercase 40-hex git commit SHA — canonical git form (cf. compiler-schema `COMMIT_SHA_RE`). */
const COMMIT_SHA_RE = /^[0-9a-f]{40}$/;
const PrNumber = z.number().int().positive();
/**
 * ADR-111 §5 — the frozen split artifact, committed before extraction. The
 * concrete `cutIndex` is the deferred window-open scalar (Deferred Decisions);
 * the field is required in the committed artifact, only its value is deferred.
 */
export const SplitArtifactSchema = z
    .object({
    asOfCommit: z.string().regex(COMMIT_SHA_RE),
    /** The OLDER ancestry segment — the mining slice. */
    trainPrs: z.array(PrNumber),
    /** The NEWER held-out segment — control evaluation. Controls are tags within this. */
    heldOutPrs: z.array(PrNumber),
    /** Explicitly enumerated drops (the atomic revert pairs). */
    excludedPrs: z.array(PrNumber),
    /** Positive controls — a designated subset (tag) of `heldOutPrs`, never a separate cover bucket. */
    positiveControlPrs: z.array(PrNumber),
    /** Negative controls — a designated subset (tag) of `heldOutPrs`. */
    negativeControlPrs: z.array(PrNumber),
    splitRule: z.object({
        /** Human-readable predicate expression that generated the corpus (mirrors the windtunnel lock's `selectionRule.predicate`). */
        predicate: z.string().refine((s) => s.trim().length > 0, {
            message: 'splitRule.predicate must be a non-empty expression',
        }),
        /**
         * Forward-chronological ancestry cut: `trainPrs` = the `cutIndex` OLDEST
         * corpus PRs (ancestry order), `heldOutPrs` = the newer remainder. Concrete
         * value deferred to window-open (ADR-111 Deferred Decisions).
         */
        cutIndex: z.number().int().nonnegative(),
    }),
    /**
     * ADR-112 §5.1 Slice D5 — the mechanical FREEZE INSTANT, stamped by the authored
     * producer at materialize. Full ISO-8601 (offset-bearing); the Q3 temporal gate
     * (`assertAuthoredFreezePreconditions`) asserts this precedes every effective
     * authoring-ledger `authoredAt` — §5.1's "frozen BEFORE authoring" embargo made
     * MECHANICAL, not attestational. Dereferenced from the split artifact (Tenet-20),
     * never recomputed. Additive-optional, no `.default()`: a mined/legacy split omits
     * it and parses/serializes BYTE-UNCHANGED.
     */
    frozenAt: z.string().datetime({ offset: true }).optional(),
})
    .superRefine((s, ctx) => {
    // Disjoint cover is a BAG/ROW property, but the cover/overlap checks dedupe via
    // Set semantics — so a duplicate row (e.g. trainPrs [1,1,2]) would slip through.
    // Reject duplicates within each slice/control list at the schema boundary.
    const lists = [
        ['trainPrs', s.trainPrs],
        ['heldOutPrs', s.heldOutPrs],
        ['excludedPrs', s.excludedPrs],
        ['positiveControlPrs', s.positiveControlPrs],
        ['negativeControlPrs', s.negativeControlPrs],
    ];
    for (const [field, arr] of lists) {
        if (new Set(arr).size !== arr.length) {
            ctx.addIssue({
                code: z.ZodIssueCode.custom,
                message: `${field} contains duplicate PRs`,
                path: [field],
            });
        }
    }
});
/** Thrown when `resolveSplit` cannot produce a clean three-way disjoint cover (Tenet 4, fail-loud). */
export class SplitCoverError extends Error {
    result;
    constructor(result) {
        super(`[Totem Error] split is not a valid disjoint cover of selectionRule(asOfCommit): ${summarizeCover(result)}`);
        this.result = result;
        this.name = 'SplitCoverError';
    }
}
function uniqueSorted(xs) {
    return [...new Set(xs)].sort((a, b) => a - b);
}
function intersect(a, b) {
    const bs = new Set(b);
    return uniqueSorted(a.filter((x) => bs.has(x)));
}
function summarizeCover(r) {
    const parts = [];
    if (r.cover.missing.length)
        parts.push(`missing(FM-g)=[${r.cover.missing}]`);
    if (r.cover.extra.length)
        parts.push(`extra(FM-d)=[${r.cover.extra}]`);
    if (r.overlaps.trainHeldOut.length)
        parts.push(`train∩heldOut=[${r.overlaps.trainHeldOut}]`);
    if (r.overlaps.trainExcluded.length)
        parts.push(`train∩excluded=[${r.overlaps.trainExcluded}]`);
    if (r.overlaps.heldOutExcluded.length)
        parts.push(`heldOut∩excluded=[${r.overlaps.heldOutExcluded}]`);
    if (r.controlsOutsideHeldOut.length)
        parts.push(`controls⊄heldOut=[${r.controlsOutsideHeldOut}]`);
    if (r.controlOverlap.length)
        parts.push(`pos∩neg=[${r.controlOverlap}]`);
    if (r.mergeCommitCollisions.length)
        parts.push(`mergeCommitCollisions=[${r.mergeCommitCollisions}]`);
    return parts.join(' ');
}
/**
 * The §8 split-ledger check: verifies the split is a three-way disjoint cover of
 * `corpus` (= `selectionRule(asOfCommit)`), that the controls are tags within
 * `heldOutPrs`, and that the slices are disjoint by merge-commit (not only PR#).
 * Pure; the harness asserts `ok` and reads the per-field diffs to pin the exact
 * Falsifying-Metric clause (FM(d) extra / FM(g) missing).
 */
export function validateSplitCover(split, corpus, mergeCommitByPr) {
    const union = [...split.trainPrs, ...split.heldOutPrs, ...split.excludedPrs];
    const cover = diffPrSets(corpus, union); // missing ⇒ FM(g), extra ⇒ FM(d)
    const overlaps = {
        trainHeldOut: intersect(split.trainPrs, split.heldOutPrs),
        trainExcluded: intersect(split.trainPrs, split.excludedPrs),
        heldOutExcluded: intersect(split.heldOutPrs, split.excludedPrs),
    };
    const heldOutSet = new Set(split.heldOutPrs);
    const controlsOutsideHeldOut = uniqueSorted([...split.positiveControlPrs, ...split.negativeControlPrs].filter((pr) => !heldOutSet.has(pr)));
    const controlOverlap = intersect(split.positiveControlPrs, split.negativeControlPrs);
    const mergeCommitCollisions = mergeCommitCollisionsAcrossSlices(split, mergeCommitByPr);
    const ok = cover.missing.length === 0 &&
        cover.extra.length === 0 &&
        overlaps.trainHeldOut.length === 0 &&
        overlaps.trainExcluded.length === 0 &&
        overlaps.heldOutExcluded.length === 0 &&
        controlsOutsideHeldOut.length === 0 &&
        controlOverlap.length === 0 &&
        mergeCommitCollisions.length === 0;
    return { ok, cover, overlaps, controlsOutsideHeldOut, controlOverlap, mergeCommitCollisions };
}
/** SHAs whose owning PRs land in >1 slice. With 1:1 PR→SHA this also catches a malformed straddle. */
function mergeCommitCollisionsAcrossSlices(split, mergeCommitByPr) {
    const slices = [split.trainPrs, split.heldOutPrs, split.excludedPrs];
    const shaSliceCount = new Map();
    for (const slice of slices) {
        const shasInSlice = new Set();
        for (const pr of slice) {
            const sha = mergeCommitByPr.get(pr);
            if (sha !== undefined)
                shasInSlice.add(sha);
        }
        for (const sha of shasInSlice)
            shaSliceCount.set(sha, (shaSliceCount.get(sha) ?? 0) + 1);
    }
    return [...shaSliceCount.entries()]
        .filter(([, n]) => n > 1)
        .map(([sha]) => sha)
        .sort();
}
/**
 * Produce a frozen split from the resolved corpus by the forward-chronological
 * ancestry cut. `corpus` is `selectionRule(asOfCommit)` (the cover base);
 * `orderedNewestFirst` is the same ancestry enumeration `resolveSelectionRule`
 * consumes (`git log --topo-order`, newest-first) and MUST cover the corpus.
 * `excludedPrs` (the atomic revert pairs) are removed from train/heldOut but
 * remain in the cover. Validates the result and throws `SplitCoverError` on any
 * cover/disjointness violation (Tenet 4) — a malformed split never freezes.
 *
 * CONTRACT (corpus-scope model A, confirmed by totem-codex's #2200 review; pending
 * strategy-claude's final §5 word): `corpus` is the cover BASE that INCLUDES the
 * PRs later assigned to `excludedPrs` — i.e. `selectionRule` is resolved here with
 * reverts retained, and `excludedPrs` enumerates them explicitly, so
 * `train ⊎ heldOut ⊎ excluded == corpus` holds and every `excludedPr` is a corpus
 * member. (The alternative — `selectionRule(asOfCommit)` pre-excludes reverts yet
 * the split still records them in `excludedPrs` — is NOT supported by the current
 * schema, which rejects excluded PRs outside the corpus; revisit only if strategy
 * pins that model.) The fail-loud `excludedPrs ⊆ corpus` guard below enforces this.
 */
export function resolveSplit(params) {
    const corpusSet = new Set(params.corpus);
    const excludedSet = new Set(params.excludedPrs);
    // Corpus PRs in ancestry order, newest-first, deduped — then reversed to oldest-first.
    const seen = new Set();
    const newestFirstCorpus = [];
    for (const pr of params.orderedNewestFirst) {
        if (!corpusSet.has(pr) || seen.has(pr))
            continue;
        seen.add(pr);
        newestFirstCorpus.push(pr);
    }
    const ordering = diffPrSets(params.corpus, newestFirstCorpus);
    if (ordering.missing.length > 0) {
        throw new Error(`[Totem Error] resolveSplit: orderedNewestFirst does not cover the corpus (missing ancestry order for [${ordering.missing}])`);
    }
    // The merge-commit collision check silently skips PRs absent from the map, so a
    // partial map would weaken the disjoint-by-merge-commit guard — require coverage.
    const missingMergeCommits = params.corpus.filter((pr) => !params.mergeCommitByPr.has(pr));
    if (missingMergeCommits.length > 0) {
        throw new Error(`[Totem Error] resolveSplit: mergeCommitByPr does not cover the corpus (missing merge commits for [${missingMergeCommits}])`);
    }
    // excludedPrs MUST be corpus-scoped revert pairs (ADR-111 §5: every listed PR is
    // a corpus member). A non-corpus entry is a caller contract violation — fail loud
    // (NOT silently filtered, which would hide the error and break total accounting).
    const nonCorpusExcluded = params.excludedPrs.filter((pr) => !corpusSet.has(pr));
    if (nonCorpusExcluded.length > 0) {
        throw new Error(`[Totem Error] resolveSplit: excludedPrs contains PRs outside the corpus [${nonCorpusExcluded}] — excludedPrs must be corpus-scoped (ADR-111 §5)`);
    }
    const oldestFirstNonExcluded = [...newestFirstCorpus]
        .reverse()
        .filter((pr) => !excludedSet.has(pr));
    // A certifying split needs BOTH a non-empty train slice and a non-empty held-out
    // slice — cutIndex 0 (empty train) or == size (empty held-out, which `slice`
    // reaches by silent clamp) makes the train/test contract vacuous. Fail loud.
    if (params.cutIndex <= 0 || params.cutIndex >= oldestFirstNonExcluded.length) {
        throw new Error(`[Totem Error] resolveSplit: cutIndex ${params.cutIndex} must be strictly between 0 and the non-excluded corpus size ${oldestFirstNonExcluded.length} (non-empty train + held-out)`);
    }
    const trainPrs = uniqueSorted(oldestFirstNonExcluded.slice(0, params.cutIndex));
    const heldOutPrs = uniqueSorted(oldestFirstNonExcluded.slice(params.cutIndex));
    const split = SplitArtifactSchema.parse({
        asOfCommit: params.asOfCommit,
        trainPrs,
        heldOutPrs,
        excludedPrs: uniqueSorted(params.excludedPrs),
        positiveControlPrs: uniqueSorted(params.positiveControlPrs ?? []),
        negativeControlPrs: uniqueSorted(params.negativeControlPrs ?? []),
        splitRule: { predicate: params.predicate, cutIndex: params.cutIndex },
        // Conditional-spread (not `key: undefined`): a mined split omits `frozenAt` entirely,
        // so `canonicalStringify` emits BYTE-IDENTICAL output to the pre-D5 shape.
        ...(params.frozenAt !== undefined ? { frozenAt: params.frozenAt } : {}),
    });
    const validation = validateSplitCover(split, params.corpus, params.mergeCommitByPr);
    if (!validation.ok)
        throw new SplitCoverError(validation);
    return split;
}
/** Re-export for callers building the `mergeCommitByPr` map from enumerated metas. */
export function mergeCommitMap(metas) {
    return new Map(metas.map((m) => [m.pr, m.mergeCommit]));
}
//# sourceMappingURL=split.js.map