Spaces:
Sleeping
Sleeping
Rename ot_engine.py to ot_engine.hpp
Browse files- ot_engine.hpp +164 -0
- ot_engine.py +0 -190
ot_engine.hpp
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#pragma once
|
| 2 |
+
/*
|
| 3 |
+
* CollabDocs C++ — Operational Transformation Engine
|
| 4 |
+
*
|
| 5 |
+
* Implements:
|
| 6 |
+
* insert-insert, insert-delete, delete-insert, delete-delete
|
| 7 |
+
* with cursor sync and transform_against_log.
|
| 8 |
+
*
|
| 9 |
+
* All operations are VALUE types (copyable, no heap allocation).
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
#include <string>
|
| 13 |
+
#include <vector>
|
| 14 |
+
#include <algorithm>
|
| 15 |
+
#include <stdexcept>
|
| 16 |
+
|
| 17 |
+
namespace collab {
|
| 18 |
+
|
| 19 |
+
// ─── Operation ───────────────────────────────────────────────────────────────
|
| 20 |
+
|
| 21 |
+
enum class OpType { INSERT, DELETE };
|
| 22 |
+
|
| 23 |
+
struct Operation {
|
| 24 |
+
OpType type;
|
| 25 |
+
int position = 0;
|
| 26 |
+
std::string value; // meaningful for INSERT
|
| 27 |
+
int length = 0; // meaningful for DELETE (insert: derived)
|
| 28 |
+
int base_version = 0;
|
| 29 |
+
std::string user_id;
|
| 30 |
+
std::string op_id;
|
| 31 |
+
|
| 32 |
+
// Normalize: insert.length always == value.size()
|
| 33 |
+
void normalize() {
|
| 34 |
+
if (type == OpType::INSERT)
|
| 35 |
+
length = static_cast<int>(value.size());
|
| 36 |
+
}
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
// ─── Pairwise transform functions ────────────────────────────────────────────
|
| 40 |
+
|
| 41 |
+
// insert vs insert
|
| 42 |
+
inline Operation transform_ii(Operation op, const Operation& against) {
|
| 43 |
+
if (against.position < op.position) {
|
| 44 |
+
op.position += static_cast<int>(against.value.size());
|
| 45 |
+
} else if (against.position == op.position) {
|
| 46 |
+
// Tie-break: lower user_id wins (goes first)
|
| 47 |
+
if (against.user_id <= op.user_id)
|
| 48 |
+
op.position += static_cast<int>(against.value.size());
|
| 49 |
+
}
|
| 50 |
+
return op;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// delete vs insert (delete incoming, insert already applied)
|
| 54 |
+
// "delete wins" — absorbs chars inserted inside its range
|
| 55 |
+
inline Operation transform_di(Operation op, const Operation& against) {
|
| 56 |
+
int ins_pos = against.position;
|
| 57 |
+
int ins_len = static_cast<int>(against.value.size());
|
| 58 |
+
int del_end = op.position + op.length;
|
| 59 |
+
|
| 60 |
+
if (ins_pos < op.position) {
|
| 61 |
+
op.position += ins_len;
|
| 62 |
+
} else if (ins_pos <= del_end) {
|
| 63 |
+
op.length += ins_len;
|
| 64 |
+
}
|
| 65 |
+
return op;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// insert vs delete (insert incoming, delete already applied)
|
| 69 |
+
// if insert fell inside deleted range → collapse to del_start
|
| 70 |
+
inline Operation transform_id(Operation op, const Operation& against) {
|
| 71 |
+
int del_start = against.position;
|
| 72 |
+
int del_end = against.position + against.length;
|
| 73 |
+
|
| 74 |
+
if (del_end <= op.position) {
|
| 75 |
+
op.position -= against.length;
|
| 76 |
+
} else if (del_start < op.position) {
|
| 77 |
+
op.position = del_start;
|
| 78 |
+
}
|
| 79 |
+
return op;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// delete vs delete
|
| 83 |
+
inline Operation transform_dd(Operation op, const Operation& against) {
|
| 84 |
+
int op_start = op.position;
|
| 85 |
+
int op_end = op.position + op.length;
|
| 86 |
+
int ag_start = against.position;
|
| 87 |
+
int ag_end = against.position + against.length;
|
| 88 |
+
|
| 89 |
+
if (ag_end <= op_start) {
|
| 90 |
+
op.position -= against.length;
|
| 91 |
+
} else if (ag_start >= op_end) {
|
| 92 |
+
// no change
|
| 93 |
+
} else {
|
| 94 |
+
int overlap_start = std::max(op_start, ag_start);
|
| 95 |
+
int overlap_end = std::min(op_end, ag_end);
|
| 96 |
+
int overlap = overlap_end - overlap_start;
|
| 97 |
+
|
| 98 |
+
if (ag_start < op_start)
|
| 99 |
+
op.position = ag_start;
|
| 100 |
+
|
| 101 |
+
op.length = std::max(0, op.length - overlap);
|
| 102 |
+
}
|
| 103 |
+
return op;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// ─── Dispatch ────────────────────────────────────────────────────────────────
|
| 107 |
+
|
| 108 |
+
inline Operation transform_operation(Operation incoming, const Operation& applied) {
|
| 109 |
+
if (incoming.type == OpType::INSERT) {
|
| 110 |
+
if (applied.type == OpType::INSERT) return transform_ii(incoming, applied);
|
| 111 |
+
else return transform_id(incoming, applied);
|
| 112 |
+
} else {
|
| 113 |
+
if (applied.type == OpType::INSERT) return transform_di(incoming, applied);
|
| 114 |
+
else return transform_dd(incoming, applied);
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Transform `incoming` (based at `from_version`) against log entries
|
| 119 |
+
// with version in range (from_version, to_version].
|
| 120 |
+
inline Operation transform_against_log(
|
| 121 |
+
Operation incoming,
|
| 122 |
+
const std::vector<std::pair<int, Operation>>& op_log,
|
| 123 |
+
int from_version,
|
| 124 |
+
int to_version)
|
| 125 |
+
{
|
| 126 |
+
for (auto& [ver, applied_op] : op_log) {
|
| 127 |
+
if (ver > from_version && ver <= to_version)
|
| 128 |
+
incoming = transform_operation(incoming, applied_op);
|
| 129 |
+
}
|
| 130 |
+
return incoming;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// ─── Apply ───────────────────────────────────────────────────────────────────
|
| 134 |
+
|
| 135 |
+
inline std::string apply_operation(const std::string& content, const Operation& op) {
|
| 136 |
+
int len = static_cast<int>(content.size());
|
| 137 |
+
if (op.type == OpType::INSERT) {
|
| 138 |
+
int pos = std::max(0, std::min(op.position, len));
|
| 139 |
+
return content.substr(0, pos) + op.value + content.substr(pos);
|
| 140 |
+
} else {
|
| 141 |
+
if (op.length <= 0) return content;
|
| 142 |
+
int pos = std::max(0, std::min(op.position, len));
|
| 143 |
+
int end = std::max(0, std::min(pos + op.length, len));
|
| 144 |
+
return content.substr(0, pos) + content.substr(end);
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// ─── Cursor sync ─────────────────────────────────────────────────────────────
|
| 149 |
+
|
| 150 |
+
inline int transform_cursor(int cursor, const Operation& op) {
|
| 151 |
+
if (op.type == OpType::INSERT) {
|
| 152 |
+
if (op.position <= cursor)
|
| 153 |
+
cursor += static_cast<int>(op.value.size());
|
| 154 |
+
} else {
|
| 155 |
+
int del_end = op.position + op.length;
|
| 156 |
+
if (del_end <= cursor)
|
| 157 |
+
cursor -= op.length;
|
| 158 |
+
else if (op.position <= cursor)
|
| 159 |
+
cursor = op.position;
|
| 160 |
+
}
|
| 161 |
+
return cursor;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
} // namespace collab
|
ot_engine.py
DELETED
|
@@ -1,190 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Operational Transformation Engine
|
| 3 |
-
Implements insert-insert, insert-delete, delete-insert, delete-delete
|
| 4 |
-
transformations with correct overlap handling.
|
| 5 |
-
|
| 6 |
-
Key fixes over original:
|
| 7 |
-
- delete-delete overlap now correctly handles all 4 positional cases
|
| 8 |
-
- transform_against_log is O(n) in missed ops, not re-scanned
|
| 9 |
-
- apply_operation is side-effect free (returns new string)
|
| 10 |
-
- All operations are immutable (copy.deepcopy on transform)
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
from __future__ import annotations
|
| 14 |
-
|
| 15 |
-
import copy
|
| 16 |
-
from dataclasses import dataclass, field
|
| 17 |
-
from typing import List, Literal, Tuple
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
@dataclass
|
| 21 |
-
class Operation:
|
| 22 |
-
op_type: Literal["insert", "delete"]
|
| 23 |
-
position: int
|
| 24 |
-
value: str = "" # meaningful only for insert
|
| 25 |
-
length: int = 1 # meaningful only for delete
|
| 26 |
-
base_version: int = 0
|
| 27 |
-
user_id: str = ""
|
| 28 |
-
op_id: str = ""
|
| 29 |
-
|
| 30 |
-
def __post_init__(self):
|
| 31 |
-
# Normalize: insert length always derived from value
|
| 32 |
-
if self.op_type == "insert":
|
| 33 |
-
self.length = len(self.value)
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
# ─── Pairwise transforms ──────────────────────────────────────────────────────
|
| 37 |
-
|
| 38 |
-
def _transform_ii(op: Operation, against: Operation) -> Operation:
|
| 39 |
-
"""insert vs insert"""
|
| 40 |
-
r = copy.deepcopy(op)
|
| 41 |
-
if against.position < op.position:
|
| 42 |
-
r.position += len(against.value)
|
| 43 |
-
elif against.position == op.position:
|
| 44 |
-
# Deterministic tie-break: lower user_id wins (goes first)
|
| 45 |
-
if against.user_id <= op.user_id:
|
| 46 |
-
r.position += len(against.value)
|
| 47 |
-
return r
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def _transform_di(op: Operation, against: Operation) -> Operation:
|
| 51 |
-
"""
|
| 52 |
-
delete vs insert (against=insert already applied on server).
|
| 53 |
-
|
| 54 |
-
Policy: "delete wins" — a concurrent insert inside the delete range is
|
| 55 |
-
absorbed by the delete (the delete expands to cover new chars).
|
| 56 |
-
This is the dOPT/Jupiter convention: deletions are authoritative over
|
| 57 |
-
concurrent inserts within their range.
|
| 58 |
-
"""
|
| 59 |
-
r = copy.deepcopy(op)
|
| 60 |
-
ins_len = len(against.value)
|
| 61 |
-
ins_pos = against.position
|
| 62 |
-
del_end = op.position + op.length
|
| 63 |
-
|
| 64 |
-
if ins_pos < op.position:
|
| 65 |
-
# Insert before delete start — shift delete right
|
| 66 |
-
r.position += ins_len
|
| 67 |
-
elif ins_pos <= del_end:
|
| 68 |
-
# Insert at or inside delete range (including right boundary) —
|
| 69 |
-
# expand delete to absorb the inserted chars
|
| 70 |
-
r.length += ins_len
|
| 71 |
-
# ins_pos > del_end: insert after delete — no change
|
| 72 |
-
return r
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
def _transform_id(op: Operation, against: Operation) -> Operation:
|
| 76 |
-
"""
|
| 77 |
-
insert vs delete (against=delete already applied on server).
|
| 78 |
-
|
| 79 |
-
Policy: "delete wins" — if the insert position falls inside the deleted
|
| 80 |
-
range, collapse the insert to del_start (the insert still happens, but
|
| 81 |
-
the content it was anchored to is gone).
|
| 82 |
-
"""
|
| 83 |
-
r = copy.deepcopy(op)
|
| 84 |
-
del_start = against.position
|
| 85 |
-
del_end = against.position + against.length
|
| 86 |
-
|
| 87 |
-
if del_end <= op.position:
|
| 88 |
-
# Delete entirely before insert — shift left by deleted amount
|
| 89 |
-
r.position -= against.length
|
| 90 |
-
elif del_start < op.position:
|
| 91 |
-
# Insert was inside deleted range — collapse to del_start
|
| 92 |
-
r.position = del_start
|
| 93 |
-
# del_start >= op.position: delete is at or after insert — no change
|
| 94 |
-
return r
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def _transform_dd(op: Operation, against: Operation) -> Operation:
|
| 98 |
-
"""delete vs delete"""
|
| 99 |
-
r = copy.deepcopy(op)
|
| 100 |
-
|
| 101 |
-
op_start = op.position
|
| 102 |
-
op_end = op.position + op.length
|
| 103 |
-
ag_start = against.position
|
| 104 |
-
ag_end = against.position + against.length
|
| 105 |
-
|
| 106 |
-
if ag_end <= op_start:
|
| 107 |
-
# Against entirely before op — shift op left
|
| 108 |
-
r.position -= against.length
|
| 109 |
-
|
| 110 |
-
elif ag_start >= op_end:
|
| 111 |
-
# Against entirely after op — no change
|
| 112 |
-
pass
|
| 113 |
-
|
| 114 |
-
else:
|
| 115 |
-
# Overlapping. Four sub-cases:
|
| 116 |
-
overlap_start = max(op_start, ag_start)
|
| 117 |
-
overlap_end = min(op_end, ag_end)
|
| 118 |
-
overlap = overlap_end - overlap_start
|
| 119 |
-
|
| 120 |
-
# Shift position if against starts before op
|
| 121 |
-
if ag_start < op_start:
|
| 122 |
-
# Characters before op that were deleted shift our start left
|
| 123 |
-
r.position = ag_start
|
| 124 |
-
# else: op starts before against, position unchanged
|
| 125 |
-
|
| 126 |
-
# Reduce length by the overlap (already deleted by `against`)
|
| 127 |
-
r.length = max(0, op.length - overlap)
|
| 128 |
-
|
| 129 |
-
return r
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
def transform_operation(incoming: Operation, applied: Operation) -> Operation:
|
| 133 |
-
"""Transform `incoming` against an already-applied `applied`."""
|
| 134 |
-
if incoming.op_type == "insert":
|
| 135 |
-
if applied.op_type == "insert":
|
| 136 |
-
return _transform_ii(incoming, applied)
|
| 137 |
-
else:
|
| 138 |
-
return _transform_id(incoming, applied)
|
| 139 |
-
else:
|
| 140 |
-
if applied.op_type == "insert":
|
| 141 |
-
return _transform_di(incoming, applied)
|
| 142 |
-
else:
|
| 143 |
-
return _transform_dd(incoming, applied)
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
def transform_against_log(
|
| 147 |
-
incoming: Operation,
|
| 148 |
-
op_log: List[Tuple[int, Operation]],
|
| 149 |
-
from_version: int,
|
| 150 |
-
to_version: int,
|
| 151 |
-
) -> Operation:
|
| 152 |
-
"""
|
| 153 |
-
Transform `incoming` (based at `from_version`) against every operation
|
| 154 |
-
in op_log with version in range (from_version, to_version].
|
| 155 |
-
"""
|
| 156 |
-
result = copy.deepcopy(incoming)
|
| 157 |
-
for ver, applied_op in op_log:
|
| 158 |
-
if from_version < ver <= to_version:
|
| 159 |
-
result = transform_operation(result, applied_op)
|
| 160 |
-
return result
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
# ─── Apply ───────────────────────────────────────────────────────────────────
|
| 164 |
-
|
| 165 |
-
def apply_operation(content: str, op: Operation) -> str:
|
| 166 |
-
"""Apply a single operation to content. Pure function."""
|
| 167 |
-
if op.op_type == "insert":
|
| 168 |
-
pos = max(0, min(op.position, len(content)))
|
| 169 |
-
return content[:pos] + op.value + content[pos:]
|
| 170 |
-
elif op.op_type == "delete":
|
| 171 |
-
if op.length <= 0:
|
| 172 |
-
return content
|
| 173 |
-
pos = max(0, min(op.position, len(content)))
|
| 174 |
-
end = max(0, min(pos + op.length, len(content)))
|
| 175 |
-
return content[:pos] + content[end:]
|
| 176 |
-
return content
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
def transform_cursor(cursor_pos: int, op: Operation) -> int:
|
| 180 |
-
"""Adjust a cursor position given an applied operation."""
|
| 181 |
-
if op.op_type == "insert":
|
| 182 |
-
if op.position <= cursor_pos:
|
| 183 |
-
return cursor_pos + len(op.value)
|
| 184 |
-
elif op.op_type == "delete":
|
| 185 |
-
del_end = op.position + op.length
|
| 186 |
-
if del_end <= cursor_pos:
|
| 187 |
-
return cursor_pos - op.length
|
| 188 |
-
elif op.position <= cursor_pos:
|
| 189 |
-
return op.position
|
| 190 |
-
return cursor_pos
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|