fleet-memory/hindsight-api-slim/hindsight_api/engine/search/trace.py
Nicolò Boschi 15ea23d5d6
feat: introduce hindsight-api-slim and hindsight-all-slim packages (#560)
* feat: introduce hindsight-api-slim and hindsight-all-slim packages

Closes #552

- Move all source code from hindsight-api/ to new hindsight-api-slim/
- hindsight-api-slim has heavy ML deps (torch, sentence-transformers,
  transformers, einops, flashrank, mlx, mlx-lm, safetensors) and
  pg0-embedded as optional extras: [local-ml], [embedded-db], [all]
- hindsight-api becomes a zero-code meta-package depending on
  hindsight-api-slim[all] for full backward compatibility
- Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed
- hindsight-all updated to depend on hindsight-api-slim[all]
- pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db]
- Dockerfile: replace sed hack with proper uv sync --extra flags
- Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and
  all path references throughout the repo

* refactor: rename hindsight/ directory to hindsight-all/

* docs: document hindsight-api-slim and hindsight-all-slim package variants

Add package variants table and extras explanation to installation.md

* docs: remove emojis from installation.md, use professional tone

* docs: link Docker slim variant to pip package variants section

* docs: consolidate Docker image variants into single table

* ci: fix working-directory paths after package restructure

- Replace all hindsight-api → hindsight-api-slim in test.yml
- Replace hindsight → hindsight-all in test.yml
- Add --extra embedded-db to test-embed API install step

* ci: add local-ml and embedded-db extras to API sync steps

These extras were previously implicit in the old hindsight-api package
(which bundled everything). Now that hindsight-api-slim uses optional
extras, we must explicitly request local-ml and embedded-db in CI.

* ci: add API install step with embedded-db to test-embed smoke test

The smoke test starts hindsight-api as a daemon, which requires pg0-embedded.
Add a dedicated install step for hindsight-api-slim with embedded-db extra
so the daemon can start successfully.

* ci: remove --no-install-project when using optional extras

When --no-install-project is combined with --extra, the optional deps
are not installed because extras require the project to be active.
Remove --no-install-project from steps that need local-ml or embedded-db.

* ci: fix ordering of uv sync steps to preserve optional extras

When uv sync runs for a different workspace member, it removes optional
extras installed for other members. Fix by always running extra-requiring
API sync last, after other workspace member syncs.

Also remove --no-install-project from embedded-db sync in test-embed,
as --no-install-project prevents optional extras from being active.

* ci: add local-ml extra to test-embed API install for smoke test

The smoke test starts the full API server which needs sentence-transformers
for local embeddings (default provider). Add local-ml extra to the install.

* ci: simplify extras with --all-extras and add slim pip smoke test

- Replace explicit --extra local-ml --extra embedded-db with --all-extras
  for cleaner, more maintainable sync steps
- Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without
  local ML models, using Cohere for embeddings/reranking (mirrors Docker
  slim smoke test approach)

* ci: simplify slim smoke test to health check only (mirrors Docker test)
2026-03-13 13:50:03 +01:00

256 lines
12 KiB
Python

