post-app-website-new/lib/flow-funding/engine.ts

465 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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),
}))
}