Domain: text-editor undo/redo transaction engine

Issue:
Canceling a compound operation corrupts redo. Two reports of the same incident class: (1) after canceling an operation, Redo lights up, and pressing it re-applies the very changes that were just canceled — edits that were never committed anywhere; (2) a user who had undone some typing, then opened and canceled a compound operation, found their pending Redo gone — the typing they had undone before the operation could no longer be redone. Committed operations and plain undo/redo behave correctly. Expected behavior: canceling a compound operation leaves the editor exactly as it was at the moment the operation began — document, undo history, and redo history all included, as the compound operation API documents.

Repository files:
content: # Present so pytest adds the repository root to sys.path, making `import src`
# resolve from the test suite.

path: conftest.py

content: # Pure standard library unless the environment image pre-bakes wheels.

path: requirements.lock

path: src/__init__.py

content: """The text buffer: a flat character sequence with positional edits."""

from .errors import BadPosition


class TextBuffer:
    """Holds the document text. Positions are absolute character offsets."""

    def __init__(self, text=""):
        self._text = text

    @property
    def text(self):
        return self._text

    def __len__(self):
        return len(self._text)

    def insert(self, pos, text):
        if not 0 <= pos <= len(self._text):
            raise BadPosition(f"insert at {pos} outside buffer of length {len(self._text)}")
        self._text = self._text[:pos] + text + self._text[pos:]

    def delete(self, pos, length):
        """Remove and return `length` characters starting at pos."""
        if length < 0 or not 0 <= pos <= len(self._text) - length:
            raise BadPosition(
                f"delete of {length} at {pos} outside buffer of length {len(self._text)}"
            )
        removed = self._text[pos:pos + length]
        self._text = self._text[:pos] + self._text[pos + length:]
        return removed

path: src/buffer.py

content: """The editor facade: buffer + history + compound operations."""

from .buffer import TextBuffer
from .errors import InvalidState
from .history import History
from .ops import Delete, Insert
from .txn import TransactionManager


class Editor:
    def __init__(self, text=""):
        self._buffer = TextBuffer(text)
        self._history = History()
        self._txn = TransactionManager(self._history)

    @property
    def text(self):
        return self._buffer.text

    # -- editing -----------------------------------------------------------
    def insert(self, pos, text):
        op = Insert(pos, text)
        op.apply(self._buffer)
        self._history.record(op)

    def delete(self, pos, length):
        removed = self._buffer.delete(pos, length)
        self._history.record(Delete(pos, removed))

    def end_typing_run(self):
        """Explicitly stop coalescing (e.g. the cursor moved)."""
        self._history.seal()

    # -- history -----------------------------------------------------------
    def can_undo(self):
        return self._history.can_undo()

    def can_redo(self):
        return self._history.can_redo()

    def undo(self):
        if self._txn.active:
            raise InvalidState("cannot undo during a compound operation")
        self._history.undo(self._buffer)

    def redo(self):
        if self._txn.active:
            raise InvalidState("cannot redo during a compound operation")
        self._history.redo(self._buffer)

    # -- compound operations -------------------------------------------------
    def begin(self):
        self._txn.begin()

    def commit(self):
        self._txn.commit()

    def rollback(self):
        self._txn.rollback(self._buffer)

path: src/editor.py

content: """Editor error types."""


class BadPosition(Exception):
    """An operation referenced a position outside the buffer."""


class InvalidState(Exception):
    """An action is not legal in the editor's current state."""

path: src/errors.py

content: """Undo groups: one user-facing undo step, possibly several operations.

A typing run coalesces: consecutive insertions where each begins exactly
where the previous one ended extend the same open group, so a burst of
typing undoes as a single step.
"""

from .ops import Insert


