diff --git a/app/flow-v2/page.tsx b/app/flow-v2/page.tsx new file mode 100644 index 0000000..fb561ba --- /dev/null +++ b/app/flow-v2/page.tsx @@ -0,0 +1,675 @@ +'use client' + +/** + * Flow Funding V2 - Continuous Flow Dynamics Demo + * + * Interactive visualization of progressive outflow zones and + * steady-state flow equilibrium + */ + +import { useState, useEffect, useCallback, useMemo } from 'react' +import type { FlowNode, FlowNetwork, ScenarioV2 } from '../../lib/flow-v2/types' +import { + calculateSteadyState, + getFlowZone, + cloneNodes, + updateBalances, + perSecondToPerMonth, +} from '../../lib/flow-v2/engine-v2' +import { + ALL_SCENARIOS_V2, + linearChainV2, +} from '../../lib/flow-v2/scenarios-v2' + +/** + * Flow particle for animation + */ +interface FlowParticle { + id: string + sourceId: string + targetId: string + progress: number // 0 to 1 + startTime: number +} + +/** + * Main component + */ +export default function FlowFundingV2() { + // Scenario selection + const [currentScenario, setCurrentScenario] = useState(linearChainV2) + + // Node state (with adjustable external inflows) + const [nodes, setNodes] = useState(() => + cloneNodes(currentScenario.nodes) + ) + + // Network state (calculated) + const [network, setNetwork] = useState(null) + + // Animation state + const [particles, setParticles] = useState([]) + const [isPlaying, setIsPlaying] = useState(true) + const [simulationTime, setSimulationTime] = useState(0) + + // UI state + const [selectedNodeId, setSelectedNodeId] = useState(null) + const [showMetrics, setShowMetrics] = useState(true) + + /** + * Recalculate network whenever nodes change + */ + useEffect(() => { + try { + const result = calculateSteadyState(cloneNodes(nodes), { + verbose: false, + }) + setNetwork(result) + } catch (error) { + console.error('Failed to calculate steady state:', error) + } + }, [nodes]) + + /** + * Handle scenario change + */ + const handleScenarioChange = useCallback((scenario: ScenarioV2) => { + setCurrentScenario(scenario) + setNodes(cloneNodes(scenario.nodes)) + setSelectedNodeId(null) + setSimulationTime(0) + }, []) + + /** + * Handle external inflow adjustment + */ + const handleInflowChange = useCallback( + (nodeId: string, newInflow: number) => { + setNodes(prev => + prev.map(n => + n.id === nodeId ? { ...n, externalInflow: newInflow } : n + ) + ) + }, + [] + ) + + /** + * Animation loop - update balances and particles + */ + useEffect(() => { + if (!isPlaying || !network) return + + let lastTime = performance.now() + let animationFrameId: number + + const animate = (currentTime: number) => { + const deltaMs = currentTime - lastTime + lastTime = currentTime + + const deltaSeconds = deltaMs / 1000 + + // Update simulation time + setSimulationTime(prev => prev + deltaSeconds) + + // Update node balances (for visualization) + const updatedNodes = cloneNodes(nodes) + + // Set total inflows/outflows from network calculation + if (network?.nodes) { + updatedNodes.forEach(node => { + const networkNode = network.nodes.get(node.id) + if (networkNode) { + node.totalInflow = networkNode.totalInflow + node.totalOutflow = networkNode.totalOutflow + } + }) + } + + updateBalances(updatedNodes, deltaSeconds) + setNodes(updatedNodes) + + // Update particles + setParticles(prev => { + const updated = prev + .map(p => ({ + ...p, + progress: p.progress + deltaSeconds / 2, // 2 second transit time + })) + .filter(p => p.progress < 1) + + // Spawn new particles + const now = currentTime / 1000 + if (network?.edges) { + network.edges.forEach(edge => { + // Spawn rate based on flow amount + const spawnRate = Math.min(2, Math.max(0.2, edge.flowRate / 500)) + const shouldSpawn = Math.random() < spawnRate * deltaSeconds + + if (shouldSpawn) { + updated.push({ + id: `${edge.source}-${edge.target}-${now}-${Math.random()}`, + sourceId: edge.source, + targetId: edge.target, + progress: 0, + startTime: now, + }) + } + }) + } + + return updated + }) + + animationFrameId = requestAnimationFrame(animate) + } + + animationFrameId = requestAnimationFrame(animate) + + return () => { + cancelAnimationFrame(animationFrameId) + } + }, [isPlaying, network, nodes]) + + /** + * Get node position + */ + const getNodePos = useCallback( + (nodeId: string): { x: number; y: number } => { + return currentScenario.layout.get(nodeId) || { x: 0, y: 0 } + }, + [currentScenario] + ) + + /** + * Get color for flow zone + */ + const getZoneColor = useCallback((node: FlowNode): string => { + const zone = getFlowZone(node) + switch (zone) { + case 'deficit': + return '#ef4444' // red + case 'building': + return '#f59e0b' // amber + case 'capacity': + return '#10b981' // green + } + }, []) + + /** + * Render network SVG + */ + const renderNetwork = useMemo(() => { + if (!network) return null + + const svgWidth = 800 + const svgHeight = 650 + + return ( + + {/* Edges */} + {network.edges.map(edge => { + const source = getNodePos(edge.source) + const target = getNodePos(edge.target) + + // Edge width based on flow rate (logarithmic scale) + const baseWidth = 2 + const maxWidth = 12 + const flowWidth = + baseWidth + + (maxWidth - baseWidth) * + Math.min(1, Math.log(edge.flowRate + 1) / Math.log(1000)) + + return ( + + {/* Edge line */} + + + {/* Flow label */} + + ${edge.flowRate.toFixed(0)}/mo + + + ) + })} + + {/* Flow particles */} + {particles.map(particle => { + const source = getNodePos(particle.sourceId) + const target = getNodePos(particle.targetId) + + const x = source.x + (target.x - source.x) * particle.progress + const y = source.y + (target.y - source.y) * particle.progress + + return ( + + ) + })} + + {/* Nodes */} + {Array.from(network.nodes.values()).map(node => { + const pos = getNodePos(node.id) + const zone = getFlowZone(node) + const color = getZoneColor(node) + const isSelected = selectedNodeId === node.id + + const totalInflow = node.totalInflow || 0 + const totalOutflow = node.totalOutflow || 0 + const retention = totalInflow - totalOutflow + + return ( + setSelectedNodeId(node.id)} + className="cursor-pointer" + > + {/* Selection ring */} + {isSelected && ( + + )} + + {/* Node circle */} + + + {/* Node label */} + + {node.name} + + + {/* Zone indicator */} + + {zone} + + + {/* Retention rate */} + + +${retention.toFixed(0)}/mo + + + ) + })} + + {/* Overflow node */} + {network.overflowNode && ( + + + + Overflow + + + ${network.overflowNode.totalInflow.toFixed(0)}/mo + + + )} + + {/* Arrow marker definition */} + + + + + + + ) + }, [network, particles, selectedNodeId, getNodePos, getZoneColor, currentScenario]) + + return ( +
+
+ {/* Header */} +
+

+ Flow Funding V2 +

+

+ Continuous flow dynamics with progressive outflow zones +

+
+ + {/* Controls */} +
+ {/* Scenario selector */} +
+ + +
+ + {/* Play/pause */} +
+ + +
+ + {/* Metrics toggle */} +
+ + +
+ + {/* Simulation time */} +
+ +
+ {simulationTime.toFixed(1)}s +
+
+
+ + {/* Scenario description */} +
+

{currentScenario.description}

+
+ + {/* Main layout */} +
+ {/* Network visualization */} +
+ {renderNetwork} +
+ + {/* Control panel */} +
+

External Inflows

+ + {/* Node inflow sliders */} + {nodes.map(node => { + const networkNode = network?.nodes.get(node.id) + const zone = networkNode ? getFlowZone(networkNode) : 'deficit' + const color = networkNode ? getZoneColor(networkNode) : '#ef4444' + + return ( +
+ {/* Node name and zone */} +
+ {node.name} + + {zone} + +
+ + {/* External inflow slider */} +
+ + + handleInflowChange(node.id, parseFloat(e.target.value)) + } + className="w-full" + /> +
+ + {/* Thresholds */} +
+
Min: ${node.minThreshold}/mo
+
Max: ${node.maxThreshold}/mo
+
+ + {/* Flow metrics */} + {showMetrics && networkNode && ( +
+
+ Total In: + + ${(networkNode.totalInflow || 0).toFixed(0)}/mo + +
+
+ Total Out: + + ${(networkNode.totalOutflow || 0).toFixed(0)}/mo + +
+
+ Retained: + + $ + {( + (networkNode.totalInflow || 0) - + (networkNode.totalOutflow || 0) + ).toFixed(0)} + /mo + +
+
+ Balance: + + ${(networkNode.balance || 0).toFixed(0)} + +
+
+ )} +
+ ) + })} + + {/* Network totals */} + {showMetrics && network && ( +
+

Network Totals

+
+
+ External Inflow: + + ${network.totalExternalInflow.toFixed(0)}/mo + +
+
+ Network Needs: + + ${network.totalNetworkNeeds.toFixed(0)}/mo + +
+
+ Network Capacity: + + ${network.totalNetworkCapacity.toFixed(0)}/mo + +
+ {network.overflowNode && ( +
+ Overflow: + + ${network.overflowNode.totalInflow.toFixed(0)}/mo + +
+ )} +
+
+ Converged: + + {network.converged ? 'āœ“ Yes' : 'āœ— No'} + +
+
+ {network.iterations} iterations +
+
+
+
+ )} +
+
+ + {/* Legend */} +
+

Flow Zones

+
+
+
+
+
Deficit Zone
+
+ Inflow below min threshold. Keep everything (0% outflow). +
+
+
+
+
+
+
Building Zone
+
+ Between min and max. Progressive sharing based on capacity. +
+
+
+
+
+
+
Capacity Zone
+
+ Above max threshold. Redirect 100% of excess. +
+
+
+
+
+
+
+ ) +} diff --git a/app/flowfunding/page.tsx b/app/flowfunding/page.tsx new file mode 100644 index 0000000..86da74a --- /dev/null +++ b/app/flowfunding/page.tsx @@ -0,0 +1,1029 @@ +'use client' + +/** + * Flow Funding Demo - Interactive Mode + * + * Enhanced with: + * - Animated flow particles + * - Distribution timeline + * - Auto-play with speed controls + * - INTERACTIVE: Click accounts to add targeted funding + */ + +import { useState, useEffect, useRef } from 'react' +import { + cloneAccounts, + runDistribution, +} from '@/lib/flow-funding/engine' +import { runTargetedDistribution } from '@/lib/flow-funding/targeted' +import type { Account, DistributionResult } from '@/lib/flow-funding/types' +import { getAccountState } from '@/lib/flow-funding/types' +import { + ALL_SCENARIOS, + getScenario, + type Scenario, +} from '@/lib/flow-funding/scenarios' + +// Flow particle for animation +interface FlowParticle { + id: string + fromX: number + fromY: number + toX: number + toY: number + progress: number // 0 to 1 + amount: number + color: string +} + +type FundingMode = 'global' | 'interactive' + +export default function FlowFundingPage() { + const [selectedScenarioId, setSelectedScenarioId] = useState( + 'mutual-aid-circle' + ) + const [funding, setFunding] = useState(1500) + const [targetedAmount, setTargetedAmount] = useState(500) + const [result, setResult] = useState(null) + const [currentIteration, setCurrentIteration] = useState(0) + const [animationSpeed, setAnimationSpeed] = useState(1) + const [particles, setParticles] = useState([]) + const [autoPlay, setAutoPlay] = useState(false) + const [fundingMode, setFundingMode] = useState('interactive') + + // Interactive mode state + const [selectedAccountId, setSelectedAccountId] = useState(null) + const [currentAccounts, setCurrentAccounts] = useState([]) + + const scenario = getScenario(selectedScenarioId) + const animationFrameRef = useRef() + + // Initialize current accounts when scenario changes + useEffect(() => { + if (scenario) { + setCurrentAccounts(cloneAccounts(scenario.accounts)) + setSelectedAccountId(null) + } + }, [selectedScenarioId]) + + // Auto-play through iterations + useEffect(() => { + if (!autoPlay || !result) return + + const interval = setInterval(() => { + setCurrentIteration(prev => { + if (prev >= result.iterations.length - 1) { + setAutoPlay(false) + return prev + } + return prev + 1 + }) + }, 1000 / animationSpeed) + + return () => clearInterval(interval) + }, [autoPlay, result, animationSpeed]) + + // Animate particles when iteration changes + useEffect(() => { + if (!result || !scenario) return + + const iteration = result.iterations[currentIteration] + if (!iteration || iteration.flows.size === 0) { + setParticles([]) + return + } + + // Create particles for each flow + const newParticles: FlowParticle[] = [] + let particleId = 0 + + iteration.flows.forEach((amount, flowKey) => { + const [sourceId, targetId] = flowKey.split('->') + const sourcePos = scenario.layout.get(sourceId) + const targetPos = scenario.layout.get(targetId) + + if (!sourcePos || !targetPos || amount <= 0) return + + const numParticles = Math.min(3, Math.max(1, Math.floor(amount / 100))) + + for (let i = 0; i < numParticles; i++) { + newParticles.push({ + id: `${flowKey}-${particleId++}`, + fromX: sourcePos.x, + fromY: sourcePos.y, + toX: targetPos.x, + toY: targetPos.y, + progress: i * (1 / numParticles), + amount: amount / numParticles, + color: '#60a5fa', + }) + } + }) + + setParticles(newParticles) + + // Animate particles + let startTime: number | null = null + const duration = 1500 / animationSpeed + + const animate = (timestamp: number) => { + if (!startTime) startTime = timestamp + const elapsed = timestamp - startTime + const progress = Math.min(elapsed / duration, 1) + + setParticles(prev => + prev.map(p => ({ + ...p, + progress: Math.min(p.progress + progress, 1), + })) + ) + + if (progress < 1) { + animationFrameRef.current = requestAnimationFrame(animate) + } else { + setParticles([]) + } + } + + animationFrameRef.current = requestAnimationFrame(animate) + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + } + } + }, [currentIteration, result, scenario, animationSpeed]) + + const handleScenarioChange = (scenarioId: string) => { + setSelectedScenarioId(scenarioId) + const newScenario = getScenario(scenarioId) + if (newScenario) { + setFunding(newScenario.suggestedFunding) + setCurrentAccounts(cloneAccounts(newScenario.accounts)) + } + setResult(null) + setCurrentIteration(0) + setAutoPlay(false) + setParticles([]) + setSelectedAccountId(null) + } + + const handleDistribute = () => { + if (!scenario) return + + const accounts = cloneAccounts(scenario.accounts) + const distributionResult = runDistribution(accounts, funding, { + verbose: true, + maxIterations: 100, + epsilon: 0.01, + }) + + setCurrentAccounts(accounts) + setResult(distributionResult) + setCurrentIteration(0) + setAutoPlay(true) + + console.log('Distribution complete:', distributionResult) + } + + const handleAddTargetedFunding = () => { + if (!selectedAccountId) return + + // Add funding to selected account + const accounts = cloneAccounts(currentAccounts) + const targetAccount = accounts.find(a => a.id === selectedAccountId) + + if (!targetAccount) return + + targetAccount.balance += targetedAmount + + console.log(`\nšŸ’° Adding $${targetedAmount} to ${targetAccount.name}`) + console.log(`New balance: $${targetAccount.balance.toFixed(2)}`) + + // Run targeted distribution + const distributionResult = runTargetedDistribution(accounts, { + verbose: true, + maxIterations: 100, + epsilon: 0.01, + }) + + setCurrentAccounts(accounts) + setResult(distributionResult) + setCurrentIteration(0) + setAutoPlay(true) + } + + const handleAccountClick = (accountId: string) => { + if (fundingMode === 'interactive') { + setSelectedAccountId(accountId) + } + } + + const handleReset = () => { + if (scenario) { + setCurrentAccounts(cloneAccounts(scenario.accounts)) + } + setResult(null) + setCurrentIteration(0) + setAutoPlay(false) + setParticles([]) + setSelectedAccountId(null) + } + + const handleTimelineClick = (iteration: number) => { + setCurrentIteration(iteration) + setAutoPlay(false) + } + + const handleNextStep = () => { + if (!result || currentIteration >= result.iterations.length - 1) return + setCurrentIteration(prev => prev + 1) + } + + const handlePrevStep = () => { + if (currentIteration <= 0) return + setCurrentIteration(prev => prev - 1) + } + + // Get current state for visualization + const getCurrentBalances = (): Map => { + if (!result) { + return new Map(currentAccounts.map(a => [a.id, a.balance])) + } + return result.iterations[currentIteration]?.balances || result.finalBalances + } + + const getCurrentOverflows = (): Map => { + if (!result || currentIteration < 0) { + return new Map() + } + return result.iterations[currentIteration]?.overflows || new Map() + } + + return ( +
+
+ {/* Header */} +
+

