OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / lib / local-graph.js

769 lines · javascript ref:
/**
 * local-graph.js — MI-based room clustering from configurable feature overlap
 *
 * Computes Mutual Information between rooms using an inverted feature index,
 * builds a maximum spanning tree, and extracts clusters by cutting weak edges.
 * Also collects one-hop suggestions from DM-shared rooms.
 *
 * Features are pluggable via algo configs:
 *   - "members"  — room membership overlap (original behavior)
 *   - "topics"   — MetadataBase topics array overlap
 *   - "category" — MetadataBase category string overlap
 *   - "creator"  — room admin/creator identity
 *   - "activity" — message recency (today/week/month/inactive)
 *   - "size"     — member count bucket (tiny/small/medium/large)
 *
 * Modes:
 *   Single feature:  algo: { features: [{type, weight}], threshold, minSize }
 *   Combined:        algo: { features: [{type, weight}, ...], threshold, minSize }
 *   Frustum:         algo: { frustum: [{type, threshold}, ...], minSize }
 *
 * Frustum mode stacks dimensions: cluster by dim[0], then within each cluster
 * sub-cluster by dim[1], recurse. Produces a tree, not a flat list.
 *
 * Usage:
 *   const graph = new LocalGraph(backend, roomMeta)
 *   await graph.compute()                                → default (members-only)
 *   await graph.compute({ algo: { features: [...], threshold, minSize } })
 *   await graph.compute({ algo: { frustum: [{type: 'activity', threshold: 0.1}, {type: 'category', threshold: 0.1}], minSize: 2 } })
 *   await graph.clusters()                               → flat: [{ id, rooms, label, topics }]
 *                                                          tree:  [{ id, dimension, label, rooms, children }]
 *   await graph.edges()                                  → [{ from, to, mi, shared }]
 */

import { parseHsMessage } from './room-meta.js'

const CACHE_TTL = 60_000

// Normalize tag/category strings: lowercase, strip non-alphanumeric edges,
// collapse internal separators. "_testing" and "Testing" both become "testing".
function _normalizeTag (raw) {
  if (!raw || typeof raw !== 'string') return ''
  return raw
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, ' ')  // non-alphanum → spaces
    .trim()
    .replace(/\s+/g, '-')         // internal spaces → single dash
}
const DEFAULT_ALGO = {
  name: 'default',
  features: [{ type: 'members', weight: 1.0 }],
  threshold: 0.5,
  minSize: 2
}

export class LocalGraph {
  constructor (backend, roomMeta) {
    this.backend = backend
    this._roomMeta = roomMeta || null
    this._caches = new Map() // keyed by algo name → { result, time }
    this._computing = false
  }

  async compute (params = {}) {
    // Back-compat: compute(true) means force refresh with default algo
    if (params === true) params = { force: true }
    if (params === false) params = {}

    const algo = params.algo || DEFAULT_ALGO
    const force = params.force ?? false
    const cacheKey = algo.name

    if (!force) {
      const cached = this._caches.get(cacheKey)
      if (cached && (Date.now() - cached.time) < CACHE_TTL) {
        return cached.result
      }
    }

    if (this._computing) {
      const cached = this._caches.get(cacheKey)
      return cached?.result || { clusters: [], edges: [], suggestions: [], algo: algo.name }
    }

    this._computing = true
    try {
      const result = await this._run(algo)
      this._caches.set(cacheKey, { result, time: Date.now() })
      return result
    } finally {
      this._computing = false
    }
  }

  async clusters (params = {}) {
    const r = await this.compute(params)
    return r.clusters
  }

  async edges (params = {}) {
    const r = await this.compute(params)
    return r.edges
  }

