import { isYieldEligibleDie, getDieKey, isPositionedDie } from '../core/dies.js';
import { buildWaferMap, getTestPassStatus, isParametricTest } from '../renderer/buildWaferMap.js';
import { buildQuadrantRegions, buildReticlePositionRegions, buildRingRegions, buildSectorRegions, buildTestSiteRegions, sectorCompassNames, areQuadrantsAdjacent, parseRegionKey, } from './regions.js';
import { buildClusterFindings } from './clusterDetection.js';
import { classifyPattern } from './patternClassification.js';
import { normalCdf } from './math.js';
import { quantile } from './math.js';
import { mean, clamp01 } from '../core/utils.js';
const DEFAULT_OPTIONS = {
    ringCount: 4,
    passBins: [1],
    significanceLevel: 0.05,
    minimumEffectSize: 0.20,
    minimumRelativeEffect: 1.0,
    minimumSampleSize: 5, // internal, not in public AnalyzeWaferMapOptions
    includePartial: false,
    includeEdgeExcluded: false,
    enableYieldAnalysis: true,
    enableHardBinAnalysis: true,
    enableSoftBinAnalysis: true,
    // Off by default: the regional Welch pass is the expensive part of analysis
    // (scales with regions × tests × dies). Callers that display regional
    // test-value findings opt in explicitly. For cheap per-test quartiles without
    // the spatial comparisons, use computePerTestStats instead.
    enableTestValueAnalysis: false,
    computePerTestStats: false,
    enableReticlePositionAnalysis: true,
    enableClusterAnalysis: true,
    enableAngularAnalysis: true,
    sectorCount: 8,
    enablePatternClassification: true,
    minimumClusterSize: 5, // overwritten by adaptOptions()
};
/**
 * Compute adaptive overrides for thresholds that scale with wafer geometry.
 * Called once per analyzeWaferMap invocation after eligible dies are known.
 *
 * Only minimumClusterSize is adapted here — other region analysis thresholds
 * (minimumSampleSize, significanceLevel) must not be adapted because changing
 * the number of tests fed into Bonferroni correction alters the correction itself,
 * producing unpredictable FP rate changes.
 */
function adaptOptions(base, dieCount) {
    const adapted = { ...base };
    // minimumClusterSize: ~0.3% of wafer die count, floored at 3.
    // A 5-die cluster is meaningful on a small wafer but noise on a 2500-die wafer.
    // Safe to adapt because cluster findings go through a separate code path
    // and do not affect the regional analysis Bonferroni denominator.
    adapted.minimumClusterSize = Math.max(5, Math.round(dieCount * 0.003));
    return adapted;
}
function normalizeInput(input) {
    return 'wafer' in input && 'dies' in input && 'view' in input ? input : buildWaferMap(input);
}
function isEligibleDie(die, options) {
    if (!isYieldEligibleDie(die, options))
        return false;
    return (die.hbin !== undefined ||
        die.sbin !== undefined ||
        (die.testValues !== undefined && Object.keys(die.testValues).length > 0));
}
function makeClusterFailurePredicate(isLotStack, hasBinData, testDefs) {
    if (!isLotStack || hasBinData)
        return undefined;
    const limited = (testDefs ?? []).filter(td => isParametricTest(td) && (td.limitLow !== undefined || td.limitHigh !== undefined));
    if (limited.length === 0)
        return undefined;
    return (die) => {
        for (const td of limited) {
            const tn = td.testNumber;
            const v = die.testValues?.[tn];
            if (v === undefined)
                continue;
            if (td.limitLow !== undefined && v < td.limitLow)
                return true;
            if (td.limitHigh !== undefined && v > td.limitHigh)
                return true;
        }
        return false;
    };
}
function collectStats(dies, analyzedDies, yieldPercent) {
    const testSet = new Set();
    const hardBinSet = new Set();
    const softBinSet = new Set();
    const hardBinCounts = new Map();
    const softBinCounts = new Map();
    for (const die of dies) {
        if (die.testValues) {
            for (const k of Object.keys(die.testValues))
                testSet.add(Number(k));
        }
        if (die.testPass) {
            for (const k of Object.keys(die.testPass))
                testSet.add(Number(k));
        }
        if (die.hbin !== undefined)
            hardBinSet.add(die.hbin);
        if (die.sbin !== undefined)
            softBinSet.add(die.sbin);
        // Counts (not just "which bins appear") are only meaningful over the
        // same yield-eligible population every other bin display uses.
        if (isYieldEligibleDie(die)) {
            if (die.hbin !== undefined)
                hardBinCounts.set(die.hbin, (hardBinCounts.get(die.hbin) ?? 0) + 1);
            if (die.sbin !== undefined)
                softBinCounts.set(die.sbin, (softBinCounts.get(die.sbin) ?? 0) + 1);
        }
    }
    return {
        totalDies: dies.length,
        analyzedDies,
        excludedDies: dies.length - analyzedDies,
        yieldPercent,
        testsConsidered: [...testSet].sort((left, right) => left - right),
        hardBinsConsidered: [...hardBinSet].sort((left, right) => left - right),
        softBinsConsidered: [...softBinSet].sort((left, right) => left - right),
        ...(hardBinCounts.size ? { hardBinCounts: Object.fromEntries(hardBinCounts) } : {}),
        ...(softBinCounts.size ? { softBinCounts: Object.fromEntries(softBinCounts) } : {}),
    };
}
function computeTestSpecYield(dies, testDefs) {
    if (!testDefs?.length)
        return undefined;
    const limited = testDefs.filter(td => isParametricTest(td) && (td.limitLow !== undefined || td.limitHigh !== undefined));
    if (!limited.length)
        return undefined;
    const result = [];
    for (const td of limited) {
        const tn = td.testNumber;
        let passDies = 0, failLowDies = 0, failHighDies = 0, totalDies = 0;
        for (const die of dies) {
            if (die.partial || die.edgeExcluded)
                continue;
            const v = die.testValues?.[tn];
            if (v === undefined)
                continue;
            totalDies++;
            if (td.limitLow !== undefined && v < td.limitLow) {
                failLowDies++;
            }
            else if (td.limitHigh !== undefined && v > td.limitHigh) {
                failHighDies++;
            }
            else {
                passDies++;
            }
        }
        result.push({
            testNumber: tn,
            label: td.name,
            passDies,
            failLowDies,
            failHighDies,
            totalDies,
            yieldPercent: totalDies > 0 ? (passDies / totalDies) * 100 : null,
        });
    }
    return result.length ? result : undefined;
}
/**
 * Per-test pass rate for functional (`testType: 'F'`) tests — the functional
 * counterpart of `computeTestSpecYield`. Same population convention: partial and
 * edge-excluded dies are skipped; the denominator is dies with a recorded
 * verdict for the test (read via `getTestPassStatus`, so legacy 0/1-encoded
 * functional data is counted identically); dies never tested count as neither.
 */
