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
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.vite/
*.tsbuildinfo
coverage/

69
frontend/README.md Normal file
View File

@ -0,0 +1,69 @@
# @ruvnet/homecore-frontend
HOMECORE web UI — built with Lit 3, TypeScript, and Vite.
Design system mirrors the cognitum-v0 / v0-appliance dashboard (ADR-131).
## Quick start
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
The Vite dev server proxies `/api``http://localhost:8123`, so you need a
`homecore-api-server` (or the `wifi-densepose-sensing-server` crate) running on `:8123`.
## Scripts
| Script | Description |
|--------|-------------|
| `npm run dev` | Start Vite dev server on port 5173 |
| `npm run build` | TypeScript compile + Vite production bundle → `dist/` |
| `npm run lint` | ESLint on `src/` |
| `npm test` | Vitest unit tests (3 suites, jsdom) |
## Package layout
```
frontend/
src/
api/
client.ts # fetch + WebSocket client (REST + WS)
types.ts # TypeScript types matching homecore-api JSON shapes
components/
AppShell.ts # <hc-app-shell> — header + nav + content slot
StateCard.ts # <hc-state-card> — single entity state card
icons/
lucide.ts # Tree-shaken Lucide icon wrapper
styles/
tokens.css # 16 CSS custom properties (--hc-*)
base.css # Typography reset, page shell, nav layout
__tests__/ # Vitest unit tests
index.html # Shell loading src/main.ts
vite.config.ts
tsconfig.json
vitest.config.ts
```
## Design system
Colors, typography, and components mirror the cognitum-v0 dashboard
(`http://cognitum-v0:9000/`). Dark-only; no light-mode. Key tokens:
- `--hc-primary` `#19d4e5` — teal (active nav, focus ring, CTA borders)
- `--hc-accent` `#26d867` — green (success, secondary CTA)
- `--hc-bg` `#0b0e13` — near-black navy page root
- Font: Outfit (display) + JetBrains Mono (mono)
- Icons: Lucide (SVG, `stroke: currentColor`, no icon font)
See `docs/design/HOMECORE-FRONTEND-design-recon.md` for the full recon.
## Architecture notes
- Components are standard Lit `LitElement` custom elements — compatible with
any HTML page and with Home Assistant's Lit-based frontend.
- The REST client uses `fetch`; the WS client uses `WebSocket`. Both accept a
bearer token and are fully typed against the Rust `homecore-api` JSON shapes.
- WASM: `vite.config.ts` enables `.wasm` asset import. Hook up via dynamic
`import('/path/to/module.wasm?init')` when WASM bindings are ready.

18
frontend/index.html Normal file
View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>HOMECORE</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<hc-app-shell></hc-app-shell>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4429
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "@ruvnet/homecore-frontend",
"version": "0.1.0-alpha.0",
"description": "HOMECORE web UI — Lit + TypeScript + Vite, cognitum-v0 design system",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src --ext .ts",
"test": "vitest run"
},
"dependencies": {
"lit": "^3.2.1",
"lucide": "^0.474.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"eslint": "^9.17.0",
"jsdom": "^25.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.6",
"vitest": "^2.1.8"
}
}

View File

