diff --git a/server/db/queries/daily_spend.sql b/server/db/queries/daily_spend.sql index c2d4342..24a1781 100644 --- a/server/db/queries/daily_spend.sql +++ b/server/db/queries/daily_spend.sql @@ -14,14 +14,12 @@ SELECT * FROM user_daily_spend WHERE user_id = ? AND day = ?; SELECT * FROM user_daily_spend WHERE day = ?; -- name: UpsertUserDailyAllowance :exec +-- Always overwrites; caller is responsible for passing MAX(fairShare, alreadySpent). INSERT INTO user_daily_allowances ( user_id, day, shared_allowance_micros, set_at, set_by_user_id ) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id, day) DO UPDATE SET - shared_allowance_micros = MAX( - excluded.shared_allowance_micros, - user_daily_allowances.shared_allowance_micros - ), + shared_allowance_micros = excluded.shared_allowance_micros, set_at = excluded.set_at, set_by_user_id = excluded.set_by_user_id; diff --git a/server/internal/api/web/allocations.go b/server/internal/api/web/allocations.go index cf15c11..bee4f14 100644 --- a/server/internal/api/web/allocations.go +++ b/server/internal/api/web/allocations.go @@ -37,33 +37,25 @@ func (s *Server) handleRecomputeAllocations(w http.ResponseWriter, r *http.Reque spentByUser[sp.UserID] = sp.SharedSpentMicros } - var totalShared, totalSpent int64 + var totalShared int64 for _, row := range rows { totalShared += toInt64(row.DailyLimitMicros) } - for _, sp := range spendRows { - totalSpent += sp.SharedSpentMicros - } - remaining := totalShared - totalSpent - if remaining < 0 { - remaining = 0 - } - // Equal split: every user gets an equal share of the remaining pool - // regardless of whether they contributed a key. - nUsers := int64(len(rows)) - for _, row := range rows { - var fairShare int64 - if nUsers > 0 { - fairShare = remaining / nUsers - } - spent := spentByUser[row.UserID] - allowance := spent + fairShare + // Build the user list with their spend, then run max-min fair share. + userIDs := make([]string, len(rows)) + spends := make([]int64, len(rows)) + for i, row := range rows { + userIDs[i] = row.UserID + spends[i] = spentByUser[row.UserID] + } + allowances := fairShareAllocate(totalShared, spends) + for i, id := range userIDs { _ = s.Q.UpsertUserDailyAllowance(r.Context(), store.UpsertUserDailyAllowanceParams{ - UserID: row.UserID, + UserID: id, Day: day, - SharedAllowanceMicros: allowance, + SharedAllowanceMicros: allowances[i], SetAt: now, SetByUserID: u.ID, }) @@ -72,6 +64,74 @@ func (s *Server) handleRecomputeAllocations(w http.ResponseWriter, r *http.Reque writeJSON(w, 200, s.buildAllocations(r)) } +// fairShareAllocate distributes `pool` across users using max-min fair share +// (water-filling). Each user's allowance is at least their existing spend +// (we never claw back what was already used); remaining capacity is split +// equally among users whose spend is below the running fair-share level. +// +// Algorithm: +// 1. Anyone whose spend exceeds the equal split gets locked at their spend. +// 2. The remaining pool is split equally among the rest. +// 3. Repeat until no new lock-ins happen (converges in <= n passes). +// +// If total spend already exceeds the pool, every user gets exactly their +// spend (i.e. the pool is over-allocated and the table will show $0 left). +func fairShareAllocate(pool int64, spends []int64) []int64 { + n := len(spends) + if n == 0 { + return nil + } + + allowances := make([]int64, n) + locked := make([]bool, n) + + for { + // Sum of locked allowances and count of unlocked users. + var lockedTotal int64 + unlocked := 0 + for i := range spends { + if locked[i] { + lockedTotal += allowances[i] + } else { + unlocked++ + } + } + if unlocked == 0 { + break + } + remaining := pool - lockedTotal + if remaining < 0 { + remaining = 0 + } + share := remaining / int64(unlocked) + + // Lock anyone whose spend exceeds the current share. Their + // allowance gets pinned to their spend. + newLocks := 0 + for i, sp := range spends { + if locked[i] { + continue + } + if sp > share { + allowances[i] = sp + locked[i] = true + newLocks++ + } + } + if newLocks == 0 { + // Stable: everyone unlocked gets the current share. + for i := range spends { + if !locked[i] { + allowances[i] = share + } + } + break + } + } + + return allowances +} + // buildAllocations computes the full allocations payload. func (s *Server) buildAllocations(r *http.Request) map[string]any { day := time.Now().UTC().Unix() / 86400 @@ -115,22 +175,29 @@ func (s *Server) buildAllocations(r *http.Request) map[string]any { ShareFraction float64 `json:"share_fraction"` } - nUsers := int64(len(rows)) + // Live fair-share estimate for users with no stored allowance yet. + // Same algorithm as the recompute, so the estimate matches what a + // recompute would produce. + liveSpends := make([]int64, len(rows)) + for i, row := range rows { + liveSpends[i] = spendByUser[row.UserID].shared + } + liveAllowances := fairShareAllocate(totalShared, liveSpends) + out := make([]userEntry, 0, len(rows)) - for _, row := range rows { + for i, row := range rows { shared := toInt64(row.DailyLimitMicros) // share_fraction reflects contribution to the pool, not the divy split. var frac float64 if totalShared > 0 { frac = float64(shared) / float64(totalShared) } - allowance := allowByUser[row.UserID] - if allowance == 0 && nUsers > 0 { - // No stored allowance yet — estimate an equal split for display. - allowance = remaining / nUsers - if allowance < 0 { - allowance = 0 - } + allowance, hasStored := allowByUser[row.UserID], false + if _, ok := allowByUser[row.UserID]; ok { + hasStored = true + } + if !hasStored { + allowance = liveAllowances[i] } spend := spendByUser[row.UserID] out = append(out, userEntry{ diff --git a/server/internal/api/web/allocations_test.go b/server/internal/api/web/allocations_test.go new file mode 100644 index 0000000..b7dfc65 --- /dev/null +++ b/server/internal/api/web/allocations_test.go @@ -0,0 +1,141 @@ +package web + +import ( + "slices" + "testing" +) + +func TestFairShareAllocate(t *testing.T) { + tests := []struct { + name string + pool int64 + spends []int64 + want []int64 + }{ + { + name: "empty", + pool: 1000, + spends: nil, + want: nil, + }, + { + name: "nobody spent anything — equal split", + pool: 1000, + spends: []int64{0, 0, 0, 0}, + want: []int64{250, 250, 250, 250}, + }, + { + name: "everyone spent under fair share — still equal", + pool: 1000, + spends: []int64{100, 50, 200, 0}, + want: []int64{250, 250, 250, 250}, + }, + { + name: "one user already over fair share — locked, rest split remainder", + pool: 1000, + spends: []int64{400, 0, 0, 0}, + want: []int64{400, 200, 200, 200}, + }, + { + name: "two users over fair share — both locked", + pool: 1000, + spends: []int64{400, 350, 0, 0}, + want: []int64{400, 350, 125, 125}, + }, + { + // pass 1: share=250, user0=500 locks at 500 + // pass 2: remaining=500/3=166, user1=240>166 locks at 240 + // pass 3: remaining=260/2=130 for both unlocked + name: "second-order cascade — locking one drops share below another's spend", + pool: 1000, + spends: []int64{500, 240, 0, 0}, + want: []int64{500, 240, 130, 130}, + }, + { + name: "actual cascade — lock 500, then 260 > new share", + pool: 1000, + spends: []int64{500, 260, 0, 0}, + want: []int64{500, 260, 120, 120}, // 1000-500=500/3=166; 260>166 -> lock; 1000-500-260=240/2=120 + }, + { + name: "pool fully consumed by overspenders", + pool: 1000, + spends: []int64{600, 600, 0, 0}, + want: []int64{600, 600, 0, 0}, + }, + { + name: "pool oversubscribed — every user gets their spend, total > pool", + pool: 1000, + spends: []int64{500, 500, 500, 0}, + want: []int64{500, 500, 500, 0}, + }, + { + name: "single user", + pool: 1000, + spends: []int64{0}, + want: []int64{1000}, + }, + { + name: "single user already over", + pool: 1000, + spends: []int64{1500}, + want: []int64{1500}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := fairShareAllocate(tt.pool, tt.spends) + if !slices.Equal(got, tt.want) { + t.Errorf("fairShareAllocate(%d, %v)\n got %v\n want %v", + tt.pool, tt.spends, got, tt.want) + } + }) + } +} + +// Invariant: nobody gets less than they already spent. +func TestFairShareAllocate_NeverBelowSpend(t *testing.T) { + cases := [][]int64{ + {100, 200, 300, 0}, + {500, 500, 0, 0}, + {1, 2, 3, 4, 5}, + {0, 0, 0}, + } + for _, spends := range cases { + got := fairShareAllocate(1000, spends) + for i, sp := range spends { + if got[i] < sp { + t.Errorf("allowance[%d]=%d < spend=%d (spends=%v, alloc=%v)", + i, got[i], sp, spends, got) + } + } + } +} + +// Invariant: sum of allowances never exceeds pool, unless pool is already +// oversubscribed by spend. +func TestFairShareAllocate_SumBounded(t *testing.T) { + cases := []struct { + pool int64 + spends []int64 + }{ + {1000, []int64{0, 0, 0, 0}}, + {1000, []int64{400, 0, 0, 0}}, + {1000, []int64{500, 500, 0, 0}}, + {1000, []int64{300, 200, 100, 50}}, + } + for _, c := range cases { + got := fairShareAllocate(c.pool, c.spends) + var total, spendTotal int64 + for i, a := range got { + total += a + spendTotal += c.spends[i] + } + // Either total <= pool, OR total == sum(spends) when oversubscribed. + if total > c.pool && total != spendTotal { + t.Errorf("pool=%d spends=%v alloc=%v sum=%d (over budget, not just spend)", + c.pool, c.spends, got, total) + } + } +} diff --git a/web/src/routes/dashboard/+page.svelte b/web/src/routes/dashboard/+page.svelte index a9e8e9d..5745263 100644 --- a/web/src/routes/dashboard/+page.svelte +++ b/web/src/routes/dashboard/+page.svelte @@ -134,10 +134,9 @@ chef keys - daily share - share - spent today - remaining today + contributed + pool % + used / allowance @@ -156,8 +155,11 @@ {pct(u.share_fraction)} - {trim(formatUSD(u.shared_spent_today_micros))} - {trim(formatUSD(u.shared_remaining_today_micros))} + + {trim(formatUSD(u.shared_spent_today_micros))} + / + {trim(formatUSD(u.shared_allowance_today_micros))} + {/each} @@ -316,4 +318,8 @@ curl {typeof window !== 'undefined' ? window.location.origin : 'https://potluck. } .negative { color: light-dark(#89023e, #ea638c); } + + .usage-cell { white-space: nowrap; } + .usage-sep { color: var(--text-faint); margin: 0 0.15em; } + .usage-total { color: var(--text-muted); }