export function computeFunctionalYield(dies, testDefs) {
    if (!testDefs?.length)
        return undefined;
    const fDefs = testDefs.filter(td => !isParametricTest(td));
    if (!fDefs.length)
        return undefined;
    const result = [];
    for (const td of fDefs) {
        const tn = td.testNumber;
        let passDies = 0, failDies = 0;
        for (const die of dies) {
            if (die.partial || die.edgeExcluded)
                continue;
            const status = getTestPassStatus(die, tn, td);
            if (status === undefined)
                continue;
            if (status)
                passDies++;
            else
                failDies++;
        }
        const totalDies = passDies + failDies;
        result.push({
            testNumber: tn,
            label: td.name,
            passDies,
            failDies,
            totalDies,
            passRatePercent: totalDies > 0 ? (passDies / totalDies) * 100 : null,
        });
    }
    return result.length ? result : undefined;
}
function computePerTestStats(dies, testNumbers, testDefs, minimumSampleSize) {
    const result = [];
    for (const tn of testNumbers) {
        const values = [];
        for (const die of dies) {
            if (die.partial || die.edgeExcluded)
                continue;
            const v = die.testValues?.[tn];
            if (v !== undefined)
                values.push(v);
        }
        if (values.length < minimumSampleSize)
            continue;
        const avg = mean(values);
        const stddev = Math.sqrt(sampleVariance(values, avg));
        const sorted = values.slice().sort((a, b) => a - b);
        const label = testDefs?.find(td => td.testNumber === tn)?.name ?? String(tn);
        result.push({
            testNumber: tn,
            label,
            count: values.length,
            min: sorted[0],
            max: sorted[sorted.length - 1],
            mean: avg,
            stddev,
            median: quantile(sorted, 0.5),
            q1: quantile(sorted, 0.25),
            q3: quantile(sorted, 0.75),
        });
    }
    return result.length ? result : undefined;
}
function twoProportionPValue(leftPass, leftTotal, rightPass, rightTotal) {
    const pooled = (leftPass + rightPass) / (leftTotal + rightTotal);
    const variance = pooled * (1 - pooled) * ((1 / leftTotal) + (1 / rightTotal));
    if (!Number.isFinite(variance) || variance <= 0)
        return 1;
    const z = ((leftPass / leftTotal) - (rightPass / rightTotal)) / Math.sqrt(variance);
    return clamp01(2 * (1 - normalCdf(Math.abs(z))));
}
function adjustPValues(findings) {
    const families = new Map();
    for (const finding of findings) {
        const key = `${finding.variable.kind}:${finding.comparison.family}`;
        const entries = families.get(key) ?? [];
        entries.push(finding);
        families.set(key, entries);
    }
    for (const entries of families.values()) {
        const sorted = [...entries]
            .filter((entry) => entry.stats.pValue !== undefined)
            .sort((left, right) => (left.stats.pValue ?? 1) - (right.stats.pValue ?? 1));
        const total = sorted.length;
        let runningMin = 1;
        for (let index = total - 1; index >= 0; index--) {
            const entry = sorted[index];
            const raw = entry.stats.pValue ?? 1;
            const adjusted = Math.min(1, Math.min(runningMin, (raw * total) / (index + 1)));
            entry.stats.adjustedPValue = adjusted;
            runningMin = adjusted;
        }
    }
    return findings;
}
function severityForFinding(pValue, delta, relativeDelta) {
    const absDelta = Math.abs(delta);
    const absRel = relativeDelta !== undefined ? Math.abs(relativeDelta) : 0;
    if (pValue <= 0.01 && (absDelta >= 0.30 || absRel >= 2.5))
        return 'unusual';
    if (pValue <= 0.05 && (absDelta >= 0.20 || absRel >= 1.5))
        return 'notable';
    return 'info';
}
function severityForScore(pValue, score) {
    if (pValue <= 0.01 && Math.abs(score) >= 0.5)
        return 'unusual';
    if (pValue <= 0.05 && Math.abs(score) >= 0.15)
        return 'notable';
    return 'info';
}
function comparisonTarget(family) {
    if (family === 'reticle-position')
        return 'other reticle positions';
    if (family === 'test-site')
        return 'other test sites';
    return 'the rest of the map';
}
function comparisonRight(family) {
    if (family === 'reticle-position')
        return 'Other reticle positions';
    if (family === 'test-site')
        return 'Other test sites';
    return 'Rest of map';
}
function summarizeYieldFinding(label, delta, family) {
    const pp = (Math.abs(delta) * 100).toFixed(1);
    return `${label} yield is ${pp} percentage points ${delta > 0 ? 'higher' : 'lower'} than ${comparisonTarget(family)}`;
}
function summarizeRegionLabel(label, family) {
    // Single-quadrant labels are a bare compass ("NE") and read better with the
    // family word prepended ("quadrant NE"). Merged labels already self-describe
    // ("Quadrants NW, SW & SE"), so leave them untouched.
    if (family === 'quadrant' && !label.startsWith('Quadrant'))
        return `quadrant ${label}`;
    return label;
}
function summarizeBinFinding(label, binLabel, delta, family) {
    const familyLabel = summarizeRegionLabel(label, family);
    const pp = (Math.abs(delta) * 100).toFixed(1);
    return `${familyLabel} has ${binLabel} occurrence ${pp} percentage points ${delta > 0 ? 'higher' : 'lower'} than ${comparisonTarget(family)}`;
}
function summarizeFunctionalFinding(label, testName, delta, family) {
    const familyLabel = summarizeRegionLabel(label, family);
    const pp = (Math.abs(delta) * 100).toFixed(1);
    return `${familyLabel} has ${testName} pass rate ${pp} percentage points ${delta > 0 ? 'higher' : 'lower'} than ${comparisonTarget(family)}`;
}
function summarizeTestFinding(label, testLabel, delta, relativeDelta, family, unit) {
    const familyLabel = summarizeRegionLabel(label, family);
    const target = comparisonTarget(family);
    const dir = delta > 0 ? 'higher' : 'lower';
    if (relativeDelta !== undefined && Number.isFinite(relativeDelta)) {
        const pct = (Math.abs(relativeDelta) * 100).toFixed(1);
        return `${familyLabel} mean ${testLabel} is ${pct}% ${dir} than ${target}`;
    }
    const unitSuffix = unit ? ` ${unit}` : '';
    return `${familyLabel} mean ${testLabel} is ${Math.abs(delta).toPrecision(3)}${unitSuffix} ${dir} than ${target}`;
}
function labelForBin(bin, defs, prefix) {
    const def = defs?.find((entry) => entry.bin === bin);
    return def?.name ? `${prefix} ${bin} (${def.name})` : `${prefix} ${bin}`;
}
function labelForTest(testNumber, defs) {
    // Match by testNumber first, then fall back to index for the deprecated path.
    const def = defs?.find((entry) => entry.testNumber === testNumber);
    return { label: def?.name ?? `Test ${testNumber}`, unit: def?.unit };
}
/**
 * Assign each eligible die to its region's bucket. Regions are non-overlapping,
 * so a "rest of the map" comparison is simply the sum of all other buckets.
 * Shared preamble of every proportion-findings builder (yield, bin, functional).
 */
function bucketDiesByRegion(eligibleDies, regionFamily) {
    const dieMap = new Map(eligibleDies.map((die) => [getDieKey(die), die]));
    const buckets = new Map();
    for (const region of regionFamily) {
        const bucket = [];
        for (const key of region.dieKeys) {
            const d = dieMap.get(key);
            if (d)
                bucket.push(d);
        }
        buckets.set(region.key, bucket);
    }
    return buckets;
}
/**
 * Shared tail of every proportion-findings builder: BH-adjust the p-values over
 * the family, drop insignificant / small-effect findings, then map the survivors
 * to their severity. Extracted so yield, bin, and functional pass-rate findings
 * can never drift in how significance is applied.
 */