class Group:
    """An ordered list of operations undone/redone as one unit."""

    def __init__(self, ops=None):
        self.ops = list(ops or [])

    def can_extend(self, op):
        """Whether op continues this group's typing run."""
        if not self.ops or not isinstance(op, Insert):
            return False
        last = self.ops[-1]
        return isinstance(last, Insert) and op.pos == last.end

    def add(self, op):
        self.ops.append(op)

    def revert(self, buffer):
        for op in reversed(self.ops):
            op.revert(buffer)

    def replay(self, buffer):
        for op in self.ops:
            op.apply(buffer)

    def __len__(self):
        return len(self.ops)

path: src/groups.py

content: """Undo/redo history over grouped operations.

Recording a new operation always clears the redo stack: once the user edits,
previously undone work can no longer be redone. undo() moves the newest group
to the redo stack; redo() moves it back.
"""

from .errors import InvalidState
from .groups import Group


class History:
    def __init__(self):
        self._undo = []
        self._redo = []
        self._open = False  # whether the top undo group accepts coalescing

    def depth(self):
        return len(self._undo)

    def can_undo(self):
        return bool(self._undo)

    def can_redo(self):
        return bool(self._redo)

    def record(self, op):
        """Register an already-applied operation as undoable."""
        self._redo.clear()
        if self._open and self._undo and self._undo[-1].can_extend(op):
            self._undo[-1].add(op)
        else:
            self._undo.append(Group([op]))
            self._open = True

    def seal(self):
        """End the current typing run; the next edit starts a new group."""
        self._open = False

    def undo(self, buffer):
        if not self._undo:
            raise InvalidState("nothing to undo")
        group = self._undo.pop()
        group.revert(buffer)
        self._redo.append(group)
        self._open = False

    def redo(self, buffer):
        if not self._redo:
            raise InvalidState("nothing to redo")
        group = self._redo.pop()
        group.replay(buffer)
        self._undo.append(group)
        self._open = False

    def fuse_since(self, depth):
        """Merge every group recorded after `depth` into a single group."""
        if depth < 0 or depth > len(self._undo):
            raise InvalidState("fuse depth outside history")
        tail = self._undo[depth:]
        if len(tail) > 1:
            fused = Group([op for group in tail for op in group.ops])
            del self._undo[depth:]
            self._undo.append(fused)
        self._open = False

path: src/history.py

content: """Edit operations: applied forward, inverted for undo."""

from dataclasses import dataclass


@dataclass(frozen=True)
class Insert:
    """Insertion of text at an absolute position."""

    pos: int
    text: str

    def apply(self, buffer):
        buffer.insert(self.pos, self.text)

    def revert(self, buffer):
        buffer.delete(self.pos, len(self.text))

    @property
    def end(self):
        return self.pos + len(self.text)


@dataclass(frozen=True)
class Delete:
    """Deletion of a span; keeps the removed text so it can be restored."""

    pos: int
    removed: str

    def apply(self, buffer):
        buffer.delete(self.pos, len(self.removed))

    def revert(self, buffer):
        buffer.insert(self.pos, self.removed)

path: src/ops.py

content: """Compound operations (transactions).

begin() opens a compound operation; every edit made until commit() or
rollback() belongs to it. commit() makes the whole operation one undo step.
rollback() cancels it: the editor is restored exactly as it was when begin()
was called — document, undo history, redo history, and typing run — as if
the operation had never started.
"""

from .errors import InvalidState


class TransactionManager:
    def __init__(self, history):
        self._history = history
        self._base_depth = None

    @property
    def active(self):
        return self._base_depth is not None

    def begin(self):
        if self.active:
            raise InvalidState("a compound operation is already open")
        self._history.seal()
        self._base_depth = self._history.depth()

    def commit(self):
        if not self.active:
            raise InvalidState("no compound operation to commit")
        self._history.fuse_since(self._base_depth)
        self._base_depth = None

    def rollback(self, buffer):
        if not self.active:
            raise InvalidState("no compound operation to cancel")
        # unwind everything this operation recorded
        while self._history.depth() > self._base_depth:
            self._history.undo(buffer)
        self._history.seal()
        self._base_depth = None

