fleet-memory/hindsight-api-slim/tests/test_causal_relationships.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

208 lines
8.4 KiB
Python

"""
Test suite for causal relationship extraction.
Tests that the fact extraction system correctly identifies and validates
causal relationships between facts, with valid indices.
"""
from datetime import datetime
import pytest
from hindsight_api import LLMConfig
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
class TestCausalRelationships:
"""Tests for causal relationship extraction and validation."""
@pytest.mark.asyncio
async def test_causal_chain_extraction(self):
"""
Test that a clear causal chain is extracted with valid relationships.
Story: Lost job -> couldn't pay rent -> had to move -> found new apartment
This is a 4-fact causal chain where each fact causes the next.
The extracted causal relations should have valid indices (0-3).
"""
text = """
I lost my job at the tech company in January because of layoffs.
Because I lost my job, I couldn't pay my rent anymore.
Since I couldn't afford rent, I had to move out of my apartment.
After searching for weeks, I finally found a cheaper apartment in Brooklyn.
"""
context = "Personal story about housing change"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 3, 15), context=context, llm_config=llm_config, agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) >= 3, f"Should extract at least 3 facts from the causal chain. Got {len(facts)}"
# Collect all causal relations from all facts
all_causal_relations = []
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
all_causal_relations.append(
{
"from_fact_index": i,
"to_fact_index": rel.target_fact_index,
"relation_type": rel.relation_type,
"strength": rel.strength,
"from_fact_text": fact.fact[:50],
}
)
# Verify that ALL causal relation indices are valid
# New constraint: target_index must be < from_fact_index (can only reference PREVIOUS facts)
num_facts = len(facts)
invalid_relations = []
for rel in all_causal_relations:
# Must be non-negative and less than the current fact's index
if rel["to_fact_index"] < 0 or rel["to_fact_index"] >= rel["from_fact_index"]:
invalid_relations.append(rel)
assert len(invalid_relations) == 0, (
f"Found {len(invalid_relations)} causal relations with invalid indices! "
f"Each target_fact_index must be < from_fact_index (can only reference previous facts). "
f"Invalid relations: {invalid_relations}"
)
# Should have at least some causal relations extracted
assert len(all_causal_relations) >= 2, (
f"Should extract at least 2 causal relationships from this clear chain. "
f"Got {len(all_causal_relations)}: {all_causal_relations}"
)
# Verify relation types are valid (passive only - facts reference PREVIOUS facts)
valid_types = {"caused_by", "enabled_by", "prevented_by"}
for rel in all_causal_relations:
assert rel["relation_type"] in valid_types, (
f"Invalid relation_type '{rel['relation_type']}'. Must be one of {valid_types}"
)
@pytest.mark.asyncio
async def test_complex_causal_web(self):
"""
Test a more complex scenario with multiple interconnected causes.
This tests the LLM's ability to identify multiple causal links and
ensure all referenced indices exist.
"""
text = """
The heavy rain caused flooding in the basement.
The flooding damaged the electrical system.
Because of the electrical damage, we had to call an electrician.
The electrician found that the wiring was old and needed replacement.
We decided to renovate the entire basement while fixing the wiring.
The renovation took three months and cost $15,000.
"""
context = "Home repair story"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 6, 1), context=context, llm_config=llm_config, agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) >= 4, f"Should extract at least 4 facts. Got {len(facts)}"
# Validate all causal relation indices (must reference PREVIOUS facts only)
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
assert 0 <= rel.target_fact_index < i, (
f"Fact {i} has causal relation to invalid index {rel.target_fact_index}. "
f"Must reference previous facts only (valid range: 0 to {i - 1}). "
f"Fact text: {fact.fact[:80]}..."
)
@pytest.mark.asyncio
async def test_no_self_referencing_causal_relations(self):
"""
Test that facts don't have causal relations pointing to themselves.
"""
text = """
I started learning Python because I wanted to automate my work tasks.
Learning Python led me to discover machine learning.
Machine learning fascinated me so much that I changed my career to data science.
"""
context = "Career change story"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 1, 1), context=context, llm_config=llm_config, agent_name="TestUser",
config=_get_raw_config(),
)
# Check no fact references itself
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
assert rel.target_fact_index != i, (
f"Fact {i} has a self-referencing causal relation! Fact text: {fact.fact}"
)
@pytest.mark.asyncio
async def test_bidirectional_causal_relationships(self):
"""
Test that bidirectional causal relationships (causes and caused_by)
are handled correctly.
"""
text = """
My promotion at work caused me to move to New York.
Moving to New York was caused by my promotion at work.
The new role enabled me to lead a team of engineers.
"""
context = "Work promotion story"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 2, 15), context=context, llm_config=llm_config, agent_name="TestUser",
config=_get_raw_config(),
)
# Validate all indices (must reference PREVIOUS facts only)
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
assert 0 <= rel.target_fact_index < i, (
f"Invalid target_fact_index {rel.target_fact_index} in fact {i}. "
f"Must reference previous facts only (valid range: 0 to {i - 1})"
)
@pytest.mark.asyncio
async def test_causal_relation_strength_values(self):
"""
Test that causal relation strength values are within valid range [0.0, 1.0].
"""
text = """
The stock market crash directly caused the company to lay off employees.
The layoffs indirectly led to reduced consumer spending in the area.
Reduced spending somewhat affected local businesses.
"""
context = "Economic impact story"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 4, 1), context=context, llm_config=llm_config, agent_name="TestUser",
config=_get_raw_config(),
)
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
assert 0.0 <= rel.strength <= 1.0, (
f"Causal relation strength {rel.strength} is outside valid range [0.0, 1.0]. "
f"Fact {i}: {fact.fact[:50]}..."
)