OverviewFilesCommitsIssuesDocsReleasesPeers

hypercore-kb / opencog-kb / basal-atomspace / hyper-atomspace.js

361 lines · javascript ref:
/**
 * HyperAtomSpace - Basal AtomSpace with Hypercore persistence
 *
 * Adds to basal-atomspace:
 * - Persistent storage via Hypercore (append-only log)
 * - Indexes via Hyperbee (B-tree on Hypercore)
 * - P2P replication via Hyperswarm
 * - Offline-first, sync when connected
 */

import Hypercore from 'hypercore'
import Hyperbee from 'hyperbee'
import Hyperswarm from 'hyperswarm'
import b4a from 'b4a'
import c from 'compact-encoding'

// ============================================================================
// ENCODING - How atoms are serialized (~40 lines)
// ============================================================================

const atomEncoding = {
  encode(atom) {
    return c.encode(c.any, {
      t: atom.type,
      n: atom.name || null,
      o: atom.outgoing?.map(a => a.hash) || null,
      v: atom.values ? Object.fromEntries(atom.values) : null
    })
  },

  decode(buf) {
    return c.decode(c.any, buf)
  }
}

// ============================================================================
// HYPER ATOMSPACE (~150 lines)
// ============================================================================

class HyperAtomSpace {
  constructor(storage, key = null) {
    this.storage = storage
    this.key = key

    // In-memory cache (subset of full atomspace)
    this.atoms = new Map()      // hash -> Atom
    this.byType = new Map()     // type -> Set<hash>
    this.byName = new Map()     // "type:name" -> hash

    this.ready = this._init()
  }

  async _init() {
    // Main data store (append-only log of atoms)
    this.core = new Hypercore(this.storage, this.key, {
      valueEncoding: atomEncoding
    })
    await this.core.ready()

    // B-tree indexes
    this.db = new Hyperbee(this.core, {
      keyEncoding: 'utf-8',
      valueEncoding: 'json'
    })
    await this.db.ready()

    // Load existing atoms into memory
    await this._loadAll()

    return this
  }

  async _loadAll() {
    for await (const { key, value } of this.db.createReadStream({
      gte: 'atom:',
      lt: 'atom:\xff'
    })) {
      const hash = key.slice(5)  // Remove 'atom:' prefix
      this._cacheAtom(hash, value)
    }
  }

  _cacheAtom(hash, data) {
    // Reconstruct atom from stored data
    const atom = data.n !== null
      ? { type: data.t, name: data.n, hash, values: new Map(Object.entries(data.v || {})) }
      : { type: data.t, outgoing: data.o, hash, values: new Map(Object.entries(data.v || {})) }

    this.atoms.set(hash, atom)

    // Index by type
    if (!this.byType.has(data.t)) this.byType.set(data.t, new Set())
    this.byType.get(data.t).add(hash)

    // Index nodes by name
    if (data.n !== null) {
      this.byName.set(`${data.t}:${data.n}`, hash)
    }

    return atom
  }

  // Compute hash for an atom (before it exists)
  _hash(type, name, outgoing) {
    const crypto = await import('crypto')
    if (name !== null) {
      return crypto.createHash('sha256')
        .update(`N:${type}:${name}`)
        .digest('hex').slice(0, 16)
    } else {
      const outHashes = outgoing.join(',')
      return crypto.createHash('sha256')
        .update(`L:${type}:[${outHashes}]`)
        .digest('hex').slice(0, 16)
    }
  }

  /**
   * Add a node
   */
  async addNode(type, name) {
    await this.ready

    const key = `${type}:${name}`

    // Check cache first
    if (this.byName.has(key)) {
      return this.atoms.get(this.byName.get(key))
    }

    // Check persistent storage
    const existing = await this.db.get(`name:${key}`)
    if (existing) {
      return this._cacheAtom(existing.value.hash, existing.value)
    }

    // Create new
    const crypto = await import('crypto')
    const hash = crypto.createHash('sha256')
      .update(`N:${type}:${name}`)
      .digest('hex').slice(0, 16)

    const data = { t: type, n: name, o: null, v: {} }

    // Persist
    const batch = this.db.batch()
    await batch.put(`atom:${hash}`, data)
    await batch.put(`name:${key}`, { hash, ...data })
    await batch.put(`type:${type}:${hash}`, hash)
    await batch.flush()

    return this._cacheAtom(hash, data)
  }

  /**
   * Add a link
   */
  async addLink(type, outgoing) {
    await this.ready

    // Ensure all outgoing atoms exist and get their hashes
    const outHashes = outgoing.map(a => a.hash)

    const crypto = await import('crypto')
    const hash = crypto.createHash('sha256')
      .update(`L:${type}:[${outHashes.join(',')}]`)
      .digest('hex').slice(0, 16)

    // Check if exists
    if (this.atoms.has(hash)) {
      return this.atoms.get(hash)
    }

    const existing = await this.db.get(`atom:${hash}`)
    if (existing) {
      return this._cacheAtom(hash, existing.value)
    }

    // Create new
    const data = { t: type, n: null, o: outHashes, v: {} }

    // Persist
    const batch = this.db.batch()
    await batch.put(`atom:${hash}`, data)
    await batch.put(`type:${type}:${hash}`, hash)

    // Index by each outgoing atom (incoming set)
    for (const outHash of outHashes) {
      await batch.put(`incoming:${outHash}:${hash}`, hash)
    }

    await batch.flush()

    const atom = this._cacheAtom(hash, data)

    // Resolve outgoing references
    atom.outgoing = outHashes.map(h => this.atoms.get(h))

    return atom
  }

  /**
   * Set a value on an atom
   */
  async setValue(atom, key, value) {
    await this.ready

    atom.values.set(key, value)

    // Update in storage
    const existing = await this.db.get(`atom:${atom.hash}`)
    if (existing) {
      existing.value.v = existing.value.v || {}
      existing.value.v[key] = value
      await this.db.put(`atom:${atom.hash}`, existing.value)
    }
  }

  /**
   * Query by type
   */
  async getByType(type) {
    await this.ready

    const results = []
    for await (const { value } of this.db.createReadStream({
      gte: `type:${type}:`,
      lt: `type:${type}:\xff`
    })) {
      const atom = this.atoms.get(value)
      if (atom) results.push(atom)
    }
    return results
  }

  /**
   * Get incoming links
   */
  async getIncoming(atom, linkType = null) {
    await this.ready

    const results = []
    for await (const { value } of this.db.createReadStream({
      gte: `incoming:${atom.hash}:`,
      lt: `incoming:${atom.hash}:\xff`
    })) {
      const link = this.atoms.get(value)
      if (link && (!linkType || link.type === linkType)) {
        results.push(link)
      }
    }
    return results
  }

  /**
   * Simple pattern query
   */
  async query(pattern) {
    await this.ready

    const candidates = await this.getByType(pattern.type)

    return candidates.filter(atom => {
      if (!atom.outgoing) return false
      if (pattern.outgoing?.length !== atom.outgoing.length) return false

      for (let i = 0; i < (pattern.outgoing?.length || 0); i++) {
        const p = pattern.outgoing[i]
        const a = atom.outgoing[i]

        if (p === null) continue  // wildcard
        if (p.hash && p.hash !== a.hash) return false
        if (p.type && p.type !== a.type) return false
        if (p.name && a.name !== p.name) return false
      }
      return true
    })
  }

  // ========== P2P SYNC ==========

  /**
   * Join swarm and replicate
   */
  async joinSwarm(topic) {
    await this.ready

    this.swarm = new Hyperswarm()

    const topicBuffer = b4a.isBuffer(topic)
      ? topic
      : b4a.from(topic, 'hex')

    this.swarm.join(topicBuffer)

    this.swarm.on('connection', (conn) => {
      console.log('Peer connected')
      this.core.replicate(conn)
    })

    return this.swarm
  }

  /**
   * Get public key for sharing
   */
  get publicKey() {
    return this.core.key?.toString('hex')
  }

  // ========== STATS ==========

  get size() {
    return this.atoms.size
  }
}

// ============================================================================
// DEMO
// ============================================================================

async function demo() {
  const as = new HyperAtomSpace('./test-atomspace')
  await as.ready

  console.log('HyperAtomSpace initialized')
  console.log('Public key:', as.publicKey)

  // Create knowledge
  const dog = await as.addNode('ConceptNode', 'dog')
  const cat = await as.addNode('ConceptNode', 'cat')
  const animal = await as.addNode('ConceptNode', 'animal')

  const dogIsAnimal = await as.addLink('InheritanceLink', [dog, animal])
  const catIsAnimal = await as.addLink('InheritanceLink', [cat, animal])

  // Set truth value
  await as.setValue(dogIsAnimal, 'truth', [1.0, 0.99])

  // Query
  const animals = await as.query({
    type: 'InheritanceLink',
    outgoing: [null, animal]
  })

  console.log('\nThings that are animals:')
  for (const link of animals) {
    console.log('  ', link.outgoing[0].name)
  }

  console.log('\nAtomSpace size:', as.size)
}

export { HyperAtomSpace }

// Run demo if executed directly
const isMain = import.meta.url === `file://${process.argv[1]}`
if (isMain) {
  demo().catch(console.error)
}