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