Skip to content

Commit a73bec4

Browse files
committed
refactor: use shallow merge for safety
too many issues with values that need to be atomic like position (won't work with just 1 coordinate on AFRAME render). just keep it shallow on top level of `data` for good gains
1 parent d460a08 commit a73bec4

4 files changed

Lines changed: 49 additions & 60 deletions

File tree

arena/delta.py

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,17 @@
22
Delta compression for ARENA MQTT messages.
33
44
Computes the minimal diff between two JSON-serialized data dicts,
5-
returning only changed fields. Used on the outbound publish path
5+
returning only changed top-level fields. Used on the outbound publish path
66
to reduce MQTT payload sizes.
77
"""
88

99

10-
def deep_diff(prev, next_val):
11-
"""Compute the minimal delta between two data dicts.
10+
def shallow_diff(prev, next_val):
11+
"""Compute the delta between two data dicts (top-level keys only).
1212
13-
Both inputs must be JSON-primitive dicts (str, int, float, bool,
14-
None, dict, list — no custom objects). This is guaranteed when
15-
called after Object.json() serialization.
16-
17-
Rules:
18-
- Recurse into nested dicts; identical sub-dicts are omitted.
19-
- None is a semantic delete and always flows through.
20-
None → None is a no-op (omitted).
21-
- Arrays compared by value (Python == does deep equality).
22-
- Keys in next_val but not prev are new → included.
23-
- Keys in prev but not next_val are removed → emitted as None.
13+
Compares top-level keys by value without recursing into nested objects.
14+
This ensures that complex nested structures (position, rotation, material, etc.)
15+
are always sent completely when they change, avoiding partial update issues.
2416
2517
Args:
2618
prev: Previously published data dict.
@@ -44,14 +36,7 @@ def deep_diff(prev, next_val):
4436
if pv is None and nv is None:
4537
continue
4638

47-
# Recurse into nested dicts
48-
if isinstance(pv, dict) and isinstance(nv, dict):
49-
sub = deep_diff(pv, nv)
50-
if sub: # Only include if something changed
51-
diff[key] = sub
52-
continue
53-
54-
# Primitives / lists: Python == handles deep equality
39+
# Compare by value (no recursion into nested dicts/lists)
5540
if pv != nv:
5641
diff[key] = nv
5742

arena/device.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import sys
66

77
from .arena_mqtt import ArenaMQTT
8-
from .delta import deep_diff
8+
from .delta import shallow_diff
99
from .env import DEVICE, _get_env
1010

1111

@@ -85,7 +85,7 @@ def publish(self, topic, payload_obj):
8585
self._last_published_state[object_id] = copy.deepcopy(data)
8686
elif action == "update" and isinstance(data, dict):
8787
prev_data = self._last_published_state[object_id]
88-
delta = deep_diff(prev_data, data)
88+
delta = shallow_diff(prev_data, data)
8989
self._last_published_state[object_id] = copy.deepcopy(data)
9090
payload_obj = {**payload_obj, "data": delta}
9191

arena/scene.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from inspect import signature
1212
from pathlib import Path
1313

14-
from .delta import deep_diff
14+
from .delta import shallow_diff
1515

1616
import __main__ as main
1717

@@ -733,7 +733,7 @@ def _apply_delta(self, payload_str, action):
733733

734734
# Compute delta against last-published state
735735
prev_data = self._last_published_state[object_id]
736-
delta = deep_diff(prev_data, data)
736+
delta = shallow_diff(prev_data, data)
737737

738738
# Store the full current state (already a deep copy from json.loads)
739739
self._last_published_state[object_id] = data

tests/test_delta.py

Lines changed: 38 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,30 @@
1-
"""Tests for delta compression (arena.delta.deep_diff)."""
1+
"""Tests for delta compression (arena.delta.shallow_diff)."""
22

33
import json
44
import unittest
55

6-
from arena.delta import deep_diff
6+
from arena.delta import shallow_diff
77

88

9-
class TestDeepDiff(unittest.TestCase):
10-
"""Unit tests for deep_diff function."""
9+
class TestShallowDiff(unittest.TestCase):
10+
"""Unit tests for shallow_diff function (top-level keys only)."""
1111

1212
def test_identical_objects(self):
1313
"""Identical dicts should produce empty diff."""
1414
d = {"position": {"x": 1, "y": 2, "z": 3}, "color": "#ff0000"}
15-
self.assertEqual(deep_diff(d, d.copy()), {})
15+
self.assertEqual(shallow_diff(d, d.copy()), {})
1616

1717
def test_position_change(self):
18-
"""Only changed position fields should appear in diff."""
18+
"""Changed position dict (even partial) should be included completely."""
1919
prev = {"position": {"x": 2.965, "y": 1.6, "z": 8.877}, "rotation": {"w": 1, "x": 0, "y": 0, "z": 0}}
2020
next_val = {"position": {"x": 2.852, "y": 1.6, "z": 8.88}, "rotation": {"w": 1, "x": 0, "y": 0, "z": 0}}
21-
diff = deep_diff(prev, next_val)
22-
self.assertEqual(diff, {"position": {"x": 2.852, "z": 8.88}})
21+
diff = shallow_diff(prev, next_val)
22+
# Position changed, so full position is included (no recursion)
23+
self.assertEqual(diff, {"position": {"x": 2.852, "y": 1.6, "z": 8.88}})
2324
self.assertNotIn("rotation", diff)
2425

2526
def test_camera_sequence(self):
26-
"""Full camera message delta from speconly position.x and position.z changed."""
27+
"""Camera message with position changesends full position (no recursion)."""
2728
prev_data = {
2829
"arena-user": {
2930
"color": "#eca7ef",
@@ -52,10 +53,11 @@ def test_camera_sequence(self):
5253
"position": {"x": 2.852, "y": 1.6, "z": 8.88},
5354
"rotation": {"w": 1, "x": -0.019, "y": 0.013, "z": 0},
5455
}
55-
diff = deep_diff(prev_data, next_data)
56-
self.assertEqual(diff, {"position": {"x": 2.852, "z": 8.88}})
56+
diff = shallow_diff(prev_data, next_data)
57+
# Position and arena-user dicts changed, so both are sent completely
58+
self.assertEqual(diff, {"position": {"x": 2.852, "y": 1.6, "z": 8.88}})
5759

58-
# Verify the delta is much smaller than the full payload
60+
# Verify the delta is still much smaller than the full payload
5961
full_size = len(json.dumps(next_data))
6062
delta_size = len(json.dumps(diff))
6163
self.assertLess(delta_size, full_size / 2)
@@ -64,103 +66,105 @@ def test_null_component_delete(self):
6466
"""None value (semantic delete) must flow through the diff."""
6567
prev = {"object_type": "box", "material": {"color": "#ff0000"}}
6668
next_val = {"object_type": "box", "material": None}
67-
diff = deep_diff(prev, next_val)
69+
diff = shallow_diff(prev, next_val)
6870
self.assertEqual(diff, {"material": None})
6971

7072
def test_null_to_null_noop(self):
7173
"""Both prev and next have None for same key → omitted from diff."""
7274
prev = {"object_type": "box", "material": None}
7375
next_val = {"object_type": "box", "material": None}
74-
diff = deep_diff(prev, next_val)
76+
diff = shallow_diff(prev, next_val)
7577
self.assertEqual(diff, {})
7678

7779
def test_new_field_added(self):
7880
"""Field in next but not prev should be included."""
7981
prev = {"object_type": "box"}
8082
next_val = {"object_type": "box", "material": {"color": "#00ff00"}}
81-
diff = deep_diff(prev, next_val)
83+
diff = shallow_diff(prev, next_val)
8284
self.assertEqual(diff, {"material": {"color": "#00ff00"}})
8385

8486
def test_field_removed(self):
8587
"""Field in prev but not next → emitted as None (semantic delete)."""
8688
prev = {"object_type": "box", "material": {"color": "#ff0000"}}
8789
next_val = {"object_type": "box"}
88-
diff = deep_diff(prev, next_val)
90+
diff = shallow_diff(prev, next_val)
8991
self.assertEqual(diff, {"material": None})
9092

9193
def test_nested_partial_change(self):
92-
"""Only changed fields in nested dicts should appear."""
94+
"""Changed nested dict is sent completely (no recursion)."""
9395
prev = {"material": {"color": "#ff0000", "opacity": 0.5, "transparent": True}}
9496
next_val = {"material": {"color": "#00ff00", "opacity": 0.5, "transparent": True}}
95-
diff = deep_diff(prev, next_val)
96-
self.assertEqual(diff, {"material": {"color": "#00ff00"}})
97+
diff = shallow_diff(prev, next_val)
98+
# Material changed, so full material dict is sent
99+
self.assertEqual(diff, {"material": {"color": "#00ff00", "opacity": 0.5, "transparent": True}})
97100

98101
def test_array_unchanged(self):
99102
"""Identical arrays should be omitted from diff."""
100103
prev = {"path": [[0, 0, 0], [1, 1, 1]], "object_type": "line"}
101104
next_val = {"path": [[0, 0, 0], [1, 1, 1]], "object_type": "line"}
102-
diff = deep_diff(prev, next_val)
105+
diff = shallow_diff(prev, next_val)
103106
self.assertEqual(diff, {})
104107

105108
def test_array_changed(self):
106109
"""Changed arrays should be included in diff."""
107110
prev = {"path": [[0, 0, 0], [1, 1, 1]]}
108111
next_val = {"path": [[0, 0, 0], [2, 2, 2]]}
109-
diff = deep_diff(prev, next_val)
112+
diff = shallow_diff(prev, next_val)
110113
self.assertEqual(diff, {"path": [[0, 0, 0], [2, 2, 2]]})
111114

112115
def test_deep_nested_diff(self):
113-
"""3+ levels of nesting should be handled correctly."""
116+
"""Deeply nested dicts are sent completely if any change (no recursion)."""
114117
prev = {"a": {"b": {"c": {"d": 1, "e": 2}}, "f": 3}}
115118
next_val = {"a": {"b": {"c": {"d": 1, "e": 99}}, "f": 3}}
116-
diff = deep_diff(prev, next_val)
117-
self.assertEqual(diff, {"a": {"b": {"c": {"e": 99}}}})
119+
diff = shallow_diff(prev, next_val)
120+
# "a" dict changed, so full "a" dict is sent
121+
self.assertEqual(diff, {"a": {"b": {"c": {"d": 1, "e": 99}}, "f": 3}})
118122

119123
def test_empty_dicts(self):
120124
"""Two empty dicts should produce empty diff."""
121-
self.assertEqual(deep_diff({}, {}), {})
125+
self.assertEqual(shallow_diff({}, {}), {})
122126

123127
def test_prev_empty(self):
124128
"""Empty prev means everything in next is new."""
125129
next_val = {"position": {"x": 1, "y": 2, "z": 3}}
126-
diff = deep_diff({}, next_val)
130+
diff = shallow_diff({}, next_val)
127131
self.assertEqual(diff, next_val)
128132

129133
def test_next_empty(self):
130134
"""Empty next means everything in prev is deleted."""
131135
prev = {"position": {"x": 1}, "color": "red"}
132-
diff = deep_diff(prev, {})
136+
diff = shallow_diff(prev, {})
133137
self.assertEqual(diff, {"position": None, "color": None})
134138

135139
def test_type_change_dict_to_primitive(self):
136140
"""Dict replaced by primitive should include the new value."""
137141
prev = {"material": {"color": "#ff0000"}}
138142
next_val = {"material": "basic"}
139-
diff = deep_diff(prev, next_val)
143+
diff = shallow_diff(prev, next_val)
140144
self.assertEqual(diff, {"material": "basic"})
141145

142146
def test_type_change_primitive_to_dict(self):
143147
"""Primitive replaced by dict should include the new dict."""
144148
prev = {"material": "basic"}
145149
next_val = {"material": {"color": "#ff0000"}}
146-
diff = deep_diff(prev, next_val)
150+
diff = shallow_diff(prev, next_val)
147151
self.assertEqual(diff, {"material": {"color": "#ff0000"}})
148152

149153
def test_bool_change(self):
150154
"""Boolean changes should be detected."""
151155
prev = {"hasAudio": False, "hasVideo": False}
152156
next_val = {"hasAudio": True, "hasVideo": False}
153-
diff = deep_diff(prev, next_val)
157+
diff = shallow_diff(prev, next_val)
154158
self.assertEqual(diff, {"hasAudio": True})
155159

156160
def test_float_precision(self):
157161
"""Float values should be compared with ==."""
158162
prev = {"x": 1.0}
159163
next_val = {"x": 1.0}
160-
self.assertEqual(deep_diff(prev, next_val), {})
164+
self.assertEqual(shallow_diff(prev, next_val), {})
161165

162166
next_val2 = {"x": 1.0000001}
163-
diff = deep_diff(prev, next_val2)
167+
diff = shallow_diff(prev, next_val2)
164168
self.assertEqual(diff, {"x": 1.0000001})
165169

166170

@@ -246,8 +250,8 @@ def test_update_with_delta(self):
246250
result = scene._apply_delta(json.dumps(update_msg), "update")
247251
result_msg = json.loads(result)
248252

249-
# data should only have position delta
250-
self.assertEqual(result_msg["data"], {"position": {"x": 2.852, "z": 8.88}})
253+
# data should have full position (shallow diff sends complete changed dicts)
254+
self.assertEqual(result_msg["data"], {"position": {"x": 2.852, "y": 1.6, "z": 8.88}})
251255
# top-level fields preserved
252256
self.assertEqual(result_msg["object_id"], "cam1")
253257
self.assertEqual(result_msg["action"], "update")

0 commit comments

Comments
 (0)