  async _run (algo) {
    const roomIds = await this._getRoomIds()
    const dmSharedRooms = await this._fetchDmSharedRooms()

    // Frustum mode: ordered dimension stack with recursive sub-clustering
    if (Array.isArray(algo.frustum) && algo.frustum.length > 0) {
      return this._runFrustum(roomIds, algo, dmSharedRooms)
    }

    // Multi-feature: hierarchical clustering (primary → sub-cluster by secondary)
    if (algo.features && algo.features.length > 1) {
      return this._runHierarchical(roomIds, algo, dmSharedRooms)
    }

    // Single feature: standard MI clustering
    const featureConfigs = algo.features || DEFAULT_ALGO.features
    const features = await this._extractFeatures(roomIds, featureConfigs)

    let featureCount = 0
    let emptyCount = 0
    for (const [, fmap] of Object.entries(features)) {
      if (fmap.size > 0) featureCount++
      else emptyCount++
    }
    // Histogram: how many rooms in each bucket?
    const histogram = {}
    for (const [, fmap] of Object.entries(features)) {
      for (const [fid] of fmap) {
        histogram[fid] = (histogram[fid] || 0) + 1
      }
    }
    console.log(`[graph] algo=${algo.name} rooms=${roomIds.length} withFeatures=${featureCount} empty=${emptyCount} buckets=${JSON.stringify(histogram)}`)

    const invertedIndex = this._buildInvertedIndex(features)
    const { pairs, totalPairs } = this._computePairCounts(invertedIndex)
    const miEdges = this._computeMI(pairs, features, totalPairs)

    console.log(`[graph] invertedKeys=${invertedIndex.size} pairs=${pairs.size} totalPairs=${totalPairs} edges=${miEdges.length}`)

    const clusters = this._extractClusters(miEdges, Object.keys(features), algo.threshold, algo.minSize)
    await this._labelClusters(clusters, algo, features)

    // Sort clusters by metric (highest first) for activity/size algos
    const featureType = algo?.features?.[0]?.type
    if (featureType === 'activity' || featureType === 'size') {
      clusters.sort((a, b) => {
        const numA = parseInt((a.label || '0').match(/\d+/)?.[0] || '0')
        const numB = parseInt((b.label || '0').match(/\d+/)?.[0] || '0')
        return numB - numA // descending: most active/biggest first
      })
      // Re-index after sort
      for (let i = 0; i < clusters.length; i++) clusters[i].id = i
    }

    const suggestions = this._buildSuggestions(features, dmSharedRooms)

    return { clusters, edges: miEdges, suggestions, algo: algo.name }
  }

  async _runHierarchical (roomIds, algo, dmSharedRooms) {
    // Legacy combined-feature mode: convert to frustum and delegate
    const dims = algo.features.map(f => ({ type: f.type, threshold: algo.threshold }))
    const frustumAlgo = { name: algo.name, frustum: dims, minSize: algo.minSize }
    return this._runFrustum(roomIds, frustumAlgo, dmSharedRooms)
  }

  // --- Frustum: recursive sub-clustering across dimension stack ---

  async _runFrustum (roomIds, algo, dmSharedRooms) {
    const dims = algo.frustum
    const minSize = algo.minSize || 2

    console.log(`[graph] frustum: ${dims.map(d => d.type).join(' → ')}, ${roomIds.length} rooms, minSize=${minSize}`)

    const tree = await this._frustumRecurse(roomIds, dims, minSize, '')
    await this._labelFrustumTree(tree)

    // Build suggestions from first dimension
    const primaryFeatures = await this._extractFeatures(roomIds, [{ type: dims[0].type, weight: 1.0 }])
    const suggestions = this._buildSuggestions(primaryFeatures, dmSharedRooms)

    return { clusters: tree, edges: [], suggestions, algo: algo.name, tree: true }
  }