+ Flow Funding Demo +

+

+ {fundingMode === 'interactive' + ? 'šŸ’” Click any account to add funding and watch it propagate through the network' + : 'Watch resources flow through the network. Thresholds create circulation.' + } +

+
+ +
+ {/* Left Panel: Controls */} +
+ {/* Mode Selector */} +
+

Funding Mode

+
+ + +
+

+ {fundingMode === 'interactive' + ? 'Click accounts to add funding directly' + : 'Distribute funding equally to all accounts' + } +

+
+ + {/* Scenario Selector */} +
+

Select Scenario

+ + + {scenario && ( +

+ {scenario.description} +

+ )} +
+ + {/* Funding Controls */} + {fundingMode === 'global' ? ( +
+

Global Funding

+
+
+ + setFunding(Number(e.target.value))} + className="w-full bg-slate-900 border border-slate-600 rounded-lg px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-blue-500" + min="0" + step="100" + /> +
+ + + + +
+
+ ) : ( +
+

Interactive Funding

+
+ {selectedAccountId ? ( + <> +
+
+ Selected Account +
+
+ {currentAccounts.find(a => a.id === selectedAccountId)?.name} +
+
+ Current: ${currentAccounts.find(a => a.id === selectedAccountId)?.balance.toFixed(0)} +
+
+ +
+ + setTargetedAmount(Number(e.target.value))} + className="w-full bg-slate-900 border border-slate-600 rounded-lg px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-blue-500" + min="0" + step="100" + /> +
+ + + + + + ) : ( +
+
šŸ‘†
+
+ Click an account on the network +
+
+ to add funding and watch it propagate +
+
+ )} + + +
+
+ )} + + {/* Playback Controls */} + {result && ( +
+

+ Playback Controls +

+
+
+ Iteration {currentIteration + 1} of {result.iterations.length} +
+ + + +
+ +
+ {[1, 2, 5].map(speed => ( + + ))} +
+
+ +
+ + +
+ + {result.converged ? ( +
+ āœ“ Converged in {result.iterationCount} iterations +
+ ) : ( +
+ ⚠ Did not converge +
+ )} +
+
+ )} + + {/* Legend */} +
+

Account States

+
+
+
+ + Below Minimum + +
+
+
+ + Sustainable + +
+
+
+ At Maximum +
+
+
+ + Overflowing + +
+ {fundingMode === 'interactive' && ( +
+
+ + Selected (click to target) + +
+ )} +
+
+
+ + {/* Right Panel: Visualization */} +
+
+

Network Visualization

