Implement Flow Funding V2: Continuous flow dynamics with progressive outflow

This commit adds a complete v2 implementation of the flow funding mechanism
with continuous flow dynamics, progressive outflow zones, and real-time
interactive visualization.

## V2 Implementation (New - Today's Work)

### Core Engine (/lib/flow-v2/)
- types.ts: Flow-oriented type system with FlowNode, FlowNetwork, FlowZone
- engine-v2.ts: Steady-state equilibrium solver with progressive outflow
  - Progressive zones: deficit (keep all), building (progressive share), capacity (redirect excess)
  - Iterative convergence algorithm
  - Dual time scale: per-second implementation, per-month UI
- scenarios-v2.ts: 5 preset networks demonstrating various topologies

### Interactive UI (/app/flow-v2/)
- Real-time external inflow sliders (0-$2000/mo per node)
- Dynamic edge width visualization based on flow rate (logarithmic scaling)
- Animated flow particles showing money movement (60 FPS)
- Zone-based node coloring: red (deficit), amber (building), green (capacity)
- Network overflow node (pure sink for unallocatable overflow)
- Comprehensive metrics: per-node and network-level statistics
- Play/pause animation controls
- Click-to-select node highlighting

### Key Features
- Progressive outflow formula implementing the water metaphor:
  * Deficit: outflow = 0
  * Building: outflow = inflow × ((inflow - min) / (max - min))
  * Capacity: outflow = inflow - max
- Steady-state convergence typically in 10-50 iterations
- Fully isolated implementation - no impact on existing routes

## V1 Implementation (Previous Session)

### Core Engine (/lib/flow-funding/)
- Discrete distribution algorithm with phases:
  * Initial distribution (prioritize minimums, fill capacity)
  * Overflow calculation and redistribution
  * Iterative convergence
- Network validation
- 5 preset scenarios

### UI Components
- /app/flowfunding/: Interactive v1 demo with animations
- /app/tbff/: Alternative visualization
- Timeline with time-travel
- Targeted funding mode (click-to-fund)
- Flow particle animations

## Routes
- /flow-v2: V2 continuous flow dynamics demo (NEW)
- /flowfunding: V1 discrete distribution demo
- /tbff: Alternative v1 visualization

All implementations are self-contained and don't affect other parts of the system.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Shawn Anderson 2025-11-09 12:59:52 -08:00
parent de07dabb36
commit a55cec9958
16 changed files with 6016 additions and 0 deletions

675
app/flow-v2/page.tsx Normal file
View File

