// engine/vector-store/hnsw.ts — HNSW index for approximate nearest neighbor search.
// SPEC-075 AC-02: Pure TypeScript, O(log n), recall > 0.9.
import { cosineSimilarity } from './similarity.js';
/**
 * HNSW (Hierarchical Navigable Small World) index.
 * For corpus < flatSearchThreshold, delegates to brute-force flat search.
 */
export class HNSWIndex {
    config;
    nodes = new Map();
    entryPoint = null;
    maxLayer = 0;
    constructor(config) {
        this.config = {
            M: config?.M ?? 16,
            efConstruction: config?.efConstruction ?? 200,
            efSearch: config?.efSearch ?? 50,
            flatSearchThreshold: config?.flatSearchThreshold ?? 1000,
        };
    }
    /** Number of indexed vectors. */
    get size() {
        return this.nodes.size;
    }
    /** Insert a vector into the index. */
    insert(id, vector) {
        const level = this.randomLevel();
        const layers = [];
        for (let i = 0; i <= level; i++) {
            layers.push(new Map());
        }
        const node = { id, vector, layers };
        if (this.nodes.size === 0) {
            this.nodes.set(id, node);
            this.entryPoint = id;
            this.maxLayer = level;
            return;
        }
        // Navigate from top to insertion level
        /* v8 ignore start -- entryPoint always set when nodes.size > 0 */
        let currentId = this.entryPoint ?? id;
        /* v8 ignore stop */
        for (let lc = this.maxLayer; lc > level; lc--) {
            currentId = this.greedyClosest(currentId, vector, lc);
        }
        // Insert at each layer from level down to 0
        for (let lc = Math.min(level, this.maxLayer); lc >= 0; lc--) {
            const neighbors = this.searchLayer(currentId, vector, this.config.efConstruction, lc);
            const selected = neighbors.slice(0, this.config.M);
            for (const nb of selected) {
                const sim = nb.similarity;
                node.layers[lc]?.set(nb.id, sim);
                const nbNode = this.nodes.get(nb.id);
                /* v8 ignore start -- neighbor always exists and has layers at lc */
                if (nbNode && lc < nbNode.layers.length) {
                    /* v8 ignore stop */
                    nbNode.layers[lc]?.set(id, sim);
                    // Prune if too many connections
                    /* v8 ignore start -- lc always within nbNode.layers bounds */
                    if ((nbNode.layers[lc]?.size ?? 0) > this.config.M * 2) {
                        /* v8 ignore stop */
                        this.pruneConnections(nbNode, lc);
                    }
                }
            }
            const first = selected[0];
            /* v8 ignore start -- searchLayer always returns at least one result */
            if (first) {
                /* v8 ignore stop */
                currentId = first.id;
            }
        }
        this.nodes.set(id, node);
        if (level > this.maxLayer) {
            this.maxLayer = level;
            this.entryPoint = id;
        }
    }
    /** Remove a vector from the index. */
    remove(id) {
        const node = this.nodes.get(id);
        if (!node) {
            return false;
        }
        // Remove references from neighbors
        for (let lc = 0; lc < node.layers.length; lc++) {
            const layer = node.layers[lc];
            /* v8 ignore start -- defensive: layers always exist within bounds */
            if (!layer) {
                continue;
            }
            /* v8 ignore stop */
            for (const neighborId of layer.keys()) {
                const neighbor = this.nodes.get(neighborId);
                /* v8 ignore start -- neighbor always exists in graph */
                if (neighbor && lc < neighbor.layers.length) {
                    /* v8 ignore stop */
                    neighbor.layers[lc]?.delete(id);
                }
            }
        }
        this.nodes.delete(id);
        // Update entry point if needed
        if (this.entryPoint === id) {
            /* v8 ignore start -- Map.keys().next().value always defined when size > 0 */
            this.entryPoint = this.nodes.size > 0 ? (this.nodes.keys().next().value ?? null) : null;
            /* v8 ignore stop */
            this.maxLayer = 0;
            for (const n of this.nodes.values()) {
                if (n.layers.length - 1 > this.maxLayer) {
                    this.maxLayer = n.layers.length - 1;
                    this.entryPoint = n.id;
                }
            }
        }
        return true;
    }
    /** Search for the top-K nearest neighbors. Uses flat search for small corpus. */
    search(query, topK) {
        if (this.nodes.size === 0) {
            return [];
        }
        // Flat search for small corpus
        if (this.nodes.size < this.config.flatSearchThreshold) {
            return this.flatSearch(query, topK);
        }
        return this.hnswSearch(query, topK);
    }
    /** Brute-force flat search -- guaranteed recall = 1.0. */
    flatSearch(query, topK) {
        const results = [];
        for (const node of this.nodes.values()) {
            results.push({ id: node.id, similarity: cosineSimilarity(query, node.vector) });
        }
        results.sort((a, b) => b.similarity - a.similarity);
        return results.slice(0, topK);
    }
    /** HNSW graph search -- approximate, O(log n). */
    hnswSearch(query, topK) {
        /* v8 ignore start -- entryPoint always set when called (size > 0) */
        let currentId = this.entryPoint ?? '';
        /* v8 ignore stop */
        // Traverse from top layer to layer 1
        for (let lc = this.maxLayer; lc > 0; lc--) {
            currentId = this.greedyClosest(currentId, query, lc);
        }
        // Search at layer 0 with efSearch candidates
        const candidates = this.searchLayer(currentId, query, this.config.efSearch, 0);
        return candidates.slice(0, topK);
    }
    /** Greedy walk to the closest node in a given layer. */
    greedyClosest(startId, query, layer) {
        let bestId = startId;
        const startNode = this.nodes.get(startId);
        /* v8 ignore start -- defensive: startId always valid when called internally */
        if (!startNode) {
            return bestId;
        }
        /* v8 ignore stop */
        let bestSim = cosineSimilarity(query, startNode.vector);
        let improved = true;
        while (improved) {
            improved = false;
            const node = this.nodes.get(bestId);
            /* v8 ignore start -- defensive: bestId always valid in walk */
            if (!node || layer >= node.layers.length) {
                break;
            }
            /* v8 ignore stop */
            const neighbors = node.layers[layer];
            /* v8 ignore start -- defensive: layer within bounds */
            if (!neighbors) {
                break;
            }
            /* v8 ignore stop */
            for (const neighborId of neighbors.keys()) {
                const neighbor = this.nodes.get(neighborId);
                /* v8 ignore start -- defensive: neighbor always valid in graph */
                if (!neighbor) {
                    continue;
                }
                /* v8 ignore stop */
                const sim = cosineSimilarity(query, neighbor.vector);
                if (sim > bestSim) {
                    bestSim = sim;
                    bestId = neighborId;
                    improved = true;
                }
            }
        }
        return bestId;
    }
    /** Search a single layer, returning ef closest candidates. */
    searchLayer(startId, query, ef, layer) {
        const visited = new Set([startId]);
        const startNode = this.nodes.get(startId);
        /* v8 ignore start -- defensive: startId always valid */
        if (!startNode) {
            return [];
        }
        /* v8 ignore stop */
        const candidates = [
            { id: startId, similarity: cosineSimilarity(query, startNode.vector) },
        ];
        const queue = [startId];
        while (queue.length > 0) {
            const currentId = queue.shift();
            /* v8 ignore start -- defensive: queue checked non-empty */
            if (!currentId) {
                continue;
            }
            /* v8 ignore stop */
            const current = this.nodes.get(currentId);
            /* v8 ignore start -- defensive: currentId always valid */
            if (!current || layer >= current.layers.length) {
                continue;
            }
            /* v8 ignore stop */
            const neighbors = current.layers[layer];
            /* v8 ignore start -- defensive: layer within bounds */
            if (!neighbors) {
                continue;
            }
            /* v8 ignore stop */
            for (const neighborId of neighbors.keys()) {
                if (visited.has(neighborId)) {
                    continue;
                }
                visited.add(neighborId);
                const neighbor = this.nodes.get(neighborId);
                /* v8 ignore start -- defensive: neighbor always in graph */
                if (!neighbor) {
                    continue;
                }
                /* v8 ignore stop */
                const sim = cosineSimilarity(query, neighbor.vector);
                candidates.push({ id: neighborId, similarity: sim });
                queue.push(neighborId);
                // Keep candidate list bounded
                /* v8 ignore start -- requires corpus > ef*2 neighbors from a single node */
                if (candidates.length > ef * 2) {
                    candidates.sort((a, b) => b.similarity - a.similarity);
                    candidates.length = ef;
                }
                /* v8 ignore stop */
            }
        }
        candidates.sort((a, b) => b.similarity - a.similarity);
        return candidates.slice(0, ef);
    }
    /** Prune connections to keep at most M neighbors. */
    pruneConnections(node, layer) {
        const layerMap = node.layers[layer];
        /* v8 ignore start -- defensive: layer always valid when called */
        if (!layerMap) {
            return;
        }
        /* v8 ignore stop */
        const entries = [...layerMap.entries()].sort((a, b) => b[1] - a[1]);
        layerMap.clear();
        for (const [id, sim] of entries.slice(0, this.config.M)) {
            layerMap.set(id, sim);
        }
    }
    /** Random level for a new node (geometric distribution). */
    randomLevel() {
        const ml = 1 / Math.log(this.config.M);
        let level = 0;
        while (Math.random() < ml && level < 16) {
            level++;
        }
        return level;
    }
    /** Export the index for serialization. */
    exportState() {
        const nodes = [...this.nodes.values()].map((n) => ({
            id: n.id,
            vector: n.vector,
            layers: n.layers.map((l) => [...l.entries()]),
        }));
        return { nodes, entryPoint: this.entryPoint, maxLayer: this.maxLayer };
    }
    /** Import the index from serialized form. */
    importState(state) {
        this.nodes.clear();
        for (const n of state.nodes) {
            const layers = n.layers.map((l) => new Map(l));
            this.nodes.set(n.id, { id: n.id, vector: n.vector, layers });
        }
        this.entryPoint = state.entryPoint;
        this.maxLayer = state.maxLayer;
    }
}
//# sourceMappingURL=hnsw.js.map