import 'dart:math'; import '../about/external/data/Commitment.dart'; import '../about/external/data/Habit.dart'; import '../about/internal/application/CommitmentClass.dart'; import '../about/internal/application/CommitmentStatus.dart'; /// The single number the whole system runs on. /// /// ``` /// debt = Sum over open/missed commitments: /// w(class) x severity(days_overdue) x recency_decay(t) /// /// w(class): non-negotiable 5.0 | standard 2.0 | elective 0.0 /// severity(d): 1 + log2(1 + d) /// recency_decay: 0.5 ^ (days_since / half_life) /// abandonment: one-time +2x w(class), no decay for 30 days /// late_complete: debt reduced to 30% of accrued, not 0 /// ``` /// /// Sublinear severity is the load-bearing choice: with linear growth a single /// ancient task swamps the score and the number stops meaning anything. class DebtEngine { /// Half-life of the recency decay, in days. static const double halfLifeDays = 14; /// Multiplier applied once when a commitment is abandoned. static const double abandonmentMultiplier = 2.0; /// Days an abandonment resists decay. static const int abandonmentProtectionDays = 30; /// What a late completion leaves behind. Never zero — otherwise you learn /// that everything is negotiable. static const double lateCompleteRetention = 0.30; /// Debt weight per habit shortfall unit. static const double habitShortfallWeight = 1.0; /// `severity(d) = 1 + log2(1 + d)`. static double severity(int daysOverdue) { final int days = daysOverdue < 0 ? 0 : daysOverdue; return 1 + (log(1 + days) / ln2); } /// `recency_decay(t) = 0.5 ^ (days_since / half_life)`. static double recencyDecay(int daysSince) { final int days = daysSince < 0 ? 0 : daysSince; return pow(0.5, days / halfLifeDays).toDouble(); } /// Debt contributed by one commitment, as of [now]. static double commitmentDebt(Commitment commitment, {DateTime? now}) { final DateTime moment = now ?? DateTime.now(); final double weight = classWeight(commitment.commitmentClass); // Electives never accrue debt, whatever happens to them. if (weight == 0) { return 0; } // Cleanly completed and archived items are settled. if (commitment.status == CommitmentStatus.Completed || commitment.status == CommitmentStatus.Archived) { return 0; } if (commitment.dueEnd == null) { return 0; } final int daysSince = moment.difference(commitment.dueEnd!).inDays; // The window is still open — nothing is owed yet. if (daysSince < 0) { return 0; } if (commitment.status == CommitmentStatus.Abandoned) { // Abandonment is the most expensive outcome and resists decay for a // month, so it cannot be waited out. final double base = weight * abandonmentMultiplier * severity(daysSince); if (daysSince <= abandonmentProtectionDays) { return base; } return base * recencyDecay(daysSince - abandonmentProtectionDays); } final double accrued = weight * severity(daysSince) * recencyDecay(daysSince); if (commitment.status == CommitmentStatus.LateCompleted) { return accrued * lateCompleteRetention; } return accrued; } /// Debt contributed by a habit. A single miss costs nothing — only falling /// below the frequency target in the rolling window does. static double habitDebt(Habit habit) { if (!habit.behindTarget) { return 0; } return habit.shortfall * habitShortfallWeight; } /// The whole score, as of [now]. static double totalDebt( List commitments, { List habits = const [], DateTime? now, }) { final DateTime moment = now ?? DateTime.now(); double total = 0; for (Commitment commitment in commitments) { total = total + commitmentDebt(commitment, now: moment); } for (Habit habit in habits) { total = total + habitDebt(habit); } return total; } /// Open items past their window — the count that [Thresholds.maxOpenOverdue] /// caps. static int openOverdueCount(List commitments) { return commitments .where((commitment) => commitment.status == CommitmentStatus.Overdue || (commitment.status == CommitmentStatus.Open && commitment.windowClosed)) .length; } /// Missed non-negotiables — three of these force Grounded regardless of the /// numeric score. static int missedNonNegotiables(List commitments) { return commitments .where((commitment) => commitment.commitmentClass == CommitmentClass.NonNegotiable && (commitment.status == CommitmentStatus.Overdue || commitment.status == CommitmentStatus.Abandoned || (commitment.status == CommitmentStatus.Open && commitment.windowClosed))) .length; } /// What clearing this item would remove from the score — used to show the /// user the actual price of each row in the overdue queue. static double reliefFromClearing(Commitment commitment, {DateTime? now}) { return commitmentDebt(commitment, now: now); } }