* 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)
481 lines
16 KiB
Python
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
|