344 lines
20 KiB
Python
344 lines
20 KiB
Python
"""Real ambiguity stays rejected; independent synthetic views exercise the new path."""
|
|
from copy import deepcopy
|
|
from dataclasses import asdict, replace
|
|
import gzip
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from scipy.spatial.transform import Rotation
|
|
|
|
from linkerhand_calibration.profiles import load_bundled_hand_profile
|
|
from linkerhand_calibration.profiles.preparation import configure_preparation
|
|
from linkerhand_calibration.core.domain.capture_plan import configure_training, CapturePlan
|
|
from linkerhand_calibration.core.geometry.tag_pose import production_image_motion as solver
|
|
from linkerhand_calibration.core.geometry.tag_pose.image_motion_projection import select_image_motion_frame
|
|
from linkerhand_calibration.core.geometry.tag_pose.ippe import square_object_points, solve_square_tag_ippe
|
|
from linkerhand_calibration.runtime.branch_initialization import BranchInitialization, solve_initialization, initialization_artifacts
|
|
from linkerhand_calibration.runtime.motion_execution import MotionCommand
|
|
from linkerhand_calibration.runtime.preparation_witness import WitnessInput, build_preparation_witness, select_witness_rows
|
|
from linkerhand_calibration.runtime.motion_provenance import _initializations, source_frame_sha256
|
|
from linkerhand_calibration.runtime.preparation_witness_evidence import validate_preparation_witnesses, validate_witness_topology
|
|
from o30_capture_fixture import SOURCE
|
|
|
|
|
|
@pytest.fixture(scope='module')
|
|
def ring_source():
|
|
data = json.loads(gzip.decompress(Path(__file__).with_name('fixtures').joinpath('o30_ring_mcp_ambiguous.json.gz').read_bytes()))
|
|
profile = load_bundled_hand_profile('o30_right_18')
|
|
owner = BranchInitialization(profile, source_urdf=SOURCE)
|
|
failure = data['failure']
|
|
spec = profile.motion.joint_zero_references['ring_mcp_pitch']
|
|
motion = MotionCommand('zero_approach', spec.command, 200., task_key=failure['task_name'], reference_joints=('ring_mcp_pitch',))
|
|
owner.begin(motion, session_epoch=1, motion_version=failure['motion_version'])
|
|
for row in data['rows']:
|
|
owner.observe(row)
|
|
request = owner.request('side', replace(motion, phase='joint_zero', zero_joints=('ring_mcp_pitch',)),
|
|
session_epoch=1, motion_version=failure['motion_version']+1)
|
|
fits = []
|
|
original = solver._fit_hypothesis
|
|
def trace(*args, **kwargs):
|
|
fit = original(*args, **kwargs)
|
|
fits.append(fit)
|
|
return fit
|
|
with pytest.MonkeyPatch.context() as patch:
|
|
patch.setattr(solver, '_fit_hypothesis', trace)
|
|
result = solve_initialization(request)
|
|
return profile, owner, request, result, tuple(fits), tuple(data['rows'])
|
|
|
|
|
|
@pytest.mark.replay
|
|
def test_real_ring_is_not_authorized_without_new_pose_information(ring_source):
|
|
_, _, request, result, _, _ = ring_source
|
|
assert len(request.image_frames) == 127
|
|
assert not result.resolved and result.reason == 'image_motion_families_not_distinguishable'
|
|
assert all(h.training_accepted for h in result.hypotheses)
|
|
|
|
|
|
@pytest.fixture(scope='module')
|
|
def witnessed_ring(ring_source):
|
|
old_profile, owner, request, _, fits, rows = ring_source
|
|
profile = configure_preparation(configure_training(old_profile, 'staged_2_to_3'), 'occlusion_aware_witness_v1')
|
|
channels = tuple(sorted(owner.tag_feedback_channels['ring_pip']))
|
|
arc_stamps = {f.evidence.stamp_ns for f in request.image_frames}
|
|
endpoints = tuple(r for r in owner._endpoint_records['side'] if r['image_stamp_ns'] not in arc_stamps)
|
|
# This is a *synthetic* independent yaw arc, anchored to candidate 0 to
|
|
# exercise selection. It does not claim that real hardware chose branch 0.
|
|
model = fits[0][1]
|
|
pose = next(s.pose for s in select_image_motion_frame(model, request.image_frames[-1]).selections if s.role == 'ring_pip')
|
|
orientation = Rotation.from_quat(pose.quaternion_xyzw)
|
|
point = np.asarray(pose.translation_xyz_m)
|
|
pivot = point-np.array([0., .03, -.04])
|
|
donor = profile.motion.preparation_witnesses['ring_mcp_pitch']
|
|
target_spec = profile.motion.joint_zero_references['ring_mcp_pitch']
|
|
start_command, excursion = donor.command[4], donor.approach_commands[1][4]
|
|
segments = [(start_command, excursion), (excursion, start_command)]
|
|
start = request.image_frames[0].evidence.stamp_ns-20_000_000_000
|
|
sources = []
|
|
for path, (before, after) in enumerate(segments, 1):
|
|
values = list(np.linspace(before, after, 33))
|
|
if path == len(segments):
|
|
values += [after]*11
|
|
for value in values:
|
|
row = deepcopy(rows[0]); stamp = start+len(sources)*40_000_000
|
|
row.update(task_name=target_spec.task_key, zero_joints=['ring_mcp_pitch'],
|
|
preparation_witness_joint='ring_mcp_roll',
|
|
motion_version=100+path, reference_path_index=path, image_stamp_ns=stamp,
|
|
command_vector=list(donor.command), feedback_vector=list(donor.command))
|
|
row['command_vector'][4] = row['feedback_vector'][4] = float(value)
|
|
turn = Rotation.from_rotvec(np.array([0., 1., 0.])*np.deg2rad((value-113)*.3))
|
|
rotation, translation = turn*orientation, pivot+turn.apply(point-pivot)
|
|
points = rotation.apply(square_object_points(.0165))+translation
|
|
image = points@np.asarray(row['camera_matrix']).T
|
|
corners = image[:, :2]/image[:, 2:]
|
|
tag = row['tags']['ring_pip']
|
|
tag['corners_xy'] = corners.tolist()
|
|
tag['selected_pose'] = None
|
|
tag['reprojection_valid_candidates'] = [asdict(p) for p in solve_square_tag_ippe(corners,
|
|
tag_size_m=.0165, camera_matrix=row['camera_matrix'])]
|
|
row['tags'] = {r: row['tags'][r] for r in ('side_base', 'ring_pip')}
|
|
for item in row['tags'].values():
|
|
item['candidate_diagnostics']['observation_stamp_ns'] = stamp
|
|
sources.append(row)
|
|
source = WitnessInput('ring_mcp_pitch', 'ring_mcp_roll', tuple(sources), endpoints, channels)
|
|
bridge, record = build_preparation_witness(profile, source)
|
|
result = solver.resolve_image_motion(request.image_frames, request.relations, pose_bridges=(bridge,))
|
|
assert result.resolved, result.reason
|
|
request = replace(request, witness_sources=(source,), witness_profile=profile)
|
|
result = replace(result, preparation_witnesses=(record,))
|
|
_, report = initialization_artifacts(request, result)
|
|
# Journal round trip makes tuple/list normalization part of this test.
|
|
records = json.loads(json.dumps([*sources, *rows, report]))
|
|
return profile, source, bridge, record, request, result, records
|
|
|
|
|
|
def test_independent_witness_resolves_real_recipient_without_changing_limits(witnessed_ring):
|
|
profile, source, bridge, record, request, result, _ = witnessed_ring
|
|
assert result.resolved and len(result.hypotheses) == 2
|
|
assert sum(h.training_accepted for h in result.hypotheses) == 1
|
|
assert result.model.maximum_reprojection_error_px == 1.5 and result.model.reprojection_tie_px == .03
|
|
assert bridge.rotation_uncertainty_rad > 0 and bridge.translation_uncertainty_m > 0
|
|
assert set(record['source_model']['training_stamps_ns']).isdisjoint(record['source_model']['validation_stamps_ns'])
|
|
assert set(record['source_frame_hashes']).isdisjoint(record['endpoint_frame_hashes'])
|
|
assert set(record['endpoint_frame_hashes']).isdisjoint(dict(request.source_frame_hashes))
|
|
assert source.rows[-1]['image_stamp_ns'] < request.image_frames[0].evidence.stamp_ns
|
|
|
|
|
|
def test_witness_formal_evidence_replays_original_pixels_and_model(witnessed_ring):
|
|
from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel
|
|
profile, _, _, _, _, _, records = witnessed_ring
|
|
evidence = _initializations(records)
|
|
validate_witness_topology(profile, records, evidence, UrdfKinematicModel(SOURCE))
|
|
validate_preparation_witnesses(profile, records, evidence)
|
|
|
|
|
|
@pytest.mark.legacy
|
|
def test_legacy_witness_evidence_keeps_its_original_admission_contract(witnessed_ring):
|
|
profile, source, _, _, request, result, records = witnessed_ring
|
|
_, legacy = build_preparation_witness(profile, source, legacy=True)
|
|
assert legacy['version'] == 'preparation_witness_v1' and 'sample_admission' not in legacy
|
|
for hypothesis in legacy['source_hypotheses']:
|
|
hypothesis.pop('training_solver_attempts', None)
|
|
_, report = initialization_artifacts(request, replace(result, preparation_witnesses=(legacy,)))
|
|
records = json.loads(json.dumps([*records[:-1], report]))
|
|
evidence = _initializations(records)
|
|
validate_preparation_witnesses(profile, records, evidence)
|
|
|
|
|
|
@pytest.mark.parametrize('change', ['pixels', 'missing', 'policy', 'window'])
|
|
def test_witness_tampering_is_rejected_without_whole_hand_refitting(witnessed_ring, change):
|
|
_, _, _, _, _, _, original = witnessed_ring
|
|
records = deepcopy(original)
|
|
if change == 'pixels':
|
|
records[1]['tags']['ring_pip']['corners_xy'][0][0] += .1
|
|
elif change == 'missing':
|
|
del records[1]
|
|
elif change == 'policy':
|
|
records[-1]['image_model_selection_policy'] = 'training_squared_loss_holm_v1'
|
|
else:
|
|
payload = next(iter(records[-1]['evidence_payloads'].values()))
|
|
# Even coordinated hash updates must not hide an inconvenient raw frame.
|
|
record = records[-1]['preparation_witnesses'][0]
|
|
stamp = sorted(record['source_frame_hashes'], key=int)[1]
|
|
del record['source_frame_hashes'][stamp]
|
|
payload['preparation_witnesses'] = deepcopy(records[-1]['preparation_witnesses'])
|
|
role = payload['tag_role']; records[-1]['evidence_ids'][role] = source_frame_sha256(payload)
|
|
with pytest.raises(ValueError):
|
|
_initializations(records)
|
|
|
|
|
|
@pytest.mark.parametrize('change', ['command', 'feedback', 'formal', 'endpoint', 'missing'])
|
|
def test_invalid_witness_never_triggers_silent_fallback(witnessed_ring, change):
|
|
profile, source, _, _, request, _, _ = witnessed_ring
|
|
rows = deepcopy(source.rows)
|
|
endpoints = deepcopy(source.endpoint_rows)
|
|
if change == 'command':
|
|
rows[3]['command_vector'][9] += 1
|
|
elif change == 'feedback':
|
|
rows[3]['feedback_vector'][9] += 3
|
|
elif change == 'formal':
|
|
rows[3]['cycle'] = 3
|
|
elif change == 'endpoint':
|
|
endpoints[0]['feedback_vector'][4] += 11
|
|
else:
|
|
rows = ()
|
|
changed = replace(source, rows=tuple(rows), endpoint_rows=tuple(endpoints))
|
|
result = solve_initialization(replace(request, witness_sources=(changed,)))
|
|
assert not result.resolved and result.reason.startswith('preparation_witness_')
|
|
|
|
|
|
def test_plan_changes_are_opt_in_and_preserve_existing_tag_and_motion_contracts():
|
|
from linkerhand_calibration.profiles.validator import validate_executable_profile
|
|
old = configure_training(load_bundled_hand_profile('o30_right_18'), 'staged_2_to_3')
|
|
new = configure_preparation(old, 'occlusion_aware_witness_v1')
|
|
assert new.vision == old.vision and new.quality == old.quality
|
|
assert {t.key: t for t in new.motion.tasks} == {t.key: t for t in old.motion.tasks}
|
|
assert new.motion.joint_zero_references == old.motion.joint_zero_references
|
|
assert CapturePlan.from_profile(new).sha256 != CapturePlan.from_profile(old).sha256
|
|
assert 'preparation_witnesses' not in CapturePlan.from_profile(old).as_dict()['motion_contract']['motion']
|
|
validate_executable_profile(new, SOURCE)
|
|
|
|
|
|
def test_live_collector_and_request_use_same_witness_as_offline(witnessed_ring, ring_source):
|
|
profile, source, bridge, _, _, expected, _ = witnessed_ring
|
|
_, _, old_request, _, _, rows = ring_source
|
|
owner = BranchInitialization(profile, source_urdf=SOURCE)
|
|
# The donor's optional side view never becomes a formal roll measurement.
|
|
for row in source.rows:
|
|
owner.observe(row)
|
|
assert owner._frames == {}
|
|
target = profile.motion.joint_zero_references[source.joint]
|
|
motion = MotionCommand('zero_approach', target.command, 200., task_key=target.task_key,
|
|
reference_joints=(source.joint,))
|
|
owner.begin(motion, session_epoch=1, motion_version=old_request.motion_version)
|
|
for row in rows:
|
|
owner.observe(row)
|
|
request = owner.request('side', replace(motion, phase='joint_zero', zero_joints=(source.joint,)),
|
|
session_epoch=1, motion_version=old_request.motion_version+1)
|
|
assert len(request.witness_sources) == 1
|
|
result = solve_initialization(request)
|
|
# The live budget now retains more recipient frames. The independent
|
|
# witness stays identical; reproduce the recipient with that exact budget.
|
|
reproduced = solver.resolve_image_motion(request.image_frames, request.relations,
|
|
pose_bridges=(bridge,), parameters=request.image_parameters)
|
|
assert result.resolved and result.model == reproduced.model
|
|
assert result.preparation_witnesses == expected.preparation_witnesses
|
|
model, report = owner.accept(request, result)
|
|
assert model is not None and report['preparation_witnesses']
|
|
|
|
|
|
def test_collector_is_bounded_and_epoch_reset_does_not_relabel_history(witnessed_ring):
|
|
from linkerhand_calibration.runtime.preparation_witness import MAXIMUM_WINDOW_FRAMES
|
|
profile, source, *_ = witnessed_ring
|
|
owner = BranchInitialization(profile, source_urdf=SOURCE)
|
|
row = source.rows[0]
|
|
for _ in range(MAXIMUM_WINDOW_FRAMES+20):
|
|
owner.witness_capture.observe(row)
|
|
assert len(next(iter(owner.witness_capture.windows.values()))) == MAXIMUM_WINDOW_FRAMES+1
|
|
owner.witness_capture.observe(dict(row, session_epoch=2))
|
|
assert list(owner.witness_capture.windows) == [(2, row['task_name'], row['view'])]
|
|
assert len(next(iter(owner.witness_capture.windows.values()))) == 1
|
|
|
|
|
|
def test_missing_optional_tag_does_not_block_donor_capture(witnessed_ring):
|
|
from linkerhand_calibration.runtime.preparation_witness import witness_roles
|
|
profile, source, *_ = witnessed_ring
|
|
owner = BranchInitialization(profile, source_urdf=SOURCE)
|
|
motion = MotionCommand('zero_approach', profile.motion.joint_zero_references[source.donor].command,
|
|
200., task_key='fingers_mcp_roll_front', reference_joints=(source.donor,))
|
|
motion = replace(motion, reference_joints=('ring_mcp_pitch',), preparation_witness_joint='ring_mcp_roll')
|
|
assert witness_roles(profile, motion) == {'side_base', 'ring_pip', 'front_base', 'ring_mcp_roll'}
|
|
row = deepcopy(source.rows[0]); row['tags'].pop('ring_pip')
|
|
owner.observe(row)
|
|
assert not owner._invalid_reason
|
|
assert owner._frames == {}
|
|
|
|
|
|
def test_reference_change_and_unobservability_cannot_trigger_same_arc_retry():
|
|
from linkerhand_calibration.runtime.zero_recovery import failure_category, permits_recovery, FailureCategory
|
|
assert failure_category('preparation_witness_unobservable:ambiguous') == FailureCategory.UNOBSERVABLE
|
|
assert failure_category('preparation_witness_reference_feedback_changed') == FailureCategory.REFERENCE_CHANGED
|
|
assert not permits_recovery(('preparation_witness_unobservable:ambiguous',), 0)
|
|
|
|
|
|
@pytest.mark.parametrize('change', ['holdout', 'installation'])
|
|
def test_new_images_can_veto_but_cannot_replace_witness(witnessed_ring, change):
|
|
profile, source, _, record, request, _, _ = witnessed_ring
|
|
rows, endpoints = deepcopy(source.rows), deepcopy(source.endpoint_rows)
|
|
if change == 'holdout':
|
|
stamp = record['source_model']['validation_stamps_ns'][4]
|
|
affected = [r for r in rows if r['image_stamp_ns'] == stamp]
|
|
else:
|
|
affected = endpoints
|
|
for row in affected:
|
|
row['tags']['ring_pip']['corners_xy'] = [[x+10, y+8] for x, y in row['tags']['ring_pip']['corners_xy']]
|
|
result = solve_initialization(replace(request, witness_sources=(replace(source,
|
|
rows=tuple(rows), endpoint_rows=tuple(endpoints)),)))
|
|
assert not result.resolved and result.reason.startswith('preparation_witness_')
|
|
assert result.model is None
|
|
|
|
|
|
def test_required_witness_cannot_be_removed_by_updating_hashes(witnessed_ring):
|
|
profile, _, _, _, _, _, original = witnessed_ring
|
|
records = deepcopy(original)
|
|
report = records[-1]
|
|
del report['preparation_witnesses']
|
|
report['image_model_selection_policy'] = 'training_squared_loss_holm_v1'
|
|
for role, payload in report['evidence_payloads'].items():
|
|
del payload['preparation_witnesses']
|
|
report['evidence_ids'][role] = source_frame_sha256(payload)
|
|
evidence = _initializations(records)
|
|
with pytest.raises(ValueError, match='required_evidence_missing'):
|
|
validate_preparation_witnesses(profile, records, evidence)
|
|
|
|
|
|
def test_witness_configuration_round_trip_and_topology_are_explicit(tmp_path):
|
|
from linkerhand_calibration.profiles.export import write_hand_profile
|
|
from linkerhand_calibration.profiles import load_hand_profile
|
|
from linkerhand_calibration.profiles.preparation import validate_preparation_witnesses
|
|
from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel
|
|
profile = configure_preparation(configure_training(load_bundled_hand_profile('o30_right_18'),
|
|
'staged_2_to_3'), 'occlusion_aware_witness_v1')
|
|
path = tmp_path/'witness.yaml'; write_hand_profile(profile, path)
|
|
restored = load_hand_profile(path)
|
|
assert CapturePlan.from_profile(restored) == CapturePlan.from_profile(profile)
|
|
invalid = replace(profile, motion=replace(profile.motion,
|
|
preparation_witnesses={'ring_mcp_pitch': replace(profile.motion.preparation_witnesses['ring_mcp_pitch'], joint='pinky_mcp_roll')}))
|
|
with pytest.raises(ValueError, match='posture_not_shared'):
|
|
validate_preparation_witnesses(invalid, UrdfKinematicModel(SOURCE))
|
|
|
|
|
|
@pytest.mark.parametrize('joint', ['pinky_mcp_pitch', 'ring_mcp_pitch', 'middle_mcp_pitch', 'index_mcp_pitch'])
|
|
def test_occluded_fingers_get_one_local_excitation_after_clearance(joint):
|
|
from linkerhand_calibration.runtime.execution import SessionExecution
|
|
from linkerhand_calibration.runtime.session import CalibrationPhase as Phase
|
|
from linkerhand_calibration.core.fitting.motion_fit import channel_for_joint
|
|
profile = configure_preparation(configure_training(load_bundled_hand_profile('o30_right_18'),
|
|
'staged_2_to_3'), 'occlusion_aware_witness_v1')
|
|
driver = SessionExecution(profile)
|
|
task_key = profile.motion.joint_zero_references[joint].task_key
|
|
driver.session._unit_index = next(i for i, unit in enumerate(driver.session._units) if unit.task_key == task_key)
|
|
driver.session.phase = Phase.PREPARE
|
|
effects = driver._make_effects(profile.command.baseline_values)
|
|
witness = profile.motion.preparation_witnesses[joint]
|
|
excitation = [m for m in effects if m.preparation_witness_joint]
|
|
assert len(excitation) == 3 and [m.reference_path_index for m in excitation] == [0, 1, 2]
|
|
first = effects.index(excitation[0])
|
|
assert first > 0 and effects[first-1].phase == 'clearance'
|
|
assert effects[first-1].target == witness.command == excitation[-1].target
|
|
channel = channel_for_joint(profile, witness.joint)
|
|
for motion in excitation:
|
|
assert not motion.recording and motion.command_index == channel
|
|
assert all(v == int(v) for v in motion.target)
|
|
assert all(v == witness.command[i] for i, v in enumerate(motion.target) if i != channel)
|
|
normal = next(i for i, m in enumerate(effects) if m.phase == 'zero_approach' and not m.preparation_witness_joint)
|
|
assert effects.index(excitation[-1]) < normal
|
|
# Revisit and independent validation do not re-excite or refit a frozen zero.
|
|
driver = SessionExecution(profile)
|
|
driver.zero_references[joint] = object()
|
|
driver.session._unit_index = next(i for i, unit in enumerate(driver.session._units)
|
|
if unit.task_key == task_key and unit.cycle == 1)
|
|
driver.session.phase = Phase.PREPARE
|
|
assert not any(m.preparation_witness_joint for m in driver._make_effects(witness.command))
|