  async _frustumRecurse (roomIds, dims, minSize, idPrefix) {
    if (dims.length === 0 || roomIds.length < minSize) {
      return []
    }

    const dim = dims[0]
    const remaining = dims.slice(1)
    const threshold = dim.threshold ?? 0.1

    // Cluster by this dimension
    const features = await this._extractFeatures(roomIds, [{ type: dim.type, weight: 1.0 }])
    const index = this._buildInvertedIndex(features)
    const { pairs, totalPairs } = this._computePairCounts(index)
    const edges = this._computeMI(pairs, features, totalPairs)
    const clusters = this._extractClusters(edges, roomIds, threshold, minSize)

    // Track which rooms got clustered
    const clustered = new Set()
    for (const c of clusters) {
      for (const r of c.rooms) clustered.add(r)
    }
    const unclustered = roomIds.filter(r => !clustered.has(r))

    const result = []

    for (let i = 0; i < clusters.length; i++) {
      const c = clusters[i]
      const nodeId = idPrefix ? `${idPrefix}.${i}` : String(i)

      // Label from the dominant shared feature tag at this dimension
      const label = this._dominantTag(c.rooms, features)

      // Recurse into remaining dimensions
      const children = await this._frustumRecurse(c.rooms, remaining, minSize, nodeId)

      result.push({
        id: nodeId,
        dimension: dim.type,
        label,
        rooms: c.rooms,
        children
      })
    }

    // Unclustered rooms as catch-all
    if (unclustered.length > 0) {
      const otherId = idPrefix ? `${idPrefix}.other` : 'other'
      const otherChildren = await this._frustumRecurse(unclustered, remaining, minSize, otherId)
      result.push({
        id: otherId,
        dimension: dim.type,
        label: 'other',
        rooms: unclustered,
        children: otherChildren
      })
    }

    console.log(`[graph] frustum: dim=${dim.type} rooms=${roomIds.length} → ${result.length} nodes`)
    return result
  }

  // Find the most common feature tag across rooms in a cluster, strip prefix
  _dominantTag (rooms, features) {
    const counts = new Map()
    for (const roomId of rooms) {
      const fmap = features[roomId]
      if (!fmap) continue
      for (const [tag] of fmap) {
        counts.set(tag, (counts.get(tag) || 0) + 1)
      }
    }

    if (counts.size === 0) return ''

    let best = ''
    let bestCount = 0
    for (const [tag, count] of counts) {
      if (count > bestCount) { best = tag; bestCount = count }
    }

    // Strip type prefix (cat:, tag:, act:, sz:, creator:)
    const colon = best.indexOf(':')
    return colon >= 0 ? best.slice(colon + 1) : best
  }

  // Post-pass: enhance labels on leaf nodes using metadata (category/topic/title fallback)
  async _labelFrustumTree (tree) {
    if (!tree || tree.length === 0) return

    for (const node of tree) {
      // If this node has no children (leaf) and label is empty or a raw id,
      // try to give it a meaningful name from metadata
      if (node.children && node.children.length > 0) {
        await this._labelFrustumTree(node.children)
      } else if (!node.label || node.label === 'other') {
        // Leaf node — try metadata labeling as fallback
        const pseudo = [{ id: 0, rooms: node.rooms, label: node.label || '', topics: [] }]
        await this._labelClusters(pseudo)
        if (pseudo[0].label && pseudo[0].label !== node.label) {
          node.label = pseudo[0].label
        }
        node.topics = pseudo[0].topics || []
      }
    }
  }

  async _getRoomIds () {
    const result = await this.backend.api.core.getRecentRooms()
    const rooms = Array.isArray(result) ? result : (result?.rooms || result?.data || [])
    return rooms.map(r => r.roomId || r.id || r.key).filter(Boolean)
  }

  async _extractFeatures (roomIds, featureConfigs) {
    const combined = {}
    for (const roomId of roomIds) combined[roomId] = new Map()

    for (const fc of featureConfigs) {
      const raw = await this._extractSingle(roomIds, fc.type)
      const w = fc.weight ?? 1.0
      for (const [roomId, featureSet] of Object.entries(raw)) {
        for (const fid of featureSet) {
          combined[roomId].set(fid, (combined[roomId].get(fid) || 0) + w)
        }
      }
    }

    return combined
  }

