From 5a45e04232bf1563a5e2a666b7ee801eef2c0b42 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:54:09 +0200 Subject: [PATCH 1/3] Improve weekly forecast reliability and explain observation quality --- CHANGELOG.md | 1 + CodexUsageDock.Tests/ReliabilityTests.cs | 2 +- CodexUsageDock.Tests/UsageAlertTests.cs | 31 +++ CodexUsageDock.Tests/UsageDataTests.cs | 71 ++---- CodexUsageDock.Tests/WeeklyForecastTests.cs | 208 +++++++++++++++ CodexUsageDock/AdaptiveWeeklyForecast.cs | 240 ++++++++---------- .../CodexUsageDockCommandsProvider.cs | 3 +- CodexUsageDock/Pages/CodexUsageDockPage.cs | 33 ++- .../Pages/CodexUsageDockSettingsPage.cs | 2 +- CodexUsageDock/Pages/UsageDashboardCard.cs | 8 + CodexUsageDock/UsageAlerts.cs | 15 +- CodexUsageDock/UsageTrendAnalyzer.cs | 67 +++++ CodexUsageDock/WeeklyUsageTrendChart.cs | 15 +- DEVELOPMENT.md | 6 +- README.md | 4 +- USER_GUIDE.md | 12 +- 16 files changed, 514 insertions(+), 204 deletions(-) create mode 100644 CodexUsageDock.Tests/WeeklyForecastTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 69f8165..2621b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Each entry links to the commit or pull request that introduced the change. ### Changed +- Make weekly estimates conditional on at least 30 minutes of measurements, show their observation basis and remaining daily budget, and use representative recent history with less influence from short bursts further ahead. Break chart lines at measurement gaps and use the same adaptive model for weekly alerts. Add chronological synthetic forecast benchmarks. - Explain where to set the optional Windows CLI path environment variable, its lookup priority, and how to apply changes after restarting PowerToys. ([commit c8c7628](https://github.com/TheBeems/CodexUsageDock/commit/c8c762852e66ed3be5411b4614516110ab8a98a4)) - Simplify the README with a quick start, settings and troubleshooting tables, and a separate user guide for detailed usage and data handling. ([commit 84e04a4](https://github.com/TheBeems/CodexUsageDock/commit/84e04a4e0e38ecf15831175c65c6afb91d9b8bb8)) diff --git a/CodexUsageDock.Tests/ReliabilityTests.cs b/CodexUsageDock.Tests/ReliabilityTests.cs index d910b62..e62b254 100644 --- a/CodexUsageDock.Tests/ReliabilityTests.cs +++ b/CodexUsageDock.Tests/ReliabilityTests.cs @@ -232,7 +232,7 @@ public void TextAndChartBothPauseForecastAfterMeasurementGap() [Fact] public void ForecastUsesOnlyContinuousMeasurementsAfterGap() { - UsageHistoryEntry[] history = [new(Now.AddDays(-1), 100), new(Now.AddMinutes(-5), 75), new(Now, 70)]; + UsageHistoryEntry[] history = [new(Now.AddDays(-1), 100), .. WeeklyForecastTests.Series(Now.AddMinutes(-30), Now, 100, 70)]; var result = UsageTrendAnalyzer.Analyze(history, Now.AddDays(-4), Now.AddDays(3), Now, true, TimeSpan.FromMinutes(5)); Assert.Equal(Now.AddMinutes(70), result.Forecast!.EndsAt); } diff --git a/CodexUsageDock.Tests/UsageAlertTests.cs b/CodexUsageDock.Tests/UsageAlertTests.cs index 75443f7..c7b6c44 100644 --- a/CodexUsageDock.Tests/UsageAlertTests.cs +++ b/CodexUsageDock.Tests/UsageAlertTests.cs @@ -322,6 +322,37 @@ private static IReadOnlyList Evaluate( UsageAlertOptions? options = null) => evaluator.Evaluate(presentation, now ?? Now, RefreshInterval, options ?? EnabledOptions); + [Theory] + [InlineData(true)] + [InlineData(false)] + public void WeeklyWarningUsesTheSameAdaptiveChoiceAsTheDashboard(bool adaptive) + { + var reset = Now.AddDays(5); + var learned = WeeklyForecastTests.History(_ => 0, reset); + var evaluator = new UsageAlertEvaluator(); + var options = EnabledOptions with { LowRemainingPercent = 1, AdaptiveWeeklyForecastEnabled = adaptive }; + var baseline = Presentation(primaryRemaining: null, secondaryRemaining: 6, secondaryReset: reset, + updatedAt: Now.AddMinutes(-1)) with { AdaptiveWeeklyHistory = learned }; + Assert.Empty(Evaluate(evaluator, baseline, Now.AddMinutes(-1), options)); + var samples = WeeklyForecastTests.Series(Now.AddMinutes(-30), Now, 8.5, 5.5); + var current = Presentation(primaryRemaining: null, secondaryRemaining: 5.5, secondaryReset: reset, + weeklyHistory: samples) with { AdaptiveWeeklyHistory = learned }; + var forecast = UsageTrendAnalyzer.Analyze(samples, reset.AddDays(-7), reset, Now, true, + TimeSpan.FromMinutes(5), adaptive, learned).Forecast!; + var alerts = Evaluate(evaluator, current, Now, options); + + if (adaptive) + { + Assert.True(forecast.EndsAt > Now.AddHours(1)); + Assert.Empty(alerts); + } + else + { + Assert.True(forecast.EndsAt <= Now.AddHours(1)); + Assert.StartsWith("forecast:", Assert.Single(alerts).Key, StringComparison.Ordinal); + } + } + private static UsagePresentation Presentation( double? primaryRemaining = 80, double? secondaryRemaining = 80, diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index 542db70..e0ac22e 100644 --- a/CodexUsageDock.Tests/UsageDataTests.cs +++ b/CodexUsageDock.Tests/UsageDataTests.cs @@ -279,8 +279,8 @@ [new RateLimitResetCredit("Full reset", "available", now.AddDays(13))]), Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("weeklyElapsedBarUrl").GetString(), StringComparison.Ordinal); Assert.True(root.GetProperty("weeklyTrendAvailable").GetBoolean()); Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("weeklyTrendChartUrl").GetString(), StringComparison.Ordinal); - Assert.Contains("Solid line connects sampled values", root.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal); - Assert.Equal("Forecast: current pace only.", root.GetProperty("weeklyForecastStatus").GetString()); + Assert.Contains("Solid line connects continuous measurements", root.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal); + Assert.StartsWith("Forecast: recent 6 h only.", root.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal); Assert.Equal("On track", root.GetProperty("fiveHourPaceStatus").GetString()); Assert.Equal("Comfortably on track", root.GetProperty("weeklyPaceStatus").GetString()); Assert.Contains("Projected at reset", root.GetProperty("fiveHourProjection").GetString(), StringComparison.Ordinal); @@ -346,7 +346,7 @@ public void WeeklyTrendUsesFullWindowHistoryBeforeTheLatestQuotaIncrease() Assert.True(root.GetProperty("weeklyTrendAvailable").GetBoolean()); Assert.Equal("250000", thursdayTokenBar.Attribute("data-tokens")?.Value); - Assert.Equal(2, observedLines.Length); + Assert.Equal(3, observedLines.Length); } [Fact] @@ -364,34 +364,21 @@ public void WeeklyDashboardUsesAdaptiveLocalHistory() now, UsageDataSource.AppServer, null); - var cycles = Enumerable.Range(1, 3) - .Select(offset => new AdaptiveWeeklyUsageCycle( - reset.AddDays(-7 * offset), - 10080, - 60, - 6, - [new AdaptiveWeeklyUsageBucket(1, 60, 12)])) - .ToArray(); + var cycles = WeeklyForecastTests.History(index => index == 1 ? 0.2 : 0.001, reset).CompletedCycles; var data = CodexUsageDockPage.FormatMainDataJson( snapshot, now, isLoading: false, primaryHistory: [], - weeklyHistory: - [ - new UsageHistoryEntry(now.AddMinutes(-10), 90), - new UsageHistoryEntry(now, 80), - ], + weeklyHistory: ContinuousHistory(now.AddMinutes(-30), 90, now, 80), refreshInterval: TimeSpan.FromMinutes(1), adaptiveWeeklyForecastEnabled: true, adaptiveWeeklyHistory: new AdaptiveWeeklyUsageHistory(cycles, null)); using var document = JsonDocument.Parse(data); - Assert.Equal( - "Forecast: current pace + local history (3/8 cycles).", - document.RootElement.GetProperty("weeklyForecastStatus").GetString()); + Assert.Contains("3 usable weeks", document.RootElement.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal); } [Fact] @@ -537,7 +524,7 @@ public void WeeklyTrendChartRendersObservedForecastAndDailyTokens() Assert.Equal(["1M", "500K"], root.Descendants(Svg + "g") .Where(group => group.Attribute("data-axis")?.Value == "tokens") .Select(group => group.Attribute("data-axis-label")!.Value)); - Assert.Contains("Solid line connects sampled values", result.AltText, StringComparison.Ordinal); + Assert.Contains("Solid line connects continuous measurements", result.AltText, StringComparison.Ordinal); Assert.Contains("locally observed total tokens", result.AltText, StringComparison.Ordinal); Assert.DoesNotContain("NaN", result.ImageUrl, StringComparison.Ordinal); Assert.DoesNotContain("Infinity", result.ImageUrl, StringComparison.Ordinal); @@ -608,7 +595,7 @@ public void WeeklyTrendOmitsOnlyTokenBarsWhenLocalTokenDataIsUnavailable() } [Fact] - public void WeeklyTrendChartConnectsAllowanceGapsWithoutChangingDailyTokens() + public void WeeklyTrendChartBreaksAllowanceGapsWithoutChangingDailyTokens() { var windowStart = new DateTimeOffset(2026, 7, 10, 9, 0, 0, TimeSpan.Zero); var reset = windowStart.AddDays(7); @@ -637,10 +624,8 @@ public void WeeklyTrendChartConnectsAllowanceGapsWithoutChangingDailyTokens() root.Descendants(Svg + "rect"), bar => bar.Attribute("data-series")?.Value == "daily-tokens"); - var observedPoints = Assert.Single(observedLines) - .Attribute("points")!.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries); - - Assert.Equal(4, observedPoints.Length); + Assert.Equal(2, observedLines.Length); + Assert.All(observedLines, line => Assert.Equal(2, line.Attribute("points")!.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length)); Assert.Equal("250000", tokenBar.Attribute("data-tokens")?.Value); } @@ -676,7 +661,7 @@ public void WeeklyTrendChartBreaksAtQuotaIncreaseAndRetainsCalendarDayTokens() Assert.Equal(2, observedLines.Length); Assert.Equal("125000", dailyTokenBar.Attribute("data-tokens")?.Value); - Assert.Contains("line breaks mark allowance increases or resets", result.AltText, StringComparison.Ordinal); + Assert.Contains("gaps and allowance increases break the line", result.AltText, StringComparison.Ordinal); } [Fact] @@ -684,7 +669,7 @@ public void WeeklyRestorationAppearsInChartCardAndDetails() { var windowStart = new DateTimeOffset(2026, 7, 18, 9, 0, 0, TimeSpan.Zero); var reset = windowStart.AddDays(7); - var now = windowStart.AddMinutes(20); + var now = windowStart.AddMinutes(45); UsageHistoryEntry[] history = [ new(windowStart.AddMinutes(1), 100, reset, 10080), @@ -694,6 +679,7 @@ public void WeeklyRestorationAppearsInChartCardAndDetails() new(windowStart.AddMinutes(15), 92, reset, 10080), new(now, 90, reset, 10080), ]; + history = history[..^1].Concat(ContinuousHistory(windowStart.AddMinutes(15), 92, now, 90)).ToArray(); var snapshot = CodexUsageSnapshot.Loading with { Secondary = new RateLimitWindow(10, 10080, reset), @@ -1014,7 +1000,7 @@ public void WeeklyTrendChartUsesTheZeroGridlineAsTheTokenBarBaseline() } [Fact] - public void WeeklyTrendChartConnectsGapIsolatedObservationsAsASampledLine() + public void WeeklyTrendChartShowsGapIsolatedObservationsAsPoints() { var windowStart = new DateTimeOffset(2026, 7, 10, 9, 0, 0, TimeSpan.Zero); var reset = windowStart.AddDays(7); @@ -1032,11 +1018,8 @@ public void WeeklyTrendChartConnectsGapIsolatedObservationsAsASampledLine() var result = Assert.IsType(chart); var svg = ParseSvg(result.ImageUrl); - var observed = Assert.Single(svg.Descendants(Svg + "polyline")); - var points = observed.Attribute("points")!.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries); - - Assert.Equal(2, points.Length); - Assert.Single(svg.Descendants(Svg + "circle")); + Assert.Empty(svg.Descendants(Svg + "polyline")); + Assert.Equal(2, svg.Descendants(Svg + "circle").Count()); } [Fact] @@ -1895,7 +1878,7 @@ public void WeeklyTrendEstimatesRemainingAllowanceAtResetWhenLimitWillNotBeReach maximumSampleAge: TimeSpan.FromMinutes(5)); Assert.Contains("Weekly usage trend", trend, StringComparison.Ordinal); - Assert.Contains("Projected at reset: 40% available", trend, StringComparison.Ordinal); + Assert.Contains("Projected at reset: about 40% available", trend, StringComparison.Ordinal); Assert.DoesNotContain("limit may be reached", trend, StringComparison.Ordinal); } @@ -1905,7 +1888,7 @@ public void WeeklyTrendEstimatesLimitWhenConsumptionWillExceedAllowanceBeforeRes var now = DateTimeOffset.Now; var trend = CodexUsageDockPage.FormatTrend( "Weekly usage trend", - [new UsageHistoryEntry(now.AddMinutes(-10), 30), new UsageHistoryEntry(now, 10)], + ContinuousHistory(now.AddMinutes(-30), 30, now, 10), new RateLimitWindow(90, 10080, now.AddDays(3)), now, dataAvailable: true, @@ -1927,7 +1910,7 @@ public void WeeklyTrendIncludesDateWhenTheEstimatedLimitIsNotToday() dataAvailable: true, maximumSampleAge: TimeSpan.FromMinutes(5)); - Assert.Contains($"limit may be reached around {estimated.ToLocalTime():ddd d MMM HH:mm}", trend, StringComparison.Ordinal); + Assert.Contains($"limit may be reached around {estimated.ToLocalTime():ddd d MMM}.", trend, StringComparison.Ordinal); } [Fact] @@ -2094,7 +2077,7 @@ public void AdaptiveWeeklyUsageStoreDropsPersistedCyclesDuplicatedByResetTimeJit } [Fact] - public void AdaptiveForecastUsesTheActiveCycleWhenItsResetTimeHasSmallJitter() + public void AdaptiveForecastDoesNotDoubleCountTheActiveCycleAsLearnedHistory() { var reset = new DateTimeOffset(2026, 7, 24, 0, 0, 0, TimeSpan.Zero); var active = new AdaptiveWeeklyUsageCycle(reset, 10080, 60, 6, []); @@ -2107,7 +2090,8 @@ public void AdaptiveForecastUsesTheActiveCycleWhenItsResetTimeHasSmallJitter() enabled: true, new AdaptiveWeeklyUsageHistory([], active)); - Assert.Equal("Forecast: current pace + limited local history.", projection.Status); + Assert.Contains("history is insufficient", projection.Status, StringComparison.Ordinal); + Assert.False(projection.UsesHistory); } [Fact] @@ -2262,21 +2246,14 @@ public void AdaptiveForecastUsesSixHourPatternAfterThreeCompletedCycles() { var reset = new DateTimeOffset(2026, 7, 24, 0, 0, 0, TimeSpan.Zero); var start = reset.AddDays(-7); - var cycles = Enumerable.Range(1, 3) - .Select(offset => new AdaptiveWeeklyUsageCycle( - reset.AddDays(-7 * offset), - 10080, - 60, - 6, - [new AdaptiveWeeklyUsageBucket(1, 60, 12)])) - .ToArray(); + var cycles = WeeklyForecastTests.History(index => index == 1 ? 0.2 : 0.001, reset).CompletedCycles; var history = new AdaptiveWeeklyUsageHistory(cycles, null); var latest = new UsageHistoryEntry(start.AddHours(6), 50); var adaptive = AdaptiveWeeklyForecast.Project(latest, start, reset, 0.1, true, history); var currentOnly = AdaptiveWeeklyForecast.Project(latest, start, reset, 0.1, false, history); - Assert.Equal("Forecast: current pace + local history (3/8 cycles).", adaptive.Status); + Assert.Contains("3 usable weeks", adaptive.Status, StringComparison.Ordinal); Assert.True(adaptive.Forecast.EndsAt < currentOnly.Forecast.EndsAt); Assert.Equal("Forecast: current pace only.", currentOnly.Status); } diff --git a/CodexUsageDock.Tests/WeeklyForecastTests.cs b/CodexUsageDock.Tests/WeeklyForecastTests.cs new file mode 100644 index 0000000..f899e18 --- /dev/null +++ b/CodexUsageDock.Tests/WeeklyForecastTests.cs @@ -0,0 +1,208 @@ +using Xunit; +using Xunit.Abstractions; +using System.Text.Json; + +namespace CodexUsageDock.Tests; + +public sealed class WeeklyForecastTests(ITestOutputHelper output) +{ + private static readonly DateTimeOffset Reset = new(2026, 9, 21, 0, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset Now = Reset.AddDays(-5); + + [Fact] + public void ShortBurstDoesNotProduceAWeekProjection() + { + var result = Analyze(Series(Now.AddMinutes(-10), Now, 80, 78)); + + Assert.Null(result.Forecast); + Assert.Contains("30 minutes", result.Message, StringComparison.Ordinal); + } + + [Fact] + public void RecentPaceDoesNotDependOnOldContinuousIdleMeasurements() + { + var recent = Series(Now.AddHours(-6), Now, 90, 84); + var withOldIdle = Series(Now.AddDays(-1), Now.AddHours(-6), 90, 90).Concat(recent).ToArray(); + + Assert.Equal(Analyze(recent).Forecast!.EndsAt, Analyze(withOldIdle).Forecast!.EndsAt); + } + + [Fact] + public void EightMinutesOfHistoryDoNotCountAsEightUsableWeeks() + { + var cycles = Enumerable.Range(1, 8) + .Select(index => new AdaptiveWeeklyUsageCycle(Reset.AddDays(-7 * index), 10080, 1, 0.01, [])) + .ToArray(); + var samples = Series(Now.AddHours(-1), Now, 80, 78); + var result = Analyze(samples, new(cycles, null)); + + Assert.Equal(Analyze(samples).Forecast!.EndsAt, result.Forecast!.EndsAt); + Assert.Contains("insufficient", result.ForecastStatus, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void RepresentativeHistoryCanForecastAfterObservedIdleTime() + { + var result = Analyze(Series(Now.AddHours(-1), Now, 80, 80), History(_ => 0.002)); + + Assert.NotNull(result.Forecast); + Assert.InRange(result.Forecast.RemainingPercent, 60, 79); + Assert.Contains("3 usable weeks", result.ForecastStatus, StringComparison.Ordinal); + } + + [Fact] + public void LearnedQuietPeriodsEventuallyOverrideABurst() + { + var result = Analyze(Series(Now.AddHours(-1), Now, 81.2, 80), History(_ => 0)); + + Assert.NotNull(result.Forecast); + Assert.False(result.Forecast.ReachesLimitBeforeReset); + Assert.InRange(result.Forecast.RemainingPercent, 70, 80); + } + + [Fact] + public void MissingNightMeasurementsAreNotRepresentativeFullDayHistory() + { + var cycles = History(_ => 0.002).CompletedCycles.Select(cycle => cycle with + { + Buckets = cycle.Buckets.Where(bucket => bucket.Index % 4 != 0).ToArray(), + ObservedMinutes = 7560, + ConsumedPercent = 15.12, + }).ToArray(); + var samples = Series(Now.AddHours(-1), Now, 80, 78); + + Assert.Equal(Analyze(samples).Forecast!.EndsAt, Analyze(samples, new(cycles, null)).Forecast!.EndsAt); + } + + [Fact] + public void OldAndFutureProfilesCannotInfluenceTheForecast() + { + var cycles = History(_ => 0).CompletedCycles.Select((cycle, index) => cycle with + { + ResetsAt = index == 0 ? Now.AddDays(1) : Reset.AddDays(-100 - index * 7), + }).ToArray(); + var samples = Series(Now.AddHours(-1), Now, 80, 78); + + Assert.Equal(Analyze(samples).Forecast!.EndsAt, Analyze(samples, new(cycles, null)).Forecast!.EndsAt); + } + + [Fact] + public void ForecastStillPausesAfterAGapEvenWithGoodHistory() + { + var samples = Series(Now.AddHours(-2), Now.AddHours(-1), 90, 85) + .Append(new UsageHistoryEntry(Now, 80)).ToArray(); + + Assert.Null(Analyze(samples, History(_ => 0.002)).Forecast); + } + + [Fact] + public void ShiftedResetPatternsFallBackToTheRepresentativeAverage() + { + var patterned = History(index => index % 4 == 0 ? 0.008 : 0).CompletedCycles + .Select(cycle => cycle with { ResetsAt = cycle.ResetsAt.AddDays(-1) }).ToArray(); + var flat = History(_ => 0.002).CompletedCycles + .Select(cycle => cycle with { ResetsAt = cycle.ResetsAt.AddDays(-1) }).ToArray(); + var samples = Series(Now.AddHours(-1), Now, 80, 78); + + Assert.Equal(Analyze(samples, new(flat, null)).Forecast!.RemainingPercent, + Analyze(samples, new(patterned, null)).Forecast!.RemainingPercent, precision: 8); + } + + [Fact] + public void PartialCyclesHaveLessInfluenceThanFullyObservedCycles() + { + var complete = History(_ => 0); + var partial = new AdaptiveWeeklyUsageHistory(complete.CompletedCycles.Select(cycle => cycle with + { + ObservedMinutes = 5040, + Buckets = cycle.Buckets.Select(bucket => bucket with { ObservedMinutes = 180 }).ToArray(), + }).ToArray(), null); + var samples = Series(Now.AddHours(-1), Now, 81.2, 80); + + Assert.True(Analyze(samples, partial).Forecast!.RemainingPercent < Analyze(samples, complete).Forecast!.RemainingPercent); + } + + [Fact] + public void BudgetIsAvailableWithoutAForecastAndSuppressedWhenStale() + { + var snapshot = CodexUsageSnapshot.Loading with + { + Source = UsageDataSource.AppServer, + Secondary = new RateLimitWindow(55, 10080, Now.AddDays(3)), + UpdatedAt = Now, + }; + using var fresh = JsonDocument.Parse(CodexUsageDockPage.FormatMainDataJson(snapshot, Now, false, [], [], TimeSpan.FromMinutes(1))); + Assert.Contains("15 percentage points per day", fresh.RootElement.GetProperty("weeklyBudget").GetString(), StringComparison.Ordinal); + using var stale = JsonDocument.Parse(CodexUsageDockPage.FormatMainDataJson(snapshot, Now.AddHours(1), false, [], [], TimeSpan.FromMinutes(1))); + Assert.Contains("unavailable", stale.RootElement.GetProperty("weeklyBudget").GetString(), StringComparison.Ordinal); + } + + [Fact] + public void WeeklyEstimateDoesNotDisplayMinutePrecisionSeveralDaysAhead() + { + var local = Now.ToLocalTime().Date.AddDays(2).AddHours(14).AddMinutes(37); + var estimate = new DateTimeOffset(local, TimeZoneInfo.Local.GetUtcOffset(local)); + + Assert.DoesNotContain("14:37", UsageTrendAnalyzer.FormatWeeklyLimitEstimate(estimate, Now), StringComparison.Ordinal); + Assert.Equal("12:15", UsageTrendAnalyzer.FormatWeeklyLimitEstimate( + new DateTimeOffset(local.AddDays(-2).Date.AddHours(12).AddMinutes(15), estimate.Offset), + new DateTimeOffset(local.AddDays(-2).Date.AddHours(11), estimate.Offset))); + } + + [Theory] + [InlineData(6)] + [InlineData(24)] + [InlineData(0)] + public void RollingEvaluationImprovesOnCurrentPaceForARepeatedUsagePattern(int horizonHours) + { + // Every origin uses only the preceding three complete weeks and already-observed current-week values. + // This is a deterministic benchmark, not a claim of accuracy on users' future workloads. + static double Rate(int index) => (index % 4) switch { 0 => 0, 1 => 0.0005, 2 => 0.006, _ => 0.0015 }; + var start = Reset.AddDays(-7); + var history = History(Rate); + var adaptiveError = 0d; + var baselineError = 0d; + var forecasts = 0; + for (var bucket = 1; bucket < 28; bucket++) + { + var origin = start.AddHours(bucket * 6); + var target = horizonHours == 0 ? Reset : origin.AddHours(horizonHours); + if (target > Reset) + { + continue; + } + + var remaining = 100 - Enumerable.Range(0, bucket).Sum(index => Rate(index) * 360); + var latest = new UsageHistoryEntry(origin, remaining); + var recentRate = Rate(bucket - 1); + var adaptive = AdaptiveWeeklyForecast.Project(latest, start, Reset, recentRate, true, history).Forecast; + var actual = 100 - Enumerable.Range(0, (int)((target - start).TotalHours / 6)).Sum(index => Rate(index) * 360); + var predicted = adaptive.Points!.Last(point => point.RecordedAt <= target).RemainingPercent; + var baseline = Math.Max(0, remaining - recentRate * (target - origin).TotalMinutes); + adaptiveError += Math.Abs(actual - predicted); + baselineError += Math.Abs(actual - baseline); + forecasts++; + } + + output.WriteLine($"Horizon {horizonHours} h (0 = reset), {forecasts} origins: MAE adaptive {adaptiveError / forecasts:0.000} pp; current pace {baselineError / forecasts:0.000} pp."); + Assert.True(adaptiveError < baselineError, $"Adaptive error {adaptiveError} should improve on baseline {baselineError}."); + } + + internal static UsageHistoryEntry[] Series(DateTimeOffset start, DateTimeOffset end, double first, double last) + { + var count = (int)Math.Ceiling((end - start).TotalMinutes / 5); + return Enumerable.Range(0, count + 1).Select(index => new UsageHistoryEntry( + start + TimeSpan.FromTicks((end - start).Ticks * index / count), + first + (last - first) * index / count)).ToArray(); + } + + internal static AdaptiveWeeklyUsageHistory History(Func rate, DateTimeOffset? reset = null) + { + var buckets = Enumerable.Range(0, 28).Select(index => new AdaptiveWeeklyUsageBucket(index, 360, 360 * rate(index))).ToArray(); + return new(Enumerable.Range(1, 3).Select(index => new AdaptiveWeeklyUsageCycle( + (reset ?? Reset).AddDays(-7 * index), 10080, 10080, buckets.Sum(bucket => bucket.ConsumedPercent), buckets)).ToArray(), null); + } + + private static UsageTrendAnalyzer.TrendAnalysis Analyze(UsageHistoryEntry[] samples, AdaptiveWeeklyUsageHistory? history = null) => + UsageTrendAnalyzer.Analyze(samples, Reset.AddDays(-7), Reset, Now, true, TimeSpan.FromMinutes(5), history is not null, history); +} diff --git a/CodexUsageDock/AdaptiveWeeklyForecast.cs b/CodexUsageDock/AdaptiveWeeklyForecast.cs index 1c11318..58b3471 100644 --- a/CodexUsageDock/AdaptiveWeeklyForecast.cs +++ b/CodexUsageDock/AdaptiveWeeklyForecast.cs @@ -22,7 +22,7 @@ internal sealed record AdaptiveWeeklyUsageHistory( AdaptiveWeeklyUsageCycle[] CompletedCycles, AdaptiveWeeklyUsageCycle? ActiveCycle); -internal sealed record AdaptiveWeeklyForecastProjection(UsageTrendForecast Forecast, string Status); +internal sealed record AdaptiveWeeklyForecastProjection(UsageTrendForecast Forecast, string Status, bool UsesHistory = false); internal sealed class AdaptiveWeeklyUsageStore { @@ -359,9 +359,10 @@ private static bool IsValidSample(UsageHistoryEntry sample) => internal static class AdaptiveWeeklyForecast { - private const double BootstrapWeight = 0.25; - private const double MatureWeight = 0.35; - private const int MinimumBucketCycles = 3; + private const int MinimumPatternCycles = 3; + private const double MinimumCoverage = 0.5; + private static readonly TimeSpan MaximumHistoryAge = TimeSpan.FromDays(56); + private static readonly TimeSpan RecentInfluenceDuration = TimeSpan.FromHours(3); internal static AdaptiveWeeklyForecastProjection Project( UsageHistoryEntry latest, @@ -369,161 +370,142 @@ internal static AdaptiveWeeklyForecastProjection Project( DateTimeOffset resetsAt, double currentRatePerMinute, bool enabled, - AdaptiveWeeklyUsageHistory? history) + AdaptiveWeeklyUsageHistory? history, + TimeSpan? recentDuration = null) { - if (!enabled || history is null) - { - return new AdaptiveWeeklyForecastProjection( - ProjectAtCurrentRate(latest, resetsAt, currentRatePerMinute), - "Forecast: current pace only."); - } - - var completed = history.CompletedCycles - .Where(cycle => cycle.WindowMinutes == (int)TimeSpan.FromDays(7).TotalMinutes && GetRate(cycle) is not null) - .OrderByDescending(cycle => cycle.ResetsAt) - .Take(AdaptiveWeeklyUsageStore.MaximumCompletedCycles) - .ToArray(); - var provisional = history.ActiveCycle is { } active - && AdaptiveWeeklyUsageStore.IsSameCycle(active, resetsAt, (int)TimeSpan.FromDays(7).TotalMinutes) - && GetRate(active) is not null - ? active - : null; - var profiles = completed.Length > 0 - ? completed - : provisional is null ? [] : [provisional]; + var basis = recentDuration is { } duration + ? duration.TotalHours < 1 ? $"recent {duration.TotalMinutes:0} min" : $"recent {duration.TotalHours:0.#} h" + : "current pace"; + var profiles = enabled && history is not null + ? history.CompletedCycles + .Where(cycle => IsRepresentative(cycle) + && cycle.ResetsAt <= windowStart.AddMinutes(1) + && windowStart - cycle.ResetsAt <= MaximumHistoryAge) + .OrderByDescending(cycle => cycle.ResetsAt) + .DistinctBy(cycle => cycle.ResetsAt) + .Take(AdaptiveWeeklyUsageStore.MaximumCompletedCycles) + .ToArray() + : []; if (profiles.Length == 0) { - return new AdaptiveWeeklyForecastProjection( - ProjectAtCurrentRate(latest, resetsAt, currentRatePerMinute), - "Forecast: current pace only."); + var reason = enabled ? " Weekly history is insufficient or outdated." : string.Empty; + return new(ProjectAtCurrentRate(latest, resetsAt, currentRatePerMinute), + $"Forecast: {basis} only.{reason}"); } - var historicalRate = Median(profiles.Select(cycle => GetRate(cycle)!.Value)); - var historicalWeight = completed.Length == 0 - ? BootstrapWeight - : BootstrapWeight + (Math.Min(completed.Length, AdaptiveWeeklyUsageStore.MaximumCompletedCycles) - 1) - * (MatureWeight - BootstrapWeight) / (AdaptiveWeeklyUsageStore.MaximumCompletedCycles - 1); - var projected = ProjectByBucket( - latest, - windowStart, - resetsAt, - currentRatePerMinute, - historicalRate, - historicalWeight, - profiles); - var status = completed.Length == 0 - ? "Forecast: current pace + limited local history." - : $"Forecast: current pace + local history ({completed.Length}/{AdaptiveWeeklyUsageStore.MaximumCompletedCycles} cycles)."; - return new AdaptiveWeeklyForecastProjection(projected, status); - } - - private static UsageTrendForecast ProjectByBucket( - UsageHistoryEntry latest, - DateTimeOffset windowStart, - DateTimeOffset resetsAt, - double currentRatePerMinute, - double historicalRatePerMinute, - double historicalWeight, - IReadOnlyList profiles) - { + var totalWeight = profiles.Sum(cycle => Weight(cycle, windowStart)); + var coverage = profiles.Sum(cycle => Coverage(cycle) * Weight(cycle, windowStart)) / totalWeight; + // Limited coverage or history depth slows the transition from recent pace to the historical baseline. + var historyInfluence = coverage * Math.Min(1, totalWeight / MinimumPatternCycles); + var historicalRate = WeightedMedian(profiles.Select(cycle => + (Rate: cycle.ConsumedPercent / cycle.ObservedMinutes, Weight: Weight(cycle, windowStart)))); var points = new List(); var cursor = latest.RecordedAt; var remaining = latest.RemainingPercent; while (cursor < resetsAt && remaining > 0) { - var bucketIndex = GetBucketIndex(cursor, windowStart); - var bucketEnd = Min(resetsAt, windowStart + TimeSpan.FromTicks(AdaptiveWeeklyUsageStore.BucketDuration.Ticks * (bucketIndex + 1))); - if (bucketEnd <= cursor) - { - break; - } - - var bucketFactor = GetBucketFactor(profiles, bucketIndex); - var rate = currentRatePerMinute * (1 - historicalWeight) + historicalRatePerMinute * bucketFactor * historicalWeight; - if (rate <= 0 || !double.IsFinite(rate)) + var index = GetBucketIndex(cursor, windowStart); + var end = Min(resetsAt, Min(cursor.AddHours(1), + windowStart + TimeSpan.FromTicks(AdaptiveWeeklyUsageStore.BucketDuration.Ticks * (index + 1)))); + var horizon = (cursor - latest.RecordedAt + (end - cursor) / 2).TotalMinutes; + var historyWeight = 1 - Math.Exp(-horizon * historyInfluence / RecentInfluenceDuration.TotalMinutes); + var bucketRate = BucketRate(profiles, index, windowStart) ?? historicalRate; + var rate = currentRatePerMinute * (1 - historyWeight) + bucketRate * historyWeight; + var consumed = rate * (end - cursor).TotalMinutes; + if (consumed >= remaining && rate > 0) { - return ProjectAtCurrentRate(latest, resetsAt, currentRatePerMinute); + var limit = cursor.AddMinutes(remaining / rate); + points.Add(new(limit, 0)); + return Result(new(limit, 0, limit < resetsAt, points)); } - var capacity = rate * (bucketEnd - cursor).TotalMinutes; - if (capacity >= remaining) - { - var endsAt = cursor.AddMinutes(remaining / rate); - points.Add(new UsageTrendForecastPoint(endsAt, 0)); - return new UsageTrendForecast(endsAt, 0, true, points); - } - - remaining -= capacity; - cursor = bucketEnd; - points.Add(new UsageTrendForecastPoint(cursor, remaining)); + remaining = Math.Max(0, remaining - consumed); + cursor = end; + points.Add(new(cursor, remaining)); } - return new UsageTrendForecast(resetsAt, Math.Max(0, remaining), false, points); - } - - private static UsageTrendForecast ProjectAtCurrentRate( - UsageHistoryEntry latest, - DateTimeOffset resetsAt, - double currentRatePerMinute) - { - var minutesToEmpty = latest.RemainingPercent / currentRatePerMinute; - var estimated = latest.RecordedAt.AddMinutes(minutesToEmpty); - if (estimated >= resetsAt) - { - var remainingAtReset = Math.Max(0, latest.RemainingPercent - currentRatePerMinute * (resetsAt - latest.RecordedAt).TotalMinutes); - return new UsageTrendForecast( - resetsAt, - remainingAtReset, - false, - [new UsageTrendForecastPoint(resetsAt, remainingAtReset)]); - } + return Result(new(resetsAt, remaining, false, points)); - return new UsageTrendForecast( - estimated, - 0, - true, - [new UsageTrendForecastPoint(estimated, 0)]); + AdaptiveWeeklyForecastProjection Result(UsageTrendForecast forecast) => new(forecast, + $"Forecast: {basis} + {profiles.Length} usable week{(profiles.Length == 1 ? string.Empty : "s")} ({coverage:P0} observed). Historical influence grows further ahead; estimate only.", + UsesHistory: true); } - private static double GetBucketFactor(IReadOnlyList profiles, int bucketIndex) + private static bool IsRepresentative(AdaptiveWeeklyUsageCycle cycle) => + cycle.WindowMinutes == 10080 + && double.IsFinite(cycle.ObservedMinutes) && cycle.ObservedMinutes is >= 5040 and <= 10080 + && double.IsFinite(cycle.ConsumedPercent) && cycle.ConsumedPercent >= 0 + && cycle.Buckets.All(bucket => bucket.Index is >= 0 and < 28 + && double.IsFinite(bucket.ObservedMinutes) && bucket.ObservedMinutes is >= 0 and <= 360 + && double.IsFinite(bucket.ConsumedPercent) && bucket.ConsumedPercent >= 0) + && cycle.Buckets.Select(bucket => bucket.Index).Distinct().Count() == cycle.Buckets.Length + && Math.Abs(cycle.Buckets.Sum(bucket => bucket.ObservedMinutes) - cycle.ObservedMinutes) < 0.001 + && Math.Abs(cycle.Buckets.Sum(bucket => bucket.ConsumedPercent) - cycle.ConsumedPercent) < 0.001 + && Coverage(cycle) >= MinimumCoverage + // Observing only daytime activity cannot establish an around-the-clock baseline. + && Enumerable.Range(0, 4).All(slot => cycle.Buckets + .Where(bucket => bucket.Index % 4 == slot).Sum(bucket => bucket.ObservedMinutes) >= 360); + + private static double Coverage(AdaptiveWeeklyUsageCycle cycle) => + Math.Min(cycle.ObservedMinutes, cycle.Buckets.Sum(bucket => bucket.ObservedMinutes)) / cycle.WindowMinutes; + + private static double Weight(AdaptiveWeeklyUsageCycle cycle, DateTimeOffset windowStart) => + Coverage(cycle) * Math.Pow(0.5, Math.Max(0, (windowStart - cycle.ResetsAt).TotalDays) / 28); + + private static double? BucketRate(AdaptiveWeeklyUsageCycle[] profiles, int index, DateTimeOffset windowStart) { - var factors = profiles - .Select(cycle => - { - var rate = GetRate(cycle); - var bucket = cycle.Buckets.FirstOrDefault(candidate => candidate.Index == bucketIndex); - return rate is > 0 && bucket is { ObservedMinutes: > 0 } - ? bucket.ConsumedPercent / bucket.ObservedMinutes / rate.Value - : double.NaN; - }) - .Where(double.IsFinite) - .Where(factor => factor >= 0) + var rates = profiles.Where(cycle => HasMatchingCalendarPhase(cycle, windowStart)) + .Select(cycle => (Cycle: cycle, Bucket: cycle.Buckets.FirstOrDefault(bucket => bucket.Index == index))) + .Where(item => item.Bucket is { ObservedMinutes: >= 180 }) + .Select(item => (Rate: item.Bucket!.ConsumedPercent / item.Bucket.ObservedMinutes, + Weight: Weight(item.Cycle, windowStart) * item.Bucket.ObservedMinutes / 360)) .ToArray(); - return factors.Length >= MinimumBucketCycles - ? Math.Clamp(Median(factors), 0, 4) - : 1; + return rates.Length >= MinimumPatternCycles ? WeightedMedian(rates) : null; } - private static double? GetRate(AdaptiveWeeklyUsageCycle cycle) => - cycle.ObservedMinutes > 0 && double.IsFinite(cycle.ObservedMinutes) && double.IsFinite(cycle.ConsumedPercent) - ? cycle.ConsumedPercent / cycle.ObservedMinutes - : null; + private static bool HasMatchingCalendarPhase(AdaptiveWeeklyUsageCycle cycle, DateTimeOffset windowStart) + { + var previous = cycle.ResetsAt.AddMinutes(-cycle.WindowMinutes).ToLocalTime(); + var current = windowStart.ToLocalTime(); + // Older files store reset-relative buckets. Do not reinterpret shifted resets or DST as the same clock-time pattern. + return previous.DayOfWeek == current.DayOfWeek && previous.Offset == current.Offset + && Math.Abs((previous.TimeOfDay - current.TimeOfDay).TotalMinutes) < 1; + } private static int GetBucketIndex(DateTimeOffset instant, DateTimeOffset windowStart) => - Math.Clamp((int)Math.Floor((instant - windowStart).TotalHours / AdaptiveWeeklyUsageStore.BucketDuration.TotalHours), 0, AdaptiveWeeklyUsageStore.BucketCount - 1); + Math.Clamp((int)((instant - windowStart).TotalHours / 6), 0, 27); - private static double Median(IEnumerable values) + private static double WeightedMedian(IEnumerable<(double Rate, double Weight)> values) { - var ordered = values.OrderBy(value => value).ToArray(); - var middle = ordered.Length / 2; - return ordered.Length % 2 == 0 - ? (ordered[middle - 1] + ordered[middle]) / 2 - : ordered[middle]; + var ordered = values.OrderBy(value => value.Rate).ToArray(); + var half = ordered.Sum(value => value.Weight) / 2; + var cumulative = 0d; + foreach (var value in ordered) + { + cumulative += value.Weight; + if (cumulative >= half) + { + return value.Rate; + } + } + + return ordered[^1].Rate; + } + + private static UsageTrendForecast ProjectAtCurrentRate(UsageHistoryEntry latest, DateTimeOffset resetsAt, double rate) + { + var remainingAtReset = latest.RemainingPercent - rate * (resetsAt - latest.RecordedAt).TotalMinutes; + if (rate <= 0 || remainingAtReset >= 0) + { + return new(resetsAt, Math.Max(0, remainingAtReset), false, [new(resetsAt, Math.Max(0, remainingAtReset))]); + } + + var limit = latest.RecordedAt.AddMinutes(latest.RemainingPercent / rate); + return new(limit, 0, true, [new(limit, 0)]); } private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) => left <= right ? left : right; } - [JsonSerializable(typeof(AdaptiveWeeklyUsageState))] internal sealed partial class AdaptiveWeeklyUsageJsonContext : JsonSerializerContext { diff --git a/CodexUsageDock/CodexUsageDockCommandsProvider.cs b/CodexUsageDock/CodexUsageDockCommandsProvider.cs index 0d8dcd7..b1baec8 100644 --- a/CodexUsageDock/CodexUsageDockCommandsProvider.cs +++ b/CodexUsageDock/CodexUsageDockCommandsProvider.cs @@ -160,7 +160,8 @@ private void OnUsageUpdated(object? sender, EventArgs e) band.NotifyItemsChanged(); } var alerts = _alerts.Evaluate(_usage.GetPresentation(), _clock(), _usage.RefreshInterval, - new UsageAlertOptions(Enabled: _settings.EnableUsageAlerts)); + new UsageAlertOptions(Enabled: _settings.EnableUsageAlerts, + AdaptiveWeeklyForecastEnabled: _settings.UseAdaptiveWeeklyForecast)); if (alerts.Count > 0) { var message = string.Join(" · ", alerts.Take(3).Select(alert => alert.Message)); diff --git a/CodexUsageDock/Pages/CodexUsageDockPage.cs b/CodexUsageDock/Pages/CodexUsageDockPage.cs index cbaca81..5bda93d 100644 --- a/CodexUsageDock/Pages/CodexUsageDockPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockPage.cs @@ -427,9 +427,28 @@ internal static string FormatWindow(string name, RateLimitWindow? window, DateTi adaptiveWeeklyForecastEnabled, adaptiveWeeklyHistory); data[$"{prefix}Projection"] = trend.Message; + if (prefix == "weekly") + { + data["weeklyBudget"] = FormatWeeklyBudget(window, now, dataAvailable); + } return trend; } + private static string FormatWeeklyBudget(RateLimitWindow window, DateTimeOffset now, bool dataAvailable) + { + if (!dataAvailable || !UsageFreshness.IsValidWindow(window, now)) + { + return "Remaining usage budget unavailable until refreshed."; + } + + var remaining = window.ResetsAt - now; + return remaining.TotalDays >= 1 + ? $"To last until reset: average at most {window.RemainingPercent / remaining.TotalDays:0.#} percentage points per day." + : remaining.TotalHours >= 1 + ? $"To last until reset: average at most {window.RemainingPercent / remaining.TotalHours:0.#} percentage points per hour." + : $"Available until reset in {Math.Ceiling(remaining.TotalMinutes):0} min: {window.RemainingPercent:0}%."; + } + private static void AddWeeklyTrendData( JsonObject data, RateLimitWindow? window, @@ -478,12 +497,12 @@ private static void AddWeeklyTrendData( data["weeklyTrendChartAlt"] = chart.AltText; var forecastLegend = dataAvailable && trend?.Forecast is not null ? "dashed: forecast" - : "forecast unavailable until usage data is refreshed"; + : "forecast pending sufficient fresh measurements"; data["weeklyTrendLegend"] = tokenUsage?.Status switch { - LocalTokenUsageStatus.Complete => $"Solid: remaining allowance (%) · {forecastLegend} · bars: local tokens per day · amber: detected restorations", - LocalTokenUsageStatus.Partial => $"Solid: remaining allowance (%) · {forecastLegend} · bars: partial local tokens per day · amber: detected restorations", - _ => $"Solid: remaining allowance (%) · {forecastLegend} · local token data unavailable · amber: detected restorations", + LocalTokenUsageStatus.Complete => $"Solid: remaining allowance (%) · breaks: gaps or restorations · {forecastLegend} · bars: local tokens per day · amber: detected restorations", + LocalTokenUsageStatus.Partial => $"Solid: remaining allowance (%) · breaks: gaps or restorations · {forecastLegend} · bars: partial local tokens per day · amber: detected restorations", + _ => $"Solid: remaining allowance (%) · breaks: gaps or restorations · {forecastLegend} · local token data unavailable · amber: detected restorations", }; var restorations = WeeklyAllowanceRestoration.Detect(history, validWindow, now); @@ -613,7 +632,7 @@ private static string FormatTrendBody( AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null) { var windowStartsAt = window.ResetsAt - TimeSpan.FromMinutes(window.WindowMinutes); - return FormatTrendBodyForReset( + var body = FormatTrendBodyForReset( history, windowStartsAt, window.ResetsAt, @@ -622,6 +641,7 @@ private static string FormatTrendBody( maximumSampleAge, adaptiveWeeklyForecastEnabled, adaptiveWeeklyHistory); + return window.WindowMinutes == 10080 ? $"{body} \n{FormatWeeklyBudget(window, now, dataAvailable)}" : body; } private static string FormatTrendBodyForReset( @@ -649,7 +669,8 @@ private static string FormatTrendBodyForReset( } var message = analysis.IsEstimate ? $"*{analysis.Message}*" : analysis.Message; - return $"{analysis.HistoryValues} \n{message}"; + var basis = resetsAt - windowStartsAt == TimeSpan.FromDays(7) ? $" \n{analysis.ForecastStatus}" : string.Empty; + return $"{analysis.HistoryValues} \n{message}{basis}"; } private static TimeSpan TrendFreshness(TimeSpan refreshInterval) => diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index 6a0f6e7..b0cc4f7 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -63,7 +63,7 @@ internal CodexUsageDockSettingsPage(string path) _settings.Add(new ToggleSetting(UseAdaptiveWeeklyForecastKey, true) { Label = "Use adaptive weekly forecast", - Description = "Blend the current pace with up to eight local weekly cycles. Turning this off pauses learning and keeps saved history.", + Description = "Use sufficiently observed recent weeks to improve longer-range estimates. Turning this off pauses learning and keeps saved history.", }); _settings.Add(new ToggleSetting(EnableUsageAlertsKey, false) { diff --git a/CodexUsageDock/Pages/UsageDashboardCard.cs b/CodexUsageDock/Pages/UsageDashboardCard.cs index 635e8bc..4984432 100644 --- a/CodexUsageDock/Pages/UsageDashboardCard.cs +++ b/CodexUsageDock/Pages/UsageDashboardCard.cs @@ -351,6 +351,14 @@ internal static class UsageDashboardCard "wrap": true, "$when": "${weeklyAvailable}" }, + { + "type": "TextBlock", + "text": "${weeklyBudget}", + "isSubtle": true, + "spacing": "small", + "wrap": true, + "$when": "${weeklyAvailable}" + }, { "type": "Container", "separator": true, diff --git a/CodexUsageDock/UsageAlerts.cs b/CodexUsageDock/UsageAlerts.cs index 7510613..e252f1b 100644 --- a/CodexUsageDock/UsageAlerts.cs +++ b/CodexUsageDock/UsageAlerts.cs @@ -6,7 +6,8 @@ internal sealed record UsageAlertOptions( bool Enabled = false, int LowRemainingPercent = 10, bool ForecastWarnings = true, - bool ResetExpiryWarnings = true); + bool ResetExpiryWarnings = true, + bool AdaptiveWeeklyForecastEnabled = true); internal sealed record UsageAlert(string Key, string Message); @@ -97,7 +98,9 @@ internal IReadOnlyList Evaluate( presentation.WeeklyHistory, now, refreshInterval, - alerts); + alerts, + options.AdaptiveWeeklyForecastEnabled, + presentation.AdaptiveWeeklyHistory); } if (options.ResetExpiryWarnings) @@ -282,7 +285,9 @@ private void EvaluateForecastWarning( IReadOnlyList history, DateTimeOffset now, TimeSpan refreshInterval, - List alerts) + List alerts, + bool adaptiveWeeklyForecastEnabled = false, + AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null) { if (targetWindow is null) { @@ -306,7 +311,9 @@ private void EvaluateForecastWarning( targetWindow.ResetsAt, now, dataAvailable: true, - UsageFreshness.MaximumAge(refreshInterval)).Forecast; + UsageFreshness.MaximumAge(refreshInterval), + adaptiveWeeklyForecastEnabled, + adaptiveWeeklyHistory).Forecast; } catch (ArgumentException) { diff --git a/CodexUsageDock/UsageTrendAnalyzer.cs b/CodexUsageDock/UsageTrendAnalyzer.cs index 0adc10d..9f92e47 100644 --- a/CodexUsageDock/UsageTrendAnalyzer.cs +++ b/CodexUsageDock/UsageTrendAnalyzer.cs @@ -34,6 +34,13 @@ internal static TrendAnalysis Analyze( var values = string.Join(" → ", samples.Select(sample => $"{sample.RemainingPercent:0}%")); var first = currentWindow[0]; var last = currentWindow[^1]; + var isWeekly = resetsAt - windowStartsAt == TimeSpan.FromDays(7); + if (isWeekly) + { + return AnalyzeWeekly(currentWindow, values, windowStartsAt!.Value, resetsAt!.Value, now, + adaptiveWeeklyForecastEnabled, adaptiveWeeklyHistory); + } + var elapsedMinutes = (last.RecordedAt - first.RecordedAt).TotalMinutes; var consumed = first.RemainingPercent - last.RemainingPercent; if (elapsedMinutes < 2 || consumed <= 0.5) @@ -112,6 +119,66 @@ internal sealed record TrendAnalysis( UsageTrendForecast? Forecast, string ForecastStatus); + private static TrendAnalysis AnalyzeWeekly( + UsageHistoryEntry[] segment, string values, DateTimeOffset start, DateTimeOffset reset, DateTimeOffset now, + bool adaptiveEnabled, AdaptiveWeeklyUsageHistory? history) + { + var last = segment[^1]; + var cutoff = last.RecordedAt.AddHours(-6); + var first = segment[0]; + for (var index = 1; index < segment.Length && segment[index - 1].RecordedAt < cutoff; index++) + { + var previous = segment[index - 1]; + var next = segment[index]; + if (next.RecordedAt < cutoff) + { + continue; + } + + var fraction = (cutoff - previous.RecordedAt).TotalMinutes / (next.RecordedAt - previous.RecordedAt).TotalMinutes; + first = new(cutoff, previous.RemainingPercent + fraction * (next.RemainingPercent - previous.RemainingPercent)); + break; + } + + var duration = last.RecordedAt - first.RecordedAt; + if (duration < TimeSpan.FromMinutes(30)) + { + return new(segment, values, "Weekly projection needs at least 30 minutes of continuous measurements.", false, null, + $"Forecast: collecting measurements ({duration.TotalMinutes:0}/30 minutes)."); + } + + var consumed = first.RemainingPercent - last.RemainingPercent; + var projection = AdaptiveWeeklyForecast.Project(last, start, reset, consumed / duration.TotalMinutes, + adaptiveEnabled, history, duration); + if ((consumed <= 0 || segment[0].RemainingPercent - last.RemainingPercent <= 0.5) && !projection.UsesHistory) + { + return new(segment, values, "No meaningful recent change; weekly projection pending.", false, null, projection.Status); + } + + var condition = projection.UsesHistory ? "With recent usage and observed history" : "If this recent pace continues"; + var forecast = projection.Forecast; + var message = forecast.ReachesLimitBeforeReset + ? $"{condition}, the limit may be reached around {FormatWeeklyLimitEstimate(forecast.EndsAt, now)}." + : $"Projected at reset: about {forecast.RemainingPercent:0}% available. {condition}."; + return new(segment, values, message, true, forecast, projection.Status); + } + + internal static string FormatWeeklyLimitEstimate(DateTimeOffset estimated, DateTimeOffset now) + { + var local = estimated.ToLocalTime(); + if (estimated - now >= TimeSpan.FromDays(1)) + { + return local.ToString("ddd d MMM", CultureInfo.CurrentCulture); + } + + // Round only the presentation; alert thresholds and chart points retain the calculated instant. + var quarter = TimeSpan.FromMinutes(15).Ticks; + var rounded = local.AddTicks((quarter - local.Ticks % quarter) % quarter); + return rounded.Date == now.ToLocalTime().Date + ? rounded.ToString("HH:mm", CultureInfo.CurrentCulture) + : rounded.ToString("ddd d MMM HH:mm", CultureInfo.CurrentCulture); + } + private static string FormatLimitEstimate(DateTimeOffset estimated, DateTimeOffset now) => estimated.ToLocalTime().Date == now.ToLocalTime().Date ? estimated.ToLocalTime().ToString("HH:mm", CultureInfo.CurrentCulture) diff --git a/CodexUsageDock/WeeklyUsageTrendChart.cs b/CodexUsageDock/WeeklyUsageTrendChart.cs index b5596eb..4aac156 100644 --- a/CodexUsageDock/WeeklyUsageTrendChart.cs +++ b/CodexUsageDock/WeeklyUsageTrendChart.cs @@ -102,7 +102,9 @@ internal static class WeeklyUsageTrendChartRenderer ApplyDailyTokens(dailyUse, tokenUsage); var tokenScaleMaximum = GetTokenScaleMaximum(dailyUse); var calendarScale = new CalendarDayScale(dailyUse); - var renderedSegments = DownsampleSegments(SplitAtQuotaIncreases(samples), windowStart, window.ResetsAt); + var renderedSegments = DownsampleSegments(SplitAtDiscontinuities(samples, + (previous, current) => WeeklyAllowanceRestoration.IsIncrease(previous, current) + || current.RecordedAt - previous.RecordedAt > maximumGap), windowStart, window.ResetsAt); var latestSegment = UsageTrendHistory.LatestSegment(samples, windowStart, window.ResetsAt, effectiveNow, maximumGap); var forecastSegment = latestSegment is { Length: >= 2 } ? latestSegment : null; var usableForecast = forecastSegment is not null && forecast is { } candidate && candidate.EndsAt > forecastSegment[^1].RecordedAt @@ -235,11 +237,6 @@ private static UsageHistoryEntry[] Downsample( .ToArray(); } - private static List SplitAtQuotaIncreases(UsageHistoryEntry[] samples) => - SplitAtDiscontinuities( - samples, - WeeklyAllowanceRestoration.IsIncrease); - private static List SplitAtDiscontinuities( UsageHistoryEntry[] samples, Func startsNewSegment) @@ -773,14 +770,14 @@ private static string FormatAltText( var daily = FormatDailyTokenAltText(dailyUse, tokenUsage, tokenScaleMaximum, culture); var forecastText = forecast switch { - { ReachesLimitBeforeReset: true } => $" Forecast reaches the limit around {TimeZoneInfo.ConvertTime(forecast.EndsAt, timeZone).ToString("ddd d MMM HH:mm", culture)}.", - { } => $" Forecast leaves {forecast.RemainingPercent:0}% at reset.", + { ReachesLimitBeforeReset: true } => $" Estimated limit day: {TimeZoneInfo.ConvertTime(forecast.EndsAt, timeZone).ToString("ddd d MMM", culture)}; actual usage may differ.", + { } => $" Forecast leaves about {forecast.RemainingPercent:0}% at reset; actual usage may differ.", null => " Forecast is unavailable.", }; var restorationText = restorations.Length > 0 ? $" {restorations.Length} allowance restoration{(restorations.Length == 1 ? " was" : "s were")} detected; the latest at {TimeZoneInfo.ConvertTime(restorations[^1].DetectedAt, timeZone).ToString("ddd d MMM HH:mm", culture)} increased remaining allowance from {restorations[^1].PreviousRemainingPercent:0}% to {restorations[^1].CurrentRemainingPercent:0}%. Amber markers show detected restorations." : " No allowance restorations were detected in this window."; - return $"Weekly quota trend from {period}. Remaining allowance changed from {first.RemainingPercent:0}% to {last.RemainingPercent:0}% across {sampleCount} observations. The left vertical scale is remaining allowance from 0% to 100%; the independent right scale is locally observed total tokens per calendar day. Horizontal labels are local calendar dates, and reset markers bound the quota window. Solid line connects sampled values across measurement gaps; line breaks mark allowance increases or resets; dashed line is forecast.{restorationText}{forecastText} {daily}"; + return $"Weekly quota trend from {period}. Remaining allowance changed from {first.RemainingPercent:0}% to {last.RemainingPercent:0}% across {sampleCount} observations. The left vertical scale is remaining allowance from 0% to 100%; the independent right scale is locally observed total tokens per calendar day. Horizontal labels are local calendar dates, and reset markers bound the quota window. Solid line connects continuous measurements; gaps and allowance increases break the line; dashed line is a conditional forecast.{restorationText}{forecastText} {daily}"; } private static string FormatDailyTokenAltText( diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 206a746..c66473d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -58,6 +58,10 @@ Always specify both `Platform` and its matching RID. Omitting `-r win-x64` or `- Tests must use `TestEnvironment` or explicitly inject both history stores and a temporary settings path. Only the production parameterless service constructor selects the current user's storage. Never instantiate it in a unit test. Each test owns and deletes its unique temporary directory; synthetic quota events must never reach real user history. Token tests await `TokenRefreshTask` separately because `RefreshAsync` completes when limits are published. Forecast arithmetic fixtures should use continuous measurements; gap and stale-data scenarios are tested separately. +## Forecast evaluation + +Run `WeeklyForecastTests` for observation-quality gates, idle periods, shifted resets, partial coverage, and chronological synthetic forecasts at six hours, 24 hours, and reset. The rolling benchmark uses only completed preceding weeks and data available at each origin, compares mean absolute error in percentage points with a current-pace baseline, and prints both errors. This repeated-pattern benchmark does not establish accuracy on real users or unexpected workload changes. Calibration on representative real histories is required before adding probabilistic intervals or making general accuracy claims. Existing short-window arithmetic fixtures stay short; weekly projection fixtures must satisfy the new observation duration and coverage requirements. + ## Integration smoke test After building the matching Debug package, run the non-destructive preflight: @@ -88,7 +92,7 @@ Complete every row on a clean x64 environment and a separate clean ARM64 environ | Automated preflight | Required | Required | `test-integration.ps1` passes manifest, package, CLSID, COM, and AppExtension checks. | | Start-menu visibility | Required | Required | Codex Usage does not appear as a standalone app in Start; it is activated only by Command Palette through its packaged COM registration. | | Discovery and reload | Required | Required | After **Reload Command Palette Extension**, **Codex Usage** appears once under **Settings > Extensions** and can be enabled. | -| Details page | Required | Required | Opening **Codex Usage** shows five-hour and weekly quota summaries that compare allowance used with elapsed window time and include a projection for each active window. The weekly view spans the quota window: continuous observed allowance is solid, a fresh projection is dashed, bars show locally observed `last_token_usage.total_tokens` per local calendar day on an independent right-hand scale, dated weekday labels include the partial first and last reset-boundary days, reset/current-time markers clarify the timeline, and amber markers identify detected allowance restorations. The token legend must identify partial local-log coverage, and unavailable token data must omit only the bars while leaving allowance data usable. The latest restoration is summarized below the chart and every restoration in the active window appears in the native Details pane; normal scheduled window rollovers must not appear as restorations. When adaptive forecasting has history, its dashed line may change at six-hour boundaries and the status line identifies the local-history depth; disabled or immature history falls back to current pace. Resets, credits, account status, and source appear in the native Details pane. Verify pace labels, inactive/projection fallback states, chart gap handling, restoration marker contrast and text wrapping, visible 0–100% allowance and token axes, partial-day placement, semantic status colors, narrow and wide window sizes, light/dark themes, and high contrast; manual refresh must update both areas without clipping or freezing Command Palette. | +| Details page | Required | Required | Opening **Codex Usage** shows five-hour and weekly quota summaries that compare allowance used with elapsed window time and include a projection for each active window. The weekly view spans the quota window: continuous observed allowance is solid, a fresh projection is dashed, bars show locally observed `last_token_usage.total_tokens` per local calendar day on an independent right-hand scale, dated weekday labels include the partial first and last reset-boundary days, reset/current-time markers clarify the timeline, and amber markers identify detected allowance restorations. The token legend must identify partial local-log coverage, and unavailable token data must omit only the bars while leaving allowance data usable. The latest restoration is summarized below the chart and every restoration in the active window appears in the native Details pane; normal scheduled window rollovers must not appear as restorations. Weekly projections require at least 30 continuous minutes and use at most six recent hours. Their basis identifies usable historical weeks and coverage; insufficient history falls back to a conditional recent-pace scenario. The dashed line can change hourly and at six-hour boundaries. Verify that measurement gaps break the solid line, isolated samples remain visible, the remaining daily/hourly budget is shown independently of a forecast, and weekly alerts follow the same adaptive setting as the chart. Resets, credits, account status, and source appear in the native Details pane. Verify pace labels, inactive/projection fallback states, chart gap handling, restoration marker contrast and text wrapping, visible 0–100% allowance and token axes, partial-day placement, semantic status colors, narrow and wide window sizes, light/dark themes, and high contrast; manual refresh must update both areas without clipping or freezing Command Palette. | | Dock band | Required | Required | The band can be added, each enabled item opens details, and values update while Command Palette remains responsive. | | Settings | Required | Required | Visibility, reset-time, refresh-interval, and adaptive-forecast choices apply immediately and persist after restarting Command Palette. Verify that disabling pauses learning without replaying measurements collected while paused, retains history, and that deleting learned history requires confirmation. | | Live app-server | Required | Required | With a signed-in standalone Codex CLI, the details page identifies the CLI app-server as the source and refreshes live data. | diff --git a/README.md b/README.md index 5740337..e7ad968 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Percentages show **remaining allowance**, not usage already consumed. Earned res Values refresh every minute by default. Details shows the data source and measurement time. During an outage, **Last confirmed** can retain the previous live measurement with its original timestamp. Local fallback data is labeled with its source and age. -Forecasts estimate future allowance from observed usage. They pause when data is stale or there are too few fresh, continuous measurements. Daily token bars reflect local session activity and are **not an exact measure of quota consumption**. +Forecasts estimate future allowance from observed usage. Weekly forecasts require at least 30 minutes of fresh, continuous measurements and show their measurement basis. Adaptive estimates use sufficiently observed recent weeks, with less influence from a short burst further ahead. A separate budget shows the average allowance available per day until reset. Daily token bars reflect local session activity and are **not an exact measure of quota consumption**. See the [user guide](USER_GUIDE.md#charts-and-forecasts) for chart legends, adaptive forecasts, and data handling. @@ -110,7 +110,7 @@ The [user guide](USER_GUIDE.md) explains availability, exports, retention, and r | Extension not visible | Confirm PowerToys meets the requirements and Command Palette is running. Run **Reload Command Palette Extension**, then check **Settings > Extensions > Codex Usage**. | | Only fallback data, or no usage | Check the standalone CLI requirement above and confirm Codex is signed in. Restart PowerToys after changing the CLI path. Start Codex once if local account and session metadata are missing. | | Old values or **Last confirmed** | Check the measurement time and latest refresh attempt in diagnostics, then retry a refresh. | -| Forecast unavailable | Allow fresh, continuous measurements to accumulate. Projections pause after gaps, stale data, or allowance increases until enough usage is observed. | +| Forecast unavailable | Allow at least 30 minutes of fresh, continuous weekly measurements to accumulate after a gap or allowance increase. Without usable history, a meaningful decrease is also needed. | | Dock items missing after changing modes | Open Dock customization and add the bands for the selected combined or separate mode. | | Settings not saved | Read the error on the settings page and retry saving. Until saving succeeds, changes apply only to the running session. | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index f9f2357..8b5bf34 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -6,13 +6,19 @@ For installation and first use, see the [README](README.md#quick-start). This gu ## Charts and forecasts -The weekly chart shows sampled remaining allowance on a 0–100% scale. Its solid line connects samples across measurement gaps but breaks at allowance increases. Amber markers identify detected allowance restorations. The latest restoration is summarized below the chart, with the current window's restoration history in Details. +The weekly chart shows sampled remaining allowance on a 0–100% scale. Its solid line connects continuous measurements and breaks at measurement gaps and allowance increases. Isolated measurements remain visible as points. Amber markers identify detected allowance restorations. The latest restoration is summarized below the chart, with the current window's restoration history in Details. Equal-width local calendar-day columns retain dated weekday labels and partial reset-boundary days. Locally observed total-token bars use an independent daily scale. -The optional adaptive weekly forecast keeps the current pace dominant and gradually blends in up to eight local quota cycles and six-hour usage patterns. Its dashed projection can follow six-hour forecast points and uses only the latest post-restoration segment. Pace indicators compare allowance used with elapsed window time. Projections estimate allowance remaining at reset, or the time the limit would be reached if consumption would exhaust it sooner. +Weekly projections require at least 30 minutes of fresh, continuous measurements. Recent pace uses at most the last six hours of that segment, so an old idle period cannot dilute the estimate indefinitely. With insufficient learned history, the projection explicitly describes what would happen if that recent pace continued. Its status identifies the measurement duration. The five-hour projection retains its shorter observation requirement. -Forecasts use the average consumption rate since the beginning of the latest continuous segment. A segment starts again after an allowance increase or a gap longer than three times the freshness allowance (the greater of five minutes and the refresh interval). Text and chart projections pause until enough fresh, continuous measurements show a meaningful decrease. The solid observed line still connects sampled values across gaps. +The optional adaptive weekly forecast uses up to eight completed local quota cycles. A usable cycle must cover at least half of the week and include at least six observed hours in each of the four six-hour positions within a day. Missing observations are unknown, not zero consumption. Profiles older than eight weeks before the current window are excluded. Better coverage and more recent history allow the projection to transition faster from recent pace to a weighted historical median. A short burst therefore has less influence far into the future. The status shows usable weeks and their observed coverage, rather than just the number of stored cycles. The active cycle is not counted again as independent history. + +Six-hour patterns require at least three usable weeks with at least three observed hours in the relevant bucket. These stored patterns are relative to the reset: they are used only when the local reset weekday, clock time, and UTC offset match the current window. A shifted reset or daylight-saving offset change falls back to the representative weekly average. No fixed workday or weekend schedule is assumed. The dashed projection changes at hourly steps and six-hour boundaries. + +A segment starts again after an allowance increase or a gap longer than three times the freshness allowance (the greater of five minutes and the refresh interval). Text, chart, and alert projections pause until enough fresh measurements are available. Without usable history, a meaningful decrease is also required; with usable history, observed idle time can still produce an estimate. Weekly warning notifications follow the same adaptive setting and calculation as the dashboard. + +Projections are conditional estimates, not calibrated probabilities. Estimated exhaustion at least a day away shows a date; nearer estimates are rounded up to a quarter hour. The chart retains the calculated points. The separate budget shows the average percentage points per day (or hour near reset) available to last until reset, even while a forecast is pending. Pace indicators compare allowance used with elapsed window time; neither they nor the budget guarantee future consumption. The model is tested with deterministic scenarios and chronological synthetic benchmarks; its accuracy on your actual future weeks has not been established. The extension also reads aggregate `token_count` records from active and archived local Codex session logs to show locally observed total tokens per calendar day. Limits update first; token bars update independently when local analysis finishes. These token bars are local activity observations, not an exact accounting of allowance consumption. The details page identifies the allowance source: the live route is explicitly the CLI app-server; session metadata may have been written by the desktop app, CLI, or another local Codex client and cannot be attributed more precisely. From e854b4fadff4534484e0cbc2e2094f3493cca16a Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:55:00 +0200 Subject: [PATCH 2/3] docs: link weekly forecast changelog to PR #26 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2621b08..b6e61c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Each entry links to the commit or pull request that introduced the change. ### Changed -- Make weekly estimates conditional on at least 30 minutes of measurements, show their observation basis and remaining daily budget, and use representative recent history with less influence from short bursts further ahead. Break chart lines at measurement gaps and use the same adaptive model for weekly alerts. Add chronological synthetic forecast benchmarks. +- Make weekly estimates conditional on at least 30 minutes of measurements, show their observation basis and remaining daily budget, and use representative recent history with less influence from short bursts further ahead. Break chart lines at measurement gaps and use the same adaptive model for weekly alerts. Add chronological synthetic forecast benchmarks. ([PR #26](https://github.com/TheBeems/CodexUsageDock/pull/26)) - Explain where to set the optional Windows CLI path environment variable, its lookup priority, and how to apply changes after restarting PowerToys. ([commit c8c7628](https://github.com/TheBeems/CodexUsageDock/commit/c8c762852e66ed3be5411b4614516110ab8a98a4)) - Simplify the README with a quick start, settings and troubleshooting tables, and a separate user guide for detailed usage and data handling. ([commit 84e04a4](https://github.com/TheBeems/CodexUsageDock/commit/84e04a4e0e38ecf15831175c65c6afb91d9b8bb8)) From 28ea2170243bb358e2a8c0d2d8c250ed3bd9e22f Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:12:07 +0200 Subject: [PATCH 3/3] Fix weekly forecast review findings with regression coverage --- CHANGELOG.md | 4 +++ CodexUsageDock.Tests/UsageDataTests.cs | 2 +- CodexUsageDock.Tests/WeeklyForecastTests.cs | 28 +++++++++++++++++++++ CodexUsageDock/UsageTrendAnalyzer.cs | 17 +++++++------ CodexUsageDock/WeeklyUsageTrendChart.cs | 4 ++- 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e61c6..471c34e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Each entry links to the commit or pull request that introduced the change. ## [Unreleased] +### Fixed + +- Apply the weekly meaningful-change threshold to the recent six-hour window and preserve near-term forecast times in chart alternative text. ([PR #26](https://github.com/TheBeems/CodexUsageDock/pull/26)) + ### Changed - Make weekly estimates conditional on at least 30 minutes of measurements, show their observation basis and remaining daily budget, and use representative recent history with less influence from short bursts further ahead. Break chart lines at measurement gaps and use the same adaptive model for weekly alerts. Add chronological synthetic forecast benchmarks. ([PR #26](https://github.com/TheBeems/CodexUsageDock/pull/26)) diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index e0ac22e..9110b54 100644 --- a/CodexUsageDock.Tests/UsageDataTests.cs +++ b/CodexUsageDock.Tests/UsageDataTests.cs @@ -260,7 +260,7 @@ [new RateLimitResetCredit("Full reset", "available", now.AddDays(13))]), now, isLoading: false, ContinuousHistory(now.AddMinutes(-30), 90, now, 80), - ContinuousHistory(now.AddHours(-12), 99, now, 98), + ContinuousHistory(now.AddHours(-12), 100, now, 98), TimeSpan.FromMinutes(1)); var details = CodexUsageDockPage.FormatDetailsBody(snapshot, now); using var mainData = JsonDocument.Parse(main); diff --git a/CodexUsageDock.Tests/WeeklyForecastTests.cs b/CodexUsageDock.Tests/WeeklyForecastTests.cs index f899e18..5a0794f 100644 --- a/CodexUsageDock.Tests/WeeklyForecastTests.cs +++ b/CodexUsageDock.Tests/WeeklyForecastTests.cs @@ -1,6 +1,7 @@ using Xunit; using Xunit.Abstractions; using System.Text.Json; +using System.Globalization; namespace CodexUsageDock.Tests; @@ -27,6 +28,33 @@ public void RecentPaceDoesNotDependOnOldContinuousIdleMeasurements() Assert.Equal(Analyze(recent).Forecast!.EndsAt, Analyze(withOldIdle).Forecast!.EndsAt); } + [Theory] + [InlineData(0.01)] + [InlineData(0.5)] + public void OlderConsumptionCannotMakeAnInsignificantRecentDecreaseForecastable(double recentDecrease) + { + var recent = Series(Now.AddHours(-6), Now, 90, 90 - recentDecrease); + var samples = Series(Now.AddDays(-1), Now.AddHours(-6), 100, 90).Concat(recent).ToArray(); + + Assert.Null(Analyze(samples).Forecast); + Assert.NotNull(Analyze(samples, History(_ => 0.002)).Forecast); + } + + [Theory] + [InlineData(1, "06:45")] + [InlineData(25, "Thu 17 Sep")] + public void ChartAlternativeTextUsesTheSameNearAndDistantEstimatePrecision(int hoursAhead, string expected) + { + var zone = TimeZoneInfo.CreateCustomTimeZone("Forecast test zone", TimeSpan.FromMinutes(330), "Test", "Test"); + var samples = Series(Now.AddHours(-1), Now, 90, 80); + var forecast = new UsageTrendForecast(Now.AddHours(hoursAhead).AddMinutes(7), 0, true); + var chart = WeeklyUsageTrendChartRenderer.Create(samples, new RateLimitWindow(20, 10080, Reset), Now, + TimeSpan.FromMinutes(15), forecast, culture: CultureInfo.InvariantCulture, timeZone: zone); + + Assert.NotNull(chart); + Assert.Contains($"Estimated limit: {expected}; actual usage may differ.", chart.AltText, StringComparison.Ordinal); + } + [Fact] public void EightMinutesOfHistoryDoNotCountAsEightUsableWeeks() { diff --git a/CodexUsageDock/UsageTrendAnalyzer.cs b/CodexUsageDock/UsageTrendAnalyzer.cs index 9f92e47..f4a983b 100644 --- a/CodexUsageDock/UsageTrendAnalyzer.cs +++ b/CodexUsageDock/UsageTrendAnalyzer.cs @@ -150,7 +150,7 @@ private static TrendAnalysis AnalyzeWeekly( var consumed = first.RemainingPercent - last.RemainingPercent; var projection = AdaptiveWeeklyForecast.Project(last, start, reset, consumed / duration.TotalMinutes, adaptiveEnabled, history, duration); - if ((consumed <= 0 || segment[0].RemainingPercent - last.RemainingPercent <= 0.5) && !projection.UsesHistory) + if (consumed <= 0.5 && !projection.UsesHistory) { return new(segment, values, "No meaningful recent change; weekly projection pending.", false, null, projection.Status); } @@ -163,20 +163,23 @@ private static TrendAnalysis AnalyzeWeekly( return new(segment, values, message, true, forecast, projection.Status); } - internal static string FormatWeeklyLimitEstimate(DateTimeOffset estimated, DateTimeOffset now) + internal static string FormatWeeklyLimitEstimate(DateTimeOffset estimated, DateTimeOffset now, + CultureInfo? culture = null, TimeZoneInfo? timeZone = null) { - var local = estimated.ToLocalTime(); + var displayCulture = culture ?? CultureInfo.CurrentCulture; + var displayTimeZone = timeZone ?? TimeZoneInfo.Local; + var local = TimeZoneInfo.ConvertTime(estimated, displayTimeZone); if (estimated - now >= TimeSpan.FromDays(1)) { - return local.ToString("ddd d MMM", CultureInfo.CurrentCulture); + return local.ToString("ddd d MMM", displayCulture); } // Round only the presentation; alert thresholds and chart points retain the calculated instant. var quarter = TimeSpan.FromMinutes(15).Ticks; var rounded = local.AddTicks((quarter - local.Ticks % quarter) % quarter); - return rounded.Date == now.ToLocalTime().Date - ? rounded.ToString("HH:mm", CultureInfo.CurrentCulture) - : rounded.ToString("ddd d MMM HH:mm", CultureInfo.CurrentCulture); + return rounded.Date == TimeZoneInfo.ConvertTime(now, displayTimeZone).Date + ? rounded.ToString("HH:mm", displayCulture) + : rounded.ToString("ddd d MMM HH:mm", displayCulture); } private static string FormatLimitEstimate(DateTimeOffset estimated, DateTimeOffset now) => diff --git a/CodexUsageDock/WeeklyUsageTrendChart.cs b/CodexUsageDock/WeeklyUsageTrendChart.cs index 4aac156..003bb48 100644 --- a/CodexUsageDock/WeeklyUsageTrendChart.cs +++ b/CodexUsageDock/WeeklyUsageTrendChart.cs @@ -132,6 +132,7 @@ internal static class WeeklyUsageTrendChartRenderer restorations, windowStart, window.ResetsAt, + effectiveNow, dailyUse, tokenUsage, tokenScaleMaximum, @@ -760,6 +761,7 @@ private static string FormatAltText( AllowanceRestoration[] restorations, DateTimeOffset windowStart, DateTimeOffset windowEnd, + DateTimeOffset now, IReadOnlyList dailyUse, LocalTokenUsageSnapshot? tokenUsage, long tokenScaleMaximum, @@ -770,7 +772,7 @@ private static string FormatAltText( var daily = FormatDailyTokenAltText(dailyUse, tokenUsage, tokenScaleMaximum, culture); var forecastText = forecast switch { - { ReachesLimitBeforeReset: true } => $" Estimated limit day: {TimeZoneInfo.ConvertTime(forecast.EndsAt, timeZone).ToString("ddd d MMM", culture)}; actual usage may differ.", + { ReachesLimitBeforeReset: true } => $" Estimated limit: {UsageTrendAnalyzer.FormatWeeklyLimitEstimate(forecast.EndsAt, now, culture, timeZone)}; actual usage may differ.", { } => $" Forecast leaves about {forecast.RemainingPercent:0}% at reset; actual usage may differ.", null => " Forecast is unavailable.", };