/**
* 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'
}))
}
}