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:
ruv
2026-05-25 18:53:12 -04:00
parent 68ae6c0bd6
commit d91ffce1ad
22 changed files with 6066 additions and 45 deletions
+5 -1
View File
@@ -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"] }
+112 -10
View File
@@ -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)?;
+253 -34
View File
@@ -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"
);
}
}