function finalizeProportionFindings(findings, options) {
    adjustPValues(findings);
    return findings
        .filter((finding) => {
        const adjusted = finding.stats.adjustedPValue ?? finding.stats.pValue ?? 1;
        const delta = Math.abs(finding.effect.absoluteDelta ?? 0);
        const relDelta = Math.abs(finding.effect.relativeDelta ?? 0);
        return adjusted <= options.significanceLevel &&
            (delta >= options.minimumEffectSize || relDelta >= options.minimumRelativeEffect);
    })
        .map((finding) => ({
        ...finding,
        severity: severityForFinding(finding.stats.adjustedPValue ?? finding.stats.pValue ?? 1, finding.effect.absoluteDelta ?? 0, finding.effect.relativeDelta),
    }));
}
function buildYieldFindings(eligibleDies, regionFamily, passBins, options) {
    const passSet = new Set(passBins);
    const buckets = bucketDiesByRegion(eligibleDies, regionFamily);
    // Pre-count pass dies and the hbin-bearing population per bucket. The yield
    // denominator must be dies that HAVE a hard bin — a die eligible only via sbin
    // or test values has no hard-bin pass/fail verdict and must not be counted as a
    // fail (which it would be if the denominator were the full bucket length, since
    // the pass test requires d.hbin). This mirrors the global yield (passDies /
    // dies-with-bin-data) so regional and overall yields use the same population.
    const passCounts = new Map();
    const bucketSizes = new Map();
    for (const [regionKey, bucket] of buckets) {
        let passes = 0;
        let withHbin = 0;
        for (const d of bucket) {
            if (d.hbin === undefined)
                continue;
            withHbin++;
            if (passSet.has(d.hbin))
                passes++;
        }
        passCounts.set(regionKey, passes);
        bucketSizes.set(regionKey, withHbin);
    }
    const findings = [];
    for (const region of regionFamily) {
        const leftSize = bucketSizes.get(region.key) ?? 0;
        const leftPass = passCounts.get(region.key) ?? 0;
        let rightSize = 0;
        let rightPass = 0;
        for (const [key, size] of bucketSizes) {
            if (key === region.key)
                continue;
            rightSize += size;
            rightPass += passCounts.get(key) ?? 0;
        }
        if (leftSize < options.minimumSampleSize || rightSize < options.minimumSampleSize)
            continue;
        const leftRate = leftPass / leftSize;
        const rightRate = rightPass / rightSize;
        const delta = leftRate - rightRate;
        const pValue = twoProportionPValue(leftPass, leftSize, rightPass, rightSize);
        findings.push({
            id: `yield:${region.key}`,
            level: 'wafer',
            severity: 'info',
            variable: {
                kind: 'yield',
                label: 'Yield',
            },
            comparison: {
                family: region.family,
                left: region.label,
                right: comparisonRight(region.family),
            },
            effect: {
                direction: delta === 0 ? 'different' : delta > 0 ? 'higher' : 'lower',
                absoluteDelta: delta,
                relativeDelta: rightRate === 0 ? undefined : delta / rightRate,
                effectSize: delta,
            },
            stats: {
                method: 'two-proportion-z',
                pValue,
                sampleSizeLeft: leftSize,
                sampleSizeRight: rightSize,
            },
            summary: summarizeYieldFinding(region.label, delta, region.family),
            highlight: {
                kind: 'region',
                regionFamily: region.family,
                regionKeys: [region.key],
                dieKeys: [...region.dieKeys],
            },
        });
    }
    return finalizeProportionFindings(findings, options);
}
function buildBinFindings(eligibleDies, regionFamily, binSpace, defs, variableKind, options) {
    const getBin = (d) => binSpace === 'soft' ? d.sbin : d.hbin;
    const bins = [...new Set(eligibleDies
            .map(getBin)
            .filter((bin) => bin !== undefined))].sort((left, right) => left - right);
    const buckets = bucketDiesByRegion(eligibleDies, regionFamily);
    // Pre-count bin occurrences per bucket to avoid O(N_bucket × bins) filter per region.
    const binCounts = new Map();
    const bucketSizes = new Map();
    for (const [regionKey, bucket] of buckets) {
        const counts = new Map();
        for (const d of bucket) {
            const b = getBin(d);
            if (b !== undefined)
                counts.set(b, (counts.get(b) ?? 0) + 1);
        }
        binCounts.set(regionKey, counts);
        bucketSizes.set(regionKey, bucket.length);
    }
    const findings = [];
    const prefix = variableKind === 'hardBin' ? 'HBin' : 'SBin';
    for (const region of regionFamily) {
        const leftSize = bucketSizes.get(region.key) ?? 0;
        const leftCounts = binCounts.get(region.key);
        let rightSize = 0;
        const rightCounts = new Map();
        for (const [key, counts] of binCounts) {
            if (key === region.key)
                continue;
            rightSize += bucketSizes.get(key) ?? 0;
            for (const [b, c] of counts)
                rightCounts.set(b, (rightCounts.get(b) ?? 0) + c);
        }
        if (leftSize < options.minimumSampleSize || rightSize < options.minimumSampleSize)
            continue;
        for (const bin of bins) {
            const leftHits = leftCounts.get(bin) ?? 0;
            const rightHits = rightCounts.get(bin) ?? 0;
            const leftRate = leftHits / leftSize;
            const rightRate = rightHits / rightSize;
            const delta = leftRate - rightRate;
            const pValue = twoProportionPValue(leftHits, leftSize, rightHits, rightSize);
            const binLabel = labelForBin(bin, defs, prefix);
            findings.push({
                id: `${variableKind}:${bin}:${region.key}`,
                level: 'wafer',
                severity: 'info',
                variable: {
                    kind: variableKind,
                    bin,
                    label: binLabel,
                },
                comparison: {
                    family: region.family,
                    left: region.label,
                    right: comparisonRight(region.family),
                },
                effect: {
                    direction: delta === 0 ? 'different' : delta > 0 ? 'higher' : 'lower',
                    absoluteDelta: delta,
                    relativeDelta: rightRate === 0 ? undefined : delta / rightRate,
                    effectSize: delta,
                },
                stats: {
                    method: 'two-proportion-z',
                    pValue,
                    sampleSizeLeft: leftSize,
                    sampleSizeRight: rightSize,
                },
                summary: summarizeBinFinding(region.label, binLabel, delta, region.family),
                highlight: {
                    kind: 'bin',
                    bin,
                    regionKeys: [region.key],
                    dieKeys: [...region.dieKeys],
                },
            });
        }
    }
    return finalizeProportionFindings(findings, options);
}
/**
 * Regional pass-rate findings for functional (`testType: 'F'`) tests: for each
 * functional test, compare each region's pass rate against the rest of the wafer
 * with the same two-proportion z-test the yield/bin findings use. Verdicts are
 * read via `getTestPassStatus`, so legacy 0/1-encoded functional data is analysed
 * identically. Denominators are dies with a recorded verdict for that test —
 * `minimumSampleSize` gates apply to those counts, not the raw bucket sizes.
 */
function buildFunctionalPassFindings(eligibleDies, regionFamily, testDefs, options) {
    const fDefs = (testDefs ?? []).filter((td) => !isParametricTest(td));
    if (!fDefs.length)
        return [];
    const testNumbers = fDefs.map(td => td.testNumber);
    const buckets = bucketDiesByRegion(eligibleDies, regionFamily);
    // Pre-count per bucket, per functional test: passes and dies-with-verdict.
    const passCounts = new Map();
    const verdictCounts = new Map();
    for (const [regionKey, bucket] of buckets) {
        const passes = new Uint32Array(fDefs.length);
        const verdicts = new Uint32Array(fDefs.length);
        for (const d of bucket) {
            for (let i = 0; i < fDefs.length; i++) {
                const status = getTestPassStatus(d, testNumbers[i], fDefs[i]);
                if (status === undefined)
                    continue;
                verdicts[i]++;
                if (status)
                    passes[i]++;
            }
        }
        passCounts.set(regionKey, passes);
        verdictCounts.set(regionKey, verdicts);
    }
    const findings = [];
    for (const region of regionFamily) {
        const leftPasses = passCounts.get(region.key);
        const leftVerdicts = verdictCounts.get(region.key);
        for (let i = 0; i < fDefs.length; i++) {
            let rightPass = 0;
            let rightSize = 0;
            for (const [key, verdicts] of verdictCounts) {
                if (key === region.key)
                    continue;
                rightSize += verdicts[i];
                rightPass += passCounts.get(key)[i];
            }
            const leftSize = leftVerdicts[i];
            const leftPass = leftPasses[i];
            if (leftSize < options.minimumSampleSize || rightSize < options.minimumSampleSize)
                continue;
            const leftRate = leftPass / leftSize;
            const rightRate = rightPass / rightSize;
            const delta = leftRate - rightRate;
            const pValue = twoProportionPValue(leftPass, leftSize, rightPass, rightSize);
            const testNumber = testNumbers[i];
            findings.push({
                id: `functional:${testNumber}:${region.key}`,
                level: 'wafer',
                severity: 'info',
                variable: {
                    kind: 'functionalTest',
                    index: testNumber,
                    label: `${fDefs[i].name} pass rate`,
                },
                comparison: {
                    family: region.family,
                    left: region.label,
                    right: comparisonRight(region.family),
                },
                effect: {
                    direction: delta === 0 ? 'different' : delta > 0 ? 'higher' : 'lower',
                    absoluteDelta: delta,
                    relativeDelta: rightRate === 0 ? undefined : delta / rightRate,
                    effectSize: delta,
                },
                stats: {
                    method: 'two-proportion-z',
                    pValue,
                    sampleSizeLeft: leftSize,
                    sampleSizeRight: rightSize,
                },
                summary: summarizeFunctionalFinding(region.label, fDefs[i].name, delta, region.family),
                highlight: {
                    kind: 'region',
                    regionFamily: region.family,
                    regionKeys: [region.key],
                    dieKeys: [...region.dieKeys],
                },
            });
        }
    }
    return finalizeProportionFindings(findings, options);
}
function sampleVariance(values, avg) {
    if (values.length < 2)
        return 0;
    return values.reduce((sum, value) => sum + (value - avg) ** 2, 0) / (values.length - 1);
}
/**
 * Welch two-sample comparison from summary statistics (count, mean, variance).
 * The findings pass accumulates these per region in a single columnar scan, so
 * the comparison never materialises the value arrays — see buildTestValueFindings.
 */
function welchFromStats(leftN, leftMean, leftVar, rightN, rightMean, rightVar) {
    const standardError = Math.sqrt((leftVar / leftN) + (rightVar / rightN));
    const delta = leftMean - rightMean;
    // Zero (or non-finite) standard error means there is no within-group spread to
    // test against — both groups are constant. A constant-vs-constant difference is
    // statistically *unmeasurable*, not infinitely significant: with zero variance
    // the Welch statistic is undefined. Treat it as a non-finding (p = 1, no effect)
    // rather than awarding p = 0 / infinite effect, which previously fired spurious
    // "unusual" findings on uniform or coarsely-quantised test data.
    if (!Number.isFinite(standardError) || standardError === 0) {
        return { pValue: 1, effectSize: 0, delta };
    }
    const z = delta / standardError;
    const pooledSd = Math.sqrt(Math.max(0, ((leftVar + rightVar) / 2)));
    const effectSize = pooledSd === 0 ? delta : delta / pooledSd;
    return {
        pValue: clamp01(2 * (1 - normalCdf(Math.abs(z)))),
        effectSize,
        delta,
    };
}
/**
 * Array-based Welch comparison — convenience wrapper over welchFromStats for the
 * cold merge/re-aggregation path, which works on arbitrary merged die sets rather
 * than the per-region running sums the columnar findings pass maintains.
 */