@ -0,0 +1,82 @@
/**
* Unit tests for <hc-state-card>.
* Verifies that the component renders entity_id and state value into the DOM.
*
* Uses jsdom (via vitest environment) no real browser required.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import type { StateView } from '../api/types.js';
// Register the custom element before tests run
beforeAll(async () => {
// jsdom does not support Lit's adoptedStyleSheets; suppress the error.
if (typeof document !== 'undefined' && !document.adoptedStyleSheets) {
Object.defineProperty(document, 'adoptedStyleSheets', { value: [], writable: true });
}
await import('../components/StateCard.js');
});
function makeState(overrides: Partial<StateView> = {}): StateView {
return {
entity_id: 'light.living_room',
state: 'on',
attributes: { brightness: 255 },
last_changed: '2026-05-25T10:00:00Z',
last_updated: '2026-05-25T10:00:00Z',
context: { id: 'abc123', user_id: null, parent_id: null },
...overrides,
};
}
describe('StateCard', () => {
it('renders entity_id in the DOM', async () => {
const el = document.createElement('hc-state-card') as HTMLElement & { state: StateView };
el.state = makeState();
document.body.appendChild(el);
// Lit renders synchronously into shadow root after a microtask
await el.updateComplete;
const shadowRoot = el.shadowRoot!;
const entityEl = shadowRoot.querySelector('.entity-id');
expect(entityEl).not.toBeNull();
expect(entityEl!.textContent).toContain('light.living_room');
document.body.removeChild(el);
});
it('renders the state value', async () => {
const el = document.createElement('hc-state-card') as HTMLElement & { state: StateView };
el.state = makeState({ state: 'off' });
document.body.appendChild(el);
await el.updateComplete;
const stateEl = el.shadowRoot!.querySelector('.state-value');
expect(stateEl).not.toBeNull();
expect(stateEl!.textContent).toBe('off');
document.body.removeChild(el);
});
it('applies .off badge class for unavailable state', async () => {
const el = document.createElement('hc-state-card') as HTMLElement & { state: StateView };
el.state = makeState({ state: 'unavailable' });
document.body.appendChild(el);
await el.updateComplete;
const badge = el.shadowRoot!.querySelector('.badge.off');
expect(badge).not.toBeNull();
document.body.removeChild(el);
});
});
// Augment for updateComplete
declare global {
interface HTMLElement {
updateComplete: Promise<boolean>;
}
}

View File

@ -0,0 +1,67 @@
/**
* Unit tests for HomecoreClient REST methods.
* Mocks global `fetch` and asserts correct URL + Authorization header.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HomecoreClient } from '../api/client.js';
describe('HomecoreClient', () => {
const token = 'test-bearer-token';
let client: HomecoreClient;
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
client = new HomecoreClient({ token });
fetchSpy = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
} as Response);
vi.stubGlobal('fetch', fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('getStates() GETs /api/states with the bearer header', async () => {
await client.getStates();
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(url).toBe('/api/states');
expect((init.headers as Record<string, string>)['Authorization']).toBe(`Bearer ${token}`);
expect(init.method).toBe('GET');
});
it('getState() GETs /api/states/:entity_id with the bearer header', async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ entity_id: 'light.living', state: 'on', attributes: {}, last_changed: '', last_updated: '', context: { id: 'x', user_id: null, parent_id: null } }),
} as Response);
await client.getState('light.living');
const [url] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(url).toBe('/api/states/light.living');
});
it('getConfig() GETs /api/config', async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ location_name: 'Home', version: '0.1.0', state: 'RUNNING', components: [] }),
} as Response);
await client.getConfig();
const [url] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(url).toBe('/api/config');
});
it('throws on non-OK response', async () => {
fetchSpy.mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized' } as Response);
await expect(client.getStates()).rejects.toThrow('401');
});
});

View File

@ -0,0 +1,66 @@
/**
* Validates that tokens.css contains all 16 documented HOMECORE design tokens.
* Reads the file from disk and checks for each CSS custom property name.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const tokensPath = resolve(__dirname, '../styles/tokens.css');
const css = readFileSync(tokensPath, 'utf-8');
/**
* The 16 design tokens from ADR-131 §9 / HOMECORE-FRONTEND-design-recon.md §1.
* 4 surfaces + 2 text + 6 accent/status + 2 border/ring + 2 radius = 16 tokens.
*/
const REQUIRED_TOKENS = [
// Surfaces (4)
'--hc-bg',
'--hc-surface-card',
'--hc-surface-elevated',
'--hc-surface-overlay',
// Text (2)
'--hc-text',
'--hc-text-muted',
// Accent palette (6)
'--hc-primary',
'--hc-primary-fg',
'--hc-accent',
'--hc-accent-fg',
'--hc-destructive',
'--hc-warning',
// Borders & rings (2)
'--hc-border',
'--hc-ring',
// Radii (2)
'--hc-radius',
'--hc-radius-sm',
] as const;
describe('tokens.css', () => {
it('contains all 16 documented design tokens', () => {
for (const token of REQUIRED_TOKENS) {
expect(css, `Missing token: ${token}`).toContain(token);
}
});
it('has exactly 16 (or more) --hc- custom properties', () => {
const matches = css.match(/--hc-[\w-]+\s*:/g) ?? [];
// De-duplicate (token may appear in comments)
const unique = new Set(matches.map(m => m.replace(/\s*:/, '')));
expect(unique.size).toBeGreaterThanOrEqual(16);
});
it('defines the teal primary token with the correct hue value', () => {
// --hc-primary must reference HSL hue 185 (teal, from cognitum-v0)
expect(css).toMatch(/--hc-primary\s*:\s*hsl\(185/);
});
it('defines the green accent token (#26d867)', () => {
// --hc-accent must reference HSL 142 70% 50%
expect(css).toMatch(/--hc-accent\s*:\s*hsl\(142/);
});
});

132
frontend/src/api/client.ts Normal file
View File

@ -0,0 +1,132 @@
/**
* HOMECORE API client.
*
* REST: fetch-based, bearer token auth. Base URL defaults to window.location.origin
* so the Vite dev-server proxy handles the `/api` `:8123` rewrite.
* WS: native WebSocket, mirrors HA's ws handshake protocol (auth_required auth auth_ok).
*/
import type {
ApiConfig,
ServiceDomainView,
StateView,
WsAuthOk,
WsAuthRequired,
WsServerMessage,
} from './types.js';
export interface ClientOptions {
baseUrl?: string;
token: string;
}
export class HomecoreClient {
private readonly base: string;
private readonly token: string;
constructor(options: ClientOptions) {
this.base = options.baseUrl ?? '';
this.token = options.token;
}
// ── REST helpers ────────────────────────────────────────────────────────────
private headers(): HeadersInit {
return {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
};
}
private async get<T>(path: string): Promise<T> {
const resp = await fetch(`${this.base}${path}`, {
method: 'GET',
headers: this.headers(),
});
if (!resp.ok) {
throw new Error(`GET ${path}${resp.status} ${resp.statusText}`);
}
return resp.json() as Promise<T>;
}
private async post<T>(path: string, body: unknown): Promise<T> {
const resp = await fetch(`${this.base}${path}`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify(body),
});
if (!resp.ok) {
throw new Error(`POST ${path}${resp.status} ${resp.statusText}`);
}
return resp.json() as Promise<T>;
}
// ── REST endpoints (mirrors rest.rs) ─────────────────────────────────────
getConfig(): Promise<ApiConfig> {
return this.get<ApiConfig>('/api/config');
}
getStates(): Promise<StateView[]> {
return this.get<StateView[]>('/api/states');
}
getState(entityId: string): Promise<StateView> {
return this.get<StateView>(`/api/states/${encodeURIComponent(entityId)}`);
}
setState(entityId: string, state: string, attributes?: Record<string, unknown>): Promise<StateView> {
return this.post<StateView>(`/api/states/${encodeURIComponent(entityId)}`, {
state,
attributes: attributes ?? {},
});
}
getServices(): Promise<ServiceDomainView[]> {
return this.get<ServiceDomainView[]>('/api/services');
}
callService(domain: string, service: string, data?: unknown): Promise<unknown> {
return this.post<unknown>(`/api/services/${domain}/${service}`, data ?? {});
}
// ── WebSocket ────────────────────────────────────────────────────────────
/**
* Open an authenticated WebSocket connection.
* Resolves once `auth_ok` is received; rejects on auth failure or network error.
* Returns the live socket; caller is responsible for `.close()`.
*/
openWebSocket(wsBase?: string): Promise<WebSocket> {
const resolved = wsBase ?? this.base.replace(/^http/, 'ws');
const origin = resolved || window.location.origin.replace(/^http/, 'ws');
const url = `${origin}/api/websocket`;
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.onmessage = (evt: MessageEvent<string>) => {
const msg = JSON.parse(evt.data) as WsServerMessage;
if ((msg as WsAuthRequired).type === 'auth_required') {
ws.send(JSON.stringify({ type: 'auth', access_token: this.token }));
return;
}
if ((msg as WsAuthOk).type === 'auth_ok') {
ws.onmessage = null;
resolve(ws);
return;
}
if (msg.type === 'auth_invalid') {
ws.close();
reject(new Error(`WS auth_invalid`));
}
};
ws.onerror = () => reject(new Error('WebSocket connection error'));
ws.onclose = () => reject(new Error('WebSocket closed before auth_ok'));
});
}
}