+ + {scenario && ( + + )} +
+ + {/* Timeline */} + {result && ( +
+

Distribution Timeline

+ +
+ )} + + {/* Results Table */} + {result && ( +
+

+ Distribution Results +

+ +
+ )} +
+
+
+
+ ) +} + +/** + * Network Visualization Component with Animated Particles and Click Handlers + */ +function NetworkVisualization({ + scenario, + balances, + overflows, + flows, + particles, + selectedAccountId, + onAccountClick, + interactiveMode, +}: { + scenario: Scenario + balances: Map + overflows: Map + flows: Map + particles: FlowParticle[] + selectedAccountId: string | null + onAccountClick: (accountId: string) => void + interactiveMode: boolean +}) { + const { accounts, layout } = scenario + + const padding = 80 + const maxX = Math.max(...Array.from(layout.values()).map(p => p.x)) + padding + const maxY = Math.max(...Array.from(layout.values()).map(p => p.y)) + padding + + return ( + + + + + + + + + + + + + + + + + + + {/* Draw arrows */} + {accounts.map(account => { + const sourcePos = layout.get(account.id) + if (!sourcePos) return null + + return Array.from(account.allocations.entries()).map( + ([targetId, percentage]) => { + const targetPos = layout.get(targetId) + if (!targetPos) return null + + const flowKey = `${account.id}->${targetId}` + const flowAmount = flows.get(flowKey) || 0 + const isActive = flowAmount > 0 + + return ( + + + + {percentage}% + + {isActive && ( + + ${flowAmount.toFixed(0)} + + )} + + ) + } + ) + })} + + {/* Draw animated particles */} + {particles.map(particle => { + const x = particle.fromX + (particle.toX - particle.fromX) * particle.progress + const y = particle.fromY + (particle.toY - particle.fromY) * particle.progress + const size = Math.max(4, Math.min(8, particle.amount / 50)) + + return ( + + ) + })} + + {/* Draw accounts */} + {accounts.map(account => { + const pos = layout.get(account.id) + if (!pos) return null + + const balance = balances.get(account.id) || account.balance + const overflow = overflows.get(account.id) || 0 + const state = getAccountState( + balance, + account.minThreshold, + account.maxThreshold, + overflow > 0 + ) + + const isSelected = selectedAccountId === account.id + + const stateColors = { + 'below-minimum': '#ef4444', + sustainable: '#eab308', + 'at-maximum': '#22c55e', + overflowing: '#3b82f6', + } + + const color = isSelected ? '#a855f7' : stateColors[state] + + return ( + onAccountClick(account.id)} + style={{ cursor: interactiveMode ? 'pointer' : 'default' }} + > + {/* Selection ring */} + {isSelected && ( + + + + )} + + {/* Account circle */} + + + {/* Account name */} + + {account.name} + + + {/* Balance */} + + ${balance.toFixed(0)} + + + {/* Thresholds */} + + ({account.minThreshold}–{account.maxThreshold}) + + + ) + })} + + ) +} + +/** + * Distribution Timeline Component + */ +function DistributionTimeline({ + result, + currentIteration, + onIterationClick, +}: { + result: DistributionResult + currentIteration: number + onIterationClick: (iteration: number) => void +}) { + const maxOverflow = Math.max( + ...result.iterations.map(iter => iter.totalOverflow), + 1 + ) + + return ( +
+
+ Click any iteration to jump to that state + + Total overflow decreases over time → + +
+ +
+
+ {result.iterations.map((iter, index) => { + const isCurrent = index === currentIteration + const dotSize = Math.max( + 8, + Math.min(20, (iter.totalOverflow / maxOverflow) * 20) + ) + + return ( + + ) + })} +
+
+ +
+
+ Start:{' '} + ${result.iterations[0]?.totalOverflow.toFixed(2)} +
+
+ Current:{' '} + ${result.iterations[currentIteration]?.totalOverflow.toFixed(2) || '0.00'} +
+
+ End:{' '} + ${result.iterations[result.iterations.length - 1]?.totalOverflow.toFixed(2)} +
+
+
+ ) +} + +/** + * Results Table Component + */ +function ResultsTable({ + scenario, + result, + currentIteration, +}: { + scenario: Scenario + result: DistributionResult + currentIteration: number +}) { + const iteration = result.iterations[currentIteration] + const balances = iteration?.balances || result.finalBalances + + return ( +
+ + + + + + + + + + + + + {scenario.accounts.map(account => { + const initial = result.initialBalances.get(account.id) || 0 + const current = balances.get(account.id) || 0 + const change = current - initial + const overflow = iteration?.overflows.get(account.id) || 0 + const state = getAccountState( + current, + account.minThreshold, + account.maxThreshold, + overflow > 0 + ) + + const stateLabels = { + 'below-minimum': 'Below Min', + sustainable: 'Sustainable', + 'at-maximum': 'At Max', + overflowing: 'Overflowing', + } + + const stateColors = { + 'below-minimum': 'text-red-400', + sustainable: 'text-yellow-400', + 'at-maximum': 'text-green-400', + overflowing: 'text-blue-400', + } + + return ( + + + + + + + + + ) + })} + +
+ Account + + Initial + + Current + + Change + + Min/Max + + State +
{account.name} + ${initial.toFixed(2)} + + ${current.toFixed(2)} + = 0 ? 'text-green-400' : 'text-red-400' + }`} + > + {change >= 0 ? '+' : ''}${change.toFixed(2)} + + ${account.minThreshold} / ${account.maxThreshold} + + {stateLabels[state]} +
+
+ ) +} diff --git a/app/tbff/page.tsx b/app/tbff/page.tsx new file mode 100644 index 0000000..549e5eb --- /dev/null +++ b/app/tbff/page.tsx @@ -0,0 +1,778 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import Link from "next/link" +import type { FlowFundingNetwork, Allocation } from "@/lib/tbff/types" +import { renderNetwork } from "@/lib/tbff/rendering" +import { sampleNetworks, networkOptions, getSampleNetwork } from "@/lib/tbff/sample-networks" +import { formatCurrency, getStatusColorClass, normalizeAllocations, calculateNetworkTotals, updateAccountComputedProperties } from "@/lib/tbff/utils" +import { initialDistribution, getDistributionSummary } from "@/lib/tbff/algorithms" + +type Tool = 'select' | 'create-allocation' + +export default function TBFFPage() { + const canvasRef = useRef(null) + const [network, setNetwork] = useState(sampleNetworks.statesDemo) + const [selectedAccountId, setSelectedAccountId] = useState(null) + const [selectedAllocationId, setSelectedAllocationId] = useState(null) + const [selectedNetworkKey, setSelectedNetworkKey] = useState('statesDemo') + const [tool, setTool] = useState('select') + const [allocationSourceId, setAllocationSourceId] = useState(null) + const [isDragging, setIsDragging] = useState(false) + const [draggedAccountId, setDraggedAccountId] = useState(null) + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) + const [mouseDownPos, setMouseDownPos] = useState<{ x: number; y: number } | null>(null) + const [fundingAmount, setFundingAmount] = useState(1000) + const [lastDistribution, setLastDistribution] = useState<{ + totalDistributed: number + accountsChanged: number + changes: Array<{ accountId: string; name: string; before: number; after: number; delta: number }> + } | null>(null) + + // Render canvas whenever network changes + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext("2d") + if (!ctx) return + + // Set canvas size + canvas.width = canvas.offsetWidth + canvas.height = canvas.offsetHeight + + // Render the network + renderNetwork(ctx, network, canvas.width, canvas.height, selectedAccountId, selectedAllocationId) + }, [network, selectedAccountId, selectedAllocationId]) + + // Handle mouse down - record position for all interactions + const handleMouseDown = (e: React.MouseEvent) => { + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + // Always record mouse down position + setMouseDownPos({ x, y }) + + // Find clicked account + const clickedAccount = network.accounts.find( + (acc) => + x >= acc.x && + x <= acc.x + acc.width && + y >= acc.y && + y <= acc.y + acc.height + ) + + // Prepare for potential drag (only in select mode) + if (tool === 'select' && clickedAccount) { + setDraggedAccountId(clickedAccount.id) + setDragOffset({ + x: x - clickedAccount.x, + y: y - clickedAccount.y, + }) + } + } + + // Handle mouse move - start drag if threshold exceeded (select mode only) + const handleMouseMove = (e: React.MouseEvent) => { + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + // Only handle dragging in select mode + if (tool === 'select' && mouseDownPos && draggedAccountId && !isDragging) { + const dx = x - mouseDownPos.x + const dy = y - mouseDownPos.y + const distance = Math.sqrt(dx * dx + dy * dy) + + // Start drag if moved more than 5 pixels + if (distance > 5) { + setIsDragging(true) + } + } + + // If dragging, update position + if (isDragging && draggedAccountId) { + const updatedNetwork = calculateNetworkTotals({ + ...network, + accounts: network.accounts.map((acc) => + acc.id === draggedAccountId + ? updateAccountComputedProperties({ + ...acc, + x: x - dragOffset.x, + y: y - dragOffset.y, + }) + : acc + ), + }) + + setNetwork(updatedNetwork) + } + } + + // Handle mouse up - end dragging or handle click + const handleMouseUp = (e: React.MouseEvent) => { + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + // If was dragging, just end drag + if (isDragging) { + setIsDragging(false) + setDraggedAccountId(null) + setMouseDownPos(null) + return + } + + // Clear drag-related state + setDraggedAccountId(null) + setMouseDownPos(null) + + // Find what was clicked + const clickedAccount = network.accounts.find( + (acc) => + x >= acc.x && + x <= acc.x + acc.width && + y >= acc.y && + y <= acc.y + acc.height + ) + + // Handle based on current tool + if (tool === 'select') { + if (clickedAccount) { + setSelectedAccountId(clickedAccount.id) + setSelectedAllocationId(null) + } else { + // Check if clicked on an allocation arrow + const clickedAllocation = findAllocationAtPoint(x, y) + if (clickedAllocation) { + setSelectedAllocationId(clickedAllocation.id) + setSelectedAccountId(null) + } else { + setSelectedAccountId(null) + setSelectedAllocationId(null) + } + } + } else if (tool === 'create-allocation') { + if (clickedAccount) { + if (!allocationSourceId) { + // First click - set source + setAllocationSourceId(clickedAccount.id) + } else { + // Second click - create allocation + if (clickedAccount.id !== allocationSourceId) { + createAllocation(allocationSourceId, clickedAccount.id) + } + setAllocationSourceId(null) + } + } + } + } + + // Handle mouse leave - only cancel drag, don't deselect + const handleMouseLeave = () => { + if (isDragging) { + setIsDragging(false) + } + setDraggedAccountId(null) + setMouseDownPos(null) + } + + // Find allocation at point (simple distance check) + const findAllocationAtPoint = (x: number, y: number): Allocation | null => { + const tolerance = 15 + + for (const allocation of network.allocations) { + const source = network.accounts.find(a => a.id === allocation.sourceAccountId) + const target = network.accounts.find(a => a.id === allocation.targetAccountId) + + if (!source || !target) continue + + const sourceCenter = { x: source.x + source.width / 2, y: source.y + source.height / 2 } + const targetCenter = { x: target.x + target.width / 2, y: target.y + target.height / 2 } + + const distance = pointToLineDistance(x, y, sourceCenter.x, sourceCenter.y, targetCenter.x, targetCenter.y) + + if (distance < tolerance) { + return allocation + } + } + + return null + } + + // Point to line distance calculation + const pointToLineDistance = ( + px: number, + py: number, + x1: number, + y1: number, + x2: number, + y2: number + ): number => { + const A = px - x1 + const B = py - y1 + const C = x2 - x1 + const D = y2 - y1 + + const dot = A * C + B * D + const lenSq = C * C + D * D + let param = -1 + + if (lenSq !== 0) { + param = dot / lenSq + } + + let xx, yy + + if (param < 0) { + xx = x1 + yy = y1 + } else if (param > 1) { + xx = x2 + yy = y2 + } else { + xx = x1 + param * C + yy = y1 + param * D + } + + const dx = px - xx + const dy = py - yy + + return Math.sqrt(dx * dx + dy * dy) + } + + // Create new allocation + const createAllocation = (sourceId: string, targetId: string) => { + const newAllocation: Allocation = { + id: `alloc_${Date.now()}`, + sourceAccountId: sourceId, + targetAccountId: targetId, + percentage: 0.5, // Default 50% + } + + // Add allocation and normalize + const updatedAllocations = [...network.allocations, newAllocation] + const sourceAllocations = updatedAllocations.filter(a => a.sourceAccountId === sourceId) + const normalized = normalizeAllocations(sourceAllocations) + + // Replace source allocations with normalized ones + const finalAllocations = updatedAllocations.map(a => { + const normalizedVersion = normalized.find(n => n.id === a.id) + return normalizedVersion || a + }) + + const updatedNetwork = calculateNetworkTotals({ + ...network, + allocations: finalAllocations, + }) + + setNetwork(updatedNetwork) + setSelectedAllocationId(newAllocation.id) + } + + // Update allocation percentage + const updateAllocationPercentage = (allocationId: string, newPercentage: number) => { + const allocation = network.allocations.find(a => a.id === allocationId) + if (!allocation) return + + const updatedAllocations = network.allocations.map(a => + a.id === allocationId ? { ...a, percentage: Math.max(0, Math.min(1, newPercentage)) } : a + ) + + // Normalize all allocations from the same source + const sourceAllocations = updatedAllocations.filter( + a => a.sourceAccountId === allocation.sourceAccountId + ) + const normalized = normalizeAllocations(sourceAllocations) + + // Replace source allocations with normalized ones + const finalAllocations = updatedAllocations.map(a => { + const normalizedVersion = normalized.find(n => n.id === a.id) + return normalizedVersion || a + }) + + const updatedNetwork = calculateNetworkTotals({ + ...network, + allocations: finalAllocations, + }) + + setNetwork(updatedNetwork) + } + + // Delete allocation + const deleteAllocation = (allocationId: string) => { + const allocation = network.allocations.find(a => a.id === allocationId) + if (!allocation) return + + const updatedAllocations = network.allocations.filter(a => a.id !== allocationId) + + // Normalize remaining allocations from the same source + const sourceAllocations = updatedAllocations.filter( + a => a.sourceAccountId === allocation.sourceAccountId + ) + const normalized = normalizeAllocations(sourceAllocations) + + // Replace source allocations with normalized ones + const finalAllocations = updatedAllocations.map(a => { + const normalizedVersion = normalized.find(n => n.id === a.id) + return normalizedVersion || a + }) + + const updatedNetwork = calculateNetworkTotals({ + ...network, + allocations: finalAllocations, + }) + + setNetwork(updatedNetwork) + setSelectedAllocationId(null) + } + + // Load different network + const handleLoadNetwork = (key: string) => { + setSelectedNetworkKey(key) + const newNetwork = getSampleNetwork(key as keyof typeof sampleNetworks) + setNetwork(newNetwork) + setSelectedAccountId(null) + setSelectedAllocationId(null) + setAllocationSourceId(null) + setTool('select') + } + + // Add funding to network + const handleAddFunding = () => { + if (fundingAmount <= 0) { + console.warn('āš ļø Funding amount must be positive') + return + } + + const beforeNetwork = network + const afterNetwork = initialDistribution(network, fundingAmount) + const summary = getDistributionSummary(beforeNetwork, afterNetwork) + + setNetwork(afterNetwork) + setLastDistribution(summary) + + console.log(`\nāœ… Distribution Complete`) + console.log(`Total distributed: ${summary.totalDistributed.toFixed(0)}`) + console.log(`Accounts changed: ${summary.accountsChanged}`) + } + + // Get selected account/allocation details + const selectedAccount = selectedAccountId + ? network.accounts.find((a) => a.id === selectedAccountId) + : null + + const selectedAllocation = selectedAllocationId + ? network.allocations.find((a) => a.id === selectedAllocationId) + : null + + // Get allocations from selected account + const outgoingAllocations = selectedAccount + ? network.allocations.filter(a => a.sourceAccountId === selectedAccount.id) + : [] + + // Get allocations from selected allocation's source (for checking if single) + const selectedAllocationSiblings = selectedAllocation + ? network.allocations.filter(a => a.sourceAccountId === selectedAllocation.sourceAccountId) + : [] + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setTool('select') + setAllocationSourceId(null) + setSelectedAccountId(null) + setSelectedAllocationId(null) + } else if (e.key === 'Delete' && selectedAllocationId) { + deleteAllocation(selectedAllocationId) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [selectedAllocationId]) + + return ( +
+ {/* Header */} +
+
+

+ Threshold-Based Flow Funding +

+

+ Milestone 3: Initial Distribution +

+
+ + ← Back to Home + +
+ + {/* Main Content */} +
+ {/* Canvas */} +
+ + + {/* Tool indicator */} + {allocationSourceId && ( +
+ Click target account to create allocation +
+ )} +
+ + {/* Sidebar */} +
+ {/* Tools */} +
+

Tools

+
+ + +
+
+ + {/* Network Selector */} +
+

Select Network

+ +
+ + {/* Network Info */} +
+

{network.name}

+
+
+ Accounts: + {network.accounts.length} +
+
+ Allocations: + {network.allocations.length} +
+
+ Total Funds: + {formatCurrency(network.totalFunds)} +
+
+ Shortfall: + {formatCurrency(network.totalShortfall)} +
+
+ Capacity: + {formatCurrency(network.totalCapacity)} +
+
+ Overflow: + {formatCurrency(network.totalOverflow)} +
+
+
+ + {/* Funding Controls */} +
+

šŸ’° Add Funding

+
+
+ + setFundingAmount(parseFloat(e.target.value) || 0)} + className="w-full px-3 py-2 bg-slate-700 rounded text-sm" + min="0" + step="100" + /> +
+ +
+ + {/* Distribution Summary */} + {lastDistribution && ( +
+
+
+ Distributed: + + {formatCurrency(lastDistribution.totalDistributed)} + +
+
+ Accounts Changed: + {lastDistribution.accountsChanged} +
+ {lastDistribution.changes.length > 0 && ( +
+
Changes:
+ {lastDistribution.changes.map((change) => ( +
+ {change.name} + + +{formatCurrency(change.delta)} + +
+ ))} +
+ )} +
+
+ )} +
+ + {/* Selected Allocation Editor */} + {selectedAllocation && ( +
+

Edit Allocation

+
+
+ From: + + {network.accounts.find(a => a.id === selectedAllocation.sourceAccountId)?.name} + +
+
+ To: + + {network.accounts.find(a => a.id === selectedAllocation.targetAccountId)?.name} + +
+
+ + {selectedAllocationSiblings.length === 1 ? ( +
+ Single allocation must be 100%. Create additional allocations to split overflow. +
+ ) : ( + <> + + updateAllocationPercentage( + selectedAllocation.id, + parseFloat(e.target.value) / 100 + ) + } + className="w-full" + /> +
+ Note: Percentages auto-normalize with other allocations from same source +
+ + )} +
+ +
+
+ )} + + {/* Selected Account Details */} + {selectedAccount && ( +
+

Account Details

+
+
+ Name: + {selectedAccount.name} +
+
+ Status: + + {selectedAccount.status.toUpperCase()} + +
+
+
+ Balance: + + {formatCurrency(selectedAccount.balance)} + +
+
+ Min Threshold: + + {formatCurrency(selectedAccount.minThreshold)} + +
+
+ Max Threshold: + + {formatCurrency(selectedAccount.maxThreshold)} + +
+
+ + {/* Outgoing Allocations */} + {outgoingAllocations.length > 0 && ( +
+
Outgoing Allocations:
+ {outgoingAllocations.map((alloc) => { + const target = network.accounts.find(a => a.id === alloc.targetAccountId) + return ( +
setSelectedAllocationId(alloc.id)} + > + → {target?.name} + + {Math.round(alloc.percentage * 100)}% + +
+ ) + })} +
+ )} +
+
+ )} + + {/* Account List */} +
+

All Accounts

+
+ {network.accounts.map((acc) => ( + + ))} +
+
+ + {/* Legend */} +
+

Legend

+
+
+
+ Deficit - Below minimum threshold +
+
+
+ Minimum - At minimum threshold +
+
+
+ Healthy - Between thresholds +
+
+
+ Overflow - Above maximum threshold +
+
+
+ + {/* Instructions */} +
+

+ Milestone 3: Initial Distribution +

+
    +
  • Add funding to distribute across accounts
  • +
  • Drag accounts to reposition them
  • +
  • Use Create Arrow tool to draw allocations
  • +
  • Click arrow to edit percentage
  • +
  • Press Delete to remove allocation
  • +
  • Check console for distribution logs
  • +
+
+
+
+
+ ) +} diff --git a/lib/flow-funding/engine.ts b/lib/flow-funding/engine.ts new file mode 100644 index 0000000..c71fa57 --- /dev/null +++ b/lib/flow-funding/engine.ts @@ -0,0 +1,464 @@ +/** + * Flow Funding Algorithm Engine + * + * Implements the threshold-based flow funding mechanism as specified in + * threshold-based-flow-funding.md + * + * Algorithm phases: + * 1. Initial Distribution: Prioritize minimum thresholds, then fill capacity + * 2. Overflow Calculation: Identify funds exceeding maximum thresholds + * 3. Overflow Redistribution: Redistribute overflow according to allocations + * 4. Recursive Processing: Repeat until convergence + */ + +import type { + Account, + DistributionResult, + IterationResult, + ValidationResult, +} from './types' + +/** + * Configuration for the distribution algorithm + */ +export interface DistributionConfig { + /** Maximum iterations before stopping (default: 100) */ + maxIterations?: number + /** Convergence threshold - stop when total overflow < epsilon (default: 0.01) */ + epsilon?: number + /** Enable detailed logging (default: false) */ + verbose?: boolean +} + +const DEFAULT_CONFIG: Required = { + maxIterations: 100, + epsilon: 0.01, + verbose: false, +} + +/** + * Validates a flow funding network + */ +export function validateNetwork(accounts: Account[]): ValidationResult { + const errors: string[] = [] + const warnings: string[] = [] + + if (accounts.length === 0) { + errors.push('Network must contain at least one account') + return { valid: false, errors, warnings } + } + + const accountIds = new Set(accounts.map(a => a.id)) + + for (const account of accounts) { + // Check threshold validity + if (account.minThreshold < 0) { + errors.push(`Account ${account.id}: minimum threshold must be non-negative`) + } + if (account.maxThreshold < 0) { + errors.push(`Account ${account.id}: maximum threshold must be non-negative`) + } + if (account.minThreshold > account.maxThreshold) { + errors.push( + `Account ${account.id}: minimum threshold (${account.minThreshold}) ` + + `exceeds maximum threshold (${account.maxThreshold})` + ) + } + + // Check balance validity + if (account.balance < 0) { + errors.push(`Account ${account.id}: balance must be non-negative`) + } + + // Check allocations + let totalAllocation = 0 + for (const [targetId, percentage] of account.allocations.entries()) { + if (percentage < 0 || percentage > 100) { + errors.push( + `Account ${account.id}: allocation to ${targetId} must be between 0 and 100` + ) + } + if (!accountIds.has(targetId)) { + errors.push( + `Account ${account.id}: allocation target ${targetId} does not exist` + ) + } + if (targetId === account.id) { + errors.push(`Account ${account.id}: cannot allocate to itself`) + } + totalAllocation += percentage + } + + if (totalAllocation > 100.01) { // Allow small floating point error + errors.push( + `Account ${account.id}: total allocations (${totalAllocation}%) exceed 100%` + ) + } + + // Warnings + if (account.allocations.size === 0 && accounts.length > 1) { + warnings.push( + `Account ${account.id}: has no outgoing allocations (overflow will be lost)` + ) + } + + const hasIncoming = accounts.some(a => + Array.from(a.allocations.keys()).includes(account.id) + ) + if (!hasIncoming && account.balance === 0) { + warnings.push( + `Account ${account.id}: has no incoming allocations and zero balance ` + + `(will never receive funds)` + ) + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + } +} + +/** + * Phase 1: Initial Distribution + * + * Distributes external funding, prioritizing minimum thresholds + * then filling remaining capacity up to maximum thresholds + */ +function distributeInitial( + accounts: Account[], + funding: number, + verbose: boolean +): void { + if (verbose) { + console.log(`\n=== Initial Distribution: $${funding.toFixed(2)} ===`) + } + + // Calculate total minimum requirement + let totalMinRequired = 0 + const minShortfalls = new Map() + + for (const account of accounts) { + const shortfall = Math.max(0, account.minThreshold - account.balance) + if (shortfall > 0) { + minShortfalls.set(account.id, shortfall) + totalMinRequired += shortfall + } + } + + if (verbose) { + console.log(`Total minimum requirement: $${totalMinRequired.toFixed(2)}`) + } + + // Case 1: Insufficient funds to meet all minimums + if (funding < totalMinRequired) { + if (verbose) { + console.log('Insufficient funds - distributing proportionally to minimums') + } + + for (const account of accounts) { + const shortfall = minShortfalls.get(account.id) || 0 + if (shortfall > 0) { + const allocation = (shortfall / totalMinRequired) * funding + account.balance += allocation + + if (verbose) { + console.log( + ` ${account.id}: +$${allocation.toFixed(2)} ` + + `(${((shortfall / totalMinRequired) * 100).toFixed(1)}% of funding)` + ) + } + } + } + return + } + + // Case 2: Can meet all minimums + if (verbose) { + console.log('Sufficient funds - meeting all minimums first') + } + + // Step 1: Fill all minimums + for (const account of accounts) { + const shortfall = minShortfalls.get(account.id) || 0 + if (shortfall > 0) { + account.balance = account.minThreshold + if (verbose) { + console.log(` ${account.id}: filled to minimum ($${account.minThreshold.toFixed(2)})`) + } + } + } + + // Step 2: Distribute remaining funds based on capacity + const remaining = funding - totalMinRequired + if (remaining <= 0) return + + if (verbose) { + console.log(`\nDistributing remaining $${remaining.toFixed(2)} based on capacity`) + } + + // Calculate total remaining capacity + let totalCapacity = 0 + const capacities = new Map() + + for (const account of accounts) { + const capacity = Math.max(0, account.maxThreshold - account.balance) + if (capacity > 0) { + capacities.set(account.id, capacity) + totalCapacity += capacity + } + } + + if (totalCapacity === 0) { + if (verbose) { + console.log('No remaining capacity - all accounts at maximum') + } + return + } + + // Distribute proportionally to capacity + for (const account of accounts) { + const capacity = capacities.get(account.id) || 0 + if (capacity > 0) { + const allocation = (capacity / totalCapacity) * remaining + account.balance += allocation + + if (verbose) { + console.log( + ` ${account.id}: +$${allocation.toFixed(2)} ` + + `(${((capacity / totalCapacity) * 100).toFixed(1)}% of remaining)` + ) + } + } + } +} + +/** + * Phase 2: Calculate Overflow + * + * Identifies funds exceeding maximum thresholds + * Returns overflow amounts and adjusts balances + */ +function calculateOverflow( + accounts: Account[], + verbose: boolean +): Map { + const overflows = new Map() + let totalOverflow = 0 + + for (const account of accounts) { + const overflow = Math.max(0, account.balance - account.maxThreshold) + if (overflow > 0) { + overflows.set(account.id, overflow) + totalOverflow += overflow + // Adjust balance to maximum + account.balance = account.maxThreshold + + if (verbose) { + console.log(` ${account.id}: overflow $${overflow.toFixed(2)}`) + } + } + } + + if (verbose && totalOverflow > 0) { + console.log(`Total overflow: $${totalOverflow.toFixed(2)}`) + } + + return overflows +} + +/** + * Phase 3: Redistribute Overflow + * + * Redistributes overflow according to allocation preferences + * Returns true if any redistribution occurred + */ +function redistributeOverflow( + accounts: Account[], + overflows: Map, + verbose: boolean +): Map { + const accountMap = new Map(accounts.map(a => [a.id, a])) + const flows = new Map() + + if (verbose && overflows.size > 0) { + console.log('\n Redistributing overflow:') + } + + for (const [sourceId, overflow] of overflows.entries()) { + const source = accountMap.get(sourceId) + if (!source) continue + + // Normalize allocations (should sum to ≤100%) + let totalAllocation = 0 + for (const percentage of source.allocations.values()) { + totalAllocation += percentage + } + + if (totalAllocation === 0) { + if (verbose) { + console.log(` ${sourceId}: no allocations - overflow lost`) + } + continue + } + + // Distribute overflow according to allocations + for (const [targetId, percentage] of source.allocations.entries()) { + const target = accountMap.get(targetId) + if (!target) continue + + const normalizedPercentage = percentage / totalAllocation + const amount = overflow * normalizedPercentage + + target.balance += amount + flows.set(`${sourceId}->${targetId}`, amount) + + if (verbose) { + console.log( + ` ${sourceId} → ${targetId}: $${amount.toFixed(2)} ` + + `(${percentage}% of overflow)` + ) + } + } + } + + return flows +} + +/** + * Main distribution function + * + * Runs the complete flow funding algorithm: + * 1. Initial distribution + * 2. Iterative overflow redistribution until convergence + */ +export function runDistribution( + accounts: Account[], + funding: number, + config: DistributionConfig = {} +): DistributionResult { + const cfg = { ...DEFAULT_CONFIG, ...config } + const { maxIterations, epsilon, verbose } = cfg + + // Validate network + const validation = validateNetwork(accounts) + if (!validation.valid) { + throw new Error( + `Invalid network:\n${validation.errors.join('\n')}` + ) + } + + if (verbose && validation.warnings.length > 0) { + console.log('āš ļø Warnings:') + validation.warnings.forEach(w => console.log(` ${w}`)) + } + + // Store initial state + const initialBalances = new Map( + accounts.map(a => [a.id, a.balance]) + ) + + if (verbose) { + console.log('\nšŸ“Š Initial State:') + accounts.forEach(a => { + console.log( + ` ${a.id}: $${a.balance.toFixed(2)} ` + + `(min: $${a.minThreshold.toFixed(2)}, max: $${a.maxThreshold.toFixed(2)})` + ) + }) + } + + // Phase 1: Initial distribution + distributeInitial(accounts, funding, verbose) + + // Phase 2-4: Iterative overflow redistribution + const iterations: IterationResult[] = [] + let converged = false + + for (let i = 0; i < maxIterations; i++) { + if (verbose) { + console.log(`\n--- Iteration ${i} ---`) + } + + // Calculate overflow + const overflows = calculateOverflow(accounts, verbose) + const totalOverflow = Array.from(overflows.values()).reduce( + (sum, o) => sum + o, + 0 + ) + + // Record iteration state + const iteration: IterationResult = { + iteration: i, + balances: new Map(accounts.map(a => [a.id, a.balance])), + overflows, + totalOverflow, + flows: new Map(), + converged: totalOverflow < epsilon, + } + + // Check convergence + if (totalOverflow < epsilon) { + if (verbose) { + console.log(`āœ“ Converged (overflow < ${epsilon})`) + } + converged = true + iterations.push(iteration) + break + } + + // Redistribute overflow + const flows = redistributeOverflow(accounts, overflows, verbose) + iteration.flows = flows + + iterations.push(iteration) + + if (verbose) { + console.log('\n Balances after redistribution:') + accounts.forEach(a => { + console.log(` ${a.id}: $${a.balance.toFixed(2)}`) + }) + } + } + + if (!converged && verbose) { + console.log(`\nāš ļø Did not converge within ${maxIterations} iterations`) + } + + // Final state + const finalBalances = new Map( + accounts.map(a => [a.id, a.balance]) + ) + + if (verbose) { + console.log('\nšŸŽÆ Final State:') + accounts.forEach(a => { + const initial = initialBalances.get(a.id) || 0 + const change = a.balance - initial + console.log( + ` ${a.id}: $${a.balance.toFixed(2)} ` + + `(${change >= 0 ? '+' : ''}$${change.toFixed(2)})` + ) + }) + } + + return { + initialBalances, + finalBalances, + iterations, + converged, + totalFunding: funding, + iterationCount: iterations.length, + } +} + +/** + * Helper: Create a deep copy of accounts for simulation + */ +export function cloneAccounts(accounts: Account[]): Account[] { + return accounts.map(a => ({ + ...a, + allocations: new Map(a.allocations), + })) +} diff --git a/lib/flow-funding/scenarios.ts b/lib/flow-funding/scenarios.ts new file mode 100644 index 0000000..6ce58c6 --- /dev/null +++ b/lib/flow-funding/scenarios.ts @@ -0,0 +1,409 @@ +/** + * Preset Flow Funding Scenarios + * + * Each scenario demonstrates different network topologies and flow patterns + */ + +import type { Account } from './types' + +export interface Scenario { + id: string + name: string + description: string + accounts: Account[] + suggestedFunding: number + /** Visual layout positions for rendering (x, y in pixels) */ + layout: Map +} + +/** + * Scenario 1: Linear Chain + * A → B → C → D + * + * Demonstrates simple cascading flow + */ +export const linearChain: Scenario = { + id: 'linear-chain', + name: 'Linear Chain', + description: + 'A simple chain showing funds flowing from left to right. ' + + 'Overflow from each account flows to the next in line.', + suggestedFunding: 1000, + accounts: [ + { + id: 'A', + name: 'Alice', + balance: 0, + minThreshold: 100, + maxThreshold: 300, + allocations: new Map([['B', 100]]), + }, + { + id: 'B', + name: 'Bob', + balance: 0, + minThreshold: 150, + maxThreshold: 350, + allocations: new Map([['C', 100]]), + }, + { + id: 'C', + name: 'Carol', + balance: 0, + minThreshold: 100, + maxThreshold: 300, + allocations: new Map([['D', 100]]), + }, + { + id: 'D', + name: 'David', + balance: 0, + minThreshold: 200, + maxThreshold: 400, + allocations: new Map(), // End of chain + }, + ], + layout: new Map([ + ['A', { x: 100, y: 250 }], + ['B', { x: 250, y: 250 }], + ['C', { x: 400, y: 250 }], + ['D', { x: 550, y: 250 }], + ]), +} + +/** + * Scenario 2: Mutual Aid Circle + * A ↔ B ↔ C ↔ A + * + * Demonstrates circular solidarity and equilibrium + */ +export const mutualAidCircle: Scenario = { + id: 'mutual-aid-circle', + name: 'Mutual Aid Circle', + description: + 'Three people in a circular mutual aid network. Each person allocates their ' + + 'overflow to help the next person in the circle, creating a self-balancing system.', + suggestedFunding: 1500, + accounts: [ + { + id: 'A', + name: 'Alice', + balance: 0, + minThreshold: 200, + maxThreshold: 400, + allocations: new Map([['B', 100]]), + }, + { + id: 'B', + name: 'Bob', + balance: 0, + minThreshold: 200, + maxThreshold: 400, + allocations: new Map([['C', 100]]), + }, + { + id: 'C', + name: 'Carol', + balance: 0, + minThreshold: 200, + maxThreshold: 400, + allocations: new Map([['A', 100]]), + }, + ], + layout: new Map([ + ['A', { x: 325, y: 150 }], + ['B', { x: 475, y: 320 }], + ['C', { x: 175, y: 320 }], + ]), +} + +/** + * Scenario 3: Hub and Spoke + * Center → {A, B, C, D} + * + * Demonstrates redistribution from a central fund + */ +export const hubAndSpoke: Scenario = { + id: 'hub-and-spoke', + name: 'Hub and Spoke', + description: + 'A central redistribution hub that allocates overflow evenly to four ' + + 'peripheral accounts. Models a community fund or mutual aid pool.', + suggestedFunding: 2000, + accounts: [ + { + id: 'Hub', + name: 'Community Fund', + balance: 0, + minThreshold: 100, + maxThreshold: 300, + allocations: new Map([ + ['A', 25], + ['B', 25], + ['C', 25], + ['D', 25], + ]), + }, + { + id: 'A', + name: 'Alice', + balance: 0, + minThreshold: 200, + maxThreshold: 500, + allocations: new Map(), // Could flow back to hub + }, + { + id: 'B', + name: 'Bob', + balance: 0, + minThreshold: 250, + maxThreshold: 550, + allocations: new Map(), + }, + { + id: 'C', + name: 'Carol', + balance: 0, + minThreshold: 150, + maxThreshold: 450, + allocations: new Map(), + }, + { + id: 'D', + name: 'David', + balance: 0, + minThreshold: 200, + maxThreshold: 500, + allocations: new Map(), + }, + ], + layout: new Map([ + ['Hub', { x: 325, y: 250 }], + ['A', { x: 325, y: 100 }], + ['B', { x: 525, y: 250 }], + ['C', { x: 325, y: 400 }], + ['D', { x: 125, y: 250 }], + ]), +} + +/** + * Scenario 4: Complex Network + * Multi-hop redistribution with various allocation strategies + */ +export const complexNetwork: Scenario = { + id: 'complex-network', + name: 'Complex Network', + description: + 'A realistic network with 8 accounts showing various allocation strategies: ' + + 'some split overflow evenly, others prioritize specific recipients. ' + + 'Demonstrates emergence of flow patterns.', + suggestedFunding: 5000, + accounts: [ + { + id: 'A', + name: 'Alice', + balance: 100, + minThreshold: 300, + maxThreshold: 600, + allocations: new Map([ + ['B', 50], + ['C', 50], + ]), + }, + { + id: 'B', + name: 'Bob', + balance: 50, + minThreshold: 250, + maxThreshold: 500, + allocations: new Map([ + ['D', 30], + ['E', 70], + ]), + }, + { + id: 'C', + name: 'Carol', + balance: 0, + minThreshold: 200, + maxThreshold: 450, + allocations: new Map([ + ['F', 100], + ]), + }, + { + id: 'D', + name: 'David', + balance: 200, + minThreshold: 300, + maxThreshold: 550, + allocations: new Map([ + ['G', 40], + ['H', 60], + ]), + }, + { + id: 'E', + name: 'Eve', + balance: 0, + minThreshold: 250, + maxThreshold: 500, + allocations: new Map([ + ['F', 50], + ['G', 50], + ]), + }, + { + id: 'F', + name: 'Frank', + balance: 150, + minThreshold: 200, + maxThreshold: 400, + allocations: new Map([ + ['H', 100], + ]), + }, + { + id: 'G', + name: 'Grace', + balance: 0, + minThreshold: 300, + maxThreshold: 600, + allocations: new Map([ + ['A', 30], + ['H', 70], + ]), + }, + { + id: 'H', + name: 'Henry', + balance: 50, + minThreshold: 350, + maxThreshold: 700, + allocations: new Map([ + ['A', 20], + ['E', 80], + ]), + }, + ], + layout: new Map([ + ['A', { x: 150, y: 150 }], + ['B', { x: 350, y: 100 }], + ['C', { x: 350, y: 200 }], + ['D', { x: 550, y: 150 }], + ['E', { x: 550, y: 300 }], + ['F', { x: 350, y: 350 }], + ['G', { x: 150, y: 350 }], + ['H', { x: 150, y: 500 }], + ]), +} + +/** + * Scenario 5: Worker Cooperative + * Models a worker coop with shared risk pool + */ +export const workerCoop: Scenario = { + id: 'worker-coop', + name: 'Worker Cooperative', + description: + 'Five workers in a cooperative. Each worker\'s overflow goes partly to a shared ' + + 'risk pool and partly to supporting other workers, creating solidarity and resilience.', + suggestedFunding: 3000, + accounts: [ + { + id: 'Pool', + name: 'Risk Pool', + balance: 500, + minThreshold: 1000, + maxThreshold: 2000, + allocations: new Map([ + ['W1', 20], + ['W2', 20], + ['W3', 20], + ['W4', 20], + ['W5', 20], + ]), + }, + { + id: 'W1', + name: 'Worker 1', + balance: 0, + minThreshold: 300, + maxThreshold: 500, + allocations: new Map([ + ['Pool', 50], + ['W2', 50], + ]), + }, + { + id: 'W2', + name: 'Worker 2', + balance: 0, + minThreshold: 300, + maxThreshold: 500, + allocations: new Map([ + ['Pool', 50], + ['W3', 50], + ]), + }, + { + id: 'W3', + name: 'Worker 3', + balance: 0, + minThreshold: 300, + maxThreshold: 500, + allocations: new Map([ + ['Pool', 50], + ['W4', 50], + ]), + }, + { + id: 'W4', + name: 'Worker 4', + balance: 0, + minThreshold: 300, + maxThreshold: 500, + allocations: new Map([ + ['Pool', 50], + ['W5', 50], + ]), + }, + { + id: 'W5', + name: 'Worker 5', + balance: 0, + minThreshold: 300, + maxThreshold: 500, + allocations: new Map([ + ['Pool', 50], + ['W1', 50], + ]), + }, + ], + layout: new Map([ + ['Pool', { x: 325, y: 250 }], + ['W1', { x: 325, y: 100 }], + ['W2', { x: 510, y: 175 }], + ['W3', { x: 510, y: 325 }], + ['W4', { x: 325, y: 400 }], + ['W5', { x: 140, y: 325 }], + ]), +} + +/** + * All available scenarios + */ +export const ALL_SCENARIOS: Scenario[] = [ + linearChain, + mutualAidCircle, + hubAndSpoke, + complexNetwork, + workerCoop, +] + +/** + * Get scenario by ID + */ +export function getScenario(id: string): Scenario | undefined { + return ALL_SCENARIOS.find(s => s.id === id) +} diff --git a/lib/flow-funding/targeted.ts b/lib/flow-funding/targeted.ts new file mode 100644 index 0000000..39d238d --- /dev/null +++ b/lib/flow-funding/targeted.ts @@ -0,0 +1,148 @@ +/** + * Targeted Funding - Add money to specific accounts and watch propagation + */ + +import type { Account, DistributionResult, IterationResult } from './types' + +/** + * Run distribution starting from current account balances + * (Skips initial distribution phase - just runs overflow redistribution) + */ +export function runTargetedDistribution( + accounts: Account[], + config: { + maxIterations?: number + epsilon?: number + verbose?: boolean + } = {} +): DistributionResult { + const { maxIterations = 100, epsilon = 0.01, verbose = false } = config + + // Store initial state + const initialBalances = new Map(accounts.map(a => [a.id, a.balance])) + + if (verbose) { + console.log('\nšŸ“ Targeted Distribution (from current balances)') + accounts.forEach(a => { + console.log(` ${a.id}: $${a.balance.toFixed(2)}`) + }) + } + + // Run overflow redistribution iterations + const iterations: IterationResult[] = [] + let converged = false + + for (let i = 0; i < maxIterations; i++) { + if (verbose) { + console.log(`\n--- Iteration ${i} ---`) + } + + // Calculate overflow + const overflows = new Map() + let totalOverflow = 0 + + for (const account of accounts) { + const overflow = Math.max(0, account.balance - account.maxThreshold) + if (overflow > 0) { + overflows.set(account.id, overflow) + totalOverflow += overflow + // Adjust balance to maximum + account.balance = account.maxThreshold + + if (verbose) { + console.log(` ${account.id}: overflow $${overflow.toFixed(2)}`) + } + } + } + + // Record iteration state + const flows = new Map() + const iteration: IterationResult = { + iteration: i, + balances: new Map(accounts.map(a => [a.id, a.balance])), + overflows, + totalOverflow, + flows, + converged: totalOverflow < epsilon, + } + + // Check convergence + if (totalOverflow < epsilon) { + if (verbose) { + console.log(`āœ“ Converged (overflow < ${epsilon})`) + } + converged = true + iterations.push(iteration) + break + } + + // Redistribute overflow + const accountMap = new Map(accounts.map(a => [a.id, a])) + + for (const [sourceId, overflow] of overflows.entries()) { + const source = accountMap.get(sourceId) + if (!source) continue + + // Normalize allocations + let totalAllocation = 0 + for (const percentage of source.allocations.values()) { + totalAllocation += percentage + } + + if (totalAllocation === 0) { + if (verbose) { + console.log(` ${sourceId}: no allocations - overflow lost`) + } + continue + } + + // Distribute overflow + for (const [targetId, percentage] of source.allocations.entries()) { + const target = accountMap.get(targetId) + if (!target) continue + + const normalizedPercentage = percentage / totalAllocation + const amount = overflow * normalizedPercentage + + target.balance += amount + flows.set(`${sourceId}->${targetId}`, amount) + + if (verbose) { + console.log( + ` ${sourceId} → ${targetId}: $${amount.toFixed(2)} (${percentage}%)` + ) + } + } + } + + iteration.flows = flows + iterations.push(iteration) + } + + if (!converged && verbose) { + console.log(`\nāš ļø Did not converge within ${maxIterations} iterations`) + } + + const finalBalances = new Map(accounts.map(a => [a.id, a.balance])) + + if (verbose) { + console.log('\nšŸŽÆ Final State:') + accounts.forEach(a => { + const initial = initialBalances.get(a.id) || 0 + const change = a.balance - initial + console.log( + ` ${a.id}: $${a.balance.toFixed(2)} ` + + `(${change >= 0 ? '+' : ''}$${change.toFixed(2)})` + ) + }) + } + + return { + initialBalances, + finalBalances, + iterations, + converged, + totalFunding: 0, // Not applicable for targeted + iterationCount: iterations.length, + } +} diff --git a/lib/flow-funding/types.ts b/lib/flow-funding/types.ts new file mode 100644 index 0000000..63305d5 --- /dev/null +++ b/lib/flow-funding/types.ts @@ -0,0 +1,90 @@ +/** + * Flow Funding Core Types + * Isolated module for threshold-based flow funding mechanism + */ + +/** + * Represents an account in the flow funding network + */ +export interface Account { + id: string + /** Display name for the account */ + name: string + /** Current balance */ + balance: number + /** Minimum sustainable funding level */ + minThreshold: number + /** Maximum threshold - beyond this, funds overflow */ + maxThreshold: number + /** Allocation preferences: map of target account ID to percentage (0-100) */ + allocations: Map +} + +/** + * Result of a single redistribution iteration + */ +export interface IterationResult { + /** Iteration number (0-indexed) */ + iteration: number + /** Account balances after this iteration */ + balances: Map + /** Overflow amounts per account */ + overflows: Map + /** Total overflow in the system */ + totalOverflow: number + /** Flows from account to account (sourceId-targetId -> amount) */ + flows: Map + /** Whether the system converged in this iteration */ + converged: boolean +} + +/** + * Complete result of running the flow funding distribution + */ +export interface DistributionResult { + /** Initial state before distribution */ + initialBalances: Map + /** Final balances after convergence */ + finalBalances: Map + /** History of each iteration */ + iterations: IterationResult[] + /** Whether the distribution converged */ + converged: boolean + /** Total external funding added */ + totalFunding: number + /** Number of iterations to convergence */ + iterationCount: number +} + +/** + * Account state for threshold visualization + */ +export type AccountState = + | 'below-minimum' // balance < minThreshold (red) + | 'sustainable' // minThreshold <= balance < maxThreshold (yellow) + | 'at-maximum' // balance >= maxThreshold (green) + | 'overflowing' // balance > maxThreshold in current iteration (blue) + +/** + * Helper to determine account state + */ +export function getAccountState( + balance: number, + minThreshold: number, + maxThreshold: number, + hasOverflow: boolean +): AccountState { + if (hasOverflow) return 'overflowing' + if (balance >= maxThreshold) return 'at-maximum' + if (balance >= minThreshold) return 'sustainable' + return 'below-minimum' +} + +/** + * Validation result for a flow funding network + */ +export interface ValidationResult { + valid: boolean + errors: string[] + warnings: string[] +} diff --git a/lib/flow-v2/engine-v2.ts b/lib/flow-v2/engine-v2.ts new file mode 100644 index 0000000..a241fbe --- /dev/null +++ b/lib/flow-v2/engine-v2.ts @@ -0,0 +1,438 @@ +/** + * Flow Funding V2 Engine - Continuous Flow Dynamics + * + * Implements progressive outflow zones with steady-state equilibrium + */ + +import type { + FlowNode, + FlowEdge, + FlowNetwork, + FlowZone, + OverflowNode, + ValidationResult, +} from './types' + +/** + * Time conversion constants + */ +const SECONDS_PER_MONTH = 30 * 24 * 60 * 60 // ~2.592M seconds +const MONTHS_PER_SECOND = 1 / SECONDS_PER_MONTH + +/** + * Configuration for flow simulation + */ +export interface FlowConfig { + maxIterations?: number + epsilon?: number // Convergence threshold + verbose?: boolean +} + +const DEFAULT_CONFIG: Required = { + maxIterations: 1000, + epsilon: 0.001, // $0.001/month + verbose: false, +} + +/** + * Convert $/month to $/second for internal calculation + */ +export function perMonthToPerSecond(amountPerMonth: number): number { + return amountPerMonth * MONTHS_PER_SECOND +} + +/** + * Convert $/second to $/month for UI display + */ +export function perSecondToPerMonth(amountPerSecond: number): number { + return amountPerSecond / MONTHS_PER_SECOND +} + +/** + * Determine which zone a node is in based on total inflow + */ +export function getFlowZone(node: FlowNode): FlowZone { + const totalInflow = node.totalInflow || 0 + + if (totalInflow < node.minThreshold) { + return 'deficit' + } else if (totalInflow <= node.maxThreshold) { + return 'building' + } else { + return 'capacity' + } +} + +/** + * Calculate progressive outflow based on zone + * + * Deficit Zone (f < min): outflow = 0 (keep everything) + * Building Zone (min ≤ f ≤ max): outflow = f Ɨ ((f - min) / (max - min)) + * Capacity Zone (f > max): outflow = f - max (redirect excess) + */ +export function calculateOutflow(node: FlowNode): number { + const totalInflow = node.totalInflow || 0 + const { minThreshold, maxThreshold } = node + + // Deficit zone: keep everything + if (totalInflow < minThreshold) { + return 0 + } + + // Capacity zone: redirect excess + if (totalInflow > maxThreshold) { + return totalInflow - maxThreshold + } + + // Building zone: progressive sharing + const range = maxThreshold - minThreshold + if (range === 0) { + // Edge case: min === max + return totalInflow > maxThreshold ? totalInflow - maxThreshold : 0 + } + + const ratio = (totalInflow - minThreshold) / range + return totalInflow * ratio +} + +/** + * Validate network structure + */ +export function validateNetwork(nodes: FlowNode[]): ValidationResult { + const errors: string[] = [] + const warnings: string[] = [] + + if (nodes.length === 0) { + errors.push('Network must contain at least one node') + return { valid: false, errors, warnings } + } + + const nodeIds = new Set(nodes.map(n => n.id)) + + for (const node of nodes) { + // Check thresholds + if (node.minThreshold < 0) { + errors.push(`Node ${node.id}: min threshold must be non-negative`) + } + if (node.maxThreshold < 0) { + errors.push(`Node ${node.id}: max threshold must be non-negative`) + } + if (node.minThreshold > node.maxThreshold) { + errors.push( + `Node ${node.id}: min threshold (${node.minThreshold}) ` + + `exceeds max threshold (${node.maxThreshold})` + ) + } + + // Check external inflow + if (node.externalInflow < 0) { + errors.push(`Node ${node.id}: external inflow must be non-negative`) + } + + // Check allocations + let totalAllocation = 0 + for (const [targetId, percentage] of node.allocations.entries()) { + if (percentage < 0 || percentage > 100) { + errors.push( + `Node ${node.id}: allocation to ${targetId} must be 0-100` + ) + } + if (!nodeIds.has(targetId)) { + errors.push( + `Node ${node.id}: allocation target ${targetId} does not exist` + ) + } + if (targetId === node.id) { + errors.push(`Node ${node.id}: cannot allocate to itself`) + } + totalAllocation += percentage + } + + if (totalAllocation > 100.01) { + errors.push( + `Node ${node.id}: total allocations (${totalAllocation}%) exceed 100%` + ) + } + + // Warnings + if (node.allocations.size === 0 && nodes.length > 1) { + warnings.push( + `Node ${node.id}: no outgoing allocations (overflow will be lost)` + ) + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + } +} + +/** + * Calculate steady-state flow equilibrium + * + * Uses iterative convergence to find stable flow rates where + * each node's inflow equals external inflow + allocations from other nodes + */ +export function calculateSteadyState( + nodes: FlowNode[], + config: FlowConfig = {} +): FlowNetwork { + const cfg = { ...DEFAULT_CONFIG, ...config } + const { maxIterations, epsilon, verbose } = cfg + + // Validate network + const validation = validateNetwork(nodes) + if (!validation.valid) { + throw new Error( + `Invalid network:\n${validation.errors.join('\n')}` + ) + } + + if (verbose && validation.warnings.length > 0) { + console.log('āš ļø Warnings:') + validation.warnings.forEach(w => console.log(` ${w}`)) + } + + // Create node map + const nodeMap = new Map(nodes.map(n => [n.id, n])) + + // Initialize total inflows with external inflows + for (const node of nodes) { + node.totalInflow = node.externalInflow + node.totalOutflow = 0 + node.balance = 0 + } + + if (verbose) { + console.log('\n🌊 Starting Steady-State Calculation') + console.log('Initial state:') + nodes.forEach(n => { + console.log( + ` ${n.id}: external=$${n.externalInflow}/mo ` + + `(min=$${n.minThreshold}, max=$${n.maxThreshold})` + ) + }) + } + + // Iterative convergence + let converged = false + let iterations = 0 + + for (let i = 0; i < maxIterations; i++) { + iterations++ + + // Calculate outflows for each node + for (const node of nodes) { + node.totalOutflow = calculateOutflow(node) + } + + // Calculate new inflows based on allocations + const newInflows = new Map() + + for (const node of nodes) { + // Start with external inflow + newInflows.set(node.id, node.externalInflow) + } + + // Add allocated flows + for (const source of nodes) { + const outflow = source.totalOutflow || 0 + + if (outflow > 0) { + // Normalize allocations + let totalAllocation = 0 + for (const percentage of source.allocations.values()) { + totalAllocation += percentage + } + + if (totalAllocation > 0) { + for (const [targetId, percentage] of source.allocations.entries()) { + const target = nodeMap.get(targetId) + if (!target) continue + + const normalizedPercentage = percentage / totalAllocation + const flowAmount = outflow * normalizedPercentage + + const currentInflow = newInflows.get(targetId) || 0 + newInflows.set(targetId, currentInflow + flowAmount) + } + } + } + } + + // Check convergence + let maxChange = 0 + for (const node of nodes) { + const newInflow = newInflows.get(node.id) || 0 + const oldInflow = node.totalInflow || 0 + const change = Math.abs(newInflow - oldInflow) + maxChange = Math.max(maxChange, change) + + node.totalInflow = newInflow + } + + if (verbose && i < 5) { + console.log(`\nIteration ${i}:`) + nodes.forEach(n => { + const zone = getFlowZone(n) + console.log( + ` ${n.id}: in=$${(n.totalInflow || 0).toFixed(2)}/mo ` + + `out=$${(n.totalOutflow || 0).toFixed(2)}/mo [${zone}]` + ) + }) + console.log(` Max change: $${maxChange.toFixed(4)}/mo`) + } + + if (maxChange < epsilon) { + converged = true + if (verbose) { + console.log(`\nāœ“ Converged after ${iterations} iterations`) + } + break + } + } + + if (!converged && verbose) { + console.log(`\nāš ļø Did not converge within ${maxIterations} iterations`) + } + + // Calculate edges + const edges: FlowEdge[] = [] + + for (const source of nodes) { + const outflow = source.totalOutflow || 0 + + if (outflow > 0) { + let totalAllocation = 0 + for (const percentage of source.allocations.values()) { + totalAllocation += percentage + } + + if (totalAllocation > 0) { + for (const [targetId, percentage] of source.allocations.entries()) { + const normalizedPercentage = percentage / totalAllocation + const flowRate = outflow * normalizedPercentage + + if (flowRate > 0) { + edges.push({ + source: source.id, + target: targetId, + flowRate, + percentage, + }) + } + } + } + } + } + + // Calculate overflow node + const totalExternalInflow = nodes.reduce( + (sum, n) => sum + n.externalInflow, + 0 + ) + const totalNetworkCapacity = nodes.reduce( + (sum, n) => sum + n.maxThreshold, + 0 + ) + const totalNetworkNeeds = nodes.reduce( + (sum, n) => sum + n.minThreshold, + 0 + ) + + // Overflow node appears when unallocated overflow exists + let overflowNode: OverflowNode | null = null + let totalUnallocatedOverflow = 0 + + for (const node of nodes) { + const outflow = node.totalOutflow || 0 + + // Calculate allocated overflow + let totalAllocation = 0 + for (const percentage of node.allocations.values()) { + totalAllocation += percentage + } + + // Unallocated percentage + const unallocatedPercentage = Math.max(0, 100 - totalAllocation) + const unallocated = (outflow * unallocatedPercentage) / 100 + + totalUnallocatedOverflow += unallocated + } + + if (totalUnallocatedOverflow > epsilon) { + overflowNode = { + id: 'overflow', + totalInflow: totalUnallocatedOverflow, + } + } + + if (verbose) { + console.log('\nšŸ“Š Final Network State:') + nodes.forEach(n => { + const zone = getFlowZone(n) + const retention = (n.totalInflow || 0) - (n.totalOutflow || 0) + console.log( + ` ${n.id}: ` + + `in=$${(n.totalInflow || 0).toFixed(2)}/mo ` + + `out=$${(n.totalOutflow || 0).toFixed(2)}/mo ` + + `retain=$${retention.toFixed(2)}/mo ` + + `[${zone}]` + ) + }) + + if (overflowNode) { + console.log( + ` Overflow: $${overflowNode.totalInflow.toFixed(2)}/mo (unallocated)` + ) + } + + console.log(`\nNetwork totals:`) + console.log(` External inflow: $${totalExternalInflow.toFixed(2)}/mo`) + console.log(` Network needs: $${totalNetworkNeeds.toFixed(2)}/mo`) + console.log(` Network capacity: $${totalNetworkCapacity.toFixed(2)}/mo`) + } + + return { + nodes: nodeMap, + edges, + overflowNode, + totalExternalInflow, + totalNetworkCapacity, + totalNetworkNeeds, + converged, + iterations, + } +} + +/** + * Clone nodes for simulation + */ +export function cloneNodes(nodes: FlowNode[]): FlowNode[] { + return nodes.map(n => ({ + ...n, + allocations: new Map(n.allocations), + totalInflow: n.totalInflow, + totalOutflow: n.totalOutflow, + balance: n.balance, + })) +} + +/** + * Update node balances based on flow rates over time + * (For visualization - accumulate balance over delta time) + */ +export function updateBalances( + nodes: FlowNode[], + deltaSeconds: number +): void { + for (const node of nodes) { + const inflowPerSecond = perMonthToPerSecond(node.totalInflow || 0) + const outflowPerSecond = perMonthToPerSecond(node.totalOutflow || 0) + const netFlowPerSecond = inflowPerSecond - outflowPerSecond + + node.balance = (node.balance || 0) + netFlowPerSecond * deltaSeconds + } +} diff --git a/lib/flow-v2/scenarios-v2.ts b/lib/flow-v2/scenarios-v2.ts new file mode 100644 index 0000000..5d3436c --- /dev/null +++ b/lib/flow-v2/scenarios-v2.ts @@ -0,0 +1,399 @@ +/** + * Flow Funding V2 - Preset Scenarios + * + * Demonstrates various network topologies with continuous flow dynamics + */ + +import type { FlowNode, ScenarioV2 } from './types' + +/** + * Scenario 1: Linear Chain + * A → B → C → D + * + * Demonstrates cascading progressive flow + */ +export const linearChainV2: ScenarioV2 = { + id: 'linear-chain-v2', + name: 'Linear Chain', + description: + 'A simple chain showing progressive flow from left to right. ' + + 'Watch how funding to A cascades through the network as each node ' + + 'enters different flow zones.', + suggestedTotalInflow: 1200, + nodes: [ + { + id: 'A', + name: 'Alice', + externalInflow: 800, + minThreshold: 200, + maxThreshold: 500, + allocations: new Map([['B', 100]]), + }, + { + id: 'B', + name: 'Bob', + externalInflow: 0, + minThreshold: 300, + maxThreshold: 600, + allocations: new Map([['C', 100]]), + }, + { + id: 'C', + name: 'Carol', + externalInflow: 0, + minThreshold: 200, + maxThreshold: 500, + allocations: new Map([['D', 100]]), + }, + { + id: 'D', + name: 'David', + externalInflow: 0, + minThreshold: 400, + maxThreshold: 800, + allocations: new Map(), + }, + ], + layout: new Map([ + ['A', { x: 100, y: 300 }], + ['B', { x: 280, y: 300 }], + ['C', { x: 460, y: 300 }], + ['D', { x: 640, y: 300 }], + ]), +} + +/** + * Scenario 2: Mutual Aid Circle + * A ↔ B ↔ C ↔ A + * + * Demonstrates circular solidarity and dynamic equilibrium + */ +export const mutualAidCircleV2: ScenarioV2 = { + id: 'mutual-aid-circle-v2', + name: 'Mutual Aid Circle', + description: + 'Three people in a circular mutual aid network. Each person shares ' + + 'their overflow with the next person, creating a self-balancing system. ' + + 'Adjust inflows to see how the network finds equilibrium.', + suggestedTotalInflow: 1500, + nodes: [ + { + id: 'A', + name: 'Alice', + externalInflow: 500, + minThreshold: 300, + maxThreshold: 600, + allocations: new Map([['B', 100]]), + }, + { + id: 'B', + name: 'Bob', + externalInflow: 500, + minThreshold: 300, + maxThreshold: 600, + allocations: new Map([['C', 100]]), + }, + { + id: 'C', + name: 'Carol', + externalInflow: 500, + minThreshold: 300, + maxThreshold: 600, + allocations: new Map([['A', 100]]), + }, + ], + layout: new Map([ + ['A', { x: 370, y: 150 }], + ['B', { x: 520, y: 380 }], + ['C', { x: 220, y: 380 }], + ]), +} + +/** + * Scenario 3: Hub and Spoke + * Center → {A, B, C, D} + * + * Demonstrates redistribution from a central fund + */ +export const hubAndSpokeV2: ScenarioV2 = { + id: 'hub-and-spoke-v2', + name: 'Hub and Spoke', + description: + 'A central redistribution hub that shares overflow evenly to four ' + + 'peripheral accounts. Models a community fund or mutual aid pool. ' + + 'Try adjusting the hub\'s external funding.', + suggestedTotalInflow: 2000, + nodes: [ + { + id: 'Hub', + name: 'Community Fund', + externalInflow: 2000, + minThreshold: 200, + maxThreshold: 500, + allocations: new Map([ + ['A', 25], + ['B', 25], + ['C', 25], + ['D', 25], + ]), + }, + { + id: 'A', + name: 'Alice', + externalInflow: 0, + minThreshold: 400, + maxThreshold: 800, + allocations: new Map(), + }, + { + id: 'B', + name: 'Bob', + externalInflow: 0, + minThreshold: 500, + maxThreshold: 1000, + allocations: new Map(), + }, + { + id: 'C', + name: 'Carol', + externalInflow: 0, + minThreshold: 300, + maxThreshold: 700, + allocations: new Map(), + }, + { + id: 'D', + name: 'David', + externalInflow: 0, + minThreshold: 400, + maxThreshold: 800, + allocations: new Map(), + }, + ], + layout: new Map([ + ['Hub', { x: 370, y: 300 }], + ['A', { x: 370, y: 120 }], + ['B', { x: 580, y: 300 }], + ['C', { x: 370, y: 480 }], + ['D', { x: 160, y: 300 }], + ]), +} + +/** + * Scenario 4: Complex Network + * Multi-hop redistribution with various strategies + */ +export const complexNetworkV2: ScenarioV2 = { + id: 'complex-network-v2', + name: 'Complex Network', + description: + 'A realistic network with 8 accounts showing various allocation strategies: ' + + 'some split overflow evenly, others prioritize specific recipients. ' + + 'Watch emergent flow patterns and steady-state behavior.', + suggestedTotalInflow: 5000, + nodes: [ + { + id: 'A', + name: 'Alice', + externalInflow: 1200, + minThreshold: 500, + maxThreshold: 1000, + allocations: new Map([ + ['B', 50], + ['C', 50], + ]), + }, + { + id: 'B', + name: 'Bob', + externalInflow: 800, + minThreshold: 400, + maxThreshold: 800, + allocations: new Map([ + ['D', 30], + ['E', 70], + ]), + }, + { + id: 'C', + name: 'Carol', + externalInflow: 600, + minThreshold: 300, + maxThreshold: 700, + allocations: new Map([['F', 100]]), + }, + { + id: 'D', + name: 'David', + externalInflow: 1000, + minThreshold: 500, + maxThreshold: 900, + allocations: new Map([ + ['G', 40], + ['H', 60], + ]), + }, + { + id: 'E', + name: 'Eve', + externalInflow: 400, + minThreshold: 400, + maxThreshold: 800, + allocations: new Map([ + ['F', 50], + ['G', 50], + ]), + }, + { + id: 'F', + name: 'Frank', + externalInflow: 500, + minThreshold: 300, + maxThreshold: 600, + allocations: new Map([['H', 100]]), + }, + { + id: 'G', + name: 'Grace', + externalInflow: 300, + minThreshold: 500, + maxThreshold: 1000, + allocations: new Map([ + ['A', 30], + ['H', 70], + ]), + }, + { + id: 'H', + name: 'Henry', + externalInflow: 200, + minThreshold: 600, + maxThreshold: 1200, + allocations: new Map([ + ['A', 20], + ['E', 80], + ]), + }, + ], + layout: new Map([ + ['A', { x: 150, y: 150 }], + ['B', { x: 380, y: 100 }], + ['C', { x: 380, y: 200 }], + ['D', { x: 610, y: 150 }], + ['E', { x: 610, y: 350 }], + ['F', { x: 380, y: 400 }], + ['G', { x: 150, y: 400 }], + ['H', { x: 150, y: 550 }], + ]), +} + +/** + * Scenario 5: Worker Cooperative + * Models a worker coop with shared risk pool + */ +export const workerCoopV2: ScenarioV2 = { + id: 'worker-coop-v2', + name: 'Worker Cooperative', + description: + 'Five workers in a cooperative. Each worker\'s overflow goes partly to a shared ' + + 'risk pool and partly to supporting other workers, creating solidarity and resilience. ' + + 'The pool redistributes evenly to all workers.', + suggestedTotalInflow: 3000, + nodes: [ + { + id: 'Pool', + name: 'Risk Pool', + externalInflow: 1000, + minThreshold: 1500, + maxThreshold: 3000, + allocations: new Map([ + ['W1', 20], + ['W2', 20], + ['W3', 20], + ['W4', 20], + ['W5', 20], + ]), + }, + { + id: 'W1', + name: 'Worker 1', + externalInflow: 400, + minThreshold: 500, + maxThreshold: 800, + allocations: new Map([ + ['Pool', 50], + ['W2', 50], + ]), + }, + { + id: 'W2', + name: 'Worker 2', + externalInflow: 400, + minThreshold: 500, + maxThreshold: 800, + allocations: new Map([ + ['Pool', 50], + ['W3', 50], + ]), + }, + { + id: 'W3', + name: 'Worker 3', + externalInflow: 400, + minThreshold: 500, + maxThreshold: 800, + allocations: new Map([ + ['Pool', 50], + ['W4', 50], + ]), + }, + { + id: 'W4', + name: 'Worker 4', + externalInflow: 400, + minThreshold: 500, + maxThreshold: 800, + allocations: new Map([ + ['Pool', 50], + ['W5', 50], + ]), + }, + { + id: 'W5', + name: 'Worker 5', + externalInflow: 400, + minThreshold: 500, + maxThreshold: 800, + allocations: new Map([ + ['Pool', 50], + ['W1', 50], + ]), + }, + ], + layout: new Map([ + ['Pool', { x: 370, y: 300 }], + ['W1', { x: 370, y: 120 }], + ['W2', { x: 570, y: 210 }], + ['W3', { x: 570, y: 390 }], + ['W4', { x: 370, y: 480 }], + ['W5', { x: 170, y: 390 }], + ]), +} + +/** + * All available scenarios + */ +export const ALL_SCENARIOS_V2: ScenarioV2[] = [ + linearChainV2, + mutualAidCircleV2, + hubAndSpokeV2, + complexNetworkV2, + workerCoopV2, +] + +/** + * Get scenario by ID + */ +export function getScenarioV2(id: string): ScenarioV2 | undefined { + return ALL_SCENARIOS_V2.find(s => s.id === id) +} diff --git a/lib/flow-v2/types.ts b/lib/flow-v2/types.ts new file mode 100644 index 0000000..b6f66ef --- /dev/null +++ b/lib/flow-v2/types.ts @@ -0,0 +1,117 @@ +/** + * Flow Funding V2 - Continuous Flow Dynamics + * + * Core types for the flow-oriented funding mechanism + */ + +/** + * Flow Node - A participant in the flow network + * + * Each node has: + * - External inflow ($/month) - what funders contribute + * - Min threshold ($/month) - needs level + * - Max threshold ($/month) - capacity level + * - Allocations - where overflow flows to + */ +export interface FlowNode { + id: string + name: string + + // Flow rates ($/month for UI, converted to $/second for simulation) + externalInflow: number // From funders/sliders + minThreshold: number // Needs level + maxThreshold: number // Capacity level + + // Where overflow flows to (percentages sum to ≤100) + allocations: Map + + // Computed during steady-state calculation + totalInflow?: number // External + incoming from other nodes + totalOutflow?: number // Sent to other nodes + balance?: number // Accumulated balance (for visualization only) +} + +/** + * Progressive Outflow Zones + * + * Deficit Zone (totalInflow < min): Keep everything, outflow = 0 + * Building Zone (min ≤ totalInflow ≤ max): Progressive sharing + * Capacity Zone (totalInflow > max): Redirect all excess + */ +export type FlowZone = 'deficit' | 'building' | 'capacity' + +/** + * Flow between two nodes + */ +export interface FlowEdge { + source: string + target: string + flowRate: number // $/month + percentage: number // Allocation percentage +} + +/** + * Network Overflow Node + * + * Pure sink that absorbs unallocatable overflow + * Created when total external inflow > total network capacity + */ +export interface OverflowNode { + id: 'overflow' + totalInflow: number // $/month +} + +/** + * Complete network state + */ +export interface FlowNetwork { + nodes: Map + edges: FlowEdge[] + overflowNode: OverflowNode | null + + // Network-level metrics + totalExternalInflow: number // Sum of all external inflows + totalNetworkCapacity: number // Sum of all max thresholds + totalNetworkNeeds: number // Sum of all min thresholds + + // Convergence info + converged: boolean + iterations: number +} + +/** + * Simulation state (per-frame) + */ +export interface SimulationState { + timestamp: number // Simulation time in seconds + network: FlowNetwork + + // Per-node state + nodeStates: Map // From specific sources + outflows: Map // To specific targets + balance: number + }> +} + +/** + * Scenario preset + */ +export interface ScenarioV2 { + id: string + name: string + description: string + nodes: FlowNode[] + layout: Map + suggestedTotalInflow: number // $/month +} + +/** + * Validation result + */ +export interface ValidationResult { + valid: boolean + errors: string[] + warnings: string[] +} diff --git a/lib/tbff/README.md b/lib/tbff/README.md new file mode 100644 index 0000000..7195363 --- /dev/null +++ b/lib/tbff/README.md @@ -0,0 +1,409 @@ +# Threshold-Based Flow Funding (TBFF) Module + +**Status**: Milestone 3 Complete āœ… +**Route**: `/tbff` +**Last Updated**: 2025-11-09 + +--- + +## Overview + +This module implements the Threshold-Based Flow Funding mechanism described in `threshold-based-flow-funding.md`. It's built as a **self-contained, modular system** that can evolve independently without affecting other parts of the application. + +## Module Structure + +``` +lib/tbff/ +ā”œā”€ā”€ types.ts # TypeScript interfaces and types +ā”œā”€ā”€ utils.ts # Utility functions (status calculations, formatting) +ā”œā”€ā”€ sample-networks.ts # Pre-configured demo networks +ā”œā”€ā”€ rendering.ts # Canvas rendering functions +ā”œā”€ā”€ algorithms.ts # Flow funding algorithms (future) +└── README.md # This file + +app/tbff/ +└── page.tsx # Main page component +``` + +## Core Concepts + +### 1. Account (Participant) + +Each account has: +- **Balance**: Current funds held +- **Min Threshold**: Minimum viable funding (survival level) +- **Max Threshold**: Overflow point (abundance level) +- **Status**: Derived state (deficit, minimum, healthy, overflow) + +**Visual Representation**: Rectangle with fill height showing balance vs thresholds. + +**Color Coding**: +- šŸ”“ Red (Deficit): balance < minThreshold +- 🟔 Yellow (Minimum): balance ā‰ˆ minThreshold +- šŸ”µ Blue (Healthy): minThreshold < balance < maxThreshold +- 🟢 Green (Overflow): balance ≄ maxThreshold + +### 2. Allocation (Connection) + +Represents where overflow flows when an account exceeds its maximum threshold. + +**Properties**: +- `sourceAccountId`: Account that overflows +- `targetAccountId`: Account that receives overflow +- `percentage`: Portion of overflow to send (0.0 to 1.0) + +**Visual Representation**: Arrow with thickness based on percentage. + +### 3. Network + +Collection of accounts and their allocations, forming a resource flow network. + +**Computed Properties**: +- Total Funds: Sum of all balances +- Total Shortfall: Sum of all deficits +- Total Capacity: Sum of all remaining capacity +- Total Overflow: Sum of all overflows + +## Current Implementation (Milestone 1-3) + +### āœ… What's Working + +1. **Static Visualization** + - Accounts rendered as colored rectangles + - Fill height shows balance vs max threshold + - Threshold lines (dashed) show min/max + - Status badges show current state + - Center dots show connection points + +2. **Allocations** + - Arrows between accounts + - Thickness based on allocation percentage + - Color indicates if source has overflow + - Percentage labels at midpoint + +3. **Interactive Selection** + - Click accounts to select + - Click arrows to select allocations + - Sidebar shows detailed info + - Account list for quick navigation + - Keyboard shortcuts (Delete, Escape) + +4. **Interactive Allocation Creation** ✨ New in M2 + - Two-tool system (Select, Create Arrow) + - Click source, then target to create allocation + - Default 50% percentage + - Auto-normalization with existing allocations + - Visual feedback during creation + +5. **Allocation Editing** ✨ New in M2 + - Select arrow to edit + - Percentage slider (0-100%) + - Real-time updates + - Auto-normalization + - Delete button + - Delete key shortcut + +6. **Sample Networks** + - **States Demo**: Shows all 4 account states + - **Simple Linear**: A → B → C flow + - **Mutual Aid Circle**: A ↔ B ↔ C circular support + - **Commons Pool**: Everyone → Pool → Everyone + +7. **Initial Distribution Algorithm** ✨ New in M3 + - Add external funding input field + - "Distribute Funding" button + - Algorithm fills minimums first, then distributes by capacity + - Distribution summary shows changes + - Console logging for debugging + - Real-time balance updates + +8. **Network Stats** + - Real-time totals displayed in corner + - Sidebar shows aggregated metrics + +### šŸ“‹ What's Not Yet Implemented + +- āŒ Overflow redistribution algorithm +- āŒ Animated flow particles +- āŒ Adding/editing accounts +- āŒ Editing account balances/thresholds +- āŒ Multi-round simulation with overflow +- āŒ Persistence (save/load) + +## Sample Networks + +### 1. States Demo (Default) + +Four accounts showing all possible states: +- Deficit (balance: 30, min: 100, max: 200) +- Minimum (balance: 100, min: 100, max: 200) +- Healthy (balance: 150, min: 100, max: 200) +- Overflow (balance: 250, min: 100, max: 200) + +**Purpose**: Understand visual language and status colors. + +### 2. Simple Linear Flow + +Three accounts in a chain: Alice → Bob → Carol + +**Purpose**: Demonstrates basic flow through a linear network. + +### 3. Mutual Aid Circle + +Three accounts in circular support: Alice ↔ Bob ↔ Carol ↔ Alice + +**Purpose**: Shows how resources can circulate through mutual aid relationships. + +### 4. Commons Pool + +Four accounts where everyone contributes to a central pool, which redistributes equally. + +**Purpose**: Demonstrates hub-and-spoke pattern with commons-based allocation. + +## API Reference + +### Types (`types.ts`) + +```typescript +interface FlowFundingAccount { + id: string + name: string + balance: number + minThreshold: number + maxThreshold: number + x: number + y: number + width: number + height: number + status: AccountStatus + shortfall: number + capacity: number + overflow: number +} + +interface Allocation { + id: string + sourceAccountId: string + targetAccountId: string + percentage: number +} + +interface FlowFundingNetwork { + name: string + accounts: FlowFundingAccount[] + allocations: Allocation[] + totalFunds: number + totalShortfall: number + totalCapacity: number + totalOverflow: number +} +``` + +### Utils (`utils.ts`) + +```typescript +// Status calculation +getAccountStatus(account: FlowFundingAccount): AccountStatus +updateAccountComputedProperties(account: FlowFundingAccount): FlowFundingAccount + +// Network calculations +calculateNetworkTotals(network: FlowFundingNetwork): FlowFundingNetwork + +// Allocation helpers +normalizeAllocations(allocations: Allocation[]): Allocation[] + +// Visual helpers +getAccountCenter(account: FlowFundingAccount): { x: number; y: number } +getStatusColor(status: AccountStatus, alpha?: number): string +``` + +### Rendering (`rendering.ts`) + +```typescript +// Render individual elements +renderAccount(ctx: CanvasRenderingContext2D, account: FlowFundingAccount, isSelected?: boolean): void +renderAllocation(ctx: CanvasRenderingContext2D, allocation: Allocation, source: FlowFundingAccount, target: FlowFundingAccount, isSelected?: boolean): void + +// Render entire network +renderNetwork(ctx: CanvasRenderingContext2D, network: FlowFundingNetwork, width: number, height: number, selectedAccountId?: string | null): void +``` + +## Next Steps (Milestone 2+) + +### āœ… Milestone 2: Add Allocations (Interactive) - COMPLETE +**Goal**: Draw arrows between accounts, edit percentages + +**Tasks**: +- [x] Arrow drawing tool (click source, click target) +- [x] Allocation percentage editor in sidebar +- [x] Delete allocations +- [x] Normalize allocations automatically + +### āœ… Milestone 3: Initial Distribution - COMPLETE +**Goal**: Add external funding and watch it distribute + +**Tasks**: +- [x] Implement `initialDistribution()` algorithm +- [x] Add "Add Funding" input + button +- [x] Distribution summary display +- [x] Console logging for debugging +- [ ] Animate balance changes (number tweening) - Future enhancement + +### Milestone 4: Overflow Redistribution +**Goal**: Trigger overflow and watch funds flow + +**Tasks**: +- [ ] Implement `redistributeOverflow()` algorithm +- [ ] Create `FlowParticle` animation system +- [ ] Animate particles along arrows +- [ ] Show iteration count and convergence +- [ ] "Run Redistribution" button + +### Milestone 5: Interactive Creation +**Goal**: Build custom networks from scratch + +**Tasks**: +- [ ] "Create Account" tool with threshold inputs +- [ ] Drag accounts to reposition +- [ ] Edit account thresholds +- [ ] Edit account balances +- [ ] Save/load network (localStorage) + +### Milestone 6: Scenarios & Presets +**Goal**: Curated examples with explanations + +**Tasks**: +- [ ] More complex preset networks +- [ ] Guided tour / tooltips +- [ ] Scenario descriptions +- [ ] Expected outcomes documentation + +### Milestone 7: Polish +**Goal**: Production-ready demo + +**Tasks**: +- [ ] Keyboard shortcuts (Delete, Esc, etc.) +- [ ] Undo/redo for edits +- [ ] Mobile responsive sidebar +- [ ] Performance optimization +- [ ] Error handling +- [ ] Demo video recording + +## Integration Points + +### With Existing Canvas (`/italism`) + +This module is **completely separate** from the existing `/italism` canvas. No shared code, no dependencies. + +**Future**: Could potentially merge propagator concepts, but for now they remain independent. + +### With Academic Paper + +This implementation directly models the concepts from `threshold-based-flow-funding.md`: + +- **Section 2.1**: Mathematical Model → `types.ts` interfaces +- **Section 2.2**: Distribution Algorithm → `algorithms.ts` (future) +- **Section 3**: Theoretical Properties → Will validate through tests + +### With Post-Appitalism Vision + +This embodies Post-Appitalism by: +- Making abstract economics **tangible** (visual, interactive) +- Demonstrating **resource circulation** vs extraction +- Showing **collective intelligence** (allocation networks) +- Creating **malleable** systems (users can experiment) + +## Development Notes + +### Design Decisions + +1. **Separate Module**: Keeps TBFF isolated, prevents breaking existing features +2. **Canvas-based**: Performance for many accounts, smooth animations +3. **Computed Properties**: Derived from balance/thresholds, not stored separately +4. **Sample Data**: Hardcoded networks for quick demos, easier testing + +### Known Limitations + +1. **No persistence**: Refresh loses changes (Milestone 5) +2. **Static only**: No algorithm execution yet (Milestone 3-4) +3. **No validation**: Can't detect invalid networks yet +4. **No tests**: Should add unit tests for algorithms + +### Performance Considerations + +- Canvas redraws entire scene on change (acceptable for <50 accounts) +- Could optimize with dirty rectangles if needed +- Animations will use `requestAnimationFrame` + +## Testing + +### Manual Testing Checklist + +**Milestone 1:** +- [x] Load default network (States Demo) +- [x] Switch between networks via dropdown +- [x] Click accounts to select +- [x] View account details in sidebar +- [x] See color coding for different states +- [x] See threshold lines in accounts +- [x] See allocation arrows with percentages +- [x] See network stats update + +**Milestone 2:** +- [x] Select "Create Arrow" tool +- [x] Click source account, then target account +- [x] New allocation appears on canvas +- [x] Click arrow to select it +- [x] Selected arrow highlights in cyan +- [x] Allocation editor appears in sidebar +- [x] Drag percentage slider +- [x] See percentage update in real-time +- [x] Create second allocation from same source +- [x] See both allocations normalize +- [x] Click "Delete Allocation" button +- [x] Press Delete key to remove allocation +- [x] Press Escape to deselect +- [x] See outgoing allocations in account details + +**Milestone 3:** +- [x] See "Add Funding" section in sidebar +- [x] Enter funding amount (default: 1000) +- [x] Click "Distribute Funding" button +- [x] See balances update immediately +- [x] See distribution summary appear +- [x] See list of changed accounts with deltas +- [x] Check console for detailed logs +- [x] Try insufficient funding (distributes proportionally) +- [x] Try sufficient funding (fills minimums, then by capacity) +- [x] See network totals update correctly + +**Future:** +- [ ] Watch overflow redistribution (Milestone 4) +- [ ] See animated flow particles (Milestone 4) + +### Future: Automated Tests + +```typescript +// Example tests for Milestone 3+ +describe('initialDistribution', () => { + it('should fill minimums first when funds insufficient', () => {}) + it('should distribute by capacity when minimums met', () => {}) +}) + +describe('redistributeOverflow', () => { + it('should converge within max iterations', () => {}) + it('should conserve total funds', () => {}) +}) +``` + +## Resources + +- **Academic Paper**: `../../../threshold-based-flow-funding.md` +- **Design Session**: `../../.claude/journal/FLOW_FUNDING_DESIGN_SESSION.md` +- **Project Vision**: `../../.claude/journal/POST_APPITALISM_VISION.md` + +--- + +**Built with**: TypeScript, React, Next.js, Canvas API +**Module Owner**: TBFF Team +**Questions?** See design session document for detailed architecture. diff --git a/lib/tbff/algorithms.ts b/lib/tbff/algorithms.ts new file mode 100644 index 0000000..a52e292 --- /dev/null +++ b/lib/tbff/algorithms.ts @@ -0,0 +1,236 @@ +/** + * Flow Funding algorithms + * Implements the mathematical model from threshold-based-flow-funding.md + */ + +import type { FlowFundingNetwork, FlowFundingAccount } from './types' +import { updateAccountComputedProperties, calculateNetworkTotals } from './utils' + +/** + * Initial distribution of external funding to accounts + * + * Algorithm: + * 1. Calculate total shortfall (funds needed to reach minimums) + * 2. If funding < shortfall: distribute proportionally to shortfalls + * 3. If funding >= shortfall: fill all minimums first, then distribute remaining by capacity + * + * @param network - Current network state + * @param externalFunding - Amount of new funding to distribute + * @returns Updated network with new balances + */ +export function initialDistribution( + network: FlowFundingNetwork, + externalFunding: number +): FlowFundingNetwork { + if (externalFunding <= 0) { + console.warn('āš ļø No funding to distribute') + return network + } + + console.log(`\nšŸ’° Initial Distribution: ${externalFunding} funding`) + console.log('━'.repeat(50)) + + // Calculate total shortfall (funds needed to reach minimums) + const totalShortfall = network.accounts.reduce( + (sum, acc) => sum + Math.max(0, acc.minThreshold - acc.balance), + 0 + ) + + console.log(`Total shortfall: ${totalShortfall.toFixed(2)}`) + + if (externalFunding < totalShortfall) { + // Not enough to cover all minimums - distribute proportionally + console.log('āš ļø Insufficient funding to cover all minimums') + console.log('Distributing proportionally by shortfall...\n') + + return distributeProportionallyByShortfall(network, externalFunding, totalShortfall) + } else { + // Enough funding - fill minimums first, then distribute by capacity + console.log('āœ“ Sufficient funding to cover all minimums') + console.log('Step 1: Filling all minimums...') + + const afterMinimums = fillAllMinimums(network) + const remainingFunds = externalFunding - totalShortfall + + console.log(`Remaining funds: ${remainingFunds.toFixed(2)}`) + console.log('Step 2: Distributing by capacity...\n') + + return distributeByCapacity(afterMinimums, remainingFunds) + } +} + +/** + * Distribute funding proportionally to shortfalls + * Used when funding is insufficient to cover all minimums + */ +function distributeProportionallyByShortfall( + network: FlowFundingNetwork, + funding: number, + totalShortfall: number +): FlowFundingNetwork { + const updatedAccounts = network.accounts.map((acc) => { + const shortfall = Math.max(0, acc.minThreshold - acc.balance) + if (shortfall === 0) return acc + + const share = (shortfall / totalShortfall) * funding + const newBalance = acc.balance + share + + console.log( + ` ${acc.name.padEnd(15)} ${acc.balance.toFixed(0)} → ${newBalance.toFixed(0)} (+${share.toFixed(0)})` + ) + + return updateAccountComputedProperties({ + ...acc, + balance: newBalance, + }) + }) + + return calculateNetworkTotals({ + ...network, + accounts: updatedAccounts, + }) +} + +/** + * Fill all accounts to their minimum thresholds + */ +function fillAllMinimums(network: FlowFundingNetwork): FlowFundingNetwork { + const updatedAccounts = network.accounts.map((acc) => { + const shortfall = Math.max(0, acc.minThreshold - acc.balance) + if (shortfall === 0) { + console.log(` ${acc.name.padEnd(15)} ${acc.balance.toFixed(0)} (already at minimum)`) + return acc + } + + const newBalance = acc.minThreshold + + console.log( + ` ${acc.name.padEnd(15)} ${acc.balance.toFixed(0)} → ${newBalance.toFixed(0)} (+${shortfall.toFixed(0)})` + ) + + return updateAccountComputedProperties({ + ...acc, + balance: newBalance, + }) + }) + + return calculateNetworkTotals({ + ...network, + accounts: updatedAccounts, + }) +} + +/** + * Distribute funding proportionally to account capacities + * Capacity = max(0, maxThreshold - balance) + */ +function distributeByCapacity( + network: FlowFundingNetwork, + funding: number +): FlowFundingNetwork { + if (funding <= 0) { + console.log(' No remaining funds to distribute') + return network + } + + // Calculate total capacity + const totalCapacity = network.accounts.reduce( + (sum, acc) => sum + Math.max(0, acc.maxThreshold - acc.balance), + 0 + ) + + if (totalCapacity === 0) { + // All accounts at max - distribute evenly (will create overflow) + console.log(' All accounts at max capacity - distributing evenly (will overflow)') + return distributeEvenly(network, funding) + } + + // Distribute proportionally to capacity + const updatedAccounts = network.accounts.map((acc) => { + const capacity = Math.max(0, acc.maxThreshold - acc.balance) + if (capacity === 0) { + console.log(` ${acc.name.padEnd(15)} ${acc.balance.toFixed(0)} (at max capacity)`) + return acc + } + + const share = (capacity / totalCapacity) * funding + const newBalance = acc.balance + share + + console.log( + ` ${acc.name.padEnd(15)} ${acc.balance.toFixed(0)} → ${newBalance.toFixed(0)} (+${share.toFixed(0)})` + ) + + return updateAccountComputedProperties({ + ...acc, + balance: newBalance, + }) + }) + + return calculateNetworkTotals({ + ...network, + accounts: updatedAccounts, + }) +} + +/** + * Distribute funding evenly across all accounts + * Used when all accounts are at max capacity + */ +function distributeEvenly( + network: FlowFundingNetwork, + funding: number +): FlowFundingNetwork { + const perAccount = funding / network.accounts.length + + const updatedAccounts = network.accounts.map((acc) => { + const newBalance = acc.balance + perAccount + + console.log( + ` ${acc.name.padEnd(15)} ${acc.balance.toFixed(0)} → ${newBalance.toFixed(0)} (+${perAccount.toFixed(0)})` + ) + + return updateAccountComputedProperties({ + ...acc, + balance: newBalance, + }) + }) + + return calculateNetworkTotals({ + ...network, + accounts: updatedAccounts, + }) +} + +/** + * Calculate distribution summary (for UI display) + */ +export function getDistributionSummary( + beforeNetwork: FlowFundingNetwork, + afterNetwork: FlowFundingNetwork +): { + totalDistributed: number + accountsChanged: number + changes: Array<{ accountId: string; name: string; before: number; after: number; delta: number }> +} { + const changes = afterNetwork.accounts.map((after) => { + const before = beforeNetwork.accounts.find((a) => a.id === after.id)! + const delta = after.balance - before.balance + + return { + accountId: after.id, + name: after.name, + before: before.balance, + after: after.balance, + delta, + } + }).filter(c => c.delta !== 0) + + const totalDistributed = changes.reduce((sum, c) => sum + c.delta, 0) + const accountsChanged = changes.length + + return { + totalDistributed, + accountsChanged, + changes, + } +} diff --git a/lib/tbff/rendering.ts b/lib/tbff/rendering.ts new file mode 100644 index 0000000..4397bd2 --- /dev/null +++ b/lib/tbff/rendering.ts @@ -0,0 +1,298 @@ +/** + * Canvas rendering functions for Flow Funding visualization + */ + +import type { FlowFundingAccount, FlowFundingNetwork, Allocation } from './types' +import { getStatusColor, getAccountCenter, formatCurrency, formatPercentage } from './utils' + +/** + * Draw threshold line inside account rectangle + */ +function drawThresholdLine( + ctx: CanvasRenderingContext2D, + account: FlowFundingAccount, + threshold: number, + color: string, + label: string +) { + if (threshold <= 0) return + + const thresholdRatio = threshold / account.maxThreshold + const lineY = account.y + account.height - thresholdRatio * account.height + + // Draw dashed line + ctx.strokeStyle = color + ctx.lineWidth = 2 + ctx.setLineDash([5, 5]) + ctx.beginPath() + ctx.moveTo(account.x, lineY) + ctx.lineTo(account.x + account.width, lineY) + ctx.stroke() + ctx.setLineDash([]) + + // Draw label + ctx.fillStyle = color + ctx.font = 'bold 10px sans-serif' + ctx.fillText(label, account.x + 5, lineY - 3) +} + +/** + * Render a Flow Funding account as a colored rectangle + */ +export function renderAccount( + ctx: CanvasRenderingContext2D, + account: FlowFundingAccount, + isSelected: boolean = false +) { + // Draw border (thicker if selected) + ctx.strokeStyle = isSelected ? '#22d3ee' : getStatusColor(account.status) + ctx.lineWidth = isSelected ? 4 : 3 + ctx.strokeRect(account.x, account.y, account.width, account.height) + + // Calculate fill height based on balance + const fillRatio = Math.min(account.balance / account.maxThreshold, 1) + const fillHeight = fillRatio * account.height + const fillY = account.y + account.height - fillHeight + + // Draw fill with gradient + const gradient = ctx.createLinearGradient( + account.x, + account.y, + account.x, + account.y + account.height + ) + gradient.addColorStop(0, getStatusColor(account.status, 0.2)) + gradient.addColorStop(1, getStatusColor(account.status, 0.6)) + + ctx.fillStyle = gradient + ctx.fillRect(account.x, fillY, account.width, fillHeight) + + // Draw threshold lines + if (account.minThreshold > 0) { + drawThresholdLine(ctx, account, account.minThreshold, '#ef4444', 'Min') + } + drawThresholdLine(ctx, account, account.maxThreshold, '#10b981', 'Max') + + // Draw text labels + ctx.fillStyle = '#ffffff' + ctx.font = 'bold 16px sans-serif' + ctx.fillText(account.name, account.x + 10, account.y + 25) + + ctx.font = '13px monospace' + ctx.fillStyle = '#e2e8f0' + ctx.fillText(`Balance: ${formatCurrency(account.balance)}`, account.x + 10, account.y + 50) + + ctx.font = '11px sans-serif' + ctx.fillStyle = '#cbd5e1' + ctx.fillText(`Min: ${formatCurrency(account.minThreshold)}`, account.x + 10, account.y + 70) + ctx.fillText(`Max: ${formatCurrency(account.maxThreshold)}`, account.x + 10, account.y + 85) + + // Show status badge + const statusColors = { + deficit: '#ef4444', + minimum: '#eab308', + healthy: '#6366f1', + overflow: '#10b981', + } + const statusLabels = { + deficit: 'DEFICIT', + minimum: 'AT MIN', + healthy: 'HEALTHY', + overflow: 'OVERFLOW', + } + + ctx.fillStyle = statusColors[account.status] + ctx.font = 'bold 10px sans-serif' + const statusText = statusLabels[account.status] + const statusWidth = ctx.measureText(statusText).width + ctx.fillRect(account.x + account.width - statusWidth - 15, account.y + 8, statusWidth + 10, 18) + ctx.fillStyle = '#ffffff' + ctx.fillText(statusText, account.x + account.width - statusWidth - 10, account.y + 20) + + // Show overflow/shortfall amount if significant + if (account.overflow > 0) { + ctx.fillStyle = '#10b981' + ctx.font = 'bold 12px sans-serif' + ctx.fillText( + `+${formatCurrency(account.overflow)} overflow`, + account.x + 10, + account.y + account.height - 10 + ) + } else if (account.shortfall > 0) { + ctx.fillStyle = '#ef4444' + ctx.font = 'bold 12px sans-serif' + ctx.fillText( + `-${formatCurrency(account.shortfall)} needed`, + account.x + 10, + account.y + account.height - 10 + ) + } + + // Draw center dot (connection point) + const center = getAccountCenter(account) + ctx.fillStyle = '#22d3ee' + ctx.beginPath() + ctx.arc(center.x, center.y, 4, 0, 2 * Math.PI) + ctx.fill() +} + +/** + * Draw arrowhead at end of line + */ +function drawArrowhead( + ctx: CanvasRenderingContext2D, + x1: number, + y1: number, + x2: number, + y2: number, + color: string, + size: number = 15 +) { + const angle = Math.atan2(y2 - y1, x2 - x1) + + ctx.fillStyle = color + ctx.beginPath() + ctx.moveTo(x2, y2) + ctx.lineTo( + x2 - size * Math.cos(angle - Math.PI / 6), + y2 - size * Math.sin(angle - Math.PI / 6) + ) + ctx.lineTo( + x2 - size * Math.cos(angle + Math.PI / 6), + y2 - size * Math.sin(angle + Math.PI / 6) + ) + ctx.closePath() + ctx.fill() +} + +/** + * Render an allocation arrow between accounts + */ +export function renderAllocation( + ctx: CanvasRenderingContext2D, + allocation: Allocation, + sourceAccount: FlowFundingAccount, + targetAccount: FlowFundingAccount, + isSelected: boolean = false +) { + const start = getAccountCenter(sourceAccount) + const end = getAccountCenter(targetAccount) + + // Line thickness based on percentage + const baseWidth = 2 + const maxWidth = 10 + const width = baseWidth + allocation.percentage * (maxWidth - baseWidth) + + // Color based on whether source has overflow + const hasOverflow = sourceAccount.balance > sourceAccount.maxThreshold + const color = hasOverflow ? '#10b981' : isSelected ? '#22d3ee' : '#64748b' + const alpha = hasOverflow ? 1.0 : isSelected ? 1.0 : 0.5 + + // Draw arrow line + ctx.strokeStyle = color + ctx.globalAlpha = alpha + ctx.lineWidth = width + ctx.beginPath() + ctx.moveTo(start.x, start.y) + ctx.lineTo(end.x, end.y) + ctx.stroke() + + // Draw arrowhead + drawArrowhead(ctx, start.x, start.y, end.x, end.y, color, width * 1.8) + + // Draw percentage label at midpoint + const midX = (start.x + end.x) / 2 + const midY = (start.y + end.y) / 2 + + // Background for label + ctx.globalAlpha = 0.8 + ctx.fillStyle = '#1e293b' + const labelText = formatPercentage(allocation.percentage) + const textMetrics = ctx.measureText(labelText) + ctx.fillRect(midX - 2, midY - 18, textMetrics.width + 8, 20) + + // Label text + ctx.globalAlpha = 1.0 + ctx.fillStyle = color + ctx.font = 'bold 12px sans-serif' + ctx.fillText(labelText, midX + 2, midY - 3) + + ctx.globalAlpha = 1.0 +} + +/** + * Clear and render entire network + */ +export function renderNetwork( + ctx: CanvasRenderingContext2D, + network: FlowFundingNetwork, + canvasWidth: number, + canvasHeight: number, + selectedAccountId: string | null = null, + selectedAllocationId: string | null = null +) { + // Clear canvas + ctx.fillStyle = '#0f172a' + ctx.fillRect(0, 0, canvasWidth, canvasHeight) + + // Draw allocations first (so they appear behind accounts) + network.allocations.forEach((allocation) => { + const sourceAccount = network.accounts.find((a) => a.id === allocation.sourceAccountId) + const targetAccount = network.accounts.find((a) => a.id === allocation.targetAccountId) + + if (sourceAccount && targetAccount) { + renderAllocation( + ctx, + allocation, + sourceAccount, + targetAccount, + allocation.id === selectedAllocationId + ) + } + }) + + // Draw accounts + network.accounts.forEach((account) => { + renderAccount(ctx, account, account.id === selectedAccountId) + }) + + // Draw network stats in corner + drawNetworkStats(ctx, network, canvasWidth) +} + +/** + * Draw network statistics in top-right corner + */ +function drawNetworkStats( + ctx: CanvasRenderingContext2D, + network: FlowFundingNetwork, + canvasWidth: number +) { + const padding = 15 + const lineHeight = 20 + const x = canvasWidth - 200 + + ctx.fillStyle = 'rgba(30, 41, 59, 0.9)' + ctx.fillRect(x - 10, padding - 5, 210, lineHeight * 5 + 10) + + ctx.fillStyle = '#22d3ee' + ctx.font = 'bold 14px sans-serif' + ctx.fillText('Network Stats', x, padding + lineHeight * 0) + + ctx.font = '12px monospace' + ctx.fillStyle = '#94a3b8' + ctx.fillText(`Total Funds: ${formatCurrency(network.totalFunds)}`, x, padding + lineHeight * 1) + + ctx.fillStyle = '#ef4444' + ctx.fillText( + `Shortfall: ${formatCurrency(network.totalShortfall)}`, + x, + padding + lineHeight * 2 + ) + + ctx.fillStyle = '#eab308' + ctx.fillText(`Capacity: ${formatCurrency(network.totalCapacity)}`, x, padding + lineHeight * 3) + + ctx.fillStyle = '#10b981' + ctx.fillText(`Overflow: ${formatCurrency(network.totalOverflow)}`, x, padding + lineHeight * 4) +} diff --git a/lib/tbff/sample-networks.ts b/lib/tbff/sample-networks.ts new file mode 100644 index 0000000..9a9d198 --- /dev/null +++ b/lib/tbff/sample-networks.ts @@ -0,0 +1,265 @@ +/** + * Sample Flow Funding networks for demonstration and testing + */ + +import type { FlowFundingNetwork, FlowFundingAccount } from './types' +import { + updateAccountComputedProperties, + calculateNetworkTotals, +} from './utils' + +/** + * Create an account with computed properties + */ +function createAccount(data: { + id: string + name: string + balance: number + minThreshold: number + maxThreshold: number + x: number + y: number + width?: number + height?: number +}): FlowFundingAccount { + return updateAccountComputedProperties({ + ...data, + width: data.width || 160, + height: data.height || 140, + status: 'deficit', // Will be computed + shortfall: 0, // Will be computed + capacity: 0, // Will be computed + overflow: 0, // Will be computed + }) +} + +/** + * Example 1: Simple Linear Flow (A → B → C) + * Demonstrates basic flow through a chain + */ +export const simpleLinearNetwork: FlowFundingNetwork = calculateNetworkTotals({ + name: 'Simple Linear Flow', + accounts: [ + createAccount({ + id: 'alice', + name: 'Alice', + balance: 0, + minThreshold: 100, + maxThreshold: 300, + x: 100, + y: 200, + }), + createAccount({ + id: 'bob', + name: 'Bob', + balance: 0, + minThreshold: 50, + maxThreshold: 200, + x: 400, + y: 200, + }), + createAccount({ + id: 'carol', + name: 'Carol', + balance: 0, + minThreshold: 75, + maxThreshold: 250, + x: 700, + y: 200, + }), + ], + allocations: [ + { id: 'a1', sourceAccountId: 'alice', targetAccountId: 'bob', percentage: 1.0 }, + { id: 'a2', sourceAccountId: 'bob', targetAccountId: 'carol', percentage: 1.0 }, + ], + totalFunds: 0, + totalShortfall: 0, + totalCapacity: 0, + totalOverflow: 0, +}) + +/** + * Example 2: Mutual Aid Circle (A ↔ B ↔ C ↔ A) + * Demonstrates circular support network + */ +export const mutualAidCircle: FlowFundingNetwork = calculateNetworkTotals({ + name: 'Mutual Aid Circle', + accounts: [ + createAccount({ + id: 'alice', + name: 'Alice', + balance: 50, + minThreshold: 100, + maxThreshold: 200, + x: 400, + y: 100, + }), + createAccount({ + id: 'bob', + name: 'Bob', + balance: 150, + minThreshold: 100, + maxThreshold: 200, + x: 600, + y: 300, + }), + createAccount({ + id: 'carol', + name: 'Carol', + balance: 250, + minThreshold: 100, + maxThreshold: 200, + x: 200, + y: 300, + }), + ], + allocations: [ + { id: 'a1', sourceAccountId: 'alice', targetAccountId: 'bob', percentage: 1.0 }, + { id: 'a2', sourceAccountId: 'bob', targetAccountId: 'carol', percentage: 1.0 }, + { id: 'a3', sourceAccountId: 'carol', targetAccountId: 'alice', percentage: 1.0 }, + ], + totalFunds: 0, + totalShortfall: 0, + totalCapacity: 0, + totalOverflow: 0, +}) + +/** + * Example 3: Commons Pool Redistribution + * Everyone contributes to pool, pool redistributes equally + */ +export const commonsPool: FlowFundingNetwork = calculateNetworkTotals({ + name: 'Commons Pool', + accounts: [ + createAccount({ + id: 'pool', + name: 'Commons Pool', + balance: 0, + minThreshold: 0, + maxThreshold: 500, + x: 400, + y: 150, + }), + createAccount({ + id: 'alice', + name: 'Alice', + balance: 0, + minThreshold: 100, + maxThreshold: 200, + x: 150, + y: 350, + }), + createAccount({ + id: 'bob', + name: 'Bob', + balance: 0, + minThreshold: 100, + maxThreshold: 200, + x: 400, + y: 400, + }), + createAccount({ + id: 'carol', + name: 'Carol', + balance: 0, + minThreshold: 100, + maxThreshold: 200, + x: 650, + y: 350, + }), + ], + allocations: [ + // Contributors to pool + { id: 'a1', sourceAccountId: 'alice', targetAccountId: 'pool', percentage: 1.0 }, + { id: 'a2', sourceAccountId: 'bob', targetAccountId: 'pool', percentage: 1.0 }, + { id: 'a3', sourceAccountId: 'carol', targetAccountId: 'pool', percentage: 1.0 }, + // Pool redistributes + { id: 'a4', sourceAccountId: 'pool', targetAccountId: 'alice', percentage: 0.33 }, + { id: 'a5', sourceAccountId: 'pool', targetAccountId: 'bob', percentage: 0.33 }, + { id: 'a6', sourceAccountId: 'pool', targetAccountId: 'carol', percentage: 0.34 }, + ], + totalFunds: 0, + totalShortfall: 0, + totalCapacity: 0, + totalOverflow: 0, +}) + +/** + * Example 4: Different States Demo + * Shows all four account states at once + */ +export const statesDemo: FlowFundingNetwork = calculateNetworkTotals({ + name: 'Account States Demo', + accounts: [ + createAccount({ + id: 'deficit', + name: 'Deficit', + balance: 30, + minThreshold: 100, + maxThreshold: 200, + x: 100, + y: 100, + }), + createAccount({ + id: 'minimum', + name: 'Minimum', + balance: 100, + minThreshold: 100, + maxThreshold: 200, + x: 350, + y: 100, + }), + createAccount({ + id: 'healthy', + name: 'Healthy', + balance: 150, + minThreshold: 100, + maxThreshold: 200, + x: 600, + y: 100, + }), + createAccount({ + id: 'overflow', + name: 'Overflow', + balance: 250, + minThreshold: 100, + maxThreshold: 200, + x: 850, + y: 100, + }), + ], + allocations: [ + { id: 'a1', sourceAccountId: 'overflow', targetAccountId: 'deficit', percentage: 1.0 }, + ], + totalFunds: 0, + totalShortfall: 0, + totalCapacity: 0, + totalOverflow: 0, +}) + +/** + * Get all sample networks + */ +export const sampleNetworks = { + simpleLinear: simpleLinearNetwork, + mutualAid: mutualAidCircle, + commonsPool: commonsPool, + statesDemo: statesDemo, +} + +/** + * Get network by key + */ +export function getSampleNetwork(key: keyof typeof sampleNetworks): FlowFundingNetwork { + return sampleNetworks[key] +} + +/** + * Get list of network options for UI + */ +export const networkOptions = [ + { value: 'simpleLinear', label: 'Simple Linear Flow (A → B → C)' }, + { value: 'mutualAid', label: 'Mutual Aid Circle (A ↔ B ↔ C)' }, + { value: 'commonsPool', label: 'Commons Pool Redistribution' }, + { value: 'statesDemo', label: 'Account States Demo' }, +] as const diff --git a/lib/tbff/types.ts b/lib/tbff/types.ts new file mode 100644 index 0000000..bd58947 --- /dev/null +++ b/lib/tbff/types.ts @@ -0,0 +1,110 @@ +/** + * Type definitions for Threshold-Based Flow Funding + * These types model the academic paper's mathematical concepts + */ + +export type AccountStatus = 'deficit' | 'minimum' | 'healthy' | 'overflow' + +/** + * FlowFundingAccount represents a participant in the network + * Each account has: + * - balance: current funds held + * - minThreshold: minimum viable funding (survival level) + * - maxThreshold: overflow point (beyond which funds redistribute) + */ +export interface FlowFundingAccount { + // Identity + id: string + name: string + + // Financial State + balance: number + minThreshold: number + maxThreshold: number + + // Visual Position (for canvas rendering) + x: number + y: number + width: number + height: number + + // Computed properties (derived from balance vs thresholds) + status: AccountStatus + shortfall: number // max(0, minThreshold - balance) + capacity: number // max(0, maxThreshold - balance) + overflow: number // max(0, balance - maxThreshold) +} + +/** + * Allocation represents where overflow goes + * When source account exceeds maxThreshold, overflow flows to target + * based on allocation percentage + */ +export interface Allocation { + id: string + sourceAccountId: string + targetAccountId: string + percentage: number // 0.0 to 1.0 (e.g., 0.5 = 50%) + + // Visual (calculated dynamically from account positions) + x1?: number + y1?: number + x2?: number + y2?: number +} + +/** + * FlowFundingNetwork represents the complete system + */ +export interface FlowFundingNetwork { + name: string + accounts: FlowFundingAccount[] + allocations: Allocation[] + + // Computed network-level properties + totalFunds: number + totalShortfall: number + totalCapacity: number + totalOverflow: number +} + +/** + * FlowParticle represents an animated particle flowing along an allocation + * Used to visualize fund transfers during redistribution + */ +export interface FlowParticle { + allocationId: string + progress: number // 0.0 to 1.0 along the path + amount: number // Funds being transferred + startTime: number // timestamp when particle was created + duration: number // milliseconds for animation +} + +/** + * RedistributionStep captures one iteration of the overflow redistribution process + */ +export interface RedistributionStep { + iteration: number + overflows: Array<{ accountId: string; amount: number }> + deltas: Record // accountId -> balance change + flowParticles: FlowParticle[] +} + +/** + * FundingStep represents a step in the funding round process + * Used for animation/visualization callbacks + */ +export type FundingStep = + | { type: 'initial-distribution'; amount: number } + | { type: 'overflow-redistribution' } + | { type: 'redistribution-step'; iteration: number; flowParticles: FlowParticle[] } + | { type: 'complete' } + +/** + * ValidationResult for network validation + */ +export interface ValidationResult { + valid: boolean + errors: string[] + warnings: string[] +} diff --git a/lib/tbff/utils.ts b/lib/tbff/utils.ts new file mode 100644 index 0000000..ea3e1cb --- /dev/null +++ b/lib/tbff/utils.ts @@ -0,0 +1,151 @@ +/** + * Utility functions for Flow Funding calculations + */ + +import type { FlowFundingAccount, AccountStatus, FlowFundingNetwork, Allocation } from './types' + +/** + * Calculate account status based on balance vs thresholds + */ +export function getAccountStatus(account: FlowFundingAccount): AccountStatus { + if (account.balance < account.minThreshold) return 'deficit' + if (account.balance >= account.maxThreshold) return 'overflow' + if (Math.abs(account.balance - account.minThreshold) < 0.01) return 'minimum' + return 'healthy' +} + +/** + * Calculate shortfall (funds needed to reach minimum) + */ +export function calculateShortfall(account: FlowFundingAccount): number { + return Math.max(0, account.minThreshold - account.balance) +} + +/** + * Calculate capacity (funds that can be added before reaching maximum) + */ +export function calculateCapacity(account: FlowFundingAccount): number { + return Math.max(0, account.maxThreshold - account.balance) +} + +/** + * Calculate overflow (funds beyond maximum threshold) + */ +export function calculateOverflow(account: FlowFundingAccount): number { + return Math.max(0, account.balance - account.maxThreshold) +} + +/** + * Update computed properties on an account + */ +export function updateAccountComputedProperties( + account: FlowFundingAccount +): FlowFundingAccount { + return { + ...account, + status: getAccountStatus(account), + shortfall: calculateShortfall(account), + capacity: calculateCapacity(account), + overflow: calculateOverflow(account), + } +} + +/** + * Calculate network-level totals + */ +export function calculateNetworkTotals(network: FlowFundingNetwork): FlowFundingNetwork { + const totalFunds = network.accounts.reduce((sum, acc) => sum + acc.balance, 0) + const totalShortfall = network.accounts.reduce((sum, acc) => sum + acc.shortfall, 0) + const totalCapacity = network.accounts.reduce((sum, acc) => sum + acc.capacity, 0) + const totalOverflow = network.accounts.reduce((sum, acc) => sum + acc.overflow, 0) + + return { + ...network, + totalFunds, + totalShortfall, + totalCapacity, + totalOverflow, + } +} + +/** + * Normalize allocations so they sum to 1.0 + */ +export function normalizeAllocations(allocations: Allocation[]): Allocation[] { + // If only one allocation, it must be 100% + if (allocations.length === 1) { + return allocations.map(a => ({ ...a, percentage: 1.0 })) + } + + const total = allocations.reduce((sum, a) => sum + a.percentage, 0) + + // If total is 0, distribute equally + if (total === 0) { + const equalShare = 1.0 / allocations.length + return allocations.map((a) => ({ + ...a, + percentage: equalShare, + })) + } + + // If already normalized (within tolerance), return as-is + if (Math.abs(total - 1.0) < 0.0001) { + return allocations + } + + // Normalize by dividing by total + return allocations.map((a) => ({ + ...a, + percentage: a.percentage / total, + })) +} + +/** + * Get center point of an account (for arrow endpoints) + */ +export function getAccountCenter(account: FlowFundingAccount): { x: number; y: number } { + return { + x: account.x + account.width / 2, + y: account.y + account.height / 2, + } +} + +/** + * Get status color for rendering + */ +export function getStatusColor(status: AccountStatus, alpha: number = 1): string { + const colors = { + deficit: `rgba(239, 68, 68, ${alpha})`, // Red + minimum: `rgba(251, 191, 36, ${alpha})`, // Yellow + healthy: `rgba(99, 102, 241, ${alpha})`, // Blue + overflow: `rgba(16, 185, 129, ${alpha})`, // Green + } + return colors[status] +} + +/** + * Get status color as Tailwind class + */ +export function getStatusColorClass(status: AccountStatus): string { + const classes = { + deficit: 'text-red-400', + minimum: 'text-yellow-400', + healthy: 'text-blue-400', + overflow: 'text-green-400', + } + return classes[status] +} + +/** + * Format currency for display + */ +export function formatCurrency(amount: number): string { + return amount.toFixed(0) +} + +/** + * Format percentage for display + */ +export function formatPercentage(decimal: number): string { + return `${Math.round(decimal * 100)}%` +}