Files
linkerhand_retarget_ros2/src/linkerhand_calibration/test/test_preparation_witness.py
T
2026-09-20 18:06:44 +08:00

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))