/**
 * VectorStore interface — contract every concrete store implements.
 *
 * Why a contract module instead of an abstract base class: the JS
 * inheritance ceremony adds nothing here (these are pure data ops),
 * but the test-helper pattern below is invaluable — every concrete
 * store re-runs the same conformance tests, so we can swap
 * `inMemoryJsonStore` for `sqliteVecStore` later without rewriting
 * the test surface.
 *
 * Shape on disk (in-memory store):
 *   {
 *     "version": 1,
 *     "dimensions": 768,
 *     "modelFingerprint": "local:Xenova/nomic-embed-text-v1.5",
 *     "rows": [
 *       {
 *         "id":       "msg_abc#0",
 *         "vector":   [0.1, …],
 *         "metadata": { ... }
 *       }
 *     ]
 *   }
 *
 * Why the fingerprint lives in the file header: when a user switches
 * providers (azure ↔ local) or models (nomic ↔ bge), the existing
 * vectors are dimensionally + semantically incompatible. The store
 * REFUSES to load a file whose fingerprint doesn't match what the
 * provider currently advertises, and the orchestrator interprets that
 * refusal as "re-embed this scope from scratch".
 *
 * @typedef {Object} VectorRow
 * @property {string}       id           - Stable identifier (e.g. "msg_abc#chunk0")
 * @property {Float32Array} vector       - L2-normalized
 * @property {Object}       metadata     - Arbitrary indexing-time payload (text, timestamps, etc.)
 *
 * @typedef {Object} QueryResult
 * @property {string} id
 * @property {number} score              - Higher = more similar; cosine in [-1, 1]
 * @property {Object} metadata
 *
 * @typedef {Object} VectorStoreStats
 * @property {number}      count
 * @property {number}      dimensions
 * @property {string}      modelFingerprint
 * @property {string|null} lastIndexedAt - ISO timestamp of newest row, or null
 */

/**
 * Reciprocal Rank Fusion constant. The standard literature value (60)
 * — higher = more weight on top hits, lower = more weight across all
 * ranks. 60 is a sane middle that handles short lists well.
 */
export const RRF_K = 60;

/**
 * Combine two ranked lists into a single hybrid ranking via Reciprocal
 * Rank Fusion. The math: score(id) = Σ over ranking lists of
 *   1 / (k + rank(id, list))
 * where rank is 1-based and missing IDs contribute 0. Ranking-only —
 * raw scores from the input lists are NOT used, which makes the fusion
 * robust to wildly different score scales (cosine in [-1,1] vs
 * substring boolean).
 *
 * @param {Array<Array<{id: string}>>} rankedLists - Already-sorted lists.
 *        Each list element only needs an `id`.
 * @param {object} [opts]
 * @param {number} [opts.k=RRF_K]
 * @param {number} [opts.topK]  - If set, returns at most this many results.
 * @returns {Array<{id: string, score: number}>} Sorted desc by score.
 */
export function reciprocalRankFusion(rankedLists, { k = RRF_K, topK } = {}) {
  const scores = new Map(); // id -> accumulated score
  for (const list of rankedLists) {
    if (!Array.isArray(list)) continue;
    for (let rank = 0; rank < list.length; rank++) {
      const id = list[rank]?.id;
      if (!id) continue;
      const inc = 1 / (k + rank + 1);
      scores.set(id, (scores.get(id) || 0) + inc);
    }
  }
  const out = Array.from(scores, ([id, score]) => ({ id, score }))
    .sort((a, b) => b.score - a.score);
  return typeof topK === 'number' ? out.slice(0, topK) : out;
}

// `runStoreContractTests` (the shared contract test suite that every
// VectorStore implementation re-uses) lives in `storeContractTests.js`
// instead of here, so production imports of this module don't pull in
// @jest/globals or other test-only deps.