"""
Search trace models for debugging and visualization.
These Pydantic models define the structure of search traces, capturing
every step of the spreading activation search process for analysis.
"""
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
class TemporalConstraint(BaseModel):
"""Detected temporal constraint from query analysis."""
start: datetime | None = Field(default=None, description="Start of temporal range")
end: datetime | None = Field(default=None, description="End of temporal range")
class QueryInfo(BaseModel):
"""Information about the search query."""
query_text: str = Field(description="Original query text")
query_embedding: list[float] = Field(description="Generated query embedding vector")
timestamp: datetime = Field(description="When the query was executed")
budget: int = Field(description="Maximum nodes to explore")
max_tokens: int = Field(description="Maximum tokens to return in results")
tags: list[str] | None = Field(default=None, description="Tags filter applied to recall")
tags_match: str | None = Field(default=None, description="Tags matching mode: any, all, any_strict, all_strict")
temporal_constraint: TemporalConstraint | None = Field(
default=None, description="Detected temporal range from query"
)
class EntryPoint(BaseModel):
"""An entry point node selected for search."""
node_id: str = Field(description="Memory unit ID")
text: str = Field(description="Memory unit text content")
similarity_score: float = Field(description="Cosine similarity to query", ge=0.0, le=1.0)
rank: int = Field(description="Rank among entry points (1-based)")
class WeightComponents(BaseModel):
"""Breakdown of weight calculation components."""
activation: float = Field(description="Activation from spreading (can exceed 1.0 through accumulation)", ge=0.0)
semantic_similarity: float = Field(description="Semantic similarity to query", ge=0.0, le=1.0)
recency: float = Field(description="Recency weight", ge=0.0, le=1.0)
frequency: float = Field(description="Normalized frequency weight", ge=0.0, le=1.0)
final_weight: float = Field(description="Combined final weight")
# Weight formula components (for transparency)
activation_contribution: float = Field(description="0.3 * activation")
semantic_contribution: float = Field(description="0.3 * semantic_similarity")
recency_contribution: float = Field(description="0.25 * recency")
frequency_contribution: float = Field(description="0.15 * frequency")
class LinkInfo(BaseModel):
"""Information about a link to a neighbor."""
to_node_id: str = Field(description="Target node ID")
link_type: Literal["temporal", "semantic", "entity"] = Field(description="Type of link")
link_weight: float = Field(
description="Weight of the link (can exceed 1.0 when aggregating multiple connections)", ge=0.0
)
entity_id: str | None = Field(default=None, description="Entity ID if link_type is 'entity'")
new_activation: float | None = Field(
default=None, description="Activation that would be passed to neighbor (None for supplementary links)"
)
followed: bool = Field(description="Whether this link was followed (or pruned)")
prune_reason: str | None = Field(default=None, description="Why link was not followed (if not followed)")
is_supplementary: bool = Field(
default=False, description="Whether this is a supplementary link (multiple connections to same node)"
)
class NodeVisit(BaseModel):
"""Information about visiting a node during search."""
step: int = Field(description="Step number in search (1-based)")
node_id: str = Field(description="Memory unit ID")
text: str = Field(description="Memory unit text content")
context: str = Field(description="Memory unit context")
event_date: datetime | None = Field(default=None, description="When the memory occurred")
# How this node was reached
is_entry_point: bool = Field(description="Whether this is an entry point")
parent_node_id: str | None = Field(default=None, description="Node that led to this one")
link_type: Literal["temporal", "semantic", "entity"] | None = Field(
default=None, description="Type of link from parent"
)
link_weight: float | None = Field(default=None, description="Weight of link from parent")
# Weights
weights: WeightComponents = Field(description="Weight calculation breakdown")
# Neighbors discovered from this node
neighbors_explored: list[LinkInfo] = Field(default_factory=list, description="Links explored from this node")
# Ranking
final_rank: int | None = Field(default=None, description="Final rank in results (1-based, None if not in top-k)")
class PruningDecision(BaseModel):
"""Records when a node was considered but not visited."""
node_id: str = Field(description="Node that was pruned")
reason: Literal["already_visited", "activation_too_low", "budget_exhausted"] = Field(
description="Why it was pruned"
)
activation: float = Field(description="Activation value when pruned")
would_have_been_step: int = Field(description="What step it would have been if visited")
class SearchPhaseMetrics(BaseModel):
"""Performance metrics for a search phase."""
phase_name: str = Field(description="Name of the phase")
duration_seconds: float = Field(description="Time taken in seconds")
details: dict[str, Any] = Field(default_factory=dict, description="Additional phase-specific metrics")
class RetrievalResult(BaseModel):
"""A single result from a retrieval method."""
rank: int = Field(description="Rank in this retrieval method (1-based)")
node_id: str = Field(description="Memory unit ID")
text: str = Field(description="Memory unit text content")
context: str = Field(default="", description="Memory unit context")
event_date: datetime | None = Field(default=None, description="When the memory occurred")
fact_type: str | None = Field(default=None, description="Fact type (world, experience, opinion)")
score: float = Field(description="Score from this retrieval method")
score_name: str = Field(description="Name of the score (e.g., 'similarity', 'bm25_score', 'activation')")
class RetrievalMethodResults(BaseModel):
"""Results from a single retrieval method."""
method_name: Literal["semantic", "bm25", "graph", "temporal"] = Field(description="Name of retrieval method")
fact_type: str | None = Field(
default=None, description="Fact type this retrieval was for (world, experience, opinion)"
)
results: list[RetrievalResult] = Field(description="Retrieved results with ranks")
duration_seconds: float = Field(description="Time taken for this retrieval")
metadata: dict[str, Any] = Field(default_factory=dict, description="Method-specific metadata")
class RRFMergeResult(BaseModel):
"""A result after RRF merging."""
node_id: str = Field(description="Memory unit ID")
text: str = Field(description="Memory unit text content")
rrf_score: float = Field(description="Reciprocal Rank Fusion score")
source_ranks: dict[str, int] = Field(description="Rank in each source that contributed (method_name -> rank)")
final_rrf_rank: int = Field(description="Rank after RRF merge (1-based)")
class RerankedResult(BaseModel):
"""A result after reranking."""
node_id: str = Field(description="Memory unit ID")
text: str = Field(description="Memory unit text content")
rerank_score: float = Field(description="Final reranking score")
rerank_rank: int = Field(description="Rank after reranking (1-based)")
rrf_rank: int = Field(description="Original RRF rank before reranking")
rank_change: int = Field(description="Change in rank (positive = moved up)")
score_components: dict[str, float] = Field(default_factory=dict, description="Score breakdown")
class SearchSummary(BaseModel):
"""Summary statistics about the search."""
total_nodes_visited: int = Field(description="Total nodes visited")
total_nodes_pruned: int = Field(description="Total nodes pruned")
entry_points_found: int = Field(description="Number of entry points")
budget_used: int = Field(description="How much budget was used")
budget_remaining: int = Field(description="How much budget remained")
total_duration_seconds: float = Field(description="Total search duration")
results_returned: int = Field(description="Number of results returned")
# Link statistics
temporal_links_followed: int = Field(default=0, description="Temporal links followed")
semantic_links_followed: int = Field(default=0, description="Semantic links followed")
entity_links_followed: int = Field(default=0, description="Entity links followed")
# Phase timings
phase_metrics: list[SearchPhaseMetrics] = Field(default_factory=list, description="Metrics for each phase")
class SearchTrace(BaseModel):
"""Complete trace of a search operation."""
query: QueryInfo = Field(description="Query information")
# New 4-way retrieval architecture
retrieval_results: list[RetrievalMethodResults] = Field(
default_factory=list, description="Results from each retrieval method"
)
rrf_merged: list[RRFMergeResult] = Field(default_factory=list, description="Results after RRF merging")
reranked: list[RerankedResult] = Field(default_factory=list, description="Results after reranking")
# Legacy fields (kept for backward compatibility with graph/temporal visualizations)
entry_points: list[EntryPoint] = Field(
default_factory=list, description="Entry points selected for search (legacy)"
)
visits: list[NodeVisit] = Field(
default_factory=list, description="All nodes visited during search (legacy, for graph viz)"
)
pruned: list[PruningDecision] = Field(default_factory=list, description="Nodes that were pruned (legacy)")
summary: SearchSummary = Field(description="Summary statistics")
# Final results (for comparison with visits)
final_results: list[dict[str, Any]] = Field(description="Final ranked results returned to user")
model_config = {"json_encoders": {datetime: lambda v: v.isoformat()}}
def to_json(self, **kwargs) -> str:
"""Export trace as JSON string."""
return self.model_dump_json(indent=2, **kwargs)
def to_dict(self) -> dict:
"""Export trace as dictionary."""
return self.model_dump()
def get_visit_by_node_id(self, node_id: str) -> NodeVisit | None:
"""Find a visit by node ID."""
for visit in self.visits:
if visit.node_id == node_id:
return visit
return None
def get_search_path_to_node(self, node_id: str) -> list[NodeVisit]:
"""Get the path from entry point to a specific node."""
path = []
current_visit = self.get_visit_by_node_id(node_id)
while current_visit:
path.insert(0, current_visit)
if current_visit.parent_node_id:
current_visit = self.get_visit_by_node_id(current_visit.parent_node_id)
else:
break
return path
def get_nodes_by_link_type(self, link_type: Literal["temporal", "semantic", "entity"]) -> list[NodeVisit]:
"""Get all nodes reached via a specific link type."""
return [v for v in self.visits if v.link_type == link_type]
def get_entry_point_nodes(self) -> list[NodeVisit]:
"""Get all entry point visits."""
return [v for v in self.visits if v.is_entry_point]