{"version":3,"file":"link-boost-C5PaNdzm.mjs","names":[],"sources":["../src/memory/link-boost.ts"],"sourcesContent":["/**\n * Rank search results by how the corpus links to them, not only by similarity.\n *\n * Why this exists: the store holds 33,709 wikilinks that are *facts* — one note\n * pointing at another, written by a person — alongside 2.4M chunks whose only\n * ranking signal is embedding similarity. Similarity answers \"what reads like\n * the query\". It cannot answer \"which of these is the one the others refer\n * back to\", which is usually the note worth reading first.\n *\n * The boost is deliberately query-local: it counts links *between the results\n * themselves*, not global popularity. A note linked by many other notes that\n * also match the query is a hub for that question. A note linked by half the\n * vault is merely popular, which is not the same thing and would flatten every\n * ranking toward the same few index pages.\n *\n * Links cost nothing to maintain — no embedding pass, no model call — so this\n * signal stays correct while the embedding backlog drains, and works for chunks\n * that have no embedding at all.\n */\n\nimport type { SearchResult } from \"./search.js\";\n\n/** A directed link between two note paths, as stored in vault_links. */\nexport interface LinkEdge {\n  sourcePath: string;\n  targetPath: string;\n}\n\nexport interface LinkBoostOptions {\n  /**\n   * How much the boost may move a result, as a fraction of its current score.\n   * 0.25 means the most-linked result gains 25%. Kept modest by default: the\n   * link graph is a supporting signal, and a note nobody links to can still be\n   * the right answer.\n   */\n  weight?: number;\n}\n\n/**\n * Re-rank results by inbound links *from other results in the same set*.\n *\n * Returns a new array, sorted by the adjusted score. Input is not mutated.\n * Results whose paths carry no inbound links are unchanged, so a corpus with\n * no links at all is a no-op rather than a distortion.\n */\nexport function applyLinkBoost(\n  results: SearchResult[],\n  edges: LinkEdge[],\n  opts?: LinkBoostOptions,\n): SearchResult[] {\n  const weight = opts?.weight ?? 0.25;\n  if (results.length === 0 || edges.length === 0 || weight === 0) {\n    return [...results];\n  }\n\n  // Only links whose BOTH ends are in the result set count. An edge pointing\n  // out of the set says nothing about the relative rank of results inside it.\n  const paths = new Set(results.map((r) => r.path));\n  const inbound = new Map<string, number>();\n  for (const e of edges) {\n    if (e.sourcePath === e.targetPath) continue;      // self-links are noise\n    if (!paths.has(e.sourcePath) || !paths.has(e.targetPath)) continue;\n    inbound.set(e.targetPath, (inbound.get(e.targetPath) ?? 0) + 1);\n  }\n  if (inbound.size === 0) return [...results];\n\n  // Normalise against the most-linked result so the boost is bounded by\n  // `weight` regardless of corpus size. Without this, a densely linked project\n  // would swamp similarity entirely while a sparse one would see no effect.\n  const maxInbound = Math.max(...inbound.values());\n\n  return results\n    .map((r) => {\n      const links = inbound.get(r.path) ?? 0;\n      if (links === 0) return { ...r };\n      const factor = 1 + weight * (links / maxInbound);\n      return { ...r, score: r.score * factor };\n    })\n    .sort((a, b) => b.score - a.score);\n}\n"],"mappings":";;;;;;;;AA6CA,SAAgB,eACd,SACA,OACA,MACgB;CAChB,MAAM,SAAS,MAAM,UAAU;AAC/B,KAAI,QAAQ,WAAW,KAAK,MAAM,WAAW,KAAK,WAAW,EAC3D,QAAO,CAAC,GAAG,QAAQ;CAKrB,MAAM,QAAQ,IAAI,IAAI,QAAQ,KAAK,MAAM,EAAE,KAAK,CAAC;CACjD,MAAM,0BAAU,IAAI,KAAqB;AACzC,MAAK,MAAM,KAAK,OAAO;AACrB,MAAI,EAAE,eAAe,EAAE,WAAY;AACnC,MAAI,CAAC,MAAM,IAAI,EAAE,WAAW,IAAI,CAAC,MAAM,IAAI,EAAE,WAAW,CAAE;AAC1D,UAAQ,IAAI,EAAE,aAAa,QAAQ,IAAI,EAAE,WAAW,IAAI,KAAK,EAAE;;AAEjE,KAAI,QAAQ,SAAS,EAAG,QAAO,CAAC,GAAG,QAAQ;CAK3C,MAAM,aAAa,KAAK,IAAI,GAAG,QAAQ,QAAQ,CAAC;AAEhD,QAAO,QACJ,KAAK,MAAM;EACV,MAAM,QAAQ,QAAQ,IAAI,EAAE,KAAK,IAAI;AACrC,MAAI,UAAU,EAAG,QAAO,EAAE,GAAG,GAAG;EAChC,MAAM,SAAS,IAAI,UAAU,QAAQ;AACrC,SAAO;GAAE,GAAG;GAAG,OAAO,EAAE,QAAQ;GAAQ;GACxC,CACD,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM"}