File size: 12,206 Bytes
9563b12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
"""Observation-only navigation separates policy proposals from controller aid."""
import copy
import json
import random

import pytest


ACTIONS = ((0, -1), (1, 0), (0, 1), (-1, 0))


def observation(size=7, agent=(3, 3), goal=(5, 1), walls=(0, 0, 0, 0)):
    scale = size - 1
    x, z = agent
    gx, gz = goal
    return [[x / scale, z / scale, 0.0, -1.0],
            [gx / scale, gz / scale, (gx - x) / scale, (gz - z) / scale],
            list(walls), [0.0, 0.0, 0.0, 1.0]]


class TinyGrid:
    def __init__(self, size, free, start, goal):
        self.size, self.free, self.agent, self.goal = size, set(free), start, goal
        self.trail = [start]

    def observe(self):
        x, z = self.agent
        walls = [(x + dx, z + dz) not in self.free for dx, dz in ACTIONS]
        return observation(self.size, self.agent, self.goal, tuple(int(v) for v in walls))

    def move(self, action):
        dx, dz = ACTIONS[action]
        target = (self.agent[0] + dx, self.agent[1] + dz)
        assert target in self.free, 'controller attempted a collision'
        self.agent = target
        self.trail.append(target)


def test_legal_mask_selects_best_available_action_and_preserves_inputs():
    from neurograft.navigation_controller import MODES, legal_action
    obs = observation(walls=(1, 0, 0, 1))
    logits = [100.0, 2.0, 3.0, 20.0]
    before = copy.deepcopy((obs, logits))
    assert MODES == ('policy', 'masked_policy', 'observed_map')
    assert legal_action(obs, logits) == 2
    assert (obs, logits) == before
    assert legal_action(obs, [0, 0, 0, 0]) == 1


@pytest.mark.parametrize('change', [
    lambda obs: obs.pop(),
    lambda obs: obs[0].pop(),
    lambda obs: obs[0].__setitem__(0, float('nan')),
    lambda obs: obs[1].__setitem__(2, float('inf')),
    lambda obs: obs[2].__setitem__(1, 0.5),
    lambda obs: obs[2].__setitem__(1, True),
    lambda obs: obs.__setitem__(2, [1, 1, 1, 1]),
])
def test_mask_rejects_invalid_sensor_data(change):
    from neurograft.navigation_controller import legal_action
    obs = observation()
    change(obs)
    with pytest.raises(ValueError):
        legal_action(obs, [0, 1, 2, 3])


@pytest.mark.parametrize('logits', [[1, 2, 3], [0, 1, 2, float('nan')],
                                    [0, 1, 2, float('inf')], [0, 1, 2, True], '0123'])
def test_invalid_prior_is_rejected(logits):
    from neurograft.navigation_controller import legal_action
    with pytest.raises(ValueError):
        legal_action(observation(), logits)


def test_mapping_records_only_current_and_four_neighbors():
    from neurograft.navigation_controller import ObservedMapNavigator
    navigator = ObservedMapNavigator(7)
    obs = observation(walls=(1, 0, 1, 0))
    before = copy.deepcopy(obs)
    rng = random.getstate()
    action = navigator.choose(obs, [9, 8, 7, 6])
    assert action == 1
    assert navigator.known_free == {(3, 3), (4, 3), (2, 3)}
    assert navigator.known_blocked == {(3, 2), (3, 4)}
    assert navigator.visits == {(3, 3): 1}
    assert obs == before and random.getstate() == rng
    snapshot = navigator.snapshot()
    assert snapshot['mode'] == 'observed_map'
    assert snapshot['policy_action'] == 0 and snapshot['last_action'] == 1
    assert snapshot['overridden'] is True
    assert snapshot['known_free'] == 3 and snapshot['known_blocked'] == 2
    assert snapshot['visited'] == 1 and snapshot['frontiers'] == 2
    assert snapshot['path'][0] == {'x': 3, 'z': 3}
    assert snapshot['size'] == 7
    assert snapshot['position'] == [3, 3] and snapshot['goal'] == [5, 1]
    assert snapshot['free_cells'] == [[2, 3], [3, 3], [4, 3]]
    assert snapshot['blocked_cells'] == [[3, 2], [3, 4]]
    assert snapshot['visited_cells'] == [[3, 3]]
    snapshot['free_cells'].clear()
    assert len(navigator.known_free) == 3
    json.dumps(snapshot, allow_nan=False)


