From 3bd846bd6ae965c49fd46990de42ed882704233c Mon Sep 17 00:00:00 2001
From: Bryce Lovell <40742705+BryceWDesign@users.noreply.github.com>
Date: Sat, 29 Aug 2026 10:40:20 -0700
Subject: [PATCH] Release IX-HapticSight v0.2.0 safety authority upgrade
---
.github/workflows/tests.yml | 32 +-
CHANGELOG.md | 64 +-
LICENSE | 69 +-
README.md | 562 ++++++++--------
RESPONSIBLE_USE.md | 16 +
ROADMAP.md | 302 ++-------
VALIDATION_REPORT.md | 89 +++
configs/culture_profiles.yaml | 2 +-
configs/force_limits.yaml | 2 +-
docs/architecture/package_map.md | 391 +++---------
.../perception_to_contact_authority.md | 43 ++
docs/architecture/ros2_and_hardware.md | 18 +
docs/architecture/webxr_observer.md | 16 +
docs/benchmarks/metrics.md | 2 +-
docs/governance/standards_crosswalk.md | 12 +-
docs/governance/threat_model.md | 6 +-
docs/safety/requirements_traceability.md | 603 ++----------------
docs/state_machine.md | 2 +-
docs/validation/claim_matrix.md | 46 ++
docs/validation/hardware_evidence_policy.md | 21 +
docs/validation/v0.2.0-release-evidence.json | 38 ++
examples/perception_to_contact_demo.py | 93 +++
examples/webxr/index.html | 171 +++++
examples/webxr/run_observer.py | 32 +
models/reference_segmenter_primary.json | 102 +++
.../reference_segmenter_primary.metrics.json | 10 +
models/reference_segmenter_secondary.json | 102 +++
...reference_segmenter_secondary.metrics.json | 10 +
pyproject.toml | 23 +
requirements.txt | 1 +
scripts/run_safety_authority_benchmark.py | 16 +
scripts/train_reference_segmenter.py | 338 ++++++++++
scripts/verify_release.py | 29 +
setup.py | 38 +-
src/ohip/__init__.py | 4 +-
src/ohip/schemas.py | 2 +-
src/ohip_agent/__init__.py | 3 +
src/ohip_agent/broker.py | 136 ++++
src/ohip_bench/__init__.py | 2 +-
src/ohip_bench/safety_authority.py | 72 +++
src/ohip_control/__init__.py | 38 ++
src/ohip_control/authority.py | 107 ++++
src/ohip_control/envelope.py | 109 ++++
src/ohip_control/invariants.py | 75 +++
src/ohip_control/multimodal.py | 122 ++++
src/ohip_control/realtime.py | 162 +++++
src/ohip_control/recovery.py | 41 ++
src/ohip_evidence/__init__.py | 11 +
src/ohip_evidence/bundle.py | 72 +++
src/ohip_evidence/hashchain.py | 88 +++
src/ohip_hil/__init__.py | 17 +
src/ohip_hil/harness.py | 113 ++++
src/ohip_interfaces/__init__.py | 2 +-
.../simulated_execution_adapter.py | 4 +-
src/ohip_logging/__init__.py | 4 +-
src/ohip_perception/__init__.py | 21 +
src/ohip_perception/fusion.py | 76 +++
src/ohip_perception/hazard_map.py | 108 ++++
src/ohip_perception/models.py | 95 +++
src/ohip_perception/pipeline.py | 84 +++
src/ohip_perception/segmentation.py | 125 ++++
src/ohip_ros2/__init__.py | 22 +
src/ohip_ros2/bridge.py | 159 +++++
src/ohip_ros2/messages.py | 101 +++
src/ohip_ros2/trajectory_adapter.py | 88 +++
src/ohip_runtime/__init__.py | 2 +-
src/ohip_runtime/coordinator.py | 2 +-
src/ohip_runtime/session_store.py | 2 +-
src/ohip_sim/__init__.py | 3 +
src/ohip_sim/contact_world.py | 52 ++
src/ohip_xr/__init__.py | 4 +
src/ohip_xr/server.py | 55 ++
src/ohip_xr/state.py | 46 ++
tests/test_agent_safety_broker.py | 51 ++
tests/test_authority_safety_properties.py | 37 ++
tests/test_contact_world.py | 22 +
tests/test_dynamic_envelope.py | 27 +
tests/test_evidence_chain.py | 26 +
tests/test_hazard_map.py | 33 +
tests/test_hil_harness.py | 27 +
tests/test_independent_safety_authority.py | 41 ++
tests/test_multimodal_safety_fusion.py | 40 ++
tests/test_perception_quorum.py | 23 +
tests/test_perception_segmentation.py | 42 ++
tests/test_realtime_controller.py | 50 ++
tests/test_reference_model_reproducibility.py | 20 +
tests/test_ros2_messages.py | 20 +
tests/test_ros2_tactile.py | 24 +
tests/test_ros2_trajectory.py | 26 +
tests/test_ros2_unavailable_is_explicit.py | 9 +
tests/test_safety_authority_benchmark.py | 7 +
tests/test_vision_pipeline.py | 21 +
tests/test_xr_state.py | 31 +
93 files changed, 4587 insertions(+), 1520 deletions(-)
create mode 100644 RESPONSIBLE_USE.md
create mode 100644 VALIDATION_REPORT.md
create mode 100644 docs/architecture/perception_to_contact_authority.md
create mode 100644 docs/architecture/ros2_and_hardware.md
create mode 100644 docs/architecture/webxr_observer.md
create mode 100644 docs/validation/claim_matrix.md
create mode 100644 docs/validation/hardware_evidence_policy.md
create mode 100644 docs/validation/v0.2.0-release-evidence.json
create mode 100644 examples/perception_to_contact_demo.py
create mode 100644 examples/webxr/index.html
create mode 100644 examples/webxr/run_observer.py
create mode 100644 models/reference_segmenter_primary.json
create mode 100644 models/reference_segmenter_primary.metrics.json
create mode 100644 models/reference_segmenter_secondary.json
create mode 100644 models/reference_segmenter_secondary.metrics.json
create mode 100644 pyproject.toml
create mode 100644 scripts/run_safety_authority_benchmark.py
create mode 100644 scripts/train_reference_segmenter.py
create mode 100644 scripts/verify_release.py
create mode 100644 src/ohip_agent/__init__.py
create mode 100644 src/ohip_agent/broker.py
create mode 100644 src/ohip_bench/safety_authority.py
create mode 100644 src/ohip_control/__init__.py
create mode 100644 src/ohip_control/authority.py
create mode 100644 src/ohip_control/envelope.py
create mode 100644 src/ohip_control/invariants.py
create mode 100644 src/ohip_control/multimodal.py
create mode 100644 src/ohip_control/realtime.py
create mode 100644 src/ohip_control/recovery.py
create mode 100644 src/ohip_evidence/__init__.py
create mode 100644 src/ohip_evidence/bundle.py
create mode 100644 src/ohip_evidence/hashchain.py
create mode 100644 src/ohip_hil/__init__.py
create mode 100644 src/ohip_hil/harness.py
create mode 100644 src/ohip_perception/__init__.py
create mode 100644 src/ohip_perception/fusion.py
create mode 100644 src/ohip_perception/hazard_map.py
create mode 100644 src/ohip_perception/models.py
create mode 100644 src/ohip_perception/pipeline.py
create mode 100644 src/ohip_perception/segmentation.py
create mode 100644 src/ohip_ros2/__init__.py
create mode 100644 src/ohip_ros2/bridge.py
create mode 100644 src/ohip_ros2/messages.py
create mode 100644 src/ohip_ros2/trajectory_adapter.py
create mode 100644 src/ohip_sim/__init__.py
create mode 100644 src/ohip_sim/contact_world.py
create mode 100644 src/ohip_xr/__init__.py
create mode 100644 src/ohip_xr/server.py
create mode 100644 src/ohip_xr/state.py
create mode 100644 tests/test_agent_safety_broker.py
create mode 100644 tests/test_authority_safety_properties.py
create mode 100644 tests/test_contact_world.py
create mode 100644 tests/test_dynamic_envelope.py
create mode 100644 tests/test_evidence_chain.py
create mode 100644 tests/test_hazard_map.py
create mode 100644 tests/test_hil_harness.py
create mode 100644 tests/test_independent_safety_authority.py
create mode 100644 tests/test_multimodal_safety_fusion.py
create mode 100644 tests/test_perception_quorum.py
create mode 100644 tests/test_perception_segmentation.py
create mode 100644 tests/test_realtime_controller.py
create mode 100644 tests/test_reference_model_reproducibility.py
create mode 100644 tests/test_ros2_messages.py
create mode 100644 tests/test_ros2_tactile.py
create mode 100644 tests/test_ros2_trajectory.py
create mode 100644 tests/test_ros2_unavailable_is_explicit.py
create mode 100644 tests/test_safety_authority_benchmark.py
create mode 100644 tests/test_vision_pipeline.py
create mode 100644 tests/test_xr_state.py
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index c3b708e..ac6ae0f 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: ["3.10", "3.11"]
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- name: Checkout
@@ -29,22 +29,20 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install -r requirements.txt
- pip install -e .
+ python -m pip install -r requirements.txt
+ python -m pip install -e .
- - name: Run unit tests
- run: |
- pytest -q
+ - name: Compile Python sources
+ run: python -m compileall -q src scripts examples
- - name: Quickstart smoke (scene → nudge → plan → safety)
- run: |
- python examples/quickstart.py --scene sim/scenes/basic_room.json --verbose
+ - name: Run automated tests
+ run: pytest -q
- - name: Import benchmark catalog smoke
- run: |
- python - <<'PY'
- from ohip_bench.scenarios import make_core_catalog
- catalog = make_core_catalog()
- assert len(catalog) >= 3
- print("benchmark scenarios:", [scenario.scenario_id for scenario in catalog])
- PY
+ - name: Run protocol quickstart
+ run: python examples/quickstart.py --scene sim/scenes/basic_room.json --verbose
+
+ - name: Run perception-to-contact integration demo
+ run: python examples/perception_to_contact_demo.py
+
+ - name: Run adversarial safety-authority benchmark
+ run: python scripts/run_safety_authority_benchmark.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5aab8de..98a67f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,41 +1,43 @@
# Changelog
-All notable changes to this project will be documented in this file.
+All notable changes to IX-HapticSight are documented here.
-The format is based on Keep a Changelog.
-This project follows semantic versioning principles for tagged releases.
-
-## [Unreleased]
+## [0.2.0] - 2026-08-29
### Added
-- Python packaging baseline via `pyproject.toml`.
-- Repository hygiene baseline via `.gitignore`.
-- Changelog for tracking safety, runtime, benchmark, and governance upgrades across the v0.1 to v1.0 buildout.
-
-### Planned
-- Repository authorship and scope cleanup.
-- Runtime package restructuring.
-- ROS 2 lifecycle node scaffolding.
-- Motion execution and safety shield expansion.
-- Tactile, proximity, thermal, and force/torque interfaces.
-- Logging, replay, integrity, and threat-model artifacts.
-- Benchmark harnesses and simulation scenario packs.
-- HIL scaffolding and safety-case traceability artifacts.
+- Executable RGB-D perception pipeline with image ingestion.
+- Two independently trained reference semantic segmenters with committed model parameters, deterministic training script, and held-out synthetic calibration metrics.
+- Perception quorum that fails closed on model disagreement, critical-class disagreement, or low confidence.
+- Vision-derived 3D GREEN/YELLOW/RED hazard projection.
+- Deterministic multimodal safety fusion across vision, force/torque, tactile, proximity, and thermal state.
+- Model-agnostic LLM/VLA safety broker that hashes untrusted physical proposals and returns bounded decision receipts.
+- Independent safety authority that returns explicit `ALLOW`, `MODIFY`, or `DENY` decisions and bounded counterfactual explanations.
+- Dynamic force and speed envelopes that derate authority for uncertainty, YELLOW state, and human proximity.
+- Cycle-level runtime invariant monitor for consent, force, speed, sensor freshness, perception quorum, watchdog timing, and E-stop state.
+- Bounded soft-real-time reference controller with deterministic recovery, zero-effort, retract, safe-hold, and operator-clear semantics.
+- Deterministic contact-world simulation for closed-loop regression testing.
+- ROS 2 bridge for bounded twist commands, `WrenchStamped` force/torque input, normalized tactile-patch input, E-stop input, and safety events.
+- Standard ROS 2 `FollowJointTrajectory` client implementation for physical robot-controller integration when ROS 2 hardware is available.
+- WebXR safety observer with live hazard, force-cap, speed-cap, consent, controller-state, and authority visualization.
+- Executable HIL harness that can only report `PASSED` from declared hardware capability plus measured samples. Missing hardware produces `NOT_RUN_NO_HARDWARE`, never a synthetic pass.
+- SHA-256 chained runtime evidence records and portable evidence-bundle verification.
+- Adversarial safety-authority benchmark covering nominal behavior, over-request derating, RED hazards, consent loss, perception disagreement, high uncertainty, and human-proximity cases.
+- End-to-end perception-to-contact software demonstration.
+- Expanded automated test suite: 183 tests passing at release-candidate build time.
+
+### Corrected
+- Package license metadata now matches the MIT `LICENSE` file.
+- Responsible-use language moved to a separate non-license statement to avoid contradictory license claims.
+- Repository author metadata normalized to Bryce Lovell.
+
+### Evidence limits
+- Reference vision models are trained on deterministic synthetic calibration data, not field robot datasets.
+- The Python controller is timing-instrumented soft real time, not a certified hard-real-time controller.
+- ROS 2 and robot-controller adapters are implemented but not physically validated in this repository build.
+- No HIL pass or real-robot pass is claimed without external measured hardware evidence.
## [0.1.0] - 2026-04-10
### Added
- Initial OHIP schemas and protocol reference implementation.
-- Consent management logic.
-- Contact planning logic.
-- Nudge scheduling logic.
-- Rest-pose generation logic.
-- Safety gate logic.
-- Example quickstart script.
-- Core configuration files for force limits and culture profiles.
-- Basic simulation scene.
-- Unit tests for schemas and scheduler.
-
-### Notes
-- `0.1.0` is the pre-upgrade baseline imported before the 72-commit architecture and runtime expansion campaign.
-- The project at this stage is a reference implementation and documentation-first prototype, not a deployable robotics runtime.
+- Consent management, contact planning, nudge scheduling, rest pose generation, safety gating, simulation scene, configuration, and baseline tests.
diff --git a/LICENSE b/LICENSE
index 8a7a025..db27650 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,62 +1,21 @@
MIT License
-Copyright (c) 2025 Bryce Lovell
+Copyright (c) 2025-2026 Bryce Lovell
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
--------------------------------------------------------------------------------
-Responsible Use Addendum for IX-HapticSight
-
-The Software is intended for humanitarian, assistive, educational, and
-industrial safety applications only.
-
-By using, modifying, or distributing this Software, you agree to the
-following additional conditions:
-
-1. **No Weaponization**
- You may not use the Software, in whole or in part, to design, develop,
- train, deploy, or operate any system intended to cause physical harm
- to humans, animals, or the environment.
-
-2. **No Coercive Use**
- You may not use the Software to engage in coercive control, surveillance
- of individuals without their consent, or any other activity that violates
- internationally recognized human rights.
-
-3. **Safety Compliance**
- Any deployment of this Software in physical systems must implement
- adequate safety measures, including force limits, hazard detection,
- and emergency stop capabilities, as recommended in the project
- documentation.
-
-4. **Ethical Attribution**
- Any public or commercial deployment must credit the original author
- ("Bryce Lovell") and retain this Responsible Use Addendum in all copies
- and derivative works.
-
-Violation of these conditions immediately terminates your rights under this
-License for the offending use, without limiting any other remedies available
-under law.
-
--------------------------------------------------------------------------------
-
-This Responsible Use Addendum is a non-legally binding expression of intent
-and good faith, designed to guide ethical use. However, the prohibitions on
-weaponization and unsafe deployment are intended to be enforceable under
-applicable law when incorporated into contracts, agreements, or terms of use.
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index a0c279e..7751380 100644
--- a/README.md
+++ b/README.md
@@ -1,332 +1,390 @@
# IX-HapticSight
-**IX-HapticSight** is a safety-first optical-haptic interaction architecture for bounded human-facing robot behavior.
+**IX-HapticSight** is an open, safety-first perception-to-contact authority for robots and XR.
-The repository is built around one narrow idea:
+It is built around one architectural rule:
-> convert perception, consent state, safety state, and bounded contact rules into explicit approach, contact, retreat, and safe-hold behavior that can be inspected, tested, replayed, and benchmarked.
+> Intelligent perception, planning, or an LLM may propose a physical action. A separate deterministic safety authority decides whether that action is allowed, must be reduced, or must be denied.
-This repo is **not** positioned as a broad “emotion-aware robot” claim, a production deployment stack, or a certified collaborative robot package. It is a **measurement-first, audit-friendly reference architecture** with working code, tests, structured logging, replay helpers, interface abstractions, and deterministic benchmark support.
+The repository connects perception-derived state to bounded robot behavior through explicit consent, hazard, force, speed, freshness, uncertainty, watchdog, recovery, and evidence rules.
----
+**License:** MIT. See `RESPONSIBLE_USE.md` for the project's non-license safety and humanitarian-use statement.
-## Current Status
+## v0.2.0 status
-**Current maturity:** strong repository architecture / reference-runtime stage
+Software reference implementation with executable perception, safety authority, controller, ROS 2 integration code, WebXR observability, simulation, HIL evidence collection, replay, benchmarks, and tests.
-What the repo currently includes:
+Current automated suite at the v0.2.0 release-candidate build: **183 tests passing**.
-- deterministic OHIP protocol core
-- backend-agnostic runtime coordination layer
-- explicit runtime session and fault models
-- structured JSONL event logging
-- replay helpers for event streams
-- normalized interface models for:
- - force/torque
- - tactile
- - proximity
- - thermal
- - execution adapters
-- in-memory simulated execution adapter
-- deterministic benchmark runner, scenario catalog, and reporting helpers
-- expanded safety, governance, replay, benchmark, and HIL-prep documentation
-- unit tests and CI workflow
+This repository does **not** claim a physical robot, HIL pass, certified safety system, or production deployment unless measured hardware evidence is explicitly included.
-What it does **not** currently include:
+## What is implemented
-- real hardware integration
-- HIL measured data
-- certified safety evidence
-- production deployment approval
-- medical or therapeutic validation
-- blanket privacy or compliance claims
+### Perception and vision
-That line matters. This repo is strongest when it stays precise.
+- RGB image ingestion with depth input
+- executable semantic segmentation model
+- two independently trained reference model parameter sets
+- deterministic training script
+- held-out synthetic calibration metrics
+- model-confidence and uncertainty output
+- independent perception quorum
+- critical-class disagreement detection
+- vision-derived 3D GREEN / YELLOW / RED hazard voxels
----
+The shipped reference segmentation models are intentionally small and auditable. They are trained on deterministic **synthetic calibration data**. This proves an executable vision path and reproducible model artifact, not production perception accuracy.
-## What the Repository Is Trying to Do
+### Agent / VLA safety broker
-IX-HapticSight is trying to make one difficult boundary explicit:
+`src/ohip_agent/` treats LLM, VLA, or planner output as an **untrusted physical proposal**. The broker hashes the exact proposal, fuses current multimodal safety state, and issues a receipt containing the granted force/speed authority and reasons. Agent output can never increase deterministic limits.
-**when a machine is allowed to approach, touch, withdraw, or stop around a person — and how that decision is made visible and reviewable.**
+### Independent safety authority
-The repo is built around:
+`src/ohip_control/authority.py` provides an explicit authority boundary:
-- bounded interaction semantics
-- consent-aware contact authorization
-- safety-veto authority over convenience behavior
-- explicit retreat and safe-hold semantics
-- replayable event trails
-- scenario-based benchmark evaluation
-- traceable evidence growth toward future HIL work
+- `ALLOW`
+- `MODIFY`
+- `DENY`
----
+The authority evaluates:
-## What the Repository Is Not
+- consent state
+- perception quorum
+- perception uncertainty
+- GREEN / YELLOW / RED safety level
+- human presence and proximity
+- requested force
+- requested speed
+- configured base limits
-This repository is **not**:
+A learned model cannot enlarge the granted envelope.
-- a general social robotics framework
-- a claim of human-emotion understanding
-- a production manipulator stack
-- a guarantee of safe real-world touch
-- a substitute for hardware safety engineering
-- a substitute for regulatory, institutional, or legal review
-- a proof of collaborative-robot certification
-- a finished physical system
+### Deterministic multimodal fusion
-The right way to read this repo is:
+Vision, force/torque, tactile, proximity, and thermal assessments are fused into one inspectable GREEN / YELLOW / RED decision. Required missing modalities fail closed. Excessive measured force, tactile pressure/shear, thermal limits, proximity stops, or perception disagreement can override a visually GREEN scene.
-**bounded concept-stage architecture with real code, real tests, real structured artifacts, and explicit evidence limits.**
+### Dynamic force and speed envelopes
----
+Authority automatically derates or removes motion based on:
-## Repository Structure
+- YELLOW safety state
+- increasing perception uncertainty
+- human proximity
+- RED hazards
-### Protocol core
-`src/ohip/`
+A request for 8 N does not become an 8 N command merely because an AI planner asked for it.
-Stable reference-implementation layer for:
-- schemas
-- consent management
-- contact planning
-- engagement scheduling
-- rest pose generation
-- safety gating
-
-### Runtime layer
-`src/ohip_runtime/`
-
-Backend-agnostic runtime ownership for:
-- interaction session state
-- runtime fault models
-- coordination requests and decisions
-- runtime coordinator
-- session store
-- configuration wiring
-- high-level runtime service
-
-### Interface layer
-`src/ohip_interfaces/`
-
-Normalized sensing and execution contracts for:
-- signal health and freshness
-- force/torque samples
-- tactile frames
-- proximity frames
-- thermal frames
-- execution adapter contracts
-- simulated execution adapter
-
-### Logging and replay
-`src/ohip_logging/`
-
-Structured evidence layer for:
-- event records
-- JSONL event logs
-- event recorder
-- replay helpers
-
-### Benchmark layer
-`src/ohip_bench/`
-
-Deterministic evaluation layer for:
-- benchmark models
-- benchmark runner
-- built-in scenario catalog
-- benchmark reporting
-
-### Supporting assets
-- `configs/` — force and culture profile configuration
-- `docs/` — spec, state machine, safety, governance, replay, benchmark, and HIL-prep docs
-- `examples/` — quickstart reference path
-- `sim/` — simulation scene assets
-- `tests/` — unit and integration-style repository tests
-- `.github/workflows/tests.yml` — CI test workflow
-
----
-
-## Documentation Map
-
-Start here if you want the repo’s architectural story in order:
-
-1. `docs/spec.md`
-2. `docs/state_machine.md`
-3. `docs/index.md`
-4. `ROADMAP.md`
-5. `docs/architecture/runtime_overview.md`
-6. `docs/safety/invariants.md`
-7. `docs/safety/requirements_traceability.md`
-8. `docs/governance/safety_case.md`
-9. `docs/benchmarks/overview.md`
-10. `docs/replay/event_log_schema.md`
-11. `docs/hil/test_rig_architecture.md`
-
-If you only want the high-level direction:
-- `ROADMAP.md`
-- `docs/governance/standards_crosswalk.md`
-- `docs/governance/safety_case.md`
-
----
-
-## Runtime Flow
-
-At the current repository stage, the main runtime story is:
-
-1. create or load an interaction session
-2. submit an explicit interaction request
-3. evaluate consent
-4. evaluate safety
-5. build a bounded planning outcome if allowed
-6. record the full structured decision trail
-7. optionally submit a bounded execution request
-8. record execution status, transitions, faults, retreat, or safe-hold behavior
-9. replay or benchmark the resulting event trail later
-
-That flow is represented across:
-- `src/ohip_runtime/`
-- `src/ohip_logging/`
-- `src/ohip_interfaces/`
-- `src/ohip_bench/`
+### Cycle-level invariant monitoring
+
+Every reference control cycle can independently verify:
----
+- E-stop state
+- consent continuity
+- perception-quorum health
+- commanded force versus granted force
+- measured force versus granted force
+- commanded speed versus granted speed
+- safety-sensor freshness
+- controller watchdog timing
-## Structured Logging and Replay
+Violations drive deterministic stop, latch, retreat, zero-effort, or safe-hold behavior.
-A major part of this upgrade is that important behavior is no longer supposed to disappear into console output.
+### Contact control and recovery
-Current logging/replay support includes:
+The reference controller is executable and timing-instrumented. It supports:
-- structured event records
-- append-friendly JSONL logs
-- request/decision/fault/transition/execution event helpers
-- replay loading and slicing
-- replay filtering by:
- - session
- - request
- - event kind
- - event range
+- approach
+- contact
+- bounded command clamping
+- measured over-force detection
+- consent-loss stop
+- perception-disagreement stop
+- zero-effort recovery
+- retract recovery
+- safe hold
+- operator-clear latch semantics
-This matters because a safety-first interaction repo should be explainable **after the fact**, not only impressive in the moment.
+The Python implementation is **soft real time**. It is not represented as a certified hard-real-time controller.
-Relevant files:
-- `src/ohip_logging/events.py`
-- `src/ohip_logging/jsonl.py`
-- `src/ohip_logging/recorder.py`
-- `src/ohip_logging/replay.py`
+### ROS 2
----
+`src/ohip_ros2/` contains real ROS 2 integration code, loaded only when ROS 2 is available:
-## Benchmarking
+- `geometry_msgs/WrenchStamped` force/torque ingestion
+- normalized tactile-patch ingestion via `std_msgs/Float32MultiArray`
+- bounded `TwistStamped` output
+- E-stop input
+- structured safety-event output
+- standard `control_msgs/FollowJointTrajectory` action client
+- robot-controller trajectory validation
-The repo now includes a deterministic benchmark layer.
+This means the repository contains an executable path into normal ROS 2 robot infrastructure. It does **not** mean a physical robot was run for this release.
-Current benchmark support includes:
+### WebXR
-- explicit scenario definitions
-- explicit expectations
-- structured observations
-- structured benchmark results
-- small built-in scenario catalog
-- reporting helpers for summaries and pass rates
+`examples/webxr/` contains a browser WebXR safety observer that exposes:
-Current built-in scenarios focus on:
-- explicit-consent approval path
-- missing-consent denial path
-- RED-safety denial path
+- live safety-authority decision
+- GREEN / YELLOW / RED hazard markers
+- force cap
+- speed cap
+- consent state
+- controller state
+- veto / derating reason
-Relevant files:
-- `src/ohip_bench/models.py`
-- `src/ohip_bench/runner.py`
-- `src/ohip_bench/scenarios.py`
-- `src/ohip_bench/reporting.py`
+A WebXR-capable browser can request an `immersive-ar` session. Device-specific registration and headset validation remain future measured work.
-And the reviewer-facing docs:
-- `docs/benchmarks/overview.md`
-- `docs/benchmarks/scenario_catalog.md`
-- `docs/benchmarks/metrics.md`
+### HIL evidence harness
----
+`src/ohip_hil/` implements a hardware-in-the-loop evidence harness with a strict rule:
-## HIL Preparation
+**no hardware, no HIL PASS.**
-This repo now includes HIL-prep documentation, but not HIL proof.
+Possible results include:
-Current HIL-prep docs define:
-- recommended test-rig architecture
-- calibration strategy
-- fault-injection strategy
+- `PASSED`
+- `FAILED`
+- `NOT_RUN_NO_HARDWARE`
+- `NOT_RUN_INCOMPLETE`
-These are here so future physical evidence can be:
-- bounded
-- calibrated
-- traceable
-- linked back to repo requirements and claims
+A PASS requires declared hardware capability plus measured samples satisfying the configured force, latency, fault, and sample-count criteria.
-Relevant docs:
-- `docs/hil/test_rig_architecture.md`
-- `docs/hil/calibration.md`
-- `docs/hil/fault_injection.md`
+### Tamper-evident evidence
-This is **evidence preparation**, not evidence completion.
+`src/ohip_evidence/` provides:
+
+- SHA-256 chained runtime records
+- sequence continuity
+- previous-hash continuity
+- portable JSONL evidence records
+- manifest hashing
+- evidence-bundle verification
+- tamper detection
+
+This makes post-run evidence independently checkable instead of relying only on console output.
+
+### Existing OHIP protocol/runtime layers
+
+The repository retains and extends its earlier architecture for:
+
+- consent management
+- contact planning
+- nudge scheduling
+- rest pose generation
+- dual-channel safety gating
+- runtime sessions and fault states
+- normalized force/torque interfaces
+- tactile interfaces
+- proximity interfaces
+- thermal interfaces
+- structured event logging
+- replay
+- deterministic benchmark scenarios
+- simulated execution
+
+## Architecture
+
+```text
+Camera / depth / scene state
+ |
+ v
+ perception model A
+ perception model B
+ |
+ v
+ perception quorum
+ |
+ v
+ vision-derived hazard map
+ |
+ +----------------------+
+ |
+AI / LLM / planner proposal |
+ | |
+ v v
+ +-----------------------------------+
+ | INDEPENDENT SAFETY AUTHORITY |
+ | |
+ | consent |
+ | uncertainty |
+ | model agreement |
+ | hazard state |
+ | force / speed envelopes |
+ | human proximity |
+ +-----------------------------------+
+ |
+ ALLOW / MODIFY / DENY
+ |
+ v
+ bounded reference controller
+ |
+ runtime invariant monitor
+ |
+ +-----+--------------------+
+ | |
+ v v
+ROS 2 / robot adapter recovery authority
+ | zero effort / retract
+ v safe hold / operator
+robot or simulator |
+ ^ |
+ | |
+force / tactile / proximity ------+
+ |
+ v
+ tamper-evident evidence + replay + XR observer
+```
----
+## Quick verification
-## Quick Start
+Run the complete software release verification:
-### 1. Install
```bash
-pip install -r requirements.txt
-pip install -e .
+python scripts/verify_release.py
```
-2. Run tests
+Or install and run the suite directly:
+
```bash
+python -m pip install -e .
pytest -q
```
-3. Run the quickstart smoke path
+Run the existing protocol quickstart:
+
```bash
-python examples/quickstart.py --scene sim/scenes/basic_room.json --verbose
+python examples/quickstart.py
```
-4. Inspect the benchmark catalog
+Run the perception-to-contact integration demo:
+
```bash
-python - <<'PY'
-from ohip_bench.scenarios import make_core_catalog
-catalog = make_core_catalog()
-print([scenario.scenario_id for scenario in catalog])
-PY
+python examples/perception_to_contact_demo.py
```
-Release Gate
+Run the adversarial independent-authority benchmark:
-A release should be checked against:
+```bash
+python scripts/run_safety_authority_benchmark.py
+```
-CHANGELOG.md
-RELEASE_CHECKLIST.md
+Retrain both committed reference segmenters reproducibly:
-That checklist is there to stop the repo from becoming more polished than it is supported.
+```bash
+python scripts/train_reference_segmenter.py
+```
-License
+Run the WebXR observer:
-This repository is released under the license terms in LICENSE
-.
+```bash
+python examples/webxr/run_observer.py
+```
-Do not rely on shorthand descriptions in old summaries. The authoritative licensing terms are the ones in the actual license file.
+Then open `http://127.0.0.1:8765` in a browser. WebXR immersive AR requires compatible browser/device support.
-Author
+## Repository map
-Bryce Lovell
+### Core protocol
+
+`src/ohip/`
+
+Consent, schemas, planning, safety gating, nudge scheduling, and rest pose behavior.
+
+### Perception
+
+`src/ohip_perception/`
+
+RGB-D frames, reference segmentation, two-model quorum, vision pipeline, and hazard projection.
-Final Positioning
+### Agent safety broker
-The strongest way to understand IX-HapticSight is this:
+`src/ohip_agent/`
-It is not trying to prove that robots “understand people.”
-It is trying to make human-facing approach, contact, retreat, and safe-hold behavior more bounded, testable, replayable, and auditable.
+Model-agnostic LLM/VLA proposal ingestion, multimodal safety brokerage, proposal hashing, and decision receipts.
-That is a narrower claim.
-It is also the more credible one.
+### Physical safety authority and control
+`src/ohip_control/`
+
+Independent action authority, dynamic envelopes, runtime invariants, bounded controller, and recovery planner.
+
+### ROS 2 integration
+
+`src/ohip_ros2/`
+
+ROS 2 force/torque ingestion, bounded motion publication, E-stop state, safety events, and joint-trajectory action client.
+
+### Hardware evidence
+
+`src/ohip_hil/`
+
+Measured HIL acceptance harness with explicit no-hardware semantics.
+
+### Evidence
+
+`src/ohip_evidence/`
+
+Tamper-evident evidence chaining and portable bundle verification.
+
+### XR
+
+`src/ohip_xr/` and `examples/webxr/`
+
+Safety-state payloads, local state server, and WebXR observer.
+
+### Simulation
+
+`src/ohip_sim/`
+
+Deterministic contact plant for controller regression tests. Simulation is clearly separated from physical evidence.
+
+### Existing runtime and interfaces
+
+- `src/ohip_runtime/`
+- `src/ohip_interfaces/`
+- `src/ohip_logging/`
+- `src/ohip_bench/`
+
+## Claim matrix
+
+| Capability | Implementation | Evidence in this repo |
+|---|---|---|
+| Safety-first protocol | YES | automated tests |
+| Consent-aware contact | YES | automated tests |
+| Force envelopes | YES | automated tests |
+| Tri-level hazards | YES | automated tests |
+| Vision pipeline | YES | executable RGB-D path |
+| Segmentation model | YES | model files + reproducible training + synthetic held-out metrics |
+| Vision-derived hazard maps | YES | executable projector + tests |
+| Independent model quorum | YES | disagreement tests |
+| Dynamic safety authority | YES | adversarial benchmark + tests |
+| Soft-real-time controller | YES | executable timing-instrumented controller + tests |
+| Recovery architecture | YES | deterministic recovery + tests |
+| ROS 2 bridge | YES | real ROS 2 code; runtime requires ROS 2 environment |
+| ROS 2 robot action client | YES | `FollowJointTrajectory` implementation; physical run not claimed |
+| WebXR observer | YES | actual browser WebXR client; headset validation not claimed |
+| Hardware F/T integration path | YES | ROS 2 `WrenchStamped` adapter; live device measurement not included |
+| Hardware tactile integration path | YES | normalized ROS 2 tactile-patch transport; live device measurement not included |
+| LLM/VLA safety broker | YES | untrusted proposal validation, multimodal fusion, deterministic receipt |
+| HIL harness | YES | executable harness |
+| HIL measured PASS | **NO** | requires physical hardware |
+| Physical robot execution evidence | **NO** | requires physical robot |
+| Certified robot safety | **NO** | requires applicable engineering, standards, and independent validation |
+
+## Why the final three NOs remain NO
+
+IX-HapticSight deliberately refuses to turn software simulation into physical evidence.
+
+A ROS 2 adapter can be complete without a robot being connected. A HIL harness can be complete without fabricated measurements. A controller can be executable without being certified hard real time.
+
+Those distinctions are part of the project, not missing marketing polish.
+
+## Responsible use and safety
+
+The MIT license is in `LICENSE`. `RESPONSIBLE_USE.md` is a separate statement of project intent and does not add contradictory license restrictions.
+
+Physical deployment should use hardware-specific safety engineering, independent emergency-stop mechanisms, calibrated sensing, watchdogs, robot limits, formal risk assessment, and the standards applicable to the actual system and environment.
+
+## Author
+
+Bryce Lovell
diff --git a/RESPONSIBLE_USE.md b/RESPONSIBLE_USE.md
new file mode 100644
index 0000000..4f87949
--- /dev/null
+++ b/RESPONSIBLE_USE.md
@@ -0,0 +1,16 @@
+# Responsible Use Statement
+
+IX-HapticSight is intended for safety-oriented robotics, human-robot interaction, assistive research, education, simulation, and industrial research.
+
+This statement expresses project intent. It is **not an additional software license restriction** and does not alter the MIT License in `LICENSE`.
+
+The project asks users to:
+
+- keep physical safety authority independent from learned or generative models;
+- retain force, speed, thermal, proximity, emergency-stop, and watchdog protections appropriate to the hardware;
+- obtain meaningful authorization or consent before human-facing contact where consent is applicable;
+- avoid weaponization, coercive physical interaction, or non-consensual surveillance applications;
+- preserve provenance and clearly distinguish simulation evidence from HIL or physical-robot evidence;
+- follow applicable robot-safety, workplace, privacy, legal, and institutional requirements.
+
+Physical deployment requires hardware-specific engineering and validation. The repository's software tests do not certify a robot, controller, facility, or use case.
diff --git a/ROADMAP.md b/ROADMAP.md
index 578e40a..021b4cc 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,253 +1,69 @@
# IX-HapticSight Roadmap
-This roadmap defines the planned upgrade path from the current reference implementation toward a stronger, more auditable, and more runtime-oriented safety stack for bounded optical-haptic interaction.
+IX-HapticSight is a safety-first perception-to-contact authority for robots and XR. The project keeps learned perception, planners, and LLMs outside the final physical safety authority.
-It is intentionally conservative.
+## M0: Protocol reference
+**Status: complete**
-The project is not represented as certified, clinically validated, production deployed, or regulator-approved. The roadmap describes engineering intent and repository milestones, not real-world deployment approval.
+Consent, state machine, contact planning, force envelopes, safety gating, retreat semantics, and core schemas.
----
+## M1: Auditable runtime
+**Status: complete**
-## Repository Mission
+Runtime coordination, explicit sessions/faults, normalized force/tactile/proximity/thermal interfaces, structured logging, replay, benchmark support, and simulated execution.
-IX-HapticSight is being developed as a safety-first optical-haptic interaction architecture for bounded human-facing robot behavior.
+## M2: Perception-to-hazard pipeline
+**Status: complete at reference-model level**
-The core project mission is to make these behaviors explicit, testable, and reviewable:
+RGB-D ingestion, executable segmentation, reproducible synthetic calibration training, independent model quorum, uncertainty handling, and vision-derived tri-level hazard voxels.
-- approach
-- pre-contact verification
-- bounded contact
-- retreat
-- safe hold
-- consent-aware interaction gating
-- hazard-aware veto behavior
-- auditable runtime policy evaluation
+**Remaining evidence:** field datasets, calibrated depth hardware, production segmentation models, adverse-lighting evaluation, occlusion benchmarks, and robot-specific camera calibration.
-The long-term direction is not broad social robotics.
-
-The long-term direction is a measurable interaction-governance stack with deterministic safety constraints.
-
----
-
-## Current Baseline
-
-The current repository already contains:
-
-- protocol schemas
-- consent logic
-- nudge scheduling logic
-- rest-pose generation
-- contact planning logic
-- safety gating logic
-- configuration files
-- example usage
-- baseline unit tests
-
-That is enough for a reference implementation, but not enough for a runtime-grade or evidence-backed package.
-
----
-
-## Upgrade Goals
-
-The upgrade campaign is designed to produce a stronger repository in the following areas:
-
-1. **Repository credibility**
- - cleaner project structure
- - clearer scope and non-claims
- - stronger contribution and review rules
- - more disciplined release notes and artifacts
-
-2. **Runtime architecture**
- - package separation by responsibility
- - runtime coordinator structure
- - ROS 2-compatible package layout
- - explicit interfaces and message models
-
-3. **Safety behavior**
- - stronger veto architecture
- - explicit fault handling
- - retreat semantics
- - stale-consent rejection
- - independent policy enforcement paths
-
-4. **Physical sensing interfaces**
- - force-torque input abstraction
- - tactile sensor input abstraction
- - proximity input abstraction
- - thermal input abstraction
- - contact-state fusion hooks
-
-5. **Evidence and replay**
- - structured logs
- - replay tooling
- - deterministic benchmark scenarios
- - simulation scene packs
- - hardware-in-the-loop scaffolding
-
-6. **Governance**
- - threat model artifacts
- - privacy and data handling docs
- - safety invariant traceability
- - standards crosswalk
- - safety-case starter materials
-
----
-
-## Planned Maturity Levels
-
-### M0 — Reference Prototype
-Status: approximately current state
-
-Characteristics:
-- pure Python reference modules
-- documentation-first posture
-- baseline configs and tests
-- no real runtime messaging layer
-- no tactile or hardware abstraction layer
-- no benchmark suite
-- no HIL scaffolding
-
-### M1 — Structured Repository
-Planned outcome:
-- stronger packaging
-- contribution and release hygiene
-- clarified roadmap, non-claims, and project boundaries
-- expanded project documentation
-
-Exit criteria:
-- repository structure is stable
-- upgrade plan is documented
-- contribution rules and release notes exist
-- package metadata is present
-
-### M2 — Modular Runtime Foundation
-Planned outcome:
-- logical package separation
-- runtime coordination interfaces
-- ROS 2 workspace and node scaffolding
-- message and service definitions
-- launch and configuration layering
-
-Exit criteria:
-- runtime module boundaries are explicit
-- state ownership is clearer
-- node lifecycle assumptions are documented
-- configuration loading is centralized
-
-### M3 — Safety-Grade Execution Layer
-Planned outcome:
-- motion execution adapter interfaces
-- collision and zone gating
-- retreat/abort logic
-- watchdog behavior
-- dual-path veto design
-- stronger fault handling tests
-
-Exit criteria:
-- execution boundaries are explicit
-- abort and retreat semantics are testable
-- safety behavior is separated from convenience behavior
-
-### M4 — Physical Signal Integration
-Planned outcome:
-- force-torque interfaces
-- tactile interfaces
-- proximity interfaces
-- thermal interfaces
-- contact-state fusion logic
-- simulated sensor fixtures
-
-Exit criteria:
-- the codebase can represent measured contact-related inputs
-- the planner and safety logic can consume those inputs without hidden assumptions
-
-### M5 — Evidence, Replay, and Benchmarking
-Planned outcome:
-- structured event logs
-- replay tooling
-- benchmark schemas
-- canonical scenarios
-- metrics reports
-- deterministic result packages
-
-Exit criteria:
-- behavior changes can be replayed
-- benchmark outputs are comparable
-- metrics are documented and reproducible
-
-### M6 — HIL and Safety Case Readiness
-Planned outcome:
-- hardware-in-the-loop scaffolding
-- calibration templates
-- fault injection templates
-- standards crosswalk
-- privacy and governance docs
-- safety invariant traceability matrix
-- safety-case starter pack
-
-Exit criteria:
-- the repository supports disciplined evidence collection
-- traceability exists between requirements, tests, and claims
-- governance artifacts exist for future review
-
----
-
-## What This Project Is Not
-
-The repository should not drift into claims it cannot support.
-
-It is not:
-
-- a certified collaborative robot package
-- a medical device
-- a therapy robot
-- a proven emotion-recognition engine
-- a production deployment stack
-- a substitute for hardware safety engineering
-- a substitute for legal, regulatory, or IRB review
-- a claim of socially correct behavior in all settings
-
----
-
-## Evidence Philosophy
-
-The strongest form of this project will rely on:
-
-- explicit requirements
-- deterministic safety behavior
-- replayable logs
-- bounded contact semantics
-- benchmark scenarios
-- hardware-in-the-loop evidence
-- traceable documentation
-
-Preference is always given to measured evidence over narrative claims.
-
----
-
-## Release Philosophy
-
-The planned release direction is:
-
-- v0.1.x: reference implementation baseline
-- v0.2.x: repository restructuring and modularization
-- v0.3.x: runtime and ROS 2 scaffolding
-- v0.4.x: sensing interfaces and execution safety expansion
-- v0.5.x: replay and benchmark package
-- v0.6.x: HIL scaffolding and safety-case preparation
-- v1.0.0: strong repository milestone, still bounded by explicit non-claims unless real evidence justifies more
-
----
-
-## Final Roadmap Rule
-
-Every major upgrade should improve at least one of these:
-
-- safety
-- clarity
-- testability
-- traceability
-- replayability
-- boundedness
-
-If it does not improve one of those, it should be treated as optional, not core.
+## M3: Independent physical safety authority
+**Status: complete at software reference level**
+
+Explicit `ALLOW`, `MODIFY`, and `DENY`; dynamic authority derating; consent enforcement; uncertainty stops; proximity stops; runtime invariant monitoring; force and speed clamping; deterministic recovery.
+
+**Remaining evidence:** hardware safety controller implementation, safety PLC/MCU partitioning, formal timing analysis, certified E-stop chain, and standards-specific validation.
+
+## M4: ROS 2 and controller integration
+**Status: implementation complete, hardware validation pending**
+
+Bounded ROS 2 twist bridge, `WrenchStamped` force/torque ingestion, E-stop state, structured safety side-channel, and `FollowJointTrajectory` action client.
+
+**Remaining evidence:** named robot/controller configuration, MoveIt Servo or equivalent integration, real robot joint limits, collision scene, calibration, and measured command/feedback latency.
+
+## M5: XR observability
+**Status: implementation complete, device validation pending**
+
+WebXR observer with live safety-authority state, hazard markers, consent state, force/speed authority, and controller state.
+
+**Remaining evidence:** headset-specific testing, spatial registration accuracy, user studies, and latency measurements.
+
+## M6: HIL evidence
+**Status: harness complete, measured evidence pending**
+
+The harness rejects synthetic HIL claims. A PASS requires positive hardware capability detection and measured samples meeting declared limits.
+
+**Exit criteria for a real HIL PASS:**
+- robot motion hardware present;
+- live force/torque source present;
+- time-synchronized measurements;
+- declared sample count reached;
+- force and latency limits not exceeded;
+- fault injection and recovery captured;
+- evidence bundle retained.
+
+## M7: Physical robot validation
+**Status: not yet claimed**
+
+Required work includes physical contact trials, diverse-object manipulation, human-proximity validation, measured recovery, controller stress testing, failure injection, sim-to-real comparison, and independent review.
+
+## M8: Production / certification track
+**Status: future**
+
+Hardware-specific safety case, applicable standards work, deployment controls, privacy review, cybersecurity, manufacturing constraints, and external validation.
+
+## Non-negotiable claim rule
+
+Simulation, software tests, ROS 2 adapter availability, and synthetic calibration do not become physical evidence by wording. IX-HapticSight should only claim what an artifact or measurement actually demonstrates.
diff --git a/VALIDATION_REPORT.md b/VALIDATION_REPORT.md
new file mode 100644
index 0000000..e984602
--- /dev/null
+++ b/VALIDATION_REPORT.md
@@ -0,0 +1,89 @@
+# IX-HapticSight v0.2.0 Validation Report
+
+**Validation date:** 2026-08-29
+**Environment:** Windows 11 / Python 3.13.2 local release verification; Linux / Python 3.13.5 artifact-preparation verification.
+**Evidence scope:** software tests, deterministic synthetic calibration, and simulation only unless explicitly stated otherwise.
+
+## Release verification
+
+Command:
+
+`python scripts/verify_release.py`
+
+Observed result:
+
+- Python compile check: PASS
+- automated test suite: **183 passed**
+- protocol quickstart: PASS, `SAFETY_OK: True`
+- perception-to-contact integration demo: PASS
+- tamper-evident evidence chain in integration demo: PASS
+- adversarial safety-authority benchmark: **8 / 8 scenarios passed**
+- overall software release verification: PASS
+
+## Reproducible perception-model evidence
+
+The test suite retrains both committed reference segmenters into a fresh temporary directory and requires the generated model and metrics files to match the committed artifacts byte-for-byte.
+
+Result: PASS.
+
+Evidence class: `SYNTHETIC_CALIBRATION`.
+
+This does not support a claim of production segmentation accuracy. The training data are deterministic synthetic calibration samples.
+
+## Randomized safety property evidence
+
+The suite evaluates 2,000 deterministic randomized authority proposals and verifies that the independent safety authority never grants force or speed above:
+
+- the requested force/speed;
+- the configured base force/speed caps;
+- zero when the disposition is `DENY`.
+
+Result: PASS.
+
+Evidence class: `SOFTWARE_TEST`.
+
+## ROS 2 evidence
+
+Implemented:
+
+- `WrenchStamped` force/torque ingestion;
+- normalized tactile patch ingestion;
+- E-stop input;
+- bounded `TwistStamped` output;
+- structured safety events;
+- `FollowJointTrajectory` action-client implementation.
+
+Validation environment does not contain `rclpy`. The repository explicitly raises `Ros2Unavailable` rather than substituting simulated hardware, and that behavior is tested.
+
+Physical ROS 2 robot validation: **NOT RUN**.
+
+## HIL evidence
+
+The HIL harness is implemented and tested. It refuses to report a PASS when required hardware is absent.
+
+Measured HIL PASS for this release: **NOT_RUN_NO_HARDWARE**.
+
+No synthetic measurement has been promoted to HIL evidence.
+
+## WebXR evidence
+
+Implemented:
+
+- local live safety-state feed;
+- 2D browser fallback;
+- WebXR `immersive-ar` session path;
+- `XRWebGLLayer`;
+- XR animation frame loop;
+- XR-space hazard marker rendering.
+
+Headset/device validation for this release: **NOT RUN**.
+
+## Physical robot evidence
+
+Physical robot execution PASS: **NOT CLAIMED**.
+
+The ROS 2 execution paths are code-complete reference integrations, but a physical manipulator, calibrated sensors, controller, and HIL rig are required before physical performance can be stated.
+
+## Claim boundary
+
+The v0.2.0 repository supports a strong claim of an executable, auditable perception-to-contact safety architecture. It does not support a claim that IX-HapticSight outperforms a deployed industrial robot in manipulation speed, object coverage, success rate, durability, or scale.
diff --git a/configs/culture_profiles.yaml b/configs/culture_profiles.yaml
index 67b3166..7c4c2a0 100644
--- a/configs/culture_profiles.yaml
+++ b/configs/culture_profiles.yaml
@@ -10,7 +10,7 @@
version: v0.1
updated: 2025-08-08
-author: Bryce Wooster
+author: Bryce Lovell
# -------------------------------------------------------------------
# Global defaults used when a field is omitted in a locale profile
diff --git a/configs/force_limits.yaml b/configs/force_limits.yaml
index 4c96dc2..08a26f2 100644
--- a/configs/force_limits.yaml
+++ b/configs/force_limits.yaml
@@ -5,7 +5,7 @@
version: v0.1
updated: 2025-08-08
-author: Bryce Wooster
+author: Bryce Lovell
# -------------------------------
# GLOBAL SAFETY TIMERS & RATES
diff --git a/docs/architecture/package_map.md b/docs/architecture/package_map.md
index 185b3c6..4dba510 100644
--- a/docs/architecture/package_map.md
+++ b/docs/architecture/package_map.md
@@ -1,351 +1,110 @@
# Package Map
-This document defines the planned package responsibilities for the IX-HapticSight upgrade path.
+IX-HapticSight v0.2 separates perception, policy, safety authority, execution, sensing, evidence, and validation so no learned model silently inherits actuator authority.
-It is written to separate stable protocol logic from runtime integration, sensing adapters, replay tooling, and benchmark infrastructure.
+## `src/ohip/`
-Where the current repository already has code, that is noted explicitly.
-Where a package is planned but not yet fully implemented, that is also noted explicitly.
+Protocol core:
+- canonical schemas;
+- consent management;
+- contact planning;
+- nudge scheduling;
+- rest pose generation;
+- legacy dual-channel safety gate.
----
+## `src/ohip_agent/`
-## 1. Current Package Baseline
+Untrusted agent/VLA boundary:
+- physical proposal schema;
+- strict numeric validation;
+- proposal SHA-256;
+- multimodal safety brokerage;
+- bounded decision receipts.
-The present repository has one core Python package:
+## `src/ohip_perception/`
-- `src/ohip/`
+Perception:
+- RGB-D frame normalization;
+- executable reference segmenters;
+- reproducible synthetic calibration training;
+- two-model quorum;
+- uncertainty and critical-disagreement handling;
+- vision-to-hazard projection.
-That package currently contains:
+## `src/ohip_control/`
-- `__init__.py`
-- `schemas.py`
-- `consent_manager.py`
-- `contact_planner.py`
-- `nudge_scheduler.py`
-- `rest_pose.py`
-- `safety_gate.py`
-
-This is a reasonable reference-implementation layout, but it mixes concerns that should eventually be separated for runtime clarity and long-term maintainability.
+Independent physical safety authority:
+- multimodal safety fusion;
+- `ALLOW` / `MODIFY` / `DENY` authority;
+- dynamic force/speed envelopes;
+- runtime invariant monitor;
+- bounded soft-real-time control kernel;
+- deterministic recovery planner.
----
+## `src/ohip_runtime/`
-## 2. Target Package Direction
+Session/runtime coordination:
+- interaction requests;
+- session state;
+- faults;
+- coordination decisions;
+- runtime service and session store.
-The long-term structure should preserve a small, understandable core and add adjacent packages for runtime, interfaces, replay, and benchmarking.
-
-The target direction is:
-
-- `src/ohip/`
-- `src/ohip_runtime/`
-- `src/ohip_interfaces/`
-- `src/ohip_logging/`
-- `src/ohip_bench/`
-- `src/ohip_ros2/`
-
-This does not mean all packages must become large immediately.
-It means responsibilities should stop collapsing into one directory as the repository grows.
-
----
-
-## 3. Planned Responsibility by Package
-
-### `src/ohip/`
-Purpose:
-- stable protocol definitions
-- canonical data models
-- policy structures
-- contact request semantics
-- shared enums and validation helpers
-- deterministic core logic that is runtime-agnostic
-
-Current modules already here:
-- `schemas.py`
-- `consent_manager.py`
-- `contact_planner.py`
-- `nudge_scheduler.py`
-- `rest_pose.py`
-- `safety_gate.py`
-
-Likely long-term contents:
-- `schemas.py`
-- `policy_models.py`
-- `interaction_state.py`
-- `consent_rules.py`
-- `contact_constraints.py`
-- `hazard_models.py`
-
-Rule:
-- this package should stay lightweight and not absorb runtime transport code
-
----
-
-### `src/ohip_runtime/`
-Purpose:
-- runtime orchestration
-- state ownership
-- coordinator logic
-- transition control
-- timeout handling
-- policy and safety evaluation sequencing
-- runtime-level fault handling
-
-Planned examples:
-- runtime coordinator
-- interaction session controller
-- state transition manager
-- fault latch manager
-- watchdog helpers
-
-Rule:
-- this package decides when things happen, not how hardware talks
-
----
-
-### `src/ohip_interfaces/`
-Purpose:
-- device-agnostic input/output interfaces
-- normalized sensor payloads
-- execution adapter contracts
-- runtime backend abstraction
-
-Planned subdomains:
-- force-torque interfaces
-- tactile interfaces
-- proximity interfaces
-- thermal interfaces
-- execution command interfaces
-
-Likely future modules:
-- `force_torque.py`
-- `tactile.py`
-- `proximity.py`
-- `thermal.py`
-- `execution_adapter.py`
-- `signal_health.py`
-
-Rule:
-- raw device-specific transport should not leak into core policy logic
-
----
-
-### `src/ohip_logging/`
-Purpose:
-- structured event logging
-- replay records
-- event serialization
-- audit bundle generation
-- trace export helpers
-
-Planned examples:
-- event schema definitions
-- log writers
-- replay session loaders
-- evidence bundle indexing
-- transition history formatting
-
-Rule:
-- logs must explain behavior without requiring a human to read unrelated console output
-
----
-
-### `src/ohip_bench/`
-Purpose:
-- benchmark scenario definitions
-- metrics collection
-- deterministic test harnesses
-- replayable benchmark execution
-- scenario result packaging
-
-Planned benchmark groups:
-- consent benchmarks
-- hazard benchmarks
-- contact benchmarks
-- retreat and veto benchmarks
-- logging/replay integrity benchmarks
-
-Rule:
-- benchmark logic should be independent from presentation docs and easy to re-run
-
----
-
-### `src/ohip_ros2/`
-Purpose:
-- ROS 2-specific node wrappers
-- ROS 2 message/service bridges
-- parameter handling integration
-- launch files
-- lifecycle integration scaffolding
-
-Planned examples:
-- lifecycle nodes
-- runtime coordinator node
-- consent node
-- safety node
-- contact planning bridge
-- replay publishing tools
-
-Rule:
-- ROS 2 integration should remain an adapter layer, not redefine protocol semantics
-
----
+## `src/ohip_interfaces/`
-## 4. Relationship Between Packages
+Backend-neutral sensing and execution contracts:
+- signal health/freshness;
+- force/torque;
+- tactile;
+- proximity;
+- thermal;
+- execution adapter;
+- simulated execution adapter.
-The dependency direction should be controlled.
+## `src/ohip_ros2/`
-Preferred dependency flow:
+Concrete ROS 2 integration:
+- `WrenchStamped` force/torque conversion;
+- normalized tactile patch transport;
+- E-stop input;
+- bounded `TwistStamped` output;
+- structured safety events;
+- `FollowJointTrajectory` action client.
-- `ohip`
- - has no dependency on ROS 2 packages
-- `ohip_runtime`
- - may depend on `ohip`
-- `ohip_interfaces`
- - may depend on `ohip`
-- `ohip_logging`
- - may depend on `ohip`
-- `ohip_bench`
- - may depend on `ohip`, `ohip_runtime`, and `ohip_logging`
-- `ohip_ros2`
- - may depend on `ohip`, `ohip_runtime`, and `ohip_interfaces`
+ROS 2 is optional at import time. Starting the bridge without ROS 2 installed fails explicitly rather than substituting simulation.
-Avoid the reverse where possible.
+## `src/ohip_sim/`
-In particular:
-- `ohip` should not depend on `ohip_ros2`
-- `ohip` should not depend on device transport libraries
-- `ohip` should not depend on benchmark harness code
+Deterministic software-only contact plant for controller regression. It is simulation evidence only.
-This keeps the protocol core portable and easy to test.
+## `src/ohip_hil/`
----
+Hardware-in-the-loop evidence harness. It cannot report PASS without declared required hardware and measured samples.
-## 5. Current-to-Target Mapping
+## `src/ohip_logging/`
-This section shows where existing modules are likely to remain or move conceptually.
+Structured runtime event logging and replay helpers.
-### `src/ohip/schemas.py`
-Current role:
-- canonical protocol data types
+## `src/ohip_evidence/`
-Likely future role:
-- remains in `ohip`
-- may be split into smaller files over time
+Tamper-evident evidence chain and portable evidence-bundle verifier.
----
+## `src/ohip_bench/`
-### `src/ohip/consent_manager.py`
-Current role:
-- consent evaluation logic
+Deterministic benchmark models, scenarios, reports, and adversarial safety-authority benchmark.
-Likely future role:
-- remains partially in `ohip`
-- runtime-facing orchestration may move to `ohip_runtime`
+## `src/ohip_xr/`
-Split concept:
-- rule evaluation stays in core
-- session/time handling moves to runtime
+XR safety-state payloads and local state server.
----
+## `examples/webxr/`
-### `src/ohip/contact_planner.py`
-Current role:
-- bounded contact decision logic
+Actual browser WebXR observer with XR-space hazard rendering plus desktop fallback.
-Likely future role:
-- core planning constraints remain in `ohip`
-- execution-bound planning orchestration may use `ohip_runtime`
-- hardware command translation belongs in interfaces or ROS 2 integration
+## `models/`
----
+Committed reference segmentation parameters and metrics. The current models are synthetic-calibration baselines, not production perception claims.
-### `src/ohip/nudge_scheduler.py`
-Current role:
-- schedule and timing logic for interaction
+## `tests/`
-Likely future role:
-- policy rules remain in `ohip`
-- runtime timers and callbacks move to `ohip_runtime`
-
----
-
-### `src/ohip/rest_pose.py`
-Current role:
-- rest and posture generation logic
-
-Likely future role:
-- posture target generation can remain in `ohip`
-- runtime delivery of poses belongs elsewhere
-
----
-
-### `src/ohip/safety_gate.py`
-Current role:
-- hazard and force gating logic
-
-Likely future role:
-- core safety decision rules remain in `ohip`
-- runtime watchdog, fault latching, and actuator abort routing live in `ohip_runtime`
-
----
-
-## 6. Why This Separation Matters
-
-The current repository is still small enough that everything in one package is understandable.
-
-That will stop being true once the project gains:
-
-- runtime coordinators
-- sensing adapters
-- message definitions
-- replay tooling
-- benchmark runners
-- ROS 2 nodes
-- HIL scaffolding
-
-Without separation, the result becomes harder to review and easier to break.
-
-With separation:
-- policy stays readable
-- runtime stays replaceable
-- interfaces stay swappable
-- evidence tooling stays organized
-
----
-
-## 7. Review Questions for New Package Work
-
-When adding or moving code, the reviewer should ask:
-
-1. Does this belong in the protocol core or in runtime plumbing?
-2. Does this code depend on a specific backend or transport?
-3. Could this logic be reused without ROS 2?
-4. Is this sensor-specific or policy-generic?
-5. Is this behavior needed at runtime, or only for replay or benchmarking?
-6. Does this change make the dependency graph cleaner or worse?
-
-If the answer is unclear, the default should be to keep the protocol core smaller.
-
----
-
-## 8. Near-Term Package Priorities
-
-The first package-growth priorities should be:
-
-1. preserve and stabilize `ohip`
-2. create `ohip_runtime` for orchestration
-3. create `ohip_interfaces` for sensing and execution boundaries
-4. create `ohip_logging` for structured event and replay artifacts
-5. create `ohip_bench` for benchmark harnesses
-6. add `ohip_ros2` after the previous boundaries are clear
-
-This order reduces confusion and prevents ROS-specific assumptions from leaking into everything else.
-
----
-
-## 9. Final Rule
-
-The package map should help the repository become easier to understand as it grows.
-
-If a package split adds ceremony without clarifying responsibility, it is premature.
-
-If a package split makes safety, runtime ownership, replay, or interface boundaries clearer, it is likely justified.
+Automated regression suite covering protocol, runtime, interfaces, perception, control, multimodal safety, agent brokerage, evidence integrity, ROS 2 contracts, HIL semantics, XR artifacts, and simulation.
diff --git a/docs/architecture/perception_to_contact_authority.md b/docs/architecture/perception_to_contact_authority.md
new file mode 100644
index 0000000..0ea20bd
--- /dev/null
+++ b/docs/architecture/perception_to_contact_authority.md
@@ -0,0 +1,43 @@
+# Perception-to-Contact Safety Authority
+
+The v0.2 architecture separates four forms of authority that are often accidentally mixed together in robot prototypes.
+
+1. **Perception authority:** a model may estimate what is present.
+2. **Task authority:** a planner or LLM may propose what the robot should attempt.
+3. **Safety authority:** deterministic logic independently decides what physical authority may be granted.
+4. **Execution authority:** a controller may execute only inside the granted force, speed, state, and timeout envelope.
+
+No layer is allowed to silently inherit a stronger authority from the layer above it.
+
+## Perception quorum
+
+Two independently trained reference models can inspect the same RGB-D frame. The quorum computes total agreement, critical-class disagreement, mean confidence, and uncertainty. Human, hot, liquid, and sharp disagreements are treated more strictly than ordinary background disagreement.
+
+The reference models are intentionally simple. The design point is the *quorum boundary*, not a claim that the supplied synthetic-calibration models are production vision.
+
+## Safety authority
+
+The independent authority emits one of three dispositions:
+
+- `ALLOW`: request already fits the current measured envelope.
+- `MODIFY`: action may proceed only after force and/or speed is reduced.
+- `DENY`: no physical authority is granted.
+
+The authority also emits a bounded counterfactual, for example that separation must increase, confidence must improve, or consent must be reacquired.
+
+## Cycle invariants
+
+After a decision is granted, the runtime still re-checks invariants every cycle. Permission is not permanent. Consent loss, stale sensors, unexpected measured force, perception disagreement, E-stop, or watchdog failure can terminate authority after motion has begun.
+
+## Recovery
+
+Recovery is explicit rather than left to learned policy behavior:
+
+- zero effort when already in problematic contact;
+- retract when safe motion away from the hazard is available;
+- safe hold when uncertainty or timing prevents a trustworthy retreat;
+- operator required for latched faults or E-stop.
+
+## Evidence
+
+Decisions and runtime transitions can be recorded into a SHA-256 evidence chain. The hash chain does not prove the physical truth of a sensor measurement, but it can detect later modification of recorded evidence.
diff --git a/docs/architecture/ros2_and_hardware.md b/docs/architecture/ros2_and_hardware.md
new file mode 100644
index 0000000..a6d3fc5
--- /dev/null
+++ b/docs/architecture/ros2_and_hardware.md
@@ -0,0 +1,18 @@
+# ROS 2 and Hardware Integration
+
+The v0.2 ROS 2 layer intentionally uses standard interfaces where possible.
+
+## Inputs
+
+- `geometry_msgs/WrenchStamped` for force/torque
+- `std_msgs/Bool` for E-stop state
+
+## Outputs
+
+- `geometry_msgs/TwistStamped` for already-bounded velocity commands
+- `std_msgs/String` structured safety side-channel
+- `control_msgs/FollowJointTrajectory` action client for standard trajectory controllers
+
+The safety authority should run upstream of these outputs. Robot-specific code may reduce authority further, but it may not expand force or speed above the granted envelope.
+
+ROS 2 is loaded lazily. A non-ROS machine can run the protocol, perception, control, evidence, simulation, and tests. Starting a ROS bridge without `rclpy` produces an explicit unavailable error rather than silently simulating hardware.
diff --git a/docs/architecture/webxr_observer.md b/docs/architecture/webxr_observer.md
new file mode 100644
index 0000000..8ec86a4
--- /dev/null
+++ b/docs/architecture/webxr_observer.md
@@ -0,0 +1,16 @@
+# WebXR Safety Observer
+
+The WebXR observer is an observability surface, not the safety authority itself.
+
+It displays:
+
+- safety-authority disposition;
+- force and speed caps;
+- consent state;
+- controller state;
+- veto or derating reason;
+- colored hazard markers.
+
+The included browser client requests an `immersive-ar` WebXR session when a compatible browser and device are available. The same page falls back to a 2D safety visualization when WebXR is unavailable.
+
+XR visualization latency and spatial registration are not assumed safe enough to close a physical control loop. Those require device-specific measured validation.
diff --git a/docs/benchmarks/metrics.md b/docs/benchmarks/metrics.md
index 2c99b32..31c07ee 100644
--- a/docs/benchmarks/metrics.md
+++ b/docs/benchmarks/metrics.md
@@ -268,7 +268,7 @@ These are still software-path metrics unless backed by real runtime measurements
This is where the metric system becomes much more serious.
-Once HIL scaffolding is connected to actual measurements, the benchmark/evidence layer should eventually support metrics like:
+Once the HIL harness is connected to actual measurements, the benchmark/evidence layer should eventually support metrics like:
### 7.1 Contact metrics
- peak measured force
diff --git a/docs/governance/standards_crosswalk.md b/docs/governance/standards_crosswalk.md
index c7d0aa1..b2a6554 100644
--- a/docs/governance/standards_crosswalk.md
+++ b/docs/governance/standards_crosswalk.md
@@ -406,12 +406,12 @@ It only means the repository is attempting to align its architecture and documen
The highest-value next steps are:
-1. add stronger tests for consent, veto, force limits, and state transitions
-2. create structured event and replay artifacts
-3. add benchmark scenario and metric definitions
-4. add integrity handling for critical configs
-5. add runtime boundary code that preserves policy/safety separation
-6. add HIL scaffolding and evidence templates
+1. extend measured hardware tests for consent, veto, force limits, and state transitions
+2. exercise the existing structured event and replay artifacts against HIL data
+3. expand benchmark scenarios with measured robot cases
+4. extend integrity handling to signed deployment configuration
+5. validate the implemented runtime boundary on a physical controller
+6. connect the implemented HIL harness to measured hardware evidence
Those steps would materially raise the maturity of the crosswalk.
diff --git a/docs/governance/threat_model.md b/docs/governance/threat_model.md
index e24a84e..62b3fe2 100644
--- a/docs/governance/threat_model.md
+++ b/docs/governance/threat_model.md
@@ -240,9 +240,9 @@ Mitigation direction:
Execution may involve:
- simulation backend
- test executor
-- future ROS 2 bridge
-- future motion-planning backend
-- future robot controller bridge
+- ROS 2 bridge
+- robot-specific motion-planning backend
+- robot controller bridge
Risk:
- backend ignores limits
diff --git a/docs/safety/requirements_traceability.md b/docs/safety/requirements_traceability.md
index e4c66c8..2e1b7a3 100644
--- a/docs/safety/requirements_traceability.md
+++ b/docs/safety/requirements_traceability.md
@@ -1,565 +1,42 @@
# Requirements Traceability Matrix
-This document defines the initial traceability matrix for IX-HapticSight as the repository evolves from a protocol-oriented reference implementation into a stronger runtime and evidence-oriented architecture.
-
-The purpose of this matrix is simple:
-
-- identify what the project claims
-- identify what each claim depends on
-- identify where that claim is implemented, documented, or tested
-- identify what evidence is still missing
-
-This document is intentionally conservative.
-A requirement is not considered satisfied merely because a concept appears in prose.
-A requirement should map to at least one of the following:
-
-- normative documentation
-- source code
-- test coverage
-- benchmark scenario
-- replay artifact
-- future hardware-in-the-loop evidence path
-
----
-
-## 1. Traceability Philosophy
-
-IX-HapticSight should prefer explicit traceability over vague assurance.
-
-A reviewer should be able to ask:
-
-- what is the requirement
-- where is it defined
-- where is it implemented
-- where is it tested
-- where is it logged
-- what evidence remains missing
-
-If that chain does not exist, the requirement is still immature.
-
----
-
-## 2. Status Labels
-
-This matrix uses the following status labels.
-
-### `IMPLEMENTED`
-There is code or documentation in the current repository that materially satisfies the requirement at the reference-implementation level.
-
-### `PARTIAL`
-Some evidence exists, but the requirement is not yet fully represented in code, tests, logging, or runtime behavior.
-
-### `PLANNED`
-The requirement is part of the intended architecture, but the current repository does not yet implement it in a meaningful way.
-
-### `EVIDENCE-GAP`
-The requirement is conceptually present, but meaningful evidence for it is not yet available.
-
----
-
-## 3. Current Baseline Artifacts
-
-Current repository artifacts relevant to traceability include:
-
-- `docs/spec.md`
-- `docs/state_machine.md`
-- `src/ohip/schemas.py`
-- `src/ohip/consent_manager.py`
-- `src/ohip/contact_planner.py`
-- `src/ohip/nudge_scheduler.py`
-- `src/ohip/rest_pose.py`
-- `src/ohip/safety_gate.py`
-- `tests/test_schemas.py`
-- `tests/test_nudge_scheduler.py`
-- `configs/force_limits.yaml`
-- `configs/culture_profiles.yaml`
-- `examples/quickstart.py`
-
-This matrix also references upgrade-era documents such as:
-
-- `docs/safety/invariants.md`
-- future benchmark, replay, runtime, and HIL artifacts that will be added later in the 72-commit campaign
-
----
-
-## 4. Requirement Matrix
-
-## RQ-001 — Canonical protocol data structures shall be defined in a stable, implementation-agnostic form.
-
-**Intent:**
-The project must have clear message/data structures for consent, safety semantics, contact planning, and execution logging so that implementations do not drift silently.
-
-**Primary references:**
-- `docs/spec.md`
-- `src/ohip/schemas.py`
-
-**Current implementation anchors:**
-- `src/ohip/schemas.py`
-
-**Current test anchors:**
-- `tests/test_schemas.py`
-
-**Logging/replay relevance:**
-- execution/event structures exist conceptually in schemas, but dedicated replay/logging tooling is not yet present
-
-**Status:**
-`IMPLEMENTED` at reference-implementation level
-
-**Evidence gap:**
-- no dedicated event-log package yet
-- no schema compatibility tests across runtime backends yet
-
----
-
-## RQ-002 — Human-facing contact shall require valid consent semantics or an explicitly documented non-contact-only mode.
-
-**Intent:**
-The system must not treat human contact as default-permitted behavior.
-
-**Primary references:**
-- `docs/spec.md`
-- `docs/state_machine.md`
-- `docs/safety/invariants.md`
-
-**Current implementation anchors:**
-- `src/ohip/consent_manager.py`
-- `src/ohip/contact_planner.py`
-
-**Current test anchors:**
-- indirect coverage may exist in behavior paths, but there is not yet a dedicated consent test suite in the current baseline
-
-**Logging/replay relevance:**
-- consent decisions are not yet captured through structured event logging
-
-**Status:**
-`PARTIAL`
-
-**Evidence gap:**
-- no dedicated unit tests for consent freshness and revocation
-- no replay artifact showing denial, revocation, or stale-consent behavior
-- no benchmark suite for consent edge cases yet
-
----
-
-## RQ-003 — Safety-map semantics shall distinguish GREEN, YELLOW, and RED conditions.
-
-**Intent:**
-The repository must preserve clear tri-level safety semantics for permitted, verify-first, and prohibited conditions.
-
-**Primary references:**
-- `docs/spec.md`
-- `src/ohip/schemas.py`
-
-**Current implementation anchors:**
-- `src/ohip/schemas.py`
-- `src/ohip/safety_gate.py`
-
-**Current test anchors:**
-- partial schema coverage in `tests/test_schemas.py`
-
-**Status:**
-`PARTIAL`
-
-**Evidence gap:**
-- no dedicated tests for hazard-to-veto behavior
-- no scenario suite validating RED intersection behavior
-- no runtime replay evidence yet
-
----
-
-## RQ-004 — A hard hazard or veto condition shall prevent or interrupt unsafe action.
-
-**Intent:**
-The system must preserve veto authority above convenience execution.
-
-**Primary references:**
-- `docs/spec.md`
-- `docs/state_machine.md`
-- `docs/safety/invariants.md`
-
-**Current implementation anchors:**
-- `src/ohip/safety_gate.py`
-- conceptual interaction flow in `examples/quickstart.py`
-
-**Current test anchors:**
-- no dedicated veto-path test file currently present in the baseline archive
-
-**Status:**
-`PARTIAL`
-
-**Evidence gap:**
-- no dedicated unit tests for hard-veto priority
-- no fault-injection or replay evidence
-- no independent runtime watchdog path yet
-
----
-
-## RQ-005 — The state machine shall define bounded interaction states and explicit recovery paths.
-
-**Intent:**
-The project must not rely on vague or hidden control flow for approach, contact, retreat, and safe-hold behavior.
-
-**Primary references:**
-- `docs/state_machine.md`
-- `docs/spec.md`
-
-**Current implementation anchors:**
-- distributed logically across:
- - `src/ohip/contact_planner.py`
- - `src/ohip/rest_pose.py`
- - `src/ohip/safety_gate.py`
- - `src/ohip/nudge_scheduler.py`
-
-**Current test anchors:**
-- no dedicated state-machine conformance tests in the present baseline
-
-**Status:**
-`PARTIAL`
-
-**Evidence gap:**
-- no transition-table tests
-- no invariant tests against the FSM
-- no replay of actual transition sequences
-
----
-
-## RQ-006 — Force-limited contact behavior shall remain bounded by configured limits.
-
-**Intent:**
-If contact is planned or executed, it must remain inside explicitly selected constraints.
-
-**Primary references:**
-- `docs/spec.md`
-- `configs/force_limits.yaml`
-- `docs/safety/invariants.md`
-
-**Current implementation anchors:**
-- `configs/force_limits.yaml`
-- `src/ohip/contact_planner.py`
-- `src/ohip/safety_gate.py`
-
-**Current test anchors:**
-- no dedicated force-limit validation tests in the current baseline
-
-**Status:**
-`PARTIAL`
-
-**Evidence gap:**
-- no force-profile selection tests
-- no overforce event tests
-- no measured evidence
-- no structured overforce logging yet
-
----
-
-## RQ-007 — Rest posture behavior shall be explicit and non-threatening when idle or after recovery.
-
-**Intent:**
-The system should maintain a clear and bounded idle/rest behavior rather than ambiguous hand motion.
-
-**Primary references:**
-- `docs/spec.md`
-- `src/ohip/rest_pose.py`
-
-**Current implementation anchors:**
-- `src/ohip/rest_pose.py`
-
-**Current test anchors:**
-- none currently visible in baseline tests
-
-**Status:**
-`PARTIAL`
-
-**Evidence gap:**
-- no posture validation tests
-- no scenario coverage for recovery-to-rest behavior
-- no runtime evidence or replay
-
----
-
-## RQ-008 — Engagement scheduling shall prioritize safer interaction opportunities and avoid unsafe targets.
-
-**Intent:**
-Scheduling logic should respect safety semantics and support deterministic prioritization.
-
-**Primary references:**
-- `docs/spec.md`
-- `src/ohip/nudge_scheduler.py`
-
-**Current implementation anchors:**
-- `src/ohip/nudge_scheduler.py`
-
-**Current test anchors:**
-- `tests/test_nudge_scheduler.py`
-
-**Status:**
-`IMPLEMENTED` at reference-implementation level
-
-**Evidence gap:**
-- no replayable benchmark pack for scheduler edge cases
-- no runtime coordination tests involving multiple simultaneous requests
-
----
-
-## RQ-009 — The repository shall support transparent auditing of important decisions and outcomes.
-
-**Intent:**
-A serious safety-first interaction stack must support review after the fact.
-
-**Primary references:**
-- `docs/spec.md`
-- `docs/safety/invariants.md`
-
-**Current implementation anchors:**
-- conceptual only through schemas and documentation
-
-**Current test anchors:**
-- none
-
-**Status:**
-`PLANNED`
-
-**Evidence gap:**
-- no structured event logger package
-- no replay tool
-- no evidence bundle format
-- no benchmark result schema package yet
-
----
-
-## RQ-010 — The repository shall preserve explicit non-claims and avoid overstating deployment readiness.
-
-**Intent:**
-Documentation must not imply certification, medical validation, or production safety that the repo does not actually support.
-
-**Primary references:**
-- `ROADMAP.md`
-- `docs/safety/invariants.md`
-
-**Current implementation anchors:**
-- currently mixed; some legacy docs still contain language that needs tightening
-
-**Current test anchors:**
-- not applicable as a code test in the current baseline
-
-**Status:**
-`PARTIAL`
-
-**Evidence gap:**
-- README and some legacy documentation still need harmonization
-- no release checklist enforcing non-claim language yet
-
----
-
-## RQ-011 — The project shall maintain backend-agnostic core logic.
-
-**Intent:**
-Core policy, consent, and safety behavior should remain portable rather than tightly bound to one transport or middleware.
-
-**Primary references:**
-- `docs/spec.md`
-- `docs/architecture/package_map.md`
-- `docs/architecture/runtime_overview.md`
-
-**Current implementation anchors:**
-- current `src/ohip/` package is Python-only and middleware-agnostic
-
-**Current test anchors:**
-- indirect through existing unit tests
-
-**Status:**
-`IMPLEMENTED` at current scale
-
-**Evidence gap:**
-- future ROS 2 integration must preserve this boundary
-- no compatibility checks across multiple runtimes yet
-
----
-
-## RQ-012 — Runtime execution shall eventually distinguish approval logic from backend command transport.
-
-**Intent:**
-Consent, safety, planning, and backend execution must not collapse into one hidden path.
-
-**Primary references:**
-- `docs/architecture/runtime_overview.md`
-- `docs/architecture/execution_adapter.md`
-- `docs/architecture/node_graph.md`
-
-**Current implementation anchors:**
-- not yet implemented as a dedicated code boundary
-
-**Current test anchors:**
-- none
-
-**Status:**
-`PLANNED`
-
-**Evidence gap:**
-- no execution adapter package yet
-- no runtime coordinator yet
-- no execution fault tests yet
-
----
-
-## RQ-013 — Sensor freshness and signal health shall be explicit once runtime sensing interfaces are added.
-
-**Intent:**
-The system must not pretend stale sensor data is trustworthy in a safety path.
-
-**Primary references:**
-- `docs/safety/invariants.md`
-- `docs/architecture/runtime_overview.md`
-
-**Current implementation anchors:**
-- not yet represented as dedicated interface modules in the baseline archive
-
-**Current test anchors:**
-- none
-
-**Status:**
-`PLANNED`
-
-**Evidence gap:**
-- no force-torque, tactile, proximity, or thermal interface packages yet
-- no stale-signal tests yet
-
----
-
-## RQ-014 — Structured logging and replay shall support after-action review and benchmark comparison.
-
-**Intent:**
-Important behavior should be inspectable without guesswork.
-
-**Primary references:**
-- `docs/architecture/runtime_overview.md`
-- `docs/architecture/node_graph.md`
-- `docs/safety/invariants.md`
-
-**Current implementation anchors:**
-- not yet implemented as code
-
-**Current test anchors:**
-- none
-
-**Status:**
-`PLANNED`
-
-**Evidence gap:**
-- no event schema package beyond conceptual schema structures
-- no replay loader/publisher
-- no result comparison tooling
-
----
-
-## RQ-015 — Benchmark scenarios shall become reproducible and tied to documented metrics.
-
-**Intent:**
-The project should be able to compare behavior across changes using consistent scenarios.
-
-**Primary references:**
-- `ROADMAP.md`
-- `docs/safety/invariants.md`
-- future benchmark docs
-
-**Current implementation anchors:**
-- not yet implemented as a benchmark package
-
-**Current test anchors:**
-- none
-
-**Status:**
-`PLANNED`
-
-**Evidence gap:**
-- no scenario catalog yet
-- no metrics collector
-- no benchmark runner
-- no benchmark report schema
-
----
-
-## RQ-016 — Future hardware-in-the-loop evidence should be traceable to repository requirements.
-
-**Intent:**
-If the project eventually gathers physical evidence, that evidence should map cleanly back to documented requirements.
-
-**Primary references:**
-- `ROADMAP.md`
-- `docs/safety/invariants.md`
-
-**Current implementation anchors:**
-- none yet; only planned architecture direction
-
-**Current test anchors:**
-- none
-
-**Status:**
-`EVIDENCE-GAP`
-
-**Evidence gap:**
-- no HIL fixture architecture yet
-- no calibration templates
-- no fault-injection templates
-- no evidence bundle structure yet
-
----
-
-## 5. Requirement-to-Artifact Summary Table
-
-| Requirement | Primary Focus | Current Code Anchor | Current Test Anchor | Status |
-|---|---|---|---|---|
-| RQ-001 | canonical schemas | `src/ohip/schemas.py` | `tests/test_schemas.py` | IMPLEMENTED |
-| RQ-002 | consent for contact | `src/ohip/consent_manager.py` | none dedicated yet | PARTIAL |
-| RQ-003 | GREEN/YELLOW/RED semantics | `src/ohip/schemas.py`, `src/ohip/safety_gate.py` | partial schema tests | PARTIAL |
-| RQ-004 | veto priority | `src/ohip/safety_gate.py` | none dedicated yet | PARTIAL |
-| RQ-005 | bounded state machine | distributed in `src/ohip/` | none dedicated yet | PARTIAL |
-| RQ-006 | force-limited contact | planner + safety + configs | none dedicated yet | PARTIAL |
-| RQ-007 | rest posture behavior | `src/ohip/rest_pose.py` | none dedicated yet | PARTIAL |
-| RQ-008 | deterministic scheduling | `src/ohip/nudge_scheduler.py` | `tests/test_nudge_scheduler.py` | IMPLEMENTED |
-| RQ-009 | transparent auditing | not yet dedicated | none | PLANNED |
-| RQ-010 | explicit non-claims | docs/roadmap layer | none | PARTIAL |
-| RQ-011 | backend-agnostic core | `src/ohip/` | indirect existing tests | IMPLEMENTED |
-| RQ-012 | execution boundary | planned docs only | none | PLANNED |
-| RQ-013 | signal freshness | planned docs only | none | PLANNED |
-| RQ-014 | replayability | planned docs only | none | PLANNED |
-| RQ-015 | benchmark reproducibility | planned docs only | none | PLANNED |
-| RQ-016 | HIL traceability | not yet present | none | EVIDENCE-GAP |
-
----
-
-## 6. Near-Term Traceability Priorities
-
-The next highest-value traceability improvements are:
-
-1. add dedicated consent tests
-2. add dedicated safety-veto tests
-3. add force-limit and overforce tests
-4. add state-transition conformance tests
-5. add structured event definitions for logging and replay
-6. add benchmark scenario and result schemas
-7. add HIL evidence folder structure and templates
-
-These will convert several `PARTIAL` and `PLANNED` requirements into something much stronger.
-
----
-
-## 7. Review Rule
-
-A new feature should not be considered mature unless it can answer four questions:
-
-1. what requirement does it satisfy
-2. where is that requirement documented
-3. where is it implemented
-4. where is it tested or otherwise evidenced
-
-If one of those links is missing, the feature is still incomplete.
-
----
-
-## 8. Final Note
-
-This matrix will need regular updates as the 72-commit campaign progresses.
-
-It is intended to become stricter over time, not looser.
-As runtime, sensing, replay, benchmark, and HIL artifacts are added, they should be inserted into this matrix rather than left as disconnected files.
+This matrix describes the v0.2.0 reference implementation. `IMPLEMENTED` means implemented and tested at the stated software level. It does not imply physical validation or certification.
+
+| ID | Requirement | Implementation anchors | Test / evidence anchors | Status | Remaining evidence |
+|---|---|---|---|---|---|
+| RQ-001 | Canonical protocol schemas | `src/ohip/schemas.py` | `tests/test_schemas.py` | IMPLEMENTED | interoperability/version migration across external implementations |
+| RQ-002 | Consent must gate contact where required | `src/ohip/consent_manager.py`, `src/ohip_control/authority.py` | consent + authority tests | IMPLEMENTED | human-subject/user-interface validation |
+| RQ-003 | RED hazards must deny physical authority | `src/ohip/safety_gate.py`, `src/ohip_control/authority.py` | adversarial benchmark, randomized authority property test | IMPLEMENTED | physical hazard-sensor validation |
+| RQ-004 | Learned/agent output may not enlarge deterministic limits | `src/ohip_agent/broker.py`, `src/ohip_control/envelope.py` | broker tests, 2,000-case randomized authority property test | IMPLEMENTED | external VLA/LLM integration trials |
+| RQ-005 | Vision shall produce explicit confidence/uncertainty | `src/ohip_perception/segmentation.py` | perception tests, reproducible model build | IMPLEMENTED | field dataset calibration |
+| RQ-006 | Independent perception disagreement shall be detectable | `src/ohip_perception/fusion.py` | quorum tests | IMPLEMENTED | independent production model families and field tests |
+| RQ-007 | Perception-derived hazards shall map to tri-level safety state | `src/ohip_perception/hazard_map.py` | hazard-map and pipeline tests | IMPLEMENTED | calibrated camera/depth geometry |
+| RQ-008 | Multimodal state shall be able to override vision | `src/ohip_control/multimodal.py` | multimodal fusion tests | IMPLEMENTED | live synchronized sensor streams |
+| RQ-009 | Force and speed requests shall be clamped or denied | `src/ohip_control/envelope.py`, `authority.py` | envelope, authority, benchmark, property tests | IMPLEMENTED | robot/controller measurements |
+| RQ-010 | Runtime shall re-check safety after initial authorization | `src/ohip_control/invariants.py`, `realtime.py` | controller/invariant tests | IMPLEMENTED | hard-real-time deployment and timing evidence |
+| RQ-011 | Recovery shall be explicit | `src/ohip_control/recovery.py` | controller/recovery tests | IMPLEMENTED | measured recovery trajectories |
+| RQ-012 | Backend transport shall remain downstream of safety authority | `src/ohip_interfaces/execution_adapter.py`, `src/ohip_ros2/` | adapter and ROS contract tests | IMPLEMENTED | robot-specific integration |
+| RQ-013 | Live F/T transport path shall exist | `src/ohip_ros2/messages.py`, `bridge.py` | ROS wrench converter tests | IMPLEMENTED | live force/torque hardware evidence |
+| RQ-014 | Live tactile transport path shall exist | `src/ohip_ros2/messages.py`, `bridge.py` | tactile converter tests | IMPLEMENTED | live tactile hardware evidence |
+| RQ-015 | Missing ROS runtime shall not silently become simulated hardware | `src/ohip_ros2/bridge.py` | `test_ros2_unavailable_is_explicit.py` | IMPLEMENTED | ROS 2 deployment test |
+| RQ-016 | HIL PASS shall require real declared hardware + measured samples | `src/ohip_hil/harness.py` | HIL harness tests | IMPLEMENTED | actual HIL run |
+| RQ-017 | Runtime evidence shall be tamper-evident | `src/ohip_evidence/` | hash-chain/bundle tamper tests | IMPLEMENTED | signed external timestamp/identity if required |
+| RQ-018 | Safety state shall be externally observable in XR | `src/ohip_xr/`, `examples/webxr/` | XR payload/static integration tests | IMPLEMENTED | headset registration/latency measurements |
+| RQ-019 | Simulation shall remain distinguishable from hardware evidence | `src/ohip_sim/`, HIL status model, docs claim matrix | HIL and simulation tests | IMPLEMENTED | process discipline in future reports |
+
+## Evidence classes
+
+Future evidence should be explicitly labeled:
+
+- `SOFTWARE_TEST`
+- `SYNTHETIC_CALIBRATION`
+- `SIMULATION`
+- `REPLAY`
+- `HIL_MEASURED`
+- `PHYSICAL_ROBOT_MEASURED`
+
+Only the final two may support physical performance claims.
+
+## Current release evidence gap
+
+The v0.2.0 software architecture is substantially implemented, but the repository does not contain a physical robot test or HIL PASS. That gap is intentional and visible.
diff --git a/docs/state_machine.md b/docs/state_machine.md
index aaced0f..db309da 100644
--- a/docs/state_machine.md
+++ b/docs/state_machine.md
@@ -200,6 +200,6 @@ Cooldowns never inhibit safety motion; only inhibit new social contact nudges.
10) Versioning
-FSM v0.1 corresponds to /docs/spec.md v0.1 and /src/ohip/* APIs OHIP_SCHEMAS_VERSION == v0.1.0.
+FSM v0.1 corresponds to /docs/spec.md v0.1 and /src/ohip/* APIs OHIP_SCHEMAS_VERSION == v0.2.0.
Breaking changes to states/transitions bump minor version.
diff --git a/docs/validation/claim_matrix.md b/docs/validation/claim_matrix.md
new file mode 100644
index 0000000..94d5091
--- /dev/null
+++ b/docs/validation/claim_matrix.md
@@ -0,0 +1,46 @@
+# v0.2.0 Claim Matrix
+
+This file is normative for release claims.
+
+## Software claims supported in repository
+
+- executable RGB-D perception path;
+- executable reference semantic segmentation;
+- reproducible synthetic calibration training;
+- two-model perception quorum;
+- vision-derived hazard voxels;
+- deterministic safety authority;
+- dynamic force/speed derating;
+- cycle-level runtime invariant monitoring;
+- bounded soft-real-time reference controller;
+- deterministic recovery;
+- ROS 2 bridge implementation;
+- standard joint-trajectory action-client implementation;
+- WebXR observer implementation;
+- executable HIL evidence harness;
+- tamper-evident evidence chain;
+- deterministic simulation and adversarial software benchmarks.
+
+## Claims not supported without external evidence
+
+- production perception accuracy;
+- HIL PASS;
+- physical robot execution PASS;
+- certified hard-real-time performance;
+- collaborative-robot certification;
+- safety certification;
+- human-subject validation;
+- production deployment reliability.
+
+## Evidence labeling rule
+
+Every future report should label its source as one of:
+
+- SOFTWARE_TEST
+- SYNTHETIC_CALIBRATION
+- SIMULATION
+- REPLAY
+- HIL_MEASURED
+- PHYSICAL_ROBOT_MEASURED
+
+Only the last two may support hardware-performance claims.
diff --git a/docs/validation/hardware_evidence_policy.md b/docs/validation/hardware_evidence_policy.md
new file mode 100644
index 0000000..336cf5b
--- /dev/null
+++ b/docs/validation/hardware_evidence_policy.md
@@ -0,0 +1,21 @@
+# Hardware Evidence Policy
+
+IX-HapticSight does not treat a mock, simulator, generated signal, or replay as hardware evidence.
+
+The HIL harness can report `PASSED` only when all required hardware capabilities are positively declared and the configured executor returns measured samples.
+
+A physical-robot evidence package should retain at minimum:
+
+- robot and controller identity;
+- sensor identity and calibration state;
+- software revision;
+- configuration hashes;
+- synchronized force/torque and motion timestamps;
+- commanded versus measured trajectories;
+- limit and watchdog events;
+- fault-injection cases;
+- recovery outcome;
+- evidence-chain manifest;
+- operator/test witness metadata where appropriate.
+
+The repository can prepare and verify that structure. It cannot manufacture the measurements.
diff --git a/docs/validation/v0.2.0-release-evidence.json b/docs/validation/v0.2.0-release-evidence.json
new file mode 100644
index 0000000..01aeb50
--- /dev/null
+++ b/docs/validation/v0.2.0-release-evidence.json
@@ -0,0 +1,38 @@
+{
+ "authority_benchmark": {
+ "passed": 8,
+ "total": 8
+ },
+ "date": "2026-08-29",
+ "evidence_classes_absent": [
+ "HIL_MEASURED",
+ "PHYSICAL_ROBOT_MEASURED"
+ ],
+ "evidence_classes_present": [
+ "SOFTWARE_TEST",
+ "SYNTHETIC_CALIBRATION",
+ "SIMULATION"
+ ],
+ "hil_measured": "NOT_RUN_NO_HARDWARE",
+ "integration_demo": {
+ "evidence_chain_verified": true,
+ "status": "PASS"
+ },
+ "physical_robot_execution": "NOT_CLAIMED",
+ "python": "3.13.5",
+ "quickstart": {
+ "safety_ok": true,
+ "status": "PASS"
+ },
+ "randomized_authority_cases": 2000,
+ "reference_model_reproducibility": "PASS_BYTE_FOR_BYTE",
+ "release": "0.2.0",
+ "ros2_runtime_validation": "NOT_RUN_RCLPY_NOT_INSTALLED",
+ "schema": "ixhs-release-evidence-v1",
+ "software_verification": "PASS",
+ "tests": {
+ "failed": 0,
+ "passed": 183
+ },
+ "webxr_device_validation": "NOT_RUN_NO_DEVICE"
+}
diff --git a/examples/perception_to_contact_demo.py b/examples/perception_to_contact_demo.py
new file mode 100644
index 0000000..cc1e808
--- /dev/null
+++ b/examples/perception_to_contact_demo.py
@@ -0,0 +1,93 @@
+"""End-to-end software demonstration for IX-HapticSight v0.2.
+
+The demo intentionally uses synthetic RGB-D calibration data and a simulated
+contact plant. It proves software integration, not physical robot validation.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from ohip.schemas import SafetyLevel
+from ohip_control import ActionProposal, BoundedRealtimeController, ControllerInput, IndependentSafetyAuthority
+from ohip_evidence import EvidenceChain
+from ohip_perception import PerceptionFrame, ReferenceSegmenter, VisionPipeline
+from ohip_sim import ContactWorld
+
+ROOT = Path(__file__).resolve().parents[1]
+primary = ReferenceSegmenter.from_file(ROOT / "models/reference_segmenter_primary.json")
+secondary = ReferenceSegmenter.from_file(ROOT / "models/reference_segmenter_secondary.json")
+pipeline = VisionPipeline(primary, secondary=secondary)
+
+# Person-colored target plus ordinary object. Synthetic input is explicit.
+frame = PerceptionFrame(
+ rgb=[[(178, 122, 94), (107, 117, 110)]],
+ depth_m=[[0.40, 1.20]],
+ timestamp_s=1.0,
+)
+perception = pipeline.process(frame)
+
+proposal = ActionProposal(
+ action_id="demo-contact",
+ requested_force_N=5.0,
+ requested_speed_mps=0.20,
+ base_force_cap_N=3.0,
+ base_speed_cap_mps=0.10,
+ safety_level=SafetyLevel.YELLOW,
+ perception_uncertainty=0.10,
+ perception_quorum_ok=bool(perception.agreement is None or perception.agreement.passed),
+ consent_required=True,
+ consent_active=True,
+ human_present=True,
+ proximity_m=0.40,
+)
+authority = IndependentSafetyAuthority().decide(proposal)
+
+chain = EvidenceChain()
+chain.append("perception", {
+ "safe_for_autonomy": perception.safe_for_autonomy,
+ "reason": perception.reason,
+ "hazard_count": len(perception.hazards),
+}, timestamp_s=1.0)
+chain.append("authority", {
+ "disposition": authority.disposition.value,
+ "force_N": authority.granted_force_N,
+ "speed_mps": authority.granted_speed_mps,
+ "reason": authority.reason,
+}, timestamp_s=2.0)
+
+controller = BoundedRealtimeController(target_period_ms=50.0)
+world = ContactWorld(surface_position_m=0.02)
+last = None
+for _ in range(120):
+ measured = 0.0 if last is None else last.measured_force_N
+ control = controller.step(ControllerInput(
+ requested_speed_mps=authority.granted_speed_mps,
+ requested_force_N=authority.granted_force_N,
+ measured_force_N=measured,
+ force_cap_N=authority.granted_force_N,
+ speed_cap_mps=authority.granted_speed_mps,
+ sensor_age_ms=1.0,
+ consent_active=True,
+ contact_requested=True,
+ perception_quorum_ok=True,
+ contact_detected=measured > 0.25,
+ ))
+ last = world.step(
+ command_velocity_mps=control.command_speed_mps,
+ force_cap_N=max(0.0, authority.granted_force_N),
+ dt_s=0.005,
+ )
+ if control.stop or control.latched:
+ break
+
+chain.append("control", {
+ "state": controller.state.value,
+ "measured_force_N": 0.0 if last is None else last.measured_force_N,
+ "latched": controller.latched,
+}, timestamp_s=3.0)
+
+print("PERCEPTION:", perception.reason)
+print("AUTHORITY:", authority.disposition.value, authority.reason)
+print("GRANTED:", round(authority.granted_force_N, 3), "N", round(authority.granted_speed_mps, 3), "m/s")
+print("CONTROLLER:", controller.state.value, "latched=", controller.latched)
+print("EVIDENCE_CHAIN:", chain.verify())
diff --git a/examples/webxr/index.html b/examples/webxr/index.html
new file mode 100644
index 0000000..033986d
--- /dev/null
+++ b/examples/webxr/index.html
@@ -0,0 +1,171 @@
+
+
+
+
+
+ IX-HapticSight WebXR Safety Observer
+
+
+
+
+
IX-HapticSight Safety Observer
+
Connecting…
+
+
+
+
+
+
diff --git a/examples/webxr/run_observer.py b/examples/webxr/run_observer.py
new file mode 100644
index 0000000..a80720c
--- /dev/null
+++ b/examples/webxr/run_observer.py
@@ -0,0 +1,32 @@
+"""Run the local IX-HapticSight WebXR safety observer."""
+from __future__ import annotations
+
+from pathlib import Path
+
+from ohip.schemas import SafetyLevel
+from ohip_xr import XRHazardMarker, XRState, XRStateStore, serve
+
+root = Path(__file__).resolve().parent
+store = XRStateStore(
+ XRState(
+ session_id="demo",
+ consent_active=True,
+ safety_authority="MODIFY",
+ force_cap_N=2.5,
+ speed_cap_mps=0.05,
+ controller_state="APPROACH",
+ reason="human_proximity_derate",
+ markers=(
+ XRHazardMarker("human", (0.1, 0.0, 0.8), 0.15, SafetyLevel.YELLOW, "person", 0.93),
+ XRHazardMarker("hot", (-0.2, 0.0, 1.1), 0.10, SafetyLevel.RED, "hot surface", 0.88),
+ ),
+ )
+)
+server = serve(root, store)
+print("IX-HapticSight WebXR observer: http://127.0.0.1:8765")
+try:
+ server.serve_forever()
+except KeyboardInterrupt:
+ pass
+finally:
+ server.server_close()
diff --git a/models/reference_segmenter_primary.json b/models/reference_segmenter_primary.json
new file mode 100644
index 0000000..db47e6a
--- /dev/null
+++ b/models/reference_segmenter_primary.json
@@ -0,0 +1,102 @@
+{
+ "centroids": {
+ "background": [
+ 0.183785188,
+ 0.176994349,
+ 0.197769416,
+ 0.800266193,
+ 0.064123338,
+ 0.188518015,
+ 0.015897953,
+ 0.026573633
+ ],
+ "hot": [
+ 0.920515941,
+ 0.25055285,
+ 0.122397625,
+ 0.420183571,
+ 0.659572737,
+ 0.431899131,
+ 0.668768366,
+ 0.017165849
+ ],
+ "liquid": [
+ 0.120606771,
+ 0.379280687,
+ 0.863111366,
+ 0.44893027,
+ 0.641672341,
+ 0.44874605,
+ 0.0160443,
+ 0.479901149
+ ],
+ "object": [
+ 0.421107663,
+ 0.461085841,
+ 0.429218564,
+ 0.482746563,
+ 0.180204334,
+ 0.439980883,
+ 0.021522624,
+ 0.02085923
+ ],
+ "person": [
+ 0.700615067,
+ 0.480947818,
+ 0.369296078,
+ 0.34916158,
+ 0.330524918,
+ 0.520968137,
+ 0.180288203,
+ 0.015517544
+ ],
+ "sharp": [
+ 0.719875792,
+ 0.732267952,
+ 0.751097497,
+ 0.297445425,
+ 0.057556823,
+ 0.728940621,
+ 0.017414651,
+ 0.027791134
+ ],
+ "unknown": [
+ 0.499869838,
+ 0.101404437,
+ 0.552667126,
+ 0.950236192,
+ 0.547952639,
+ 0.381025937,
+ 0.118342767,
+ 0.170868001
+ ]
+ },
+ "feature_names": [
+ "r",
+ "g",
+ "b",
+ "depth",
+ "saturation",
+ "brightness",
+ "red_dominance",
+ "blue_dominance"
+ ],
+ "limitations": [
+ "Synthetic calibration only; not field validated.",
+ "No claim of production-grade semantic segmentation accuracy.",
+ "Safety authority must fail closed on low confidence or model disagreement."
+ ],
+ "name": "ixhs-centroid-primary-v1",
+ "scales": [
+ 0.27497427633954,
+ 0.203418909867227,
+ 0.256683873173613,
+ 0.228462528933721,
+ 0.248599841330164,
+ 0.154712975644045,
+ 0.223060123449171,
+ 0.163221680965392
+ ],
+ "training_data": "deterministic synthetic calibration set",
+ "training_seed": 41021
+}
diff --git a/models/reference_segmenter_primary.metrics.json b/models/reference_segmenter_primary.metrics.json
new file mode 100644
index 0000000..d0064c2
--- /dev/null
+++ b/models/reference_segmenter_primary.metrics.json
@@ -0,0 +1,10 @@
+{
+ "accuracy": 1.0,
+ "accuracy_background": 1.0,
+ "accuracy_hot": 1.0,
+ "accuracy_liquid": 1.0,
+ "accuracy_object": 1.0,
+ "accuracy_person": 1.0,
+ "accuracy_sharp": 1.0,
+ "accuracy_unknown": 1.0
+}
diff --git a/models/reference_segmenter_secondary.json b/models/reference_segmenter_secondary.json
new file mode 100644
index 0000000..8e377c3
--- /dev/null
+++ b/models/reference_segmenter_secondary.json
@@ -0,0 +1,102 @@
+{
+ "centroids": {
+ "background": [
+ 0.178801503,
+ 0.180610292,
+ 0.199610357,
+ 0.799544828,
+ 0.063976733,
+ 0.188571193,
+ 0.016139842,
+ 0.029172822
+ ],
+ "hot": [
+ 0.917168015,
+ 0.249487988,
+ 0.11801692,
+ 0.420501194,
+ 0.659335549,
+ 0.430084818,
+ 0.669774547,
+ 0.015529968
+ ],
+ "liquid": [
+ 0.119937648,
+ 0.37740779,
+ 0.859917045,
+ 0.450085846,
+ 0.640311176,
+ 0.452599828,
+ 0.014862796,
+ 0.480538916
+ ],
+ "object": [
+ 0.422971223,
+ 0.45789742,
+ 0.432596253,
+ 0.47967473,
+ 0.181015943,
+ 0.441052082,
+ 0.022031378,
+ 0.021783481
+ ],
+ "person": [
+ 0.700482422,
+ 0.480658279,
+ 0.370322859,
+ 0.349808047,
+ 0.332213937,
+ 0.521206096,
+ 0.179478987,
+ 0.015983388
+ ],
+ "sharp": [
+ 0.719161765,
+ 0.73100048,
+ 0.749082029,
+ 0.297994776,
+ 0.056234573,
+ 0.728304995,
+ 0.017196163,
+ 0.027178603
+ ],
+ "unknown": [
+ 0.499420674,
+ 0.101742062,
+ 0.550106842,
+ 0.947977085,
+ 0.550467237,
+ 0.379285038,
+ 0.117491599,
+ 0.166954555
+ ]
+ },
+ "feature_names": [
+ "r",
+ "g",
+ "b",
+ "depth",
+ "saturation",
+ "brightness",
+ "red_dominance",
+ "blue_dominance"
+ ],
+ "limitations": [
+ "Synthetic calibration only; not field validated.",
+ "No claim of production-grade semantic segmentation accuracy.",
+ "Safety authority must fail closed on low confidence or model disagreement."
+ ],
+ "name": "ixhs-centroid-secondary-v1",
+ "scales": [
+ 0.275058199702266,
+ 0.202468282259775,
+ 0.255921205264228,
+ 0.227715172909874,
+ 0.2488583839749,
+ 0.154745848907818,
+ 0.223385642660662,
+ 0.163116429370917
+ ],
+ "training_data": "deterministic synthetic calibration set",
+ "training_seed": 73199
+}
diff --git a/models/reference_segmenter_secondary.metrics.json b/models/reference_segmenter_secondary.metrics.json
new file mode 100644
index 0000000..d0064c2
--- /dev/null
+++ b/models/reference_segmenter_secondary.metrics.json
@@ -0,0 +1,10 @@
+{
+ "accuracy": 1.0,
+ "accuracy_background": 1.0,
+ "accuracy_hot": 1.0,
+ "accuracy_liquid": 1.0,
+ "accuracy_object": 1.0,
+ "accuracy_person": 1.0,
+ "accuracy_sharp": 1.0,
+ "accuracy_unknown": 1.0
+}
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..88b1f38
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,23 @@
+[build-system]
+requires = ["setuptools>=68", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "ix-hapticsight-ohip"
+version = "0.2.0"
+description = "Safety-first perception-to-contact authority for bounded robot and XR interaction."
+readme = "README.md"
+requires-python = ">=3.10"
+license = {text = "MIT"}
+authors = [{name = "Bryce Lovell"}]
+dependencies = ["pyyaml>=6.0", "pillow>=10.0"]
+keywords = ["robotics", "haptics", "computer-vision", "human-robot-interaction", "safety", "ros2", "xr"]
+
+[project.optional-dependencies]
+dev = ["pytest>=7.0"]
+
+[tool.setuptools]
+package-dir = {"" = "src"}
+
+[tool.setuptools.packages.find]
+where = ["src"]
diff --git a/requirements.txt b/requirements.txt
index 8a34fae..fc39ad7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,3 @@
pytest>=7.0
pyyaml>=6.0
+pillow>=10.0
diff --git a/scripts/run_safety_authority_benchmark.py b/scripts/run_safety_authority_benchmark.py
new file mode 100644
index 0000000..e04ba6e
--- /dev/null
+++ b/scripts/run_safety_authority_benchmark.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+import json
+
+from ohip_bench.safety_authority import run_authority_benchmark
+
+results = run_authority_benchmark()
+report = {
+ "schema": "ixhs-safety-authority-benchmark-v1",
+ "passed": all(r.passed for r in results),
+ "pass_count": sum(1 for r in results if r.passed),
+ "scenario_count": len(results),
+ "results": [r.to_dict() for r in results],
+}
+print(json.dumps(report, indent=2, sort_keys=True))
+raise SystemExit(0 if report["passed"] else 1)
diff --git a/scripts/train_reference_segmenter.py b/scripts/train_reference_segmenter.py
new file mode 100644
index 0000000..27fa34a
--- /dev/null
+++ b/scripts/train_reference_segmenter.py
@@ -0,0 +1,338 @@
+"""Reproducibly train the tiny IX-HapticSight reference RGB-D segmenters.
+
+The dataset is synthetic calibration data. It exists to make the repository's
+vision path executable and testable without pretending that synthetic training
+proves real-world perception quality. Hardware/field data should replace or
+augment these models for deployment work.
+
+The generator intentionally avoids Python's random.gauss() and platform-sensitive
+floating-point training state. Synthetic samples are produced with a repository-
+local integer PRNG and fixed-point arithmetic, then exported as quantized floats.
+That makes the committed reference artifacts reproducible byte-for-byte across
+supported operating systems and Python patch releases.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from collections import defaultdict
+from decimal import Decimal, ROUND_HALF_EVEN, localcontext
+from pathlib import Path
+
+FEATURE_NAMES = [
+ "r",
+ "g",
+ "b",
+ "depth",
+ "saturation",
+ "brightness",
+ "red_dominance",
+ "blue_dominance",
+]
+
+FIXED_SCALE = 1_000_000
+DECIMAL_QUANTUM = Decimal("0.000000000000001")
+MASK_64 = (1 << 64) - 1
+
+# Feature-space prototypes in fixed-point millionths. Integer source values keep
+# the synthetic calibration corpus stable across platforms.
+PROTOTYPES = {
+ "background": (180000, 180000, 200000, 800000, 60000, 190000, 0, 20000),
+ "person": (700000, 480000, 370000, 350000, 330000, 520000, 180000, 0),
+ "object": (420000, 460000, 430000, 480000, 180000, 440000, 10000, 10000),
+ "hot": (920000, 250000, 120000, 420000, 660000, 430000, 670000, 0),
+ "liquid": (120000, 380000, 860000, 450000, 640000, 450000, 0, 480000),
+ "sharp": (720000, 730000, 750000, 300000, 50000, 730000, 0, 20000),
+ "unknown": (500000, 100000, 550000, 950000, 550000, 380000, 120000, 170000),
+}
+
+NOISE = (45000, 45000, 45000, 35000, 50000, 40000, 40000, 40000)
+
+
+class StableRNG:
+ """Small repository-local SplitMix64 generator with integer-only state."""
+
+ def __init__(self, seed: int) -> None:
+ self.state = seed & MASK_64
+
+ def next_u64(self) -> int:
+ self.state = (self.state + 0x9E3779B97F4A7C15) & MASK_64
+ z = self.state
+ z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & MASK_64
+ z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & MASK_64
+ return (z ^ (z >> 31)) & MASK_64
+
+ def randbelow(self, upper: int) -> int:
+ if upper <= 0:
+ raise ValueError("upper must be positive")
+ return self.next_u64() % upper
+
+
+def clamp_units(value: int) -> int:
+ return max(0, min(FIXED_SCALE, value))
+
+
+def gaussian_like_units(rng: StableRNG) -> int:
+ """Return a deterministic approximately N(0,1) deviate in fixed-point.
+
+ Irwin-Hall: sum of 12 U(0,1) values minus 6 has variance 1. The
+ implementation stays integer-only so the synthetic corpus is identical on
+ Windows, Linux, and macOS.
+ """
+
+ total = sum(rng.randbelow(FIXED_SCALE + 1) for _ in range(12))
+ return total - (6 * FIXED_SCALE)
+
+
+def sample_class(rng: StableRNG, label: str) -> tuple[int, ...]:
+ values: list[int] = []
+
+ for mu, sigma in zip(PROTOTYPES[label], NOISE):
+ z = gaussian_like_units(rng)
+
+ # Deterministic integer truncation is sufficient for the synthetic
+ # calibration corpus.
+ delta = (sigma * z) // FIXED_SCALE
+ values.append(clamp_units(mu + delta))
+
+ return tuple(values)
+
+
+def _quantized_float(value: Decimal) -> float:
+ quantized = value.quantize(
+ DECIMAL_QUANTUM,
+ rounding=ROUND_HALF_EVEN,
+ )
+ return float(format(quantized, "f"))
+
+
+def train(
+ seed: int,
+ samples_per_class: int,
+) -> tuple[dict[str, list[float]], list[float]]:
+ rng = StableRNG(seed)
+ sums = defaultdict(lambda: [0] * len(FEATURE_NAMES))
+ counts = defaultdict(int)
+ all_values: list[list[int]] = [[] for _ in FEATURE_NAMES]
+
+ for label in PROTOTYPES:
+ for _ in range(samples_per_class):
+ sample = sample_class(rng, label)
+ counts[label] += 1
+
+ for i, value in enumerate(sample):
+ sums[label][i] += value
+ all_values[i].append(value)
+
+ with localcontext() as context:
+ context.prec = 50
+ scale = Decimal(FIXED_SCALE)
+
+ centroids = {
+ label: [
+ _quantized_float(
+ Decimal(value)
+ / Decimal(counts[label])
+ / scale
+ )
+ for value in sums[label]
+ ]
+ for label in PROTOTYPES
+ }
+
+ scales: list[float] = []
+
+ for values in all_values:
+ count = Decimal(len(values))
+ mean_units = Decimal(sum(values)) / count
+
+ variance_units = sum(
+ (Decimal(value) - mean_units) ** 2
+ for value in values
+ ) / count
+
+ standard_deviation = variance_units.sqrt() / scale
+
+ scales.append(
+ _quantized_float(
+ max(
+ standard_deviation,
+ Decimal("0.05"),
+ )
+ )
+ )
+
+ return centroids, scales
+
+
+def nearest(
+ sample: tuple[int, ...],
+ centroids: dict[str, list[float]],
+ scales: list[float],
+) -> str:
+ best_label = ""
+ best_distance = float("inf")
+
+ for label, center in centroids.items():
+ distance = sum(
+ (((value / FIXED_SCALE) - centroid) / scale) ** 2
+ for value, centroid, scale in zip(
+ sample,
+ center,
+ scales,
+ )
+ ) / len(sample)
+
+ if distance < best_distance:
+ best_distance = distance
+ best_label = label
+
+ return best_label
+
+
+def evaluate(
+ seed: int,
+ centroids: dict[str, list[float]],
+ scales: list[float],
+ n: int = 500,
+) -> dict[str, float]:
+ rng = StableRNG(seed)
+ correct = 0
+ total = 0
+ per_class_correct = defaultdict(int)
+ per_class_total = defaultdict(int)
+
+ for label in PROTOTYPES:
+ for _ in range(n):
+ sample = sample_class(rng, label)
+ pred = nearest(
+ sample,
+ centroids,
+ scales,
+ )
+
+ total += 1
+ per_class_total[label] += 1
+
+ if pred == label:
+ correct += 1
+ per_class_correct[label] += 1
+
+ metrics = {
+ "accuracy": correct / total,
+ }
+
+ for label in PROTOTYPES:
+ metrics[f"accuracy_{label}"] = (
+ per_class_correct[label]
+ / per_class_total[label]
+ )
+
+ return metrics
+
+
+def write_json_lf(path: Path, payload: object) -> None:
+ """Write deterministic UTF-8 JSON using LF line endings on every OS."""
+
+ serialized = (
+ json.dumps(
+ payload,
+ indent=2,
+ sort_keys=True,
+ )
+ + "\n"
+ )
+
+ with path.open(
+ "w",
+ encoding="utf-8",
+ newline="\n",
+ ) as handle:
+ handle.write(serialized)
+
+
+def write_model(
+ output: Path,
+ seed: int,
+ name: str,
+) -> None:
+ centroids, scales = train(
+ seed=seed,
+ samples_per_class=1000,
+ )
+
+ metrics = evaluate(
+ seed=seed + 100_000,
+ centroids=centroids,
+ scales=scales,
+ )
+
+ model = {
+ "name": name,
+ "training_data": "deterministic synthetic calibration set",
+ "training_seed": seed,
+ "feature_names": FEATURE_NAMES,
+ "scales": scales,
+ "centroids": centroids,
+ "limitations": [
+ "Synthetic calibration only; not field validated.",
+ "No claim of production-grade semantic segmentation accuracy.",
+ "Safety authority must fail closed on low confidence or model disagreement.",
+ ],
+ }
+
+ write_json_lf(
+ output,
+ model,
+ )
+
+ metrics_path = output.with_suffix(
+ ".metrics.json"
+ )
+
+ write_json_lf(
+ metrics_path,
+ metrics,
+ )
+
+ print(
+ output,
+ metrics["accuracy"],
+ )
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+
+ parser.add_argument(
+ "--output-dir",
+ default="models",
+ )
+
+ args = parser.parse_args()
+
+ out_dir = Path(
+ args.output_dir
+ )
+
+ out_dir.mkdir(
+ parents=True,
+ exist_ok=True,
+ )
+
+ write_model(
+ out_dir / "reference_segmenter_primary.json",
+ 41021,
+ "ixhs-centroid-primary-v1",
+ )
+
+ write_model(
+ out_dir / "reference_segmenter_secondary.json",
+ 73199,
+ "ixhs-centroid-secondary-v1",
+ )
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/scripts/verify_release.py b/scripts/verify_release.py
new file mode 100644
index 0000000..a84c135
--- /dev/null
+++ b/scripts/verify_release.py
@@ -0,0 +1,29 @@
+"""Reproduce the software-side IX-HapticSight v0.2 release checks."""
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+ENV = dict(os.environ)
+ENV["PYTHONPATH"] = str(ROOT / "src") + os.pathsep + ENV.get("PYTHONPATH", "")
+
+
+def run(label: str, args: list[str]) -> None:
+ print(f"\n== {label} ==")
+ subprocess.run(args, cwd=ROOT, env=ENV, check=True)
+
+
+def main() -> None:
+ run("compile", [sys.executable, "-m", "compileall", "-q", "src", "scripts", "examples"])
+ run("tests", [sys.executable, "-m", "pytest", "-q"])
+ run("quickstart", [sys.executable, "examples/quickstart.py", "--scene", "sim/scenes/basic_room.json", "--verbose"])
+ run("perception-to-contact demo", [sys.executable, "examples/perception_to_contact_demo.py"])
+ run("safety-authority benchmark", [sys.executable, "scripts/run_safety_authority_benchmark.py"])
+ print("\nIX-HapticSight software release verification: PASS")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/setup.py b/setup.py
index c9676ef..dbd058b 100644
--- a/setup.py
+++ b/setup.py
@@ -1,24 +1,26 @@
from setuptools import find_packages, setup
setup(
- name='ix-hapticsight-ohip',
- version='0.1.0',
- description='Safety-first optical-haptic interaction protocol reference implementation.',
- long_description=open('README.md', encoding='utf-8').read(),
- long_description_content_type='text/markdown',
- author='Bryce Lovell',
- license='Apache-2.0',
- python_requires='>=3.10',
- package_dir={'': 'src'},
- packages=find_packages(where='src'),
- install_requires=['pyyaml>=6.0'],
- extras_require={'dev': ['pytest>=7.0']},
+ name="ix-hapticsight-ohip",
+ version="0.2.0",
+ description="Safety-first perception-to-contact authority for bounded robot and XR interaction.",
+ long_description=open("README.md", encoding="utf-8").read(),
+ long_description_content_type="text/markdown",
+ author="Bryce Lovell",
+ license="MIT",
+ python_requires=">=3.10",
+ package_dir={"": "src"},
+ packages=find_packages(where="src"),
+ install_requires=["pyyaml>=6.0", "pillow>=10.0"],
+ extras_require={"dev": ["pytest>=7.0"]},
keywords=[
- 'robotics',
- 'human-robot-interaction',
- 'haptics',
- 'safety',
- 'consent',
- 'protocol',
+ "robotics",
+ "computer-vision",
+ "human-robot-interaction",
+ "haptics",
+ "safety",
+ "ros2",
+ "webxr",
+ "protocol",
],
)
diff --git a/src/ohip/__init__.py b/src/ohip/__init__.py
index d2ded77..5aaf8bf 100644
--- a/src/ohip/__init__.py
+++ b/src/ohip/__init__.py
@@ -6,7 +6,7 @@
Versioning
----------
-__version__ : project/package version (v0.1.0 for the v0.1 spec drop)
+__version__ : project/package version (v0.2.0 for the v0.1 spec drop)
__schema_version__ : canonical schema version from ohip.schemas
Do not import heavy dependencies here. Keep imports shallow.
@@ -34,7 +34,7 @@
from .consent_manager import ConsentManager, ProfileRules
# Project/package version for this release of the reference implementation.
-__version__ = "0.1.0"
+__version__ = "0.2.0"
__all__ = [
# versions
diff --git a/src/ohip/schemas.py b/src/ohip/schemas.py
index 9277eb4..8061c7e 100644
--- a/src/ohip/schemas.py
+++ b/src/ohip/schemas.py
@@ -19,7 +19,7 @@
from datetime import datetime, timezone
-OHIP_SCHEMAS_VERSION = "v0.1.0"
+OHIP_SCHEMAS_VERSION = "v0.2.0"
# ------------------------- #
diff --git a/src/ohip_agent/__init__.py b/src/ohip_agent/__init__.py
new file mode 100644
index 0000000..0904371
--- /dev/null
+++ b/src/ohip_agent/__init__.py
@@ -0,0 +1,3 @@
+from .broker import AgentPhysicalProposal, AgentSafetyBroker, BrokerDecisionReceipt
+
+__all__ = ["AgentPhysicalProposal", "AgentSafetyBroker", "BrokerDecisionReceipt"]
diff --git a/src/ohip_agent/broker.py b/src/ohip_agent/broker.py
new file mode 100644
index 0000000..8c0013f
--- /dev/null
+++ b/src/ohip_agent/broker.py
@@ -0,0 +1,136 @@
+"""Model-agnostic broker for LLM/VLA-proposed physical actions.
+
+The broker exists so an agent can be arbitrarily capable without becoming the
+final physical authority. Agent output is treated as an untrusted proposal.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import math
+from dataclasses import asdict, dataclass
+from time import time
+
+from ohip.schemas import SafetyLevel
+from ohip_control import (
+ ActionProposal,
+ AuthorityDisposition,
+ IndependentSafetyAuthority,
+ MultimodalSafetyDecision,
+ MultimodalSafetyFusion,
+ MultimodalSafetyInput,
+)
+
+
+@dataclass(frozen=True)
+class AgentPhysicalProposal:
+ proposal_id: str
+ agent_id: str
+ action_kind: str
+ requested_force_N: float
+ requested_speed_mps: float
+ base_force_cap_N: float
+ base_speed_cap_mps: float
+ consent_required: bool
+ consent_active: bool
+ human_present: bool = False
+ proximity_m: float | None = None
+ created_at_s: float = 0.0
+ rationale: str = ""
+
+ def validate(self) -> None:
+ if not self.proposal_id.strip():
+ raise ValueError("proposal_id is required")
+ if not self.agent_id.strip():
+ raise ValueError("agent_id is required")
+ if not self.action_kind.strip():
+ raise ValueError("action_kind is required")
+ for name in ("requested_force_N", "requested_speed_mps", "base_force_cap_N", "base_speed_cap_mps"):
+ value = float(getattr(self, name))
+ if not math.isfinite(value) or value < 0.0:
+ raise ValueError(f"{name} must be finite and non-negative")
+ if self.proximity_m is not None and (not math.isfinite(float(self.proximity_m)) or float(self.proximity_m) < 0.0):
+ raise ValueError("proximity_m must be finite and non-negative")
+
+ def canonical_dict(self) -> dict:
+ doc = asdict(self)
+ # Timestamp is provenance, but keeping it in the digest makes each proposal unique.
+ return doc
+
+ def sha256(self) -> str:
+ body = json.dumps(self.canonical_dict(), sort_keys=True, separators=(",", ":")).encode("utf-8")
+ return hashlib.sha256(body).hexdigest()
+
+
+@dataclass(frozen=True)
+class BrokerDecisionReceipt:
+ proposal_id: str
+ proposal_sha256: str
+ agent_id: str
+ disposition: AuthorityDisposition
+ granted_force_N: float
+ granted_speed_mps: float
+ fused_safety_level: SafetyLevel
+ reasons: tuple[str, ...]
+ counterfactual: str
+ decided_at_s: float
+
+ def to_dict(self) -> dict:
+ return {
+ "proposal_id": self.proposal_id,
+ "proposal_sha256": self.proposal_sha256,
+ "agent_id": self.agent_id,
+ "disposition": self.disposition.value,
+ "granted_force_N": self.granted_force_N,
+ "granted_speed_mps": self.granted_speed_mps,
+ "fused_safety_level": self.fused_safety_level.value,
+ "reasons": list(self.reasons),
+ "counterfactual": self.counterfactual,
+ "decided_at_s": self.decided_at_s,
+ }
+
+
+class AgentSafetyBroker:
+ """Combine multimodal safety state with an untrusted agent proposal."""
+
+ def __init__(
+ self,
+ *,
+ fusion: MultimodalSafetyFusion | None = None,
+ authority: IndependentSafetyAuthority | None = None,
+ ) -> None:
+ self.fusion = fusion or MultimodalSafetyFusion()
+ self.authority = authority or IndependentSafetyAuthority()
+
+ def decide(self, proposal: AgentPhysicalProposal, sensor_state: MultimodalSafetyInput) -> BrokerDecisionReceipt:
+ proposal.validate()
+ fused = self.fusion.evaluate(sensor_state)
+ decision = self.authority.decide(
+ ActionProposal(
+ action_id=proposal.proposal_id,
+ requested_force_N=proposal.requested_force_N,
+ requested_speed_mps=proposal.requested_speed_mps,
+ base_force_cap_N=proposal.base_force_cap_N,
+ base_speed_cap_mps=proposal.base_speed_cap_mps,
+ safety_level=fused.level,
+ perception_uncertainty=sensor_state.perception_uncertainty,
+ perception_quorum_ok=sensor_state.perception_quorum_ok,
+ consent_required=proposal.consent_required,
+ consent_active=proposal.consent_active,
+ human_present=proposal.human_present,
+ proximity_m=proposal.proximity_m,
+ )
+ )
+ reasons = tuple(fused.reasons) + (decision.reason,)
+ return BrokerDecisionReceipt(
+ proposal_id=proposal.proposal_id,
+ proposal_sha256=proposal.sha256(),
+ agent_id=proposal.agent_id,
+ disposition=decision.disposition,
+ granted_force_N=decision.granted_force_N,
+ granted_speed_mps=decision.granted_speed_mps,
+ fused_safety_level=fused.level,
+ reasons=reasons,
+ counterfactual=decision.counterfactual,
+ decided_at_s=time(),
+ )
diff --git a/src/ohip_bench/__init__.py b/src/ohip_bench/__init__.py
index 32dff80..0760a3c 100644
--- a/src/ohip_bench/__init__.py
+++ b/src/ohip_bench/__init__.py
@@ -21,4 +21,4 @@
"__version__",
]
-__version__ = "0.1.0"
+__version__ = "0.2.0"
diff --git a/src/ohip_bench/safety_authority.py b/src/ohip_bench/safety_authority.py
new file mode 100644
index 0000000..f9a4db8
--- /dev/null
+++ b/src/ohip_bench/safety_authority.py
@@ -0,0 +1,72 @@
+"""Adversarial benchmark scenarios for the independent safety authority."""
+from __future__ import annotations
+
+from dataclasses import dataclass, asdict
+
+from ohip.schemas import SafetyLevel
+from ohip_control import ActionProposal, AuthorityDisposition, IndependentSafetyAuthority
+
+
+@dataclass(frozen=True)
+class AuthorityScenarioResult:
+ scenario: str
+ expected: AuthorityDisposition
+ observed: AuthorityDisposition
+ passed: bool
+ reason: str
+ granted_force_N: float
+ granted_speed_mps: float
+
+ def to_dict(self) -> dict:
+ d = asdict(self)
+ d["expected"] = self.expected.value
+ d["observed"] = self.observed.value
+ return d
+
+
+def _base(**updates) -> ActionProposal:
+ d = dict(
+ action_id="benchmark",
+ requested_force_N=2.0,
+ requested_speed_mps=0.08,
+ base_force_cap_N=3.0,
+ base_speed_cap_mps=0.10,
+ safety_level=SafetyLevel.GREEN,
+ perception_uncertainty=0.05,
+ perception_quorum_ok=True,
+ consent_required=True,
+ consent_active=True,
+ human_present=False,
+ proximity_m=None,
+ )
+ d.update(updates)
+ return ActionProposal(**d)
+
+
+def run_authority_benchmark(authority: IndependentSafetyAuthority | None = None) -> list[AuthorityScenarioResult]:
+ authority = authority or IndependentSafetyAuthority()
+ scenarios = [
+ ("nominal", _base(), AuthorityDisposition.ALLOW),
+ ("over_request", _base(requested_force_N=8.0, requested_speed_mps=0.5), AuthorityDisposition.MODIFY),
+ ("red_hazard", _base(safety_level=SafetyLevel.RED), AuthorityDisposition.DENY),
+ ("consent_loss", _base(consent_active=False), AuthorityDisposition.DENY),
+ ("model_disagreement", _base(perception_quorum_ok=False), AuthorityDisposition.DENY),
+ ("high_uncertainty", _base(perception_uncertainty=0.85), AuthorityDisposition.DENY),
+ ("human_too_close", _base(human_present=True, proximity_m=0.10), AuthorityDisposition.DENY),
+ ("human_near_derate", _base(human_present=True, proximity_m=0.40), AuthorityDisposition.MODIFY),
+ ]
+ results: list[AuthorityScenarioResult] = []
+ for name, proposal, expected in scenarios:
+ decision = authority.decide(proposal)
+ results.append(
+ AuthorityScenarioResult(
+ scenario=name,
+ expected=expected,
+ observed=decision.disposition,
+ passed=decision.disposition == expected,
+ reason=decision.reason,
+ granted_force_N=decision.granted_force_N,
+ granted_speed_mps=decision.granted_speed_mps,
+ )
+ )
+ return results
diff --git a/src/ohip_control/__init__.py b/src/ohip_control/__init__.py
new file mode 100644
index 0000000..04bd097
--- /dev/null
+++ b/src/ohip_control/__init__.py
@@ -0,0 +1,38 @@
+"""Deterministic contact-control safety kernel for IX-HapticSight."""
+from .authority import ActionProposal, AuthorityDecision, AuthorityDisposition, IndependentSafetyAuthority
+from .envelope import DynamicEnvelopeDecision, DynamicEnvelopeInput, DynamicSafetyEnvelope
+from .multimodal import MultimodalSafetyDecision, MultimodalSafetyFusion, MultimodalSafetyInput
+from .invariants import (
+ InvariantReport,
+ InvariantSeverity,
+ InvariantViolation,
+ RuntimeInvariantInput,
+ RuntimeInvariantMonitor,
+)
+from .realtime import BoundedRealtimeController, ControllerInput, ControllerOutput, ControllerState
+from .recovery import RecoveryCommand, RecoveryPlanner, RecoveryStage
+
+__all__ = [
+ "ActionProposal",
+ "AuthorityDecision",
+ "AuthorityDisposition",
+ "BoundedRealtimeController",
+ "ControllerInput",
+ "ControllerOutput",
+ "ControllerState",
+ "DynamicEnvelopeDecision",
+ "DynamicEnvelopeInput",
+ "DynamicSafetyEnvelope",
+ "IndependentSafetyAuthority",
+ "InvariantReport",
+ "MultimodalSafetyDecision",
+ "MultimodalSafetyFusion",
+ "MultimodalSafetyInput",
+ "InvariantSeverity",
+ "InvariantViolation",
+ "RecoveryCommand",
+ "RecoveryPlanner",
+ "RecoveryStage",
+ "RuntimeInvariantInput",
+ "RuntimeInvariantMonitor",
+]
diff --git a/src/ohip_control/authority.py b/src/ohip_control/authority.py
new file mode 100644
index 0000000..92bd62f
--- /dev/null
+++ b/src/ohip_control/authority.py
@@ -0,0 +1,107 @@
+"""Independent action authority between AI/planning and physical execution."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+
+from ohip.schemas import SafetyLevel
+
+from .envelope import DynamicEnvelopeInput, DynamicSafetyEnvelope
+
+
+class AuthorityDisposition(str, Enum):
+ ALLOW = "ALLOW"
+ MODIFY = "MODIFY"
+ DENY = "DENY"
+
+
+@dataclass(frozen=True)
+class ActionProposal:
+ action_id: str
+ requested_force_N: float
+ requested_speed_mps: float
+ base_force_cap_N: float
+ base_speed_cap_mps: float
+ safety_level: SafetyLevel
+ perception_uncertainty: float
+ perception_quorum_ok: bool
+ consent_required: bool
+ consent_active: bool
+ human_present: bool = False
+ proximity_m: float | None = None
+
+
+@dataclass(frozen=True)
+class AuthorityDecision:
+ action_id: str
+ disposition: AuthorityDisposition
+ granted_force_N: float
+ granted_speed_mps: float
+ reason: str
+ counterfactual: str
+
+
+class IndependentSafetyAuthority:
+ """Fail-closed safety authority that no learned policy may bypass."""
+
+ def __init__(self, envelope: DynamicSafetyEnvelope | None = None) -> None:
+ self.envelope = envelope or DynamicSafetyEnvelope()
+
+ def decide(self, proposal: ActionProposal) -> AuthorityDecision:
+ if proposal.consent_required and not proposal.consent_active:
+ return AuthorityDecision(
+ proposal.action_id,
+ AuthorityDisposition.DENY,
+ 0.0,
+ 0.0,
+ "consent_missing",
+ "Obtain active consent in the requested contact scope before contact authority can be granted.",
+ )
+ if not proposal.perception_quorum_ok:
+ return AuthorityDecision(
+ proposal.action_id,
+ AuthorityDisposition.DENY,
+ 0.0,
+ 0.0,
+ "perception_quorum_failed",
+ "Restore agreement between independent perception channels or require human verification.",
+ )
+ env = self.envelope.evaluate(
+ DynamicEnvelopeInput(
+ requested_force_N=proposal.requested_force_N,
+ requested_speed_mps=proposal.requested_speed_mps,
+ base_force_cap_N=proposal.base_force_cap_N,
+ base_speed_cap_mps=proposal.base_speed_cap_mps,
+ proximity_m=proposal.proximity_m,
+ perception_uncertainty=proposal.perception_uncertainty,
+ safety_level=proposal.safety_level,
+ human_present=proposal.human_present,
+ )
+ )
+ if not env.allowed:
+ counterfactual = {
+ "red_hazard": "Move the target/corridor out of RED or clear the hazard before retrying.",
+ "perception_uncertainty_stop": "Reduce perception uncertainty below the stop threshold or require human verification.",
+ "human_proximity_stop": "Increase separation beyond the configured human stop distance.",
+ }.get(env.reason, "Resolve the safety veto before retrying.")
+ return AuthorityDecision(proposal.action_id, AuthorityDisposition.DENY, 0.0, 0.0, env.reason, counterfactual)
+ modified = (
+ env.force_command_N + 1e-9 < max(0.0, proposal.requested_force_N)
+ or env.speed_command_mps + 1e-9 < max(0.0, proposal.requested_speed_mps)
+ )
+ disposition = AuthorityDisposition.MODIFY if modified else AuthorityDisposition.ALLOW
+ if modified:
+ counterfactual = (
+ f"Requested action can proceed only inside the granted envelope: force <= {env.force_command_N:.3f} N, "
+ f"speed <= {env.speed_command_mps:.3f} m/s."
+ )
+ else:
+ counterfactual = "No modification required under current measured safety state."
+ return AuthorityDecision(
+ proposal.action_id,
+ disposition,
+ env.force_command_N,
+ env.speed_command_mps,
+ env.reason,
+ counterfactual,
+ )
diff --git a/src/ohip_control/envelope.py b/src/ohip_control/envelope.py
new file mode 100644
index 0000000..a01888a
--- /dev/null
+++ b/src/ohip_control/envelope.py
@@ -0,0 +1,109 @@
+"""Dynamic, uncertainty-aware execution envelopes for IX-HapticSight.
+
+This module is deliberately independent of any learned policy. Learned systems
+may request an action; this envelope computes the maximum motion/force authority
+that the deterministic runtime is willing to grant at that instant.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from ohip.schemas import SafetyLevel
+
+
+@dataclass(frozen=True)
+class DynamicEnvelopeInput:
+ requested_force_N: float
+ requested_speed_mps: float
+ base_force_cap_N: float
+ base_speed_cap_mps: float
+ proximity_m: float | None = None
+ perception_uncertainty: float = 0.0
+ safety_level: SafetyLevel = SafetyLevel.GREEN
+ human_present: bool = False
+
+
+@dataclass(frozen=True)
+class DynamicEnvelopeDecision:
+ allowed: bool
+ force_cap_N: float
+ speed_cap_mps: float
+ force_command_N: float
+ speed_command_mps: float
+ authority_scale: float
+ reason: str
+
+
+class DynamicSafetyEnvelope:
+ """Compute a conservative action envelope from dynamic scene state."""
+
+ def __init__(
+ self,
+ *,
+ yellow_scale: float = 0.35,
+ uncertainty_start: float = 0.25,
+ uncertainty_stop: float = 0.70,
+ human_slow_distance_m: float = 0.8,
+ human_stop_distance_m: float = 0.20,
+ ) -> None:
+ self.yellow_scale = float(yellow_scale)
+ self.uncertainty_start = float(uncertainty_start)
+ self.uncertainty_stop = float(uncertainty_stop)
+ self.human_slow_distance_m = float(human_slow_distance_m)
+ self.human_stop_distance_m = float(human_stop_distance_m)
+
+ def evaluate(self, state: DynamicEnvelopeInput) -> DynamicEnvelopeDecision:
+ if state.safety_level == SafetyLevel.RED:
+ return self._deny("red_hazard")
+ uncertainty = max(0.0, min(1.0, float(state.perception_uncertainty)))
+ if uncertainty >= self.uncertainty_stop:
+ return self._deny("perception_uncertainty_stop")
+
+ scale = 1.0
+ reasons: list[str] = []
+ if state.safety_level == SafetyLevel.YELLOW:
+ scale = min(scale, self.yellow_scale)
+ reasons.append("yellow_zone")
+
+ if uncertainty > self.uncertainty_start:
+ span = max(1e-9, self.uncertainty_stop - self.uncertainty_start)
+ uncertainty_scale = max(0.0, 1.0 - (uncertainty - self.uncertainty_start) / span)
+ scale = min(scale, uncertainty_scale)
+ reasons.append("uncertainty_derate")
+
+ if state.human_present and state.proximity_m is not None:
+ proximity = float(state.proximity_m)
+ if proximity <= self.human_stop_distance_m:
+ return self._deny("human_proximity_stop")
+ if proximity < self.human_slow_distance_m:
+ span = max(1e-9, self.human_slow_distance_m - self.human_stop_distance_m)
+ proximity_scale = max(0.05, (proximity - self.human_stop_distance_m) / span)
+ scale = min(scale, proximity_scale)
+ reasons.append("human_proximity_derate")
+
+ force_cap = max(0.0, float(state.base_force_cap_N)) * scale
+ speed_cap = max(0.0, float(state.base_speed_cap_mps)) * scale
+ force_cmd = min(max(0.0, float(state.requested_force_N)), force_cap)
+ speed_cmd = min(max(0.0, float(state.requested_speed_mps)), speed_cap)
+ reason = "+".join(reasons) if reasons else "full_authority"
+ return DynamicEnvelopeDecision(
+ allowed=True,
+ force_cap_N=force_cap,
+ speed_cap_mps=speed_cap,
+ force_command_N=force_cmd,
+ speed_command_mps=speed_cmd,
+ authority_scale=scale,
+ reason=reason,
+ )
+
+ @staticmethod
+ def _deny(reason: str) -> DynamicEnvelopeDecision:
+ return DynamicEnvelopeDecision(
+ allowed=False,
+ force_cap_N=0.0,
+ speed_cap_mps=0.0,
+ force_command_N=0.0,
+ speed_command_mps=0.0,
+ authority_scale=0.0,
+ reason=reason,
+ )
diff --git a/src/ohip_control/invariants.py b/src/ohip_control/invariants.py
new file mode 100644
index 0000000..eb1d8ad
--- /dev/null
+++ b/src/ohip_control/invariants.py
@@ -0,0 +1,75 @@
+"""Runtime safety invariant monitoring."""
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import Enum
+
+
+class InvariantSeverity(str, Enum):
+ WARN = "WARN"
+ STOP = "STOP"
+ LATCH = "LATCH"
+
+
+@dataclass(frozen=True)
+class InvariantViolation:
+ code: str
+ severity: InvariantSeverity
+ observed: float | str | bool | None
+ limit: float | str | bool | None
+ message: str
+
+
+@dataclass(frozen=True)
+class RuntimeInvariantInput:
+ commanded_force_N: float
+ measured_force_N: float
+ force_cap_N: float
+ commanded_speed_mps: float
+ speed_cap_mps: float
+ sensor_age_ms: float
+ max_sensor_age_ms: float
+ control_age_ms: float
+ max_control_age_ms: float
+ consent_active: bool
+ contact_requested: bool
+ e_stop: bool = False
+ perception_quorum_ok: bool = True
+
+
+@dataclass(frozen=True)
+class InvariantReport:
+ ok: bool
+ violations: tuple[InvariantViolation, ...] = field(default_factory=tuple)
+
+ @property
+ def requires_stop(self) -> bool:
+ return any(v.severity in {InvariantSeverity.STOP, InvariantSeverity.LATCH} for v in self.violations)
+
+ @property
+ def requires_latch(self) -> bool:
+ return any(v.severity == InvariantSeverity.LATCH for v in self.violations)
+
+
+class RuntimeInvariantMonitor:
+ """Fail-closed invariant monitor intended to run every control cycle."""
+
+ def evaluate(self, state: RuntimeInvariantInput) -> InvariantReport:
+ violations: list[InvariantViolation] = []
+ if state.e_stop:
+ violations.append(InvariantViolation("e_stop", InvariantSeverity.LATCH, True, False, "emergency stop asserted"))
+ if state.contact_requested and not state.consent_active:
+ violations.append(InvariantViolation("consent_lost", InvariantSeverity.LATCH, False, True, "contact authority requires active consent"))
+ if not state.perception_quorum_ok:
+ violations.append(InvariantViolation("perception_quorum_failed", InvariantSeverity.STOP, False, True, "independent perception models do not agree"))
+ if state.commanded_force_N > state.force_cap_N + 1e-9:
+ violations.append(InvariantViolation("force_command_over_cap", InvariantSeverity.LATCH, state.commanded_force_N, state.force_cap_N, "commanded force exceeds granted envelope"))
+ if state.measured_force_N > state.force_cap_N + 1e-9:
+ violations.append(InvariantViolation("measured_force_over_cap", InvariantSeverity.LATCH, state.measured_force_N, state.force_cap_N, "measured force exceeds granted envelope"))
+ if state.commanded_speed_mps > state.speed_cap_mps + 1e-9:
+ violations.append(InvariantViolation("speed_command_over_cap", InvariantSeverity.STOP, state.commanded_speed_mps, state.speed_cap_mps, "commanded speed exceeds granted envelope"))
+ if state.sensor_age_ms > state.max_sensor_age_ms:
+ violations.append(InvariantViolation("sensor_stale", InvariantSeverity.LATCH, state.sensor_age_ms, state.max_sensor_age_ms, "safety sensor data is stale"))
+ if state.control_age_ms > state.max_control_age_ms:
+ violations.append(InvariantViolation("controller_deadline_miss", InvariantSeverity.STOP, state.control_age_ms, state.max_control_age_ms, "controller update missed watchdog deadline"))
+ return InvariantReport(ok=not violations, violations=tuple(violations))
diff --git a/src/ohip_control/multimodal.py b/src/ohip_control/multimodal.py
new file mode 100644
index 0000000..787b280
--- /dev/null
+++ b/src/ohip_control/multimodal.py
@@ -0,0 +1,122 @@
+"""Deterministic multimodal safety fusion.
+
+Vision, force/torque, tactile, proximity, and thermal modalities contribute to
+one conservative safety classification. This is deliberately rule-based and
+inspectable. Learned representations can feed it, but they do not get to hide
+which modality caused a veto.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from ohip.schemas import SafetyLevel
+from ohip_interfaces.force_torque import ContactForceAssessment
+from ohip_interfaces.proximity import ProximityAssessment
+from ohip_interfaces.tactile import TactileContactAssessment
+from ohip_interfaces.thermal import ThermalAssessment
+
+
+@dataclass(frozen=True)
+class MultimodalSafetyInput:
+ vision_level: SafetyLevel = SafetyLevel.GREEN
+ perception_quorum_ok: bool = True
+ perception_uncertainty: float = 0.0
+ force: ContactForceAssessment | None = None
+ tactile: TactileContactAssessment | None = None
+ proximity: ProximityAssessment | None = None
+ thermal: ThermalAssessment | None = None
+ require_force: bool = False
+ require_tactile: bool = False
+ require_proximity: bool = False
+ require_thermal: bool = False
+
+
+@dataclass(frozen=True)
+class MultimodalSafetyDecision:
+ level: SafetyLevel
+ reasons: tuple[str, ...]
+ available_modalities: tuple[str, ...]
+ missing_required_modalities: tuple[str, ...]
+
+
+class MultimodalSafetyFusion:
+ def __init__(self, *, uncertainty_yellow: float = 0.25, uncertainty_red: float = 0.70) -> None:
+ self.uncertainty_yellow = float(uncertainty_yellow)
+ self.uncertainty_red = float(uncertainty_red)
+
+ def evaluate(self, state: MultimodalSafetyInput) -> MultimodalSafetyDecision:
+ reasons: list[str] = []
+ available: list[str] = []
+ missing: list[str] = []
+ level = state.vision_level
+
+ def escalate(candidate: SafetyLevel, reason: str) -> None:
+ nonlocal level
+ if self._severity(candidate) > self._severity(level):
+ level = candidate
+ reasons.append(reason)
+
+ if not state.perception_quorum_ok:
+ escalate(SafetyLevel.RED, "perception_quorum_failed")
+ uncertainty = max(0.0, min(1.0, float(state.perception_uncertainty)))
+ if uncertainty >= self.uncertainty_red:
+ escalate(SafetyLevel.RED, "perception_uncertainty_red")
+ elif uncertainty >= self.uncertainty_yellow:
+ escalate(SafetyLevel.YELLOW, "perception_uncertainty_yellow")
+
+ if state.force is None:
+ if state.require_force:
+ missing.append("force")
+ else:
+ available.append("force")
+ if state.force.excessive_force:
+ escalate(SafetyLevel.RED, "force_excessive")
+ elif state.force.contact_detected:
+ escalate(SafetyLevel.YELLOW, "force_contact")
+
+ if state.tactile is None:
+ if state.require_tactile:
+ missing.append("tactile")
+ else:
+ available.append("tactile")
+ if state.tactile.excessive_pressure:
+ escalate(SafetyLevel.RED, "tactile_pressure_excessive")
+ if state.tactile.excessive_shear:
+ escalate(SafetyLevel.RED, "tactile_shear_excessive")
+ elif state.tactile.contact_detected:
+ escalate(SafetyLevel.YELLOW, "tactile_contact")
+
+ if state.proximity is None:
+ if state.require_proximity:
+ missing.append("proximity")
+ else:
+ available.append("proximity")
+ if not state.proximity.corridor_clear:
+ escalate(SafetyLevel.RED, "proximity_stop")
+ elif state.proximity.near_contact:
+ escalate(SafetyLevel.YELLOW, "proximity_caution")
+
+ if state.thermal is None:
+ if state.require_thermal:
+ missing.append("thermal")
+ else:
+ available.append("thermal")
+ if state.thermal.over_limit:
+ escalate(SafetyLevel.RED, "thermal_stop")
+ elif state.thermal.heat_detected:
+ escalate(SafetyLevel.YELLOW, "thermal_caution")
+
+ if missing:
+ escalate(SafetyLevel.RED, "required_modality_missing")
+ if not reasons:
+ reasons.append("multimodal_nominal")
+ return MultimodalSafetyDecision(
+ level=level,
+ reasons=tuple(reasons),
+ available_modalities=tuple(available),
+ missing_required_modalities=tuple(missing),
+ )
+
+ @staticmethod
+ def _severity(level: SafetyLevel) -> int:
+ return {SafetyLevel.GREEN: 0, SafetyLevel.YELLOW: 1, SafetyLevel.RED: 2}[level]
diff --git a/src/ohip_control/realtime.py b/src/ohip_control/realtime.py
new file mode 100644
index 0000000..0ff38a1
--- /dev/null
+++ b/src/ohip_control/realtime.py
@@ -0,0 +1,162 @@
+"""Bounded soft-real-time contact controller.
+
+The controller is executable and timing-instrumented, but Python cannot provide
+a certified hard-real-time guarantee. Hardware deployments should move the same
+invariants into a suitable RT process/PLC/safety controller.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+from time import perf_counter
+
+from .invariants import RuntimeInvariantInput, RuntimeInvariantMonitor
+from .recovery import RecoveryCommand, RecoveryPlanner, RecoveryStage
+
+
+class ControllerState(str, Enum):
+ IDLE = "IDLE"
+ APPROACH = "APPROACH"
+ CONTACT = "CONTACT"
+ RETREAT = "RETREAT"
+ SAFE_HOLD = "SAFE_HOLD"
+ FAULTED = "FAULTED"
+
+
+@dataclass(frozen=True)
+class ControllerInput:
+ requested_speed_mps: float
+ requested_force_N: float
+ measured_force_N: float
+ force_cap_N: float
+ speed_cap_mps: float
+ sensor_age_ms: float
+ consent_active: bool
+ contact_requested: bool
+ perception_quorum_ok: bool
+ e_stop: bool = False
+ contact_detected: bool = False
+
+
+@dataclass(frozen=True)
+class ControllerOutput:
+ state: ControllerState
+ command_speed_mps: float
+ command_force_N: float
+ stop: bool
+ latched: bool
+ reason: str
+ recovery: RecoveryCommand | None
+ compute_time_ms: float
+ deadline_missed: bool
+
+
+class BoundedRealtimeController:
+ """A fail-closed control-cycle kernel with explicit watchdog timing."""
+
+ def __init__(
+ self,
+ *,
+ target_period_ms: float = 5.0,
+ max_sensor_age_ms: float = 25.0,
+ monitor: RuntimeInvariantMonitor | None = None,
+ recovery: RecoveryPlanner | None = None,
+ ) -> None:
+ self.target_period_ms = float(target_period_ms)
+ self.max_sensor_age_ms = float(max_sensor_age_ms)
+ self.monitor = monitor or RuntimeInvariantMonitor()
+ self.recovery = recovery or RecoveryPlanner()
+ self.state = ControllerState.IDLE
+ self.latched = False
+ self.last_reason = "idle"
+
+ def clear_latch(self) -> None:
+ self.latched = False
+ self.last_reason = "cleared"
+ self.state = ControllerState.IDLE
+
+ def step(self, state: ControllerInput) -> ControllerOutput:
+ start = perf_counter()
+ if self.latched:
+ elapsed = (perf_counter() - start) * 1000.0
+ return ControllerOutput(
+ state=ControllerState.FAULTED,
+ command_speed_mps=0.0,
+ command_force_N=0.0,
+ stop=True,
+ latched=True,
+ reason=self.last_reason,
+ recovery=RecoveryCommand(RecoveryStage.OPERATOR_REQUIRED, 0.0, 0.0, True, self.last_reason),
+ compute_time_ms=elapsed,
+ deadline_missed=elapsed > self.target_period_ms,
+ )
+
+ command_speed = min(max(0.0, state.requested_speed_mps), max(0.0, state.speed_cap_mps))
+ command_force = min(max(0.0, state.requested_force_N), max(0.0, state.force_cap_N))
+
+ inv = RuntimeInvariantInput(
+ commanded_force_N=command_force,
+ measured_force_N=float(state.measured_force_N),
+ force_cap_N=float(state.force_cap_N),
+ commanded_speed_mps=command_speed,
+ speed_cap_mps=float(state.speed_cap_mps),
+ sensor_age_ms=float(state.sensor_age_ms),
+ max_sensor_age_ms=self.max_sensor_age_ms,
+ control_age_ms=0.0,
+ max_control_age_ms=self.target_period_ms * 2.0,
+ consent_active=bool(state.consent_active),
+ contact_requested=bool(state.contact_requested),
+ e_stop=bool(state.e_stop),
+ perception_quorum_ok=bool(state.perception_quorum_ok),
+ )
+ report = self.monitor.evaluate(inv)
+ recovery_cmd: RecoveryCommand | None = None
+ reason = "control_ok"
+ stop = False
+ if report.violations:
+ primary = report.violations[0]
+ reason = primary.code
+ stop = report.requires_stop
+ recovery_cmd = self.recovery.from_reason(
+ primary.code,
+ contact_detected=state.contact_detected,
+ e_stop=state.e_stop,
+ )
+ command_speed = recovery_cmd.target_speed_mps if recovery_cmd.stage == RecoveryStage.RETRACT else 0.0
+ command_force = recovery_cmd.target_force_N
+ if report.requires_latch or recovery_cmd.requires_operator_clear:
+ self.latched = True
+ self.state = ControllerState.FAULTED
+ self.last_reason = reason
+ elif recovery_cmd.stage == RecoveryStage.SAFE_HOLD:
+ self.state = ControllerState.SAFE_HOLD
+ else:
+ self.state = ControllerState.RETREAT
+ else:
+ if state.contact_requested and state.contact_detected:
+ self.state = ControllerState.CONTACT
+ elif state.contact_requested:
+ self.state = ControllerState.APPROACH
+ else:
+ self.state = ControllerState.IDLE
+
+ elapsed = (perf_counter() - start) * 1000.0
+ deadline_missed = elapsed > self.target_period_ms
+ if deadline_missed and not self.latched:
+ self.state = ControllerState.SAFE_HOLD
+ stop = True
+ reason = "controller_compute_deadline_miss"
+ command_speed = 0.0
+ command_force = 0.0
+ recovery_cmd = self.recovery.from_reason(reason, contact_detected=state.contact_detected)
+ return ControllerOutput(
+ state=self.state,
+ command_speed_mps=command_speed,
+ command_force_N=command_force,
+ stop=stop,
+ latched=self.latched,
+ reason=reason,
+ recovery=recovery_cmd,
+ compute_time_ms=elapsed,
+ deadline_missed=deadline_missed,
+ )
diff --git a/src/ohip_control/recovery.py b/src/ohip_control/recovery.py
new file mode 100644
index 0000000..cbdde3c
--- /dev/null
+++ b/src/ohip_control/recovery.py
@@ -0,0 +1,41 @@
+"""Deterministic recovery state machine for unexpected contact and faults."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+
+
+class RecoveryStage(str, Enum):
+ NONE = "NONE"
+ ZERO_EFFORT = "ZERO_EFFORT"
+ RETRACT = "RETRACT"
+ SAFE_HOLD = "SAFE_HOLD"
+ OPERATOR_REQUIRED = "OPERATOR_REQUIRED"
+
+
+@dataclass(frozen=True)
+class RecoveryCommand:
+ stage: RecoveryStage
+ target_speed_mps: float
+ target_force_N: float
+ requires_operator_clear: bool
+ reason: str
+
+
+class RecoveryPlanner:
+ """Translate runtime violations into bounded recovery behavior."""
+
+ def __init__(self, *, retract_speed_mps: float = 0.03) -> None:
+ self.retract_speed_mps = float(retract_speed_mps)
+
+ def from_reason(self, reason: str, *, contact_detected: bool, e_stop: bool = False) -> RecoveryCommand:
+ code = reason.lower()
+ if e_stop or "e_stop" in code:
+ return RecoveryCommand(RecoveryStage.OPERATOR_REQUIRED, 0.0, 0.0, True, "e_stop")
+ if any(token in code for token in ("overforce", "force_over", "collision", "red_hazard", "consent_lost")):
+ if contact_detected:
+ return RecoveryCommand(RecoveryStage.ZERO_EFFORT, 0.0, 0.0, True, reason)
+ return RecoveryCommand(RecoveryStage.RETRACT, self.retract_speed_mps, 0.0, True, reason)
+ if any(token in code for token in ("stale", "deadline", "quorum", "uncertainty", "watchdog")):
+ return RecoveryCommand(RecoveryStage.SAFE_HOLD, 0.0, 0.0, True, reason)
+ return RecoveryCommand(RecoveryStage.RETRACT, self.retract_speed_mps, 0.0, False, reason)
diff --git a/src/ohip_evidence/__init__.py b/src/ohip_evidence/__init__.py
new file mode 100644
index 0000000..13b01b8
--- /dev/null
+++ b/src/ohip_evidence/__init__.py
@@ -0,0 +1,11 @@
+from .bundle import EvidenceBundle, EvidenceBundleManifest
+from .hashchain import ChainedEvidenceRecord, EvidenceChain, GENESIS_HASH, verify_records
+
+__all__ = [
+ "ChainedEvidenceRecord",
+ "EvidenceBundle",
+ "EvidenceBundleManifest",
+ "EvidenceChain",
+ "GENESIS_HASH",
+ "verify_records",
+]
diff --git a/src/ohip_evidence/bundle.py b/src/ohip_evidence/bundle.py
new file mode 100644
index 0000000..b9363fd
--- /dev/null
+++ b/src/ohip_evidence/bundle.py
@@ -0,0 +1,72 @@
+"""Portable evidence-bundle writer and verifier."""
+from __future__ import annotations
+
+import hashlib
+import json
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from .hashchain import ChainedEvidenceRecord, verify_records
+
+
+@dataclass(frozen=True)
+class EvidenceBundleManifest:
+ schema_version: str
+ record_count: int
+ chain_head_sha256: str
+ records_sha256: str
+ provenance: dict[str, Any]
+
+
+class EvidenceBundle:
+ @staticmethod
+ def write(directory: str | Path, records: tuple[ChainedEvidenceRecord, ...], *, provenance: dict[str, Any]) -> EvidenceBundleManifest:
+ path = Path(directory)
+ path.mkdir(parents=True, exist_ok=True)
+ ok, reason = verify_records(records)
+ if not ok:
+ raise ValueError(f"cannot bundle invalid evidence chain: {reason}")
+ records_text = "".join(json.dumps(r.to_dict(), sort_keys=True) + "\n" for r in records)
+ records_path = path / "records.jsonl"
+ records_path.write_text(records_text, encoding="utf-8")
+ records_hash = hashlib.sha256(records_text.encode("utf-8")).hexdigest()
+ head = records[-1].record_hash if records else "0" * 64
+ manifest = EvidenceBundleManifest(
+ schema_version="ixhs-evidence-v1",
+ record_count=len(records),
+ chain_head_sha256=head,
+ records_sha256=records_hash,
+ provenance=dict(provenance),
+ )
+ (path / "manifest.json").write_text(
+ json.dumps(manifest.__dict__, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ return manifest
+
+ @staticmethod
+ def verify(directory: str | Path) -> tuple[bool, str]:
+ path = Path(directory)
+ try:
+ manifest = json.loads((path / "manifest.json").read_text(encoding="utf-8"))
+ text = (path / "records.jsonl").read_text(encoding="utf-8")
+ except Exception as exc:
+ return False, f"bundle_read_error:{exc.__class__.__name__}"
+ if hashlib.sha256(text.encode("utf-8")).hexdigest() != manifest.get("records_sha256"):
+ return False, "records_digest_mismatch"
+ records: list[ChainedEvidenceRecord] = []
+ for line in text.splitlines():
+ if not line.strip():
+ continue
+ doc = json.loads(line)
+ records.append(ChainedEvidenceRecord(**doc))
+ ok, reason = verify_records(records)
+ if not ok:
+ return False, reason
+ head = records[-1].record_hash if records else "0" * 64
+ if head != manifest.get("chain_head_sha256"):
+ return False, "manifest_head_mismatch"
+ if len(records) != int(manifest.get("record_count", -1)):
+ return False, "record_count_mismatch"
+ return True, "ok"
diff --git a/src/ohip_evidence/hashchain.py b/src/ohip_evidence/hashchain.py
new file mode 100644
index 0000000..7f1ba42
--- /dev/null
+++ b/src/ohip_evidence/hashchain.py
@@ -0,0 +1,88 @@
+"""Tamper-evident evidence chain for IX-HapticSight runtime records."""
+from __future__ import annotations
+
+import hashlib
+import json
+from dataclasses import dataclass, asdict
+from time import time
+from typing import Any, Iterable
+
+GENESIS_HASH = "0" * 64
+
+
+@dataclass(frozen=True)
+class ChainedEvidenceRecord:
+ sequence: int
+ timestamp_s: float
+ kind: str
+ payload: dict[str, Any]
+ previous_hash: str
+ record_hash: str
+
+ def to_dict(self) -> dict[str, Any]:
+ return asdict(self)
+
+
+def _canonical_bytes(*, sequence: int, timestamp_s: float, kind: str, payload: dict[str, Any], previous_hash: str) -> bytes:
+ body = {
+ "sequence": int(sequence),
+ "timestamp_s": float(timestamp_s),
+ "kind": str(kind),
+ "payload": payload,
+ "previous_hash": str(previous_hash),
+ }
+ return json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
+
+
+class EvidenceChain:
+ def __init__(self) -> None:
+ self._records: list[ChainedEvidenceRecord] = []
+
+ @property
+ def records(self) -> tuple[ChainedEvidenceRecord, ...]:
+ return tuple(self._records)
+
+ def append(self, kind: str, payload: dict[str, Any], *, timestamp_s: float | None = None) -> ChainedEvidenceRecord:
+ sequence = len(self._records)
+ previous_hash = self._records[-1].record_hash if self._records else GENESIS_HASH
+ ts = time() if timestamp_s is None else float(timestamp_s)
+ digest = hashlib.sha256(
+ _canonical_bytes(
+ sequence=sequence,
+ timestamp_s=ts,
+ kind=kind,
+ payload=payload,
+ previous_hash=previous_hash,
+ )
+ ).hexdigest()
+ record = ChainedEvidenceRecord(sequence, ts, str(kind), dict(payload), previous_hash, digest)
+ self._records.append(record)
+ return record
+
+ def verify(self) -> tuple[bool, str]:
+ return verify_records(self._records)
+
+ def to_jsonl(self) -> str:
+ return "".join(json.dumps(r.to_dict(), sort_keys=True) + "\n" for r in self._records)
+
+
+def verify_records(records: Iterable[ChainedEvidenceRecord]) -> tuple[bool, str]:
+ previous = GENESIS_HASH
+ for expected_sequence, record in enumerate(records):
+ if record.sequence != expected_sequence:
+ return False, f"sequence_mismatch:{expected_sequence}"
+ if record.previous_hash != previous:
+ return False, f"previous_hash_mismatch:{expected_sequence}"
+ expected = hashlib.sha256(
+ _canonical_bytes(
+ sequence=record.sequence,
+ timestamp_s=record.timestamp_s,
+ kind=record.kind,
+ payload=record.payload,
+ previous_hash=record.previous_hash,
+ )
+ ).hexdigest()
+ if expected != record.record_hash:
+ return False, f"record_hash_mismatch:{expected_sequence}"
+ previous = record.record_hash
+ return True, "ok"
diff --git a/src/ohip_hil/__init__.py b/src/ohip_hil/__init__.py
new file mode 100644
index 0000000..4599adf
--- /dev/null
+++ b/src/ohip_hil/__init__.py
@@ -0,0 +1,17 @@
+from .harness import (
+ HILAcceptanceCriteria,
+ HILHarness,
+ HILResult,
+ HILSample,
+ HILStatus,
+ HardwareCapability,
+)
+
+__all__ = [
+ "HILAcceptanceCriteria",
+ "HILHarness",
+ "HILResult",
+ "HILSample",
+ "HILStatus",
+ "HardwareCapability",
+]
diff --git a/src/ohip_hil/harness.py b/src/ohip_hil/harness.py
new file mode 100644
index 0000000..9b88542
--- /dev/null
+++ b/src/ohip_hil/harness.py
@@ -0,0 +1,113 @@
+"""Executable HIL harness with explicit no-hardware semantics.
+
+The harness never manufactures HIL success. A run is ``PASSED`` only when a
+hardware probe positively reports required devices and the supplied executor
+returns measured samples satisfying the declared acceptance criteria.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass, asdict
+from enum import Enum
+from time import time
+from typing import Callable, Iterable
+
+
+class HILStatus(str, Enum):
+ PASSED = "PASSED"
+ FAILED = "FAILED"
+ NOT_RUN_NO_HARDWARE = "NOT_RUN_NO_HARDWARE"
+ NOT_RUN_INCOMPLETE = "NOT_RUN_INCOMPLETE"
+
+
+@dataclass(frozen=True)
+class HardwareCapability:
+ capability: str
+ present: bool
+ device_id: str = ""
+ detail: str = ""
+
+
+@dataclass(frozen=True)
+class HILSample:
+ timestamp_s: float
+ measured_force_N: float
+ measured_latency_ms: float
+ faulted: bool = False
+
+
+@dataclass(frozen=True)
+class HILAcceptanceCriteria:
+ required_capabilities: tuple[str, ...] = ("robot_motion", "force_torque")
+ max_force_N: float = 3.5
+ max_latency_ms: float = 25.0
+ min_samples: int = 20
+
+
+@dataclass(frozen=True)
+class HILResult:
+ status: HILStatus
+ reason: str
+ capabilities: tuple[HardwareCapability, ...]
+ samples: tuple[HILSample, ...]
+ criteria: HILAcceptanceCriteria
+ started_at_s: float
+ finished_at_s: float
+
+ def to_dict(self) -> dict:
+ return {
+ "status": self.status.value,
+ "reason": self.reason,
+ "capabilities": [asdict(c) for c in self.capabilities],
+ "samples": [asdict(s) for s in self.samples],
+ "criteria": asdict(self.criteria),
+ "started_at_s": self.started_at_s,
+ "finished_at_s": self.finished_at_s,
+ }
+
+
+class HILHarness:
+ def __init__(self, criteria: HILAcceptanceCriteria | None = None) -> None:
+ self.criteria = criteria or HILAcceptanceCriteria()
+
+ def run(
+ self,
+ *,
+ probe: Callable[[], Iterable[HardwareCapability]],
+ executor: Callable[[], Iterable[HILSample]] | None,
+ ) -> HILResult:
+ started = time()
+ capabilities = tuple(probe())
+ present = {c.capability for c in capabilities if c.present}
+ missing = [name for name in self.criteria.required_capabilities if name not in present]
+ if missing:
+ return HILResult(
+ HILStatus.NOT_RUN_NO_HARDWARE,
+ "missing:" + ",".join(missing),
+ capabilities,
+ (),
+ self.criteria,
+ started,
+ time(),
+ )
+ if executor is None:
+ return HILResult(
+ HILStatus.NOT_RUN_INCOMPLETE,
+ "executor_not_configured",
+ capabilities,
+ (),
+ self.criteria,
+ started,
+ time(),
+ )
+ samples = tuple(executor())
+ if len(samples) < self.criteria.min_samples:
+ status, reason = HILStatus.FAILED, "insufficient_samples"
+ elif any(s.faulted for s in samples):
+ status, reason = HILStatus.FAILED, "fault_observed"
+ elif any(s.measured_force_N > self.criteria.max_force_N for s in samples):
+ status, reason = HILStatus.FAILED, "force_limit_exceeded"
+ elif any(s.measured_latency_ms > self.criteria.max_latency_ms for s in samples):
+ status, reason = HILStatus.FAILED, "latency_limit_exceeded"
+ else:
+ status, reason = HILStatus.PASSED, "measured_hil_pass"
+ return HILResult(status, reason, capabilities, samples, self.criteria, started, time())
diff --git a/src/ohip_interfaces/__init__.py b/src/ohip_interfaces/__init__.py
index 4dd8a37..708ed7f 100644
--- a/src/ohip_interfaces/__init__.py
+++ b/src/ohip_interfaces/__init__.py
@@ -25,4 +25,4 @@
"__version__",
]
-__version__ = "0.1.0"
+__version__ = "0.2.0"
diff --git a/src/ohip_interfaces/simulated_execution_adapter.py b/src/ohip_interfaces/simulated_execution_adapter.py
index 7fb61b4..a9050fe 100644
--- a/src/ohip_interfaces/simulated_execution_adapter.py
+++ b/src/ohip_interfaces/simulated_execution_adapter.py
@@ -3,8 +3,8 @@
This adapter is intentionally simple and deterministic. Its purpose is to:
- exercise the execution adapter contract in tests
-- provide a backend-agnostic placeholder for local runtime integration
-- support replay and benchmark scaffolding before any ROS 2 or hardware bridge
+- provide a deterministic backend for local runtime integration
+- support replay and benchmarks independently of ROS 2 or hardware
It does not perform real motion planning or physics.
It simulates execution state transitions in a conservative, inspectable way.
diff --git a/src/ohip_logging/__init__.py b/src/ohip_logging/__init__.py
index c287640..988c13a 100644
--- a/src/ohip_logging/__init__.py
+++ b/src/ohip_logging/__init__.py
@@ -10,7 +10,7 @@
It is intentionally separate from:
- the stable OHIP protocol core in ``src/ohip``
- runtime orchestration in ``src/ohip_runtime``
-- future ROS 2 integration layers
+- ROS 2 integration layers
The design goal is simple:
important runtime behavior should be inspectable after the fact without relying
@@ -23,4 +23,4 @@
"__version__",
]
-__version__ = "0.1.0"
+__version__ = "0.2.0"
diff --git a/src/ohip_perception/__init__.py b/src/ohip_perception/__init__.py
new file mode 100644
index 0000000..757d160
--- /dev/null
+++ b/src/ohip_perception/__init__.py
@@ -0,0 +1,21 @@
+"""Perception stack for IX-HapticSight."""
+from .fusion import PerceptionQuorum
+from .hazard_map import HazardVoxel, VisionHazardProjector
+from .models import ModelAgreement, PerceptionFrame, SegmentationPixel, SegmentationResult, SemanticClass
+from .pipeline import PerceptionPipelineResult, VisionPipeline
+from .segmentation import CentroidModel, ReferenceSegmenter
+
+__all__ = [
+ "CentroidModel",
+ "HazardVoxel",
+ "ModelAgreement",
+ "PerceptionFrame",
+ "PerceptionPipelineResult",
+ "PerceptionQuorum",
+ "ReferenceSegmenter",
+ "SegmentationPixel",
+ "SegmentationResult",
+ "SemanticClass",
+ "VisionHazardProjector",
+ "VisionPipeline",
+]
diff --git a/src/ohip_perception/fusion.py b/src/ohip_perception/fusion.py
new file mode 100644
index 0000000..9ef7bcf
--- /dev/null
+++ b/src/ohip_perception/fusion.py
@@ -0,0 +1,76 @@
+"""Perception quorum and model-disagreement handling.
+
+Safety-critical perception should not silently trust one classifier. The quorum
+compares independently configured models and fails closed when disagreement is
+high, especially for human/sharp/hot/liquid classes.
+"""
+from __future__ import annotations
+
+from .models import ModelAgreement, SegmentationResult, SemanticClass
+
+
+CRITICAL_CLASSES = {
+ SemanticClass.PERSON,
+ SemanticClass.HOT,
+ SemanticClass.LIQUID,
+ SemanticClass.SHARP,
+}
+
+
+class PerceptionQuorum:
+ def __init__(
+ self,
+ *,
+ min_agreement: float = 0.90,
+ max_critical_disagreement: float = 0.03,
+ min_mean_confidence: float = 0.55,
+ ) -> None:
+ self.min_agreement = float(min_agreement)
+ self.max_critical_disagreement = float(max_critical_disagreement)
+ self.min_mean_confidence = float(min_mean_confidence)
+
+ def compare(self, primary: SegmentationResult, secondary: SegmentationResult) -> ModelAgreement:
+ if primary.height != secondary.height or primary.width != secondary.width:
+ return ModelAgreement(0.0, 0.0, 1.0, 1.0, False, "shape_mismatch")
+
+ total = 0
+ agree = 0
+ critical_disagree = 0
+ conf_sum = 0.0
+ unc_sum = 0.0
+ for prow, srow in zip(primary.pixels, secondary.pixels):
+ for p, s in zip(prow, srow):
+ total += 1
+ if p.semantic_class == s.semantic_class:
+ agree += 1
+ elif p.semantic_class in CRITICAL_CLASSES or s.semantic_class in CRITICAL_CLASSES:
+ critical_disagree += 1
+ conf_sum += min(p.confidence, s.confidence)
+ unc_sum += max(p.uncertainty, s.uncertainty)
+
+ denom = float(max(1, total))
+ agreement_ratio = agree / denom
+ critical_ratio = critical_disagree / denom
+ mean_conf = conf_sum / denom
+ mean_unc = unc_sum / denom
+ passed = (
+ agreement_ratio >= self.min_agreement
+ and critical_ratio <= self.max_critical_disagreement
+ and mean_conf >= self.min_mean_confidence
+ )
+ if agreement_ratio < self.min_agreement:
+ reason = "model_disagreement"
+ elif critical_ratio > self.max_critical_disagreement:
+ reason = "critical_class_disagreement"
+ elif mean_conf < self.min_mean_confidence:
+ reason = "low_confidence"
+ else:
+ reason = "quorum_ok"
+ return ModelAgreement(
+ agreement_ratio=agreement_ratio,
+ mean_confidence=mean_conf,
+ mean_uncertainty=mean_unc,
+ critical_disagreement_ratio=critical_ratio,
+ passed=passed,
+ reason=reason,
+ )
diff --git a/src/ohip_perception/hazard_map.py b/src/ohip_perception/hazard_map.py
new file mode 100644
index 0000000..122bfe1
--- /dev/null
+++ b/src/ohip_perception/hazard_map.py
@@ -0,0 +1,108 @@
+"""Vision-derived tri-level hazard map generation."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from math import floor
+from typing import Iterable
+
+from ohip.schemas import HazardClass, SafetyLevel, SafetyMapCell
+
+from .models import PerceptionFrame, SegmentationResult, SemanticClass
+
+
+@dataclass(frozen=True)
+class HazardVoxel:
+ cell: tuple[int, int, int]
+ level: SafetyLevel
+ hazard_class: HazardClass
+ confidence: float
+ source: str
+
+
+_CLASS_POLICY: dict[SemanticClass, tuple[SafetyLevel, HazardClass]] = {
+ SemanticClass.BACKGROUND: (SafetyLevel.GREEN, HazardClass.UNKNOWN),
+ SemanticClass.OBJECT: (SafetyLevel.YELLOW, HazardClass.UNKNOWN),
+ SemanticClass.PERSON: (SafetyLevel.YELLOW, HazardClass.MOVING),
+ SemanticClass.HOT: (SafetyLevel.RED, HazardClass.HOT),
+ SemanticClass.LIQUID: (SafetyLevel.RED, HazardClass.LIQUID),
+ SemanticClass.SHARP: (SafetyLevel.RED, HazardClass.BLADE),
+ SemanticClass.UNKNOWN: (SafetyLevel.RED, HazardClass.UNKNOWN),
+}
+
+
+class VisionHazardProjector:
+ """Project RGB-D segmentation into a coarse camera-frame safety voxel map.
+
+ The reference projection intentionally uses a simple pinhole approximation
+ and keeps uncertain/no-depth observations conservative.
+ """
+
+ def __init__(
+ self,
+ *,
+ voxel_size_m: float = 0.05,
+ horizontal_fov_deg: float = 70.0,
+ min_confidence: float = 0.55,
+ ) -> None:
+ self.voxel_size_m = float(voxel_size_m)
+ self.horizontal_fov_deg = float(horizontal_fov_deg)
+ self.min_confidence = float(min_confidence)
+
+ def project(self, frame: PerceptionFrame, segmentation: SegmentationResult) -> list[HazardVoxel]:
+ frame.validate()
+ if segmentation.height != frame.height or segmentation.width != frame.width:
+ raise ValueError("segmentation dimensions must match perception frame")
+ output: dict[tuple[int, int, int], HazardVoxel] = {}
+ half_width = max(1.0, frame.width / 2.0)
+ tan_half_fov = __import__("math").tan(__import__("math").radians(self.horizontal_fov_deg / 2.0))
+
+ for y in range(frame.height):
+ for x in range(frame.width):
+ pixel = segmentation.pixels[y][x]
+ depth = frame.depth_m[y][x]
+ if depth is None or depth <= 0.0:
+ if pixel.semantic_class in {SemanticClass.BACKGROUND, SemanticClass.OBJECT}:
+ continue
+ depth = self.voxel_size_m
+ z = float(depth)
+ x_norm = (x - half_width) / half_width
+ x_m = x_norm * z * tan_half_fov
+ y_m = ((frame.height / 2.0 - y) / max(1.0, frame.height / 2.0)) * z * tan_half_fov
+ cell = (
+ floor(x_m / self.voxel_size_m),
+ floor(y_m / self.voxel_size_m),
+ floor(z / self.voxel_size_m),
+ )
+ level, hazard = _CLASS_POLICY[pixel.semantic_class]
+ if pixel.confidence < self.min_confidence:
+ level = SafetyLevel.RED
+ hazard = HazardClass.UNKNOWN
+ voxel = HazardVoxel(
+ cell=cell,
+ level=level,
+ hazard_class=hazard,
+ confidence=float(pixel.confidence),
+ source=f"vision:{segmentation.model_name}",
+ )
+ prev = output.get(cell)
+ if prev is None or self._severity(voxel.level) > self._severity(prev.level):
+ output[cell] = voxel
+ elif prev.level == voxel.level and voxel.confidence > prev.confidence:
+ output[cell] = voxel
+ return sorted(output.values(), key=lambda v: v.cell)
+
+ @staticmethod
+ def to_safety_cells(voxels: Iterable[HazardVoxel], updated_ms: int) -> list[SafetyMapCell]:
+ return [
+ SafetyMapCell(
+ cell=v.cell,
+ hazard_class=v.hazard_class,
+ level=v.level,
+ updated_ms=updated_ms,
+ )
+ for v in voxels
+ ]
+
+ @staticmethod
+ def _severity(level: SafetyLevel) -> int:
+ return {SafetyLevel.GREEN: 0, SafetyLevel.YELLOW: 1, SafetyLevel.RED: 2}[level]
diff --git a/src/ohip_perception/models.py b/src/ohip_perception/models.py
new file mode 100644
index 0000000..dcf9e78
--- /dev/null
+++ b/src/ohip_perception/models.py
@@ -0,0 +1,95 @@
+"""Canonical perception models for IX-HapticSight.
+
+The perception layer deliberately keeps learned perception separate from the
+safety authority. A model may propose semantic state; it cannot authorize
+contact or motion.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Iterable
+
+
+class SemanticClass(str, Enum):
+ BACKGROUND = "background"
+ PERSON = "person"
+ OBJECT = "object"
+ HOT = "hot"
+ LIQUID = "liquid"
+ SHARP = "sharp"
+ UNKNOWN = "unknown"
+
+
+@dataclass(frozen=True)
+class PerceptionFrame:
+ """Normalized RGB-D frame used by the reference vision pipeline.
+
+ RGB values are uint8-like integers in nested [height][width][3] form.
+ Depth is metres in nested [height][width] form. ``None`` means depth is
+ unavailable for that pixel.
+ """
+
+ rgb: list[list[tuple[int, int, int]]]
+ depth_m: list[list[float | None]]
+ timestamp_s: float
+ frame_id: str = "camera"
+
+ @property
+ def height(self) -> int:
+ return len(self.rgb)
+
+ @property
+ def width(self) -> int:
+ return len(self.rgb[0]) if self.rgb else 0
+
+ def validate(self) -> None:
+ if not self.rgb or not self.rgb[0]:
+ raise ValueError("rgb frame must be non-empty")
+ if len(self.depth_m) != self.height:
+ raise ValueError("depth height must match rgb height")
+ width = self.width
+ for row, drow in zip(self.rgb, self.depth_m):
+ if len(row) != width or len(drow) != width:
+ raise ValueError("all rgb/depth rows must have equal width")
+ for px in row:
+ if len(px) != 3 or any(int(v) < 0 or int(v) > 255 for v in px):
+ raise ValueError("rgb values must be three channels in [0,255]")
+
+
+@dataclass(frozen=True)
+class SegmentationPixel:
+ semantic_class: SemanticClass
+ confidence: float
+ uncertainty: float
+
+
+@dataclass(frozen=True)
+class SegmentationResult:
+ pixels: list[list[SegmentationPixel]]
+ model_name: str
+ timestamp_s: float
+ metrics: dict[str, float] = field(default_factory=dict)
+
+ @property
+ def height(self) -> int:
+ return len(self.pixels)
+
+ @property
+ def width(self) -> int:
+ return len(self.pixels[0]) if self.pixels else 0
+
+ def classes(self) -> Iterable[SemanticClass]:
+ for row in self.pixels:
+ for pixel in row:
+ yield pixel.semantic_class
+
+
+@dataclass(frozen=True)
+class ModelAgreement:
+ agreement_ratio: float
+ mean_confidence: float
+ mean_uncertainty: float
+ critical_disagreement_ratio: float
+ passed: bool
+ reason: str
diff --git a/src/ohip_perception/pipeline.py b/src/ohip_perception/pipeline.py
new file mode 100644
index 0000000..ec9d3a1
--- /dev/null
+++ b/src/ohip_perception/pipeline.py
@@ -0,0 +1,84 @@
+"""End-to-end image/depth ingestion to semantic and hazard state."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from time import time
+
+from PIL import Image
+
+from .fusion import PerceptionQuorum
+from .hazard_map import HazardVoxel, VisionHazardProjector
+from .models import ModelAgreement, PerceptionFrame, SegmentationResult
+from .segmentation import ReferenceSegmenter
+
+
+@dataclass(frozen=True)
+class PerceptionPipelineResult:
+ primary: SegmentationResult
+ secondary: SegmentationResult | None
+ agreement: ModelAgreement | None
+ hazards: list[HazardVoxel]
+ safe_for_autonomy: bool
+ reason: str
+
+
+class VisionPipeline:
+ def __init__(
+ self,
+ primary: ReferenceSegmenter,
+ *,
+ secondary: ReferenceSegmenter | None = None,
+ quorum: PerceptionQuorum | None = None,
+ projector: VisionHazardProjector | None = None,
+ ) -> None:
+ self.primary = primary
+ self.secondary = secondary
+ self.quorum = quorum or PerceptionQuorum()
+ self.projector = projector or VisionHazardProjector()
+
+ @staticmethod
+ def load_rgbd(
+ rgb_path: str | Path,
+ *,
+ depth_m: list[list[float | None]] | None = None,
+ default_depth_m: float = 1.0,
+ timestamp_s: float | None = None,
+ ) -> PerceptionFrame:
+ image = Image.open(rgb_path).convert("RGB")
+ width, height = image.size
+ flat = list(image.getdata())
+ rgb = [flat[y * width : (y + 1) * width] for y in range(height)]
+ if depth_m is None:
+ depth_m = [[float(default_depth_m) for _ in range(width)] for _ in range(height)]
+ return PerceptionFrame(
+ rgb=[[tuple(int(v) for v in px) for px in row] for row in rgb],
+ depth_m=depth_m,
+ timestamp_s=time() if timestamp_s is None else float(timestamp_s),
+ )
+
+ def process(self, frame: PerceptionFrame) -> PerceptionPipelineResult:
+ primary = self.primary.infer(frame)
+ secondary_result = self.secondary.infer(frame) if self.secondary is not None else None
+ agreement = None
+ if secondary_result is not None:
+ agreement = self.quorum.compare(primary, secondary_result)
+ if not agreement.passed:
+ hazards = self.projector.project(frame, primary)
+ return PerceptionPipelineResult(
+ primary=primary,
+ secondary=secondary_result,
+ agreement=agreement,
+ hazards=hazards,
+ safe_for_autonomy=False,
+ reason=agreement.reason,
+ )
+ hazards = self.projector.project(frame, primary)
+ return PerceptionPipelineResult(
+ primary=primary,
+ secondary=secondary_result,
+ agreement=agreement,
+ hazards=hazards,
+ safe_for_autonomy=True,
+ reason="perception_ok",
+ )
diff --git a/src/ohip_perception/segmentation.py b/src/ohip_perception/segmentation.py
new file mode 100644
index 0000000..25d1e4b
--- /dev/null
+++ b/src/ohip_perception/segmentation.py
@@ -0,0 +1,125 @@
+"""Runnable reference segmentation for IX-HapticSight.
+
+This is intentionally small and auditable. It is a nearest-centroid semantic
+classifier operating on normalized RGB-D features. The committed model file is
+reproducibly trained by ``scripts/train_reference_segmenter.py`` using a
+synthetic calibration dataset. It is a real executable model, but it is not
+claimed to be production perception or a substitute for robot-specific data.
+"""
+from __future__ import annotations
+
+import json
+import math
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Mapping, Sequence
+
+from .models import PerceptionFrame, SegmentationPixel, SegmentationResult, SemanticClass
+
+
+@dataclass(frozen=True)
+class CentroidModel:
+ name: str
+ feature_names: tuple[str, ...]
+ centroids: Mapping[SemanticClass, tuple[float, ...]]
+ scales: tuple[float, ...]
+
+ @classmethod
+ def load(cls, path: str | Path) -> "CentroidModel":
+ doc = json.loads(Path(path).read_text(encoding="utf-8"))
+ feature_names = tuple(str(x) for x in doc["feature_names"])
+ scales = tuple(float(x) for x in doc.get("scales", [1.0] * len(feature_names)))
+ centroids = {
+ SemanticClass(key): tuple(float(x) for x in values)
+ for key, values in doc["centroids"].items()
+ }
+ if any(len(v) != len(feature_names) for v in centroids.values()):
+ raise ValueError("centroid feature dimensions do not match feature_names")
+ if len(scales) != len(feature_names):
+ raise ValueError("scale dimensions do not match feature_names")
+ return cls(
+ name=str(doc.get("name", "centroid-segmenter")),
+ feature_names=feature_names,
+ centroids=centroids,
+ scales=scales,
+ )
+
+
+class ReferenceSegmenter:
+ """Small semantic segmentation model with calibrated confidence output."""
+
+ def __init__(self, model: CentroidModel) -> None:
+ self.model = model
+
+ @classmethod
+ def from_file(cls, path: str | Path) -> "ReferenceSegmenter":
+ return cls(CentroidModel.load(path))
+
+ def infer(self, frame: PerceptionFrame) -> SegmentationResult:
+ frame.validate()
+ rows: list[list[SegmentationPixel]] = []
+ conf_sum = 0.0
+ uncertainty_sum = 0.0
+ count = 0
+ for y in range(frame.height):
+ out_row: list[SegmentationPixel] = []
+ for x in range(frame.width):
+ features = self._features(frame, x, y)
+ ranked = sorted(
+ (
+ (self._distance(features, centroid), cls_name)
+ for cls_name, centroid in self.model.centroids.items()
+ ),
+ key=lambda item: item[0],
+ )
+ best_d, best_cls = ranked[0]
+ second_d = ranked[1][0] if len(ranked) > 1 else best_d + 1.0
+ margin = max(0.0, second_d - best_d)
+ confidence = max(0.0, min(1.0, 1.0 - best_d / 1.75))
+ separation = margin / (second_d + 1e-9)
+ confidence = max(0.0, min(1.0, 0.65 * confidence + 0.35 * separation))
+ uncertainty = 1.0 - confidence
+ out_row.append(SegmentationPixel(best_cls, confidence, uncertainty))
+ conf_sum += confidence
+ uncertainty_sum += uncertainty
+ count += 1
+ rows.append(out_row)
+ denom = float(max(1, count))
+ return SegmentationResult(
+ pixels=rows,
+ model_name=self.model.name,
+ timestamp_s=frame.timestamp_s,
+ metrics={
+ "mean_confidence": conf_sum / denom,
+ "mean_uncertainty": uncertainty_sum / denom,
+ },
+ )
+
+ def _distance(self, features: Sequence[float], centroid: Sequence[float]) -> float:
+ total = 0.0
+ for value, center, scale in zip(features, centroid, self.model.scales):
+ s = max(abs(scale), 1e-6)
+ total += ((value - center) / s) ** 2
+ return math.sqrt(total / max(1, len(features)))
+
+ @staticmethod
+ def _features(frame: PerceptionFrame, x: int, y: int) -> tuple[float, ...]:
+ r, g, b = frame.rgb[y][x]
+ depth = frame.depth_m[y][x]
+ depth_norm = 1.0 if depth is None else max(0.0, min(1.0, float(depth) / 4.0))
+ max_c = max(r, g, b)
+ min_c = min(r, g, b)
+ saturation = (max_c - min_c) / 255.0
+ brightness = (r + g + b) / (3.0 * 255.0)
+ red_dominance = max(0.0, (r - max(g, b)) / 255.0)
+ blue_dominance = max(0.0, (b - max(r, g)) / 255.0)
+ return (
+ r / 255.0,
+ g / 255.0,
+ b / 255.0,
+ depth_norm,
+ saturation,
+ brightness,
+ red_dominance,
+ blue_dominance,
+ )
diff --git a/src/ohip_ros2/__init__.py b/src/ohip_ros2/__init__.py
new file mode 100644
index 0000000..c509e54
--- /dev/null
+++ b/src/ohip_ros2/__init__.py
@@ -0,0 +1,22 @@
+"""Optional ROS 2 integration for IX-HapticSight."""
+from .bridge import Ros2BridgeConfig, Ros2ContactBridge, Ros2Unavailable
+from .messages import Ros2Command, tactile_multiarray_to_frame, wrench_stamped_to_sample
+from .trajectory_adapter import (
+ FollowJointTrajectoryClient,
+ JointTrajectoryPointSpec,
+ JointTrajectorySpec,
+ Ros2TrajectoryUnavailable,
+)
+
+__all__ = [
+ "FollowJointTrajectoryClient",
+ "JointTrajectoryPointSpec",
+ "JointTrajectorySpec",
+ "Ros2BridgeConfig",
+ "Ros2Command",
+ "Ros2ContactBridge",
+ "Ros2TrajectoryUnavailable",
+ "Ros2Unavailable",
+ "tactile_multiarray_to_frame",
+ "wrench_stamped_to_sample",
+]
diff --git a/src/ohip_ros2/bridge.py b/src/ohip_ros2/bridge.py
new file mode 100644
index 0000000..fa550a8
--- /dev/null
+++ b/src/ohip_ros2/bridge.py
@@ -0,0 +1,159 @@
+"""ROS 2 execution and sensor bridge for IX-HapticSight.
+
+The bridge uses standard ROS 2 messages when ``rclpy`` is available. It does
+not weaken HapticSight safety authority: only already-bounded velocity/force
+commands should be published.
+"""
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from threading import Lock
+from time import time
+from typing import Any
+
+from ohip_interfaces.force_torque import ForceTorqueSample
+from ohip_interfaces.tactile import TactileFrame
+
+from .messages import Ros2Command, tactile_multiarray_to_frame, wrench_stamped_to_sample
+
+
+@dataclass(frozen=True)
+class Ros2BridgeConfig:
+ node_name: str = "ix_hapticsight_bridge"
+ wrench_topic: str = "/hapticsight/force_torque"
+ twist_topic: str = "/hapticsight/bounded_twist"
+ safety_event_topic: str = "/hapticsight/safety_event"
+ e_stop_topic: str = "/hapticsight/e_stop"
+ tactile_topic: str = "/hapticsight/tactile_patches"
+ qos_depth: int = 10
+
+
+class Ros2Unavailable(RuntimeError):
+ pass
+
+
+class Ros2ContactBridge:
+ """Runtime ROS 2 bridge using ``geometry_msgs`` and ``std_msgs``.
+
+ ``start`` imports ROS lazily so the rest of the repository remains usable on
+ systems without ROS 2. ``publish_bounded_command`` publishes a TwistStamped
+ plus a structured safety side-channel containing force authority and audit
+ identifiers.
+ """
+
+ def __init__(self, config: Ros2BridgeConfig | None = None) -> None:
+ self.config = config or Ros2BridgeConfig()
+ self._node: Any | None = None
+ self._rclpy: Any | None = None
+ self._twist_pub: Any | None = None
+ self._event_pub: Any | None = None
+ self._wrench_sub: Any | None = None
+ self._estop_sub: Any | None = None
+ self._last_wrench: ForceTorqueSample | None = None
+ self._e_stop = False
+ self._last_tactile: TactileFrame | None = None
+ self._lock = Lock()
+
+ @property
+ def started(self) -> bool:
+ return self._node is not None
+
+ @property
+ def e_stop(self) -> bool:
+ with self._lock:
+ return self._e_stop
+
+ def latest_wrench(self) -> ForceTorqueSample | None:
+ with self._lock:
+ return self._last_wrench
+
+ def latest_tactile(self) -> TactileFrame | None:
+ with self._lock:
+ return self._last_tactile
+
+ def start(self) -> None:
+ if self.started:
+ return
+ try:
+ import rclpy
+ from geometry_msgs.msg import TwistStamped, WrenchStamped
+ from std_msgs.msg import Bool, Float32MultiArray, String
+ except Exception as exc: # pragma: no cover - requires ROS2 environment
+ raise Ros2Unavailable(
+ "ROS 2 runtime dependencies are not installed. Install this package inside a ROS 2 environment with rclpy, geometry_msgs and std_msgs."
+ ) from exc
+
+ if not rclpy.ok():
+ rclpy.init(args=None)
+ node = rclpy.create_node(self.config.node_name)
+ self._rclpy = rclpy
+ self._node = node
+ self._TwistStamped = TwistStamped
+ self._String = String
+ self._twist_pub = node.create_publisher(TwistStamped, self.config.twist_topic, self.config.qos_depth)
+ self._event_pub = node.create_publisher(String, self.config.safety_event_topic, self.config.qos_depth)
+ self._wrench_sub = node.create_subscription(WrenchStamped, self.config.wrench_topic, self._on_wrench, self.config.qos_depth)
+ self._estop_sub = node.create_subscription(Bool, self.config.e_stop_topic, self._on_estop, self.config.qos_depth)
+ self._tactile_sub = node.create_subscription(Float32MultiArray, self.config.tactile_topic, self._on_tactile, self.config.qos_depth)
+
+ def spin_once(self, timeout_sec: float = 0.0) -> None:
+ if not self.started or self._rclpy is None:
+ raise Ros2Unavailable("bridge has not been started")
+ self._rclpy.spin_once(self._node, timeout_sec=float(timeout_sec)) # pragma: no cover
+
+ def close(self) -> None:
+ if self._node is not None: # pragma: no cover - requires ROS2 environment
+ self._node.destroy_node()
+ self._node = None
+
+ def publish_bounded_command(self, command: Ros2Command) -> None:
+ if not self.started or self._node is None:
+ raise Ros2Unavailable("bridge has not been started")
+ if command.force_cap_N < 0.0:
+ raise ValueError("force_cap_N must be non-negative")
+ msg = self._TwistStamped()
+ msg.header.stamp = self._node.get_clock().now().to_msg()
+ msg.header.frame_id = "base_link"
+ msg.twist.linear.x, msg.twist.linear.y, msg.twist.linear.z = command.linear_xyz_mps
+ msg.twist.angular.x, msg.twist.angular.y, msg.twist.angular.z = command.angular_xyz_rps
+ self._twist_pub.publish(msg)
+ event = self._String()
+ event.data = json.dumps(
+ {
+ "kind": "bounded_command",
+ "session_id": command.session_id,
+ "request_id": command.request_id,
+ "force_cap_N": command.force_cap_N,
+ "reason_code": command.reason_code,
+ "timestamp_s": time(),
+ },
+ sort_keys=True,
+ )
+ self._event_pub.publish(event)
+
+ def publish_stop(self, *, session_id: str, reason_code: str) -> None:
+ self.publish_bounded_command(
+ Ros2Command(
+ session_id=session_id,
+ request_id="safety-stop",
+ linear_xyz_mps=(0.0, 0.0, 0.0),
+ angular_xyz_rps=(0.0, 0.0, 0.0),
+ force_cap_N=0.0,
+ reason_code=reason_code,
+ )
+ )
+
+ def _on_wrench(self, msg: Any) -> None: # pragma: no cover - callback tested via converter
+ sample = wrench_stamped_to_sample(msg)
+ with self._lock:
+ self._last_wrench = sample
+
+ def _on_tactile(self, msg: Any) -> None: # pragma: no cover
+ frame = tactile_multiarray_to_frame(msg)
+ with self._lock:
+ self._last_tactile = frame
+
+ def _on_estop(self, msg: Any) -> None: # pragma: no cover
+ with self._lock:
+ self._e_stop = bool(msg.data)
diff --git a/src/ohip_ros2/messages.py b/src/ohip_ros2/messages.py
new file mode 100644
index 0000000..739fc76
--- /dev/null
+++ b/src/ohip_ros2/messages.py
@@ -0,0 +1,101 @@
+"""ROS 2 message conversion helpers.
+
+These converters are deliberately duck-typed so their numeric behavior can be
+tested without ROS 2 being installed. At runtime they accept standard
+``geometry_msgs/WrenchStamped``-like and proximity message objects.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from time import time
+from typing import Any
+
+from ohip.schemas import Vector3
+from ohip_interfaces.force_torque import ForceTorqueSample
+from ohip_interfaces.signal_health import SignalHealth, SignalQuality, SignalSourceMode
+from ohip_interfaces.tactile import TactileFrame, make_tactile_patch
+
+
+@dataclass(frozen=True)
+class Ros2Command:
+ session_id: str
+ request_id: str
+ linear_xyz_mps: tuple[float, float, float]
+ angular_xyz_rps: tuple[float, float, float]
+ force_cap_N: float
+ reason_code: str
+
+
+def _stamp_to_seconds(stamp: Any) -> float:
+ sec = float(getattr(stamp, "sec", 0.0))
+ nanosec = float(getattr(stamp, "nanosec", 0.0))
+ value = sec + nanosec / 1_000_000_000.0
+ return value if value > 0.0 else time()
+
+
+def wrench_stamped_to_sample(msg: Any, *, source_id: str = "ros2_wrench") -> ForceTorqueSample:
+ header = getattr(msg, "header", None)
+ stamp = getattr(header, "stamp", None)
+ frame_id = str(getattr(header, "frame_id", "tool"))
+ wrench = getattr(msg, "wrench")
+ force = getattr(wrench, "force")
+ torque = getattr(wrench, "torque")
+ ts = _stamp_to_seconds(stamp) if stamp is not None else time()
+ quality = SignalQuality(
+ source_mode=SignalSourceMode.LIVE,
+ health=SignalHealth.NOMINAL,
+ sample_timestamp_utc_s=ts,
+ received_timestamp_utc_s=time(),
+ sequence_id=None,
+ source_name=source_id,
+ frame=frame_id,
+ note="ROS2 WrenchStamped",
+ )
+ return ForceTorqueSample(
+ frame=frame_id,
+ force=Vector3(float(force.x), float(force.y), float(force.z)),
+ torque=Vector3(float(torque.x), float(torque.y), float(torque.z)),
+ quality=quality,
+ )
+
+
+def tactile_multiarray_to_frame(
+ msg: Any,
+ *,
+ surface_name: str = "tool_tactile",
+ frame: str = "tool",
+ source_id: str = "ros2_tactile",
+) -> TactileFrame:
+ """Convert a ``std_msgs/Float32MultiArray``-like payload to tactile patches.
+
+ The wire contract is ten floats per patch:
+ ``x,y,z,nx,ny,nz,area_mm2,pressure_kpa,shear_x_kpa,shear_y_kpa``.
+ A hardware driver or vendor bridge can publish this normalized topic without
+ HapticSight depending on one proprietary tactile SDK.
+ """
+ values = [float(v) for v in getattr(msg, "data", ())]
+ stride = 10
+ if len(values) % stride != 0:
+ raise ValueError("tactile Float32MultiArray length must be a multiple of 10")
+ now = time()
+ quality = SignalQuality(
+ source_mode=SignalSourceMode.LIVE,
+ health=SignalHealth.NOMINAL,
+ sample_timestamp_utc_s=now,
+ received_timestamp_utc_s=now,
+ source_name=source_id,
+ frame=frame,
+ note="ROS2 Float32MultiArray normalized tactile patches",
+ )
+ patches = []
+ for index in range(0, len(values), stride):
+ chunk = values[index:index + stride]
+ patches.append(make_tactile_patch(
+ patch_id=f"patch-{index // stride}",
+ location_xyz=chunk[0:3],
+ normal_xyz=chunk[3:6],
+ area_mm2=chunk[6],
+ pressure_kpa=chunk[7],
+ shear_xy_kpa=chunk[8:10],
+ ))
+ return TactileFrame(surface_name=surface_name, frame=frame, quality=quality, patches=tuple(patches))
diff --git a/src/ohip_ros2/trajectory_adapter.py b/src/ohip_ros2/trajectory_adapter.py
new file mode 100644
index 0000000..b839e1a
--- /dev/null
+++ b/src/ohip_ros2/trajectory_adapter.py
@@ -0,0 +1,88 @@
+"""Optional ROS 2 FollowJointTrajectory adapter for real-robot integration.
+
+This module intentionally separates *capability implementation* from *measured
+hardware evidence*. It can submit bounded joint trajectories to a standard ROS
+2 controller, but the repository does not claim that a physical robot has been
+run unless an external HIL evidence bundle is supplied.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Sequence
+
+
+class Ros2TrajectoryUnavailable(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True)
+class JointTrajectoryPointSpec:
+ positions: tuple[float, ...]
+ velocities: tuple[float, ...] = ()
+ time_from_start_s: float = 1.0
+
+
+@dataclass(frozen=True)
+class JointTrajectorySpec:
+ joint_names: tuple[str, ...]
+ points: tuple[JointTrajectoryPointSpec, ...]
+
+ def validate(self) -> None:
+ if not self.joint_names:
+ raise ValueError("joint_names must not be empty")
+ if not self.points:
+ raise ValueError("trajectory must contain at least one point")
+ n = len(self.joint_names)
+ previous = -1.0
+ for point in self.points:
+ if len(point.positions) != n:
+ raise ValueError("each positions vector must match joint_names")
+ if point.velocities and len(point.velocities) != n:
+ raise ValueError("each velocities vector must match joint_names")
+ if point.time_from_start_s <= previous:
+ raise ValueError("time_from_start_s must be strictly increasing")
+ previous = point.time_from_start_s
+
+
+class FollowJointTrajectoryClient:
+ """Thin action client around ``control_msgs/FollowJointTrajectory``."""
+
+ def __init__(self, *, action_name: str = "/joint_trajectory_controller/follow_joint_trajectory") -> None:
+ self.action_name = action_name
+ self._node = None
+ self._client = None
+ self._rclpy = None
+
+ def start(self, node_name: str = "ix_hapticsight_trajectory_client") -> None:
+ try:
+ import rclpy
+ from rclpy.action import ActionClient
+ from control_msgs.action import FollowJointTrajectory
+ except Exception as exc: # pragma: no cover
+ raise Ros2TrajectoryUnavailable("ROS2 control_msgs/rclpy not installed") from exc
+ if not rclpy.ok():
+ rclpy.init(args=None)
+ self._rclpy = rclpy
+ self._FollowJointTrajectory = FollowJointTrajectory
+ self._node = rclpy.create_node(node_name)
+ self._client = ActionClient(self._node, FollowJointTrajectory, self.action_name)
+
+ def submit(self, spec: JointTrajectorySpec, *, timeout_sec: float = 5.0):
+ spec.validate()
+ if self._client is None or self._node is None:
+ raise Ros2TrajectoryUnavailable("client has not been started")
+ if not self._client.wait_for_server(timeout_sec=float(timeout_sec)): # pragma: no cover
+ raise Ros2TrajectoryUnavailable(f"trajectory action server unavailable: {self.action_name}")
+ from trajectory_msgs.msg import JointTrajectoryPoint # pragma: no cover
+ goal = self._FollowJointTrajectory.Goal()
+ goal.trajectory.joint_names = list(spec.joint_names)
+ for point in spec.points:
+ ros_point = JointTrajectoryPoint()
+ ros_point.positions = list(point.positions)
+ ros_point.velocities = list(point.velocities)
+ sec = int(point.time_from_start_s)
+ nanosec = int((point.time_from_start_s - sec) * 1_000_000_000)
+ ros_point.time_from_start.sec = sec
+ ros_point.time_from_start.nanosec = nanosec
+ goal.trajectory.points.append(ros_point)
+ return self._client.send_goal_async(goal) # pragma: no cover
diff --git a/src/ohip_runtime/__init__.py b/src/ohip_runtime/__init__.py
index d7bbb70..3d5c1b7 100644
--- a/src/ohip_runtime/__init__.py
+++ b/src/ohip_runtime/__init__.py
@@ -39,4 +39,4 @@
"__version__",
]
-__version__ = "0.1.0"
+__version__ = "0.2.0"
diff --git a/src/ohip_runtime/coordinator.py b/src/ohip_runtime/coordinator.py
index 9f39f84..eb180f0 100644
--- a/src/ohip_runtime/coordinator.py
+++ b/src/ohip_runtime/coordinator.py
@@ -2,7 +2,7 @@
Runtime coordinator for IX-HapticSight.
This module provides the first real orchestration layer that sits above the
-protocol core in ``src/ohip`` and below any future ROS 2 or backend-specific
+protocol core in ``src/ohip`` and below any ROS 2 or backend-specific
transport layer.
The coordinator is intentionally conservative:
diff --git a/src/ohip_runtime/session_store.py b/src/ohip_runtime/session_store.py
index 5beac65..ba42967 100644
--- a/src/ohip_runtime/session_store.py
+++ b/src/ohip_runtime/session_store.py
@@ -5,7 +5,7 @@
It is intentionally simple and backend-agnostic so it can be reused by:
- local runtime coordinators
-- future ROS 2 wrappers
+- ROS 2 wrappers
- replay tools
- benchmark harnesses
- integration tests
diff --git a/src/ohip_sim/__init__.py b/src/ohip_sim/__init__.py
new file mode 100644
index 0000000..c66039b
--- /dev/null
+++ b/src/ohip_sim/__init__.py
@@ -0,0 +1,3 @@
+from .contact_world import ContactWorld, ContactWorldState
+
+__all__ = ["ContactWorld", "ContactWorldState"]
diff --git a/src/ohip_sim/contact_world.py b/src/ohip_sim/contact_world.py
new file mode 100644
index 0000000..d6bd082
--- /dev/null
+++ b/src/ohip_sim/contact_world.py
@@ -0,0 +1,52 @@
+"""Deterministic 1-D contact world for safety/controller regression tests.
+
+This is not claimed as physical validation. It provides a small executable
+closed-loop plant so force caps, contact detection, overshoot, sensor dropouts,
+and recovery can be regression-tested without replacing future HIL work.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass
+class ContactWorldState:
+ position_m: float = 0.0
+ velocity_mps: float = 0.0
+ measured_force_N: float = 0.0
+ sim_time_s: float = 0.0
+
+
+class ContactWorld:
+ def __init__(
+ self,
+ *,
+ surface_position_m: float = 0.10,
+ stiffness_N_per_m: float = 800.0,
+ damping_Ns_per_m: float = 12.0,
+ mass_kg: float = 1.0,
+ ) -> None:
+ self.surface_position_m = float(surface_position_m)
+ self.stiffness_N_per_m = float(stiffness_N_per_m)
+ self.damping_Ns_per_m = float(damping_Ns_per_m)
+ self.mass_kg = float(mass_kg)
+ self.state = ContactWorldState()
+
+ def reset(self) -> ContactWorldState:
+ self.state = ContactWorldState()
+ return self.state
+
+ def step(self, *, command_velocity_mps: float, force_cap_N: float, dt_s: float = 0.005) -> ContactWorldState:
+ dt = float(dt_s)
+ desired_velocity = float(command_velocity_mps)
+ accel = (desired_velocity - self.state.velocity_mps) * 25.0
+ self.state.velocity_mps += accel * dt
+ self.state.position_m += self.state.velocity_mps * dt
+ penetration = max(0.0, self.state.position_m - self.surface_position_m)
+ raw_force = self.stiffness_N_per_m * penetration + self.damping_Ns_per_m * max(0.0, self.state.velocity_mps)
+ self.state.measured_force_N = max(0.0, raw_force)
+ if self.state.measured_force_N > max(0.0, force_cap_N):
+ # Simulate local low-level limiter dissipating forward motion.
+ self.state.velocity_mps = min(0.0, self.state.velocity_mps)
+ self.state.sim_time_s += dt
+ return ContactWorldState(**self.state.__dict__)
diff --git a/src/ohip_xr/__init__.py b/src/ohip_xr/__init__.py
new file mode 100644
index 0000000..24dbb02
--- /dev/null
+++ b/src/ohip_xr/__init__.py
@@ -0,0 +1,4 @@
+from .server import XRStateStore, make_handler, serve
+from .state import XRHazardMarker, XRState
+
+__all__ = ["XRHazardMarker", "XRState", "XRStateStore", "make_handler", "serve"]
diff --git a/src/ohip_xr/server.py b/src/ohip_xr/server.py
new file mode 100644
index 0000000..6d8b675
--- /dev/null
+++ b/src/ohip_xr/server.py
@@ -0,0 +1,55 @@
+"""Dependency-free local server for the WebXR HapticSight observer."""
+from __future__ import annotations
+
+import json
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from threading import Lock
+from typing import Type
+
+from .state import XRState
+
+
+class XRStateStore:
+ def __init__(self, initial: XRState | None = None) -> None:
+ self._state = initial
+ self._lock = Lock()
+
+ def set(self, state: XRState) -> None:
+ with self._lock:
+ self._state = state
+
+ def get(self) -> XRState | None:
+ with self._lock:
+ return self._state
+
+
+def make_handler(web_root: str | Path, store: XRStateStore) -> Type[SimpleHTTPRequestHandler]:
+ root = str(Path(web_root).resolve())
+
+ class Handler(SimpleHTTPRequestHandler):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, directory=root, **kwargs)
+
+ def do_GET(self): # noqa: N802
+ if self.path == "/state.json":
+ state = store.get()
+ body = json.dumps({} if state is None else state.to_dict(), sort_keys=True).encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ return
+ super().do_GET()
+
+ def log_message(self, format, *args): # noqa: A002
+ return
+
+ return Handler
+
+
+def serve(web_root: str | Path, store: XRStateStore, *, host: str = "127.0.0.1", port: int = 8765) -> ThreadingHTTPServer:
+ server = ThreadingHTTPServer((host, int(port)), make_handler(web_root, store))
+ return server
diff --git a/src/ohip_xr/state.py b/src/ohip_xr/state.py
new file mode 100644
index 0000000..22af845
--- /dev/null
+++ b/src/ohip_xr/state.py
@@ -0,0 +1,46 @@
+"""XR observability payloads for hazard, intent, consent and force authority."""
+from __future__ import annotations
+
+from dataclasses import dataclass, asdict
+from typing import Iterable
+
+from ohip.schemas import SafetyLevel
+
+
+@dataclass(frozen=True)
+class XRHazardMarker:
+ marker_id: str
+ xyz_m: tuple[float, float, float]
+ size_m: float
+ level: SafetyLevel
+ label: str
+ confidence: float
+
+ def to_dict(self) -> dict:
+ doc = asdict(self)
+ doc["level"] = self.level.value
+ return doc
+
+
+@dataclass(frozen=True)
+class XRState:
+ session_id: str
+ consent_active: bool
+ safety_authority: str
+ force_cap_N: float
+ speed_cap_mps: float
+ controller_state: str
+ reason: str
+ markers: tuple[XRHazardMarker, ...] = ()
+
+ def to_dict(self) -> dict:
+ return {
+ "session_id": self.session_id,
+ "consent_active": self.consent_active,
+ "safety_authority": self.safety_authority,
+ "force_cap_N": self.force_cap_N,
+ "speed_cap_mps": self.speed_cap_mps,
+ "controller_state": self.controller_state,
+ "reason": self.reason,
+ "markers": [m.to_dict() for m in self.markers],
+ }
diff --git a/tests/test_agent_safety_broker.py b/tests/test_agent_safety_broker.py
new file mode 100644
index 0000000..9dcba56
--- /dev/null
+++ b/tests/test_agent_safety_broker.py
@@ -0,0 +1,51 @@
+import pytest
+
+from ohip.schemas import SafetyLevel
+from ohip_agent import AgentPhysicalProposal, AgentSafetyBroker
+from ohip_control import AuthorityDisposition, MultimodalSafetyInput
+
+
+def proposal(**updates):
+ d = dict(
+ proposal_id="p-1",
+ agent_id="vla:test",
+ action_kind="contact",
+ requested_force_N=8.0,
+ requested_speed_mps=.5,
+ base_force_cap_N=3.0,
+ base_speed_cap_mps=.1,
+ consent_required=True,
+ consent_active=True,
+ created_at_s=10.0,
+ rationale="agent says contact is useful",
+ )
+ d.update(updates)
+ return AgentPhysicalProposal(**d)
+
+
+def test_agent_broker_never_grants_more_than_deterministic_caps():
+ out = AgentSafetyBroker().decide(proposal(), MultimodalSafetyInput())
+ assert out.disposition == AuthorityDisposition.MODIFY
+ assert out.granted_force_N <= 3.0
+ assert out.granted_speed_mps <= .1
+ assert len(out.proposal_sha256) == 64
+
+
+def test_agent_broker_denies_red_sensor_fusion_even_if_agent_requests_contact():
+ out = AgentSafetyBroker().decide(
+ proposal(),
+ MultimodalSafetyInput(vision_level=SafetyLevel.RED),
+ )
+ assert out.disposition == AuthorityDisposition.DENY
+ assert out.granted_force_N == 0.0
+
+
+def test_agent_broker_denies_missing_consent():
+ out = AgentSafetyBroker().decide(proposal(consent_active=False), MultimodalSafetyInput())
+ assert out.disposition == AuthorityDisposition.DENY
+ assert "consent_missing" in out.reasons
+
+
+def test_agent_proposal_rejects_nan_instead_of_forwarding_it():
+ with pytest.raises(ValueError):
+ AgentSafetyBroker().decide(proposal(requested_force_N=float("nan")), MultimodalSafetyInput())
diff --git a/tests/test_authority_safety_properties.py b/tests/test_authority_safety_properties.py
new file mode 100644
index 0000000..8b3b1ab
--- /dev/null
+++ b/tests/test_authority_safety_properties.py
@@ -0,0 +1,37 @@
+import random
+
+from ohip.schemas import SafetyLevel
+from ohip_control import ActionProposal, AuthorityDisposition, IndependentSafetyAuthority
+
+
+def test_authority_never_expands_randomized_action_authority():
+ rng = random.Random(20260829)
+ authority = IndependentSafetyAuthority()
+ levels = [SafetyLevel.GREEN, SafetyLevel.YELLOW, SafetyLevel.RED]
+ for i in range(2000):
+ base_force = rng.uniform(0.0, 20.0)
+ base_speed = rng.uniform(0.0, 2.0)
+ requested_force = rng.uniform(0.0, 50.0)
+ requested_speed = rng.uniform(0.0, 5.0)
+ p = ActionProposal(
+ action_id=str(i),
+ requested_force_N=requested_force,
+ requested_speed_mps=requested_speed,
+ base_force_cap_N=base_force,
+ base_speed_cap_mps=base_speed,
+ safety_level=rng.choice(levels),
+ perception_uncertainty=rng.random(),
+ perception_quorum_ok=rng.choice([True, True, True, False]),
+ consent_required=rng.choice([True, False]),
+ consent_active=rng.choice([True, False]),
+ human_present=rng.choice([True, False]),
+ proximity_m=rng.uniform(0.0, 2.0),
+ )
+ out = authority.decide(p)
+ assert 0.0 <= out.granted_force_N <= base_force + 1e-9
+ assert 0.0 <= out.granted_speed_mps <= base_speed + 1e-9
+ assert out.granted_force_N <= requested_force + 1e-9
+ assert out.granted_speed_mps <= requested_speed + 1e-9
+ if out.disposition == AuthorityDisposition.DENY:
+ assert out.granted_force_N == 0.0
+ assert out.granted_speed_mps == 0.0
diff --git a/tests/test_contact_world.py b/tests/test_contact_world.py
new file mode 100644
index 0000000..14c64f1
--- /dev/null
+++ b/tests/test_contact_world.py
@@ -0,0 +1,22 @@
+from ohip_sim import ContactWorld
+
+
+def test_contact_world_generates_force_after_surface_contact():
+ world = ContactWorld(surface_position_m=.01)
+ state = None
+ for _ in range(100):
+ state = world.step(command_velocity_mps=.05, force_cap_N=10.0, dt_s=.005)
+ assert state is not None
+ assert state.measured_force_N > 0.0
+
+
+def test_contact_world_low_level_limiter_stops_forward_motion_over_cap():
+ world = ContactWorld(surface_position_m=.001, stiffness_N_per_m=5000)
+ state = None
+ for _ in range(50):
+ state = world.step(command_velocity_mps=.2, force_cap_N=.5, dt_s=.005)
+ if state.measured_force_N > .5:
+ break
+ assert state is not None
+ assert state.measured_force_N > .5
+ assert state.velocity_mps <= 0.0
diff --git a/tests/test_dynamic_envelope.py b/tests/test_dynamic_envelope.py
new file mode 100644
index 0000000..b7bafd2
--- /dev/null
+++ b/tests/test_dynamic_envelope.py
@@ -0,0 +1,27 @@
+from ohip.schemas import SafetyLevel
+from ohip_control import DynamicEnvelopeInput, DynamicSafetyEnvelope
+
+
+def test_red_hazard_denies_all_authority():
+ out = DynamicSafetyEnvelope().evaluate(
+ DynamicEnvelopeInput(2.0, .1, 3.0, .2, safety_level=SafetyLevel.RED)
+ )
+ assert not out.allowed
+ assert out.force_command_N == 0.0
+
+
+def test_human_proximity_derates_authority():
+ out = DynamicSafetyEnvelope().evaluate(
+ DynamicEnvelopeInput(
+ requested_force_N=3.0,
+ requested_speed_mps=.2,
+ base_force_cap_N=3.0,
+ base_speed_cap_mps=.2,
+ safety_level=SafetyLevel.GREEN,
+ human_present=True,
+ proximity_m=.4,
+ )
+ )
+ assert out.allowed
+ assert 0.0 < out.authority_scale < 1.0
+ assert out.force_command_N < 3.0
diff --git a/tests/test_evidence_chain.py b/tests/test_evidence_chain.py
new file mode 100644
index 0000000..c9635bd
--- /dev/null
+++ b/tests/test_evidence_chain.py
@@ -0,0 +1,26 @@
+import json
+from dataclasses import replace
+
+from ohip_evidence import EvidenceBundle, EvidenceChain, verify_records
+
+
+def test_chain_verifies_and_detects_tamper(tmp_path):
+ chain = EvidenceChain()
+ chain.append("proposal", {"force_N": 4.0}, timestamp_s=1.0)
+ chain.append("decision", {"force_N": 2.0}, timestamp_s=2.0)
+ assert chain.verify() == (True, "ok")
+ records = list(chain.records)
+ records[0] = replace(records[0], payload={"force_N": 40.0})
+ ok, reason = verify_records(records)
+ assert not ok
+ assert reason.startswith("record_hash_mismatch")
+
+
+def test_bundle_roundtrip_and_digest_detection(tmp_path):
+ chain = EvidenceChain()
+ chain.append("a", {"x": 1}, timestamp_s=1.0)
+ EvidenceBundle.write(tmp_path, chain.records, provenance={"mode": "test"})
+ assert EvidenceBundle.verify(tmp_path) == (True, "ok")
+ p = tmp_path / "records.jsonl"
+ p.write_text(p.read_text() + "{}\n")
+ assert EvidenceBundle.verify(tmp_path)[0] is False
diff --git a/tests/test_hazard_map.py b/tests/test_hazard_map.py
new file mode 100644
index 0000000..3c7d251
--- /dev/null
+++ b/tests/test_hazard_map.py
@@ -0,0 +1,33 @@
+from ohip.schemas import HazardClass, SafetyLevel
+from ohip_perception import (
+ PerceptionFrame,
+ SegmentationPixel,
+ SegmentationResult,
+ SemanticClass,
+ VisionHazardProjector,
+)
+
+
+def test_hot_pixel_becomes_red_hot_voxel():
+ frame = PerceptionFrame(rgb=[[(255, 0, 0)]], depth_m=[[1.0]], timestamp_s=1.0)
+ seg = SegmentationResult(
+ pixels=[[SegmentationPixel(SemanticClass.HOT, 0.99, 0.01)]],
+ model_name="test",
+ timestamp_s=1.0,
+ )
+ voxels = VisionHazardProjector().project(frame, seg)
+ assert len(voxels) == 1
+ assert voxels[0].level == SafetyLevel.RED
+ assert voxels[0].hazard_class == HazardClass.HOT
+
+
+def test_low_confidence_fails_closed_to_red_unknown():
+ frame = PerceptionFrame(rgb=[[(0, 0, 0)]], depth_m=[[1.0]], timestamp_s=1.0)
+ seg = SegmentationResult(
+ pixels=[[SegmentationPixel(SemanticClass.BACKGROUND, 0.20, 0.80)]],
+ model_name="test",
+ timestamp_s=1.0,
+ )
+ voxels = VisionHazardProjector(min_confidence=0.55).project(frame, seg)
+ assert voxels[0].level == SafetyLevel.RED
+ assert voxels[0].hazard_class == HazardClass.UNKNOWN
diff --git a/tests/test_hil_harness.py b/tests/test_hil_harness.py
new file mode 100644
index 0000000..f38b364
--- /dev/null
+++ b/tests/test_hil_harness.py
@@ -0,0 +1,27 @@
+from ohip_hil import HILAcceptanceCriteria, HILHarness, HILSample, HILStatus, HardwareCapability
+
+
+def test_hil_refuses_to_fake_pass_without_hardware():
+ harness = HILHarness()
+ out = harness.run(
+ probe=lambda: [HardwareCapability("force_torque", False)],
+ executor=lambda: [],
+ )
+ assert out.status == HILStatus.NOT_RUN_NO_HARDWARE
+
+
+def test_hil_can_pass_only_with_declared_hardware_and_measured_samples():
+ criteria = HILAcceptanceCriteria(max_force_N=3.5, max_latency_ms=25, min_samples=3)
+ harness = HILHarness(criteria)
+ out = harness.run(
+ probe=lambda: [
+ HardwareCapability("robot_motion", True, "robot-1"),
+ HardwareCapability("force_torque", True, "ft-1"),
+ ],
+ executor=lambda: [
+ HILSample(1.0, 1.0, 3.0),
+ HILSample(1.1, 2.0, 4.0),
+ HILSample(1.2, 3.0, 5.0),
+ ],
+ )
+ assert out.status == HILStatus.PASSED
diff --git a/tests/test_independent_safety_authority.py b/tests/test_independent_safety_authority.py
new file mode 100644
index 0000000..dcb9743
--- /dev/null
+++ b/tests/test_independent_safety_authority.py
@@ -0,0 +1,41 @@
+from ohip.schemas import SafetyLevel
+from ohip_control import ActionProposal, AuthorityDisposition, IndependentSafetyAuthority
+
+
+def proposal(**kw):
+ base = dict(
+ action_id="a",
+ requested_force_N=2.0,
+ requested_speed_mps=.1,
+ base_force_cap_N=3.0,
+ base_speed_cap_mps=.2,
+ safety_level=SafetyLevel.GREEN,
+ perception_uncertainty=.05,
+ perception_quorum_ok=True,
+ consent_required=True,
+ consent_active=True,
+ human_present=False,
+ proximity_m=None,
+ )
+ base.update(kw)
+ return ActionProposal(**base)
+
+
+def test_authority_denies_without_consent():
+ out = IndependentSafetyAuthority().decide(proposal(consent_active=False))
+ assert out.disposition == AuthorityDisposition.DENY
+ assert out.granted_force_N == 0.0
+ assert "consent" in out.reason
+
+
+def test_authority_denies_model_disagreement():
+ out = IndependentSafetyAuthority().decide(proposal(perception_quorum_ok=False))
+ assert out.disposition == AuthorityDisposition.DENY
+ assert out.reason == "perception_quorum_failed"
+
+
+def test_authority_modifies_excessive_request():
+ out = IndependentSafetyAuthority().decide(proposal(requested_force_N=9.0, requested_speed_mps=1.0))
+ assert out.disposition == AuthorityDisposition.MODIFY
+ assert out.granted_force_N <= 3.0
+ assert out.granted_speed_mps <= .2
diff --git a/tests/test_multimodal_safety_fusion.py b/tests/test_multimodal_safety_fusion.py
new file mode 100644
index 0000000..3788ba7
--- /dev/null
+++ b/tests/test_multimodal_safety_fusion.py
@@ -0,0 +1,40 @@
+from ohip.schemas import SafetyLevel
+from ohip_control import MultimodalSafetyFusion, MultimodalSafetyInput
+from ohip_interfaces.force_torque import ContactForceAssessment
+from ohip_interfaces.proximity import ProximityAssessment
+from ohip_interfaces.tactile import TactileContactAssessment
+from ohip_interfaces.thermal import ThermalAssessment
+
+
+def test_multimodal_nominal_stays_green():
+ out = MultimodalSafetyFusion().evaluate(MultimodalSafetyInput())
+ assert out.level == SafetyLevel.GREEN
+
+
+def test_missing_required_sensor_fails_closed():
+ out = MultimodalSafetyFusion().evaluate(MultimodalSafetyInput(require_force=True))
+ assert out.level == SafetyLevel.RED
+ assert out.missing_required_modalities == ("force",)
+
+
+def test_excessive_force_overrides_green_vision():
+ force = ContactForceAssessment(True, True, 4.0, .1, .25, 3.5)
+ out = MultimodalSafetyFusion().evaluate(MultimodalSafetyInput(force=force))
+ assert out.level == SafetyLevel.RED
+ assert "force_excessive" in out.reasons
+
+
+def test_multiple_caution_modalities_produce_yellow():
+ prox = ProximityAssessment(True, True, True, 1, 80.0, 120.0, 40.0)
+ thermal = ThermalAssessment(True, False, 1, 40.0, 38.0, 45.0)
+ tactile = TactileContactAssessment(True, False, 1, 20.0, 2.0, .2, False, False)
+ out = MultimodalSafetyFusion().evaluate(
+ MultimodalSafetyInput(proximity=prox, thermal=thermal, tactile=tactile)
+ )
+ assert out.level == SafetyLevel.YELLOW
+ assert len(out.available_modalities) == 3
+
+
+def test_perception_quorum_failure_is_red_even_if_sensors_nominal():
+ out = MultimodalSafetyFusion().evaluate(MultimodalSafetyInput(perception_quorum_ok=False))
+ assert out.level == SafetyLevel.RED
diff --git a/tests/test_perception_quorum.py b/tests/test_perception_quorum.py
new file mode 100644
index 0000000..b65a476
--- /dev/null
+++ b/tests/test_perception_quorum.py
@@ -0,0 +1,23 @@
+from ohip_perception import PerceptionQuorum, SegmentationPixel, SegmentationResult, SemanticClass
+
+
+def result(cls, conf=0.9):
+ return SegmentationResult(
+ pixels=[[SegmentationPixel(cls, conf, 1-conf)]],
+ model_name="m",
+ timestamp_s=1.0,
+ )
+
+
+def test_quorum_passes_matching_models():
+ q = PerceptionQuorum(min_agreement=1.0, max_critical_disagreement=0.0)
+ out = q.compare(result(SemanticClass.PERSON), result(SemanticClass.PERSON))
+ assert out.passed
+ assert out.reason == "quorum_ok"
+
+
+def test_quorum_fails_critical_disagreement():
+ q = PerceptionQuorum(min_agreement=0.0, max_critical_disagreement=0.0)
+ out = q.compare(result(SemanticClass.PERSON), result(SemanticClass.BACKGROUND))
+ assert not out.passed
+ assert out.critical_disagreement_ratio == 1.0
diff --git a/tests/test_perception_segmentation.py b/tests/test_perception_segmentation.py
new file mode 100644
index 0000000..e66f88d
--- /dev/null
+++ b/tests/test_perception_segmentation.py
@@ -0,0 +1,42 @@
+from pathlib import Path
+
+import pytest
+
+from ohip_perception import PerceptionFrame, ReferenceSegmenter, SemanticClass
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def segmenter(name="reference_segmenter_primary.json"):
+ return ReferenceSegmenter.from_file(ROOT / "models" / name)
+
+
+def frame(rgb, depth=1.0):
+ return PerceptionFrame(
+ rgb=[[rgb]],
+ depth_m=[[depth]],
+ timestamp_s=1.0,
+ )
+
+
+@pytest.mark.parametrize(
+ "rgb,depth,expected",
+ [
+ ((46, 46, 51), 3.2, SemanticClass.BACKGROUND),
+ ((178, 122, 94), 1.4, SemanticClass.PERSON),
+ ((107, 117, 110), 1.9, SemanticClass.OBJECT),
+ ((235, 64, 31), 1.7, SemanticClass.HOT),
+ ((31, 97, 219), 1.8, SemanticClass.LIQUID),
+ ((184, 186, 191), 1.2, SemanticClass.SHARP),
+ ],
+)
+def test_reference_segmenter_classifies_calibration_prototypes(rgb, depth, expected):
+ result = segmenter().infer(frame(rgb, depth))
+ assert result.pixels[0][0].semantic_class == expected
+ assert 0.0 <= result.pixels[0][0].confidence <= 1.0
+
+
+def test_frame_rejects_bad_shape():
+ f = PerceptionFrame(rgb=[[(0, 0, 0)]], depth_m=[], timestamp_s=1.0)
+ with pytest.raises(ValueError):
+ f.validate()
diff --git a/tests/test_realtime_controller.py b/tests/test_realtime_controller.py
new file mode 100644
index 0000000..753473f
--- /dev/null
+++ b/tests/test_realtime_controller.py
@@ -0,0 +1,50 @@
+from ohip_control import BoundedRealtimeController, ControllerInput, ControllerState
+
+
+def inp(**kw):
+ base = dict(
+ requested_speed_mps=.05,
+ requested_force_N=1.0,
+ measured_force_N=.2,
+ force_cap_N=2.0,
+ speed_cap_mps=.1,
+ sensor_age_ms=2.0,
+ consent_active=True,
+ contact_requested=True,
+ perception_quorum_ok=True,
+ e_stop=False,
+ contact_detected=False,
+ )
+ base.update(kw)
+ return ControllerInput(**base)
+
+
+def test_controller_clamps_request_inside_envelope():
+ c = BoundedRealtimeController(target_period_ms=50)
+ out = c.step(inp(requested_force_N=5.0, requested_speed_mps=.5))
+ assert out.command_force_N == 2.0
+ assert out.command_speed_mps == .1
+ assert not out.stop
+
+
+def test_controller_latches_on_measured_overforce():
+ c = BoundedRealtimeController(target_period_ms=50)
+ out = c.step(inp(measured_force_N=2.5, force_cap_N=2.0, contact_detected=True))
+ assert out.stop
+ assert out.latched
+ assert out.state == ControllerState.FAULTED
+ assert out.command_force_N == 0.0
+
+
+def test_controller_latches_when_consent_disappears_during_contact_request():
+ c = BoundedRealtimeController(target_period_ms=50)
+ out = c.step(inp(consent_active=False))
+ assert out.latched
+ assert out.reason == "consent_lost"
+
+
+def test_controller_stops_on_perception_disagreement():
+ c = BoundedRealtimeController(target_period_ms=50)
+ out = c.step(inp(perception_quorum_ok=False))
+ assert out.stop
+ assert out.reason == "perception_quorum_failed"
diff --git a/tests/test_reference_model_reproducibility.py b/tests/test_reference_model_reproducibility.py
new file mode 100644
index 0000000..f250084
--- /dev/null
+++ b/tests/test_reference_model_reproducibility.py
@@ -0,0 +1,20 @@
+import subprocess
+import sys
+from pathlib import Path
+
+
+def test_reference_models_retrain_byte_for_byte(tmp_path):
+ root = Path(__file__).resolve().parents[1]
+ subprocess.run(
+ [sys.executable, str(root / "scripts/train_reference_segmenter.py"), "--output-dir", str(tmp_path)],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ for name in (
+ "reference_segmenter_primary.json",
+ "reference_segmenter_primary.metrics.json",
+ "reference_segmenter_secondary.json",
+ "reference_segmenter_secondary.metrics.json",
+ ):
+ assert (tmp_path / name).read_bytes() == (root / "models" / name).read_bytes()
diff --git a/tests/test_ros2_messages.py b/tests/test_ros2_messages.py
new file mode 100644
index 0000000..88b9b3e
--- /dev/null
+++ b/tests/test_ros2_messages.py
@@ -0,0 +1,20 @@
+from types import SimpleNamespace
+
+from ohip_ros2 import wrench_stamped_to_sample
+
+
+def test_wrench_stamped_converter_without_ros_runtime():
+ msg = SimpleNamespace(
+ header=SimpleNamespace(
+ frame_id="tool0",
+ stamp=SimpleNamespace(sec=10, nanosec=500_000_000),
+ ),
+ wrench=SimpleNamespace(
+ force=SimpleNamespace(x=1.0, y=2.0, z=2.0),
+ torque=SimpleNamespace(x=.1, y=.2, z=.2),
+ ),
+ )
+ sample = wrench_stamped_to_sample(msg)
+ assert sample.frame == "tool0"
+ assert sample.force_magnitude_N() == 3.0
+ assert sample.quality.source_name == "ros2_wrench"
diff --git a/tests/test_ros2_tactile.py b/tests/test_ros2_tactile.py
new file mode 100644
index 0000000..7b1b20b
--- /dev/null
+++ b/tests/test_ros2_tactile.py
@@ -0,0 +1,24 @@
+from types import SimpleNamespace
+
+import pytest
+
+from ohip_ros2 import tactile_multiarray_to_frame
+
+
+def test_tactile_multiarray_converts_real_transport_contract():
+ msg = SimpleNamespace(data=[
+ 0.0, 0.0, 0.0,
+ 0.0, 0.0, 1.0,
+ 25.0, 3.0,
+ 0.2, 0.1,
+ ])
+ frame = tactile_multiarray_to_frame(msg)
+ assert frame.patch_count() == 1
+ assert frame.patches[0].pressure_kpa == 3.0
+ assert frame.patches[0].area_mm2 == 25.0
+ assert frame.quality.source_name == "ros2_tactile"
+
+
+def test_tactile_multiarray_rejects_malformed_payload():
+ with pytest.raises(ValueError):
+ tactile_multiarray_to_frame(SimpleNamespace(data=[1.0, 2.0]))
diff --git a/tests/test_ros2_trajectory.py b/tests/test_ros2_trajectory.py
new file mode 100644
index 0000000..32edc90
--- /dev/null
+++ b/tests/test_ros2_trajectory.py
@@ -0,0 +1,26 @@
+import pytest
+
+from ohip_ros2 import JointTrajectoryPointSpec, JointTrajectorySpec
+
+
+def test_joint_trajectory_spec_validation():
+ spec = JointTrajectorySpec(
+ joint_names=("j1", "j2"),
+ points=(
+ JointTrajectoryPointSpec((0.0, 0.0), time_from_start_s=.5),
+ JointTrajectoryPointSpec((.1, -.1), (.2, -.2), time_from_start_s=1.0),
+ ),
+ )
+ spec.validate()
+
+
+def test_joint_trajectory_rejects_non_monotonic_time():
+ spec = JointTrajectorySpec(
+ joint_names=("j1",),
+ points=(
+ JointTrajectoryPointSpec((0.0,), time_from_start_s=1.0),
+ JointTrajectoryPointSpec((.1,), time_from_start_s=.5),
+ ),
+ )
+ with pytest.raises(ValueError):
+ spec.validate()
diff --git a/tests/test_ros2_unavailable_is_explicit.py b/tests/test_ros2_unavailable_is_explicit.py
new file mode 100644
index 0000000..fe8532b
--- /dev/null
+++ b/tests/test_ros2_unavailable_is_explicit.py
@@ -0,0 +1,9 @@
+import pytest
+
+from ohip_ros2 import Ros2ContactBridge, Ros2Unavailable
+
+
+def test_ros2_bridge_does_not_fake_runtime_when_rclpy_missing():
+ bridge = Ros2ContactBridge()
+ with pytest.raises(Ros2Unavailable):
+ bridge.start()
diff --git a/tests/test_safety_authority_benchmark.py b/tests/test_safety_authority_benchmark.py
new file mode 100644
index 0000000..5c1cd5c
--- /dev/null
+++ b/tests/test_safety_authority_benchmark.py
@@ -0,0 +1,7 @@
+from ohip_bench.safety_authority import run_authority_benchmark
+
+
+def test_all_independent_safety_authority_scenarios_pass():
+ results = run_authority_benchmark()
+ assert len(results) >= 8
+ assert all(r.passed for r in results)
diff --git a/tests/test_vision_pipeline.py b/tests/test_vision_pipeline.py
new file mode 100644
index 0000000..7ea91e3
--- /dev/null
+++ b/tests/test_vision_pipeline.py
@@ -0,0 +1,21 @@
+from pathlib import Path
+
+from ohip_perception import PerceptionFrame, ReferenceSegmenter, VisionPipeline
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_two_model_pipeline_reaches_quorum_on_calibration_scene():
+ primary = ReferenceSegmenter.from_file(ROOT / "models/reference_segmenter_primary.json")
+ secondary = ReferenceSegmenter.from_file(ROOT / "models/reference_segmenter_secondary.json")
+ pipeline = VisionPipeline(primary, secondary=secondary)
+ f = PerceptionFrame(
+ rgb=[[(178, 122, 94), (235, 64, 31)]],
+ depth_m=[[1.4, 1.7]],
+ timestamp_s=1.0,
+ )
+ out = pipeline.process(f)
+ assert out.agreement is not None
+ assert out.agreement.passed
+ assert out.safe_for_autonomy
+ assert any(v.level.value == "RED" for v in out.hazards)
diff --git a/tests/test_xr_state.py b/tests/test_xr_state.py
new file mode 100644
index 0000000..1e46421
--- /dev/null
+++ b/tests/test_xr_state.py
@@ -0,0 +1,31 @@
+from pathlib import Path
+
+from ohip.schemas import SafetyLevel
+from ohip_xr import XRHazardMarker, XRState
+
+
+def test_xr_payload_exposes_safety_authority_and_markers():
+ state = XRState(
+ session_id="s",
+ consent_active=True,
+ safety_authority="DENY",
+ force_cap_N=0.0,
+ speed_cap_mps=0.0,
+ controller_state="SAFE_HOLD",
+ reason="red_hazard",
+ markers=(XRHazardMarker("h", (0,0,1), .1, SafetyLevel.RED, "hot", .9),),
+ )
+ doc = state.to_dict()
+ assert doc["markers"][0]["level"] == "RED"
+ assert doc["safety_authority"] == "DENY"
+
+
+def test_webxr_client_is_shipped():
+ root = Path(__file__).resolve().parents[1]
+ text = (root / "examples/webxr/index.html").read_text(encoding="utf-8")
+ assert "navigator.xr" in text
+ assert "immersive-ar" in text
+ assert "XRWebGLLayer" in text
+ assert "session.requestAnimationFrame" in text
+ assert "view.projectionMatrix" in text
+ assert "/state.json" in text