98
frontend/src/api/types.ts Normal file
View File

@ -0,0 +1,98 @@
/**
* TypeScript types mirroring the JSON shapes from homecore-api/src/rest.rs and ws.rs.
* Keep in sync with Rust `StateView`, `ApiConfig`, `ServiceDomainView`.
*/
/** Context for a state change — mirrors Rust `ContextView`. */
export interface ContextView {
id: string;
user_id: string | null;
parent_id: string | null;
}
/** Snapshot of a single entity state — mirrors Rust `StateView`. */
export interface StateView {
entity_id: string;
state: string;
/** Arbitrary JSON attributes attached to the entity. */
attributes: Record<string, unknown>;
/** RFC 3339 timestamp of last state value change. */
last_changed: string;
/** RFC 3339 timestamp of last update (attributes may have changed). */
last_updated: string;
context: ContextView;
}
/** HOMECORE configuration — mirrors Rust `ApiConfig`. */
export interface ApiConfig {
location_name: string;
version: string;
state: 'RUNNING' | 'STARTING' | 'STOPPING';
components: string[];
}
/** Services grouped by domain — mirrors Rust `ServiceDomainView`. */
export interface ServiceDomainView {
domain: string;
/** Keyed by service name; value is the service schema (may be empty `{}`). */
services: Record<string, unknown>;
}
// ── WebSocket protocol types ──────────────────────────────────────────────────
/** Sent by server immediately upon WS upgrade. */
export interface WsAuthRequired {
type: 'auth_required';
ha_version: string;
}
/** Sent by client to authenticate. */
export interface WsAuth {
type: 'auth';
access_token: string;
}
/** Sent by server on successful auth. */
export interface WsAuthOk {
type: 'auth_ok';
ha_version: string;
}
/** Sent by server on failed auth. */
export interface WsAuthInvalid {
type: 'auth_invalid';
message: string;
}
/** Generic result message from server. */
export interface WsResult<T = unknown> {
id: number;
type: 'result';
success: boolean;
result?: T;
error?: { code: string; message: string };
}
/** State-changed event pushed by server via `subscribe_events`. */
export interface WsStateChangedEvent {
id: number;
type: 'event';
event: {
event_type: 'state_changed';
data: {
entity_id: string;
old_state: StateView | null;
new_state: StateView | null;
};
origin: 'LOCAL' | 'REMOTE';
time_fired: string;
};
}
/** Union of all inbound WS server messages. */
export type WsServerMessage =
| WsAuthRequired
| WsAuthOk
| WsAuthInvalid
| WsResult
| WsStateChangedEvent;

