Skip to content

Commit e96a372

Browse files
mutation-trace: Allow no-change Flush commits at max revision
Revision headroom is only required when a commit will advance the worktree, so a no-change `Flush` can commit at `u64::MAX` without wrapping or being rejected. Compute whether the boundary would advance before applying the checked revision guard, and only derive the next revision for advancing commits. Add regression coverage for both the rejected advancing case and the accepted no-change case. Co-authored-by: SCE <sce@crocoder.dev>
1 parent 33cc2aa commit e96a372

5 files changed

Lines changed: 209 additions & 45 deletions

File tree

cli/src/services/mutation_trace/mod.rs

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -128,19 +128,32 @@
128128
//! `abandon`, `recover` — routes through the private `next_revision`
129129
//! (`revision.checked_add(1)`) helper in `protocol.rs` rather than a raw
130130
//! `+ 1`, so a worktree already at `revision: u64::MAX` cannot be advanced
131-
//! and cannot wrap to `0`. `commit`'s `accepted` gate folds this check in
132-
//! unconditionally (a would-be-overflowing attempt is rejected exactly like
133-
//! a stale one, with no cursor movement, scope transition, processed-
134-
//! `EventKey` insertion, or `MutationEvent`); `taint`/`abandon`/`recover`
135-
//! treat it as an additional guarded no-op alongside their existing
136-
//! existence/precondition guards. This has no Quint counterpart — Quint's
137-
//! `revision` never needs such a guard — so it is a Rust-only refinement
138-
//! precondition, verified by `commit_does_not_wrap_revision_at_u64_max`,
139-
//! `taint_does_not_wrap_revision_at_u64_max`,
131+
//! and cannot wrap to `0`. `taint`/`abandon`/`recover` always advance
132+
//! revision when they execute, so each treats the headroom check as an
133+
//! additional guarded no-op alongside their existing existence/precondition
134+
//! guards, verified by `taint_does_not_wrap_revision_at_u64_max`,
140135
//! `abandon_does_not_wrap_revision_at_u64_max`, and
141136
//! `recover_does_not_wrap_revision_at_u64_max`, each starting from
142-
//! `revision: u64::MAX` and proving the action is a no-op (or, for `commit`,
143-
//! a rejection) rather than a wrap.
137+
//! `revision: u64::MAX` and proving the action is a no-op rather than a
138+
//! wrap.
139+
//!
140+
//! `commit`'s `accepted` gate requires headroom only when this commit would
141+
//! actually advance the worktree's revision: a non-`Flush` boundary always
142+
//! advances revision when accepted, and a `Flush` advances revision only
143+
//! when it observes a real tree change, so headroom is required for
144+
//! non-`Flush` commits and for a `Flush` with an observed change, but *not*
145+
//! for a `Flush` that observes no change — that commit may still succeed at
146+
//! `revision: u64::MAX`, matching Quint's `commitAttempt`, since nothing
147+
//! about it needs to advance. A rejected (would-be-overflowing) attempt is
148+
//! rejected exactly like a stale one, with no cursor movement, scope
149+
//! transition, processed-`EventKey` insertion, or `MutationEvent`. This
150+
//! headroom guard has no Quint counterpart — Quint's `revision` never needs
151+
//! one — so it is a Rust-only refinement precondition, verified by
152+
//! `commit_that_would_advance_is_rejected_at_u64_max` (a commit that would
153+
//! advance revision is rejected at `u64::MAX`) and
154+
//! `no_change_flush_commits_at_u64_max_without_advancing_revision` (a
155+
//! no-change `Flush` still commits successfully at `u64::MAX`, with revision
156+
//! unchanged).
144157
145158
pub mod protocol;
146159
pub mod types;

