arcisvlm / agents /memory.py
Hardik Sanghvi
feat: integrate Gemma 4 E2B backbone for production-quality VLM inference
7a564e3
Raw
History Blame Contribute Delete
16.1 kB
"""
Memory Layer — per-camera embedding ring buffer and event store for ArcisVLM.
Provides temporal context for the agent system:
- EmbeddingRingBuffer: Fixed-size circular buffer storing JEPA embeddings per camera
- EventStore: SQLite-backed persistent event storage with query/aggregation
- MemoryManager: Orchestrates buffers and events across all cameras
The memory layer enables agents to reason about recent scene history,
detect recurring patterns, and retrieve similar past observations via
cosine similarity search over the embedding ring buffer.
"""
from __future__ import annotations
import hashlib
import json
import logging
import sqlite3
import threading
import time
from typing import Any, List, Optional
import torch
import torch.nn.functional as F
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# EmbeddingRingBuffer — fixed-size circular buffer per camera
# ---------------------------------------------------------------------------
class EmbeddingRingBuffer:
"""
Fixed-size circular buffer that stores the last N JEPA embeddings
for a single camera stream.
Supports cosine similarity search for retrieving the most similar
past embeddings to a query vector.
Args:
camera_id: Unique camera identifier
capacity: Maximum number of embeddings to store
"""
def __init__(self, camera_id: str, capacity: int = 100) -> None:
self._camera_id = camera_id
self._capacity = capacity
self._buffer: list[dict] = []
self._write_pos: int = 0
self._count: int = 0
@property
def camera_id(self) -> str:
return self._camera_id
@property
def capacity(self) -> int:
return self._capacity
@property
def size(self) -> int:
return min(self._count, self._capacity)
@property
def is_full(self) -> bool:
return self._count >= self._capacity
def push(
self,
embedding: torch.Tensor,
timestamp: float,
metadata: dict = None,
) -> None:
"""
Add an embedding to the ring buffer.
When the buffer is full, the oldest entry is overwritten.
Args:
embedding: JEPA embedding tensor (any shape, stored detached on CPU)
timestamp: Wall-clock time of the observation
metadata: Optional extra data (event type, confidence, etc.)
"""
entry = {
"embedding": embedding.detach().cpu(),
"timestamp": timestamp,
"metadata": metadata or {},
}
if self._count < self._capacity:
self._buffer.append(entry)
else:
self._buffer[self._write_pos] = entry
self._write_pos = (self._write_pos + 1) % self._capacity
self._count += 1
def get_recent(self, n: int = 10) -> List[dict]:
"""
Return the last N entries in chronological order (oldest first).
Args:
n: Number of recent entries to retrieve
Returns:
List of dicts with keys: embedding, timestamp, metadata
"""
actual = min(n, self.size)
if actual == 0:
return []
# Determine indices in reverse insertion order (most recent first)
results = []
pos = (self._write_pos - 1) % self._capacity if self.size > 0 else 0
for _ in range(actual):
if pos < 0:
pos = self.size - 1
results.append(self._buffer[pos])
pos = (pos - 1) % self.size
# Return in chronological order (oldest first)
results.reverse()
return results
def similarity_search(
self,
query_embedding: torch.Tensor,
top_k: int = 5,
) -> List[dict]:
"""
Find the top-K most similar embeddings via cosine similarity.
Args:
query_embedding: Query embedding tensor
top_k: Number of results to return
Returns:
List of dicts with keys: embedding, timestamp, metadata, similarity
Sorted by descending similarity.
"""
if self.size == 0:
return []
query = query_embedding.detach().cpu().float()
if query.dim() > 1:
query = query.squeeze()
# Stack all stored embeddings
stored = torch.stack([
entry["embedding"].float().squeeze()
for entry in self._buffer[:self.size]
])
# Cosine similarity: [size]
similarities = F.cosine_similarity(
query.unsqueeze(0),
stored,
dim=1,
)
# Top-K
k = min(top_k, self.size)
top_vals, top_indices = similarities.topk(k)
results = []
for sim_val, idx in zip(top_vals, top_indices):
entry = self._buffer[idx.item()]
results.append({
"embedding": entry["embedding"],
"timestamp": entry["timestamp"],
"metadata": entry["metadata"],
"similarity": sim_val.item(),
})
return results
def clear(self) -> None:
"""Reset the buffer, discarding all stored embeddings."""
self._buffer.clear()
self._write_pos = 0
self._count = 0
# ---------------------------------------------------------------------------
# EventStore — SQLite-backed event persistence
# ---------------------------------------------------------------------------
class EventStore:
"""
SQLite-backed persistent event storage for detection events.
Stores events with camera_id, type, description, confidence,
optional embedding hash for cross-referencing, and JSON metadata.
Thread-safe via SQLite's built-in locking + check_same_thread=False.
Args:
db_path: Path to SQLite database file, or ":memory:" for in-memory DB
"""
def __init__(self, db_path: str = "events.db") -> None:
self.db_path = db_path
self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._create_tables()
def _create_tables(self) -> None:
self._conn.execute("""
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
camera_id TEXT NOT NULL,
event_type TEXT NOT NULL,
description TEXT NOT NULL,
confidence REAL NOT NULL,
embedding_hash TEXT,
metadata TEXT,
timestamp REAL NOT NULL
)
""")
self._conn.execute("""
CREATE INDEX IF NOT EXISTS idx_events_camera
ON events (camera_id, timestamp)
""")
self._conn.execute("""
CREATE INDEX IF NOT EXISTS idx_events_type
ON events (event_type, timestamp)
""")
self._conn.commit()
def record_event(
self,
camera_id: str,
event_type: str,
description: str,
confidence: float,
embedding_hash: str = None,
metadata: dict = None,
) -> int:
"""
Store a detection event.
Args:
camera_id: Source camera
event_type: Event category (e.g., "person_detected", "vehicle", "anomaly")
description: Human-readable description
confidence: Model confidence [0.0, 1.0]
embedding_hash: Optional hash of the associated embedding
metadata: Optional JSON-serializable extra data
Returns:
Row ID of the inserted event
"""
cursor = self._conn.execute(
"""INSERT INTO events
(camera_id, event_type, description, confidence,
embedding_hash, metadata, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(
camera_id,
event_type,
description,
confidence,
embedding_hash,
json.dumps(metadata) if metadata else None,
time.time(),
),
)
self._conn.commit()
return cursor.lastrowid
def query_events(
self,
camera_id: str = None,
event_type: str = None,
since: float = None,
limit: int = 50,
) -> List[dict]:
"""
Query events with optional filters.
Args:
camera_id: Filter by camera (None = all cameras)
event_type: Filter by event type (None = all types)
since: Only events after this Unix timestamp
limit: Maximum number of results
Returns:
List of event dicts, most recent first
"""
clauses = []
params: list[Any] = []
if camera_id is not None:
clauses.append("camera_id = ?")
params.append(camera_id)
if event_type is not None:
clauses.append("event_type = ?")
params.append(event_type)
if since is not None:
clauses.append("timestamp >= ?")
params.append(since)
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
query = f"SELECT * FROM events{where} ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
rows = self._conn.execute(query, params).fetchall()
results = []
for row in rows:
d = dict(row)
if d.get("metadata"):
d["metadata"] = json.loads(d["metadata"])
results.append(d)
return results
def get_event_counts(
self,
camera_id: str = None,
hours: int = 24,
) -> dict:
"""
Count events by type within a time window.
Args:
camera_id: Filter by camera (None = all cameras)
hours: Look-back window in hours
Returns:
Dict mapping event_type -> count
"""
since = time.time() - (hours * 3600)
clauses = ["timestamp >= ?"]
params: list[Any] = [since]
if camera_id is not None:
clauses.append("camera_id = ?")
params.append(camera_id)
where = " WHERE " + " AND ".join(clauses)
query = f"SELECT event_type, COUNT(*) as cnt FROM events{where} GROUP BY event_type"
rows = self._conn.execute(query, params).fetchall()
return {row["event_type"]: row["cnt"] for row in rows}
def cleanup_old(self, days: int = 30) -> int:
"""
Delete events older than the specified number of days.
Args:
days: Age threshold in days
Returns:
Number of deleted rows
"""
cutoff = time.time() - (days * 86400)
cursor = self._conn.execute(
"DELETE FROM events WHERE timestamp < ?", (cutoff,)
)
self._conn.commit()
return cursor.rowcount
def close(self) -> None:
"""Close the database connection."""
self._conn.close()
# ---------------------------------------------------------------------------
# MemoryManager — orchestrates buffers + events
# ---------------------------------------------------------------------------
def _embedding_hash(embedding: torch.Tensor) -> str:
"""Compute a short hash of an embedding for cross-referencing."""
data = embedding.detach().cpu().numpy().tobytes()
return hashlib.sha256(data).hexdigest()[:16]
class MemoryManager:
"""
Central memory orchestrator for the ArcisVLM agent system.
Lazily creates per-camera EmbeddingRingBuffers and provides a unified
interface for recording detections and retrieving camera context.
Args:
buffer_capacity: Ring buffer size per camera
db_path: SQLite database path for the event store
"""
def __init__(
self,
buffer_capacity: int = 100,
db_path: str = "events.db",
) -> None:
self._buffer_capacity = buffer_capacity
self._buffers: dict[str, EmbeddingRingBuffer] = {}
self._lock = threading.Lock()
self.event_store = EventStore(db_path=db_path)
def get_buffer(self, camera_id: str) -> EmbeddingRingBuffer:
"""
Get or lazily create the ring buffer for a camera.
Args:
camera_id: Camera identifier
Returns:
The camera's EmbeddingRingBuffer
"""
with self._lock:
if camera_id not in self._buffers:
self._buffers[camera_id] = EmbeddingRingBuffer(
camera_id=camera_id,
capacity=self._buffer_capacity,
)
logger.info("Created ring buffer for camera '%s'", camera_id)
return self._buffers[camera_id]
def record_detection(
self,
camera_id: str,
embedding: torch.Tensor,
event_type: str,
description: str,
confidence: float,
) -> None:
"""
Record a detection: push embedding to ring buffer and log event.
Args:
camera_id: Source camera
embedding: JEPA embedding for this detection
event_type: Event category
description: Human-readable description
confidence: Model confidence [0.0, 1.0]
"""
timestamp = time.time()
emb_hash = _embedding_hash(embedding)
# Push to ring buffer
buf = self.get_buffer(camera_id)
buf.push(
embedding,
timestamp,
metadata={
"event_type": event_type,
"confidence": confidence,
},
)
# Record in event store
self.event_store.record_event(
camera_id=camera_id,
event_type=event_type,
description=description,
confidence=confidence,
embedding_hash=emb_hash,
metadata={"embedding_hash": emb_hash},
)
def get_scene_descriptor(
self,
camera_id: str,
n_frames: int = 16,
) -> Optional[torch.Tensor]:
"""
Compute a scene descriptor for a camera by averaging recent embeddings.
This is the primary input to the ConditionEncoder's scene context path.
Returns the mean of the last N JEPA embeddings from the ring buffer.
Args:
camera_id: Camera identifier
n_frames: Number of recent frames to average (default: 16)
Returns:
[embed_dim] tensor — mean embedding, or None if no embeddings stored
"""
buf = self.get_buffer(camera_id)
recent = buf.get_recent(n_frames)
if not recent:
return None
embeddings = torch.stack([entry["embedding"].float() for entry in recent])
if embeddings.dim() > 2:
embeddings = embeddings.squeeze()
return embeddings.mean(dim=0)
def get_camera_context(
self,
camera_id: str,
n_recent: int = 5,
) -> dict:
"""
Build a context snapshot for a camera: recent embeddings + events.
Useful for providing temporal context to agents making decisions.
Args:
camera_id: Camera identifier
n_recent: Number of recent items to include
Returns:
Dict with keys:
camera_id, recent_embeddings, recent_events, event_counts
"""
buf = self.get_buffer(camera_id)
recent_embeddings = buf.get_recent(n_recent)
recent_events = self.event_store.query_events(
camera_id=camera_id, limit=n_recent
)
event_counts = self.event_store.get_event_counts(camera_id=camera_id)
return {
"camera_id": camera_id,
"recent_embeddings": recent_embeddings,
"recent_events": recent_events,
"event_counts": event_counts,
"buffer_size": buf.size,
"buffer_full": buf.is_full,
}