diff --git a/benchmarks/piper-retention-v02/README.md b/benchmarks/piper-retention-v02/README.md new file mode 100644 index 0000000..22ff4cf --- /dev/null +++ b/benchmarks/piper-retention-v02/README.md @@ -0,0 +1,67 @@ +# Checking grip retention without changing the squeeze · PiPER v0.2 + +This frozen CPU study compares three authored ways of requesting a PiPER grasp hold across 12 fresh synthetic scenes: measured-joint, issued-joint and Cartesian. Each variant is scheduled once per scene (36 attempts). The primary outcome is whether the object remains continuously held for five simulated seconds after the policy's admission decision, checked against false confirmation. A second outcome also requires the TCP to stay within 4 mm and 0.05 rad of its admission pose for every step of that five-second pause; it applies the same threshold to every variant. Every valid failure and invalid attempt remains in the denominator. + +This is not a learning study, full transport or placement task, physical-sensor test, hardware qualification or reliability estimate for real objects. The controllers and simulator use authored privileged state. A five-second simulator hold does not establish safe physical retention. + +## Reproduce and inspect + +Use Python 3.12 and a separate source checkout. This experiment is a source preview; the existing dev3 wheel does not include it. No GPU, language model or API key is required. Fetching dependencies and the pinned PiPER assets needs a network connection; the simulation then runs locally. Disk use is about 180 MiB for this recorded study, plus dependencies/assets; hardware minimums beyond this CPU run have not been qualified. + +```sh +git clone --branch piper-retention-v0.2-preview https://github.com/Dvidia-Inference/dvidia-training.git +cd dvidia-training +python3.12 -m venv .venv +. .venv/bin/activate +python -m pip install '.[arm]' +dvidia-robot-sim fetch piper --output runs/robot-profile-upstream/assets +``` + +Use the absolute path to that asset directory below, and a fresh study output directory: + +```sh +python -m dvidia_training.piper_retention_benchmark prepare \ + --assets /absolute/path/to/robot-profile-upstream/assets \ + --output runs/piper-retention-v02 +python -m dvidia_training.piper_retention_benchmark run \ + --assets /absolute/path/to/robot-profile-upstream/assets \ + --output runs/piper-retention-v02 +python -m dvidia_training.piper_retention_benchmark inspect \ + --output runs/piper-retention-v02 > runs/piper-retention-v02/inspection.json +python benchmarks/piper-retention-v02/render_report.py \ + runs/piper-retention-v02/report.json +python benchmarks/piper-retention-v02/independent_audit.py \ + --study runs/piper-retention-v02 --repo . \ + --output runs/piper-retention-v02-audit/audit.json +``` + +The renderer uses only Python's standard library. It reports all 36 scheduled outcomes, including failures, and marks unreported fields as unknown rather than inferring them. + +## Evidence package + +After the run and independent audit are complete, package the full run directory and audit directory without rerunning physics: + +```sh +python benchmarks/piper-retention-v02/package_evidence.py \ + --study runs/piper-retention-v02 \ + --audit runs/piper-retention-v02-audit \ + --output runs/piper-retention-v02-release +``` + +The package is deterministic and retains every file from both inputs with a checked inventory. It produces the six-file dataset payload (`README.md`, `LICENSE`, ZIP archive, `report.json`, `protocol.json`, `inspection.json`) plus local package receipts. The package command refuses missing, mismatched or failed audit evidence. It does not publish. + +## Frozen results + +The source protocol was frozen at `6ab5e34ecbdd39ba58f4bb143135d0bfd0db3202`, before the 36 fresh attempts. All completed valid recordings. Each variant admitted the same four high-friction scenes, with zero false initial admissions; all eight lower-friction scenes lacked admission. + +| Hold | Native grip for 5 s | Grip + stationary TCP for 5 s | Controller qualified at 5 s | Final controller + native | +|---|---:|---:|---:|---:| +| Measured joint | 2/12 | 2/12 | 2/12 | 0/12 | +| Issued joint | 4/12 | 0/12 | 4/12 | 4/12 | +| Cartesian | 4/12 | 0/12 | 0/12 | 0/12 | + +Issued targets retained the four initially qualified grips, but TCP displacement reached 4.68–4.80 mm, beyond the common 4 mm bound. Cartesian refused all four admitted holds after 1.62–1.74 seconds; its native grip score includes the measured-position fallback. It is not a successful active stationary hold. No controller is promoted. + +The complete archive also retains one earlier selected Cartesian development smoke under `development-smoke/`, excluded from these 36 attempts. It refused after 1.60 seconds beyond the same pose bound. No trial or parameter was retried or tuned after observing these results. + +[Research paper](https://research.dvidia.org/papers/piper-retention-v02/) · [GitHub evidence release](https://github.com/Dvidia-Inference/dvidia-training/releases/tag/piper-retention-v0.2-preview) · [Hugging Face dataset](https://huggingface.co/datasets/Dvidia/piper-retention-v02) diff --git a/benchmarks/piper-retention-v02/independent_audit.py b/benchmarks/piper-retention-v02/independent_audit.py new file mode 100644 index 0000000..b3ceff2 --- /dev/null +++ b/benchmarks/piper-retention-v02/independent_audit.py @@ -0,0 +1,636 @@ +"""Independent readback of frozen retention evidence. No simulator/runtime imports. + +All predicate/streak computations below are independent of probe_evidence.py. +This checks recorded consistency, not physics authenticity or physical sensors. +The raw study and source files are only read and rehashed; no trial is rerun. +""" +from __future__ import annotations + +if not __debug__: + raise RuntimeError("Independent retention audit refuses optimized Python.") + +import argparse +from collections import Counter +import gzip +from hashlib import sha256 +import json +from pathlib import Path +import math + +import numpy as np + +MAX_FILE = 32*1024*1024 +VARIANTS = ('measured_joint', 'issued_joint', 'cartesian') +FIELDS = {'t_seconds': 1, 'native_tick': 1, 'object_position_m': 3, + 'object_quaternion_wxyz': 4, 'object_linear_velocity_m_s': 3, + 'object_angular_velocity_rad_s': 3, 'tcp_position_m': 3, + 'tcp_quaternion_wxyz': 4, 'object_minus_tcp_local_m': 3, + 'grasp_contact_counts': 2, 'pad_normal_forces_n': 2, + 'table_object_contact_count': 1, 'unexpected_contact_count': 1, + 'gripper_joint_position_m': 2, 'joint_position_rad': 6, + 'arm_actuator_controls_rad': 6, 'requested_jaw_target_m': 1, + 'normal_rate_limited_jaw_target_m': 1, 'applied_jaw_target_m': 1, + 'pulse_active': 1, 'pulse_minimum_opening_m': 1, 'pulse_closing_tail': 1} +ADMISSIONS = ('evidence_qualified', 'timer_admitted') +HOLD_EVENTS = (*ADMISSIONS, 'hold_invalidated', 'abort', 'timed_out_hold') +BOUND_REFUSALS = frozenset({'initial_bias_exhausted', 'native_joint_margin_exhausted', + 'tcp_position_error_exhausted', 'tcp_orientation_error_exhausted', + 'joint_correction_exhausted', 'pose_solver_refused', + 'missing_prior_issued_target'}) +CORRECTION_LIMIT_RAD = .03 +CORRECTION_RATE_RAD_S = .10 +INITIAL_BIAS_LIMIT_RAD = .08 +CORRECTION_DT_S = .02 +TCP_POSITION_LIMIT_M = .004 +TCP_ORIENTATION_LIMIT_RAD = .05 + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode() + + +def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError('Duplicate JSON field') + result[key] = value + return result + + +def bad(token): + raise ValueError('Nonfinite JSON '+token) + + +def identity(path): + size, digest = 0, sha256() + assert not path.is_symlink() and path.is_file() + with path.open('rb') as stream: + while chunk := stream.read(65536): + size += len(chunk) + assert size <= MAX_FILE + digest.update(chunk) + return {'bytes': size, 'sha256': digest.hexdigest()} + + +def read(path): + identity(path) + result = json.loads(path.read_bytes(), object_pairs_hook=unique, parse_constant=bad) + canonical(result) + return result + + +def episode(path): + identity(path) + with gzip.open(path, 'rb') as stream: + raw = stream.read(MAX_FILE+1) + assert len(raw) <= MAX_FILE + result = json.loads(raw, object_pairs_hook=unique, parse_constant=bad) + canonical(result) + return result + + +def row_at(seconds, times): + index = round(seconds/.001)-1 + assert 0 <= index < len(times) and abs(times[index]-seconds) < 1e-8 + return index + + +def rotation_local(arrays): + q = arrays['tcp_quaternion_wxyz'] + assert np.max(np.abs(np.linalg.norm(q, axis=1)-1)) < 1e-8 + # Independently construct the transpose of the quaternion rotation matrix. + w, x, y, z = q.T + rotation = np.empty((len(q), 3, 3)) + rotation[:, 0, 0] = 1-2*(y*y+z*z) + rotation[:, 0, 1] = 2*(x*y-w*z) + rotation[:, 0, 2] = 2*(x*z+w*y) + rotation[:, 1, 0] = 2*(x*y+w*z) + rotation[:, 1, 1] = 1-2*(x*x+z*z) + rotation[:, 1, 2] = 2*(y*z-w*x) + rotation[:, 2, 0] = 2*(x*z-w*y) + rotation[:, 2, 1] = 2*(y*z+w*x) + rotation[:, 2, 2] = 1-2*(x*x+y*y) + vector = arrays['object_position_m']-arrays['tcp_position_m'] + local = np.einsum('nji,nj->ni', rotation, vector) + assert np.max(np.abs(local-arrays['object_minus_tcp_local_m'])) < 1e-9 + return arrays['object_minus_tcp_local_m'] + + +def native_predicate(arrays, probe): + times = arrays['t_seconds'] + origin = row_at(probe['start_time_s'], times) + assert np.linalg.norm(arrays['object_position_m'][origin]-probe['object_origin_m']) < 1e-9 + assert np.linalg.norm(arrays['object_minus_tcp_local_m'][origin]-probe['local_offset_origin_m']) < 1e-9 + rise = arrays['object_position_m'][:, 2]-probe['object_origin_m'][2] + drift = np.linalg.norm(arrays['object_minus_tcp_local_m']-probe['local_offset_origin_m'], axis=1) + components = { + 'bilateral_contact': np.all(arrays['grasp_contact_counts'] > 0, axis=1), + 'bilateral_force': np.all(arrays['pad_normal_forces_n'] > .01, axis=1), + 'force_ceiling': np.all(arrays['pad_normal_forces_n'] <= 10., axis=1), + 'unsupported': arrays['table_object_contact_count'] == 0, + 'no_unexpected_contact': arrays['unexpected_contact_count'] == 0, + 'minimum_rise': rise+1e-12 >= .006, + 'maximum_local_drift': drift <= .003+1e-12, + } + condition = np.arange(len(times)) > origin + for component in components.values(): + condition &= component + streak = np.empty(len(times), dtype=np.int64) + previous = 0 + for index, good in enumerate(condition): + previous = previous+1 if good else 0 + streak[index] = previous + return condition, streak, streak >= 100, components, rise, drift + + +def reasons_at(index, components): + return [name for name, values in components.items() if not values[index]] + + +def motion(arrays, seconds): + start = row_at(seconds, arrays['t_seconds']) + end = min(start+100, len(arrays['t_seconds'])-1) + tcp = arrays['tcp_position_m'][start:end+1] + obj = arrays['object_position_m'][start:end+1] + return {'requested_interval_s': .1, 'observed_interval_s': (end-start)*.001, + 'complete_100ms': end-start == 100, + 'tcp_endpoint_displacement_m': float(np.linalg.norm(tcp[-1]-tcp[0])), + 'tcp_path_length_m': float(np.sum(np.linalg.norm(np.diff(tcp, axis=0), axis=1))), + 'tcp_maximum_excursion_m': float(np.max(np.linalg.norm(tcp-tcp[0], axis=1))), + 'object_endpoint_displacement_m': float(np.linalg.norm(obj[-1]-obj[0])), + 'scope': 'Native motion after a position-hold request, not measured stopping distance or physical response.'} + + +def treatment_boundary(policy): + """First admission or eligible pre-admission hold-bound refusal.""" + events = policy.get('events') if type(policy) is dict else None + if type(events) is not list: + raise ValueError('Policy event tape is missing.') + admissions = [event for event in events if type(event) is dict and event.get('type') == 'evidence_qualified'] + if len(admissions) > 1: + raise ValueError('Evidence policy admits more than once.') + admission_time = admissions[0]['t_seconds'] if admissions else None + candidates = [] + if admissions: + candidates.append({'time_s': admission_time, 'reason': 'admission', + 'detail': admissions[0].get('reason')}) + for event in events: + if (type(event) is dict and event.get('type') == 'hold_invalidated' + and event.get('reason') in BOUND_REFUSALS): + timestamp = event.get('t_seconds') + if type(timestamp) not in (int, float) or not math.isfinite(timestamp): + raise ValueError('Bound refusal has a malformed timestamp.') + if admission_time is None or timestamp <= admission_time+1e-8: + candidates.append({'time_s': float(timestamp), 'reason': 'pre_admission_bound_refusal', + 'detail': event['reason']}) + if not candidates: + return None + return min(candidates, key=lambda row: row['time_s']) + + +def common_cutoff(boundaries): + """Choose one scene cutoff for all three variants, including refusals.""" + if set(boundaries) != {'measured_joint', 'issued_joint', 'cartesian'}: + raise ValueError('Common cutoff requires all three variant records.') + candidates = [{'variant': variant, **row} for variant, row in boundaries.items() if row is not None] + candidates.sort(key=lambda row: row['variant']) + timestamp = min((row['time_s'] for row in candidates), default=None) + reasons = sorted({row['reason'] for row in candidates + if timestamp is not None and abs(row['time_s']-timestamp) <= 1e-8}) + return {'time_s': timestamp, + 'reason': '+'.join(reasons) if reasons else 'full_run_no_treatment', + 'treatment_boundaries': candidates} + + +def prefix_identity(arrays, actions, cutoff): + """Hash native rows through the cutoff and only prior commands.""" + if len(arrays['t_seconds']) != 20000 or len(actions) != 1000: + return {'verified': False, 'reason': 'incomplete_native_or_command_prefix'} + if cutoff['time_s'] is None: + native_rows = 20000 + command_rows = list(actions) + else: + native_rows = row_at(cutoff['time_s'], arrays['t_seconds'])+1 + command_rows = [action for action in actions if action['t_seconds'] < cutoff['time_s']-1e-8] + prefix_hash = sha256() + for name in sorted(arrays): + value = np.ascontiguousarray(arrays[name][:native_rows]) + prefix_hash.update(json.dumps({'name': name, 'shape': list(value.shape), 'dtype': str(value.dtype)}, + sort_keys=True, indent=2, allow_nan=False).encode()+b'\n') + prefix_hash.update(value.tobytes()) + return {'verified': True, 'cutoff_time_s': cutoff['time_s'], 'cutoff_reason': cutoff['reason'], + 'native_rows': native_rows, 'native_sha256': prefix_hash.hexdigest(), + 'commands': len(command_rows), 'commands_sha256': sha256( + json.dumps(command_rows, sort_keys=True, indent=2, allow_nan=False).encode()+b'\n').hexdigest()} + + +def stationarity(arrays, start, end, complete, primary_success): + """Recompute 5s native TCP stationarity from the admission pose.""" + positions = arrays['tcp_position_m'][start:end+1] + quaternions = arrays['tcp_quaternion_wxyz'][start:end+1] + displacement = np.linalg.norm(positions-positions[0], axis=1) + dots = np.abs(quaternions @ quaternions[0]) + angles = 2*np.arccos(np.clip(dots, 0., 1.)) + max_position, max_angle = float(np.max(displacement)), float(np.max(angles)) + within = max_position <= TCP_POSITION_LIMIT_M+1e-12 and max_angle <= TCP_ORIENTATION_LIMIT_RAD+1e-12 + return {'maximum_tcp_position_excursion_m': max_position, + 'maximum_tcp_orientation_excursion_rad': max_angle, + 'terminal_tcp_position_delta_m': (positions[-1]-positions[0]).tolist(), + 'terminal_tcp_orientation_delta_rad': float(angles[-1]), + 'tcp_pose_within_bounds': bool(complete and within), + 'grip_retention_and_stationarity': bool(complete and primary_success and within)} + + +def paired_prefix(scene, attempts): + """Return an explicit unknown for incomplete triplets, never an implicit pass.""" + variants = ('measured_joint', 'issued_joint', 'cartesian') + if set(attempts) != set(variants): + raise ValueError('Scene must retain all three scheduled variants.') + if not all(attempts[variant].get('valid') is True for variant in variants): + entries = [{'variant': variant, 'verified': False, + 'reason': attempts[variant].get('reason', 'common_triplet_incomplete') + if attempts[variant].get('valid') is not True + else 'common_triplet_incomplete', + 'native_rows_available': attempts[variant].get('native_rows_available')} + for variant in variants] + return {'scene': scene, 'verified': False, + 'reason': 'invalid_attempt_prevents_complete_triplet', + 'variants': entries, 'treatment_boundaries': []} + cutoff = common_cutoff({variant: attempts[variant]['boundary'] for variant in variants}) + prefixes = {variant: prefix_identity(attempts[variant]['arrays'], attempts[variant]['actions'], cutoff) + for variant in variants} + if any(record.get('verified') is not True for record in prefixes.values()): + return {'scene': scene, 'verified': False, + 'reason': 'incomplete_common_prefix_evidence', 'variants': prefixes, + 'treatment_boundaries': cutoff['treatment_boundaries']} + identities = {(record['cutoff_time_s'], record['cutoff_reason'], record['native_rows'], + record['native_sha256'], record['commands'], record['commands_sha256']) + for record in prefixes.values()} + matched = len(identities) == 1 + if not matched: + raise ValueError('Common acquisition prefixes differ: '+scene) + expected = next(iter(prefixes.values())) + return {'scene': scene, 'verified': True, **expected, + 'treatment_boundaries': cutoff['treatment_boundaries'], + 'variants': [{'variant': variant, **prefixes[variant]} for variant in variants]} + + +def check_stationarity_reported(actual, reported, complete): + assert reported['complete_5s'] is bool(complete) + for field in ('maximum_tcp_position_excursion_m', 'maximum_tcp_orientation_excursion_rad'): + assert abs(reported[field]-actual[field]) <= 1e-10, field + assert np.allclose(reported['terminal_tcp_position_delta_m'], actual['terminal_tcp_position_delta_m'], + atol=1e-10, rtol=0.) + assert abs(reported['terminal_tcp_orientation_delta_rad']- + actual['terminal_tcp_orientation_delta_rad']) <= 1e-10 + assert reported['tcp_pose_within_bounds'] is (complete and actual['tcp_pose_within_bounds']) + assert reported['grip_retention_and_stationarity'] is ( + complete and actual['grip_retention_and_stationarity']) + + +def corrections_audit(policy, actions, boundary, variant): + """Independently check bounded hold diagnostics and first issued command.""" + retention = policy.get('retention') or {} + records = retention.get('hold_records') + reference = retention.get('hold_reference') + if type(records) is not list: + raise ValueError('Hold diagnostics are missing.') + assert policy.get('variant') == variant + if reference is not None: + assert type(reference) is dict + assert reference['frozen_jaw_target_m'] == policy['frozen_jaw_target_m'] + issued = np.asarray(reference['issued_joint_targets_rad'], dtype=np.float64) + assert issued.shape == (6,) and np.isfinite(issued).all() + assert boundary is not None + if boundary is not None and boundary['reason'] in ('admission', 'pre_admission_bound_refusal'): + assert reference is not None + if reference is not None: + first = records[0] + assert first['command_issued'] is True + assert abs(first['t_seconds']-boundary['time_s']) <= 1e-8 + assert abs(reference['t_seconds']-boundary['time_s']) <= 1e-8 + action = next(row for row in actions if abs(row['t_seconds']-boundary['time_s']) <= 1e-8) + if boundary['reason'] == 'admission': + measured = np.asarray(reference['measured_joint_position_rad'], dtype=np.float64) + assert issued.shape == measured.shape == (6,) + expected_first = measured if variant == 'measured_joint' else issued + assert np.array_equal(np.asarray(first['joint_targets_rad'], dtype=np.float64), expected_first) + else: + # A refused bound emits the terminal measured-joint/frozen-jaw hold. + assert first['refused_bounds'] == boundary['detail'] + measured = reference['measured_joint_position_rad'] + assert first['joint_targets_rad'] == measured + assert action['joint_targets_rad'] == measured + assert first['joint_targets_rad'] == action['joint_targets_rad'] + assert first['frozen_jaw_target_m'] == action['gripper_position_m'] + if reference is not None: + assert all(row['frozen_jaw_target_m'] == reference['frozen_jaw_target_m'] for row in records) + for record in records: + if not record['command_issued']: + assert record.get('final_observation') is True + continue + action = next(row for row in actions if abs(row['t_seconds']-record['t_seconds']) <= 1e-8) + assert record['joint_targets_rad'] == action['joint_targets_rad'] + assert record['frozen_jaw_target_m'] == action['gripper_position_m'] + records_by_time = {row['t_seconds']: row for row in records if row['command_issued']} + assert len(records_by_time) == sum(row['command_issued'] for row in records) + for action in actions: + if action['t_seconds'] in records_by_time: + assert records_by_time[action['t_seconds']]['joint_targets_rad'] == action['joint_targets_rad'] + assert records_by_time[action['t_seconds']]['frozen_jaw_target_m'] == action['gripper_position_m'] + + if variant == 'cartesian' and reference is not None: + raw_initial = np.asarray(reference['raw_pose_joint_targets_rad'], dtype=np.float64) + fixed_bias = np.asarray(reference['fixed_bias_rad'], dtype=np.float64) + assert raw_initial.shape == fixed_bias.shape == (6,) + expected_bias = issued-raw_initial + assert np.allclose(fixed_bias, expected_bias, atol=1e-12, rtol=0.) + initial_refused = first['refused_bounds'] == 'initial_bias_exhausted' + if not initial_refused: + assert np.max(np.abs(fixed_bias)) <= INITIAL_BIAS_LIMIT_RAD+1e-12 + previous = np.zeros(6, dtype=np.float64) + checked_rate_steps = 0 + for record in records: + assert record['fixed_bias_rad'] == reference['fixed_bias_rad'] + if record.get('refused_bounds') is not None: + assert any(event.get('type') == 'hold_invalidated' + and event.get('reason') == record['refused_bounds'] + and abs(event.get('t_seconds', -1)-record['t_seconds']) <= 1e-8 + for event in policy['events']) + correction_value = record.get('correction_rad') + if correction_value is None: + continue + correction = np.asarray(correction_value, dtype=np.float64) + assert correction.shape == (6,) and np.isfinite(correction).all() + assert np.max(np.abs(correction)) <= CORRECTION_LIMIT_RAD+1e-12 + desired_value = record.get('desired_correction_rad') + if desired_value is not None: + desired = np.asarray(desired_value, dtype=np.float64) + assert desired.shape == (6,) and np.isfinite(desired).all() + raw_current_value = record.get('raw_pose_joint_targets_rad') + assert raw_current_value is not None + raw_current = np.asarray(raw_current_value, dtype=np.float64) + assert raw_current.shape == (6,) and np.isfinite(raw_current).all() + assert np.allclose(desired, raw_current-raw_initial, atol=1e-12, rtol=0.) + failure = record.get('refused_bounds') == 'joint_correction_exhausted' + if not failure: + assert np.max(np.abs(desired)) <= CORRECTION_LIMIT_RAD+1e-12 + axes = [index for index, (wanted, old) in enumerate(zip(desired, previous)) + if abs(wanted-old) > CORRECTION_RATE_RAD_S*CORRECTION_DT_S+1e-12] + assert record['correction_rate_limited_axes'] == axes + if record['command_issued']: + assert np.max(np.abs(correction-previous)) <= CORRECTION_RATE_RAD_S*CORRECTION_DT_S+1e-12 + if record.get('refused_bounds') is None: + assert np.allclose(record['joint_targets_rad'], issued+correction, atol=1e-12, rtol=0.) + previous = correction + checked_rate_steps += 1 + elif reference is not None: + assert reference.get('fixed_bias_rad') is None + + flags, refusals, axis_count, active, desired = {}, {}, 0, [], [] + for record in records: + for key, value in record.items(): + if type(value) is bool and ('saturat' in key or 'limit' in key or 'refus' in key): + flags[key] = flags.get(key, 0)+int(value) + if record.get('refused_bounds') is not None: + key = record['refused_bounds']; refusals[key] = refusals.get(key, 0)+1 + axis_count += len(record.get('correction_rate_limited_axes') or []) + for name, target in (('correction_rad', active), ('desired_correction_rad', desired)): + if record.get(name) is not None: + values = np.asarray(record[name], dtype=np.float64) + assert values.shape == (6,) and np.isfinite(values).all() + target.extend(np.abs(values).tolist()) + computed = {'hold_record_count': len(records), 'flag_counts': flags, + 'bound_refusal_counts': refusals, 'rate_limited_axis_occurrences': axis_count, + 'maximum_abs_active_correction_rad': max(active) if active else None, + 'maximum_abs_desired_correction_rad': max(desired) if desired else None, + 'input_refusals': retention.get('input_refusals', [])} + return computed + + +def self_tests(): + count = 240 + arrays = {name: np.zeros((count,) if width == 1 else (count, width), dtype=np.float64) + for name, width in FIELDS.items()} + arrays['t_seconds'] = np.arange(1, count+1)*.001 + arrays['object_position_m'][:] = [.3, 0, .08] + arrays['object_minus_tcp_local_m'][:] = [0, 0, -.1] + arrays['object_position_m'][20:, 2] += .006 + arrays['grasp_contact_counts'][:] = 1 + arrays['pad_normal_forces_n'][:] = .02 + probe = {'start_time_s': .02, 'object_origin_m': [.3, 0, .08], + 'local_offset_origin_m': [0, 0, -.1]} + _, streak, qualified, _, _, _ = native_predicate(arrays, probe) + assert streak[118] == 99 and not qualified[118] + assert streak[119] == 100 and qualified[119] + arrays['pad_normal_forces_n'][60, 0] = .01 + _, streak, qualified, _, _, _ = native_predicate(arrays, probe) + assert streak[60] == 0 and not qualified[119] + assert qualified[160] + arrays['object_minus_tcp_local_m'][220, 0] = .0031 + _, _, qualified, components, _, _ = native_predicate(arrays, probe) + assert not qualified[220] and reasons_at(220, components) == ['maximum_local_drift'] + return {'passed': True, 'checks': ['99_vs100_native_ticks', 'strict_force_threshold_and_gap', '3mm_drift']} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--study', type=Path, required=True) + parser.add_argument('--repo', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + study, repo, output = args.study, args.repo, args.output + if output.exists() or output.is_symlink(): + raise ValueError('Audit output must be new') + def inventory(): + result = {} + for p in sorted(study.rglob('*')): + if p.is_symlink(): raise ValueError('Symlink in frozen study') + if p.is_file(): result[p.relative_to(study).as_posix()] = identity(p) + return result + raw_before = inventory() + protocol, report, seal = read(study/'protocol.json'), read(study/'report.json'), read(study/'frozen.json') + assert seal['protocol_sha256'] == raw_before['protocol.json']['sha256'] + assert protocol['schema'] == report['schema'] == 'dvidia.piper-retention-study/v1' + assert protocol['planned_attempts'] == report['planned_attempts'] == report['attempted'] == 36 + assert protocol['variants'] == ['measured_joint', 'issued_joint', 'cartesian'] + assert protocol['primary_endpoint']['duration_s'] == 5. + assert protocol['force_ceiling_n'] == 10. + assert protocol['secondary_endpoint']['duration_s'] == 5. + assert protocol['secondary_endpoint']['position_ceiling_m'] == TCP_POSITION_LIMIT_M + assert protocol['secondary_endpoint']['orientation_ceiling_rad'] == TCP_ORIENTATION_LIMIT_RAD + planned = [(case, variant) for case in protocol['cases'] for variant in case['variant_order']] + assert len({case['id'] for case, _ in planned}) == 12 + assert [r['id'] for r in report['rows']] == [c['id']+'-'+v for c,v in planned] + source_before = {name: identity(repo/'src/dvidia_training'/name)['sha256'] for name in protocol['source_sha256']} + assert source_before == protocol['source_sha256'] == report['source_sha256'] + rows, attempts_by_scene, paired_by_scene = [], {case['id']: {} for case in protocol['cases']}, {} + def retain_attempt(scene, variant, value): + attempts_by_scene[scene][variant] = value + if len(attempts_by_scene[scene]) == 3: + # The fixed roster is case-major. Release each scene's native arrays + # immediately after its three common-prefix digests are produced. + paired_by_scene[scene] = paired_prefix(scene, attempts_by_scene[scene]) + attempts_by_scene[scene] = {} + totals = {v:Counter(attempts=0, valid=0, admissions=0, false_initial=0, + native_5s_holds=0, controller_5s_holds=0, combined_5s_holds=0, + final_both_qualified=0, grip_and_stationary_5s=0, + native_rows=0, commands=0) for v in protocol['variants']} + for summary,(case,variant) in zip(report['rows'],planned): + folder = study/summary['id'] + for name, expected in summary['files'].items(): + assert '/' not in name and name not in ('.','..') + assert identity(folder/name) == expected + item = episode(folder/'episode.json.gz') + assert item['id'] == summary['id'] and item['config'] == case['config'] and item['variant'] == variant + assert item['valid'] == summary['valid'] + counter = totals[variant]; counter['attempts'] += 1 + if not item['valid'] or item['error'] is not None: + assert not summary['evaluation']['primary']['success'] + native_summary = summary.get('evaluation', {}).get('native') or {} + retain_attempt(case['id'], variant, {'valid': False, + 'native_rows_available': native_summary.get('rows'), + 'reason': 'invalid_attempt_retained_as_unknown'}) + rows.append({'id':item['id'],'valid':False,'scope':'Retained unknown; cannot qualify.'}) + continue + import zipfile + with zipfile.ZipFile(folder/'native.npz') as bundle: + entries=bundle.infolist() + assert len(entries)==22 and len({e.filename for e in entries})==22 + assert sum(e.file_size for e in entries)<=MAX_FILE + assert all(e.file_size<=2*1024*1024 and '/' not in e.filename for e in entries) + with np.load(folder/'native.npz',allow_pickle=False) as archive: + assert set(archive.files)==set(FIELDS) + arrays={name:archive[name] for name in archive.files} + for name,width in FIELDS.items(): + assert arrays[name].dtype==np.float64 + assert arrays[name].shape==((20000,) if width==1 else (20000,width)) + assert np.isfinite(arrays[name]).all() + times=arrays['t_seconds'] + assert np.array_equal(arrays['native_tick'],np.arange(1,20001)) + assert np.max(np.abs(times-np.arange(1,20001)*.001))<1e-8 + assert not np.any(arrays['pulse_active']) and not np.any(arrays['pulse_closing_tail']) + rotation_local(arrays) + policy,metrics=item['policy'],item['metrics'] + assert metrics['valid'] and metrics['warnings']==[] + unexpected_ticks = int(np.count_nonzero(arrays['unexpected_contact_count'])) + assert unexpected_ticks == metrics['unexpected_contact_native_ticks'] == 0 + actions,trajectory=item['actions'],item['trajectory'] + assert len(actions)==1000 and len(trajectory)==1001 + counter.update(valid=1,native_rows=20000,commands=1000) + control=np.array(trajectory[0]['native_actuator_controls'],dtype=float) + rates=np.array([case['config']['joint_target_rate_rad_s']*.001]*6+[case['config']['jaw_target_rate_m_s']*.001]) + requested=policy['requested_controls'] + assert len(requested)==1000 + for i,(action,request) in enumerate(zip(actions,requested)): + assert abs(action['t_seconds']-i*.02)<1e-8 and request['index']==i + assert all(action[k]==request[k] for k in ('t_seconds','joint_targets_rad','gripper_position_m')) + target=np.array(action['joint_targets_rad']+[action['gripper_position_m']]) + for tick in range(i*20,(i+1)*20): + control += np.clip(target-control,-rates,rates) + actual=np.r_[arrays['arm_actuator_controls_rad'][tick],arrays['applied_jaw_target_m'][tick]] + assert np.max(np.abs(control-actual))<1e-12 + for field in ('object_position_m','joint_position_rad','tcp_position_m'): + assert np.max(np.abs(np.array(trajectory[i+1][field])-arrays[field][(i+1)*20-1]))<1e-9 + admissions=[e for e in policy['events'] if e['type']=='evidence_qualified'] + assert len(admissions)<=1 + audited={'id':item['id'],'valid':True,'admitted':bool(admissions), + 'native_5s_hold':False,'controller_5s_hold':False,'combined_5s_hold':False, + 'false_initial':False,'final_both_qualified':False,'commands_reconstructed':1000} + if policy['probes']: + _,_,last_qualified,_,_,_=native_predicate(arrays,policy['probes'][-1]) + audited['final_both_qualified']=bool(last_qualified[-1] and policy['admitted']) + boundary = treatment_boundary(policy) + if admissions: + event=admissions[0]; counter['admissions']+=1 + probe=next(p for p in policy['probes'] if p['probe_index']==event['probe_index']) + condition,streak,qualified,components,rise,drift=native_predicate(arrays,probe) + start=row_at(event['t_seconds'],times) + target_time=event['t_seconds']+5. + complete=bool(target_time<=times[-1]+1e-8) + end=row_at(target_time,times) if complete else len(times)-1 + correct=bool(qualified[start]); ceiling_ok=bool(np.all(arrays['pad_normal_forces_n'][start:]<=10.)) + retained=[q for q in policy['qualification_checks'] if abs(q['t_seconds']-target_time)<1e-8 and q['probe_index']==event['probe_index']] + controller=bool(complete and len(retained)==1 and retained[0]['qualified'] is True) + success=bool(correct and complete and np.all(condition[start:end+1]) and ceiling_ok) + losses=np.flatnonzero(~condition[start:end+1]); crossing=start+int(losses[0]) if len(losses) else None + audited.update(admission_s=event['t_seconds'],native_admission_streak_ms=int(streak[start]), + false_initial=not correct,native_5s_hold=success,controller_5s_hold=controller, + combined_5s_hold=success and controller,force_ceiling_respected=ceiling_ok, + complete_5s=complete,first_loss_s=float(times[crossing]) if crossing is not None else None, + first_loss_reasons=reasons_at(crossing,components) if crossing is not None else [], + object_z_delta_at_endpoint_m=float(arrays['object_position_m'][end,2]-arrays['object_position_m'][start,2]), + tcp_z_delta_at_endpoint_m=float(arrays['tcp_position_m'][end,2]-arrays['tcp_position_m'][start,2])) + evaluation=summary['evaluation'] + assert evaluation['first_admission']['native_prefix_qualified']==correct + assert evaluation['primary']['success']==success + assert evaluation['primary']['controller_retained_at_5s']==controller + assert evaluation['primary']['combined_qualified_pause']==(success and controller) + secondary = stationarity(arrays, start, end, complete, success) + check_stationarity_reported(secondary, evaluation['stationarity'], complete) + audited['grip_and_stationary_5s'] = secondary['grip_retention_and_stationarity'] + # Same jaw target after admission: no hidden squeeze escalation. + held_jaw=policy['frozen_jaw_target_m'] + assert all(a['gripper_position_m']==held_jaw for a in actions if a['t_seconds']>=event['t_seconds']-1e-8) + else: + assert summary['evaluation']['first_admission'] is None + assert not summary['evaluation']['primary']['success'] + assert summary['evaluation']['stationarity']['complete_5s'] is False + assert summary['evaluation']['stationarity']['tcp_pose_within_bounds'] is False + assert summary['evaluation']['stationarity']['grip_retention_and_stationarity'] is False + audited['grip_and_stationary_5s'] = False + correction_summary = corrections_audit(policy, actions, boundary, variant) + assert correction_summary == {key: summary['evaluation']['corrections'][key] + for key in correction_summary} + assert summary['evaluation']['final']['both_qualified']==audited['final_both_qualified'] + counter.update(false_initial=int(audited['false_initial']),native_5s_holds=int(audited['native_5s_hold']), + controller_5s_holds=int(audited['controller_5s_hold']),combined_5s_holds=int(audited['combined_5s_hold']), + final_both_qualified=int(audited['final_both_qualified']), + grip_and_stationary_5s=int(audited['grip_and_stationary_5s'])) + retain_attempt(case['id'], variant, {'valid': True, 'boundary': boundary, + 'arrays': arrays, 'actions': actions}) + rows.append(audited) + inspection = read(study/'inspection.json') + assert inspection['schema'] == 'dvidia.piper-retention-inspection/v1' + assert inspection['attempts'] == 36 and inspection['native_rows'] == 720000 + inspected_rows = {row['id']: row for row in inspection['rows']} + assert set(inspected_rows) == {row['id'] for row in report['rows']} + for row in report['rows']: + checked = inspected_rows[row['id']] + assert checked['evaluation_recomputed'] is True + if row['valid']: + assert checked['native_validated'] is True and checked['controls_recomputed'] is True + else: + assert checked['retained_refusal'] is True or checked['native_validated'] is False + inspected_prefixes = {row['scene_id']: row for row in inspection['acquisition_prefixes']} + assert set(inspected_prefixes) == {case['id'] for case in protocol['cases']} + paired=[] + for scene, pair in paired_by_scene.items(): + if pair['verified'] is False: + inspection_row = inspected_prefixes[scene] + assert inspection_row['verified'] is False + assert inspection_row['reason'] == 'invalid_attempt_prevents_complete_triplet' + paired.append(pair) + continue + inspector = inspected_prefixes[scene] + assert inspector['verified'] is True + for field in ('cutoff_time_s', 'cutoff_reason', 'native_rows', 'native_sha256', + 'commands', 'commands_sha256'): + value = pair[field] + assert inspector[field] == value, (scene, field) + assert inspector['treatment_boundaries'] == pair['treatment_boundaries'] + paired.append(pair) + source_after={name:identity(repo/'src/dvidia_training'/name)['sha256'] for name in source_before} + assert source_before==source_after and raw_before==inventory() + output.parent.mkdir(parents=True,exist_ok=True) + value={'schema':'dvidia.retention-independent-audit/v1','passed':True,'self_tests':self_tests(), + 'protocol_sha256':raw_before['protocol.json']['sha256'],'source_sha256':source_before, + 'frozen_raw_files':raw_before,'rows':rows,'totals':{v:dict(c) for v,c in totals.items()}, + 'paired_acquisition_prefixes':paired,'source_and_raw_inputs_unchanged':True, + 'scope':'Independent recorded-predicate and command readback, not independent physics, authenticated sensors or hardware qualification.'} + with output.open('x') as stream:json.dump(value,stream,sort_keys=True,indent=2,allow_nan=False);stream.write('\n') + print(json.dumps({'passed':True,'totals':value['totals'],'raw_files':len(raw_before)})) + + +if __name__=='__main__':main() diff --git a/benchmarks/piper-retention-v02/package_evidence.py b/benchmarks/piper-retention-v02/package_evidence.py new file mode 100644 index 0000000..dc2e55e --- /dev/null +++ b/benchmarks/piper-retention-v02/package_evidence.py @@ -0,0 +1,428 @@ +"""Package the reviewed retention run and independent audit, offline. + +The entire selected run and audit trees are archived in deterministic order. +Packaging never imports frozen runtime code, runs physics, reads credentials or +publishes. It refuses incomplete or failed audit evidence. +""" +from __future__ import annotations + +import argparse +import gzip +from hashlib import sha256 +import io +import json +import os +from pathlib import Path +import re +import stat +import sys +import zipfile + +SCHEMA = 'dvidia.piper-retention-study/v1' +VARIANTS = {'measured_joint', 'issued_joint', 'cartesian'} +ARCHIVE_NAME = 'piper-retention-v02.zip' +HF_NAMES = {'README.md', 'LICENSE', ARCHIVE_NAME, 'report.json', 'protocol.json', 'inspection.json'} +MAX_FILE = 64 * 1024 * 1024 +MAX_ARCHIVE = 192 * 1024 * 1024 +MAX_TOTAL = 480 * 1024 * 1024 +MAX_FILES = 2048 +MAX_JSON = 16 * 1024 * 1024 +PRIVATE = (b'/'+b'Users/', b'/private/'+b'tmp/', b'/opt/'+b'homebrew/', b'/var/'+b'folders/') +SECRETS = tuple(re.compile(value) for value in + (rb'hf_[A-Za-z0-9]{20,}', rb'gh[pousr]_[A-Za-z0-9]{20,}', + rb'apikey_[A-Za-z0-9_]{20,}', rb'AKIA[A-Z0-9]{16}')) + + +def encoded(value): + return json.dumps(value, sort_keys=True, indent=2, allow_nan=False).encode()+b'\n' + + +def pairs(items): + result = {} + for key, value in items: + if key in result: + raise ValueError('Duplicate JSON fields.') + result[key] = value + return result + + +def nonfinite(_value): + raise ValueError('Nonfinite JSON number.') + + +def load_json(path, limit=MAX_JSON): + raw = read(path, limit) + value = json.loads(raw, object_pairs_hook=pairs, parse_constant=nonfinite) + return value, raw + + +def read(path, limit=MAX_FILE): + path = Path(path) + if path.is_symlink() or not path.is_file() or path.stat().st_size > limit: + raise ValueError('Missing, linked or oversized evidence file.') + with path.open('rb') as stream: + raw = stream.read(limit+1) + if len(raw) > limit: + raise ValueError('Evidence grew beyond its byte limit.') + return raw + + +def safe_path(name): + if (type(name) is not str or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._/-]{0,399}', name) + or any(part in ('', '.', '..') for part in name.split('/'))): + raise ValueError('Unsafe archive path.') + return name + + +def scan_stream(stream, limit=MAX_FILE): + size, tail = 0, b'' + while chunk := stream.read(65536): + size += len(chunk) + if size > limit: + raise ValueError('Decoded evidence exceeds its byte limit.') + block = tail+chunk + if any(value in block for value in PRIVATE) or any(pattern.search(block) for pattern in SECRETS): + raise ValueError('Private path or credential-shaped bytes in evidence.') + tail = block[-256:] + return size + + +def parse_json_bytes(raw): + value = json.loads(raw, object_pairs_hook=pairs, parse_constant=nonfinite) + scan_stream(io.BytesIO(json.dumps(value, ensure_ascii=False, allow_nan=False).encode())) + return value + + +def scan_file(path): + raw = read(path) + scan_stream(io.BytesIO(raw)) + if path.suffix == '.json': + parse_json_bytes(raw) + elif path.suffix == '.jsonl': + for line in raw.splitlines(): + if line.strip(): + parse_json_bytes(line) + elif path.suffix == '.gz': + total = 0 + with gzip.open(path, 'rb') as stream: + chunks = [] + while chunk := stream.read(65536): + total += len(chunk) + if total > MAX_FILE: + raise ValueError('Gzip content exceeds its byte limit.') + scan_stream(io.BytesIO(chunk)) + chunks.append(chunk) + decoded = b''.join(chunks) + if decoded.lstrip().startswith((b'{', b'[')): + parse_json_bytes(decoded) + elif path.suffix in ('.npz', '.zip'): + with zipfile.ZipFile(path) as bundle: + entries = bundle.infolist() + names = [item.filename for item in entries] + if (not entries or len(entries) > MAX_FILES or len(names) != len(set(names)) + or sum(item.file_size for item in entries) > MAX_TOTAL): + raise ValueError('Nested archive exceeds its member/byte bound.') + for item in entries: + safe_path(item.filename) + if (item.file_size > MAX_FILE or item.flag_bits & 1 + or stat.S_IFMT(item.external_attr >> 16) not in (0, stat.S_IFREG) + or item.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)): + raise ValueError('Unsafe nested archive member.') + with bundle.open(item) as stream: + scan_stream(stream) + + +def identity(path, limit=MAX_FILE): + raw = read(path, limit) + return {'bytes': len(raw), 'sha256': sha256(raw).hexdigest()} + + +def tree(root): + root = Path(root) + if root.is_symlink() or not root.is_dir(): + raise ValueError('Evidence root must be a regular directory.') + result = {} + for path in sorted(root.rglob('*')): + if path.is_symlink() or not (path.is_file() or path.is_dir()): + raise ValueError('Evidence tree contains a link or special file.') + if path.is_dir(): + continue + name = safe_path(path.relative_to(root).as_posix()) + if name in result: + raise ValueError('Duplicate evidence path.') + scan_file(path) + result[name] = identity(path) + if len(result) > MAX_FILES or sum(item['bytes'] for item in result.values()) > MAX_TOTAL: + raise ValueError('Evidence tree exceeds its count/size bound.') + if not result: + raise ValueError('Evidence tree is empty.') + return result + + +def validate_inputs(study, audit): + study, audit = Path(study), Path(audit) + if 'piper-retention-v02' not in study.name: + raise ValueError('Study directory must be the new piper-retention-v02 run.') + study_files, audit_files = tree(study), tree(audit) + for name in ('report.json', 'protocol.json', 'inspection.json'): + if name not in study_files: + raise ValueError('Run is missing '+name+'.') + if 'audit.json' not in audit_files: + raise ValueError('Independent audit is missing audit.json.') + report, report_raw = load_json(study/'report.json') + protocol, protocol_raw = load_json(study/'protocol.json') + inspection, inspection_raw = load_json(study/'inspection.json') + audit_doc, audit_raw = load_json(audit/'audit.json') + for name, raw, identities in (('report.json', report_raw, study_files), + ('protocol.json', protocol_raw, study_files), + ('inspection.json', inspection_raw, study_files), + ('audit.json', audit_raw, audit_files)): + if {'bytes': len(raw), 'sha256': sha256(raw).hexdigest()} != identities[name]: + raise ValueError('Parsed metadata differs from its pinned evidence identity.') + rows = report.get('rows', report.get('attempts')) if type(report) is dict else None + if (type(report) is not dict or report.get('schema') != SCHEMA or type(rows) is not list or len(rows) != 36 + or report.get('planned_attempts') != 36 or report.get('attempted') != 36 + or type(protocol) is not dict or type(inspection) is not dict or type(audit_doc) is not dict): + raise ValueError('Run report, protocol or audit schema is incomplete.') + inspection_rows = inspection.get('rows') + if (inspection.get('schema') != 'dvidia.piper-retention-inspection/v1' + or inspection.get('attempts') != 36 or type(inspection_rows) is not list + or len(inspection_rows) != 36 + or [(row.get('id'), row.get('evaluation_recomputed')) for row in inspection_rows + if type(row) is dict] != [(row.get('id'), True) for row in rows]): + raise ValueError('Inspection does not cover all 36 attempts with recomputed evaluations.') + if (audit_doc.get('schema') != 'dvidia.retention-independent-audit/v1' + or type(audit_doc.get('passed')) is not bool or audit_doc['passed'] is not True + or audit_doc.get('source_and_raw_inputs_unchanged') is not True + or inspection.get('passed') is False or inspection.get('status') in ('FAIL', 'failed', 'refused')): + raise ValueError('The independent audit or run inspection did not pass.') + roster = {} + scene_sets = {} + pairs_seen = set() + for row in rows: + variant = row.get('variant') if type(row) is dict else None + scene = row.get('scene_id') if type(row) is dict else None + if variant not in VARIANTS or type(scene) is not str: + raise ValueError('Attempt records must identify a scene and one of the three variants.') + pair = (variant, scene) + if pair in pairs_seen: + raise ValueError('Duplicate scene/variant attempt in report.') + pairs_seen.add(pair) + roster.setdefault(variant, set()).add(scene) + if (set(roster) != VARIANTS or any(len(scenes) != 12 for scenes in roster.values()) + or len({frozenset(scenes) for scenes in roster.values()}) != 1): + raise ValueError('Expected twelve scenes for each of the three variants.') + for field in ('protocol_sha256',): + expected = sha256(protocol_raw).hexdigest() + for doc in (audit_doc, report, inspection): + if field in doc and doc[field] != expected: + raise ValueError('Protocol digest differs across run and audit records.') + if audit_doc.get(field) != expected: + raise ValueError('Independent audit is not bound to this frozen protocol.') + source_hashes = protocol.get('source_sha256') + if (type(source_hashes) is not dict or report.get('source_sha256') != source_hashes + or audit_doc.get('source_sha256') != source_hashes + or audit_doc.get('frozen_raw_files') != study_files): + raise ValueError('Independent audit source or frozen-input inventory differs from this run.') + audit_rows = audit_doc.get('rows') + if (type(audit_rows) is not list or len(audit_rows) != 36 + or [(row.get('id'), row.get('valid')) for row in audit_rows] + != [(row.get('id'), row.get('valid')) for row in rows]): + raise ValueError('Independent audit does not cover the exact 36 ordered run attempts.') + return {'study': study, 'audit': audit, 'study_files': study_files, + 'audit_files': audit_files, 'report': report, 'report_raw': report_raw, + 'protocol_raw': protocol_raw, 'inspection_raw': inspection_raw, + 'audit_raw': audit_raw, 'report_doc': report, 'protocol': protocol, + 'inspection': inspection, 'audit_doc': audit_doc} + + +VERIFY_SOURCE = r'''"""Verify/reconstruct a DVIDIA PiPER retention archive; no pickle or imports.""" +import argparse, hashlib, json, re, stat, zipfile +from pathlib import Path +MAX_FILE=64*1024*1024 +MAX_TOTAL=480*1024*1024 +def unique(pairs): + out={} + for k,v in pairs: + if k in out: raise ValueError('duplicate JSON field') + out[k]=v + return out +def safe(name): + if type(name) is not str or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._/-]{0,399}',name) or any(p in ('','.','..') for p in name.split('/')): raise ValueError('unsafe archive path') + return name +def verify(path,output=None): + with zipfile.ZipFile(path) as z: + es=z.infolist(); names=[e.filename for e in es] + if not 1<=len(es)<=2050 or len(names)!=len(set(names)) or sum(e.file_size for e in es)>MAX_TOTAL: raise ValueError('archive bounds/roster invalid') + for e in es: + safe(e.filename) + if e.file_size>MAX_FILE or e.flag_bits&1 or stat.S_IFMT(e.external_attr>>16) not in (0,stat.S_IFREG) or e.compress_type not in (zipfile.ZIP_STORED,zipfile.ZIP_DEFLATED): raise ValueError('unsafe archive member') + if 'inventory.json' not in names or z.getinfo('inventory.json').file_size>4*1024*1024: raise ValueError('missing/large inventory') + inv=json.loads(z.read('inventory.json'),object_pairs_hook=unique,parse_constant=lambda _:(_ for _ in ()).throw(ValueError('nonfinite'))) + if inv.get('schema')!='dvidia.piper-retention-inventory/v1' or type(inv.get('files')) is not list: raise ValueError('unknown inventory schema') + records={} + for r in inv['files']: + if type(r) is not dict or set(r)!={'path','bytes','sha256'}: raise ValueError('malformed inventory record') + n=safe(r['path']) + if n in records or n=='inventory.json' or type(r['bytes']) is not int or not 0<=r['bytes']<=MAX_FILE or type(r['sha256']) is not str or not re.fullmatch('[0-9a-f]{64}',r['sha256']): raise ValueError('invalid inventory identity') + records[n]=r + if set(names)!=set(records)|{'inventory.json'}: raise ValueError('inventory roster differs') + for name,r in records.items(): + h=hashlib.sha256(); size=0 + with z.open(name) as f: + while c:=f.read(65536): + size+=len(c) + if size>r['bytes']: raise ValueError('member exceeds expected bytes') + h.update(c) + if size!=r['bytes'] or h.hexdigest()!=r['sha256']: raise ValueError('member digest differs') + if output is not None: + root=Path(output) + if root.exists() or root.is_symlink(): raise ValueError('output must be new') + root.mkdir(parents=True); base=root.resolve() + for n in names: + dest=root/n; dest.parent.mkdir(parents=True,exist_ok=True) + if not dest.parent.resolve().is_relative_to(base): raise ValueError('output escape') + with z.open(n) as src,dest.open('xb') as dst: + while c:=src.read(65536): dst.write(c) + return {'verified':True,'files':len(records),'logical_bytes':sum(r['bytes'] for r in records.values()),'reconstructed':output is not None} +def main(): + p=argparse.ArgumentParser(description=__doc__); p.add_argument('archive'); p.add_argument('--output'); a=p.parse_args(); print(json.dumps(verify(a.archive,a.output),sort_keys=True)) +if __name__=='__main__': main() +''' + + +def archive(path, files, generated, *, expected_identities): + if set(expected_identities) != set(files): + raise ValueError('Archive input roster differs from pinned identities.') + expected = {name: dict(value) for name, value in expected_identities.items()} + inventory = [] + for name, source in sorted(files.items()): + safe_path(name) + if identity(source) != expected[name]: + raise ValueError('Archive input differs from its pinned evidence identity.') + inventory.append({'path': name, **expected[name]}) + for name, raw in sorted(generated.items()): + safe_path(name) + scan_stream(io.BytesIO(raw)) + inventory.append({'path': name, 'bytes': len(raw), 'sha256': sha256(raw).hexdigest()}) + inventory.sort(key=lambda item: item['path']) + if len(inventory) > MAX_FILES or sum(item['bytes'] for item in inventory) > MAX_TOTAL-1024*1024: + raise ValueError('Expanded archive exceeds its limits.') + content = {**files, **generated, 'inventory.json': encoded({'schema':'dvidia.piper-retention-inventory/v1', + 'files':inventory,'scope':'All run/audit artifacts and included tools; inventory excludes itself.'})} + with zipfile.ZipFile(path, 'x', compression=zipfile.ZIP_DEFLATED, compresslevel=9, allowZip64=True) as z: + for name, source in sorted(content.items()): + info=zipfile.ZipInfo(name, date_time=(1980,1,1,0,0,0)); info.create_system=3 + info.external_attr=(stat.S_IFREG|0o644)<<16 + info.compress_type=zipfile.ZIP_STORED if isinstance(source,Path) and source.suffix in ('.gz','.npz') else zipfile.ZIP_DEFLATED + with z.open(info,'w',force_zip64=True) as dest: + if isinstance(source,Path): + if source.is_symlink() or not source.is_file(): + raise ValueError('Archive input is no longer a plain file.') + copied, copied_hash = 0, sha256() + with source.open('rb') as stream: + while chunk:=stream.read(65536): + copied += len(chunk) + if copied > expected[name]['bytes']: + raise ValueError('Copied archive input exceeds its pinned size.') + copied_hash.update(chunk) + dest.write(chunk) + if {'bytes': copied, 'sha256': copied_hash.hexdigest()} != expected[name]: + raise ValueError('Copied archive input differs from its pinned evidence identity.') + else: + dest.write(source) + return inventory + + +def archive_digest(path): + h=sha256(); size=0 + with path.open('rb') as stream: + while chunk:=stream.read(65536): + size+=len(chunk) + if size>MAX_ARCHIVE: raise ValueError('Compressed archive exceeds its limit.') + h.update(chunk) + return {'bytes':size,'sha256':h.hexdigest()} + + +def prepare(study, audit, output, repo_root): + output=Path(output) + if output.exists() or output.is_symlink(): raise ValueError('Package output must be new.') + frozen=validate_inputs(study,audit) + initial=(frozen['study_files'],frozen['audit_files']) + files={**{'study/'+name:frozen['study']/name for name in frozen['study_files']}, + **{'audit/'+name:frozen['audit']/name for name in frozen['audit_files']}} + expected_identities = {**{'study/'+name: value for name, value in frozen['study_files'].items()}, + **{'audit/'+name: value for name, value in frozen['audit_files'].items()}} + tool_dir=Path(__file__).parent + files.update({ + 'tools/package_evidence.py':Path(__file__), + 'tools/render_report.py':tool_dir/'render_report.py', + 'tools/test_package_evidence.py':tool_dir/'test_package_evidence.py', + 'tools/independent_audit.py':tool_dir/'independent_audit.py', + 'tools/verify_archive.py':None, + 'licenses/agilex_piper-LICENSE.txt':Path(repo_root)/'third_party/robot-models/agilex_piper-LICENSE.txt', + 'licenses/mujoco-LICENSE.txt':Path(repo_root)/'third_party/mujoco/LICENSE.txt'}) + generated_tools={name:raw for name,raw in files.items() if raw is None} + files={name:path for name,path in files.items() if path is not None} + readme=b'''# DVIDIA | PiPER retention v0.2\n\nA bounded native CPU study compares measured-joint, issued-joint and Cartesian hold strategies on twelve fresh synthetic scenes (36 attempts). The primary outcome is a continuous five-second hold after admission, checked against false confirmation. Every scheduled attempt and failure is retained. See report.json and the protocol for the frozen results and definitions.\n\nThis is not a learned policy, full transport or placement task, physical-sensor validation, hardware qualification or real-object reliability estimate. The simulator uses authored privileged state.\n\nThe ZIP preserves the complete new run tree under study/ and its independent audit under audit/. It includes deterministic inventory hashes and tools/verify_archive.py. To verify and optionally reconstruct without importing project code, extract the archive and run:\n\n python tools/verify_archive.py piper-retention-v02.zip --output evidence\n\nResearch article: https://research.dvidia.org/papers/piper-retention-v02/\nSource release: https://github.com/Dvidia-Inference/dvidia-training/releases/tag/piper-retention-v0.2-preview\nEvidence dataset: https://huggingface.co/datasets/Dvidia/piper-retention-v02\n\nDVIDIA source and authored synthetic records are MIT. Upstream simulator and robot asset notices are included. No physical robot footage, credentials or hardware data are bundled.\n''' + generated={**generated_tools,'README.md':readme,'LICENSE':read(Path(repo_root)/'LICENSE'), + 'tools/verify_archive.py':VERIFY_SOURCE.encode()} + # Top-level project README/renderer/tool paths are included in the archive; scanner validates them too. + for name, source in files.items(): + scan_file(source) + if name not in expected_identities: + expected_identities[name] = identity(source) + output.mkdir(parents=True); stage=output/'huggingface'; stage.mkdir() + archive_path=stage/ARCHIVE_NAME + inventory=archive(archive_path,files,generated,expected_identities=expected_identities) + verifier={'__name__':'retention_archive_verifier'} + exec(compile(VERIFY_SOURCE,'verify_archive.py','exec'),verifier) + verified=verifier['verify'](archive_path) + if (tree(frozen['study'])!=initial[0] or tree(frozen['audit'])!=initial[1]): + raise ValueError('Study or audit changed during packaging.') + archive_id=archive_digest(archive_path) + table = ['\n## Frozen results\n', '| Hold | Admissions | False initial | Native 5 s grip | Grip + TCP stationarity | Final controller + native |', + '|---|---:|---:|---:|---:|---:|'] + for variant in ('measured_joint', 'issued_joint', 'cartesian'): + result = frozen['report']['aggregates'][variant] + table.append('| '+variant+' | '+str(result['admissions'])+'/12 | '+str(result['false_initial_admissions'])+' | '+str(result['native_5s_pause_success'])+'/12 | '+str(result['grip_retention_and_stationarity'])+'/12 | '+str(result['final_both_qualified'])+'/12 |') + table.append('\nFresh finite development cells, not a population accuracy estimate. The separate development-smoke directory preserves the earlier selected old-case Cartesian refusal; it is excluded from all 36-attempt counts.\n') + card=b'''---\nlicense: mit\npretty_name: DVIDIA PiPER retention v0.2\ntask_categories:\n- robotics\ntags:\n- robotics\n- simulation\n- piper\n- mujoco\n- research\n---\n\n'''+readme+'\n'.join(table).encode()+b'\nArchive SHA-256: `'+archive_id['sha256'].encode()+b'`\n' + payload={'README.md':card,'LICENSE':generated['LICENSE'], + 'report.json':frozen['report_raw'],'protocol.json':frozen['protocol_raw'], + 'inspection.json':frozen['inspection_raw'],ARCHIVE_NAME:archive_path.read_bytes()} + for name, raw in payload.items(): + scan_stream(io.BytesIO(raw),MAX_ARCHIVE if name==ARCHIVE_NAME else MAX_FILE) + if name == ARCHIVE_NAME: + if (stage/name).read_bytes() != raw: + raise ValueError('Staged archive bytes changed while preparing dataset payload.') + else: + with (stage/name).open('xb') as stream: stream.write(raw) + if {p.name for p in stage.iterdir()}!=HF_NAMES: raise ValueError('Dataset payload must contain exactly six files.') + inventory_path=output/'inventory.json'; inventory_path.write_bytes(encoded({'schema':'dvidia.piper-retention-package/v1','files':[{'path':p.name,**identity(p,MAX_ARCHIVE if p.name==ARCHIVE_NAME else MAX_FILE)} for p in sorted(stage.iterdir())]})) + receipt={'schema':'dvidia.piper-retention-package-receipt/v1','remote_operations':False, + 'target_dataset':'Dvidia/piper-retention-v02','source_tag':'piper-retention-v0.2-preview', + 'attempts':36,'variants':sorted(VARIANTS),'study_files':len(frozen['study_files']), + 'audit_files':len(frozen['audit_files']),'archive':{'filename':ARCHIVE_NAME,**archive_id}, + 'verified':verified,'frozen_inputs_unchanged':True, + 'pinned_archive_inputs_verified':{'files':len(expected_identities),'at_index':True,'at_stream_copy':True}, + 'inventory_sha256':sha256(inventory_path.read_bytes()).hexdigest()} + (output/'package-receipt.json').write_bytes(encoded(receipt)) + return receipt + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--study',type=Path,required=True); parser.add_argument('--audit',type=Path,required=True) + parser.add_argument('--repo',type=Path,default=Path(__file__).resolve().parents[2]) + parser.add_argument('--output',type=Path,required=True) + args=parser.parse_args() + try: + result=prepare(args.study,args.audit,args.output,args.repo) + print(json.dumps({'archive':result['archive'],'verified':result['verified']},indent=2)) + return 0 + except (ValueError,OSError,KeyError,TypeError,zipfile.BadZipFile) as exc: + print('Evidence package refused: '+str(exc),file=sys.stderr); return 2 + + +if __name__=='__main__': raise SystemExit(main()) diff --git a/benchmarks/piper-retention-v02/render_report.py b/benchmarks/piper-retention-v02/render_report.py new file mode 100644 index 0000000..e288fec --- /dev/null +++ b/benchmarks/piper-retention-v02/render_report.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Render the bounded PiPER retention v0.2 JSON report as static HTML.""" +import argparse +import html +import json +import math +from pathlib import Path + +SCHEMA = 'dvidia.piper-retention-study/v1' +VARIANTS = ('measured_joint', 'issued_joint', 'cartesian') +MAX_BYTES = 16 * 1024 * 1024 + + +def pairs(items): + result = {} + for key, value in items: + if key in result: + raise ValueError('Duplicate report JSON field.') + result[key] = value + return result + + +def constant(_value): + raise ValueError('Nonfinite report JSON value.') + + +def finite_float(value): + result = float(value) + if not math.isfinite(result): + raise ValueError('Nonfinite report JSON number.') + return result + + +def load(path): + path = Path(path) + if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_BYTES: + raise ValueError('Expected a bounded regular report.json.') + raw = path.read_bytes() + if len(raw) > MAX_BYTES: + raise ValueError('Report grew beyond its byte limit.') + report = json.loads(raw, object_pairs_hook=pairs, parse_constant=constant, parse_float=finite_float) + rows = report.get('rows') if type(report) is dict else None + if (type(report) is not dict or report.get('schema') != SCHEMA + or report.get('planned_attempts') != 36 or report.get('attempted') != 36 + or type(rows) is not list or len(rows) != 36 + or any(type(row) is not dict for row in rows)): + raise ValueError('Expected all 36 completed scheduled attempt records.') + seen = set() + for row in rows: + variant, scene = row.get('variant'), row.get('scene_id') + if variant not in VARIANTS or type(scene) is not str or (scene, variant) in seen: + raise ValueError('Report contains an unknown or duplicate scene/variant attempt.') + seen.add((scene, variant)) + evaluation = row.get('evaluation') + if (type(evaluation) is not dict or type(evaluation.get('primary')) is not dict + or type(evaluation.get('stationarity')) is not dict): + raise ValueError('Attempt lacks the frozen primary/stationarity evaluation fields.') + scenes = {scene for scene, _ in seen} + if (len(scenes) != 12 or seen != {(scene, variant) for scene in scenes for variant in VARIANTS} + or {row['variant'] for row in rows} != set(VARIANTS)): + raise ValueError('Expected the complete 12-scene by 3-variant roster.') + return report, rows + + +def esc(value): + return html.escape(str(value), quote=True) + + +def truth(value): + return 'yes' if value is True else ('no' if value is False else 'not reported') + + +def fmt_seconds(value): + return f'{value:.3f} s' if type(value) in (int, float) and math.isfinite(value) else '—' + + +def row_values(row): + evaluation = row['evaluation'] + primary = evaluation['primary'] + admission = evaluation.get('first_admission') + admitted_at = 'none' + prefix = '—' + if type(admission) is dict: + admitted_at = f"{fmt_seconds(admission.get('time_s'))} · probe {admission.get('probe_index', '—')}" + prefix = truth(admission.get('native_prefix_qualified')) + complete = primary.get('complete_5s') is True + native = primary.get('native_pause_success') + controller = primary.get('controller_retained_at_5s') + stationarity = evaluation.get('stationarity') or {} + stationary_success = stationarity.get('grip_retention_and_stationarity') + false_confirmation = complete and native is False and controller is True + conservative = complete and native is True and controller is False + timing = row.get('timing') + step_wall = timing.get('step_wall') if type(timing) is dict else None + p95 = step_wall.get('p95_s') if type(step_wall) is dict else None + reason = primary.get('reason') or row.get('error') or '—' + if type(reason) is dict: + reason = reason.get('message') or reason.get('type') or '—' + values = (row.get('id', '—'), row.get('scene_id', '—'), row.get('variant', '—'), + truth(row.get('valid')), admitted_at, prefix, + truth(native) if complete else 'incomplete follow-up', + truth(stationary_success) if complete else 'incomplete follow-up', + truth(controller) if complete else 'not evaluated', + 'yes' if false_confirmation else ('no' if complete else '—'), + 'yes' if conservative else ('no' if complete else '—'), fmt_seconds(p95), reason) + return values, complete, native, controller + + +def render(report, rows): + sections = [] + for variant in VARIANTS: + group = [row for row in rows if row.get('variant') == variant] + classified = [row_values(row) for row in group] + complete = sum(item[1] for item in classified) + native_pass = sum(item[1] and item[2] is True for item in classified) + stationary_pass = sum(item[1] and row['evaluation']['stationarity'].get('grip_retention_and_stationarity') is True + for item, row in zip(classified, group)) + false_confirm = sum(item[1] and item[2] is False and item[3] is True for item in classified) + abstain = sum(item[1] and item[2] is True and item[3] is False for item in classified) + false_initial = sum(type(row['evaluation'].get('first_admission')) is dict + and row['evaluation']['first_admission'].get('native_prefix_qualified') is False + for row in group) + sections.append(f'

