Viewing files at commit bd39a563b7f5Commit detailsView latest

suites/robustness/robustness/lincheck.py 171 lines · 6.1 KB · python
1
"""Small linearizability checker for branch-head histories (stdlib only).
2
3
Method: Wing & Gong search with Lowe-style memoisation of (linearized-set,
4
model-state), the approach Porcupine (MIT, github.com/anishathalye/porcupine)
5
implements; this is an independent re-implementation, no Porcupine code is
6
copied. Histories are partitioned per branch (P-compositionality): branch
7
heads are independent registers.
8
9
Model (fast-forward-only branch head, what `oak push` / `git push` without
10
--force promise):
11
12
- ``push(c) -> ok``      legal iff head is None or head is an ancestor of c
13
                         (c == head included); head := c.
14
- ``push(c) -> rejected`` no effect (a rejected push must not move the head).
15
- ``push(c) -> unknown``  the client died / timed out / lost the reply: it may
16
                         take effect at any point after its call, or never.
17
- ``read -> h``           legal iff head == h (an observed server head).
18
19
Ancestry comes from the union of independently-read commit graphs.
20
"""
21
22
from __future__ import annotations
23
24
import math
25
from dataclasses import dataclass, field
26
from typing import Callable, Hashable, Iterable, Optional
27
28
OK = "ok"
29
REJECTED = "rejected"
30
UNKNOWN = "unknown"
31
32
33
@dataclass
34
class Op:
35
    client: str
36
    kind: str  # "push" | "read"
37
    value: Optional[str]  # pushed commit, or observed head
38
    call: float
39
    ret: float  # math.inf for unknown outcomes
40
    outcome: str = OK
41
    branch: str = "main"
42
    op_id: int = -1
43
    meta: dict = field(default_factory=dict)
44
45
46
@dataclass
47
class CheckResult:
48
    linearizable: Optional[bool]  # None = search budget exhausted (unmeasured)
49
    order: list[int]
50
    explored: int
51
    reason: str = ""
52
53
54
def _next_states(state: Optional[str], op: Op, is_ancestor: Callable[[str, str], bool]) -> list[Optional[str]]:
55
    if op.kind == "read":
56
        return [state] if state == op.value else []
57
    if op.kind != "push":
58
        raise ValueError(f"unknown op kind {op.kind!r}")
59
    ff = op.value is not None and (state is None or state == op.value or is_ancestor(state, op.value))
60
    if op.outcome == OK:
61
        return [op.value] if ff else []
62
    if op.outcome == REJECTED:
63
        return [state]
64
    # unknown: either it never took effect, or it did (only legal as a fast-forward)
65
    return [state, op.value] if ff and op.value != state else [state]
66
67
68
def check(ops: list[Op], is_ancestor: Callable[[str, str], bool], initial: Optional[str] = None,
69
          budget: int = 2_000_000) -> CheckResult:
70
    """Is there a total order of ``ops`` respecting real time and the model?"""
71
    ops = list(ops)
72
    for index, op in enumerate(ops):
73
        op.op_id = index
74
    n = len(ops)
75
    must = 0
76
    for op in ops:
77
        if op.ret != math.inf:
78
            must |= 1 << op.op_id
79
    explored = 0
80
    seen: set[tuple[int, Hashable]] = set()
81
    order: list[int] = []
82
83
    def candidates(mask: int) -> list[Op]:
84
        pending = [op for op in ops if not (mask >> op.op_id) & 1]
85
        if not pending:
86
            return []
87
        horizon = min(op.ret for op in pending)
88
        return [op for op in pending if op.call <= horizon]
89
90
    # Iterative DFS to avoid recursion limits.
91
    stack: list[tuple[int, Optional[str], list, int]] = []  # (mask, state, choices, choice_index)
92
93
    def expand(mask: int, state: Optional[str]) -> list[tuple[int, Optional[str]]]:
94
        result = []
95
        for op in candidates(mask):
96
            for nxt in _next_states(state, op, is_ancestor):
97
                result.append((op.op_id, nxt))
98
        return result
99
100
    mask, state = 0, initial
101
    if must == 0:
102
        return CheckResult(True, [], 0)
103
    stack.append((mask, state, expand(mask, state), 0))
104
    seen.add((mask, state))
105
    while stack:
106
        mask, state, choices, idx = stack[-1]
107
        if (mask & must) == must:
108
            return CheckResult(True, list(order), explored)
109
        if idx >= len(choices):
110
            stack.pop()
111
            if order:
112
                order.pop()
113
            continue
114
        stack[-1] = (mask, state, choices, idx + 1)
115
        op_id, nxt = choices[idx]
116
        new_mask = mask | (1 << op_id)
117
        key = (new_mask, nxt)
118
        explored += 1
119
        if explored > budget:
120
            return CheckResult(None, list(order), explored, "search budget exhausted")
121
        if key in seen:
122
            continue
123
        seen.add(key)
124
        order.append(op_id)
125
        stack.append((new_mask, nxt, expand(new_mask, nxt), 0))
126
    return CheckResult(False, [], explored, "no linearization respects real time and the fast-forward model")
127
128
129
def check_partitioned(ops: Iterable[Op], is_ancestor: Callable[[str, str], bool],
130
                      initial: Optional[dict[str, Optional[str]]] = None) -> dict[str, CheckResult]:
131
    by_branch: dict[str, list[Op]] = {}
132
    for op in ops:
133
        by_branch.setdefault(op.branch, []).append(op)
134
    initial = initial or {}
135
    return {branch: check(branch_ops, is_ancestor, initial.get(branch)) for branch, branch_ops in by_branch.items()}
136
137
138
def lost_acknowledged(ops: Iterable[Op], final_heads: dict[str, Optional[str]],
139
                      is_ancestor: Callable[[str, str], bool]) -> list[Op]:
140
    """Acknowledged pushes whose commit is not contained in the branch's final head."""
141
    lost = []
142
    for op in ops:
143
        if op.kind != "push" or op.outcome != OK or op.value is None:
144
            continue
145
        final = final_heads.get(op.branch)
146
        if final is None or not (final == op.value or is_ancestor(op.value, final)):
147
            lost.append(op)
148
    return lost
149
150
151
def ancestry_from_parents(parents: dict[str, tuple]) -> Callable[[str, str], bool]:
152
    cache: dict[str, frozenset] = {}
153
154
    def ancestors(commit: str) -> frozenset:
155
        if commit in cache:
156
            return cache[commit]
157
        seen = set()
158
        stack = [commit]
159
        while stack:
160
            current = stack.pop()
161
            if current in seen:
162
                continue
163
            seen.add(current)
164
            stack.extend(p for p in parents.get(current, ()) if p)
165
        cache[commit] = frozenset(seen)
166
        return cache[commit]
167
168
    def is_ancestor(ancestor: str, descendant: str) -> bool:
169
        return ancestor in ancestors(descendant)
170
171
    return is_ancestor