fix(recall): cap entity fanout in graph expansion (#911)

* fix(recall): cap entity fanout in graph expansion to prevent slow queries

On large banks, the entity co-occurrence self-join in _expand_combined()
produces massive intermediate row counts when seeds reference high-fanout
entities (e.g. an entity with 25K+ mentions). This causes recall latency
to degrade significantly.

Changes:
- Replace unbounded entity self-join with LATERAL per-entity cap
  (graph_per_entity_limit, default 200), reducing intermediate rows
  from potentially millions to at most num_entities * 200
- Add ORDER BY unit_id DESC in LATERAL subquery for deterministic
  recency-biased sampling (rides the PK index, no extra sort)
- Add timeout fallback (graph_expansion_timeout, default 10s) that
  drops entity expansion and falls back to semantic+causal only
- Add composite index (entity_id, unit_id) on unit_entities for
  index-only scans in the LATERAL subquery
- Merge 3 unmerged migration heads into one
- Fix recall_perf.py dotenv override issue

Unlike the approach in #895, this does NOT filter out hub entities
entirely — all entities are kept but capped equally, preserving
retrieval quality for queries about frequently-mentioned entities.

Benchmarked on a 67K-unit bank (top entity = 25K mentions):
- retrieval_graph: 0.337s → 0.055s (84% faster)
- end-to-end recall: 0.912s → 0.519s (43% faster)

* fix(tests): fix broken test_combined_scoring and test_reranking_proof_count

- test_combined_scoring: replace MagicMock(spec=RetrievalResult) with real
  dataclass instances — MagicMock attributes returned nested mocks that
  failed on >= comparisons with int
- test_reranking_proof_count: remove deleted `embedding` param from
  RetrievalResult constructor, use None for occurred_start/end to get
  neutral recency (datetime.now gave recency=1.0 which boosted scores)

* refactor: rename config to link_expansion_ prefix, fix observation fanout

- Rename GRAPH_PER_ENTITY_LIMIT → LINK_EXPANSION_PER_ENTITY_LIMIT and
  GRAPH_EXPANSION_TIMEOUT → LINK_EXPANSION_TIMEOUT to follow the
  convention that these are specific to the link_expansion graph retriever
- Apply the same LATERAL per-entity cap to _expand_observations(), which
  had the same unbounded self-join through unit_entities

* style: fix formatting in config.py
This commit is contained in:
Nicolò Boschi 2026-04-07 18:59:50 +02:00 committed by GitHub
parent 4028dd91f8
commit 57f154454d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 367 additions and 50 deletions

View file

@ -0,0 +1,42 @@
"""Merge 3 migration heads and add unit_entities composite index
Revision ID: h3i4j5k6l7m8
Revises: a4b5c6d7e8f9, c2d3e4f5g6h7, g2h3i4j5k6l7
Create Date: 2026-04-07
Merges three unmerged migration heads into one, and adds a composite index
(entity_id, unit_id) on unit_entities for index-only scans in the LATERAL
entity expansion query.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "h3i4j5k6l7m8"
down_revision: str | Sequence[str] | None = ("a4b5c6d7e8f9", "c2d3e4f5g6h7", "g2h3i4j5k6l7")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# Composite index enables index-only scans for entity_id -> unit_id lookups
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_unit_entities_entity_unit ON {schema}unit_entities (entity_id, unit_id)"
)
# Drop the now-redundant single-column index
op.execute(f"DROP INDEX IF EXISTS {schema}idx_unit_entities_entity")
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_unit_entities_entity_unit")
# Restore the single-column index
op.execute(f"CREATE INDEX IF NOT EXISTS idx_unit_entities_entity ON {schema}unit_entities (entity_id)")

View file

@ -262,6 +262,8 @@ ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
ENV_RECALL_CONNECTION_BUDGET = "HINDSIGHT_API_RECALL_CONNECTION_BUDGET" ENV_RECALL_CONNECTION_BUDGET = "HINDSIGHT_API_RECALL_CONNECTION_BUDGET"
ENV_RECALL_MAX_QUERY_TOKENS = "HINDSIGHT_API_RECALL_MAX_QUERY_TOKENS" ENV_RECALL_MAX_QUERY_TOKENS = "HINDSIGHT_API_RECALL_MAX_QUERY_TOKENS"
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY" ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
ENV_LINK_EXPANSION_PER_ENTITY_LIMIT = "HINDSIGHT_API_LINK_EXPANSION_PER_ENTITY_LIMIT"
ENV_LINK_EXPANSION_TIMEOUT = "HINDSIGHT_API_LINK_EXPANSION_TIMEOUT"
# OpenTelemetry tracing configuration # OpenTelemetry tracing configuration
ENV_OTEL_TRACES_ENABLED = "HINDSIGHT_API_OTEL_TRACES_ENABLED" ENV_OTEL_TRACES_ENABLED = "HINDSIGHT_API_OTEL_TRACES_ENABLED"
@ -475,6 +477,8 @@ DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worke
DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall operation DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall operation
DEFAULT_RECALL_MAX_QUERY_TOKENS = 500 # Maximum tokens allowed in recall query DEFAULT_RECALL_MAX_QUERY_TOKENS = 500 # Maximum tokens allowed in recall query
DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY = 8 # Max concurrent mental model refreshes DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY = 8 # Max concurrent mental model refreshes
DEFAULT_LINK_EXPANSION_PER_ENTITY_LIMIT = 200 # Max target units per entity in graph expansion
DEFAULT_LINK_EXPANSION_TIMEOUT = 10.0 # Timeout (seconds) for entity expansion query
# Retain settings # Retain settings
DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call
@ -780,6 +784,8 @@ class HindsightConfig:
recall_connection_budget: int recall_connection_budget: int
recall_max_query_tokens: int recall_max_query_tokens: int
mental_model_refresh_concurrency: int mental_model_refresh_concurrency: int
link_expansion_per_entity_limit: int
link_expansion_timeout: float
# Retain settings # Retain settings
retain_max_completion_tokens: int retain_max_completion_tokens: int
@ -1286,6 +1292,10 @@ class HindsightConfig:
mental_model_refresh_concurrency=int( mental_model_refresh_concurrency=int(
os.getenv(ENV_MENTAL_MODEL_REFRESH_CONCURRENCY, str(DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY)) os.getenv(ENV_MENTAL_MODEL_REFRESH_CONCURRENCY, str(DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY))
), ),
link_expansion_per_entity_limit=int(
os.getenv(ENV_LINK_EXPANSION_PER_ENTITY_LIMIT, str(DEFAULT_LINK_EXPANSION_PER_ENTITY_LIMIT))
),
link_expansion_timeout=float(os.getenv(ENV_LINK_EXPANSION_TIMEOUT, str(DEFAULT_LINK_EXPANSION_TIMEOUT))),
# Optimization flags # Optimization flags
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true", skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true", lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",

View file

@ -6,25 +6,30 @@ stored in memory_links:
1. Entity links query-time self-join through unit_entities. Score = number of distinct 1. Entity links query-time self-join through unit_entities. Score = number of distinct
shared entities between the seed set and each candidate, computed via shared entities between the seed set and each candidate, computed via
COUNT(DISTINCT entity_id). More accurate than precomputed entity links. COUNT(DISTINCT entity_id). Uses a LATERAL per-entity cap
(graph_per_entity_limit, default 200) to prevent high-fanout entities
from exploding the self-join intermediate rows.
2. Semantic links precomputed kNN graph (each new fact linked to its top-5 most 2. Semantic links precomputed kNN graph (each new fact linked to its top-5 most
similar existing facts at insert time, similarity >= 0.7). Checked similar existing facts at insert time, similarity >= 0.7). Checked
in both directions since the graph is not symmetric. Score = weight. in both directions since the graph is not symmetric. Score = weight.
3. Causal links explicit causal chains (causes/caused_by/enables/prevents). 3. Causal links explicit causal chains (causes/caused_by/enables/prevents).
Score = weight + 1.0 (boosted as highest-quality signal). Score = weight + 1.0 (boosted as highest-quality signal).
All three signals are bounded at retain time, so no LATERAL fan-out caps are needed Entity expansion is bounded by graph_per_entity_limit (LATERAL cap per entity).
at query time. Each expansion is a simple aggregation over a small result set. A timeout fallback (graph_expansion_timeout) drops entity expansion entirely if the
query still exceeds the budget.
For non-observation fact types the three expansions are issued as a single CTE query For non-observation fact types the three expansions are issued as a single CTE query
(one roundtrip, one connection) with a `source` discriminator column so the Python (one roundtrip, one connection) with a `source` discriminator column so the Python
merge step can apply per-signal score transformations. merge step can apply per-signal score transformations.
""" """
import asyncio
import logging import logging
import math import math
import time import time
from ...config import get_config
from ..db_utils import acquire_with_retry from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table from ..memory_engine import fq_table
from .graph_retrieval import GraphRetriever from .graph_retrieval import GraphRetriever
@ -262,35 +267,48 @@ class LinkExpansionRetriever(GraphRetriever):
idx_memory_links_to_type_weight (to_unit_id, link_type, weight DESC) idx_memory_links_to_type_weight (to_unit_id, link_type, weight DESC)
replaces costly BitmapAnd of two separate scans replaces costly BitmapAnd of two separate scans
""" """
config = get_config()
ml = fq_table("memory_links") ml = fq_table("memory_links")
mu = fq_table("memory_units") mu = fq_table("memory_units")
ue = fq_table("unit_entities") ue = fq_table("unit_entities")
per_entity_limit = config.link_expansion_per_entity_limit
# Entity CTE with LATERAL fanout cap.
# Every seed entity (including high-frequency ones) is kept, but each
# entity's expansion is capped to per_entity_limit target units. The
# LATERAL subquery orders by unit_id DESC so the most recently inserted
# units are preferred (a recency proxy that is free — it rides the PK
# index with no extra sort).
entity_cte = f""" entity_cte = f"""
seed_entities AS (
SELECT DISTINCT ue.entity_id
FROM {ue} ue
WHERE ue.unit_id = ANY($1::uuid[])
),
entity_expanded AS ( entity_expanded AS (
-- Entity co-occurrence via unit_entities self-join.
-- Finds units sharing entities with seeds at query time more accurate
-- than precomputed entity links (no stale 50-neighbor cap).
-- Score = COUNT(DISTINCT shared entities), mapped to [0,1] via tanh.
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at, mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
COUNT(DISTINCT ue_seed.entity_id)::float AS score, COUNT(DISTINCT se.entity_id)::float AS score,
'entity'::text AS source 'entity'::text AS source
FROM {ue} ue_seed FROM seed_entities se
JOIN {ue} ue_target ON ue_seed.entity_id = ue_target.entity_id CROSS JOIN LATERAL (
JOIN {mu} mu ON mu.id = ue_target.unit_id SELECT ue_target.unit_id
WHERE ue_seed.unit_id = ANY($1::uuid[]) FROM {ue} ue_target
AND ue_target.unit_id != ALL($1::uuid[]) WHERE ue_target.entity_id = se.entity_id
AND mu.fact_type = $2 AND ue_target.unit_id != ALL($1::uuid[])
ORDER BY ue_target.unit_id DESC
LIMIT {per_entity_limit}
) t
JOIN {mu} mu ON mu.id = t.unit_id
WHERE mu.fact_type = $2
GROUP BY mu.id GROUP BY mu.id
ORDER BY score DESC ORDER BY score DESC
LIMIT $3 LIMIT $3
)""" )"""
all_rows = await conn.fetch( semantic_causal_cte = f"""
f"""
WITH {entity_cte},
semantic_expanded AS ( semantic_expanded AS (
-- Semantic kNN: both outgoing (seeds their kNN at insert time) and -- Semantic kNN: both outgoing (seeds their kNN at insert time) and
-- incoming (facts inserted after seeds that found seeds as kNN). -- incoming (facts inserted after seeds that found seeds as kNN).
@ -350,18 +368,37 @@ class LinkExpansionRetriever(GraphRetriever):
AND mu.fact_type = $2 AND mu.fact_type = $2
ORDER BY mu.id, ml.weight DESC ORDER BY mu.id, ml.weight DESC
LIMIT $3 LIMIT $3
) )"""
full_query = f"""
WITH {entity_cte},
{semantic_causal_cte}
SELECT * FROM entity_expanded SELECT * FROM entity_expanded
UNION ALL UNION ALL
SELECT * FROM semantic_expanded SELECT * FROM semantic_expanded
UNION ALL UNION ALL
SELECT * FROM causal_expanded SELECT * FROM causal_expanded
""", """
seed_ids,
fact_type, params = [seed_ids, fact_type, budget, self.causal_weight_threshold]
budget,
self.causal_weight_threshold, try:
) all_rows = await asyncio.wait_for(
conn.fetch(full_query, *params),
timeout=config.link_expansion_timeout,
)
except asyncio.TimeoutError:
logger.warning(
f"[LinkExpansion] Entity expansion timed out after {config.link_expansion_timeout}s "
f"for fact_type={fact_type}, falling back to semantic+causal only"
)
fallback_query = f"""
WITH {semantic_causal_cte}
SELECT * FROM semantic_expanded
UNION ALL
SELECT * FROM causal_expanded
"""
all_rows = await conn.fetch(fallback_query, *params)
entity_rows = [r for r in all_rows if r["source"] == "entity"] entity_rows = [r for r in all_rows if r["source"] == "entity"]
semantic_rows = [r for r in all_rows if r["source"] == "semantic"] semantic_rows = [r for r in all_rows if r["source"] == "semantic"]
@ -401,17 +438,31 @@ class LinkExpansionRetriever(GraphRetriever):
f"{len(source_ids_found)} source_memory_ids found" f"{len(source_ids_found)} source_memory_ids found"
) )
config = get_config()
ue = fq_table("unit_entities") ue = fq_table("unit_entities")
per_entity_limit = config.link_expansion_per_entity_limit
connected_sources_cte = f""" connected_sources_cte = f"""
connected_sources AS ( source_entities AS (
-- Find sources sharing entities with seed observation sources SELECT DISTINCT ue_seed.entity_id
-- via unit_entities self-join (query-time, no precomputed links needed).
SELECT DISTINCT ue_target.unit_id AS source_id
FROM seed_sources ss FROM seed_sources ss
JOIN {ue} ue_seed ON ue_seed.unit_id = ss.source_id JOIN {ue} ue_seed ON ue_seed.unit_id = ss.source_id
JOIN {ue} ue_target ON ue_seed.entity_id = ue_target.entity_id ),
WHERE ue_target.unit_id != ss.source_id connected_sources AS (
-- Find sources sharing entities with seed observation sources
-- via LATERAL-capped self-join (prevents hub entity fanout).
SELECT DISTINCT t.unit_id AS source_id
FROM source_entities se
CROSS JOIN LATERAL (
SELECT ue_target.unit_id
FROM {ue} ue_target
WHERE ue_target.entity_id = se.entity_id
ORDER BY ue_target.unit_id DESC
LIMIT {per_entity_limit}
) t
WHERE NOT EXISTS (
SELECT 1 FROM seed_sources ss WHERE ss.source_id = t.unit_id
)
)""" )"""
entity_rows = await conn.fetch( entity_rows = await conn.fetch(

View file

@ -7,7 +7,6 @@ relevance score, independent of the cross-encoder model's score calibration.
""" """
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
import pytest import pytest
@ -23,13 +22,18 @@ def _make_result(
occurred_start: datetime | None = None, occurred_start: datetime | None = None,
temporal_proximity: float | None = None, temporal_proximity: float | None = None,
) -> ScoredResult: ) -> ScoredResult:
retrieval = MagicMock(spec=RetrievalResult) retrieval = RetrievalResult(
retrieval.occurred_start = occurred_start id="test",
retrieval.temporal_proximity = temporal_proximity text="test",
fact_type="world",
occurred_start=occurred_start,
temporal_proximity=temporal_proximity,
)
candidate = MagicMock(spec=MergedCandidate) candidate = MergedCandidate(
candidate.retrieval = retrieval retrieval=retrieval,
candidate.rrf_score = 0.05 rrf_score=0.05,
)
return ScoredResult( return ScoredResult(
candidate=candidate, candidate=candidate,

View file

@ -0,0 +1,211 @@
"""
Tests for LATERAL entity fanout cap in graph expansion.
Verifies that the per-entity LIMIT in _expand_combined prevents high-fanout
entities from exploding the self-join, while still returning entity-based
graph results.
"""
import asyncio
from datetime import datetime, timezone
import pytest
@pytest.mark.asyncio
async def test_high_fanout_entity_returns_results(memory, request_context):
"""
A high-fanout entity (appearing in many facts) should still produce
graph retrieval results the LATERAL cap limits rows per entity but
does not drop the entity entirely.
"""
bank_id = f"test_fanout_cap_{datetime.now(timezone.utc).timestamp()}"
try:
# Create many facts sharing one common entity ("Acme Corp") plus
# a few with a unique entity so we can query for the unique one
# and verify graph expansion finds siblings via "Acme Corp".
contents = [
# Target: unique entity "Zara" shares "Acme Corp" with the rest
{
"content": "Zara joined Acme Corp as a senior engineer last month",
"context": "hr update",
"entities": [{"text": "Zara"}, {"text": "Acme Corp"}],
},
]
# Add many facts that all share "Acme Corp" — creates a high-fanout entity
for i in range(60):
contents.append(
{
"content": f"Employee {i} completed onboarding at Acme Corp in department {i % 5}",
"context": "hr update",
"entities": [{"text": f"Employee {i}"}, {"text": "Acme Corp"}],
}
)
await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
from hindsight_api.engine.memory_engine import Budget
# Query for "Zara" — semantic search finds Zara's fact as a seed,
# then graph expansion should find other Acme Corp facts via the
# shared entity, even though "Acme Corp" has 60+ mentions.
result = await memory.recall_async(
bank_id=bank_id,
query="Zara",
budget=Budget.HIGH,
max_tokens=4096,
enable_trace=True,
request_context=request_context,
_quiet=True,
)
assert result.results is not None
assert len(result.results) > 0
# Verify graph retrieval ran and found results
retrieval_results = result.trace.get("retrieval_results", [])
graph_results = [r for r in retrieval_results if r.get("method_name") == "graph"]
assert len(graph_results) > 0, "Graph retrieval should have run"
# At least one graph result should contain Acme Corp content
# (found via shared entity, not just semantic similarity)
all_texts = [r.text for r in result.results]
acme_found = any("Acme Corp" in t for t in all_texts)
assert acme_found, "Should find Acme Corp facts via entity graph expansion"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_entity_expansion_timeout_fallback(memory, request_context):
"""
When graph_expansion_timeout is set very low, entity expansion should
time out gracefully and fall back to semantic+causal links only,
rather than failing the entire recall.
"""
bank_id = f"test_timeout_fallback_{datetime.now(timezone.utc).timestamp()}"
try:
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{
"content": "Alice works on the backend API at TechCorp",
"context": "team info",
"entities": [{"text": "Alice"}, {"text": "TechCorp"}],
},
{
"content": "Bob maintains the frontend at TechCorp",
"context": "team info",
"entities": [{"text": "Bob"}, {"text": "TechCorp"}],
},
],
request_context=request_context,
)
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.memory_engine import Budget
config = _get_raw_config()
original_timeout = config.link_expansion_timeout
try:
# Set an impossibly low timeout to force the fallback path
config.link_expansion_timeout = 0.0001
result = await memory.recall_async(
bank_id=bank_id,
query="Alice",
budget=Budget.MID,
max_tokens=2048,
enable_trace=True,
request_context=request_context,
_quiet=True,
)
# Recall should succeed even when entity expansion times out
assert result.results is not None
assert len(result.results) > 0
# Alice should still be found via semantic search
result_texts = [r.text for r in result.results]
alice_found = any("Alice" in t for t in result_texts)
assert alice_found, "Should find Alice via semantic search despite graph timeout"
finally:
config.link_expansion_timeout = original_timeout
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_per_entity_limit_caps_expansion(memory, request_context):
"""
With graph_per_entity_limit set to a small value, entity expansion should
still work but return fewer results from high-fanout entities.
"""
bank_id = f"test_per_entity_limit_{datetime.now(timezone.utc).timestamp()}"
try:
# Create facts with a shared entity
contents = [
{
"content": "Lead engineer Dana oversees the Widgets project at MegaCorp",
"context": "project info",
"entities": [{"text": "Dana"}, {"text": "MegaCorp"}],
},
]
for i in range(30):
contents.append(
{
"content": f"MegaCorp hired contractor {i} for the Q4 push",
"context": "hiring info",
"entities": [{"text": f"Contractor {i}"}, {"text": "MegaCorp"}],
}
)
await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.memory_engine import Budget
config = _get_raw_config()
original_limit = config.link_expansion_per_entity_limit
try:
# Set a very small per-entity limit
config.link_expansion_per_entity_limit = 5
result = await memory.recall_async(
bank_id=bank_id,
query="Dana",
budget=Budget.HIGH,
max_tokens=4096,
enable_trace=True,
request_context=request_context,
_quiet=True,
)
# Recall should succeed with the cap
assert result.results is not None
assert len(result.results) > 0
# Graph retrieval should have run
retrieval_results = result.trace.get("retrieval_results", [])
graph_results = [r for r in retrieval_results if r.get("method_name") == "graph"]
assert len(graph_results) > 0, "Graph retrieval should have run"
finally:
config.link_expansion_per_entity_limit = original_limit
finally:
await memory.delete_bank(bank_id, request_context=request_context)

View file

@ -14,24 +14,19 @@ UTC = timezone.utc
def create_mock_scored_result(proof_count: int | None = None, ce_score: float = 0.8) -> ScoredResult: def create_mock_scored_result(proof_count: int | None = None, ce_score: float = 0.8) -> ScoredResult:
"""Helper to create a minimal ScoredResult suitable for scoring tests.""" """Helper to create a minimal ScoredResult suitable for scoring tests."""
retrieval = RetrievalResult( retrieval = RetrievalResult(
id=uuid4(), id=str(uuid4()),
text="Test mock fact", text="Test mock fact",
fact_type="observation" if proof_count is not None else "world", fact_type="observation" if proof_count is not None else "world",
document_id=uuid4(), document_id=str(uuid4()),
chunk_id=uuid4(), chunk_id=str(uuid4()),
embedding=[0.1]*384,
similarity=0.9,
proof_count=proof_count, proof_count=proof_count,
# Default neutral dates for testing so only proof_count changes score # Use None for neutral recency so only proof_count changes score
occurred_start=datetime.now(UTC), occurred_start=None,
occurred_end=datetime.now(UTC) occurred_end=None
) )
candidate = MergedCandidate( candidate = MergedCandidate(
id=retrieval.id,
retrieval=retrieval, retrieval=retrieval,
semantic_rank=1, rrf_score=0.1,
bm25_rank=1,
rrf_score=0.1
) )
return ScoredResult( return ScoredResult(
candidate=candidate, candidate=candidate,

View file

@ -27,6 +27,10 @@ import statistics
import time import time
from typing import Any from typing import Any
# Capture DB URL early before hindsight_api imports trigger dotenv override
# (config.py uses load_dotenv(override=True) which stomps env vars)
_EARLY_DB_URL = os.environ.get("HINDSIGHT_API_DATABASE_URL")
from rich.console import Console from rich.console import Console
from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
from rich.table import Table from rich.table import Table
@ -546,7 +550,7 @@ def _build_engine(*, disable_observations: bool = False) -> "Any":
"""Create a MemoryEngine using mock LLM and DB from env.""" """Create a MemoryEngine using mock LLM and DB from env."""
from hindsight_api import MemoryEngine from hindsight_api import MemoryEngine
db_url = os.getenv("HINDSIGHT_API_DATABASE_URL", "pg0") db_url = _EARLY_DB_URL or os.getenv("HINDSIGHT_API_DATABASE_URL", "pg0")
if disable_observations: if disable_observations:
os.environ["HINDSIGHT_API_ENABLE_OBSERVATIONS"] = "false" os.environ["HINDSIGHT_API_ENABLE_OBSERVATIONS"] = "false"
engine = MemoryEngine( engine = MemoryEngine(