feat: add dynamic enoughness layer to Budget River

Funnels can now declare a sufficientThreshold and dynamicOverflow.
When dynamicOverflow is true, surplus routes to the most underfunded
targets by need-weight instead of fixed percentages. Visual layer adds
golden glow on sufficient funnels, sufficiency progress bar, ENOUGH
status pill, and a system-wide Enoughness Score badge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-20 21:38:23 +00:00
parent 15b9ba62a6
commit 4264ac9be2
5 changed files with 242 additions and 16 deletions

View File

@ -7,6 +7,7 @@ import { demoNodes } from '@/lib/presets'
import { detectSafeChains, getBalances, computeTransferSummary } from '@/lib/api/safe-client'
import { safeBalancesToFunnels } from '@/lib/integrations'
import { getFlow, listFlows, syncNodesToBackend, fromSmallestUnit } from '@/lib/api/flows-client'
import { computeSystemSufficiency } from '@/lib/simulation'
import type { FlowNode, FunnelNodeData } from '@/lib/types'
import type { BackendFlow } from '@/lib/api/flows-client'
@ -22,6 +23,19 @@ const BudgetRiver = dynamic(() => import('@/components/BudgetRiver'), {
),
})
function EnoughnessPill({ nodes }: { nodes: FlowNode[] }) {
const score = Math.round(computeSystemSufficiency(nodes) * 100)
const color = score >= 90 ? 'bg-amber-500/30 text-amber-300' :
score >= 60 ? 'bg-emerald-600/30 text-emerald-300' :
score >= 30 ? 'bg-amber-600/30 text-amber-300' :
'bg-red-600/30 text-red-300'
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${color}`}>
Enoughness: {score}%
</span>
)
}
export default function RiverPage() {
const [mode, setMode] = useState<'demo' | 'live'>('demo')
const [nodes, setNodes] = useState<FlowNode[]>(demoNodes)
@ -209,6 +223,8 @@ export default function RiverPage() {
</Link>
<span className="text-slate-500">|</span>
<span className="text-cyan-300 font-medium">Budget River</span>
<span className="text-slate-500">|</span>
<EnoughnessPill nodes={nodes} />
{mode === 'live' && connectedChains.length > 0 && (
<>
<span className="text-slate-500">|</span>
@ -347,6 +363,10 @@ export default function RiverPage() {
<div className="w-3 h-3 rounded-md border border-blue-500 bg-blue-500/30" />
<span className="text-slate-400">Outcome pool</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-1.5 rounded bg-amber-400" style={{ boxShadow: '0 0 6px #fbbf24' }} />
<span className="text-slate-400">Sufficient (golden)</span>
</div>
</div>
</div>
</main>

View File

@ -1,7 +1,8 @@
'use client'
import { useState, useEffect, useMemo, useCallback } from 'react'
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from '@/lib/types'
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from '@/lib/types'
import { computeSufficiencyState, computeSystemSufficiency } from '@/lib/simulation'
// ─── Layout Types ────────────────────────────────────────
@ -35,6 +36,7 @@ interface FunnelLayout {
segmentLength: number
layer: number
status: 'healthy' | 'overflow' | 'critical'
sufficiency: SufficiencyState
}
interface OutcomeLayout {
@ -95,9 +97,11 @@ const COLORS = {
riverHealthy: ['#0ea5e9', '#06b6d4'],
riverOverflow: ['#f59e0b', '#fbbf24'],
riverCritical: ['#ef4444', '#f87171'],
riverSufficient: ['#fbbf24', '#10b981'],
overflowBranch: '#f59e0b',
spendingWaterfall: ['#8b5cf6', '#ec4899', '#06b6d4', '#3b82f6', '#10b981', '#6366f1'],
outcomePool: '#3b82f6',
goldenGlow: '#fbbf24',
bg: '#0f172a',
text: '#e2e8f0',
textMuted: '#94a3b8',
@ -216,6 +220,7 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
segmentLength: SEGMENT_LENGTH,
layer,
status,
sufficiency: computeSufficiencyState(data),
})
})
}
@ -648,13 +653,26 @@ function SankeyWaterfall({ wf }: { wf: WaterfallLayout }) {
}
function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
const { id, label, data, x, y, riverWidth, segmentLength, status } = funnel
const gradColors = status === 'overflow' ? COLORS.riverOverflow :
const { id, label, data, x, y, riverWidth, segmentLength, status, sufficiency } = funnel
const isSufficientOrAbundant = sufficiency === 'sufficient' || sufficiency === 'abundant'
const gradColors = isSufficientOrAbundant ? COLORS.riverSufficient :
status === 'overflow' ? COLORS.riverOverflow :
status === 'critical' ? COLORS.riverCritical : COLORS.riverHealthy
const thresholdMinY = y + riverWidth * 0.85
const thresholdMaxY = y + riverWidth * 0.15
// Sufficiency ring: arc fill from 0 to sufficientThreshold
const sufficientThreshold = data.sufficientThreshold ?? data.maxThreshold
const sufficiencyFill = Math.min(1, data.currentValue / (sufficientThreshold || 1))
// Status pill text/color
const pillText = isSufficientOrAbundant ? 'ENOUGH' :
status === 'overflow' ? 'OVER' : status === 'critical' ? 'LOW' : 'OK'
const pillColor = isSufficientOrAbundant ? COLORS.goldenGlow :
status === 'overflow' ? '#f59e0b' : status === 'critical' ? '#ef4444' : '#10b981'
const pillWidth = isSufficientOrAbundant ? 42 : 32
return (
<g>
<defs>
@ -666,6 +684,20 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
</linearGradient>
</defs>
{/* Golden glow for sufficient/abundant funnels */}
{isSufficientOrAbundant && (
<rect
x={x - 8}
y={y - 8}
width={segmentLength + 16}
height={riverWidth + 16}
rx={riverWidth / 2 + 8}
fill={COLORS.goldenGlow}
opacity={0.15}
style={{ animation: 'shimmer 2s ease-in-out infinite' }}
/>
)}
{/* River glow */}
<rect
x={x - 4}
@ -677,6 +709,25 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
opacity={0.1}
/>
{/* Sufficiency ring: thin progress bar above the river */}
<rect
x={x}
y={y - 3}
width={segmentLength}
height={2}
rx={1}
fill="rgba(255,255,255,0.1)"
/>
<rect
x={x}
y={y - 3}
width={segmentLength * sufficiencyFill}
height={2}
rx={1}
fill={sufficiencyFill >= 1 ? COLORS.goldenGlow : '#06b6d4'}
opacity={0.8}
/>
{/* River body */}
<rect
x={x}
@ -685,6 +736,7 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
height={riverWidth}
rx={riverWidth / 2}
fill={`url(#river-grad-${id})`}
style={isSufficientOrAbundant ? { filter: `drop-shadow(0 0 8px ${COLORS.goldenGlow})` } : undefined}
/>
{/* Surface current lines */}
@ -739,10 +791,24 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
/>
<text x={x + segmentLength + 6} y={thresholdMinY + 3} fontSize={7} fill="#ef4444" fontWeight="bold">MIN</text>
{/* Dynamic overflow indicator */}
{data.dynamicOverflow && (
<text
x={x + segmentLength + 6}
y={y + riverWidth / 2 + 3}
fontSize={7}
fill={COLORS.goldenGlow}
fontWeight="bold"
opacity={0.7}
>
DYN
</text>
)}
{/* Label */}
<text
x={x + segmentLength / 2}
y={y - 10}
y={y - 14}
textAnchor="middle"
fill={COLORS.text}
fontSize={12}
@ -766,23 +832,23 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
{/* Status pill */}
<rect
x={x + segmentLength / 2 - 16}
x={x + segmentLength / 2 - pillWidth / 2}
y={y + riverWidth + 6}
width={32}
width={pillWidth}
height={14}
rx={7}
fill={status === 'overflow' ? '#f59e0b' : status === 'critical' ? '#ef4444' : '#10b981'}
fill={pillColor}
opacity={0.9}
/>
<text
x={x + segmentLength / 2}
y={y + riverWidth + 16}
textAnchor="middle"
fill="white"
fill={isSufficientOrAbundant ? '#1a1a2e' : 'white'}
fontSize={7}
fontWeight={700}
>
{status === 'overflow' ? 'OVER' : status === 'critical' ? 'LOW' : 'OK'}
{pillText}
</text>
</g>
)
@ -1023,6 +1089,11 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver
}, [isSimulating])
const layout = useMemo(() => computeLayout(animatedNodes), [animatedNodes])
const systemSufficiency = useMemo(() => computeSystemSufficiency(animatedNodes), [animatedNodes])
const sufficiencyPct = Math.round(systemSufficiency * 100)
const sufficiencyColor = sufficiencyPct >= 90 ? '#fbbf24' :
sufficiencyPct >= 60 ? '#10b981' :
sufficiencyPct >= 30 ? '#f59e0b' : '#ef4444'
return (
<div className="w-full h-full overflow-auto">
@ -1081,6 +1152,33 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver
<OutcomePool key={o.id} outcome={o} />
))}
{/* System Enoughness Badge (top-right) */}
<g transform={`translate(${layout.width - 80}, 20)`}>
{/* Background circle */}
<circle cx={30} cy={30} r={28} fill="rgba(15,23,42,0.85)" stroke="rgba(255,255,255,0.1)" strokeWidth={1} />
{/* Track ring */}
<circle cx={30} cy={30} r={22} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={4} />
{/* Progress ring */}
<circle
cx={30} cy={30} r={22}
fill="none"
stroke={sufficiencyColor}
strokeWidth={4}
strokeLinecap="round"
strokeDasharray={`${sufficiencyPct * 1.382} ${138.2 - sufficiencyPct * 1.382}`}
strokeDashoffset={34.56}
style={{ transition: 'stroke-dasharray 0.5s ease, stroke 0.5s ease' }}
/>
{/* Percentage text */}
<text x={30} y={28} textAnchor="middle" fill={sufficiencyColor} fontSize={12} fontWeight={700} fontFamily="monospace">
{sufficiencyPct}%
</text>
{/* Label */}
<text x={30} y={40} textAnchor="middle" fill={COLORS.textMuted} fontSize={6} fontWeight={600} letterSpacing={0.5}>
ENOUGHNESS
</text>
</g>
{/* Title watermark */}
<text
x={layout.width / 2}