{esc(variant.replace("_", " ").title())}

') + sections.append(f'

12 scheduled · {sum(row.get("valid") is True for row in group)}/12 valid · {sum(row["evaluation"].get("first_admission") is not None for row in group)} first admissions · {false_initial} false initial admissions · {complete}/12 with complete five-second follow-up · {native_pass} native grip holds · {stationary_pass} grip plus stationary-tool pauses · {false_confirm} false confirmations · {abstain} conservative abstentions

') + sections.append('
') + for row in group: + values, *_ = row_values(row) + sections.append('' + ''.join(f'' for value in values) + '') + sections.append('
AttemptSceneVariantValid runFirst admissionNative prefix qualifiedNative five-second grip holdGrip + stationary toolController retainedFalse confirmationConservative abstentionStep p95Primary reason / error
{esc(value)}
') + return f''' +Checking grip retention · DVIDIA PiPER v0.2
DVIDIA · PiPER retention v0.2

Checking grip retention without changing the squeeze.

+

Three authored hold strategies across twelve fresh synthetic scenes. The primary question is whether an admitted grasp remains held continuously for five simulated seconds without a false confirmation. A second reported outcome also requires the TCP to stay within the same five-second pose bounds for every variant.

+ +

{esc(report.get('scope', ''))}

{''.join(sections)} +

