diff --git a/modules/rtime/components/folk-timebank-app.ts b/modules/rtime/components/folk-timebank-app.ts index e72876f..1cae700 100644 --- a/modules/rtime/components/folk-timebank-app.ts +++ b/modules/rtime/components/folk-timebank-app.ts @@ -79,6 +79,7 @@ class Orb { phase: number; opacity = 0; 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) { this.c = c; @@ -152,6 +153,19 @@ class Orb { ctx.lineWidth = 1.5; 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) { ctx.globalAlpha = this.opacity * 0.08 * this.hoverT; ctx.beginPath(); @@ -231,7 +245,7 @@ function svgText(txt: string, x: number, y: number, size: number, color: string, class FolkTimebankApp extends HTMLElement { private shadow: ShadowRoot; private space = 'demo'; - private currentView: 'pool' | 'weave' = 'pool'; + private currentView: 'pool' | 'weave' | 'collaborate' = 'pool'; // Pool state private canvas!: HTMLCanvasElement; @@ -270,6 +284,12 @@ class FolkTimebankApp extends HTMLElement { private commitments: Commitment[] = []; private tasks: TaskData[] = []; + // Collaborate state + private intents: any[] = []; + private solverResults: any[] = []; + private skillPrices: Record = {}; + private memberIntentStatus: Map = new Map(); + // Exec state private execStepStates: Record> = {}; @@ -282,7 +302,7 @@ class FolkTimebankApp extends HTMLElement { attributeChangedCallback(name: string, _old: string, val: string) { 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() { @@ -292,6 +312,7 @@ class FolkTimebankApp extends HTMLElement { this.render(); this.setupPool(); this.setupWeave(); + this.setupCollaborate(); this.fetchData(); } @@ -328,6 +349,7 @@ class FolkTimebankApp extends HTMLElement {
Commitment Pool
Weaving Dashboard
+
Collaborate
0 hours available
@@ -376,6 +398,71 @@ class FolkTimebankApp extends HTMLElement {
+ + + + + @@ -457,16 +544,19 @@ class FolkTimebankApp extends HTMLElement { // Tab switching this.shadow.querySelectorAll('.tab').forEach(tab => { 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; this.currentView = 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 weaveView = this.shadow.getElementById('weave-view')!; + const collabView = this.shadow.getElementById('collaborate-view')!; poolView.style.display = view === 'pool' ? 'block' : 'none'; weaveView.style.display = view === 'weave' ? 'flex' : 'none'; + collabView.style.display = view === 'collaborate' ? 'flex' : 'none'; if (view === 'pool') this.resizePoolCanvas(); if (view === 'weave') this.rebuildSidebar(); + if (view === 'collaborate') this.refreshCollaborate(); }); }); @@ -536,7 +626,11 @@ class FolkTimebankApp extends HTMLElement { } 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() { @@ -1290,6 +1384,256 @@ class FolkTimebankApp extends HTMLElement { await this._initTour(); 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 = '
No active intents yet. Create an offer or need to get started.
'; + 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 ` +
+
+ ${typeLabel} + ${SKILL_LABELS[intent.skill] || intent.skill} + ${intent.hours}h + ${intent.status} +
+
+
${intent.memberName}
+
${intent.description || 'No description'}
+
+
+ `; + }).join(''); + } + + private renderSolverResults() { + const container = this.shadow.getElementById('collabResults'); + if (!container) return; + + if (this.solverResults.length === 0) { + container.innerHTML = '
No solver results yet. Create intents and run the solver to find matches.
'; + 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 `${SKILL_LABELS[s] || s}`; + }).join(''); + + const acceptCount = Object.values(result.acceptances || {}).filter(Boolean).length; + const totalMembers = (result.members || []).length; + + return ` +
+
+
+
+ ${scorePercent} +
+
+
+
${skills}
+
${result.totalHours}h total · ${totalMembers} members · ${acceptCount}/${totalMembers} accepted
+
+
+
+ ${(result.members || []).map((m: string) => { + const accepted = result.acceptances?.[m]; + const icon = accepted ? '✓' : '○'; + const cls = accepted ? 'accepted' : 'pending'; + return `${icon} ${m.split(':').pop()}`; + }).join('')} +
+
+ + +
+
+ `; + }).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 ` +
+
+ ${SKILL_LABELS[skill] || skill} + ${price} tok/h +
+ `; + }).join(''); + } } // ── CSS ── @@ -1658,6 +2002,255 @@ const CSS_TEXT = ` 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) { .sidebar { width: 200px; } .exec-panel { width: 95vw; } diff --git a/modules/rtime/intent-routes.ts b/modules/rtime/intent-routes.ts new file mode 100644 index 0000000..5c8f348 --- /dev/null +++ b/modules/rtime/intent-routes.ts @@ -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(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), '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(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), '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(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), '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(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), '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(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(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(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(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(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(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(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(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(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(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(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(solverResultsDocId(space), 'accept result', (d) => { + d.results[resultId].acceptances[auth.did] = true; + }); + + // Check if all accepted → auto-settle + const updated = syncServer().getDoc(solverResultsDocId(space))!; + const updatedResult = updated.results[resultId]; + + if (isReadyForSettlement(updatedResult)) { + // Mark as accepted first + syncServer().changeDoc(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(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(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(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(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(intentsDocId(space))!; + const reputationDoc = syncServer().getDoc(reputationDocId(space))!; + const skillCurvesDoc = syncServer().getDoc(skillCurvesDocId(space))!; + + const candidates = solve(intentsDoc, reputationDoc, skillCurvesDoc); + + if (candidates.length === 0) return 0; + + // Clear old proposed results and write new ones + syncServer().changeDoc(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; +} diff --git a/modules/rtime/mod.ts b/modules/rtime/mod.ts index e26cf93..2aea90b 100644 --- a/modules/rtime/mod.ts +++ b/modules/rtime/mod.ts @@ -27,12 +27,21 @@ import type { CommitmentsDoc, TasksDoc, Commitment, Task, Connection, ExecState, Skill, } from './schemas'; +import { + intentsSchema, solverResultsSchema, + skillCurvesSchema, reputationSchema, +} from './schemas-intent'; +import { createIntentRoutes } from './intent-routes'; const routes = new Hono(); // ── SyncServer ref (set during onInit) ── let _syncServer: SyncServer | null = null; +// ── Mount intent routes ── +const intentRoutes = createIntentRoutes(() => _syncServer); +routes.route('/', intentRoutes); + // ── Automerge helpers ── function ensureCommitmentsDoc(space: string): CommitmentsDoc { @@ -391,6 +400,10 @@ export const timeModule: RSpaceModule = { docSchemas: [ { 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: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, landingPage: renderLanding, @@ -411,6 +424,7 @@ export const timeModule: RSpaceModule = { outputPaths: [ { path: "commitments", name: "Commitments", icon: "🧺", description: "Community hour pledges" }, { path: "weave", name: "Weave", icon: "🧶", description: "Task weaving dashboard" }, + { path: "collaborate", name: "Collaborate", icon: "🤝", description: "Intent-routed collaboration matching" }, ], onboardingActions: [ { label: "Pledge Hours", icon: "⏳", description: "Add a commitment to the pool", type: 'create', href: '/{space}/rtime' }, diff --git a/modules/rtime/reputation.ts b/modules/rtime/reputation.ts new file mode 100644 index 0000000..b2335cb --- /dev/null +++ b/modules/rtime/reputation.ts @@ -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(), + }; +} diff --git a/modules/rtime/schemas-intent.ts b/modules/rtime/schemas-intent.ts new file mode 100644 index 0000000..0292161 --- /dev/null +++ b/modules/rtime/schemas-intent.ts @@ -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; // 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; +} + +export interface SolverResultsDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + results: Record; +} + +export interface SkillCurvesDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + curves: Record; +} + +export interface ReputationDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + entries: Record; +} + +// ── 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 = { + module: 'rtime', + collection: 'intents', + version: 1, + init: (): IntentsDoc => ({ + meta: { + module: 'rtime', + collection: 'intents', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + intents: {}, + }), +}; + +export const solverResultsSchema: DocSchema = { + 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 = { + 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 = { + module: 'rtime', + collection: 'reputation', + version: 1, + init: (): ReputationDoc => ({ + meta: { + module: 'rtime', + collection: 'reputation', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + entries: {}, + }), +}; diff --git a/modules/rtime/schemas.ts b/modules/rtime/schemas.ts index 9b4b5d5..47c95e3 100644 --- a/modules/rtime/schemas.ts +++ b/modules/rtime/schemas.ts @@ -38,6 +38,8 @@ export interface Commitment { desc: string; cyclosMemberId?: string; createdAt: number; + intentId?: string; // links commitment to its intent + status?: 'active' | 'matched' | 'settled' | 'withdrawn'; } // ── Task / Connection / ExecState ── diff --git a/modules/rtime/settlement.ts b/modules/rtime/settlement.ts new file mode 100644 index 0000000..bc5dd21 --- /dev/null +++ b/modules/rtime/settlement.ts @@ -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 { + const outcome: SettlementOutcome = { + success: false, + resultId, + connectionsCreated: 0, + tasksCreated: 0, + }; + + // Load all docs + const solverDoc = syncServer.getDoc(solverResultsDocId(space)); + const intentsDoc = syncServer.getDoc(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 = {}; + for (const need of needIntentsAll) { + taskNeeds[need.skill] = (taskNeeds[need.skill] || 0) + need.hours; + } + + syncServer.changeDoc(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(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(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(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(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(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(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(); + + 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(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); +} diff --git a/modules/rtime/skill-curve.ts b/modules/rtime/skill-curve.ts new file mode 100644 index 0000000..838c278 --- /dev/null +++ b/modules/rtime/skill-curve.ts @@ -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 { + const skills: Skill[] = ['facilitation', 'design', 'tech', 'outreach', 'logistics']; + const prices = {} as Record; + 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 { + 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, + }; +} diff --git a/modules/rtime/solver.ts b/modules/rtime/solver.ts new file mode 100644 index 0000000..626f8d2 --- /dev/null +++ b/modules/rtime/solver.ts @@ -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; + skills: Set; + offerHours: number; + needHours: number; + edges: Edge[]; +} + +// ── Solver ── + +/** + * Run the Mycelium Clustering solver on the current intent pool. + * Returns scored SolverResult candidates (without IDs — caller assigns). + */ +export function solve( + intentsDoc: IntentsDoc, + reputationDoc: ReputationDoc, + skillCurvesDoc: SkillCurvesDoc, +): Omit[] { + const intents = Object.values(intentsDoc.intents) + .filter(i => i.status === 'active'); + + const offers = intents.filter(i => i.type === 'offer'); + const needs = intents.filter(i => i.type === 'need'); + + if (offers.length === 0 || needs.length === 0) return []; + + // Step 1: Build bipartite edges + const edges = buildEdges(offers, needs); + + // Step 2: Filter by validity predicates + const filtered = filterByVPs(edges, offers, needs, reputationDoc, skillCurvesDoc); + + if (filtered.length === 0) return []; + + // Step 3: Greedy clustering + const clusters = greedyCluster(filtered, intents); + + // Step 4: Score and rank + const scored = clusters + .map(c => scoreCluster(c, intents, reputationDoc)) + .sort((a, b) => b.score - a.score); + + // Step 5: De-duplicate by member overlap, take top-K + const results = deduplicateResults(scored, TOP_K); + + return results; +} + +/** + * Build bipartite edges between matching offer and need intents. + */ +function buildEdges(offers: Intent[], needs: Intent[]): Edge[] { + const edges: Edge[] = []; + + for (const offer of offers) { + for (const need of needs) { + // Must match on skill + if (offer.skill !== need.skill) continue; + // Don't match same member with themselves + if (offer.memberId === need.memberId) continue; + + edges.push({ + offerId: offer.id, + needId: need.id, + skill: offer.skill, + offerHours: offer.hours, + needHours: need.hours, + }); + } + } + + return edges; +} + +/** + * Filter edges by validity predicates. + */ +function filterByVPs( + edges: Edge[], + offers: Intent[], + needs: Intent[], + reputationDoc: ReputationDoc, + skillCurvesDoc: SkillCurvesDoc, +): Edge[] { + const offerMap = new Map(offers.map(o => [o.id, o])); + const needMap = new Map(needs.map(n => [n.id, n])); + + return edges.filter(edge => { + const offer = offerMap.get(edge.offerId)!; + const need = needMap.get(edge.needId)!; + + // Check need's minReputation against offer's reputation + if (need.minReputation != null) { + const offerRep = getMemberSkillReputation(offer.memberId, edge.skill, reputationDoc); + if (offerRep < need.minReputation) return false; + } + + // Check offer's minReputation against need's reputation + if (offer.minReputation != null) { + const needRep = getMemberSkillReputation(need.memberId, edge.skill, reputationDoc); + if (needRep < offer.minReputation) return false; + } + + // Check need's maxPrice against current skill price + if (need.maxPrice != null) { + const price = getSkillPrice(edge.skill, skillCurvesDoc); + if (price > need.maxPrice) return false; + } + + // Check preferred members (if specified, counterparty must be in list) + if (need.preferredMembers?.length) { + if (!need.preferredMembers.includes(offer.memberId)) return false; + } + if (offer.preferredMembers?.length) { + if (!offer.preferredMembers.includes(need.memberId)) return false; + } + + return true; + }); +} + +/** + * Greedy cluster formation — starting from highest-demand skills. + */ +function greedyCluster(edges: Edge[], allIntents: Intent[]): Cluster[] { + const intentMap = new Map(allIntents.map(i => [i.id, i])); + + // Count demand per skill to prioritize + const skillDemand = new Map(); + for (const edge of edges) { + skillDemand.set(edge.skill, (skillDemand.get(edge.skill) || 0) + edge.needHours); + } + + // Sort skills by demand (highest first) + const skillOrder = Array.from(skillDemand.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([skill]) => skill); + + const clusters: Cluster[] = []; + const usedIntents = new Set(); + + for (const skill of skillOrder) { + // Get edges for this skill, excluding already-used intents + const skillEdges = edges.filter(e => + e.skill === skill && + !usedIntents.has(e.offerId) && + !usedIntents.has(e.needId) + ); + + if (skillEdges.length === 0) continue; + + // Group by need — each need tries to find the best offer + const needIds = [...new Set(skillEdges.map(e => e.needId))]; + + for (const needId of needIds) { + if (usedIntents.has(needId)) continue; + + const need = intentMap.get(needId)!; + const candidateEdges = skillEdges.filter(e => + e.needId === needId && !usedIntents.has(e.offerId) + ); + + if (candidateEdges.length === 0) continue; + + // Greedy: pick the offer with the closest hour match + const bestEdge = candidateEdges.reduce((best, e) => { + const bestDiff = Math.abs(best.offerHours - best.needHours); + const eDiff = Math.abs(e.offerHours - e.needHours); + return eDiff < bestDiff ? e : best; + }); + + const offer = intentMap.get(bestEdge.offerId)!; + + const cluster: Cluster = { + intentIds: [bestEdge.offerId, bestEdge.needId], + memberIds: new Set([offer.memberId, need.memberId]), + skills: new Set([skill]), + offerHours: bestEdge.offerHours, + needHours: bestEdge.needHours, + edges: [bestEdge], + }; + + usedIntents.add(bestEdge.offerId); + usedIntents.add(bestEdge.needId); + clusters.push(cluster); + } + } + + // Second pass: try to merge clusters that share members (multi-skill collaborations) + return mergeClusters(clusters); +} + +/** + * Merge clusters that share members into multi-skill collaborations. + */ +function mergeClusters(clusters: Cluster[]): Cluster[] { + const merged: Cluster[] = []; + const consumed = new Set(); + + for (let i = 0; i < clusters.length; i++) { + if (consumed.has(i)) continue; + + const current = { ...clusters[i], memberIds: new Set(clusters[i].memberIds), skills: new Set(clusters[i].skills) }; + + for (let j = i + 1; j < clusters.length; j++) { + if (consumed.has(j)) continue; + + // Check if clusters share any members + const overlap = [...clusters[j].memberIds].some(m => current.memberIds.has(m)); + if (!overlap) continue; + + // Merge + for (const id of clusters[j].intentIds) current.intentIds.push(id); + for (const m of clusters[j].memberIds) current.memberIds.add(m); + for (const s of clusters[j].skills) current.skills.add(s); + current.offerHours += clusters[j].offerHours; + current.needHours += clusters[j].needHours; + current.edges.push(...clusters[j].edges); + consumed.add(j); + } + + merged.push(current); + } + + return merged; +} + +/** + * Score a cluster and convert to SolverResult shape. + */ +function scoreCluster( + cluster: Cluster, + allIntents: Intent[], + reputationDoc: ReputationDoc, +): Omit { + const intentMap = new Map(allIntents.map(i => [i.id, i])); + const clusterIntents = cluster.intentIds.map(id => intentMap.get(id)!); + + // 1. Skill match score (1.0 = all intents match on skill) + const skillMatchScore = 1.0; // edges only exist where skills match + + // 2. Hours balance (1.0 = perfect offer/need balance, 0 = severe imbalance) + const hoursBalance = cluster.offerHours > 0 && cluster.needHours > 0 + ? 1 - Math.abs(cluster.offerHours - cluster.needHours) / Math.max(cluster.offerHours, cluster.needHours) + : 0; + + // 3. Average reputation of participants + const memberIds = [...cluster.memberIds]; + const repScores = memberIds.map(memberId => { + const memberIntents = clusterIntents.filter(i => i.memberId === memberId); + const skills = [...new Set(memberIntents.map(i => i.skill))]; + if (skills.length === 0) return DEFAULT_REPUTATION; + const avg = skills.reduce((sum, skill) => + sum + getMemberSkillReputation(memberId, skill, reputationDoc), 0) / skills.length; + return avg; + }); + const avgReputation = repScores.length > 0 + ? repScores.reduce((a, b) => a + b, 0) / repScores.length / 100 + : 0.5; + + // 4. VP satisfaction (what fraction of VPs are satisfied — already pre-filtered, so mostly 1.0) + const vpSatisfaction = 1.0; + + const score = Number(( + W_SKILL_MATCH * skillMatchScore + + W_HOURS_BALANCE * hoursBalance + + W_REPUTATION * avgReputation + + W_VP_SATISFACTION * vpSatisfaction + ).toFixed(4)); + + return { + intents: cluster.intentIds, + members: memberIds, + skills: [...cluster.skills], + totalHours: cluster.offerHours + cluster.needHours, + score, + status: 'proposed', + acceptances: Object.fromEntries(memberIds.map(m => [m, false])), + }; +} + +/** + * De-duplicate results by member overlap. + * Two results overlap if they share > MAX_OVERLAP fraction of members. + */ +function deduplicateResults( + results: Omit[], + limit: number, +): Omit[] { + const kept: Omit[] = []; + + for (const result of results) { + if (kept.length >= limit) break; + + const resultMembers = new Set(result.members); + const overlapping = kept.some(existing => { + const existingMembers = new Set(existing.members); + const overlap = [...resultMembers].filter(m => existingMembers.has(m)).length; + const minSize = Math.min(resultMembers.size, existingMembers.size); + return minSize > 0 && overlap / minSize > MAX_OVERLAP; + }); + + if (!overlapping) kept.push(result); + } + + return kept; +}