View File

@ -33,6 +33,8 @@ export const demoNodes: FlowNode[] = [
maxThreshold: 70000,
maxCapacity: 100000,
inflowRate: 1000,
sufficientThreshold: 60000,
dynamicOverflow: true,
overflowAllocations: [
{ targetId: 'public-goods', percentage: 40, color: OVERFLOW_COLORS[0] },
{ targetId: 'research', percentage: 35, color: OVERFLOW_COLORS[1] },
@ -55,6 +57,7 @@ export const demoNodes: FlowNode[] = [
maxThreshold: 50000,
maxCapacity: 70000,
inflowRate: 400,
sufficientThreshold: 42000,
overflowAllocations: [],
spendingAllocations: [
{ targetId: 'pg-infra', percentage: 50, color: SPENDING_COLORS[0] },
@ -74,6 +77,7 @@ export const demoNodes: FlowNode[] = [
maxThreshold: 45000,
maxCapacity: 60000,
inflowRate: 350,
sufficientThreshold: 38000,
overflowAllocations: [],
spendingAllocations: [
{ targetId: 'research-grants', percentage: 70, color: SPENDING_COLORS[0] },
@ -92,6 +96,7 @@ export const demoNodes: FlowNode[] = [
maxThreshold: 60000,
maxCapacity: 80000,
inflowRate: 250,
sufficientThreshold: 50000,
overflowAllocations: [],
spendingAllocations: [
{ targetId: 'emergency-response', percentage: 100, color: SPENDING_COLORS[0] },

View File

@ -3,9 +3,12 @@
*
* Replaces the random-noise simulation with actual flow logic:
* inflow overflow distribution spending drain outcome accumulation
*
* Sufficiency layer: funnels can declare a sufficientThreshold and dynamicOverflow.
* When dynamicOverflow is true, surplus routes to the most underfunded targets by need-weight.
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types'
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SufficiencyState } from './types'
export interface SimulationConfig {
tickDivisor: number // inflowRate divided by this per tick
@ -21,6 +24,85 @@ export const DEFAULT_CONFIG: SimulationConfig = {
spendingRateCritical: 0.1,
}
// ─── Sufficiency helpers ────────────────────────────────────
export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState {
const threshold = data.sufficientThreshold ?? data.maxThreshold
if (data.currentValue >= data.maxCapacity) return 'abundant'
if (data.currentValue >= threshold) return 'sufficient'
return 'seeking'
}
/**
* Compute need-weights for a set of overflow target IDs.
* For funnels: need = max(0, 1 - currentValue / sufficientThreshold)
* For outcomes: need = max(0, 1 - fundingReceived / fundingTarget)
* Returns a Map of targetId percentage (normalized to 100).
*/
export function computeNeedWeights(
targetIds: string[],
allNodes: FlowNode[],
): Map<string, number> {
const nodeMap = new Map(allNodes.map(n => [n.id, n]))
const needs = new Map<string, number>()
for (const tid of targetIds) {
const node = nodeMap.get(tid)
if (!node) { needs.set(tid, 0); continue }
if (node.type === 'funnel') {
const d = node.data as FunnelNodeData
const threshold = d.sufficientThreshold ?? d.maxThreshold
const need = Math.max(0, 1 - d.currentValue / (threshold || 1))
needs.set(tid, need)
} else if (node.type === 'outcome') {
const d = node.data as OutcomeNodeData
const need = Math.max(0, 1 - d.fundingReceived / Math.max(d.fundingTarget, 1))
needs.set(tid, need)
} else {
needs.set(tid, 0)
}
}
// Normalize to percentages summing to 100
const totalNeed = Array.from(needs.values()).reduce((s, n) => s + n, 0)
const weights = new Map<string, number>()
if (totalNeed === 0) {
// Equal distribution when all targets are satisfied
const equal = targetIds.length > 0 ? 100 / targetIds.length : 0
targetIds.forEach(id => weights.set(id, equal))
} else {
needs.forEach((need, id) => {
weights.set(id, (need / totalNeed) * 100)
})
}
return weights
}
/**
* Compute system-wide sufficiency score (0-1).
* Averages fill ratios of all funnels and progress ratios of all outcomes.
*/
export function computeSystemSufficiency(nodes: FlowNode[]): number {
let sum = 0
let count = 0
for (const node of nodes) {
if (node.type === 'funnel') {
const d = node.data as FunnelNodeData
const threshold = d.sufficientThreshold ?? d.maxThreshold
sum += Math.min(1, d.currentValue / (threshold || 1))
count++
} else if (node.type === 'outcome') {
const d = node.data as OutcomeNodeData
sum += Math.min(1, d.fundingReceived / Math.max(d.fundingTarget, 1))
count++
}
}
return count > 0 ? sum / count : 0
}
export function simulateTick(
nodes: FlowNode[],
config: SimulationConfig = DEFAULT_CONFIG,
@ -55,12 +137,28 @@ export function simulateTick(
// 4. Distribute overflow when above maxThreshold
if (value > data.maxThreshold && data.overflowAllocations.length > 0) {
const excess = value - data.maxThreshold
for (const alloc of data.overflowAllocations) {
const share = excess * (alloc.percentage / 100)
overflowIncoming.set(
alloc.targetId,
(overflowIncoming.get(alloc.targetId) ?? 0) + share,
)
if (data.dynamicOverflow) {
// Dynamic overflow: route by need-weight instead of fixed percentages
const targetIds = data.overflowAllocations.map(a => a.targetId)
const needWeights = computeNeedWeights(targetIds, nodes)
for (const alloc of data.overflowAllocations) {
const weight = needWeights.get(alloc.targetId) ?? 0
const share = excess * (weight / 100)
overflowIncoming.set(
alloc.targetId,
(overflowIncoming.get(alloc.targetId) ?? 0) + share,
)
}
} else {
// Fixed-percentage overflow (existing behavior)
for (const alloc of data.overflowAllocations) {
const share = excess * (alloc.percentage / 100)
overflowIncoming.set(
alloc.targetId,
(overflowIncoming.get(alloc.targetId) ?? 0) + share,
)
}
}
value = data.maxThreshold
}

View File

@ -115,6 +115,8 @@ export interface SpendingAllocation {
color: string
}
export type SufficiencyState = 'seeking' | 'sufficient' | 'abundant'
export interface FunnelNodeData {
label: string
currentValue: number
@ -122,6 +124,9 @@ export interface FunnelNodeData {
maxThreshold: number
maxCapacity: number
inflowRate: number
// Sufficiency layer
sufficientThreshold?: number // level at which funnel has "enough" (defaults to maxThreshold)
dynamicOverflow?: boolean // when true, overflow routes by need instead of fixed %
// Overflow goes SIDEWAYS to other funnels
overflowAllocations: OverflowAllocation[]
// Spending goes DOWN to outcomes/outputs