How to read the outcomes

“Native five-second grip hold” is the simulator outcome after first admission. “Grip + stationary tool” additionally requires the TCP to remain within 4 mm and 0.05 rad of its admission pose throughout the five-second window. “Controller retained” is the controller's corresponding qualification claim. A false confirmation is counted only when complete follow-up exists, the native grip hold fails, and the controller claims retention. Conservative abstention is shown separately when the native hold succeeds and the controller does not claim retention. Initial admission against the native prefix is a separate event. Incomplete follow-up is not counted as either mismatch.

+

Step p95 is unpaced full control-step compute wall time (including dense telemetry); it is not sensor latency or a real-time guarantee. Runs use privileged simulator state and do not establish physical retention.

+
''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('report', type=Path) + parser.add_argument('--output', type=Path) + args = parser.parse_args() + report, rows = load(args.report) + output = args.output or args.report.with_name('report.html') + if output.exists() or output.is_symlink(): + raise ValueError('HTML output path must be new; existing files are not overwritten.') + output.write_text(render(report, rows), encoding='utf-8') + print(output) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/piper-retention-v02/test_independent_audit.py b/benchmarks/piper-retention-v02/test_independent_audit.py new file mode 100644 index 0000000..7881dbb --- /dev/null +++ b/benchmarks/piper-retention-v02/test_independent_audit.py @@ -0,0 +1,40 @@ +import importlib.util +from pathlib import Path +import subprocess +import sys +import unittest +import numpy as np + +PATH=Path(__file__).with_name('independent_audit.py') +spec=importlib.util.spec_from_file_location('retention_independent',PATH) +audit=importlib.util.module_from_spec(spec);spec.loader.exec_module(audit) + +class IndependentAuditTests(unittest.TestCase): + def fixture(self): + n=5200 + arrays={name:np.zeros((n,) if width==1 else(n,width),dtype=np.float64) for name,width in audit.FIELDS.items()} + arrays['t_seconds']=np.arange(1,n+1)*.001 + arrays['object_position_m'][:]=[.3,0,.08] + arrays['object_position_m'][20:,2]+=.006 + arrays['object_minus_tcp_local_m'][:]=[0,0,-.1] + arrays['grasp_contact_counts'][:]=1;arrays['pad_normal_forces_n'][:]=.02 + return arrays,{'start_time_s':.02,'object_origin_m':[.3,0,.08],'local_offset_origin_m':[0,0,-.1]} + def test_native_100ms_and_hidden_gap(self): + self.assertTrue(audit.self_tests()['passed']) + def test_force_ceiling_in_admission_window(self): + a,p=self.fixture();a['pad_normal_forces_n'][100,0]=10.001 + condition,streak,qualified,*_=audit.native_predicate(a,p) + self.assertFalse(condition[100]);self.assertEqual(streak[100],0);self.assertFalse(qualified[119]) + a['pad_normal_forces_n'][100,0]=10. + self.assertTrue(audit.native_predicate(a,p)[2][119]) + def test_exact_five_second_native_interval_and_gap(self): + a,p=self.fixture();condition,*_=audit.native_predicate(a,p) + start=audit.row_at(.12,a['t_seconds']);end=audit.row_at(5.12,a['t_seconds']) + self.assertEqual(end-start,5000);self.assertTrue(condition[start:end+1].all()) + a['grasp_contact_counts'][end-1,1]=0 + self.assertFalse(audit.native_predicate(a,p)[0][start:end+1].all()) + def test_optimized_python_refused_before_import(self): + result=subprocess.run([sys.executable,'-O',str(PATH),'--help'],capture_output=True,text=True) + self.assertNotEqual(result.returncode,0);self.assertIn('refuses optimized Python',result.stderr) + +if __name__=='__main__':unittest.main() diff --git a/benchmarks/piper-retention-v02/test_package_evidence.py b/benchmarks/piper-retention-v02/test_package_evidence.py new file mode 100644 index 0000000..11aa697 --- /dev/null +++ b/benchmarks/piper-retention-v02/test_package_evidence.py @@ -0,0 +1,177 @@ +from hashlib import sha256 +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +import package_evidence as package + + +def write_json(path, value): + path.write_bytes(package.encoded(value)) + + +class PackageEvidenceTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.study = self.root / 'piper-retention-v02-run' + self.audit = self.root / 'piper-retention-v02-audit' + self.study.mkdir(); self.audit.mkdir() + rows = [{'id': f'{scene}-{variant}', 'scene_id': f'scene-{scene}', 'variant': variant} + for scene in range(12) for variant in sorted(package.VARIANTS)] + write_json(self.study / 'report.json', {'schema': package.SCHEMA, 'planned_attempts': 36, + 'attempted': 36, 'rows': rows, 'source_sha256': {}, + 'aggregates': {v: {k: 0 for k in ('admissions', 'false_initial_admissions', + 'native_5s_pause_success', 'grip_retention_and_stationarity', 'final_both_qualified')} + for v in package.VARIANTS}}) + write_json(self.study / 'protocol.json', {'schema': 'synthetic-protocol', 'source_sha256': {}}) + write_json(self.study / 'inspection.json', { + 'schema': 'dvidia.piper-retention-inspection/v1', 'attempts': 36, + 'rows': [{'id': row['id'], 'evaluation_recomputed': True} for row in rows]}) + (self.study / 'attempt-0.bin').write_bytes(b'synthetic attempt evidence') + (self.audit / 'independent.txt').write_text('synthetic independent audit fixture') + study_inventory = package.tree(self.study) + write_json(self.audit / 'audit.json', { + 'schema': 'dvidia.retention-independent-audit/v1', 'passed': True, + 'source_and_raw_inputs_unchanged': True, + 'protocol_sha256': package.identity(self.study / 'protocol.json')['sha256'], + 'source_sha256': {}, 'frozen_raw_files': study_inventory, 'rows': rows}) + + def test_prepares_repeatable_complete_archive_with_verified_reconstruction(self): + outputs = [self.root / 'package-a', self.root / 'package-b'] + receipts = [package.prepare(self.study, self.audit, output, Path(__file__).resolve().parents[2]) + for output in outputs] + archive_a = outputs[0] / 'huggingface' / package.ARCHIVE_NAME + archive_b = outputs[1] / 'huggingface' / package.ARCHIVE_NAME + self.assertEqual(archive_a.read_bytes(), archive_b.read_bytes()) + self.assertEqual(receipts[0]['verified']['verified'], True) + self.assertEqual(receipts[0]['attempts'], 36) + self.assertEqual({path.name for path in (outputs[0] / 'huggingface').iterdir()}, package.HF_NAMES) + with zipfile.ZipFile(archive_a) as archive: + self.assertIn('study/report.json', archive.namelist()) + self.assertIn('audit/audit.json', archive.namelist()) + self.assertIn('tools/verify_archive.py', archive.namelist()) + self.assertEqual(json.loads(archive.read('inventory.json'))['schema'], + 'dvidia.piper-retention-inventory/v1') + verifier = {'__name__': 'retention_archive_verifier'} + exec(compile(package.VERIFY_SOURCE, 'verify_archive.py', 'exec'), verifier) + destination = self.root / 'reconstructed' + proof = verifier['verify'](archive_a, destination) + self.assertTrue(proof['verified']) + self.assertEqual((destination / 'study/report.json').read_bytes(), + (self.study / 'report.json').read_bytes()) + + def test_requires_passed_audit_and_same_twelve_scene_roster(self): + audit = json.loads((self.audit / 'audit.json').read_bytes()) + audit['passed'] = False + write_json(self.audit / 'audit.json', audit) + with self.assertRaisesRegex(ValueError, 'audit'): + package.validate_inputs(self.study, self.audit) + audit['passed'] = True + write_json(self.audit / 'audit.json', audit) + report = json.loads((self.study / 'report.json').read_bytes()) + report['rows'][-1]['scene_id'] = 'different-scene' + write_json(self.study / 'report.json', report) + with self.assertRaisesRegex(ValueError, 'twelve scenes'): + package.validate_inputs(self.study, self.audit) + + def test_refuses_duplicate_nonfinite_and_private_evidence(self): + (self.study / 'bad.json').write_bytes(b'{"x":1,"x":2}') + with self.assertRaises(ValueError): + package.tree(self.study) + (self.study / 'bad.json').unlink() + (self.study / 'bad.json').write_bytes(b'{"x":NaN}') + with self.assertRaises(ValueError): + package.tree(self.study) + (self.study / 'bad.json').unlink() + (self.study / 'attempt-0.bin').write_bytes(b'/'+b'Users/private/person/video.mp4') + with self.assertRaisesRegex(ValueError, 'Private path'): + package.tree(self.study) + + def test_verifier_rejects_modified_archive_and_refuses_overwrite(self): + receipt = package.prepare(self.study, self.audit, self.root / 'package', + Path(__file__).resolve().parents[2]) + archive_path = self.root / 'package/huggingface' / package.ARCHIVE_NAME + verifier = {'__name__': 'retention_archive_verifier'} + exec(compile(package.VERIFY_SOURCE, 'verify_archive.py', 'exec'), verifier) + output = self.root / 'reconstructed' + verifier['verify'](archive_path, output) + with self.assertRaisesRegex(ValueError, 'new'): + verifier['verify'](archive_path, output) + altered = self.root / 'altered.zip' + with zipfile.ZipFile(archive_path) as source, zipfile.ZipFile(altered, 'w') as dest: + for name in source.namelist(): + raw = source.read(name) + if name == 'study/report.json': + raw += b' ' + dest.writestr(name, raw) + with self.assertRaisesRegex(ValueError, 'expected bytes|digest'): + verifier['verify'](altered) + self.assertEqual(receipt['archive']['sha256'], sha256(archive_path.read_bytes()).hexdigest()) + + def test_prepare_rejects_transient_same_size_mutation_before_archive_indexing(self): + target = self.study/'attempt-0.bin' + original = target.read_bytes() + real_archive = package.archive + def mutate_then_archive(*args, **kwargs): + target.write_bytes(b'X'*len(original)) + try: + return real_archive(*args, **kwargs) + finally: + # Restoration would defeat a final-tree-only check. + target.write_bytes(original) + with patch.object(package, 'archive', side_effect=mutate_then_archive): + with self.assertRaisesRegex(ValueError, 'pinned evidence identity'): + package.prepare(self.study,self.audit,self.root/'mutated-index', + Path(__file__).resolve().parents[2]) + self.assertEqual(target.read_bytes(), original) + + def test_archive_stream_copy_rejects_mutation_after_indexing(self): + target = self.study/'attempt-0.bin' + original, real_zip = target.read_bytes(), zipfile.ZipFile + expected = {'study/attempt-0.bin': package.identity(target)} + def mutate_before_copy(*args, **kwargs): + target.write_bytes(b'Y'*len(original)) + return real_zip(*args, **kwargs) + try: + with patch.object(package.zipfile, 'ZipFile', side_effect=mutate_before_copy): + with self.assertRaisesRegex(ValueError, 'Copied archive input differs'): + package.archive(self.root/'mutated-copy.zip',{'study/attempt-0.bin':target},{}, + expected_identities=expected) + finally: + target.write_bytes(original) + + def test_parsed_metadata_is_bound_to_the_tree_snapshot(self): + real_load = package.load_json + def changed_read(path, *args, **kwargs): + value, raw = real_load(path, *args, **kwargs) + return (value, raw+b' ') if Path(path).name=='report.json' else (value,raw) + with patch.object(package, 'load_json', side_effect=changed_read): + with self.assertRaisesRegex(ValueError, 'Parsed metadata differs'): + package.validate_inputs(self.study,self.audit) + + def test_archive_identity_has_its_own_larger_byte_bound(self): + large = self.root/package.ARCHIVE_NAME + with large.open('wb') as stream: + stream.truncate(package.MAX_FILE+1) + with self.assertRaisesRegex(ValueError, 'oversized'): + package.identity(large) + self.assertEqual(package.identity(large,package.MAX_ARCHIVE),package.archive_digest(large)) + real_identity, archive_calls = package.identity, [] + def checked_identity(path, limit=package.MAX_FILE): + if Path(path).name==package.ARCHIVE_NAME: + self.assertEqual(limit,package.MAX_ARCHIVE) + archive_calls.append(path) + return real_identity(path,limit) + with patch.object(package, 'identity', side_effect=checked_identity): + package.prepare(self.study,self.audit,self.root/'archive-limit', + Path(__file__).resolve().parents[2]) + self.assertEqual(len(archive_calls),1) + + +if __name__ == '__main__': + unittest.main() diff --git a/publishing/huggingface/publish_piper_retention_v02.py b/publishing/huggingface/publish_piper_retention_v02.py new file mode 100644 index 0000000..79af2ef --- /dev/null +++ b/publishing/huggingface/publish_piper_retention_v02.py @@ -0,0 +1,184 @@ +"""Stage or publish the reviewed PiPER retention v0.2 evidence dataset. + +This publisher is intentionally inert until its explicit ``publish`` command. +It freezes exactly six reviewed files and writes one commit only to an empty, +public dataset head. It does not modify any Space. +""" +from __future__ import annotations + +import argparse +from hashlib import sha256 +import json +from pathlib import Path +import re +import sys + +from publish import PublicationError, client +from publish_task_environments_v02 import digest, encoded, plain, public_client, download, scan + + +REPO = 'Dvidia/piper-retention-v02' +SCHEMA = 'dvidia.piper-retention-publication/v1' +ARCHIVE = 'piper-retention-v02.zip' +NAMES = {'README.md', 'LICENSE', ARCHIVE, 'report.json', 'protocol.json', 'inspection.json'} +PLAN_LIMIT = 4 * 1024 * 1024 +LIMITS = {name: (192 * 1024 * 1024 if name == ARCHIVE else + 16 * 1024 * 1024 if name == 'report.json' else 4 * 1024 * 1024) + for name in NAMES} +OID = re.compile(r'(?:[a-f0-9]{40}|[a-f0-9]{64})\Z') + + +def read(path, limit=None): + path = plain(path) + limit = LIMITS.get(path.name, PLAN_LIMIT) if limit is None else limit + if not path.is_file() or path.stat().st_size > limit: + raise PublicationError('Missing or oversized reviewed publication file.') + with path.open('rb') as stream: + raw = stream.read(limit + 1) + if len(raw) > limit: + raise PublicationError('Publication file changed beyond its byte limit.') + return raw + + +def files(root): + root = plain(root) + if not root.is_dir(): + raise PublicationError('Expected a plain publication directory.') + paths = sorted(root.iterdir()) + if {path.name for path in paths} != NAMES: + raise PublicationError('Dataset must contain exactly the reviewed six files.') + inventory = [] + for path in paths: + raw = read(path) + scan(raw) + inventory.append({'path': path.name, **digest(raw), + 'representation': 'lfs' if path.name == ARCHIVE else 'git'}) + return inventory + + +def stage(root, plan): + root, plan = plain(root), plain(plan) + value = {'schema': SCHEMA, 'repo_id': REPO, 'repo_type': 'dataset', + 'private': False, 'root': str(root), 'files': files(root)} + with plan.open('xb') as stream: + stream.write(encoded(value)) + return {'plan_sha256': sha256(read(plan, PLAN_LIMIT)).hexdigest(), + 'files': len(value['files']), 'bytes': sum(row['bytes'] for row in value['files'])} + + +def _pairs(items): + result = {} + for key, value in items: + if key in result: + raise PublicationError('Duplicate keys in reviewed publication plan.') + result[key] = value + return result + + +def _constant(_value): + raise PublicationError('Nonfinite values in reviewed publication plan.') + + +def checked(plan, expected_digest): + raw = read(plan, PLAN_LIMIT) + if sha256(raw).hexdigest() != expected_digest: + raise PublicationError('Reviewed plan digest changed.') + try: + value = json.loads(raw, object_pairs_hook=_pairs, parse_constant=_constant) + except (ValueError, TypeError, UnicodeError) as exc: + raise PublicationError('Malformed reviewed publication plan.') from exc + if (type(value) is not dict + or set(value) != {'schema', 'repo_id', 'repo_type', 'private', 'root', 'files'} + or value['schema'] != SCHEMA or value['repo_id'] != REPO + or value['repo_type'] != 'dataset' or value['private'] is not False + or type(value['root']) is not str or type(value['files']) is not list + or len(value['files']) != len(NAMES)): + raise PublicationError('Reviewed dataset identity changed.') + for row in value['files']: + if (type(row) is not dict or set(row) != {'path', 'bytes', 'sha256', 'representation'} + or type(row['path']) is not str or row['path'] not in NAMES + or type(row['bytes']) is not int or row['bytes'] < 0 + or type(row['sha256']) is not str or not re.fullmatch(r'[a-f0-9]{64}', row['sha256']) + or row['representation'] != ('lfs' if row['path'] == ARCHIVE else 'git')): + raise PublicationError('Reviewed dataset inventory is malformed.') + if ({row['path'] for row in value['files']} != NAMES + or files(value['root']) != value['files']): + raise PublicationError('Reviewed dataset identity or bytes changed.') + return value + + +def _remote(info): + if (getattr(info, 'private', None) is not False + or not OID.fullmatch(getattr(info, 'sha', '') or '') + or not getattr(info, 'siblings', None)): + raise PublicationError('Expected an initialized public dataset with an exact commit.') + + +def publish(plan, expected_digest, cache): + value = checked(plan, expected_digest) + payloads = [] + for row in value['files']: + raw = read(Path(value['root']) / row['path']) + if digest(raw) != {key: row[key] for key in ('bytes', 'sha256')}: + raise PublicationError('Payload changed while freezing reviewed publication bytes.') + payloads.append((row['path'], raw)) + # No payload file is reopened after credential loading or remote access. + api, token = client(cache) + from huggingface_hub import CommitOperationAdd + from huggingface_hub.errors import RepositoryNotFoundError + try: + info = api.repo_info(REPO, repo_type='dataset', files_metadata=True) + except RepositoryNotFoundError: + api.create_repo(REPO, repo_type='dataset', private=False, exist_ok=False) + info = api.repo_info(REPO, repo_type='dataset', files_metadata=True) + _remote(info) + if len(info.siblings) != 1 or {item.rfilename for item in info.siblings} != {'.gitattributes'}: + raise PublicationError('Dataset is not empty. Verify the existing publication; do not overwrite it.') + operations = [CommitOperationAdd(path_in_repo=name, path_or_fileobj=raw) for name, raw in payloads] + commit = api.create_commit(REPO, repo_type='dataset', operations=operations, + parent_commit=info.sha, + commit_message='Publish PiPER retention v0.2 simulator evidence') + del token, api + return {'repo_id': REPO, 'commit': commit.oid, 'files_added': len(operations)} + + +def verify(plan, expected_digest, cache): + value = checked(plan, expected_digest) + api = public_client() + info = api.repo_info(REPO, repo_type='dataset', files_metadata=True, token=False) + _remote(info) + if (len(info.siblings) != len(NAMES) + 1 + or {item.rfilename for item in info.siblings} != NAMES | {'.gitattributes'}): + raise PublicationError('Public dataset roster differs from the reviewed files.') + for row in value['files']: + raw = download(REPO, row['path'], 'dataset', info.sha, cache) + if digest(raw) != {key: row[key] for key in ('bytes', 'sha256')}: + raise PublicationError('Anonymous public bytes differ from the reviewed artifacts.') + return {'repo_id': REPO, 'commit': info.sha, 'anonymous_files_verified': len(value['files'])} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + command = commands.add_parser('stage') + command.add_argument('root'); command.add_argument('--plan', required=True) + for name in ('publish', 'verify'): + command = commands.add_parser(name) + command.add_argument('--plan', required=True) + command.add_argument('--sha256', required=True) + command.add_argument('--cache', required=True) + args = parser.parse_args() + try: + result = (stage(args.root, args.plan) if args.command == 'stage' else + globals()[args.command](args.plan, args.sha256, Path(args.cache))) + print(json.dumps(result, indent=2)) + return 0 + except PublicationError as exc: + print(str(exc), file=sys.stderr) + except Exception: + print('Publication operation failed; no credential or remote response details displayed.', file=sys.stderr) + return 2 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/publishing/huggingface/test_publish_piper_retention_v02.py b/publishing/huggingface/test_publish_piper_retention_v02.py new file mode 100644 index 0000000..1c4c20d --- /dev/null +++ b/publishing/huggingface/test_publish_piper_retention_v02.py @@ -0,0 +1,143 @@ +from copy import deepcopy +from hashlib import sha256 +import json +from pathlib import Path +import sys +from types import SimpleNamespace +import tempfile +import unittest +from unittest.mock import Mock, patch + +import publish_piper_retention_v02 as publisher + + +class RetentionPublicationTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.dataset = self.root / 'dataset'; self.dataset.mkdir() + for name in publisher.NAMES: + (self.dataset / name).write_bytes(b'synthetic retention fixture') + self.plan = self.root / 'plan.json' + self.receipt = publisher.stage(self.dataset, self.plan) + class Operation: + def __init__(self, path_in_repo, path_or_fileobj): + self.path_in_repo, self.path_or_fileobj = path_in_repo, path_or_fileobj + class NotFound(Exception): + pass + sdk = SimpleNamespace(CommitOperationAdd=Operation) + errors = SimpleNamespace(RepositoryNotFoundError=NotFound) + patcher = patch.dict(sys.modules, {'huggingface_hub': sdk, + 'huggingface_hub.errors': errors}) + patcher.start(); self.addCleanup(patcher.stop) + + def info(self, names=('.gitattributes',), private=False, revision='a' * 40): + return SimpleNamespace(private=private, sha=revision, + siblings=[SimpleNamespace(rfilename=name) for name in names]) + + def test_staging_checking_and_public_readback_do_not_load_credentials(self): + api = Mock(); api.repo_info.return_value = self.info(sorted(publisher.NAMES | {'.gitattributes'})) + with patch.object(publisher, 'client', side_effect=AssertionError('Credential accessed')), \ + patch.object(publisher, 'public_client', return_value=api), \ + patch.object(publisher, 'download', side_effect=lambda repo, name, kind, revision, cache: + (self.dataset / name).read_bytes()) as download: + value = publisher.checked(self.plan, self.receipt['plan_sha256']) + proof = publisher.verify(self.plan, self.receipt['plan_sha256'], self.root / 'cache') + self.assertEqual(value['repo_id'], 'Dvidia/piper-retention-v02') + self.assertEqual(proof['anonymous_files_verified'], 6) + self.assertEqual(download.call_count, 6) + api.repo_info.assert_called_once_with(publisher.REPO, repo_type='dataset', + files_metadata=True, token=False) + self.assertTrue(all(call.args[3] == 'a' * 40 for call in download.call_args_list)) + with self.assertRaises(FileExistsError): + publisher.stage(self.dataset, self.plan) + + def test_mutated_payload_and_unreviewed_roster_fail_before_credentials(self): + with patch.object(publisher, 'client') as client: + (self.dataset / 'README.md').write_bytes(b'changed') + with self.assertRaises(publisher.PublicationError): + publisher.publish(self.plan, self.receipt['plan_sha256'], self.root / 'cache') + (self.dataset / 'README.md').write_bytes(b'synthetic retention fixture') + (self.dataset / 'unreviewed.json').write_text('{}') + with self.assertRaises(publisher.PublicationError): + publisher.publish(self.plan, self.receipt['plan_sha256'], self.root / 'cache') + client.assert_not_called() + + def test_resealed_destination_duplicate_and_nonfinite_plans_are_rejected(self): + original = json.loads(self.plan.read_bytes()) + for field, replacement in [('repo_id', 'Dvidia/robot-simulation-lab'), + ('repo_type', 'space'), ('private', True)]: + value = deepcopy(original); value[field] = replacement + raw = publisher.encoded(value); self.plan.write_bytes(raw) + with self.subTest(field=field), self.assertRaises(publisher.PublicationError): + publisher.checked(self.plan, sha256(raw).hexdigest()) + for raw in (b'{"schema":1,"schema":2}', b'{"private":NaN}'): + self.plan.write_bytes(raw) + with self.assertRaises(publisher.PublicationError): + publisher.checked(self.plan, sha256(raw).hexdigest()) + value = deepcopy(original); value['files'][1] = value['files'][0] + raw = publisher.encoded(value); self.plan.write_bytes(raw) + with self.assertRaises(publisher.PublicationError): + publisher.checked(self.plan, sha256(raw).hexdigest()) + + def test_existing_private_or_unpinned_dataset_cannot_be_overwritten(self): + for info in (self.info(['README.md']), self.info(private=True), self.info(revision='main')): + api = Mock(); api.repo_info.return_value = info + with self.subTest(info=info), patch.object(publisher, 'client', return_value=(api, 'fixture')): + with self.assertRaises(publisher.PublicationError): + publisher.publish(self.plan, self.receipt['plan_sha256'], self.root / 'cache') + api.create_commit.assert_not_called() + api.create_repo.assert_not_called() + + def test_payload_freeze_and_upload_bytes_are_immutable_across_credential_boundary(self): + original = publisher.read + reads = 0 + def changed(path, *args, **kwargs): + nonlocal reads + if Path(path).name == 'README.md': + reads += 1 + if reads == 2: + return b'changed after review' + return original(path, *args, **kwargs) + with patch.object(publisher, 'read', side_effect=changed), patch.object(publisher, 'client') as client: + with self.assertRaisesRegex(publisher.PublicationError, 'freezing'): + publisher.publish(self.plan, self.receipt['plan_sha256'], self.root / 'cache') + client.assert_not_called() + api = Mock(); api.repo_info.return_value = self.info() + api.create_commit.return_value = SimpleNamespace(oid='b' * 40) + def credential_boundary(_cache): + (self.dataset / 'README.md').write_bytes(b'late mutation not uploaded') + return api, 'fixture' + with patch.object(publisher, 'client', side_effect=credential_boundary): + result = publisher.publish(self.plan, self.receipt['plan_sha256'], self.root / 'cache') + self.assertEqual(result['files_added'], 6) + commit = api.create_commit.call_args + self.assertEqual(commit.kwargs['parent_commit'], 'a' * 40) + uploaded = {operation.path_in_repo: operation.path_or_fileobj + for operation in commit.kwargs['operations']} + self.assertEqual(uploaded['README.md'], b'synthetic retention fixture') + + def test_oversized_file_and_changed_anonymous_bytes_are_rejected(self): + path = self.dataset / publisher.ARCHIVE + with path.open('wb') as stream: + stream.truncate(publisher.LIMITS[publisher.ARCHIVE] + 1) + with self.assertRaisesRegex(publisher.PublicationError, 'oversized'): + publisher.read(path) + (self.dataset / publisher.ARCHIVE).write_bytes(b'synthetic retention fixture') + self.plan.unlink(); self.receipt = publisher.stage(self.dataset, self.plan) + api = Mock(); api.repo_info.return_value = self.info(sorted(publisher.NAMES | {'.gitattributes'})) + with patch.object(publisher, 'client', side_effect=AssertionError('Credential accessed')), \ + patch.object(publisher, 'public_client', return_value=api), \ + patch.object(publisher, 'download', return_value=b'changed bytes'): + with self.assertRaisesRegex(publisher.PublicationError, 'Anonymous public bytes'): + publisher.verify(self.plan, self.receipt['plan_sha256'], self.root / 'cache') + target = self.root / 'unreviewed'; target.write_text('fixture') + (self.dataset / 'README.md').unlink() + (self.dataset / 'README.md').symlink_to(target) + with self.assertRaises(publisher.PublicationError): + publisher.checked(self.plan, self.receipt['plan_sha256']) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/dvidia_training/piper_retention.py b/src/dvidia_training/piper_retention.py new file mode 100644 index 0000000..9b182ce --- /dev/null +++ b/src/dvidia_training/piper_retention.py @@ -0,0 +1,236 @@ +"""Three bounded, authored native-PiPER holds after the unchanged evidence probe. + +All inputs are privileged simulator observations. Position regulation and a +synthetic force ceiling do not implement physical stopping or force control. +The fixed parameters below are engineering assumptions, not tuned hardware +calibration. No full transport, squeeze escalation or requalification occurs. +""" +from __future__ import annotations + +from copy import deepcopy +import math + +from . import piper_probe as probe + +SCHEMA = 'dvidia.piper-retention/v2' +VARIANTS = ('measured_joint', 'issued_joint', 'cartesian') +FORCE_CEILING_N = 10. +POSITION_ERROR_CEILING_M = .004 +ORIENTATION_ERROR_CEILING_RAD = .05 +INITIAL_BIAS_CEILING_RAD = .08 +CORRECTION_CEILING_RAD = .03 +CORRECTION_RATE_RAD_S = .10 +JOINT_MARGIN_RAD = .02 + + +def retention_contract(): + return { + 'schema': SCHEMA, 'variants': list(VARIANTS), 'physical_robot_ready': False, + 'observation_origin': probe.ORIGIN, 'control_dt_s': probe.DT, + 'acquisition': 'Unchanged ProbeStopPolicy evidence acquisition, including its bounded supported retry. All variants share the same prefix through the first admission observation.', + 'measured_joint': 'Original hold: latch measured six joint coordinates and the last issued jaw target at admission.', + 'issued_joint': 'Latch the preceding issued six joint targets and the same last issued jaw target at admission.', + 'cartesian': 'Latch measured TCP position/orientation and preceding issued targets. Fixed joint bias equals issued target minus pose_action at the admission pose. First command repeats the exact issued target; later pose_action updates receive that fixed bias.', + 'limits': {'per_finger_force_ceiling_n': FORCE_CEILING_N, + 'tcp_position_error_ceiling_m': POSITION_ERROR_CEILING_M, + 'tcp_orientation_error_ceiling_rad': ORIENTATION_ERROR_CEILING_RAD, + 'initial_joint_bias_ceiling_rad': INITIAL_BIAS_CEILING_RAD, + 'joint_correction_ceiling_rad': CORRECTION_CEILING_RAD, + 'joint_correction_rate_rad_s': CORRECTION_RATE_RAD_S, + 'native_joint_target_margin_rad': JOINT_MARGIN_RAD}, + 'rate_scope': 'The extra correction slews at at most 0.002 rad per 20 ms per joint. Original native 1 ms target slew, servos, gains and force caps remain unchanged.', + 'refusal': 'Original eligibility loss, unexpected contact or observed pad force >10 N terminally requests measured joint hold and the frozen jaw target. Cartesian error, initial-bias, total-correction or native-margin exhaustion also refuses terminally; it never chases an already failed grip.', + 'invalid_measurements': 'Malformed, nonfinite or noncausal observations terminally refuse and raise without issuing a command. No measured-position stop is claimed from invalid measurements.', + 'final_observation': 'Repeat force, original eligibility and Cartesian pose-error checks. Do not solve or issue a hypothetical next control at the terminal observation.', + 'sampling': 'Control checks occur at 20 ms boundaries. Independent saved 1 ms native evidence must judge a continuous 5 s hold; missed between-boundary failures remain findings.', + 'qualifications': 'Engineering limits and the 10 N ceiling are authored synthetic assumptions, not damage limits or calibrated physical telemetry. Position targets are not physical stops or force limiters. No load/friction inference, model training or complete placement is implemented.', + } + + +class RetentionPolicy(probe.ProbeStopPolicy): + """Preserve acquisition while changing only the admitted hold request.""" + + def __init__(self, environment, variant='measured_joint'): + if type(variant) is not str or variant not in VARIANTS: + raise ValueError('Unknown bounded retention variant') + super().__init__(environment, variant='evidence') + self.retention_variant = variant + self.hold_reference, self.hold_records, self.input_refusals = None, [], [] + self.input_refused = False + self.correction = [0.]*6 + self.calculation = {} + self.final_inspection = False + ranges = environment.model.actuator_ctrlrange + if len(ranges) != 7: + raise ValueError('Retention requires the native seven-actuator PiPER model') + self.joint_limits = [] + for row in ranges[:6]: + if len(row) != 2: + raise ValueError('Malformed native arm control limits') + low, high = (probe._number(float(value), 'native joint limit') for value in row) + if high-low <= 2*JOINT_MARGIN_RAD: + raise ValueError('Native joint interval cannot contain the unchanged margin') + self.joint_limits.append([low, high]) + + def _clock(self, observation): + try: + super()._clock(observation) + except ValueError: + self.input_refused = True + self.admitted = self.qualified = False + self.aborted, self.state = True, 'abort' + self.status, self.abort_reason = 'invalid_measurement_refused', 'invalid_measurement_or_cadence' + refusal = {'last_valid_observation_s': self.last_observed_s, + 'requested_control_count': len(self.requested_controls), + 'reason': self.abort_reason, 'command_issued': False, + 'scope': 'No trusted current position exists; no new hold target was issued.'} + self.input_refusals.append(refusal) + raise + if (self.status not in ('invalidated_hold', 'timed_out_hold') + and max(observation['pad_normal_forces_n'].values()) > FORCE_CEILING_N): + self.diagnostic = self._diagnose(observation) + self._terminal_hold(observation, 'declared_force_ceiling_exceeded') + + def _event(self, kind, observation, reason): + super()._event(kind, observation, reason) + self.events[-1]['retention_variant'] = self.retention_variant + + def _pose_errors(self, observation): + if self.hold_reference is None: + return None, None + reference = self.hold_reference + position = math.dist(observation['tcp_position_m'], reference['position_m']) + product = abs(sum(a*b for a, b in zip(observation['tcp_quaternion_wxyz'], reference['quaternion_wxyz']))) + orientation = 2*math.acos(min(1., product)) + return position, orientation + + def _range_failure(self, targets, *, margin): + return any(not low+margin-1e-12 <= value <= high-margin+1e-12 + for value, (low, high) in zip(targets, self.joint_limits)) + + def _refuse_bound(self, observation, reason): + self.calculation['refused_bounds'] = reason + return self._terminal_hold(observation, reason) + + def _admit(self, observation, kind): + if self.last_requested is None: + return self._refuse_bound(observation, 'missing_prior_issued_target') + issued = probe._vector(self.last_requested['joint_targets_rad'], 'prior issued targets', 6) + jaw = self._jaw_to_freeze(observation) + self.hold_reference = { + 't_seconds': observation['t_seconds'], 'position_m': list(observation['tcp_position_m']), + 'quaternion_wxyz': list(observation['tcp_quaternion_wxyz']), + 'issued_joint_targets_rad': issued, 'measured_joint_position_rad': list(observation['joint_position_rad']), + 'frozen_jaw_target_m': jaw, 'raw_pose_joint_targets_rad': None, 'fixed_bias_rad': None, + } + if self.retention_variant == 'cartesian': + try: + raw = self.env.pose_action(self.hold_reference['position_m'], self.hold_reference['quaternion_wxyz'], jaw) + raw = probe._vector(raw['joint_targets_rad'], 'admission pose action', 6) + except (ValueError, TypeError, KeyError, ArithmeticError): + return self._refuse_bound(observation, 'pose_solver_refused') + bias = [target-current for target, current in zip(issued, raw)] + self.hold_reference.update(raw_pose_joint_targets_rad=raw, fixed_bias_rad=bias) + self.calculation = {'raw_pose_joint_targets_rad': raw, 'desired_correction_rad': [0.]*6, + 'correction_rate_limited_axes': [], 'refused_bounds': None} + if max(map(abs, bias)) > INITIAL_BIAS_CEILING_RAD+1e-12: + return self._refuse_bound(observation, 'initial_bias_exhausted') + if self._range_failure(issued, margin=JOINT_MARGIN_RAD): + return self._refuse_bound(observation, 'native_joint_margin_exhausted') + action = super()._admit(observation, kind) + if self.retention_variant != 'measured_joint': + # Explicitly reuse the prior vector: raw+bias can differ by rounding. + self.stop_targets = list(issued) + action = self._hold() + self.events[-1]['retention_hold_reference'] = deepcopy(self.hold_reference) + self.events[-1]['diagnostic']['decision'] = 'bounded_'+self.retention_variant+'_hold' + return action + + def _hold_check(self, observation): + action = super()._hold_check(observation) + if not self.admitted or self.retention_variant != 'cartesian': + return action + position_error, orientation_error = self._pose_errors(observation) + self.calculation = {'raw_pose_joint_targets_rad': None, 'desired_correction_rad': None, + 'correction_rate_limited_axes': [], 'refused_bounds': None} + if position_error > POSITION_ERROR_CEILING_M+1e-12: + return self._refuse_bound(observation, 'tcp_position_error_exhausted') + if orientation_error > ORIENTATION_ERROR_CEILING_RAD+1e-12: + return self._refuse_bound(observation, 'tcp_orientation_error_exhausted') + if self.final_inspection: + return action + reference = self.hold_reference + try: + raw = self.env.pose_action(reference['position_m'], reference['quaternion_wxyz'], self.frozen_jaw_target) + raw = probe._vector(raw['joint_targets_rad'], 'retention pose action', 6) + except (ValueError, TypeError, KeyError, ArithmeticError): + return self._refuse_bound(observation, 'pose_solver_refused') + # raw+bias-issued == raw-raw_at_admission; keep the fixed bias exactly. + desired = [value-initial for value, initial in zip(raw, reference['raw_pose_joint_targets_rad'])] + self.calculation.update(raw_pose_joint_targets_rad=raw, desired_correction_rad=desired) + if max(map(abs, desired)) > CORRECTION_CEILING_RAD+1e-12: + return self._refuse_bound(observation, 'joint_correction_exhausted') + step = CORRECTION_RATE_RAD_S*probe.DT + limited = [index for index, (wanted, prior) in enumerate(zip(desired, self.correction)) + if abs(wanted-prior) > step+1e-12] + correction = [prior+max(-step, min(step, wanted-prior)) for wanted, prior in zip(desired, self.correction)] + targets = [baseline+value for baseline, value in zip(reference['issued_joint_targets_rad'], correction)] + self.calculation['correction_rate_limited_axes'] = limited + if self._range_failure(targets, margin=JOINT_MARGIN_RAD): + return self._refuse_bound(observation, 'native_joint_margin_exhausted') + self.correction, self.stop_targets = correction, targets + self.diagnostic['decision'] = 'bounded_cartesian_pose_regulation' + return self._hold() + + def _record_hold(self, observation, action, *, final=False): + if self.hold_reference is None and self.status not in ('invalidated_hold', 'timed_out_hold'): + return + reference = self.hold_reference or {} + position_error, orientation_error = self._pose_errors(observation) + self.hold_records.append({ + 't_seconds': observation['t_seconds'], 'final_observation': final, 'command_issued': not final, + 'status': self.status, 'admitted': self.admitted, 'variant': self.retention_variant, + 'reference_position_m': reference.get('position_m'), 'reference_quaternion_wxyz': reference.get('quaternion_wxyz'), + 'baseline_issued_joint_targets_rad': reference.get('issued_joint_targets_rad'), + 'joint_targets_rad': list(action['joint_targets_rad']) if action is not None else None, + 'frozen_jaw_target_m': self.frozen_jaw_target, + 'raw_pose_joint_targets_rad': deepcopy(self.calculation.get('raw_pose_joint_targets_rad')), + 'fixed_bias_rad': reference.get('fixed_bias_rad'), + 'desired_correction_rad': deepcopy(self.calculation.get('desired_correction_rad')), + 'correction_rad': list(self.correction) if self.admitted and self.retention_variant == 'cartesian' else None, + 'correction_rate_limited': bool(self.calculation.get('correction_rate_limited_axes')), + 'correction_rate_limited_axes': list(self.calculation.get('correction_rate_limited_axes', [])), + 'tcp_position_error_m': position_error, 'tcp_orientation_error_rad': orientation_error, + 'refused_bounds': self.calculation.get('refused_bounds'), 'terminal_reason': self.abort_reason, + }) + + def __call__(self, observation): + if self.input_refused: + raise ValueError('Retention input refusal is terminal; no further command may be issued') + # A stopped boundary must not inherit a preceding boundary's IK solve + # or rate-limiting flags and present them as current computations. + self.calculation = {} + action = super().__call__(observation) + self._record_hold(observation, action) + return action + + def observe_final(self, observation): + if self.input_refused: + raise ValueError('Retention input refusal is terminal; final state is unknown') + self.calculation = {} + self.final_inspection = True + try: + super().observe_final(observation) + finally: + self.final_inspection = False + self._record_hold(observation, self.last_requested, final=True) + return self.summary() + + def summary(self): + result = super().summary() + result.update(schema=SCHEMA, variant=self.retention_variant, acquisition_variant='evidence', + acquisition_contract=result['contract'], contract=retention_contract()) + result['retention'] = deepcopy({'variant': self.retention_variant, 'hold_reference': self.hold_reference, + 'hold_records': self.hold_records, 'input_refusals': self.input_refusals, + 'native_joint_control_limits_rad': self.joint_limits}) + return result diff --git a/src/dvidia_training/piper_retention_benchmark.py b/src/dvidia_training/piper_retention_benchmark.py new file mode 100644 index 0000000..0fa8209 --- /dev/null +++ b/src/dvidia_training/piper_retention_benchmark.py @@ -0,0 +1,602 @@ +"""Fixed fresh-scene comparison of authored PiPER short-pause hold controls. + +This compares three closed-loop controllers, not learned policies or hardware. +Every attempt, refusal and native row is retained. The primary endpoint is a +continuous five-second native predicate after the first valid admission; +controller status at that endpoint and the final hold are separate outcomes. +""" +from __future__ import annotations + +import argparse +from copy import deepcopy +from dataclasses import asdict, replace +from datetime import datetime, timezone +import gzip +from hashlib import sha256 +import io +import json +from pathlib import Path +import sys +import time +import zipfile + +import numpy as np + +from . import piper_probe_benchmark as prior + +SCHEMA = 'dvidia.piper-retention-study/v1' +VARIANTS = ('measured_joint', 'issued_joint', 'cartesian') +ORDERS = ((0, 1, 2), (1, 2, 0), (2, 0, 1), (2, 1, 0), (1, 0, 2), (0, 2, 1)) +HOLD_S = 5. +FORCE_CEILING_N = 10. +TCP_POSITION_CEILING_M = .004 +TCP_ORIENTATION_CEILING_RAD = .05 +BOUND_REFUSALS = frozenset(('initial_bias_exhausted', 'native_joint_margin_exhausted', + 'tcp_position_error_exhausted', 'tcp_orientation_error_exhausted', 'joint_correction_exhausted', + 'pose_solver_refused', 'missing_prior_issued_target')) +MAX_FILE = 32*1024*1024 +SCOPE = ('Authored synthetic PiPER probe and short-pause controls with privileged simulator state. ' + 'Fresh finite development cells; no learning, full placement, physical sensing or hardware qualification.') +encoded, digest, write, read = prior.encoded, prior.digest, prior.write, prior.read + + +def case_definitions(): + """Pure fixed cells/order; no native imports, asset fetch or parameter search.""" + from .piper_task import PiperTaskConfig + base, cases = PiperTaskConfig(), [] + for mass in (.045, .055): + for friction in (.75, .50, .20): + for layout, start in enumerate(((.303, -.026), (.307, -.037))): + index = len(cases) + config = replace(base, mass_kg=mass, friction=friction, object_position_m=(*start, .082)) + cases.append({'id': f'm{round(mass*1000):02}-f{round(friction*100):02}-p{layout}', + 'seed': 311100+index, 'config': asdict(config), + 'variant_order': [VARIANTS[i] for i in ORDERS[index % len(ORDERS)]]}) + return cases + + +def roster(protocol): + return [(case, variant) for case in protocol['cases'] for variant in case['variant_order']] + + +def make_protocol(assets): + from .robot_profiles import verify_assets, LOCK_PATH + from .piper_retention import retention_contract + return {'schema': SCHEMA, 'scope': SCOPE, 'physical_robot_ready': False, 'learned_policy': False, + 'cohort': 'fresh_development_method_check', 'cases': case_definitions(), 'variants': list(VARIANTS), + 'planned_attempts': 36, 'source_sha256': prior.sources(), 'runtime': prior.runtime(), + 'asset_provenance': verify_assets('piper', assets), + 'asset_lock_sha256': sha256(LOCK_PATH.read_bytes()).hexdigest(), + 'controller_contract': retention_contract(), 'force_ceiling_n': FORCE_CEILING_N, + 'force_ceiling_scope': 'Unchanged arbitrary synthetic admission ceiling, not measured damage calibration or a force command.', + 'primary_endpoint': {'duration_s': HOLD_S, 'native_dt_s': .001, + 'definition': 'Correct 100 ms native admission prefix, then every native predicate sample through the next 5 s; complete valid warning-free run, no unexpected contact anywhere in the run and no force-ceiling violation on any subsequent hold row.', + 'predicate': 'Both contact counts positive, both pad forces strictly >0.01 N and <=10 N including the admission-prefix window, no table support or unexpected contact, rise >=0.006 m from this probe origin and TCP-local drift <=0.003 m; distance epsilon1e-12.', + 'missing_followup': 'No admission or less than five seconds of recorded followup is retained as failure, never padded.', + 'controller_status': 'Controller retained-at-5s and final hold are reported separately; native success never grants actuator permission.'}, + 'secondary_endpoint': {'name': 'grip_retention_and_tcp_stationarity', + 'duration_s': HOLD_S, 'position_ceiling_m': TCP_POSITION_CEILING_M, + 'orientation_ceiling_rad': TCP_ORIENTATION_CEILING_RAD, + 'definition': 'Primary native grip retention succeeds, and every native TCP pose from admission through five seconds stays within 4 mm and 0.05 rad of the admission pose. Identical bounds for all three controllers, measured from recorded native poses.'}, + 'order': 'Six balanced three-variant permutations repeated twice; each variant occupies each position four times. One attempt per cell/variant; no automatic resume, retry of a trial, selection or tuning.', + 'comparison': 'Shared acquisition native prefixes must be exactly equal through the earliest common treatment boundary: an admission or an explicit pre-admission hold-bound refusal in any variant. Commands strictly before that boundary must match. If no treatment begins, compare the full traces. Closed-loop hold controls may then change trajectories. These finite cells do not estimate population accuracy or isolate arbitrary physical mechanisms.', + 'timing': 'Unpaced shared-host policy-call and full control-step wall/CPU compute; step includes dense telemetry. Reset/compile and evidence serialization are separate from step samples. No sensor/hardware latency or real-time guarantee.'} + + +def prepare(output, assets): + output = Path(output) + if output.exists() or output.is_symlink(): + raise ValueError('Choose a fresh output directory; no evidence is overwritten.') + protocol = make_protocol(assets) + output.mkdir(parents=True) + write(output/'protocol.json', protocol) + write(output/'frozen.json', {'created_at_utc': datetime.now(timezone.utc).isoformat(), + 'protocol_sha256': sha256((output/'protocol.json').read_bytes()).hexdigest()}) + sources = output/'sources'; sources.mkdir() + for name, expected in protocol['source_sha256'].items(): + raw = Path(__file__).with_name(name).read_bytes() + if sha256(raw).hexdigest() != expected: + raise ValueError('Source changed during preparation.') + (sources/name).write_bytes(raw) + return protocol + + +def _timing(values): + result = prior._timing(values) + if result is not None: + result['p99_s'] = float(np.percentile(values, 99)) + return result + + +def _attempt(assets, case, variant): + from .piper_task import PiperTaskConfig + from .piper_feedback_env import FeedbackPiperTaskEnv + from .piper_retention import RetentionPolicy + from .reference_sim import model_digest + env = FeedbackPiperTaskEnv(assets, PiperTaskConfig(**case['config'])) + result = {'schema': SCHEMA, 'id': case['id']+'-'+variant, 'scene_id': case['id'], 'variant': variant, + 'seed': case['seed'], 'config': case['config'], 'valid': False, 'physical_robot_ready': False, + 'trajectory': [], 'actions': [], 'error': None, 'metrics': {}, 'policy': None} + timers = {name: [] for name in ('policy_wall', 'policy_cpu', 'step_wall', 'step_cpu')} + policy = None + began = time.perf_counter() + try: + start = time.perf_counter() + obs, info = env.reset(case['seed']) + result['setup_reset_wall_s'] = time.perf_counter()-start + result.update(initial_object_position_m=env.initial_object_position.tolist(), + compiled_model_sha256=model_digest(env.model), + native_force_limits=env.model.actuator_forcerange.tolist()) + policy = RetentionPolicy(env, variant=variant) + result['trajectory'].append(obs) + for _ in range(round(env.config.timeout_s/env.config.control_dt)): + wall, cpu = time.perf_counter(), time.process_time() + action = policy(obs) + timers['policy_wall'].append(time.perf_counter()-wall) + timers['policy_cpu'].append(time.process_time()-cpu) + record = {'t_seconds': obs['t_seconds'], **action} + result['actions'].append(record) + obs.update(command=record, issued_joint_targets_rad=list(action['joint_targets_rad']), + issued_gripper_position_m=action['gripper_position_m']) + wall, cpu = time.perf_counter(), time.process_time() + obs, info = env.step(action) + timers['step_wall'].append(time.perf_counter()-wall) + timers['step_cpu'].append(time.process_time()-cpu) + if obs is not None: + result['trajectory'].append(obs) + if env.done: + break + if obs is not None: + policy.observe_final(obs) + result.update(valid=info['valid'], metrics=info, policy=policy.summary()) + except Exception as exc: + result['error'] = {'type': type(exc).__name__, 'message': str(exc)[:300]} + if env.ready: + result['metrics'] = env.info() + if policy is not None: + try: + result['policy'] = policy.summary() + except Exception as summary_exc: + result['policy_summary_error'] = {'type': type(summary_exc).__name__, + 'message': str(summary_exc)[:300]} + result['timing'] = {'attempt_wall_s': time.perf_counter()-began, + 'summaries': {name: _timing(values) for name, values in timers.items()}, 'samples': timers} + return result, env.native_arrays(), env.native_metadata() + + +def _index(timestamp, times): + index = round(timestamp/.001)-1 + if not 0 <= index < len(times) or abs(times[index]-timestamp) > 1e-8: + raise ValueError('Expected an observed native timestamp.') + return index + + +def probe_series(arrays, probe, force_ceiling_n=FORCE_CEILING_N): + """Pure causal native predicate; independent of controller status labels.""" + times = arrays['t_seconds'] + origin = _index(probe['start_time_s'], times) + if (np.linalg.norm(arrays['object_position_m'][origin]-probe['object_origin_m']) > 1e-9 + or np.linalg.norm(arrays['object_minus_tcp_local_m'][origin]-probe['local_offset_origin_m']) > 1e-9): + raise ValueError('Probe origin differs from native evidence.') + rise = arrays['object_position_m'][:, 2]-probe['object_origin_m'][2] + drift = np.linalg.norm(arrays['object_minus_tcp_local_m']-probe['local_offset_origin_m'], axis=1) + condition = ((np.arange(len(times)) > origin) + & np.all(arrays['grasp_contact_counts'] > 0, axis=1) + & np.all(arrays['pad_normal_forces_n'] > .01, axis=1) + & np.all(arrays['pad_normal_forces_n'] <= force_ceiling_n, axis=1) + & (arrays['table_object_contact_count'] == 0) + & (arrays['unexpected_contact_count'] == 0) + & (rise+1e-12 >= .006) & (drift <= .003+1e-12)) + streak = np.empty(len(times), dtype=np.int64) + count = 0 + for index, good in enumerate(condition): + count = count+1 if good else 0 + streak[index] = count + return condition, streak, streak >= 100, rise, drift + + +def _decomposition(arrays, start, end): + obj = arrays['object_position_m'][end]-arrays['object_position_m'][start] + tcp = arrays['tcp_position_m'][end]-arrays['tcp_position_m'][start] + return {'object_world_z_delta_m': float(obj[2]), 'tcp_world_z_delta_m': float(tcp[2]), + 'relative_world_z_delta_m': float(obj[2]-tcp[2]), + 'local_offset_change_m': float(np.linalg.norm(arrays['object_minus_tcp_local_m'][end]-arrays['object_minus_tcp_local_m'][start])), + 'elapsed_s': (end-start)*.001, + 'scope': 'Kinematic components, not causal attribution or calibrated physical slip.'} + + +def _stationarity(arrays, start, end, enough, grip_success): + positions = arrays['tcp_position_m'][start:end+1] + quaternions = arrays['tcp_quaternion_wxyz'][start:end+1] + displacement = np.linalg.norm(positions-positions[0], axis=1) + # q and -q are the same orientation; shortest quaternion angular distance. + angles = 2*np.arccos(np.clip(np.abs(quaternions @ quaternions[0]), 0., 1.)) + max_position, max_angle = float(np.max(displacement)), float(np.max(angles)) + within = max_position <= TCP_POSITION_CEILING_M+1e-12 and max_angle <= TCP_ORIENTATION_CEILING_RAD+1e-12 + return {'required_s': HOLD_S, 'complete_5s': bool(enough), + 'position_ceiling_m': TCP_POSITION_CEILING_M, + 'orientation_ceiling_rad': TCP_ORIENTATION_CEILING_RAD, + 'maximum_tcp_position_excursion_m': max_position, + 'maximum_tcp_orientation_excursion_rad': max_angle, + 'terminal_tcp_position_delta_m': (positions[-1]-positions[0]).tolist(), + 'terminal_tcp_orientation_delta_rad': float(angles[-1]), + 'tcp_pose_within_bounds': bool(enough and within), + 'grip_retention_and_stationarity': bool(grip_success and enough and within), + 'scope': 'Common recorded native TCP pose endpoint, not calibrated hardware stationarity.'} + + +def _corrections(policy): + retention = (policy or {}).get('retention') or {} + records = retention.get('hold_records', []) + flags, refusals = {}, {} + active, desired, axis_count = [], [], 0 + for record in records: + for key, value in record.items(): + if type(value) is bool and ('saturat' in key or 'limit' in key or 'refus' in key): + flags[key] = flags.get(key, 0)+int(value) + if record.get('refused_bounds') is not None: + reason = record['refused_bounds'] + refusals[reason] = refusals.get(reason, 0)+1 + axis_count += len(record.get('correction_rate_limited_axes') or []) + for key, destination in (('correction_rad', active), ('desired_correction_rad', desired)): + if record.get(key) is not None: + vector = np.asarray(record[key], dtype=float) + if vector.shape != (6,) or not np.isfinite(vector).all(): + raise ValueError('Malformed correction diagnostic.') + destination.append(vector) + return {'hold_record_count': len(records), 'flag_counts': flags, + 'bound_refusal_counts': refusals, 'rate_limited_axis_occurrences': axis_count, + 'maximum_abs_active_correction_rad': float(np.max(np.abs(active))) if active else None, + 'maximum_abs_desired_correction_rad': float(np.max(np.abs(desired))) if desired else None, + 'input_refusals': deepcopy(retention.get('input_refusals', [])), + 'scope': 'Includes recorded final diagnostic checks; only command_issued rows represent issued controls.'} + + +def evaluate(result, arrays, metadata, protocol): + """Retain invalid/refused attempts, with their primary endpoint false.""" + from .probe_evidence import _arrays + base = {'status': 'refused', 'primary': {'required_s': HOLD_S, 'complete_5s': False, + 'native_pause_success': False, 'success': False, 'controller_retained_at_5s': False, + 'combined_qualified_pause': False, 'reason': 'invalid_or_incomplete_attempt'}, + 'first_admission': None, 'probes': [], 'final': {'controller_qualified': False, + 'native_qualified': False, 'both_qualified': False, 'evidence_admissible': False}, + 'stationarity': {'complete_5s': False, 'tcp_pose_within_bounds': False, + 'grip_retention_and_stationarity': False}, + 'decomposition': None, 'corrections': _corrections(result.get('policy')), + 'physical_robot_ready': False} + try: + data, array_digest = _arrays(arrays, metadata) + except (ValueError, TypeError, KeyError) as exc: + base['reason'] = 'native_sidecar_refused: '+str(exc) + return base + metrics, policy = result['metrics'], result.get('policy') + complete = (len(result['actions']) == 1000 and len(result['trajectory']) == 1001 + and len(data['t_seconds']) == 20000) + critical = int(np.count_nonzero(data['unexpected_contact_count'])) + max_force = float(np.max(data['pad_normal_forces_n'])) + native_valid = (result['valid'] and result['error'] is None and metrics.get('valid') is True + and not metrics.get('warnings') and complete + and critical == 0 and critical == metrics.get('unexpected_contact_native_ticks')) + base['native'] = {'arrays_sha256': array_digest, 'rows': len(data['t_seconds']), 'complete': complete, + 'valid': bool(native_valid), 'warnings': metrics.get('warnings'), + 'unexpected_contact_native_ticks': critical, 'maximum_per_finger_force_n': max_force} + if not native_valid or policy is None: + return base + times, by_probe = data['t_seconds'], {} + for probe in policy['probes']: + condition, streak, qualified, rise, drift = probe_series(data, probe, protocol['force_ceiling_n']) + by_probe[probe['probe_index']] = (probe, condition, streak, qualified, rise, drift) + events = [event for event in policy['events'] if event['type'] == 'evidence_qualified' + and event['probe_index'] == probe['probe_index']] + claims = [{'t_seconds': event['t_seconds'], 'native_prefix_qualified': bool(qualified[_index(event['t_seconds'], times)])} + for event in events] + base['probes'].append({'probe_index': probe['probe_index'], 'start_time_s': probe['start_time_s'], + 'disposition': probe['disposition'], 'claims': claims, + 'final_native_qualified_from_origin': bool(qualified[-1])}) + final_qualified = bool(by_probe[policy['probes'][-1]['probe_index']][3][-1]) if policy['probes'] else False + base['final'].update(controller_qualified=bool(policy['admitted']), native_qualified=final_qualified, + both_qualified=bool(policy['admitted'] and final_qualified)) + try: + base['receipt'] = prior._evaluate(result, data, metadata, protocol) + base['final']['evidence_admissible'] = bool((base['receipt'].get('verification') or {}).get('admissible')) + except (ValueError, TypeError, KeyError) as exc: + base['receipt'] = {'status': 'audit_refused', 'reason': str(exc)[:300]} + admissions = [event for event in policy['events'] if event['type'] == 'evidence_qualified'] + base['status'] = 'inspected' + if not admissions: + base['primary']['reason'] = 'no_admission' + return base + event = admissions[0] + probe, condition, streak, qualified, rise, drift = by_probe[event['probe_index']] + start = _index(event['t_seconds'], times) + target = event['t_seconds']+HOLD_S + enough = target <= times[-1]+1e-8 + end = _index(target, times) if enough else len(times)-1 + correct = bool(qualified[start]) + continuous = bool(np.all(condition[start:end+1])) + # Every actual hold row must respect the unchanged synthetic ceiling, + # including rows after the primary short-pause endpoint. + ceiling_ok = bool(np.all(data['pad_normal_forces_n'][start:] <= protocol['force_ceiling_n'])) + checks = [check for check in policy['qualification_checks'] if abs(check['t_seconds']-target) <= 1e-8 + and check['probe_index'] == event['probe_index']] + retained = enough and len(checks) == 1 and checks[0]['qualified'] is True + success = correct and enough and continuous and ceiling_ok + reason = ('false_initial_admission' if not correct else 'insufficient_followup' if not enough else + 'native_predicate_lost' if not continuous else 'force_ceiling_exceeded' if not ceiling_ok else 'success') + base['first_admission'] = {'time_s': event['t_seconds'], 'probe_index': event['probe_index'], + 'native_prefix_qualified': correct, 'native_qualifying_window_s': int(streak[start])*.001} + failures = np.flatnonzero(~condition[start:end+1]) + base['primary'] = {'required_s': HOLD_S, 'target_time_s': target, + 'followup_available_s': float(times[-1]-event['t_seconds']), 'complete_5s': bool(enough), + 'native_pause_success': bool(success), 'success': bool(success), + 'controller_retained_at_5s': bool(retained), + 'combined_qualified_pause': bool(success and retained), + 'all_native_hold_forces_within_ceiling': ceiling_ok, + 'first_native_predicate_failure_s': float(times[start+int(failures[0])]) if failures.size else None, + 'reason': reason} + base['decomposition'] = _decomposition(data, start, end) + base['stationarity'] = _stationarity(data, start, end, enough, success) + return base + + +def _compact_policy(policy): + if policy is None: + return None + result = {key: deepcopy(value) for key, value in policy.items() + if key not in ('requested_controls', 'qualification_checks')} + if 'retention' in result: + result['retention'].pop('hold_records', None) + return result + + +def aggregates(rows): + result = {} + for variant in VARIANTS: + selected = [row for row in rows if row['variant'] == variant] + admissions = [row['evaluation']['first_admission'] for row in selected if row['evaluation']['first_admission'] is not None] + result[variant] = {'attempts': len(selected), 'valid': sum(row['valid'] for row in selected), + 'admissions': len(admissions), + 'false_initial_admissions': sum(not row['native_prefix_qualified'] for row in admissions), + 'native_5s_pause_success': sum(row['evaluation']['primary']['success'] for row in selected), + 'controller_retained_at_5s': sum(row['evaluation']['primary']['controller_retained_at_5s'] for row in selected), + 'combined_qualified_5s_pause': sum(row['evaluation']['primary']['combined_qualified_pause'] for row in selected), + 'grip_retention_and_stationarity': sum(row['evaluation']['stationarity']['grip_retention_and_stationarity'] for row in selected), + 'final_both_qualified': sum(row['evaluation']['final']['both_qualified'] for row in selected)} + return result + + +def run(output, assets): + output = Path(output) + protocol = read(output/'protocol.json') + if encoded(protocol) != encoded(make_protocol(assets)): + raise ValueError('Frozen protocol/source/runtime/assets changed; create a new study.') + write(output/'started.json', {'protocol_sha256': sha256((output/'protocol.json').read_bytes()).hexdigest(), + 'started_at_utc': datetime.now(timezone.utc).isoformat()}) + rows = [] + for case, variant in roster(protocol): + if prior.sources() != protocol['source_sha256']: + raise ValueError('Source changed during the fixed study.') + folder = output/(case['id']+'-'+variant); folder.mkdir() + write(folder/'started.json', {'scene_id': case['id'], 'variant': variant, + 'protocol_sha256': sha256((output/'protocol.json').read_bytes()).hexdigest()}) + result, arrays, metadata = _attempt(assets, case, variant) + (folder/'episode.json.gz').write_bytes(gzip.compress(encoded(result), mtime=0)) + with (folder/'native.npz').open('xb') as stream: + np.savez_compressed(stream, **arrays) + write(folder/'native.json', metadata) + try: + evaluation = evaluate(result, arrays, metadata, protocol) + except Exception as exc: + evaluation = {'status': 'audit_refused', 'reason': type(exc).__name__+': '+str(exc)[:300], + 'primary': {'success': False, 'controller_retained_at_5s': False, + 'combined_qualified_pause': False}, + 'first_admission': None, 'final': {'both_qualified': False}, 'physical_robot_ready': False} + evaluation['stationarity'] = {'grip_retention_and_stationarity': False} + write(folder/'evaluation.json', evaluation) + files = {path.name: {'bytes': path.stat().st_size, 'sha256': sha256(path.read_bytes()).hexdigest()} + for path in sorted(folder.iterdir())} + row = {'id': result['id'], 'scene_id': case['id'], 'variant': variant, + 'mass_kg': case['config']['mass_kg'], 'friction': case['config']['friction'], + 'valid': result['valid'], 'error': result['error'], 'metrics': result['metrics'], + 'policy': _compact_policy(result.get('policy')), 'timing': result['timing']['summaries'], + 'evaluation': evaluation, 'files': files} + rows.append(row) + write(folder/'summary.json', row) + print(json.dumps({'attempt': result['id'], 'valid': result['valid'], 'primary': evaluation['primary']['success']}), flush=True) + if prior.sources() != protocol['source_sha256']: + raise ValueError('Source changed during an attempt; all evidence remains retained.') + if prior.runtime() != protocol['runtime']: + raise ValueError('Runtime changed during the study.') + report = {'schema': SCHEMA, 'scope': SCOPE, 'planned_attempts': 36, 'attempted': len(rows), + 'valid_attempts': sum(row['valid'] for row in rows), 'rows': rows, 'aggregates': aggregates(rows), + 'physical_robot_ready': False, 'source_sha256': protocol['source_sha256'], + 'previous_learned_heldout_result': '5/12 unchanged. No learning or full-placement evaluation in this study.'} + write(output/'report.json', report) + return report + + +def _read_native(path): + with zipfile.ZipFile(path) as archive: + entries = archive.infolist() + if (len(entries) != 22 or len({entry.filename for entry in entries}) != 22 + or sum(entry.file_size for entry in entries) > MAX_FILE + or any(entry.file_size > 2*1024*1024 or '/' in entry.filename or not entry.filename.endswith('.npy') for entry in entries)): + raise ValueError('Native NPZ exceeds the fixed numeric contract.') + with np.load(path, allow_pickle=False) as native: + return {name: native[name] for name in native.files} + + +def _controls(episode, arrays): + actions, trajectory = episode['actions'], episode['trajectory'] + if len(actions) != 1000 or len(trajectory) != 1001 or len(arrays['t_seconds']) != 20000: + raise ValueError('Complete control reconstruction requires 1000 actions/20000 native rows.') + requested = episode['policy']['requested_controls'] + if len(requested) != 1000: + raise ValueError('Missing policy control records.') + control = np.array(trajectory[0]['native_actuator_controls'], dtype=float) + maximum = np.array([episode['config']['joint_target_rate_rad_s']*.001]*6+[episode['config']['jaw_target_rate_m_s']*.001]) + for i, (action, request) in enumerate(zip(actions, requested)): + if (abs(action['t_seconds']-i*.02) > 1e-8 or request['index'] != i + or any(action[key] != request[key] for key in ('t_seconds', 'joint_targets_rad', 'gripper_position_m')) + or trajectory[i]['command'] != action + or trajectory[i]['issued_joint_targets_rad'] != action['joint_targets_rad'] + or trajectory[i]['issued_gripper_position_m'] != action['gripper_position_m']): + raise ValueError('Issued action/boundary/policy records disagree.') + target = np.array(action['joint_targets_rad']+[action['gripper_position_m']]) + for tick in range(i*20, (i+1)*20): + control += np.clip(target-control, -maximum, maximum) + actual = np.r_[arrays['arm_actuator_controls_rad'][tick], arrays['applied_jaw_target_m'][tick]] + if (not np.allclose(control, actual, rtol=0, atol=1e-12) + or arrays['requested_jaw_target_m'][tick] != action['gripper_position_m']): + raise ValueError('Native applied targets differ from unchanged slew law.') + for field in ('object_position_m', 'joint_position_rad', 'tcp_position_m'): + if not np.allclose(trajectory[i+1][field], arrays[field][(i+1)*20-1], rtol=0, atol=1e-9): + raise ValueError('Boundary state differs from native record.') + if any(trajectory[-1][key] is not None for key in ('command', 'issued_joint_targets_rad', 'issued_gripper_position_m')): + raise ValueError('Terminal observation contains an unexecuted command.') + + +def _treatment_boundary(policy): + events = (policy or {}).get('events', []) + admissions = [event for event in events if event['type'] == 'evidence_qualified'] + admission = min((event['t_seconds'] for event in admissions), default=None) + candidates = [] + for event in events: + if event['type'] == 'evidence_qualified': + reason = 'admission' + elif (event['type'] == 'hold_invalidated' and event.get('reason') in BOUND_REFUSALS + and (admission is None or event['t_seconds'] <= admission+1e-8)): + reason = 'pre_admission_bound_refusal' + else: + continue + candidates.append({'time_s': event['t_seconds'], 'reason': reason, 'detail': event.get('reason')}) + return min(candidates, key=lambda row: row['time_s']) if candidates else None + + +def _common_cutoff(rows): + boundaries = [] + for row in rows: + boundary = _treatment_boundary(row.get('policy')) + if boundary is not None: + boundaries.append({'variant': row['variant'], **boundary}) + boundaries.sort(key=lambda row: row['variant']) + timestamp = min((row['time_s'] for row in boundaries), default=None) + reasons = sorted({row['reason'] for row in boundaries if abs(row['time_s']-timestamp) <= 1e-8}) if timestamp is not None else [] + return {'time_s': timestamp, 'reason': '+'.join(reasons) if reasons else 'full_run_no_treatment', + 'treatment_boundaries': boundaries} + + +def _prefix(episode, arrays, *, cutoff=None): + if cutoff is None: + cutoff = _common_cutoff([episode]) + timestamp = cutoff['time_s'] + count = _index(timestamp, arrays['t_seconds'])+1 if timestamp is not None else len(arrays['t_seconds']) + digest_native = sha256() + for name in sorted(arrays): + value = np.ascontiguousarray(arrays[name][:count]) + digest_native.update(encoded({'name': name, 'shape': list(value.shape), 'dtype': str(value.dtype)})) + digest_native.update(value.tobytes()) + actions = [row for row in episode['actions'] if timestamp is None or row['t_seconds'] < timestamp-1e-8] + return {'cutoff_time_s': timestamp, 'cutoff_reason': cutoff['reason'], + 'native_rows': count, 'native_sha256': digest_native.hexdigest(), + 'commands': len(actions), 'commands_sha256': digest(actions)} + + +def inspect(output): + from .piper_grip_benchmark import decode_attempt + from .probe_evidence import _arrays + output = Path(output) + protocol_path = output/'protocol.json' + if protocol_path.is_symlink() or not protocol_path.is_file() or protocol_path.stat().st_size > 16*1024*1024: + raise ValueError('Expected a bounded plain frozen protocol.') + with protocol_path.open('rb') as stream: + raw_protocol = stream.read(16*1024*1024+1) + if (len(raw_protocol) > 16*1024*1024 + or sha256(raw_protocol).hexdigest() != read(output/'frozen.json')['protocol_sha256']): + raise ValueError('Frozen protocol digest changed.') + protocol, report = json.loads(raw_protocol), read(output/'report.json') + if prior.sources() != protocol['source_sha256'] or report['source_sha256'] != protocol['source_sha256']: + raise ValueError('Inspect with the frozen implementation.') + planned = roster(protocol) + ids = [case['id']+'-'+variant for case, variant in planned] + if ([row['id'] for row in report['rows']] != ids or len(ids) != 36 + or report['attempted'] != 36 or protocol['planned_attempts'] != 36): + raise ValueError('Attempt roster/denominator differs from frozen protocol.') + checked, prefixes = [], {} + cutoffs = {case['id']: _common_cutoff([row for row in report['rows'] if row['scene_id'] == case['id']]) + for case in protocol['cases']} + for row, (case, variant) in zip(report['rows'], planned): + folder = output/row['id'] + if set(row['files']) != {'started.json', 'episode.json.gz', 'native.npz', 'native.json', 'evaluation.json'}: + raise ValueError('Unexpected attempt artifact roster.') + for name, expected in row['files'].items(): + path = folder/name + if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_FILE: + raise ValueError('Expected bounded plain evidence file.') + raw = path.read_bytes() + if {'bytes': len(raw), 'sha256': sha256(raw).hexdigest()} != expected: + raise ValueError('Frozen artifact digest changed.') + episode = json.loads(decode_attempt((folder/'episode.json.gz').read_bytes())) + if (episode['id'] != row['id'] or episode['variant'] != variant or episode['seed'] != case['seed'] + or episode['config'] != case['config'] or episode['metrics'] != row['metrics'] or episode['valid'] != row['valid'] + or encoded(_compact_policy(episode.get('policy'))) != encoded(row['policy'])): + raise ValueError('Episode identity/config/metrics disagree.') + arrays, metadata = _read_native(folder/'native.npz'), read(folder/'native.json') + reevaluated = evaluate(episode, arrays, metadata, protocol) + if encoded(reevaluated) != encoded(row['evaluation']) or encoded(read(folder/'evaluation.json')) != encoded(reevaluated): + raise ValueError('Recomputed endpoint/receipt differs.') + native_validated, reconstruction, reason = False, False, None + try: + _arrays(arrays, metadata) + native_validated = True + if episode['valid'] and episode['error'] is None: + _controls(episode, arrays) + reconstruction = True + prefixes.setdefault(case['id'], []).append((variant, _prefix(episode, arrays, cutoff=cutoffs[case['id']]))) + except (ValueError, TypeError, KeyError) as exc: + reason = str(exc) + if episode['valid'] and episode['error'] is None: + raise ValueError('Valid attempt failed native readback: '+reason) from exc + checked.append({'id': row['id'], 'native_rows': len(arrays.get('t_seconds', [])), + 'native_validated': native_validated, 'controls_recomputed': reconstruction, + 'retained_refusal': not reconstruction, 'reason': reason, 'evaluation_recomputed': True}) + prefix_checks = [] + for case in protocol['cases']: + group = prefixes.get(case['id'], []) + if len(group) == 3: + base = group[0][1] + if any(encoded(value) != encoded(base) for _, value in group[1:]): + raise ValueError('Acquisition prefixes differ before any hold treatment.') + prefix_checks.append({'scene_id': case['id'], 'verified': True, **base, + 'treatment_boundaries': cutoffs[case['id']]['treatment_boundaries']}) + else: + prefix_checks.append({'scene_id': case['id'], 'verified': False, 'reason': 'invalid_attempt_prevents_complete_triplet'}) + if encoded(aggregates(report['rows'])) != encoded(report['aggregates']): + raise ValueError('Aggregate counts differ from retained rows.') + return {'schema': 'dvidia.piper-retention-inspection/v1', 'attempts': len(checked), + 'native_rows': sum(row['native_rows'] for row in checked), 'rows': checked, + 'acquisition_prefixes': prefix_checks, 'aggregates': report['aggregates'], 'physical_robot_ready': False, + 'scope': 'Read-only recorded consistency and causal native predicate reconstruction; hashes do not authenticate external data or hardware.'} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='command', required=True) + for name in ('prepare', 'run'): + command = sub.add_parser(name) + command.add_argument('--output', type=Path, required=True) + command.add_argument('--assets', type=Path, required=True) + command = sub.add_parser('inspect'); command.add_argument('--output', type=Path, required=True) + args = parser.parse_args(argv) + try: + result = inspect(args.output) if args.command == 'inspect' else globals()[args.command](args.output, args.assets) + print(json.dumps(result if args.command == 'inspect' else {'schema': SCHEMA, 'planned_attempts': 36, + 'physical_robot_ready': False}, indent=2)) + return 0 + except (ValueError, OSError, KeyError, ImportError) as exc: + print('Retention study refused: '+str(exc), file=sys.stderr) + return 2 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_piper_retention.py b/tests/test_piper_retention.py new file mode 100644 index 0000000..237caa2 --- /dev/null +++ b/tests/test_piper_retention.py @@ -0,0 +1,231 @@ +"""Synthetic retention-controller boundaries; no native physics or hardware.""" +from copy import deepcopy +import math +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from dvidia_training import piper_retention as hold +from dvidia_training import piper_task + + +class Environment: + def __init__(self): + self.config = SimpleNamespace(control_dt=.02, timeout_s=20., tool_orientation_wxyz=[1.,0.,0.,0.], + goal_position_tolerance_m=.025) + self.model = SimpleNamespace(actuator_ctrlrange=[[-2.,2.]]*6+[[0.,.035]]) + self.requests, self.raw = [], None + self.observation = None + + def pose_action(self, position, quaternion, jaw): + self.requests.append((list(position),list(quaternion),jaw)) + # A nonzero moving-goal servo offset becomes zero only at the exact + # admission pose, making issued and measured holds meaningfully differ. + offset = .03 if math.dist(position,self.observation['tcp_position_m']) > 1e-10 else 0. + return {'joint_targets_rad': list(self.raw) if self.raw is not None else [q+offset for q in self.observation['joint_position_rad']], + 'gripper_position_m': .012} + + +class Teacher: + def __init__(self, env): + self.env, self.phase, self.issued_phase, self.ticks = env, 'close', 'close', 35 + self.held_offset, self.grasp_position, self.calls = None, [.3,0.,.082], [] + + def __call__(self, observation): + if self.phase not in ('approach','descend','close'): + raise AssertionError('Full transport must not be dispatched') + self.calls.append(self.phase) + self.issued_phase = self.phase + self.phase, self.ticks = 'lift', 0 + return {'joint_targets_rad': [.05]*6, 'gripper_position_m': .012} + + +def observation(t, *, rise=None, force=.4, supported=False, both=True, slip=0., joints=.02, tcp_offset=0.): + rise = max(0.,min(.01,.02*(t-.02))) if rise is None else rise + return {'t_seconds':t,'joint_position_rad':[joints]*6,'gripper_joint_position_m':[.02,-.02], + 'tcp_position_m':[.3,0.,.082+rise+tcp_offset],'tcp_quaternion_wxyz':[1.,0.,0.,0.], + 'object_position_m':[.3+slip,0.,.08+rise+tcp_offset], 'target_position_m':[.35,.08,.08], + 'grasp_contact_counts':{'left':int(both),'right':int(both)}, + 'pad_normal_forces_n':{'left':force,'right':force},'table_object_contact_count':int(supported), + 'unexpected_contact_count':0,'native_actuator_controls':[0.]*6+[.027]} + + +def make(variant): + env = Environment() + with patch.object(piper_task,'PiperTaskPolicy',Teacher):value=hold.RetentionPolicy(env,variant) + return value,env + + +def call(value,env,obs): + env.observation = obs + return value(obs) + + +def admitted(value,env): + for index in range(40): + action=call(value,env,observation(index*.02)) + if value.admitted:return action + raise AssertionError('Bounded synthetic fixture did not admit') + + +class RetentionTests(unittest.TestCase): + def test_contract_has_fixed_pretrial_limits_and_explicit_scope(self): + contract=hold.retention_contract() + self.assertEqual(contract['variants'],['measured_joint','issued_joint','cartesian']) + self.assertEqual(contract['limits']['joint_correction_ceiling_rad'],.03) + self.assertEqual(contract['limits']['joint_correction_rate_rad_s'],.10) + self.assertFalse(contract['physical_robot_ready']) + for variant in ('evidence',True,None): + with self.assertRaises(ValueError):make(variant) + + def test_prefix_is_exactly_equal_until_admission_and_no_full_teacher_dispatch(self): + values=[make(variant) for variant in hold.VARIANTS] + for index in range(40): + results=[call(value,env,observation(index*.02)) for value,env in values] + if any(value.admitted for value,_ in values): + self.assertTrue(all(value.admitted for value,_ in values));break + self.assertEqual(results[0],results[1]);self.assertEqual(results[0],results[2]) + self.assertTrue(all(value.teacher.calls==['close'] for value,_ in values)) + for value,_ in values: + self.assertEqual(value.admission_time_s,values[0][0].admission_time_s) + + def test_measured_and_issued_freeze_distinct_actual_targets_without_jaw_change(self): + for variant in ('measured_joint','issued_joint'): + value,env=make(variant) + action=admitted(value,env) + expected=([.02]*6 if variant=='measured_joint' else value.hold_reference['issued_joint_targets_rad']) + self.assertEqual(action['joint_targets_rad'],expected) + self.assertEqual(action['gripper_position_m'],.012) + later=call(value,env,observation(value.last_observed_s+.02,joints=.04)) + self.assertEqual(later,action) + if variant=='issued_joint': + self.assertNotEqual(action['joint_targets_rad'],[.02]*6) + + def test_cartesian_admission_is_bumpless_then_correction_has_fixed_bias_and_rate(self): + value,env=make('cartesian');action=admitted(value,env) + reference=value.hold_reference + self.assertEqual(action['joint_targets_rad'],reference['issued_joint_targets_rad']) + self.assertEqual(action['gripper_position_m'],.012) + env.raw=[.03]*6 + corrected=call(value,env,observation(value.last_observed_s+.02)) + self.assertEqual(corrected['joint_targets_rad'],[baseline+.002 for baseline in reference['issued_joint_targets_rad']]) + self.assertEqual(corrected['gripper_position_m'],.012) + record=value.hold_records[-1] + self.assertEqual(record['correction_rate_limited_axes'],list(range(6))) + self.assertEqual(record['correction_rad'],[.002]*6) + self.assertEqual(record['reference_position_m'],reference['position_m']) + self.assertEqual(record['fixed_bias_rad'],reference['fixed_bias_rad']) + + def test_original_loss_and_force_ceiling_terminally_refuse_without_requalification(self): + for mutation,reason in (({'both':False},'bilateral_contact_or_force_loss'), + ({'supported':True},'support_returned'), + ({'force':10.0001},'declared_force_ceiling_exceeded'), + ({'slip':.0031},'relative_slip')): + for variant in hold.VARIANTS: + value,env=make(variant);admitted(value,env) + action=call(value,env,observation(value.last_observed_s+.02,joints=.04,**mutation)) + self.assertFalse(value.admitted) + self.assertEqual(value.abort_reason,reason) + self.assertEqual(action,{'joint_targets_rad':[.04]*6,'gripper_position_m':.012}) + again=call(value,env,observation(value.last_observed_s+.02)) + self.assertEqual(again,action) + self.assertFalse(value.admitted) + + def test_force_ceiling_also_stops_acquisition_before_teacher(self): + value,env=make('cartesian') + action=call(value,env,observation(0.,force=10.001)) + self.assertEqual(value.teacher.calls,[]) + self.assertEqual(action,{'joint_targets_rad':[.02]*6,'gripper_position_m':.027}) + self.assertEqual(value.abort_reason,'declared_force_ceiling_exceeded') + + def test_terminal_records_do_not_relabel_old_ik_or_rate_flags_as_current(self): + value,env=make('cartesian');admitted(value,env) + env.raw=[.03]*6 + call(value,env,observation(value.last_observed_s+.02)) + self.assertTrue(value.hold_records[-1]['correction_rate_limited']) + call(value,env,observation(value.last_observed_s+.02,force=10.001)) + for record in (value.hold_records[-1],): + self.assertIsNone(record['raw_pose_joint_targets_rad']) + self.assertIsNone(record['desired_correction_rad']) + self.assertFalse(record['correction_rate_limited']) + call(value,env,observation(value.last_observed_s+.02)) + self.assertIsNone(value.hold_records[-1]['raw_pose_joint_targets_rad']) + self.assertFalse(value.hold_records[-1]['correction_rate_limited']) + + def test_position_orientation_correction_and_initial_bias_exhaustion_refuse(self): + for bound in ('position','orientation','correction','initial_bias'): + value,env=make('cartesian') + action=admitted(value,env) if bound!='initial_bias' else None + if bound=='initial_bias': + for index in range(40): + if value.qualification_since is not None and index*.02-value.qualification_since >= .1-1e-9: + env.raw=[-.2]*6 + action=call(value,env,observation(index*.02)) + if value.aborted:break + expected='initial_bias_exhausted' + else: + obs=observation(value.last_observed_s+.02) + if bound=='position': + obs=observation(obs['t_seconds'],tcp_offset=.0041) + expected='tcp_position_error_exhausted' + elif bound=='orientation': + angle=.051 + obs['tcp_quaternion_wxyz']=[math.cos(angle/2),math.sin(angle/2),0.,0.] + expected='tcp_orientation_error_exhausted' + else: + env.raw=[.051]*6 + expected='joint_correction_exhausted' + action=call(value,env,obs) + with self.subTest(bound=bound): + self.assertFalse(value.admitted) + self.assertEqual(value.abort_reason,expected) + self.assertEqual(action['joint_targets_rad'],[.02]*6) + self.assertEqual(action['gripper_position_m'],.012) + + def test_native_margin_exhaustion_refuses_instead_of_clipping(self): + value,env=make('cartesian');admitted(value,env) + value.joint_limits=[[.001,.041]]*6 + env.raw=[.022]*6 + action=call(value,env,observation(value.last_observed_s+.02)) + self.assertEqual(value.abort_reason,'native_joint_margin_exhausted') + self.assertFalse(value.admitted) + self.assertEqual(action['joint_targets_rad'],[.02]*6) + + def test_invalid_measurement_refuses_without_new_command_or_untrusted_hold(self): + for bad in ({'joint_position_rad':[float('nan')]*6},{'t_seconds':.01}): + value,env=make('cartesian');admitted(value,env) + obs=observation(value.last_observed_s+.02);obs.update(bad) + before=len(value.requested_controls) + with self.assertRaises(ValueError):call(value,env,obs) + self.assertEqual(len(value.requested_controls),before) + self.assertFalse(value.admitted) + self.assertEqual(value.status,'invalid_measurement_refused') + self.assertFalse(value.input_refusals[-1]['command_issued']) + with self.assertRaises(ValueError):call(value,env,observation(value.last_observed_s+.02)) + + def test_final_observation_checks_force_and_error_without_another_solve_or_command(self): + for mutation,reason in (({'force':10.001},'declared_force_ceiling_exceeded'), + ({'tcp_offset':.0041},'tcp_position_error_exhausted'), + ({},None)): + value,env=make('cartesian');admitted(value,env) + requests,commands=len(env.requests),len(value.requested_controls) + result=value.observe_final(observation(value.last_observed_s+.02,**mutation)) + self.assertEqual(len(env.requests),requests) + self.assertEqual(len(value.requested_controls),commands) + self.assertEqual(result['abort_reason'],reason) + self.assertEqual(result['admitted'],reason is None) + self.assertTrue(result['retention']['hold_records'][-1]['final_observation']) + self.assertFalse(result['retention']['hold_records'][-1]['command_issued']) + + def test_summary_records_are_detached(self): + value,env=make('cartesian');admitted(value,env) + summary=value.summary() + self.assertEqual(summary['variant'],'cartesian') + self.assertEqual(summary['acquisition_variant'],'evidence') + summary['retention']['hold_reference']['position_m'][0]=99. + summary['retention']['hold_records'][0]['joint_targets_rad'][0]=99. + self.assertNotEqual(value.hold_reference['position_m'][0],99.) + self.assertNotEqual(value.hold_records[0]['joint_targets_rad'][0],99.) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_piper_retention_benchmark.py b/tests/test_piper_retention_benchmark.py new file mode 100644 index 0000000..b179f1f --- /dev/null +++ b/tests/test_piper_retention_benchmark.py @@ -0,0 +1,333 @@ +"""Fixed-protocol and native-shaped falsification tests; no physics runs.""" +from copy import deepcopy +from hashlib import sha256 +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import patch + +import numpy as np + +from dvidia_training import piper_retention_benchmark as study +from dvidia_training import probe_evidence as proof + + +def fixture(admission_s=2.): + count = 20000 + arrays = {name: np.zeros((count,) if width == 1 else (count, width), dtype=np.float64) + for name, width in proof.FIELDS} + arrays['native_tick'][:] = np.arange(1, count+1) + arrays['t_seconds'][:] = arrays['native_tick']*.001 + arrays['object_position_m'][:] = [.303, -.026, .082] + arrays['tcp_position_m'][:] = [.303, -.026, .182] + lifted = arrays['t_seconds'] >= 1.1 + arrays['object_position_m'][lifted, 2] += .01 + arrays['tcp_position_m'][lifted, 2] += .01 + arrays['object_minus_tcp_local_m'][:] = arrays['object_position_m']-arrays['tcp_position_m'] + arrays['object_quaternion_wxyz'][:, 0] = 1. + arrays['tcp_quaternion_wxyz'][:, 0] = 1. + arrays['grasp_contact_counts'][:] = [1., 1.] + arrays['pad_normal_forces_n'][:] = [.2, .3] + arrays['table_object_contact_count'][:] = ~lifted + metadata = {'schema': 'dvidia.piper-feedback-native/v1', 'native_timestep_s': .001, + 'rows': count, 'fields': [{'name': name, 'width': width} for name, width in proof.FIELDS], + 'side_order': ['left', 'right'], 'physical_robot_ready': False} + case = study.case_definitions()[0] + probe = {'probe_index': 0, 'start_time_s': 1., + 'object_origin_m': arrays['object_position_m'][999].tolist(), + 'tcp_origin_m': arrays['tcp_position_m'][999].tolist(), + 'tcp_quaternion_wxyz': [1., 0., 0., 0.], + 'local_offset_origin_m': arrays['object_minus_tcp_local_m'][999].tolist(), + 'end_time_s': 20., 'disposition': 'admitted', 'reason': 'qualified'} + checks = [{'t_seconds': t, 'qualified': True, 'probe_index': 0} + for t in sorted({admission_s, min(20., admission_s+5.), 20.})] + result = {'id': case['id']+'-measured_joint', 'scene_id': case['id'], 'seed': case['seed'], + 'variant': 'measured_joint', 'config': case['config'], 'valid': True, 'error': None, + 'initial_object_position_m': arrays['object_position_m'][0].tolist(), + 'compiled_model_sha256': 'a'*64, + 'actions': [{} for _ in range(1000)], 'trajectory': [{} for _ in range(1001)], + 'metrics': {'valid': True, 'warnings': [], 'unexpected_contact_native_ticks': 0, + 'simulation_time': 20.}, + 'policy': {'probes': [probe], 'admitted': True, + 'events': [{'type': 'evidence_qualified', 't_seconds': admission_s, 'probe_index': 0}], + 'qualification_checks': checks, 'requested_controls': [], + 'retention': {'hold_records': [], 'input_refusals': []}}} + protocol = {'force_ceiling_n': 10., 'source_sha256': {}, 'asset_lock_sha256': 'a'*64} + return result, arrays, metadata, protocol + + +def endpoint(result, arrays, metadata, protocol): + # The bound receipt has its own integration test below. Endpoint mutations + # stay isolated from that existing module's terminal qualification semantics. + with patch.object(study.prior, '_evaluate', return_value={'verification': {'admissible': False}}): + return study.evaluate(result, arrays, metadata, protocol) + + +def controls_fixture(): + result, arrays, _, _ = fixture() + result['actions'], result['trajectory'] = [], [] + result['policy']['requested_controls'] = [] + control = np.zeros(7) + config = result['config'] + maximum = np.array([config['joint_target_rate_rad_s']*.001]*6+[config['jaw_target_rate_m_s']*.001]) + for i in range(1001): + sample = max(0, i*20-1) + obs = {field: arrays[field][sample].tolist() + for field in ('object_position_m', 'joint_position_rad', 'tcp_position_m')} + obs.update(native_actuator_controls=control.tolist(), command=None, + issued_joint_targets_rad=None, issued_gripper_position_m=None) + result['trajectory'].append(obs) + if i == 1000: + break + action = {'t_seconds': i*.02, 'joint_targets_rad': [(.01 if i % 2 else -.01), 0., 0., 0., 0., 0.], + 'gripper_position_m': .005} + result['actions'].append(action) + result['policy']['requested_controls'].append({'index': i, **deepcopy(action)}) + obs.update(command=deepcopy(action), issued_joint_targets_rad=list(action['joint_targets_rad']), + issued_gripper_position_m=action['gripper_position_m']) + target = np.array(action['joint_targets_rad']+[action['gripper_position_m']]) + for tick in range(i*20, (i+1)*20): + control += np.clip(target-control, -maximum, maximum) + arrays['arm_actuator_controls_rad'][tick] = control[:6] + arrays['applied_jaw_target_m'][tick] = control[-1] + arrays['requested_jaw_target_m'][tick] = target[-1] + return result, arrays + + +class RetentionStudyTests(unittest.TestCase): + def test_fresh_fixed_cells_balanced_order_and_every_attempt_once(self): + cases = study.case_definitions() + self.assertEqual(len(cases), 12) + self.assertEqual([case['seed'] for case in cases], list(range(311100, 311112))) + self.assertEqual({case['config']['mass_kg'] for case in cases}, {.045, .055}) + self.assertEqual({case['config']['friction'] for case in cases}, {.75, .50, .20}) + self.assertEqual({tuple(case['config']['object_position_m'][:2]) for case in cases}, + {(.303, -.026), (.307, -.037)}) + roster = study.roster({'cases': cases}) + self.assertEqual(len({(case['id'], variant) for case, variant in roster}), 36) + for variant in study.VARIANTS: + self.assertEqual([sum(case['variant_order'][position] == variant for case in cases) + for position in range(3)], [4, 4, 4]) + + def test_correct_five_second_retention_and_bound_receipt_integration(self): + result, arrays, metadata, protocol = fixture() + evaluation = study.evaluate(result, arrays, metadata, protocol) + self.assertTrue(evaluation['primary']['success']) + self.assertTrue(evaluation['stationarity']['grip_retention_and_stationarity']) + self.assertTrue(evaluation['receipt']['verification']['admissible']) + self.assertFalse(evaluation['physical_robot_ready']) + + def test_one_ms_force_gap_and_ceiling_breach_reset_admission_prefix(self): + for force in (.01, 10.000001): + result, arrays, metadata, protocol = fixture() + arrays['pad_normal_forces_n'][1949, 0] = force + evaluation = endpoint(result, arrays, metadata, protocol) + self.assertFalse(evaluation['first_admission']['native_prefix_qualified']) + self.assertEqual(evaluation['primary']['reason'], 'false_initial_admission') + self.assertFalse(evaluation['primary']['success']) + + def test_99_native_prefix_samples_do_not_equal_100(self): + result, arrays, _, _ = fixture() + probe = result['policy']['probes'][0] + _, streak, qualified, _, _ = study.probe_series(arrays, probe) + self.assertEqual(streak[1197], 99) + self.assertFalse(qualified[1197]) + self.assertEqual(streak[1198], 100) + self.assertTrue(qualified[1198]) + + def test_five_second_endpoint_includes_all_5000_followup_ticks(self): + for failure_time, success in ((6.999, False), (7., False), (7.001, True)): + result, arrays, metadata, protocol = fixture() + arrays['pad_normal_forces_n'][round(failure_time/.001)-1, 0] = 0. + evaluation = endpoint(result, arrays, metadata, protocol) + self.assertEqual(evaluation['primary']['success'], success, failure_time) + self.assertEqual(evaluation['primary']['complete_5s'], True) + + def test_less_than_five_seconds_followup_is_never_padded(self): + for admission, complete in ((15., True), (15.02, False)): + result, arrays, metadata, protocol = fixture(admission) + evaluation = endpoint(result, arrays, metadata, protocol) + self.assertEqual(evaluation['primary']['success'], complete) + self.assertEqual(evaluation['primary']['complete_5s'], complete) + if not complete: + self.assertEqual(evaluation['primary']['reason'], 'insufficient_followup') + + def test_native_retention_does_not_resurrect_controller_abstention(self): + result, arrays, metadata, protocol = fixture() + result['policy']['admitted'] = False + for claim in result['policy']['qualification_checks'][1:]: + claim['qualified'] = False + evaluation = study.evaluate(result, arrays, metadata, protocol) + self.assertTrue(evaluation['primary']['success']) + self.assertFalse(evaluation['primary']['controller_retained_at_5s']) + self.assertFalse(evaluation['primary']['combined_qualified_pause']) + self.assertFalse(evaluation['final']['both_qualified']) + self.assertFalse(evaluation['final']['evidence_admissible']) + + def test_no_admission_and_invalid_native_attempts_are_retained_as_failures(self): + result, arrays, metadata, protocol = fixture() + result['policy']['events'] = [] + result['policy']['admitted'] = False + evaluation = endpoint(result, arrays, metadata, protocol) + self.assertEqual(evaluation['primary']['reason'], 'no_admission') + self.assertIsNone(evaluation['first_admission']) + self.assertFalse(evaluation['stationarity']['grip_retention_and_stationarity']) + for field in ('warning', 'incomplete', 'critical_count_mismatch', 'critical_before_probe', 'malformed_sidecar'): + changed, data, meta, protocol = fixture() + if field == 'warning': changed['metrics']['warnings'] = ['native warning'] + elif field == 'incomplete': changed['actions'].pop() + elif field == 'critical_count_mismatch': data['unexpected_contact_count'][100] = 1. + elif field == 'critical_before_probe': + data['unexpected_contact_count'][100] = 1. + changed['metrics']['unexpected_contact_native_ticks'] = 1 + else: data['native_tick'][100] += 1. + evaluation = endpoint(changed, data, meta, protocol) + self.assertFalse(evaluation['primary']['success'], field) + self.assertEqual(evaluation['status'], 'refused') + + def test_broader_post_endpoint_force_ceiling_veto_is_preserved(self): + result, arrays, metadata, protocol = fixture() + arrays['pad_normal_forces_n'][15000, 0] = 10.0001 + evaluation = endpoint(result, arrays, metadata, protocol) + self.assertFalse(evaluation['primary']['success']) + self.assertEqual(evaluation['primary']['reason'], 'force_ceiling_exceeded') + self.assertFalse(evaluation['primary']['all_native_hold_forces_within_ceiling']) + + def test_common_tcp_position_and_orientation_endpoint_for_all_variants(self): + for variant in study.VARIANTS: + result, arrays, metadata, protocol = fixture() + result['variant'] = variant + # The tool and object move together: retained grip alone cannot + # qualify a stationary pause. + arrays['tcp_position_m'][2999:, 0] += .004001 + arrays['object_position_m'][2999:, 0] += .004001 + evaluation = endpoint(result, arrays, metadata, protocol) + self.assertTrue(evaluation['primary']['success']) + self.assertFalse(evaluation['stationarity']['grip_retention_and_stationarity']) + self.assertAlmostEqual(evaluation['stationarity']['maximum_tcp_position_excursion_m'], .004001) + result, arrays, metadata, protocol = fixture() + angle = .050001 + arrays['tcp_quaternion_wxyz'][2999:] = [np.cos(angle/2), 0., 0., np.sin(angle/2)] + evaluation = endpoint(result, arrays, metadata, protocol) + self.assertTrue(evaluation['primary']['success']) + self.assertFalse(evaluation['stationarity']['grip_retention_and_stationarity']) + self.assertAlmostEqual(evaluation['stationarity']['maximum_tcp_orientation_excursion_rad'], angle) + arrays['tcp_quaternion_wxyz'][2999:] *= -1 # Quaternion sign changes no pose. + opposite = endpoint(result, arrays, metadata, protocol) + self.assertEqual(evaluation['stationarity'], opposite['stationarity']) + + def test_exact_stationarity_bounds_pass_and_terminal_recovery_cannot_hide_excursion(self): + result, arrays, metadata, protocol = fixture() + arrays['tcp_position_m'][2999:3999, 0] += .004 + arrays['object_position_m'][2999:3999, 0] += .004 + angle = .05 + arrays['tcp_quaternion_wxyz'][2999:3999] = [np.cos(angle/2), 0., 0., np.sin(angle/2)] + # With a z-only local offset, yaw preserves the redundant local field. + evaluation = endpoint(result, arrays, metadata, protocol) + self.assertTrue(evaluation['stationarity']['grip_retention_and_stationarity']) + self.assertEqual(evaluation['stationarity']['terminal_tcp_position_delta_m'], [0., 0., 0.]) + arrays['tcp_position_m'][3499, 0] += .000001 + arrays['object_position_m'][3499, 0] += .000001 + changed = endpoint(result, arrays, metadata, protocol) + self.assertFalse(changed['stationarity']['grip_retention_and_stationarity']) + self.assertEqual(changed['stationarity']['terminal_tcp_position_delta_m'], [0., 0., 0.]) + + def test_corrected_control_tape_requires_every_native_slew_sample_and_boundary(self): + result, arrays = controls_fixture() + study._controls(result, arrays) + arrays['arm_actuator_controls_rad'][54, 0] += 1e-6 + with self.assertRaisesRegex(ValueError, 'slew law'): + study._controls(result, arrays) + result, arrays = controls_fixture() + result['trajectory'][4]['issued_gripper_position_m'] += .001 + with self.assertRaisesRegex(ValueError, 'disagree'): + study._controls(result, arrays) + + def test_prefix_excludes_first_hold_command_but_includes_admission_native_row(self): + result, arrays = controls_fixture() + original = study._prefix(result, arrays) + result['actions'][100]['joint_targets_rad'][0] += .01 # Command at admission=2s. + arrays['joint_position_rad'][2000, 0] += .01 # First post-admission native tick. + self.assertEqual(study._prefix(result, arrays), original) + arrays['joint_position_rad'][1999, 0] += .01 + self.assertNotEqual(study._prefix(result, arrays)['native_sha256'], original['native_sha256']) + + def test_cartesian_pre_admission_bound_refusal_keeps_common_acquisition_proof(self): + measured, arrays = controls_fixture() + issued = deepcopy(measured); issued['variant'] = 'issued_joint' + cartesian = deepcopy(measured); cartesian['variant'] = 'cartesian' + cartesian['policy']['events'] = [{'type': 'hold_invalidated', 't_seconds': 2., + 'reason': 'initial_bias_exhausted', 'probe_index': 0}] + cutoff = study._common_cutoff([measured, issued, cartesian]) + self.assertEqual(cutoff['time_s'], 2.) + self.assertIn('pre_admission_bound_refusal', cutoff['reason']) + self.assertEqual(len(cutoff['treatment_boundaries']), 3) + # Different treatment command at the common boundary is expected. + cartesian['actions'][100]['joint_targets_rad'][0] += .01 + prefixes = [study._prefix(row, arrays, cutoff=cutoff) for row in (measured, issued, cartesian)] + self.assertEqual(prefixes[0], prefixes[1]); self.assertEqual(prefixes[0], prefixes[2]) + self.assertEqual(prefixes[0]['native_rows'], 2000) + self.assertEqual(prefixes[0]['commands'], 100) + cartesian['actions'][99]['joint_targets_rad'][0] += .01 + self.assertNotEqual(study._prefix(cartesian, arrays, cutoff=cutoff)['commands_sha256'], + prefixes[0]['commands_sha256']) + + def test_acquisition_alarm_is_not_a_treatment_cutoff_and_no_treatment_uses_full_run(self): + result, arrays = controls_fixture() + result['policy']['events'].insert(0, {'type': 'alarm', 't_seconds': 1.5, 'reason': 'relative_slip'}) + self.assertEqual(study._common_cutoff([result])['time_s'], 2.) + result['policy']['events'] = [{'type': 'hold_invalidated', 't_seconds': 1.5, + 'reason': 'bilateral_contact_or_force_loss'}] + cutoff = study._common_cutoff([result]) + self.assertIsNone(cutoff['time_s']) + prefix = study._prefix(result, arrays, cutoff=cutoff) + self.assertEqual(prefix['native_rows'], 20000) + self.assertEqual(prefix['commands'], 1000) + + def test_correction_summary_does_not_mix_terminal_checks_with_issued_controls(self): + record = {'command_issued': True, 'correction_rate_limited': True, + 'correction_rate_limited_axes': [0, 2], 'correction_rad': [.01]*6, + 'desired_correction_rad': [.02]*6, 'refused_bounds': None} + final = {**record, 'command_issued': False, 'refused_bounds': 'tcp_position_error_exhausted'} + summary = study._corrections({'retention': {'hold_records': [record, final], 'input_refusals': []}}) + self.assertEqual(summary['hold_record_count'], 2) + self.assertEqual(summary['bound_refusal_counts'], {'tcp_position_error_exhausted': 1}) + self.assertEqual(summary['rate_limited_axis_occurrences'], 4) + self.assertEqual(summary['maximum_abs_active_correction_rad'], .01) + self.assertEqual(summary['maximum_abs_desired_correction_rad'], .02) + + def test_existing_output_and_started_study_are_not_overwritten(self): + with TemporaryDirectory() as folder, patch.object(study, 'make_protocol') as make: + with self.assertRaises(ValueError): study.prepare(folder, 'unused-assets') + make.assert_not_called() + protocol = {'schema': study.SCHEMA, 'cases': []} + with TemporaryDirectory() as folder, patch.object(study, 'make_protocol', return_value=protocol), \ + patch.object(study, '_attempt') as attempt: + study.write(Path(folder)/'protocol.json', protocol) + study.write(Path(folder)/'started.json', {'original': 'retained'}) + with self.assertRaises(FileExistsError): study.run(folder, 'unused-assets') + attempt.assert_not_called() + self.assertEqual(study.read(Path(folder)/'started.json'), {'original': 'retained'}) + + def test_changed_protocol_refuses_before_native_attempt(self): + original = {'schema': study.SCHEMA, 'planned_attempts': 36, 'cases': []} + with TemporaryDirectory() as folder, patch.object(study, 'make_protocol', return_value=original), \ + patch.object(study, '_attempt') as attempt: + study.write(Path(folder)/'protocol.json', {**original, 'planned_attempts': 35}) + with self.assertRaises(ValueError): study.run(folder, 'unused-assets') + attempt.assert_not_called() + self.assertFalse((Path(folder)/'started.json').exists()) + + def test_inspection_checks_frozen_protocol_bytes_before_any_report_or_source_read(self): + with TemporaryDirectory() as folder, patch.object(study.prior, 'sources') as sources: + path = Path(folder)/'protocol.json' + study.write(path, {'planned_attempts': 36}) + study.write(Path(folder)/'frozen.json', {'protocol_sha256': sha256(path.read_bytes()).hexdigest()}) + path.write_bytes(path.read_bytes().replace(b'36', b'35')) + with self.assertRaisesRegex(ValueError, 'Frozen protocol digest changed'): + study.inspect(folder) + sources.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_piper_retention_independent_audit.py b/tests/test_piper_retention_independent_audit.py new file mode 100644 index 0000000..5e370d9 --- /dev/null +++ b/tests/test_piper_retention_independent_audit.py @@ -0,0 +1,152 @@ +"""Pure evidence audits; no simulation/runtime imports or native runs.""" +import sys +from pathlib import Path +import unittest + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'benchmarks' / 'piper-retention-v02')) +import independent_audit as audit + + +class TreatmentBoundaryTests(unittest.TestCase): + def test_earliest_pre_admission_bound_refusal_is_cutoff_but_later_refusal_is_not(self): + policy = {'events': [ + {'type': 'hold_invalidated', 'reason': 'tcp_position_error_exhausted', 't_seconds': 1.02}, + {'type': 'evidence_qualified', 'reason': 'sampled', 't_seconds': 2.}, + {'type': 'hold_invalidated', 'reason': 'tcp_orientation_error_exhausted', 't_seconds': 3.}, + {'type': 'hold_invalidated', 'reason': 'relative_slip', 't_seconds': .5}, + ]} + self.assertEqual(audit.treatment_boundary(policy), { + 'time_s': 1.02, 'reason': 'pre_admission_bound_refusal', + 'detail': 'tcp_position_error_exhausted'}) + + def test_only_declared_bound_reasons_and_admissions_create_cutoffs(self): + self.assertIsNone(audit.treatment_boundary({'events': [ + {'type': 'hold_invalidated', 'reason': 'relative_slip', 't_seconds': 1.}, + {'type': 'abort', 'reason': 'pose_solver_refused', 't_seconds': 2.}, + ]})) + self.assertEqual(audit.treatment_boundary({'events': [ + {'type': 'evidence_qualified', 'reason': 'sampled', 't_seconds': 2.}, + {'type': 'hold_invalidated', 'reason': 'joint_correction_exhausted', 't_seconds': 2.1}, + ]})['reason'], 'admission') + + def test_common_cutoff_combines_tied_reasons_deterministically(self): + result = audit.common_cutoff({ + 'measured_joint': {'time_s': 1., 'reason': 'admission', 'detail': 'evidence_qualified'}, + 'issued_joint': {'time_s': 1., 'reason': 'pre_admission_bound_refusal', 'detail': 'pose_solver_refused'}, + 'cartesian': None, + }) + self.assertEqual(result['time_s'], 1.) + self.assertEqual(result['reason'], 'admission+pre_admission_bound_refusal') + self.assertEqual([row['variant'] for row in result['treatment_boundaries']], + ['issued_joint', 'measured_joint']) + + def test_invalid_triplet_remains_unknown_instead_of_asserting_three_way_match(self): + attempts = {variant: {'valid': True} for variant in audit.VARIANTS} + attempts['cartesian'] = {'valid': False, 'reason': 'invalid native sidecar', + 'native_rows_available': 100} + result = audit.paired_prefix('scene-1', attempts) + self.assertFalse(result['verified']) + self.assertEqual(result['reason'], 'invalid_attempt_prevents_complete_triplet') + self.assertEqual(result['variants'][2]['reason'], 'invalid native sidecar') + + +class PrefixTests(unittest.TestCase): + def test_common_prefix_includes_cutoff_native_row_and_excludes_cutoff_command(self): + times = np.arange(1, 20001, dtype=np.float64)*.001 + arrays = {'t_seconds': times, 'signal': np.arange(20000, dtype=np.float64)} + actions = [{'t_seconds': i*.02, 'command': i} for i in range(1000)] + cutoff = {'time_s': .04, 'reason': 'admission', 'treatment_boundaries': []} + result = audit.prefix_identity(arrays, actions, cutoff) + self.assertEqual(result['native_rows'], 40) + self.assertEqual(result['commands'], 2) + self.assertEqual(result['cutoff_time_s'], .04) + self.assertEqual(result['cutoff_reason'], 'admission') + + +class StationarityTests(unittest.TestCase): + def arrays(self): + return {'tcp_position_m': np.zeros((4, 3), dtype=np.float64), + 'tcp_quaternion_wxyz': np.tile([1., 0., 0., 0.], (4, 1))} + + def test_stationary_window_and_quaternion_sign_invariance(self): + arrays = self.arrays() + arrays['tcp_quaternion_wxyz'][2:] *= -1. + actual = audit.stationarity(arrays, 0, 3, True, True) + self.assertTrue(actual['tcp_pose_within_bounds']) + self.assertTrue(actual['grip_retention_and_stationarity']) + report = {'complete_5s': True, **actual} + audit.check_stationarity_reported(actual, report, True) + + def test_position_orientation_and_incomplete_windows_fail_stationarity(self): + arrays = self.arrays(); arrays['tcp_position_m'][2, 0] = .004001 + actual = audit.stationarity(arrays, 0, 3, True, True) + self.assertFalse(actual['tcp_pose_within_bounds']) + self.assertFalse(actual['grip_retention_and_stationarity']) + arrays = self.arrays(); theta = .050001 + arrays['tcp_quaternion_wxyz'][2:] = [np.cos(theta/2), 0., 0., np.sin(theta/2)] + actual = audit.stationarity(arrays, 0, 3, True, True) + self.assertFalse(actual['tcp_pose_within_bounds']) + incomplete = audit.stationarity(self.arrays(), 0, 2, False, True) + self.assertFalse(incomplete['tcp_pose_within_bounds']) + self.assertFalse(incomplete['grip_retention_and_stationarity']) + + +def correction_fixture(): + issued = [.1]*6 + raw0 = [.1]*6 + jaw = .02 + records, actions = [], [] + for index, (time, delta, desired_delta, limited) in enumerate( + ((1., 0., 0., []), (1.02, .001, .001, []), (1.04, .003, .01, [0]))): + correction = [delta]+[0.]*5 + desired = [desired_delta]+[0.]*5 + raw = [raw0[0]+desired_delta]+raw0[1:] + targets = [base+offset for base, offset in zip(issued, correction)] + record = {'t_seconds': time, 'command_issued': True, 'fixed_bias_rad': [0.]*6, + 'joint_targets_rad': targets, 'frozen_jaw_target_m': jaw, + 'raw_pose_joint_targets_rad': raw, 'desired_correction_rad': desired, + 'correction_rad': correction, 'correction_rate_limited_axes': limited, + 'correction_rate_limited': bool(limited), 'refused_bounds': None} + records.append(record) + actions.append({'t_seconds': time, 'joint_targets_rad': targets, + 'gripper_position_m': jaw}) + reference = {'t_seconds': 1., 'issued_joint_targets_rad': issued, + 'measured_joint_position_rad': issued, 'raw_pose_joint_targets_rad': raw0, + 'fixed_bias_rad': [0.]*6, 'frozen_jaw_target_m': jaw} + policy = {'variant': 'cartesian', 'frozen_jaw_target_m': jaw, + 'events': [{'type': 'evidence_qualified', 't_seconds': 1., 'reason': 'sampled'}], + 'retention': {'hold_reference': reference, 'hold_records': records, 'input_refusals': []}} + return policy, actions + + +class CorrectionAuditTests(unittest.TestCase): + def test_cartesian_bias_first_command_correction_envelope_and_rate_are_checked(self): + policy, actions = correction_fixture() + result = audit.corrections_audit(policy, actions, audit.treatment_boundary(policy), 'cartesian') + self.assertEqual(result['hold_record_count'], 3) + self.assertEqual(result['rate_limited_axis_occurrences'], 1) + self.assertEqual(result['maximum_abs_active_correction_rad'], .003) + self.assertEqual(result['maximum_abs_desired_correction_rad'], .01) + + def test_bad_first_target_bias_or_rate_exceedance_is_rejected(self): + policy, actions = correction_fixture() + policy['retention']['hold_records'][0]['joint_targets_rad'][0] += .001 + actions[0]['joint_targets_rad'][0] += .001 + with self.assertRaises(AssertionError): + audit.corrections_audit(policy, actions, audit.treatment_boundary(policy), 'cartesian') + policy, actions = correction_fixture() + policy['retention']['hold_reference']['fixed_bias_rad'][0] = .081 + with self.assertRaises(AssertionError): + audit.corrections_audit(policy, actions, audit.treatment_boundary(policy), 'cartesian') + policy, actions = correction_fixture() + policy['retention']['hold_records'][2]['correction_rad'][0] = .004 + policy['retention']['hold_records'][2]['joint_targets_rad'][0] = .104 + actions[2]['joint_targets_rad'][0] = .104 + with self.assertRaises(AssertionError): + audit.corrections_audit(policy, actions, audit.treatment_boundary(policy), 'cartesian') + + +if __name__ == '__main__': + unittest.main()