def test_equal_frontiers_use_prior_and_map_only_control_ignores_it():
    from neurograft.navigation_controller import ObservedMapNavigator
    obs = observation(goal=(5, 1))
    north = ObservedMapNavigator(7)
    east = ObservedMapNavigator(7)
    assert north.choose(obs, [9, 8, 0, 0]) == 0
    assert east.choose(obs, [8, 9, 0, 0]) == 1
    left = ObservedMapNavigator(7)
    right = ObservedMapNavigator(7)
    assert left.choose(obs, [9, 8, 0, 0], use_policy=False) == 0
    assert right.choose(obs, [8, 9, 0, 0], use_policy=False) == 0
    # The raw proposal remains separately visible even when it is not consulted.
    assert left.snapshot()['policy_action'] == 0
    assert right.snapshot()['policy_action'] == 1


def test_known_goal_route_overrides_a_strong_wrong_prior():
    from neurograft.navigation_controller import ObservedMapNavigator
    navigator = ObservedMapNavigator(7)
    assert navigator.choose(observation(goal=(4, 3)), [100, 0, 0, 0]) == 1
    assert navigator.snapshot()['reason'] == 'known_goal'
    assert navigator.snapshot()['path'] == [{'x': 3, 'z': 3}, {'x': 4, 'z': 3}]


def test_equal_known_goal_routes_use_prior_but_keep_shortest_path():
    from neurograft.navigation_controller import ObservedMapNavigator
    navigator = ObservedMapNavigator(7)
    for position in ((3, 3), (3, 2), (4, 2)):
        navigator.choose(observation(agent=position), [0, 1, 2, 3])
    north = ObservedMapNavigator.from_dict(navigator.to_dict())
    east = ObservedMapNavigator.from_dict(navigator.to_dict())
    obs = observation(agent=(3, 3), goal=(4, 2))
    assert north.choose(obs, [9, 8, 0, 0]) == 0
    assert east.choose(obs, [8, 9, 0, 0]) == 1
    assert len(north.snapshot()['path']) == len(east.snapshot()['path']) == 3


def test_discontinuous_unseen_position_requires_episodic_reset():
    from neurograft.navigation_controller import ObservedMapNavigator
    navigator = ObservedMapNavigator(7)
    navigator.choose(observation(agent=(2, 2)), [0, 1, 2, 3])
    before = navigator.to_dict()
    with pytest.raises(ValueError, match='position'):
        navigator.choose(observation(agent=(4, 4)), [0, 1, 2, 3])
    assert navigator.to_dict() == before


def test_goal_cannot_conflict_with_sensed_wall_and_failure_is_atomic():
    from neurograft.navigation_controller import ObservedMapNavigator
    navigator = ObservedMapNavigator(7)
    before = navigator.to_dict()
    with pytest.raises(ValueError, match='goal'):
        navigator.choose(observation(goal=(4, 3), walls=(0, 1, 0, 0)), [0, 1, 2, 3])
    assert navigator.to_dict() == before


def test_map_exploration_reaches_goal_around_a_wall_without_collisions():
    from neurograft.navigation_controller import ObservedMapNavigator
    free = {(x, z) for x in range(1, 6) for z in range(1, 6)} - {(3, 2), (3, 3), (3, 4)}
    world = TinyGrid(7, free, (2, 3), (4, 3))
    navigator = ObservedMapNavigator(7)
    for _ in range(80):
        world.move(navigator.choose(world.observe(), [0, 10, 0, 0]))
        if world.agent == world.goal:
            break
    assert world.agent == world.goal
    assert len(world.trail) - 1 > 2
    assert navigator.known_free <= free
    assert len(navigator.known_free | navigator.known_blocked) <= 49


def test_dead_end_backtracking_keeps_frontier_and_restores_exact_continuation():
    from neurograft.navigation_controller import ObservedMapNavigator
    free = {(1, 3), (2, 3), (3, 3), (3, 2), (3, 1), (4, 3), (5, 3), (5, 2), (5, 1)}
    world = TinyGrid(7, free, (2, 3), (5, 1))
    navigator = ObservedMapNavigator(7)
    committed = None
    restored = None
    for _ in range(30):
        obs = world.observe()
        action = navigator.choose(obs, [10, 9, 0, 0])
        if world.agent == (3, 1):
            committed = navigator.target
            assert committed is not None and committed != world.agent
            state = json.loads(json.dumps(navigator.to_dict()))
            restored = ObservedMapNavigator.from_dict(state)
            assert restored.to_dict() == state
            assert restored.snapshot() == navigator.snapshot()
        elif restored is not None:
            assert restored.choose(obs, [10, 9, 0, 0]) == action
            assert restored.to_dict() == navigator.to_dict()
            if world.agent == (3, 2):
                assert navigator.target == committed
        world.move(action)
        if world.agent == world.goal:
            break
    assert world.agent == world.goal
    assert (3, 1) in world.trail and world.trail.count((3, 2)) >= 2