function welchPValue(leftValues, rightValues) {
    const leftMean = mean(leftValues);
    const rightMean = mean(rightValues);
    return welchFromStats(leftValues.length, leftMean, sampleVariance(leftValues, leftMean), rightValues.length, rightMean, sampleVariance(rightValues, rightMean));
}
const TEST_COUNT_WARN_THRESHOLD = 250;
/**
 * Discover the test numbers present in the die data, or echo back an explicit
 * caller-supplied subset. Shared by the cheap perTestStats pass and the regional
 * findings pass so the auto-cap behaviour is defined in exactly one place.
 * Stops scanning once the cap is exceeded and returns a warning instead.
 *
 * Functional tests (`testType: 'F'` in `testDefs`) are excluded from the
 * result — both discovered and explicitly requested — because every consumer
 * of this list computes parametric statistics, which are meaningless for a
 * pass/fail outcome. A test number with no matching def counts as parametric.
 */
function discoverTestNumbers(dies, explicit, testDefs) {
    const parametricOnly = (numbers) => numbers.filter(tn => isParametricTest(testDefs?.find(td => td.testNumber === tn)));
    if (explicit)
        return { testNumbers: parametricOnly(explicit.slice().sort((a, b) => a - b)) };
    const testNumberSet = new Set();
    let capped = false;
    outer: for (const die of dies) {
        for (const k of Object.keys(die.testValues ?? {})) {
            const n = Number(k);
            if (!testNumberSet.has(n)) {
                testNumberSet.add(n);
                if (testNumberSet.size > TEST_COUNT_WARN_THRESHOLD) {
                    capped = true;
                    break outer;
                }
            }
        }
    }
    if (capped) {
        // Deliberately explicit that the OUTCOME is "no test findings at all", not
        // "some tests were skipped". The failure is silent — findings are simply
        // absent — so the message has to say so, since nothing else will.
        const message = `Test-value analysis was skipped: more than ${TEST_COUNT_WARN_THRESHOLD} tests were found ` +
            `in the die data, so no test findings were computed. Pass testNumbers: [...] to ` +
            `analyse specific tests.`;
        console.warn(`[wafermap] analyzeWaferMap: ${message}`);
        return {
            testNumbers: [],
            warning: { code: 'test-count-capped', message, severity: 'warning' },
        };
    }
    return { testNumbers: parametricOnly([...testNumberSet].sort((a, b) => a - b)) };
}
/**
 * Regional parametric significance findings: for each test, compare each region's
 * values against the rest of the wafer (Welch).
 *
 * Performance: the previous implementation allocated two value arrays per
 * (region × test) via `.map().filter()` and rebuilt the "rest of wafer" die set
 * per region — O(regions² + regions·tests) allocations that dominated analysis
 * (profiled at ~95% of cost, mostly GC). This version is allocation-light:
 *   • each die is assigned its region index once (regionOf), and
 *   • per test we walk the dies once accumulating running sums (n, Σ, Σ²) per
 *     region plus a family total, over values shifted by a per-test constant for
 *     numerical stability; the "rest of wafer" stats are derived by subtraction
 *     (total − region), never materialised.
 * Welch needs only count/mean/variance, all available from the running sums, so
 * no value arrays are built in the hot path. The region set the comparison sees
 * is the same as the array-based path because the regions are disjoint (each die
 * maps to at most one bucket), so total − region == union of the other regions.
 * Findings match the array path to within floating-point tolerance (the shifted
 * one-pass variance is, if anything, better-conditioned than a raw two-pass sum
 * on large-magnitude data).
 */
function buildTestValueFindings(dies, regionFamily, defs, options) {
    const discovered = discoverTestNumbers(dies, options.testNumbers, defs);
    if (discovered.warning)
        return { findings: [], warning: discovered.warning };
    const activeTestNumbers = discovered.testNumbers;
    // Assign each die to its region index once (−1 = not in this family).
    const keyToRegion = new Map();
    for (let r = 0; r < regionFamily.length; r++) {
        for (const key of regionFamily[r].dieKeys)
            keyToRegion.set(key, r);
    }
    const nRegions = regionFamily.length;
    // Dies that belong to some region in this family, paired with their region idx.
    const regionDies = [];
    const regionIdx = [];
    for (const die of dies) {
        const r = keyToRegion.get(getDieKey(die));
        if (r !== undefined) {
            regionDies.push(die);
            regionIdx.push(r);
        }
    }
    const findings = [];
    // Per-region accumulators, reused across tests (cleared each test).
    const n = new Float64Array(nRegions);
    const sum = new Float64Array(nRegions);
    const sq = new Float64Array(nRegions);
    for (const testNumber of activeTestNumbers) {
        n.fill(0);
        sum.fill(0);
        sq.fill(0);
        let totN = 0, totSum = 0, totSq = 0;
        // Accumulate sums of (v − shift) rather than v. Subtracting a constant close
        // to the data leaves variance and the between-region delta unchanged but keeps
        // Σ(v−shift)² well-conditioned, avoiding the catastrophic cancellation of the
        // raw (Σv² − n·mean²) form for large-magnitude / low-variance test values
        // (e.g. voltages ≈1e6 with mV spread). We use the first observed value as the
        // shift — it is O(1), needs no pre-pass, and is guaranteed to be on-scale.
        let shift;
        for (let i = 0; i < regionDies.length; i++) {
            const die = regionDies[i];
            const raw = die.testValues?.[testNumber];
            if (raw === undefined)
                continue;
            if (shift === undefined)
                shift = raw;
            const v = raw - shift;
            const r = regionIdx[i];
            n[r] += 1;
            sum[r] += v;
            sq[r] += v * v;
            totN += 1;
            totSum += v;
            totSq += v * v;
        }
        if (shift === undefined)
            continue; // no data for this test
        for (let r = 0; r < nRegions; r++) {
            const leftN = n[r];
            const rightN = totN - leftN;
            if (leftN < options.minimumSampleSize || rightN < options.minimumSampleSize)
                continue;
            // Means in shifted space; true means add `shift` back (delta is shift-invariant).
            const leftMeanS = sum[r] / leftN;
            const rightSum = totSum - sum[r];
            const rightMeanS = rightSum / rightN;
            const leftMean = leftMeanS + shift;
            const rightMean = rightMeanS + shift;
            // Sample variance from running sums of the shifted data:
            // (Σ(v−k)² − n·meanS²) / (n − 1) — identical to the variance of v.
            const leftVar = leftN > 1 ? Math.max(0, (sq[r] - leftN * leftMeanS * leftMeanS)) / (leftN - 1) : 0;
            const rightVar = rightN > 1 ? Math.max(0, ((totSq - sq[r]) - rightN * rightMeanS * rightMeanS)) / (rightN - 1) : 0;
            const { pValue, effectSize, delta } = welchFromStats(leftN, leftMean, leftVar, rightN, rightMean, rightVar);
            const region = regionFamily[r];
            const { label, unit } = labelForTest(testNumber, defs);
            const relativeDelta = rightMean !== 0 ? delta / Math.abs(rightMean) : undefined;
            findings.push({
                id: `test:${testNumber}:${region.key}`,
                level: 'wafer',
                severity: 'info',
                variable: {
                    kind: 'test',
                    index: testNumber,
                    label,
                    unit,
                },
                comparison: {
                    family: region.family,
                    left: region.label,
                    right: comparisonRight(region.family),
                },
                effect: {
                    direction: delta === 0 ? 'different' : delta > 0 ? 'higher' : 'lower',
                    absoluteDelta: delta,
                    relativeDelta,
                    effectSize,
                },
                stats: {
                    method: 'welch-z-approx',
                    pValue,
                    sampleSizeLeft: leftN,
                    sampleSizeRight: rightN,
                },
                summary: summarizeTestFinding(region.label, label, delta, relativeDelta, region.family, unit),
                highlight: {
                    kind: 'region',
                    regionFamily: region.family,
                    regionKeys: [region.key],
                    dieKeys: [...region.dieKeys],
                },
            });
        }
    }
    adjustPValues(findings);
    return {
        activeTestNumbers,
        findings: findings
            .filter((finding) => {
            const adjusted = finding.stats.adjustedPValue ?? finding.stats.pValue ?? 1;
            const effectSize = Math.abs(finding.effect.effectSize ?? 0);
            return adjusted <= options.significanceLevel && effectSize >= options.minimumEffectSize;
        })
            .map((finding) => ({
            ...finding,
            severity: severityForScore(finding.stats.adjustedPValue ?? finding.stats.pValue ?? 1, finding.effect.effectSize ?? 0),
        })),
    };
}
function buildSpecLimitFindings(dies, regionFamilies, testDefs, options) {
    if (!testDefs?.length)
        return [];
    const limited = testDefs.filter(td => isParametricTest(td) && (td.limitLow !== undefined || td.limitHigh !== undefined));
    if (!limited.length)
        return [];
    const allFindings = [];
    const dieMap = new Map(dies.map(d => [getDieKey(d), d]));
    for (const td of limited) {
        const tn = td.testNumber;
        for (const regionFamily of regionFamilies) {
            const buckets = new Map();
            for (const region of regionFamily) {
                const bucket = [];
                for (const key of region.dieKeys) {
                    const d = dieMap.get(key);
                    if (d)
                        bucket.push(d);
                }
                buckets.set(region.key, bucket);
            }
            const findings = [];
            for (const region of regionFamily) {
                const leftDies = buckets.get(region.key);
                const rightDies = [];
                for (const [key, bucket] of buckets) {
                    if (key !== region.key)
                        for (const d of bucket)
                            rightDies.push(d);
                }
                const hasValue = (d) => (d.testValues?.[tn]) !== undefined;
                const isSpecFail = (d) => {
                    const v = d.testValues?.[tn];
                    if (v === undefined)
                        return false;
                    if (td.limitLow !== undefined && v < td.limitLow)
                        return true;
                    if (td.limitHigh !== undefined && v > td.limitHigh)
                        return true;
                    return false;
                };
                const leftValid = leftDies.filter(hasValue);
                const rightValid = rightDies.filter(hasValue);
                if (leftValid.length < options.minimumSampleSize || rightValid.length < options.minimumSampleSize)
                    continue;
                const leftFail = leftValid.filter(isSpecFail).length;
                const rightFail = rightValid.filter(isSpecFail).length;
                const leftRate = leftFail / leftValid.length;
                const rightRate = rightFail / rightValid.length;
                const delta = leftRate - rightRate;
                const pValue = twoProportionPValue(leftFail, leftValid.length, rightFail, rightValid.length);
                findings.push({
                    id: `specLimit:${tn}:${region.key}`,
                    level: 'wafer',
                    severity: 'info',
                    variable: {
                        kind: 'test',
                        index: tn,
                        label: td.name,
                        unit: td.unit,
                    },
                    comparison: {
                        family: region.family,
                        left: region.label,
                        right: comparisonRight(region.family),
                    },
                    effect: {
                        direction: delta === 0 ? 'different' : delta > 0 ? 'higher' : 'lower',
                        absoluteDelta: delta,
                        relativeDelta: rightRate === 0 ? undefined : delta / rightRate,
                        effectSize: delta,
                    },
                    stats: {
                        method: 'two-proportion-z',
                        pValue,
                        sampleSizeLeft: leftValid.length,
                        sampleSizeRight: rightValid.length,
                    },
                    summary: `${region.label} spec-fail rate for ${td.name} is ${(Math.abs(delta) * 100).toFixed(1)} pp ${delta > 0 ? 'higher' : 'lower'} than the rest of the wafer`,
                    highlight: {
                        kind: 'region',
                        regionFamily: region.family,
                        regionKeys: [region.key],
                        dieKeys: [...region.dieKeys],
                    },
                });
            }
            adjustPValues(findings);
            allFindings.push(...findings
                .filter(f => {
                const adj = f.stats.adjustedPValue ?? f.stats.pValue ?? 1;
                const delta = Math.abs(f.effect.absoluteDelta ?? 0);
                const relDelta = Math.abs(f.effect.relativeDelta ?? 0);
                return adj <= options.significanceLevel &&
                    (delta >= options.minimumEffectSize || relDelta >= options.minimumRelativeEffect);
            })
                .map(f => ({
                ...f,
                severity: severityForScore(f.stats.adjustedPValue ?? f.stats.pValue ?? 1, f.effect.effectSize ?? 0),
            })));
        }
    }
    return allFindings;
}
/** Region keys this finding covers (e.g. `["ring:1","ring:2"]`); empty for non-region targets. */
function regionKeysOf(f) {
    return f.highlight.regionKeys ?? [];
}
/** Group key for findings that describe the same signal in different regions. */
function mergeGroupKey(f) {
    const variableKey = f.variable.bin ?? f.variable.index ?? '';
    return `${f.comparison.family}\0${f.variable.kind}\0${variableKey}\0${f.effect.direction}`;
}
/** Order findings within a ring/sector group so contiguous-run detection is linear. */
function findingOrderIndex(f, sectorCount) {
    const key = regionKeysOf(f)[0] ?? '';
    const parsed = parseRegionKey(key);
    if (parsed.family === 'ring')
        return parsed.ring ?? 0;
    if (parsed.family === 'sector') {
        const names = sectorCompassNames(sectorCount);
        return names.indexOf(parsed.sector ?? '');
    }
    return 0;
}
/**
 * Partition a same-signal group into maximal runs of spatially adjacent regions.
 * Singletons (no adjacent same-signal neighbour) come back as length-1 runs.
 */