path: src/txn.py

content: """Read-only rendering helpers."""


def numbered_lines(text):
    """Render text as 'N: line' rows (1-based)."""
    lines = text.split("\n")
    return ["{}: {}".format(i, line) for i, line in enumerate(lines, 1)]


def excerpt(text, pos, radius=10):
    """A window of text around pos, for status displays."""
    start = max(0, pos - radius)
    return text[start:pos + radius]

path: src/view.py

content: """Tests for the buffer, history, compound operations, and views."""

import pytest

from src.editor import Editor
from src.errors import BadPosition, InvalidState
from src.view import excerpt, numbered_lines


def test_buffer_edits_and_validation():
    e = Editor("hello")
    e.insert(5, " world")
    assert e.text == "hello world"
    e.delete(0, 6)
    assert e.text == "world"
    with pytest.raises(BadPosition):
        e.insert(99, "x")
    with pytest.raises(BadPosition):
        e.delete(3, 10)


def test_typing_run_coalesces_into_one_undo():
    e = Editor()
    e.insert(0, "he")
    e.insert(2, "llo")
    assert e.text == "hello"
    e.undo()
    assert e.text == ""
    assert not e.can_undo()


def test_non_continuing_inserts_are_separate_steps():
    e = Editor()
    e.insert(0, "a")
    e.insert(0, "b")  # does not continue the run at position 1
    assert e.text == "ba"
    e.undo()
    assert e.text == "a"
    e.undo()
    assert e.text == ""


def test_undo_redo_round_trip_and_redo_clears_on_edit():
    e = Editor()
    e.insert(0, "a")
    e.end_typing_run()
    e.insert(1, "b")
    e.undo()
    assert e.text == "a"
    e.redo()
    assert e.text == "ab"
    e.undo()
    e.insert(1, "c")
    assert not e.can_redo()


def test_commit_makes_one_undo_step():
    e = Editor()
    e.insert(0, "m")
    e.begin()
    e.insert(1, "x")
    e.insert(0, "q")  # separate group inside the operation
    e.commit()
    assert e.text == "qmx"
    e.undo()
    assert e.text == "m"


def test_rollback_restores_document():
    e = Editor()
    e.insert(0, "a")
    e.begin()
    e.insert(1, "XYZ")
    assert e.text == "aXYZ"
    e.rollback()
    assert e.text == "a"
    e.undo()
    assert e.text == ""


def test_rollback_of_empty_operation_is_a_no_op():
    e = Editor()
    e.insert(0, "a")
    e.begin()
    e.rollback()
    assert e.text == "a"
    assert e.can_undo()
    e.undo()
    assert e.text == ""


def test_state_guards():
    e = Editor()
    with pytest.raises(InvalidState):
        e.rollback()
    with pytest.raises(InvalidState):
        e.commit()
    e.begin()
    with pytest.raises(InvalidState):
        e.begin()
    e.insert(0, "x")
    with pytest.raises(InvalidState):
        e.undo()
    with pytest.raises(InvalidState):
        e.redo()
    e.commit()
    assert numbered_lines("a\nb") == ["1: a", "2: b"]
    assert excerpt("abcdefghij", 5, radius=2) == "defg"

path: tests/public/test_public.py

Reference patch:
--- a/src/history.py
+++ b/src/history.py
@@ -53,6 +53,19 @@ class History:
         self._undo.append(group)
         self._open = False
 
+    def snapshot_redo(self):
+        """A copy of the redo stack, for restore-exactly rollback."""
+        return list(self._redo)
+
+    def restore_redo(self, groups):
+        self._redo[:] = groups
+
+    def discard_since(self, depth, buffer):
+        """Revert and drop every group above depth without touching redo."""
+        while len(self._undo) > depth:
+            self._undo.pop().revert(buffer)
+        self._open = False
+
     def fuse_since(self, depth):
         """Merge every group recorded after `depth` into a single group."""
         if depth < 0 or depth > len(self._undo):
