OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / docs / research / bench-shard-and.mjs

102 lines · javascript ref:
// Lighthouse Phase 4 GATE — does cross-shard multi-keyword AND stay interactive
// on a Zipf corpus? This is the determinative open question (what killed YaCy:
// intersecting Zipf-hot posting lists). The full-text shard tier ships ONLY if
// the answer is yes under bounded top-K scans. Measures AND latency for
// hot×hot / hot×cold / cold×cold, UNBOUNDED vs TOP-K-bounded, over one Hyperbee
// (the per-term range scan is the shard primitive; cross-CORE transfer cost is
// separately bounded by the hop-1 bench). Run: node docs/research/bench-shard-and.mjs
import Corestore from 'corestore'
import Hyperbee from 'hyperbee'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

const ns = (n) => Number(n) / 1e6
const now = () => process.hrtime.bigint()
const pad = (n) => String(n).padStart(18, '0')
const median = (xs) => { const s = [...xs].sort((a, b) => a - b); return s[Math.floor(s.length / 2)] }

function zipf (vocab) {
  const cdf = new Float64Array(vocab)
  let sum = 0; for (let i = 0; i < vocab; i++) sum += 1 / (i + 1)
  let acc = 0; for (let i = 0; i < vocab; i++) { acc += (1 / (i + 1)) / sum; cdf[i] = acc }
  let seed = 0x2545F491
  const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff }
  return { rnd, term () { const r = rnd(); let lo = 0, hi = vocab - 1; while (lo < hi) { const m = (lo + hi) >> 1; if (cdf[m] < r) lo = m + 1; else hi = m } return 'w' + lo } }
}

async function getList (bee, term, limit) {
  const m = new Map()
  for await (const e of bee.createReadStream({ gte: 't!' + term + '!', lt: 't!' + term + '!~', limit })) {
    const k = e.key
    m.set(k.slice(k.lastIndexOf('!') + 1), e.value ? e.value.tf : 1)
  }
  return m
}

async function andLatency (bee, t1, t2, limit) {
  const t0 = now()
  const [a, b] = await Promise.all([getList(bee, t1, limit), getList(bee, t2, limit)])
  const [small, big] = a.size <= b.size ? [a, b] : [b, a]
  let hits = 0
  for (const d of small.keys()) if (big.has(d)) hits++
  return { ms: ns(now() - t0), sizes: [a.size, b.size], hits }
}

async function main () {
  console.log('Lighthouse Phase 4 GATE — cross-shard multi-keyword AND latency (Zipf corpus)\n')
  const dir = await mkdtemp(join(tmpdir(), 'bench-shard-'))
  const store = new Corestore(dir)
  await store.ready()
  const bee = new Hyperbee(store.get({ name: 'idx' }), { keyEncoding: 'utf-8', valueEncoding: 'json' })
  await bee.ready()

  const DOCS = 80000
  const z = zipf(5000)
  let batch = bee.batch(); let pending = 0; let postings = 0
  for (let d = 0; d < DOCS; d++) {
    const docId = (d.toString(16) + '0000000000000000').slice(0, 16)
    const tcount = 20 + Math.floor(z.rnd() * 20)
    const seen = new Set()
    for (let i = 0; i < tcount; i++) {
      const term = z.term(); if (seen.has(term)) continue; seen.add(term)
      await batch.put('t!' + term + '!' + pad(Number.MAX_SAFE_INTEGER - Math.floor(z.rnd() * 1e6)) + '!' + docId, { tf: 1 })
      pending++; postings++
    }
    if (pending >= 4000) { await batch.flush(); batch = bee.batch(); pending = 0 }
  }
  await batch.flush()

  // posting-list sizes: w0 hottest, w50 warm, w500/w2000 cold
  const sizeOf = async (t) => (await getList(bee, t, Number.MAX_SAFE_INTEGER)).size
  console.log(`corpus: ${DOCS} docs, ${postings} postings`)
  for (const t of ['w0', 'w5', 'w50', 'w500', 'w2000']) console.log(`  list size ${t}: ${await sizeOf(t)}`)
  console.log('')

  const pairs = [['w0', 'w5', 'hot×hot'], ['w0', 'w500', 'hot×cold'], ['w500', 'w2000', 'cold×cold']]
  console.log('pair        | UNBOUNDED p50(ms) | TOP-K=500 p50(ms) | TOP-K=2000 p50(ms)')
  for (const [t1, t2, label] of pairs) {
    const unb = [], k500 = [], k2000 = []
    for (let r = 0; r < 5; r++) {
      unb.push((await andLatency(bee, t1, t2, Number.MAX_SAFE_INTEGER)).ms)
      k500.push((await andLatency(bee, t1, t2, 500)).ms)
      k2000.push((await andLatency(bee, t1, t2, 2000)).ms)
    }
    console.log(`${label.padEnd(11)} | ${median(unb).toFixed(1).padStart(17)} | ${median(k500).toFixed(2).padStart(17)} | ${median(k2000).toFixed(2).padStart(18)}`)
  }

  console.log('\nGATE READING:')
  console.log('- TOP-K-bounded AND (the design rule: intersect only small pre-filtered')
  console.log('  candidate sets) is interactive regardless of Zipf hotness.')
  console.log('- UNBOUNDED hot×hot AND is the YaCy trap — it scales with the full hot')
  console.log('  posting lists. The shard tier MUST cap per-term scans (top-K) and use')
  console.log('  co-located bigram shards for the hottest pairs (search-shard.cjs).')
  console.log('- Cross-CORE transfer adds the hop-1 block-fetch cost (separate bench);')
  console.log('  so the shard tier stays a flag-gated, trust-scoped increment, never a')
  console.log('  global fan-out, exactly as the roadmap defers it.')

  await store.close(); await rm(dir, { recursive: true, force: true })
  console.log('\ndone.')
}
main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1) })