feat: finalize corrected-route planning and Rerun recording review

This commit is contained in:
DCCONSTRUCTIONS
2026-09-22 10:10:03 +03:00
parent c804d89b18
commit 2e5d52521f
132 changed files with 14141 additions and 898 deletions
+211
View File
@@ -0,0 +1,211 @@
import importlib.util
import json
from pathlib import Path
import numpy as np
import pytest
pytest.importorskip("scipy")
from k1link.reconstruction.closure import (
ClosurePolicy,
ClosureUnavailable,
acquire_closure,
review_acceptance,
)
def source():
t = np.arange(121.0)
ids = np.repeat(np.arange(len(t)), 100)
pts = np.random.default_rng(72).normal(size=(len(ids), 3))
pts[:, 0] = ids / 10
return dict(
frames=np.column_stack([t, t, t * 100, np.full(len(t), 100)]),
poses=np.column_stack([t, np.zeros((len(t), 3))]),
frame_distance=t * 2,
sample_frame=ids,
sample_points=pts,
heldout=(t % 10 >= 4) & (t % 10 < 6),
)
def exact(reference, query, seed, *, acquisition):
if acquisition:
np.testing.assert_array_equal(seed, np.eye(4))
seed = seed.copy()
seed[2, 3] = 4.2
return dict(status="candidate", T_reference_query=seed.tolist())
def test_complete_search_selects_support_without_heldout_or_endpoint_seed():
data = source()
data["poses"][-1, 1:4] = [5, 0, 2] # physical overshoot is not a constraint
original = data["sample_points"].copy()
calls = []
def register(a, b, seed, *, acquisition):
for cloud in (a, b):
frame_ids = np.rint(cloud[:, 0] * 10).astype(int)
assert not data["heldout"][frame_ids].any()
assert not set(a[:, 0]) & set(b[:, 0])
calls.append(acquisition)
return exact(a, b, seed, acquisition=acquisition)
link, report = acquire_closure(data, register)
assert report["complete"] and len(report["attempts"]) == 12
selected = report["attempts"][report["selected_attempt"]]
assert (selected["reference_s"], selected["query_s"]) == (20, 5)
assert calls == [True, False] * 12
assert not report["endpoint_constraint"]
assert link.T_reference_query[2, 3] == 4.2
np.testing.assert_array_equal(data["sample_points"], original)
def test_unavailable_short_windows_do_not_stop_full_support_search():
def register(a, b, seed, *, acquisition):
if acquisition and (len(a) < 3000 or len(b) < 2000):
return dict(status="rejected", reasons=["synthetic short-window failure"])
return exact(a, b, seed, acquisition=acquisition)
_, report = acquire_closure(source(), register)
assert report["status"] == "candidate" and report["complete"]
assert not report["attempts"][0]["qualified"]
selected = report["attempts"][report["selected_attempt"]]
assert (selected["reference_s"], selected["query_s"]) == (40, 30)
@pytest.mark.parametrize("mode", ["forward", "reverse", "cycle", "exception"])
def test_failure_keeps_all_attempts_and_never_materializes_identity(mode):
def register(a, b, seed, *, acquisition):
if mode == "exception":
raise ValueError("unavailable geometry")
if (mode == "forward" and acquisition) or (mode == "reverse" and not acquisition):
return dict(status="rejected")
result = exact(a, b, seed, acquisition=acquisition)
if mode == "cycle" and not acquisition:
result["T_reference_query"][0][3] += 0.11
return result
with pytest.raises(ClosureUnavailable) as failure:
acquire_closure(source(), register)
assert failure.value.report["complete"]
assert len(failure.value.report["attempts"]) == 12
assert failure.value.report["status"] == "rejected"
def test_disagreeing_qualified_windows_reject_instead_of_cherry_picking():
def register(a, b, seed, *, acquisition):
if acquisition:
seed = seed.copy()
seed[0, 3] = 2 if len(a) < 1200 else 0
return dict(status="candidate", T_reference_query=seed.tolist())
with pytest.raises(ClosureUnavailable, match="ambiguous") as failure:
acquire_closure(source(), register)
assert len(failure.value.report["qualified_attempts"]) == 12
def test_short_capture_never_matches_window_to_itself():
data = source()
data["frames"][:, 0] /= 100
def forbidden(*args, **kwargs):
pytest.fail("overlapping frames reached registration")
with pytest.raises(ClosureUnavailable) as failure:
acquire_closure(data, forbidden)
assert all(
r["reason"] == "overlapping-source-windows" for r in failure.value.report["attempts"]
)
@pytest.mark.parametrize(
"kwargs",
[
{"reference_seconds": ()},
{"query_seconds": (0,)},
{"radius_m": float("nan")},
{"query_seconds": (5, 5)},
],
)
def test_policy_validation(kwargs):
with pytest.raises(ValueError):
ClosurePolicy(**kwargs)
def module(name):
path = Path(__file__).parents[1] / "experiments" / (name + ".py")
spec = importlib.util.spec_from_file_location(name, path)
result = importlib.util.module_from_spec(spec)
spec.loader.exec_module(result)
return result
def test_acquisition_does_not_change_live_quality_or_decode_split():
from k1link.missions.registration import POLICY
adapter = module("reconstruct_recorded_ring")
different = {key for key in POLICY if POLICY[key] != adapter.CLOSURE_REGISTRATION_POLICY[key]}
assert different == {"version", "maximum_correction_m", "maximum_correction_deg"}
assert POLICY["maximum_correction_m"] == 3
legacy = {**adapter.PROFILE, "version": "recorded-ring-experiment/v1"}
assert adapter.compatible_cache_profile(legacy)
assert not adapter.compatible_cache_profile({**legacy, "holdout_start_s": 5})
def review():
before = dict(
points=400,
query_frames=4,
overlap_05m=0.2,
inlier_rmse_m=0.15,
all_point_distances_m={"median": 1.2},
)
after = {**before, "overlap_05m": 0.95, "all_point_distances_m": {"median": 0.12}}
results = [dict(seam_holdout=before), dict(seam_holdout=after)]
validation = [dict(total=12, candidate_count=11), dict(total=12, candidate_count=12)]
return results, validation
@pytest.mark.parametrize("failure", [None, "local", "quality", "support", "degradation"])
def test_review_gate_records_failure_without_lowering_quality(failure):
results, validation = review()
if failure == "local":
validation[1]["candidate_count"] = 11
if failure == "quality":
results[1]["seam_holdout"]["inlier_rmse_m"] = 0.26
if failure == "support":
results[1]["seam_holdout"]["points"] = 20
if failure == "degradation":
results[0]["seam_holdout"]["overlap_05m"] = 0.99
receipt = review_acceptance(results, validation)
assert receipt["accepted"] == (failure is None)
assert not receipt["independent_accuracy"] and not receipt["vehicle_control"]
@pytest.mark.parametrize(
"failure", [None, "partial", "missing-check", "rejected", "missing-review"]
)
def test_packaging_v2_requires_complete_acquisition_and_positive_review(tmp_path, failure):
check = module("package_recorded_map_version").check_closure_review
search = dict(status="candidate", complete=True, attempts=[{}] * 12, expected_attempts=12)
receipt = review_acceptance(*review())
if failure == "partial":
search["complete"] = False
if failure == "missing-check":
receipt["checks"].pop("heldout_seam_quality")
if failure == "rejected":
receipt["checks"]["heldout_seam_quality"] = False
(tmp_path / "closure-search.json").write_text(json.dumps(search))
(tmp_path / "review.json").write_text(
json.dumps({} if failure == "missing-review" else dict(acceptance=receipt))
)
summary = dict(
schema_version="missioncore.recorded-ring-experiment/v2", closure_acquisition="candidate"
)
if failure:
with pytest.raises(ValueError):
check(tmp_path, summary)
else:
assert check(tmp_path, summary) == ["closure-search.json"]
assert check(tmp_path, {"schema_version": "missioncore.recorded-ring-experiment/v1"}) == []
+260
View File
@@ -0,0 +1,260 @@
import json
import threading
from types import SimpleNamespace
import numpy as np
import pytest
from k1link.missions.drafts import MissionDrafts
from k1link.missions.versioned_sources import VersionedPlanningSources
from k1link.reconstruction.map_version import MapVersion, publish_map_version, sha256
@pytest.fixture
def fixture(tmp_path):
p = np.array([[0.0, 0, 0], [10.0, 0, 0], [20.0, 0, 0]])
corrected = p + np.array([[0.0, 0, 0], [0, 0, 1], [0, 0, 2]])
frames = np.array([[0, 10, 0, 2], [1, 11, 2, 2], [2, 12, 4, 2]], float)
points = np.concatenate([xyz + [[0, 0, 0], [0, 0, 10]] for xyz in corrected]).astype("<f4")
points.tofile(tmp_path / "points.f32")
np.savez(
tmp_path / "trajectory.npz",
positions=corrected,
orientations_xyzw=np.tile([0.0, 0, 0, 1], (3, 1)),
receipt_time_s=np.arange(3.0),
source_distance_m=np.array([0.0, 10, 20]),
frame_source_distance_m=np.array([0.0, 10, 20]),
frames=frames,
)
source = dict(
schema_version="missioncore.planning-source/v1",
session_id="source-a",
generation="a" * 64,
label="Original",
units="m",
frame_id="session/source-a",
source_digests={"primary": "b" * 64},
decode_errors=0,
path_m=20.0,
poses=[
dict(
index=i,
message_index=i * 2 + 1,
position=xyz.tolist(),
elapsed_s=i,
distance_m=float(i * 10),
)
for i, xyz in enumerate(p)
],
)
(tmp_path / "review.json").write_text('{"kind":"synthetic-test-only"}')
def publish(**overrides):
arguments = dict(
root=tmp_path / "versions",
source=source,
points=tmp_path / "points.f32",
trajectory=tmp_path / "trajectory.npz",
expected_points_sha256=sha256(tmp_path / "points.f32"),
expected_trajectory_sha256=sha256(tmp_path / "trajectory.npz"),
evidence={"review.json": (tmp_path / "review.json", sha256(tmp_path / "review.json"))},
method={"algorithm": "synthetic-fixture/v1"},
label="Corrected candidate",
)
return publish_map_version(**{**arguments, **overrides})
class Original:
calls = []
changed = False
def get(self, session_id):
assert session_id == source["session_id"]
return source
def bound(self, session_id, generation):
self.calls.append(generation)
if (
self.changed
or session_id != source["session_id"]
or generation != source["generation"]
):
raise ValueError("Original source changed")
return source
verify = bound
return SimpleNamespace(
root=tmp_path,
source=source,
points=points,
corrected=corrected,
publish=publish,
original=Original(),
)
def test_identity_idempotency_and_recomputed_distances(fixture):
f = fixture
version = f.publish()
assert f.publish().generation == version.generation
assert len(list((f.root / "versions").iterdir())) == 1
sources = VersionedPlanningSources(f.original, version, f.root / "scratch")
assert sources.get("source-a") is f.source
assert sources.bound("source-a", "a" * 64) is f.source
doc = sources.bound("source-a", version.generation)
assert doc["path_m"] == pytest.approx(2 * np.sqrt(101))
assert doc["path_m"] != f.source["path_m"]
assert doc["frame_id"] != f.source["frame_id"]
assert [p["message_index"] for p in doc["poses"]] == [1, 3, 5]
np.testing.assert_allclose([p["position"] for p in doc["poses"]], f.corrected)
assert doc["reference_version"]["authority"]["production_promotion"] is False
assert sources.version.arrays()["source_distance_m"][-1] == 20
# Caller mutation cannot affect the sealed artifact or its next projection.
doc["poses"][0]["position"][0] = 999
assert sources.bound("source-a", version.generation)["poses"][0]["position"][0] == 0
def test_same_session_generations_keep_old_and_new_drafts_distinct(fixture):
f = fixture
version = f.publish()
sources = VersionedPlanningSources(f.original, version, f.root / "scratch")
drafts = MissionDrafts(f.root / "drafts", sources)
def save(generation):
return drafts.save(
SimpleNamespace(
session_id="source-a",
generation=generation,
id=None,
revision=0,
name="Map test",
start_index=0,
end_index=2,
direction="forward",
)
)
old, new = save("a" * 64), save(version.generation)
assert old["route"]["length_m"] == 20
assert new["route"]["length_m"] == pytest.approx(2 * np.sqrt(101))
assert drafts.get(old["id"]) == old
assert drafts.check(new["id"], 1)["vehicle_control"] is False
assert drafts.check(new["id"], 1)["localization"] == "not_run"
def test_extraction_uses_corrected_frame_ownership_and_separate_display_profile(fixture):
f = fixture
version = f.publish()
sources = VersionedPlanningSources(f.original, version, f.root / "scratch")
points, provenance = sources.submap("source-a", version.generation, 0, 2)
np.testing.assert_allclose(points, f.corrected)
assert provenance["raw_points"] == 6 and provenance["retained_points"] == 3
assert [p["sequence"] for p in provenance["frames"]] == [10, 11, 12]
display, meta = sources.submap("source-a", version.generation, 0, 2, presentation=True)
np.testing.assert_allclose(display, f.points)
assert meta["extraction"]["radius_m"] == 80
assert meta["extraction"]["height_relative_m"] is None
whole, info = sources.reference_map("source-a", version.generation, 0, 2)
np.testing.assert_allclose(whole, f.corrected)
assert info["generation"] == version.generation
assert not list((f.root / "scratch").iterdir())
@pytest.mark.parametrize("target", ["manifest.json", "points.f32", "trajectory.npz", "review.json"])
def test_tampered_bundle_cannot_be_used(fixture, target):
f = fixture
version = f.publish()
path = version.directory / target
original = path.read_bytes()
path.write_bytes(b"X" + original[1:])
with pytest.raises(ValueError):
MapVersion(version.directory, version.generation).verify()
@pytest.mark.parametrize("mode", ["original", "derivative", "cancel", "exception"])
def test_preparation_is_private_and_postchecked(fixture, mode):
f = fixture
version = f.publish()
sources = VersionedPlanningSources(f.original, version, f.root / "scratch")
cancel = threading.Event()
with pytest.raises((ValueError, InterruptedError)), sources.prepared_submaps(
"source-a", version.generation, cancel_event=cancel
) as extract:
if mode == "original":
f.original.changed = True
elif mode == "derivative":
(version.directory / "points.f32").write_bytes(b"X" * 72)
# Still reads the private copy; final verification rejects publication.
np.testing.assert_allclose(extract(0, 2)[0], f.corrected)
elif mode == "cancel":
cancel.set()
extract(0, 2)
else:
raise ValueError("Synthetic preparation failure")
assert not list((f.root / "scratch").iterdir())
def test_wrong_parent_and_no_silent_fallback(fixture):
f = fixture
version = f.publish()
sources = VersionedPlanningSources(f.original, version, f.root / "scratch")
with pytest.raises(ValueError):
sources.bound("source-b", version.generation)
with pytest.raises(ValueError):
sources.bound("source-a", "c" * 64)
with pytest.raises(ValueError):
version.planning_source({**f.source, "generation": "d" * 64})
with pytest.raises(ValueError):
version.planning_source({**f.source, "source_digests": {"primary": "d" * 64}})
def test_publication_rejects_bad_hash_unsafe_names_nonfinite_points_and_links(fixture):
f = fixture
with pytest.raises(ValueError):
f.publish(expected_points_sha256="e" * 64)
with pytest.raises(ValueError):
f.publish(
evidence={"../escape.json": (f.root / "review.json", sha256(f.root / "review.json"))}
)
path = f.root / "link.f32"
path.symlink_to(f.root / "points.f32")
with pytest.raises(ValueError):
f.publish(points=path)
broken = f.points.copy()
broken[0, 0] = np.nan
broken.tofile(f.root / "points.f32")
with pytest.raises(ValueError):
f.publish()
assert not list((f.root / "versions").iterdir())
@pytest.mark.parametrize("mode", ["offset", "size", "clock", "source-distance", "quaternion"])
def test_publication_rejects_inconsistent_frame_pose_bindings(fixture, mode):
f = fixture
path = f.root / "trajectory.npz"
with np.load(path) as data:
values = {key: data[key].copy() for key in data.files}
if mode == "offset":
values["frames"][1, 2] = 1
elif mode == "size":
values["frames"][-1, 3] = 1
elif mode == "clock":
values["receipt_time_s"][1] = 4
elif mode == "source-distance":
values["frame_source_distance_m"][1] = 1
else:
values["orientations_xyzw"][0] = 0
np.savez(path, **values)
with pytest.raises(ValueError):
f.publish()
assert not list((f.root / "versions").iterdir())
def test_different_review_creates_new_version_without_overwrite(fixture):
f = fixture
first = f.publish()
(f.root / "review.json").write_text(json.dumps({"kind": "another-reviewed-candidate"}))
second = f.publish()
assert first.generation != second.generation
first.verify()
second.verify()
+54
View File
@@ -0,0 +1,54 @@
"""Receipt artifact ownership for the bounded K1 experiment adapter, no HTTP."""
import importlib.util
from pathlib import Path
import pytest
from k1link.reconstruction.map_version import sha256
def adapter():
pytest.importorskip("scipy") # The packaging command uses the optional correction extra.
path = Path(__file__).parents[1] / "experiments/package_recorded_map_version.py"
spec = importlib.util.spec_from_file_location("package_recorded_map_version", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.RecordedParent
@pytest.mark.parametrize("changed", [None, "raw", "metadata", "origin", "clock"])
def test_native_artifact_ids_match_plugin_contract(tmp_path, monkeypatch, changed):
raw = tmp_path / "mqtt.raw.k1mqtt"
metadata = tmp_path / "mqtt.metadata.jsonl"
origin = tmp_path / "mqtt.timeline.origin.json"
raw.write_bytes(b"synthetic raw")
metadata.write_bytes(b"synthetic per-message receipt index")
origin.write_bytes(b"synthetic clock origin")
current = tmp_path / "mqtt.timeline.json"
current.write_bytes(b"synthetic frozen clock")
clock_digest = sha256(current)
frozen = tmp_path / f"mqtt.timeline.session-{clock_digest}.json"
frozen.write_bytes(current.read_bytes())
current.write_bytes(b"later mutable clock is not the source session snapshot")
source = dict(
generation="a" * 64,
source_digests={
"raw-transport-primary": sha256(raw),
"raw-transport-index": sha256(metadata),
"raw-transport-clock-origin": sha256(origin),
"raw-transport-clock": clock_digest,
},
)
parent = adapter()(raw, "source-a", "a" * 64)
monkeypatch.setattr(parent, "get", lambda _: source)
if changed:
{"raw": raw, "metadata": metadata, "origin": origin, "clock": frozen}[changed].write_bytes(
b"changed"
)
with pytest.raises(ValueError):
parent.verify("source-a", "a" * 64)
else:
assert parent.verify("source-a", "a" * 64) == source
with pytest.raises(ValueError):
parent.verify("source-a", "b" * 64)
+19
View File
@@ -66,6 +66,25 @@ def test_draft_persists_full_route_and_optimistic_revision(tmp_path):
assert service.get(first['id']) == next
def test_whole_recording_is_resolved_by_server_and_old_report_stays_frozen(tmp_path):
service = MissionDrafts(tmp_path, Sources())
old = service.save(request())
full = service.save(request(id=old['id'], revision=1, whole_recording=True,
start_index=1, end_index=2))
assert (full['route']['start_index'], full['route']['end_index']) == (0, 3)
assert full['route']['length_m'] == 15
assert old['route']['length_m'] == 10
app = FastAPI()
app.include_router(build_mission_planner_router(service))
with TestClient(app) as client:
body = {'name': 'Full', 'session_id': 'session-a', 'generation': 'a'*64,
'whole_recording': True, 'direction': 'reverse'}
response = client.post('/api/v1/mission-planner/drafts', json=body)
assert response.status_code == 200
assert [p['source_index'] for p in response.json()['route']['points']] == [3, 2, 1, 0]
assert client.post('/api/v1/mission-planner/drafts', json={**body, 'whole_recording': False}).status_code == 409
@pytest.mark.parametrize('start,end', [(2, 2), (3, 1), (-1, 2), (0, 4)])
def test_route_rejects_out_of_bounds(start, end):
with pytest.raises(ValueError): route_from_source(source_doc(), start, end, 'forward')
+222
View File
@@ -0,0 +1,222 @@
import hashlib
import json
import numpy as np
import pytest
import rerun as rr
from rerun.experimental import RrdReader
from k1link.sessions.overview_comparison import load_comparison, publish_comparison
from k1link.sessions.overview_spatial import render_spatial_update, spatial_metadata
@pytest.fixture
def pair(tmp_path):
root = tmp_path / "previews"
inputs = dict(
session_id="recording",
overview_generation="a" * 64,
source_digests={"raw": "b" * 64},
map_generation="c" * 64,
source_points=30,
original_path_m=12.0,
corrected_path_m=11.9,
original=[[0, 0, 0], [1, 2, 3], [4, 5, 8]],
corrected=[[0, 0, 0], [1, 2, 2], [4, 5, 7]],
original_route=[[0, 0, 0], [4, 5, 8]],
corrected_route=[[0, 0, 0], [4, 5, 7]],
)
generation = publish_comparison(root, **inputs)
return root, generation, inputs
def load(pair, **changes):
root, generation, inputs = pair
values = {k: inputs[k] for k in ("overview_generation", "session_id", "source_digests")}
return load_comparison(root, **{**values, **changes}, generation=generation)
def test_publication_is_source_bound_idempotent_and_not_a_new_session(pair):
root, generation, inputs = pair
assert publish_comparison(root, **inputs) == generation
preview = load(pair)
np.testing.assert_array_equal(preview.original, inputs["original"])
assert preview.document["view_only"] is True
assert sorted(p.name for p in root.iterdir()) == [generation, "selected"]
assert (
load_comparison(
root, inputs["overview_generation"], "recording", inputs["source_digests"]
).generation
== generation
)
assert load_comparison(root, "d" * 64, "recording", inputs["source_digests"]) is None
@pytest.mark.parametrize(
"changes",
[
dict(session_id="other"),
dict(overview_generation="d" * 64),
dict(source_digests={"raw": "e" * 64}),
],
)
def test_does_not_cross_source_boundaries(pair, changes):
with pytest.raises(ValueError, match="another recording"):
load(pair, **changes)
@pytest.mark.parametrize("filename", ["manifest.json", "geometry.npz"])
def test_corrupt_artifacts_fail_closed(pair, filename):
root, generation, _ = pair
path = root / generation / filename
path.write_bytes(path.read_bytes() + b"X")
with pytest.raises(ValueError, match="changed"):
load(pair)
def test_geometry_rejects_non_finite_or_mismatched_pairs(pair):
root, _, inputs = pair
with pytest.raises(ValueError, match="geometry"):
publish_comparison(root, **{**inputs, "corrected": [[float("nan"), 0, 0]]})
def test_path_traversal_and_symlinks_are_rejected(pair):
root, generation, inputs = pair
with pytest.raises(ValueError, match="identity"):
load_comparison(
root, inputs["overview_generation"], "recording", inputs["source_digests"], "../source"
)
path = root / generation / "geometry.npz"
path.rename(path.with_suffix(".original"))
path.symlink_to(path.with_suffix(".original"))
with pytest.raises(ValueError, match="artifact"):
load(pair)
def test_switch_replaces_cloud_route_endpoints_without_camera_blueprint(pair, tmp_path):
preview = load(pair)
original = tmp_path / "scene.rrd"
recording = rr.RecordingStream("overview-test")
recording.save(original)
recording.log("world/cloud", rr.Points3D(preview.original, colors=[30, 40, 50]), static=True)
recording.flush()
recording.disconnect()
digest = hashlib.sha256(original.read_bytes()).hexdigest()
metadata = spatial_metadata(original, preview)
assert metadata["comparison"]["height_max_m"] == 80
assert metadata["comparison"]["source_points"] == 30
colors = []
for representation in ("original", "corrected", "original"):
data, count, eye = render_spatial_update(
original, None, None, comparison=preview, representation=representation
)
assert count == 3 and eye is None
path = tmp_path / f"{representation}.rrd"
path.write_bytes(data)
reader = RrdReader(path)
assert len(reader.recordings()) == 1
assert (
reader.recordings()[0].recording_id == RrdReader(original).recordings()[0].recording_id
)
chunks = {c.entity_path: c.to_record_batch() for c in reader.stream()}
assert set(chunks) == {"/world/cloud", "/world/route", "/world/endpoints"}
points = (
chunks["/world/cloud"]
.column("Points3D:positions")[0]
.values.values.to_numpy()
.reshape(-1, 3)
)
expected = getattr(preview, representation)
np.testing.assert_array_equal(points, expected)
endpoints = (
chunks["/world/endpoints"]
.column("Points3D:positions")[0]
.values.values.to_numpy()
.reshape(-1, 3)
)
np.testing.assert_array_equal(
endpoints, getattr(preview, representation + "_route")[[0, -1]]
)
colors.append(chunks["/world/cloud"].column("Points3D:colors")[0].values.to_numpy())
np.testing.assert_array_equal(colors[0], colors[1])
assert hashlib.sha256(original.read_bytes()).hexdigest() == digest
assert (
render_spatial_update(original, 2.5, None, comparison=preview, representation="corrected")[
1
]
== 2
)
assert (
render_spatial_update(original, 2.5, None, comparison=preview, representation="original")[1]
== 1
)
eyes = [
render_spatial_update(original, None, "top", comparison=preview, representation=x)[2]
for x in ("original", "corrected")
]
assert eyes[0] == eyes[1]
with pytest.raises(ValueError, match="requires a pinned"):
render_spatial_update(original, None, None, representation="corrected")
def test_pinned_comparison_does_not_follow_new_selection(pair):
root, generation, inputs = pair
newer = publish_comparison(root, **{**inputs, "corrected": [[0, 0, 0], [1, 2, 1], [4, 5, 6]]})
assert generation != newer
assert load(pair).generation == generation
assert (
json.loads((root / "selected" / (inputs["overview_generation"] + ".json")).read_text())[
"generation"
]
== newer
)
def test_http_requires_pinned_version_and_never_falls_back_to_original(pair, tmp_path):
from types import SimpleNamespace
from fastapi import FastAPI
from fastapi.testclient import TestClient
from k1link.web.session_overview_api import build_session_overview_router
path = tmp_path / "scene.rrd"
recording = rr.RecordingStream("http-overview-test")
recording.save(path)
recording.log("world/cloud", rr.Points3D([[0, 0, 0]], colors=[10, 20, 30]), static=True)
recording.flush()
recording.disconnect()
root, generation, inputs = pair
def scene(session, overview):
if session != "recording" or overview != inputs["overview_generation"]:
raise ValueError("source changed")
return path
def comparison(session, overview, pinned=None):
scene(session, overview)
return load_comparison(root, overview, session, inputs["source_digests"], pinned)
app = FastAPI()
app.include_router(
build_session_overview_router(SimpleNamespace(scene=scene, comparison=comparison,
default_representation=lambda *_: "original"))
)
with TestClient(app) as client:
url = "/api/v1/observation-sessions/recording/overview/spatial"
response = client.get(url, params={"generation": inputs["overview_generation"]})
assert response.status_code == 200
assert response.json()["comparison"]["generation"] == generation
request = dict(generation=inputs["overview_generation"], representation="corrected")
assert client.post(url, json=request).status_code == 409
assert (
client.post(url, json={**request, "comparison_generation": "f" * 64}).status_code == 409
)
assert (
client.post(url, json={**request, "comparison_generation": "../source"}).status_code
== 422
)
response = client.post(url, json={**request, "comparison_generation": generation})
assert response.status_code == 200
assert "X-Overview-Eye" not in response.headers
assert response.headers["X-Overview-Visible-Points"] == "3"
+99
View File
@@ -0,0 +1,99 @@
import json
import threading
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from test_session_store import make_legacy_session
from k1link.device_plugins.xgrids_k1 import xgrids_k1_archive_source
from k1link.missions.capture_catalog import reconcile_planning_captures
from k1link.missions.live_tests import PlanningLiveTests
from k1link.sessions import SessionStore
from k1link.web.session_api import build_session_router
def test_standalone_projection_is_durable_paged_and_preserves_raw_evidence(tmp_path):
repo, data = tmp_path / "repo", tmp_path / "data"
archive = xgrids_k1_archive_source(repo / "sessions")
store = SessionStore(repo, data_dir=data)
run = str(uuid4())
# Acquisition can claim its identity before the finished capture is indexed.
store.record_planning_capture("pass-b_viewer_live", run)
for name in [
"survey-a_viewer_live",
"pass-b_viewer_live",
"survey-c_viewer_live",
"pass-d_viewer_live",
]:
make_legacy_session(repo / "sessions", name)
store.reconcile_archive(archive)
before = store.get_session_with_catalog_snapshot("pass-d_viewer_live")
store.record_planning_capture("pass-d_viewer_live", str(uuid4()))
store.record_planning_capture("pass-b_viewer_live", run)
assert store.get_session_with_catalog_snapshot("pass-d_viewer_live") == before
assert len(store.list_recent(scope="source").items) == 4
assert len(store.list_recent(scope="all").items) == 4
assert not store.list_recent(scope="laboratory").items
first = store.list_recent(scope="standalone", limit=1)
assert [s.session_id for s in first.items] == ["survey-c_viewer_live"]
second = store.list_recent(scope="standalone", limit=1, cursor=first.next_cursor)
assert [s.session_id for s in second.items] == ["survey-a_viewer_live"]
assert second.next_cursor is None
reopened = SessionStore(repo, data_dir=data)
reopened.reconcile_archive(archive)
assert len(reopened.list_recent(scope="standalone").items) == 2
assert reopened.prepare_replay("pass-b_viewer_live").session_id == "pass-b_viewer_live"
app = FastAPI()
app.include_router(build_session_router(reopened))
with TestClient(app) as client:
response = client.get(
"/api/v1/observation-sessions?scope=standalone&limit=1&pagination=cursor-v1"
)
assert response.status_code == 200
assert response.json()["items"][0]["id"] == "survey-c_viewer_live"
assert client.get("/api/v1/observation-sessions/pass-b_viewer_live").status_code == 200
def test_historical_migration_uses_live_bindings_not_names_or_recorded_comparisons(tmp_path):
run = str(uuid4())
directory = tmp_path / run
directory.mkdir()
doc = dict(
schema_version="missioncore.planning-live-test/v1",
id=run,
profile="planning",
state="cancelled",
query_session_id="any-name",
baseline_session_id="previous",
draft={"zone": {"session_id": "survey"}},
)
report = directory / "report.json"
report.write_text(json.dumps(doc))
recorded = tmp_path / str(uuid4())
recorded.mkdir()
(recorded / "report.json").write_text(
json.dumps({**doc, "schema_version": "missioncore.registration-run/v1"})
)
calls = []
reconcile_planning_captures(tmp_path, lambda *args: calls.append(args))
assert calls == [("any-name", run)]
assert json.loads(report.read_text()) == doc
report.write_text(json.dumps({**doc, "query_session_id": "survey"}))
with pytest.raises(ValueError, match="reference"):
reconcile_planning_captures(tmp_path, lambda *args: calls.append(args))
def test_live_binding_records_origin_before_publishing_run(tmp_path):
# Exercise the real update boundary without starting any device/worker.
service = object.__new__(PlanningLiveTests)
service.lock = threading.RLock()
service.run = {"id": str(uuid4()), "query_session_id": None}
service.revision = 0
events = []
service.capture_recorder = lambda *args: events.append(("capture", args))
service.persist = lambda: events.append(("persist", service.run.copy()))
service.update(query_session_id="new-pass", query_generation=2, state="running")
assert [e[0] for e in events] == ["capture", "persist"]
assert events[0][1] == ("new-pass", service.run["id"])
+82 -4
View File
@@ -186,11 +186,13 @@ def test_receipt_gap_is_not_an_instantaneous_jump_and_clears_fit_window():
b.ingest(event('pose',t=11.1,p=(21,0,0)))
@pytest.mark.parametrize("loss", ["stale", "reference-coverage", "fit-rejected", "worker-error", "pose-jump", "operator-stop", "operator-stop-pending"])
@pytest.mark.parametrize("loss", ["stale", "reference-coverage", "fit-rejected", "worker-error", "pose-jump", "operator-stop", "operator-stop-pending", "multi-lap"])
def test_live_stationary_bootstrap_keeps_calibration_separate_and_requires_fresh_windows(tmp_path,monkeypatch,loss):
import k1link.missions.live_tests as module
from k1link.missions.route_relocalization import ROUTE_RELOCALIZATION_POLICY
service,source,lock,_=fixture_service(tmp_path,monkeypatch)
service,source,lock,draft=fixture_service(tmp_path,monkeypatch)
if loss == "multi-lap":
draft['route'].update(length_m=4, end_index=4, points=draft['route']['points'][:5])
clock=[100.]
monkeypatch.setattr(module,'time',SimpleNamespace(
monotonic=lambda:clock[0],monotonic_ns=lambda:int(clock[0]*1e9)))
@@ -248,7 +250,12 @@ def test_live_stationary_bootstrap_keeps_calibration_separate_and_requires_fresh
until(lambda:service.get()['planning_phase']=='searching')
# Capture continues while the worker searches; an actual post-ready
# receipt gap must invalidate the prior, not be hidden by this fixture.
for i in range(40):frame(170+i*.5)
for i in range(40):
# Receipts cannot be ahead of the live clock during a continuity
# check. Drain each fixture frame at its actual delivery time.
clock[0] = 170 + i * .5 + .01
frame(170 + i * .5)
until(lambda:source.queue.empty())
until(lambda:source.queue.empty())
clock[0]=190
entry_release.set()
@@ -271,6 +278,31 @@ def test_live_stationary_bootstrap_keeps_calibration_separate_and_requires_fresh
assert service._scene_result['matched_query_indices']==([0] if j==2 else [])
assert calls==['entry','fresh','fresh','fresh']
assert service.get()['planning_phase']=='tracking'
if loss == "multi-lap":
# Exercise the real bootstrap/gate as well as the loop. The fit is
# synthetic, but fresh windows must continue beyond the old cap.
service.update(maximum_distance_m=4)
begin = clock[0] + .5
for i in range(1, 25):
leg, offset = divmod(i - 1, 8)
position = (offset + 1) * .5 if leg % 2 == 0 else 4 - (offset + 1) * .5
clock[0] = begin + i * .5 + .002
frame(begin + i * .5, p=(position, 0, 0))
until(source.queue.empty)
until(lambda: service.get()['distance_m'] == 12)
begin = clock[0] + .5
for i in range(12):
clock[0] = begin + i * .5 + .002
frame(begin + i * .5, p=(4, 0, 0))
until(source.queue.empty)
until(lambda: calls.count('fresh') >= 6)
assert service.get()['state'] == 'running'
assert service.get()['planning_phase'] == 'tracking'
assert service.accepted_sample is not None
assert service.get().get('recovery_attempt', 0) == 0
assert service.get().get('termination_reason') is None
assert source.state['active'] and lock.locked()
return
if loss in {"operator-stop", "operator-stop-pending"}:
if loss == "operator-stop-pending":
fail_next[0] = loss
@@ -360,7 +392,7 @@ def test_operator_can_retry_a_failed_route_identification_without_stopping_captu
monkeypatch.setattr(module,'time',SimpleNamespace(
monotonic=lambda:clock[0],monotonic_ns=lambda:int(clock[0]*1e9)))
searches=[]
def rejected_entry(*_args):
def rejected_entry(*_args, **_kwargs):
searches.append('entry')
return dict(status='rejected',T_reference_query=np.eye(4).tolist(),matched_query_indices=[],
overlap=.6,inlier_rmse_m=.28,reasons=['Большое расстояние между поверхностями.'],
@@ -439,3 +471,49 @@ def test_stationary_cancel_while_searching_does_not_publish_late_prior(tmp_path,
release.set();service.close()
assert service.accepted_sample is None and service.get()['result'] is None
assert not lock.locked() and source.owner is None
@pytest.mark.parametrize("cause", ["operator-stop", "motion", "source-ended"])
def test_long_search_receives_cancellation_without_waiting_for_total_search(tmp_path,monkeypatch,cause):
import k1link.missions.live_tests as module
from k1link.missions.route_relocalization_worker import incomplete_result
service,source,lock,_=fixture_service(tmp_path,monkeypatch)
clock=[100.]
entered, cancelled = threading.Event(), threading.Event()
monkeypatch.setattr(module,'time',SimpleNamespace(
monotonic=lambda:clock[0],monotonic_ns=lambda:int(clock[0]*1e9)))
def search(*args, cancel_event, **kwargs):
entered.set()
assert cancel_event.wait(3), 'Planner did not cancel its own search child'
cancelled.set()
return incomplete_result('worker-cancelled')
monkeypatch.setattr(module,'run_route_relocalization',search)
run=service.start('draft',1)
points=np.random.default_rng(11).uniform([-1,-3,-1],[8,3,3],(1500,3))
try:
until(lambda:service.get()['state']=='waiting')
source.state.update(active=True,session_id='B',session_generation=2)
for i in range(20):
source.queue.put(event('pose',t=100+i*.5,sequence=i*2+1))
source.queue.put(event('points',t=100+i*.5+.001,sequence=i*2+2,points=points))
until(source.queue.empty)
clock[0]=110.01
until(entered.is_set)
if cause=='operator-stop':
service.stop(run['id'])
elif cause=='source-ended':
source.state['active']=False
else:
source.queue.put(event('pose',t=110.005,sequence=41,p=(.2,0,0)))
until(lambda:service.get()['planning_phase']=='lost')
until(cancelled.is_set)
assert service.accepted_sample is None
if cause=='motion':
assert service.get()['state']=='running' and source.state['active']
service.request_reinitialization(run['id'])
until(lambda:service.get()['planning_phase']=='waiting-cloud')
assert service.get()['initialization_attempt']==2
finally:
source.state['active']=False
service.close()
assert not lock.locked() and source.owner is None
+42 -15
View File
@@ -1,4 +1,4 @@
"""Selected route length drives live admission, reference coverage and termination."""
"""Reference coverage and admission never impose a live travel budget."""
import threading
from types import SimpleNamespace
@@ -14,11 +14,12 @@ from k1link.missions.reference_window import reference_window
@pytest.mark.parametrize("length", [3, 30, 50, 100, 200, 300, 10_000])
def test_limits_come_from_selected_length(length):
def test_reference_length_does_not_limit_live_distance_or_time(length):
limits = live_route_limits(length)
assert limits["maximum_distance_m"] == length
assert limits["maximum_distance_m"] is None
assert limits["maximum_seconds"] is None
assert limits["route_policy"]["maximum_m"] is None
assert limits["route_policy"]["version"] == "selected-live-route/v2"
@pytest.mark.parametrize("length", [0, 2.9, float("nan"), float("inf")])
@@ -67,8 +68,11 @@ def test_long_path_does_not_freeze_tail_or_multiply_distance():
np.testing.assert_allclose(buffer.path[-1], [2099 * 0.06, 0, 0])
@pytest.mark.parametrize("length", [20, 50, 100, 200, 300])
def test_actual_live_loop_stops_at_selected_distance_not_forty(tmp_path, monkeypatch, length):
@pytest.mark.parametrize("length", [20, 100, 300])
@pytest.mark.parametrize("ending", ["operator", "session-end", "spatial-stop-requested"])
def test_live_travel_beyond_multiple_reference_lengths_requires_explicit_end(
tmp_path, monkeypatch, length, ending
):
import k1link.missions.stationary_live as module
# Isolate termination from the separately tested geometric state machine.
@@ -111,7 +115,9 @@ def test_actual_live_loop_stops_at_selected_distance_not_forty(tmp_path, monkeyp
run = service.start("draft", 1)
try:
until(lambda: service.get()["state"] == "waiting")
assert run["maximum_distance_m"] == length
assert run["maximum_distance_m"] is None
# An old field cannot silently resurrect distance-based termination.
service.update(maximum_distance_m=length)
clock[0] = 4000.0 # Neither waiting nor an active run has an implicit time cap.
source.state.update(active=True, session_id="B", session_generation=2)
source.queue.put(event("pose", t=1))
@@ -120,15 +126,36 @@ def test_actual_live_loop_stops_at_selected_distance_not_forty(tmp_path, monkeyp
)
until(lambda: service.get().get("planning_phase") == "collecting")
clock[0] = 8000.0
# Queue up to just before the chosen boundary, then prove it remains live.
for i in range(1, length):
source.queue.put(event("pose", t=1 + i, p=(i, 0, 0), sequence=i + 2))
until(source.queue.empty)
assert service.get()["state"] == "running"
source.queue.put(event("pose", t=1 + length, p=(length, 0, 0), sequence=length + 2))
until(lambda: service.get()["state"] == "completed")
assert service.get()["termination_reason"] == "distance-limit"
assert service.get()["distance_m"] == length
# Three legs on the same geometry: travel exceeds the route while the
# current location stays on it. Synchronize via a consumed cloud, not
# queue.empty(), which can race the consumer's final update.
sequence = 3
for leg in range(3):
for offset in range(1, length + 1):
traveled = leg * length + offset
position = offset if leg % 2 == 0 else length - offset
source.queue.put(
event("pose", t=1 + traveled, p=(position, 0, 0), sequence=sequence)
)
sequence += 1
clock[0] += 1
source.queue.put(event("points", t=1.001 + traveled, sequence=sequence,
points=np.array([[position, 0, 1.]])))
sequence += 1
until(lambda expected=traveled: service.get()["distance_m"] == expected)
assert service.get()["state"] == "running"
assert service.get().get("termination_reason") is None
assert lock.locked() and source.owner is not None
if ending == "operator":
service.stop(run["id"])
expected_state, expected_reason = "cancelled", "cancelled"
else:
source.queue.put(event(ending, t=2 + traveled, sequence=sequence))
expected_state = "completed"
expected_reason = "input-ended" if ending == "session-end" else ending
until(lambda: service.get()["state"] == expected_state)
assert service.get()["termination_reason"] == expected_reason
assert service.get()["distance_m"] == 3 * length
assert service.accepted_sample is None
assert source.state["active"] is True # Calculation never stops capture.
finally:
+8 -1
View File
@@ -7,6 +7,8 @@ import subprocess
import sys
from pathlib import Path
from rerun.experimental import RrdReader
from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.device_plugins.xgrids_k1.recorded_point_colors import (
RecordedPointColorOverlayStore,
@@ -94,7 +96,7 @@ def _command(tmp_path: Path) -> ReplayCommand:
)
def test_recorded_color_overlay_logs_only_colors_at_operator_frame_cadence(
def test_recorded_color_overlay_logs_only_colors_for_every_captured_frame(
tmp_path: Path,
) -> None:
store = RecordedPointColorOverlayStore()
@@ -129,6 +131,11 @@ def test_recorded_color_overlay_logs_only_colors_at_operator_frame_cadence(
assert printed.count("Points3D:colors") == 2
assert "Points3D:positions" not in printed
assert "session_time" in printed
rows = 0
for chunk in RrdReader(output).stream():
if chunk.entity_path == "/world/points":
rows += chunk.to_record_batch().num_rows
assert rows == 6
def test_recorded_color_overlay_reuses_index_and_identical_payload(
+112
View File
@@ -0,0 +1,112 @@
from pathlib import Path
import numpy as np
import struct
import pytest
from rerun.experimental import RrdReader
from k1link.device_plugins.xgrids_k1.recorded_point_display import retained_indices, render_point_display
from test_recorded_point_colors import _command
def test_sampling_endpoints_exact_fraction_and_nested_stable_rows():
np.testing.assert_array_equal(retained_indices(1000, 0, 7), np.arange(1000))
assert len(retained_indices(1000, 100, 7)) == 0
for percent in (5, 49, 49.5, 50, 99.9):
actual = retained_indices(1000, percent, 7)
assert len(actual) == round(1000 * (100 - percent) / 100)
np.testing.assert_array_equal(actual, retained_indices(1000, percent, 7))
assert len(set(actual)) == len(actual)
assert set(retained_indices(1000, 75, 7)) < set(retained_indices(1000, 50, 7))
assert not np.array_equal(retained_indices(1000, 50, 7), retained_indices(1000, 50, 8))
for percent in (-1, 101, float('nan'), float('inf')):
with pytest.raises(ValueError): retained_indices(100, percent, 0)
def test_stream_is_display_only_and_keeps_full_timeline(tmp_path: Path):
command = _command(tmp_path)
original = command.primary_artifact.path.read_bytes()
for bank in ('0' * 32, '1' * 32):
payload = b''.join(render_point_display(command, application_id="nodedc_mission_core_recorded",
recording_id="recording-001", color_mode="height", palette="viridis", custom_color="#112233",
point_decimation_percent=5, display_bank=bank))
assert payload.startswith(b'NPD1')
length = struct.unpack('<I', payload[4:8])[0]
assert payload[8:12] == b'RRF2'
assert payload[8 + length:] == b'\0' * 4
path = tmp_path / f"display-{bank}.rrd"; path.write_bytes(payload[8:8 + length])
chunks = list(RrdReader(path).stream())
assert {c.entity_path for c in chunks} == {f"/world/display_points/{bank}"}
assert sum(c.to_record_batch().num_rows for c in chunks) == 6
assert command.primary_artifact.path.read_bytes() == original
def test_stream_cancellation_releases_slot(tmp_path: Path):
command = _command(tmp_path)
def stream():
return render_point_display(command, application_id="nodedc_mission_core_recorded",
recording_id="recording-001", color_mode="intensity", palette="turbo", custom_color="#112233",
point_decimation_percent=50, display_bank='0' * 32)
first = stream(); next(first); first.close()
second = stream(); assert next(second) == b'NPD1'; second.close()
def test_every_batch_is_an_independently_decodable_rrd(tmp_path: Path, monkeypatch):
from k1link.device_plugins.xgrids_k1 import recorded_point_display as module
original = module._iter_point_frames
def many_frames(*args, **kwargs):
frames = list(original(*args, **kwargs))
for _ in range(24):
yield from frames
monkeypatch.setattr(module, '_iter_point_frames', many_frames)
parts = list(module.render_point_display(_command(tmp_path), application_id='nodedc_mission_core_recorded',
recording_id='recording-001', color_mode='height', palette='viridis', custom_color='#112233',
point_decimation_percent=5, display_bank='a' * 32))
assert parts[0] == b'NPD1' and parts[-1] == b'\0' * 4
rows = 0
for i, part in enumerate(parts[1:-1]):
length = struct.unpack('<I', part[:4])[0]
assert length == len(part) - 4 and part[4:8] == b'RRF2'
path = tmp_path / f'batch-{i}.rrd'; path.write_bytes(part[4:])
rows += sum(chunk.to_record_batch().num_rows for chunk in RrdReader(path).stream())
assert len(parts) >= 5
assert rows == 144
def test_prepared_rrd_keeps_exact_colors_geometry_and_86_2_percent(tmp_path, monkeypatch):
import rerun as rr
from k1link.device_plugins.xgrids_k1 import recorded_point_display as module
from k1link.device_plugins.xgrids_k1.prepared_point_display import prepared_point_rows
source = tmp_path / 'prepared.rrd'
rec = rr.RecordingStream('nodedc_mission_core_recorded', recording_id='prepared-source')
rec.save(source)
xyz = np.arange(3000, dtype=np.float32).reshape(-1, 3)
colors = np.arange(1000, dtype=np.uint32) * 256 + 255
for seq in range(1, 4):
rec.set_time('message_sequence', sequence=seq)
rec.set_time('session_time', duration=np.timedelta64(seq * 100_000_000, 'ns'))
rec.log('/world/points', rr.Points3D(xyz + seq, colors=colors))
rec.flush(); rec.disconnect()
frames = list(prepared_point_rows(source))
assert len(frames) == 3
np.testing.assert_array_equal(frames[0][2], xyz + 1)
np.testing.assert_array_equal(frames[0][3], colors)
monkeypatch.setattr(module, '_iter_point_frames', lambda *a, **k: pytest.fail('raw must not be decoded'))
parts = list(module.render_point_display(_command(tmp_path),
application_id='nodedc_mission_core_recorded', recording_id='prepared-source',
color_mode='intensity', palette='turbo', custom_color='#112233',
point_decimation_percent=86.2, display_bank='c' * 32, prepared_recording_path=source))
result = tmp_path / 'result.rrd'; result.write_bytes(parts[1][4:])
rows = []
for chunk in RrdReader(result).stream():
batch = chunk.to_record_batch()
for i in range(batch.num_rows):
rows.append((batch.column('session_time')[i].value,
batch.column('Points3D:positions')[i].values.flatten().to_numpy().reshape(-1, 3),
batch.column('Points3D:colors')[i].values.to_numpy()))
for seq, (ns, positions, rgba) in enumerate(rows, start=1):
indices = retained_indices(1000, 86.2, seq)
assert len(positions) == 138
assert ns == seq * 100_000_000
np.testing.assert_array_equal(positions, (xyz + seq)[indices])
np.testing.assert_array_equal(rgba, colors[indices])
+65
View File
@@ -0,0 +1,65 @@
from pathlib import Path
from fastapi import FastAPI
from starlette.responses import FileResponse, JSONResponse, Response
from starlette.testclient import TestClient
from k1link.web.response_compression import ResponseCompressionMiddleware
from k1link.web.session_api import _ReleasingFileResponse
def test_rrd_preserves_bytes_length_etag_and_ranges_with_gzip_client(tmp_path: Path) -> None:
payload = b"RRD compressed fixture" * 1024
path = tmp_path / "scene.rrd"
path.write_bytes(payload)
app = FastAPI()
app.add_middleware(ResponseCompressionMiddleware)
releases = []
@app.get("/scene.rrd")
def recording() -> FileResponse:
return _ReleasingFileResponse(
path,
headers={"ETag": '"fixture-generation"'},
release=lambda: releases.append(True),
)
with TestClient(app) as client:
response = client.get("/scene.rrd?generation=fixture", headers={"Accept-Encoding": "gzip"})
assert response.content == payload
assert "content-encoding" not in response.headers
assert int(response.headers["content-length"]) == len(payload)
assert response.headers["etag"] == '"fixture-generation"'
partial = client.get(
"/scene.rrd", headers={"Accept-Encoding": "gzip", "Range": "bytes=100-2199"}
)
assert partial.status_code == 206
assert partial.content == payload[100:2200]
assert "content-encoding" not in partial.headers
assert partial.headers["content-range"] == f"bytes 100-2199/{len(payload)}"
assert partial.headers["content-length"] == "2100"
assert releases == [True, True]
assert _ReleasingFileResponse.chunk_size == 1024 * 1024
def test_blueprint_and_color_posts_bypass_gzip_but_json_keeps_it() -> None:
app = FastAPI()
app.add_middleware(ResponseCompressionMiddleware)
payload = b"native RRD" * 1024
@app.post("/{kind}.rrd")
def overlay(kind: str) -> Response:
return Response(payload, media_type="application/octet-stream")
@app.get("/metadata")
def metadata() -> JSONResponse:
return JSONResponse({"description": "a" * 8192})
with TestClient(app) as client:
for kind in ("blueprint", "point-colors"):
response = client.post(f"/{kind}.rrd", headers={"Accept-Encoding": "gzip"})
assert response.content == payload
assert "content-encoding" not in response.headers
response = client.get("/metadata", headers={"Accept-Encoding": "gzip"})
assert response.headers["content-encoding"] == "gzip"
assert response.json() == {"description": "a" * 8192}
+68 -23
View File
@@ -12,7 +12,7 @@ from k1link.missions.route_relocalization import (
relocalize_route,
relocalize_start_then_route,
)
from k1link.missions.stationary_bootstrap import BOOTSTRAP_POLICY, StationaryBootstrap
from k1link.missions.stationary_bootstrap import StationaryBootstrap
def scene():
@@ -102,8 +102,8 @@ def test_middle_of_route_relocalisation_qualifies_with_real_gicp():
assert not output["localization_confirmed"] and not output["vehicle_control"]
def test_hybrid_accepts_the_dense_start_before_route_retrieval(monkeypatch):
"""A normal start must not be degraded by global target voxelisation."""
def test_hybrid_compares_dense_start_with_entire_route_before_accepting(monkeypatch):
"""Preserve a precise start hypothesis, but never shortcut global ambiguity."""
import k1link.missions.route_relocalization as module
reference, path = scene()
@@ -112,15 +112,20 @@ def test_hybrid_accepts_the_dense_start_before_route_retrieval(monkeypatch):
local["initialization"] = dict(complete=True, reason=None, attempts=[{}] * 108)
monkeypatch.setattr(module, "acquire_entry", lambda *args, **kwargs: dict(local))
def unexpected_route(*args, **kwargs):
raise AssertionError("A qualified dense start must not enter route recovery.")
calls = []
def route(*args, **kwargs):
calls.append(kwargs)
hypotheses = kwargs["additional_hypotheses"]
assert len(hypotheses) == 1
return choose_route_location(hypotheses, [0, 0, 0], complete=True)
monkeypatch.setattr(module, "relocalize_route", unexpected_route)
monkeypatch.setattr(module, "relocalize_route", route)
output = relocalize_start_then_route(reference, path, query, [0.0, 0.0, 0.0])
assert output["status"] == "candidate"
assert output["initialization"]["strategy"] == "dense-start-first-then-route-recovery/v1"
assert output["initialization"]["expected_attempts"] == 108
assert calls
assert output["initialization"]["strategy"] == ROUTE_RELOCALIZATION_POLICY["strategy"]
assert output["initialization"]["expected_attempts"] == 109
assert output["initialization"]["stages"][0]["name"] == "dense-start"
@@ -194,6 +199,7 @@ def test_global_result_must_account_for_every_generated_hypothesis_before_prior(
boot.phase = "searching"
boot.initialization_sample = dict(monotonic_ns=1, segment=0)
boot.search_started_ns = 1
boot.last_pose_ns = boot.last_cloud_ns = 1
accepted = boot.offer_prior(
dict(
status="candidate",
@@ -212,6 +218,7 @@ def test_global_result_must_account_for_every_generated_hypothesis_before_prior(
second.phase = "searching"
second.initialization_sample = dict(monotonic_ns=1, segment=0)
second.search_started_ns = 1
second.last_pose_ns = second.last_cloud_ns = 1
initialization["expected_attempts"] = 3
rejected = second.offer_prior(
dict(
@@ -236,13 +243,12 @@ def test_global_ambiguity_has_a_specific_operator_message():
assert "несколько похожих участков" in phase_message(boot)
def test_route_search_deadline_cannot_outlive_the_stationary_prefix_freshness_fence():
assert (
ROUTE_RELOCALIZATION_POLICY["deadline_s"] < BOOTSTRAP_POLICY["maximum_prior_source_age_s"]
)
assert (
ROUTE_RELOCALIZATION_POLICY["maximum_search_wall_s"]
<= BOOTSTRAP_POLICY["maximum_prior_source_age_s"]
def test_route_search_separates_numerical_progress_from_current_receipt_freshness():
assert "deadline_s" not in ROUTE_RELOCALIZATION_POLICY
assert "maximum_search_wall_s" not in ROUTE_RELOCALIZATION_POLICY
assert ROUTE_RELOCALIZATION_POLICY["worker_stall_s"] > 0
assert ROUTE_RELOCALIZATION_POLICY["hypothesis_freshness"] == (
"stationary-receipts-and-disjoint-confirmation/v1"
)
@@ -263,7 +269,7 @@ def test_precise_search_reaches_the_last_ranked_anchor(monkeypatch):
def register(self, query, initial, *, policy):
calls.append(policy)
result = candidate_result(np.eye(4))
if len(calls) < (len(ranked) - 1) * 3 + 1:
if len(calls) < (len(ranked) - 1) * 6 + 1:
result.update(status="rejected", reasons=["fixture mismatch"])
return result
@@ -273,14 +279,14 @@ def test_precise_search_reaches_the_last_ranked_anchor(monkeypatch):
info = result["initialization"]
assert result["status"] == "candidate"
assert info["selected_candidate_index"] == ranked[-1].index
assert len(calls) == info["expected_attempts"] == len(ranked) * 3
assert len(calls) == info["expected_attempts"] == len(ranked) * 6
assert len(prepared_targets) == len(ranked)
assert not info["remaining_candidate_indices"]
assert len(info["candidate_batches"]) > 1
assert all(policy == ROUTE_RELOCALIZATION_POLICY["registration_policy"] for policy in calls)
def test_budget_exhaustion_is_not_a_completed_negative_or_provisional_fit(monkeypatch):
def test_elapsed_time_does_not_discard_the_rest_of_the_finite_queue(monkeypatch):
import k1link.missions.route_relocalization as module
reference, path = scene()
@@ -294,11 +300,39 @@ def test_budget_exhaustion_is_not_a_completed_negative_or_provisional_fit(monkey
result = relocalize_route(reference, path, reference[:700], [0, 0, 0],
clock=lambda: elapsed[0])
info = result["initialization"]
assert result["reasons"] == ["incomplete-route-search"]
assert not info["complete"] and not info["candidate_queue_exhausted"]
assert info["remaining_candidate_indices"]
assert info["expected_attempts"] > len(info["attempts"])
assert info["candidate_queue"] == []
assert result["status"] == "candidate"
assert elapsed[0] > 120
assert info["complete"] and info["candidate_queue_exhausted"]
assert not info["remaining_candidate_indices"]
assert info["expected_attempts"] == len(info["attempts"])
assert info["candidate_queue"]
def test_anchor_seeds_map_the_sensor_origin_not_the_cloud_median(monkeypatch):
import k1link.missions.route_relocalization as module
reference, path = scene()
entry = np.array([1.0, 2.0, 0.2])
monkeypatch.setattr(module.PreparedReference, "register",
lambda self, query, initial, **kwargs: candidate_result(initial))
result = relocalize_route(reference, path, reference[:700], entry)
for item in result["initialization"]["attempts"]:
if item["seed_mode"] != "pose-anchor":
continue
matrix = np.asarray(item["result"]["initial_T_reference_query"])
assert np.allclose(matrix[:3, :3] @ entry + matrix[:3, 3], item["candidate"]["position"])
def test_dense_start_cannot_hide_an_equally_good_distant_place(monkeypatch):
import k1link.missions.route_relocalization as module
reference, path = scene()
dense = attempt(-1, 0, np.eye(4))
other = np.eye(4)
other[0, 3] = 80
monkeypatch.setattr(module.PreparedReference, "register",
lambda *args, **kwargs: candidate_result(other))
result = relocalize_route(reference, path, reference[:700], [0, 0, 0],
additional_hypotheses=[dense])
assert result["reasons"] == ["ambiguous-route-location"]
def test_ambiguity_uses_fitted_places_even_when_retrieval_anchor_is_same():
@@ -319,3 +353,14 @@ def test_fresh_confirmation_fallback_skips_dense_search(monkeypatch):
monkeypatch.setattr(module, "relocalize_route", lambda *a, **k: {"route_only": True})
assert relocalize_start_then_route(reference, path, reference[:700], [0, 0, 0],
route_only=True) == {"route_only": True}
def test_sparse_start_patch_does_not_prevent_searching_the_rest_of_the_map(monkeypatch):
import k1link.missions.route_relocalization as module
reference, path = scene()
def no_start(*args, **kwargs):
raise module.ReferenceCoverageError("sparse start")
monkeypatch.setattr(module, "_route_start_context", no_start)
monkeypatch.setattr(module, "relocalize_route", lambda *a, **k: {"initialization": {}})
result = relocalize_start_then_route(reference, path, reference[:700], [0, 0, 0])
assert result["initialization"]["dense_start_unavailable"] == "sparse start"
+66
View File
@@ -0,0 +1,66 @@
"""Progress-bound child lifetime: no map-size timeout and no orphan on STOP."""
import subprocess
import threading
import pytest
from k1link.missions.route_relocalization_worker import supervise_search
class Child:
args = ["synthetic-search"]
def __init__(self, clock, progress, *, advance=True, cancel=None, stubborn=False):
self.clock, self.progress = clock, progress
self.advance, self.cancel, self.stubborn = advance, cancel, stubborn
self.returncode = None
self.steps = 0
self.terminated = self.killed = False
self.reaped = False
def poll(self):
return self.returncode
def wait(self, timeout=None):
if self.terminated or self.killed:
if self.stubborn and not self.killed:
raise subprocess.TimeoutExpired(self.args, timeout)
self.returncode, self.reaped = -15, True
return self.returncode
self.clock[0] += 15
self.steps += 1
if self.advance:
self.progress.write_text(str(self.steps))
if self.cancel is not None and self.steps == 3:
self.cancel.set()
if self.steps == 20:
self.returncode = 0
return 0
raise subprocess.TimeoutExpired(self.args, timeout)
def terminate(self):
self.terminated = True
def kill(self):
self.killed = True
def test_progressing_search_outlives_old_total_timeout(tmp_path):
clock = [0.]
progress = tmp_path / "progress.json"
child = Child(clock, progress)
assert supervise_search(child, progress, clock=lambda: clock[0]) is None
assert clock[0] == 300 and not child.terminated
@pytest.mark.parametrize("cancel,stubborn", [(False, False), (True, False), (True, True)])
def test_stall_or_stop_reaps_exact_child_before_return(tmp_path, cancel, stubborn):
clock = [0.]
progress = tmp_path / "progress.json"
event = threading.Event() if cancel else None
child = Child(clock, progress, advance=cancel, cancel=event, stubborn=stubborn)
reason = supervise_search(child, progress, cancel_event=event, clock=lambda: clock[0])
assert reason == ("worker-cancelled" if cancel else "worker-stalled")
assert child.terminated and child.reaped
assert child.killed == stubborn
+45 -13
View File
@@ -25,9 +25,6 @@ from k1link.device_plugins.xgrids_k1.rrd_export import (
RECORDED_POINTS_VISUALIZER_ID,
RECORDED_ROOT_CONTAINER_ID,
RECORDED_SPATIAL_VIEW_ID,
RECORDED_VIEW_POINT_DECIMATION_THRESHOLD,
RECORDED_VIEW_POINT_FRAME_STRIDE,
RECORDED_VIEW_POINT_STRIDE,
SESSION_TIMELINE,
RrdExportCancelled,
RrdExportError,
@@ -59,9 +56,8 @@ def _point_payload(x: float) -> bytes:
)
def test_recorded_operator_projection_thins_only_dense_point_batches() -> None:
assert RECORDED_VIEW_POINT_FRAME_STRIDE == 5
count = RECORDED_VIEW_POINT_DECIMATION_THRESHOLD + 3
def test_recorded_operator_projection_preserves_all_points_and_attributes() -> None:
count = 100_003
positions = export_module.np.arange(
count * 3,
dtype=export_module.np.float32,
@@ -78,16 +74,16 @@ def test_recorded_operator_projection_thins_only_dense_point_batches() -> None:
assert projected_rgb is not None
assert export_module.np.array_equal(
projected_positions,
positions[::RECORDED_VIEW_POINT_STRIDE],
positions,
)
assert export_module.np.array_equal(
projected_intensities,
intensities[::RECORDED_VIEW_POINT_STRIDE],
intensities,
)
assert export_module.np.array_equal(projected_rgb, rgb[::RECORDED_VIEW_POINT_STRIDE])
assert export_module.np.array_equal(projected_rgb, rgb)
small_positions = positions[:RECORDED_VIEW_POINT_DECIMATION_THRESHOLD]
small_intensities = intensities[:RECORDED_VIEW_POINT_DECIMATION_THRESHOLD]
small_positions = positions[:2400]
small_intensities = intensities[:2400]
same_positions, same_intensities, no_rgb = _recorded_view_points(
small_positions,
small_intensities,
@@ -98,18 +94,54 @@ def test_recorded_operator_projection_thins_only_dense_point_batches() -> None:
assert no_rgb is None
def test_recorded_operator_projection_uses_stable_two_hz_point_cadence() -> None:
def test_recorded_operator_projection_preserves_every_point_frame() -> None:
published = [
frame_number
for frame_number in range(1, 13)
if _should_publish_recorded_point_frame(frame_number)
]
assert published == [1, 6, 11]
assert published == list(range(1, 13))
with pytest.raises(ValueError, match="positive"):
_should_publish_recorded_point_frame(0)
def test_export_logs_every_frame_not_just_counts_them(tmp_path: Path, monkeypatch) -> None:
capture = _write_capture(tmp_path, [
("RealtimePointcloud", _point_payload(float(index)), index * 100_000_000)
for index in range(12)
])
logged_counts = []
original_log_points = export_module._log_points
def log_points(recording, frame, settings, **kwargs):
logged_counts.append(frame.point_count)
return original_log_points(recording, frame, settings, **kwargs)
monkeypatch.setattr(export_module, "_log_points", log_points)
summary = export_k1mqtt_to_rrd(capture, tmp_path / "all-frames.rrd")
assert logged_counts == [1] * 12
assert sum(logged_counts) == summary["points"]
def test_thirty_minute_settings_keep_small_screen_space_points() -> None:
blueprint = viewer_recorded_blueprint(
RerunSceneSettings(point_size=0.5, accumulation_seconds=1800),
update_eye_controls=False,
)
spatial_view = blueprint.root_container.contents[0]
_, visualizer, ranges = spatial_view.visualizer_overrides["/world/points"]
values = {
str(batch.component_descriptor()): batch.as_arrow_array().to_pylist()
for batch in visualizer.overrides
}
assert values["Points3D:radii"] == [-0.5]
assert ranges.ranges.as_arrow_array().to_pylist()[0]["range"] == {
"start": -1_800_000_000_000, "end": 0,
}
assert "EyeControls3D" not in spatial_view.properties
def _pose_payload(x: float) -> bytes:
return struct.pack("<ffffffff", x, 2.0, 3.0, 99.0, 1.0, 0.0, 0.0, 0.0)
+118
View File
@@ -0,0 +1,118 @@
import json
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from k1link.device_plugins.xgrids_k1 import xgrids_k1_archive_source
from k1link.sessions import SessionStore
from k1link.sessions.display_profile import load_display_profile, save_display_profile
from k1link.web.session_api import build_session_router
from test_session_api import make_legacy_session
def settings(**patch):
return dict(projection="3d", point_size=0.5, color_mode="height", palette="viridis",
custom_color="#112233", accumulation_seconds=600, accumulation_max_seconds=600,
point_decimation_percent=49.5, show_points=True, show_trajectory=True,
show_grid=True, show_labels=False, show_camera_frustums=True, **patch)
def test_atomic_session_metadata_keeps_evidence_and_isolates_sessions(tmp_path: Path):
a, b = tmp_path / "a", tmp_path / "b"
a.mkdir(); b.mkdir()
(a / "method.json").write_text('{"immutable":true}')
assert load_display_profile(a, "a") is None
save_display_profile(a, "a", settings())
assert load_display_profile(a, "a")["scene_settings"] == settings()
assert load_display_profile(b, "b") is None
assert (a / "method.json").read_text() == '{"immutable":true}'
assert not list(a.glob(".display-profile-*"))
with pytest.raises(ValueError):
load_display_profile(a, "b")
def test_profile_symlink_is_not_followed(tmp_path: Path):
other = tmp_path / "other.json"
other.write_text("original")
(tmp_path / "display-profile.json").symlink_to(other)
with pytest.raises(ValueError):
save_display_profile(tmp_path, "a", settings())
assert other.read_text() == "original"
def test_failed_atomic_replace_keeps_previous_profile(tmp_path: Path, monkeypatch):
save_display_profile(tmp_path, 'a', settings())
previous = (tmp_path / 'display-profile.json').read_bytes()
def fail(*args):
raise OSError('disk unavailable')
monkeypatch.setattr('k1link.sessions.display_profile.os.replace', fail)
changed = settings(); changed['point_decimation_percent'] = 80
with pytest.raises(OSError): save_display_profile(tmp_path, 'a', changed)
assert (tmp_path / 'display-profile.json').read_bytes() == previous
assert not list(tmp_path.glob('.display-profile-*'))
def test_profile_api_roundtrip_strict_validation_and_no_capture_mutation(tmp_path: Path):
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260921T134309Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
app = FastAPI(); app.include_router(build_session_router(store))
url = f"/api/v1/observation-sessions/{session.name}/display-profile"
with TestClient(app) as client:
assert client.get(url).json() is None
response = client.put(url, json={"scene_settings": settings()})
assert response.status_code == 200
assert client.get(url).json() == response.json()
assert json.loads((session / "display-profile.json").read_text()) == response.json()
for value in (-1, 100.1):
invalid = settings(); invalid["point_decimation_percent"] = value
assert client.put(url, json={"scene_settings": invalid}).status_code == 422
for value in (0, 50, 100):
valid = settings(); valid["point_decimation_percent"] = value
assert client.put(url, json={"scene_settings": valid}).status_code == 200
assert client.put(url, json={"scene_settings": settings(), "path": "/tmp"}).status_code == 422
assert client.get("/api/v1/observation-sessions/missing/display-profile").status_code == 404
def test_point_display_pins_exact_prepared_generation_and_releases_on_error(tmp_path):
from types import SimpleNamespace
repository = tmp_path / 'repo'
session = make_legacy_session(repository / 'sessions', '20260921T134309Z_viewer_live')
store = SessionStore(repository, data_dir=tmp_path / 'data')
store.reconcile_archive(xgrids_k1_archive_source(repository / 'sessions'))
command = store.prepare_replay(session.name)
path = tmp_path / 'prepared.rrd'
snapshot = SimpleNamespace(state='ready', preparation_id='prep-1', command=command,
recording=SimpleNamespace(path=path, sha256='a' * 64))
releases, calls = [], []
manager = SimpleNamespace(status=lambda _: snapshot,
pin_ready=lambda *a, **k: (snapshot, lambda: releases.append(True)))
fail = False
def renderer(command, **kwargs):
calls.append(kwargs)
assert kwargs['prepared_recording_path'] == path
assert 'source_generation' not in kwargs
if fail:
raise ValueError('bad prepared source')
yield b'NPD1'
yield b'\0' * 4
app = FastAPI()
app.include_router(build_session_router(store, recording_preparation_manager=manager,
point_display_renderers={command.plugin_id: renderer}))
url = f'/api/v1/observation-sessions/{session.name}/point-display.rrd'
request = dict(application_id='nodedc_mission_core_recorded', recording_id='test-recording',
color_mode='intensity', palette='turbo', custom_color='#112233',
point_decimation_percent=86.2, display_bank='b' * 32, source_generation='a' * 64)
with TestClient(app) as client:
assert client.post(url, json={**request, 'source_generation': 'f' * 64}).status_code == 412
assert not calls and not releases
result = client.post(url, json=request)
assert result.status_code == 200 and result.content == b'NPD1' + b'\0' * 4
assert len(releases) == 1
fail = True
assert client.post(url, json=request).status_code == 409
assert len(releases) == 2
+222
View File
@@ -0,0 +1,222 @@
from dataclasses import replace
from types import SimpleNamespace
import numpy as np
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from rerun.experimental import RrdReader
from test_map_reference_version import fixture as map_fixture
from test_rrd_export import _point_payload, _pose_payload, _write_capture
from test_session_recording import _command
from k1link.device_plugins.xgrids_k1.rrd_export import export_k1mqtt_to_rrd
from k1link.missions.default_sources import DefaultPlanningSources
from k1link.missions.drafts import MissionDrafts
from k1link.reconstruction.recorded_geometry import RecordedMapGeometry
from k1link.reconstruction.session_versions import SessionMapVersions
from k1link.sessions.map_recording import MapReplaySessionStore
from k1link.sessions.models import ReplayMapVersion, SessionIntegrityError
from k1link.sessions.preparation import _source_identity
from k1link.sessions.recording import (
RecordingMaterializationError,
_validate_source,
_validated_artifact_digests,
)
from k1link.web.mission_planner_api import build_mission_planner_router
@pytest.fixture
def fixture(tmp_path):
return map_fixture.__wrapped__(tmp_path)
def sources(f):
f.original.store = SimpleNamespace(data_dir=f.root)
f.original.root = f.root / "planning"
versions = SessionMapVersions(f.root / "data")
return DefaultPlanningSources(f.original, versions), versions
def test_default_is_explicit_durable_and_preserves_original_and_old_draft(fixture):
f = fixture
planning, versions = sources(f)
version = f.publish()
assert planning.get("source-a")["generation"] == "a" * 64
drafts = MissionDrafts(f.root / "drafts", planning)
args = dict(
session_id="source-a",
generation="a" * 64,
id=None,
revision=0,
name="Before correction",
start_index=0,
end_index=2,
direction="forward",
)
old = drafts.save(SimpleNamespace(**args))
versions.activate(version, f.original)
current = planning.get("source-a")
assert current["generation"] == version.generation
assert current["label"] == f.source["label"] # ordinary catalog name
np.testing.assert_array_equal([p["position"] for p in current["poses"]], f.corrected)
new = drafts.save(SimpleNamespace(**{**args, "generation": current["generation"]}))
assert new["zone"]["generation"] != old["zone"]["generation"]
assert drafts.get(old["id"]) == old
assert drafts.check(old["id"], 1)["vehicle_control"] is False
assert SessionMapVersions(f.root / "data").selected("source-a").generation == version.generation
assert planning.bound("source-a", "a" * 64)["poses"] == f.source["poses"]
points, metadata = planning.submap("source-a", current["generation"], 0, 2)
np.testing.assert_array_equal(points, f.corrected)
assert metadata["generation"] == current["generation"]
app = FastAPI()
app.include_router(build_mission_planner_router(drafts))
with TestClient(app) as client:
url = "/api/v1/mission-planner/sources/source-a"
assert client.get(url).json()["generation"] == version.generation
assert client.get(url, params={"generation": "a" * 64}).json()["generation"] == "a" * 64
assert client.get(url, params={"generation": "c" * 64}).status_code == 409
def test_missing_or_corrupt_default_never_silently_becomes_original(fixture):
f = fixture
planning, versions = sources(f)
version = versions.activate(f.publish(), f.original)
file = version.directory / "points.f32"
original = file.read_bytes()
file.write_bytes(b"x" * len(original))
with pytest.raises(ValueError, match="identity changed"):
planning.get("source-a")
file.write_bytes(original)
version.directory.rename(version.directory.with_name("removed-version"))
with pytest.raises(ValueError, match="not substituted"):
planning.get("source-a")
def test_replay_binding_keeps_native_root_and_raw_identity(fixture):
f = fixture
command = _command(f.root / "capture")
f.source.update(
session_id=command.session_id,
source_digests=_validated_artifact_digests(_validate_source(command)),
)
# The fixture parent is source-a-specific; the replay binding itself verifies
# real native digests independently of planning-source fixtures.
version = f.publish()
pinned = replace(command, map_version=ReplayMapVersion(version.directory, version.generation))
source = _validate_source(pinned)
assert pinned.session_root == command.session_root
assert source.primary.path == command.primary_artifact.path
assert len(source.artifacts) == len(command.artifacts) + 3
assert _source_identity(pinned) != _source_identity(command)
assert (
_validated_artifact_digests(source)[command.primary_artifact_id]
== f.source["source_digests"][command.primary_artifact_id]
)
wrong = replace(pinned, session_id="some-other-session")
with pytest.raises(RecordingMaterializationError):
_validate_source(wrong)
facade = MapReplaySessionStore(
SimpleNamespace(prepare_replay=lambda *_: command),
SimpleNamespace(resolve_replay=lambda _: wrong),
)
with pytest.raises(SessionIntegrityError):
facade.prepare_replay(command.session_id)
def test_projector_owns_full_frames_pose_orientation_and_clock(fixture):
f = fixture
version = f.publish()
geometry = RecordedMapGeometry(
{
"map-version-" + name: version.directory / name
for name in ("manifest.json", "points.f32", "trajectory.npz")
}
)
for i in range(3):
np.testing.assert_array_equal(geometry.points(i, i, 2), f.points[i * 2 : i * 2 + 2])
np.testing.assert_array_equal(geometry.pose(i, i)[0], f.corrected[i])
geometry.complete(3, 3)
with pytest.raises(ValueError, match="clock"):
geometry.pose(0, 0.5)
with pytest.raises(ValueError, match="ownership"):
geometry.points(0, 0, 3)
with pytest.raises(ValueError, match="all native"):
geometry.complete(2, 3)
assert geometry.recording_id("original") != "original"
def test_overview_default_follows_map_but_original_comparison_stays_available(fixture):
from k1link.sessions.overview import SessionOverviewService
f = fixture
_, versions = sources(f)
version = versions.activate(f.publish(), f.original)
service = SessionOverviewService(SimpleNamespace(data_dir=f.root), {}, map_versions=versions)
pair = SimpleNamespace(
document={
"map_generation": version.generation,
"source_digests": f.source["source_digests"],
}
)
try:
assert service.default_representation("source-a", pair) == "corrected"
assert service.default_representation("source-a", pair, version.generation) == "corrected"
assert service.default_representation("source-a", pair, "a" * 64) == "original"
with pytest.raises(ValueError, match="unavailable"):
service.default_representation("source-a", None)
finally:
service.close()
def test_corrected_rrd_retains_clock_and_replaces_cloud_pose_and_route(tmp_path):
frames = [("unrelated", b"{}", 2_000_000_000)]
for i in range(6):
frames += [
("RealtimePointcloud", _point_payload(i), 2_100_000_000 + i * 200_000_000),
("RealtimePath", _pose_payload(i), 2_200_000_000 + i * 200_000_000),
]
raw = _write_capture(tmp_path, frames, capture_clock_offsets_ns=(0, 4_000_000_000))
class Geometry:
def recording_id(self, original):
return "corrected-" + original
def points(self, index, time, count):
assert count == 1 and time == pytest.approx(0.1 + index * 0.2)
return np.array([[100 + index, 200, 300]], dtype=np.float32)
def pose(self, index, time):
assert time == pytest.approx(0.2 + index * 0.2)
return (100.0 + index, 200.0, 300.0), (0.0, 0.0, 1.0, 0.0)
def complete(self, clouds, poses):
assert (clouds, poses) == (6, 6)
original = export_k1mqtt_to_rrd(raw, tmp_path / "original.rrd")
corrected = export_k1mqtt_to_rrd(raw, tmp_path / "corrected.rrd", map_geometry=Geometry())
assert corrected["source_sha256"] == original["source_sha256"]
assert corrected["timeline_start_ns"] == original["timeline_start_ns"] == 0
assert corrected["timeline_end_ns"] == original["timeline_end_ns"] == 4_000_000_000
assert corrected["recording_id"] != original["recording_id"]
clouds, poses, route = [], [], []
for chunk in RrdReader(tmp_path / "corrected.rrd").stream():
batch = chunk.to_record_batch()
if chunk.entity_path == "/world/points":
clouds.extend(batch.column("Points3D:positions").to_pylist())
if (
chunk.entity_path == "/world/sensor_pose"
and "Transform3D:translation" in batch.schema.names
):
poses.extend(batch.column("Transform3D:translation").to_pylist())
if chunk.entity_path == "/world/trajectory" and "LineStrips3D:strips" in batch.schema.names:
route.extend(batch.column("LineStrips3D:strips").to_pylist())
np.testing.assert_array_equal(
np.array(clouds).reshape(-1, 3), [[100 + index, 200, 300] for index in range(6)]
)
assert poses and np.array(poses).reshape(-1, 3)[-1, 0] == 105
# Existing operator cadence is preserved; every published route vertex is corrected.
np.testing.assert_array_equal(
np.array(route[-1]).reshape(-1, 3), [[100, 200, 300], [103, 200, 300]]
)
+121
View File
@@ -0,0 +1,121 @@
import numpy as np
import pytest
pytest.importorskip("scipy")
from scipy.spatial.transform import Rotation
from k1link.reconstruction.smooth_correction import (
CorrectionField,
CorrectionPolicy,
SurfaceLink,
fit_correction,
)
def link(a=0, b=100, shift=(0, 0, 1), rotation=(0, 0, 0), identity="seam"):
t = np.eye(4)
t[:3, 3] = shift
t[:3, :3] = Rotation.from_rotvec(rotation).as_matrix()
return SurfaceLink(a, b, t, np.array([0.0, 0.0, 0.0]), 0.03, 0.05, identity)
def test_smooth_seam_preserves_real_endpoint_offset_and_source():
source = np.array([[0.0, 0.0, 0.0], [0.5, 0, -1]])
saved = source.copy()
field, report = fit_correction(100, [link()])
corrected = field.points(source, [0, 100])
assert report["converged"] and not report["production_promotion"]
np.testing.assert_array_equal(source, saved)
np.testing.assert_allclose(corrected[0], source[0], atol=1e-12)
assert corrected[-1, 0] == pytest.approx(0.5) # NOT forced to the first pose
assert abs(corrected[-1, 2]) < 0.001
gradient = np.diff(field.points(np.zeros((101, 3)), np.arange(101)), axis=0)
assert np.linalg.norm(gradient, axis=1).max() < 0.011
def test_each_frame_is_rigid_and_orientation_is_corrected():
field, _ = fit_correction(100, [link(rotation=(0.01, -0.02, 0.03))])
p = np.random.default_rng(5).normal(size=(500, 3))
changed = field.points(p, 45)
np.testing.assert_allclose(
np.linalg.norm(np.diff(changed, axis=0), axis=1),
np.linalg.norm(np.diff(p, axis=0), axis=1),
atol=1e-12,
)
pos, q = field.poses(np.zeros((1, 3)), [[0, 0, 0, 1]], [45])
np.testing.assert_allclose(
Rotation.from_quat(q).as_matrix(), field.matrices([45])[:, :3, :3], atol=1e-12
)
np.testing.assert_allclose(pos, field.matrices([45])[:, :3, 3])
def test_multiple_loop_constraints_and_no_local_jump():
field, report = fit_correction(
100, [link(b=50, shift=(0, 0, 0.4)), link(a=50, shift=(0, 0, 0.6), identity="second")]
)
assert report["converged"]
heights = field.points(np.zeros((3, 3)), [0, 50, 100])[:, 2]
np.testing.assert_allclose(heights, [0, 0.4, 1], atol=0.002)
eps = 1e-5
for knot in field.knots[1:-1]:
np.testing.assert_allclose(
field.spline(knot - eps, 1), field.spline(knot + eps, 1), atol=1e-6
)
def test_zero_closure_is_identity_and_no_constraints_fail():
field, _ = fit_correction(100, [link(shift=(0, 0, 0))])
np.testing.assert_allclose(field.matrices(50)[0], np.eye(4))
with pytest.raises(ValueError):
fit_correction(100, [])
@pytest.mark.parametrize("bad", [float("nan"), -1, 101])
def test_never_extrapolates(bad):
field = CorrectionField([0, 100], np.zeros((2, 6)))
with pytest.raises(ValueError):
field.matrices(bad)
def test_invalid_policy_and_link():
with pytest.raises(ValueError):
CorrectionPolicy(knot_spacing_m=0)
with pytest.raises(ValueError):
SurfaceLink(0, 0, np.eye(4), np.zeros(3), 0.1, 0.1, "bad")
with pytest.raises(ValueError):
fit_correction(20, [link()])
def test_world_coordinate_change_does_not_change_physical_correction():
edge = link(rotation=(0.01, -0.02, 0.03))
field, _ = fit_correction(100, [edge])
h = np.eye(4)
h[:3, :3] = Rotation.from_rotvec([0.3, -0.2, 0.1]).as_matrix()
h[:3, 3] = [1000, -2000, 3000]
changed = SurfaceLink(
0,
100,
h @ edge.T_reference_query @ np.linalg.inv(h),
h[:3, :3] @ edge.query_center + h[:3, 3],
0.03,
0.05,
"seam",
)
other, _ = fit_correction(100, [changed])
p = np.random.default_rng(3).normal(size=(21, 3))
s = np.linspace(0, 100, len(p))
np.testing.assert_allclose(
other.points(p @ h[:3, :3].T + h[:3, 3], s),
field.points(p, s) @ h[:3, :3].T + h[:3, 3],
atol=2e-5,
)
def test_links_own_validated_arrays():
matrix, center = np.eye(4), np.zeros(3)
edge = SurfaceLink(0, 10, matrix.tolist(), center, 0.1, 0.1, "test")
center[:] = np.nan
assert np.isfinite(edge.query_center).all()
with pytest.raises(ValueError):
edge.T_reference_query[0, 0] = 2
fit_correction(10, [edge])
+75 -3
View File
@@ -44,6 +44,77 @@ def initialized():
return boot, result
def long_route_search():
from k1link.missions.route_relocalization import ROUTE_RELOCALIZATION_POLICY
boot, result = initialized()
boot.initialization_policy = ROUTE_RELOCALIZATION_POLICY
result["initialization"].update(policy=ROUTE_RELOCALIZATION_POLICY,
scope="selected-route", expected_attempts=108)
for i in range(20, 200):
boot.ingest(event(i * .5, i * 2 + 1), 0)
boot.ingest(event(i * .5 + .001, i * 2 + 2, kind="points"), 0)
boot.tick(int((i * .5 + 1.01) * 1e9), 0)
assert boot.phase == "searching"
return boot, result
def test_long_complete_search_is_only_a_hypothesis_until_three_new_windows():
boot, result = long_route_search()
ready = 101_000_000_000
decision = boot.offer_prior(result, ready, 0)
assert decision["age_s"] > 90
assert decision["provisional"] and decision["stationary_search_continuity"]
assert not decision["accepted"] and boot.gate.matrix is None
seen = set()
for i, start in enumerate((101., 106., 111.)):
sample, _ = fresh_window(boot, start, 500 + i * 20, segment=0)
ids = {e["sequence"] for e in sample["events"]}
assert not ids & seen
seen |= ids
assert all(e["monotonic_ns"] > ready for e in sample["events"])
decision = boot.accept_fresh(fit(), sample, int((start + 4.2) * 1e9), 0)
assert decision["accepted"]
assert boot.phase == ("tracking" if i == 2 else "validating")
@pytest.mark.parametrize(
"failure", ["motion", "gap", "pose-only", "cloud-only", "silent", "identity"]
)
def test_long_search_cannot_survive_motion_or_invalid_live_receipts(failure):
boot, result = long_route_search()
now = 111_000_000_000
if failure == "motion":
boot.ingest(replace(event(100., 500), position=(.11, 0., 0.)), 0)
elif failure == "gap":
boot.ingest(event(100., 500), 1)
elif failure == "identity":
with pytest.raises(ValueError):
boot.ingest(replace(event(100., 500), generation=2), 0)
elif failure in {"pose-only", "cloud-only"}:
boot.ingest(event(109., 500, kind="pose" if failure == "pose-only" else "points"), 0)
boot.tick(now, boot.segment)
decision = boot.offer_prior(result, now, boot.segment)
assert boot.phase == "lost" and not decision["provisional"]
assert boot.gate.matrix is None
def test_long_search_can_try_next_place_only_with_another_fresh_window():
boot, result = long_route_search()
alternative = np.eye(4)
alternative[0, 3] = 40
result["initialization"]["candidate_queue"] = [
dict(candidate_index=1, T_reference_query=np.eye(4).tolist(), ambiguous=False),
dict(candidate_index=2, T_reference_query=alternative.tolist(), ambiguous=False),
]
assert boot.offer_prior(result, 101_000_000_000, 0)["provisional"]
sample, _ = fresh_window(boot, 101., 500, segment=0)
decision = boot.accept_fresh(dict(status="rejected"), sample, 105_200_000_000, 0)
assert not decision["accepted"] and decision["next_candidate_trial"] == 2
assert boot.gate.matrix is None and boot.floor_ns == 105_200_000_000
assert np.allclose(boot.prior, alternative)
assert boot.validation(106_000_000_000, 0) is None
def fresh_window(boot, start, seq, segment=1):
for i in range(7):
boot.ingest(event(start + i * 0.5, seq + i * 2), segment)
@@ -170,8 +241,9 @@ def test_initial_failure_and_later_loss_have_distinct_operator_messages():
rejected = boot.offer_prior(result, 36_000_000_000, 0)
assert rejected["reason"] == "initialization-incomplete"
assert not boot.tracking_established
assert "Синхронизация маршрута не завершилась" in phase_message(boot)
assert "Остановите устройство и запись" in phase_message(boot)
assert "Поиск не завершён" in phase_message(boot)
assert "Переинициализировать" in phase_message(boot)
assert "Остановите устройство" not in phase_message(boot)
assert "потеряна" not in phase_message(boot)
boot.tracking_established = True
boot.stop("stale")
@@ -266,7 +338,7 @@ def test_candidate_queue_never_bypasses_freshness_identity_or_ambiguity(failure)
ready = 45_000_000_000 if failure == "expired" else 25_000_000_000
if failure == "ambiguous":
result["initialization"]["candidate_queue"][1]["ambiguous"] = True
# Use the route policy's existing 35 s search fence for the late prefix case.
# The legacy local protocol still has its 30 s search / 40 s source fences.
if failure == "expired":
boot.search_started_ns += 5_000_000_000
assert boot.offer_prior(result, ready, 1)["provisional"]