From 7f19f35a0837903c634abfa2cd552d0b49ad81ca Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:13:21 +0200 Subject: [PATCH 01/17] fix: anchor analytics presets to now (#47) --- Sources/CodexLimits/AnalyticsWorkspace.swift | 8 +- .../CodexLimits/DeterministicInsights.swift | 2 +- Sources/CodexLimits/MenuContentView.swift | 24 +- .../AnalyticsWorkspaceTests.swift | 100 +++++-- docs/MEASUREMENT-CONTRACT.md | 246 ++++++++++++++++++ docs/PRODUCT-LANGUAGE.md | 58 +++++ 6 files changed, 396 insertions(+), 42 deletions(-) create mode 100644 docs/MEASUREMENT-CONTRACT.md create mode 100644 docs/PRODUCT-LANGUAGE.md diff --git a/Sources/CodexLimits/AnalyticsWorkspace.swift b/Sources/CodexLimits/AnalyticsWorkspace.swift index c4e6400..04ea57e 100644 --- a/Sources/CodexLimits/AnalyticsWorkspace.swift +++ b/Sources/CodexLimits/AnalyticsWorkspace.swift @@ -50,7 +50,7 @@ enum AnalyticsTimeRange: String, CaseIterable, Codable, Identifiable, Sendable { func interval( within bounds: DateInterval, - endingAt proposedEnd: Date + now: Date ) -> DateInterval { let duration: TimeInterval switch self { @@ -65,7 +65,7 @@ enum AnalyticsTimeRange: String, CaseIterable, Codable, Identifiable, Sendable { case .twelveWeeks: duration = 84 * 86_400 } - let end = min(max(proposedEnd, bounds.start), bounds.end) + let end = min(max(now, bounds.start), bounds.end) return DateInterval( start: max(bounds.start, end.addingTimeInterval(-duration)), end: end @@ -312,7 +312,7 @@ final class AnalyticsWorkspaceStore: ObservableObject { func effectiveRange( within bounds: DateInterval, - endingAt latestObserved: Date + now: Date ) -> DateInterval { if state.timeRange == .selected, let visibleRange = state.visibleRange, @@ -321,7 +321,7 @@ final class AnalyticsWorkspaceStore: ObservableObject { } return state.timeRange.interval( within: bounds, - endingAt: latestObserved + now: now ) } diff --git a/Sources/CodexLimits/DeterministicInsights.swift b/Sources/CodexLimits/DeterministicInsights.swift index 92ad282..ed1784f 100644 --- a/Sources/CodexLimits/DeterministicInsights.swift +++ b/Sources/CodexLimits/DeterministicInsights.swift @@ -195,7 +195,7 @@ struct DeterministicInsightInput: Equatable, Sendable { } return exploration.timeRange.interval( within: bounds, - endingAt: fetchedAt ?? evidenceEnd ?? end + now: fetchedAt ?? evidenceEnd ?? end ) } } diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index c364c28..30e609e 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -634,7 +634,7 @@ struct UsagePerTokenWorkspace: View { } return store.effectiveRange( within: bounds, - endingAt: snapshot.current?.interval.end ?? bounds.end + now: Date() ) } @@ -1165,10 +1165,7 @@ private struct ConcurrencyWorkspace: View { private var visibleRange: DateInterval { store.effectiveRange( within: bounds, - endingAt: min( - reader.localTokenActivity.observedAt ?? bounds.end, - bounds.end - ) + now: Date() ) } @@ -1555,10 +1552,7 @@ private struct TokenActivityWorkspace: View { } return store.effectiveRange( within: bounds, - endingAt: min( - reader.fetchedAt ?? bounds.end, - bounds.end - ) + now: Date() ) } @@ -1961,10 +1955,7 @@ private struct WorkspaceFilterMenu: View { let bounds = reader.usageReceipts.interval return store.effectiveRange( within: bounds, - endingAt: min( - reader.localTokenActivity.observedAt ?? bounds.end, - bounds.end - ) + now: Date() ) } @@ -2066,7 +2057,7 @@ private struct UsageRemainingChart: View { } return store.effectiveRange( within: bounds, - endingAt: chart.preferredZoomAnchor ?? min(Date(), bounds.end) + now: Date() ) } @@ -2933,10 +2924,7 @@ private struct FactsWorkspace: View { let bounds = reader.usageReceipts.interval return store.effectiveRange( within: bounds, - endingAt: min( - reader.localTokenActivity.observedAt ?? bounds.end, - bounds.end - ) + now: Date() ) } diff --git a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift index 06819c1..770a349 100644 --- a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift +++ b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift @@ -277,33 +277,80 @@ final class AnalyticsWorkspaceTests: XCTestCase { XCTAssertNil(reader.localTokenActivity.tokens) } - func testPresetRangeEndsAtLatestObservedTimeAndIsClampedToWindow() { - let end = Date(timeIntervalSince1970: 10 * 86_400) + func testRollingPresetsEndAtInjectedNowDespiteStaleObservation() throws { + let now = try date("2026-08-03T09:08:00Z") + let latestObserved = now.addingTimeInterval(-6 * 3_600) let bounds = DateInterval( - start: end.addingTimeInterval(-7 * 86_400), - end: end + start: now.addingTimeInterval(-90 * 86_400), + end: now.addingTimeInterval(3_600) ) - let latestObserved = end.addingTimeInterval(-5 * 86_400) - XCTAssertEqual( - AnalyticsTimeRange.oneDay.interval( - within: bounds, - endingAt: latestObserved - ), - DateInterval( - start: latestObserved.addingTimeInterval(-86_400), - end: latestObserved - ) + for (range, duration) in [ + (AnalyticsTimeRange.oneDay, 86_400.0), + (.threeDays, 259_200.0), + (.fourWeeks, 2_419_200.0), + (.twelveWeeks, 7_257_600.0) + ] { + let resolved = range.interval(within: bounds, now: now) + XCTAssertEqual(resolved.start, now.addingTimeInterval(-duration)) + XCTAssertEqual(resolved.end, now) + XCTAssertEqual(resolved.duration, duration) + } + XCTAssertNotEqual( + AnalyticsTimeRange.oneDay.interval(within: bounds, now: now).end, + latestObserved ) XCTAssertEqual( AnalyticsTimeRange.currentWindow.interval( within: bounds, - endingAt: latestObserved + now: now ), bounds ) } + func testRollingPresetDatesKeepElapsedDurationAcrossTimezonesAndDST() throws { + let berlin = try XCTUnwrap(TimeZone(identifier: "Europe/Berlin")) + let losAngeles = try XCTUnwrap( + TimeZone(identifier: "America/Los_Angeles") + ) + + for now in [ + try date("2026-03-29T10:08:00Z"), + try date("2026-10-25T11:08:00Z") + ] { + let interval = AnalyticsTimeRange.oneDay.interval( + within: DateInterval( + start: now.addingTimeInterval(-2 * 86_400), + end: now.addingTimeInterval(3_600) + ), + now: now + ) + XCTAssertEqual(interval.start, now.addingTimeInterval(-86_400)) + XCTAssertEqual(interval.end, now) + XCTAssertEqual(interval.duration, 86_400) + XCTAssertNotEqual( + boundaryLabel(interval.start, timeZone: berlin), + boundaryLabel(interval.end, timeZone: berlin) + ) + } + + let now = try date("2026-08-03T09:08:00Z") + let interval = AnalyticsTimeRange.oneDay.interval( + within: DateInterval( + start: now.addingTimeInterval(-2 * 86_400), + end: now.addingTimeInterval(3_600) + ), + now: now + ) + XCTAssertEqual(interval.end, now) + XCTAssertEqual(interval.duration, 86_400) + XCTAssertNotEqual( + boundaryLabel(interval.end, timeZone: berlin), + boundaryLabel(interval.end, timeZone: losAngeles) + ) + } + func testSelectedRangeIsClampedAndRejectsTinySelections() { let bounds = DateInterval( start: Date(timeIntervalSince1970: 1_000), @@ -369,11 +416,11 @@ final class AnalyticsWorkspaceTests: XCTestCase { start: Date(timeIntervalSince1970: 0), end: Date(timeIntervalSince1970: 200_000) ) - let latestObserved = Date(timeIntervalSince1970: 150_000) + let now = Date(timeIntervalSince1970: 150_000) store.selectTimeRange(.oneDay) let visible = store.effectiveRange( within: bounds, - endingAt: latestObserved + now: now ) store.zoom( @@ -604,7 +651,7 @@ final class AnalyticsWorkspaceTests: XCTestCase { store.selectTimeRange(.fourWeeks) XCTAssertEqual( - store.effectiveRange(within: bounds, endingAt: observedAt), + store.effectiveRange(within: bounds, now: observedAt), DateInterval( start: observedAt.addingTimeInterval(-28 * 86_400), end: observedAt @@ -613,7 +660,7 @@ final class AnalyticsWorkspaceTests: XCTestCase { store.selectTimeRange(.twelveWeeks) XCTAssertEqual( - store.effectiveRange(within: bounds, endingAt: observedAt), + store.effectiveRange(within: bounds, now: observedAt), DateInterval(start: oldest, end: observedAt) ) } @@ -978,6 +1025,21 @@ final class AnalyticsWorkspaceTests: XCTestCase { } } + private func date(_ value: String) throws -> Date { + try XCTUnwrap(ISO8601DateFormatter().date(from: value)) + } + + private func boundaryLabel( + _ date: Date, + timeZone: TimeZone + ) -> String { + date.formatted( + Date.FormatStyle(date: .abbreviated, time: .shortened) + .locale(Locale(identifier: "en_US")) + .timeZone(timeZone) + ) + } + private func renders( _ view: Content, size: CGSize diff --git a/docs/MEASUREMENT-CONTRACT.md b/docs/MEASUREMENT-CONTRACT.md new file mode 100644 index 0000000..5e35a12 --- /dev/null +++ b/docs/MEASUREMENT-CONTRACT.md @@ -0,0 +1,246 @@ +# Measurement contract + +This contract defines how Codex Limits labels facts, Coverage, Confidence, and comparable work. It applies to the reader snapshot, charts, Facts, Insights, tooltips, notifications, and tests. + +The product prefers no estimate to a weak estimate. + +## Source classes + +Every value has one source class: + +1. **Account fact** — returned by the Codex account API. +2. **Local fact** — observed in Codex records on this Mac. +3. **Derived estimate** — calculated from named account and local facts. + +The UI never merges these classes into one unexplained value. + +## Primary allowance + +The weekly Codex allowance is the primary allowance. + +- Select the Codex window whose `windowDurationMins` is `10080`. +- Show its Usage remaining in the menu bar, current-state header, Runway, Suggested Pace, and default Usage remaining chart. +- Do not replace it with a five-hour window because that window has a lower percentage. +- Show five-hour and model-specific windows as Other limits in Facts. +- If no weekly window is returned, show `Weekly usage unavailable`. Do not substitute another window without naming it. + +Every allowance-derived metric carries the selected limit ID, duration, start, and reset time. + +## Account Token Activity + +Account Token Activity is the primary weekly token total. Use the strongest available method in this order: + +1. **Observed lifetime delta** — subtract two monotonic `summary.lifetimeTokens` readings that bound the same account and interval. +2. **Exact daily sum** — sum complete account daily buckets only when their calendar boundaries match the selected interval. +3. **Partial daily sum** — show complete daily buckets inside the interval as a factual partial value. Do not scale partial days or call the result a weekly total. +4. **Unavailable** — withhold the total when no method above applies. + +An observed lifetime delta is valid only when: + +- both readings belong to the same local account partition; +- the counter did not decrease; +- both interval boundaries meet the boundary rules below; +- no account change occurred between the readings. + +Daily buckets may seed a historical chart, but they never become observed allowance readings. + +## Account facts + +Facts may show these values when the account API returns them: + +- Lifetime tokens +- Peak daily tokens +- Longest running turn +- Current streak +- Longest streak +- Credits balance or unlimited credits +- Spend-control limit, Usage remaining, and reset time + +These are Account facts. They do not need Confidence. They do need source, fetched time, and an unavailable state. + +## Local Activity source boundary + +Issue `Prove read-only Local Activity ingestion and Coverage` owns the source decision before Local Token Activity ships. + +Until that spike is complete: + +- do not assume that a separate app-server connection receives live events from Tasks owned by another Codex process; +- do not resume, load, start, stop, or take ownership of a user Task to observe it; +- treat supported read-only app-server projections as the preferred metadata source; +- treat incrementally tailed local Codex records as a candidate source for token, turn, tool, and timing facts; +- record source capability and CLI version with every normalized event. + +If no safe read-only source exists for a fact, the fact is unavailable. + +## Time boundaries + +### Rolling ranges + +`24 hours`, `3 days`, `4 weeks`, and `12 weeks` end at the current instant and use exact elapsed durations of 86,400, 259,200, 2,419,200, and 7,257,600 seconds. A delayed observation does not move a rolling range into the past. + +### Machine-local time + +Reader-facing dates and clock labels use the Mac's current time zone when rendered. A time-zone or daylight-saving change changes local labels, not the underlying elapsed interval. + +An interval is: + +- **Tightly bounded** when the closest account readings are no more than 15 minutes from both boundaries. +- **Loosely bounded** when both readings are no more than 60 minutes from the boundaries. +- **Unbounded** when either reading is farther away or missing. + +For allowance movement: + +- a gap of no more than 30 minutes between account readings supports High Coverage; +- a gap over 30 minutes and no more than 6 hours lowers Coverage to Partial; +- a gap over 6 hours makes comparable allowance movement unavailable; +- any gap that may contain an unknown reset or correction makes the interval unbounded. + +A known scheduled reset, banked reset, account change, or detected correction always splits the interval. + +## Coverage + +Coverage says how much of the required source data was observed. It does not mean accuracy. + +Reader-facing Coverage states are: + +| State | Meaning | +|---|---| +| `Complete` | Every required source and boundary is present, with no known gap or ambiguity. | +| `High` | At least 80% of aligned activity is represented and every required boundary is tight. | +| `Partial` | Useful evidence exists, but coverage is between 50% and 79%, a boundary is loose, or a named source is missing. | +| `Low` | Less than 50% is represented or a material gap prevents a dependable conclusion. | +| `Unavailable` | The required source, identity, token definition, or time boundary cannot be reconciled. | +| `Not applicable` | The metric has no meaningful coverage denominator, such as an interval with no activity. | + +Every state other than Complete names at least one reason, such as: + +- `Account boundary is 42 minutes late` +- `Local Tasks are missing` +- `Activity from another device is possible` +- `Token definitions do not align` +- `Unknown reset or correction` +- `Codex version does not expose this field` + +### Numeric Local Coverage + +Numeric Local Coverage is shown only when the source spike proves that Account Token Activity and Local Token Activity use compatible token definitions for the active Codex version and both values cover the same interval. + +For aligned values: + +`Local Coverage = Local Token Activity / Account Token Activity` + +Rules: + +- When both totals are zero, Coverage is Not applicable. +- When account activity is zero but local activity is positive, numeric Coverage is unavailable. +- When local activity is more than 2% above account activity, numeric Coverage is unavailable and the UI says `Account and local totals do not align`. +- A difference of at most 2% may be treated as rounding and clamped to 100%. +- Numeric Coverage describes the share of Account Token Activity visible in local records. It does not prove that local records explain account billing. + +### Reset Detail Coverage + +Reset Detail Coverage uses the authoritative reset count and returned available detail: + +- `Complete` when detail count equals the authoritative count. +- `Partial` when detail count is greater than zero and lower than the count. +- `Unavailable` when the count is greater than zero and no detail is returned. +- `Not applicable` when the authoritative count is zero. + +## Confidence + +Confidence says how strongly the observed evidence supports a derived estimate or Insight. + +| State | Product behavior | +|---|---| +| `High` | Show the estimate or Insight. Coverage is Complete or High, the interval is tightly bounded, and no material comparability warning applies. | +| `Medium` | Show the estimate with its range and named caveat. The interval is still bounded and the conclusion remains useful. | +| `Low` | Withhold the estimate or Insight. Show the observed facts and the reason more evidence is needed. | +| `Unavailable` | Do not calculate the result. | + +Direct Account facts and Local facts show provenance and freshness instead of artificial Confidence. + +The engine, not the view, owns Confidence and its reasons. Thresholds are versioned policy values and have deterministic tests. + +## Comparable work + +Two intervals are comparable only when all these gates pass: + +- both intervals belong to the same account partition; +- both use the weekly Codex allowance; +- both are bounded; +- neither contains a reset, account change, unknown correction, or counter decrease; +- both have non-zero Account Token Activity; +- Local Coverage is at least 50% when workload mix is part of the comparison; +- the dominant model family and reasoning level are known; +- model, reasoning, and cached-input shares differ by no more than 20 percentage points; +- the product can name every reason that lowers comparability. + +Comparability is: + +- **High** when Local Coverage is at least 80%, both intervals are tightly bounded, and each observed workload-mix share differs by no more than 10 percentage points. +- **Medium** when Local Coverage is at least 50%, the intervals are at least loosely bounded, and each share differs by no more than 20 percentage points. +- **Not comparable** otherwise. + +Low-comparability conclusions are withheld. + +## Reference Baseline + +The default Reference Baseline is the median Allowance Intensity of the previous four complete, High-comparability weekly windows. + +- Use exactly four eligible windows. +- If fewer than four exist, show `Not enough comparable weeks`. +- A user-pinned period must pass at least Medium comparability. +- Pinning a period does not override reset, identity, boundary, or token-definition failures. +- Store the baseline interval IDs and policy version with the derived result. + +Allowance Intensity divides observed weekly Account Movement by aligned Account Token Activity. Equivalent Capacity extrapolates from that intensity and always remains an estimate. + +## Account partitions + +Analytics History never mixes signed-in accounts. + +- Read account state before joining new observations to history. +- When an email is available, derive an on-device keyed fingerprint and never persist the email as the partition key. +- When stable identity is unavailable, start an isolated unknown-account partition after every observed auth transition. +- A plan change does not create a new partition, but it splits comparable intervals. + +## Delete analytics history + +`Delete analytics history` means all Analytics History owned by Codex Limits: + +- all local Derived Records; +- Codex-assisted Insight results; +- Analytics Overhead records; +- account usage samples in the selected sync folder; +- records written by every installation in that sync folder. + +Preferences, notification settings, and Codex source records remain. + +Deletion creates a new empty sync generation so another Mac cannot republish older history. Each installation that observes the generation discards older local analytics before it publishes again. + +If the selected sync folder is unavailable, the product must not claim that deletion completed. It prevents older synced records from being imported, keeps a pending deletion state, and offers retry. + +The app does not rebuild deleted history automatically. A separate explicit `Rebuild available history` action may read only source data that still exists. New observations after deletion belong to the new generation. + +## Codex-assisted availability + +`Analyze with Codex` is visible only when `model/list` advertises: + +- GPT-5.6 Luna; +- Medium reasoning for that exact model; +- an account state that can run the request. + +If any condition is missing or model availability cannot be checked, hide the action. Do not fall back to GPT-5.5, Terra, Sol, another reasoning level, or the analyzed Task model. + +Metadata-only Analysis uses a closed payload allowlist. Source-backed Analysis sends only the categories and scope accepted in its current preflight. The analysis Task cannot use tools, read additional files, or change the workspace. + +## Reader rules + +- Show the source beside a value when sources may disagree. +- Show the observed interval for every derived value. +- Show raw facts before estimates. +- Use `Not enough data` or a specific reason instead of a Low-confidence number. +- Never call Coverage accuracy. +- Never call Confidence certainty. +- Never call Account Token Activity a token allowance. +- Never call Local Coverage billing coverage. diff --git a/docs/PRODUCT-LANGUAGE.md b/docs/PRODUCT-LANGUAGE.md new file mode 100644 index 0000000..6e74f9a --- /dev/null +++ b/docs/PRODUCT-LANGUAGE.md @@ -0,0 +1,58 @@ +# Product language + +Codex Limits uses clear, direct English. These rules apply to every label, tooltip, chart, notification, and insight. + +## Orwell’s six rules + +1. Use literal words. Avoid familiar metaphors and figures of speech. +2. Use a short word when it says the same thing as a long word. +3. Cut every word that adds no meaning. +4. Use active voice. +5. Prefer everyday English to jargon or foreign phrases. +6. Break a rule when following it would make the text harsh, false, or unclear. + +## Product rules + +- Name the quantity: `remaining`, `used`, `tokens`, or `percentage points`. +- Use Codex’s label `Usage remaining` for the primary allowance percentage. +- Separate account facts, local facts, and estimates. +- State uncertainty instead of hiding it. +- Name the source when two sources can disagree. +- Describe what changed; do not invent a cause. +- Use one canonical domain term for one concept. +- Put the action first in buttons. +- Keep tooltips to one fact or consequence. +- Do not call local activity billing, cost, waste, or efficiency. +- Do not claim that OpenAI changed a limit when the product only observed a change in intensity. +- Describe a usage deviation in `Insights`; do not call it an anomaly or send an alert. +- Use the weekly Codex window for the primary `Usage remaining`; name every other window. +- Withhold a Low-confidence estimate and say what data is missing. +- Do not call a partial sum of daily token buckets a weekly total. + +## Time labels + +Rolling ranges end now. Show their dates and clock labels in the Mac's current time zone; daylight-saving and time-zone changes do not change the elapsed range. + +## Navigation labels + +- `Graphs` — Usage remaining, Token activity, Usage per token, and Concurrency charts. +- `Facts` — account facts, banked resets, other limits, and Usage receipts. +- `Insights` — structured observations and recommendations. + +The current-state header remains visible while these views change. + +## Examples + +| Avoid | Use | +|---|---| +| `37% left` or `37% allowance remaining` | `Usage remaining · 37%` | +| `You're burning through your quota` | `Usage increased faster than your baseline` | +| `Token efficiency` | `Allowance used per 1M local tokens` | +| `Workload cost` as a visible chart label | `Usage per token` | +| `Oldest reset expires` | `Next known expiry` | +| `3 resets available` when only one expiry is known | `3 banked resets · 1 expiry known` | +| `AI-powered analysis` | `Analyze with Codex` | +| `We detected hidden usage` | `Account and local totals differ` | +| `Your limit got worse` | `Comparable work used 1.3× more allowance` | +| `Usage anomaly detected` | `Usage increased faster than your baseline` | +| `Low confidence · 3.2 days` | `Not enough data · Account gap over 6 hours` | From 69b2f6119671b1b7de11dace1cdefc3f1b324d21 Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:17:43 +0200 Subject: [PATCH 02/17] test: preserve selected analytics ranges (#48) --- .../AnalyticsWorkspaceTests.swift | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift index 770a349..37d5870 100644 --- a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift +++ b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift @@ -381,6 +381,189 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) } + func testSelectedRangeStaysFixedAfterRefresh() throws { + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! + ) + let chosen = DateInterval( + start: try date("2026-08-02T08:15:00Z"), + end: try date("2026-08-02T14:45:00Z") + ) + store.selectVisibleRange( + chosen, + within: DateInterval( + start: try date("2026-08-01T00:00:00Z"), + end: try date("2026-08-03T00:00:00Z") + ) + ) + + XCTAssertEqual( + store.effectiveRange( + within: DateInterval( + start: try date("2026-08-01T00:00:00Z"), + end: try date("2026-08-04T00:00:00Z") + ), + now: try date("2026-08-03T12:00:00Z") + ), + chosen + ) + } + + func testSelectedRangeIgnoresAdvancingNowAndPresetsClearIt() { + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! + ) + let bounds = DateInterval( + start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 20_000) + ) + let chosen = DateInterval( + start: Date(timeIntervalSince1970: 2_000), + end: Date(timeIntervalSince1970: 8_000) + ) + store.selectVisibleRange(chosen, within: bounds) + + XCTAssertEqual( + store.effectiveRange( + within: bounds, + now: Date(timeIntervalSince1970: 15_000) + ), + chosen + ) + + store.selectTimeRange(.oneDay) + XCTAssertNil(store.state.visibleRange) + store.selectVisibleRange(chosen, within: bounds) + store.resetVisibleRange() + XCTAssertEqual(store.state.timeRange, .currentWindow) + XCTAssertNil(store.state.visibleRange) + } + + func testSelectedRangeRestoresExactBoundaries() { + let suite = "AnalyticsWorkspaceTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let chosen = DateInterval( + start: Date(timeIntervalSince1970: 2_000), + end: Date(timeIntervalSince1970: 8_000) + ) + AnalyticsWorkspaceStore(defaults: defaults).selectVisibleRange( + chosen, + within: DateInterval( + start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 10_000) + ) + ) + + let restored = AnalyticsWorkspaceStore(defaults: defaults) + XCTAssertEqual(restored.state.timeRange, .selected) + XCTAssertEqual(restored.state.visibleRange, chosen) + } + + func testSelectedRangeClampsOneSideWithoutMovingTheOther() { + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! + ) + let chosen = DateInterval( + start: Date(timeIntervalSince1970: 2_000), + end: Date(timeIntervalSince1970: 8_000) + ) + store.selectVisibleRange( + chosen, + within: DateInterval( + start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 10_000) + ) + ) + + XCTAssertEqual( + store.effectiveRange( + within: DateInterval( + start: Date(timeIntervalSince1970: 4_000), + end: Date(timeIntervalSince1970: 10_000) + ), + now: .now + ), + DateInterval( + start: Date(timeIntervalSince1970: 4_000), + end: Date(timeIntervalSince1970: 8_000) + ) + ) + } + + func testSelectedRangeUsesBoundsWhenItNoLongerIntersects() { + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! + ) + let chosen = DateInterval( + start: Date(timeIntervalSince1970: 2_000), + end: Date(timeIntervalSince1970: 8_000) + ) + store.selectVisibleRange( + chosen, + within: DateInterval( + start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 10_000) + ) + ) + let refreshedBounds = DateInterval( + start: Date(timeIntervalSince1970: 10_000), + end: Date(timeIntervalSince1970: 20_000) + ) + + XCTAssertEqual( + store.effectiveRange(within: refreshedBounds, now: .now), + refreshedBounds + ) + XCTAssertEqual(store.state.visibleRange, chosen) + } + + func testZoomInAndOutContinueFromSelectedRange() { + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! + ) + let bounds = DateInterval( + start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 10_000) + ) + let chosen = DateInterval( + start: Date(timeIntervalSince1970: 2_000), + end: Date(timeIntervalSince1970: 8_000) + ) + let anchor = Date(timeIntervalSince1970: 5_000) + store.selectVisibleRange(chosen, within: bounds) + store.zoom( + factor: 2, + anchor: anchor, + currentRange: store.effectiveRange(within: bounds, now: .now), + within: bounds + ) + + let zoomed = DateInterval( + start: Date(timeIntervalSince1970: 3_500), + end: Date(timeIntervalSince1970: 6_500) + ) + XCTAssertEqual(store.state.visibleRange, zoomed) + + store.zoom( + factor: 0.5, + anchor: anchor, + currentRange: store.effectiveRange(within: bounds, now: .now), + within: bounds + ) + XCTAssertEqual(store.state.visibleRange, chosen) + } + func testZoomKeepsRangeInsideWindowAndAroundAnchor() { let bounds = DateInterval( start: Date(timeIntervalSince1970: 0), From 5d7603aa05d561e15923698ef244ff033ade6fdd Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:24:14 +0200 Subject: [PATCH 03/17] fix: render active allowance frame (#49) --- Sources/CodexLimits/MenuContentView.swift | 32 ++++-- .../CodexLimits/UsageIntelligenceEngine.swift | 31 ++++-- .../AnalyticsWorkspaceTests.swift | 65 +++++++++++ .../UsageIntelligenceEngineTests.swift | 104 ++++++++++++++++++ docs/MEASUREMENT-CONTRACT.md | 2 + docs/PRODUCT-LANGUAGE.md | 1 + 6 files changed, 217 insertions(+), 18 deletions(-) diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index 30e609e..d0b87f4 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -296,7 +296,7 @@ private struct WorkspaceHeader: View { Text("% remaining") .foregroundStyle(.secondary) } else { - Text("Weekly usage unavailable") + Text(reader.evidence.reason ?? "Weekly usage unavailable") .font(.headline) } @@ -457,7 +457,11 @@ private struct GraphsWorkspace: View { case .usageRemaining: usageRemaining case .tokenActivity: - TokenActivityWorkspace(reader: reader, store: store) + if canExploreHistoricalUsage { + TokenActivityWorkspace(reader: reader, store: store) + } else { + unavailableCurrentWindow + } case .usagePerToken: UsagePerTokenWorkspace( sourceSnapshot: reader.usagePerToken, @@ -536,7 +540,8 @@ private struct GraphsWorkspace: View { @ViewBuilder private var usageRemaining: some View { - if let weekly = reader.weeklyUsageRemaining { + if let weekly = reader.weeklyUsageRemaining + ?? historicalWeeklyUsageRemaining { VStack(alignment: .leading, spacing: 14) { UsageRemainingChart( window: weekly.window, @@ -584,12 +589,25 @@ private struct GraphsWorkspace: View { .font(.callout) } } else { - UnavailableGraph( - title: "Weekly usage unavailable", - message: "Try refreshing to check again." - ) + unavailableCurrentWindow } } + + private var historicalWeeklyUsageRemaining: LimitReading? { + store.state.timeRange == .currentWindow ? nil : reader.account?.mainLimit + } + + private var canExploreHistoricalUsage: Bool { + reader.weeklyUsageRemaining != nil + || historicalWeeklyUsageRemaining != nil + } + + private var unavailableCurrentWindow: some View { + UnavailableGraph( + title: reader.evidence.reason ?? "Weekly usage unavailable", + message: "Try refreshing to check again." + ) + } } struct UsagePerTokenWorkspace: View { diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index 3535d5b..5714632 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -570,14 +570,16 @@ struct UsageReaderSnapshot: Equatable, Sendable { var fetchedAt: Date? { account?.fetchedAt } - var weeklyUsageRemaining: LimitReading? { account?.mainLimit } + var weeklyUsageRemaining: LimitReading? { + interval == nil ? nil : account?.mainLimit + } var accountFacts: AccountFacts? { account?.accountFacts } var otherLimits: [LimitReading] { account?.otherLimits ?? [] } var guidanceTitle: String { - guidance?.title ?? "Not enough data" + guidance?.title ?? evidence.reason ?? "Not enough data" } var guidanceMessage: String { @@ -585,7 +587,7 @@ struct UsageReaderSnapshot: Equatable, Sendable { } var suggestedPaceText: String { - guidance?.suggestedPace ?? "Not enough data" + guidance?.suggestedPace ?? evidence.reason ?? "Not enough data" } var evidenceText: String { @@ -930,13 +932,18 @@ enum UsageIntelligenceEngine { localHistory: localHistoryCache ) } - let observedInterval = input.account.flatMap { account in - account.mainLimit.map { - UsageObservedInterval( - limitID: $0.limitId, - durationMinutes: $0.window.durationMinutes, - startsAt: $0.window.startsAt, - resetsAt: $0.window.resetsAt + let observedInterval = input.account.flatMap { + account -> UsageObservedInterval? in + return account.mainLimit.flatMap { limit in + guard input.now >= limit.window.startsAt, + input.now < limit.window.resetsAt else { + return nil + } + return UsageObservedInterval( + limitID: limit.limitId, + durationMinutes: limit.window.durationMinutes, + startsAt: limit.window.startsAt, + resetsAt: limit.window.resetsAt ) } } @@ -1507,7 +1514,9 @@ enum UsageIntelligenceEngine { return UsageEvidence( coverage: .notApplicable, confidence: .unavailable, - reason: "Weekly window ended", + reason: now >= window.resetsAt + ? "Current allowance window unavailable" + : "Weekly window has not started", policyVersion: CurrentUsagePolicy.version ) } diff --git a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift index 37d5870..cb7c423 100644 --- a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift +++ b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift @@ -1166,6 +1166,71 @@ final class AnalyticsWorkspaceTests: XCTestCase { } } + func testExpiredCurrentWindowRendersUnavailableAndHistoricalUsage() throws { + let start = try date("2026-08-01T12:13:00Z") + let reset = try date("2026-08-08T12:13:00Z") + let observedAt = try date("2026-08-03T12:13:00Z") + let reader = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: UsageSnapshot( + mainLimit: LimitReading( + limitId: "weekly", + name: "Weekly", + window: UsageWindow( + remainingPercent: 70, + resetsAt: reset, + durationMinutes: 10_080 + ) + ), + otherLimits: [], + tokenHistory: [], + emergencyResetCount: 0, + fetchedAt: observedAt + ), + samples: [ + UsageSample( + observedAt: start, + remainingPercent: 100, + resetsAt: reset + ) + ], + safetyBuffer: 3, + sourceState: .available, + now: try date("2026-08-09T12:13:00Z"), + previousStatus: nil + ) + ) + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! + ) + + XCTAssertNil(reader.weeklyUsageRemaining) + XCTAssertTrue( + renders( + AnalyticsWorkspaceBody( + reader: reader, + store: store, + assistedInsights: CodexAssistedInsightStore() + ), + size: CGSize(width: 640, height: 780) + ) + ) + + store.selectTimeRange(.fourWeeks) + XCTAssertTrue( + renders( + AnalyticsWorkspaceBody( + reader: reader, + store: store, + assistedInsights: CodexAssistedInsightStore() + ), + size: CGSize(width: 640, height: 780) + ) + ) + } + func testUsagePerTokenComparisonRendersAtSmallAndLargeSizes() { let suiteName = "AnalyticsWorkspaceTests-usage-per-token-\(UUID().uuidString)" diff --git a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift index 01b9c1e..9020f29 100644 --- a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift +++ b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift @@ -1540,6 +1540,106 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertTrue(ended.chart.historicalProjection.isEmpty) } + func testCurrentWindowUsesFullAllowanceFrameAndStopsObservedSeries() throws { + let start = try date("2026-08-01T12:13:00Z") + let latestObservation = try date("2026-08-03T12:13:00Z") + let reset = try date("2026-08-08T12:13:00Z") + let account = UsageSnapshot( + mainLimit: LimitReading( + limitId: "codex", + name: "Codex", + window: UsageWindow( + remainingPercent: 70, + resetsAt: reset, + durationMinutes: 10_080 + ) + ), + otherLimits: [], + tokenHistory: [ + TokenDay( + date: latestObservation.addingTimeInterval(-86_400), + tokens: 1_000, + completeness: .complete + ) + ], + emergencyResetCount: 0, + fetchedAt: latestObservation + ) + let reader = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [ + UsageSample( + observedAt: start, + remainingPercent: 100, + resetsAt: reset + ), + UsageSample( + observedAt: latestObservation, + remainingPercent: 70, + resetsAt: reset + ) + ], + safetyBuffer: 3, + sourceState: .available, + now: latestObservation, + previousStatus: nil + ) + ) + + XCTAssertEqual(reader.interval?.startsAt, start) + XCTAssertEqual(reader.interval?.resetsAt, reset) + XCTAssertEqual(reader.chart.target.map(\.date), [start, reset]) + XCTAssertEqual(reader.chart.observed.map(\.date).max(), latestObservation) + XCTAssertFalse(reader.chart.observed.contains { $0.date > latestObservation }) + XCTAssertEqual(reader.accountTokenActivity.interval?.end, latestObservation) + } + + func testExpiredCurrentWindowIsUnavailableButKeepsHistoricalSamples() throws { + let start = try date("2026-08-01T12:13:00Z") + let reset = try date("2026-08-08T12:13:00Z") + let historicalObservation = try date("2026-08-03T12:13:00Z") + let account = UsageSnapshot( + mainLimit: LimitReading( + limitId: "codex", + name: "Codex", + window: UsageWindow( + remainingPercent: 70, + resetsAt: reset, + durationMinutes: 10_080 + ) + ), + otherLimits: [], + tokenHistory: [], + emergencyResetCount: 0, + fetchedAt: historicalObservation + ) + let reader = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [ + UsageSample( + observedAt: historicalObservation, + remainingPercent: 75, + resetsAt: reset + ) + ], + safetyBuffer: 3, + sourceState: .available, + now: try date("2026-08-09T12:13:00Z"), + previousStatus: nil + ) + ) + + XCTAssertNil(reader.weeklyUsageRemaining) + XCTAssertNil(reader.interval) + XCTAssertEqual(reader.evidence.reason, "Current allowance window unavailable") + XCTAssertEqual(reader.guidanceTitle, "Current allowance window unavailable") + XCTAssertTrue(reader.chart.allObserved.contains { + $0.date == historicalObservation + }) + } + func testQuietDenseHistoryLeavesRoomToUseMore() { let now = Date(timeIntervalSince1970: 9_000_000) let account = makeSnapshot(remaining: 40, fetchedAt: now) @@ -2185,6 +2285,10 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) } + private func date(_ value: String) throws -> Date { + try XCTUnwrap(ISO8601DateFormatter().date(from: value)) + } + private func timingFact( id: String, taskID: String, diff --git a/docs/MEASUREMENT-CONTRACT.md b/docs/MEASUREMENT-CONTRACT.md index 5e35a12..82eb90a 100644 --- a/docs/MEASUREMENT-CONTRACT.md +++ b/docs/MEASUREMENT-CONTRACT.md @@ -26,6 +26,8 @@ The weekly Codex allowance is the primary allowance. Every allowance-derived metric carries the selected limit ID, duration, start, and reset time. +`Current window` is the active 10,080-minute allowance frame from its start through its reset. It is not a promise of future observations: chart domains continue to reset while observed series end at their latest factual reading. + ## Account Token Activity Account Token Activity is the primary weekly token total. Use the strongest available method in this order: diff --git a/docs/PRODUCT-LANGUAGE.md b/docs/PRODUCT-LANGUAGE.md index 6e74f9a..e690579 100644 --- a/docs/PRODUCT-LANGUAGE.md +++ b/docs/PRODUCT-LANGUAGE.md @@ -26,6 +26,7 @@ Codex Limits uses clear, direct English. These rules apply to every label, toolt - Do not claim that OpenAI changed a limit when the product only observed a change in intensity. - Describe a usage deviation in `Insights`; do not call it an anomaly or send an alert. - Use the weekly Codex window for the primary `Usage remaining`; name every other window. +- Describe `Current window` as the active allowance frame, not as a promise of future observations. - Withhold a Low-confidence estimate and say what data is missing. - Do not call a partial sum of daily token buckets a weekly total. From 38958b178490fa31ab1a3d60dde0c58239101668 Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:48:10 +0200 Subject: [PATCH 04/17] fix: address analytics range review --- Sources/CodexLimits/AnalyticsWorkspace.swift | 2 +- .../CodexLimits/CodexAssistedInsights.swift | 3 +- .../CodexLimits/DeterministicInsights.swift | 15 +- Sources/CodexLimits/MenuContentView.swift | 101 ++++--- .../CodexLimits/UsageIntelligenceEngine.swift | 12 +- Sources/CodexLimits/UsageMonitor.swift | 3 +- .../AnalyticsWorkspaceTests.swift | 13 +- .../DeterministicInsightTests.swift | 42 ++- docs/MEASUREMENT-CONTRACT.md | 248 ------------------ docs/PRODUCT-LANGUAGE.md | 59 ----- 10 files changed, 132 insertions(+), 366 deletions(-) delete mode 100644 docs/MEASUREMENT-CONTRACT.md delete mode 100644 docs/PRODUCT-LANGUAGE.md diff --git a/Sources/CodexLimits/AnalyticsWorkspace.swift b/Sources/CodexLimits/AnalyticsWorkspace.swift index 04ea57e..c6f17f5 100644 --- a/Sources/CodexLimits/AnalyticsWorkspace.swift +++ b/Sources/CodexLimits/AnalyticsWorkspace.swift @@ -65,7 +65,7 @@ enum AnalyticsTimeRange: String, CaseIterable, Codable, Identifiable, Sendable { case .twelveWeeks: duration = 84 * 86_400 } - let end = min(max(now, bounds.start), bounds.end) + let end = max(now, bounds.start) return DateInterval( start: max(bounds.start, end.addingTimeInterval(-duration)), end: end diff --git a/Sources/CodexLimits/CodexAssistedInsights.swift b/Sources/CodexLimits/CodexAssistedInsights.swift index 8d09a2a..0b7c4a6 100644 --- a/Sources/CodexLimits/CodexAssistedInsights.swift +++ b/Sources/CodexLimits/CodexAssistedInsights.swift @@ -193,7 +193,8 @@ struct CodexMetadataAnalysisPayload: Codable, Equatable, Sendable { ) -> CodexMetadataAnalysisPayload { let deterministicInput = DeterministicInsightInput( reader: reader, - exploration: exploration + exploration: exploration, + now: now ) let selectedUsage = deterministicInput.usagePerToken let range = deterministicInput.selectedRange ?? reader.interval.map { diff --git a/Sources/CodexLimits/DeterministicInsights.swift b/Sources/CodexLimits/DeterministicInsights.swift index ed1784f..20f593b 100644 --- a/Sources/CodexLimits/DeterministicInsights.swift +++ b/Sources/CodexLimits/DeterministicInsights.swift @@ -110,7 +110,8 @@ struct DeterministicInsightInput: Equatable, Sendable { init( reader: UsageReaderSnapshot, - exploration: AnalyticsExplorationState + exploration: AnalyticsExplorationState, + now: Date = Date() ) { sourceState = reader.sourceState freshness = reader.freshness @@ -133,7 +134,7 @@ struct DeterministicInsightInput: Equatable, Sendable { selectedRange = Self.effectiveRange( usagePerToken: selectedUsage, observedInterval: reader.interval, - fetchedAt: reader.fetchedAt, + now: now, exploration: exploration ) filters = exploration.filters @@ -166,7 +167,7 @@ struct DeterministicInsightInput: Equatable, Sendable { static func effectiveRange( usagePerToken: UsagePerTokenSnapshot, observedInterval: UsageObservedInterval?, - fetchedAt: Date?, + now: Date, exploration: AnalyticsExplorationState ) -> DateInterval? { let evidence = usagePerToken.history @@ -183,7 +184,7 @@ struct DeterministicInsightInput: Equatable, Sendable { ? exploration.visibleRange : nil } - let bounds = DateInterval(start: start, end: end) + let bounds = DateInterval(start: start, end: max(end, now)) if exploration.timeRange == .currentWindow { return usagePerToken.current?.interval ?? observedRange } @@ -195,7 +196,7 @@ struct DeterministicInsightInput: Equatable, Sendable { } return exploration.timeRange.interval( within: bounds, - now: fetchedAt ?? evidenceEnd ?? end + now: now ) } } @@ -207,12 +208,14 @@ enum DeterministicInsightEngine { static func evaluate( reader: UsageReaderSnapshot, exploration: AnalyticsExplorationState, + now: Date = Date(), dispositions: [String: InsightDisposition] ) -> DeterministicInsightsSnapshot { evaluate( DeterministicInsightInput( reader: reader, - exploration: exploration + exploration: exploration, + now: now ), dispositions: dispositions ) diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index d0b87f4..eae392a 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -100,28 +100,31 @@ struct MenuContentView: View { Task { await monitor.refresh() } } ) { - AnalyticsWorkspaceBody( - reader: monitor.readerSnapshot, - store: workspace, - assistedInsights: assistedInsights, - analyticsPreferencesChanged: { - monitor.analyticsPreferencesDidChange( - exploration: workspace.state, - dispositions: workspace.insightDispositions - ) - }, - resetReminderState: monitor.resetReminderState, - setResetReminderEnabled: { isEnabled in - Task { - await monitor.setResetReminderEnabled(isEnabled) - } - }, - setResetReminderLeadTime: { leadTime in - Task { - await monitor.setResetReminderLeadTime(leadTime) + TimelineView(.periodic(from: .now, by: 60)) { context in + AnalyticsWorkspaceBody( + reader: monitor.readerSnapshot, + store: workspace, + assistedInsights: assistedInsights, + now: context.date, + analyticsPreferencesChanged: { + monitor.analyticsPreferencesDidChange( + exploration: workspace.state, + dispositions: workspace.insightDispositions + ) + }, + resetReminderState: monitor.resetReminderState, + setResetReminderEnabled: { isEnabled in + Task { + await monitor.setResetReminderEnabled(isEnabled) + } + }, + setResetReminderLeadTime: { leadTime in + Task { + await monitor.setResetReminderLeadTime(leadTime) + } } - } - ) + ) + } } } @@ -215,6 +218,7 @@ struct AnalyticsWorkspaceBody: View { let reader: UsageReaderSnapshot @ObservedObject var store: AnalyticsWorkspaceStore @ObservedObject var assistedInsights: CodexAssistedInsightStore + let now: Date let analyticsPreferencesChanged: () -> Void let resetReminderState: ResetReminderState let setResetReminderEnabled: (Bool) -> Void @@ -224,6 +228,7 @@ struct AnalyticsWorkspaceBody: View { reader: UsageReaderSnapshot, store: AnalyticsWorkspaceStore, assistedInsights: CodexAssistedInsightStore, + now: Date = Date(), analyticsPreferencesChanged: @escaping () -> Void = {}, resetReminderState: ResetReminderState = ResetReminderState( isEnabled: false, @@ -237,6 +242,7 @@ struct AnalyticsWorkspaceBody: View { self.reader = reader self.store = store self.assistedInsights = assistedInsights + self.now = now self.analyticsPreferencesChanged = analyticsPreferencesChanged self.resetReminderState = resetReminderState self.setResetReminderEnabled = setResetReminderEnabled @@ -248,7 +254,7 @@ struct AnalyticsWorkspaceBody: View { Group { switch store.state.section { case .graphs: - GraphsWorkspace(reader: reader, store: store) + GraphsWorkspace(reader: reader, store: store, now: now) case .facts: FactsWorkspace( reader: reader, @@ -448,6 +454,7 @@ private struct HeaderFact: View { private struct GraphsWorkspace: View { let reader: UsageReaderSnapshot @ObservedObject var store: AnalyticsWorkspaceStore + let now: Date var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -457,8 +464,13 @@ private struct GraphsWorkspace: View { case .usageRemaining: usageRemaining case .tokenActivity: - if canExploreHistoricalUsage { - TokenActivityWorkspace(reader: reader, store: store) + if store.state.timeRange != .currentWindow + || reader.weeklyUsageRemaining != nil { + TokenActivityWorkspace( + reader: reader, + store: store, + now: now + ) } else { unavailableCurrentWindow } @@ -547,7 +559,8 @@ private struct GraphsWorkspace: View { window: weekly.window, chart: reader.chart, evidence: reader.evidence, - store: store + store: store, + now: now ) Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) { @@ -597,11 +610,6 @@ private struct GraphsWorkspace: View { store.state.timeRange == .currentWindow ? nil : reader.account?.mainLimit } - private var canExploreHistoricalUsage: Bool { - reader.weeklyUsageRemaining != nil - || historicalWeeklyUsageRemaining != nil - } - private var unavailableCurrentWindow: some View { UnavailableGraph( title: reader.evidence.reason ?? "Weekly usage unavailable", @@ -652,7 +660,7 @@ struct UsagePerTokenWorkspace: View { } return store.effectiveRange( within: bounds, - now: Date() + now: snapshot.current?.interval.end ?? bounds.end ) } @@ -1183,7 +1191,10 @@ private struct ConcurrencyWorkspace: View { private var visibleRange: DateInterval { store.effectiveRange( within: bounds, - now: Date() + now: min( + reader.localTokenActivity.observedAt ?? bounds.end, + bounds.end + ) ) } @@ -1532,6 +1543,7 @@ private struct ConcurrencyWorkspace: View { private struct TokenActivityWorkspace: View { let reader: UsageReaderSnapshot @ObservedObject var store: AnalyticsWorkspaceStore + let now: Date @State private var selectedDay: TokenDay? private let day: TimeInterval = 86_400 @@ -1550,7 +1562,7 @@ private struct TokenActivityWorkspace: View { } private var bounds: DateInterval { - let fallback = reader.fetchedAt ?? Date() + let fallback = reader.fetchedAt ?? now let first = accountDays.first?.date ?? currentWindowBounds?.start ?? fallback.addingTimeInterval(-day) @@ -1559,7 +1571,7 @@ private struct TokenActivityWorkspace: View { ?? fallback return DateInterval( start: min(first, currentWindowBounds?.start ?? first), - end: max(last, currentWindowBounds?.end ?? last) + end: max(last, currentWindowBounds?.end ?? last, now) ) } @@ -1570,7 +1582,7 @@ private struct TokenActivityWorkspace: View { } return store.effectiveRange( within: bounds, - now: Date() + now: now ) } @@ -1973,7 +1985,10 @@ private struct WorkspaceFilterMenu: View { let bounds = reader.usageReceipts.interval return store.effectiveRange( within: bounds, - now: Date() + now: min( + reader.localTokenActivity.observedAt ?? bounds.end, + bounds.end + ) ) } @@ -2055,6 +2070,7 @@ private struct UsageRemainingChart: View { let chart: UsageChartSnapshot let evidence: UsageEvidence @ObservedObject var store: AnalyticsWorkspaceStore + let now: Date @State private var selection: UsageChartSelection? @State private var pendingRange: DateInterval? @@ -2066,7 +2082,11 @@ private struct UsageRemainingChart: View { } private var bounds: DateInterval { - chart.availableRange(including: currentWindowBounds) + let available = chart.availableRange(including: currentWindowBounds) + return DateInterval( + start: available.start, + end: max(available.end, now) + ) } private var visibleRange: DateInterval { @@ -2075,7 +2095,7 @@ private struct UsageRemainingChart: View { } return store.effectiveRange( within: bounds, - now: Date() + now: now ) } @@ -2942,7 +2962,10 @@ private struct FactsWorkspace: View { let bounds = reader.usageReceipts.interval return store.effectiveRange( within: bounds, - now: Date() + now: min( + reader.localTokenActivity.observedAt ?? bounds.end, + bounds.end + ) ) } diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index 5714632..eb15285 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -579,7 +579,9 @@ struct UsageReaderSnapshot: Equatable, Sendable { var otherLimits: [LimitReading] { account?.otherLimits ?? [] } var guidanceTitle: String { - guidance?.title ?? evidence.reason ?? "Not enough data" + guidance?.title + ?? (evidence.coverage == .notApplicable ? evidence.reason : nil) + ?? "Not enough data" } var guidanceMessage: String { @@ -587,7 +589,9 @@ struct UsageReaderSnapshot: Equatable, Sendable { } var suggestedPaceText: String { - guidance?.suggestedPace ?? evidence.reason ?? "Not enough data" + guidance?.suggestedPace + ?? (evidence.coverage == .notApplicable ? evidence.reason : nil) + ?? "Not enough data" } var evidenceText: String { @@ -959,7 +963,7 @@ enum UsageIntelligenceEngine { selectedRange: DeterministicInsightInput.effectiveRange( usagePerToken: usagePerToken, observedInterval: observedInterval, - fetchedAt: input.account?.fetchedAt, + now: input.now, exploration: input.analyticsExploration ), filters: input.analyticsExploration.filters @@ -1516,7 +1520,7 @@ enum UsageIntelligenceEngine { confidence: .unavailable, reason: now >= window.resetsAt ? "Current allowance window unavailable" - : "Weekly window has not started", + : "Current allowance window has not started", policyVersion: CurrentUsagePolicy.version ) } diff --git a/Sources/CodexLimits/UsageMonitor.swift b/Sources/CodexLimits/UsageMonitor.swift index c9b5d6a..f105074 100644 --- a/Sources/CodexLimits/UsageMonitor.swift +++ b/Sources/CodexLimits/UsageMonitor.swift @@ -918,7 +918,8 @@ final class UsageMonitor: ObservableObject { ) { let input = DeterministicInsightInput( reader: readerSnapshot, - exploration: exploration + exploration: exploration, + now: Date() ) readerSnapshot.insights = DeterministicInsightEngine.evaluate( input, diff --git a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift index cb7c423..4e37fae 100644 --- a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift +++ b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift @@ -282,7 +282,7 @@ final class AnalyticsWorkspaceTests: XCTestCase { let latestObserved = now.addingTimeInterval(-6 * 3_600) let bounds = DateInterval( start: now.addingTimeInterval(-90 * 86_400), - end: now.addingTimeInterval(3_600) + end: latestObserved ) for (range, duration) in [ @@ -1281,11 +1281,12 @@ final class AnalyticsWorkspaceTests: XCTestCase { _ date: Date, timeZone: TimeZone ) -> String { - date.formatted( - Date.FormatStyle(date: .abbreviated, time: .shortened) - .locale(Locale(identifier: "en_US")) - .timeZone(timeZone) - ) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US") + formatter.timeZone = timeZone + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) } private func renders( diff --git a/Tests/CodexLimitsTests/DeterministicInsightTests.swift b/Tests/CodexLimitsTests/DeterministicInsightTests.swift index 4032c3a..116dbbd 100644 --- a/Tests/CodexLimitsTests/DeterministicInsightTests.swift +++ b/Tests/CodexLimitsTests/DeterministicInsightTests.swift @@ -121,7 +121,7 @@ final class DeterministicInsightTests: XCTestCase { DeterministicInsightInput.effectiveRange( usagePerToken: usage, observedInterval: nil, - fetchedAt: current.interval.end, + now: current.interval.end, exploration: exploration ) ) @@ -133,6 +133,46 @@ final class DeterministicInsightTests: XCTestCase { } } + func testPresetInsightRangeEndsAtNowWhenEvidenceIsStale() throws { + let usage = insightInput(multiplier: 1.4).usagePerToken + let latestObserved = try XCTUnwrap(usage.current?.interval.end) + let now = latestObserved.addingTimeInterval(6 * 3_600) + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .oneDay + + let resolved = try XCTUnwrap( + DeterministicInsightInput.effectiveRange( + usagePerToken: usage, + observedInterval: nil, + now: now, + exploration: exploration + ) + ) + + XCTAssertEqual(resolved.start, now.addingTimeInterval(-86_400)) + XCTAssertEqual(resolved.end, now) + } + + func testSelectedInsightRangeKeepsEmptyTimeAfterStaleEvidence() throws { + let usage = insightInput(multiplier: 1.4).usagePerToken + let latestObserved = try XCTUnwrap(usage.current?.interval.end) + let now = latestObserved.addingTimeInterval(6 * 3_600) + let selected = DateInterval(start: latestObserved, end: now) + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .selected + exploration.visibleRange = selected + + XCTAssertEqual( + DeterministicInsightInput.effectiveRange( + usagePerToken: usage, + observedInterval: nil, + now: now, + exploration: exploration + ), + selected + ) + } + func testStaleLowCoverageAndUnavailableSourceWithholdConclusion() { let stale = insightSnapshot( multiplier: 1.4, diff --git a/docs/MEASUREMENT-CONTRACT.md b/docs/MEASUREMENT-CONTRACT.md deleted file mode 100644 index 82eb90a..0000000 --- a/docs/MEASUREMENT-CONTRACT.md +++ /dev/null @@ -1,248 +0,0 @@ -# Measurement contract - -This contract defines how Codex Limits labels facts, Coverage, Confidence, and comparable work. It applies to the reader snapshot, charts, Facts, Insights, tooltips, notifications, and tests. - -The product prefers no estimate to a weak estimate. - -## Source classes - -Every value has one source class: - -1. **Account fact** — returned by the Codex account API. -2. **Local fact** — observed in Codex records on this Mac. -3. **Derived estimate** — calculated from named account and local facts. - -The UI never merges these classes into one unexplained value. - -## Primary allowance - -The weekly Codex allowance is the primary allowance. - -- Select the Codex window whose `windowDurationMins` is `10080`. -- Show its Usage remaining in the menu bar, current-state header, Runway, Suggested Pace, and default Usage remaining chart. -- Do not replace it with a five-hour window because that window has a lower percentage. -- Show five-hour and model-specific windows as Other limits in Facts. -- If no weekly window is returned, show `Weekly usage unavailable`. Do not substitute another window without naming it. - -Every allowance-derived metric carries the selected limit ID, duration, start, and reset time. - -`Current window` is the active 10,080-minute allowance frame from its start through its reset. It is not a promise of future observations: chart domains continue to reset while observed series end at their latest factual reading. - -## Account Token Activity - -Account Token Activity is the primary weekly token total. Use the strongest available method in this order: - -1. **Observed lifetime delta** — subtract two monotonic `summary.lifetimeTokens` readings that bound the same account and interval. -2. **Exact daily sum** — sum complete account daily buckets only when their calendar boundaries match the selected interval. -3. **Partial daily sum** — show complete daily buckets inside the interval as a factual partial value. Do not scale partial days or call the result a weekly total. -4. **Unavailable** — withhold the total when no method above applies. - -An observed lifetime delta is valid only when: - -- both readings belong to the same local account partition; -- the counter did not decrease; -- both interval boundaries meet the boundary rules below; -- no account change occurred between the readings. - -Daily buckets may seed a historical chart, but they never become observed allowance readings. - -## Account facts - -Facts may show these values when the account API returns them: - -- Lifetime tokens -- Peak daily tokens -- Longest running turn -- Current streak -- Longest streak -- Credits balance or unlimited credits -- Spend-control limit, Usage remaining, and reset time - -These are Account facts. They do not need Confidence. They do need source, fetched time, and an unavailable state. - -## Local Activity source boundary - -Issue `Prove read-only Local Activity ingestion and Coverage` owns the source decision before Local Token Activity ships. - -Until that spike is complete: - -- do not assume that a separate app-server connection receives live events from Tasks owned by another Codex process; -- do not resume, load, start, stop, or take ownership of a user Task to observe it; -- treat supported read-only app-server projections as the preferred metadata source; -- treat incrementally tailed local Codex records as a candidate source for token, turn, tool, and timing facts; -- record source capability and CLI version with every normalized event. - -If no safe read-only source exists for a fact, the fact is unavailable. - -## Time boundaries - -### Rolling ranges - -`24 hours`, `3 days`, `4 weeks`, and `12 weeks` end at the current instant and use exact elapsed durations of 86,400, 259,200, 2,419,200, and 7,257,600 seconds. A delayed observation does not move a rolling range into the past. - -### Machine-local time - -Reader-facing dates and clock labels use the Mac's current time zone when rendered. A time-zone or daylight-saving change changes local labels, not the underlying elapsed interval. - -An interval is: - -- **Tightly bounded** when the closest account readings are no more than 15 minutes from both boundaries. -- **Loosely bounded** when both readings are no more than 60 minutes from the boundaries. -- **Unbounded** when either reading is farther away or missing. - -For allowance movement: - -- a gap of no more than 30 minutes between account readings supports High Coverage; -- a gap over 30 minutes and no more than 6 hours lowers Coverage to Partial; -- a gap over 6 hours makes comparable allowance movement unavailable; -- any gap that may contain an unknown reset or correction makes the interval unbounded. - -A known scheduled reset, banked reset, account change, or detected correction always splits the interval. - -## Coverage - -Coverage says how much of the required source data was observed. It does not mean accuracy. - -Reader-facing Coverage states are: - -| State | Meaning | -|---|---| -| `Complete` | Every required source and boundary is present, with no known gap or ambiguity. | -| `High` | At least 80% of aligned activity is represented and every required boundary is tight. | -| `Partial` | Useful evidence exists, but coverage is between 50% and 79%, a boundary is loose, or a named source is missing. | -| `Low` | Less than 50% is represented or a material gap prevents a dependable conclusion. | -| `Unavailable` | The required source, identity, token definition, or time boundary cannot be reconciled. | -| `Not applicable` | The metric has no meaningful coverage denominator, such as an interval with no activity. | - -Every state other than Complete names at least one reason, such as: - -- `Account boundary is 42 minutes late` -- `Local Tasks are missing` -- `Activity from another device is possible` -- `Token definitions do not align` -- `Unknown reset or correction` -- `Codex version does not expose this field` - -### Numeric Local Coverage - -Numeric Local Coverage is shown only when the source spike proves that Account Token Activity and Local Token Activity use compatible token definitions for the active Codex version and both values cover the same interval. - -For aligned values: - -`Local Coverage = Local Token Activity / Account Token Activity` - -Rules: - -- When both totals are zero, Coverage is Not applicable. -- When account activity is zero but local activity is positive, numeric Coverage is unavailable. -- When local activity is more than 2% above account activity, numeric Coverage is unavailable and the UI says `Account and local totals do not align`. -- A difference of at most 2% may be treated as rounding and clamped to 100%. -- Numeric Coverage describes the share of Account Token Activity visible in local records. It does not prove that local records explain account billing. - -### Reset Detail Coverage - -Reset Detail Coverage uses the authoritative reset count and returned available detail: - -- `Complete` when detail count equals the authoritative count. -- `Partial` when detail count is greater than zero and lower than the count. -- `Unavailable` when the count is greater than zero and no detail is returned. -- `Not applicable` when the authoritative count is zero. - -## Confidence - -Confidence says how strongly the observed evidence supports a derived estimate or Insight. - -| State | Product behavior | -|---|---| -| `High` | Show the estimate or Insight. Coverage is Complete or High, the interval is tightly bounded, and no material comparability warning applies. | -| `Medium` | Show the estimate with its range and named caveat. The interval is still bounded and the conclusion remains useful. | -| `Low` | Withhold the estimate or Insight. Show the observed facts and the reason more evidence is needed. | -| `Unavailable` | Do not calculate the result. | - -Direct Account facts and Local facts show provenance and freshness instead of artificial Confidence. - -The engine, not the view, owns Confidence and its reasons. Thresholds are versioned policy values and have deterministic tests. - -## Comparable work - -Two intervals are comparable only when all these gates pass: - -- both intervals belong to the same account partition; -- both use the weekly Codex allowance; -- both are bounded; -- neither contains a reset, account change, unknown correction, or counter decrease; -- both have non-zero Account Token Activity; -- Local Coverage is at least 50% when workload mix is part of the comparison; -- the dominant model family and reasoning level are known; -- model, reasoning, and cached-input shares differ by no more than 20 percentage points; -- the product can name every reason that lowers comparability. - -Comparability is: - -- **High** when Local Coverage is at least 80%, both intervals are tightly bounded, and each observed workload-mix share differs by no more than 10 percentage points. -- **Medium** when Local Coverage is at least 50%, the intervals are at least loosely bounded, and each share differs by no more than 20 percentage points. -- **Not comparable** otherwise. - -Low-comparability conclusions are withheld. - -## Reference Baseline - -The default Reference Baseline is the median Allowance Intensity of the previous four complete, High-comparability weekly windows. - -- Use exactly four eligible windows. -- If fewer than four exist, show `Not enough comparable weeks`. -- A user-pinned period must pass at least Medium comparability. -- Pinning a period does not override reset, identity, boundary, or token-definition failures. -- Store the baseline interval IDs and policy version with the derived result. - -Allowance Intensity divides observed weekly Account Movement by aligned Account Token Activity. Equivalent Capacity extrapolates from that intensity and always remains an estimate. - -## Account partitions - -Analytics History never mixes signed-in accounts. - -- Read account state before joining new observations to history. -- When an email is available, derive an on-device keyed fingerprint and never persist the email as the partition key. -- When stable identity is unavailable, start an isolated unknown-account partition after every observed auth transition. -- A plan change does not create a new partition, but it splits comparable intervals. - -## Delete analytics history - -`Delete analytics history` means all Analytics History owned by Codex Limits: - -- all local Derived Records; -- Codex-assisted Insight results; -- Analytics Overhead records; -- account usage samples in the selected sync folder; -- records written by every installation in that sync folder. - -Preferences, notification settings, and Codex source records remain. - -Deletion creates a new empty sync generation so another Mac cannot republish older history. Each installation that observes the generation discards older local analytics before it publishes again. - -If the selected sync folder is unavailable, the product must not claim that deletion completed. It prevents older synced records from being imported, keeps a pending deletion state, and offers retry. - -The app does not rebuild deleted history automatically. A separate explicit `Rebuild available history` action may read only source data that still exists. New observations after deletion belong to the new generation. - -## Codex-assisted availability - -`Analyze with Codex` is visible only when `model/list` advertises: - -- GPT-5.6 Luna; -- Medium reasoning for that exact model; -- an account state that can run the request. - -If any condition is missing or model availability cannot be checked, hide the action. Do not fall back to GPT-5.5, Terra, Sol, another reasoning level, or the analyzed Task model. - -Metadata-only Analysis uses a closed payload allowlist. Source-backed Analysis sends only the categories and scope accepted in its current preflight. The analysis Task cannot use tools, read additional files, or change the workspace. - -## Reader rules - -- Show the source beside a value when sources may disagree. -- Show the observed interval for every derived value. -- Show raw facts before estimates. -- Use `Not enough data` or a specific reason instead of a Low-confidence number. -- Never call Coverage accuracy. -- Never call Confidence certainty. -- Never call Account Token Activity a token allowance. -- Never call Local Coverage billing coverage. diff --git a/docs/PRODUCT-LANGUAGE.md b/docs/PRODUCT-LANGUAGE.md deleted file mode 100644 index e690579..0000000 --- a/docs/PRODUCT-LANGUAGE.md +++ /dev/null @@ -1,59 +0,0 @@ -# Product language - -Codex Limits uses clear, direct English. These rules apply to every label, tooltip, chart, notification, and insight. - -## Orwell’s six rules - -1. Use literal words. Avoid familiar metaphors and figures of speech. -2. Use a short word when it says the same thing as a long word. -3. Cut every word that adds no meaning. -4. Use active voice. -5. Prefer everyday English to jargon or foreign phrases. -6. Break a rule when following it would make the text harsh, false, or unclear. - -## Product rules - -- Name the quantity: `remaining`, `used`, `tokens`, or `percentage points`. -- Use Codex’s label `Usage remaining` for the primary allowance percentage. -- Separate account facts, local facts, and estimates. -- State uncertainty instead of hiding it. -- Name the source when two sources can disagree. -- Describe what changed; do not invent a cause. -- Use one canonical domain term for one concept. -- Put the action first in buttons. -- Keep tooltips to one fact or consequence. -- Do not call local activity billing, cost, waste, or efficiency. -- Do not claim that OpenAI changed a limit when the product only observed a change in intensity. -- Describe a usage deviation in `Insights`; do not call it an anomaly or send an alert. -- Use the weekly Codex window for the primary `Usage remaining`; name every other window. -- Describe `Current window` as the active allowance frame, not as a promise of future observations. -- Withhold a Low-confidence estimate and say what data is missing. -- Do not call a partial sum of daily token buckets a weekly total. - -## Time labels - -Rolling ranges end now. Show their dates and clock labels in the Mac's current time zone; daylight-saving and time-zone changes do not change the elapsed range. - -## Navigation labels - -- `Graphs` — Usage remaining, Token activity, Usage per token, and Concurrency charts. -- `Facts` — account facts, banked resets, other limits, and Usage receipts. -- `Insights` — structured observations and recommendations. - -The current-state header remains visible while these views change. - -## Examples - -| Avoid | Use | -|---|---| -| `37% left` or `37% allowance remaining` | `Usage remaining · 37%` | -| `You're burning through your quota` | `Usage increased faster than your baseline` | -| `Token efficiency` | `Allowance used per 1M local tokens` | -| `Workload cost` as a visible chart label | `Usage per token` | -| `Oldest reset expires` | `Next known expiry` | -| `3 resets available` when only one expiry is known | `3 banked resets · 1 expiry known` | -| `AI-powered analysis` | `Analyze with Codex` | -| `We detected hidden usage` | `Account and local totals differ` | -| `Your limit got worse` | `Comparable work used 1.3× more allowance` | -| `Usage anomaly detected` | `Usage increased faster than your baseline` | -| `Low confidence · 3.2 days` | `Not enough data · Account gap over 6 hours` | From f58169ac71667ae819febe0ef9cffa29d0383161 Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:22:17 +0200 Subject: [PATCH 05/17] feat: render rolling account token intervals (#50) --- Sources/CodexLimits/AnalyticsWorkspace.swift | 2 +- Sources/CodexLimits/MenuContentView.swift | 127 ++++++--- .../CodexLimits/UsageIntelligenceEngine.swift | 146 +++++++++- .../AnalyticsWorkspaceTests.swift | 89 ++++++- .../UsageIntelligenceEngineTests.swift | 250 ++++++++++++++++++ 5 files changed, 573 insertions(+), 41 deletions(-) diff --git a/Sources/CodexLimits/AnalyticsWorkspace.swift b/Sources/CodexLimits/AnalyticsWorkspace.swift index c6f17f5..4d978f0 100644 --- a/Sources/CodexLimits/AnalyticsWorkspace.swift +++ b/Sources/CodexLimits/AnalyticsWorkspace.swift @@ -67,7 +67,7 @@ enum AnalyticsTimeRange: String, CaseIterable, Codable, Identifiable, Sendable { } let end = max(now, bounds.start) return DateInterval( - start: max(bounds.start, end.addingTimeInterval(-duration)), + start: end.addingTimeInterval(-duration), end: end ) } diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index eae392a..4bbed99 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -1602,7 +1602,7 @@ private struct TokenActivityWorkspace: View { VStack(alignment: .leading, spacing: 4) { Text("Token activity") .font(.title3.weight(.semibold)) - Text("Daily token totals from your Codex account.") + Text("Observed token activity from your Codex account.") .font(.callout) .foregroundStyle(.secondary) } @@ -1622,7 +1622,23 @@ private struct TokenActivityWorkspace: View { } } - if range.days.isEmpty { + if store.state.timeRange == .oneDay { + if reader.accountTokenActivity.intervals.isEmpty { + WorkspaceMessage( + icon: "chart.xyaxis.line", + title: reader.accountTokenActivity.reason + ?? "No account readings in this range", + message: "Missing time remains empty." + ) { + EmptyView() + } + .frame(minHeight: 170) + } else { + accountIntervalChart( + reader.accountTokenActivity.intervals + ) + } + } else if range.days.isEmpty { WorkspaceMessage( icon: "chart.xyaxis.line", title: "No complete daily totals", @@ -1637,7 +1653,9 @@ private struct TokenActivityWorkspace: View { accountChart(range) } - selectedPointDetail(range) + if store.state.timeRange != .oneDay { + selectedPointDetail(range) + } } } .onChange(of: visibleRange) { _, range in @@ -1651,7 +1669,9 @@ private struct TokenActivityWorkspace: View { private var chartSourceLabel: some View { ChartLegendItem( - label: "Daily totals · Account", + label: store.state.timeRange == .oneDay + ? "Observed intervals · Account" + : "Daily totals · Account", color: .blue ) } @@ -1667,13 +1687,14 @@ private struct TokenActivityWorkspace: View { ) -> some View { TokenSourceCard( title: "Account", - source: store.state.timeRange == .currentWindow + source: store.state.timeRange == .oneDay + ? accountSource + : store.state.timeRange == .currentWindow ? accountSource : "Codex daily token totals", value: summaryTokens(in: range).map(compactTokenCount) ?? "Not available", detail: summaryDetail(in: range), - coverage: summaryCoverage(in: range), freshness: reader.fetchedAt, freshnessLabel: "Updated", color: .blue @@ -1694,7 +1715,8 @@ private struct TokenActivityWorkspace: View { private func summaryTokens( in range: AccountTokenActivityRange ) -> Int64? { - if store.state.timeRange == .currentWindow { + if store.state.timeRange == .oneDay + || store.state.timeRange == .currentWindow { return reader.accountTokenActivity.tokens } return range.completeTokens @@ -1703,6 +1725,13 @@ private struct TokenActivityWorkspace: View { private func summaryDetail( in range: AccountTokenActivityRange ) -> String { + if store.state.timeRange == .oneDay { + guard let observed = reader.accountTokenActivity.interval else { + return reader.accountTokenActivity.reason + ?? "No account readings in this range" + } + return "Observed from \(intervalText(observed))" + } guard store.state.timeRange == .currentWindow else { if range.completeDayCount == 0 { return "No full days in this range" @@ -1724,21 +1753,6 @@ private struct TokenActivityWorkspace: View { } } - private func summaryCoverage( - in range: AccountTokenActivityRange - ) -> String { - guard store.state.timeRange == .currentWindow else { - return range.completeTokens == nil - ? "Unavailable" - : "Complete days" - } - switch reader.accountTokenActivity.state { - case .exact: return "Complete" - case .partial: return "Partial" - case .unavailable: return "Unavailable" - } - } - private func renderedDays( _ range: AccountTokenActivityRange ) -> [TokenDay] { @@ -1815,6 +1829,55 @@ private struct TokenActivityWorkspace: View { ) } + private func accountIntervalChart( + _ intervals: [AccountTokenActivityInterval] + ) -> some View { + Chart { + ForEach(intervals) { interval in + if interval.tokenDelta == 0 { + PointMark( + x: .value("Observed interval", midpoint(of: interval)), + y: .value("Account tokens", 0) + ) + .foregroundStyle(Color.blue) + .symbol(.diamond) + .symbolSize(55) + } else { + RectangleMark( + xStart: .value("Start", interval.start), + xEnd: .value("End", interval.end), + yStart: .value("Baseline", 0), + yEnd: .value("Account tokens", interval.tokenDelta) + ) + .foregroundStyle(Color.blue) + } + } + } + .chartXScale(domain: visibleRange.start ... visibleRange.end) + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisGridLine() + .foregroundStyle(Color.secondary.opacity(0.16)) + AxisValueLabel { + if let tokens = value.as(Int64.self) { + Text(compactTokenCount(tokens)) + } + } + } + } + .chartLegend(.hidden) + .frame(height: 180) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Account token activity") + .accessibilityValue(reader.accountTokenActivity.accessibilityValue) + } + + private func midpoint( + of interval: AccountTokenActivityInterval + ) -> Date { + interval.start.addingTimeInterval(interval.duration / 2) + } + @ViewBuilder private func selectedPointDetail( _ range: AccountTokenActivityRange @@ -1927,7 +1990,6 @@ private struct TokenSourceCard: View { let source: String let value: String let detail: String - let coverage: String let freshness: Date? let freshnessLabel: String let color: Color @@ -1949,17 +2011,14 @@ private struct TokenSourceCard: View { .font(.caption) .foregroundStyle(.secondary) .lineLimit(3) - HStack(spacing: 12) { - Text("Coverage \(coverage)") - if let freshness { - Text(freshnessLabel + " " + freshness.formatted( - date: .abbreviated, - time: .shortened - )) - } + if let freshness { + Text(freshnessLabel + " " + freshness.formatted( + date: .abbreviated, + time: .shortened + )) + .font(.caption2) + .foregroundStyle(.tertiary) } - .font(.caption2) - .foregroundStyle(.tertiary) } .padding(14) .frame(maxWidth: .infinity, minHeight: 155, alignment: .topLeading) @@ -1972,7 +2031,7 @@ private struct TokenSourceCard: View { .stroke(color.opacity(0.16)) } .help( - "\(title), \(source). \(value) tokens. Coverage \(coverage). \(detail)" + "\(title), \(source). \(value) tokens. \(detail)" ) } } diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index eb15285..3f773ac 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -358,30 +358,75 @@ enum AccountTokenActivityState: String, Equatable, Sendable { case unavailable } -enum AccountTokenActivityMethod: String, Equatable, Sendable { +enum AccountTokenActivityMethod: String, Equatable, Hashable, Sendable { case lifetimeDelta case dailyBuckets } +struct AccountTokenActivityInterval: Equatable, Hashable, Identifiable, Sendable { + let start: Date + let end: Date + let tokenDelta: Int64 + let method: AccountTokenActivityMethod + let accountPartitionID: String? + let limitID: String + let allowanceReset: Date + + var id: Self { self } + var duration: TimeInterval { end.timeIntervalSince(start) } +} + struct AccountTokenActivitySnapshot: Equatable, Sendable { let state: AccountTokenActivityState let tokens: Int64? let method: AccountTokenActivityMethod? let interval: DateInterval? let reason: String? + let range: DateInterval? + let intervals: [AccountTokenActivityInterval] + + init( + state: AccountTokenActivityState, + tokens: Int64?, + method: AccountTokenActivityMethod?, + interval: DateInterval?, + reason: String?, + range: DateInterval? = nil, + intervals: [AccountTokenActivityInterval] = [] + ) { + self.state = state + self.tokens = tokens + self.method = method + self.interval = interval + self.reason = reason + self.range = range + self.intervals = intervals + } static func unavailable( _ reason: String, - interval: DateInterval? = nil + interval: DateInterval? = nil, + range: DateInterval? = nil ) -> AccountTokenActivitySnapshot { AccountTokenActivitySnapshot( state: .unavailable, tokens: nil, method: nil, interval: interval, - reason: reason + reason: reason, + range: range ) } + + var accessibilityValue: String { + guard let tokens else { + return reason ?? "No account readings in this range" + } + let zero = intervals.contains { $0.tokenDelta == 0 } + ? " Includes an observed zero-token interval." + : "" + return "\(tokens) account tokens observed.\(zero) Time without an account reading is empty." + } } struct UsageIntelligenceInput: Equatable, Sendable { @@ -740,14 +785,22 @@ enum UsageIntelligenceEngine { sourceState: input.sourceState, now: input.now ) - let accountTokenActivity = accountTokenActivity( + let weeklyAccountTokenActivity = accountTokenActivity( account: input.account, samples: currentSamples ) + let accountTokenActivity = input.analyticsExploration.timeRange == .oneDay + ? rollingDayAccountTokenActivity( + account: input.account, + samples: input.samples, + now: input.now, + accountPartitionID: input.accountPartitionID + ) + : weeklyAccountTokenActivity let localTokenActivity: LocalTokenActivitySnapshot if let interval = tokenActivityInterval( account: input.account, - accountActivity: accountTokenActivity, + accountActivity: weeklyAccountTokenActivity, accountEpochStartedAt: input.accountEpochStartedAt ) { if let cached = reusableLocalAggregates?.localTokenActivity, @@ -1204,6 +1257,89 @@ enum UsageIntelligenceEngine { ) } + private static func rollingDayAccountTokenActivity( + account: UsageSnapshot?, + samples: [UsageSample], + now: Date, + accountPartitionID: String? + ) -> AccountTokenActivitySnapshot { + let range = DateInterval( + start: now.addingTimeInterval(-86_400), + end: now + ) + guard let limit = account?.mainLimit else { + return .unavailable( + "No account readings in this range", + range: range + ) + } + let readings = samples.compactMap { sample -> (Date, Int64)? in + guard sample.resetsAt == limit.window.resetsAt, + sample.observedAt <= now, + let tokens = sample.lifetimeTokens, + tokens >= 0 else { + return nil + } + return (sample.observedAt, tokens) + }.sorted { + $0.0 == $1.0 ? $0.1 < $1.1 : $0.0 < $1.0 + } + var unique: [(Date, Int64)] = [] + for reading in readings { + if let last = unique.last, + last.0 == reading.0, + last.1 == reading.1 { + continue + } + unique.append(reading) + } + let intervals = zip(unique, unique.dropFirst()).compactMap { + start, end -> AccountTokenActivityInterval? in + guard end.0 > start.0, + end.1 >= start.1, + start.0 >= range.start, + end.0 <= range.end else { + return nil + } + return AccountTokenActivityInterval( + start: start.0, + end: end.0, + tokenDelta: end.1 - start.1, + method: .lifetimeDelta, + accountPartitionID: accountPartitionID, + limitID: limit.limitId, + allowanceReset: limit.window.resetsAt + ) + } + guard let first = intervals.first, + let last = intervals.last else { + return .unavailable( + "No account readings in this range", + range: range + ) + } + var total: Int64 = 0 + for interval in intervals { + let sum = total.addingReportingOverflow(interval.tokenDelta) + guard !sum.overflow else { + return .unavailable( + "Lifetime token reading is invalid", + range: range + ) + } + total = sum.partialValue + } + return AccountTokenActivitySnapshot( + state: .partial, + tokens: total, + method: .lifetimeDelta, + interval: DateInterval(start: first.start, end: last.end), + reason: nil, + range: range, + intervals: intervals + ) + } + private static func dailyTokenActivity( account: UsageSnapshot, window: UsageWindow diff --git a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift index 4e37fae..81dc85b 100644 --- a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift +++ b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift @@ -277,11 +277,98 @@ final class AnalyticsWorkspaceTests: XCTestCase { XCTAssertNil(reader.localTokenActivity.tokens) } + func testRollingTokenActivityViewDistinguishesObservedZeroAndMissing() { + let now = Date(timeIntervalSince1970: 10 * 86_400) + let account = usageSnapshot(fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + var exploration = AnalyticsExplorationState.initial + exploration.graph = .tokenActivity + exploration.timeRange = .oneDay + let evaluate: ([UsageSample]) -> UsageReaderSnapshot = { samples in + UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: samples, + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + analyticsExploration: exploration + ) + ) + } + let first = UsageSample( + observedAt: now.addingTimeInterval(-6 * 3_600), + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + let positive = evaluate([ + first, + UsageSample( + observedAt: now, + remainingPercent: 75, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ]) + let zero = evaluate([ + first, + UsageSample( + observedAt: now, + remainingPercent: 75, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + ]) + let missing = evaluate([first]) + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! + ) + store.selectGraph(.tokenActivity) + store.selectTimeRange(.oneDay) + + XCTAssertEqual(positive.accountTokenActivity.tokens, 300) + XCTAssertTrue( + positive.accountTokenActivity.accessibilityValue.contains( + "Time without an account reading is empty" + ) + ) + XCTAssertFalse( + positive.accountTokenActivity.accessibilityValue.contains( + "Coverage" + ) + ) + XCTAssertEqual(zero.accountTokenActivity.tokens, 0) + XCTAssertTrue( + zero.accountTokenActivity.accessibilityValue.contains( + "observed zero-token interval" + ) + ) + XCTAssertEqual( + missing.accountTokenActivity.accessibilityValue, + "No account readings in this range" + ) + XCTAssertTrue( + renders( + AnalyticsWorkspaceBody( + reader: positive, + store: store, + assistedInsights: CodexAssistedInsightStore(), + now: now + ), + size: CGSize(width: 640, height: 780) + ) + ) + } + func testRollingPresetsEndAtInjectedNowDespiteStaleObservation() throws { let now = try date("2026-08-03T09:08:00Z") let latestObserved = now.addingTimeInterval(-6 * 3_600) let bounds = DateInterval( - start: now.addingTimeInterval(-90 * 86_400), + start: now.addingTimeInterval(-12 * 3_600), end: latestObserved ) diff --git a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift index 9020f29..53fe929 100644 --- a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift +++ b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift @@ -83,6 +83,256 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertNil(reader.accountTokenActivity.reason) } + func testRollingDayUsesContainedLifetimeIntervals() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let readings = [ + UsageSample( + observedAt: now.addingTimeInterval(-6 * 3_600), + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: now.addingTimeInterval(-4 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_500 + ) + ] + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .oneDay + + let activity = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: readings, + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + accountPartitionID: "account-a", + analyticsExploration: exploration + ) + ).accountTokenActivity + + XCTAssertEqual( + activity.range, + DateInterval( + start: now.addingTimeInterval(-86_400), + end: now + ) + ) + XCTAssertEqual(activity.tokens, 500) + XCTAssertEqual(activity.method, .lifetimeDelta) + XCTAssertEqual(activity.intervals.map(\.tokenDelta), [200, 300]) + XCTAssertEqual( + activity.interval, + DateInterval( + start: readings[0].observedAt, + end: readings[2].observedAt + ) + ) + XCTAssertNil(activity.reason) + } + + func testRollingDayStaysAtNowWhenLatestReadingIsStale() { + let now = Date(timeIntervalSince1970: 2_000_000) + let observedAt = now.addingTimeInterval(-6 * 3_600) + let account = makeSnapshot(remaining: 80, fetchedAt: observedAt) + let reset = account.mainLimit!.window.resetsAt + let readings = [ + UsageSample( + observedAt: observedAt.addingTimeInterval(-4 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: observedAt, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ] + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .oneDay + + let activity = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: readings, + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + analyticsExploration: exploration + ) + ).accountTokenActivity + + XCTAssertEqual(activity.range?.end, now) + XCTAssertEqual(activity.range?.duration, 86_400) + XCTAssertEqual(activity.tokens, 300) + XCTAssertEqual(activity.intervals.last?.end, observedAt) + } + + func testRollingDayExcludesCrossingIntervalsAndIncludesExactBoundaries() { + let now = Date(timeIntervalSince1970: 2_000_000) + let start = now.addingTimeInterval(-86_400) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let readings = [ + UsageSample( + observedAt: start.addingTimeInterval(-60), + remainingPercent: 95, + resetsAt: reset, + lifetimeTokens: 900 + ), + UsageSample( + observedAt: start, + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: start.addingTimeInterval(3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_500 + ) + ] + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .oneDay + + let activity = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: readings, + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + analyticsExploration: exploration + ) + ).accountTokenActivity + + XCTAssertEqual(activity.tokens, 500) + XCTAssertEqual(activity.intervals.count, 2) + XCTAssertEqual(activity.intervals.first?.start, start) + XCTAssertEqual(activity.intervals.last?.end, now) + } + + func testRollingDayKeepsLongAndZeroIntervalsFactual() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let readings = [ + UsageSample( + observedAt: now.addingTimeInterval(-23 * 3_600), + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + ] + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .oneDay + + let activity = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: readings, + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + analyticsExploration: exploration + ) + ).accountTokenActivity + + XCTAssertEqual(activity.tokens, 0) + XCTAssertEqual(activity.intervals.count, 1) + XCTAssertEqual(activity.intervals.first?.tokenDelta, 0) + XCTAssertEqual(activity.intervals.first?.duration, 22 * 3_600) + } + + func testRollingDayNamesMissingCompleteIntervals() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let onlyReading = UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .oneDay + + let beforeRange = [ + UsageSample( + observedAt: now.addingTimeInterval(-30 * 3_600), + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 800 + ), + UsageSample( + observedAt: now.addingTimeInterval(-25 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 900 + ) + ] + let crossingEnd = [ + onlyReading, + UsageSample( + observedAt: now.addingTimeInterval(60), + remainingPercent: 75, + resetsAt: reset, + lifetimeTokens: 1_100 + ) + ] + + for samples in [[onlyReading], beforeRange, crossingEnd] { + let activity = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: samples, + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + analyticsExploration: exploration + ) + ).accountTokenActivity + + XCTAssertNil(activity.tokens) + XCTAssertTrue(activity.intervals.isEmpty) + XCTAssertEqual( + activity.reason, + "No account readings in this range" + ) + } + } + func testReaderPublishesBoundedCurrentUsagePerTokenFacts() throws { let now = Date(timeIntervalSince1970: 2_000_000) let account = makeSnapshot( From 3c4450bd67bf3be59b14aa6341dfa6ed14982f8d Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:25:58 +0200 Subject: [PATCH 06/17] fix: merge account token timelines safely (#51) --- .../CodexLimits/UsageIntelligenceEngine.swift | 52 ++++-- .../CodexLimitsTests/UsageHistoryTests.swift | 10 +- .../UsageIntelligenceEngineTests.swift | 176 ++++++++++++++++++ 3 files changed, 217 insertions(+), 21 deletions(-) diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index 3f773ac..4e7d1d1 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -1273,38 +1273,52 @@ enum UsageIntelligenceEngine { range: range ) } - let readings = samples.compactMap { sample -> (Date, Int64)? in - guard sample.resetsAt == limit.window.resetsAt, - sample.observedAt <= now, - let tokens = sample.lifetimeTokens, - tokens >= 0 else { - return nil - } - return (sample.observedAt, tokens) + let readings = samples.filter { + $0.observedAt <= now + && ($0.lifetimeTokens ?? -1) >= 0 }.sorted { - $0.0 == $1.0 ? $0.1 < $1.1 : $0.0 < $1.0 + if $0.observedAt != $1.observedAt { + return $0.observedAt < $1.observedAt + } + if $0.resetsAt != $1.resetsAt { + return $0.resetsAt < $1.resetsAt + } + if $0.lifetimeTokens != $1.lifetimeTokens { + return ($0.lifetimeTokens ?? -1) < ($1.lifetimeTokens ?? -1) + } + if $0.remainingPercent != $1.remainingPercent { + return $0.remainingPercent < $1.remainingPercent + } + return !$0.comparisonBreak && $1.comparisonBreak } - var unique: [(Date, Int64)] = [] + var unique: [UsageSample] = [] for reading in readings { if let last = unique.last, - last.0 == reading.0, - last.1 == reading.1 { + last.observedAt == reading.observedAt, + last.resetsAt == reading.resetsAt, + last.lifetimeTokens == reading.lifetimeTokens, + last.remainingPercent == reading.remainingPercent, + last.comparisonBreak == reading.comparisonBreak { continue } unique.append(reading) } let intervals = zip(unique, unique.dropFirst()).compactMap { start, end -> AccountTokenActivityInterval? in - guard end.0 > start.0, - end.1 >= start.1, - start.0 >= range.start, - end.0 <= range.end else { + guard start.resetsAt == limit.window.resetsAt, + end.resetsAt == start.resetsAt, + let startTokens = start.lifetimeTokens, + let endTokens = end.lifetimeTokens, + end.observedAt > start.observedAt, + endTokens >= startTokens, + start.observedAt >= range.start, + end.observedAt <= range.end else { return nil } return AccountTokenActivityInterval( - start: start.0, - end: end.0, - tokenDelta: end.1 - start.1, + start: start.observedAt, + end: end.observedAt, + tokenDelta: endTokens - startTokens, method: .lifetimeDelta, accountPartitionID: accountPartitionID, limitID: limit.limitId, diff --git a/Tests/CodexLimitsTests/UsageHistoryTests.swift b/Tests/CodexLimitsTests/UsageHistoryTests.swift index 43bb09d..6ee953a 100644 --- a/Tests/CodexLimitsTests/UsageHistoryTests.swift +++ b/Tests/CodexLimitsTests/UsageHistoryTests.swift @@ -1556,12 +1556,14 @@ final class UsageHistoryTests: XCTestCase { let firstSample = UsageSample( observedAt: Date(timeIntervalSince1970: 1_900_000), remainingPercent: 82, - resetsAt: reset + resetsAt: reset, + lifetimeTokens: 1_000 ) let secondSample = UsageSample( observedAt: Date(timeIntervalSince1970: 1_900_060), remainingPercent: 81, - resetsAt: reset + resetsAt: reset, + lifetimeTokens: 1_200 ) _ = await firstWriter.load() @@ -1582,6 +1584,10 @@ final class UsageHistoryTests: XCTestCase { XCTAssertEqual(firstState.samples, [firstSample, secondSample]) XCTAssertEqual(secondState.samples, [firstSample, secondSample]) + XCTAssertEqual( + firstState.samples.compactMap(\.lifetimeTokens), + [1_000, 1_200] + ) XCTAssertNil(firstState.errorMessage) XCTAssertNil(secondState.errorMessage) } diff --git a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift index 53fe929..2ac83b2 100644 --- a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift +++ b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift @@ -333,6 +333,160 @@ final class UsageIntelligenceEngineTests: XCTestCase { } } + func testRollingDayMergesInterleavedReadingsWithoutDoubleCounting() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let start = now.addingTimeInterval(-6 * 3_600) + let first = UsageSample( + observedAt: start, + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + let middle = UsageSample( + observedAt: start.addingTimeInterval(2 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ) + let sameCounter = UsageSample( + observedAt: start.addingTimeInterval(4 * 3_600), + remainingPercent: 83, + resetsAt: reset, + lifetimeTokens: 1_200 + ) + let last = UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_600 + ) + let singleMac = rollingDayActivity( + account: account, + samples: [first, last], + now: now + ) + let merged = rollingDayActivity( + account: account, + samples: [first, middle, middle, sameCounter, last], + now: now + ) + let reversed = rollingDayActivity( + account: account, + samples: [first, middle, middle, sameCounter, last].reversed(), + now: now + ) + + XCTAssertEqual(singleMac.tokens, 600) + XCTAssertEqual(singleMac.intervals.map(\.tokenDelta), [600]) + XCTAssertEqual(merged.tokens, singleMac.tokens) + XCTAssertEqual(merged.intervals.map(\.tokenDelta), [200, 0, 400]) + XCTAssertEqual(merged, reversed) + } + + func testRollingDayOrdersEqualTimestampsDeterministically() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let readings = [ + UsageSample( + observedAt: now.addingTimeInterval(-3 * 3_600), + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: now.addingTimeInterval(-2 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_100 + ), + UsageSample( + observedAt: now.addingTimeInterval(-2 * 3_600), + remainingPercent: 84, + resetsAt: reset, + lifetimeTokens: 1_150 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ] + + XCTAssertEqual( + rollingDayActivity( + account: account, + samples: readings, + now: now + ), + rollingDayActivity( + account: account, + samples: readings.reversed(), + now: now + ) + ) + } + + func testRollingDayDoesNotJoinAcrossAllowanceResetsOrAccounts() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let start = now.addingTimeInterval(-4 * 3_600) + let readings = [ + UsageSample( + observedAt: start, + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: start.addingTimeInterval(3_600), + remainingPercent: 88, + resetsAt: reset.addingTimeInterval(7 * 86_400), + lifetimeTokens: 1_100 + ), + UsageSample( + observedAt: start.addingTimeInterval(2 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ] + let firstAccount = rollingDayActivity( + account: account, + samples: readings, + now: now, + accountPartitionID: "account-a" + ) + let secondAccount = rollingDayActivity( + account: account, + samples: readings, + now: now, + accountPartitionID: "account-b" + ) + + XCTAssertEqual(firstAccount.tokens, 100) + XCTAssertEqual(firstAccount.intervals.count, 1) + XCTAssertEqual(firstAccount.intervals.first?.start, readings[2].observedAt) + XCTAssertEqual( + firstAccount.intervals.map(\.accountPartitionID), + ["account-a"] + ) + XCTAssertEqual( + secondAccount.intervals.map(\.accountPartitionID), + ["account-b"] + ) + } + func testReaderPublishesBoundedCurrentUsagePerTokenFacts() throws { let now = Date(timeIntervalSince1970: 2_000_000) let account = makeSnapshot( @@ -2619,6 +2773,28 @@ final class UsageIntelligenceEngineTests: XCTestCase { } } + private func rollingDayActivity( + account: UsageSnapshot, + samples: S, + now: Date, + accountPartitionID: String? = nil + ) -> AccountTokenActivitySnapshot where S.Element == UsageSample { + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .oneDay + return UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: Array(samples), + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + accountPartitionID: accountPartitionID, + analyticsExploration: exploration + ) + ).accountTokenActivity + } + private func weeklySamples( start: Date, end: Date, From de212c62843fa5dd5a4e0163d0b74055928b58ab Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:29:27 +0200 Subject: [PATCH 07/17] fix: split incompatible account counters (#52) --- .../CodexLimits/UsageIntelligenceEngine.swift | 143 ++++++++++---- .../UsageIntelligenceEngineTests.swift | 175 +++++++++++++++++- 2 files changed, 279 insertions(+), 39 deletions(-) diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index 4e7d1d1..c688454 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -369,13 +369,29 @@ struct AccountTokenActivityInterval: Equatable, Hashable, Identifiable, Sendable let tokenDelta: Int64 let method: AccountTokenActivityMethod let accountPartitionID: String? - let limitID: String + let limitID: String? let allowanceReset: Date var id: Self { self } var duration: TimeInterval { end.timeIntervalSince(start) } } +enum AccountTokenActivityBreakReason: String, Equatable, Hashable, Sendable { + case counterDecrease + case accountChange + case allowanceWindowChange + case conflictingObservation + case correction + case invalidCounter +} + +struct AccountTokenActivityBreak: Equatable, Hashable, Identifiable, Sendable { + let timestamp: Date + let reason: AccountTokenActivityBreakReason + + var id: Self { self } +} + struct AccountTokenActivitySnapshot: Equatable, Sendable { let state: AccountTokenActivityState let tokens: Int64? @@ -384,6 +400,7 @@ struct AccountTokenActivitySnapshot: Equatable, Sendable { let reason: String? let range: DateInterval? let intervals: [AccountTokenActivityInterval] + let breaks: [AccountTokenActivityBreak] init( state: AccountTokenActivityState, @@ -392,7 +409,8 @@ struct AccountTokenActivitySnapshot: Equatable, Sendable { interval: DateInterval?, reason: String?, range: DateInterval? = nil, - intervals: [AccountTokenActivityInterval] = [] + intervals: [AccountTokenActivityInterval] = [], + breaks: [AccountTokenActivityBreak] = [] ) { self.state = state self.tokens = tokens @@ -401,12 +419,14 @@ struct AccountTokenActivitySnapshot: Equatable, Sendable { self.reason = reason self.range = range self.intervals = intervals + self.breaks = breaks } static func unavailable( _ reason: String, interval: DateInterval? = nil, - range: DateInterval? = nil + range: DateInterval? = nil, + breaks: [AccountTokenActivityBreak] = [] ) -> AccountTokenActivitySnapshot { AccountTokenActivitySnapshot( state: .unavailable, @@ -414,7 +434,8 @@ struct AccountTokenActivitySnapshot: Equatable, Sendable { method: nil, interval: interval, reason: reason, - range: range + range: range, + breaks: breaks ) } @@ -794,7 +815,8 @@ enum UsageIntelligenceEngine { account: input.account, samples: input.samples, now: input.now, - accountPartitionID: input.accountPartitionID + accountPartitionID: input.accountPartitionID, + accountEpochStartedAt: input.accountEpochStartedAt ) : weeklyAccountTokenActivity let localTokenActivity: LocalTokenActivitySnapshot @@ -1261,22 +1283,14 @@ enum UsageIntelligenceEngine { account: UsageSnapshot?, samples: [UsageSample], now: Date, - accountPartitionID: String? + accountPartitionID: String?, + accountEpochStartedAt: Date? ) -> AccountTokenActivitySnapshot { let range = DateInterval( start: now.addingTimeInterval(-86_400), end: now ) - guard let limit = account?.mainLimit else { - return .unavailable( - "No account readings in this range", - range: range - ) - } - let readings = samples.filter { - $0.observedAt <= now - && ($0.lifetimeTokens ?? -1) >= 0 - }.sorted { + let readings = samples.filter { $0.observedAt <= now }.sorted { if $0.observedAt != $1.observedAt { return $0.observedAt < $1.observedAt } @@ -1303,33 +1317,95 @@ enum UsageIntelligenceEngine { } unique.append(reading) } - let intervals = zip(unique, unique.dropFirst()).compactMap { - start, end -> AccountTokenActivityInterval? in - guard start.resetsAt == limit.window.resetsAt, - end.resetsAt == start.resetsAt, + let conflictingTimestamps = Set( + Dictionary(grouping: unique, by: \.observedAt) + .compactMap { $0.value.count > 1 ? $0.key : nil } + ) + var intervals: [AccountTokenActivityInterval] = [] + var breaks: [AccountTokenActivityBreak] = [] + var previous: UsageSample? + var sawEarlierEpoch = false + + func recordBreak( + at timestamp: Date, + reason: AccountTokenActivityBreakReason + ) { + guard range.contains(timestamp) else { return } + let value = AccountTokenActivityBreak( + timestamp: timestamp, + reason: reason + ) + if breaks.last != value { breaks.append(value) } + } + + for reading in unique { + if let accountEpochStartedAt, + reading.observedAt < accountEpochStartedAt { + sawEarlierEpoch = true + previous = nil + continue + } + if sawEarlierEpoch, let accountEpochStartedAt { + recordBreak(at: accountEpochStartedAt, reason: .accountChange) + sawEarlierEpoch = false + } + if conflictingTimestamps.contains(reading.observedAt) { + recordBreak( + at: reading.observedAt, + reason: .conflictingObservation + ) + previous = nil + continue + } + guard reading.isValid, reading.lifetimeTokens != nil else { + recordBreak(at: reading.observedAt, reason: .invalidCounter) + previous = nil + continue + } + if reading.comparisonBreak { + recordBreak(at: reading.observedAt, reason: .correction) + previous = reading + continue + } + guard let start = previous, let startTokens = start.lifetimeTokens, - let endTokens = end.lifetimeTokens, - end.observedAt > start.observedAt, - endTokens >= startTokens, - start.observedAt >= range.start, - end.observedAt <= range.end else { - return nil + let endTokens = reading.lifetimeTokens else { + previous = reading + continue + } + defer { previous = reading } + guard reading.resetsAt == start.resetsAt else { + recordBreak( + at: reading.observedAt, + reason: .allowanceWindowChange + ) + continue } - return AccountTokenActivityInterval( + guard endTokens >= startTokens else { + recordBreak( + at: reading.observedAt, + reason: .counterDecrease + ) + continue + } + guard start.observedAt >= range.start, + reading.observedAt <= range.end else { continue } + intervals.append(AccountTokenActivityInterval( start: start.observedAt, - end: end.observedAt, + end: reading.observedAt, tokenDelta: endTokens - startTokens, method: .lifetimeDelta, accountPartitionID: accountPartitionID, - limitID: limit.limitId, - allowanceReset: limit.window.resetsAt - ) + limitID: account?.mainLimit?.limitId, + allowanceReset: reading.resetsAt + )) } guard let first = intervals.first, let last = intervals.last else { return .unavailable( "No account readings in this range", - range: range + range: range, + breaks: breaks ) } var total: Int64 = 0 @@ -1350,7 +1426,8 @@ enum UsageIntelligenceEngine { interval: DateInterval(start: first.start, end: last.end), reason: nil, range: range, - intervals: intervals + intervals: intervals, + breaks: breaks ) } diff --git a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift index 2ac83b2..9f91fb3 100644 --- a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift +++ b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift @@ -416,18 +416,24 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) ] + let ordered = rollingDayActivity( + account: account, + samples: readings, + now: now + ) XCTAssertEqual( - rollingDayActivity( - account: account, - samples: readings, - now: now - ), + ordered, rollingDayActivity( account: account, samples: readings.reversed(), now: now ) ) + XCTAssertEqual( + ordered.breaks.map(\.reason), + [.conflictingObservation] + ) + XCTAssertTrue(ordered.intervals.isEmpty) } func testRollingDayDoesNotJoinAcrossAllowanceResetsOrAccounts() { @@ -477,6 +483,10 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertEqual(firstAccount.tokens, 100) XCTAssertEqual(firstAccount.intervals.count, 1) XCTAssertEqual(firstAccount.intervals.first?.start, readings[2].observedAt) + XCTAssertEqual( + firstAccount.breaks.map(\.reason), + [.allowanceWindowChange, .allowanceWindowChange] + ) XCTAssertEqual( firstAccount.intervals.map(\.accountPartitionID), ["account-a"] @@ -487,6 +497,157 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) } + func testRollingDayPreservesIntervalsOnBothSidesOfCounterDecrease() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let start = now.addingTimeInterval(-4 * 3_600) + let readings = [ + UsageSample( + observedAt: start, + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: start.addingTimeInterval(3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_500 + ), + UsageSample( + observedAt: start.addingTimeInterval(2 * 3_600), + remainingPercent: 83, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_400 + ) + ] + + let activity = rollingDayActivity( + account: account, + samples: readings, + now: now + ) + + XCTAssertEqual(activity.tokens, 700) + XCTAssertEqual(activity.intervals.map(\.tokenDelta), [500, 200]) + XCTAssertEqual( + activity.breaks, + [AccountTokenActivityBreak( + timestamp: readings[2].observedAt, + reason: .counterDecrease + )] + ) + XCTAssertTrue(activity.intervals.allSatisfy { $0.tokenDelta >= 0 }) + XCTAssertFalse(activity.intervals.contains { + $0.start < readings[2].observedAt + && $0.end >= readings[2].observedAt + }) + } + + func testRollingDayBreaksAtInvalidAndCorrectionReadings() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let start = now.addingTimeInterval(-5 * 3_600) + let readings = [ + UsageSample( + observedAt: start, + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: start.addingTimeInterval(3_600), + remainingPercent: 88, + resetsAt: reset, + lifetimeTokens: nil + ), + UsageSample( + observedAt: start.addingTimeInterval(2 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: start.addingTimeInterval(3 * 3_600), + remainingPercent: 83, + resetsAt: reset, + lifetimeTokens: 1_300, + comparisonBreak: true + ), + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 81, + resetsAt: reset, + lifetimeTokens: 1_400 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_500 + ) + ] + + let activity = rollingDayActivity( + account: account, + samples: readings, + now: now + ) + + XCTAssertEqual(activity.tokens, 200) + XCTAssertEqual(activity.intervals.map(\.tokenDelta), [100, 100]) + XCTAssertEqual( + activity.breaks.map(\.reason), + [.invalidCounter, .correction] + ) + } + + func testRollingDayBreaksAtAccountObservationEpoch() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let epoch = now.addingTimeInterval(-2 * 3_600) + let readings = [ + UsageSample( + observedAt: epoch.addingTimeInterval(-3_600), + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: epoch.addingTimeInterval(3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ] + + let activity = rollingDayActivity( + account: account, + samples: readings, + now: now, + accountEpochStartedAt: epoch + ) + + XCTAssertEqual(activity.tokens, 100) + XCTAssertEqual(activity.breaks.map(\.reason), [.accountChange]) + XCTAssertEqual(activity.breaks.first?.timestamp, epoch) + } + func testReaderPublishesBoundedCurrentUsagePerTokenFacts() throws { let now = Date(timeIntervalSince1970: 2_000_000) let account = makeSnapshot( @@ -2777,7 +2938,8 @@ final class UsageIntelligenceEngineTests: XCTestCase { account: UsageSnapshot, samples: S, now: Date, - accountPartitionID: String? = nil + accountPartitionID: String? = nil, + accountEpochStartedAt: Date? = nil ) -> AccountTokenActivitySnapshot where S.Element == UsageSample { var exploration = AnalyticsExplorationState.initial exploration.timeRange = .oneDay @@ -2790,6 +2952,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { now: now, previousStatus: nil, accountPartitionID: accountPartitionID, + accountEpochStartedAt: accountEpochStartedAt, analyticsExploration: exploration ) ).accountTokenActivity From ab29be6f57cc8bad686f71a114472251dd2ea74c Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:33:11 +0200 Subject: [PATCH 08/17] feat: add UTC token bucket fallback (#53) --- Sources/CodexLimits/MenuContentView.swift | 37 +++---- .../CodexLimits/UsageIntelligenceEngine.swift | 61 +++++++++++- .../AnalyticsWorkspaceTests.swift | 43 ++++++++ .../UsageIntelligenceEngineTests.swift | 99 +++++++++++++++++++ 4 files changed, 218 insertions(+), 22 deletions(-) diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index 4bbed99..01cca86 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -1540,6 +1540,19 @@ private struct ConcurrencyWorkspace: View { } } +func accountTokenIntervalText( + _ interval: DateInterval, + timeZone: TimeZone = .autoupdatingCurrent, + locale: Locale = .autoupdatingCurrent +) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + formatter.timeZone = timeZone + formatter.locale = locale + return "\(formatter.string(from: interval.start))–\(formatter.string(from: interval.end))" +} + private struct TokenActivityWorkspace: View { let reader: UsageReaderSnapshot @ObservedObject var store: AnalyticsWorkspaceStore @@ -1677,7 +1690,7 @@ private struct TokenActivityWorkspace: View { } private var chartIntervalLabel: some View { - Text(intervalText(visibleRange)) + Text(accountTokenIntervalText(visibleRange)) .font(.caption) .foregroundStyle(.tertiary) } @@ -1702,14 +1715,7 @@ private struct TokenActivityWorkspace: View { } private var accountSource: String { - switch reader.accountTokenActivity.method { - case .lifetimeDelta: - "Codex account summary" - case .dailyBuckets: - "Codex daily token totals" - case nil: - "Codex account" - } + reader.accountTokenActivity.sourceDescription } private func summaryTokens( @@ -1730,7 +1736,7 @@ private struct TokenActivityWorkspace: View { return reader.accountTokenActivity.reason ?? "No account readings in this range" } - return "Observed from \(intervalText(observed))" + return "Observed from \(accountTokenIntervalText(observed))" } guard store.state.timeRange == .currentWindow else { if range.completeDayCount == 0 { @@ -1972,17 +1978,6 @@ private struct TokenActivityWorkspace: View { ) } - private func intervalText(_ interval: DateInterval) -> String { - let start = interval.start.formatted( - date: .abbreviated, - time: .shortened - ) - let end = interval.end.formatted( - date: .abbreviated, - time: .shortened - ) - return "\(start)–\(end)" - } } private struct TokenSourceCard: View { diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index c688454..c25dded 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -370,7 +370,7 @@ struct AccountTokenActivityInterval: Equatable, Hashable, Identifiable, Sendable let method: AccountTokenActivityMethod let accountPartitionID: String? let limitID: String? - let allowanceReset: Date + let allowanceReset: Date? var id: Self { self } var duration: TimeInterval { end.timeIntervalSince(start) } @@ -448,6 +448,14 @@ struct AccountTokenActivitySnapshot: Equatable, Sendable { : "" return "\(tokens) account tokens observed.\(zero) Time without an account reading is empty." } + + var sourceDescription: String { + switch method { + case .lifetimeDelta: "Codex account summary" + case .dailyBuckets: "Codex UTC daily token totals" + case nil: "Codex account" + } + } } struct UsageIntelligenceInput: Equatable, Sendable { @@ -1402,6 +1410,14 @@ enum UsageIntelligenceEngine { } guard let first = intervals.first, let last = intervals.last else { + if let account, + let fallback = dailyTokenActivity( + account: account, + range: range, + accountPartitionID: accountPartitionID + ) { + return fallback + } return .unavailable( "No account readings in this range", range: range, @@ -1431,6 +1447,49 @@ enum UsageIntelligenceEngine { ) } + private static func dailyTokenActivity( + account: UsageSnapshot, + range: DateInterval, + accountPartitionID: String? + ) -> AccountTokenActivitySnapshot? { + let intervals = account.tokenHistory.compactMap { + day -> AccountTokenActivityInterval? in + let end = day.date.addingTimeInterval(86_400) + guard day.completeness == .complete, + day.tokens >= 0, + day.date >= range.start, + end <= range.end else { return nil } + return AccountTokenActivityInterval( + start: day.date, + end: end, + tokenDelta: day.tokens, + method: .dailyBuckets, + accountPartitionID: accountPartitionID, + limitID: account.mainLimit?.limitId, + allowanceReset: nil + ) + }.sorted { $0.start < $1.start } + guard let first = intervals.first, + let last = intervals.last else { return nil } + var total: Int64 = 0 + for interval in intervals { + let sum = total.addingReportingOverflow(interval.tokenDelta) + guard !sum.overflow else { return nil } + total = sum.partialValue + } + return AccountTokenActivitySnapshot( + state: intervals.count == 1 + && first.start == range.start + && first.end == range.end ? .exact : .partial, + tokens: total, + method: .dailyBuckets, + interval: DateInterval(start: first.start, end: last.end), + reason: nil, + range: range, + intervals: intervals + ) + } + private static func dailyTokenActivity( account: UsageSnapshot, window: UsageWindow diff --git a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift index 81dc85b..244f6ed 100644 --- a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift +++ b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift @@ -364,6 +364,49 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) } + func testUTCBucketIntervalUsesTheRequestedMacTimeZoneForDisplay() throws { + let formatter = ISO8601DateFormatter() + let locale = Locale(identifier: "en_US_POSIX") + let utc = try XCTUnwrap(TimeZone(identifier: "UTC")) + let berlin = try XCTUnwrap(TimeZone(identifier: "Europe/Berlin")) + let winter = DateInterval( + start: try XCTUnwrap(formatter.date(from: "2026-01-01T00:00:00Z")), + end: try XCTUnwrap(formatter.date(from: "2026-01-02T00:00:00Z")) + ) + let summer = DateInterval( + start: try XCTUnwrap(formatter.date(from: "2026-07-01T00:00:00Z")), + end: try XCTUnwrap(formatter.date(from: "2026-07-02T00:00:00Z")) + ) + + let utcText = accountTokenIntervalText( + winter, + timeZone: utc, + locale: locale + ) + let cetText = accountTokenIntervalText( + winter, + timeZone: berlin, + locale: locale + ) + let summerUTCText = accountTokenIntervalText( + summer, + timeZone: utc, + locale: locale + ) + let cestText = accountTokenIntervalText( + summer, + timeZone: berlin, + locale: locale + ) + + XCTAssertNotEqual(utcText, cetText) + XCTAssertNotEqual(summerUTCText, cestText) + XCTAssertEqual(berlin.secondsFromGMT(for: winter.start), 3_600) + XCTAssertEqual(berlin.secondsFromGMT(for: summer.start), 7_200) + XCTAssertEqual(winter.duration, 86_400) + XCTAssertEqual(summer.duration, 86_400) + } + func testRollingPresetsEndAtInjectedNowDespiteStaleObservation() throws { let now = try date("2026-08-03T09:08:00Z") let latestObserved = now.addingTimeInterval(-6 * 3_600) diff --git a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift index 9f91fb3..5763d35 100644 --- a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift +++ b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift @@ -648,6 +648,105 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertEqual(activity.breaks.first?.timestamp, epoch) } + func testRollingDayLifetimeIntervalsTakePrecedenceOverDailyBuckets() throws { + let now = try date("2026-08-03T00:00:00Z") + let bucketStart = try date("2026-08-02T00:00:00Z") + let account = makeSnapshot( + remaining: 80, + fetchedAt: now, + tokenHistory: [TokenDay( + date: bucketStart, + tokens: 900, + completeness: .complete + )] + ) + let reset = account.mainLimit!.window.resetsAt + let readings = [ + UsageSample( + observedAt: bucketStart.addingTimeInterval(6 * 3_600), + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: bucketStart.addingTimeInterval(12 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ] + + let activity = rollingDayActivity( + account: account, + samples: readings, + now: now + ) + + XCTAssertEqual(activity.method, .lifetimeDelta) + XCTAssertEqual(activity.tokens, 300) + XCTAssertEqual(activity.intervals.count, 1) + XCTAssertEqual(activity.intervals.first?.method, .lifetimeDelta) + } + + func testRollingDayFallsBackToACompleteUTCDailyBucket() throws { + let now = try date("2026-08-03T00:00:00Z") + let bucketStart = try date("2026-08-02T00:00:00Z") + let account = makeSnapshot( + remaining: 80, + fetchedAt: now, + tokenHistory: [TokenDay( + date: bucketStart, + tokens: 900, + completeness: .complete + )] + ) + + let activity = rollingDayActivity( + account: account, + samples: [], + now: now + ) + + XCTAssertEqual(activity.method, .dailyBuckets) + XCTAssertEqual(activity.tokens, 900) + XCTAssertEqual(activity.sourceDescription, "Codex UTC daily token totals") + XCTAssertEqual( + activity.interval, + DateInterval(start: bucketStart, end: now) + ) + XCTAssertEqual(activity.intervals.map(\.method), [.dailyBuckets]) + } + + func testRollingDayExcludesPartialUTCDailyBuckets() throws { + let now = try date("2026-08-03T12:00:00Z") + let account = makeSnapshot( + remaining: 80, + fetchedAt: now, + tokenHistory: [ + TokenDay( + date: try date("2026-08-02T00:00:00Z"), + tokens: 800, + completeness: .complete + ), + TokenDay( + date: try date("2026-08-03T00:00:00Z"), + tokens: 900, + completeness: .complete + ) + ] + ) + + let activity = rollingDayActivity( + account: account, + samples: [], + now: now + ) + + XCTAssertNil(activity.tokens) + XCTAssertTrue(activity.intervals.isEmpty) + XCTAssertEqual(activity.reason, "No account readings in this range") + } + func testReaderPublishesBoundedCurrentUsagePerTokenFacts() throws { let now = Date(timeIntervalSince1970: 2_000_000) let account = makeSnapshot( From 76aa01b927eb25c8a34024d954017d18b14b66ab Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:39:07 +0200 Subject: [PATCH 09/17] feat: show current-window token activity so far (#54) --- Sources/CodexLimits/MenuContentView.swift | 27 ++-- .../CodexLimits/UsageIntelligenceEngine.swift | 67 +++++++-- .../AnalyticsWorkspaceTests.swift | 72 ++++++++++ .../UsageIntelligenceEngineTests.swift | 133 ++++++++++++++++-- 4 files changed, 263 insertions(+), 36 deletions(-) diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index 01cca86..0471e99 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -1606,6 +1606,11 @@ private struct TokenActivityWorkspace: View { ) } + private var usesAccountIntervals: Bool { + store.state.timeRange == .oneDay + || store.state.timeRange == .currentWindow + } + var body: some View { content(accountRange) } @@ -1635,7 +1640,7 @@ private struct TokenActivityWorkspace: View { } } - if store.state.timeRange == .oneDay { + if usesAccountIntervals { if reader.accountTokenActivity.intervals.isEmpty { WorkspaceMessage( icon: "chart.xyaxis.line", @@ -1666,7 +1671,7 @@ private struct TokenActivityWorkspace: View { accountChart(range) } - if store.state.timeRange != .oneDay { + if !usesAccountIntervals { selectedPointDetail(range) } } @@ -1682,7 +1687,7 @@ private struct TokenActivityWorkspace: View { private var chartSourceLabel: some View { ChartLegendItem( - label: store.state.timeRange == .oneDay + label: usesAccountIntervals ? "Observed intervals · Account" : "Daily totals · Account", color: .blue @@ -1746,17 +1751,11 @@ private struct TokenActivityWorkspace: View { ? "Codex returned only part of this range" : "Sum of \(range.completeDayCount) complete days" } - switch reader.accountTokenActivity.method { - case .lifetimeDelta: - return "Change between two account readings" - case .dailyBuckets: - return reader.accountTokenActivity.state == .partial - ? "Sum of complete days" - : "Complete daily totals" - case nil: + guard let observed = reader.accountTokenActivity.interval else { return reader.accountTokenActivity.reason ?? "Account token activity is unavailable" } + return "Activity so far · Observed from \(accountTokenIntervalText(observed))" } private func renderedDays( @@ -1875,7 +1874,11 @@ private struct TokenActivityWorkspace: View { .frame(height: 180) .accessibilityElement(children: .ignore) .accessibilityLabel("Account token activity") - .accessibilityValue(reader.accountTokenActivity.accessibilityValue) + .accessibilityValue( + store.state.timeRange == .currentWindow + ? reader.accountTokenActivity.currentWindowAccessibilityValue + : reader.accountTokenActivity.accessibilityValue + ) } private func midpoint( diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index c25dded..a03024a 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -449,6 +449,13 @@ struct AccountTokenActivitySnapshot: Equatable, Sendable { return "\(tokens) account tokens observed.\(zero) Time without an account reading is empty." } + var currentWindowAccessibilityValue: String { + guard let tokens else { + return reason ?? "No account readings in this range" + } + return "\(tokens) account tokens so far. Activity after the latest account reading is empty." + } + var sourceDescription: String { switch method { case .lifetimeDelta: "Codex account summary" @@ -818,15 +825,43 @@ enum UsageIntelligenceEngine { account: input.account, samples: currentSamples ) - let accountTokenActivity = input.analyticsExploration.timeRange == .oneDay - ? rollingDayAccountTokenActivity( + let accountTokenActivity: AccountTokenActivitySnapshot + switch input.analyticsExploration.timeRange { + case .oneDay: + accountTokenActivity = selectedRangeAccountTokenActivity( account: input.account, samples: input.samples, + range: DateInterval( + start: input.now.addingTimeInterval(-86_400), + end: input.now + ), now: input.now, accountPartitionID: input.accountPartitionID, accountEpochStartedAt: input.accountEpochStartedAt ) - : weeklyAccountTokenActivity + case .currentWindow: + if let window = input.account?.mainLimit?.window, + window.startsAt <= input.now, + input.now < window.resetsAt { + accountTokenActivity = selectedRangeAccountTokenActivity( + account: input.account, + samples: input.samples, + range: DateInterval( + start: window.startsAt, + end: window.resetsAt + ), + now: input.now, + accountPartitionID: input.accountPartitionID, + accountEpochStartedAt: input.accountEpochStartedAt + ) + } else { + accountTokenActivity = .unavailable( + "Current allowance window unavailable" + ) + } + default: + accountTokenActivity = weeklyAccountTokenActivity + } let localTokenActivity: LocalTokenActivitySnapshot if let interval = tokenActivityInterval( account: input.account, @@ -1287,18 +1322,27 @@ enum UsageIntelligenceEngine { ) } - private static func rollingDayAccountTokenActivity( + private static func selectedRangeAccountTokenActivity( account: UsageSnapshot?, samples: [UsageSample], + range: DateInterval, now: Date, accountPartitionID: String?, accountEpochStartedAt: Date? ) -> AccountTokenActivitySnapshot { - let range = DateInterval( - start: now.addingTimeInterval(-86_400), - end: now - ) - let readings = samples.filter { $0.observedAt <= now }.sorted { + var timeline = samples + if let account, + let window = account.mainLimit?.window, + let lifetimeTokens = account.accountFacts?.lifetimeTokens { + timeline.append(UsageSample( + observedAt: account.accountFacts?.lifetimeTokensObservedAt + ?? account.fetchedAt, + remainingPercent: window.remainingPercent, + resetsAt: window.resetsAt, + lifetimeTokens: lifetimeTokens + )) + } + let readings = timeline.filter { $0.observedAt <= now }.sorted { if $0.observedAt != $1.observedAt { return $0.observedAt < $1.observedAt } @@ -1414,6 +1458,7 @@ enum UsageIntelligenceEngine { let fallback = dailyTokenActivity( account: account, range: range, + now: now, accountPartitionID: accountPartitionID ) { return fallback @@ -1450,6 +1495,7 @@ enum UsageIntelligenceEngine { private static func dailyTokenActivity( account: UsageSnapshot, range: DateInterval, + now: Date, accountPartitionID: String? ) -> AccountTokenActivitySnapshot? { let intervals = account.tokenHistory.compactMap { @@ -1458,7 +1504,8 @@ enum UsageIntelligenceEngine { guard day.completeness == .complete, day.tokens >= 0, day.date >= range.start, - end <= range.end else { return nil } + end <= range.end, + end <= now else { return nil } return AccountTokenActivityInterval( start: day.date, end: end, diff --git a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift index 244f6ed..baa0b1e 100644 --- a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift +++ b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift @@ -407,6 +407,78 @@ final class AnalyticsWorkspaceTests: XCTestCase { XCTAssertEqual(summer.duration, 86_400) } + func testCurrentWindowTokenActivityExposesSoFarCopyAndRenders() throws { + let start = try date("2026-08-01T12:13:00Z") + let now = try date("2026-08-03T12:13:00Z") + let reset = try date("2026-08-08T12:13:00Z") + let account = UsageSnapshot( + mainLimit: LimitReading( + limitId: "weekly", + name: "Weekly", + window: UsageWindow( + remainingPercent: 70, + resetsAt: reset, + durationMinutes: 10_080 + ) + ), + otherLimits: [], + tokenHistory: [], + emergencyResetCount: 0, + fetchedAt: now + ) + let reader = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [ + UsageSample( + observedAt: start, + remainingPercent: 100, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: now, + remainingPercent: 70, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ], + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil + ) + ) + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! + ) + store.selectGraph(.tokenActivity) + store.selectTimeRange(.currentWindow) + + XCTAssertEqual(reader.accountTokenActivity.tokens, 300) + XCTAssertTrue( + reader.accountTokenActivity.currentWindowAccessibilityValue + .contains("so far") + ) + XCTAssertTrue( + reader.accountTokenActivity.currentWindowAccessibilityValue + .contains("after the latest account reading is empty") + ) + XCTAssertTrue( + renders( + AnalyticsWorkspaceBody( + reader: reader, + store: store, + assistedInsights: CodexAssistedInsightStore(), + now: now + ), + size: CGSize(width: 640, height: 780) + ) + ) + } + func testRollingPresetsEndAtInjectedNowDespiteStaleObservation() throws { let now = try date("2026-08-03T09:08:00Z") let latestObserved = now.addingTimeInterval(-6 * 3_600) diff --git a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift index 5763d35..7991353 100644 --- a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift +++ b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift @@ -73,7 +73,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) ) - XCTAssertEqual(reader.accountTokenActivity.state, .exact) + XCTAssertEqual(reader.accountTokenActivity.state, .partial) XCTAssertEqual(reader.accountTokenActivity.tokens, 600) XCTAssertEqual(reader.accountTokenActivity.method, .lifetimeDelta) XCTAssertEqual( @@ -1232,7 +1232,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) ) - XCTAssertEqual(reader.accountTokenActivity.state, .exact) + XCTAssertEqual(reader.accountTokenActivity.state, .partial) XCTAssertEqual( reader.accountTokenActivity.interval, DateInterval(start: boundary.observedAt, end: observedAt) @@ -1312,10 +1312,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) ) ) - XCTAssertEqual( - reader.accountTokenActivity.reason, - "Only complete daily token totals are available" - ) + XCTAssertNil(reader.accountTokenActivity.reason) } func testAlignedCompleteDailyBucketsProduceExactAccountTokenActivity() throws { @@ -1353,7 +1350,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) ) - XCTAssertEqual(reader.accountTokenActivity.state, .exact) + XCTAssertEqual(reader.accountTokenActivity.state, .partial) XCTAssertEqual(reader.accountTokenActivity.tokens, 500) XCTAssertEqual(reader.accountTokenActivity.method, .dailyBuckets) XCTAssertEqual( @@ -1402,8 +1399,8 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertEqual(reader.accountTokenActivity.state, .unavailable) XCTAssertNil(reader.accountTokenActivity.tokens) XCTAssertEqual( - reader.accountTokenActivity.reason, - "Lifetime token counter decreased" + reader.accountTokenActivity.breaks.map(\.reason), + [.counterDecrease] ) } @@ -1443,8 +1440,8 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertEqual(reader.accountTokenActivity.state, .unavailable) XCTAssertNil(reader.accountTokenActivity.tokens) XCTAssertEqual( - reader.accountTokenActivity.reason, - "Lifetime token reading is invalid" + reader.accountTokenActivity.breaks.map(\.reason), + [.invalidCounter] ) } @@ -2236,12 +2233,14 @@ final class UsageIntelligenceEngineTests: XCTestCase { UsageSample( observedAt: start, remainingPercent: 100, - resetsAt: reset + resetsAt: reset, + lifetimeTokens: 1_000 ), UsageSample( observedAt: latestObservation, remainingPercent: 70, - resetsAt: reset + resetsAt: reset, + lifetimeTokens: 1_300 ) ], safetyBuffer: 3, @@ -2256,7 +2255,108 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertEqual(reader.chart.target.map(\.date), [start, reset]) XCTAssertEqual(reader.chart.observed.map(\.date).max(), latestObservation) XCTAssertFalse(reader.chart.observed.contains { $0.date > latestObservation }) - XCTAssertEqual(reader.accountTokenActivity.interval?.end, latestObservation) + XCTAssertEqual( + reader.accountTokenActivity.range, + DateInterval(start: start, end: reset) + ) + XCTAssertEqual(reader.accountTokenActivity.tokens, 300) + XCTAssertEqual( + reader.accountTokenActivity.interval, + DateInterval(start: start, end: latestObservation) + ) + XCTAssertEqual(reader.accountTokenActivity.intervals.count, 1) + XCTAssertTrue( + reader.accountTokenActivity.currentWindowAccessibilityValue + .contains("300 account tokens so far") + ) + + let refreshedWithoutReading = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [ + UsageSample( + observedAt: start, + remainingPercent: 100, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: latestObservation, + remainingPercent: 70, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ], + safetyBuffer: 3, + sourceState: .available, + now: latestObservation.addingTimeInterval(3_600), + previousStatus: nil + ) + ) + XCTAssertEqual( + refreshedWithoutReading.accountTokenActivity.intervals, + reader.accountTokenActivity.intervals + ) + } + + func testCurrentWindowDistinguishesZeroFromNoInterval() throws { + let start = try date("2026-08-01T12:13:00Z") + let now = try date("2026-08-03T12:13:00Z") + let reset = try date("2026-08-08T12:13:00Z") + let account = UsageSnapshot( + mainLimit: LimitReading( + limitId: "codex", + name: "Codex", + window: UsageWindow( + remainingPercent: 70, + resetsAt: reset, + durationMinutes: 10_080 + ) + ), + otherLimits: [], + tokenHistory: [], + emergencyResetCount: 0, + fetchedAt: now + ) + let first = UsageSample( + observedAt: start, + remainingPercent: 100, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + let zero = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [ + first, + UsageSample( + observedAt: now, + remainingPercent: 70, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + ], + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil + ) + ).accountTokenActivity + let missing = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [first], + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil + ) + ).accountTokenActivity + + XCTAssertEqual(zero.tokens, 0) + XCTAssertEqual(zero.intervals.first?.tokenDelta, 0) + XCTAssertNil(missing.tokens) + XCTAssertEqual(missing.reason, "No account readings in this range") } func testExpiredCurrentWindowIsUnavailableButKeepsHistoricalSamples() throws { @@ -2302,6 +2402,11 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertTrue(reader.chart.allObserved.contains { $0.date == historicalObservation }) + XCTAssertEqual( + reader.accountTokenActivity.reason, + "Current allowance window unavailable" + ) + XCTAssertTrue(reader.accountTokenActivity.intervals.isEmpty) } func testQuietDenseHistoryLeavesRoomToUseMore() { From 13fdf367e4d24bc3201eda12cb7dd89e08ba1e14 Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:45:46 +0200 Subject: [PATCH 10/17] feat: apply token intervals to every range (#55) --- Sources/CodexLimits/AnalyticsWorkspace.swift | 51 --- Sources/CodexLimits/MenuContentView.swift | 314 ++---------------- .../CodexLimits/UsageIntelligenceEngine.swift | 54 +-- Sources/CodexLimits/UsageMonitor.swift | 1 - .../AnalyticsWorkspaceTests.swift | 39 --- .../UsageIntelligenceEngineTests.swift | 195 ++++++++++- .../UsageMonitorHistoryTests.swift | 45 +++ 7 files changed, 289 insertions(+), 410 deletions(-) diff --git a/Sources/CodexLimits/AnalyticsWorkspace.swift b/Sources/CodexLimits/AnalyticsWorkspace.swift index 4d978f0..6ca31d4 100644 --- a/Sources/CodexLimits/AnalyticsWorkspace.swift +++ b/Sources/CodexLimits/AnalyticsWorkspace.swift @@ -73,57 +73,6 @@ enum AnalyticsTimeRange: String, CaseIterable, Codable, Identifiable, Sendable { } } -struct AccountTokenActivityRange: Equatable, Sendable { - let days: [TokenDay] - let completeDayCount: Int - let completeTokens: Int64? - - init(days: [TokenDay], interval: DateInterval) { - let day: TimeInterval = 86_400 - self.days = days - .filter { - $0.date >= interval.start - && $0.date.addingTimeInterval(day) <= interval.end - } - .sorted { $0.date < $1.date } - - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(secondsFromGMT: 0) - ?? calendar.timeZone - let startOfDay = calendar.startOfDay(for: interval.start) - var expected = startOfDay < interval.start - ? startOfDay.addingTimeInterval(day) - : startOfDay - var expectedDates: [Date] = [] - while expected.addingTimeInterval(day) <= interval.end { - expectedDates.append(expected) - expected = expected.addingTimeInterval(day) - } - completeDayCount = expectedDates.count - - guard !expectedDates.isEmpty else { - completeTokens = nil - return - } - var total: Int64 = 0 - for date in expectedDates { - guard let bucket = self.days.first(where: { $0.date == date }), - bucket.completeness == .complete, - bucket.tokens >= 0 else { - completeTokens = nil - return - } - let result = total.addingReportingOverflow(bucket.tokens) - guard !result.overflow else { - completeTokens = nil - return - } - total = result.partialValue - } - completeTokens = total - } -} - struct WorkspaceFilters: Codable, Equatable, Sendable { var projectID: String? var taskTreeID: String? diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index 0471e99..c61dd3d 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -1558,13 +1558,6 @@ private struct TokenActivityWorkspace: View { @ObservedObject var store: AnalyticsWorkspaceStore let now: Date - @State private var selectedDay: TokenDay? - private let day: TimeInterval = 86_400 - - private var accountDays: [TokenDay] { - (reader.account?.tokenHistory ?? []).sorted { $0.date < $1.date } - } - private var currentWindowBounds: DateInterval? { reader.weeklyUsageRemaining.map { DateInterval( @@ -1574,48 +1567,22 @@ private struct TokenActivityWorkspace: View { } } - private var bounds: DateInterval { - let fallback = reader.fetchedAt ?? now - let first = accountDays.first?.date - ?? currentWindowBounds?.start - ?? fallback.addingTimeInterval(-day) - let last = accountDays.last?.date.addingTimeInterval(day) - ?? currentWindowBounds?.end - ?? fallback - return DateInterval( - start: min(first, currentWindowBounds?.start ?? first), - end: max(last, currentWindowBounds?.end ?? last, now) - ) - } - private var visibleRange: DateInterval { if store.state.timeRange == .currentWindow, let currentWindowBounds { return currentWindowBounds } - return store.effectiveRange( - within: bounds, + if store.state.timeRange == .selected, + let selected = store.state.visibleRange { + return selected + } + return store.state.timeRange.interval( + within: DateInterval(start: now, end: now), now: now ) } - private var accountRange: AccountTokenActivityRange { - AccountTokenActivityRange( - days: accountDays, - interval: visibleRange - ) - } - - private var usesAccountIntervals: Bool { - store.state.timeRange == .oneDay - || store.state.timeRange == .currentWindow - } - var body: some View { - content(accountRange) - } - - private func content(_ range: AccountTokenActivityRange) -> some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text("Token activity") @@ -1625,7 +1592,7 @@ private struct TokenActivityWorkspace: View { .foregroundStyle(.secondary) } - accountCard(range) + accountCard VStack(alignment: .leading, spacing: 10) { ViewThatFits(in: .horizontal) { @@ -1640,56 +1607,28 @@ private struct TokenActivityWorkspace: View { } } - if usesAccountIntervals { - if reader.accountTokenActivity.intervals.isEmpty { - WorkspaceMessage( - icon: "chart.xyaxis.line", - title: reader.accountTokenActivity.reason - ?? "No account readings in this range", - message: "Missing time remains empty." - ) { - EmptyView() - } - .frame(minHeight: 170) - } else { - accountIntervalChart( - reader.accountTokenActivity.intervals - ) - } - } else if range.days.isEmpty { + if reader.accountTokenActivity.intervals.isEmpty { WorkspaceMessage( icon: "chart.xyaxis.line", - title: "No complete daily totals", - message: store.state.timeRange == .currentWindow - ? "Choose 4 weeks to see account history." - : "Codex did not return a complete day for this range." + title: reader.accountTokenActivity.reason + ?? "No account readings in this range", + message: "Missing time remains empty." ) { EmptyView() } .frame(minHeight: 170) } else { - accountChart(range) - } - - if !usesAccountIntervals { - selectedPointDetail(range) + accountIntervalChart( + reader.accountTokenActivity.intervals + ) } } } - .onChange(of: visibleRange) { _, range in - if let selectedDay, - selectedDay.date >= range.end - || selectedDay.date.addingTimeInterval(day) <= range.start { - self.selectedDay = nil - } - } } private var chartSourceLabel: some View { ChartLegendItem( - label: usesAccountIntervals - ? "Observed intervals · Account" - : "Daily totals · Account", + label: "Observed intervals · Account", color: .blue ) } @@ -1700,138 +1639,28 @@ private struct TokenActivityWorkspace: View { .foregroundStyle(.tertiary) } - private func accountCard( - _ range: AccountTokenActivityRange - ) -> some View { + private var accountCard: some View { TokenSourceCard( title: "Account", - source: store.state.timeRange == .oneDay - ? accountSource - : store.state.timeRange == .currentWindow - ? accountSource - : "Codex daily token totals", - value: summaryTokens(in: range).map(compactTokenCount) + source: reader.accountTokenActivity.sourceDescription, + value: reader.accountTokenActivity.tokens.map(compactTokenCount) ?? "Not available", - detail: summaryDetail(in: range), + detail: summaryDetail, freshness: reader.fetchedAt, freshnessLabel: "Updated", color: .blue ) } - private var accountSource: String { - reader.accountTokenActivity.sourceDescription - } - - private func summaryTokens( - in range: AccountTokenActivityRange - ) -> Int64? { - if store.state.timeRange == .oneDay - || store.state.timeRange == .currentWindow { - return reader.accountTokenActivity.tokens - } - return range.completeTokens - } - - private func summaryDetail( - in range: AccountTokenActivityRange - ) -> String { - if store.state.timeRange == .oneDay { - guard let observed = reader.accountTokenActivity.interval else { - return reader.accountTokenActivity.reason - ?? "No account readings in this range" - } - return "Observed from \(accountTokenIntervalText(observed))" - } - guard store.state.timeRange == .currentWindow else { - if range.completeDayCount == 0 { - return "No full days in this range" - } - return range.completeTokens == nil - ? "Codex returned only part of this range" - : "Sum of \(range.completeDayCount) complete days" - } + private var summaryDetail: String { guard let observed = reader.accountTokenActivity.interval else { return reader.accountTokenActivity.reason ?? "Account token activity is unavailable" } - return "Activity so far · Observed from \(accountTokenIntervalText(observed))" - } - - private func renderedDays( - _ range: AccountTokenActivityRange - ) -> [TokenDay] { - downsampledForDisplay(range.days) - } - - private func accountChart( - _ range: AccountTokenActivityRange - ) -> some View { - Chart { - ForEach(renderedDays(range), id: \.date) { tokenDay in - BarMark( - x: .value("Day", tokenDay.date, unit: .day), - y: .value("Account tokens", tokenDay.tokens) - ) - .foregroundStyle(Color.blue) - } - - if let selectedDay { - RuleMark(x: .value("Selected time", selectedDay.date)) - .foregroundStyle(Color.primary.opacity(0.45)) - .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) - PointMark( - x: .value("Selected day", selectedDay.date), - y: .value("Account tokens", selectedDay.tokens) - ) - .foregroundStyle(Color.blue) - .symbolSize(52) - } - } - .chartXScale(domain: visibleRange.start ... visibleRange.end) - .chartYAxis { - AxisMarks(position: .leading) { value in - AxisGridLine() - .foregroundStyle(Color.secondary.opacity(0.16)) - AxisValueLabel { - if let tokens = value.as(Int64.self) { - Text(compactTokenCount(tokens)) - } - } - } - } - .chartLegend(.hidden) - .chartOverlay { proxy in - GeometryReader { geometry in - Rectangle() - .fill(.clear) - .contentShape(Rectangle()) - .onContinuousHover { phase in - switch phase { - case let .active(location): - selectNearestPoint( - at: location, - proxy: proxy, - geometry: geometry, - days: range.days - ) - case .ended: - selectedDay = nil - } - } - } - } - .frame(height: 180) - .accessibilityElement(children: .ignore) - .accessibilityLabel("Account token activity") - .accessibilityValue( - selectedDay.map { - "\(compactTokenCount($0.tokens)) account tokens, \($0.date.formatted(date: .abbreviated, time: .omitted))" - } ?? "Daily account token totals are shown." - ) - .accessibilityHint( - "Use Previous point and Next point for exact values." - ) + let qualifier = store.state.timeRange == .currentWindow + ? "Activity so far · " + : "" + return "\(qualifier)Observed from \(accountTokenIntervalText(observed))" } private func accountIntervalChart( @@ -1886,101 +1715,6 @@ private struct TokenActivityWorkspace: View { ) -> Date { interval.start.addingTimeInterval(interval.duration / 2) } - - @ViewBuilder - private func selectedPointDetail( - _ range: AccountTokenActivityRange - ) -> some View { - VStack(alignment: .leading, spacing: 7) { - HStack(spacing: 10) { - if let selectedDay { - ViewThatFits(in: .horizontal) { - HStack(spacing: 10) { - Text("Daily account total") - .fontWeight(.semibold) - Text(compactTokenCount(selectedDay.tokens)) - .monospacedDigit() - Text( - selectedDay.date.formatted( - date: .abbreviated, - time: .omitted - ) - ) - .foregroundStyle(.secondary) - } - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 8) { - Text("Daily account total") - .fontWeight(.semibold) - Text(compactTokenCount(selectedDay.tokens)) - .monospacedDigit() - } - Text( - selectedDay.date.formatted( - date: .abbreviated, - time: .omitted - ) - ) - .foregroundStyle(.secondary) - } - } - } else { - Text("Choose a point for exact details.") - .foregroundStyle(.secondary) - } - Spacer() - Button { - moveSelection(in: range.days, by: -1) - } label: { - Image(systemName: "chevron.left") - } - .accessibilityLabel("Previous point") - Button { - moveSelection(in: range.days, by: 1) - } label: { - Image(systemName: "chevron.right") - } - .accessibilityLabel("Next point") - } - } - .font(.caption) - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - .quaternary.opacity(0.7), - in: RoundedRectangle(cornerRadius: 8) - ) - } - - private func moveSelection( - in days: [TokenDay], - by offset: Int - ) { - selectedDay = steppedPoint( - in: days, - from: selectedDay, - by: offset - ) - } - - private func selectNearestPoint( - at location: CGPoint, - proxy: ChartProxy, - geometry: GeometryProxy, - days: [TokenDay] - ) { - guard let date = chartDate( - at: location, - proxy: proxy, - geometry: geometry - ) else { return } - selectedDay = nearestPoint( - in: days, - to: date, - date: \.date - ) - } - } private struct TokenSourceCard: View { diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index a03024a..3134629 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -825,42 +825,42 @@ enum UsageIntelligenceEngine { account: input.account, samples: currentSamples ) - let accountTokenActivity: AccountTokenActivitySnapshot + let selectedTokenRange: DateInterval? switch input.analyticsExploration.timeRange { - case .oneDay: - accountTokenActivity = selectedRangeAccountTokenActivity( - account: input.account, - samples: input.samples, - range: DateInterval( - start: input.now.addingTimeInterval(-86_400), - end: input.now - ), - now: input.now, - accountPartitionID: input.accountPartitionID, - accountEpochStartedAt: input.accountEpochStartedAt - ) case .currentWindow: if let window = input.account?.mainLimit?.window, window.startsAt <= input.now, input.now < window.resetsAt { - accountTokenActivity = selectedRangeAccountTokenActivity( - account: input.account, - samples: input.samples, - range: DateInterval( - start: window.startsAt, - end: window.resetsAt - ), - now: input.now, - accountPartitionID: input.accountPartitionID, - accountEpochStartedAt: input.accountEpochStartedAt + selectedTokenRange = DateInterval( + start: window.startsAt, + end: window.resetsAt ) } else { - accountTokenActivity = .unavailable( - "Current allowance window unavailable" - ) + selectedTokenRange = nil } + case .selected: + selectedTokenRange = input.analyticsExploration.visibleRange default: - accountTokenActivity = weeklyAccountTokenActivity + selectedTokenRange = input.analyticsExploration.timeRange.interval( + within: DateInterval(start: input.now, end: input.now), + now: input.now + ) + } + let accountTokenActivity = if let selectedTokenRange { + selectedRangeAccountTokenActivity( + account: input.account, + samples: input.samples, + range: selectedTokenRange, + now: input.now, + accountPartitionID: input.accountPartitionID, + accountEpochStartedAt: input.accountEpochStartedAt + ) + } else { + AccountTokenActivitySnapshot.unavailable( + input.analyticsExploration.timeRange == .currentWindow + ? "Current allowance window unavailable" + : "No account readings in this range" + ) } let localTokenActivity: LocalTokenActivitySnapshot if let interval = tokenActivityInterval( diff --git a/Sources/CodexLimits/UsageMonitor.swift b/Sources/CodexLimits/UsageMonitor.swift index f105074..ed5197e 100644 --- a/Sources/CodexLimits/UsageMonitor.swift +++ b/Sources/CodexLimits/UsageMonitor.swift @@ -925,7 +925,6 @@ final class UsageMonitor: ObservableObject { input, dispositions: dispositions ) - guard evaluationTask != nil else { return } let pending = beginRecalculation( analyticsExploration: exploration, insightDispositions: dispositions diff --git a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift index baa0b1e..bdc793e 100644 --- a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift +++ b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift @@ -123,45 +123,6 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) } - func testAccountTokenRangeSumsOnlyCompleteFullDays() throws { - let formatter = ISO8601DateFormatter() - let interval = DateInterval( - start: try XCTUnwrap( - formatter.date(from: "2026-07-01T12:00:00Z") - ), - end: try XCTUnwrap( - formatter.date(from: "2026-07-04T12:00:00Z") - ) - ) - let days = try [ - ("2026-07-01T00:00:00Z", 100, TokenDayCompleteness.complete), - ("2026-07-02T00:00:00Z", 200, .complete), - ("2026-07-03T00:00:00Z", 300, .complete), - ("2026-07-04T00:00:00Z", 400, .partial) - ].map { - TokenDay( - date: try XCTUnwrap(formatter.date(from: $0.0)), - tokens: Int64($0.1), - completeness: $0.2 - ) - } - - let range = AccountTokenActivityRange( - days: days, - interval: interval - ) - - XCTAssertEqual(range.days.map(\.tokens), [200, 300]) - XCTAssertEqual(range.completeDayCount, 2) - XCTAssertEqual(range.completeTokens, 500) - - let missingDay = AccountTokenActivityRange( - days: days.filter { $0.date != days[2].date }, - interval: interval - ) - XCTAssertNil(missingDay.completeTokens) - } - func testRestoredLocalGraphFallsBackToUsageRemaining() throws { let defaults = try XCTUnwrap( UserDefaults( diff --git a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift index 7991353..b31e655 100644 --- a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift +++ b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift @@ -747,6 +747,194 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertEqual(activity.reason, "No account readings in this range") } + func testEveryRollingTokenRangeUsesExactContainedIntervals() { + let now = Date(timeIntervalSince1970: 10_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let cases: [(AnalyticsTimeRange, TimeInterval)] = [ + (.threeDays, 3 * 86_400), + (.fourWeeks, 28 * 86_400), + (.twelveWeeks, 84 * 86_400) + ] + + for (timeRange, duration) in cases { + let rangeStart = now.addingTimeInterval(-duration) + let readings = [ + UsageSample( + observedAt: rangeStart.addingTimeInterval(-60), + remainingPercent: 95, + resetsAt: reset, + lifetimeTokens: 900 + ), + UsageSample( + observedAt: rangeStart, + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: rangeStart.addingTimeInterval(3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_600 + ) + ] + + let activity = rollingDayActivity( + account: account, + samples: readings, + now: now, + timeRange: timeRange + ) + + XCTAssertEqual( + activity.range, + DateInterval(start: rangeStart, end: now), + timeRange.rawValue + ) + XCTAssertEqual(activity.range?.duration, duration, timeRange.rawValue) + XCTAssertEqual(activity.tokens, 600, timeRange.rawValue) + XCTAssertEqual( + activity.interval, + DateInterval(start: rangeStart, end: now), + timeRange.rawValue + ) + XCTAssertEqual(activity.intervals.count, 2, timeRange.rawValue) + XCTAssertFalse(activity.intervals.contains { + $0.start < rangeStart + }, timeRange.rawValue) + XCTAssertFalse(activity.intervals.contains { + $0.end > now + }, timeRange.rawValue) + } + } + + func testSelectedTokenRangeKeepsExactPersistedBoundariesAcrossRefresh() { + let now = Date(timeIntervalSince1970: 10_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let selected = DateInterval( + start: now.addingTimeInterval(-10 * 86_400), + end: now.addingTimeInterval(-5 * 86_400) + ) + let readings = [ + UsageSample( + observedAt: selected.start.addingTimeInterval(-60), + remainingPercent: 95, + resetsAt: reset, + lifetimeTokens: 900 + ), + UsageSample( + observedAt: selected.start, + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: selected.start.addingTimeInterval(3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: selected.end, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_500 + ), + UsageSample( + observedAt: selected.end.addingTimeInterval(60), + remainingPercent: 79, + resetsAt: reset, + lifetimeTokens: 1_600 + ) + ] + + let activity = rollingDayActivity( + account: account, + samples: readings, + now: now, + timeRange: .selected, + visibleRange: selected + ) + let refreshed = rollingDayActivity( + account: account, + samples: readings, + now: now.addingTimeInterval(86_400), + timeRange: .selected, + visibleRange: selected + ) + + XCTAssertEqual(activity.range, selected) + XCTAssertEqual(activity.tokens, 500) + XCTAssertEqual(activity.interval, selected) + XCTAssertEqual(activity.intervals.count, 2) + XCTAssertEqual(refreshed.range, selected) + XCTAssertEqual(refreshed.intervals, activity.intervals) + XCTAssertEqual(refreshed.tokens, activity.tokens) + } + + func testTokenSourcePrecedenceIsEvaluatedInsideEachRange() throws { + let now = try date("2026-08-03T00:00:00Z") + let account = makeSnapshot( + remaining: 80, + fetchedAt: now, + tokenHistory: [ + TokenDay( + date: try date("2026-08-01T00:00:00Z"), + tokens: 100, + completeness: .complete + ), + TokenDay( + date: try date("2026-08-02T00:00:00Z"), + tokens: 200, + completeness: .complete + ) + ] + ) + let reset = account.mainLimit!.window.resetsAt + let lifetimeReadings = [ + UsageSample( + observedAt: try date("2026-07-23T00:00:00Z"), + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: try date("2026-07-24T00:00:00Z"), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ] + + let threeDays = rollingDayActivity( + account: account, + samples: lifetimeReadings, + now: now, + timeRange: .threeDays + ) + let twelveWeeks = rollingDayActivity( + account: account, + samples: lifetimeReadings, + now: now, + timeRange: .twelveWeeks + ) + + XCTAssertEqual(threeDays.method, .dailyBuckets) + XCTAssertEqual(threeDays.tokens, 300) + XCTAssertEqual(threeDays.intervals.count, 2) + XCTAssertEqual(twelveWeeks.method, .lifetimeDelta) + XCTAssertEqual(twelveWeeks.tokens, 300) + XCTAssertEqual(twelveWeeks.intervals.count, 1) + } + func testReaderPublishesBoundedCurrentUsagePerTokenFacts() throws { let now = Date(timeIntervalSince1970: 2_000_000) let account = makeSnapshot( @@ -3143,10 +3331,13 @@ final class UsageIntelligenceEngineTests: XCTestCase { samples: S, now: Date, accountPartitionID: String? = nil, - accountEpochStartedAt: Date? = nil + accountEpochStartedAt: Date? = nil, + timeRange: AnalyticsTimeRange = .oneDay, + visibleRange: DateInterval? = nil ) -> AccountTokenActivitySnapshot where S.Element == UsageSample { var exploration = AnalyticsExplorationState.initial - exploration.timeRange = .oneDay + exploration.timeRange = timeRange + exploration.visibleRange = visibleRange return UsageIntelligenceEngine.evaluate( UsageIntelligenceInput( account: account, diff --git a/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift b/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift index bb79e6f..dd6fcab 100644 --- a/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift +++ b/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift @@ -720,6 +720,51 @@ final class UsageMonitorHistoryTests: XCTestCase { XCTAssertEqual(monitor.readerSnapshot.menuBarText, "80%") } + func testPreferenceChangeRecalculatesTokenRangeWhenIdle() async throws { + let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let evaluationStarted = expectation( + description: "account evaluation started" + ) + let evaluator = BlockingUsageEvaluator(started: evaluationStarted) + let fetchedAt = Date(timeIntervalSince1970: 1_900_000) + let source = FetchSequence([ + makeFetchResult( + identity: "user@example.com", + fetchedAt: fetchedAt, + remaining: 80 + ) + ]) + let monitor = UsageMonitor( + defaults: defaults, + historyDirectory: temporaryDirectory(), + startsAutomatically: false, + fetchUsage: { try await source.next() }, + evaluateUsage: { evaluator.evaluate($0) } + ) + let refresh = Task { await monitor.refresh() } + await fulfillment(of: [evaluationStarted], timeout: 2) + evaluator.release() + await refresh.value + let initialCalls = evaluator.callCount + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .threeDays + + monitor.analyticsPreferencesDidChange( + exploration: exploration, + dispositions: [:] + ) + let deadline = ContinuousClock.now + .seconds(2) + while evaluator.callCount == initialCalls, + ContinuousClock.now < deadline { + await Task.yield() + } + + XCTAssertGreaterThan(evaluator.callCount, initialCalls) + XCTAssertEqual(evaluator.lastExploration, exploration) + } + func testFailedRefreshRetainsTheLastReaderSnapshot() async throws { let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) From 2fccef96396be7146561ece7fd8c47429851e055 Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:49:21 +0200 Subject: [PATCH 11/17] feat: inspect account token intervals (#56) --- Sources/CodexLimits/AnalyticsWorkspace.swift | 40 +++++ Sources/CodexLimits/MenuContentView.swift | 167 ++++++++++++++++++ .../CodexLimits/UsageIntelligenceEngine.swift | 7 + .../AnalyticsWorkspaceTests.swift | 146 +++++++++++++++ 4 files changed, 360 insertions(+) diff --git a/Sources/CodexLimits/AnalyticsWorkspace.swift b/Sources/CodexLimits/AnalyticsWorkspace.swift index 6ca31d4..f8f980b 100644 --- a/Sources/CodexLimits/AnalyticsWorkspace.swift +++ b/Sources/CodexLimits/AnalyticsWorkspace.swift @@ -73,6 +73,46 @@ enum AnalyticsTimeRange: String, CaseIterable, Codable, Identifiable, Sendable { } } +func accountTokenInterval( + at date: Date, + in intervals: [AccountTokenActivityInterval], + within range: DateInterval +) -> AccountTokenActivityInterval? { + intervals.first { + $0.start >= range.start + && $0.end <= range.end + && $0.start <= date + && date <= $0.end + } +} + +func steppedAccountTokenInterval( + in intervals: [AccountTokenActivityInterval], + from selected: AccountTokenActivityInterval?, + by offset: Int +) -> AccountTokenActivityInterval? { + let ordered = intervals.sorted { + $0.start == $1.start ? $0.end < $1.end : $0.start < $1.start + } + guard !ordered.isEmpty else { return nil } + guard let selected, + let index = ordered.firstIndex(of: selected) else { + return offset < 0 ? ordered.last : ordered.first + } + return ordered[min(max(index + offset, 0), ordered.count - 1)] +} + +func retainedAccountTokenInterval( + _ selected: AccountTokenActivityInterval?, + in intervals: [AccountTokenActivityInterval], + range: DateInterval +) -> AccountTokenActivityInterval? { + guard let selected, + selected.start >= range.start, + selected.end <= range.end else { return nil } + return intervals.first { $0 == selected } +} + struct WorkspaceFilters: Codable, Equatable, Sendable { var projectID: String? var taskTreeID: String? diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index c61dd3d..626ac74 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -1553,11 +1553,26 @@ func accountTokenIntervalText( return "\(formatter.string(from: interval.start))–\(formatter.string(from: interval.end))" } +func accountTokenIntervalAccessibilityValue( + _ interval: AccountTokenActivityInterval, + timeZone: TimeZone = .autoupdatingCurrent, + locale: Locale = .autoupdatingCurrent +) -> String { + let dates = accountTokenIntervalText( + DateInterval(start: interval.start, end: interval.end), + timeZone: timeZone, + locale: locale + ) + return "\(interval.tokenDelta) account tokens. Account. \(dates). \(interval.method.displayName)." +} + private struct TokenActivityWorkspace: View { let reader: UsageReaderSnapshot @ObservedObject var store: AnalyticsWorkspaceStore let now: Date + @State private var selectedInterval: AccountTokenActivityInterval? + private var currentWindowBounds: DateInterval? { reader.weeklyUsageRemaining.map { DateInterval( @@ -1622,8 +1637,25 @@ private struct TokenActivityWorkspace: View { reader.accountTokenActivity.intervals ) } + + intervalSelectionDetail + evidenceDetails } } + .onChange(of: reader.accountTokenActivity.intervals) { _, intervals in + selectedInterval = retainedAccountTokenInterval( + selectedInterval, + in: intervals, + range: visibleRange + ) + } + .onChange(of: visibleRange) { _, range in + selectedInterval = retainedAccountTokenInterval( + selectedInterval, + in: reader.accountTokenActivity.intervals, + range: range + ) + } } private var chartSourceLabel: some View { @@ -1686,6 +1718,15 @@ private struct TokenActivityWorkspace: View { .foregroundStyle(Color.blue) } } + + if let selectedInterval { + RuleMark(x: .value("Selected start", selectedInterval.start)) + .foregroundStyle(Color.primary.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + RuleMark(x: .value("Selected end", selectedInterval.end)) + .foregroundStyle(Color.primary.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + } } .chartXScale(domain: visibleRange.start ... visibleRange.end) .chartYAxis { @@ -1700,6 +1741,23 @@ private struct TokenActivityWorkspace: View { } } .chartLegend(.hidden) + .chartOverlay { proxy in + GeometryReader { geometry in + Rectangle() + .fill(.clear) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onEnded { value in + selectInterval( + at: value.location, + proxy: proxy, + geometry: geometry + ) + } + ) + } + } .frame(height: 180) .accessibilityElement(children: .ignore) .accessibilityLabel("Account token activity") @@ -1715,6 +1773,115 @@ private struct TokenActivityWorkspace: View { ) -> Date { interval.start.addingTimeInterval(interval.duration / 2) } + + private var intervalSelectionDetail: some View { + HStack(spacing: 10) { + if let selectedInterval { + VStack(alignment: .leading, spacing: 3) { + Text("Selected interval") + .fontWeight(.semibold) + Text("\(compactTokenCount(selectedInterval.tokenDelta)) tokens · Account") + .monospacedDigit() + Text( + "\(accountTokenIntervalText(DateInterval(start: selectedInterval.start, end: selectedInterval.end))) · \(selectedInterval.method.displayName)" + ) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel( + accountTokenIntervalAccessibilityValue(selectedInterval) + ) + } else { + Text("Choose an observed interval for exact details.") + .foregroundStyle(.secondary) + } + Spacer() + Button { + moveIntervalSelection(by: -1) + } label: { + Image(systemName: "chevron.left") + } + .accessibilityLabel("Previous interval") + Button { + moveIntervalSelection(by: 1) + } label: { + Image(systemName: "chevron.right") + } + .accessibilityLabel("Next interval") + } + .font(.caption) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + .quaternary.opacity(0.7), + in: RoundedRectangle(cornerRadius: 8) + ) + } + + private var evidenceDetails: some View { + DisclosureGroup("Evidence Details") { + VStack(alignment: .leading, spacing: 5) { + Text("Source · \(reader.accountTokenActivity.sourceDescription)") + if let observed = reader.accountTokenActivity.interval { + Text("Observed Interval · \(accountTokenIntervalText(observed))") + } + if let method = selectedInterval?.method + ?? reader.accountTokenActivity.method { + Text("Method · \(method.displayName)") + } + ForEach(evidenceCaveats, id: \.self) { caveat in + Text(caveat) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 5) + } + .font(.caption) + } + + private var evidenceCaveats: [String] { + var caveats: [String] = [] + if reader.accountTokenActivity.state == .partial { + caveats.append( + "Only complete observed intervals inside the selected range are included." + ) + } + if reader.accountTokenActivity.method == .dailyBuckets { + caveats.append( + "Lifetime intervals were unavailable; complete UTC daily buckets were used." + ) + } + if !reader.accountTokenActivity.breaks.isEmpty { + caveats.append("Counter breaks remain empty on the chart.") + } + return caveats + } + + private func moveIntervalSelection(by offset: Int) { + selectedInterval = steppedAccountTokenInterval( + in: reader.accountTokenActivity.intervals, + from: selectedInterval, + by: offset + ) + } + + private func selectInterval( + at location: CGPoint, + proxy: ChartProxy, + geometry: GeometryProxy + ) { + guard let date = chartDate( + at: location, + proxy: proxy, + geometry: geometry + ) else { return } + selectedInterval = accountTokenInterval( + at: date, + in: reader.accountTokenActivity.intervals, + within: visibleRange + ) + } } private struct TokenSourceCard: View { diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index 3134629..ebc30d0 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -361,6 +361,13 @@ enum AccountTokenActivityState: String, Equatable, Sendable { enum AccountTokenActivityMethod: String, Equatable, Hashable, Sendable { case lifetimeDelta case dailyBuckets + + var displayName: String { + switch self { + case .lifetimeDelta: "Lifetime counter interval" + case .dailyBuckets: "UTC daily bucket" + } + } } struct AccountTokenActivityInterval: Equatable, Hashable, Identifiable, Sendable { diff --git a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift index bdc793e..ca18cbd 100644 --- a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift +++ b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift @@ -440,6 +440,152 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) } + func testAccountTokenIntervalSelectionPreservesFactualIdentity() { + let range = DateInterval( + start: Date(timeIntervalSince1970: 1_000), + end: Date(timeIntervalSince1970: 10_000) + ) + let positive = AccountTokenActivityInterval( + start: Date(timeIntervalSince1970: 2_000), + end: Date(timeIntervalSince1970: 3_000), + tokenDelta: 300, + method: .lifetimeDelta, + accountPartitionID: "account-a", + limitID: "weekly", + allowanceReset: range.end + ) + let zero = AccountTokenActivityInterval( + start: Date(timeIntervalSince1970: 5_000), + end: Date(timeIntervalSince1970: 6_000), + tokenDelta: 0, + method: .lifetimeDelta, + accountPartitionID: "account-a", + limitID: "weekly", + allowanceReset: range.end + ) + let daily = AccountTokenActivityInterval( + start: Date(timeIntervalSince1970: 7_000), + end: Date(timeIntervalSince1970: 8_000), + tokenDelta: 900, + method: .dailyBuckets, + accountPartitionID: "account-a", + limitID: "weekly", + allowanceReset: nil + ) + let intervals = [daily, zero, positive] + + XCTAssertEqual( + accountTokenInterval( + at: Date(timeIntervalSince1970: 2_500), + in: intervals, + within: range + ), + positive + ) + XCTAssertEqual( + accountTokenInterval( + at: Date(timeIntervalSince1970: 5_500), + in: intervals, + within: range + ), + zero + ) + XCTAssertNil(accountTokenInterval( + at: Date(timeIntervalSince1970: 4_000), + in: intervals, + within: range + )) + XCTAssertNil(accountTokenInterval( + at: Date(timeIntervalSince1970: 9_000), + in: intervals, + within: range + )) + XCTAssertEqual( + steppedAccountTokenInterval( + in: intervals, + from: nil, + by: 1 + ), + positive + ) + XCTAssertEqual( + steppedAccountTokenInterval( + in: intervals, + from: positive, + by: 1 + ), + zero + ) + XCTAssertEqual( + steppedAccountTokenInterval( + in: intervals, + from: zero, + by: 1 + ), + daily + ) + XCTAssertEqual( + retainedAccountTokenInterval( + daily, + in: intervals, + range: range + ), + daily + ) + XCTAssertNil(retainedAccountTokenInterval( + daily, + in: [positive, zero], + range: range + )) + XCTAssertNil(retainedAccountTokenInterval( + daily, + in: intervals, + range: DateInterval(start: range.start, end: daily.start) + )) + XCTAssertEqual(zero.method.displayName, "Lifetime counter interval") + XCTAssertEqual(daily.method.displayName, "UTC daily bucket") + XCTAssertTrue( + accountTokenIntervalAccessibilityValue(zero) + .contains("0 account tokens. Account.") + ) + XCTAssertTrue( + accountTokenIntervalAccessibilityValue(daily) + .contains("UTC daily bucket") + ) + } + + func testSelectedTokenIntervalFormattingDoesNotChangeIdentity() throws { + let formatter = ISO8601DateFormatter() + let interval = AccountTokenActivityInterval( + start: try XCTUnwrap(formatter.date(from: "2026-07-01T00:00:00Z")), + end: try XCTUnwrap(formatter.date(from: "2026-07-02T00:00:00Z")), + tokenDelta: 900, + method: .dailyBuckets, + accountPartitionID: "account-a", + limitID: "weekly", + allowanceReset: nil + ) + let utc = try XCTUnwrap(TimeZone(identifier: "UTC")) + let berlin = try XCTUnwrap(TimeZone(identifier: "Europe/Berlin")) + let locale = Locale(identifier: "en_US_POSIX") + let dateInterval = DateInterval(start: interval.start, end: interval.end) + + XCTAssertNotEqual( + accountTokenIntervalText( + dateInterval, + timeZone: utc, + locale: locale + ), + accountTokenIntervalText( + dateInterval, + timeZone: berlin, + locale: locale + ) + ) + XCTAssertEqual(interval.tokenDelta, 900) + XCTAssertEqual(interval.id, interval) + } + func testRollingPresetsEndAtInjectedNowDespiteStaleObservation() throws { let now = try date("2026-08-03T09:08:00Z") let latestObserved = now.addingTimeInterval(-6 * 3_600) From 0fa3d63f1e4fd64f612f121e93800083ec8754d3 Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:55:04 +0200 Subject: [PATCH 12/17] feat: aggregate token chart intervals safely (#57) --- Sources/CodexLimits/AnalyticsWorkspace.swift | 86 +++++++++ Sources/CodexLimits/MenuContentView.swift | 139 ++++++++++++--- .../CodexLimits/UsageIntelligenceEngine.swift | 4 +- .../AnalyticsWorkspaceTests.swift | 165 +++++++++++++++++- 4 files changed, 369 insertions(+), 25 deletions(-) diff --git a/Sources/CodexLimits/AnalyticsWorkspace.swift b/Sources/CodexLimits/AnalyticsWorkspace.swift index f8f980b..db4302e 100644 --- a/Sources/CodexLimits/AnalyticsWorkspace.swift +++ b/Sources/CodexLimits/AnalyticsWorkspace.swift @@ -113,6 +113,92 @@ func retainedAccountTokenInterval( return intervals.first { $0 == selected } } +struct AccountTokenActivityDisplayInterval: Equatable, Hashable, Identifiable, + Sendable { + let sourceIntervals: [AccountTokenActivityInterval] + + init(sourceIntervals: [AccountTokenActivityInterval]) { + precondition(!sourceIntervals.isEmpty) + self.sourceIntervals = sourceIntervals + } + + var id: Self { self } + var start: Date { sourceIntervals[0].start } + var end: Date { sourceIntervals[sourceIntervals.count - 1].end } + var tokenDelta: Int64 { + sourceIntervals.reduce(0) { $0 + $1.tokenDelta } + } + var method: AccountTokenActivityMethod { sourceIntervals[0].method } + var isAggregated: Bool { sourceIntervals.count > 1 } +} + +func displayedAccountTokenIntervals( + _ intervals: [AccountTokenActivityInterval], + breaks: [AccountTokenActivityBreak], + maximumMarks: Int +) -> [AccountTokenActivityDisplayInterval] { + let ordered = intervals.sorted { + $0.start == $1.start ? $0.end < $1.end : $0.start < $1.start + } + guard !ordered.isEmpty else { return [] } + let groupLimit = max( + Int(ceil(Double(ordered.count) / Double(max(maximumMarks, 1)))), + 1 + ) + var result: [AccountTokenActivityDisplayInterval] = [] + var group = [ordered[0]] + for interval in ordered.dropFirst() { + let previous = group[group.count - 1] + let hasBreak = breaks.contains { + $0.timestamp == previous.end + && $0.timestamp == interval.start + } + let compatible = previous.end == interval.start + && previous.method == interval.method + && previous.accountPartitionID == interval.accountPartitionID + && previous.limitID == interval.limitID + && previous.allowanceReset == interval.allowanceReset + && (previous.tokenDelta == 0) == (interval.tokenDelta == 0) + && !hasBreak + if compatible, group.count < groupLimit { + group.append(interval) + } else { + result.append(AccountTokenActivityDisplayInterval( + sourceIntervals: group + )) + group = [interval] + } + } + result.append(AccountTokenActivityDisplayInterval(sourceIntervals: group)) + return result +} + +func accountTokenDisplayInterval( + at date: Date, + in intervals: [AccountTokenActivityDisplayInterval], + within range: DateInterval +) -> AccountTokenActivityDisplayInterval? { + intervals.first { + $0.start >= range.start + && $0.end <= range.end + && $0.start <= date + && date <= $0.end + } +} + +func steppedAccountTokenDisplayInterval( + in intervals: [AccountTokenActivityDisplayInterval], + from selected: AccountTokenActivityDisplayInterval?, + by offset: Int +) -> AccountTokenActivityDisplayInterval? { + guard !intervals.isEmpty else { return nil } + guard let selected, + let index = intervals.firstIndex(of: selected) else { + return offset < 0 ? intervals.last : intervals.first + } + return intervals[min(max(index + offset, 0), intervals.count - 1)] +} + struct WorkspaceFilters: Codable, Equatable, Sendable { var projectID: String? var taskTreeID: String? diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index 626ac74..f97c288 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -1566,12 +1566,39 @@ func accountTokenIntervalAccessibilityValue( return "\(interval.tokenDelta) account tokens. Account. \(dates). \(interval.method.displayName)." } +func accountTokenDisplayIntervalAccessibilityValue( + _ interval: AccountTokenActivityDisplayInterval, + timeZone: TimeZone = .autoupdatingCurrent, + locale: Locale = .autoupdatingCurrent +) -> String { + let dates = accountTokenIntervalText( + DateInterval(start: interval.start, end: interval.end), + timeZone: timeZone, + locale: locale + ) + let aggregation = interval.isAggregated + ? " Aggregated from \(interval.sourceIntervals.count) source intervals." + : "" + let zero = interval.tokenDelta == 0 + ? " Observed zero-token interval." + : "" + return "\(interval.tokenDelta) account tokens. Account. \(dates). \(interval.method.displayName).\(aggregation)\(zero)" +} + private struct TokenActivityWorkspace: View { let reader: UsageReaderSnapshot @ObservedObject var store: AnalyticsWorkspaceStore let now: Date - @State private var selectedInterval: AccountTokenActivityInterval? + @State private var selectedInterval: AccountTokenActivityDisplayInterval? + + private var displayIntervals: [AccountTokenActivityDisplayInterval] { + displayedAccountTokenIntervals( + reader.accountTokenActivity.intervals, + breaks: reader.accountTokenActivity.breaks, + maximumMarks: 120 + ) + } private var currentWindowBounds: DateInterval? { reader.weeklyUsageRemaining.map { @@ -1582,6 +1609,20 @@ private struct TokenActivityWorkspace: View { } } + private var zoomBounds: DateInterval { + let tokenDates = (reader.account?.tokenHistory ?? []).flatMap { + [$0.date, $0.date.addingTimeInterval(86_400)] + } + let dates = reader.chart.allObserved.map(\.date) + + tokenDates + + [visibleRange.start, visibleRange.end, now] + + [currentWindowBounds?.start, currentWindowBounds?.end].compactMap { $0 } + return DateInterval( + start: dates.min() ?? visibleRange.start, + end: dates.max() ?? visibleRange.end + ) + } + private var visibleRange: DateInterval { if store.state.timeRange == .currentWindow, let currentWindowBounds { @@ -1622,6 +1663,8 @@ private struct TokenActivityWorkspace: View { } } + zoomControls + if reader.accountTokenActivity.intervals.isEmpty { WorkspaceMessage( icon: "chart.xyaxis.line", @@ -1634,7 +1677,7 @@ private struct TokenActivityWorkspace: View { .frame(minHeight: 170) } else { accountIntervalChart( - reader.accountTokenActivity.intervals + displayIntervals ) } @@ -1642,19 +1685,19 @@ private struct TokenActivityWorkspace: View { evidenceDetails } } - .onChange(of: reader.accountTokenActivity.intervals) { _, intervals in - selectedInterval = retainedAccountTokenInterval( - selectedInterval, - in: intervals, - range: visibleRange - ) + .onChange(of: reader.accountTokenActivity.intervals) { _, _ in + selectedInterval = displayIntervals.first { $0 == selectedInterval } } .onChange(of: visibleRange) { _, range in - selectedInterval = retainedAccountTokenInterval( - selectedInterval, - in: reader.accountTokenActivity.intervals, - range: range - ) + guard let selectedInterval, + selectedInterval.start >= range.start, + selectedInterval.end <= range.end else { + self.selectedInterval = nil + return + } + self.selectedInterval = displayIntervals.first { + $0 == selectedInterval + } } } @@ -1671,6 +1714,30 @@ private struct TokenActivityWorkspace: View { .foregroundStyle(.tertiary) } + private var zoomControls: some View { + HStack(spacing: 10) { + Button { + zoom(by: 1.5) + } label: { + Label("Zoom in", systemImage: "plus.magnifyingglass") + } + .help("Show a shorter range") + Button { + zoom(by: 1 / 1.5) + } label: { + Label("Zoom out", systemImage: "minus.magnifyingglass") + } + .help("Show a longer range") + if store.state.timeRange == .selected { + Button("Reset range") { + store.resetVisibleRange() + } + } + } + .buttonStyle(.borderless) + .font(.caption) + } + private var accountCard: some View { TokenSourceCard( title: "Account", @@ -1696,7 +1763,7 @@ private struct TokenActivityWorkspace: View { } private func accountIntervalChart( - _ intervals: [AccountTokenActivityInterval] + _ intervals: [AccountTokenActivityDisplayInterval] ) -> some View { Chart { ForEach(intervals) { interval in @@ -1769,9 +1836,11 @@ private struct TokenActivityWorkspace: View { } private func midpoint( - of interval: AccountTokenActivityInterval + of interval: AccountTokenActivityDisplayInterval ) -> Date { - interval.start.addingTimeInterval(interval.duration / 2) + interval.start.addingTimeInterval( + interval.end.timeIntervalSince(interval.start) / 2 + ) } private var intervalSelectionDetail: some View { @@ -1786,10 +1855,18 @@ private struct TokenActivityWorkspace: View { "\(accountTokenIntervalText(DateInterval(start: selectedInterval.start, end: selectedInterval.end))) · \(selectedInterval.method.displayName)" ) .foregroundStyle(.secondary) + if selectedInterval.isAggregated { + Text( + "Aggregated display interval · \(selectedInterval.sourceIntervals.count) source intervals" + ) + .foregroundStyle(.secondary) + } } .accessibilityElement(children: .ignore) .accessibilityLabel( - accountTokenIntervalAccessibilityValue(selectedInterval) + accountTokenDisplayIntervalAccessibilityValue( + selectedInterval + ) ) } else { Text("Choose an observed interval for exact details.") @@ -1859,13 +1936,33 @@ private struct TokenActivityWorkspace: View { } private func moveIntervalSelection(by offset: Int) { - selectedInterval = steppedAccountTokenInterval( - in: reader.accountTokenActivity.intervals, + selectedInterval = steppedAccountTokenDisplayInterval( + in: displayIntervals, from: selectedInterval, by: offset ) } + private func zoom(by factor: CGFloat) { + let anchor = selectedInterval.map { + Date( + timeIntervalSince1970: + ($0.start.timeIntervalSince1970 + + $0.end.timeIntervalSince1970) / 2 + ) + } ?? Date( + timeIntervalSince1970: + (visibleRange.start.timeIntervalSince1970 + + visibleRange.end.timeIntervalSince1970) / 2 + ) + store.zoom( + factor: Double(factor), + anchor: anchor, + currentRange: visibleRange, + within: zoomBounds + ) + } + private func selectInterval( at location: CGPoint, proxy: ChartProxy, @@ -1876,9 +1973,9 @@ private struct TokenActivityWorkspace: View { proxy: proxy, geometry: geometry ) else { return } - selectedInterval = accountTokenInterval( + selectedInterval = accountTokenDisplayInterval( at: date, - in: reader.accountTokenActivity.intervals, + in: displayIntervals, within: visibleRange ) } diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index ebc30d0..1df1f52 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -453,14 +453,14 @@ struct AccountTokenActivitySnapshot: Equatable, Sendable { let zero = intervals.contains { $0.tokenDelta == 0 } ? " Includes an observed zero-token interval." : "" - return "\(tokens) account tokens observed.\(zero) Time without an account reading is empty." + return "\(tokens) account tokens observed.\(zero) Time without account observations is missing, not zero." } var currentWindowAccessibilityValue: String { guard let tokens else { return reason ?? "No account readings in this range" } - return "\(tokens) account tokens so far. Activity after the latest account reading is empty." + return "\(tokens) account tokens so far. Future time after the latest account reading has no observation." } var sourceDescription: String { diff --git a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift index ca18cbd..8234e9b 100644 --- a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift +++ b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift @@ -294,7 +294,7 @@ final class AnalyticsWorkspaceTests: XCTestCase { XCTAssertEqual(positive.accountTokenActivity.tokens, 300) XCTAssertTrue( positive.accountTokenActivity.accessibilityValue.contains( - "Time without an account reading is empty" + "Time without account observations is missing, not zero" ) ) XCTAssertFalse( @@ -425,7 +425,7 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) XCTAssertTrue( reader.accountTokenActivity.currentWindowAccessibilityValue - .contains("after the latest account reading is empty") + .contains("Future time after the latest account reading has no observation") ) XCTAssertTrue( renders( @@ -586,6 +586,167 @@ final class AnalyticsWorkspaceTests: XCTestCase { XCTAssertEqual(interval.id, interval) } + func testTokenDisplayAggregationPreservesTotalsGapsAndBreaks() { + let base = Date(timeIntervalSince1970: 1_000) + let reset = base.addingTimeInterval(20_000) + func interval( + _ start: TimeInterval, + _ end: TimeInterval, + _ tokens: Int64, + method: AccountTokenActivityMethod = .lifetimeDelta + ) -> AccountTokenActivityInterval { + AccountTokenActivityInterval( + start: base.addingTimeInterval(start), + end: base.addingTimeInterval(end), + tokenDelta: tokens, + method: method, + accountPartitionID: "account-a", + limitID: "weekly", + allowanceReset: reset + ) + } + let source = [ + interval(0, 10, 10), + interval(10, 20, 20), + interval(30, 40, 30), + interval(40, 50, 40), + interval(50, 60, 50, method: .dailyBuckets), + interval(60, 70, 0, method: .dailyBuckets), + interval(70, 80, 0, method: .dailyBuckets) + ] + let breakAt40 = AccountTokenActivityBreak( + timestamp: base.addingTimeInterval(40), + reason: .counterDecrease + ) + let coarse = displayedAccountTokenIntervals( + source.reversed(), + breaks: [breakAt40], + maximumMarks: 2 + ) + let fine = displayedAccountTokenIntervals( + source, + breaks: [breakAt40], + maximumMarks: 100 + ) + let sourceTotal = source.reduce(Int64(0)) { $0 + $1.tokenDelta } + + XCTAssertEqual( + coarse.reduce(Int64(0)) { $0 + $1.tokenDelta }, + sourceTotal + ) + XCTAssertEqual( + fine.reduce(Int64(0)) { $0 + $1.tokenDelta }, + sourceTotal + ) + XCTAssertEqual( + coarse, + displayedAccountTokenIntervals( + source, + breaks: [breakAt40], + maximumMarks: 2 + ) + ) + XCTAssertGreaterThanOrEqual(fine.count, coarse.count) + XCTAssertEqual(fine.count, source.count) + XCTAssertEqual(coarse.first?.sourceIntervals.count, 2) + XCTAssertFalse(coarse.contains { + $0.start < base.addingTimeInterval(30) + && $0.end > base.addingTimeInterval(20) + }) + XCTAssertFalse(coarse.contains { + $0.start < breakAt40.timestamp + && $0.end > breakAt40.timestamp + }) + XCTAssertFalse(coarse.contains { + Set($0.sourceIntervals.map(\.method)).count > 1 + }) + XCTAssertFalse(coarse.contains { + $0.sourceIntervals.contains { $0.tokenDelta == 0 } + && $0.sourceIntervals.contains { $0.tokenDelta > 0 } + }) + XCTAssertEqual(source.count, 7) + } + + func testAggregatedTokenSelectionAndAccessibilityRemainTruthful() { + let range = DateInterval( + start: Date(timeIntervalSince1970: 1_000), + end: Date(timeIntervalSince1970: 2_000) + ) + func sourceInterval(_ index: Int) -> AccountTokenActivityInterval { + AccountTokenActivityInterval( + start: range.start.addingTimeInterval(Double(index) * 100), + end: range.start.addingTimeInterval(Double(index + 1) * 100), + tokenDelta: Int64((index + 1) * 100), + method: .lifetimeDelta, + accountPartitionID: "account-a", + limitID: "weekly", + allowanceReset: range.end + ) + } + let source = [0, 1, 2].map(sourceInterval) + let display = displayedAccountTokenIntervals( + source, + breaks: [], + maximumMarks: 1 + ) + let zoomedRange = DateInterval( + start: source[0].start, + end: source[1].end + ) + let zoomed = displayedAccountTokenIntervals( + source.filter { + $0.start >= zoomedRange.start && $0.end <= zoomedRange.end + }, + breaks: [], + maximumMarks: 3 + ) + let selected = accountTokenDisplayInterval( + at: range.start.addingTimeInterval(150), + in: display, + within: range + ) + + XCTAssertEqual(display.count, 1) + XCTAssertEqual(selected?.tokenDelta, 600) + XCTAssertEqual(selected?.sourceIntervals.count, 3) + XCTAssertEqual(zoomed.count, 2) + XCTAssertTrue(zoomed.allSatisfy { !$0.isAggregated }) + XCTAssertEqual(source.count, 3) + XCTAssertEqual( + steppedAccountTokenDisplayInterval( + in: display, + from: nil, + by: 1 + ), + selected + ) + XCTAssertTrue( + accountTokenDisplayIntervalAccessibilityValue(selected!) + .contains("Aggregated from 3 source intervals") + ) + XCTAssertNil(accountTokenDisplayInterval( + at: range.start.addingTimeInterval(500), + in: display, + within: range + )) + + let zero = AccountTokenActivityDisplayInterval( + sourceIntervals: [AccountTokenActivityInterval( + start: range.start, + end: range.start.addingTimeInterval(100), + tokenDelta: 0, + method: .lifetimeDelta, + accountPartitionID: "account-a", + limitID: "weekly", + allowanceReset: range.end + )] + ) + XCTAssertTrue( + accountTokenDisplayIntervalAccessibilityValue(zero) + .contains("Observed zero-token interval") + ) + } + func testRollingPresetsEndAtInjectedNowDespiteStaleObservation() throws { let now = try date("2026-08-03T09:08:00Z") let latestObserved = now.addingTimeInterval(-6 * 3_600) From e6ddba8b1c8ec2fc994ff16961af069a114d4cf5 Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:12:28 +0200 Subject: [PATCH 13/17] fix: address Stage B verification --- Sources/CodexLimits/MenuContentView.swift | 27 +++- Sources/CodexLimits/UsageHistory.swift | 51 +++---- .../CodexLimits/UsageIntelligenceEngine.swift | 75 ++++++++--- Sources/CodexLimits/UsageModels.swift | 6 +- Sources/CodexLimits/UsageMonitor.swift | 4 +- .../AnalyticsWorkspaceTests.swift | 4 +- .../DeterministicInsightTests.swift | 4 +- .../CodexLimitsTests/UsageHistoryTests.swift | 30 ++++- .../UsageIntelligenceEngineTests.swift | 127 +++++++++++++++++- .../UsageMonitorHistoryTests.swift | 6 +- 10 files changed, 283 insertions(+), 51 deletions(-) diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index f97c288..b65e871 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -277,6 +277,16 @@ struct AnalyticsWorkspaceBody: View { .onChange(of: store.insightDispositions) { _, _ in analyticsPreferencesChanged() } + .onChange(of: now) { _, _ in + guard store.state.section == .graphs, + store.state.graph == .tokenActivity else { return } + switch store.state.timeRange { + case .oneDay, .threeDays, .fourWeeks, .twelveWeeks: + analyticsPreferencesChanged() + case .currentWindow, .selected: + break + } + } } } @@ -1577,7 +1587,7 @@ func accountTokenDisplayIntervalAccessibilityValue( locale: locale ) let aggregation = interval.isAggregated - ? " Aggregated from \(interval.sourceIntervals.count) source intervals." + ? " Combined from \(interval.sourceIntervals.count) observed periods." : "" let zero = interval.tokenDelta == 0 ? " Observed zero-token interval." @@ -1624,6 +1634,9 @@ private struct TokenActivityWorkspace: View { } private var visibleRange: DateInterval { + if let evaluatedRange = reader.accountTokenActivity.range { + return evaluatedRange + } if store.state.timeRange == .currentWindow, let currentWindowBounds { return currentWindowBounds @@ -1775,6 +1788,10 @@ private struct TokenActivityWorkspace: View { .foregroundStyle(Color.blue) .symbol(.diamond) .symbolSize(55) + .accessibilityLabel("Account token interval") + .accessibilityValue( + accountTokenDisplayIntervalAccessibilityValue(interval) + ) } else { RectangleMark( xStart: .value("Start", interval.start), @@ -1783,6 +1800,10 @@ private struct TokenActivityWorkspace: View { yEnd: .value("Account tokens", interval.tokenDelta) ) .foregroundStyle(Color.blue) + .accessibilityLabel("Account token interval") + .accessibilityValue( + accountTokenDisplayIntervalAccessibilityValue(interval) + ) } } @@ -1826,7 +1847,7 @@ private struct TokenActivityWorkspace: View { } } .frame(height: 180) - .accessibilityElement(children: .ignore) + .accessibilityElement(children: .contain) .accessibilityLabel("Account token activity") .accessibilityValue( store.state.timeRange == .currentWindow @@ -1857,7 +1878,7 @@ private struct TokenActivityWorkspace: View { .foregroundStyle(.secondary) if selectedInterval.isAggregated { Text( - "Aggregated display interval · \(selectedInterval.sourceIntervals.count) source intervals" + "Combined from \(selectedInterval.sourceIntervals.count) observed periods" ) .foregroundStyle(.secondary) } diff --git a/Sources/CodexLimits/UsageHistory.swift b/Sources/CodexLimits/UsageHistory.swift index d0b31b4..da35ec5 100644 --- a/Sources/CodexLimits/UsageHistory.swift +++ b/Sources/CodexLimits/UsageHistory.swift @@ -983,34 +983,39 @@ actor UsageHistory { } private func normalized(_ samples: [UsageSample]) -> [UsageSample] { - let valid = samples.filter(\.isValid) - var byIdentity: [UsageSample: UsageSample] = [:] - for sample in valid { - guard let existing = byIdentity[sample] else { - byIdentity[sample] = sample - continue - } - let lifetimeTokens = if let newTokens = sample.lifetimeTokens, - newTokens > (existing.lifetimeTokens ?? .min) { - newTokens - } else { - existing.lifetimeTokens + let valid = samples.filter(\.hasValidObservationMetadata) + let normalized = Dictionary(grouping: valid, by: { $0 }).values + .flatMap { matchingSamples -> [UsageSample] in + let sample = matchingSamples[0] + let comparisonBreak = matchingSamples.contains { + $0.comparisonBreak + } + let counters = Set(matchingSamples.compactMap(\.lifetimeTokens)) + if counters.isEmpty { + return [UsageSample( + observedAt: sample.observedAt, + remainingPercent: sample.remainingPercent, + resetsAt: sample.resetsAt, + comparisonBreak: comparisonBreak + )] + } + return counters.map { + UsageSample( + observedAt: sample.observedAt, + remainingPercent: sample.remainingPercent, + resetsAt: sample.resetsAt, + lifetimeTokens: $0, + comparisonBreak: comparisonBreak + ) + } } - byIdentity[sample] = UsageSample( - observedAt: existing.observedAt, - remainingPercent: existing.remainingPercent, - resetsAt: existing.resetsAt, - lifetimeTokens: lifetimeTokens, - comparisonBreak: - existing.comparisonBreak || sample.comparisonBreak - ) - } - return byIdentity.values.sorted { + return normalized.sorted { if $0.observedAt != $1.observedAt { return $0.observedAt < $1.observedAt } if $0.remainingPercent != $1.remainingPercent { return $0.remainingPercent > $1.remainingPercent } - return $0.resetsAt < $1.resetsAt + if $0.resetsAt != $1.resetsAt { return $0.resetsAt < $1.resetsAt } + return ($0.lifetimeTokens ?? .min) < ($1.lifetimeTokens ?? .min) } } diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index 1df1f52..eb8c0df 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -460,7 +460,10 @@ struct AccountTokenActivitySnapshot: Equatable, Sendable { guard let tokens else { return reason ?? "No account readings in this range" } - return "\(tokens) account tokens so far. Future time after the latest account reading has no observation." + let zero = intervals.contains { $0.tokenDelta == 0 } + ? " Includes an observed zero-token interval." + : "" + return "\(tokens) account tokens so far.\(zero) Gaps between readings and time since the latest reading are missing, not zero. Time from now until reset is future and has no observation." } var sourceDescription: String { @@ -1340,15 +1343,45 @@ enum UsageIntelligenceEngine { var timeline = samples if let account, let window = account.mainLimit?.window, - let lifetimeTokens = account.accountFacts?.lifetimeTokens { + let facts = account.accountFacts, + let lifetimeTokens = facts.lifetimeTokens { + let observedAt = facts.lifetimeTokensObservedAt ?? account.fetchedAt + guard window.startsAt <= observedAt, + observedAt <= window.resetsAt else { + return evaluateAccountTokenTimeline( + timeline, + account: account, + range: range, + now: now, + accountPartitionID: accountPartitionID, + accountEpochStartedAt: accountEpochStartedAt + ) + } timeline.append(UsageSample( - observedAt: account.accountFacts?.lifetimeTokensObservedAt - ?? account.fetchedAt, + observedAt: observedAt, remainingPercent: window.remainingPercent, resetsAt: window.resetsAt, lifetimeTokens: lifetimeTokens )) } + return evaluateAccountTokenTimeline( + timeline, + account: account, + range: range, + now: now, + accountPartitionID: accountPartitionID, + accountEpochStartedAt: accountEpochStartedAt + ) + } + + private static func evaluateAccountTokenTimeline( + _ timeline: [UsageSample], + account: UsageSnapshot?, + range: DateInterval, + now: Date, + accountPartitionID: String?, + accountEpochStartedAt: Date? + ) -> AccountTokenActivitySnapshot { let readings = timeline.filter { $0.observedAt <= now }.sorted { if $0.observedAt != $1.observedAt { return $0.observedAt < $1.observedAt @@ -1370,8 +1403,10 @@ enum UsageIntelligenceEngine { last.observedAt == reading.observedAt, last.resetsAt == reading.resetsAt, last.lifetimeTokens == reading.lifetimeTokens, - last.remainingPercent == reading.remainingPercent, - last.comparisonBreak == reading.comparisonBreak { + last.remainingPercent == reading.remainingPercent { + if reading.comparisonBreak { + unique[unique.count - 1] = reading + } continue } unique.append(reading) @@ -1404,7 +1439,8 @@ enum UsageIntelligenceEngine { previous = nil continue } - if sawEarlierEpoch, let accountEpochStartedAt { + let crossedAccountEpoch = sawEarlierEpoch + if crossedAccountEpoch, let accountEpochStartedAt { recordBreak(at: accountEpochStartedAt, reason: .accountChange) sawEarlierEpoch = false } @@ -1416,13 +1452,17 @@ enum UsageIntelligenceEngine { previous = nil continue } - guard reading.isValid, reading.lifetimeTokens != nil else { + guard reading.isValid, + let readingTokens = reading.lifetimeTokens, + readingTokens >= 0 else { recordBreak(at: reading.observedAt, reason: .invalidCounter) previous = nil continue } if reading.comparisonBreak { - recordBreak(at: reading.observedAt, reason: .correction) + if !crossedAccountEpoch { + recordBreak(at: reading.observedAt, reason: .correction) + } previous = reading continue } @@ -1466,7 +1506,8 @@ enum UsageIntelligenceEngine { account: account, range: range, now: now, - accountPartitionID: accountPartitionID + accountPartitionID: accountPartitionID, + breaks: breaks ) { return fallback } @@ -1503,7 +1544,8 @@ enum UsageIntelligenceEngine { account: UsageSnapshot, range: DateInterval, now: Date, - accountPartitionID: String? + accountPartitionID: String?, + breaks: [AccountTokenActivityBreak] = [] ) -> AccountTokenActivitySnapshot? { let intervals = account.tokenHistory.compactMap { day -> AccountTokenActivityInterval? in @@ -1531,16 +1573,19 @@ enum UsageIntelligenceEngine { guard !sum.overflow else { return nil } total = sum.partialValue } + let isContinuous = zip(intervals, intervals.dropFirst()) + .allSatisfy { $0.0.end == $0.1.start } return AccountTokenActivitySnapshot( - state: intervals.count == 1 - && first.start == range.start - && first.end == range.end ? .exact : .partial, + state: first.start == range.start + && last.end == range.end + && isContinuous ? .exact : .partial, tokens: total, method: .dailyBuckets, interval: DateInterval(start: first.start, end: last.end), reason: nil, range: range, - intervals: intervals + intervals: intervals, + breaks: breaks ) } diff --git a/Sources/CodexLimits/UsageModels.swift b/Sources/CodexLimits/UsageModels.swift index f9c5e7d..79e7789 100644 --- a/Sources/CodexLimits/UsageModels.swift +++ b/Sources/CodexLimits/UsageModels.swift @@ -69,12 +69,16 @@ struct UsageSample: Codable, Equatable, Hashable, Sendable { let lifetimeTokens: Int64? let comparisonBreak: Bool - var isValid: Bool { + var hasValidObservationMetadata: Bool { observedAt.isSupportedUsageDate && resetsAt.isSupportedUsageDate && observedAt <= resetsAt && remainingPercent.isFinite && (0 ... 100).contains(remainingPercent) + } + + var isValid: Bool { + hasValidObservationMetadata && (lifetimeTokens.map { $0 >= 0 } ?? true) } diff --git a/Sources/CodexLimits/UsageMonitor.swift b/Sources/CodexLimits/UsageMonitor.swift index ed5197e..c52c021 100644 --- a/Sources/CodexLimits/UsageMonitor.swift +++ b/Sources/CodexLimits/UsageMonitor.swift @@ -144,7 +144,9 @@ final class UsageMonitor: ObservableObject { } if let data = defaults.data(forKey: Self.stateKey), let state = try? JSONDecoder().decode(StoredState.self, from: data) { - let restoredSamples = state.samples.filter(\.isValid) + let restoredSamples = state.samples.filter( + \.hasValidObservationMetadata + ) accountSnapshot = state.snapshot.flatMap { $0.isValid ? $0 : nil } diff --git a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift index 8234e9b..a7736ba 100644 --- a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift +++ b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift @@ -425,7 +425,7 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) XCTAssertTrue( reader.accountTokenActivity.currentWindowAccessibilityValue - .contains("Future time after the latest account reading has no observation") + .contains("Time from now until reset is future and has no observation") ) XCTAssertTrue( renders( @@ -722,7 +722,7 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) XCTAssertTrue( accountTokenDisplayIntervalAccessibilityValue(selected!) - .contains("Aggregated from 3 source intervals") + .contains("Combined from 3 observed periods") ) XCTAssertNil(accountTokenDisplayInterval( at: range.start.addingTimeInterval(500), diff --git a/Tests/CodexLimitsTests/DeterministicInsightTests.swift b/Tests/CodexLimitsTests/DeterministicInsightTests.swift index 116dbbd..2d99cc9 100644 --- a/Tests/CodexLimitsTests/DeterministicInsightTests.swift +++ b/Tests/CodexLimitsTests/DeterministicInsightTests.swift @@ -113,7 +113,7 @@ final class DeterministicInsightTests: XCTestCase { (AnalyticsTimeRange.oneDay, 86_400.0), (.threeDays, 3 * 86_400.0), (.fourWeeks, 28 * 86_400.0), - (.twelveWeeks, 35 * 86_400.0) + (.twelveWeeks, 84 * 86_400.0) ] { var exploration = AnalyticsExplorationState.initial exploration.timeRange = range @@ -127,7 +127,7 @@ final class DeterministicInsightTests: XCTestCase { ) XCTAssertEqual( resolved.duration, - min(duration, 35 * 86_400), + duration, accuracy: 0.1 ) } diff --git a/Tests/CodexLimitsTests/UsageHistoryTests.swift b/Tests/CodexLimitsTests/UsageHistoryTests.swift index 6ee953a..444dc16 100644 --- a/Tests/CodexLimitsTests/UsageHistoryTests.swift +++ b/Tests/CodexLimitsTests/UsageHistoryTests.swift @@ -108,7 +108,33 @@ final class UsageHistoryTests: XCTestCase { XCTAssertEqual(state.samples.first?.comparisonBreak, true) } - func testNegativeLifetimeTokenReadingIsRejectedDuringNormalization() async throws { + func testConflictingLifetimeCountersKeepBothReadings() async throws { + let root = temporaryDirectory() + let observedAt = Date(timeIntervalSince1970: 1_900_000) + let resetsAt = Date(timeIntervalSince1970: 2_000_000) + let history = UsageHistory( + localDirectory: root, + installationID: "writer-a" + ) + _ = await history.load() + _ = await history.record(UsageSample( + observedAt: observedAt, + remainingPercent: 80, + resetsAt: resetsAt, + lifetimeTokens: 1_200 + )) + + let state = await history.record(UsageSample( + observedAt: observedAt, + remainingPercent: 80, + resetsAt: resetsAt, + lifetimeTokens: 1_500 + )) + + XCTAssertEqual(state.samples.map(\.lifetimeTokens), [1_200, 1_500]) + } + + func testNegativeLifetimeTokenReadingIsPreservedAsABreak() async throws { let root = temporaryDirectory() let history = UsageHistory( localDirectory: root, @@ -125,7 +151,7 @@ final class UsageHistoryTests: XCTestCase { ) ) - XCTAssertTrue(state.samples.isEmpty) + XCTAssertEqual(state.samples.map(\.lifetimeTokens), [-1]) } func testDailyFileRestoreKeepsValidSamplesBesideAnInvalidSample() async throws { diff --git a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift index b31e655..06dd2f7 100644 --- a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift +++ b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift @@ -717,6 +717,69 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertEqual(activity.intervals.map(\.method), [.dailyBuckets]) } + func testCompleteContiguousUTCDaysExactlyCoverAThreeDayRange() throws { + let now = try date("2026-08-04T00:00:00Z") + let account = makeSnapshot( + remaining: 80, + fetchedAt: now, + tokenHistory: [1, 2, 3].map { day in + TokenDay( + date: now.addingTimeInterval(TimeInterval(-day * 86_400)), + tokens: Int64(day * 100), + completeness: .complete + ) + } + ) + + let activity = rollingDayActivity( + account: account, + samples: [], + now: now, + timeRange: .threeDays + ) + + XCTAssertEqual(activity.state, .exact) + XCTAssertEqual(activity.tokens, 600) + XCTAssertEqual(activity.intervals.count, 3) + } + + func testUTCFallbackPreservesDetectedCounterBreaks() throws { + let now = try date("2026-08-03T00:00:00Z") + let account = makeSnapshot( + remaining: 80, + fetchedAt: now, + tokenHistory: [TokenDay( + date: now.addingTimeInterval(-86_400), + tokens: 900, + completeness: .complete + )] + ) + let reset = account.mainLimit!.window.resetsAt + let readings = [ + UsageSample( + observedAt: now.addingTimeInterval(-7_200), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 2_000 + ), + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + ] + + let activity = rollingDayActivity( + account: account, + samples: readings, + now: now + ) + + XCTAssertEqual(activity.method, .dailyBuckets) + XCTAssertEqual(activity.breaks.map(\.reason), [.counterDecrease]) + } + func testRollingDayExcludesPartialUTCDailyBuckets() throws { let now = try date("2026-08-03T12:00:00Z") let account = makeSnapshot( @@ -1427,6 +1490,57 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) } + func testPreservedLifetimeReadingDoesNotInheritANewerAllowanceReset() { + let now = Date(timeIntervalSince1970: 2_000_000) + let observedAt = now.addingTimeInterval(-6 * 86_400) + let oldReset = now.addingTimeInterval(-4 * 86_400) + let account = makeSnapshot( + remaining: 80, + fetchedAt: now, + accountFacts: AccountFacts( + lifetimeTokens: 1_200, + peakDailyTokens: nil, + longestRunningTurnSeconds: nil, + currentStreakDays: nil, + longestStreakDays: nil, + credits: nil, + spendControl: nil, + lifetimeTokensObservedAt: observedAt + ) + ) + let samples = [ + UsageSample( + observedAt: now.addingTimeInterval(-7 * 86_400), + remainingPercent: 90, + resetsAt: oldReset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: observedAt, + remainingPercent: 85, + resetsAt: oldReset, + lifetimeTokens: 1_200 + ) + ] + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .twelveWeeks + + let activity = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: samples, + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + analyticsExploration: exploration + ) + ).accountTokenActivity + + XCTAssertEqual(activity.tokens, 200) + XCTAssertTrue(activity.breaks.isEmpty) + } + func testCompleteDailyBucketsProducePartialAccountTokenActivity() throws { let fetchedAt = try XCTUnwrap( ISO8601DateFormatter().date(from: "2026-07-28T12:00:00Z") @@ -2543,12 +2657,23 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertEqual(zero.tokens, 0) XCTAssertEqual(zero.intervals.first?.tokenDelta, 0) + XCTAssertTrue( + zero.currentWindowAccessibilityValue + .contains("observed zero-token interval") + ) + XCTAssertTrue( + zero.currentWindowAccessibilityValue + .contains("missing, not zero") + ) + XCTAssertTrue( + zero.currentWindowAccessibilityValue + .contains("future and has no observation") + ) XCTAssertNil(missing.tokens) XCTAssertEqual(missing.reason, "No account readings in this range") } func testExpiredCurrentWindowIsUnavailableButKeepsHistoricalSamples() throws { - let start = try date("2026-08-01T12:13:00Z") let reset = try date("2026-08-08T12:13:00Z") let historicalObservation = try date("2026-08-03T12:13:00Z") let account = UsageSnapshot( diff --git a/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift b/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift index dd6fcab..1f2ea65 100644 --- a/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift +++ b/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift @@ -410,7 +410,11 @@ final class UsageMonitorHistoryTests: XCTestCase { XCTAssertNil(monitor.readerSnapshot.accountTokenActivity.tokens) XCTAssertEqual( monitor.readerSnapshot.accountTokenActivity.reason, - "No lifetime token reading at the weekly boundary" + "No account readings in this range" + ) + XCTAssertEqual( + monitor.readerSnapshot.accountTokenActivity.breaks.map(\.reason), + [.accountChange] ) defaults.removeObject( From 490ce16462f6cb683af66aa485c61e218b55df40 Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:33:27 +0200 Subject: [PATCH 14/17] feat: calculate suggested pace without history (#58) --- .../CodexLimits/UsageIntelligenceEngine.swift | 73 ++++++--- .../UsageIntelligenceEngineTests.swift | 149 +++++++++++++++++- 2 files changed, 196 insertions(+), 26 deletions(-) diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index eb8c0df..6f87f5b 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -646,6 +646,8 @@ struct UsageReaderSnapshot: Equatable, Sendable { let freshness: UsageFreshness let evidence: UsageEvidence let guidance: UsageGuidance? + let suggestedPacePercentPerDay: Double? + let suggestedPaceUnavailableReason: String? let chart: UsageChartSnapshot let bankedResets: BankedResetSummary? let accountTokenActivity: AccountTokenActivitySnapshot @@ -680,9 +682,9 @@ struct UsageReaderSnapshot: Equatable, Sendable { } var suggestedPaceText: String { - guidance?.suggestedPace - ?? (evidence.coverage == .notApplicable ? evidence.reason : nil) - ?? "Not enough data" + suggestedPacePercentPerDay.map(formattedSuggestedPace) + ?? suggestedPaceUnavailableReason + ?? "Weekly usage unavailable" } var evidenceText: String { @@ -788,6 +790,12 @@ enum UsageIntelligenceEngine { previousStatus: input.previousStatus ) } + let suggestedPace = suggestedPace( + account: input.account, + sourceState: input.sourceState, + safetyBuffer: input.safetyBuffer, + now: input.now + ) let guidance: UsageGuidance? = forecast.flatMap { forecast in guard evidence.confidence == .high || evidence.confidence == .medium else { return nil @@ -803,11 +811,10 @@ enum UsageIntelligenceEngine { safetyBuffer: input.safetyBuffer, now: input.now ), - suggestedPace: suggestedPace( - forecast: forecast, - reset: weeklyLimit.window.resetsAt, - now: input.now - ), + suggestedPace: suggestedPace.percentPerDay + .map(formattedSuggestedPace) + ?? suggestedPace.reason + ?? "Weekly usage unavailable", runway: runway( window: weeklyLimit.window, forecast: forecast, @@ -1111,6 +1118,8 @@ enum UsageIntelligenceEngine { freshness: currentFreshness, evidence: evidence, guidance: guidance, + suggestedPacePercentPerDay: suggestedPace.percentPerDay, + suggestedPaceUnavailableReason: suggestedPace.reason, chart: chart, bankedResets: bankedResetSummary( account: input.account, @@ -2092,23 +2101,32 @@ enum UsageIntelligenceEngine { } private static func suggestedPace( - forecast: Forecast, - reset: Date, + account: UsageSnapshot?, + sourceState: UsageSourceState, + safetyBuffer: Double, now: Date - ) -> String { - let value = reset.timeIntervalSince(now) <= 86_400 - ? forecast.recommendedPercentPerDay / 24 - : forecast.recommendedPercentPerDay - let unit = reset.timeIntervalSince(now) <= 86_400 ? "an hour" : "a day" - return "Up to \(oneDecimal(value))% \(unit)" - } - - private static func oneDecimal(_ value: Double) -> String { - value.formatted( - .number - .precision(.fractionLength(1)) - .locale(Locale(identifier: "en_US")) + ) -> (percentPerDay: Double?, reason: String?) { + if case let .failed(message) = sourceState { + return (nil, message) + } + guard let window = account?.mainLimit?.window, window.isValid else { + return (nil, "Weekly usage unavailable") + } + guard now >= window.startsAt else { + return (nil, "Current allowance window has not started") + } + let secondsToReset = window.resetsAt.timeIntervalSince(now) + guard secondsToReset > 0, secondsToReset.isFinite else { + return (nil, "Current allowance window unavailable") + } + let value = max( + (window.remainingPercent - safetyBuffer) + / (secondsToReset / 86_400), + 0 ) + return value.isFinite + ? (value, nil) + : (nil, "Current allowance window unavailable") } private static func durationText(_ seconds: TimeInterval) -> String { @@ -2120,3 +2138,12 @@ enum UsageIntelligenceEngine { return "\(hours) \(hours == 1 ? "hour" : "hours")" } } + +private func formattedSuggestedPace(_ percentPerDay: Double) -> String { + let value = percentPerDay.formatted( + .number + .precision(.fractionLength(1)) + .locale(Locale(identifier: "en_US")) + ) + return "Up to \(value) percentage points a day" +} diff --git a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift index 06dd2f7..49b71c9 100644 --- a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift +++ b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift @@ -1781,7 +1781,10 @@ final class UsageIntelligenceEngineTests: XCTestCase { reader.guidance?.message, "At this pace, your limit may run out 23 hours early." ) - XCTAssertEqual(reader.guidance?.suggestedPace, "Up to 8.5% a day") + XCTAssertEqual( + reader.guidance?.suggestedPace, + "Up to 8.5 percentage points a day" + ) XCTAssertNotNil(reader.guidance?.runway) XCTAssertNil(reader.guidance?.remainingAtResetRange) XCTAssertNil(reader.guidance?.caveat) @@ -1892,7 +1895,11 @@ final class UsageIntelligenceEngineTests: XCTestCase { reader.guidanceMessage, "Couldn’t read Codex usage. Try refreshing again." ) - XCTAssertEqual(reader.suggestedPaceText, "Not enough data") + XCTAssertNil(reader.suggestedPacePercentPerDay) + XCTAssertEqual( + reader.suggestedPaceText, + "Couldn’t read Codex usage. Try refreshing again." + ) } func testReaderFormatsFreshnessFromSuppliedTime() { @@ -2434,6 +2441,122 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertNil(reader.guidance) } + func testSuggestedPaceNeedsOnlyCurrentReadingAndUsesExactFractionalDay() throws { + let now = Date(timeIntervalSince1970: 6_010_000) + let reader = makeReader( + account: makeSnapshot( + remaining: 23, + fetchedAt: now, + resetAfter: 12 * 60 * 60 + ), + now: now + ) + + let pace = try XCTUnwrap(reader.suggestedPacePercentPerDay) + XCTAssertEqual(pace, 40, accuracy: 0.000_001) + XCTAssertTrue(pace.isFinite) + XCTAssertGreaterThanOrEqual(pace, 0) + XCTAssertEqual( + reader.suggestedPaceText, + "Up to 40.0 percentage points a day" + ) + XCTAssertNil(reader.guidance) + } + + func testSuggestedPaceIgnoresLongAccountHistoryGap() throws { + let now = Date(timeIntervalSince1970: 6_020_000) + let account = makeSnapshot(remaining: 40, fetchedAt: now) + let withoutHistory = makeReader(account: account, now: now) + let withLongGap = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: account.mainLimit!.window.startsAt, + remainingPercent: 100, + resetsAt: account.mainLimit!.window.resetsAt + ), + UsageSample( + observedAt: now, + remainingPercent: 40, + resetsAt: account.mainLimit!.window.resetsAt + ) + ], + now: now + ) + + XCTAssertEqual(withLongGap.evidence.coverage, .low) + XCTAssertEqual( + try XCTUnwrap(withLongGap.suggestedPacePercentPerDay), + try XCTUnwrap(withoutHistory.suggestedPacePercentPerDay), + accuracy: 0.000_001 + ) + } + + func testSuggestedPaceClampsAllowanceAtOrBelowBufferToZero() throws { + let now = Date(timeIntervalSince1970: 6_030_000) + let cases: [(remaining: Double, expected: Double)] = [ + (100, 48.5), + (3, 0), + (2, 0), + (0, 0) + ] + + for testCase in cases { + let reader = makeReader( + account: makeSnapshot( + remaining: testCase.remaining, + fetchedAt: now + ), + now: now + ) + let pace = try XCTUnwrap(reader.suggestedPacePercentPerDay) + + XCTAssertEqual(pace, testCase.expected, accuracy: 0.000_001) + XCTAssertTrue(pace.isFinite) + XCTAssertGreaterThanOrEqual(pace, 0) + } + } + + func testSuggestedPaceUsesSpecificUnavailableReasons() { + let now = Date(timeIntervalSince1970: 6_040_000) + let sourceError = "Couldn’t read Codex usage. Try refreshing again." + let missing = makeReader(account: nil, now: now) + let atReset = makeReader( + account: makeSnapshot( + remaining: 50, + fetchedAt: now.addingTimeInterval(-2 * 86_400) + ), + now: now + ) + let expired = makeReader( + account: makeSnapshot( + remaining: 50, + fetchedAt: now.addingTimeInterval(-3 * 86_400) + ), + now: now + ) + let failed = makeReader( + account: makeSnapshot(remaining: 50, fetchedAt: now), + sourceState: .failed(sourceError), + now: now + ) + + XCTAssertNil(missing.suggestedPacePercentPerDay) + XCTAssertEqual(missing.suggestedPaceText, "Weekly usage unavailable") + XCTAssertNil(atReset.suggestedPacePercentPerDay) + XCTAssertEqual( + atReset.suggestedPaceText, + "Current allowance window unavailable" + ) + XCTAssertNil(expired.suggestedPacePercentPerDay) + XCTAssertEqual( + expired.suggestedPaceText, + "Current allowance window unavailable" + ) + XCTAssertNil(failed.suggestedPacePercentPerDay) + XCTAssertEqual(failed.suggestedPaceText, sourceError) + } + func testCompleteCoverageNeedsTheWholeWindowWithoutMaterialGaps() { let step: TimeInterval = 30 * 60 let now = Date(timeIntervalSince1970: 7_000_000) @@ -3344,6 +3467,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { private func makeSnapshot( remaining: Double, fetchedAt: Date, + resetAfter: TimeInterval = 2 * 86_400, tokenHistory: [TokenDay] = [], accountFacts: AccountFacts? = nil, emergencyResetCount: Int = 0, @@ -3352,7 +3476,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) -> UsageSnapshot { let window = UsageWindow( remainingPercent: remaining, - resetsAt: fetchedAt.addingTimeInterval(2 * 86_400), + resetsAt: fetchedAt.addingTimeInterval(resetAfter), durationMinutes: 10_080 ) return UsageSnapshot( @@ -3367,6 +3491,25 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) } + private func makeReader( + account: UsageSnapshot?, + samples: [UsageSample] = [], + safetyBuffer: Double = 3, + sourceState: UsageSourceState = .available, + now: Date + ) -> UsageReaderSnapshot { + UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: samples, + safetyBuffer: safetyBuffer, + sourceState: sourceState, + now: now, + previousStatus: nil + ) + ) + } + private func date(_ value: String) throws -> Date { try XCTUnwrap(ISO8601DateFormatter().date(from: value)) } From a0f0b2ac49b6b4580dcb5b00750bfbee323fa6d1 Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:57:37 +0200 Subject: [PATCH 15/17] fix: restore guidance after account gaps (#59) --- Sources/CodexLimits/ForecastEngine.swift | 35 +- Sources/CodexLimits/MenuContentView.swift | 5 +- .../CodexLimits/UsageIntelligenceEngine.swift | 224 +++++++++-- .../UsageIntelligenceEngineTests.swift | 361 +++++++++++++++++- 4 files changed, 567 insertions(+), 58 deletions(-) diff --git a/Sources/CodexLimits/ForecastEngine.swift b/Sources/CodexLimits/ForecastEngine.swift index 7bbc64c..5fc6679 100644 --- a/Sources/CodexLimits/ForecastEngine.swift +++ b/Sources/CodexLimits/ForecastEngine.swift @@ -1,5 +1,10 @@ import Foundation +struct RecentAccountMovement: Equatable, Sendable { + let latest: UsageSample + let percentPerDay: Double +} + enum ForecastEngine { static func evaluate( window: UsageWindow, @@ -7,9 +12,16 @@ enum ForecastEngine { tokenHistory: [TokenDay], safetyBuffer: Double, now: Date, - previousStatus: PaceStatus? + previousStatus: PaceStatus?, + recentMovement: RecentAccountMovement? = nil ) -> Forecast { - let daysLeft = max(window.resetsAt.timeIntervalSince(now) / 86_400, 0) + let latestDate = recentMovement?.latest.observedAt ?? now + let latestRemaining = recentMovement?.latest.remainingPercent + ?? window.remainingPercent + let daysLeft = max( + window.resetsAt.timeIntervalSince(latestDate) / 86_400, + 0 + ) let currentSamples = samples .filter { $0.resetsAt == window.resetsAt && $0.observedAt <= now } .sorted { $0.observedAt < $1.observedAt } @@ -27,9 +39,10 @@ enum ForecastEngine { recentRate = windowRate } - let currentRate = currentIntervalSamples.count > 1 - ? 0.7 * recentRate + 0.3 * windowRate - : windowRate + let currentRate = recentMovement?.percentPerDay + ?? (currentIntervalSamples.count > 1 + ? 0.7 * recentRate + 0.3 * windowRate + : windowRate) let historicalRates = comparableHistoricalRates( samples: samples, excluding: window.resetsAt @@ -49,11 +62,13 @@ enum ForecastEngine { historicalRate = median(Array(historicalRates.prefix(4))) historicalReferenceSource = .accountHistory } - let expectedRate = 0.75 * currentRate + 0.25 * historicalRate + let expectedRate = recentMovement == nil + ? 0.75 * currentRate + 0.25 * historicalRate + : currentRate let safetyRate = max(currentRate, historicalRate) * 1.2 - let expected = max(window.remainingPercent - expectedRate * daysLeft, 0) - let safety = max(window.remainingPercent - safetyRate * daysLeft, 0) - let historical = max(window.remainingPercent - historicalRate * daysLeft, 0) + let expected = max(latestRemaining - expectedRate * daysLeft, 0) + let safety = max(latestRemaining - safetyRate * daysLeft, 0) + let historical = max(latestRemaining - historicalRate * daysLeft, 0) let historicalReference = historicalReferenceSource.map { UsageForecastReference( source: $0, @@ -62,7 +77,7 @@ enum ForecastEngine { ) } let allowanceRate = daysLeft > 0 - ? max(window.remainingPercent - safetyBuffer, 0) / daysLeft + ? max(latestRemaining - safetyBuffer, 0) / daysLeft : 0 let recommended = historicalReferenceSource == .accountHistory ? min(allowanceRate, historicalRate * 1.2) diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index b65e871..3448c13 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -360,9 +360,6 @@ private struct WorkspaceHeader: View { .font(.callout) .foregroundStyle(.secondary) .lineLimit(isCompact ? 2 : 1) - Text(reader.evidenceText) - .font(.caption) - .foregroundStyle(.tertiary) } } } @@ -592,7 +589,7 @@ private struct GraphsWorkspace: View { GridRow { Text("Runway") .foregroundStyle(.secondary) - Text(reader.guidance?.runway.text ?? "Not enough data") + Text(reader.runwayText) } if let gap = reader.guidance?.runway.gapText { GridRow { diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index 6f87f5b..f2fd496 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -97,7 +97,7 @@ enum UsageRunway: Equatable, Sendable { .locale(Locale(identifier: "en_US")) ) case .throughReset: - "Through reset" + "Limit should last through reset" } } @@ -646,6 +646,7 @@ struct UsageReaderSnapshot: Equatable, Sendable { let freshness: UsageFreshness let evidence: UsageEvidence let guidance: UsageGuidance? + let guidanceUnavailableReason: String? let suggestedPacePercentPerDay: Double? let suggestedPaceUnavailableReason: String? let chart: UsageChartSnapshot @@ -673,12 +674,22 @@ struct UsageReaderSnapshot: Equatable, Sendable { var guidanceTitle: String { guidance?.title + ?? guidanceUnavailableReason ?? (evidence.coverage == .notApplicable ? evidence.reason : nil) - ?? "Not enough data" + ?? "Guidance unavailable" } var guidanceMessage: String { - guidance?.message ?? evidence.reason ?? "More account history is needed." + guidance?.message + ?? guidanceUnavailableReason + ?? evidence.reason + ?? "Guidance unavailable" + } + + var runwayText: String { + guidance?.runway.text + ?? guidanceUnavailableReason + ?? "Guidance unavailable" } var suggestedPaceText: String { @@ -727,6 +738,11 @@ private enum CurrentUsagePolicy { static let partialShare = 0.5 } +private enum RecentMovementBreak { + case resetOrCorrection + case accountChange +} + enum UsageIntelligenceEngine { static func evaluate(_ input: UsageIntelligenceInput) -> UsageReaderSnapshot { let currentSamples = input.account.flatMap { account in @@ -774,20 +790,24 @@ enum UsageIntelligenceEngine { now: input.now, workloadMixChanged: workloadMixChanged ) - let forecast: Forecast? = input.account.flatMap { account in - guard let weeklyLimit = account.mainLimit else { return nil } - guard case .available = input.sourceState, - input.now >= weeklyLimit.window.startsAt, - input.now < weeklyLimit.window.resetsAt else { - return nil - } + let recentMovement = recentAccountMovement( + account: input.account, + samples: input.samples, + sourceState: input.sourceState, + now: input.now, + accountEpochStartedAt: input.accountEpochStartedAt + ) + let forecast: Forecast? = recentMovement.movement.flatMap { movement in + guard let account = input.account, + let weeklyLimit = account.mainLimit else { return nil } return ForecastEngine.evaluate( window: weeklyLimit.window, samples: input.samples, tokenHistory: account.tokenHistory, safetyBuffer: input.safetyBuffer, now: input.now, - previousStatus: input.previousStatus + previousStatus: input.previousStatus, + recentMovement: movement ) } let suggestedPace = suggestedPace( @@ -796,12 +816,11 @@ enum UsageIntelligenceEngine { safetyBuffer: input.safetyBuffer, now: input.now ) - let guidance: UsageGuidance? = forecast.flatMap { forecast in - guard evidence.confidence == .high || evidence.confidence == .medium else { - return nil - } - guard let weeklyLimit = input.account?.mainLimit else { return nil } - return UsageGuidance( + let guidance: UsageGuidance? + if let forecast, + let movement = recentMovement.movement, + let weeklyLimit = input.account?.mainLimit { + guidance = UsageGuidance( source: .derivedEstimate, status: forecast.status, title: title(for: forecast.status), @@ -809,7 +828,7 @@ enum UsageIntelligenceEngine { window: weeklyLimit.window, forecast: forecast, safetyBuffer: input.safetyBuffer, - now: input.now + movement: movement ), suggestedPace: suggestedPace.percentPerDay .map(formattedSuggestedPace) @@ -817,8 +836,7 @@ enum UsageIntelligenceEngine { ?? "Weekly usage unavailable", runway: runway( window: weeklyLimit.window, - forecast: forecast, - now: input.now + movement: movement ), remainingAtResetRange: evidence.confidence == .medium ? estimateRange(forecast) @@ -826,11 +844,14 @@ enum UsageIntelligenceEngine { caveat: evidence.confidence == .medium ? evidence.reason : nil, forecast: forecast ) + } else { + guidance = nil } let chart = chart( account: input.account, samples: input.samples, forecast: forecast, + recentMovement: recentMovement.movement, safetyBuffer: input.safetyBuffer ) let currentFreshness = freshness( @@ -1118,6 +1139,7 @@ enum UsageIntelligenceEngine { freshness: currentFreshness, evidence: evidence, guidance: guidance, + guidanceUnavailableReason: recentMovement.reason, suggestedPacePercentPerDay: suggestedPace.percentPerDay, suggestedPaceUnavailableReason: suggestedPace.reason, chart: chart, @@ -1653,6 +1675,7 @@ enum UsageIntelligenceEngine { account: UsageSnapshot?, samples: [UsageSample], forecast: Forecast?, + recentMovement: RecentAccountMovement?, safetyBuffer: Double ) -> UsageChartSnapshot { guard let account, let weeklyLimit = account.mainLimit else { @@ -1675,7 +1698,7 @@ enum UsageIntelligenceEngine { account: account, samples: samples ) - guard let forecast else { + guard let forecast, let recentMovement else { return UsageChartSnapshot( observedSource: .account, target: target, @@ -1706,7 +1729,7 @@ enum UsageIntelligenceEngine { UsageChartReferenceSeries( source: $0.source, points: projection( - account: account, + reading: recentMovement.latest, window: window, rate: $0.percentPerDay, remainingAtReset: $0.remainingAtReset @@ -1720,7 +1743,7 @@ enum UsageIntelligenceEngine { observedSource: .account, target: target, currentProjection: projection( - account: account, + reading: recentMovement.latest, window: window, rate: forecast.currentPercentPerDay, remainingAtReset: forecast.expectedRemainingAtReset @@ -1831,26 +1854,26 @@ enum UsageIntelligenceEngine { } private static func projection( - account: UsageSnapshot, + reading: UsageSample, window: UsageWindow, rate: Double, remainingAtReset: Double ) -> [UsageChartPoint] { let current = UsageChartPoint( - date: account.fetchedAt, - remaining: window.remainingPercent + date: reading.observedAt, + remaining: reading.remainingPercent ) guard rate > 0 else { return [ current, UsageChartPoint( date: window.resetsAt, - remaining: window.remainingPercent + remaining: reading.remainingPercent ) ] } - let exhaustion = account.fetchedAt.addingTimeInterval( - window.remainingPercent / rate * 86_400 + let exhaustion = reading.observedAt.addingTimeInterval( + reading.remainingPercent / rate * 86_400 ) let endpoint = exhaustion < window.resetsAt ? UsageChartPoint(date: exhaustion, remaining: 0) @@ -1892,6 +1915,130 @@ enum UsageIntelligenceEngine { : .fresh } + private static func recentAccountMovement( + account: UsageSnapshot?, + samples: [UsageSample], + sourceState: UsageSourceState, + now: Date, + accountEpochStartedAt: Date? + ) -> (movement: RecentAccountMovement?, reason: String?) { + if case let .failed(message) = sourceState { + return (nil, message) + } + guard let account, + let window = account.mainLimit?.window, + window.isValid else { + return (nil, "No current weekly allowance reading") + } + guard now >= window.startsAt else { + return (nil, "Current allowance window has not started") + } + guard now < window.resetsAt else { + return (nil, "Current allowance window unavailable") + } + + let current = UsageSample( + observedAt: account.fetchedAt, + remainingPercent: window.remainingPercent, + resetsAt: window.resetsAt, + comparisonBreak: accountEpochStartedAt == account.fetchedAt + ) + let rollingStart = max( + now.addingTimeInterval(-86_400), + window.startsAt, + accountEpochStartedAt ?? .distantPast + ) + let grouped = Dictionary( + grouping: (samples + [current]).filter { + $0.hasValidObservationMetadata + && $0.observedAt >= rollingStart + && $0.observedAt <= account.fetchedAt + && $0.observedAt <= now + }, + by: \.observedAt + ) + var conflicts = Set() + let readings = grouped.compactMap { date, values -> UsageSample? in + let preferred = values.first { + date == current.observedAt + && $0.remainingPercent == current.remainingPercent + && $0.resetsAt == current.resetsAt + } ?? values.min { + if $0.resetsAt != $1.resetsAt { + return $0.resetsAt < $1.resetsAt + } + return $0.remainingPercent < $1.remainingPercent + } + guard let preferred else { return nil } + if values.contains(where: { + $0.remainingPercent != preferred.remainingPercent + || $0.resetsAt != preferred.resetsAt + }) { + conflicts.insert(date) + } + return UsageSample( + observedAt: preferred.observedAt, + remainingPercent: preferred.remainingPercent, + resetsAt: preferred.resetsAt, + lifetimeTokens: preferred.lifetimeTokens, + comparisonBreak: values.contains { $0.comparisonBreak } + ) + }.sorted { $0.observedAt < $1.observedAt } + + var segment: [UsageSample] = [] + var latestBreak: RecentMovementBreak? + for reading in readings { + let accountChanged = reading.comparisonBreak + && accountEpochStartedAt == reading.observedAt + if conflicts.contains(reading.observedAt) + || reading.comparisonBreak + || segment.last.map({ + $0.resetsAt != reading.resetsAt + || reading.remainingPercent + > $0.remainingPercent + + UsageHistoryPolicy.correctionTolerance + }) == true { + segment = [reading] + latestBreak = accountChanged ? .accountChange : .resetOrCorrection + } else { + segment.append(reading) + } + } + + guard let first = segment.first, + let latest = segment.last, + segment.count >= 2, + latest.observedAt > first.observedAt else { + switch latestBreak { + case .accountChange: + return (nil, "Account changed — waiting for a second reading") + case .resetOrCorrection: + return ( + nil, + "Waiting for a second account reading after reset or correction" + ) + case nil: + return (nil, "At least two compatible account readings are needed") + } + } + let elapsedDays = latest.observedAt.timeIntervalSince(first.observedAt) + / 86_400 + let percentPerDay = max( + (first.remainingPercent - latest.remainingPercent) / elapsedDays, + 0 + ) + guard percentPerDay.isFinite else { + return (nil, "At least two compatible account readings are needed") + } + return ( + RecentAccountMovement( + latest: latest, + percentPerDay: percentPerDay + ), + nil + ) + } + private static func evidence( account: UsageSnapshot?, samples: [UsageSample], @@ -1903,7 +2050,7 @@ enum UsageIntelligenceEngine { return UsageEvidence( coverage: .unavailable, confidence: .unavailable, - reason: "Weekly usage unavailable", + reason: "No current weekly allowance reading", policyVersion: CurrentUsagePolicy.version ) } @@ -2042,12 +2189,11 @@ enum UsageIntelligenceEngine { private static func runway( window: UsageWindow, - forecast: Forecast, - now: Date + movement: RecentAccountMovement ) -> UsageRunway { - guard forecast.currentPercentPerDay > 0 else { return .throughReset } - let exhaustsAt = now.addingTimeInterval( - window.remainingPercent / forecast.currentPercentPerDay * 86_400 + guard movement.percentPerDay > 0 else { return .throughReset } + let exhaustsAt = movement.latest.observedAt.addingTimeInterval( + movement.latest.remainingPercent / movement.percentPerDay * 86_400 ) return exhaustsAt < window.resetsAt ? .exhausts( @@ -2081,12 +2227,14 @@ enum UsageIntelligenceEngine { window: UsageWindow, forecast: Forecast, safetyBuffer: Double, - now: Date + movement: RecentAccountMovement ) -> String { switch forecast.status { case .slowDown: - let timeLeft = window.resetsAt.timeIntervalSince(now) - let timeToEmpty = window.remainingPercent + let timeLeft = window.resetsAt.timeIntervalSince( + movement.latest.observedAt + ) + let timeToEmpty = movement.latest.remainingPercent / max(forecast.safetyPercentPerDay, 0.01) * 86_400 let early = max(timeLeft - timeToEmpty, 0) return early > 0 diff --git a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift index 49b71c9..1474629 100644 --- a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift +++ b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift @@ -1890,7 +1890,10 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertNil(reader.guidance) XCTAssertTrue(reader.chart.currentProjection.isEmpty) XCTAssertTrue(reader.chart.historicalProjection.isEmpty) - XCTAssertEqual(reader.guidanceTitle, "Not enough data") + XCTAssertEqual( + reader.guidanceTitle, + "Couldn’t read Codex usage. Try refreshing again." + ) XCTAssertEqual( reader.guidanceMessage, "Couldn’t read Codex usage. Try refreshing again." @@ -1946,7 +1949,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertEqual(reader.evidence.coverage, .low) XCTAssertEqual(reader.evidence.confidence, .low) XCTAssertEqual(reader.evidence.reason, "Less than half of this weekly window is observed") - XCTAssertNil(reader.guidance) + XCTAssertNotNil(reader.guidance) XCTAssertFalse(reader.chart.currentProjection.isEmpty) } @@ -1999,7 +2002,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) XCTAssertEqual(reader.evidence.confidence, .low) - XCTAssertNil(reader.guidance) + XCTAssertNotNil(reader.guidance) XCTAssertEqual(reader.chart.currentProjection.count, 2) XCTAssertEqual(reader.chart.historicalProjection.count, 2) XCTAssertTrue(reader.chart.estimatedBackfill.isEmpty) @@ -2140,7 +2143,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) } - func testUnknownCorrectionWithholdsGuidance() { + func testCorrectionRestoresGuidanceAfterSecondReading() { let now = Date(timeIntervalSince1970: 5_900_000) let account = makeSnapshot(remaining: 70, fetchedAt: now) var samples = denseSamples( @@ -2170,7 +2173,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertEqual(reader.evidence.coverage, .low) XCTAssertEqual(reader.evidence.confidence, .low) XCTAssertEqual(reader.evidence.reason, "Unknown reset or correction") - XCTAssertNil(reader.guidance) + XCTAssertNotNil(reader.guidance) XCTAssertEqual(reader.chart.observedSegments.count, 2) XCTAssertEqual(reader.chart.observedSegments.flatMap { $0 }, reader.chart.observed) } @@ -2274,7 +2277,10 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertNil(reader.weeklyUsageRemaining) XCTAssertEqual(reader.evidence.coverage, .unavailable) - XCTAssertEqual(reader.evidence.reason, "Weekly usage unavailable") + XCTAssertEqual( + reader.evidence.reason, + "No current weekly allowance reading" + ) XCTAssertNil(reader.guidance) XCTAssertTrue(reader.chart.observed.isEmpty) } @@ -2439,6 +2445,349 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertEqual(reader.evidence.confidence, .low) XCTAssertEqual(reader.evidence.reason, "Not enough account history") XCTAssertNil(reader.guidance) + XCTAssertEqual( + reader.guidanceTitle, + "At least two compatible account readings are needed" + ) + } + + func testRecentMovementUsesTwoReadingsFromLessThanTwentyFourHours() throws { + let now = Date(timeIntervalSince1970: 6_005_000) + let account = makeSnapshot(remaining: 70, fetchedAt: now) + let first = UsageSample( + observedAt: now.addingTimeInterval(-6 * 3_600), + remainingPercent: 75, + resetsAt: account.mainLimit!.window.resetsAt + ) + + let reader = makeReader(account: account, samples: [first], now: now) + let forecast = try XCTUnwrap(reader.guidance?.forecast) + + XCTAssertEqual(forecast.currentPercentPerDay, 20, accuracy: 0.000_001) + XCTAssertEqual(reader.chart.currentProjection.first?.date, now) + XCTAssertEqual(reader.chart.currentProjection.first?.remaining, 70) + } + + func testRecentMovementUsesExactRollingDayAndExcludesOlderReadings() throws { + let now = Date(timeIntervalSince1970: 6_006_000) + let account = makeSnapshot(remaining: 70, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let boundary = UsageSample( + observedAt: now.addingTimeInterval(-86_400), + remainingPercent: 90, + resetsAt: reset + ) + let reader = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-25 * 3_600), + remainingPercent: 100, + resetsAt: reset + ), + boundary + ], + now: now + ) + let forecast = try XCTUnwrap(reader.guidance?.forecast) + + XCTAssertEqual(forecast.currentPercentPerDay, 20, accuracy: 0.000_001) + } + + func testLongCompatibleIntervalIsFactualCurrentMovement() throws { + let now = Date(timeIntervalSince1970: 6_007_000) + let account = makeSnapshot(remaining: 70, fetchedAt: now) + let gap = 8 * 3_600 + 36 * 60 + let reader = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-Double(gap)), + remainingPercent: 80, + resetsAt: account.mainLimit!.window.resetsAt + ) + ], + now: now + ) + let forecast = try XCTUnwrap(reader.guidance?.forecast) + + XCTAssertEqual( + forecast.currentPercentPerDay, + 10 / (Double(gap) / 86_400), + accuracy: 0.000_001 + ) + XCTAssertNotNil(reader.guidance) + XCTAssertEqual(reader.chart.currentProjection.count, 2) + } + + func testZeroRecentMovementLastsThroughResetAndProjectsFlat() throws { + let now = Date(timeIntervalSince1970: 6_008_000) + let account = makeSnapshot(remaining: 50, fetchedAt: now) + let reader = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 50, + resetsAt: account.mainLimit!.window.resetsAt + ) + ], + now: now + ) + + XCTAssertEqual(reader.guidance?.runway, .throughReset) + XCTAssertEqual(reader.runwayText, "Limit should last through reset") + XCTAssertEqual(reader.guidance?.forecast.currentPercentPerDay, 0) + XCTAssertEqual( + reader.chart.currentProjection.last, + UsageChartPoint( + date: account.mainLimit!.window.resetsAt, + remaining: 50 + ) + ) + } + + func testAllowanceBreaksNeedOneThenTwoNewReadings() { + let now = Date(timeIntervalSince1970: 6_009_000) + let account = makeSnapshot( + remaining: 70, + fetchedAt: now, + resetAfter: 7 * 86_400 - 4 * 3_600 + ) + let reset = account.mainLimit!.window.resetsAt + let previousReset = account.mainLimit!.window.startsAt + + let afterResetWaiting = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: previousReset, + remainingPercent: 40, + resetsAt: previousReset + ) + ], + now: now + ) + let afterResetRestored = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: previousReset, + remainingPercent: 40, + resetsAt: previousReset + ), + UsageSample( + observedAt: previousReset.addingTimeInterval(3_600), + remainingPercent: 80, + resetsAt: reset + ) + ], + now: now + ) + let afterCorrectionWaiting = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now, + remainingPercent: 70, + resetsAt: reset, + comparisonBreak: true + ) + ], + now: now + ) + let afterCorrectionRestored = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 80, + resetsAt: reset, + comparisonBreak: true + ) + ], + now: now + ) + let afterIncreaseWaiting = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 50, + resetsAt: reset + ) + ], + now: now + ) + let afterIncreaseRestored = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-2 * 3_600), + remainingPercent: 50, + resetsAt: reset + ), + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 80, + resetsAt: reset + ) + ], + now: now + ) + let accountChanged = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [], + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + accountEpochStartedAt: now + ) + ) + let accountRecovered = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 80, + resetsAt: reset, + comparisonBreak: true + ) + ], + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + accountEpochStartedAt: now.addingTimeInterval(-3_600) + ) + ) + + XCTAssertEqual( + afterResetWaiting.guidanceUnavailableReason, + "Waiting for a second account reading after reset or correction" + ) + XCTAssertNotNil(afterResetRestored.guidance) + XCTAssertEqual( + afterCorrectionWaiting.guidanceUnavailableReason, + "Waiting for a second account reading after reset or correction" + ) + XCTAssertNotNil(afterCorrectionRestored.guidance) + XCTAssertEqual( + afterIncreaseWaiting.guidanceUnavailableReason, + "Waiting for a second account reading after reset or correction" + ) + XCTAssertNotNil(afterIncreaseRestored.guidance) + XCTAssertEqual( + accountChanged.guidanceUnavailableReason, + "Account changed — waiting for a second reading" + ) + XCTAssertNotNil(accountRecovered.guidance) + } + + func testRunwayAndCurrentEstimateSharePaceAndLatestReading() throws { + let now = Date(timeIntervalSince1970: 6_009_500) + let account = makeSnapshot(remaining: 20, fetchedAt: now) + let reader = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-12 * 3_600), + remainingPercent: 80, + resetsAt: account.mainLimit!.window.resetsAt + ) + ], + now: now + ) + let forecast = try XCTUnwrap(reader.guidance?.forecast) + let firstProjection = try XCTUnwrap(reader.chart.currentProjection.first) + let lastProjection = try XCTUnwrap(reader.chart.currentProjection.last) + guard case let .exhausts(runwayEnd, _) = reader.guidance?.runway else { + return XCTFail("Expected current pace to exhaust the allowance") + } + + XCTAssertEqual(firstProjection.date, account.fetchedAt) + XCTAssertEqual(firstProjection.remaining, 20) + XCTAssertEqual(forecast.currentPercentPerDay, 120, accuracy: 0.000_001) + XCTAssertEqual(lastProjection.date, runwayEnd) + } + + func testRefreshFailureRecoversWithoutExtendingObservedFacts() { + let now = Date(timeIntervalSince1970: 6_009_750) + let fetchedAt = now.addingTimeInterval(-10 * 60) + let account = makeSnapshot(remaining: 70, fetchedAt: fetchedAt) + let samples = [ + UsageSample( + observedAt: fetchedAt.addingTimeInterval(-3_600), + remainingPercent: 80, + resetsAt: account.mainLimit!.window.resetsAt + ) + ] + let failed = makeReader( + account: account, + samples: samples, + sourceState: .failed("Account source failed"), + now: now + ) + let recovered = makeReader( + account: account, + samples: samples, + now: now + ) + + XCTAssertNil(failed.guidance) + XCTAssertEqual(failed.guidanceTitle, "Account source failed") + XCTAssertEqual(failed.chart.observed.last?.date, fetchedAt) + XCTAssertTrue(failed.chart.currentProjection.isEmpty) + XCTAssertNil(failed.suggestedPacePercentPerDay) + XCTAssertNotNil(recovered.guidance) + XCTAssertFalse(recovered.chart.currentProjection.isEmpty) + XCTAssertNotNil(recovered.suggestedPacePercentPerDay) + } + + func testProductionGapShapeStillProducesCurrentGuidance() throws { + let now = Date(timeIntervalSince1970: 6_009_900) + let account = makeSnapshot(remaining: 60, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let gapStart = now.addingTimeInterval(-(47 * 3_600 + 27 * 60)) + let denseStart = gapStart.addingTimeInterval(8 * 3_600 + 36 * 60) + var samples = [ + UsageSample( + observedAt: gapStart, + remainingPercent: 92, + resetsAt: reset + ) + ] + samples += stride( + from: denseStart.timeIntervalSince1970, + to: now.timeIntervalSince1970, + by: 20 * 60 + ).map { timestamp in + let progress = (timestamp - denseStart.timeIntervalSince1970) + / now.timeIntervalSince(denseStart) + return UsageSample( + observedAt: Date(timeIntervalSince1970: timestamp), + remainingPercent: 85 - 25 * progress, + resetsAt: reset + ) + } + let maximumGap = zip(samples, samples.dropFirst()) + .map { $1.observedAt.timeIntervalSince($0.observedAt) } + .max() ?? 0 + let reader = makeReader(account: account, samples: samples, now: now) + let withoutHistory = makeReader(account: account, now: now) + + XCTAssertGreaterThan(maximumGap, 6 * 3_600) + XCTAssertGreaterThan(now.timeIntervalSince(denseStart), 24 * 3_600) + XCTAssertNotNil(reader.guidance) + XCTAssertFalse(reader.chart.currentProjection.isEmpty) + XCTAssertEqual( + try XCTUnwrap(reader.suggestedPacePercentPerDay), + try XCTUnwrap(withoutHistory.suggestedPacePercentPerDay), + accuracy: 0.000_001 + ) } func testSuggestedPaceNeedsOnlyCurrentReadingAndUsesExactFractionalDay() throws { From 600fbd3e7b34afa0c81c9818e58c73e0af9c7d91 Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:09:27 +0200 Subject: [PATCH 16/17] fix: address Stage C verification --- Sources/CodexLimits/ForecastEngine.swift | 6 +- .../CodexLimits/UsageIntelligenceEngine.swift | 43 ++++++++++---- .../UsageIntelligenceEngineTests.swift | 59 +++++++++++++++++++ 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/Sources/CodexLimits/ForecastEngine.swift b/Sources/CodexLimits/ForecastEngine.swift index 5fc6679..c907f4e 100644 --- a/Sources/CodexLimits/ForecastEngine.swift +++ b/Sources/CodexLimits/ForecastEngine.swift @@ -65,7 +65,11 @@ enum ForecastEngine { let expectedRate = recentMovement == nil ? 0.75 * currentRate + 0.25 * historicalRate : currentRate - let safetyRate = max(currentRate, historicalRate) * 1.2 + let safetyRate = ( + recentMovement == nil + ? max(currentRate, historicalRate) + : currentRate + ) * 1.2 let expected = max(latestRemaining - expectedRate * daysLeft, 0) let safety = max(latestRemaining - safetyRate * daysLeft, 0) let historical = max(latestRemaining - historicalRate * daysLeft, 0) diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index f2fd496..92c7099 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -852,6 +852,7 @@ enum UsageIntelligenceEngine { samples: input.samples, forecast: forecast, recentMovement: recentMovement.movement, + sourceState: input.sourceState, safetyBuffer: input.safetyBuffer ) let currentFreshness = freshness( @@ -1676,6 +1677,7 @@ enum UsageIntelligenceEngine { samples: [UsageSample], forecast: Forecast?, recentMovement: RecentAccountMovement?, + sourceState: UsageSourceState, safetyBuffer: Double ) -> UsageChartSnapshot { guard let account, let weeklyLimit = account.mainLimit else { @@ -1698,11 +1700,41 @@ enum UsageIntelligenceEngine { account: account, samples: samples ) + let historicalReference: UsageForecastReference? = if case .available = sourceState { + chartHistoricalReference( + samples: samples, + excluding: window.resetsAt, + remaining: window.remainingPercent, + daysLeft: max( + window.resetsAt.timeIntervalSince(account.fetchedAt) + / 86_400, + 0 + ) + ) + } else { + nil + } guard let forecast, let recentMovement else { + let reference = historicalReference.map { + UsageChartReferenceSeries( + source: $0.source, + points: projection( + reading: UsageSample( + observedAt: account.fetchedAt, + remainingPercent: window.remainingPercent, + resetsAt: window.resetsAt + ), + window: window, + rate: $0.percentPerDay, + remainingAtReset: $0.remainingAtReset + ) + ) + } return UsageChartSnapshot( observedSource: .account, target: target, currentProjection: [], + reference: reference, currentAllowanceReset: window.resetsAt, allowanceWindows: allowanceWindows, currentRunsFaster: false, @@ -1714,16 +1746,7 @@ enum UsageIntelligenceEngine { ? forecast.historicalReference : nil let chartReference = strictAccountReference - ?? chartHistoricalReference( - samples: samples, - excluding: window.resetsAt, - remaining: window.remainingPercent, - daysLeft: max( - window.resetsAt.timeIntervalSince(account.fetchedAt) - / 86_400, - 0 - ) - ) + ?? historicalReference ?? forecast.historicalReference let reference = chartReference.map { UsageChartReferenceSeries( diff --git a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift index 1474629..7b2790c 100644 --- a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift +++ b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift @@ -2547,6 +2547,65 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) } + func testZeroRecentMovementKeepsPrimaryGuidanceConsistentWithFastHistory() { + let day: TimeInterval = 86_400 + let now = Date(timeIntervalSince1970: 6_008_500) + let account = makeSnapshot( + remaining: 50, + fetchedAt: now, + tokenHistory: (-33 ... -6).map { + TokenDay( + date: now.addingTimeInterval(Double($0) * day), + tokens: 400 + ) + } + (-5 ... -1).map { + TokenDay( + date: now.addingTimeInterval(Double($0) * day), + tokens: 100 + ) + } + ) + let reader = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 50, + resetsAt: account.mainLimit!.window.resetsAt + ) + ], + now: now + ) + + XCTAssertEqual(reader.guidance?.forecast.currentPercentPerDay, 0) + XCTAssertEqual(reader.guidance?.status, .roomToUseMore) + XCTAssertEqual(reader.guidance?.runway, .throughReset) + XCTAssertEqual( + reader.chart.currentProjection.last?.remaining, + 50 + ) + } + + func testPastEstimateRemainsWhenCurrentMovementNeedsAnotherReading() { + let now = Date(timeIntervalSince1970: 6_008_750) + let account = makeSnapshot(remaining: 70, fetchedAt: now) + let priorReset = account.mainLimit!.window.startsAt + let history = weeklySamples( + start: priorReset.addingTimeInterval(-12 * 3_600), + end: priorReset, + reset: priorReset, + startRemaining: 80, + endRemaining: 60, + startTokens: 1_000, + endTokens: 2_000 + ) + let reader = makeReader(account: account, samples: history, now: now) + + XCTAssertNil(reader.guidance) + XCTAssertTrue(reader.chart.currentProjection.isEmpty) + XCTAssertEqual(reader.chart.historicalProjection.count, 2) + } + func testAllowanceBreaksNeedOneThenTwoNewReadings() { let now = Date(timeIntervalSince1970: 6_009_000) let account = makeSnapshot( From e6261f10c9df6fe9edcdc11d41e0852e92b3358c Mon Sep 17 00:00:00 2001 From: thrr87 <193831865+thrr87@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:02:54 +0200 Subject: [PATCH 17/17] chore: prepare v0.2.6 --- Resources/Info.plist | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Resources/Info.plist b/Resources/Info.plist index bc2b3c6..4adf947 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -13,9 +13,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.2.5 + 0.2.6 CFBundleVersion - 6 + 7 LSApplicationCategoryType public.app-category.developer-tools LSMinimumSystemVersion