@ -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<ScenarioV2>(linearChainV2)
// Node state (with adjustable external inflows)
const [nodes, setNodes] = useState<FlowNode[]>(() =>
cloneNodes(currentScenario.nodes)
)
// Network state (calculated)
const [network, setNetwork] = useState<FlowNetwork | null>(null)
// Animation state
const [particles, setParticles] = useState<FlowParticle[]>([])
const [isPlaying, setIsPlaying] = useState(true)
const [simulationTime, setSimulationTime] = useState(0)
// UI state
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(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 (
<svg
width={svgWidth}
height={svgHeight}
className="border border-gray-700 rounded-lg bg-gray-900"
>
{/* 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 (
<g key={`${edge.source}-${edge.target}`}>
{/* Edge line */}
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke="#4b5563"
strokeWidth={flowWidth}
strokeOpacity={0.6}
markerEnd="url(#arrowhead)"
/>
{/* Flow label */}
<text
x={(source.x + target.x) / 2}
y={(source.y + target.y) / 2 - 5}
fill="#9ca3af"
fontSize="11"
textAnchor="middle"
className="pointer-events-none"
>
${edge.flowRate.toFixed(0)}/mo
</text>
</g>
)
})}
{/* 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 (
<circle
key={particle.id}
cx={x}
cy={y}
r={3}
fill="#3b82f6"
opacity={0.8}
/>
)
})}
{/* 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 (
<g
key={node.id}
onClick={() => setSelectedNodeId(node.id)}
className="cursor-pointer"
>
{/* Selection ring */}
{isSelected && (
<circle
cx={pos.x}
cy={pos.y}
r={38}
fill="none"
stroke="#a855f7"
strokeWidth={3}
opacity={0.8}
/>
)}
{/* Node circle */}
<circle
cx={pos.x}
cy={pos.y}
r={30}
fill={color}
fillOpacity={0.2}
stroke={color}
strokeWidth={2}
/>
{/* Node label */}
<text
x={pos.x}
y={pos.y - 5}
fill="white"
fontSize="13"
fontWeight="bold"
textAnchor="middle"
className="pointer-events-none"
>
{node.name}
</text>
{/* Zone indicator */}
<text
x={pos.x}
y={pos.y + 8}
fill={color}
fontSize="10"
textAnchor="middle"
className="pointer-events-none"
>
{zone}
</text>
{/* Retention rate */}
<text
x={pos.x}
y={pos.y + 20}
fill="#9ca3af"
fontSize="9"
textAnchor="middle"
className="pointer-events-none"
>
+${retention.toFixed(0)}/mo
</text>
</g>
)
})}
{/* Overflow node */}
{network.overflowNode && (
<g>
<circle
cx={svgWidth - 80}
cy={svgHeight - 80}
r={30}
fill="#6b7280"
fillOpacity={0.2}
stroke="#6b7280"
strokeWidth={2}
/>
<text
x={svgWidth - 80}
y={svgHeight - 85}
fill="white"
fontSize="11"
fontWeight="bold"
textAnchor="middle"
>
Overflow
</text>
<text
x={svgWidth - 80}
y={svgHeight - 72}
fill="#9ca3af"
fontSize="9"
textAnchor="middle"
>
${network.overflowNode.totalInflow.toFixed(0)}/mo
</text>
</g>
)}
{/* Arrow marker definition */}
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="10"
refX="9"
refY="3"
orient="auto"
>
<polygon points="0 0, 10 3, 0 6" fill="#4b5563" />
</marker>
</defs>
</svg>
)
}, [network, particles, selectedNodeId, getNodePos, getZoneColor, currentScenario])
return (
<div className="min-h-screen bg-gray-950 text-white p-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<header className="mb-8">
<h1 className="text-4xl font-bold mb-2">
Flow Funding V2
</h1>
<p className="text-gray-400 text-lg">
Continuous flow dynamics with progressive outflow zones
</p>
</header>
{/* Controls */}
<div className="mb-6 flex gap-4 items-center flex-wrap">
{/* Scenario selector */}
<div>
<label className="block text-sm text-gray-400 mb-1">
Scenario
</label>
<select
value={currentScenario.id}
onChange={e => {
const scenario = ALL_SCENARIOS_V2.find(
s => s.id === e.target.value
)
if (scenario) handleScenarioChange(scenario)
}}
className="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white"
>
{ALL_SCENARIOS_V2.map(s => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
{/* Play/pause */}
<div>
<label className="block text-sm text-gray-400 mb-1">
Animation
</label>
<button
onClick={() => setIsPlaying(!isPlaying)}
className="bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded px-4 py-2"
>
{isPlaying ? '⏸ Pause' : '▶ Play'}
</button>
</div>
{/* Metrics toggle */}
<div>
<label className="block text-sm text-gray-400 mb-1">
Display
</label>
<button
onClick={() => setShowMetrics(!showMetrics)}
className="bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded px-4 py-2"
>
{showMetrics ? '📊 Metrics On' : '📊 Metrics Off'}
</button>
</div>
{/* Simulation time */}
<div className="ml-auto">
<label className="block text-sm text-gray-400 mb-1">
Simulation Time
</label>
<div className="text-lg font-mono">
{simulationTime.toFixed(1)}s
</div>
</div>
</div>
{/* Scenario description */}
<div className="mb-6 p-4 bg-gray-900 border border-gray-800 rounded-lg">
<p className="text-gray-300">{currentScenario.description}</p>
</div>
{/* Main layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Network visualization */}
<div className="lg:col-span-2">
{renderNetwork}
</div>
{/* Control panel */}
<div className="space-y-4">
<h3 className="text-xl font-bold mb-4">External Inflows</h3>
{/* 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 (
<div
key={node.id}
className={`p-4 rounded-lg border-2 ${
selectedNodeId === node.id
? 'border-purple-500 bg-purple-950/20'
: 'border-gray-800 bg-gray-900'
}`}
>
{/* Node name and zone */}
<div className="flex justify-between items-center mb-2">
<span className="font-semibold">{node.name}</span>
<span
className="text-xs px-2 py-1 rounded"
style={{ backgroundColor: color + '40', color }}
>
{zone}
</span>
</div>
{/* External inflow slider */}
<div className="mb-2">
<label className="text-xs text-gray-400 block mb-1">
External Inflow: ${node.externalInflow.toFixed(0)}/mo
</label>
<input
type="range"
min={0}
max={2000}
step={50}
value={node.externalInflow}
onChange={e =>
handleInflowChange(node.id, parseFloat(e.target.value))
}
className="w-full"
/>
</div>
{/* Thresholds */}
<div className="text-xs text-gray-500 space-y-1">
<div>Min: ${node.minThreshold}/mo</div>
<div>Max: ${node.maxThreshold}/mo</div>
</div>
{/* Flow metrics */}
{showMetrics && networkNode && (
<div className="mt-3 pt-3 border-t border-gray-800 text-xs space-y-1">
<div className="flex justify-between">
<span className="text-gray-400">Total In:</span>
<span className="text-green-400">
${(networkNode.totalInflow || 0).toFixed(0)}/mo
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Total Out:</span>
<span className="text-red-400">
${(networkNode.totalOutflow || 0).toFixed(0)}/mo
</span>
</div>
<div className="flex justify-between font-semibold">
<span className="text-gray-400">Retained:</span>
<span className="text-blue-400">
$
{(
(networkNode.totalInflow || 0) -
(networkNode.totalOutflow || 0)
).toFixed(0)}
/mo
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Balance:</span>
<span className="text-gray-300">
${(networkNode.balance || 0).toFixed(0)}
</span>
</div>
</div>
)}
</div>
)
})}
{/* Network totals */}
{showMetrics && network && (
<div className="p-4 bg-gray-900 border-2 border-gray-800 rounded-lg">
<h4 className="font-semibold mb-3">Network Totals</h4>
<div className="text-sm space-y-2">
<div className="flex justify-between">
<span className="text-gray-400">External Inflow:</span>
<span className="text-green-400">
${network.totalExternalInflow.toFixed(0)}/mo
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Network Needs:</span>
<span className="text-amber-400">
${network.totalNetworkNeeds.toFixed(0)}/mo
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Network Capacity:</span>
<span className="text-blue-400">
${network.totalNetworkCapacity.toFixed(0)}/mo
</span>
</div>
{network.overflowNode && (
<div className="flex justify-between pt-2 border-t border-gray-800">
<span className="text-gray-400">Overflow:</span>
<span className="text-gray-400">
${network.overflowNode.totalInflow.toFixed(0)}/mo
</span>
</div>
)}
<div className="pt-2 border-t border-gray-800">
<div className="flex justify-between items-center">
<span className="text-gray-400">Converged:</span>
<span
className={
network.converged ? 'text-green-400' : 'text-red-400'
}
>
{network.converged ? '✓ Yes' : '✗ No'}
</span>
</div>
<div className="text-xs text-gray-500 mt-1">
{network.iterations} iterations
</div>
</div>
</div>
</div>
)}
</div>
</div>
{/* Legend */}
<div className="mt-8 p-4 bg-gray-900 border border-gray-800 rounded-lg">
<h4 className="font-semibold mb-3">Flow Zones</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div className="flex items-start gap-3">
<div className="w-4 h-4 mt-0.5 rounded-full bg-red-500"></div>
<div>
<div className="font-semibold text-red-400">Deficit Zone</div>
<div className="text-gray-400 text-xs">
Inflow below min threshold. Keep everything (0% outflow).
</div>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-4 h-4 mt-0.5 rounded-full bg-amber-500"></div>
<div>
<div className="font-semibold text-amber-400">Building Zone</div>
<div className="text-gray-400 text-xs">
Between min and max. Progressive sharing based on capacity.
</div>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-4 h-4 mt-0.5 rounded-full bg-green-500"></div>
<div>
<div className="font-semibold text-green-400">Capacity Zone</div>
<div className="text-gray-400 text-xs">
Above max threshold. Redirect 100% of excess.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

