EvolvingNav / code /tests /test_agent_core.py
fengnian1678's picture
Mirror ZJU4EmbodiedAI/EvolvingNav at dfa6872
ad91e86 verified
Raw History Blame Contribute Delete
11.5 kB
from __future__ import annotations
import numpy as np
import pytest
from evolvingnav_paper.agent import Agent, AgentConfig, ViewEvidence
from evolvingnav_paper.filter import BeliefFilter, EvidenceLedger
from evolvingnav_paper.memory import VersionedMemory, backproject
from evolvingnav_paper.transition import IdentityTransition, MatrixTransition
from evolvingnav_paper.coverage import (
camera_forward, candidate_surface_samples, depth_quality, heading_quaternion,
visible_sample_ids,
)
def test_backprojection_and_causal_versions() -> None:
point = backproject(1, 1, 2.0, np.diag([2., 2., 1.]), np.eye(4))
np.testing.assert_allclose(point, [1., 1., 2.])
memory = VersionedMemory()
memory.observe("cup", 2, 4.0, 0.8, "frame-1", point)
memory.observe("cup", 2, 8.0, 0.95, "frame-later", point)
memory.observe("cup", 3, 9.0, 0.9, "frame-2", point)
assert memory.at("cup", 7.0).state_id == 2
assert memory.at("cup", 7.0).valid_to is None
assert memory.at("cup", 7.0).evidence_handles == ["frame-1"]
assert memory.at("cup", 7.0).confidence == 0.8
assert memory.at("cup", 10.0).state_id == 3
assert memory.history("cup", 10.0)[0].valid_to == 9.0
memory.record_negative("frame-neg", 2, 10.5, [1, 2, 3])
assert memory.evidence_at(10.0) == []
assert memory.evidence_at(11.0)[0].state_id == 2
with pytest.raises(ValueError, match="causal"):
memory.observe("cup", 1, 8.0, 0.8, "old", point)
def test_filter_propagation_then_new_evidence_once_and_arrival_independent() -> None:
kernel = MatrixTransition(np.array([[0.8, 0.2], [0.1, 0.9]]))
belief = BeliefFilter({1: 0.8, 2: 0.2}, kernel)
arrival = belief.arrival(5.0)
assert arrival[2] > 0.2
assert belief.posterior == {1: 0.8, 2: 0.2}
belief.advance(5.0)
assert belief.posterior[2] == pytest.approx(arrival[2])
evidence = ViewEvidence("frame-1", 1, frozenset({1, 2, 3, 4}), 0.8)
ledger = EvidenceLedger(min_new_coverage=0.05, sample_count={1: 10, 2: 10})
admitted = ledger.admit(evidence)
assert admitted == pytest.approx(0.4)
prior = belief.posterior[1]
belief.negative({1: 0.8 * admitted}, "frame-1:1")
assert belief.posterior[1] < prior
unchanged = belief.posterior.copy()
assert ledger.admit(evidence) == 0.0
assert belief.negative({1: 0.3}, "frame-1:1") is False
assert belief.posterior == unchanged
def test_filter_advances_transition_context_only_after_elapsed_chunk() -> None:
class Clocked(IdentityTransition):
def __init__(self):
self.clock = 0.0
def advance_clock(self, seconds):
self.clock += seconds
transition = Clocked()
belief = BeliefFilter({1: 1.0}, transition)
belief.arrival(10.0)
assert transition.clock == 0.0
belief.advance(4.0)
assert transition.clock == 4.0
def test_dynamic_reopening_and_static_no_return() -> None:
ledger = EvidenceLedger(sample_count={1: 10})
ledger.admit(ViewEvidence("a", 1, frozenset(range(8)), 0.9))
assert not ledger.eligible(1, belief=0.01, return_probability=0.0, new_coverage=0.0)
assert ledger.eligible(1, belief=0.01, return_probability=0.06, new_coverage=0.0)
assert ledger.round(1) == 1
assert ledger.admit(ViewEvidence("b", 1, frozenset(range(8)), 0.9)) == 0.8
fixed = BeliefFilter({1: 0.8, 2: 0.2}, IdentityTransition())
fixed.advance(100.0)
assert fixed.posterior == {1: 0.8, 2: 0.2}
def test_dynamic_round_does_not_reopen_without_elapsed_time() -> None:
ledger = EvidenceLedger(sample_count={1: 10})
ledger.admit(ViewEvidence("a", 1, frozenset(range(8)), 0.9))
ledger.mark_inspected(1, now_s=5.0)
assert not ledger.eligible(1, belief=0.8, return_probability=0.0,
new_coverage=0.0, now_s=5.0)
assert ledger.eligible(1, belief=0.8, return_probability=0.0,
new_coverage=0.0, now_s=6.0)
assert ledger.round(1) == 1
assert ledger.eligible(1, belief=0.8, return_probability=0.0,
new_coverage=0.0, now_s=6.0)
assert ledger.round(1) == 1
def test_agent_replans_after_negative_and_stops_on_verified_detection() -> None:
class World:
def __init__(self):
self.position = 0.0
def distance(self, goal):
return abs(goal - self.position)
def move_chunk(self, goal, max_distance):
delta = min(abs(goal - self.position), max_distance)
self.position += np.sign(goal - self.position) * delta
return delta, delta / 1.0
def inspect(self, state):
return (state == 2, [ViewEvidence(f"frame-{state}", state,
frozenset(range(10)), 0.9)])
def explore(self):
return {}, 0.0
world = World()
agent = Agent(
{1: 0.8, 2: 0.2}, {1: 1.0, 2: 3.0, 3: 0.1}, world,
IdentityTransition(), AgentConfig(max_inspections=2, max_path_m=10.0,
chunk_m=1.0), sample_count={1: 10, 2: 10},
)
result = agent.run()
assert result.found
assert result.inspections == [1, 2]
assert result.actions[-1] == "STOP"
assert agent.filter.posterior[1] < 0.8
def test_agent_uses_new_rgbd_evidence_before_arrival() -> None:
class World:
def __init__(self):
self.position = 0.0
self.frames = 0
def distance(self, goal):
return abs(goal - self.position)
def move_chunk(self, goal, max_distance):
displacement = min(abs(goal - self.position), max_distance)
self.position += np.sign(goal - self.position) * displacement
return displacement, displacement
def observe_chunk(self):
self.frames += 1
return [ViewEvidence("en-route-1", 1, frozenset({0}), 1.0)] if self.frames == 1 else []
def inspect(self, state):
return state == 2, []
agent = Agent({1: 0.8, 2: 0.2}, {1: 2.0, 2: 4.0}, World(),
IdentityTransition(), AgentConfig(max_inspections=2, chunk_m=1.0),
sample_count={1: 1, 2: 1})
result = agent.run()
assert result.found
assert result.inspections == [2]
assert agent.filter.posterior[1] == 0.0
def test_unknown_mass_executes_explore_and_adds_a_searchable_state() -> None:
class World:
def __init__(self):
self.position = 0.0
def distance(self, goal):
return abs(goal - self.position)
def move_chunk(self, goal, max_distance):
distance = min(abs(goal - self.position), max_distance)
self.position += distance
return distance, distance
def explore(self, _budget_m):
self.position = 1.0
return {2: (2.0, 0.8)}, 1.0, 1.0
def inspect(self, state):
return state == 2, []
agent = Agent(
{1: 0.05, 99: 0.95}, {1: 10.0}, World(), IdentityTransition(),
AgentConfig(unknown_state=99, max_inspections=2),
)
result = agent.run()
assert result.found
assert result.actions[0] == "EXPLORE"
assert result.inspections == [2]
assert result.posterior[99] < 0.95
def test_online_depth_coverage_uses_public_geometry_not_target_mask() -> None:
samples = candidate_surface_samples([0, 0, -2], radius_m=0.0)
depth = np.full((100, 100), 2.0, dtype=float)
seen = visible_sample_ids(samples, [0, 0, 0], [0, 0, 0, 1], depth, 90.0,
sensor_height_m=0.0)
assert seen == frozenset(range(len(samples)))
depth[:] = 1.0
assert not visible_sample_ids(samples, [0, 0, 0], [0, 0, 0, 1], depth, 90.0,
sensor_height_m=0.0)
np.testing.assert_allclose(camera_forward(heading_quaternion([1, 0, 0])),
[1, 0, 0], atol=1e-6)
assert depth_quality(np.array([[1., 2.], [0., np.nan]])) == 0.5
slots = [[-1., 0.9, -1.], [1., 0.9, -1.], [-1., 0.9, 1.], [1., 0.9, 1.]]
surface = candidate_surface_samples([0., 0.9, 0.], place_points=slots)
assert len(surface) == 25
np.testing.assert_allclose(surface.min(axis=0), [-1., 0.9, -1.])
np.testing.assert_allclose(surface.max(axis=0), [1., 0.9, 1.])
def test_learned_transition_rows_are_normalized_and_chronological_loss() -> None:
import torch
from evolvingnav_paper.transition_model import TransitionHead, transition_nll
head = TransitionHead(hidden_dim=8)
context = torch.randn(2, 8)
candidates = torch.randn(2, 3, 8)
probabilities = head(context, candidates, torch.tensor([2.0, 4.0]),
torch.ones(2, 3, dtype=torch.bool))
assert probabilities.shape == (2, 3, 3)
torch.testing.assert_close(probabilities.sum(-1), torch.ones(2, 3))
loss = transition_nll(probabilities, torch.tensor([0, 2]), torch.tensor([1, 0]))
assert torch.isfinite(loss)
loss.backward()
assert head.mlp[0].weight.grad is not None
def test_transition_pairs_use_only_same_world_instance_and_later_times() -> None:
from evolvingnav_paper.transition_model import chronological_pairs
pairs = chronological_pairs(
instance_ids=np.array(["a", "a", "b", "a"]),
world_ids=np.array([0, 0, 0, 1]),
times_s=np.array([1., 4., 2., 5.]),
states=np.array([1, 2, 3, 4]), max_horizon_s=10,
)
assert pairs == [(0, 1, 3.0, 1, 2)]
def test_event_horizon_pairs_are_causal_and_cover_short_target_motion() -> None:
from evolvingnav_paper.transition_model import event_horizon_pairs
pairs = event_horizon_pairs(
instance_ids=np.array(["a", "a", "a"]),
world_ids=np.array([0, 0, 0]),
query_times_s=np.array([10., 40., 100.]),
query_states=np.array([1, 1, 2]),
events=[{"instance_uuid": "a", "event_time_s": 60.,
"source_state_id": 1, "destination_state_id": 2}],
horizons_s=(20.,),
)
assert (1, 50.0, 20.0, 1, 2) in pairs
assert all(source_time >= [10., 40., 100.][source] for source, source_time, *_ in pairs)
def test_validation_fitted_detection_probability_uses_online_features() -> None:
from evolvingnav_paper.calibration import DetectionCalibrator
rows = [
{"coverage": float(i) / 20, "range_m": 1.0, "angle_cos": 1.0,
"projected_pixels": 100, "depth_quality": 1.0, "category_recall": 0.9,
"detected": i >= 10}
for i in range(21)
]
calibrator = DetectionCalibrator.fit(rows)
low = calibrator.predict({key: value for key, value in rows[0].items() if key != "detected"})
high = calibrator.predict({key: value for key, value in rows[-1].items() if key != "detected"})
assert 0 < low < high < 1
def test_frozen_vlm_controller_can_only_select_legal_public_action() -> None:
from evolvingnav_paper.controller import LunaToolController
def requester(payload):
assert payload["model"] == "gpt-5.6-luna"
assert "evaluation_private" not in str(payload)
assert payload["tools"][0]["parameters"]["properties"]["action"]["enum"] == [
"NAVIGATE_TO(1)", "EXPLORE"
]
return {"output": [{"type": "function_call", "name": "select_action",
"arguments": '{"action":"NAVIGATE_TO(1)"}'}]}
controller = LunaToolController(requester=requester)
assert controller.choose(["NAVIGATE_TO(1)", "EXPLORE"],
{"belief": {1: 0.7}}) == "NAVIGATE_TO(1)"