View File

@ -0,0 +1,194 @@
/**
* `<hc-app-shell>` top-level layout: sticky header + horizontal sidenav + content slot.
* Page shell mirrors cognitum-v0's appbar + wrap layout (ADR-131 §3).
*/
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
export interface NavItem {
id: string;
label: string;
/** Raw SVG string for the icon */
iconSvg?: string;
}
const DEFAULT_NAV: NavItem[] = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'states', label: 'States' },
{ id: 'services', label: 'Services' },
{ id: 'settings', label: 'Settings' },
];
@customElement('hc-app-shell')
export class AppShell extends LitElement {
@property({ type: String }) locationName = 'HOMECORE';
@property({ type: String }) version = '0.1.0';
@property({ type: Array }) navItems: NavItem[] = DEFAULT_NAV;
@state() private activeId = 'dashboard';
static styles = css`
:host { display: block; min-height: 100dvh; background: var(--hc-bg, #0b0e13); }
/* ── Appbar ── */
.appbar {
position: sticky;
top: 0;
z-index: 50;
background: hsl(220 25% 6% / 0.9);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid hsl(220 15% 18% / 0.8);
display: flex;
align-items: center;
gap: 1rem;
padding: 0 1.25rem;
height: 3.25rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--hc-font-display, 'Outfit', system-ui, sans-serif);
font-weight: 600;
font-size: 0.9375rem;
color: var(--hc-text, #e6eaee);
white-space: nowrap;
flex-shrink: 0;
}
.brand-icon {
width: 32px;
height: 32px;
border-radius: 0.4rem;
background: var(--hc-primary, #19d4e5);
display: flex;
align-items: center;
justify-content: center;
color: var(--hc-primary-fg, #0b0e13);
font-size: 1rem;
font-weight: 700;
}
.nav {
display: flex;
align-items: center;
gap: 0.25rem;
overflow-x: auto;
scrollbar-width: none;
flex: 1;
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
}
.nav::-webkit-scrollbar { display: none; }
.nav-link {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.7rem;
border-radius: 0.4rem;
font-family: var(--hc-font-display, 'Outfit', system-ui, sans-serif);
font-size: 0.8125rem;
font-weight: 500;
color: var(--hc-text-muted, #7b899d);
background: transparent;
border: none;
cursor: pointer;
white-space: nowrap;
transition: color 150ms, background 150ms;
}
.nav-link:hover {
color: var(--hc-text, #e6eaee);
background: hsl(220 20% 14%);
}
.nav-link:focus-visible {
outline: 2px solid hsl(185 80% 50% / 0.6);
outline-offset: 1px;
}
.nav-link:active { transform: translateY(1px); }
.nav-link.active { color: var(--hc-primary, #19d4e5); }
.nav-link.active::after {
content: '';
position: absolute;
bottom: -2px;
left: 0.7rem;
right: 0.7rem;
height: 2px;
background: var(--hc-primary, #19d4e5);
border-radius: 9999px;
}
.version-chip {
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
font-size: 0.6875rem;
color: var(--hc-text-muted, #7b899d);
white-space: nowrap;
flex-shrink: 0;
}
/* ── Main content ── */
main {
max-width: 1400px;
margin-inline: auto;
padding-inline: 1.25rem;
padding-block: 1.5rem;
}
/* ── Footer ── */
footer {
border-top: 1px solid hsl(220 15% 18%);
text-align: center;
padding: 1rem 1.25rem;
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
font-size: 0.75rem;
color: var(--hc-text-muted, #7b899d);
}
`;
private onNavClick(id: string) {
this.activeId = id;
this.dispatchEvent(new CustomEvent('hc-navigate', { detail: { id }, bubbles: true, composed: true }));
}
render() {
return html`
<header class="appbar" part="appbar">
<div class="brand">
<div class="brand-icon" aria-hidden="true">H</div>
${this.locationName}
</div>
<nav class="nav" aria-label="Primary navigation">
${this.navItems.map(item => html`
<button
class="nav-link ${this.activeId === item.id ? 'active' : ''}"
@click=${() => this.onNavClick(item.id)}
aria-current=${this.activeId === item.id ? 'page' : 'false'}
>${item.label}</button>
`)}
</nav>
<span class="version-chip">v${this.version}</span>
</header>
<main part="content">
<slot></slot>
</main>
<footer part="footer">
HOMECORE &mdash; ${this.locationName} &mdash; v${this.version}
</footer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'hc-app-shell': AppShell;
}
}

View File

@ -0,0 +1,132 @@
/**
* `<hc-state-card>` renders one HOMECORE entity state in the cognitum-v0 card style.
* Uses Lit 3 (LitElement + html/css template tags).
*/
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { StateView } from '../api/types.js';
@customElement('hc-state-card')
export class StateCard extends LitElement {
@property({ type: Object }) state!: StateView;
/** Optional: icon SVG string (use `iconSvg()` from lucide.ts) */
@property({ type: String }) iconSvg?: string;
static styles = css`
:host {
display: block;
}
.card {
background: var(--hc-gradient-card, linear-gradient(180deg, #181c24 0%, #111318 100%));
border: 1px solid hsl(220 15% 18% / 0.5);
border-radius: var(--hc-radius, 0.75rem);
box-shadow: var(--hc-shadow-card, 0 8px 32px -8px hsl(220 25% 2% / 0.8));
padding: 1.25rem;
transition: transform 200ms, border-color 200ms;
}
.card:hover {
transform: translateY(-2px);
border-color: hsl(185 80% 50% / 0.4);
}
.header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.icon-wrap {
flex-shrink: 0;
width: 38px;
height: 38px;
border-radius: var(--hc-radius-sm, 0.4rem);
background: hsl(220 20% 14%);
display: flex;
align-items: center;
justify-content: center;
color: var(--hc-primary, #19d4e5);
}
.meta { flex: 1; min-width: 0; }
.entity-id {
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
font-size: 0.6875rem;
font-weight: 600;
color: var(--hc-text-muted, #7b899d);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
letter-spacing: 0.05em;
}
.state-value {
font-family: var(--hc-font-display, 'Outfit', system-ui, sans-serif);
font-size: 1.125rem;
font-weight: 600;
color: var(--hc-text, #e6eaee);
letter-spacing: -0.02em;
margin-top: 0.2rem;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
border-radius: 9999px;
border: 1px solid var(--hc-border, #272b34);
font-family: var(--hc-font-mono, monospace);
font-size: 0.6875rem;
font-weight: 600;
}
.badge.on { color: #26d867; border-color: hsl(142 70% 50% / 0.4); }
.badge.off { color: #d22c2c; border-color: hsl(0 65% 50% / 0.4); }
.timestamp {
font-family: var(--hc-font-mono, monospace);
font-size: 0.625rem;
color: var(--hc-text-muted, #7b899d);
margin-top: 0.75rem;
}
`;
private badgeClass(state: string): string {
const s = state.toLowerCase();
if (s === 'on' || s === 'open' || s === 'home' || s === 'running') return 'on';
if (s === 'off' || s === 'closed' || s === 'away' || s === 'unavailable') return 'off';
return '';
}
render() {
if (!this.state) return nothing;
const { entity_id, state, last_updated } = this.state;
const badge = this.badgeClass(state);
return html`
<div class="card" part="card">
<div class="header">
${this.iconSvg
? html`<div class="icon-wrap" .innerHTML=${this.iconSvg}></div>`
: nothing}
<div class="meta">
<div class="entity-id" title=${entity_id}>${entity_id}</div>
<div class="state-value">${state}</div>
</div>
<span class="badge ${badge}">${state}</span>
</div>
<div class="timestamp">updated ${new Date(last_updated).toLocaleTimeString()}</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'hc-state-card': StateCard;
}
}

View File

@ -0,0 +1,39 @@
/**
* Minimal Lucide icon wrapper.
* Import only the icons used by HOMECORE components Vite tree-shakes the rest.
*/
export {
Activity,
BarChart3,
Book,
ChevronRight,
Grid2X2,
Home,
LayoutDashboard,
Settings,
Shield,
Sun,
Wifi,
Zap,
} from 'lucide';
/** Re-export the icon node type for consumers that need it. */
export type { IconNode as LucideIconNode } from 'lucide';
/**
* Render a Lucide icon as an SVG string suitable for Lit's `unsafeHTML`.
* Each icon is 24×24, no fill, stroke = currentColor, stroke-width = 2.
*/
export function iconSvg(
paths: string,
{ size = 24, label }: { size?: number; label?: string } = {},
): string {
const ariaAttrs = label
? `role="img" aria-label="${label}"`
: `aria-hidden="true"`;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
${ariaAttrs}>${paths}</svg>`;
}

11
frontend/src/main.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* HOMECORE frontend entry point.
* Imports global styles, registers Lit components, and mounts the app shell.
*/
import './styles/tokens.css';
import './styles/base.css';
// Register custom elements
import './components/AppShell.js';
import './components/StateCard.js';

View File

@ -0,0 +1,224 @@
/**
* HOMECORE base styles typography reset, page shell, nav layout.
* Component vocabulary mirrors cognitum-v0 (ADR-131 §34).
*/
@import './tokens.css';
/* ── Reset ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html {
color-scheme: dark;
font-family: var(--hc-font-display);
font-size: 16px;
background: var(--hc-bg);
color: var(--hc-text);
}
body { min-height: 100dvh; }
/* ── Typography scale ── */
h1 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.02em; }
h2 { font-size: 1.125rem; font-weight: 700; letter-spacing: -0.02em; }
h3 { font-size: 0.9375rem; font-weight: 600; letter-spacing: -0.02em; }
h4 { font-size: 0.875rem; font-weight: 600; letter-spacing: -0.02em; }
p { font-size: 0.875rem; line-height: 1.45; }
.mono { font-family: var(--hc-font-mono); }
/* ── Page shell ── */
.hc-wrap {
max-width: 1400px;
margin-inline: auto;
padding-inline: 1.25rem;
padding-block: 1.5rem;
}
/* ── Appbar ── */
.hc-appbar {
position: sticky;
top: 0;
z-index: 50;
background: hsl(220 25% 6% / 0.9);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--hc-border);
display: flex;
align-items: center;
gap: 1rem;
padding: 0 1.25rem;
height: 3.25rem;
}
.hc-brand {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 0.9375rem;
white-space: nowrap;
flex-shrink: 0;
text-decoration: none;
color: var(--hc-text);
}
.hc-brand-icon {
width: 32px;
height: 32px;
border-radius: 0.4rem;
background: var(--hc-primary);
display: flex;
align-items: center;
justify-content: center;
color: var(--hc-primary-fg);
}
.hc-nav {
display: flex;
align-items: center;
gap: 0.25rem;
overflow-x: auto;
scrollbar-width: none;
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
flex: 1;
}
.hc-nav::-webkit-scrollbar { display: none; }
.hc-nav-link {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.7rem;
border-radius: var(--hc-radius-sm);
font-size: 0.8125rem;
font-weight: 500;
color: var(--hc-text-muted);
text-decoration: none;
white-space: nowrap;
transition: color 150ms, background 150ms;
}
.hc-nav-link:hover {
color: var(--hc-text);
background: hsl(220 20% 14%);
}
.hc-nav-link:focus-visible {
outline: 2px solid hsl(185 80% 50% / 0.6);
outline-offset: 1px;
}
.hc-nav-link:active { transform: translateY(1px); transition-duration: 50ms; }
.hc-nav-link.active {
color: var(--hc-primary);
}
.hc-nav-link.active::after {
content: '';
position: absolute;
bottom: -2px;
left: 0.7rem;
right: 0.7rem;
height: 2px;
background: var(--hc-primary);
border-radius: 9999px;
}
/* ── Card ── */
.hc-card {
background: var(--hc-gradient-card);
border: 1px solid hsl(220 15% 18% / 0.5);
border-radius: var(--hc-radius);
box-shadow: var(--hc-shadow-card);
padding: 1.25rem;
transition: transform 200ms, border-color 200ms;
}
.hc-card:hover {
transform: translateY(-2px);
border-color: hsl(185 80% 50% / 0.4);
}
/* ── Badge ── */
.hc-badge {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
border-radius: var(--hc-radius-pill);
border: 1px solid var(--hc-border);
font-family: var(--hc-font-mono);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.08em;
}
.hc-badge.online { color: var(--hc-accent); border-color: hsl(142 70% 50% / 0.4); }
.hc-badge.offline { color: var(--hc-destructive); border-color: hsl(0 65% 50% / 0.4); }
.hc-badge.warning { color: var(--hc-warning); border-color: hsl(38 80% 60% / 0.4); }
/* ── Button ── */
.hc-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.875rem;
border-radius: var(--hc-radius-sm);
font-family: var(--hc-font-display);
font-size: 0.8125rem;
font-weight: 500;
border: 1px solid var(--hc-border);
background: hsl(220 20% 14%);
color: var(--hc-text);
cursor: pointer;
transition: background 150ms, border-color 150ms;
}
.hc-btn:hover { background: hsl(220 20% 18%); }
.hc-btn.primary {
background: var(--hc-primary);
color: var(--hc-primary-fg);
border-color: transparent;
font-weight: 600;
box-shadow: var(--hc-shadow-glow);
}
.hc-btn.primary:hover { background: hsl(185 80% 55%); }
/* ── Section ── */
.hc-section { margin-bottom: 1.5rem; }
.hc-section-label {
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--hc-text-muted);
margin-bottom: 0.75rem;
}
/* ── Grid helpers ── */
.hc-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 0.75rem;
}
.hc-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 0.75rem;
}
/* ── Footer ── */
.hc-footer {
border-top: 1px solid var(--hc-border);
text-align: center;
padding: 1rem 1.25rem;
font-size: 0.75rem;
color: var(--hc-text-muted);
font-family: var(--hc-font-mono);
}

View File

@ -0,0 +1,45 @@
/**
* HOMECORE design tokens sourced from cognitum-v0 (ADR-131 §9).
* 16 CSS custom properties: 4 surfaces + 2 text + 6 accent/status + 2 border/ring + 2 radius.
* Dark-only; no light-mode overrides.
*/
:root {
/* ── Surfaces (darkest → lightest within dark palette) ── */
--hc-bg: hsl(220 25% 6%); /* #0b0e13 — page root */
--hc-surface-card: hsl(220 20% 10%); /* #14171e — card fill */
--hc-surface-elevated: hsl(220 20% 12%); /* #181c24 — raised panel */
--hc-surface-overlay: hsl(220 20% 8%); /* #111318 — modal / sticky nav base */
/* ── Text ── */
--hc-text: hsl(210 20% 92%); /* #e6eaee — primary body text */
--hc-text-muted: hsl(215 15% 55%); /* #7b899d — secondary / labels / timestamps */
/* ── Accent palette ── */
--hc-primary: hsl(185 80% 50%); /* #19d4e5 — teal: active nav, CTA border, focus ring */
--hc-primary-fg: hsl(220 25% 6%); /* #0b0e13 — text on filled primary buttons */
--hc-accent: hsl(142 70% 50%); /* #26d867 — green: success / secondary CTA */
--hc-accent-fg: hsl(220 25% 6%); /* #0b0e13 — text on filled accent buttons */
--hc-destructive: hsl(0 65% 50%); /* #d22c2c — error / danger */
--hc-warning: hsl(38 80% 60%); /* #e69940 — warning / amber (elevated from inline) */
/* ── Borders & rings ── */
--hc-border: hsl(220 15% 18%); /* #272b34 — subtle 1px border */
--hc-ring: hsl(185 80% 50%); /* #19d4e5 — focus ring (same hue as primary) */
/* ── Radii ── */
--hc-radius: 0.75rem; /* cards, modals */
--hc-radius-sm: 0.4rem; /* buttons, inputs, chips */
--hc-radius-pill: 9999px; /* badges, CTA pills */
/* ── Typography ── */
--hc-font-display: 'Outfit', system-ui, sans-serif;
--hc-font-mono: 'JetBrains Mono', monospace;
/* ── Shadows ── */
--hc-shadow-card: 0 8px 32px -8px hsl(220 25% 2% / 0.8);
--hc-shadow-glow: 0 0 60px -10px hsl(185 80% 50% / 0.3);
/* ── Gradients ── */
--hc-gradient-card: linear-gradient(180deg, hsl(220 20% 12%) 0%, hsl(220 20% 8%) 100%);
}

23
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"experimentalDecorators": true,
"useDefineForClassFields": false,
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

25
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8123',
changeOrigin: true,
ws: true,
},
},
},
build: {
target: 'es2022',
outDir: 'dist',
sourcemap: true,
},
optimizeDeps: {
// Allow WASM async import via dynamic import()
exclude: [],
},
// WASM async import support: vite handles .wasm?init natively
assetsInclude: ['**/*.wasm'],
});

13
frontend/vitest.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: false,
include: ['src/__tests__/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text'],
},
},
});

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"] }

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)?;

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"
);
}
}