--- a/src/txn.py
+++ b/src/txn.py
@@ -14,6 +14,7 @@ class TransactionManager:
     def __init__(self, history):
         self._history = history
         self._base_depth = None
+        self._redo_before = None
 
     @property
     def active(self):
@@ -24,18 +25,20 @@ class TransactionManager:
             raise InvalidState("a compound operation is already open")
         self._history.seal()
         self._base_depth = self._history.depth()
+        self._redo_before = self._history.snapshot_redo()
 
     def commit(self):
         if not self.active:
             raise InvalidState("no compound operation to commit")
         self._history.fuse_since(self._base_depth)
         self._base_depth = None
+        self._redo_before = None
 
     def rollback(self, buffer):
         if not self.active:
             raise InvalidState("no compound operation to cancel")
-        # unwind everything this operation recorded
-        while self._history.depth() > self._base_depth:
-            self._history.undo(buffer)
+        self._history.discard_since(self._base_depth, buffer)
+        self._history.restore_redo(self._redo_before)
         self._history.seal()
         self._base_depth = None
+        self._redo_before = None


Hidden acceptance tests (diff):
diff --git a/tests/hidden/test_hidden.py b/tests/hidden/test_hidden.py
new file mode 100644
--- /dev/null
+++ b/tests/hidden/test_hidden.py
@@ -0,0 +1,84 @@
+"""Hidden evaluation tests. Never agent-visible (DEC-015).
+
+FAIL_TO_PASS: rollback is implemented by replaying the normal undo path, so
+every unwound operation lands on the redo stack (canceled changes become
+redoable) and any redo that existed before begin() is destroyed. All five
+tests fail at baseline. Graded gradient (DEC-036/DEC-042): the shallow
+repair — purging redo inside rollback — passes the three ghost-redo tests
+but fails both redo-preservation tests (3/5); only a fix that snapshots the
+redo stack at begin() and discards canceled work without routing it through
+undo satisfies the documented restore-exactly contract (5/5).
+"""
+
+import pytest
+
+from src.editor import Editor
+from src.errors import InvalidState
+
+
+def test_canceled_changes_are_not_redoable():
+    e = Editor()
+    e.begin()
+    e.insert(0, "X")
+    e.rollback()
+    assert not e.can_redo()
+    with pytest.raises(InvalidState):
+        e.redo()
+    assert e.text == ""
+
+
+def test_cancel_preserves_prior_redo():
+    e = Editor()
+    e.insert(0, "a")
+    e.end_typing_run()
+    e.insert(1, "b")
+    e.undo()  # 'b' is now pending redo
+    e.begin()
+    e.insert(1, "Q")
+    e.rollback()
+    assert e.can_redo()
+    e.redo()
+    assert e.text == "ab"
+
+
+def test_multi_step_cancel_leaves_no_ghosts():
+    e = Editor()
+    e.begin()
+    e.insert(0, "X")
+    e.insert(0, "Y")  # second group inside the operation
+    e.rollback()
+    assert e.text == ""
+    assert not e.can_redo()
+
+
+def test_full_redo_stack_survives_cancel_in_order():
+    e = Editor()
+    e.insert(0, "a")
+    e.end_typing_run()
+    e.insert(1, "b")
+    e.undo()
+    e.undo()  # redo pending: 'a' then 'b'
+    e.begin()
+    e.insert(0, "Z")
+    e.rollback()
+    assert e.text == ""
+    assert e.can_redo()
+    e.redo()
+    assert e.text == "a"
+    e.redo()
+    assert e.text == "ab"
+
+
+def test_cancel_leaves_clean_history_for_later_flows():
+    e = Editor()
+    e.insert(0, "hello")
+    e.begin()
+    e.insert(5, " world")
+    e.rollback()
+    assert e.text == "hello"
+    assert not e.can_redo()
+    e.undo()
+    assert e.text == ""
+    assert e.can_redo()
+    e.redo()
+    assert e.text == "hello"