  async _extractSingle (roomIds, type) {
    const result = {}

    if (type === 'members') {
      // Exclude local user so rooms aren't universally linked by self-membership
      let selfId = null
      try {
        const identity = await this.backend.api.core.getIdentity()
        selfId = identity?.memberId || null
      } catch {}

      await Promise.all(roomIds.map(async (roomId) => {
        try {
          const mResult = await this.backend.api.core.getMembers(roomId)
          const members = Array.isArray(mResult) ? mResult : (mResult?.members || mResult?.data || [])
          const ids = members.map(m => m.memberId || m.id).filter(Boolean)
          result[roomId] = new Set(selfId ? ids.filter(id => id !== selfId) : ids)
        } catch {
          result[roomId] = new Set()
        }
      }))
      return result
    }

    if (type === 'topics') {
      for (const roomId of roomIds) {
        const tags = new Set()
        if (this._roomMeta) {
          try {
            const meta = await this._roomMeta.get(roomId)
            if (Array.isArray(meta?.topics)) {
              for (const t of meta.topics) {
                const norm = _normalizeTag(t)
                if (norm) tags.add('tag:' + norm)
              }
            }
          } catch {}
        }
        result[roomId] = tags
      }
      return result
    }

    if (type === 'category') {
      for (const roomId of roomIds) {
        const cats = new Set()
        if (this._roomMeta) {
          try {
            const meta = await this._roomMeta.get(roomId)
            if (meta?.category) {
              const norm = _normalizeTag(meta.category)
              if (norm) cats.add('cat:' + norm)
            }
          } catch {}
        }
        result[roomId] = cats
      }
      return result
    }

    // Creator — rooms by the same admin cluster together
    if (type === 'creator') {
      await Promise.all(roomIds.map(async (roomId) => {
        try {
          const mResult = await this.backend.api.core.getMembers(roomId)
          const members = Array.isArray(mResult) ? mResult : (mResult?.members || mResult?.data || [])
          // Check multiple admin indicators: status.isAdmin, numeric role=1, string role='admin'
          const admin = members.find(m => m.status?.isAdmin || m.role === 1 || m.role === 'admin')
          if (admin) {
            result[roomId] = new Set(['creator:' + (admin.memberId || admin.id)])
          } else {
            // Fallback: use first member as creator (room owner is usually first)
            const first = members[0]
            result[roomId] = first ? new Set(['creator:' + (first.memberId || first.id)]) : new Set()
          }
        } catch (err) {
          console.error(`[graph] creator extract failed for ${roomId}: ${err.message}`)
          result[roomId] = new Set()
        }
      }))
      return result
    }

    // Activity — quantile buckets based on message count (6 groups)
    // Fetches recent messages per room, counts them, then divides into
    // 6 equal-sized groups so buckets always reflect the actual data.
    if (type === 'activity') {
      const counts = {}
      await Promise.all(roomIds.map(async (roomId) => {
        try {
          const msgs = await this.backend.api.core.getChatMessages(roomId, { limit: 100, reverse: true })
          const list = Array.isArray(msgs) ? msgs : (msgs?.messages || msgs?.data || [])
          counts[roomId] = list.length
        } catch {
          counts[roomId] = 0
        }
      }))

      // Sort descending (most active first) and bucket by unique count values
      // so rooms with the same message count always land in the same group.
      const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1])
      const uniqueCounts = [...new Set(sorted.map(([, c]) => c))].sort((a, b) => b - a)
      const N_BUCKETS = Math.min(6, uniqueCounts.length || 1)
      const perBucket = Math.max(1, Math.ceil(uniqueCounts.length / N_BUCKETS))

