| 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
|