function findContiguousRuns(group, family, sectorCount) {
    if (family === 'quadrant') {
        // Connected components in the quadrant adjacency graph.
        const byQuadrant = new Map();
        for (const f of group) {
            const q = parseRegionKey(regionKeysOf(f)[0] ?? '').quadrant;
            if (q)
                byQuadrant.set(q, f);
        }
        const quadrants = [...byQuadrant.keys()];
        const seen = new Set();
        const runs = [];
        for (const start of quadrants) {
            if (seen.has(start))
                continue;
            const component = [];
            const stack = [start];
            seen.add(start);
            while (stack.length) {
                const q = stack.pop();
                component.push(q);
                for (const other of quadrants) {
                    if (!seen.has(other) && areQuadrantsAdjacent(q, other)) {
                        seen.add(other);
                        stack.push(other);
                    }
                }
            }
            runs.push(component.map(q => byQuadrant.get(q)));
        }
        return runs;
    }
    // Ring (linear) and sector (cyclic) — sort by order index, then split where the
    // index gap exceeds 1. For sectors, also stitch the wrap-around (last↔first).
    const sorted = [...group].sort((a, b) => findingOrderIndex(a, sectorCount) - findingOrderIndex(b, sectorCount));
    const runs = [];
    let current = [];
    for (const f of sorted) {
        if (current.length === 0) {
            current.push(f);
            continue;
        }
        const prev = findingOrderIndex(current[current.length - 1], sectorCount);
        const next = findingOrderIndex(f, sectorCount);
        if (next - prev === 1) {
            current.push(f);
        }
        else {
            runs.push(current);
            current = [f];
        }
    }
    if (current.length)
        runs.push(current);
    if (family === 'sector' && runs.length > 1) {
        // Cyclic wrap: if the first sector and last sector are adjacent (indices 0 and N-1),
        // the last run continues into the first run.
        const names = sectorCompassNames(sectorCount);
        const n = names.length;
        const firstIdx = findingOrderIndex(runs[0][0], sectorCount);
        const lastIdx = findingOrderIndex(runs[runs.length - 1][runs[runs.length - 1].length - 1], sectorCount);
        if (firstIdx === 0 && lastIdx === n - 1) {
            const last = runs.pop();
            runs[0] = [...last, ...runs[0]];
        }
    }
    return runs;
}
/** Build the merged label for a run of ring findings. */
function mergeRingLabel(run, ringCount) {
    const rings = run
        .map(f => parseRegionKey(regionKeysOf(f)[0] ?? '').ring)
        .filter((r) => r !== undefined)
        .sort((a, b) => a - b);
    const min = rings[0];
    const max = rings[rings.length - 1];
    // Attach a zone suffix only when the run is uniformly that zone.
    let suffix = '';
    if (min === max && min === 1 && ringCount > 1)
        suffix = ' (core)';
    else if (min === max && min === ringCount && ringCount > 1)
        suffix = ' (edge)';
    return min === max ? `Ring ${min}${suffix}` : `Rings ${min}–${max}`;
}
/** Build the merged label for a run of sector findings (contiguous arc). */
function mergeSectorLabel(run, sectorCount) {
    const sectors = run.map(f => parseRegionKey(regionKeysOf(f)[0] ?? '').sector ?? '');
    return sectors.length === 1 ? `Sector ${sectors[0]}` : `Sectors ${sectors[0]}–${sectors[sectors.length - 1]}`;
}
/** Build the merged label for a run of quadrant findings, e.g. "Quadrants NW, SW & SE". */
function mergeQuadrantLabel(run) {
    const quads = run.map(f => parseRegionKey(regionKeysOf(f)[0] ?? '').quadrant ?? '');
    if (quads.length === 1)
        return quads[0];
    if (quads.length === 2)
        return `Quadrants ${quads[0]} & ${quads[1]}`;
    return `Quadrants ${quads.slice(0, -1).join(', ')} & ${quads[quads.length - 1]}`;
}
function uniqueKeys(arr) {
    return [...new Set(arr)];
}
/**
 * Recompute stats for a merged finding over the union of its constituent dies.
 * Uses the same proportion/Welch helpers the builders use — never averages
 * the per-region p-values. Returns a finding ready to replace the run.
 */
