Both CRDT and LWW must coexist. The strategy is selected at graph construction time via GraphSettings. Existing CRDT code is preserved unchanged.
- The CRDT path must remain untouched and fully functional. Zero modifications to
delta_crdt.h, existingcrdt_types.h, or the currentdsr_api.cpplogic. - The public API (
Node,Edge,Attribute,DSRGraph) must not change. User agents compile without modification regardless of which strategy is active. - Strategy is selected at construction time via a new field in
GraphSettings. All agents on the same network must use the same strategy (enforced by incompatible DDS topic types). - Each step must leave the system compilable and testable. The CRDT path passes all existing tests at every step.
- No code duplication of shared infrastructure. Cache maps, signals, locks, DDS participant setup, subscription thread structure, and the full public API are shared.
The CRDT-specific logic in DSRGraph is concentrated in ~15 internal methods that touch mvreg operations (write, read_reg, empty, reset, join). These methods also interact with:
- The
nodesmap (whose value type differs:mvreg<CRDTNode>vsLWWEntry) - IDL types for serialization (different wire formats)
- DDS publishers (different message types)
- The unprocessed delta buffers (different stored types)
Everything else is shared: cache maps, signals, mutexes, user-facing API, DDS participant, subscription thread lifecycle.
Extract the 15 strategy-dependent methods into a virtual SyncEngine interface. DSRGraph holds a unique_ptr<SyncEngine> and delegates to it. Two implementations:
┌──────────────────────────────────────────┐
│ DSRGraph │
│ (public API, signals, cache maps, locks, │
│ DDS participant, subscription threads) │
│ │
│ unique_ptr<SyncEngine> engine_; │
└──────────────┬───────────────────────────┘
│
┌────────┴────────┐
│ SyncEngine │ (virtual interface)
│ │
│ get_node_() │
│ insert_node_()│
│ update_node_()|
│ delete_node_()|
│ join_delta_* │
│ ... │
└───────┬────────┘
┌─────────┴──────────┐
│ │
┌────────┴───────┐ ┌───────┴────────┐
│ CRDTSyncEngine │ │ LWWSyncEngine │
│ │ │ │
│ nodes: map<id, │ │ nodes: map<id, │
│ mvreg<Node>> │ │ LWWEntry> │
│ │ │ │
│ delta_crdt.h │ │ lww_types.h │
│ translator.h │ │ lww_translator │
│ CRDT IDL types │ │ LWW IDL types │
└────────────────┘ └────────────────┘
- Node storage (the
nodesmap with its strategy-specific value type) - Unprocessed delta buffers (different stored types per strategy)
- All 15 internal methods that touch mvreg or LWW operations
- IDL serialization (strategy-specific translator functions)
- DDS publishers for strategy-specific message types
- Public API methods (
get_node,insert_node,update_node,delete_node, etc.) - Cache maps (
name_map,id_map,nodeType,edgeType,edges,to_edges,deleted) - Mutexes (
_mutex,_mutex_cache_maps) - Signal emission infrastructure
- DDS participant, subscriber setup, subscription thread lifecycle
- ThreadPools
- User type conversions (these only touch
Node,Edge,Attribute) - JSON read/write
- Graph copy mechanism
Branch from development. All work happens on feature/lww-strategy.
Run the existing benchmark suite and record CRDT results. These are the baseline.
cmake -DWITH_TESTS=ON -DWITH_BENCHMARKS=ON ..
make -j && ctest --output-on-failureFiles to create:
api/include/dsr/api/dsr_sync_engine.h
The interface must expose the 15 internal methods that DSRGraph currently calls directly. It must also expose a way for DSRGraph to read node data (for cache map updates, signal emission, and user-facing conversions).
Key methods to extract:
class SyncEngine {
public:
virtual ~SyncEngine() = default;
// --- Node storage access ---
// Returns a CRDTNode copy (the common internal type) or nullopt
virtual std::optional<CRDTNode> get_node(uint64_t id) = 0;
virtual bool node_exists(uint64_t id) = 0;
virtual size_t node_count() = 0;
virtual void for_each_node(std::function<void(uint64_t, const CRDTNode&)>) = 0;
// --- Edge access ---
virtual std::optional<CRDTEdge> get_edge(uint64_t from, uint64_t to,
const std::string& key) = 0;
// --- Local write operations ---
// Return IDL-agnostic results; publishing handled by DSRGraph
virtual InsertNodeResult insert_node(CRDTNode&& node) = 0;
virtual UpdateNodeResult update_node(CRDTNode&& node) = 0;
virtual DeleteNodeResult delete_node(uint64_t id) = 0;
virtual InsertEdgeResult insert_or_assign_edge(CRDTEdge&& edge,
uint64_t from, uint64_t to) = 0;
virtual DeleteEdgeResult delete_edge(uint64_t from, uint64_t to,
const std::string& key) = 0;
// --- Network join operations ---
// Called from subscription threads with raw DDS data
virtual void join_delta_node(eprosima::fastdds::dds::DataReader* reader) = 0;
virtual void join_delta_edge(eprosima::fastdds::dds::DataReader* reader) = 0;
virtual void join_delta_node_attr(eprosima::fastdds::dds::DataReader* reader) = 0;
virtual void join_delta_edge_attr(eprosima::fastdds::dds::DataReader* reader) = 0;
// --- Full graph sync ---
virtual void serve_full_graph(DSRPublisher& pub) = 0;
virtual void join_full_graph(eprosima::fastdds::dds::DataReader* reader) = 0;
// --- DDS topic setup ---
virtual void register_topics(DSRParticipant& participant) = 0;
virtual void publish_node_delta(/* strategy-specific delta */) = 0;
// ... etc
};
The write operations need to return enough information for DSRGraph to:
- Publish the delta over DDS (strategy-specific — so the engine publishes internally)
- Emit signals (needs: node id, type, changed attribute names, edge from/to/type)
- Update cache maps (needs: id, name, type, edge keys)
Since the delta format is strategy-specific, the engine should handle DDS publishing internally. The result types only carry signal/cache data:
struct InsertNodeResult {
bool success;
// Signal data (only if success):
uint64_t id;
std::string type;
std::vector<std::pair<uint64_t, std::string>> edges; // fano keys
};
struct UpdateNodeResult {
bool success;
std::vector<std::string> changed_attrs;
};
struct DeleteNodeResult {
bool success;
std::vector<Edge> deleted_edges; // for signal emission
std::optional<Node> deleted_node; // for deleted_node_signal
};
// ... similar for edge results
The engine needs to call back into DSRGraph for:
- Cache map updates (
update_maps_node_insert,update_maps_node_delete, etc.) - Signal emission
- Accessing the
deletedset - Acquiring locks
Define a SyncEngineHost interface that DSRGraph implements:
class SyncEngineHost {
public:
virtual void update_maps_node_insert(uint64_t id, const CRDTNode& n) = 0;
virtual void update_maps_node_delete(uint64_t id, const std::optional<CRDTNode>& n) = 0;
virtual void update_maps_edge_insert(uint64_t from, uint64_t to, const std::string& key) = 0;
virtual void update_maps_edge_delete(uint64_t from, uint64_t to, const std::string& key) = 0;
virtual bool is_deleted(uint64_t id) = 0;
virtual void mark_deleted(uint64_t id) = 0;
virtual std::shared_mutex& mutex() = 0;
virtual std::shared_mutex& cache_mutex() = 0;
virtual signals_fns& emitter() = 0;
virtual uint32_t agent_id() = 0;
virtual bool is_copy() = 0;
};
Files to modify:
api/include/dsr/api/dsr_graph_settings.h
enum struct SyncMode : uint8_t {
CRDT = 0, // Default: delta-based CRDT (current behavior)
LWW = 1, // Last-Write-Wins (same-host only)
};struct GraphSettings {
// ... existing fields ...
SyncMode sync_mode = SyncMode::CRDT; // default preserves current behavior
};This is a refactor-only step. No new functionality. Move the 15 internal methods and the nodes map from DSRGraph into CRDTSyncEngine, which implements SyncEngine.
Files to create:
api/include/dsr/api/dsr_crdt_sync_engine.hapi/dsr_crdt_sync_engine.cpp
Files to modify:
api/include/dsr/api/dsr_api.h(remove moved members, addunique_ptr<SyncEngine>)api/dsr_api.cpp(remove moved method bodies, delegate to engine)
Move from DSRGraph:
Nodes nodes; // unordered_map<uint64_t, mvreg<CRDTNode>>Into CRDTSyncEngine:
class CRDTSyncEngine : public SyncEngine {
Nodes nodes;
// ... unprocessed delta maps ...
};Move these method bodies from dsr_api.cpp into dsr_crdt_sync_engine.cpp:
get_()→CRDTSyncEngine::get_node()get_edge_()→CRDTSyncEngine::get_edge()insert_node_()→CRDTSyncEngine::insert_node()update_node_()→CRDTSyncEngine::update_node()delete_node_()→CRDTSyncEngine::delete_node()insert_or_assign_edge_()→CRDTSyncEngine::insert_or_assign_edge()delete_edge_()→CRDTSyncEngine::delete_edge()join_delta_node()→CRDTSyncEngine::join_delta_node()join_delta_edge()→CRDTSyncEngine::join_delta_edge()join_delta_node_attr()→CRDTSyncEngine::join_delta_node_attr()join_delta_edge_attr()→CRDTSyncEngine::join_delta_edge_attr()process_delta_edge()→ stays private in CRDTSyncEngineprocess_delta_node_attr()→ stays private in CRDTSyncEngineprocess_delta_edge_attr()→ stays private in CRDTSyncEnginejoin_full_graph()→CRDTSyncEngine::join_full_graph()Map()→CRDTSyncEngine::serialize_graph()
These four maps move from DSRGraph to CRDTSyncEngine:
std::unordered_multimap<uint64_t, std::tuple<std::string, mvreg<CRDTAttribute>, uint64_t>>
unprocessed_delta_node_att;
std::unordered_multimap<uint64_t, std::tuple<uint64_t, std::string, mvreg<CRDTEdge>, uint64_t>>
unprocessed_delta_edge_from;
std::unordered_multimap<uint64_t, std::tuple<uint64_t, std::string, mvreg<CRDTEdge>, uint64_t>>
unprocessed_delta_edge_to;
std::unordered_multimap<std::tuple<uint64_t, uint64_t, std::string>,
std::tuple<std::string, mvreg<CRDTAttribute>, uint64_t>, hash_tuple>
unprocessed_delta_edge_att;The four delta publishers move into the engine (node, edge, node_attrs, edge_attrs). The graph_request/answer publishers stay in DSRGraph (they are strategy-independent).
Replace direct method calls with engine delegation:
// Before:
std::optional<CRDTNode> DSRGraph::get_(uint64_t id) {
auto it = nodes.find(id);
if (it != nodes.end() and !it->second.empty())
return std::make_optional(it->second.read_reg());
return {};
}
// After:
std::optional<CRDTNode> DSRGraph::get_(uint64_t id) {
return engine_->get_node(id);
}The CRDTSyncEngine method bodies must be identical to the original code, just receiving host callbacks instead of accessing DSRGraph members directly. The engine accesses cache maps and signals through the SyncEngineHost interface.
Checkpoint: all existing tests pass with CRDTSyncEngine. Behavior is identical. This is the most critical step — if tests pass here, the abstraction is correct.
Files to create:
core/include/dsr/core/types/lww_types.h
class LWWNode {
std::string m_type, m_name;
uint64_t m_id;
uint32_t m_agent_id;
uint64_t m_timestamp; // node-level timestamp
std::unordered_map<std::string, Attribute> m_attrs;
std::unordered_map<std::pair<uint64_t, std::string>, LWWEdge, hash_tuple> m_fano;
};Provide the same accessor API as CRDTNode (type(), name(), id(), attrs(), fano()) so that:
- User type constructors (
Node(const LWWNode&)) can work - Cache map update functions can work
- The SyncEngine interface can return
CRDTNodeby converting fromLWWNode(or we generalize the interface — see 4.3)
class LWWEdge {
uint64_t m_to, m_from;
std::string m_type;
uint32_t m_agent_id;
uint64_t m_timestamp;
std::unordered_map<std::string, Attribute> m_attrs;
};Option A: SyncEngine always returns CRDTNode/CRDTEdge, and LWWSyncEngine converts internally. Simple but adds a conversion cost.
Option B: SyncEngine returns Node/Edge (user types). These are strategy-independent. This means the engine does the internal→user conversion, not DSRGraph.
Option C: Template SyncEngine on the internal node type. Adds compile-time complexity but zero runtime cost.
Recommended: Option B. The SyncEngine returns user-facing Node/Edge objects. DSRGraph already wants to return Node/Edge to the user, so the conversion happens once inside the engine. The cache map update functions only need id, name, type, and edge keys — these can be passed as arguments or extracted from Node.
This means the SyncEngine interface from Step 1 changes slightly:
virtual std::optional<Node> get_node(uint64_t id) = 0;
virtual std::optional<Edge> get_edge(uint64_t from, uint64_t to, const std::string& key) = 0;
For internal operations that need the raw node (cache map updates), the engine calls the host callbacks with the data directly.
struct LWWClock {
static bool wins(uint64_t ts_a, uint32_t agent_a,
uint64_t ts_b, uint32_t agent_b) {
if (ts_a != ts_b) return ts_a > ts_b;
return agent_a > agent_b; // deterministic tiebreak
}
};Files to modify:
core/topics/IDLGraph.idl— append new types, do NOT modify existing ones
Append to the end of IDLGraph.idl:
// ============================================================
// LWW (Last-Write-Wins) message types — same-host deployment
// ============================================================
struct LWWNodeAttrDelta {
unsigned long long node_id;
string attr_name;
Attrib value;
unsigned long agent_id;
unsigned long long timestamp;
boolean is_delete;
};
struct LWWEdgeAttrDelta {
unsigned long long from;
unsigned long long to;
string edge_type;
string attr_name;
Attrib value;
unsigned long agent_id;
unsigned long long timestamp;
boolean is_delete;
};
struct LWWEdgeDelta {
unsigned long long from;
unsigned long long to;
string edge_type;
map<string, Attrib> attrs;
unsigned long agent_id;
unsigned long long timestamp;
boolean is_delete;
};
struct LWWNodeDelta {
unsigned long long id;
string type;
string name;
unsigned long agent_id;
unsigned long long timestamp;
boolean is_delete;
map<string, Attrib> attrs;
map<EdgeKey, LWWEdgeDelta> fano;
};
struct LWWFullGraph {
map<unsigned long long, LWWNodeDelta> nodes;
};fastddsgen -replace core/topics/IDLGraph.idlThe existing CRDT types (DotContext, DotKernel, MvregNode, etc.) are unchanged. New LWW types are added alongside them.
The generated code will include new PubSubType classes for each LWW struct. These will be registered on separate DDS topics by LWWSyncEngine (not the same topics as CRDT).
Checkpoint: IDL regenerated. Old CRDT types unchanged. New LWW types available. All existing tests pass.
Files to create:
core/include/dsr/core/types/lww_translator.h
These are trivial field copies — no dot context, no dot kernel:
LWWNodeDelta node_to_lww_idl(const LWWNode& node);
LWWNode lww_idl_to_node(LWWNodeDelta&& delta);
Node lww_node_to_user(const LWWNode& node);
LWWNode user_to_lww_node(const Node& node, uint64_t timestamp, uint32_t agent_id);
Same trivial pattern for edges and attributes.
Files to create:
api/include/dsr/api/dsr_lww_sync_engine.hapi/dsr_lww_sync_engine.cpp
This is the core implementation step. Each method is dramatically simpler than its CRDT counterpart.
class LWWSyncEngine : public SyncEngine {
std::unordered_map<uint64_t, LWWNode> nodes;
// Unprocessed delta buffers (same structure, no mvreg)
std::unordered_multimap<uint64_t,
std::tuple<std::string, Attribute, uint64_t>> unprocessed_delta_node_att;
// ... similar for edges ...
};auto it = nodes.find(id);
if (it != nodes.end())
return lww_node_to_user(it->second);
return {};
1. Check host->is_deleted(id) — if deleted with newer timestamp, reject
2. nodes[id] = std::move(node)
3. host->update_maps_node_insert(id, ...)
4. Create LWWNodeDelta, publish via DDS
5. Return InsertNodeResult with signal data
1. Find existing in nodes map
2. For each incoming attribute:
- If newer timestamp: replace
- Add to changed list
3. For each existing attribute not in incoming: remove
4. Create vector<LWWNodeAttrDelta> for changes
5. Publish via DDS
6. Return UpdateNodeResult with changed attr names
1. Snapshot node for signals
2. Collect outgoing + incoming edges for cascade delete
3. Remove from nodes map
4. host->mark_deleted(id)
5. host->update_maps_node_delete(id, ...)
6. Create LWWNodeDelta{is_delete=true}, publish
7. Create LWWEdgeDelta{is_delete=true} for each cascade, publish
8. Return DeleteNodeResult
1. Deserialize LWWNodeDelta from DDS
2. Acquire unique_lock via host->mutex()
3. If is_delete:
- If node exists and delete timestamp > node.timestamp: delete + signal
- Else: ignore
4. If not is_delete:
- If deleted with newer timestamp: ignore
- If deleted with older timestamp: resurrect
- Apply LWW merge per attribute (timestamp comparison)
- host->update_maps_*
- Consume unprocessed deltas
5. Release lock
6. Emit signals via host->emitter()
Same pattern: deserialize, timestamp comparison, apply if newer.
1. Deserialize LWWFullGraph
2. For each node:
- Apply same logic as join_delta_node but in batch
- No publishing (we're receiving)
3. Consume unprocessed deltas
LWWSyncEngine uses different DDS topic names than CRDTSyncEngine to prevent cross-talk:
CRDT topics: "DSR_NODE", "DSR_EDGE", "DSR_NODE_ATTRS", "DSR_EDGE_ATTRS"
LWW topics: "DSR_LWW_NODE", "DSR_LWW_EDGE", "DSR_LWW_NODE_ATTRS", "DSR_LWW_EDGE_ATTRS"
If a CRDT agent and an LWW agent accidentally end up on the same DDS domain, they simply don't see each other's messages — safe failure mode.
Files to modify:
api/include/dsr/api/dsr_api.hapi/dsr_api.cpp
class DSRGraph : public QObject, public SyncEngineHost {
// ...
std::unique_ptr<SyncEngine> engine_;
};DSRGraph::DSRGraph(GraphSettings settings) : /* ... */ {
if (settings.sync_mode == SyncMode::LWW) {
engine_ = std::make_unique<LWWSyncEngine>(this);
} else {
engine_ = std::make_unique<CRDTSyncEngine>(this);
}
// ... rest of constructor (DDS setup, file load, threads) ...
}DSRGraph implements the host interface, exposing cache maps and signals to the engine:
void DSRGraph::update_maps_node_insert(uint64_t id, const CRDTNode& n) {
// Existing code, unchanged
}
// ... etc ...These methods are already implemented in DSRGraph. They just need to be exposed via the SyncEngineHost interface.
The subscription threads currently call hardcoded CRDT join methods. Change them to delegate to the engine:
void DSRGraph::node_subscription_thread() {
// ... DDS reader setup ...
// Callback:
engine_->join_delta_node(reader);
}Public methods like get_node(), insert_node(), etc. delegate to the engine:
std::optional<Node> DSRGraph::get_node(uint64_t id) {
std::shared_lock<std::shared_mutex> lock(_mutex);
return engine_->get_node(id);
}Checkpoint: DSRGraph compiles with both engines. SyncMode::CRDT produces identical behavior to before. SyncMode::LWW is functional.
Files to modify:
core/include/dsr/core/utils.h
Keep get_unix_timestamp() unchanged (CRDT path uses it). Add a new function:
[[maybe_unused]] static uint64_t get_monotonic_timestamp() {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return static_cast<uint64_t>(ts.tv_sec) * 1'000'000'000ULL
+ static_cast<uint64_t>(ts.tv_nsec);
}LWWSyncEngine uses get_monotonic_timestamp(). CRDTSyncEngine keeps using get_unix_timestamp().
Files to create:
tests/lww/lww_operations.cpp— unit tests for LWW register semanticstests/lww/lww_sync.cpp— two-agent synchronization teststests/lww/lww_deletion.cpp— tombstone and delete-insert ordering tests
- Two writes: newer timestamp wins
- Tiebreak: same timestamp, higher agent_id wins
- Stale delta ignored
- Attribute-level LWW resolution
- Agent A inserts node, Agent B receives it
- Agent A updates attribute, Agent B converges
- Agent A deletes node, Agent B removes it
- Concurrent updates from A and B resolve by timestamp
- Full graph sync: new Agent C joins and gets current state
- Delete at T1, insert at T2>T1: node exists
- Insert at T1, delete at T2>T1: node deleted
- Full graph sync doesn't resurrect deleted nodes (timestamped tombstones)
clear_deleted()works
All existing CRDT tests must still pass unchanged. They test SyncMode::CRDT.
Files to modify:
python-wrapper/python_api.cpp
pydsr.SyncMode.CRDT
pydsr.SyncMode.LWWAdd sync_mode parameter to the Python DSRGraph constructor, defaulting to SyncMode.CRDT.
Python bindings only expose Node, Edge, Attribute — no CRDT types. Both engines produce the same user-facing types.
Files to create:
benchmarks/strategy/crdt_vs_lww_bench.cpp
For each benchmark (throughput, latency, convergence, memory):
- Run with
SyncMode::CRDT - Run with
SyncMode::LWW - Report side-by-side
| Metric | CRDT | LWW | Speedup |
|---|---|---|---|
| Write throughput | baseline | ~10x | |
| Join latency | baseline | ~25x | |
| Memory per node | baseline | ~8x smaller | |
| Message size | baseline | ~1.7x smaller |
In README or a dedicated doc, explain:
SyncMode::CRDT— default, safe for multi-host deploymentSyncMode::LWW— same-host only, ~10x faster, requires shared monotonic clock- All agents on the network must use the same mode (different DDS topics enforce this)
Add new source files to the build:
dsr_crdt_sync_engine.cppdsr_lww_sync_engine.cpp- New test files
- New benchmark files
delta_crdt.h, crdt_types.h, translator.h, existing IDL types — all stay. The CRDT path is fully preserved.
| File | Purpose |
|---|---|
api/include/dsr/api/dsr_sync_engine.h |
Abstract SyncEngine + SyncEngineHost interfaces |
api/include/dsr/api/dsr_crdt_sync_engine.h |
CRDT engine header |
api/dsr_crdt_sync_engine.cpp |
CRDT engine impl (code moved from dsr_api.cpp) |
api/include/dsr/api/dsr_lww_sync_engine.h |
LWW engine header |
api/dsr_lww_sync_engine.cpp |
LWW engine impl (new code) |
core/include/dsr/core/types/lww_types.h |
LWWNode, LWWEdge, LWWClock |
core/include/dsr/core/types/lww_translator.h |
LWW ↔ IDL ↔ User conversions |
tests/lww/lww_operations.cpp |
LWW unit tests |
tests/lww/lww_sync.cpp |
LWW sync tests |
tests/lww/lww_deletion.cpp |
LWW deletion tests |
benchmarks/strategy/crdt_vs_lww_bench.cpp |
Comparative benchmark |
| File | Change |
|---|---|
api/include/dsr/api/dsr_graph_settings.h |
Add SyncMode enum + field |
api/include/dsr/api/dsr_api.h |
Add unique_ptr<SyncEngine>, implement SyncEngineHost, remove moved members |
api/dsr_api.cpp |
Remove moved method bodies, delegate to engine |
core/topics/IDLGraph.idl |
Append LWW IDL types (no modification to existing) |
core/include/dsr/core/utils.h |
Add get_monotonic_timestamp() |
python-wrapper/python_api.cpp |
Expose SyncMode, add constructor parameter |
CMakeLists.txt (api, core, tests, benchmarks) |
Add new source files |
| File | Why |
|---|---|
core/include/dsr/core/crdt/delta_crdt.h |
CRDT library stays |
core/include/dsr/core/types/crdt_types.h |
CRDT types stay |
core/include/dsr/core/types/translator.h |
CRDT translator stays |
core/include/dsr/core/types/user_types.h |
User types unchanged |
core/include/dsr/core/types/common_types.h |
Attribute unchanged |
core/rtps/dsrpublisher.h |
Base publisher unchanged |
core/rtps/dsrsubscriber.h |
Base subscriber unchanged |
| All existing test files | CRDT tests unchanged |
| All existing benchmark files | CRDT benchmarks unchanged |
Step 0 (baseline, verify green)
│
├──► Step 1 (SyncEngine interface)
│ │
│ ▼
│ Step 3 (Extract CRDTSyncEngine — refactor only, critical step)
│ │
│ │ ┌──────────────────────────────────────────┐
│ │ │ In parallel after Step 3: │
│ ├────►│ Step 4 (LWW types) │
├──► │ │ Step 5 (LWW IDL types) │
│ Step 2 │ Step 6 (LWW translator) │
│ (settings) │ Step 9 (monotonic clock) │
│ │ └──────────────────────────────────────────┘
│ │ │
│ │ ▼
│ └──────────► Step 7 (LWWSyncEngine implementation)
│ │
│ ▼
│ Step 8 (Wire DSRGraph to select engine)
│ │
│ ┌────┴────┐
│ ▼ ▼
│ Step 10 Step 11
│ (tests) (python)
│ │ │
│ └────┬────┘
│ ▼
│ Step 12 (benchmarks)
│ │
│ ▼
└───────────────► Step 13 (docs + cleanup)
Critical path: Steps 0 → 1 → 3 → 7 → 8 → 10
Step 3 is the riskiest: extracting CRDTSyncEngine without changing behavior. If existing tests pass after Step 3, the abstraction is correct and everything else is additive.
| Step | New Lines | Modified Lines | Risk |
|---|---|---|---|
| 1. SyncEngine interface | ~120 | 0 | Low |
| 2. GraphSettings | ~5 | ~3 | Trivial |
| 3. Extract CRDTSyncEngine | ~800 (moved) | ~200 (DSRGraph delegation) | High |
| 4. LWW types | ~200 | 0 | Low |
| 5. LWW IDL types | ~60 | ~0 (append only) | Low |
| 6. LWW translator | ~150 | 0 | Low |
| 7. LWWSyncEngine | ~600 | 0 | Medium |
| 8. Wire DSRGraph | ~30 | ~80 | Medium |
| 9. Monotonic clock | ~8 | 0 | Trivial |
| 10. LWW tests | ~400 | 0 | Low |
| 11. Python bindings | ~15 | ~10 | Low |
| 12. Benchmarks | ~200 | 0 | Low |
| 13. Docs + cleanup | ~50 | ~20 | Trivial |
| Total | ~2,600 new | ~300 modified |
No existing code is deleted. The CRDT path is preserved in full.