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:
parent
15b9ba62a6
commit
4264ac9be2
|
|
@ -7,6 +7,7 @@ import { demoNodes } from '@/lib/presets'
|
||||||
import { detectSafeChains, getBalances, computeTransferSummary } from '@/lib/api/safe-client'
|
import { detectSafeChains, getBalances, computeTransferSummary } from '@/lib/api/safe-client'
|
||||||
import { safeBalancesToFunnels } from '@/lib/integrations'
|
import { safeBalancesToFunnels } from '@/lib/integrations'
|
||||||
import { getFlow, listFlows, syncNodesToBackend, fromSmallestUnit } from '@/lib/api/flows-client'
|
import { getFlow, listFlows, syncNodesToBackend, fromSmallestUnit } from '@/lib/api/flows-client'
|
||||||
|
import { computeSystemSufficiency } from '@/lib/simulation'
|
||||||
import type { FlowNode, FunnelNodeData } from '@/lib/types'
|
import type { FlowNode, FunnelNodeData } from '@/lib/types'
|
||||||
import type { BackendFlow } from '@/lib/api/flows-client'
|
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() {
|
export default function RiverPage() {
|
||||||
const [mode, setMode] = useState<'demo' | 'live'>('demo')
|
const [mode, setMode] = useState<'demo' | 'live'>('demo')
|
||||||
const [nodes, setNodes] = useState<FlowNode[]>(demoNodes)
|
const [nodes, setNodes] = useState<FlowNode[]>(demoNodes)
|
||||||
|
|
@ -209,6 +223,8 @@ export default function RiverPage() {
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-slate-500">|</span>
|
<span className="text-slate-500">|</span>
|
||||||
<span className="text-cyan-300 font-medium">Budget River</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 && (
|
{mode === 'live' && connectedChains.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-slate-500">|</span>
|
<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" />
|
<div className="w-3 h-3 rounded-md border border-blue-500 bg-blue-500/30" />
|
||||||
<span className="text-slate-400">Outcome pool</span>
|
<span className="text-slate-400">Outcome pool</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
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 ────────────────────────────────────────
|
// ─── Layout Types ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -35,6 +36,7 @@ interface FunnelLayout {
|
||||||
segmentLength: number
|
segmentLength: number
|
||||||
layer: number
|
layer: number
|
||||||
status: 'healthy' | 'overflow' | 'critical'
|
status: 'healthy' | 'overflow' | 'critical'
|
||||||
|
sufficiency: SufficiencyState
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OutcomeLayout {
|
interface OutcomeLayout {
|
||||||
|
|
@ -95,9 +97,11 @@ const COLORS = {
|
||||||
riverHealthy: ['#0ea5e9', '#06b6d4'],
|
riverHealthy: ['#0ea5e9', '#06b6d4'],
|
||||||
riverOverflow: ['#f59e0b', '#fbbf24'],
|
riverOverflow: ['#f59e0b', '#fbbf24'],
|
||||||
riverCritical: ['#ef4444', '#f87171'],
|
riverCritical: ['#ef4444', '#f87171'],
|
||||||
|
riverSufficient: ['#fbbf24', '#10b981'],
|
||||||
overflowBranch: '#f59e0b',
|
overflowBranch: '#f59e0b',
|
||||||
spendingWaterfall: ['#8b5cf6', '#ec4899', '#06b6d4', '#3b82f6', '#10b981', '#6366f1'],
|
spendingWaterfall: ['#8b5cf6', '#ec4899', '#06b6d4', '#3b82f6', '#10b981', '#6366f1'],
|
||||||
outcomePool: '#3b82f6',
|
outcomePool: '#3b82f6',
|
||||||
|
goldenGlow: '#fbbf24',
|
||||||
bg: '#0f172a',
|
bg: '#0f172a',
|
||||||
text: '#e2e8f0',
|
text: '#e2e8f0',
|
||||||
textMuted: '#94a3b8',
|
textMuted: '#94a3b8',
|
||||||
|
|
@ -216,6 +220,7 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
||||||
segmentLength: SEGMENT_LENGTH,
|
segmentLength: SEGMENT_LENGTH,
|
||||||
layer,
|
layer,
|
||||||
status,
|
status,
|
||||||
|
sufficiency: computeSufficiencyState(data),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -648,13 +653,26 @@ function SankeyWaterfall({ wf }: { wf: WaterfallLayout }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
|
function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
|
||||||
const { id, label, data, x, y, riverWidth, segmentLength, status } = funnel
|
const { id, label, data, x, y, riverWidth, segmentLength, status, sufficiency } = funnel
|
||||||
const gradColors = status === 'overflow' ? COLORS.riverOverflow :
|
const isSufficientOrAbundant = sufficiency === 'sufficient' || sufficiency === 'abundant'
|
||||||
|
const gradColors = isSufficientOrAbundant ? COLORS.riverSufficient :
|
||||||
|
status === 'overflow' ? COLORS.riverOverflow :
|
||||||
status === 'critical' ? COLORS.riverCritical : COLORS.riverHealthy
|
status === 'critical' ? COLORS.riverCritical : COLORS.riverHealthy
|
||||||
|
|
||||||
const thresholdMinY = y + riverWidth * 0.85
|
const thresholdMinY = y + riverWidth * 0.85
|
||||||
const thresholdMaxY = y + riverWidth * 0.15
|
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 (
|
return (
|
||||||
<g>
|
<g>
|
||||||
<defs>
|
<defs>
|
||||||
|
|
@ -666,6 +684,20 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</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 */}
|
{/* River glow */}
|
||||||
<rect
|
<rect
|
||||||
x={x - 4}
|
x={x - 4}
|
||||||
|
|
@ -677,6 +709,25 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
|
||||||
opacity={0.1}
|
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 */}
|
{/* River body */}
|
||||||
<rect
|
<rect
|
||||||
x={x}
|
x={x}
|
||||||
|
|
@ -685,6 +736,7 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
|
||||||
height={riverWidth}
|
height={riverWidth}
|
||||||
rx={riverWidth / 2}
|
rx={riverWidth / 2}
|
||||||
fill={`url(#river-grad-${id})`}
|
fill={`url(#river-grad-${id})`}
|
||||||
|
style={isSufficientOrAbundant ? { filter: `drop-shadow(0 0 8px ${COLORS.goldenGlow})` } : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Surface current lines */}
|
{/* 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>
|
<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 */}
|
{/* Label */}
|
||||||
<text
|
<text
|
||||||
x={x + segmentLength / 2}
|
x={x + segmentLength / 2}
|
||||||
y={y - 10}
|
y={y - 14}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fill={COLORS.text}
|
fill={COLORS.text}
|
||||||
fontSize={12}
|
fontSize={12}
|
||||||
|
|
@ -766,23 +832,23 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
|
||||||
|
|
||||||
{/* Status pill */}
|
{/* Status pill */}
|
||||||
<rect
|
<rect
|
||||||
x={x + segmentLength / 2 - 16}
|
x={x + segmentLength / 2 - pillWidth / 2}
|
||||||
y={y + riverWidth + 6}
|
y={y + riverWidth + 6}
|
||||||
width={32}
|
width={pillWidth}
|
||||||
height={14}
|
height={14}
|
||||||
rx={7}
|
rx={7}
|
||||||
fill={status === 'overflow' ? '#f59e0b' : status === 'critical' ? '#ef4444' : '#10b981'}
|
fill={pillColor}
|
||||||
opacity={0.9}
|
opacity={0.9}
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={x + segmentLength / 2}
|
x={x + segmentLength / 2}
|
||||||
y={y + riverWidth + 16}
|
y={y + riverWidth + 16}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fill="white"
|
fill={isSufficientOrAbundant ? '#1a1a2e' : 'white'}
|
||||||
fontSize={7}
|
fontSize={7}
|
||||||
fontWeight={700}
|
fontWeight={700}
|
||||||
>
|
>
|
||||||
{status === 'overflow' ? 'OVER' : status === 'critical' ? 'LOW' : 'OK'}
|
{pillText}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
|
|
@ -1023,6 +1089,11 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver
|
||||||
}, [isSimulating])
|
}, [isSimulating])
|
||||||
|
|
||||||
const layout = useMemo(() => computeLayout(animatedNodes), [animatedNodes])
|
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 (
|
return (
|
||||||
<div className="w-full h-full overflow-auto">
|
<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} />
|
<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 */}
|
{/* Title watermark */}
|
||||||
<text
|
<text
|
||||||
x={layout.width / 2}
|
x={layout.width / 2}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ export const demoNodes: FlowNode[] = [
|
||||||
maxThreshold: 70000,
|
maxThreshold: 70000,
|
||||||
maxCapacity: 100000,
|
maxCapacity: 100000,
|
||||||
inflowRate: 1000,
|
inflowRate: 1000,
|
||||||
|
sufficientThreshold: 60000,
|
||||||
|
dynamicOverflow: true,
|
||||||
overflowAllocations: [
|
overflowAllocations: [
|
||||||
{ targetId: 'public-goods', percentage: 40, color: OVERFLOW_COLORS[0] },
|
{ targetId: 'public-goods', percentage: 40, color: OVERFLOW_COLORS[0] },
|
||||||
{ targetId: 'research', percentage: 35, color: OVERFLOW_COLORS[1] },
|
{ targetId: 'research', percentage: 35, color: OVERFLOW_COLORS[1] },
|
||||||
|
|
@ -55,6 +57,7 @@ export const demoNodes: FlowNode[] = [
|
||||||
maxThreshold: 50000,
|
maxThreshold: 50000,
|
||||||
maxCapacity: 70000,
|
maxCapacity: 70000,
|
||||||
inflowRate: 400,
|
inflowRate: 400,
|
||||||
|
sufficientThreshold: 42000,
|
||||||
overflowAllocations: [],
|
overflowAllocations: [],
|
||||||
spendingAllocations: [
|
spendingAllocations: [
|
||||||
{ targetId: 'pg-infra', percentage: 50, color: SPENDING_COLORS[0] },
|
{ targetId: 'pg-infra', percentage: 50, color: SPENDING_COLORS[0] },
|
||||||
|
|
@ -74,6 +77,7 @@ export const demoNodes: FlowNode[] = [
|
||||||
maxThreshold: 45000,
|
maxThreshold: 45000,
|
||||||
maxCapacity: 60000,
|
maxCapacity: 60000,
|
||||||
inflowRate: 350,
|
inflowRate: 350,
|
||||||
|
sufficientThreshold: 38000,
|
||||||
overflowAllocations: [],
|
overflowAllocations: [],
|
||||||
spendingAllocations: [
|
spendingAllocations: [
|
||||||
{ targetId: 'research-grants', percentage: 70, color: SPENDING_COLORS[0] },
|
{ targetId: 'research-grants', percentage: 70, color: SPENDING_COLORS[0] },
|
||||||
|
|
@ -92,6 +96,7 @@ export const demoNodes: FlowNode[] = [
|
||||||
maxThreshold: 60000,
|
maxThreshold: 60000,
|
||||||
maxCapacity: 80000,
|
maxCapacity: 80000,
|
||||||
inflowRate: 250,
|
inflowRate: 250,
|
||||||
|
sufficientThreshold: 50000,
|
||||||
overflowAllocations: [],
|
overflowAllocations: [],
|
||||||
spendingAllocations: [
|
spendingAllocations: [
|
||||||
{ targetId: 'emergency-response', percentage: 100, color: SPENDING_COLORS[0] },
|
{ targetId: 'emergency-response', percentage: 100, color: SPENDING_COLORS[0] },
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@
|
||||||
*
|
*
|
||||||
* Replaces the random-noise simulation with actual flow logic:
|
* Replaces the random-noise simulation with actual flow logic:
|
||||||
* inflow → overflow distribution → spending drain → outcome accumulation
|
* 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 {
|
export interface SimulationConfig {
|
||||||
tickDivisor: number // inflowRate divided by this per tick
|
tickDivisor: number // inflowRate divided by this per tick
|
||||||
|
|
@ -21,6 +24,85 @@ export const DEFAULT_CONFIG: SimulationConfig = {
|
||||||
spendingRateCritical: 0.1,
|
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(
|
export function simulateTick(
|
||||||
nodes: FlowNode[],
|
nodes: FlowNode[],
|
||||||
config: SimulationConfig = DEFAULT_CONFIG,
|
config: SimulationConfig = DEFAULT_CONFIG,
|
||||||
|
|
@ -55,12 +137,28 @@ export function simulateTick(
|
||||||
// 4. Distribute overflow when above maxThreshold
|
// 4. Distribute overflow when above maxThreshold
|
||||||
if (value > data.maxThreshold && data.overflowAllocations.length > 0) {
|
if (value > data.maxThreshold && data.overflowAllocations.length > 0) {
|
||||||
const excess = value - data.maxThreshold
|
const excess = value - data.maxThreshold
|
||||||
for (const alloc of data.overflowAllocations) {
|
|
||||||
const share = excess * (alloc.percentage / 100)
|
if (data.dynamicOverflow) {
|
||||||
overflowIncoming.set(
|
// Dynamic overflow: route by need-weight instead of fixed percentages
|
||||||
alloc.targetId,
|
const targetIds = data.overflowAllocations.map(a => a.targetId)
|
||||||
(overflowIncoming.get(alloc.targetId) ?? 0) + share,
|
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
|
value = data.maxThreshold
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,8 @@ export interface SpendingAllocation {
|
||||||
color: string
|
color: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SufficiencyState = 'seeking' | 'sufficient' | 'abundant'
|
||||||
|
|
||||||
export interface FunnelNodeData {
|
export interface FunnelNodeData {
|
||||||
label: string
|
label: string
|
||||||
currentValue: number
|
currentValue: number
|
||||||
|
|
@ -122,6 +124,9 @@ export interface FunnelNodeData {
|
||||||
maxThreshold: number
|
maxThreshold: number
|
||||||
maxCapacity: number
|
maxCapacity: number
|
||||||
inflowRate: 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
|
// Overflow goes SIDEWAYS to other funnels
|
||||||
overflowAllocations: OverflowAllocation[]
|
overflowAllocations: OverflowAllocation[]
|
||||||
// Spending goes DOWN to outcomes/outputs
|
// Spending goes DOWN to outcomes/outputs
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue