From 02d3f85996267df698ff466d107944b101fe6dcd Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:38:59 +0700 Subject: [PATCH 1/2] Port upstream 0.65.0: confirm unused rolling weekly Codex resets --- rust/src/providers/codex/weekly_reset.rs | 30 ++- .../src/providers/codex/weekly_reset/tests.rs | 210 ++++++++++++++++++ 2 files changed, 238 insertions(+), 2 deletions(-) diff --git a/rust/src/providers/codex/weekly_reset.rs b/rust/src/providers/codex/weekly_reset.rs index fdc4bb65a9..c27a3158eb 100644 --- a/rust/src/providers/codex/weekly_reset.rs +++ b/rust/src/providers/codex/weekly_reset.rs @@ -15,6 +15,8 @@ const RESET_TOLERANCE_SECONDS: i64 = 2 * 60; const STABLE_BOUNDARY_TOLERANCE_SECONDS: i64 = 1; const CANDIDATE_MINIMUM_AGE_SECONDS: i64 = 60; const CANDIDATE_MAXIMUM_AGE_SECONDS: i64 = 30 * 60; +const WEEKLY_WINDOW_MINUTES: u32 = 7 * 24 * 60; +const WEEKLY_WINDOW_SECONDS: i64 = WEEKLY_WINDOW_MINUTES as i64 * 60; #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -578,8 +580,7 @@ fn delayed_candidate_decision( ); return DelayedDecision::Discard; } - if boundary_distance_seconds(&candidate.weekly, current_weekly).abs() >= RESET_TOLERANCE_SECONDS - { + if !delayed_boundaries_consistent(candidate, current_weekly, current.updated_at) { log_reset_diagnostic( "delayedCandidate", "discard", @@ -665,6 +666,31 @@ fn boundary_moves_backward(previous: &RateWindow, current: &RateWindow) -> bool boundary_distance_seconds(previous, current) < -RESET_TOLERANCE_SECONDS } +/// Delayed confirmation accepts equivalent boundaries, or an unused rolling +/// weekly window whose reset date advances with each zero-use observation. +fn delayed_boundaries_consistent( + candidate: &DelayedCandidate, + current_weekly: &RateWindow, + current_updated_at: DateTime, +) -> bool { + if boundary_distance_seconds(&candidate.weekly, current_weekly).abs() < RESET_TOLERANCE_SECONDS + { + return true; + } + is_unused_rolling_weekly(&candidate.weekly, candidate.snapshot_updated_at) + && is_unused_rolling_weekly(current_weekly, current_updated_at) + && boundary_distance_seconds(&candidate.weekly, current_weekly) >= 0 +} + +fn is_unused_rolling_weekly(window: &RateWindow, captured_at: DateTime) -> bool { + window.used_percent == 0.0 + && window.window_minutes == Some(WEEKLY_WINDOW_MINUTES) + && window.resets_at.is_some_and(|boundary| { + let ahead = boundary.signed_duration_since(captured_at).num_seconds(); + (ahead - WEEKLY_WINDOW_SECONDS).abs() < RESET_TOLERANCE_SECONDS + }) +} + fn supported_delayed_boundary(previous: &RateWindow, current: &RateWindow) -> bool { let distance = boundary_distance_seconds(previous, current); distance.abs() < STABLE_BOUNDARY_TOLERANCE_SECONDS || distance >= RESET_TOLERANCE_SECONDS diff --git a/rust/src/providers/codex/weekly_reset/tests.rs b/rust/src/providers/codex/weekly_reset/tests.rs index 9859fd9834..224ab8c355 100644 --- a/rust/src/providers/codex/weekly_reset/tests.rs +++ b/rust/src/providers/codex/weekly_reset/tests.rs @@ -317,3 +317,213 @@ fn consumed_credit_allows_immediate_confirmation() { ConfirmationDecision::Publish ); } + +const WEEK_SECONDS: i64 = 7 * 24 * 60 * 60; + +/// Unused weekly window whose reset date sits `boundary_ahead` seconds after its own capture time. +fn rolling_snapshot( + used: f64, + window_minutes: u32, + captured_seconds: i64, + boundary_ahead: i64, +) -> UsageSnapshot { + let captured = now() + chrono::Duration::seconds(captured_seconds); + let weekly = RateWindow::with_details( + used, + Some(window_minutes), + Some(captured + chrono::Duration::seconds(boundary_ahead)), + None, + ); + let mut snapshot = UsageSnapshot::new(RateWindow::new(20.0)).with_secondary(weekly); + snapshot.updated_at = captured; + snapshot.login_method = Some("ChatGPT Pro".to_string()); + snapshot +} + +fn rolling_current(offset_seconds: i64) -> UsageSnapshot { + rolling_snapshot(0.0, 7 * 24 * 60, offset_seconds, WEEK_SECONDS - 1) +} + +/// Candidate created from two unused observations whose boundaries roll with capture time. +fn rolling_state() -> AccountState { + let mut state = baseline(); + let inv = inventory("credit-a"); + let initial = rolling_snapshot(0.0, 7 * 24 * 60, 1, WEEK_SECONDS - 1); + let confirmation = rolling_snapshot(0.0, 7 * 24 * 60, 2, WEEK_SECONDS - 2); + assert_eq!( + confirmation_decision( + &mut state, + &initial, + Some(&inv), + &confirmation, + Some(&inv), + true, + now(), + ), + ConfirmationDecision::Preserve + ); + assert!(state.candidate.is_some()); + state +} + +fn rolling_decision( + state: &AccountState, + current: &UsageSnapshot, + inv: &CreditInventory, + age_seconds: i64, +) -> DelayedDecision { + let candidate = state.candidate.clone().unwrap(); + delayed_candidate_decision( + state, + &candidate, + current, + Some(inv), + true, + now() + chrono::Duration::seconds(age_seconds), + ) +} + +#[test] +fn unused_rolling_weekly_boundaries_confirm_across_refresh_intervals() { + let inv = inventory("credit-a"); + for offset in [180, 300, 900] { + let state = rolling_state(); + let current = rolling_current(offset); + assert_eq!( + rolling_decision(&state, ¤t, &inv, offset), + DelayedDecision::Publish, + "offset {offset}" + ); + } + let state = rolling_state(); + assert_eq!( + rolling_decision(&state, &rolling_current(30), &inv, 30), + DelayedDecision::Retain, + "minimum age still applies" + ); + // Equivalent boundaries keep working exactly as before. + assert_eq!( + rolling_decision( + &state, + &rolling_snapshot(0.0, 7 * 24 * 60, 120, WEEK_SECONDS - 118), + &inv, + 120 + ), + DelayedDecision::Publish + ); +} + +#[test] +fn ordinary_publication_after_rolling_confirmation_is_unchanged() { + let mut state = rolling_state(); + let ordinary = rolling_snapshot(2.0, 7 * 24 * 60, 300, WEEK_SECONDS - 1); + assert_eq!( + initial_decision( + &mut state, + &ordinary, + Some(&inventory("credit-a")), + true, + now() + chrono::Duration::seconds(300), + ), + InitialDecision::Publish + ); +} + +#[test] +fn rolling_weekly_confirmation_rejects_incompatible_observations() { + let inv = inventory("credit-a"); + let week_minutes = 7 * 24 * 60; + let cases: Vec<(&str, UsageSnapshot)> = vec![ + ( + "nonzero usage", + rolling_snapshot(0.5, week_minutes, 300, WEEK_SECONDS - 1), + ), + ( + "wrong window minutes", + rolling_snapshot(0.0, 300, 300, WEEK_SECONDS - 1), + ), + ( + "boundary just outside capture plus one week", + rolling_snapshot(0.0, week_minutes, 300, WEEK_SECONDS + 121), + ), + ( + "boundary far from capture plus one week", + rolling_snapshot(0.0, week_minutes, 300, 600_000), + ), + ]; + for (name, current) in cases { + let state = rolling_state(); + assert_eq!( + rolling_decision(&state, ¤t, &inv, 300), + DelayedDecision::Discard, + "{name}" + ); + } +} + +#[test] +fn rolling_weekly_confirmation_rejects_a_boundary_that_moves_backward() { + let inv = inventory("credit-a"); + let mut state = rolling_state(); + // Both windows stay within two minutes of capture plus one week, but the + // later observation resets earlier than the candidate. + let candidate = state.candidate.as_mut().unwrap(); + candidate.weekly.resets_at = + Some(candidate.snapshot_updated_at + chrono::Duration::seconds(WEEK_SECONDS + 100)); + let current = rolling_snapshot(0.0, 7 * 24 * 60, 62, WEEK_SECONDS - 100); + assert_eq!( + rolling_decision(&state, ¤t, &inv, 62), + DelayedDecision::Discard + ); + // The same pair with a non-decreasing boundary confirms. + let current = rolling_snapshot(0.0, 7 * 24 * 60, 62, WEEK_SECONDS + 100); + assert_eq!( + rolling_decision(&state, ¤t, &inv, 62), + DelayedDecision::Publish + ); +} + +#[test] +fn rolling_weekly_confirmation_rejects_nonzero_or_mismatched_candidate_window() { + let inv = inventory("credit-a"); + for (name, mutate) in [ + ( + "candidate used", + (|weekly: &mut RateWindow| weekly.used_percent = 0.5) as fn(&mut RateWindow), + ), + ("candidate window minutes", |weekly| { + weekly.window_minutes = Some(300); + }), + ("candidate boundary not near a week", |weekly| { + weekly.resets_at = weekly.resets_at.map(|at| at + chrono::Duration::hours(1)); + }), + ] { + let mut state = rolling_state(); + if let Some(candidate) = state.candidate.as_mut() { + mutate(&mut candidate.weekly); + } + assert_eq!( + rolling_decision(&state, &rolling_current(300), &inv, 300), + DelayedDecision::Discard, + "{name}" + ); + } +} + +#[test] +fn rolling_weekly_confirmation_keeps_inventory_and_expiry_guards() { + let state = rolling_state(); + let current = rolling_current(300); + assert_eq!( + rolling_decision(&state, ¤t, &inventory("credit-b"), 300), + DelayedDecision::Discard, + "inventory changed" + ); + let inv = inventory("credit-a"); + let current = rolling_current(31 * 60); + assert_eq!( + rolling_decision(&state, ¤t, &inv, 31 * 60), + DelayedDecision::Discard, + "candidate expired" + ); +} From 4a1674e665fb9aa432729e1143463ba248bc9d89 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:31:12 +0700 Subject: [PATCH 2/2] Port upstream 0.65.0: exact rolling weekly checks and upstream reset tests - Compare the unused rolling weekly offset at full timestamp precision, like upstream's floating-point |boundary - capturedAt - 604800| < 120 check; the seconds-truncated version rejected resets 119.x s short of one week. - Split delayed-candidate revalidation into a pure evaluation that returns the decision and its fixed reason code, and report a missing current credit inventory as missingCreditInventory instead of changedCreditInventory. - Port the upstream rejection table (plan mismatch, changed and missing credit inventory, non-exact source) with reason codes, the confirmed observation reason, and the persisted relaunch test for fixed and rolling boundaries through the StateFile envelope. --- rust/src/providers/codex/weekly_reset.rs | 161 +++++------ .../src/providers/codex/weekly_reset/tests.rs | 262 +++++++++++++++++- 2 files changed, 325 insertions(+), 98 deletions(-) diff --git a/rust/src/providers/codex/weekly_reset.rs b/rust/src/providers/codex/weekly_reset.rs index c27a3158eb..9d0c306a89 100644 --- a/rust/src/providers/codex/weekly_reset.rs +++ b/rust/src/providers/codex/weekly_reset.rs @@ -95,6 +95,16 @@ pub(super) enum DelayedDecision { Discard, } +impl DelayedDecision { + const fn diagnostic_code(self) -> &'static str { + match self { + Self::Publish => "publish", + Self::Retain => "retain", + Self::Discard => "discard", + } + } +} + mod diagnostics; use diagnostics::{ResetDiagnosticReason, log_reset_diagnostic}; @@ -492,132 +502,85 @@ fn delayed_candidate_decision( exact_oauth: bool, observed_at: DateTime, ) -> DelayedDecision { + let (decision, reason) = delayed_candidate_evaluation( + state, + candidate, + current, + current_inventory, + exact_oauth, + observed_at, + ); + log_reset_diagnostic("delayedCandidate", decision.diagnostic_code(), reason); + decision +} + +/// Pure delayed-candidate revalidation: the decision plus the fixed reason +/// code that `delayed_candidate_decision` logs (upstream `DelayedEvaluation`). +fn delayed_candidate_evaluation( + state: &AccountState, + candidate: &DelayedCandidate, + current: &UsageSnapshot, + current_inventory: Option<&CreditInventory>, + exact_oauth: bool, + observed_at: DateTime, +) -> (DelayedDecision, ResetDiagnosticReason) { + use DelayedDecision::{Discard, Publish, Retain}; + use ResetDiagnosticReason as Reason; + let age = observed_at .signed_duration_since(candidate.created_at) .num_seconds(); if candidate.evidence_version != EVIDENCE_VERSION { - log_reset_diagnostic( - "delayedCandidate", - "discard", - ResetDiagnosticReason::EvidenceVersionMismatch, - ); - return DelayedDecision::Discard; + return (Discard, Reason::EvidenceVersionMismatch); } if age < 0 { - log_reset_diagnostic( - "delayedCandidate", - "discard", - ResetDiagnosticReason::FutureCandidate, - ); - return DelayedDecision::Discard; + return (Discard, Reason::FutureCandidate); } if age > CANDIDATE_MAXIMUM_AGE_SECONDS { - log_reset_diagnostic( - "delayedCandidate", - "discard", - ResetDiagnosticReason::ExpiredCandidate, - ); - return DelayedDecision::Discard; + return (Discard, Reason::ExpiredCandidate); } if !exact_oauth { - log_reset_diagnostic( - "delayedCandidate", - "discard", - ResetDiagnosticReason::SourceNotExactOAuth, - ); - return DelayedDecision::Discard; + return (Discard, Reason::SourceNotExactOAuth); } let Some(previous_weekly) = state.published_weekly.as_ref() else { - log_reset_diagnostic( - "delayedCandidate", - "discard", - ResetDiagnosticReason::MissingPreviousSnapshot, - ); - return DelayedDecision::Discard; + return (Discard, Reason::MissingPreviousSnapshot); }; let Some(current_weekly) = weekly(current) else { // Credits-only refreshes do not carry the weekly window (or necessarily // the plan/inventory fields). Preserve the candidate and let the next // complete usage observation validate it. - log_reset_diagnostic( - "delayedCandidate", - "retain", - ResetDiagnosticReason::MissingWeeklyWindow, - ); - return DelayedDecision::Retain; + return (Retain, Reason::MissingWeeklyWindow); }; if !plans_match(state.plan.as_deref(), current, current) { - log_reset_diagnostic( - "delayedCandidate", - "discard", - ResetDiagnosticReason::PlanMismatch, - ); - return DelayedDecision::Discard; + return (Discard, Reason::PlanMismatch); } if previous_weekly.used_percent <= RESET_THRESHOLD || current_weekly.used_percent > RESET_THRESHOLD { - log_reset_diagnostic( - "delayedCandidate", - "discard", - ResetDiagnosticReason::ResetThresholdMismatch, - ); - return DelayedDecision::Discard; + return (Discard, Reason::ResetThresholdMismatch); } if current.updated_at <= candidate.snapshot_updated_at { - log_reset_diagnostic( - "delayedCandidate", - "discard", - ResetDiagnosticReason::StaleObservation, - ); - return DelayedDecision::Discard; + return (Discard, Reason::StaleObservation); } if !is_valid_boundary(current_weekly, current.updated_at) { - log_reset_diagnostic( - "delayedCandidate", - "discard", - ResetDiagnosticReason::InvalidResetBoundary, - ); - return DelayedDecision::Discard; + return (Discard, Reason::InvalidResetBoundary); } if !delayed_boundaries_consistent(candidate, current_weekly, current.updated_at) { - log_reset_diagnostic( - "delayedCandidate", - "discard", - ResetDiagnosticReason::InconsistentResetBoundary, - ); - return DelayedDecision::Discard; + return (Discard, Reason::InconsistentResetBoundary); } if !supported_delayed_boundary(previous_weekly, current_weekly) { - log_reset_diagnostic( - "delayedCandidate", - "discard", - ResetDiagnosticReason::UnsupportedResetBoundary, - ); - return DelayedDecision::Discard; + return (Discard, Reason::UnsupportedResetBoundary); } - if current_inventory != Some(&candidate.inventory) { - log_reset_diagnostic( - "delayedCandidate", - "discard", - ResetDiagnosticReason::ChangedCreditInventory, - ); - return DelayedDecision::Discard; + let Some(current_inventory) = current_inventory else { + return (Discard, Reason::MissingCreditInventory); + }; + if current_inventory != &candidate.inventory { + return (Discard, Reason::ChangedCreditInventory); } if age >= CANDIDATE_MINIMUM_AGE_SECONDS { - log_reset_diagnostic( - "delayedCandidate", - "publish", - ResetDiagnosticReason::ConfirmedObservation, - ); - DelayedDecision::Publish + (Publish, Reason::ConfirmedObservation) } else { - log_reset_diagnostic( - "delayedCandidate", - "retain", - ResetDiagnosticReason::MinimumDelay, - ); - DelayedDecision::Retain + (Retain, Reason::MinimumDelay) } } @@ -677,17 +640,27 @@ fn delayed_boundaries_consistent( { return true; } + let (Some(candidate_boundary), Some(current_boundary)) = + (candidate.weekly.resets_at, current_weekly.resets_at) + else { + return false; + }; is_unused_rolling_weekly(&candidate.weekly, candidate.snapshot_updated_at) && is_unused_rolling_weekly(current_weekly, current_updated_at) - && boundary_distance_seconds(&candidate.weekly, current_weekly) >= 0 + && current_boundary >= candidate_boundary } +/// Zero usage, a seven-day window, and a reset within two minutes of one full +/// week after capture. Compared at full timestamp precision, like upstream's +/// floating-point interval check, so sub-second capture skew cannot flip the +/// two-minute edge. fn is_unused_rolling_weekly(window: &RateWindow, captured_at: DateTime) -> bool { window.used_percent == 0.0 && window.window_minutes == Some(WEEKLY_WINDOW_MINUTES) && window.resets_at.is_some_and(|boundary| { - let ahead = boundary.signed_duration_since(captured_at).num_seconds(); - (ahead - WEEKLY_WINDOW_SECONDS).abs() < RESET_TOLERANCE_SECONDS + let offset = boundary.signed_duration_since(captured_at) + - chrono::TimeDelta::seconds(WEEKLY_WINDOW_SECONDS); + offset.abs() < chrono::TimeDelta::seconds(RESET_TOLERANCE_SECONDS) }) } diff --git a/rust/src/providers/codex/weekly_reset/tests.rs b/rust/src/providers/codex/weekly_reset/tests.rs index 224ab8c355..686b70a8ca 100644 --- a/rust/src/providers/codex/weekly_reset/tests.rs +++ b/rust/src/providers/codex/weekly_reset/tests.rs @@ -429,38 +429,159 @@ fn ordinary_publication_after_rolling_confirmation_is_unchanged() { ); } +fn rolling_evaluation( + state: &AccountState, + current: &UsageSnapshot, + inv: Option<&CreditInventory>, + exact_oauth: bool, + age_seconds: i64, +) -> (DelayedDecision, ResetDiagnosticReason) { + let candidate = state.candidate.clone().unwrap(); + delayed_candidate_evaluation( + state, + &candidate, + current, + inv, + exact_oauth, + now() + chrono::Duration::seconds(age_seconds), + ) +} + +/// Upstream `unused weekly boundaries advancing with observation time confirm +/// on later refreshes` rejection table. Account mismatch has no row: the state +/// is scoped per account (`scope_key`), so another account never sees this +/// candidate. Upstream's `confidenceNotExact` maps to the exact-OAuth source. #[test] fn rolling_weekly_confirmation_rejects_incompatible_observations() { + use ResetDiagnosticReason as Reason; let inv = inventory("credit-a"); let week_minutes = 7 * 24 * 60; - let cases: Vec<(&str, UsageSnapshot)> = vec![ + let mut other_plan = rolling_current(300); + other_plan.login_method = Some("different-plan".to_string()); + let cases: Vec<(&str, UsageSnapshot, Option, bool, Reason)> = vec![ ( "nonzero usage", rolling_snapshot(0.5, week_minutes, 300, WEEK_SECONDS - 1), + Some(inv.clone()), + true, + Reason::InconsistentResetBoundary, ), ( "wrong window minutes", rolling_snapshot(0.0, 300, 300, WEEK_SECONDS - 1), + Some(inv.clone()), + true, + Reason::InconsistentResetBoundary, ), ( "boundary just outside capture plus one week", rolling_snapshot(0.0, week_minutes, 300, WEEK_SECONDS + 121), + Some(inv.clone()), + true, + Reason::InconsistentResetBoundary, ), ( "boundary far from capture plus one week", rolling_snapshot(0.0, week_minutes, 300, 600_000), + Some(inv.clone()), + true, + Reason::InconsistentResetBoundary, + ), + ( + "plan mismatch", + other_plan, + Some(inv.clone()), + true, + Reason::PlanMismatch, + ), + ( + "changed credit inventory", + rolling_current(300), + Some(inventory("different-credit")), + true, + Reason::ChangedCreditInventory, + ), + ( + "missing credit inventory", + rolling_current(300), + None, + true, + Reason::MissingCreditInventory, + ), + ( + "source not exact OAuth", + rolling_current(300), + Some(inv.clone()), + false, + Reason::SourceNotExactOAuth, ), ]; - for (name, current) in cases { + for (name, current, current_inventory, exact_oauth, reason) in cases { let state = rolling_state(); assert_eq!( - rolling_decision(&state, ¤t, &inv, 300), - DelayedDecision::Discard, + rolling_evaluation( + &state, + ¤t, + current_inventory.as_ref(), + exact_oauth, + 300 + ), + (DelayedDecision::Discard, reason), "{name}" ); } } +#[test] +fn rolling_weekly_confirmation_reports_confirmed_observation() { + let inv = inventory("credit-a"); + for offset in [180, 300, 900] { + let state = rolling_state(); + assert_eq!( + rolling_evaluation(&state, &rolling_current(offset), Some(&inv), true, offset), + ( + DelayedDecision::Publish, + ResetDiagnosticReason::ConfirmedObservation + ), + "offset {offset}" + ); + } +} + +/// Upstream checks `abs(boundary - capturedAt - 604_800) < 120` on floating +/// seconds, so a reset 119.8 s off one week still rolls and 120 s does not. +#[test] +fn rolling_weekly_tolerance_uses_full_timestamp_precision() { + let inv = inventory("credit-a"); + let week_minutes = 7 * 24 * 60; + let offset_from_week = |millis: i64| { + let mut current = rolling_snapshot(0.0, week_minutes, 300, WEEK_SECONDS); + let captured = current.updated_at; + if let Some(weekly) = current.secondary.as_mut() { + weekly.resets_at = Some( + captured + + chrono::Duration::seconds(WEEK_SECONDS) + + chrono::Duration::milliseconds(millis), + ); + } + current + }; + for (millis, expected) in [ + (-119_800, DelayedDecision::Publish), + (119_800, DelayedDecision::Publish), + (-120_000, DelayedDecision::Discard), + (-120_200, DelayedDecision::Discard), + (120_200, DelayedDecision::Discard), + ] { + let state = rolling_state(); + assert_eq!( + rolling_decision(&state, &offset_from_week(millis), &inv, 300), + expected, + "{millis} ms from one week" + ); + } +} + #[test] fn rolling_weekly_confirmation_rejects_a_boundary_that_moves_backward() { let inv = inventory("credit-a"); @@ -527,3 +648,136 @@ fn rolling_weekly_confirmation_keeps_inventory_and_expiry_guards() { "candidate expired" ); } + +/// Upstream `persisted stale baseline recovers after delayed reset +/// confirmation across relaunch` for fixed and rolling boundaries, driven +/// through the decisions `CodexApi::fetch_usage` makes and the persisted +/// `StateFile` envelope (without touching the real LocalAppData). +#[test] +fn persisted_stale_baseline_recovers_after_delayed_reset_confirmation_across_relaunch() { + for rolling_boundary in [false, true] { + let at = |seconds: i64| now() + chrono::Duration::seconds(seconds); + let prior_boundary = now() + chrono::Duration::days(2); + let next_boundary = if rolling_boundary { + at(WEEK_SECONDS - 301) + } else { + prior_boundary + chrono::Duration::seconds(WEEK_SECONDS) + }; + let credits = CreditInventory { + available_count: 1, + credits: vec![CreditIdentity { + id: "credit-a".to_string(), + reset_type: "weekly".to_string(), + status: "available".to_string(), + expires_at: Some(next_boundary + chrono::Duration::days(1)), + }], + }; + let weekly_snapshot = |used: f64, boundary: DateTime, updated_at: DateTime| { + let weekly = + RateWindow::with_details(used, Some(WEEKLY_WINDOW_MINUTES), Some(boundary), None); + let mut snapshot = UsageSnapshot::new(RateWindow::new(20.0)).with_secondary(weekly); + snapshot.updated_at = updated_at; + snapshot.login_method = Some("ChatGPT Pro".to_string()); + snapshot + }; + let roll = + |seconds: i64| chrono::Duration::seconds(if rolling_boundary { seconds } else { 0 }); + let prior = weekly_snapshot(81.0, prior_boundary, at(-360)); + let initial_low = weekly_snapshot(0.0, next_boundary, at(-300)); + let confirmed_low = weekly_snapshot(0.0, next_boundary + roll(1), at(-299)); + let later_low = weekly_snapshot( + if rolling_boundary { 0.0 } else { 0.4 }, + next_boundary + roll(300), + now(), + ); + + let mut state = AccountState::default(); + assert_eq!( + initial_decision(&mut state, &prior, Some(&credits), true, prior.updated_at), + InitialDecision::Publish, + "rolling {rolling_boundary}: seed" + ); + commit_publication(&mut state, &prior, Some(credits.clone())); + + // First low refresh: the confirmation fetch admits a delayed candidate + // while the published weekly stays on the stale baseline. + let first_observed = initial_low.updated_at; + assert_eq!( + initial_decision( + &mut state, + &initial_low, + Some(&credits), + true, + first_observed + ), + InitialDecision::RequiresConfirmation, + "rolling {rolling_boundary}: initial low" + ); + assert_eq!( + confirmation_decision( + &mut state, + &initial_low, + Some(&credits), + &confirmed_low, + Some(&credits), + true, + first_observed, + ), + ConfirmationDecision::Preserve, + "rolling {rolling_boundary}: confirmed low" + ); + assert_eq!(state.published_at, prior.updated_at); + let shown = preserve_weekly(&state, initial_low.clone()); + assert_eq!(shown.secondary.as_ref().unwrap().used_percent, 81.0); + let admitted = state.candidate.clone().expect("delayed candidate admitted"); + assert_eq!(admitted.snapshot_updated_at, confirmed_low.updated_at); + assert_eq!(admitted.created_at, initial_low.updated_at); + let admitted_json = serde_json::to_value(&state.candidate).unwrap(); + + // The credits phase of the same refresh keeps the admitted evidence. + let mut credits_only = UsageSnapshot::new(RateWindow::new(20.0)); + credits_only.updated_at = at(-298); + assert_eq!( + initial_decision(&mut state, &credits_only, None, true, at(-298)), + InitialDecision::Preserve + ); + assert_eq!( + serde_json::to_value(&state.candidate).unwrap(), + admitted_json + ); + + // Relaunch: the account state round-trips through the StateFile envelope. + let encoded = serde_json::to_vec(&StateFile { + version: STATE_VERSION, + accounts: HashMap::from([(String::from("scope"), state)]), + }) + .unwrap(); + let mut reloaded_file: StateFile = serde_json::from_slice(&encoded).unwrap(); + let mut reloaded = reloaded_file.accounts.remove("scope").unwrap(); + assert_eq!(reloaded.published_at, prior.updated_at); + assert_eq!( + serde_json::to_value(&reloaded.candidate).unwrap(), + admitted_json + ); + + // The later low refresh confirms the candidate and publishes itself. + assert_eq!( + initial_decision( + &mut reloaded, + &later_low, + Some(&credits), + true, + later_low.updated_at + ), + InitialDecision::Publish, + "rolling {rolling_boundary}: later low" + ); + commit_publication(&mut reloaded, &later_low, Some(credits.clone())); + let published = reloaded.published_weekly.as_ref().unwrap(); + let expected = later_low.secondary.as_ref().unwrap(); + assert_eq!(published.used_percent, expected.used_percent); + assert_eq!(published.resets_at, expected.resets_at); + assert_eq!(reloaded.published_at, later_low.updated_at); + assert!(reloaded.candidate.is_none()); + } +}