fleet-memory/hindsight-api-slim/hindsight_api/engine/search/tracer.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

481 lines
16 KiB
Python

"""
Search tracer for collecting detailed search execution traces.
The SearchTracer collects comprehensive information about each step
of the spreading activation search process for debugging and visualization.
"""
import time
from datetime import UTC, datetime
from typing import Any, Literal
from .trace import (
EntryPoint,
LinkInfo,
NodeVisit,
PruningDecision,
QueryInfo,
RerankedResult,
RetrievalMethodResults,
RetrievalResult,
RRFMergeResult,
SearchPhaseMetrics,
SearchSummary,
SearchTrace,
TemporalConstraint,
WeightComponents,
)
class SearchTracer:
"""
Tracer for collecting detailed search execution information.
Usage:
tracer = SearchTracer(query="Who is Alice?", budget=50, max_tokens=4096)
tracer.start()
# During search...
tracer.record_query_embedding(embedding)
tracer.add_entry_point(node_id, text, similarity, rank)
tracer.visit_node(...)
tracer.prune_node(...)
# After search...
trace = tracer.finalize(final_results)
json_output = trace.to_json()
"""
def __init__(
self,
query: str,
budget: int,
max_tokens: int,
tags: list[str] | None = None,
tags_match: str | None = None,
):
"""
Initialize tracer.
Args:
query: Search query text
budget: Maximum nodes to explore
max_tokens: Maximum tokens to return in results
tags: Tags filter applied to recall
tags_match: Tags matching mode (any, all, any_strict, all_strict)
"""
self.query_text = query
self.budget = budget
self.max_tokens = max_tokens
self.tags = tags
self.tags_match = tags_match
# Trace data
self.query_embedding: list[float] | None = None
self.start_time: float | None = None
self.entry_points: list[EntryPoint] = []
self.visits: list[NodeVisit] = []
self.pruned: list[PruningDecision] = []
self.phase_metrics: list[SearchPhaseMetrics] = []
# Temporal constraint detected from query
self.temporal_constraint: TemporalConstraint | None = None
# New 4-way retrieval tracking
self.retrieval_results: list[RetrievalMethodResults] = []
self.rrf_merged: list[RRFMergeResult] = []
self.reranked: list[RerankedResult] = []
# Tracking state
self.current_step = 0
self.nodes_visited_set = set() # For quick lookups
# Link statistics
self.temporal_links_followed = 0
self.semantic_links_followed = 0
self.entity_links_followed = 0
def start(self):
"""Start timing the search."""
self.start_time = time.time()
def record_query_embedding(self, embedding: list[float]):
"""Record the query embedding."""
self.query_embedding = embedding
def record_temporal_constraint(self, start: datetime | None, end: datetime | None):
"""Record the detected temporal constraint from query analysis."""
if start is not None or end is not None:
self.temporal_constraint = TemporalConstraint(start=start, end=end)
def add_entry_point(self, node_id: str, text: str, similarity: float, rank: int):
"""
Record an entry point.
Args:
node_id: Memory unit ID
text: Memory unit text
similarity: Cosine similarity to query
rank: Rank among entry points (1-based)
"""
# Clamp similarity to [0.0, 1.0] to handle floating-point precision
similarity = min(1.0, max(0.0, similarity))
self.entry_points.append(
EntryPoint(
node_id=node_id,
text=text,
similarity_score=similarity,
rank=rank,
)
)
def visit_node(
self,
node_id: str,
text: str,
context: str,
event_date: datetime | None,
is_entry_point: bool,
parent_node_id: str | None,
link_type: Literal["temporal", "semantic", "entity"] | None,
link_weight: float | None,
activation: float,
semantic_similarity: float,
recency: float,
frequency: float,
final_weight: float,
):
"""
Record visiting a node.
Args:
node_id: Memory unit ID
text: Memory unit text
context: Memory unit context
event_date: When the memory occurred
is_entry_point: Whether this is an entry point
parent_node_id: Node that led here (None for entry points)
link_type: Type of link from parent
link_weight: Weight of link from parent
activation: Activation score
semantic_similarity: Semantic similarity to query
recency: Recency weight
frequency: Frequency weight
final_weight: Combined final weight
"""
self.current_step += 1
self.nodes_visited_set.add(node_id)
# Clamp values to handle floating-point precision issues
# (sometimes normalization produces values like 1.0000005 instead of 1.0)
semantic_similarity = min(1.0, max(0.0, semantic_similarity))
recency = min(1.0, max(0.0, recency))
frequency = min(1.0, max(0.0, frequency))
# Calculate weight contributions for transparency
weights = WeightComponents(
activation=activation,
semantic_similarity=semantic_similarity,
recency=recency,
frequency=frequency,
final_weight=final_weight,
activation_contribution=0.3 * activation,
semantic_contribution=0.3 * semantic_similarity,
recency_contribution=0.25 * recency,
frequency_contribution=0.15 * frequency,
)
visit = NodeVisit(
step=self.current_step,
node_id=node_id,
text=text,
context=context,
event_date=event_date,
is_entry_point=is_entry_point,
parent_node_id=parent_node_id,
link_type=link_type,
link_weight=link_weight,
weights=weights,
neighbors_explored=[],
final_rank=None, # Will be set later
)
self.visits.append(visit)
# Track link statistics
if link_type == "temporal":
self.temporal_links_followed += 1
elif link_type == "semantic":
self.semantic_links_followed += 1
elif link_type == "entity":
self.entity_links_followed += 1
def add_neighbor_link(
self,
from_node_id: str,
to_node_id: str,
link_type: Literal["temporal", "semantic", "entity"],
link_weight: float,
entity_id: str | None,
new_activation: float | None,
followed: bool,
prune_reason: str | None = None,
is_supplementary: bool = False,
):
"""
Record a link to a neighbor (whether followed or not).
Args:
from_node_id: Source node
to_node_id: Target node
link_type: Type of link
link_weight: Weight of link
entity_id: Entity ID if link is entity-based
new_activation: Activation passed to neighbor (None for supplementary links)
followed: Whether link was followed
prune_reason: Why link was not followed (if not followed)
is_supplementary: Whether this is a supplementary link (multiple connections)
"""
# Find the visit for the source node
visit = None
for v in self.visits:
if v.node_id == from_node_id:
visit = v
break
if visit is None:
# Node not found, skip
return
link_info = LinkInfo(
to_node_id=to_node_id,
link_type=link_type,
link_weight=link_weight,
entity_id=entity_id,
new_activation=new_activation,
followed=followed,
prune_reason=prune_reason,
is_supplementary=is_supplementary,
)
visit.neighbors_explored.append(link_info)
def prune_node(
self,
node_id: str,
reason: Literal["already_visited", "activation_too_low", "budget_exhausted"],
activation: float,
):
"""
Record a node being pruned (not visited).
Args:
node_id: Node that was pruned
reason: Why it was pruned
activation: Activation value when pruned
"""
self.pruned.append(
PruningDecision(
node_id=node_id,
reason=reason,
activation=activation,
would_have_been_step=self.current_step + 1,
)
)
def add_phase_metric(self, phase_name: str, duration_seconds: float, details: dict[str, Any] | None = None):
"""
Record metrics for a search phase.
Args:
phase_name: Name of the phase
duration_seconds: Time taken
details: Additional phase-specific details
"""
self.phase_metrics.append(
SearchPhaseMetrics(
phase_name=phase_name,
duration_seconds=duration_seconds,
details=details or {},
)
)
def add_retrieval_results(
self,
method_name: Literal["semantic", "bm25", "graph", "temporal"],
results: list[tuple], # List of (doc_id, data) tuples
duration_seconds: float,
score_field: str, # e.g., "similarity", "bm25_score"
metadata: dict[str, Any] | None = None,
fact_type: str | None = None,
):
"""
Record results from a single retrieval method.
Args:
method_name: Name of the retrieval method
results: List of (doc_id, data) tuples from retrieval
duration_seconds: Time taken for this retrieval
score_field: Field name containing the score in data dict
metadata: Optional metadata about this retrieval method
fact_type: Fact type this retrieval was for (world, experience, opinion)
"""
retrieval_results = []
for rank, (doc_id, data) in enumerate(results, start=1):
score = data.get(score_field)
if score is None:
score = 0.0
retrieval_results.append(
RetrievalResult(
rank=rank,
node_id=doc_id,
text=data.get("text") or "",
context=data.get("context") or "",
event_date=data.get("event_date"),
fact_type=data.get("fact_type") or fact_type,
score=score,
score_name=score_field,
)
)
self.retrieval_results.append(
RetrievalMethodResults(
method_name=method_name,
fact_type=fact_type,
results=retrieval_results,
duration_seconds=duration_seconds,
metadata=metadata or {},
)
)
def add_rrf_merged(self, merged_results: list[tuple]):
"""
Record RRF merged results.
Args:
merged_results: List of (doc_id, data, rrf_meta) tuples from RRF merge
"""
self.rrf_merged = []
for rank, (doc_id, data, rrf_meta) in enumerate(merged_results, start=1):
self.rrf_merged.append(
RRFMergeResult(
node_id=doc_id,
text=data.get("text", ""),
rrf_score=rrf_meta.get("rrf_score", 0.0),
source_ranks=rrf_meta.get("source_ranks", {}),
final_rrf_rank=rank,
)
)
def add_reranked(self, reranked_results: list[dict[str, Any]], rrf_merged: list):
"""
Record reranked results.
Args:
reranked_results: List of result dicts after reranking
rrf_merged: Original RRF merged results for comparison
"""
# Build map of node_id -> rrf_rank
rrf_rank_map = {}
for item in self.rrf_merged:
rrf_rank_map[item.node_id] = item.final_rrf_rank
self.reranked = []
for rank, result in enumerate(reranked_results, start=1):
node_id = result["id"]
rrf_rank = rrf_rank_map.get(node_id, len(rrf_merged) + 1)
rank_change = rrf_rank - rank # Positive = moved up
# Extract score components (only include non-None values)
# Keys from ScoredResult.to_dict(): cross_encoder_score, cross_encoder_score_normalized,
# rrf_normalized, temporal, recency, combined_score, weight
score_components = {}
for key in [
"cross_encoder_score",
"cross_encoder_score_normalized",
"rrf_score",
"rrf_normalized",
"temporal",
"recency",
"combined_score",
]:
if key in result and result[key] is not None:
score_components[key] = result[key]
self.reranked.append(
RerankedResult(
node_id=node_id,
text=result.get("text", ""),
rerank_score=result.get("weight", 0.0),
rerank_rank=rank,
rrf_rank=rrf_rank,
rank_change=rank_change,
score_components=score_components,
)
)
def finalize(self, final_results: list[dict[str, Any]]) -> SearchTrace:
"""
Finalize the trace and return the complete SearchTrace object.
Args:
final_results: Final ranked results returned to user
Returns:
Complete SearchTrace object
"""
if self.start_time is None:
raise ValueError("Tracer not started - call start() first")
total_duration = time.time() - self.start_time
# Set final ranks on visits based on results
for rank, result in enumerate(final_results, 1):
result_node_id = result["id"]
for visit in self.visits:
if visit.node_id == result_node_id:
visit.final_rank = rank
break
# Create query info
query_info = QueryInfo(
query_text=self.query_text,
query_embedding=self.query_embedding or [],
timestamp=datetime.now(UTC),
budget=self.budget,
max_tokens=self.max_tokens,
tags=self.tags,
tags_match=self.tags_match,
temporal_constraint=self.temporal_constraint,
)
# Create summary
summary = SearchSummary(
total_nodes_visited=len(self.visits),
total_nodes_pruned=len(self.pruned),
entry_points_found=len(self.entry_points),
budget_used=len(self.visits),
budget_remaining=self.budget - len(self.visits),
total_duration_seconds=total_duration,
results_returned=len(final_results),
temporal_links_followed=self.temporal_links_followed,
semantic_links_followed=self.semantic_links_followed,
entity_links_followed=self.entity_links_followed,
phase_metrics=self.phase_metrics,
)
# Create complete trace
trace = SearchTrace(
query=query_info,
retrieval_results=self.retrieval_results,
rrf_merged=self.rrf_merged,
reranked=self.reranked,
entry_points=self.entry_points,
visits=self.visits,
pruned=self.pruned,
summary=summary,
final_results=final_results,
)
return trace