function buildMergedFinding(run, ctx) {
    const template = run[0];
    const family = template.comparison.family;
    const kind = template.variable.kind;
    const unionDieKeys = uniqueKeys(run.flatMap(f => f.highlight.dieKeys ?? []));
    const unionRegionKeys = uniqueKeys(run.flatMap(regionKeysOf));
    const leftKeySet = new Set(unionDieKeys);
    const label = family === 'ring' ? mergeRingLabel(run, ctx.ringCount) :
        family === 'sector' ? mergeSectorLabel(run, ctx.sectorCount) :
            mergeQuadrantLabel(run);
    // Pick the die pool the original builder used for this kind.
    const pool = kind === 'softBin' ? ctx.softEligibleDies :
        kind === 'test' ? ctx.testDies :
            ctx.eligibleDies;
    const leftDies = pool.filter(d => leftKeySet.has(getDieKey(d)));
    const rightDies = pool.filter(d => !leftKeySet.has(getDieKey(d)));
    let effect;
    let stats;
    let severity;
    let summary;
    let idMetric;
    if (kind === 'test') {
        const testNumber = template.variable.index;
        const read = (d) => d.testValues?.[testNumber];
        const leftValues = leftDies.map(read).filter((v) => v !== undefined);
        const rightValues = rightDies.map(read).filter((v) => v !== undefined);
        const { pValue, effectSize, delta } = welchPValue(leftValues, rightValues);
        const rightMean = mean(rightValues);
        const relativeDelta = rightMean !== 0 ? delta / Math.abs(rightMean) : undefined;
        effect = {
            direction: delta === 0 ? 'different' : delta > 0 ? 'higher' : 'lower',
            absoluteDelta: delta,
            relativeDelta,
            effectSize,
        };
        stats = { method: 'welch-z-approx', pValue, sampleSizeLeft: leftValues.length, sampleSizeRight: rightValues.length };
        severity = severityForScore(pValue, effectSize);
        summary = summarizeTestFinding(label, template.variable.label, delta, relativeDelta, family, template.variable.unit);
        idMetric = `test:${testNumber}`;
    }
    else if (kind === 'yield') {
        const passSet = new Set(ctx.passBins);
        // Denominator is the hbin-bearing population only (see buildYieldFindings):
        // dies without a hard bin have no pass/fail verdict and must not deflate yield.
        const leftHbin = leftDies.filter(d => d.hbin !== undefined);
        const rightHbin = rightDies.filter(d => d.hbin !== undefined);
        const leftN = leftHbin.length;
        const rightN = rightHbin.length;
        const leftPass = leftHbin.filter(d => passSet.has(d.hbin)).length;
        const rightPass = rightHbin.filter(d => passSet.has(d.hbin)).length;
        const leftRate = leftN > 0 ? leftPass / leftN : 0;
        const rightRate = rightN > 0 ? rightPass / rightN : 0;
        const delta = leftRate - rightRate;
        const pValue = twoProportionPValue(leftPass, leftN, rightPass, rightN);
        effect = {
            direction: delta === 0 ? 'different' : delta > 0 ? 'higher' : 'lower',
            absoluteDelta: delta,
            relativeDelta: rightRate === 0 ? undefined : delta / rightRate,
            effectSize: delta,
        };
        stats = { method: 'two-proportion-z', pValue, sampleSizeLeft: leftN, sampleSizeRight: rightN };
        severity = severityForFinding(pValue, delta, effect.relativeDelta);
        summary = summarizeYieldFinding(label, delta, family);
        idMetric = 'yield';
    }
    else {
        // hardBin / softBin — count occurrences of the target bin.
        const bin = template.variable.bin;
        const getBin = (d) => kind === 'softBin' ? d.sbin : d.hbin;
        const leftHit = leftDies.filter(d => getBin(d) === bin).length;
        const rightHit = rightDies.filter(d => getBin(d) === bin).length;
        const leftRate = leftHit / leftDies.length;
        const rightRate = rightHit / rightDies.length;
        const delta = leftRate - rightRate;
        const pValue = twoProportionPValue(leftHit, leftDies.length, rightHit, rightDies.length);
        effect = {
            direction: delta === 0 ? 'different' : delta > 0 ? 'higher' : 'lower',
            absoluteDelta: delta,
            relativeDelta: rightRate === 0 ? undefined : delta / rightRate,
            effectSize: delta,
        };
        stats = { method: 'two-proportion-z', pValue, sampleSizeLeft: leftDies.length, sampleSizeRight: rightDies.length };
        severity = severityForFinding(pValue, delta, effect.relativeDelta);
        const defs = kind === 'softBin' ? ctx.sbinDefs : ctx.hbinDefs;
        summary = summarizeBinFinding(label, labelForBin(bin, defs, kind === 'softBin' ? 'SBin' : 'HBin'), delta, family);
        idMetric = `${kind}:${bin}`;
    }
    // Deterministic id from the sorted region keys (e.g. yield:ring:1-2).
    const regionIds = uniqueKeys(unionRegionKeys.map(k => k.replace(`${family}:`, ''))).join('-');
    // Preserve the original highlight shape per kind: bin findings carry a bin +
    // regionKeys (so click-to-highlight still applies highlightBin); yield/test
    // findings carry a region target. dieKeys is the union in both cases.
    const highlight = kind === 'hardBin' || kind === 'softBin'
        ? { kind: 'bin', bin: template.variable.bin, regionKeys: unionRegionKeys, dieKeys: unionDieKeys }
        : { kind: 'region', regionFamily: family, regionKeys: unionRegionKeys, dieKeys: unionDieKeys };
    return {
        ...template,
        id: `${idMetric}:${family}:${regionIds}`,
        severity,
        comparison: { family, left: label, right: comparisonRight(family) },
        effect,
        stats,
        summary,
        highlight,
        relatedIds: run.map(f => f.id),
    };
}
/**
 * Replace runs of adjacent same-signal ring/quadrant/sector findings with a single
 * merged finding each. Findings of other families (cluster, edge-arc, reticle-position,
 * test-site) and singleton runs pass through unchanged.
 */
function mergeAdjacentFindings(findings, ctx) {
    const MERGEABLE = new Set(['ring', 'quadrant', 'sector']);
    const passthrough = [];
    const groups = new Map();
    for (const f of findings) {
        if (!MERGEABLE.has(f.comparison.family)) {
            passthrough.push(f);
            continue;
        }
        const key = mergeGroupKey(f);
        const arr = groups.get(key) ?? [];
        arr.push(f);
        groups.set(key, arr);
    }
    const merged = [];
    for (const group of groups.values()) {
        if (group.length === 1) {
            merged.push(group[0]);
            continue;
        }
        const family = group[0].comparison.family;
        for (const run of findContiguousRuns(group, family, ctx.sectorCount)) {
            merged.push(run.length === 1 ? run[0] : buildMergedFinding(run, ctx));
        }
    }
    return [...passthrough, ...merged];
}
// ─────────────────────────────────────────────────────────────────────────────
// Redundancy collapse.
//
// The same physical phenomenon is legitimately detected by several independent
// passes, and the findings list then restates one fact many times. On a wafer
// with an edge failure the list can read:
//
//   Hard bin 1  -22.2 pp   Soft bin 1  -22.2 pp   Yield  -22.2 pp
//   Hard bin 2   +8.1 pp   Soft bin 2   +8.1 pp
//   Hard bin 3   +8.8 pp   Soft bin 3   +8.8 pp
//
// — seven rows for what an engineer would state in one sentence. Two exact
// redundancies are responsible, and both are collapsed here by CLAIMING the
// duplicate via `relatedIds` rather than deleting it: the panel and the report
// hide claimed findings, while `filterFindings` and any host reading
// `summary.findings` still see every finding individually. Nothing is lost.
//
// The merge conditions are deliberately structural, never "the numbers look the
// same":
//
//  1. Hard/soft twins. Hard and soft bins are INDEPENDENT number spaces — "hard
//     bin 3" and "soft bin 3" usually mean different things, and merging on the
//     bin number would conflate two unrelated populations. They are merged only
//     when the die sets are provably identical, computed from the dies.
//
//  2. Pass bin ≡ yield. When exactly one pass bin is configured, "hard bin 1
//     occurrence is 22.2 pp lower" and "yield is 22.2 pp lower" are the same
//     statement by definition. With several pass bins no single bin finding
//     equals yield, so the rule correctly does not fire.
// ─────────────────────────────────────────────────────────────────────────────
/**
 * Hard/soft bin pairs whose die sets are exactly equal, as `"h:s"` keys.
 * Only such pairs may be presented as one finding.
 */
