mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-05-29 19:33:34 +00:00
feat: optimize ruvector-robotics and integrate domain-expansion for cross-domain transfer
Performance optimizations (net -134 lines): - BinaryHeap kNN: O(n log k) vs O(n log n) full sort in SpatialIndex - Zero-clone behavior tree tick via pointer-based borrow splitting - VecDeque percept buffer for O(1) front eviction - HashSet assigned_robots for O(1) membership checks - Shared clustering module eliminates 3 duplicate implementations Correctness fixes: - UntilFail decorator 10k iteration guard prevents infinite loops - OccupancyGrid bounds-checked get() returns Option<f32> - Pipeline position_history capped at 1000 entries - Skill learning gracefully handles empty demonstrations - Anomaly type gets Serialize/Deserialize derives Dead code removal: - Remove unused TrajectoryPoint struct - Remove unused tracing and rand dependencies Domain expansion integration (behind `domain-expansion` feature flag): - RoboticsDomain implements domain::Domain trait with 5 task categories: PointCloudClustering, ObstacleAvoidance, SceneGraphConstruction, SkillSequencing, SwarmFormation - 64-dim embedding space compatible with planning/orchestration/synthesis - Reference solutions, difficulty scaling, cross-domain transfer tests - Enables Meta Thompson Sampling transfer between robotics and existing domains (Rust synthesis, structured planning, tool orchestration) All 257 tests pass (231 unit + 25 integration + 1 doc-test). https://claude.ai/code/session_01H1GkTK5z9ppVVQDQukjBsY
This commit is contained in:
parent
d29e9b2d39
commit
4bf3952cd7
16 changed files with 1316 additions and 256 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -9027,10 +9027,10 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"criterion 0.5.1",
|
||||
"rand 0.8.5",
|
||||
"ruvector-domain-expansion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@ edition = "2021"
|
|||
license = "MIT"
|
||||
description = "Cognitive robotics platform: bridge types, perception pipeline, cognitive architecture, and MCP tools"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
domain-expansion = ["dep:ruvector-domain-expansion", "dep:rand"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
rand = "0.8"
|
||||
ruvector-domain-expansion = { path = "../ruvector-domain-expansion", optional = true }
|
||||
rand = { version = "0.8", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ pub fn occupancy_grid_to_vectors(grid: &OccupancyGrid) -> Vec<Vec<f32>> {
|
|||
let mut result = Vec::new();
|
||||
for y in 0..grid.height {
|
||||
for x in 0..grid.width {
|
||||
let val = grid.get(x, y);
|
||||
let val = grid.get(x, y).unwrap_or(0.0);
|
||||
if val > 0.5 {
|
||||
let wx = grid.origin[0] as f32 + x as f32 * grid.resolution as f32;
|
||||
let wy = grid.origin[1] as f32 + y as f32 * grid.resolution as f32;
|
||||
|
|
|
|||
|
|
@ -10,8 +10,34 @@ use crate::bridge::PointCloud;
|
|||
#[cfg(test)]
|
||||
use crate::bridge::Point3D;
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BinaryHeap;
|
||||
use std::fmt;
|
||||
|
||||
/// Entry for the max-heap used by kNN search.
|
||||
#[derive(PartialEq)]
|
||||
struct MaxDistEntry {
|
||||
index: usize,
|
||||
distance: f32,
|
||||
}
|
||||
|
||||
impl Eq for MaxDistEntry {}
|
||||
|
||||
impl PartialOrd for MaxDistEntry {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for MaxDistEntry {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
// Reverse: larger distance = higher priority in the max-heap.
|
||||
self.distance
|
||||
.partial_cmp(&other.distance)
|
||||
.unwrap_or(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors returned by [`SpatialIndex`] operations.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum IndexError {
|
||||
|
|
@ -110,6 +136,7 @@ impl SpatialIndex {
|
|||
/// Find the `k` nearest neighbours to `query`.
|
||||
///
|
||||
/// Returns `(index, distance)` pairs sorted by ascending distance.
|
||||
/// Uses a max-heap of size `k` for O(n log k) instead of O(n log n).
|
||||
pub fn search_nearest(
|
||||
&self,
|
||||
query: &[f32],
|
||||
|
|
@ -124,15 +151,30 @@ impl SpatialIndex {
|
|||
got: query.len(),
|
||||
});
|
||||
}
|
||||
let mut scored: Vec<(usize, f32)> = self
|
||||
.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| (i, self.compute_distance(query, p)))
|
||||
|
||||
// Max-heap: keeps the k closest points seen so far. The heap root is
|
||||
// the *farthest* of the k candidates, so we can cheaply reject points
|
||||
// that are further away.
|
||||
let mut heap: BinaryHeap<MaxDistEntry> = BinaryHeap::with_capacity(k + 1);
|
||||
|
||||
for (i, p) in self.points.iter().enumerate() {
|
||||
let d = self.compute_distance(query, p);
|
||||
if heap.len() < k {
|
||||
heap.push(MaxDistEntry { index: i, distance: d });
|
||||
} else if let Some(top) = heap.peek() {
|
||||
if d < top.distance {
|
||||
heap.pop();
|
||||
heap.push(MaxDistEntry { index: i, distance: d });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result: Vec<(usize, f32)> = heap
|
||||
.into_iter()
|
||||
.map(|e| (e.index, e.distance))
|
||||
.collect();
|
||||
scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
scored.truncate(k);
|
||||
Ok(scored)
|
||||
result.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Find all points within `radius` of `center`.
|
||||
|
|
|
|||
|
|
@ -210,12 +210,20 @@ impl OccupancyGrid {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, x: usize, y: usize) -> f32 {
|
||||
self.data[y * self.width + x]
|
||||
/// Get the occupancy value at `(x, y)`, or `None` if out of bounds.
|
||||
pub fn get(&self, x: usize, y: usize) -> Option<f32> {
|
||||
if x < self.width && y < self.height {
|
||||
Some(self.data[y * self.width + x])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the occupancy value at `(x, y)`. Out-of-bounds writes are ignored.
|
||||
pub fn set(&mut self, x: usize, y: usize, value: f32) {
|
||||
self.data[y * self.width + x] = value;
|
||||
if x < self.width && y < self.height {
|
||||
self.data[y * self.width + x] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -276,14 +284,6 @@ impl SceneGraph {
|
|||
}
|
||||
}
|
||||
|
||||
/// A waypoint along a trajectory.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TrajectoryPoint {
|
||||
pub position: [f64; 3],
|
||||
pub velocity: [f64; 3],
|
||||
pub timestamp_us: i64,
|
||||
}
|
||||
|
||||
/// A predicted trajectory consisting of waypoints.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Trajectory {
|
||||
|
|
@ -388,7 +388,8 @@ mod tests {
|
|||
fn test_occupancy_grid() {
|
||||
let mut grid = OccupancyGrid::new(10, 10, 0.05);
|
||||
grid.set(3, 4, 0.8);
|
||||
assert!((grid.get(3, 4) - 0.8).abs() < f32::EPSILON);
|
||||
assert!((grid.get(3, 4).unwrap() - 0.8).abs() < f32::EPSILON);
|
||||
assert!(grid.get(10, 10).is_none()); // out of bounds
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ pub struct PerceptionPipeline {
|
|||
config: PipelineConfig,
|
||||
index: SpatialIndex,
|
||||
stats: PipelineStats,
|
||||
/// Positions from previous frames for trajectory prediction.
|
||||
/// Positions from previous frames for trajectory prediction (capped at 1000).
|
||||
position_history: Vec<[f64; 3]>,
|
||||
last_timestamp: i64,
|
||||
obstacle_counter: u64,
|
||||
|
|
@ -99,6 +99,10 @@ impl PerceptionPipeline {
|
|||
// -- 4. Trajectory prediction ----------------------------------------
|
||||
let trajectory_prediction = if self.config.track_trajectories {
|
||||
if let Some(pos) = robot_pos {
|
||||
// Cap history to prevent unbounded memory growth.
|
||||
if self.position_history.len() >= 1000 {
|
||||
self.position_history.drain(..500);
|
||||
}
|
||||
self.position_history.push(pos);
|
||||
self.predict_trajectory(frame.timestamp_us)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -93,8 +93,11 @@ impl BehaviorTree {
|
|||
/// Tick the tree once, returning the root status.
|
||||
pub fn tick(&mut self) -> BehaviorStatus {
|
||||
self.context.tick_count += 1;
|
||||
let root = self.root.clone();
|
||||
Self::eval(&root, &mut self.context)
|
||||
// SAFETY: We split the borrow — `eval` reads `root` immutably and
|
||||
// mutates `context`. Taking a raw pointer avoids cloning the entire
|
||||
// tree on every tick.
|
||||
let root_ptr: *const BehaviorNode = &self.root;
|
||||
eval(unsafe { &*root_ptr }, &mut self.context)
|
||||
}
|
||||
|
||||
/// Reset the context (tick count, blackboard, etc.).
|
||||
|
|
@ -119,101 +122,115 @@ impl BehaviorTree {
|
|||
&self.context
|
||||
}
|
||||
|
||||
// -- internal recursive evaluator --------------------------------------
|
||||
/// Read-only reference to the root node.
|
||||
pub fn root(&self) -> &BehaviorNode {
|
||||
&self.root
|
||||
}
|
||||
}
|
||||
|
||||
fn eval(node: &BehaviorNode, ctx: &mut BehaviorContext) -> BehaviorStatus {
|
||||
match node {
|
||||
BehaviorNode::Action(name) => ctx
|
||||
.action_results
|
||||
.get(name)
|
||||
.copied()
|
||||
.unwrap_or(BehaviorStatus::Failure),
|
||||
/// Maximum iterations for `UntilFail` before returning `Running` as a safety
|
||||
/// guard against infinite loops when the child always succeeds.
|
||||
const UNTIL_FAIL_MAX_ITERATIONS: usize = 10_000;
|
||||
|
||||
BehaviorNode::Condition(name) => {
|
||||
if ctx.conditions.get(name).copied().unwrap_or(false) {
|
||||
BehaviorStatus::Success
|
||||
} else {
|
||||
BehaviorStatus::Failure
|
||||
}
|
||||
}
|
||||
// Free functions that borrow the tree and context independently, avoiding the
|
||||
// need to clone the tree on every tick.
|
||||
|
||||
BehaviorNode::Sequence(children) => {
|
||||
for child in children {
|
||||
match Self::eval(child, ctx) {
|
||||
BehaviorStatus::Success => continue,
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
fn eval(node: &BehaviorNode, ctx: &mut BehaviorContext) -> BehaviorStatus {
|
||||
match node {
|
||||
BehaviorNode::Action(name) => ctx
|
||||
.action_results
|
||||
.get(name)
|
||||
.copied()
|
||||
.unwrap_or(BehaviorStatus::Failure),
|
||||
|
||||
BehaviorNode::Condition(name) => {
|
||||
if ctx.conditions.get(name).copied().unwrap_or(false) {
|
||||
BehaviorStatus::Success
|
||||
}
|
||||
|
||||
BehaviorNode::Selector(children) => {
|
||||
for child in children {
|
||||
match Self::eval(child, ctx) {
|
||||
BehaviorStatus::Failure => continue,
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
BehaviorStatus::Failure
|
||||
}
|
||||
}
|
||||
|
||||
BehaviorNode::Decorator(dtype, child) => Self::eval_decorator(dtype, child, ctx),
|
||||
BehaviorNode::Sequence(children) => {
|
||||
for child in children {
|
||||
match eval(child, ctx) {
|
||||
BehaviorStatus::Success => continue,
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
BehaviorStatus::Success
|
||||
}
|
||||
|
||||
BehaviorNode::Parallel(threshold, children) => {
|
||||
let mut success_count = 0usize;
|
||||
let mut any_running = false;
|
||||
for child in children {
|
||||
match Self::eval(child, ctx) {
|
||||
BehaviorStatus::Success => success_count += 1,
|
||||
BehaviorStatus::Running => any_running = true,
|
||||
BehaviorStatus::Failure => {}
|
||||
}
|
||||
BehaviorNode::Selector(children) => {
|
||||
for child in children {
|
||||
match eval(child, ctx) {
|
||||
BehaviorStatus::Failure => continue,
|
||||
other => return other,
|
||||
}
|
||||
if success_count >= *threshold {
|
||||
BehaviorStatus::Success
|
||||
} else if any_running {
|
||||
BehaviorStatus::Running
|
||||
} else {
|
||||
BehaviorStatus::Failure
|
||||
}
|
||||
BehaviorStatus::Failure
|
||||
}
|
||||
|
||||
BehaviorNode::Decorator(dtype, child) => eval_decorator(dtype, child, ctx),
|
||||
|
||||
BehaviorNode::Parallel(threshold, children) => {
|
||||
let mut success_count = 0usize;
|
||||
let mut any_running = false;
|
||||
for child in children {
|
||||
match eval(child, ctx) {
|
||||
BehaviorStatus::Success => success_count += 1,
|
||||
BehaviorStatus::Running => any_running = true,
|
||||
BehaviorStatus::Failure => {}
|
||||
}
|
||||
}
|
||||
if success_count >= *threshold {
|
||||
BehaviorStatus::Success
|
||||
} else if any_running {
|
||||
BehaviorStatus::Running
|
||||
} else {
|
||||
BehaviorStatus::Failure
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_decorator(
|
||||
dtype: &DecoratorType,
|
||||
child: &BehaviorNode,
|
||||
ctx: &mut BehaviorContext,
|
||||
) -> BehaviorStatus {
|
||||
match dtype {
|
||||
DecoratorType::Inverter => match Self::eval(child, ctx) {
|
||||
BehaviorStatus::Success => BehaviorStatus::Failure,
|
||||
BehaviorStatus::Failure => BehaviorStatus::Success,
|
||||
BehaviorStatus::Running => BehaviorStatus::Running,
|
||||
},
|
||||
DecoratorType::Repeat(n) => {
|
||||
for _ in 0..*n {
|
||||
match Self::eval(child, ctx) {
|
||||
BehaviorStatus::Failure => return BehaviorStatus::Failure,
|
||||
BehaviorStatus::Running => return BehaviorStatus::Running,
|
||||
BehaviorStatus::Success => {}
|
||||
}
|
||||
fn eval_decorator(
|
||||
dtype: &DecoratorType,
|
||||
child: &BehaviorNode,
|
||||
ctx: &mut BehaviorContext,
|
||||
) -> BehaviorStatus {
|
||||
match dtype {
|
||||
DecoratorType::Inverter => match eval(child, ctx) {
|
||||
BehaviorStatus::Success => BehaviorStatus::Failure,
|
||||
BehaviorStatus::Failure => BehaviorStatus::Success,
|
||||
BehaviorStatus::Running => BehaviorStatus::Running,
|
||||
},
|
||||
DecoratorType::Repeat(n) => {
|
||||
for _ in 0..*n {
|
||||
match eval(child, ctx) {
|
||||
BehaviorStatus::Failure => return BehaviorStatus::Failure,
|
||||
BehaviorStatus::Running => return BehaviorStatus::Running,
|
||||
BehaviorStatus::Success => {}
|
||||
}
|
||||
BehaviorStatus::Success
|
||||
}
|
||||
DecoratorType::UntilFail => loop {
|
||||
match Self::eval(child, ctx) {
|
||||
BehaviorStatus::Success
|
||||
}
|
||||
DecoratorType::UntilFail => {
|
||||
for _ in 0..UNTIL_FAIL_MAX_ITERATIONS {
|
||||
match eval(child, ctx) {
|
||||
BehaviorStatus::Failure => return BehaviorStatus::Success,
|
||||
BehaviorStatus::Running => return BehaviorStatus::Running,
|
||||
BehaviorStatus::Success => continue,
|
||||
}
|
||||
},
|
||||
DecoratorType::Timeout(max_ticks) => {
|
||||
if ctx.tick_count > *max_ticks {
|
||||
return BehaviorStatus::Failure;
|
||||
}
|
||||
Self::eval(child, ctx)
|
||||
}
|
||||
// Safety: child never failed within the iteration budget.
|
||||
BehaviorStatus::Running
|
||||
}
|
||||
DecoratorType::Timeout(max_ticks) => {
|
||||
if ctx.tick_count > *max_ticks {
|
||||
return BehaviorStatus::Failure;
|
||||
}
|
||||
eval(child, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
//! incorporating feedback to improve future decisions.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enums
|
||||
|
|
@ -108,7 +109,7 @@ impl Default for CognitiveConfig {
|
|||
pub struct CognitiveCore {
|
||||
state: CognitiveState,
|
||||
config: CognitiveConfig,
|
||||
percept_buffer: Vec<Percept>,
|
||||
percept_buffer: VecDeque<Percept>,
|
||||
decision_history: Vec<Decision>,
|
||||
cumulative_reward: f64,
|
||||
}
|
||||
|
|
@ -119,7 +120,7 @@ impl CognitiveCore {
|
|||
Self {
|
||||
state: CognitiveState::Idle,
|
||||
config,
|
||||
percept_buffer: Vec::new(),
|
||||
percept_buffer: VecDeque::new(),
|
||||
decision_history: Vec::new(),
|
||||
cumulative_reward: 0.0,
|
||||
}
|
||||
|
|
@ -137,9 +138,9 @@ impl CognitiveCore {
|
|||
}
|
||||
|
||||
if self.percept_buffer.len() >= self.config.max_percepts {
|
||||
self.percept_buffer.remove(0);
|
||||
self.percept_buffer.pop_front(); // O(1) with VecDeque
|
||||
}
|
||||
self.percept_buffer.push(percept);
|
||||
self.percept_buffer.push_back(percept);
|
||||
self.state
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,8 +46,20 @@ impl SkillLibrary {
|
|||
/// Learn a skill from one or more demonstrations by averaging their
|
||||
/// trajectories point-by-point. The resulting trajectory length equals
|
||||
/// the shortest demonstration.
|
||||
/// # Panics
|
||||
///
|
||||
/// Returns early with a zero-confidence skill if `demos` is empty.
|
||||
pub fn learn_from_demonstration(&mut self, name: &str, demos: &[Demonstration]) -> Skill {
|
||||
assert!(!demos.is_empty(), "at least one demonstration required");
|
||||
if demos.is_empty() {
|
||||
let skill = Skill {
|
||||
name: name.to_string(),
|
||||
trajectory: Vec::new(),
|
||||
confidence: 0.0,
|
||||
execution_count: 0,
|
||||
};
|
||||
self.skills.insert(name.to_string(), skill.clone());
|
||||
return skill;
|
||||
}
|
||||
|
||||
let min_len = demos.iter().map(|d| d.trajectory.len()).min().unwrap_or(0);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
//! simple majority consensus for distributed decision making.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formation
|
||||
|
|
@ -136,7 +136,7 @@ impl SwarmCoordinator {
|
|||
let mut sorted_tasks: Vec<&SwarmTask> = tasks.iter().collect();
|
||||
sorted_tasks.sort_by(|a, b| b.priority.cmp(&a.priority));
|
||||
|
||||
let mut assigned_robots: Vec<u64> = Vec::new();
|
||||
let mut assigned_robots: HashSet<u64> = HashSet::new();
|
||||
let mut assignments = Vec::new();
|
||||
|
||||
for task in &sorted_tasks {
|
||||
|
|
@ -165,7 +165,7 @@ impl SwarmCoordinator {
|
|||
task_id: task.id,
|
||||
estimated_completion: est,
|
||||
});
|
||||
assigned_robots.push(*id);
|
||||
assigned_robots.insert(*id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
970
crates/ruvector-robotics/src/domain_expansion.rs
Normal file
970
crates/ruvector-robotics/src/domain_expansion.rs
Normal file
|
|
@ -0,0 +1,970 @@
|
|||
//! Robotics domain for cross-domain transfer learning.
|
||||
//!
|
||||
//! Implements [`ruvector_domain_expansion::Domain`] so that robotics tasks
|
||||
//! (perception, planning, skill learning) participate in the domain-expansion
|
||||
//! engine's transfer-learning pipeline alongside Rust synthesis, structured
|
||||
//! planning, and tool orchestration.
|
||||
//!
|
||||
//! ## Task categories
|
||||
//!
|
||||
//! | Category | Description |
|
||||
//! |---|---|
|
||||
//! | `PointCloudClustering` | Cluster a synthetic point cloud into objects |
|
||||
//! | `ObstacleAvoidance` | Plan a collision-free path through obstacles |
|
||||
//! | `SceneGraphConstruction` | Build a scene graph from a set of objects |
|
||||
//! | `SkillSequencing` | Select and sequence learned motor skills |
|
||||
//! | `SwarmFormation` | Assign robots to formation positions |
|
||||
//!
|
||||
//! ## Transfer synergies
|
||||
//!
|
||||
//! - **Planning ↔ Robotics**: Both decompose goals into ordered steps with
|
||||
//! resource constraints. Robotics adds spatial reasoning.
|
||||
//! - **Tool Orchestration ↔ Robotics**: Swarm coordination is structurally
|
||||
//! similar to multi-tool pipeline coordination.
|
||||
//! - **Rust Synthesis ↔ Robotics**: Algorithmic solutions (search, sort,
|
||||
//! graph traversal) directly appear in perception and planning kernels.
|
||||
|
||||
use rand::Rng;
|
||||
use ruvector_domain_expansion::domain::{Domain, DomainEmbedding, DomainId, Evaluation, Solution, Task};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const EMBEDDING_DIM: usize = 64;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task specification types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Categories of robotics tasks.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum RoboticsCategory {
|
||||
/// Cluster a point cloud into distinct objects.
|
||||
PointCloudClustering,
|
||||
/// Plan a path that avoids all obstacles.
|
||||
ObstacleAvoidance,
|
||||
/// Build a scene graph from detected objects.
|
||||
SceneGraphConstruction,
|
||||
/// Select and order skills to achieve a goal.
|
||||
SkillSequencing,
|
||||
/// Assign N robots to formation positions.
|
||||
SwarmFormation,
|
||||
}
|
||||
|
||||
/// A synthetic obstacle for avoidance tasks.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskObstacle {
|
||||
pub center: [f64; 3],
|
||||
pub radius: f64,
|
||||
}
|
||||
|
||||
/// A skill reference for sequencing tasks.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskSkill {
|
||||
pub name: String,
|
||||
pub preconditions: Vec<String>,
|
||||
pub effects: Vec<String>,
|
||||
pub cost: f32,
|
||||
}
|
||||
|
||||
/// Specification for a robotics task.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoboticsTaskSpec {
|
||||
pub category: RoboticsCategory,
|
||||
pub description: String,
|
||||
/// Number of points (clustering), obstacles (avoidance), objects (scene
|
||||
/// graph), skills (sequencing), or robots (formation).
|
||||
pub size: usize,
|
||||
/// Spatial extent of the task environment.
|
||||
pub world_bounds: [f64; 3],
|
||||
/// Obstacles (used by avoidance and scene graph tasks).
|
||||
pub obstacles: Vec<TaskObstacle>,
|
||||
/// Skills (used by sequencing tasks).
|
||||
pub skills: Vec<TaskSkill>,
|
||||
/// Start position (avoidance).
|
||||
pub start: Option<[f64; 3]>,
|
||||
/// Goal position (avoidance).
|
||||
pub goal: Option<[f64; 3]>,
|
||||
/// Desired formation type name (swarm).
|
||||
pub formation: Option<String>,
|
||||
}
|
||||
|
||||
/// A parsed robotics solution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoboticsSolution {
|
||||
/// Waypoints for path or formation positions.
|
||||
pub waypoints: Vec<[f64; 3]>,
|
||||
/// Cluster assignments (point index → cluster id).
|
||||
pub cluster_ids: Vec<usize>,
|
||||
/// Ordered skill names.
|
||||
pub skill_sequence: Vec<String>,
|
||||
/// Scene graph edges as (from, to) pairs.
|
||||
pub edges: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Robotics domain for the domain-expansion engine.
|
||||
pub struct RoboticsDomain {
|
||||
id: DomainId,
|
||||
}
|
||||
|
||||
impl RoboticsDomain {
|
||||
/// Create a new robotics domain.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
id: DomainId("robotics".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// -- task generators ---------------------------------------------------
|
||||
|
||||
fn gen_clustering(&self, difficulty: f32, rng: &mut impl Rng) -> RoboticsTaskSpec {
|
||||
let num_clusters = if difficulty < 0.3 { 2 } else if difficulty < 0.7 { 5 } else { 10 };
|
||||
let pts_per_cluster = if difficulty < 0.3 { 10 } else { 20 };
|
||||
let spread = if difficulty < 0.5 { 0.5 } else { 2.0 };
|
||||
|
||||
let mut obstacles = Vec::new();
|
||||
for _ in 0..num_clusters {
|
||||
obstacles.push(TaskObstacle {
|
||||
center: [
|
||||
rng.gen_range(-10.0..10.0),
|
||||
rng.gen_range(-10.0..10.0),
|
||||
rng.gen_range(0.0..5.0),
|
||||
],
|
||||
radius: spread,
|
||||
});
|
||||
}
|
||||
|
||||
RoboticsTaskSpec {
|
||||
category: RoboticsCategory::PointCloudClustering,
|
||||
description: format!(
|
||||
"Cluster {} points into {} groups (spread={:.1}).",
|
||||
num_clusters * pts_per_cluster,
|
||||
num_clusters,
|
||||
spread,
|
||||
),
|
||||
size: num_clusters * pts_per_cluster,
|
||||
world_bounds: [20.0, 20.0, 5.0],
|
||||
obstacles,
|
||||
skills: Vec::new(),
|
||||
start: None,
|
||||
goal: None,
|
||||
formation: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_avoidance(&self, difficulty: f32, rng: &mut impl Rng) -> RoboticsTaskSpec {
|
||||
let num_obstacles = if difficulty < 0.3 { 3 } else if difficulty < 0.7 { 8 } else { 15 };
|
||||
let mut obstacles = Vec::new();
|
||||
for _ in 0..num_obstacles {
|
||||
obstacles.push(TaskObstacle {
|
||||
center: [
|
||||
rng.gen_range(1.0..9.0),
|
||||
rng.gen_range(1.0..9.0),
|
||||
0.0,
|
||||
],
|
||||
radius: rng.gen_range(0.3..1.5),
|
||||
});
|
||||
}
|
||||
|
||||
RoboticsTaskSpec {
|
||||
category: RoboticsCategory::ObstacleAvoidance,
|
||||
description: format!(
|
||||
"Plan a collision-free path through {} obstacles.",
|
||||
num_obstacles,
|
||||
),
|
||||
size: num_obstacles,
|
||||
world_bounds: [10.0, 10.0, 1.0],
|
||||
obstacles,
|
||||
skills: Vec::new(),
|
||||
start: Some([0.0, 0.0, 0.0]),
|
||||
goal: Some([10.0, 10.0, 0.0]),
|
||||
formation: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_scene_graph(&self, difficulty: f32, rng: &mut impl Rng) -> RoboticsTaskSpec {
|
||||
let num_objects = if difficulty < 0.3 { 3 } else if difficulty < 0.7 { 8 } else { 15 };
|
||||
let mut obstacles = Vec::new();
|
||||
for _ in 0..num_objects {
|
||||
obstacles.push(TaskObstacle {
|
||||
center: [
|
||||
rng.gen_range(0.0..20.0),
|
||||
rng.gen_range(0.0..20.0),
|
||||
rng.gen_range(0.0..5.0),
|
||||
],
|
||||
radius: rng.gen_range(0.5..2.0),
|
||||
});
|
||||
}
|
||||
|
||||
RoboticsTaskSpec {
|
||||
category: RoboticsCategory::SceneGraphConstruction,
|
||||
description: format!(
|
||||
"Build a scene graph with spatial relations for {} objects.",
|
||||
num_objects,
|
||||
),
|
||||
size: num_objects,
|
||||
world_bounds: [20.0, 20.0, 5.0],
|
||||
obstacles,
|
||||
skills: Vec::new(),
|
||||
start: None,
|
||||
goal: None,
|
||||
formation: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_skill_sequencing(&self, difficulty: f32, _rng: &mut impl Rng) -> RoboticsTaskSpec {
|
||||
let skill_names = if difficulty < 0.3 {
|
||||
vec!["approach", "grasp", "lift"]
|
||||
} else if difficulty < 0.7 {
|
||||
vec!["scan", "approach", "align", "grasp", "lift", "place"]
|
||||
} else {
|
||||
vec![
|
||||
"scan", "classify", "approach", "align", "grasp",
|
||||
"lift", "navigate", "place", "verify", "retreat",
|
||||
]
|
||||
};
|
||||
|
||||
let skills: Vec<TaskSkill> = skill_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &name)| TaskSkill {
|
||||
name: name.to_string(),
|
||||
preconditions: if i > 0 {
|
||||
vec![format!("{}_done", skill_names[i - 1])]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
effects: vec![format!("{}_done", name)],
|
||||
cost: (i as f32 + 1.0) * 0.5,
|
||||
})
|
||||
.collect();
|
||||
|
||||
RoboticsTaskSpec {
|
||||
category: RoboticsCategory::SkillSequencing,
|
||||
description: format!(
|
||||
"Sequence {} skills to achieve a pick-and-place goal.",
|
||||
skills.len(),
|
||||
),
|
||||
size: skills.len(),
|
||||
world_bounds: [10.0, 10.0, 3.0],
|
||||
obstacles: Vec::new(),
|
||||
skills,
|
||||
start: None,
|
||||
goal: None,
|
||||
formation: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_swarm_formation(&self, difficulty: f32, _rng: &mut impl Rng) -> RoboticsTaskSpec {
|
||||
let num_robots = if difficulty < 0.3 { 4 } else if difficulty < 0.7 { 8 } else { 16 };
|
||||
let formation = if difficulty < 0.5 { "circle" } else { "grid" };
|
||||
|
||||
RoboticsTaskSpec {
|
||||
category: RoboticsCategory::SwarmFormation,
|
||||
description: format!(
|
||||
"Assign {} robots to a {} formation.",
|
||||
num_robots, formation,
|
||||
),
|
||||
size: num_robots,
|
||||
world_bounds: [20.0, 20.0, 1.0],
|
||||
obstacles: Vec::new(),
|
||||
skills: Vec::new(),
|
||||
start: None,
|
||||
goal: None,
|
||||
formation: Some(formation.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// -- evaluation helpers ------------------------------------------------
|
||||
|
||||
fn score_clustering(&self, spec: &RoboticsTaskSpec, sol: &RoboticsSolution) -> Evaluation {
|
||||
let expected_clusters = spec.obstacles.len();
|
||||
let mut notes = Vec::new();
|
||||
|
||||
if sol.cluster_ids.is_empty() {
|
||||
return Evaluation::zero(vec!["No cluster assignments".into()]);
|
||||
}
|
||||
|
||||
let actual_clusters = *sol.cluster_ids.iter().max().unwrap_or(&0) + 1;
|
||||
let cluster_accuracy = if expected_clusters > 0 {
|
||||
1.0 - ((actual_clusters as f32 - expected_clusters as f32).abs()
|
||||
/ expected_clusters as f32)
|
||||
.min(1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let correctness = cluster_accuracy;
|
||||
let efficiency = if sol.cluster_ids.len() == spec.size { 1.0 } else { 0.5 };
|
||||
let elegance = if actual_clusters <= expected_clusters * 2 { 0.8 } else { 0.3 };
|
||||
|
||||
if (actual_clusters as i32 - expected_clusters as i32).unsigned_abs() > 2 {
|
||||
notes.push(format!(
|
||||
"Expected ~{} clusters, got {}",
|
||||
expected_clusters, actual_clusters,
|
||||
));
|
||||
}
|
||||
|
||||
Evaluation {
|
||||
score: (0.6 * correctness + 0.25 * efficiency + 0.15 * elegance).clamp(0.0, 1.0),
|
||||
correctness,
|
||||
efficiency,
|
||||
elegance,
|
||||
constraint_results: Vec::new(),
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
fn score_avoidance(&self, spec: &RoboticsTaskSpec, sol: &RoboticsSolution) -> Evaluation {
|
||||
let mut notes = Vec::new();
|
||||
|
||||
if sol.waypoints.is_empty() {
|
||||
return Evaluation::zero(vec!["Empty path".into()]);
|
||||
}
|
||||
|
||||
// Check start/goal proximity.
|
||||
let start = spec.start.unwrap_or([0.0; 3]);
|
||||
let goal = spec.goal.unwrap_or([10.0, 10.0, 0.0]);
|
||||
let start_dist = dist3(&sol.waypoints[0], &start);
|
||||
let goal_dist = dist3(sol.waypoints.last().unwrap(), &goal);
|
||||
let reaches_goal = start_dist < 1.0 && goal_dist < 1.0;
|
||||
|
||||
// Check collisions.
|
||||
let mut collisions = 0;
|
||||
for wp in &sol.waypoints {
|
||||
for obs in &spec.obstacles {
|
||||
if dist3(wp, &obs.center) < obs.radius {
|
||||
collisions += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let correctness = if reaches_goal { 0.6 } else { 0.2 }
|
||||
+ (1.0 - (collisions as f32 / (sol.waypoints.len() * spec.obstacles.len()).max(1) as f32).min(1.0)) * 0.4;
|
||||
let efficiency = 1.0 - (sol.waypoints.len() as f32 / 100.0).min(1.0);
|
||||
let elegance = if collisions == 0 { 0.9 } else { 0.3 };
|
||||
|
||||
if collisions > 0 {
|
||||
notes.push(format!("{} collision(s) detected", collisions));
|
||||
}
|
||||
if !reaches_goal {
|
||||
notes.push("Path does not reach goal".into());
|
||||
}
|
||||
|
||||
Evaluation {
|
||||
score: (0.6 * correctness + 0.25 * efficiency + 0.15 * elegance).clamp(0.0, 1.0),
|
||||
correctness,
|
||||
efficiency,
|
||||
elegance,
|
||||
constraint_results: Vec::new(),
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
fn score_scene_graph(&self, spec: &RoboticsTaskSpec, sol: &RoboticsSolution) -> Evaluation {
|
||||
let expected_objects = spec.obstacles.len();
|
||||
let mut notes = Vec::new();
|
||||
|
||||
if sol.edges.is_empty() && expected_objects > 1 {
|
||||
notes.push("No edges in scene graph".into());
|
||||
}
|
||||
|
||||
// Check node coverage.
|
||||
let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||
for &(a, b) in &sol.edges {
|
||||
seen.insert(a);
|
||||
seen.insert(b);
|
||||
}
|
||||
let coverage = if expected_objects > 0 {
|
||||
seen.len() as f32 / expected_objects as f32
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let correctness = coverage.min(1.0);
|
||||
let efficiency = if sol.edges.len() <= expected_objects * (expected_objects - 1) / 2 {
|
||||
0.9
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
let elegance = if coverage >= 0.8 { 0.8 } else { 0.4 };
|
||||
|
||||
Evaluation {
|
||||
score: (0.6 * correctness + 0.25 * efficiency + 0.15 * elegance).clamp(0.0, 1.0),
|
||||
correctness,
|
||||
efficiency,
|
||||
elegance,
|
||||
constraint_results: Vec::new(),
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
fn score_skill_sequence(&self, spec: &RoboticsTaskSpec, sol: &RoboticsSolution) -> Evaluation {
|
||||
let mut notes = Vec::new();
|
||||
|
||||
if sol.skill_sequence.is_empty() {
|
||||
return Evaluation::zero(vec!["Empty skill sequence".into()]);
|
||||
}
|
||||
|
||||
// Check dependency ordering.
|
||||
let mut violations = 0;
|
||||
for (i, name) in sol.skill_sequence.iter().enumerate() {
|
||||
if let Some(skill) = spec.skills.iter().find(|s| &s.name == name) {
|
||||
for pre in &skill.preconditions {
|
||||
// Precondition must appear earlier.
|
||||
let pre_skill = pre.trim_end_matches("_done");
|
||||
let pre_pos = sol.skill_sequence.iter().position(|s| s == pre_skill);
|
||||
if let Some(pp) = pre_pos {
|
||||
if pp >= i {
|
||||
violations += 1;
|
||||
notes.push(format!("{} before its precondition {}", name, pre_skill));
|
||||
}
|
||||
} else {
|
||||
violations += 1;
|
||||
notes.push(format!("Missing precondition {} for {}", pre_skill, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let expected_skills = spec.skills.len();
|
||||
let coverage = sol.skill_sequence.len() as f32 / expected_skills.max(1) as f32;
|
||||
let dep_penalty = violations as f32 / expected_skills.max(1) as f32;
|
||||
|
||||
let correctness = (coverage.min(1.0) * (1.0 - dep_penalty.min(1.0))).max(0.0);
|
||||
let efficiency = if sol.skill_sequence.len() <= expected_skills + 2 { 0.9 } else { 0.5 };
|
||||
let elegance = if violations == 0 { 0.9 } else { 0.3 };
|
||||
|
||||
Evaluation {
|
||||
score: (0.6 * correctness + 0.25 * efficiency + 0.15 * elegance).clamp(0.0, 1.0),
|
||||
correctness,
|
||||
efficiency,
|
||||
elegance,
|
||||
constraint_results: Vec::new(),
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
fn score_formation(&self, spec: &RoboticsTaskSpec, sol: &RoboticsSolution) -> Evaluation {
|
||||
let expected_robots = spec.size;
|
||||
let mut notes = Vec::new();
|
||||
|
||||
if sol.waypoints.is_empty() {
|
||||
return Evaluation::zero(vec!["No formation positions".into()]);
|
||||
}
|
||||
|
||||
let correctness = (sol.waypoints.len() as f32 / expected_robots.max(1) as f32).min(1.0);
|
||||
|
||||
// Check positions are within bounds.
|
||||
let bounds = &spec.world_bounds;
|
||||
let in_bounds = sol
|
||||
.waypoints
|
||||
.iter()
|
||||
.filter(|w| {
|
||||
w[0].abs() <= bounds[0] && w[1].abs() <= bounds[1] && w[2].abs() <= bounds[2]
|
||||
})
|
||||
.count() as f32
|
||||
/ sol.waypoints.len().max(1) as f32;
|
||||
let efficiency = in_bounds;
|
||||
|
||||
// Check for collisions between robots (min spacing).
|
||||
let mut too_close = 0;
|
||||
for i in 0..sol.waypoints.len() {
|
||||
for j in (i + 1)..sol.waypoints.len() {
|
||||
if dist3(&sol.waypoints[i], &sol.waypoints[j]) < 0.5 {
|
||||
too_close += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let elegance = if too_close == 0 { 0.9 } else { 0.4 };
|
||||
if too_close > 0 {
|
||||
notes.push(format!("{} robot pair(s) too close (<0.5m)", too_close));
|
||||
}
|
||||
|
||||
Evaluation {
|
||||
score: (0.6 * correctness + 0.25 * efficiency + 0.15 * elegance).clamp(0.0, 1.0),
|
||||
correctness,
|
||||
efficiency,
|
||||
elegance,
|
||||
constraint_results: Vec::new(),
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
// -- embedding ---------------------------------------------------------
|
||||
|
||||
fn extract_features(&self, solution: &Solution) -> Vec<f32> {
|
||||
let content = &solution.content;
|
||||
let mut features = vec![0.0f32; EMBEDDING_DIM];
|
||||
|
||||
// Parse the structured solution if present.
|
||||
let sol: RoboticsSolution = serde_json::from_str(&solution.data.to_string())
|
||||
.or_else(|_| serde_json::from_str(content))
|
||||
.unwrap_or(RoboticsSolution {
|
||||
waypoints: Vec::new(),
|
||||
cluster_ids: Vec::new(),
|
||||
skill_sequence: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
});
|
||||
|
||||
// Feature 0-7: Solution structure.
|
||||
features[0] = sol.waypoints.len() as f32 / 50.0;
|
||||
features[1] = sol.cluster_ids.len() as f32 / 100.0;
|
||||
features[2] = sol.skill_sequence.len() as f32 / 20.0;
|
||||
features[3] = sol.edges.len() as f32 / 50.0;
|
||||
// Unique clusters.
|
||||
let unique_clusters: std::collections::HashSet<&usize> = sol.cluster_ids.iter().collect();
|
||||
features[4] = unique_clusters.len() as f32 / 20.0;
|
||||
// Unique skills.
|
||||
let unique_skills: std::collections::HashSet<&String> = sol.skill_sequence.iter().collect();
|
||||
features[5] = unique_skills.len() as f32 / 20.0;
|
||||
// Spatial extent of waypoints.
|
||||
if !sol.waypoints.is_empty() {
|
||||
let max_dist = sol
|
||||
.waypoints
|
||||
.iter()
|
||||
.map(|w| (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt() as f32)
|
||||
.fold(0.0f32, f32::max);
|
||||
features[6] = max_dist / 50.0;
|
||||
}
|
||||
|
||||
// Feature 8-15: Text-based features (cross-domain compatible).
|
||||
features[8] = content.matches("cluster").count() as f32 / 5.0;
|
||||
features[9] = content.matches("obstacle").count() as f32 / 5.0;
|
||||
features[10] = content.matches("path").count() as f32 / 5.0;
|
||||
features[11] = content.matches("scene").count() as f32 / 3.0;
|
||||
features[12] = content.matches("formation").count() as f32 / 3.0;
|
||||
features[13] = content.matches("skill").count() as f32 / 5.0;
|
||||
features[14] = content.matches("robot").count() as f32 / 5.0;
|
||||
features[15] = content.matches("point").count() as f32 / 10.0;
|
||||
|
||||
// Feature 16-23: Spatial reasoning indicators.
|
||||
features[16] = content.matches("distance").count() as f32 / 5.0;
|
||||
features[17] = content.matches("position").count() as f32 / 5.0;
|
||||
features[18] = content.matches("radius").count() as f32 / 3.0;
|
||||
features[19] = content.matches("collision").count() as f32 / 3.0;
|
||||
features[20] = content.matches("adjacent").count() as f32 / 3.0;
|
||||
features[21] = content.matches("near").count() as f32 / 3.0;
|
||||
features[22] = content.matches("velocity").count() as f32 / 3.0;
|
||||
features[23] = content.matches("trajectory").count() as f32 / 3.0;
|
||||
|
||||
// Feature 32-39: Planning overlap (cross-domain with PlanningDomain).
|
||||
features[32] = content.matches("allocate").count() as f32 / 3.0;
|
||||
features[33] = content.matches("schedule").count() as f32 / 3.0;
|
||||
features[34] = content.matches("constraint").count() as f32 / 3.0;
|
||||
features[35] = content.matches("goal").count() as f32 / 3.0;
|
||||
features[36] = content.matches("precondition").count() as f32 / 3.0;
|
||||
features[37] = content.matches("parallel").count() as f32 / 3.0;
|
||||
features[38] = content.matches("sequence").count() as f32 / 3.0;
|
||||
features[39] = content.matches("assign").count() as f32 / 3.0;
|
||||
|
||||
// Feature 48-55: Orchestration overlap (cross-domain with ToolOrchestration).
|
||||
features[48] = content.matches("pipeline").count() as f32 / 3.0;
|
||||
features[49] = content.matches("sensor").count() as f32 / 3.0;
|
||||
features[50] = content.matches("fuse").count() as f32 / 2.0;
|
||||
features[51] = content.matches("detect").count() as f32 / 3.0;
|
||||
features[52] = content.matches("track").count() as f32 / 3.0;
|
||||
features[53] = content.matches("coordinate").count() as f32 / 3.0;
|
||||
features[54] = content.matches("merge").count() as f32 / 3.0;
|
||||
features[55] = content.matches("update").count() as f32 / 3.0;
|
||||
|
||||
// Normalize to unit length.
|
||||
let norm: f32 = features.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 1e-10 {
|
||||
for f in &mut features {
|
||||
*f /= norm;
|
||||
}
|
||||
}
|
||||
|
||||
features
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RoboticsDomain {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Domain for RoboticsDomain {
|
||||
fn id(&self) -> &DomainId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"Cognitive Robotics"
|
||||
}
|
||||
|
||||
fn generate_tasks(&self, count: usize, difficulty: f32) -> Vec<Task> {
|
||||
let mut rng = rand::thread_rng();
|
||||
let difficulty = difficulty.clamp(0.0, 1.0);
|
||||
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let roll: f32 = rng.gen();
|
||||
let spec = if roll < 0.2 {
|
||||
self.gen_clustering(difficulty, &mut rng)
|
||||
} else if roll < 0.4 {
|
||||
self.gen_avoidance(difficulty, &mut rng)
|
||||
} else if roll < 0.6 {
|
||||
self.gen_scene_graph(difficulty, &mut rng)
|
||||
} else if roll < 0.8 {
|
||||
self.gen_skill_sequencing(difficulty, &mut rng)
|
||||
} else {
|
||||
self.gen_swarm_formation(difficulty, &mut rng)
|
||||
};
|
||||
|
||||
Task {
|
||||
id: format!("robotics_{}_d{:.0}", i, difficulty * 100.0),
|
||||
domain_id: self.id.clone(),
|
||||
difficulty,
|
||||
spec: serde_json::to_value(&spec).unwrap_or_default(),
|
||||
constraints: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn evaluate(&self, task: &Task, solution: &Solution) -> Evaluation {
|
||||
let spec: RoboticsTaskSpec = match serde_json::from_value(task.spec.clone()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Evaluation::zero(vec![format!("Invalid task spec: {}", e)]),
|
||||
};
|
||||
|
||||
let sol: RoboticsSolution = serde_json::from_str(&solution.data.to_string())
|
||||
.or_else(|_| serde_json::from_str(&solution.content))
|
||||
.unwrap_or(RoboticsSolution {
|
||||
waypoints: Vec::new(),
|
||||
cluster_ids: Vec::new(),
|
||||
skill_sequence: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
});
|
||||
|
||||
match spec.category {
|
||||
RoboticsCategory::PointCloudClustering => self.score_clustering(&spec, &sol),
|
||||
RoboticsCategory::ObstacleAvoidance => self.score_avoidance(&spec, &sol),
|
||||
RoboticsCategory::SceneGraphConstruction => self.score_scene_graph(&spec, &sol),
|
||||
RoboticsCategory::SkillSequencing => self.score_skill_sequence(&spec, &sol),
|
||||
RoboticsCategory::SwarmFormation => self.score_formation(&spec, &sol),
|
||||
}
|
||||
}
|
||||
|
||||
fn embed(&self, solution: &Solution) -> DomainEmbedding {
|
||||
let features = self.extract_features(solution);
|
||||
DomainEmbedding::new(features, self.id.clone())
|
||||
}
|
||||
|
||||
fn embedding_dim(&self) -> usize {
|
||||
EMBEDDING_DIM
|
||||
}
|
||||
|
||||
fn reference_solution(&self, task: &Task) -> Option<Solution> {
|
||||
let spec: RoboticsTaskSpec = serde_json::from_value(task.spec.clone()).ok()?;
|
||||
|
||||
let sol = match spec.category {
|
||||
RoboticsCategory::PointCloudClustering => {
|
||||
// Assign each point-group to its own cluster.
|
||||
let cluster_ids: Vec<usize> = (0..spec.size)
|
||||
.map(|i| i / (spec.size / spec.obstacles.len().max(1)).max(1))
|
||||
.collect();
|
||||
RoboticsSolution {
|
||||
waypoints: Vec::new(),
|
||||
cluster_ids,
|
||||
skill_sequence: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
}
|
||||
}
|
||||
RoboticsCategory::ObstacleAvoidance => {
|
||||
// Straight-line path (naive reference).
|
||||
let start = spec.start.unwrap_or([0.0; 3]);
|
||||
let goal = spec.goal.unwrap_or([10.0, 10.0, 0.0]);
|
||||
let steps = 10;
|
||||
let waypoints: Vec<[f64; 3]> = (0..=steps)
|
||||
.map(|s| {
|
||||
let t = s as f64 / steps as f64;
|
||||
[
|
||||
start[0] + (goal[0] - start[0]) * t,
|
||||
start[1] + (goal[1] - start[1]) * t,
|
||||
start[2] + (goal[2] - start[2]) * t,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
RoboticsSolution {
|
||||
waypoints,
|
||||
cluster_ids: Vec::new(),
|
||||
skill_sequence: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
}
|
||||
}
|
||||
RoboticsCategory::SceneGraphConstruction => {
|
||||
// Connect all pairs within distance 10.
|
||||
let mut edges = Vec::new();
|
||||
for i in 0..spec.obstacles.len() {
|
||||
for j in (i + 1)..spec.obstacles.len() {
|
||||
let d = dist3(&spec.obstacles[i].center, &spec.obstacles[j].center);
|
||||
if d < 10.0 {
|
||||
edges.push((i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
RoboticsSolution {
|
||||
waypoints: Vec::new(),
|
||||
cluster_ids: Vec::new(),
|
||||
skill_sequence: Vec::new(),
|
||||
edges,
|
||||
}
|
||||
}
|
||||
RoboticsCategory::SkillSequencing => {
|
||||
let skill_sequence: Vec<String> =
|
||||
spec.skills.iter().map(|s| s.name.clone()).collect();
|
||||
RoboticsSolution {
|
||||
waypoints: Vec::new(),
|
||||
cluster_ids: Vec::new(),
|
||||
skill_sequence,
|
||||
edges: Vec::new(),
|
||||
}
|
||||
}
|
||||
RoboticsCategory::SwarmFormation => {
|
||||
// Circle formation.
|
||||
let n = spec.size;
|
||||
let waypoints: Vec<[f64; 3]> = (0..n)
|
||||
.map(|i| {
|
||||
let angle = 2.0 * std::f64::consts::PI * i as f64 / n as f64;
|
||||
[5.0 * angle.cos(), 5.0 * angle.sin(), 0.0]
|
||||
})
|
||||
.collect();
|
||||
RoboticsSolution {
|
||||
waypoints,
|
||||
cluster_ids: Vec::new(),
|
||||
skill_sequence: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let content = serde_json::to_string_pretty(&sol).ok()?;
|
||||
Some(Solution {
|
||||
task_id: task.id.clone(),
|
||||
content,
|
||||
data: serde_json::to_value(&sol).ok()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn dist3(a: &[f64; 3], b: &[f64; 3]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_robotics_domain_id() {
|
||||
let domain = RoboticsDomain::new();
|
||||
assert_eq!(domain.id().0, "robotics");
|
||||
assert_eq!(domain.name(), "Cognitive Robotics");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_tasks_all_difficulties() {
|
||||
let domain = RoboticsDomain::new();
|
||||
for &d in &[0.1, 0.5, 0.9] {
|
||||
let tasks = domain.generate_tasks(10, d);
|
||||
assert_eq!(tasks.len(), 10);
|
||||
for task in &tasks {
|
||||
assert_eq!(task.domain_id, *domain.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reference_solution_exists() {
|
||||
let domain = RoboticsDomain::new();
|
||||
let tasks = domain.generate_tasks(20, 0.5);
|
||||
for task in &tasks {
|
||||
let ref_sol = domain.reference_solution(task);
|
||||
assert!(ref_sol.is_some(), "Reference solution missing for {}", task.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_reference_solutions() {
|
||||
let domain = RoboticsDomain::new();
|
||||
let tasks = domain.generate_tasks(20, 0.3);
|
||||
for task in &tasks {
|
||||
if let Some(solution) = domain.reference_solution(task) {
|
||||
let eval = domain.evaluate(task, &solution);
|
||||
assert!(
|
||||
eval.score >= 0.0 && eval.score <= 1.0,
|
||||
"Score out of range for {}: {}",
|
||||
task.id,
|
||||
eval.score,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_embedding_dimension() {
|
||||
let domain = RoboticsDomain::new();
|
||||
assert_eq!(domain.embedding_dim(), EMBEDDING_DIM);
|
||||
|
||||
let sol = Solution {
|
||||
task_id: "test".into(),
|
||||
content: "cluster points into groups near obstacles using distance threshold".into(),
|
||||
data: serde_json::Value::Null,
|
||||
};
|
||||
let embedding = domain.embed(&sol);
|
||||
assert_eq!(embedding.dim, EMBEDDING_DIM);
|
||||
assert_eq!(embedding.vector.len(), EMBEDDING_DIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_domain_embedding_compatibility() {
|
||||
let domain = RoboticsDomain::new();
|
||||
|
||||
let robotics_sol = Solution {
|
||||
task_id: "r1".into(),
|
||||
content: "plan path through obstacles avoiding collision with distance checks".into(),
|
||||
data: serde_json::Value::Null,
|
||||
};
|
||||
let robotics_emb = domain.embed(&robotics_sol);
|
||||
|
||||
// Embedding should be same dimension as other domains (64).
|
||||
assert_eq!(robotics_emb.dim, 64);
|
||||
|
||||
// Cosine similarity with itself should be 1.0.
|
||||
let self_sim = robotics_emb.cosine_similarity(&robotics_emb);
|
||||
assert!((self_sim - 1.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_skill_ordering_violation() {
|
||||
let domain = RoboticsDomain::new();
|
||||
|
||||
let spec = RoboticsTaskSpec {
|
||||
category: RoboticsCategory::SkillSequencing,
|
||||
description: "test".into(),
|
||||
size: 3,
|
||||
world_bounds: [10.0, 10.0, 3.0],
|
||||
obstacles: Vec::new(),
|
||||
skills: vec![
|
||||
TaskSkill {
|
||||
name: "approach".into(),
|
||||
preconditions: Vec::new(),
|
||||
effects: vec!["approach_done".into()],
|
||||
cost: 1.0,
|
||||
},
|
||||
TaskSkill {
|
||||
name: "grasp".into(),
|
||||
preconditions: vec!["approach_done".into()],
|
||||
effects: vec!["grasp_done".into()],
|
||||
cost: 1.0,
|
||||
},
|
||||
TaskSkill {
|
||||
name: "lift".into(),
|
||||
preconditions: vec!["grasp_done".into()],
|
||||
effects: vec!["lift_done".into()],
|
||||
cost: 1.0,
|
||||
},
|
||||
],
|
||||
start: None,
|
||||
goal: None,
|
||||
formation: None,
|
||||
};
|
||||
|
||||
// Correct ordering.
|
||||
let good = RoboticsSolution {
|
||||
waypoints: Vec::new(),
|
||||
cluster_ids: Vec::new(),
|
||||
skill_sequence: vec!["approach".into(), "grasp".into(), "lift".into()],
|
||||
edges: Vec::new(),
|
||||
};
|
||||
let good_eval = domain.score_skill_sequence(&spec, &good);
|
||||
assert!(good_eval.correctness > 0.5);
|
||||
|
||||
// Bad ordering (reversed).
|
||||
let bad = RoboticsSolution {
|
||||
waypoints: Vec::new(),
|
||||
cluster_ids: Vec::new(),
|
||||
skill_sequence: vec!["lift".into(), "grasp".into(), "approach".into()],
|
||||
edges: Vec::new(),
|
||||
};
|
||||
let bad_eval = domain.score_skill_sequence(&spec, &bad);
|
||||
assert!(bad_eval.score < good_eval.score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_engine_with_robotics_domain() {
|
||||
use ruvector_domain_expansion::DomainExpansionEngine;
|
||||
|
||||
let mut engine = DomainExpansionEngine::new();
|
||||
engine.register_domain(Box::new(RoboticsDomain::new()));
|
||||
|
||||
let ids = engine.domain_ids();
|
||||
// 3 built-in + robotics = 4.
|
||||
assert_eq!(ids.len(), 4);
|
||||
assert!(ids.iter().any(|id| id.0 == "robotics"));
|
||||
|
||||
// Generate tasks from robotics domain.
|
||||
let domain_id = DomainId("robotics".into());
|
||||
let tasks = engine.generate_tasks(&domain_id, 5, 0.5);
|
||||
assert_eq!(tasks.len(), 5);
|
||||
|
||||
// Embed a robotics solution.
|
||||
let sol = Solution {
|
||||
task_id: "r".into(),
|
||||
content: "navigate robot through obstacle field using sensor fusion pipeline".into(),
|
||||
data: serde_json::Value::Null,
|
||||
};
|
||||
let emb = engine.embed(&domain_id, &sol);
|
||||
assert!(emb.is_some());
|
||||
assert_eq!(emb.unwrap().dim, 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_from_planning_to_robotics() {
|
||||
use ruvector_domain_expansion::transfer::{ArmId, ContextBucket};
|
||||
use ruvector_domain_expansion::DomainExpansionEngine;
|
||||
|
||||
let mut engine = DomainExpansionEngine::new();
|
||||
engine.register_domain(Box::new(RoboticsDomain::new()));
|
||||
|
||||
let planning_id = DomainId("structured_planning".into());
|
||||
let robotics_id = DomainId("robotics".into());
|
||||
|
||||
let bucket = ContextBucket {
|
||||
difficulty_tier: "medium".into(),
|
||||
category: "spatial".into(),
|
||||
};
|
||||
|
||||
// Train on planning domain.
|
||||
for _ in 0..30 {
|
||||
engine.thompson.record_outcome(
|
||||
&planning_id,
|
||||
bucket.clone(),
|
||||
ArmId("greedy".into()),
|
||||
0.85,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
|
||||
// Transfer to robotics.
|
||||
engine.initiate_transfer(&planning_id, &robotics_id);
|
||||
|
||||
// Verify transfer priors are seeded.
|
||||
let arm = engine.select_arm(&robotics_id, &bucket);
|
||||
assert!(arm.is_some());
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,12 @@ pub mod cognitive;
|
|||
pub mod mcp;
|
||||
pub mod perception;
|
||||
|
||||
/// Cross-domain transfer learning integration with `ruvector-domain-expansion`.
|
||||
///
|
||||
/// Requires the `domain-expansion` feature flag.
|
||||
#[cfg(feature = "domain-expansion")]
|
||||
pub mod domain_expansion;
|
||||
|
||||
// Convenience re-exports of the most commonly used types.
|
||||
pub use bridge::{
|
||||
BridgeConfig, DistanceMetric, OccupancyGrid, Obstacle as BridgeObstacle, Point3D, PointCloud,
|
||||
|
|
|
|||
142
crates/ruvector-robotics/src/perception/clustering.rs
Normal file
142
crates/ruvector-robotics/src/perception/clustering.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
//! Shared spatial-hash clustering with union-find.
|
||||
//!
|
||||
//! Used by the obstacle detector, scene graph builder, and perception pipeline
|
||||
//! to avoid duplicating the same algorithm.
|
||||
|
||||
use crate::bridge::{Point3D, PointCloud};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Cluster a point cloud using spatial hashing and union-find over adjacent cells.
|
||||
///
|
||||
/// Points are binned into a 3-D grid with the given `cell_size`. Cells that
|
||||
/// share a face, edge, or corner (26-neighbourhood) are merged via union-find.
|
||||
/// Returns the resulting groups as separate point vectors.
|
||||
pub fn cluster_point_cloud(cloud: &PointCloud, cell_size: f64) -> Vec<Vec<Point3D>> {
|
||||
if cloud.points.is_empty() || cell_size <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// 1. Map each point to a grid cell.
|
||||
let mut cell_map: HashMap<(i64, i64, i64), Vec<usize>> = HashMap::new();
|
||||
for (idx, p) in cloud.points.iter().enumerate() {
|
||||
let key = cell_key(p, cell_size);
|
||||
cell_map.entry(key).or_default().push(idx);
|
||||
}
|
||||
|
||||
// 2. Build union-find over cells.
|
||||
let cells: Vec<(i64, i64, i64)> = cell_map.keys().copied().collect();
|
||||
let cell_count = cells.len();
|
||||
let cell_idx: HashMap<(i64, i64, i64), usize> = cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &k)| (k, i))
|
||||
.collect();
|
||||
|
||||
let mut parent: Vec<usize> = (0..cell_count).collect();
|
||||
|
||||
for &(cx, cy, cz) in &cells {
|
||||
let a = cell_idx[&(cx, cy, cz)];
|
||||
for dx in -1..=1_i64 {
|
||||
for dy in -1..=1_i64 {
|
||||
for dz in -1..=1_i64 {
|
||||
let neighbor = (cx + dx, cy + dy, cz + dz);
|
||||
if let Some(&b) = cell_idx.get(&neighbor) {
|
||||
uf_union(&mut parent, a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Group points by their root representative.
|
||||
let mut groups: HashMap<usize, Vec<Point3D>> = HashMap::new();
|
||||
for (key, point_indices) in &cell_map {
|
||||
let ci = cell_idx[key];
|
||||
let root = uf_find(&mut parent, ci);
|
||||
let entry = groups.entry(root).or_default();
|
||||
for &pi in point_indices {
|
||||
entry.push(cloud.points[pi]);
|
||||
}
|
||||
}
|
||||
|
||||
groups.into_values().collect()
|
||||
}
|
||||
|
||||
/// Compute the grid cell key for a point.
|
||||
fn cell_key(p: &Point3D, cell_size: f64) -> (i64, i64, i64) {
|
||||
(
|
||||
(p.x as f64 / cell_size).floor() as i64,
|
||||
(p.y as f64 / cell_size).floor() as i64,
|
||||
(p.z as f64 / cell_size).floor() as i64,
|
||||
)
|
||||
}
|
||||
|
||||
/// Path-compressing find.
|
||||
fn uf_find(parent: &mut [usize], mut i: usize) -> usize {
|
||||
while parent[i] != i {
|
||||
parent[i] = parent[parent[i]];
|
||||
i = parent[i];
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Union by attaching one root to another.
|
||||
fn uf_union(parent: &mut [usize], a: usize, b: usize) {
|
||||
let ra = uf_find(parent, a);
|
||||
let rb = uf_find(parent, b);
|
||||
if ra != rb {
|
||||
parent[ra] = rb;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_cloud(pts: &[[f32; 3]]) -> PointCloud {
|
||||
let points: Vec<Point3D> = pts.iter().map(|a| Point3D::new(a[0], a[1], a[2])).collect();
|
||||
PointCloud::new(points, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_cloud() {
|
||||
let cloud = PointCloud::default();
|
||||
let clusters = cluster_point_cloud(&cloud, 1.0);
|
||||
assert!(clusters.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_cluster() {
|
||||
let cloud = make_cloud(&[
|
||||
[1.0, 1.0, 0.0],
|
||||
[1.1, 1.0, 0.0],
|
||||
[1.0, 1.1, 0.0],
|
||||
]);
|
||||
let clusters = cluster_point_cloud(&cloud, 0.5);
|
||||
assert_eq!(clusters.len(), 1);
|
||||
assert_eq!(clusters[0].len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_two_clusters() {
|
||||
let cloud = make_cloud(&[
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.1, 0.0, 0.0],
|
||||
[10.0, 10.0, 0.0],
|
||||
[10.1, 10.0, 0.0],
|
||||
]);
|
||||
let clusters = cluster_point_cloud(&cloud, 0.5);
|
||||
assert_eq!(clusters.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negative_coordinates() {
|
||||
let cloud = make_cloud(&[
|
||||
[-1.0, -1.0, 0.0],
|
||||
[-0.9, -1.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
]);
|
||||
let clusters = cluster_point_cloud(&cloud, 0.5);
|
||||
assert_eq!(clusters.len(), 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
//! This module sits on top of [`crate::bridge`] types and provides higher-level
|
||||
//! perception building blocks used by the cognitive architecture.
|
||||
|
||||
pub mod clustering;
|
||||
pub mod config;
|
||||
pub mod obstacle_detector;
|
||||
pub mod scene_graph;
|
||||
|
|
@ -11,6 +12,8 @@ pub use config::{ObstacleConfig, PerceptionConfig, SceneGraphConfig};
|
|||
pub use obstacle_detector::{ClassifiedObstacle, DetectedObstacle, ObstacleClass, ObstacleDetector};
|
||||
pub use scene_graph::PointCloudSceneGraphBuilder;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bridge::{
|
||||
Obstacle, Point3D, PointCloud, SceneEdge, SceneGraph, SceneObject, Trajectory,
|
||||
};
|
||||
|
|
@ -36,7 +39,7 @@ pub type Result<T> = std::result::Result<T, PerceptionError>;
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A point-cloud anomaly detected via z-score outlier analysis.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Anomaly {
|
||||
pub position: [f64; 3],
|
||||
pub score: f64,
|
||||
|
|
@ -248,7 +251,7 @@ impl PerceptionPipeline {
|
|||
}
|
||||
|
||||
let cell_size = self.obstacle_threshold.max(0.1);
|
||||
let clusters = Self::cluster_points(cloud, cell_size);
|
||||
let clusters = clustering::cluster_point_cloud(cloud, cell_size);
|
||||
|
||||
let mut obstacles: Vec<Obstacle> = Vec::new();
|
||||
let mut next_id: u64 = 0;
|
||||
|
|
@ -470,69 +473,6 @@ impl PerceptionPipeline {
|
|||
|
||||
// -- private helpers ----------------------------------------------------
|
||||
|
||||
fn cluster_points(cloud: &PointCloud, cell_size: f64) -> Vec<Vec<Point3D>> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut cell_map: HashMap<(i64, i64, i64), Vec<usize>> = HashMap::new();
|
||||
for (idx, p) in cloud.points.iter().enumerate() {
|
||||
let key = (
|
||||
(p.x as f64 / cell_size).floor() as i64,
|
||||
(p.y as f64 / cell_size).floor() as i64,
|
||||
(p.z as f64 / cell_size).floor() as i64,
|
||||
);
|
||||
cell_map.entry(key).or_default().push(idx);
|
||||
}
|
||||
|
||||
let cells: Vec<(i64, i64, i64)> = cell_map.keys().copied().collect();
|
||||
let cell_count = cells.len();
|
||||
let cell_idx: HashMap<(i64, i64, i64), usize> =
|
||||
cells.iter().enumerate().map(|(i, &k)| (k, i)).collect();
|
||||
|
||||
let mut parent: Vec<usize> = (0..cell_count).collect();
|
||||
|
||||
for &(gx, gy, gz) in &cells {
|
||||
let a = cell_idx[&(gx, gy, gz)];
|
||||
for dx in -1..=1_i64 {
|
||||
for dy in -1..=1_i64 {
|
||||
for dz in -1..=1_i64 {
|
||||
let nb = (gx + dx, gy + dy, gz + dz);
|
||||
if let Some(&b) = cell_idx.get(&nb) {
|
||||
Self::uf_union(&mut parent, a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut groups: HashMap<usize, Vec<Point3D>> = HashMap::new();
|
||||
for (cell_key, point_indices) in &cell_map {
|
||||
let ci = cell_idx[cell_key];
|
||||
let root = Self::uf_find(&mut parent, ci);
|
||||
let entry = groups.entry(root).or_default();
|
||||
for &pi in point_indices {
|
||||
entry.push(cloud.points[pi]);
|
||||
}
|
||||
}
|
||||
|
||||
groups.into_values().collect()
|
||||
}
|
||||
|
||||
fn uf_find(parent: &mut [usize], mut i: usize) -> usize {
|
||||
while parent[i] != i {
|
||||
parent[i] = parent[parent[i]];
|
||||
i = parent[i];
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn uf_union(parent: &mut [usize], a: usize, b: usize) {
|
||||
let ra = Self::uf_find(parent, a);
|
||||
let rb = Self::uf_find(parent, b);
|
||||
if ra != rb {
|
||||
parent[ra] = rb;
|
||||
}
|
||||
}
|
||||
|
||||
fn bounding_sphere(points: &[Point3D]) -> ([f64; 3], f64) {
|
||||
let n = points.len() as f64;
|
||||
let (mut sx, mut sy, mut sz) = (0.0_f64, 0.0_f64, 0.0_f64);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@
|
|||
//! Uses spatial-hash clustering to group nearby points into obstacle
|
||||
//! candidates, then filters and classifies them based on geometry.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::bridge::{Point3D, PointCloud};
|
||||
use crate::perception::clustering;
|
||||
use crate::perception::config::ObstacleConfig;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -80,7 +79,7 @@ impl ObstacleDetector {
|
|||
}
|
||||
|
||||
let cell_size = (self.config.safety_margin * 5.0).max(0.5);
|
||||
let clusters = self.cluster_points(cloud, cell_size);
|
||||
let clusters = clustering::cluster_point_cloud(cloud, cell_size);
|
||||
|
||||
let mut obstacles: Vec<DetectedObstacle> = clusters
|
||||
.into_iter()
|
||||
|
|
@ -124,84 +123,6 @@ impl ObstacleDetector {
|
|||
|
||||
// -- private helpers ----------------------------------------------------
|
||||
|
||||
/// Spatial-hash clustering: assign every point to a grid cell, then
|
||||
/// merge neighbouring cells via union-find.
|
||||
fn cluster_points(
|
||||
&self,
|
||||
cloud: &PointCloud,
|
||||
cell_size: f64,
|
||||
) -> Vec<Vec<Point3D>> {
|
||||
// Map each point to a grid cell.
|
||||
let mut cell_map: HashMap<(i64, i64, i64), Vec<usize>> = HashMap::new();
|
||||
|
||||
for (idx, p) in cloud.points.iter().enumerate() {
|
||||
let key = Self::cell_key(p, cell_size);
|
||||
cell_map.entry(key).or_default().push(idx);
|
||||
}
|
||||
|
||||
// Union-find over cells.
|
||||
let cells: Vec<(i64, i64, i64)> = cell_map.keys().copied().collect();
|
||||
let cell_count = cells.len();
|
||||
let cell_idx: HashMap<(i64, i64, i64), usize> = cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &k)| (k, i))
|
||||
.collect();
|
||||
|
||||
let mut parent: Vec<usize> = (0..cell_count).collect();
|
||||
|
||||
for &(cx, cy, cz) in &cells {
|
||||
let a = cell_idx[&(cx, cy, cz)];
|
||||
for dx in -1..=1_i64 {
|
||||
for dy in -1..=1_i64 {
|
||||
for dz in -1..=1_i64 {
|
||||
let neighbor = (cx + dx, cy + dy, cz + dz);
|
||||
if let Some(&b) = cell_idx.get(&neighbor) {
|
||||
Self::union(&mut parent, a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group points by root.
|
||||
let mut groups: HashMap<usize, Vec<Point3D>> = HashMap::new();
|
||||
for (cell_key, point_indices) in &cell_map {
|
||||
let ci = cell_idx[cell_key];
|
||||
let root = Self::find(&mut parent, ci);
|
||||
let entry = groups.entry(root).or_default();
|
||||
for &pi in point_indices {
|
||||
entry.push(cloud.points[pi]);
|
||||
}
|
||||
}
|
||||
|
||||
groups.into_values().collect()
|
||||
}
|
||||
|
||||
fn cell_key(p: &Point3D, cell_size: f64) -> (i64, i64, i64) {
|
||||
(
|
||||
(p.x as f64 / cell_size).floor() as i64,
|
||||
(p.y as f64 / cell_size).floor() as i64,
|
||||
(p.z as f64 / cell_size).floor() as i64,
|
||||
)
|
||||
}
|
||||
|
||||
fn find(parent: &mut [usize], mut i: usize) -> usize {
|
||||
while parent[i] != i {
|
||||
parent[i] = parent[parent[i]]; // path compression
|
||||
i = parent[i];
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn union(parent: &mut [usize], a: usize, b: usize) {
|
||||
let ra = Self::find(parent, a);
|
||||
let rb = Self::find(parent, b);
|
||||
if ra != rb {
|
||||
parent[ra] = rb;
|
||||
}
|
||||
}
|
||||
|
||||
fn cluster_to_obstacle(
|
||||
&self,
|
||||
points: &[Point3D],
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ fn test_bridge_types_roundtrip() {
|
|||
grid.set(2, 2, 0.9);
|
||||
let json = serde_json::to_string(&grid).unwrap();
|
||||
let grid2: OccupancyGrid = serde_json::from_str(&json).unwrap();
|
||||
assert!((grid2.get(2, 2) - 0.9).abs() < f32::EPSILON);
|
||||
assert!((grid2.get(2, 2).unwrap() - 0.9).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue