/** * Mycelium Clustering Solver — finds optimal collaboration matches * from the intent pool using bipartite graph matching. * * Algorithm: * 1. Build bipartite graph: offer intents ↔ need intents (edges where skill matches) * 2. Filter by validity predicates (min reputation, max price, preferred members) * 3. Greedy cluster formation starting from highest-demand skills * 4. Score clusters: skillMatch×0.4 + hoursBalance×0.3 + avgReputation×0.2 + vpSatisfaction×0.1 * 5. Output top-K results, de-duplicated by member overlap */ import type { Skill } from './schemas'; import type { Intent, SolverResult, IntentsDoc, SkillCurvesDoc, ReputationDoc, } from './schemas-intent'; import { getMemberSkillReputation, DEFAULT_REPUTATION } from './reputation'; import { getSkillPrice } from './skill-curve'; // ── Config ── /** Maximum number of results to output */ const TOP_K = 10; /** Maximum member overlap between results (0-1) */ const MAX_OVERLAP = 0.5; /** Scoring weights */ const W_SKILL_MATCH = 0.4; const W_HOURS_BALANCE = 0.3; const W_REPUTATION = 0.2; const W_VP_SATISFACTION = 0.1; // ── Types ── interface Edge { offerId: string; needId: string; skill: Skill; offerHours: number; needHours: number; } interface Cluster { intentIds: string[]; memberIds: Set; skills: Set; offerHours: number; needHours: number; edges: Edge[]; } // ── Solver ── /** * Run the Mycelium Clustering solver on the current intent pool. * Returns scored SolverResult candidates (without IDs — caller assigns). */ export function solve( intentsDoc: IntentsDoc, reputationDoc: ReputationDoc, skillCurvesDoc: SkillCurvesDoc, ): Omit[] { const intents = Object.values(intentsDoc.intents) .filter(i => i.status === 'active'); const offers = intents.filter(i => i.type === 'offer'); const needs = intents.filter(i => i.type === 'need'); if (offers.length === 0 || needs.length === 0) return []; // Step 1: Build bipartite edges const edges = buildEdges(offers, needs); // Step 2: Filter by validity predicates const filtered = filterByVPs(edges, offers, needs, reputationDoc, skillCurvesDoc); if (filtered.length === 0) return []; // Step 3: Greedy clustering const clusters = greedyCluster(filtered, intents); // Step 4: Score and rank const scored = clusters .map(c => scoreCluster(c, intents, reputationDoc)) .sort((a, b) => b.score - a.score); // Step 5: De-duplicate by member overlap, take top-K const results = deduplicateResults(scored, TOP_K); return results; } /** * Build bipartite edges between matching offer and need intents. */ function buildEdges(offers: Intent[], needs: Intent[]): Edge[] { const edges: Edge[] = []; for (const offer of offers) { for (const need of needs) { // Must match on skill if (offer.skill !== need.skill) continue; // Don't match same member with themselves if (offer.memberId === need.memberId) continue; edges.push({ offerId: offer.id, needId: need.id, skill: offer.skill, offerHours: offer.hours, needHours: need.hours, }); } } return edges; } /** * Filter edges by validity predicates. */ function filterByVPs( edges: Edge[], offers: Intent[], needs: Intent[], reputationDoc: ReputationDoc, skillCurvesDoc: SkillCurvesDoc, ): Edge[] { const offerMap = new Map(offers.map(o => [o.id, o])); const needMap = new Map(needs.map(n => [n.id, n])); return edges.filter(edge => { const offer = offerMap.get(edge.offerId)!; const need = needMap.get(edge.needId)!; // Check need's minReputation against offer's reputation if (need.minReputation != null) { const offerRep = getMemberSkillReputation(offer.memberId, edge.skill, reputationDoc); if (offerRep < need.minReputation) return false; } // Check offer's minReputation against need's reputation if (offer.minReputation != null) { const needRep = getMemberSkillReputation(need.memberId, edge.skill, reputationDoc); if (needRep < offer.minReputation) return false; } // Check need's maxPrice against current skill price if (need.maxPrice != null) { const price = getSkillPrice(edge.skill, skillCurvesDoc); if (price > need.maxPrice) return false; } // Check preferred members (if specified, counterparty must be in list) if (need.preferredMembers?.length) { if (!need.preferredMembers.includes(offer.memberId)) return false; } if (offer.preferredMembers?.length) { if (!offer.preferredMembers.includes(need.memberId)) return false; } return true; }); } /** * Greedy cluster formation — starting from highest-demand skills. */ function greedyCluster(edges: Edge[], allIntents: Intent[]): Cluster[] { const intentMap = new Map(allIntents.map(i => [i.id, i])); // Count demand per skill to prioritize const skillDemand = new Map(); for (const edge of edges) { skillDemand.set(edge.skill, (skillDemand.get(edge.skill) || 0) + edge.needHours); } // Sort skills by demand (highest first) const skillOrder = Array.from(skillDemand.entries()) .sort((a, b) => b[1] - a[1]) .map(([skill]) => skill); const clusters: Cluster[] = []; const usedIntents = new Set(); for (const skill of skillOrder) { // Get edges for this skill, excluding already-used intents const skillEdges = edges.filter(e => e.skill === skill && !usedIntents.has(e.offerId) && !usedIntents.has(e.needId) ); if (skillEdges.length === 0) continue; // Group by need — each need tries to find the best offer const needIds = [...new Set(skillEdges.map(e => e.needId))]; for (const needId of needIds) { if (usedIntents.has(needId)) continue; const need = intentMap.get(needId)!; const candidateEdges = skillEdges.filter(e => e.needId === needId && !usedIntents.has(e.offerId) ); if (candidateEdges.length === 0) continue; // Greedy: pick the offer with the closest hour match const bestEdge = candidateEdges.reduce((best, e) => { const bestDiff = Math.abs(best.offerHours - best.needHours); const eDiff = Math.abs(e.offerHours - e.needHours); return eDiff < bestDiff ? e : best; }); const offer = intentMap.get(bestEdge.offerId)!; const cluster: Cluster = { intentIds: [bestEdge.offerId, bestEdge.needId], memberIds: new Set([offer.memberId, need.memberId]), skills: new Set([skill]), offerHours: bestEdge.offerHours, needHours: bestEdge.needHours, edges: [bestEdge], }; usedIntents.add(bestEdge.offerId); usedIntents.add(bestEdge.needId); clusters.push(cluster); } } // Second pass: try to merge clusters that share members (multi-skill collaborations) return mergeClusters(clusters); } /** * Merge clusters that share members into multi-skill collaborations. */ function mergeClusters(clusters: Cluster[]): Cluster[] { const merged: Cluster[] = []; const consumed = new Set(); for (let i = 0; i < clusters.length; i++) { if (consumed.has(i)) continue; const current = { ...clusters[i], memberIds: new Set(clusters[i].memberIds), skills: new Set(clusters[i].skills) }; for (let j = i + 1; j < clusters.length; j++) { if (consumed.has(j)) continue; // Check if clusters share any members const overlap = [...clusters[j].memberIds].some(m => current.memberIds.has(m)); if (!overlap) continue; // Merge for (const id of clusters[j].intentIds) current.intentIds.push(id); for (const m of clusters[j].memberIds) current.memberIds.add(m); for (const s of clusters[j].skills) current.skills.add(s); current.offerHours += clusters[j].offerHours; current.needHours += clusters[j].needHours; current.edges.push(...clusters[j].edges); consumed.add(j); } merged.push(current); } return merged; } /** * Score a cluster and convert to SolverResult shape. */ function scoreCluster( cluster: Cluster, allIntents: Intent[], reputationDoc: ReputationDoc, ): Omit { const intentMap = new Map(allIntents.map(i => [i.id, i])); const clusterIntents = cluster.intentIds.map(id => intentMap.get(id)!); // 1. Skill match score (1.0 = all intents match on skill) const skillMatchScore = 1.0; // edges only exist where skills match // 2. Hours balance (1.0 = perfect offer/need balance, 0 = severe imbalance) const hoursBalance = cluster.offerHours > 0 && cluster.needHours > 0 ? 1 - Math.abs(cluster.offerHours - cluster.needHours) / Math.max(cluster.offerHours, cluster.needHours) : 0; // 3. Average reputation of participants const memberIds = [...cluster.memberIds]; const repScores = memberIds.map(memberId => { const memberIntents = clusterIntents.filter(i => i.memberId === memberId); const skills = [...new Set(memberIntents.map(i => i.skill))]; if (skills.length === 0) return DEFAULT_REPUTATION; const avg = skills.reduce((sum, skill) => sum + getMemberSkillReputation(memberId, skill, reputationDoc), 0) / skills.length; return avg; }); const avgReputation = repScores.length > 0 ? repScores.reduce((a, b) => a + b, 0) / repScores.length / 100 : 0.5; // 4. VP satisfaction (what fraction of VPs are satisfied — already pre-filtered, so mostly 1.0) const vpSatisfaction = 1.0; const score = Number(( W_SKILL_MATCH * skillMatchScore + W_HOURS_BALANCE * hoursBalance + W_REPUTATION * avgReputation + W_VP_SATISFACTION * vpSatisfaction ).toFixed(4)); return { intents: cluster.intentIds, members: memberIds, skills: [...cluster.skills], totalHours: cluster.offerHours + cluster.needHours, score, status: 'proposed', acceptances: Object.fromEntries(memberIds.map(m => [m, false])), }; } /** * De-duplicate results by member overlap. * Two results overlap if they share > MAX_OVERLAP fraction of members. */ function deduplicateResults( results: Omit[], limit: number, ): Omit[] { const kept: Omit[] = []; for (const result of results) { if (kept.length >= limit) break; const resultMembers = new Set(result.members); const overlapping = kept.some(existing => { const existingMembers = new Set(existing.members); const overlap = [...resultMembers].filter(m => existingMembers.has(m)).length; const minSize = Math.min(resultMembers.size, existingMembers.size); return minSize > 0 && overlap / minSize > MAX_OVERLAP; }); if (!overlapping) kept.push(result); } return kept; }