rspace-online/modules/rtime/solver.ts

354 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}