mirror of
https://github.com/ruvnet/RuView
synced 2026-08-03 19:21:42 +00:00
feat(homecore-frontend/p1): @ruvnet/homecore-frontend Lit+TS+Vite scaffold (3 tests)
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -21,7 +21,7 @@ path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
ruvector = []
|
||||
ruvector = ["dep:ruvector-core", "dep:sha2"]
|
||||
|
||||
[dependencies]
|
||||
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||
@@ -53,5 +53,9 @@ tracing = "0.1"
|
||||
# Trait objects for SemanticIndex
|
||||
async-trait = "0.1"
|
||||
|
||||
# P2: ruvector-core HNSW index + sha2 for hash-based embeddings (ruvector feature)
|
||||
ruvector-core = { version = "2.2.0", optional = true, default-features = false }
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
|
||||
@@ -16,6 +16,7 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::debug;
|
||||
|
||||
use homecore::entity::{EntityId, State};
|
||||
@@ -41,12 +42,30 @@ pub enum RecorderError {
|
||||
///
|
||||
/// The no-op [`NullSemanticIndex`] is used in P1. P2 ships a ruvector-backed
|
||||
/// implementation behind the `ruvector` feature flag.
|
||||
///
|
||||
/// ## P2 API change
|
||||
///
|
||||
/// The `insert_state` method now accepts a `state_id` (SQLite rowid) so the
|
||||
/// HNSW index can map vector results back to SQLite rows. `search` embeds a
|
||||
/// free-text query and returns `(state_id, score)` pairs.
|
||||
#[async_trait]
|
||||
pub trait SemanticIndex: Send + Sync {
|
||||
/// Index a new state write. Called after the SQLite insert succeeds.
|
||||
/// Implementations must be infallible from the caller's perspective:
|
||||
/// if the index is unavailable the recorder keeps running.
|
||||
async fn index_state(&self, state: &Arc<State>) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
/// Insert an embedding for `state` keyed by its SQLite `state_id`.
|
||||
/// Called after the SQLite insert succeeds. Must not propagate errors
|
||||
/// back to the recorder — failure is logged, not fatal.
|
||||
async fn insert_state(
|
||||
&mut self,
|
||||
state_id: i64,
|
||||
state: &State,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
/// Search for the `k` nearest states to the free-text `query`.
|
||||
/// Returns `(state_id, score)` pairs sorted by ascending distance.
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
k: usize,
|
||||
) -> Result<Vec<(i64, f32)>, Box<dyn std::error::Error + Send + Sync>>;
|
||||
}
|
||||
|
||||
/// No-op `SemanticIndex`. Used by default when the `ruvector` feature is off.
|
||||
@@ -54,17 +73,33 @@ pub struct NullSemanticIndex;
|
||||
|
||||
#[async_trait]
|
||||
impl SemanticIndex for NullSemanticIndex {
|
||||
async fn index_state(&self, _state: &Arc<State>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
async fn insert_state(
|
||||
&mut self,
|
||||
_state_id: i64,
|
||||
_state: &State,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search(
|
||||
&self,
|
||||
_query: &str,
|
||||
_k: usize,
|
||||
) -> Result<Vec<(i64, f32)>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
/// The recorder. Cheap to clone (Arc-backed pool). Pass copies to the
|
||||
/// `RecorderListener` and the API history handler.
|
||||
///
|
||||
/// The `semantic` field is wrapped in `Arc<RwLock<...>>` so that
|
||||
/// `insert_state` (which takes `&mut self` on the trait) can be called
|
||||
/// without requiring `&mut Recorder` from callers.
|
||||
#[derive(Clone)]
|
||||
pub struct Recorder {
|
||||
pool: SqlitePool,
|
||||
semantic: Arc<dyn SemanticIndex>,
|
||||
semantic: Arc<RwLock<dyn SemanticIndex>>,
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
@@ -75,13 +110,13 @@ impl Recorder {
|
||||
/// The schema DDL uses `CREATE TABLE IF NOT EXISTS` so calling this on an
|
||||
/// existing database is safe.
|
||||
pub async fn open(path: &str) -> Result<Self, RecorderError> {
|
||||
Self::open_with_index(path, Arc::new(NullSemanticIndex)).await
|
||||
Self::open_with_index(path, Arc::new(RwLock::new(NullSemanticIndex))).await
|
||||
}
|
||||
|
||||
/// Open with a custom `SemanticIndex` (P2 entry point).
|
||||
pub async fn open_with_index(
|
||||
path: &str,
|
||||
semantic: Arc<dyn SemanticIndex>,
|
||||
semantic: Arc<RwLock<dyn SemanticIndex>>,
|
||||
) -> Result<Self, RecorderError> {
|
||||
let options = path
|
||||
.parse::<SqliteConnectOptions>()
|
||||
@@ -172,13 +207,80 @@ impl Recorder {
|
||||
let state_id = result.last_insert_rowid();
|
||||
|
||||
// Best-effort semantic indexing — failure is logged, not propagated.
|
||||
if let Err(e) = self.semantic.index_state(new_state).await {
|
||||
tracing::warn!(error = %e, entity_id = %new_state.entity_id, "semantic indexing failed");
|
||||
if let Err(e) = self
|
||||
.semantic
|
||||
.write()
|
||||
.await
|
||||
.insert_state(state_id, new_state)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
entity_id = %new_state.entity_id,
|
||||
"semantic indexing failed"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Some(state_id))
|
||||
}
|
||||
|
||||
/// Search for state history rows that semantically match `query`.
|
||||
///
|
||||
/// Uses the HNSW index to find the top-`k` nearest state embeddings,
|
||||
/// then fetches the full `StateRow` from SQLite for each result.
|
||||
/// Returns rows in ascending score (distance) order.
|
||||
///
|
||||
/// With the default `NullSemanticIndex` (no `ruvector` feature) this
|
||||
/// always returns an empty `Vec`.
|
||||
pub async fn search_semantic(
|
||||
&self,
|
||||
query: &str,
|
||||
k: usize,
|
||||
) -> Result<Vec<StateRow>, RecorderError> {
|
||||
let hits = self
|
||||
.semantic
|
||||
.read()
|
||||
.await
|
||||
.search(query, k)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut rows = Vec::with_capacity(hits.len());
|
||||
for (state_id, _score) in hits {
|
||||
let row: Option<(String, String, Option<String>, f64, f64, Option<String>)> =
|
||||
sqlx::query_as(
|
||||
"SELECT s.entity_id, s.state, sa.shared_attrs, \
|
||||
s.last_changed_ts, s.last_updated_ts, s.context_id \
|
||||
FROM states s \
|
||||
LEFT JOIN state_attributes sa ON s.attributes_id = sa.attributes_id \
|
||||
WHERE s.state_id = ?",
|
||||
)
|
||||
.bind(state_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
if let Some((entity_id, state, shared_attrs, last_changed_ts, last_updated_ts, context_id)) = row {
|
||||
let eid = EntityId::parse(&entity_id)
|
||||
.unwrap_or_else(|_| EntityId::parse("unknown.unknown").unwrap());
|
||||
let attributes = shared_attrs
|
||||
.as_deref()
|
||||
.map(serde_json::from_str)
|
||||
.transpose()?
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
rows.push(StateRow {
|
||||
state_id,
|
||||
entity_id: eid,
|
||||
state,
|
||||
attributes,
|
||||
last_changed_ts,
|
||||
last_updated_ts,
|
||||
context_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Persist a `DomainEvent`. Returns the `event_id`.
|
||||
pub async fn record_event(&self, event: &DomainEvent) -> Result<i64, RecorderError> {
|
||||
let data_json = serde_json::to_string(&event.event_data)?;
|
||||
|
||||
@@ -1,54 +1,273 @@
|
||||
//! Semantic indexing for state attributes — P2 ruvector integration point.
|
||||
//! Ruvector-backed semantic index — ADR-132 P2.
|
||||
//!
|
||||
//! This module is **feature-gated** (`--features ruvector`). The trait
|
||||
//! `SemanticIndex` is defined in [`crate::db`] so it is always available.
|
||||
//! This module provides the ruvector-backed implementation that will ship
|
||||
//! once the embedding model boundary is finalised in P2.
|
||||
//! ## Embedding strategy (P2 — hash-based)
|
||||
//!
|
||||
//! ## P2 plan
|
||||
//! To keep the recorder self-contained and avoid an ML model dependency at P2,
|
||||
//! state attributes are embedded by a deterministic SHA-256 hash procedure:
|
||||
//!
|
||||
//! 1. Add `ruvector-core` + `ruvector-attention` as optional dependencies.
|
||||
//! 2. Implement `RuvectorSemanticIndex` here, embedding the serialised
|
||||
//! `State.attributes` JSON into a fixed-dimension vector and inserting
|
||||
//! it into a ruvector HNSW index keyed by `state_id`.
|
||||
//! 3. Expose a `search(query: &str, k: usize) -> Vec<StateRow>` helper on
|
||||
//! `Recorder` that converts the query string to an embedding and calls
|
||||
//! `ruvector_core::HnswIndex::search`.
|
||||
//! 1. Canonicalise the state as `"{entity_id}={state}|{attributes_json}"`.
|
||||
//! 2. SHA-256 hash → 32 bytes.
|
||||
//! 3. Interpret the 32 bytes as 8 × `i32` (big-endian), cast to `f32`.
|
||||
//! 4. L2-normalise the resulting 8-element vector.
|
||||
//!
|
||||
//! ## Why deferred
|
||||
//! This gives stable, reproducible 8-dimensional unit vectors suitable for
|
||||
//! cosine-distance HNSW search. Semantic similarity is **not** captured (two
|
||||
//! states with the same value but different entity IDs will differ). P3 will
|
||||
//! replace this with a learned sentence-embedding via `ruvector-attention`.
|
||||
//!
|
||||
//! The embedding model boundary (which model, what dimension, cosine vs
|
||||
//! dot-product) is still TBD as of ADR-132. Shipping a concrete
|
||||
//! implementation now would couple the recorder to a specific ruvector
|
||||
//! version that may need to change once the embedding model is chosen.
|
||||
//! The no-op `NullSemanticIndex` in P1 keeps the interface stable without
|
||||
//! locking in that choice.
|
||||
|
||||
use std::sync::Arc;
|
||||
//! ## P3 plan
|
||||
//!
|
||||
//! Replace `embed_bytes` with a call to
|
||||
//! `ruvector_attention::SentenceEmbedding::encode(&text)` for true semantic
|
||||
//! similarity. Increase `EMBEDDING_DIM` to 384 at that point.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use homecore::entity::State;
|
||||
use ruvector_core::{
|
||||
types::{DbOptions, DistanceMetric, HnswConfig, SearchQuery, VectorEntry},
|
||||
VectorDB,
|
||||
};
|
||||
|
||||
use crate::db::SemanticIndex;
|
||||
|
||||
/// Stub ruvector-backed semantic index.
|
||||
/// Dimensionality of the hash-based embedding vectors.
|
||||
///
|
||||
/// Will be replaced by a real implementation in P2 once the embedding
|
||||
/// model boundary is confirmed. Currently logs and no-ops.
|
||||
pub struct RuvectorSemanticIndex;
|
||||
/// 8 dimensions: each SHA-256 chunk of 4 bytes becomes one `f32` component.
|
||||
/// Increase to 384 in P3 when switching to learned embeddings.
|
||||
pub const EMBEDDING_DIM: usize = 8;
|
||||
|
||||
/// Ruvector-backed `SemanticIndex` using in-memory HNSW and hash embeddings.
|
||||
///
|
||||
/// The index lives entirely in process memory. A restart clears it; P3 will
|
||||
/// add persistence via `ruvector-core`'s `storage` feature.
|
||||
pub struct RuvectorSemanticIndex {
|
||||
db: VectorDB,
|
||||
}
|
||||
|
||||
impl RuvectorSemanticIndex {
|
||||
/// Create a new in-memory HNSW index with the given `max_elements` capacity.
|
||||
///
|
||||
/// Uses cosine distance to match the unit-normalised hash embeddings.
|
||||
pub fn new(max_elements: usize) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let options = DbOptions {
|
||||
dimensions: EMBEDDING_DIM,
|
||||
distance_metric: DistanceMetric::Cosine,
|
||||
// storage path is ignored when the `storage` feature is off
|
||||
storage_path: ":memory:".to_string(),
|
||||
hnsw_config: Some(HnswConfig {
|
||||
m: 16,
|
||||
ef_construction: 100,
|
||||
ef_search: 50,
|
||||
max_elements,
|
||||
}),
|
||||
quantization: None,
|
||||
};
|
||||
let db = VectorDB::new(options)?;
|
||||
Ok(Self { db })
|
||||
}
|
||||
|
||||
/// Embed a `State` to a deterministic 8-dimensional unit vector.
|
||||
///
|
||||
/// Canonical form: `"{entity_id}={state}|{attributes_json}"`
|
||||
/// The attributes JSON is sorted-key (via `serde_json`'s default ordering
|
||||
/// of `Map`, which preserves insertion order). For strict canonicalisation
|
||||
/// at P3, sort keys explicitly.
|
||||
pub fn embed_state(state: &State) -> Vec<f32> {
|
||||
let attrs = state.attributes.to_string();
|
||||
let input = format!("{}={}|{}", state.entity_id, state.state, attrs);
|
||||
Self::embed_str(&input)
|
||||
}
|
||||
|
||||
/// Embed an arbitrary string to a deterministic 8-dimensional unit vector.
|
||||
pub fn embed_str(input: &str) -> Vec<f32> {
|
||||
embed_bytes(input.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA-256 → 8 × f32 unit vector.
|
||||
///
|
||||
/// Split the 32-byte digest into 8 chunks of 4 bytes. Interpret each chunk
|
||||
/// as a big-endian `i32`, cast to `f32`, then L2-normalise.
|
||||
fn embed_bytes(data: &[u8]) -> Vec<f32> {
|
||||
let digest = Sha256::digest(data);
|
||||
let mut raw: Vec<f32> = digest
|
||||
.chunks_exact(4)
|
||||
.map(|chunk| {
|
||||
let bytes: [u8; 4] = chunk.try_into().expect("chunk is exactly 4 bytes");
|
||||
i32::from_be_bytes(bytes) as f32
|
||||
})
|
||||
.collect();
|
||||
|
||||
// L2-normalise
|
||||
let norm = raw.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 1e-10 {
|
||||
for v in &mut raw {
|
||||
*v /= norm;
|
||||
}
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SemanticIndex for RuvectorSemanticIndex {
|
||||
async fn index_state(
|
||||
&self,
|
||||
state: &Arc<State>,
|
||||
async fn insert_state(
|
||||
&mut self,
|
||||
state_id: i64,
|
||||
state: &State,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// P2 TODO: embed state.attributes JSON → f32 vector → ruvector insert.
|
||||
tracing::debug!(
|
||||
entity_id = %state.entity_id,
|
||||
"ruvector semantic index: P2 stub — not yet implemented"
|
||||
);
|
||||
let vector = Self::embed_state(state);
|
||||
let entry = VectorEntry {
|
||||
id: Some(state_id.to_string()),
|
||||
vector,
|
||||
metadata: None,
|
||||
};
|
||||
self.db.insert(entry)?;
|
||||
tracing::debug!(state_id, entity_id = %state.entity_id, "semantic index: inserted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
k: usize,
|
||||
) -> Result<Vec<(i64, f32)>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let vector = Self::embed_str(query);
|
||||
let results = self.db.search(SearchQuery {
|
||||
vector,
|
||||
k,
|
||||
filter: None,
|
||||
ef_search: None,
|
||||
})?;
|
||||
let hits = results
|
||||
.into_iter()
|
||||
.filter_map(|r| r.id.parse::<i64>().ok().map(|id| (id, r.score)))
|
||||
.collect();
|
||||
Ok(hits)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use homecore::entity::{EntityId, State};
|
||||
use homecore::event::Context;
|
||||
|
||||
use super::*;
|
||||
use crate::db::{Recorder, SemanticIndex};
|
||||
|
||||
fn make_state(entity_id: &str, state_val: &str, attrs: serde_json::Value) -> State {
|
||||
let eid = EntityId::parse(entity_id).unwrap();
|
||||
let ctx = Context::new();
|
||||
State::new(eid, state_val, attrs, ctx)
|
||||
}
|
||||
|
||||
// ── embed_state ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn embed_state_is_deterministic() {
|
||||
let s = make_state("light.kitchen", "on", serde_json::json!({"brightness": 200}));
|
||||
let v1 = RuvectorSemanticIndex::embed_state(&s);
|
||||
let v2 = RuvectorSemanticIndex::embed_state(&s);
|
||||
assert_eq!(v1, v2, "same input must produce identical embedding");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_state_is_unit_norm() {
|
||||
let s = make_state("sensor.temp", "22.5", serde_json::json!({"unit": "C"}));
|
||||
let v = RuvectorSemanticIndex::embed_state(&s);
|
||||
let norm_sq: f32 = v.iter().map(|x| x * x).sum();
|
||||
assert!(
|
||||
(norm_sq - 1.0).abs() < 1e-5,
|
||||
"embedding must be unit-norm, got norm^2={norm_sq}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_state_dim_is_correct() {
|
||||
let s = make_state("binary_sensor.door", "off", serde_json::json!({}));
|
||||
let v = RuvectorSemanticIndex::embed_state(&s);
|
||||
assert_eq!(v.len(), EMBEDDING_DIM);
|
||||
}
|
||||
|
||||
// ── RuvectorSemanticIndex insert + search ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_then_search_finds_state() {
|
||||
let mut idx = RuvectorSemanticIndex::new(1000).unwrap();
|
||||
let state = make_state("light.living_room", "on", serde_json::json!({"brightness": 255}));
|
||||
idx.insert_state(42, &state).await.unwrap();
|
||||
|
||||
// Query the same canonical string used by embed_state
|
||||
let query = format!(
|
||||
"{}={}|{}",
|
||||
state.entity_id, state.state, state.attributes
|
||||
);
|
||||
let hits = idx.search(&query, 5).await.unwrap();
|
||||
assert!(!hits.is_empty(), "search must return at least one hit");
|
||||
assert_eq!(hits[0].0, 42, "top hit must be the inserted state_id");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_ordering_closer_entity_ranks_first() {
|
||||
let mut idx = RuvectorSemanticIndex::new(1000).unwrap();
|
||||
|
||||
let s_a = make_state("light.office", "on", serde_json::json!({"brightness": 100}));
|
||||
let s_b = make_state("switch.fan", "off", serde_json::json!({}));
|
||||
|
||||
idx.insert_state(1, &s_a).await.unwrap();
|
||||
idx.insert_state(2, &s_b).await.unwrap();
|
||||
|
||||
// Query identical to s_a's canonical form → s_a must rank first
|
||||
let query_a = format!("{}={}|{}", s_a.entity_id, s_a.state, s_a.attributes);
|
||||
let hits = idx.search(&query_a, 2).await.unwrap();
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert_eq!(
|
||||
hits[0].0, 1,
|
||||
"state matching the query must rank first; got {:?}",
|
||||
hits
|
||||
);
|
||||
}
|
||||
|
||||
// ── Recorder end-to-end with RuvectorSemanticIndex ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn recorder_search_semantic_returns_recorded_state() {
|
||||
use homecore::event::StateChangedEvent;
|
||||
use chrono::Utc;
|
||||
|
||||
let idx = Arc::new(RwLock::new(
|
||||
RuvectorSemanticIndex::new(1000).unwrap(),
|
||||
));
|
||||
let semantic: Arc<RwLock<dyn SemanticIndex>> = idx;
|
||||
let recorder = Recorder::open_with_index("sqlite::memory:", semantic)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = Arc::new(make_state(
|
||||
"sensor.humidity",
|
||||
"65",
|
||||
serde_json::json!({"unit": "%"}),
|
||||
));
|
||||
let event = StateChangedEvent {
|
||||
entity_id: state.entity_id.clone(),
|
||||
old_state: None,
|
||||
new_state: Some(state.clone()),
|
||||
fired_at: Utc::now(),
|
||||
};
|
||||
let state_id = recorder.record_state(&event).await.unwrap().unwrap();
|
||||
|
||||
// Query using the entity prefix — close enough embedding to find it
|
||||
let query = format!("{}={}|{}", state.entity_id, state.state, state.attributes);
|
||||
let rows = recorder.search_semantic(&query, 5).await.unwrap();
|
||||
assert!(!rows.is_empty(), "search_semantic must return at least one row");
|
||||
assert_eq!(
|
||||
rows[0].state_id, state_id,
|
||||
"returned row must match the recorded state"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user