      // Map each unique count value to a bucket index
      const countToBucket = new Map()
      const bucketRanges = new Map() // bucket → {hi, lo}
      for (let i = 0; i < uniqueCounts.length; i++) {
        const b = Math.min(N_BUCKETS - 1, Math.floor(i / perBucket))
        countToBucket.set(uniqueCounts[i], b)
        if (!bucketRanges.has(b)) {
          bucketRanges.set(b, { hi: uniqueCounts[i], lo: uniqueCounts[i] })
        } else {
          const r = bucketRanges.get(b)
          r.hi = Math.max(r.hi, uniqueCounts[i])
          r.lo = Math.min(r.lo, uniqueCounts[i])
        }
      }

      for (const [roomId, count] of sorted) {
        const b = countToBucket.get(count) ?? 0
        const range = bucketRanges.get(b) || { hi: count, lo: count }
        const tag = count === 0
          ? 'act:no messages'
          : range.lo === range.hi
            ? `act:${range.lo} messages`
            : `act:${range.lo}-${range.hi} messages`
        result[roomId] = new Set([tag])
      }

      console.log(`[graph] activity buckets: ${sorted.length} rooms, ${uniqueCounts.length} unique counts, ${N_BUCKETS} buckets, ranges=${JSON.stringify([...bucketRanges.values()])}`)
      return result
    }

    // Size — quantile buckets based on member count (6 groups)
    // Divides rooms into 6 equal-sized groups by member count.
    if (type === 'size') {
      const counts = {}
      await Promise.all(roomIds.map(async (roomId) => {
        try {
          const mResult = await this.backend.api.core.getMembers(roomId)
          const members = Array.isArray(mResult) ? mResult : (mResult?.members || mResult?.data || [])
          counts[roomId] = members.length
        } catch {
          counts[roomId] = 0
        }
      }))

      // Sort descending (most members first) and bucket by unique values
      const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1])
      const uniqueCounts = [...new Set(sorted.map(([, c]) => c))].sort((a, b) => b - a)
      const N_BUCKETS = Math.min(6, uniqueCounts.length || 1)
      const perBucket = Math.max(1, Math.ceil(uniqueCounts.length / N_BUCKETS))

      const countToBucket = new Map()
      const bucketRanges = new Map()
      for (let i = 0; i < uniqueCounts.length; i++) {
        const b = Math.min(N_BUCKETS - 1, Math.floor(i / perBucket))
        countToBucket.set(uniqueCounts[i], b)
        if (!bucketRanges.has(b)) {
          bucketRanges.set(b, { hi: uniqueCounts[i], lo: uniqueCounts[i] })
        } else {
          const r = bucketRanges.get(b)
          r.hi = Math.max(r.hi, uniqueCounts[i])
          r.lo = Math.min(r.lo, uniqueCounts[i])
        }
      }

      for (const [roomId, count] of sorted) {
        const b = countToBucket.get(count) ?? 0
        const range = bucketRanges.get(b) || { hi: count, lo: count }
        const tag = count === 0
          ? 'sz:no members'
          : range.lo === range.hi
            ? `sz:${range.lo} members`
            : `sz:${range.lo}-${range.hi} members`
        result[roomId] = new Set([tag])
      }

      console.log(`[graph] size buckets: ${sorted.length} rooms, ${uniqueCounts.length} unique counts, ${N_BUCKETS} buckets, ranges=${JSON.stringify([...bucketRanges.values()])}`)
      return result
    }

    // Unknown type — empty features
    for (const roomId of roomIds) result[roomId] = new Set()
    return result
  }

  async _fetchDmSharedRooms () {
    const shared = []
    try {
      const dms = await this.backend.api.core.getDmRequestsByStatus(1)
      const dmList = Array.isArray(dms) ? dms : []

      await Promise.all(dmList.map(async (dm) => {
        const dmRoomId = dm.roomId || dm.id
        if (!dmRoomId) return
        try {
          const msgs = await this.backend.api.core.getChatMessages(dmRoomId, { limit: 200, reverse: true })
          const messages = Array.isArray(msgs) ? msgs : (msgs?.messages || msgs?.data || [])
          for (const msg of messages) {
            if (msg.deleted) continue
            const text = msg.chat?.text || msg.text || msg.message?.text || ''
            const parsed = parseHsMessage(text)
            if (parsed?._hs === 'room-share' && parsed.roomId) {
              shared.push({ ...parsed, sharedBy: msg.memberId || null })
            }
          }
        } catch {}
      }))
    } catch {}
    return shared
  }

  _buildInvertedIndex (features) {
    const index = new Map()
    for (const [roomId, featureMap] of Object.entries(features)) {
      for (const [fid, weight] of featureMap) {
        if (!index.has(fid)) index.set(fid, new Map())
        index.get(fid).set(roomId, weight)
      }
    }
    return index
  }

  _computePairCounts (invertedIndex) {
    const pairs = new Map()
    let totalPairs = 0

    for (const roomWeights of invertedIndex.values()) {
      const entries = [...roomWeights.entries()]
      for (let i = 0; i < entries.length; i++) {
        for (let j = i + 1; j < entries.length; j++) {
          const [roomA, wA] = entries[i]
          const [roomB, wB] = entries[j]
          const key = roomA < roomB ? roomA + ':' + roomB : roomB + ':' + roomA
          const increment = Math.min(wA, wB)
          pairs.set(key, (pairs.get(key) || 0) + increment)
          totalPairs += increment
        }
      }
    }

    return { pairs, totalPairs }
  }

  _computeMI (pairs, features, totalPairs) {
    if (totalPairs === 0) return []

    // Compute total weighted feature count per room
    const roomSizes = {}
    for (const [roomId, featureMap] of Object.entries(features)) {
      let total = 0
      for (const w of featureMap.values()) total += w
      roomSizes[roomId] = total
    }

    // Total N = sum of all room sizes (for probability space)
    let N = 0
    for (const s of Object.values(roomSizes)) N += s
    if (N === 0) return []

    const edges = []
    for (const [key, sharedCount] of pairs) {
      const sep = key.indexOf(':')
      const roomA = key.slice(0, sep)
      const roomB = key.slice(sep + 1)

      const sizeA = roomSizes[roomA] || 0
      const sizeB = roomSizes[roomB] || 0
      if (sizeA === 0 || sizeB === 0) continue

      const mi = Math.log2((sharedCount * N) / (sizeA * sizeB))
      if (mi > 0) edges.push({ from: roomA, to: roomB, mi: Math.round(mi * 100) / 100, shared: Math.round(sharedCount * 100) / 100 })
    }

    return edges.sort((a, b) => b.mi - a.mi)
  }

  _extractClusters (miEdges, allRooms, threshold, minSize) {
    if (allRooms.length === 0) return []

    const t = threshold ?? 0.5
    const ms = minSize ?? 2

    // Union-Find for Kruskal's max spanning tree with threshold cut
    const parent = {}
    const rank = {}
    for (const r of allRooms) {
      parent[r] = r
      rank[r] = 0
    }

    const find = (x) => {
      if (parent[x] !== x) parent[x] = find(parent[x])
      return parent[x]
    }

    const union = (a, b) => {
      const ra = find(a)
      const rb = find(b)
      if (ra === rb) return
      if (rank[ra] < rank[rb]) { parent[ra] = rb }
      else if (rank[ra] > rank[rb]) { parent[rb] = ra }
      else { parent[rb] = ra; rank[ra]++ }
    }

    // Edges already sorted desc by MI — only union if above threshold
    for (const edge of miEdges) {
      if (edge.mi < t) break
      if (find(edge.from) !== find(edge.to)) {
        union(edge.from, edge.to)
      }
    }

    // Group rooms by root
    const groups = {}
    for (const r of allRooms) {
      const root = find(r)
      if (!groups[root]) groups[root] = []
      groups[root].push(r)
    }

    // Keep merged clusters that meet minSize, plus orphans as solo groups
    const merged = []
    const orphans = []
    for (const rooms of Object.values(groups)) {
      if (rooms.length >= ms) {
        merged.push(rooms)
      } else {
        // Each room in an under-sized group becomes its own solo cluster
        for (const r of rooms) orphans.push([r])
      }
    }

    const all = [...merged, ...orphans]
    return all.map((rooms, id) => ({ id, rooms, label: '', topics: [] }))
  }

  async _labelClusters (clusters, algo = null, features = null) {
    if (clusters.length === 0) return

    // For quantile-based algos (activity, size/people), use the feature tag directly
    // as the label since tags contain the actual metric (e.g. "5-15msgs", "3-5ppl")
    const featureType = algo?.features?.[0]?.type
    if (features && (featureType === 'activity' || featureType === 'size')) {
      for (const cluster of clusters) {
        const label = this._dominantTag(cluster.rooms, features)
        cluster.label = label || `${featureType}`
        cluster.topics = []
      }
      return
    }

    // Fetch room titles for emergent naming fallback
    const titleMap = new Map()
    try {
      const result = await this.backend.api.core.getRecentRooms()
      const rooms = Array.isArray(result) ? result : (result?.rooms || result?.data || [])
      for (const r of rooms) {
        const id = r.roomId || r.id || r.key
        const title = r.config?.title || r.title || ''
        if (id && title) titleMap.set(id, title)
      }
    } catch {}

    const STOP_WORDS = new Set([
      'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
      'of', 'with', 'by', 'from', 'is', 'it', 'this', 'that', 'my', 'your',
      'room', 'chat', 'channel', 'group', 'new', 'test', 'untitled'
    ])

    for (const cluster of clusters) {
      const catCounts = new Map()
      const allTopics = new Set()

      if (this._roomMeta) {
        for (const roomId of cluster.rooms) {
          try {
            const meta = await this._roomMeta.get(roomId)
            if (meta?.category) {
              catCounts.set(meta.category, (catCounts.get(meta.category) || 0) + 1)
            }
            if (Array.isArray(meta?.topics)) {
              for (const t of meta.topics) allTopics.add(t)
            }
          } catch {}
        }
      }

      cluster.topics = [...allTopics]

      // 1. Try majority category
      if (catCounts.size > 0) {
        let best = ''
        let bestCount = 0
        for (const [cat, count] of catCounts) {
          if (count > bestCount) { best = cat; bestCount = count }
        }
        cluster.label = best
        continue
      }

      // 2. Try majority topic
      if (allTopics.size > 0) {
        cluster.label = [...allTopics][0]
        continue
      }

      // 3. Emergent: most common significant word from room titles
      const wordCounts = new Map()
      for (const roomId of cluster.rooms) {
        const title = titleMap.get(roomId)
        if (!title) continue
        const words = title.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/)
        for (const w of words) {
          if (w.length < 3 || STOP_WORDS.has(w)) continue
          wordCounts.set(w, (wordCounts.get(w) || 0) + 1)
        }
      }
      if (wordCounts.size > 0) {
        let bestWord = ''
        let bestCount = 0
        for (const [word, count] of wordCounts) {
          if (count > bestCount) { bestWord = word; bestCount = count }
        }
        if (bestWord) cluster.label = bestWord
      }
    }
  }

  _buildSuggestions (features, dmSharedRooms) {
    const joinedIds = new Set(Object.keys(features))
    const seen = new Set()

    return dmSharedRooms
      .filter(s => {
        if (joinedIds.has(s.roomId) || seen.has(s.roomId)) return false
        seen.add(s.roomId)
        return true
      })
      .map(s => ({
        room: s.roomId,
        title: s.title || '',
        topics: s.topics || [],
        inviteToken: s.inviteToken || '',
        via: s.sharedBy,
        reason: 'Shared by a DM contact'
      }))
  }
}