def test_hidden_cells_do_not_affect_action_or_memory():
    from neurograft.navigation_controller import ObservedMapNavigator
    free = {(x, z) for x in range(1, 6) for z in range(1, 6)}
    first = TinyGrid(7, free, (3, 3), (5, 1))
    second = TinyGrid(7, free - {(1, 1), (5, 5)}, (3, 3), (5, 1))
    left, right = ObservedMapNavigator(7), ObservedMapNavigator(7)
    assert first.observe() == second.observe()
    assert left.choose(first.observe(), [0, 9, 0, 0]) == right.choose(second.observe(), [0, 9, 0, 0])
    assert left.to_dict() == right.to_dict()


@pytest.mark.parametrize('size', [True, 4, 32, 7.0, None])
def test_grid_size_is_strict_and_bounded(size):
    from neurograft.navigation_controller import ObservedMapNavigator
    with pytest.raises(ValueError):
        ObservedMapNavigator(size)


@pytest.mark.parametrize('change', [
    lambda obs: obs[0].__setitem__(0, 0.49),
    lambda obs: obs[1].__setitem__(0, 1.0),
    lambda obs: obs[2].__setitem__(0, 0),  # Previously sensed wall cannot become free.
    lambda obs: obs[3].__setitem__(3, float('nan')),
])
def test_invalid_observation_leaves_all_controller_state_untouched(change):
    from neurograft.navigation_controller import ObservedMapNavigator
    navigator = ObservedMapNavigator(7)
    navigator.choose(observation(walls=(1, 0, 0, 0)), [0, 1, 2, 3])
    before = navigator.to_dict()
    obs = observation(walls=(1, 0, 0, 0))
    change(obs)
    with pytest.raises(ValueError):
        navigator.choose(obs, [0, 1, 2, 3])
    assert navigator.to_dict() == before


def test_boundary_free_sensor_and_invalid_prior_are_atomic():
    from neurograft.navigation_controller import ObservedMapNavigator
    navigator = ObservedMapNavigator(7)
    before = navigator.to_dict()
    for obs, logits in [(observation(agent=(1, 1)), [0, 1, 2, 3]),
                        (observation(), [0, 1, 2, float('nan')])]:
        with pytest.raises(ValueError):
            navigator.choose(obs, logits)
        assert navigator.to_dict() == before


@pytest.mark.parametrize('mutate', [
    lambda state: state.__setitem__('version', 2),
    lambda state: state.__setitem__('extra', 1),
    lambda state: state['known_free'].append(state['known_free'][0]),
    lambda state: state['known_free'].append([-1, 2]),
    lambda state: state['known_blocked'].append(state['known_free'][0]),
    lambda state: state['visits'][0].__setitem__('count', True),
    lambda state: state['visits'][0].__setitem__('count', 0),
    lambda state: state.__setitem__('target', [1, 1]),
    lambda state: state.__setitem__('last_action', 4),
    lambda state: state.__setitem__('policy_action', True),
    lambda state: state.__setitem__('reason', 'invented'),
    lambda state: state['path'].append([1, 1]),
    lambda state: state.__setitem__('use_policy', 1),
])
def test_restoration_rejects_malformed_and_inconsistent_state_without_mutating_input(mutate):
    from neurograft.navigation_controller import ObservedMapNavigator
    navigator = ObservedMapNavigator(7)
    navigator.choose(observation(), [0, 1, 2, 3])
    state = navigator.to_dict()
    mutate(state)
    before = copy.deepcopy(state)
    with pytest.raises(ValueError):
        ObservedMapNavigator.from_dict(state)
    assert state == before


def test_empty_controller_has_exact_json_roundtrip_and_serialization_is_detached():
    from neurograft.navigation_controller import ObservedMapNavigator
    navigator = ObservedMapNavigator(31)
    state = navigator.to_dict()
    restored = ObservedMapNavigator.from_dict(json.loads(json.dumps(state)))
    assert restored.to_dict() == state
    assert restored.snapshot() == navigator.snapshot()
    navigator.choose(observation(31, (15, 15), (29, 1)), [0, 1, 2, 3])
    detached = navigator.to_dict()
    detached['known_free'].clear()
    assert navigator.known_free