function coincidentBinPairs(dies) {
    const hard = new Map();
    const soft = new Map();
    for (const d of dies) {
        const key = getDieKey(d);
        if (d.hbin !== undefined && d.hbin !== null) {
            (hard.get(d.hbin) ?? hard.set(d.hbin, new Set()).get(d.hbin)).add(key);
        }
        if (d.sbin !== undefined && d.sbin !== null) {
            (soft.get(d.sbin) ?? soft.set(d.sbin, new Set()).get(d.sbin)).add(key);
        }
    }
    const out = new Set();
    for (const [h, hKeys] of hard) {
        for (const [sb, sKeys] of soft) {
            if (hKeys.size !== sKeys.size)
                continue;
            let same = true;
            for (const k of hKeys)
                if (!sKeys.has(k)) {
                    same = false;
                    break;
                }
            if (same)
                out.add(`${h}:${sb}`);
        }
    }
    return out;
}
/** Region identity for redundancy purposes: same family, same region label. */
function regionKeyOf(f) {
    return `${f.comparison.family}\u0000${f.comparison.left}`;
}
function collapseRedundantFindings(findings, eligibleDies, passBins) {
    // `absorbedIds`, not `relatedIds`: the latter already means two things —
    // a run-merge's audit trail of constituents it REPLACED (which no longer
    // exist) and a spatial pattern's supporting detail. Everything absorbed here
    // is still live and readable, so it needs a field that says so.
    const claim = (owner, claimed) => {
        owner.absorbedIds = [...(owner.absorbedIds ?? []), claimed.id];
    };
    const byRegion = new Map();
    for (const f of findings) {
        const k = regionKeyOf(f);
        (byRegion.get(k) ?? byRegion.set(k, []).get(k)).push(f);
    }
    const coincident = coincidentBinPairs(eligibleDies);
    const alreadyClaimed = new Set();
    for (const group of byRegion.values()) {
        // ── 1. Hard/soft twins ──────────────────────────────────────────────────
        for (const hardF of group) {
            if (hardF.variable.kind !== 'hardBin' || hardF.variable.bin === undefined)
                continue;
            for (const softF of group) {
                if (softF.variable.kind !== 'softBin' || softF.variable.bin === undefined)
                    continue;
                if (alreadyClaimed.has(softF.id))
                    continue;
                if (!coincident.has(`${hardF.variable.bin}:${softF.variable.bin}`))
                    continue;
                // Same dies AND the same statement about them.
                if (softF.effect.direction !== hardF.effect.direction)
                    continue;
                claim(hardF, softF);
                alreadyClaimed.add(softF.id);
                // Name BOTH bins, rather than silently showing only the hard-bin row:
                // an engineer who filters on soft bins must not conclude the soft
                // finding was never raised. "(same dies)" is load-bearing — without it
                // "hard bin 3 and soft bin 3" could be read as two populations summed.
                //
                // Built from the findings' own labels in the internal `HBin N`/`SBin N`
                // vocabulary so `plainBinTerms` expands them to "hard bin"/"soft bin"
                // on every display surface, exactly as it does for unmerged findings.
                const both = `${hardF.variable.label} and ${softF.variable.label} (same dies)`;
                // Replace the bin term inside the sentence rather than anchoring at the
                // start: the summary reads "<region> has <binLabel> occurrence …", so
                // the bin term is mid-string.
                hardF.summary = hardF.summary.replace(hardF.variable.label, both);
                hardF.variable.label = both;
                break;
            }
        }
        // ── 2. Pass bin ≡ yield ─────────────────────────────────────────────────
        if (passBins.length !== 1)
            continue;
        const yieldF = group.find(f => f.variable.kind === 'yield');
        if (!yieldF)
            continue;
        for (const f of group) {
            if (f === yieldF || alreadyClaimed.has(f.id))
                continue;
            const isPassBinRow = (f.variable.kind === 'hardBin' || f.variable.kind === 'softBin') &&
                f.variable.bin === passBins[0];
            if (!isPassBinRow)
                continue;
            claim(yieldF, f);
            alreadyClaimed.add(f.id);
            // Anything the pass-bin row had already absorbed moves across too, so a
            // claimed finding is never orphaned behind a claimed claimer.
            for (const id of f.absorbedIds ?? []) {
                yieldF.absorbedIds = [...(yieldF.absorbedIds ?? []), id];
            }
        }
    }
}
export function analyzeWaferMap(input, options = {}) {
    const baseResolved = { ...DEFAULT_OPTIONS, ...options };
    const result = normalizeInput(input);
    const isLotStack = result.isLotStack;
    const stackMethod = result.aggrMethod;
    const hasHbinData = !isLotStack ||
        stackMethod === 'mode' || stackMethod === 'countBin' || stackMethod === 'percent';
    const eligibleDies = result.dies.filter((die) => isEligibleDie(die, baseResolved));
    // Cluster/pattern detection are spatial (physX/physY-based flood-fill and
    // shape features) — an unpositioned die can't belong to a spatial cluster
    // or contribute to a spatial pattern, so both take this instead of
    // eligibleDies directly.
    const positionedEligibleDies = eligibleDies.filter(isPositionedDie);
    const resolved = adaptOptions(baseResolved, eligibleDies.length);
    const includedDies = result.dies.filter((die) => isYieldEligibleDie(die, resolved));
    // Ring/quadrant/reticle-position/sector are spatial — an unpositioned die
    // has no ring/quadrant/etc. by definition, so it's excluded from every one
    // of these region families. buildTestSiteRegions is deliberately exempt
    // (keyed by siteNum, not coordinates) and stays on the full includedDies.
    // isPositionedDie narrows physX/physY alongside x/y, so this filter produces
    // the PositionedDie[] the region builders require without a cast.
    const positionedIncludedDies = includedDies.filter(isPositionedDie);
    const ringRegions = buildRingRegions(positionedIncludedDies, result.wafer, resolved.ringCount);
    const quadrantRegions = buildQuadrantRegions(positionedIncludedDies, result.wafer, resolved.ringCount);
    const reticlePositionRegions = resolved.enableReticlePositionAnalysis
        ? buildReticlePositionRegions(positionedIncludedDies, result.reticleConfig)
        : [];
    // enableTestSiteAnalysis: undefined means auto (guard in buildTestSiteRegions decides);
    // true forces it on; false suppresses it.
    const testSiteRegions = resolved.enableTestSiteAnalysis === false
        ? []
        : buildTestSiteRegions(includedDies, resolved.enableTestSiteAnalysis === true);
    const sectorRegions = resolved.enableAngularAnalysis
        ? buildSectorRegions(positionedIncludedDies, result.wafer, resolved.sectorCount)
        : [];
    const findings = [];
    if (resolved.enableYieldAnalysis && hasHbinData) {
        findings.push(...buildYieldFindings(eligibleDies, ringRegions, resolved.passBins, resolved), ...buildYieldFindings(eligibleDies, quadrantRegions, resolved.passBins, resolved), ...buildYieldFindings(eligibleDies, reticlePositionRegions, resolved.passBins, resolved), ...buildYieldFindings(eligibleDies, testSiteRegions, resolved.passBins, resolved), ...buildYieldFindings(eligibleDies, sectorRegions, resolved.passBins, resolved));
    }
    if (resolved.enableHardBinAnalysis && hasHbinData) {
        findings.push(...buildBinFindings(eligibleDies, ringRegions, 'hard', result.hbinDefs, 'hardBin', resolved), ...buildBinFindings(eligibleDies, quadrantRegions, 'hard', result.hbinDefs, 'hardBin', resolved), ...buildBinFindings(eligibleDies, reticlePositionRegions, 'hard', result.hbinDefs, 'hardBin', resolved), ...buildBinFindings(eligibleDies, testSiteRegions, 'hard', result.hbinDefs, 'hardBin', resolved), ...buildBinFindings(eligibleDies, sectorRegions, 'hard', result.hbinDefs, 'hardBin', resolved));
    }
    if (resolved.enableSoftBinAnalysis) {
        const softEligibleDies = eligibleDies.filter((die) => die.sbin !== undefined);
        findings.push(...buildBinFindings(softEligibleDies, ringRegions, 'soft', result.sbinDefs, 'softBin', resolved), ...buildBinFindings(softEligibleDies, quadrantRegions, 'soft', result.sbinDefs, 'softBin', resolved), ...buildBinFindings(softEligibleDies, reticlePositionRegions, 'soft', result.sbinDefs, 'softBin', resolved), ...buildBinFindings(softEligibleDies, testSiteRegions, 'soft', result.sbinDefs, 'softBin', resolved), ...buildBinFindings(softEligibleDies, sectorRegions, 'soft', result.sbinDefs, 'softBin', resolved));
    }
    const warnings = [];
    let activeTestNumbers;
    if (resolved.enableTestValueAnalysis) {
        const ring = buildTestValueFindings(eligibleDies, ringRegions, result.testDefs, resolved);
        // ── set below; the cheap perTestStats pass reuses ring.activeTestNumbers ──
        const quad = buildTestValueFindings(eligibleDies, quadrantRegions, result.testDefs, resolved);
        const reticle = buildTestValueFindings(eligibleDies, reticlePositionRegions, result.testDefs, resolved);
        const testSite = buildTestValueFindings(eligibleDies, testSiteRegions, result.testDefs, resolved);
        const sector = buildTestValueFindings(eligibleDies, sectorRegions, result.testDefs, resolved);
        findings.push(...ring.findings, ...quad.findings, ...reticle.findings, ...testSite.findings, ...sector.findings);
        if (ring.warning)
            warnings.push(ring.warning);
        activeTestNumbers = ring.activeTestNumbers;
        findings.push(...buildSpecLimitFindings(eligibleDies, [ringRegions, quadrantRegions, reticlePositionRegions, testSiteRegions, sectorRegions], result.testDefs, resolved));
        // Functional tests get pass-rate findings (two-proportion, like yield/bin)
        // instead of the parametric Welch comparisons above, from which they are
        // excluded. No-op unless a functional testDef exists.
        findings.push(...buildFunctionalPassFindings(eligibleDies, ringRegions, result.testDefs, resolved), ...buildFunctionalPassFindings(eligibleDies, quadrantRegions, result.testDefs, resolved), ...buildFunctionalPassFindings(eligibleDies, reticlePositionRegions, result.testDefs, resolved), ...buildFunctionalPassFindings(eligibleDies, testSiteRegions, result.testDefs, resolved), ...buildFunctionalPassFindings(eligibleDies, sectorRegions, result.testDefs, resolved));
    }
    if (resolved.enableClusterAnalysis) {
        const failPredicate = makeClusterFailurePredicate(isLotStack, hasHbinData, result.testDefs);
        if (!isLotStack || hasHbinData || failPredicate !== undefined) {
            findings.push(...buildClusterFindings(positionedEligibleDies, result.wafer, {
                ...resolved,
                isFailingDie: failPredicate,
            }));
        }
    }
    // Collapse runs of adjacent same-signal ring/quadrant/sector findings into one
    // each. Runs BEFORE pattern classification so the pattern pass links the merged
    // finding (not the constituents) via relatedIds.
    const mergedFindings = mergeAdjacentFindings(findings, {
        eligibleDies,
        softEligibleDies: eligibleDies.filter((die) => die.sbin !== undefined),
        testDies: eligibleDies,
        passBins: resolved.passBins,
        ringCount: resolved.ringCount,
        sectorCount: resolved.sectorCount,
        hbinDefs: result.hbinDefs,
        sbinDefs: result.sbinDefs,
        testDefs: result.testDefs,
    });
    findings.length = 0;
    findings.push(...mergedFindings);
    if (resolved.enablePatternClassification && hasHbinData) {
        const patternResult = classifyPattern(positionedEligibleDies, result.wafer, {
            passBins: resolved.passBins,
            ringCount: resolved.ringCount,
        });
        if (patternResult !== null && patternResult.pattern !== 'random' && patternResult.pattern !== 'none') {
            const LABEL_MAP = {
                'center': 'Center cluster',
                'donut': 'Donut',
                'edge-ring': 'Edge-ring',
                'edge-local': 'Edge-local',
                'scratch': 'Scratch',
                'near-full': 'Near-full',
            };
            const label = LABEL_MAP[patternResult.pattern] ?? patternResult.pattern;
            const severity = patternResult.confidence === 'high' ? 'unusual' :
                patternResult.confidence === 'medium' ? 'notable' : 'info';
            const f = patternResult.features;
            const pct = (v) => `${(v * 100).toFixed(0)}%`;
            const detail = patternResult.pattern === 'edge-ring' || patternResult.pattern === 'edge-local'
                ? `${pct(f.edgeRdd)} of edge dies failing`
                : patternResult.pattern === 'center' || patternResult.pattern === 'donut'
                    ? `centroid at ${pct(f.centroidDistNorm)} of wafer radius from centre`
                    : patternResult.pattern === 'scratch'
                        ? `linear score ${f.linearScore.toFixed(2)}, eccentricity ${f.eccentricity.toFixed(2)}`
                        : `${pct(f.globalRdd)} of dies failing`;
            const failingDies = eligibleDies.filter(d => {
                const bin = d.hbin ?? d.sbin;
                return bin !== undefined && !new Set(resolved.passBins).has(bin);
            });
            // Find existing findings that are correlated with this spatial pattern.
            // A finding is related when its comparison family is one the pattern explains.
            const RELATED_FAMILIES = {
                'edge-ring': ['ring', 'edge-arc'],
                'edge-local': ['edge-arc', 'sector', 'quadrant'],
                'center': ['ring', 'cluster'],
                'donut': ['ring'],
                'scratch': ['cluster', 'sector', 'quadrant'],
                'near-full': ['ring'],
            };
            const relatedFamilies = new Set(RELATED_FAMILIES[patternResult.pattern] ?? []);
            // For ring-based patterns, further filter to only rings that are relevant:
            // edge patterns → outer ring; center → core ring; donut → middle rings.
            // Parse ring indices from highlight.regionKeys (robust to merged labels
            // like "Rings 3–4" which a substring match on the label would miss).
            const detectedPattern = patternResult.pattern;
            const ringCount = resolved.ringCount;
            function isRingRelevant(existing) {
                const rings = regionKeysOf(existing)
                    .map(k => parseRegionKey(k).ring)
                    .filter((r) => r !== undefined);
                if (rings.length === 0)
                    return true;
                const includesEdge = rings.some(r => r === ringCount);
                const includesCore = rings.some(r => r === 1);
                if (detectedPattern === 'edge-ring' || detectedPattern === 'edge-local')
                    return includesEdge;
                if (detectedPattern === 'center')
                    return includesCore;
                if (detectedPattern === 'donut')
                    return !includesEdge && !includesCore;
                return true; // near-full: all rings
            }
            const relatedIds = [];
            for (const existing of findings) {
                if (!relatedFamilies.has(existing.comparison.family))
                    continue;
                if (existing.comparison.family === 'ring' && !isRingRelevant(existing))
                    continue;
                relatedIds.push(existing.id);
                // Downgrade ring-family findings to info so they don't double-count in the badge/hasNotable.
                // Cluster and edge-arc findings carry their own statistical evidence (p-value, exact die count)
                // and should keep their computed severity — they are supporting detail, not redundant.
                const isRegionOnly = existing.comparison.family === 'ring' ||
                    existing.comparison.family === 'quadrant' || existing.comparison.family === 'sector';
                if (severity !== 'info' && isRegionOnly) {
                    existing.severity = 'info';
                }
            }
            findings.push({
                id: `spatial-pattern:${patternResult.pattern}`,
                level: 'wafer',
                severity,
                variable: { kind: 'spatialPattern', label },
                comparison: { family: 'spatial-pattern', left: label, right: 'Wafer' },
                effect: { direction: 'different', effectSize: f.globalRdd },
                stats: {
                    method: 'geometry',
                    sampleSizeLeft: failingDies.length,
                    sampleSizeRight: eligibleDies.length,
                },
                summary: `Spatial pattern: ${label.toLowerCase()} (${patternResult.confidence} confidence) — ${detail}${patternResult.note ? ` [${patternResult.note}]` : ''}`,
                highlight: { kind: 'dies', dieKeys: failingDies.map(d => getDieKey(d)) },
                relatedIds: relatedIds.length > 0 ? relatedIds : undefined,
            });
        }
    }
    findings.sort((left, right) => {
        const leftScore = left.stats.adjustedPValue ?? left.stats.pValue ?? 1;
        const rightScore = right.stats.adjustedPValue ?? right.stats.pValue ?? 1;
        if (leftScore !== rightScore)
            return leftScore - rightScore;
        return Math.abs((right.effect.absoluteDelta ?? 0)) - Math.abs((left.effect.absoluteDelta ?? 0));
    });
    const stats = collectStats(result.dies, eligibleDies.length, result.yield.yieldPercent);
    if (isLotStack) {
        stats.isLotStack = true;
        if (stackMethod)
            stats.aggregationMethod = stackMethod;
    }
    const specYield = computeTestSpecYield(result.dies, result.testDefs);
    if (specYield)
        stats.testSpecYield = specYield;
    const functionalYield = computeFunctionalYield(result.dies, result.testDefs);
    if (functionalYield)
        stats.functionalYield = functionalYield;
    // Per-test descriptive stats (quartiles for box plots). Produced when the full
    // test-value analysis ran (reusing its discovered test numbers) OR when the
    // caller asked for the cheap computePerTestStats pass on its own. This may push
    // a cap warning, so it must run BEFORE stats.warnings is assigned below.
    if (activeTestNumbers === undefined && resolved.computePerTestStats) {
        const discovered = discoverTestNumbers(eligibleDies, resolved.testNumbers, result.testDefs);
        if (discovered.warning)
            warnings.push(discovered.warning);
        activeTestNumbers = discovered.testNumbers;
    }
    if (activeTestNumbers?.length) {
        const perTestStats = computePerTestStats(result.dies, activeTestNumbers, result.testDefs, resolved.minimumSampleSize);
        if (perTestStats)
            stats.perTestStats = perTestStats;
    }
    // Assign warnings last so cap warnings raised by the cheap perTestStats path
    // above are not lost (they are pushed after the earlier assignment point).
    if (warnings.length > 0)
        stats.warnings = warnings;
    // Runs last, over the complete list: the spatial-pattern pass above also
    // claims findings, and collapsing before it would leave those claims dangling.
    collapseRedundantFindings(findings, eligibleDies, resolved.passBins);
    return {
        level: 'wafer',
        hasNotableFindings: findings.some((finding) => finding.severity === 'notable' || finding.severity === 'unusual'),
        findings,
        wafer: result.wafer.metadata ?? undefined,
        stats,
    };
}
//# sourceMappingURL=analyzeWaferMap.js.map