cli/src/services/mutation_trace/protocol.rs

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -229,15 +229,16 @@ impl ResolvedAttempt {
229229
/// Refines the `fresh`/`observes`/`accepted`/`observedChange`/`changed`/
230230
/// `advancesRevision` computation at `spec/mutation_cursor.qnt:462-483`.
231231
///
232-
/// `accepted` additionally requires the worktree's revision to have
233-
/// headroom to advance ([`next_revision`]) — a Rust-only refinement
234-
/// guard with no Quint counterpart, since Quint's `revision` is
235-
/// unbounded. This is deliberately unconditional on whether the
236-
/// boundary would actually advance the revision (a `Flush` observing no
237-
/// change would not), because [`ResolvedAttempt::apply`] must never
238-
/// discover a would-be overflow after `accepted` was already decided;
239-
/// keeping the guard uniform here means every accepted commit is proven
240-
/// safe to advance before any state is touched.
232+
/// `accepted` additionally requires revision headroom
233+
/// ([`next_revision`]) — a Rust-only refinement guard with no Quint
234+
/// counterpart, since Quint's `revision` is unbounded — but only when
235+
/// this commit would actually advance the worktree's revision.
236+
/// Non-`Flush` commits always advance revision when accepted, so they
237+
/// always require headroom. A `Flush` advances revision only when it
238+
/// observes a real tree change, so a fresh no-change `Flush` may still
239+
/// commit at `revision: u64::MAX`, matching Quint: [`ResolvedAttempt::apply`]
240+
/// never advances the revision for such a commit, so there is nothing
241+
/// for the guard to protect there.
241242
fn evaluate(&self, state: &ProtocolState) -> CommitEvaluation {
242243
let current_scope = self
243244
.scope_id
@@ -260,9 +261,14 @@ impl ResolvedAttempt {
260261
} else {
261262
true
262263
};
263-
let accepted = fresh && next_revision(self.worktree_state.revision).is_some();
264-
let observed_change =
265-
accepted && observes && self.planned.before_tree != self.planned.after_tree;
264+
265+
let tree_changed = observes && self.planned.before_tree != self.planned.after_tree;
266+
let would_advance_revision = !is_flush(&self.boundary) || tree_changed;
267+
let has_revision_headroom =
268+
!would_advance_revision || next_revision(self.worktree_state.revision).is_some();
269+
270+
let accepted = fresh && has_revision_headroom;
271+
let observed_change = accepted && tree_changed;
266272
let changed = observed_change && !self.worktree_state.needs_rebaseline;
267273
let advances_revision = accepted && (!is_flush(&self.boundary) || observed_change);
268274

@@ -294,12 +300,20 @@ impl ResolvedAttempt {
294300
return next;
295301
}
296302

297-
// `accepted` already proved (in `evaluate`) that advancing the
298-
// worktree's revision cannot wrap; this recomputes the same checked
299-
// value rather than trusting a stored flag, so this function has no
300-
// raw `+ 1` of its own.
301-
let advanced_revision = next_revision(self.worktree_state.revision)
302-
.expect("accepted requires revision headroom, see `evaluate`");
303+
// `evaluate` already proved that advancing the worktree's revision
304+
// cannot wrap whenever `advances_revision` is true; this recomputes
305+
// the same checked value rather than trusting a stored flag, so this
306+
// function has no raw `+ 1` of its own. When `advances_revision` is
307+
// false (a fresh no-change `Flush`), no headroom was required or
308+
// proved, so no next revision is computed at all.
309+
let advanced_revision = if evaluation.advances_revision {
310+
Some(
311+
next_revision(self.worktree_state.revision)
312+
.expect("advancing revision requires headroom, see `evaluate`"),
313+
)
314+
} else {
315+
None
316+
};
303317

304318
// `observes` already encodes the exact scope-status guard
305319
// `commitAttempt` repeats for its own scope transition (`NeverSeen`
@@ -323,7 +337,7 @@ impl ResolvedAttempt {
323337
self.worktree_state.cursor_tree.clone()
324338
};
325339

326-
if evaluation.advances_revision {
340+
if let Some(advanced_revision) = advanced_revision {
327341
next.worktrees.insert(
328342
self.worktree.clone(),
329343
WorktreeState {
@@ -343,9 +357,10 @@ impl ResolvedAttempt {
343357
}
344358

345359
if evaluation.changed {
360+
let revision = advanced_revision.expect("changed implies advances_revision");
346361
next.mutation_events.insert(MutationEvent {
347362
worktree_id: self.worktree.clone(),
348-
revision: advanced_revision,
363+
revision,
349364
before_tree: self.planned.before_tree.clone(),
350365
after_tree: self.planned.after_tree.clone(),
351366
active_scopes: live_scopes_on(state, &self.worktree),

cli/src/services/mutation_trace/tests.rs

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2105,8 +2105,13 @@ fn needs_rebaseline_suppresses_mutation_event_even_when_commit_observes_a_real_t
21052105
// must refuse to wrap past `u64::MAX` rather than commit partial or wrapped
21062106
// state.
21072107

2108+
// A commit that would advance revision is rejected at `u64::MAX`: headroom
2109+
// is required for advancement, not for acceptance in general. Paired with
2110+
// `no_change_flush_commits_at_u64_max_without_advancing_revision` below,
2111+
// which proves the other half — a commit that would NOT advance revision
2112+
// (a no-change `Flush`) is accepted at `u64::MAX`.
21082113
#[test]
2109-
fn commit_does_not_wrap_revision_at_u64_max() {
2114+
fn commit_that_would_advance_is_rejected_at_u64_max() {
21102115
let mut state = ProtocolState::default();
21112116
state
21122117
.worktrees
@@ -2153,6 +2158,68 @@ fn commit_does_not_wrap_revision_at_u64_max() {
21532158
);
21542159
}
21552160

2161+
// T07 post-review correction: the first checked-u64 guard required revision
2162+
// headroom for every accepted commit, even a no-change `Flush` that would
2163+
// not advance revision. Quint's `commitAttempt` accepts and commits that
2164+
// case without advancing revision, so a fresh no-change `Flush` at
2165+
// `revision: u64::MAX` must commit successfully rather than being rejected
2166+
// for headroom it does not need.
2167+
#[test]
2168+
fn no_change_flush_commits_at_u64_max_without_advancing_revision() {
2169+
let mut state = ProtocolState::default();
2170+
state
2171+
.worktrees
2172+
.insert(worktree("wt0"), healthy_worktree(tree("tree0"), u64::MAX));
2173+
let before = state.clone();
2174+
2175+
let outcome = prepare_and_commit(
2176+
&state,
2177+
&attempt_id("attempt0"),
2178+
flush_boundary(),
2179+
tree("tree0"),
2180+
);
2181+
2182+
assert!(outcome.evaluation.accepted);
2183+
assert!(outcome.evaluation.observes);
2184+
assert!(!outcome.evaluation.observed_change);
2185+
assert!(!outcome.evaluation.changed);
2186+
assert!(
2187+
!outcome.evaluation.advances_revision,
2188+
"a no-change Flush must not advance revision even when accepted"
2189+
);
2190+
2191+
assert_eq!(
2192+
outcome
2193+
.state
2194+
.attempts
2195+
.get(&attempt_id("attempt0"))
2196+
.unwrap()
2197+
.status,
2198+
AttemptStatus::Committed
2199+
);
2200+
2201+
let committed_worktree = outcome.state.worktrees.get(&worktree("wt0")).unwrap();
2202+
assert_eq!(
2203+
committed_worktree.revision,
2204+
u64::MAX,
2205+
"revision must stay at u64::MAX; a no-change Flush requires no headroom"
2206+
);
2207+
assert_eq!(committed_worktree.cursor_tree, tree("tree0"));
2208+
assert_eq!(
2209+
outcome.state.worktrees.get(&worktree("wt0")).unwrap(),
2210+
before.worktrees.get(&worktree("wt0")).unwrap(),
2211+
"the worktree must be otherwise unchanged"
2212+
);
2213+
2214+
assert!(
2215+
outcome.state.mutation_events.is_empty(),
2216+
"no MutationEvent may be emitted for a no-change Flush"
2217+
);
2218+
assert_eq!(outcome.state.processed_events, before.processed_events);
2219+
assert_eq!(outcome.state.scopes, before.scopes);
2220+
assert_eq!(outcome.state.external_taint, before.external_taint);
2221+
}
2222+
21562223
#[test]
21572224
fn taint_does_not_wrap_revision_at_u64_max() {
21582225
let mut state = ProtocolState::default();

context/cli/mutation-trace-revision-refinement.md

Lines changed: 41 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

context/plans/mutation-cursor-protocol-kernel.md

Lines changed: 41 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)