feat(rtime): add intent-routed resource-backed commitments
Integrate Anoma-style intent routing into rTime so that commitments become resource-backed intents that a solver can compose into multi-party collaboration recommendations. New modules: - schemas-intent.ts: Intent, SolverResult, SkillCurve, Reputation types + 4 CRDT doc schemas - solver.ts: Mycelium Clustering algorithm (bipartite graph matching, VP filtering, scoring) - settlement.ts: Atomic settlement via saga pattern with escrow confirm/rollback - skill-curve.ts: Per-skill bonding curve (demand/supply pricing) - reputation.ts: Per-skill reputation scoring with time-based decay - intent-routes.ts: 10 Hono API routes (intent CRUD, solver, settlement, curves, reputation) Modified: - schemas.ts: Added intentId/status fields to Commitment - mod.ts: Registered 4 new docSchemas, mounted intent routes, added Collaborate output path - folk-timebank-app.ts: Added Collaborate tab with intent cards, solver results panel, accept/reject buttons, create intent modal, skill price display, and status rings on pool orbs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
16fe8f7626
commit
08cae267fe
|
|
@ -79,6 +79,7 @@ class Orb {
|
||||||
phase: number;
|
phase: number;
|
||||||
opacity = 0;
|
opacity = 0;
|
||||||
color: string;
|
color: string;
|
||||||
|
statusRing: 'offer' | 'need' | 'matched' | null = null; // intent status ring color
|
||||||
|
|
||||||
constructor(c: Commitment, basketCX: number, basketCY: number, basketR: number, x?: number, y?: number) {
|
constructor(c: Commitment, basketCX: number, basketCY: number, basketR: number, x?: number, y?: number) {
|
||||||
this.c = c;
|
this.c = c;
|
||||||
|
|
@ -152,6 +153,19 @@ class Orb {
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Status ring (green=offer, blue=need, gold=matched)
|
||||||
|
if (this.statusRing) {
|
||||||
|
const ringColor = this.statusRing === 'offer' ? '#10b981'
|
||||||
|
: this.statusRing === 'need' ? '#3b82f6' : '#fbbf24';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(this.x, this.y, this.radius + 4, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = ringColor;
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.setLineDash(this.statusRing === 'matched' ? [] : [6, 4]);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.hoverT > 0.05) {
|
if (this.hoverT > 0.05) {
|
||||||
ctx.globalAlpha = this.opacity * 0.08 * this.hoverT;
|
ctx.globalAlpha = this.opacity * 0.08 * this.hoverT;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
|
|
@ -231,7 +245,7 @@ function svgText(txt: string, x: number, y: number, size: number, color: string,
|
||||||
class FolkTimebankApp extends HTMLElement {
|
class FolkTimebankApp extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private space = 'demo';
|
private space = 'demo';
|
||||||
private currentView: 'pool' | 'weave' = 'pool';
|
private currentView: 'pool' | 'weave' | 'collaborate' = 'pool';
|
||||||
|
|
||||||
// Pool state
|
// Pool state
|
||||||
private canvas!: HTMLCanvasElement;
|
private canvas!: HTMLCanvasElement;
|
||||||
|
|
@ -270,6 +284,12 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
private commitments: Commitment[] = [];
|
private commitments: Commitment[] = [];
|
||||||
private tasks: TaskData[] = [];
|
private tasks: TaskData[] = [];
|
||||||
|
|
||||||
|
// Collaborate state
|
||||||
|
private intents: any[] = [];
|
||||||
|
private solverResults: any[] = [];
|
||||||
|
private skillPrices: Record<string, number> = {};
|
||||||
|
private memberIntentStatus: Map<string, 'offer' | 'need' | 'matched'> = new Map();
|
||||||
|
|
||||||
// Exec state
|
// Exec state
|
||||||
private execStepStates: Record<string, Record<number, string>> = {};
|
private execStepStates: Record<string, Record<number, string>> = {};
|
||||||
|
|
||||||
|
|
@ -282,7 +302,7 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
|
|
||||||
attributeChangedCallback(name: string, _old: string, val: string) {
|
attributeChangedCallback(name: string, _old: string, val: string) {
|
||||||
if (name === 'space') this.space = val;
|
if (name === 'space') this.space = val;
|
||||||
if (name === 'view' && (val === 'pool' || val === 'weave')) this.currentView = val;
|
if (name === 'view' && (val === 'pool' || val === 'weave' || val === 'collaborate')) this.currentView = val;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
@ -292,6 +312,7 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
this.render();
|
this.render();
|
||||||
this.setupPool();
|
this.setupPool();
|
||||||
this.setupWeave();
|
this.setupWeave();
|
||||||
|
this.setupCollaborate();
|
||||||
this.fetchData();
|
this.fetchData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,6 +349,7 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<div class="tab active" data-view="pool">Commitment Pool</div>
|
<div class="tab active" data-view="pool">Commitment Pool</div>
|
||||||
<div class="tab" data-view="weave">Weaving Dashboard</div>
|
<div class="tab" data-view="weave">Weaving Dashboard</div>
|
||||||
|
<div class="tab" data-view="collaborate">Collaborate</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-bar">
|
<div class="stats-bar">
|
||||||
<div class="stat"><span class="stat-value" id="statHours">0</span> hours available</div>
|
<div class="stat"><span class="stat-value" id="statHours">0</span> hours available</div>
|
||||||
|
|
@ -376,6 +398,71 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="collaborate-view" style="display:none">
|
||||||
|
<div class="collab-panel">
|
||||||
|
<div class="collab-section">
|
||||||
|
<div class="collab-section-header">
|
||||||
|
<h3>Active Intents</h3>
|
||||||
|
<button class="collab-create-btn" id="createIntentBtn">+ New Intent</button>
|
||||||
|
</div>
|
||||||
|
<div class="collab-intents" id="collabIntents">
|
||||||
|
<div class="collab-empty">No active intents yet. Create an offer or need to get started.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="collab-section">
|
||||||
|
<div class="collab-section-header">
|
||||||
|
<h3>Recommended Collaborations</h3>
|
||||||
|
<button class="collab-solver-btn" id="runSolverBtn">Run Solver</button>
|
||||||
|
</div>
|
||||||
|
<div class="collab-results" id="collabResults">
|
||||||
|
<div class="collab-empty">No solver results yet. Create intents and run the solver to find matches.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="collab-section collab-prices-section">
|
||||||
|
<h3>Skill Market Prices</h3>
|
||||||
|
<div class="collab-prices" id="collabPrices"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Intent Modal -->
|
||||||
|
<div class="modal-overlay" id="intentModalOverlay">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>Create Intent</h3>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Type</label>
|
||||||
|
<div class="intent-type-toggle" id="intentTypeToggle">
|
||||||
|
<button class="intent-type-btn active" data-type="offer">Offer</button>
|
||||||
|
<button class="intent-type-btn" data-type="need">Need</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Skill</label>
|
||||||
|
<select id="intentSkill">
|
||||||
|
<option value="facilitation">Facilitation</option>
|
||||||
|
<option value="design">Design</option>
|
||||||
|
<option value="tech">Tech</option>
|
||||||
|
<option value="outreach">Outreach</option>
|
||||||
|
<option value="logistics">Logistics</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Hours</label>
|
||||||
|
<input type="number" id="intentHours" min="1" max="100" value="2">
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Description</label>
|
||||||
|
<input type="text" id="intentDesc" placeholder="What will you offer or need?">
|
||||||
|
</div>
|
||||||
|
<div class="intent-cost" id="intentCost" style="display:none">
|
||||||
|
<span>Escrow cost: </span><strong id="intentCostValue">0</strong> tokens
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="modal-cancel" id="intentCancel">Cancel</button>
|
||||||
|
<button class="modal-submit" id="intentSubmit">Create Intent</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Commitment Modal -->
|
<!-- Add Commitment Modal -->
|
||||||
|
|
@ -457,16 +544,19 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
// Tab switching
|
// Tab switching
|
||||||
this.shadow.querySelectorAll('.tab').forEach(tab => {
|
this.shadow.querySelectorAll('.tab').forEach(tab => {
|
||||||
tab.addEventListener('click', () => {
|
tab.addEventListener('click', () => {
|
||||||
const view = (tab as HTMLElement).dataset.view as 'pool' | 'weave';
|
const view = (tab as HTMLElement).dataset.view as 'pool' | 'weave' | 'collaborate';
|
||||||
if (view === this.currentView) return;
|
if (view === this.currentView) return;
|
||||||
this.currentView = view;
|
this.currentView = view;
|
||||||
this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === view));
|
this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === view));
|
||||||
const poolView = this.shadow.getElementById('pool-view')!;
|
const poolView = this.shadow.getElementById('pool-view')!;
|
||||||
const weaveView = this.shadow.getElementById('weave-view')!;
|
const weaveView = this.shadow.getElementById('weave-view')!;
|
||||||
|
const collabView = this.shadow.getElementById('collaborate-view')!;
|
||||||
poolView.style.display = view === 'pool' ? 'block' : 'none';
|
poolView.style.display = view === 'pool' ? 'block' : 'none';
|
||||||
weaveView.style.display = view === 'weave' ? 'flex' : 'none';
|
weaveView.style.display = view === 'weave' ? 'flex' : 'none';
|
||||||
|
collabView.style.display = view === 'collaborate' ? 'flex' : 'none';
|
||||||
if (view === 'pool') this.resizePoolCanvas();
|
if (view === 'pool') this.resizePoolCanvas();
|
||||||
if (view === 'weave') this.rebuildSidebar();
|
if (view === 'weave') this.rebuildSidebar();
|
||||||
|
if (view === 'collaborate') this.refreshCollaborate();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -536,7 +626,11 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildOrbs() {
|
private buildOrbs() {
|
||||||
this.orbs = this.commitments.map(c => new Orb(c, this.basketCX, this.basketCY, this.basketR));
|
this.orbs = this.commitments.map(c => {
|
||||||
|
const orb = new Orb(c, this.basketCX, this.basketCY, this.basketR);
|
||||||
|
orb.statusRing = this.memberIntentStatus.get(c.memberName) || null;
|
||||||
|
return orb;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveCollisions() {
|
private resolveCollisions() {
|
||||||
|
|
@ -1290,6 +1384,256 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
await this._initTour();
|
await this._initTour();
|
||||||
this._tour?.start();
|
this._tour?.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Collaborate setup ──
|
||||||
|
|
||||||
|
private setupCollaborate() {
|
||||||
|
// Create Intent modal
|
||||||
|
const intentModal = this.shadow.getElementById('intentModalOverlay')!;
|
||||||
|
const createBtn = this.shadow.getElementById('createIntentBtn');
|
||||||
|
const solverBtn = this.shadow.getElementById('runSolverBtn');
|
||||||
|
|
||||||
|
createBtn?.addEventListener('click', () => {
|
||||||
|
intentModal.classList.add('visible');
|
||||||
|
(this.shadow.getElementById('intentDesc') as HTMLInputElement).value = '';
|
||||||
|
(this.shadow.getElementById('intentHours') as HTMLInputElement).value = '2';
|
||||||
|
this.updateIntentCost();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.shadow.getElementById('intentCancel')?.addEventListener('click', () => {
|
||||||
|
intentModal.classList.remove('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type toggle
|
||||||
|
this.shadow.querySelectorAll('.intent-type-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
this.shadow.querySelectorAll('.intent-type-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
this.updateIntentCost();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cost update on skill/hours change
|
||||||
|
this.shadow.getElementById('intentSkill')?.addEventListener('change', () => this.updateIntentCost());
|
||||||
|
this.shadow.getElementById('intentHours')?.addEventListener('input', () => this.updateIntentCost());
|
||||||
|
|
||||||
|
// Submit intent
|
||||||
|
this.shadow.getElementById('intentSubmit')?.addEventListener('click', () => this.submitIntent());
|
||||||
|
|
||||||
|
// Run solver
|
||||||
|
solverBtn?.addEventListener('click', () => this.triggerSolver());
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateIntentCost() {
|
||||||
|
const typeBtn = this.shadow.querySelector('.intent-type-btn.active') as HTMLElement;
|
||||||
|
const type = typeBtn?.dataset.type || 'offer';
|
||||||
|
const costDiv = this.shadow.getElementById('intentCost')!;
|
||||||
|
const costVal = this.shadow.getElementById('intentCostValue')!;
|
||||||
|
|
||||||
|
if (type === 'need') {
|
||||||
|
const skill = (this.shadow.getElementById('intentSkill') as HTMLSelectElement).value;
|
||||||
|
const hours = parseInt((this.shadow.getElementById('intentHours') as HTMLInputElement).value) || 1;
|
||||||
|
const price = this.skillPrices[skill] || 100;
|
||||||
|
costVal.textContent = String(price * hours);
|
||||||
|
costDiv.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
costDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async submitIntent() {
|
||||||
|
const typeBtn = this.shadow.querySelector('.intent-type-btn.active') as HTMLElement;
|
||||||
|
const type = typeBtn?.dataset.type || 'offer';
|
||||||
|
const skill = (this.shadow.getElementById('intentSkill') as HTMLSelectElement).value;
|
||||||
|
const hours = parseInt((this.shadow.getElementById('intentHours') as HTMLInputElement).value) || 1;
|
||||||
|
const description = (this.shadow.getElementById('intentDesc') as HTMLInputElement).value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/${this.space}/rtime/api/intent`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type, skill, hours, description }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
alert(err.error || 'Failed to create intent');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.shadow.getElementById('intentModalOverlay')!.classList.remove('visible');
|
||||||
|
this.refreshCollaborate();
|
||||||
|
} catch {
|
||||||
|
alert('Failed to create intent');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async triggerSolver() {
|
||||||
|
try {
|
||||||
|
await fetch(`/${this.space}/rtime/api/solver/run`, { method: 'POST' });
|
||||||
|
this.refreshCollaborate();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshCollaborate() {
|
||||||
|
const base = `/${this.space}/rtime`;
|
||||||
|
try {
|
||||||
|
const [iResp, sResp, cResp] = await Promise.all([
|
||||||
|
fetch(`${base}/api/intents`),
|
||||||
|
fetch(`${base}/api/solver-results`),
|
||||||
|
fetch(`${base}/api/skill-curves`),
|
||||||
|
]);
|
||||||
|
if (iResp.ok) {
|
||||||
|
const data = await iResp.json();
|
||||||
|
this.intents = data.intents || [];
|
||||||
|
}
|
||||||
|
if (sResp.ok) {
|
||||||
|
const data = await sResp.json();
|
||||||
|
this.solverResults = data.results || [];
|
||||||
|
}
|
||||||
|
if (cResp.ok) {
|
||||||
|
const data = await cResp.json();
|
||||||
|
this.skillPrices = data.prices || {};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// offline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build member intent status map for pool view status rings
|
||||||
|
this.memberIntentStatus.clear();
|
||||||
|
for (const intent of this.intents) {
|
||||||
|
if (intent.status !== 'active' && intent.status !== 'matched') continue;
|
||||||
|
const existing = this.memberIntentStatus.get(intent.memberName);
|
||||||
|
if (intent.status === 'matched') {
|
||||||
|
this.memberIntentStatus.set(intent.memberName, 'matched');
|
||||||
|
} else if (!existing) {
|
||||||
|
this.memberIntentStatus.set(intent.memberName, intent.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderIntentsList();
|
||||||
|
this.renderSolverResults();
|
||||||
|
this.renderSkillPrices();
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderIntentsList() {
|
||||||
|
const container = this.shadow.getElementById('collabIntents');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const activeIntents = this.intents.filter(i => i.status === 'active' || i.status === 'matched');
|
||||||
|
if (activeIntents.length === 0) {
|
||||||
|
container.innerHTML = '<div class="collab-empty">No active intents yet. Create an offer or need to get started.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = activeIntents.map(intent => {
|
||||||
|
const color = SKILL_COLORS[intent.skill] || '#8b5cf6';
|
||||||
|
const typeLabel = intent.type === 'offer' ? 'OFFER' : 'NEED';
|
||||||
|
const typeClass = intent.type === 'offer' ? 'intent-offer' : 'intent-need';
|
||||||
|
return `
|
||||||
|
<div class="intent-card ${typeClass}">
|
||||||
|
<div class="intent-card-header">
|
||||||
|
<span class="intent-type-badge ${typeClass}">${typeLabel}</span>
|
||||||
|
<span class="intent-skill-badge" style="background:${color}20;color:${color}">${SKILL_LABELS[intent.skill] || intent.skill}</span>
|
||||||
|
<span class="intent-hours">${intent.hours}h</span>
|
||||||
|
<span class="intent-status-badge intent-status-${intent.status}">${intent.status}</span>
|
||||||
|
</div>
|
||||||
|
<div class="intent-card-body">
|
||||||
|
<div class="intent-member">${intent.memberName}</div>
|
||||||
|
<div class="intent-desc">${intent.description || 'No description'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSolverResults() {
|
||||||
|
const container = this.shadow.getElementById('collabResults');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (this.solverResults.length === 0) {
|
||||||
|
container.innerHTML = '<div class="collab-empty">No solver results yet. Create intents and run the solver to find matches.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = this.solverResults.map(result => {
|
||||||
|
const scorePercent = Math.round(result.score * 100);
|
||||||
|
const skills = (result.skills || []).map((s: string) => {
|
||||||
|
const color = SKILL_COLORS[s] || '#8b5cf6';
|
||||||
|
return `<span class="intent-skill-badge" style="background:${color}20;color:${color}">${SKILL_LABELS[s] || s}</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const acceptCount = Object.values(result.acceptances || {}).filter(Boolean).length;
|
||||||
|
const totalMembers = (result.members || []).length;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="solver-result-card">
|
||||||
|
<div class="solver-result-header">
|
||||||
|
<div class="solver-score">
|
||||||
|
<div class="solver-score-ring" style="--score: ${scorePercent}%">
|
||||||
|
<span>${scorePercent}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="solver-result-info">
|
||||||
|
<div class="solver-skills">${skills}</div>
|
||||||
|
<div class="solver-meta">${result.totalHours}h total · ${totalMembers} members · ${acceptCount}/${totalMembers} accepted</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="solver-result-members">
|
||||||
|
${(result.members || []).map((m: string) => {
|
||||||
|
const accepted = result.acceptances?.[m];
|
||||||
|
const icon = accepted ? '✓' : '○';
|
||||||
|
const cls = accepted ? 'accepted' : 'pending';
|
||||||
|
return `<span class="solver-member ${cls}">${icon} ${m.split(':').pop()}</span>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="solver-result-actions">
|
||||||
|
<button class="solver-accept-btn" data-result-id="${result.id}">Accept</button>
|
||||||
|
<button class="solver-reject-btn" data-result-id="${result.id}">Reject</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Bind accept/reject buttons
|
||||||
|
container.querySelectorAll('.solver-accept-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const resultId = (btn as HTMLElement).dataset.resultId;
|
||||||
|
try {
|
||||||
|
await fetch(`/${this.space}/rtime/api/solver-results/${resultId}/accept`, { method: 'POST' });
|
||||||
|
this.refreshCollaborate();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll('.solver-reject-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const resultId = (btn as HTMLElement).dataset.resultId;
|
||||||
|
try {
|
||||||
|
await fetch(`/${this.space}/rtime/api/solver-results/${resultId}/reject`, { method: 'POST' });
|
||||||
|
this.refreshCollaborate();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSkillPrices() {
|
||||||
|
const container = this.shadow.getElementById('collabPrices');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const skills = ['facilitation', 'design', 'tech', 'outreach', 'logistics'];
|
||||||
|
container.innerHTML = skills.map(skill => {
|
||||||
|
const color = SKILL_COLORS[skill] || '#8b5cf6';
|
||||||
|
const price = this.skillPrices[skill] || 100;
|
||||||
|
return `
|
||||||
|
<div class="price-card">
|
||||||
|
<div class="price-dot" style="background:${color}"></div>
|
||||||
|
<span class="price-skill">${SKILL_LABELS[skill] || skill}</span>
|
||||||
|
<span class="price-value">${price} tok/h</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CSS ──
|
// ── CSS ──
|
||||||
|
|
@ -1658,6 +2002,255 @@ const CSS_TEXT = `
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collaborate view */
|
||||||
|
#collaborate-view {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
position: absolute; top: 0; left: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.25rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.collab-panel {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.collab-section {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
.collab-section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.collab-section-header h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.collab-create-btn, .collab-solver-btn {
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.collab-create-btn:hover, .collab-solver-btn:hover { opacity: 0.85; }
|
||||||
|
.collab-solver-btn {
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #06b6d4);
|
||||||
|
}
|
||||||
|
.collab-empty {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
.collab-intents, .collab-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Intent cards */
|
||||||
|
.intent-card {
|
||||||
|
background: #0f172a;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.intent-card:hover { border-color: #475569; }
|
||||||
|
.intent-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.intent-type-badge {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.intent-type-badge.intent-offer { background: rgba(16,185,129,0.15); color: #10b981; }
|
||||||
|
.intent-type-badge.intent-need { background: rgba(59,130,246,0.15); color: #3b82f6; }
|
||||||
|
.intent-skill-badge {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
.intent-hours {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.intent-status-badge {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #334155;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.intent-status-active { background: rgba(16,185,129,0.15); color: #10b981; }
|
||||||
|
.intent-status-matched { background: rgba(251,191,36,0.15); color: #fbbf24; }
|
||||||
|
.intent-status-settled { background: rgba(139,92,246,0.15); color: #8b5cf6; }
|
||||||
|
.intent-member {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.intent-desc {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Intent type toggle */
|
||||||
|
.intent-type-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.intent-type-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.intent-type-btn.active {
|
||||||
|
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.intent-cost {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.intent-cost strong { color: #fbbf24; }
|
||||||
|
|
||||||
|
/* Solver result cards */
|
||||||
|
.solver-result-card {
|
||||||
|
background: #0f172a;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
.solver-result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.solver-score {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.solver-score-ring {
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: conic-gradient(#8b5cf6 var(--score, 0%), #334155 var(--score, 0%));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.solver-score-ring span {
|
||||||
|
background: #0f172a;
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
.solver-result-info { flex: 1; }
|
||||||
|
.solver-skills { display: flex; gap: 0.35rem; flex-wrap: wrap; }
|
||||||
|
.solver-meta { font-size: 0.75rem; color: #64748b; margin-top: 0.25rem; }
|
||||||
|
.solver-result-members {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.solver-member {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: #1e293b;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.solver-member.accepted { background: rgba(16,185,129,0.15); color: #10b981; }
|
||||||
|
.solver-result-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.solver-accept-btn, .solver-reject-btn {
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.solver-accept-btn {
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.solver-reject-btn {
|
||||||
|
background: #334155;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skill prices */
|
||||||
|
.collab-prices-section h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
.collab-prices {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.price-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
background: #0f172a;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
.price-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||||
|
.price-skill { font-size: 0.78rem; color: #94a3b8; }
|
||||||
|
.price-value { font-size: 0.82rem; font-weight: 600; color: #f1f5f9; margin-left: 0.25rem; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar { width: 200px; }
|
.sidebar { width: 200px; }
|
||||||
.exec-panel { width: 95vw; }
|
.exec-panel { width: 95vw; }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,408 @@
|
||||||
|
/**
|
||||||
|
* Intent API routes — Hono sub-router for intent CRUD, solver, and settlement.
|
||||||
|
*
|
||||||
|
* All routes are relative to the module mount point (/:space/rtime/).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import * as Automerge from '@automerge/automerge';
|
||||||
|
import { verifyToken, extractToken } from '../../server/auth';
|
||||||
|
import { burnTokensEscrow, reverseBurn } from '../../server/token-service';
|
||||||
|
import type { SyncServer } from '../../server/local-first/sync-server';
|
||||||
|
import {
|
||||||
|
intentsDocId, solverResultsDocId,
|
||||||
|
skillCurvesDocId, reputationDocId,
|
||||||
|
intentsSchema, solverResultsSchema,
|
||||||
|
skillCurvesSchema, reputationSchema,
|
||||||
|
} from './schemas-intent';
|
||||||
|
import type {
|
||||||
|
IntentsDoc, SolverResultsDoc,
|
||||||
|
SkillCurvesDoc, ReputationDoc,
|
||||||
|
Intent, IntentStatus,
|
||||||
|
} from './schemas-intent';
|
||||||
|
import type { Skill } from './schemas';
|
||||||
|
import { getSkillPrice, calculateIntentCost, getAllSkillPrices, getSkillCurveConfig } from './skill-curve';
|
||||||
|
import { getMemberSkillReputation, getMemberOverallReputation } from './reputation';
|
||||||
|
import { solve } from './solver';
|
||||||
|
import { settleResult, isReadyForSettlement, updateSkillCurves } from './settlement';
|
||||||
|
|
||||||
|
const VALID_SKILLS: Skill[] = ['facilitation', 'design', 'tech', 'outreach', 'logistics'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the intent routes sub-router.
|
||||||
|
* Requires a SyncServer reference (passed from mod.ts onInit).
|
||||||
|
*/
|
||||||
|
export function createIntentRoutes(getSyncServer: () => SyncServer | null): Hono {
|
||||||
|
const routes = new Hono();
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function syncServer(): SyncServer {
|
||||||
|
const ss = getSyncServer();
|
||||||
|
if (!ss) throw new Error('SyncServer not initialized');
|
||||||
|
return ss;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureIntentsDoc(space: string): IntentsDoc {
|
||||||
|
const docId = intentsDocId(space);
|
||||||
|
let doc = syncServer().getDoc<IntentsDoc>(docId);
|
||||||
|
if (!doc) {
|
||||||
|
doc = Automerge.change(Automerge.init<IntentsDoc>(), 'init intents', (d) => {
|
||||||
|
const init = intentsSchema.init();
|
||||||
|
Object.assign(d, init);
|
||||||
|
d.meta.spaceSlug = space;
|
||||||
|
});
|
||||||
|
syncServer().setDoc(docId, doc);
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSolverResultsDoc(space: string): SolverResultsDoc {
|
||||||
|
const docId = solverResultsDocId(space);
|
||||||
|
let doc = syncServer().getDoc<SolverResultsDoc>(docId);
|
||||||
|
if (!doc) {
|
||||||
|
doc = Automerge.change(Automerge.init<SolverResultsDoc>(), 'init solver results', (d) => {
|
||||||
|
const init = solverResultsSchema.init();
|
||||||
|
Object.assign(d, init);
|
||||||
|
d.meta.spaceSlug = space;
|
||||||
|
});
|
||||||
|
syncServer().setDoc(docId, doc);
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSkillCurvesDoc(space: string): SkillCurvesDoc {
|
||||||
|
const docId = skillCurvesDocId(space);
|
||||||
|
let doc = syncServer().getDoc<SkillCurvesDoc>(docId);
|
||||||
|
if (!doc) {
|
||||||
|
doc = Automerge.change(Automerge.init<SkillCurvesDoc>(), 'init skill curves', (d) => {
|
||||||
|
const init = skillCurvesSchema.init();
|
||||||
|
Object.assign(d, init);
|
||||||
|
d.meta.spaceSlug = space;
|
||||||
|
});
|
||||||
|
syncServer().setDoc(docId, doc);
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureReputationDoc(space: string): ReputationDoc {
|
||||||
|
const docId = reputationDocId(space);
|
||||||
|
let doc = syncServer().getDoc<ReputationDoc>(docId);
|
||||||
|
if (!doc) {
|
||||||
|
doc = Automerge.change(Automerge.init<ReputationDoc>(), 'init reputation', (d) => {
|
||||||
|
const init = reputationSchema.init();
|
||||||
|
Object.assign(d, init);
|
||||||
|
d.meta.spaceSlug = space;
|
||||||
|
});
|
||||||
|
syncServer().setDoc(docId, doc);
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireAuth(headers: Headers): Promise<{ did: string; username: string } | null> {
|
||||||
|
const token = extractToken(headers);
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const claims = await verifyToken(token);
|
||||||
|
return { did: claims.sub, username: (claims as any).username || claims.sub };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Intent CRUD ──
|
||||||
|
|
||||||
|
// POST /api/intent — Create a new intent
|
||||||
|
routes.post('/api/intent', async (c) => {
|
||||||
|
const auth = await requireAuth(c.req.raw.headers);
|
||||||
|
if (!auth) return c.json({ error: 'Authentication required' }, 401);
|
||||||
|
|
||||||
|
const space = c.req.param('space') || 'demo';
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { type, skill, hours, description, minReputation, maxPrice, preferredMembers, expiresAt } = body;
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (!type || !['offer', 'need'].includes(type)) {
|
||||||
|
return c.json({ error: 'type must be "offer" or "need"' }, 400);
|
||||||
|
}
|
||||||
|
if (!skill || !VALID_SKILLS.includes(skill)) {
|
||||||
|
return c.json({ error: `skill must be one of: ${VALID_SKILLS.join(', ')}` }, 400);
|
||||||
|
}
|
||||||
|
if (!hours || hours < 1 || hours > 100) {
|
||||||
|
return c.json({ error: 'hours must be between 1 and 100' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureIntentsDoc(space);
|
||||||
|
ensureSkillCurvesDoc(space);
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = Date.now();
|
||||||
|
let escrowTxId: string | undefined;
|
||||||
|
|
||||||
|
// For need intents: lock tokens in escrow
|
||||||
|
if (type === 'need') {
|
||||||
|
const curvesDoc = syncServer().getDoc<SkillCurvesDoc>(skillCurvesDocId(space))!;
|
||||||
|
const cost = calculateIntentCost(skill, hours, curvesDoc);
|
||||||
|
|
||||||
|
const offRampId = `intent-${id}`;
|
||||||
|
const success = burnTokensEscrow(
|
||||||
|
'cusdc',
|
||||||
|
auth.did,
|
||||||
|
auth.username,
|
||||||
|
cost,
|
||||||
|
offRampId,
|
||||||
|
`Intent escrow: ${hours}h ${skill} @ ${getSkillPrice(skill, curvesDoc)} tokens/h`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return c.json({ error: 'Insufficient balance for escrow' }, 402);
|
||||||
|
}
|
||||||
|
escrowTxId = offRampId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write intent
|
||||||
|
syncServer().changeDoc<IntentsDoc>(intentsDocId(space), 'create intent', (d) => {
|
||||||
|
d.intents[id] = {
|
||||||
|
id,
|
||||||
|
memberId: auth.did,
|
||||||
|
memberName: auth.username,
|
||||||
|
type,
|
||||||
|
skill,
|
||||||
|
hours: Math.max(1, Math.min(100, hours)),
|
||||||
|
description: description || '',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: now,
|
||||||
|
...(minReputation != null ? { minReputation } : {}),
|
||||||
|
...(maxPrice != null ? { maxPrice } : {}),
|
||||||
|
...(preferredMembers?.length ? { preferredMembers } : {}),
|
||||||
|
...(escrowTxId ? { escrowTxId } : {}),
|
||||||
|
...(expiresAt ? { expiresAt } : {}),
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update skill curves
|
||||||
|
updateSkillCurves(space, syncServer());
|
||||||
|
|
||||||
|
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
|
||||||
|
return c.json(doc.intents[id], 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/intent/:id — Update or withdraw an intent
|
||||||
|
routes.patch('/api/intent/:id', async (c) => {
|
||||||
|
const auth = await requireAuth(c.req.raw.headers);
|
||||||
|
if (!auth) return c.json({ error: 'Authentication required' }, 401);
|
||||||
|
|
||||||
|
const space = c.req.param('space') || 'demo';
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
ensureIntentsDoc(space);
|
||||||
|
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
|
||||||
|
const intent = doc.intents[id];
|
||||||
|
|
||||||
|
if (!intent) return c.json({ error: 'Intent not found' }, 404);
|
||||||
|
if (intent.memberId !== auth.did) return c.json({ error: 'Not your intent' }, 403);
|
||||||
|
if (intent.status !== 'active') return c.json({ error: `Cannot modify intent in status: ${intent.status}` }, 400);
|
||||||
|
|
||||||
|
// Handle withdrawal
|
||||||
|
if (body.status === 'withdrawn') {
|
||||||
|
// Reverse escrow if need intent
|
||||||
|
if (intent.escrowTxId) {
|
||||||
|
reverseBurn('cusdc', intent.escrowTxId);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncServer().changeDoc<IntentsDoc>(intentsDocId(space), 'withdraw intent', (d) => {
|
||||||
|
d.intents[id].status = 'withdrawn';
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSkillCurves(space, syncServer());
|
||||||
|
return c.json({ ok: true, status: 'withdrawn' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle field updates
|
||||||
|
syncServer().changeDoc<IntentsDoc>(intentsDocId(space), 'update intent', (d) => {
|
||||||
|
if (body.description !== undefined) d.intents[id].description = body.description;
|
||||||
|
if (body.minReputation !== undefined) d.intents[id].minReputation = body.minReputation;
|
||||||
|
if (body.maxPrice !== undefined) d.intents[id].maxPrice = body.maxPrice;
|
||||||
|
if (body.preferredMembers !== undefined) d.intents[id].preferredMembers = body.preferredMembers;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
|
||||||
|
return c.json(updated.intents[id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/intents — List active intents for space
|
||||||
|
routes.get('/api/intents', (c) => {
|
||||||
|
const space = c.req.param('space') || 'demo';
|
||||||
|
ensureIntentsDoc(space);
|
||||||
|
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
|
||||||
|
const intents = Object.values(doc.intents);
|
||||||
|
return c.json({ intents });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/intents/:memberId — List member's intents
|
||||||
|
routes.get('/api/intents/:memberId', (c) => {
|
||||||
|
const space = c.req.param('space') || 'demo';
|
||||||
|
const memberId = c.req.param('memberId');
|
||||||
|
ensureIntentsDoc(space);
|
||||||
|
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
|
||||||
|
const intents = Object.values(doc.intents).filter(i => i.memberId === memberId);
|
||||||
|
return c.json({ intents });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Solver ──
|
||||||
|
|
||||||
|
// GET /api/solver-results — Get current solver recommendations
|
||||||
|
routes.get('/api/solver-results', (c) => {
|
||||||
|
const space = c.req.param('space') || 'demo';
|
||||||
|
ensureSolverResultsDoc(space);
|
||||||
|
const doc = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
|
||||||
|
const results = Object.values(doc.results)
|
||||||
|
.filter(r => r.status === 'proposed' || r.status === 'accepted');
|
||||||
|
return c.json({ results });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/solver/run — Manual solver trigger
|
||||||
|
routes.post('/api/solver/run', async (c) => {
|
||||||
|
const auth = await requireAuth(c.req.raw.headers);
|
||||||
|
if (!auth) return c.json({ error: 'Authentication required' }, 401);
|
||||||
|
|
||||||
|
const space = c.req.param('space') || 'demo';
|
||||||
|
const resultCount = runSolver(space);
|
||||||
|
|
||||||
|
return c.json({ ok: true, resultsGenerated: resultCount });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/solver-results/:id/accept — Member accepts a recommendation
|
||||||
|
routes.post('/api/solver-results/:id/accept', async (c) => {
|
||||||
|
const auth = await requireAuth(c.req.raw.headers);
|
||||||
|
if (!auth) return c.json({ error: 'Authentication required' }, 401);
|
||||||
|
|
||||||
|
const space = c.req.param('space') || 'demo';
|
||||||
|
const resultId = c.req.param('id');
|
||||||
|
|
||||||
|
ensureSolverResultsDoc(space);
|
||||||
|
const doc = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
|
||||||
|
const result = doc.results[resultId];
|
||||||
|
|
||||||
|
if (!result) return c.json({ error: 'Result not found' }, 404);
|
||||||
|
if (!result.members.includes(auth.did)) return c.json({ error: 'Not a participant' }, 403);
|
||||||
|
if (result.status !== 'proposed' && result.status !== 'accepted') {
|
||||||
|
return c.json({ error: `Cannot accept result in status: ${result.status}` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'accept result', (d) => {
|
||||||
|
d.results[resultId].acceptances[auth.did] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if all accepted → auto-settle
|
||||||
|
const updated = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
|
||||||
|
const updatedResult = updated.results[resultId];
|
||||||
|
|
||||||
|
if (isReadyForSettlement(updatedResult)) {
|
||||||
|
// Mark as accepted first
|
||||||
|
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'mark accepted', (d) => {
|
||||||
|
d.results[resultId].status = 'accepted';
|
||||||
|
});
|
||||||
|
|
||||||
|
const outcome = await settleResult(resultId, space, syncServer());
|
||||||
|
return c.json({ ok: true, settled: outcome.success, outcome });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
ok: true,
|
||||||
|
settled: false,
|
||||||
|
acceptances: updatedResult.acceptances,
|
||||||
|
remaining: updatedResult.members.filter(m => !updatedResult.acceptances[m]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/solver-results/:id/reject — Member rejects a recommendation
|
||||||
|
routes.post('/api/solver-results/:id/reject', async (c) => {
|
||||||
|
const auth = await requireAuth(c.req.raw.headers);
|
||||||
|
if (!auth) return c.json({ error: 'Authentication required' }, 401);
|
||||||
|
|
||||||
|
const space = c.req.param('space') || 'demo';
|
||||||
|
const resultId = c.req.param('id');
|
||||||
|
|
||||||
|
ensureSolverResultsDoc(space);
|
||||||
|
const doc = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
|
||||||
|
const result = doc.results[resultId];
|
||||||
|
|
||||||
|
if (!result) return c.json({ error: 'Result not found' }, 404);
|
||||||
|
if (!result.members.includes(auth.did)) return c.json({ error: 'Not a participant' }, 403);
|
||||||
|
|
||||||
|
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'reject result', (d) => {
|
||||||
|
d.results[resultId].status = 'rejected';
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Skill Curves ──
|
||||||
|
|
||||||
|
// GET /api/skill-curves — Get current skill prices
|
||||||
|
routes.get('/api/skill-curves', (c) => {
|
||||||
|
const space = c.req.param('space') || 'demo';
|
||||||
|
ensureSkillCurvesDoc(space);
|
||||||
|
const doc = syncServer().getDoc<SkillCurvesDoc>(skillCurvesDocId(space))!;
|
||||||
|
const prices = getAllSkillPrices(doc);
|
||||||
|
return c.json({ curves: Object.values(doc.curves), prices, config: getSkillCurveConfig() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Reputation ──
|
||||||
|
|
||||||
|
// GET /api/reputation/:memberId — Get member reputation
|
||||||
|
routes.get('/api/reputation/:memberId', (c) => {
|
||||||
|
const space = c.req.param('space') || 'demo';
|
||||||
|
const memberId = c.req.param('memberId');
|
||||||
|
ensureReputationDoc(space);
|
||||||
|
const doc = syncServer().getDoc<ReputationDoc>(reputationDocId(space))!;
|
||||||
|
|
||||||
|
const entries = Object.values(doc.entries).filter(e => e.memberId === memberId);
|
||||||
|
const overall = getMemberOverallReputation(memberId, doc);
|
||||||
|
|
||||||
|
return c.json({ memberId, overall, skills: entries });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Internal: Run solver ──
|
||||||
|
|
||||||
|
function runSolver(space: string): number {
|
||||||
|
ensureIntentsDoc(space);
|
||||||
|
ensureSolverResultsDoc(space);
|
||||||
|
ensureSkillCurvesDoc(space);
|
||||||
|
ensureReputationDoc(space);
|
||||||
|
|
||||||
|
const intentsDoc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
|
||||||
|
const reputationDoc = syncServer().getDoc<ReputationDoc>(reputationDocId(space))!;
|
||||||
|
const skillCurvesDoc = syncServer().getDoc<SkillCurvesDoc>(skillCurvesDocId(space))!;
|
||||||
|
|
||||||
|
const candidates = solve(intentsDoc, reputationDoc, skillCurvesDoc);
|
||||||
|
|
||||||
|
if (candidates.length === 0) return 0;
|
||||||
|
|
||||||
|
// Clear old proposed results and write new ones
|
||||||
|
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'solver: write results', (d) => {
|
||||||
|
// Remove stale proposed results
|
||||||
|
for (const [id, result] of Object.entries(d.results)) {
|
||||||
|
if (result.status === 'proposed') delete d.results[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new results
|
||||||
|
const now = Date.now();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
d.results[id] = {
|
||||||
|
id,
|
||||||
|
...candidate,
|
||||||
|
createdAt: now,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[rTime] Solver produced ${candidates.length} results for space "${space}"`);
|
||||||
|
return candidates.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
@ -27,12 +27,21 @@ import type {
|
||||||
CommitmentsDoc, TasksDoc,
|
CommitmentsDoc, TasksDoc,
|
||||||
Commitment, Task, Connection, ExecState, Skill,
|
Commitment, Task, Connection, ExecState, Skill,
|
||||||
} from './schemas';
|
} from './schemas';
|
||||||
|
import {
|
||||||
|
intentsSchema, solverResultsSchema,
|
||||||
|
skillCurvesSchema, reputationSchema,
|
||||||
|
} from './schemas-intent';
|
||||||
|
import { createIntentRoutes } from './intent-routes';
|
||||||
|
|
||||||
const routes = new Hono();
|
const routes = new Hono();
|
||||||
|
|
||||||
// ── SyncServer ref (set during onInit) ──
|
// ── SyncServer ref (set during onInit) ──
|
||||||
let _syncServer: SyncServer | null = null;
|
let _syncServer: SyncServer | null = null;
|
||||||
|
|
||||||
|
// ── Mount intent routes ──
|
||||||
|
const intentRoutes = createIntentRoutes(() => _syncServer);
|
||||||
|
routes.route('/', intentRoutes);
|
||||||
|
|
||||||
// ── Automerge helpers ──
|
// ── Automerge helpers ──
|
||||||
|
|
||||||
function ensureCommitmentsDoc(space: string): CommitmentsDoc {
|
function ensureCommitmentsDoc(space: string): CommitmentsDoc {
|
||||||
|
|
@ -391,6 +400,10 @@ export const timeModule: RSpaceModule = {
|
||||||
docSchemas: [
|
docSchemas: [
|
||||||
{ pattern: '{space}:rtime:commitments', description: 'Commitment pool', init: commitmentsSchema.init },
|
{ pattern: '{space}:rtime:commitments', description: 'Commitment pool', init: commitmentsSchema.init },
|
||||||
{ pattern: '{space}:rtime:tasks', description: 'Tasks, connections, exec states', init: tasksSchema.init },
|
{ pattern: '{space}:rtime:tasks', description: 'Tasks, connections, exec states', init: tasksSchema.init },
|
||||||
|
{ pattern: '{space}:rtime:intents', description: 'Intent pool (offers & needs)', init: intentsSchema.init },
|
||||||
|
{ pattern: '{space}:rtime:solver-results', description: 'Solver collaboration recommendations', init: solverResultsSchema.init },
|
||||||
|
{ pattern: '{space}:rtime:skill-curves', description: 'Per-skill demand/supply pricing', init: skillCurvesSchema.init },
|
||||||
|
{ pattern: '{space}:rtime:reputation', description: 'Per-member per-skill reputation', init: reputationSchema.init },
|
||||||
],
|
],
|
||||||
routes,
|
routes,
|
||||||
landingPage: renderLanding,
|
landingPage: renderLanding,
|
||||||
|
|
@ -411,6 +424,7 @@ export const timeModule: RSpaceModule = {
|
||||||
outputPaths: [
|
outputPaths: [
|
||||||
{ path: "commitments", name: "Commitments", icon: "🧺", description: "Community hour pledges" },
|
{ path: "commitments", name: "Commitments", icon: "🧺", description: "Community hour pledges" },
|
||||||
{ path: "weave", name: "Weave", icon: "🧶", description: "Task weaving dashboard" },
|
{ path: "weave", name: "Weave", icon: "🧶", description: "Task weaving dashboard" },
|
||||||
|
{ path: "collaborate", name: "Collaborate", icon: "🤝", description: "Intent-routed collaboration matching" },
|
||||||
],
|
],
|
||||||
onboardingActions: [
|
onboardingActions: [
|
||||||
{ label: "Pledge Hours", icon: "⏳", description: "Add a commitment to the pool", type: 'create', href: '/{space}/rtime' },
|
{ label: "Pledge Hours", icon: "⏳", description: "Add a commitment to the pool", type: 'create', href: '/{space}/rtime' },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
/**
|
||||||
|
* Per-skill reputation scoring with time-based decay.
|
||||||
|
*
|
||||||
|
* Score formula: baseScore × decayFactor
|
||||||
|
* baseScore = (avgRating / 5) × 80 + (min(completedHours, 50) / 50) × 20
|
||||||
|
* decayFactor = e^(-λ × daysSinceLastRating) where λ = 0.005
|
||||||
|
*
|
||||||
|
* Score range: 0-100. New members start at 50 (neutral).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Skill } from './schemas';
|
||||||
|
import type { ReputationDoc, ReputationEntry, ReputationRating } from './schemas-intent';
|
||||||
|
|
||||||
|
// ── Constants ──
|
||||||
|
|
||||||
|
/** Default score for members with no reputation */
|
||||||
|
export const DEFAULT_REPUTATION = 50;
|
||||||
|
|
||||||
|
/** Decay rate (per day). At λ=0.005, score halves after ~139 days of inactivity */
|
||||||
|
const DECAY_LAMBDA = 0.005;
|
||||||
|
|
||||||
|
/** Rating weight (out of 100) */
|
||||||
|
const RATING_WEIGHT = 80;
|
||||||
|
|
||||||
|
/** Hours weight (out of 100) */
|
||||||
|
const HOURS_WEIGHT = 20;
|
||||||
|
|
||||||
|
/** Hours cap for scoring (diminishing returns beyond this) */
|
||||||
|
const HOURS_CAP = 50;
|
||||||
|
|
||||||
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
|
||||||
|
// ── Scoring ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the reputation score for a member-skill pair.
|
||||||
|
*/
|
||||||
|
export function calculateScore(entry: ReputationEntry, now: number = Date.now()): number {
|
||||||
|
if (entry.ratings.length === 0) return DEFAULT_REPUTATION;
|
||||||
|
|
||||||
|
// Average rating (1-5 scale)
|
||||||
|
const avgRating = entry.ratings.reduce((sum, r) => sum + r.score, 0) / entry.ratings.length;
|
||||||
|
|
||||||
|
// Base score: rating component + hours component
|
||||||
|
const ratingComponent = (avgRating / 5) * RATING_WEIGHT;
|
||||||
|
const hoursComponent = (Math.min(entry.completedHours, HOURS_CAP) / HOURS_CAP) * HOURS_WEIGHT;
|
||||||
|
const baseScore = ratingComponent + hoursComponent;
|
||||||
|
|
||||||
|
// Decay based on time since most recent rating
|
||||||
|
const lastRating = entry.ratings.reduce((latest, r) => Math.max(latest, r.timestamp), 0);
|
||||||
|
const daysSince = (now - lastRating) / MS_PER_DAY;
|
||||||
|
const decayFactor = Math.exp(-DECAY_LAMBDA * Math.max(0, daysSince));
|
||||||
|
|
||||||
|
return Math.round(baseScore * decayFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a member's reputation for a specific skill, or DEFAULT_REPUTATION if none.
|
||||||
|
*/
|
||||||
|
export function getMemberSkillReputation(
|
||||||
|
memberId: string,
|
||||||
|
skill: Skill,
|
||||||
|
reputationDoc: ReputationDoc,
|
||||||
|
now?: number,
|
||||||
|
): number {
|
||||||
|
const key = `${memberId}:${skill}`;
|
||||||
|
const entry = reputationDoc.entries[key];
|
||||||
|
if (!entry) return DEFAULT_REPUTATION;
|
||||||
|
return calculateScore(entry, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a member's average reputation across all skills they have entries for.
|
||||||
|
*/
|
||||||
|
export function getMemberOverallReputation(
|
||||||
|
memberId: string,
|
||||||
|
reputationDoc: ReputationDoc,
|
||||||
|
now?: number,
|
||||||
|
): number {
|
||||||
|
const entries = Object.values(reputationDoc.entries)
|
||||||
|
.filter(e => e.memberId === memberId);
|
||||||
|
|
||||||
|
if (entries.length === 0) return DEFAULT_REPUTATION;
|
||||||
|
|
||||||
|
const total = entries.reduce((sum, e) => sum + calculateScore(e, now), 0);
|
||||||
|
return Math.round(total / entries.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a reputation entry key from memberId and skill.
|
||||||
|
*/
|
||||||
|
export function reputationKey(memberId: string, skill: Skill): string {
|
||||||
|
return `${memberId}:${skill}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new rating to add to a reputation entry.
|
||||||
|
*/
|
||||||
|
export function createRating(fromMemberId: string, score: number): ReputationRating {
|
||||||
|
return {
|
||||||
|
from: fromMemberId,
|
||||||
|
score: Math.max(1, Math.min(5, Math.round(score))),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
/**
|
||||||
|
* rTime Intent Schemas — Anoma-style resource-backed commitments.
|
||||||
|
*
|
||||||
|
* DocId formats:
|
||||||
|
* {space}:rtime:intents → IntentsDoc (active intents pool)
|
||||||
|
* {space}:rtime:solver-results → SolverResultsDoc (solver recommendations)
|
||||||
|
* {space}:rtime:skill-curves → SkillCurvesDoc (per-skill pricing)
|
||||||
|
* {space}:rtime:reputation → ReputationDoc (per-member per-skill reputation)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DocSchema } from '../../shared/local-first/document';
|
||||||
|
import type { Skill } from './schemas';
|
||||||
|
|
||||||
|
// ── Intent ──
|
||||||
|
|
||||||
|
export type IntentType = 'offer' | 'need';
|
||||||
|
export type IntentStatus = 'active' | 'matched' | 'settled' | 'withdrawn';
|
||||||
|
|
||||||
|
export interface Intent {
|
||||||
|
id: string;
|
||||||
|
memberId: string; // DID of the member
|
||||||
|
memberName: string;
|
||||||
|
type: IntentType;
|
||||||
|
skill: Skill;
|
||||||
|
hours: number;
|
||||||
|
description: string;
|
||||||
|
// Validity predicates
|
||||||
|
minReputation?: number; // minimum counterparty reputation (0-100)
|
||||||
|
maxPrice?: number; // maximum acceptable skill price (tokens/hour)
|
||||||
|
preferredMembers?: string[];// preferred collaborator DIDs
|
||||||
|
escrowTxId?: string; // token escrow reference (for needs)
|
||||||
|
status: IntentStatus;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Solver Result ──
|
||||||
|
|
||||||
|
export type SolverResultStatus = 'proposed' | 'accepted' | 'rejected' | 'settled';
|
||||||
|
|
||||||
|
export interface SolverResult {
|
||||||
|
id: string;
|
||||||
|
intents: string[]; // intent IDs in this cluster
|
||||||
|
members: string[]; // participating member IDs
|
||||||
|
skills: Skill[];
|
||||||
|
totalHours: number;
|
||||||
|
score: number; // solver confidence score (0-1)
|
||||||
|
status: SolverResultStatus;
|
||||||
|
acceptances: Record<string, boolean>; // memberId → accepted?
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Skill Curve ──
|
||||||
|
|
||||||
|
export interface SkillCurveEntry {
|
||||||
|
skill: Skill;
|
||||||
|
supplyHours: number; // total offered hours
|
||||||
|
demandHours: number; // total needed hours
|
||||||
|
currentPrice: number; // calculated price per hour (tokens)
|
||||||
|
history: Array<{ price: number; timestamp: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reputation ──
|
||||||
|
|
||||||
|
export interface ReputationRating {
|
||||||
|
from: string; // rater memberId
|
||||||
|
score: number; // 1-5 stars
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReputationEntry {
|
||||||
|
memberId: string;
|
||||||
|
skill: Skill;
|
||||||
|
score: number; // 0-100 computed score
|
||||||
|
completedHours: number;
|
||||||
|
ratings: ReputationRating[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Documents ──
|
||||||
|
|
||||||
|
export interface IntentsDoc {
|
||||||
|
meta: {
|
||||||
|
module: string;
|
||||||
|
collection: string;
|
||||||
|
version: number;
|
||||||
|
spaceSlug: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
intents: Record<string, Intent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SolverResultsDoc {
|
||||||
|
meta: {
|
||||||
|
module: string;
|
||||||
|
collection: string;
|
||||||
|
version: number;
|
||||||
|
spaceSlug: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
results: Record<string, SolverResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillCurvesDoc {
|
||||||
|
meta: {
|
||||||
|
module: string;
|
||||||
|
collection: string;
|
||||||
|
version: number;
|
||||||
|
spaceSlug: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
curves: Record<string, SkillCurveEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReputationDoc {
|
||||||
|
meta: {
|
||||||
|
module: string;
|
||||||
|
collection: string;
|
||||||
|
version: number;
|
||||||
|
spaceSlug: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
entries: Record<string, ReputationEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DocId helpers ──
|
||||||
|
|
||||||
|
export function intentsDocId(space: string) {
|
||||||
|
return `${space}:rtime:intents` as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function solverResultsDocId(space: string) {
|
||||||
|
return `${space}:rtime:solver-results` as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skillCurvesDocId(space: string) {
|
||||||
|
return `${space}:rtime:skill-curves` as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reputationDocId(space: string) {
|
||||||
|
return `${space}:rtime:reputation` as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schema registrations ──
|
||||||
|
|
||||||
|
export const intentsSchema: DocSchema<IntentsDoc> = {
|
||||||
|
module: 'rtime',
|
||||||
|
collection: 'intents',
|
||||||
|
version: 1,
|
||||||
|
init: (): IntentsDoc => ({
|
||||||
|
meta: {
|
||||||
|
module: 'rtime',
|
||||||
|
collection: 'intents',
|
||||||
|
version: 1,
|
||||||
|
spaceSlug: '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
intents: {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const solverResultsSchema: DocSchema<SolverResultsDoc> = {
|
||||||
|
module: 'rtime',
|
||||||
|
collection: 'solver-results',
|
||||||
|
version: 1,
|
||||||
|
init: (): SolverResultsDoc => ({
|
||||||
|
meta: {
|
||||||
|
module: 'rtime',
|
||||||
|
collection: 'solver-results',
|
||||||
|
version: 1,
|
||||||
|
spaceSlug: '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
results: {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const skillCurvesSchema: DocSchema<SkillCurvesDoc> = {
|
||||||
|
module: 'rtime',
|
||||||
|
collection: 'skill-curves',
|
||||||
|
version: 1,
|
||||||
|
init: (): SkillCurvesDoc => ({
|
||||||
|
meta: {
|
||||||
|
module: 'rtime',
|
||||||
|
collection: 'skill-curves',
|
||||||
|
version: 1,
|
||||||
|
spaceSlug: '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
curves: {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reputationSchema: DocSchema<ReputationDoc> = {
|
||||||
|
module: 'rtime',
|
||||||
|
collection: 'reputation',
|
||||||
|
version: 1,
|
||||||
|
init: (): ReputationDoc => ({
|
||||||
|
meta: {
|
||||||
|
module: 'rtime',
|
||||||
|
collection: 'reputation',
|
||||||
|
version: 1,
|
||||||
|
spaceSlug: '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
entries: {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
@ -38,6 +38,8 @@ export interface Commitment {
|
||||||
desc: string;
|
desc: string;
|
||||||
cyclosMemberId?: string;
|
cyclosMemberId?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
intentId?: string; // links commitment to its intent
|
||||||
|
status?: 'active' | 'matched' | 'settled' | 'withdrawn';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Task / Connection / ExecState ──
|
// ── Task / Connection / ExecState ──
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
/**
|
||||||
|
* Atomic settlement via saga pattern.
|
||||||
|
*
|
||||||
|
* When all members accept a solver result:
|
||||||
|
* 1. Validate all acceptances
|
||||||
|
* 2. Confirm escrow burns for need intents
|
||||||
|
* 3. Create Connection entries in CommitmentsDoc
|
||||||
|
* 4. Create Task entries in TasksDoc
|
||||||
|
* 5. Update intent statuses → 'settled'
|
||||||
|
* 6. Update reputation entries
|
||||||
|
* 7. Update skill curve supply/demand counters
|
||||||
|
*
|
||||||
|
* On failure at any step: reverse all confirmed burns and abort.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Automerge from '@automerge/automerge';
|
||||||
|
import type { SyncServer } from '../../server/local-first/sync-server';
|
||||||
|
import { confirmBurn, reverseBurn } from '../../server/token-service';
|
||||||
|
import { commitmentsDocId, tasksDocId } from './schemas';
|
||||||
|
import type { CommitmentsDoc, TasksDoc, Skill } from './schemas';
|
||||||
|
import {
|
||||||
|
intentsDocId, solverResultsDocId,
|
||||||
|
skillCurvesDocId, reputationDocId,
|
||||||
|
} from './schemas-intent';
|
||||||
|
import type {
|
||||||
|
IntentsDoc, SolverResultsDoc,
|
||||||
|
SkillCurvesDoc, ReputationDoc,
|
||||||
|
SolverResult, Intent,
|
||||||
|
} from './schemas-intent';
|
||||||
|
import { reputationKey, DEFAULT_REPUTATION } from './reputation';
|
||||||
|
import { recalculateCurve } from './skill-curve';
|
||||||
|
|
||||||
|
// ── Settlement ──
|
||||||
|
|
||||||
|
export interface SettlementOutcome {
|
||||||
|
success: boolean;
|
||||||
|
resultId: string;
|
||||||
|
connectionsCreated: number;
|
||||||
|
tasksCreated: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle a solver result — atomic multi-doc update with saga rollback.
|
||||||
|
*/
|
||||||
|
export async function settleResult(
|
||||||
|
resultId: string,
|
||||||
|
space: string,
|
||||||
|
syncServer: SyncServer,
|
||||||
|
): Promise<SettlementOutcome> {
|
||||||
|
const outcome: SettlementOutcome = {
|
||||||
|
success: false,
|
||||||
|
resultId,
|
||||||
|
connectionsCreated: 0,
|
||||||
|
tasksCreated: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load all docs
|
||||||
|
const solverDoc = syncServer.getDoc<SolverResultsDoc>(solverResultsDocId(space));
|
||||||
|
const intentsDoc = syncServer.getDoc<IntentsDoc>(intentsDocId(space));
|
||||||
|
|
||||||
|
if (!solverDoc || !intentsDoc) {
|
||||||
|
outcome.error = 'Required documents not found';
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = solverDoc.results[resultId];
|
||||||
|
if (!result) {
|
||||||
|
outcome.error = `Solver result ${resultId} not found`;
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Validate all acceptances
|
||||||
|
const allAccepted = result.members.every(m => result.acceptances[m] === true);
|
||||||
|
if (!allAccepted) {
|
||||||
|
outcome.error = 'Not all members have accepted';
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 'proposed' && result.status !== 'accepted') {
|
||||||
|
outcome.error = `Result status is ${result.status}, expected proposed or accepted`;
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather intents
|
||||||
|
const intents = result.intents
|
||||||
|
.map(id => intentsDoc.intents[id])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (intents.length !== result.intents.length) {
|
||||||
|
outcome.error = 'Some intents in the result no longer exist';
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Confirm escrow burns for need intents (with saga tracking)
|
||||||
|
const confirmedEscrows: string[] = [];
|
||||||
|
const needIntents = intents.filter(i => i.type === 'need' && i.escrowTxId);
|
||||||
|
|
||||||
|
for (const need of needIntents) {
|
||||||
|
const success = confirmBurn('cusdc', need.escrowTxId!);
|
||||||
|
if (!success) {
|
||||||
|
// Rollback all previously confirmed burns
|
||||||
|
for (const txId of confirmedEscrows) {
|
||||||
|
reverseBurn('cusdc', txId);
|
||||||
|
}
|
||||||
|
outcome.error = `Failed to confirm escrow for intent ${need.id} (txId: ${need.escrowTxId})`;
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
confirmedEscrows.push(need.escrowTxId!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3 & 4: Create connections and tasks
|
||||||
|
const now = Date.now();
|
||||||
|
const offerIntents = intents.filter(i => i.type === 'offer');
|
||||||
|
const needIntentsAll = intents.filter(i => i.type === 'need');
|
||||||
|
|
||||||
|
// Create a task for this collaboration
|
||||||
|
const taskId = crypto.randomUUID();
|
||||||
|
const taskSkills = [...new Set(intents.map(i => i.skill))];
|
||||||
|
const taskNeeds: Record<string, number> = {};
|
||||||
|
for (const need of needIntentsAll) {
|
||||||
|
taskNeeds[need.skill] = (taskNeeds[need.skill] || 0) + need.hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncServer.changeDoc<TasksDoc>(tasksDocId(space), 'settlement: create task', (d) => {
|
||||||
|
d.tasks[taskId] = {
|
||||||
|
id: taskId,
|
||||||
|
name: `Collaboration: ${taskSkills.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' + ')}`,
|
||||||
|
description: `Auto-generated from solver result. Members: ${result.members.length}`,
|
||||||
|
needs: taskNeeds,
|
||||||
|
links: [],
|
||||||
|
notes: `Settled from solver result ${resultId}`,
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
outcome.tasksCreated = 1;
|
||||||
|
|
||||||
|
// Create connections (offer → task)
|
||||||
|
syncServer.changeDoc<TasksDoc>(tasksDocId(space), 'settlement: create connections', (d) => {
|
||||||
|
for (const offer of offerIntents) {
|
||||||
|
// Find or create a commitment for this offer in CommitmentsDoc
|
||||||
|
const connId = crypto.randomUUID();
|
||||||
|
d.connections[connId] = {
|
||||||
|
id: connId,
|
||||||
|
fromCommitmentId: offer.id, // Using intent ID as reference
|
||||||
|
toTaskId: taskId,
|
||||||
|
skill: offer.skill,
|
||||||
|
} as any;
|
||||||
|
outcome.connectionsCreated++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create commitment entries for each offer intent (so they appear in the pool)
|
||||||
|
syncServer.changeDoc<CommitmentsDoc>(commitmentsDocId(space), 'settlement: create commitments', (d) => {
|
||||||
|
for (const offer of offerIntents) {
|
||||||
|
if (!d.items[offer.id]) {
|
||||||
|
d.items[offer.id] = {
|
||||||
|
id: offer.id,
|
||||||
|
memberName: offer.memberName,
|
||||||
|
hours: offer.hours,
|
||||||
|
skill: offer.skill,
|
||||||
|
desc: offer.description,
|
||||||
|
createdAt: offer.createdAt,
|
||||||
|
intentId: offer.id,
|
||||||
|
status: 'settled',
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 5: Update intent statuses
|
||||||
|
syncServer.changeDoc<IntentsDoc>(intentsDocId(space), 'settlement: mark intents settled', (d) => {
|
||||||
|
for (const intent of intents) {
|
||||||
|
if (d.intents[intent.id]) {
|
||||||
|
d.intents[intent.id].status = 'settled';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 6: Update solver result status
|
||||||
|
syncServer.changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'settlement: mark result settled', (d) => {
|
||||||
|
if (d.results[resultId]) {
|
||||||
|
d.results[resultId].status = 'settled';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 7: Update reputation (initial entry — actual ratings come later)
|
||||||
|
syncServer.changeDoc<ReputationDoc>(reputationDocId(space), 'settlement: init reputation', (d) => {
|
||||||
|
for (const intent of intents) {
|
||||||
|
const key = reputationKey(intent.memberId, intent.skill);
|
||||||
|
if (!d.entries[key]) {
|
||||||
|
d.entries[key] = {
|
||||||
|
memberId: intent.memberId,
|
||||||
|
skill: intent.skill,
|
||||||
|
score: DEFAULT_REPUTATION,
|
||||||
|
completedHours: 0,
|
||||||
|
ratings: [],
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
// Add completed hours
|
||||||
|
d.entries[key].completedHours += intent.hours;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 8: Update skill curves
|
||||||
|
updateSkillCurves(space, syncServer);
|
||||||
|
|
||||||
|
outcome.success = true;
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate skill curves from current active intents.
|
||||||
|
*/
|
||||||
|
export function updateSkillCurves(space: string, syncServer: SyncServer): void {
|
||||||
|
const intentsDoc = syncServer.getDoc<IntentsDoc>(intentsDocId(space));
|
||||||
|
if (!intentsDoc) return;
|
||||||
|
|
||||||
|
const activeIntents = Object.values(intentsDoc.intents)
|
||||||
|
.filter(i => i.status === 'active');
|
||||||
|
|
||||||
|
// Aggregate supply/demand per skill
|
||||||
|
const skills: Skill[] = ['facilitation', 'design', 'tech', 'outreach', 'logistics'];
|
||||||
|
const aggregates = new Map<Skill, { supply: number; demand: number }>();
|
||||||
|
|
||||||
|
for (const skill of skills) {
|
||||||
|
aggregates.set(skill, { supply: 0, demand: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const intent of activeIntents) {
|
||||||
|
const agg = aggregates.get(intent.skill);
|
||||||
|
if (!agg) continue;
|
||||||
|
if (intent.type === 'offer') agg.supply += intent.hours;
|
||||||
|
else agg.demand += intent.hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncServer.changeDoc<SkillCurvesDoc>(skillCurvesDocId(space), 'update skill curves', (d) => {
|
||||||
|
for (const [skill, agg] of aggregates) {
|
||||||
|
const updated = recalculateCurve(skill, agg.supply, agg.demand);
|
||||||
|
const now = Date.now();
|
||||||
|
if (!d.curves[skill]) {
|
||||||
|
d.curves[skill] = {
|
||||||
|
...updated,
|
||||||
|
history: [{ price: updated.currentPrice, timestamp: now }],
|
||||||
|
} as any;
|
||||||
|
} else {
|
||||||
|
d.curves[skill].supplyHours = updated.supplyHours;
|
||||||
|
d.curves[skill].demandHours = updated.demandHours;
|
||||||
|
d.curves[skill].currentPrice = updated.currentPrice;
|
||||||
|
// Append to history (keep last 100 entries)
|
||||||
|
if (!d.curves[skill].history) d.curves[skill].history = [] as any;
|
||||||
|
d.curves[skill].history.push({ price: updated.currentPrice, timestamp: now } as any);
|
||||||
|
if (d.curves[skill].history.length > 100) {
|
||||||
|
d.curves[skill].history.splice(0, d.curves[skill].history.length - 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a result is ready for settlement (all members accepted).
|
||||||
|
*/
|
||||||
|
export function isReadyForSettlement(result: SolverResult): boolean {
|
||||||
|
if (result.status !== 'proposed' && result.status !== 'accepted') return false;
|
||||||
|
return result.members.every(m => result.acceptances[m] === true);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
/**
|
||||||
|
* Per-skill bonding curve — demand/supply pricing for intent marketplace.
|
||||||
|
*
|
||||||
|
* Formula: price = BASE_SKILL_PRICE × (demandHours / supplyHours) ^ ELASTICITY
|
||||||
|
*
|
||||||
|
* High demand + low supply → higher price → incentivizes offers.
|
||||||
|
* All prices in tokens per hour.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Skill } from './schemas';
|
||||||
|
import type { SkillCurvesDoc, SkillCurveEntry } from './schemas-intent';
|
||||||
|
|
||||||
|
// ── Curve parameters ──
|
||||||
|
|
||||||
|
/** Base price in tokens per hour */
|
||||||
|
export const BASE_SKILL_PRICE = 100;
|
||||||
|
|
||||||
|
/** Elasticity exponent — 0.5 = square root (moderate responsiveness) */
|
||||||
|
export const ELASTICITY = 0.5;
|
||||||
|
|
||||||
|
/** Minimum supply to avoid division by zero / extreme prices */
|
||||||
|
const MIN_SUPPLY = 1;
|
||||||
|
|
||||||
|
/** Maximum price cap (10x base) to prevent runaway pricing */
|
||||||
|
const MAX_PRICE = BASE_SKILL_PRICE * 10;
|
||||||
|
|
||||||
|
// ── Price functions ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the current price per hour for a skill.
|
||||||
|
* Returns tokens per hour.
|
||||||
|
*/
|
||||||
|
export function getSkillPrice(skill: Skill, curves: SkillCurvesDoc): number {
|
||||||
|
const curve = curves.curves[skill];
|
||||||
|
if (!curve || curve.supplyHours <= 0) return BASE_SKILL_PRICE;
|
||||||
|
|
||||||
|
const supply = Math.max(curve.supplyHours, MIN_SUPPLY);
|
||||||
|
const demand = Math.max(curve.demandHours, 0);
|
||||||
|
const ratio = demand / supply;
|
||||||
|
|
||||||
|
const price = Math.round(BASE_SKILL_PRICE * Math.pow(ratio, ELASTICITY));
|
||||||
|
return Math.min(price, MAX_PRICE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the total cost for a need intent (hours × skill price).
|
||||||
|
*/
|
||||||
|
export function calculateIntentCost(skill: Skill, hours: number, curves: SkillCurvesDoc): number {
|
||||||
|
const price = getSkillPrice(skill, curves);
|
||||||
|
return price * hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all current skill prices.
|
||||||
|
*/
|
||||||
|
export function getAllSkillPrices(curves: SkillCurvesDoc): Record<Skill, number> {
|
||||||
|
const skills: Skill[] = ['facilitation', 'design', 'tech', 'outreach', 'logistics'];
|
||||||
|
const prices = {} as Record<Skill, number>;
|
||||||
|
for (const skill of skills) {
|
||||||
|
prices[skill] = getSkillPrice(skill, curves);
|
||||||
|
}
|
||||||
|
return prices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update a skill curve entry after an intent is created/withdrawn.
|
||||||
|
* Returns the updated curve entry.
|
||||||
|
*/
|
||||||
|
export function recalculateCurve(
|
||||||
|
skill: Skill,
|
||||||
|
supplyHours: number,
|
||||||
|
demandHours: number,
|
||||||
|
): Omit<SkillCurveEntry, 'history'> {
|
||||||
|
const supply = Math.max(supplyHours, MIN_SUPPLY);
|
||||||
|
const demand = Math.max(demandHours, 0);
|
||||||
|
const ratio = demand / supply;
|
||||||
|
const price = Math.min(Math.round(BASE_SKILL_PRICE * Math.pow(ratio, ELASTICITY)), MAX_PRICE);
|
||||||
|
|
||||||
|
return {
|
||||||
|
skill,
|
||||||
|
supplyHours,
|
||||||
|
demandHours,
|
||||||
|
currentPrice: price,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get curve configuration for UI display.
|
||||||
|
*/
|
||||||
|
export function getSkillCurveConfig() {
|
||||||
|
return {
|
||||||
|
basePrice: BASE_SKILL_PRICE,
|
||||||
|
elasticity: ELASTICITY,
|
||||||
|
maxPrice: MAX_PRICE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue