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