1029
app/flowfunding/page.tsx Normal file

File diff suppressed because it is too large Load Diff

778
app/tbff/page.tsx Normal file
View File

@ -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<HTMLCanvasElement>(null)
const [network, setNetwork] = useState<FlowFundingNetwork>(sampleNetworks.statesDemo)
const [selectedAccountId, setSelectedAccountId] = useState<string | null>(null)
const [selectedAllocationId, setSelectedAllocationId] = useState<string | null>(null)
const [selectedNetworkKey, setSelectedNetworkKey] = useState<string>('statesDemo')
const [tool, setTool] = useState<Tool>('select')
const [allocationSourceId, setAllocationSourceId] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [draggedAccountId, setDraggedAccountId] = useState<string | null>(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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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 (
<div className="min-h-screen bg-slate-900 text-white">
{/* Header */}
<header className="flex items-center justify-between p-4 border-b border-slate-700">
<div>
<h1 className="text-2xl font-bold text-cyan-400">
Threshold-Based Flow Funding
</h1>
<p className="text-sm text-slate-400 mt-1">
Milestone 3: Initial Distribution
</p>
</div>
<Link href="/" className="text-cyan-400 hover:text-cyan-300 transition-colors">
Back to Home
</Link>
</header>
{/* Main Content */}
<div className="flex h-[calc(100vh-73px)]">
{/* Canvas */}
<div className="flex-1 relative">
<canvas
ref={canvasRef}
className={`w-full h-full ${
tool === 'create-allocation'
? 'cursor-crosshair'
: isDragging
? 'cursor-grabbing'
: 'cursor-grab'
}`}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
/>
{/* Tool indicator */}
{allocationSourceId && (
<div className="absolute top-4 left-4 bg-cyan-600 px-4 py-2 rounded-lg text-sm font-medium">
Click target account to create allocation
</div>
)}
</div>
{/* Sidebar */}
<div className="w-80 bg-slate-800 p-6 space-y-6 overflow-y-auto">
{/* Tools */}
<div className="space-y-2">
<h3 className="text-sm font-semibold text-slate-400">Tools</h3>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => {
setTool('select')
setAllocationSourceId(null)
}}
className={`px-3 py-2 rounded text-sm transition-colors ${
tool === 'select'
? 'bg-cyan-600 text-white'
: 'bg-slate-700 hover:bg-slate-600'
}`}
>
Select
</button>
<button
onClick={() => setTool('create-allocation')}
className={`px-3 py-2 rounded text-sm transition-colors ${
tool === 'create-allocation'
? 'bg-cyan-600 text-white'
: 'bg-slate-700 hover:bg-slate-600'
}`}
>
Create Arrow
</button>
</div>
</div>
{/* Network Selector */}
<div className="space-y-2">
<h3 className="text-sm font-semibold text-slate-400">Select Network</h3>
<select
value={selectedNetworkKey}
onChange={(e) => handleLoadNetwork(e.target.value)}
className="w-full px-3 py-2 bg-slate-700 rounded text-sm"
>
{networkOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* Network Info */}
<div className="bg-slate-700 p-4 rounded">
<h3 className="font-semibold text-cyan-400 mb-3">{network.name}</h3>
<div className="text-xs space-y-2">
<div className="flex justify-between">
<span className="text-slate-400">Accounts:</span>
<span className="text-white">{network.accounts.length}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Allocations:</span>
<span className="text-white">{network.allocations.length}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Total Funds:</span>
<span className="text-white">{formatCurrency(network.totalFunds)}</span>
</div>
<div className="flex justify-between">
<span className="text-red-400">Shortfall:</span>
<span className="text-red-400">{formatCurrency(network.totalShortfall)}</span>
</div>
<div className="flex justify-between">
<span className="text-yellow-400">Capacity:</span>
<span className="text-yellow-400">{formatCurrency(network.totalCapacity)}</span>
</div>
<div className="flex justify-between">
<span className="text-green-400">Overflow:</span>
<span className="text-green-400">{formatCurrency(network.totalOverflow)}</span>
</div>
</div>
</div>
{/* Funding Controls */}
<div className="bg-green-900/30 border border-green-500/30 p-4 rounded">
<h3 className="font-semibold text-green-400 mb-3">💰 Add Funding</h3>
<div className="space-y-3">
<div>
<label className="text-xs text-slate-400 block mb-1">
Funding Amount
</label>
<input
type="number"
value={fundingAmount}
onChange={(e) => setFundingAmount(parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 bg-slate-700 rounded text-sm"
min="0"
step="100"
/>
</div>
<button
onClick={handleAddFunding}
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 rounded text-sm font-medium transition-colors"
>
Distribute Funding
</button>
</div>
{/* Distribution Summary */}
{lastDistribution && (
<div className="mt-4 pt-4 border-t border-green-500/30">
<div className="text-xs space-y-2">
<div className="flex justify-between">
<span className="text-slate-400">Distributed:</span>
<span className="text-green-400 font-medium">
{formatCurrency(lastDistribution.totalDistributed)}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Accounts Changed:</span>
<span className="text-white">{lastDistribution.accountsChanged}</span>
</div>
{lastDistribution.changes.length > 0 && (
<div className="mt-3 space-y-1">
<div className="text-slate-400 text-[10px] mb-1">Changes:</div>
{lastDistribution.changes.map((change) => (
<div
key={change.accountId}
className="flex justify-between items-center bg-slate-800/50 p-1.5 rounded"
>
<span className="text-white text-[11px]">{change.name}</span>
<span className="text-green-400 text-[11px] font-mono">
+{formatCurrency(change.delta)}
</span>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
{/* Selected Allocation Editor */}
{selectedAllocation && (
<div className="bg-slate-700 p-4 rounded">
<h3 className="font-semibold text-cyan-400 mb-3">Edit Allocation</h3>
<div className="text-xs space-y-3">
<div>
<span className="text-slate-400">From: </span>
<span className="text-white font-medium">
{network.accounts.find(a => a.id === selectedAllocation.sourceAccountId)?.name}
</span>
</div>
<div>
<span className="text-slate-400">To: </span>
<span className="text-white font-medium">
{network.accounts.find(a => a.id === selectedAllocation.targetAccountId)?.name}
</span>
</div>
<div>
<label className="text-slate-400 block mb-1">
Percentage: {Math.round(selectedAllocation.percentage * 100)}%
</label>
{selectedAllocationSiblings.length === 1 ? (
<div className="text-[10px] text-yellow-400 bg-slate-800 p-2 rounded">
Single allocation must be 100%. Create additional allocations to split overflow.
</div>
) : (
<>
<input
type="range"
min="0"
max="100"
value={selectedAllocation.percentage * 100}
onChange={(e) =>
updateAllocationPercentage(
selectedAllocation.id,
parseFloat(e.target.value) / 100
)
}
className="w-full"
/>
<div className="text-[10px] text-slate-500 mt-1">
Note: Percentages auto-normalize with other allocations from same source
</div>
</>
)}
</div>
<button
onClick={() => deleteAllocation(selectedAllocation.id)}
className="w-full px-3 py-2 bg-red-600 hover:bg-red-700 rounded text-sm transition-colors"
>
Delete Allocation
</button>
</div>
</div>
)}
{/* Selected Account Details */}
{selectedAccount && (
<div className="bg-slate-700 p-4 rounded">
<h3 className="font-semibold text-cyan-400 mb-3">Account Details</h3>
<div className="text-xs space-y-2">
<div>
<span className="text-slate-400">Name: </span>
<span className="text-white font-medium">{selectedAccount.name}</span>
</div>
<div>
<span className="text-slate-400">Status: </span>
<span className={`font-medium ${getStatusColorClass(selectedAccount.status)}`}>
{selectedAccount.status.toUpperCase()}
</span>
</div>
<div className="pt-2 space-y-1">
<div className="flex justify-between">
<span className="text-slate-400">Balance:</span>
<span className="text-white font-mono">
{formatCurrency(selectedAccount.balance)}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Min Threshold:</span>
<span className="text-white font-mono">
{formatCurrency(selectedAccount.minThreshold)}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Max Threshold:</span>
<span className="text-white font-mono">
{formatCurrency(selectedAccount.maxThreshold)}
</span>
</div>
</div>
{/* Outgoing Allocations */}
{outgoingAllocations.length > 0 && (
<div className="pt-3 border-t border-slate-600">
<div className="text-slate-400 mb-2">Outgoing Allocations:</div>
{outgoingAllocations.map((alloc) => {
const target = network.accounts.find(a => a.id === alloc.targetAccountId)
return (
<div
key={alloc.id}
className="flex justify-between items-center mb-1 cursor-pointer hover:bg-slate-600 p-1 rounded"
onClick={() => setSelectedAllocationId(alloc.id)}
>
<span className="text-white"> {target?.name}</span>
<span className="text-cyan-400 font-mono">
{Math.round(alloc.percentage * 100)}%
</span>
</div>
)
})}
</div>
)}
</div>
</div>
)}
{/* Account List */}
<div className="space-y-2">
<h3 className="text-sm font-semibold text-slate-400">All Accounts</h3>
<div className="space-y-1 text-xs">
{network.accounts.map((acc) => (
<button
key={acc.id}
onClick={() => {
setSelectedAccountId(acc.id)
setSelectedAllocationId(null)
}}
className={`w-full p-2 rounded text-left transition-colors ${
selectedAccountId === acc.id
? 'bg-cyan-600 text-white'
: 'bg-slate-700 hover:bg-slate-600'
}`}
>
<div className="flex justify-between items-center">
<span className="font-medium">{acc.name}</span>
<span className={`font-mono ${getStatusColorClass(acc.status)}`}>
{formatCurrency(acc.balance)}
</span>
</div>
<div className="text-slate-400 text-[10px] mt-1">
{acc.status.toUpperCase()}
</div>
</button>
))}
</div>
</div>
{/* Legend */}
<div className="bg-slate-700 p-4 rounded">
<h3 className="text-sm font-semibold text-slate-400 mb-3">Legend</h3>
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-500 rounded"></div>
<span>Deficit - Below minimum threshold</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-yellow-500 rounded"></div>
<span>Minimum - At minimum threshold</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-blue-500 rounded"></div>
<span>Healthy - Between thresholds</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded"></div>
<span>Overflow - Above maximum threshold</span>
</div>
</div>
</div>
{/* Instructions */}
<div className="bg-slate-700 p-4 rounded text-xs text-slate-300">
<p className="mb-2">
<strong className="text-white">Milestone 3:</strong> Initial Distribution
</p>
<ul className="space-y-1 list-disc list-inside">
<li><strong className="text-green-400">Add funding</strong> to distribute across accounts</li>
<li><strong className="text-cyan-400">Drag</strong> accounts to reposition them</li>
<li>Use <strong className="text-cyan-400">Create Arrow</strong> tool to draw allocations</li>
<li>Click arrow to edit percentage</li>
<li>Press <kbd className="px-1 bg-slate-800 rounded">Delete</kbd> to remove allocation</li>
<li>Check console for distribution logs</li>
</ul>
</div>
</div>
</div>
</div>
)
}

464
lib/flow-funding/engine.ts Normal file
View File

@ -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<DistributionConfig> = {
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<string, number>()
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<string, number>()
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<string, number> {
const overflows = new Map<string, number>()
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<string, number>,
verbose: boolean
): Map<string, number> {
const accountMap = new Map(accounts.map(a => [a.id, a]))
const flows = new Map<string, number>()
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),
}))
}

View File

@ -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<string, { x: number; y: number }>
}
/**
* 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)
}

View File

@ -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<string, number>()
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<string, number>()
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,
}
}

90
lib/flow-funding/types.ts Normal file
View File

@ -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<string, number>
}
/**
* Result of a single redistribution iteration
*/
export interface IterationResult {
/** Iteration number (0-indexed) */
iteration: number
/** Account balances after this iteration */
balances: Map<string, number>
/** Overflow amounts per account */
overflows: Map<string, number>
/** Total overflow in the system */
totalOverflow: number
/** Flows from account to account (sourceId-targetId -> amount) */
flows: Map<string, number>
/** 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<string, number>
/** Final balances after convergence */
finalBalances: Map<string, number>
/** 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[]
}

438
lib/flow-v2/engine-v2.ts Normal file
View File

@ -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<FlowConfig> = {
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<string, number>()
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
}
}

399
lib/flow-v2/scenarios-v2.ts Normal file
View File

@ -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)
}

117
lib/flow-v2/types.ts Normal file
View File

@ -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<string, number>
// 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<string, FlowNode>
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<string, {
zone: FlowZone
inflows: Map<string, number> // From specific sources
outflows: Map<string, number> // To specific targets
balance: number
}>
}
/**
* Scenario preset
*/
export interface ScenarioV2 {
id: string
name: string
description: string
nodes: FlowNode[]
layout: Map<string, { x: number; y: number }>
suggestedTotalInflow: number // $/month
}
/**
* Validation result
*/
export interface ValidationResult {
valid: boolean
errors: string[]
warnings: string[]
}

409
lib/tbff/README.md Normal file
View File

@ -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.

236
lib/tbff/algorithms.ts Normal file
View File

@ -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,
}
}

298
lib/tbff/rendering.ts Normal file
View File

@ -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)
}

265
lib/tbff/sample-networks.ts Normal file
View File

@ -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

110
lib/tbff/types.ts Normal file
View File

@ -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<string, number> // 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[]
}

151
lib/tbff/utils.ts Normal file
View File

@ -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)}%`
}