354 lines
10 KiB
TypeScript
354 lines
10 KiB
TypeScript
/**
|
||
* 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<string>;
|
||
skills: Set<Skill>;
|
||
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<SolverResult, 'id' | 'createdAt'>[] {
|
||
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<Skill, number>();
|
||
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<string>();
|
||
|
||
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<number>();
|
||
|
||
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<SolverResult, 'id' | 'createdAt'> {
|
||
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<SolverResult, 'id' | 'createdAt'>[],
|
||
limit: number,
|
||
): Omit<SolverResult, 'id' | 'createdAt'>[] {
|
||
const kept: Omit<SolverResult, 'id' | 'createdAt'>[] = [];
|
||
|
||
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;
|
||
}
|