* perf: 3-phase retain pipeline — fix deadlocks, cap temporal links, query-time entity expansion
Major retain pipeline overhaul addressing deadlocks, write amplification,
and TimeoutErrors. Restructures retain into three phases:
Phase 1: Entity resolution on separate connection (read-heavy)
Phase 2: Core write transaction (atomic) — facts, unit_entities, links
Phase 3: Best-effort display data (error-isolated) — entity viz links, stats
Key changes:
- Sorted bulk INSERT FROM unnest() prevents deadlocks
- Temporal links capped to top-20 per unit (95% reduction)
- Batched semantic ANN via temp table + LATERAL
- Query-time entity expansion via unit_entities self-join
- Entity viz links moved to Phase 3 (post-transaction)
- HINDSIGHT_API_RETAIN_MAX_CONCURRENT config (default: 32)
* fix: increase semantic link top_k from 5 to 20
The hardcoded top_k=5 was artificially limiting semantic link creation.
Link expansion retrieval can consume up to budget (50-200) semantic
neighbors per seed set, but each fact only had 5 outgoing edges — making
the bidirectional graph very sparse.
Increasing to 20 gives retrieval 4x more edges to work with. The ANN
probe cost is unchanged (same HNSW traversal per fact, just returning
more rows). INSERT cost is negligible (~14k rows via bulk INSERT).
Also: all 18 TimeoutErrors in the latest benchmark (beam-1m-u20) were
from Gemini LLM calls, zero from the database — confirming the entity
resolution split eliminated DB timeouts entirely.
* perf: move semantic ANN search to Phase 1 to avoid transaction timeouts
The batched LATERAL ANN query (700 HNSW probes) was the last remaining
source of DB TimeoutErrors — all 29 in the latest benchmark were from
create_semantic_links_batch inside the Phase 2 write transaction.
Split semantic link creation into three phases:
- Phase 1 (separate conn, autocommit): ANN search via temp table + LATERAL.
No transaction locks, no contention with concurrent writers.
- Phase 2 (write transaction): within-batch numpy similarities (instant) +
INSERT of both within-batch and Phase 1 ANN results. No DB reads.
- Phase 3 (flush_pending_stats): future hook point for re-checking ANN
results after commit to catch links missed by concurrent batches.
Also adds 7 unit tests for compute_semantic_links_within_batch covering
empty input, identical/orthogonal embeddings, threshold filtering, top_k
cap, and tuple structure validation.
* fix: handle placeholder unit_ids in Phase 1 ANN search (not valid UUIDs)
* test: add Phase 1 ANN cross-batch test + configurable test PG port
- New test_semantic_links_phase1_ann_cross_batch verifies that the Phase 1
ANN search with placeholder unit IDs correctly creates cross-batch
semantic links after remapping to real IDs.
- Test PG port now configurable via HINDSIGHT_TEST_PG_PORT env var
(default: 5556) to avoid conflicts with running benchmark daemons.
* perf: remove retry_with_backoff from retain, set semaphore default to 4
Remove retry_with_backoff from _run_db_work and _run_delta_db_work:
- Deadlocks are prevented by sorted bulk INSERT (no need for retry)
- Transient timeouts are handled by the worker poller's task-level retry
(3 attempts, 60s spacing) which is better than rapid internal retries
that amplify I/O pressure during contention storms
Set HINDSIGHT_API_RETAIN_MAX_CONCURRENT default from 32 to 4:
- The semaphore gates Phase 1 (ANN + entity resolution) + Phase 2 (writes)
- At 4 concurrent, HNSW index I/O is manageable; at 10+ concurrent the
probes saturate disk and cause cascading timeouts
- LLM extraction still runs at full parallelism (semaphore acquired after)
* fix: add fact_type filter to Phase 1 ANN query to use per-bank HNSW indexes
The LATERAL ANN query was falling back to sequential scan + sort (90ms/probe)
because the per-bank HNSW indexes are partial indexes filtered on fact_type.
Without fact_type in the WHERE clause, PostgreSQL couldn't use them.
Fix: iterate over ('world', 'experience') and run one HNSW-indexed ANN per
type. EXPLAIN shows 8ms/probe (was 90ms) — 11x faster.
700 probes × 8ms × 2 types = ~11s total (was ~63s via seq scan).
* fix: scope temporal links by fact_type + add integration tests
Temporal links now filter by fact_type in the LATERAL query — world facts
only link to world facts, experience to experience. This matches how
retrieval filters results and avoids wasted cross-type link rows.
New integration tests:
- test_semantic_ann_uses_hnsw_index: verifies Phase 1 ANN creates
cross-batch semantic links (tests fact_type filter + placeholder remap)
- test_temporal_links_scoped_by_fact_type: verifies world facts get
temporal links to other world facts but NOT to experience facts
* fix: tolerate individual chunk LLM failures instead of failing entire batch
Changed asyncio.gather(*tasks) to asyncio.gather(*tasks, return_exceptions=True)
in both chunk-level and content-level fact extraction. A single chunk timeout
(e.g., Gemini >90s) no longer discards all other successfully extracted facts.
For a 50MB document with 17k chunks, even a 2% chunk failure rate previously
caused 0 completions (entire batch discarded). Now 16,700 facts are extracted
and only the 300 failed chunks are skipped with a warning log.
* fix: batch temporal LATERAL query for large documents (16k+ chunks)
The LATERAL query for temporal links passed all unit_ids at once into
unnest(), causing PostgreSQL timeouts on documents with 16k+ chunks.
Split into batches of 500 units per query to keep each under the
command_timeout.
Also identified: HNSW index creation on shared pg0 instances with
50k+ existing units exceeds the 60s command_timeout. This is a
test infrastructure issue (shared pg0 accumulates data) but also
affects production when creating new banks on large instances.
* feat: streaming chunk batching for large documents (RETAIN_CHUNK_BATCH_SIZE)
Process chunks in mini-batches of N (default 500), committing each batch
to the DB before starting the next. This prevents OOM kills on large
documents (50MB / 17k+ chunks) by keeping only ~500 facts + embeddings
in memory at a time instead of 50k+.
Each mini-batch goes through the full Phase 1 → 2 → 3 pipeline
independently, sharing the same document_id. On recovery (process dies
mid-way), delta retain detects already-committed chunks via content_hash
and skips them — only remaining chunks get re-extracted.
Config: HINDSIGHT_API_RETAIN_CHUNK_BATCH_SIZE (default: 500, 0 to disable)
Per-bank configurable via the hierarchical config system.
Tests:
- test_streaming_chunk_batching_produces_same_facts
- test_streaming_chunk_batching_recovery (delta retain skips committed chunks)
- test_streaming_disabled_for_small_docs
* perf(retain): producer-consumer pipeline + deferred semantic ANN
Replace the sequential streaming loop with a producer-consumer pipeline:
- LLM producer fires concurrent chunk extractions (semaphore-bounded)
- DB consumer drains queue in batches, runs Phase 1+2+3 per batch
- LLM and DB work overlap instead of running sequentially
Defer semantic links to a single final ANN pass after all batches commit:
- Remove within-batch semantic links from Phase 2 (was 2.6s/batch)
- Run parallel ANN (4 connections) after all facts committed
- top_k reduced from 50 to 20 (recall uses at most 20 neighbors)
- Recovery via operation result_metadata checkpoint
Additional optimizations:
- skip_exists_check on temporal/causal link INSERT (saves ~0.5s/batch)
- WHERE EXISTS guard on semantic link INSERT (handles document upsert)
- timeout=300s on ANN queries and bulk INSERT for large banks
- Demote [ANN] debug logs to logger.debug()
- Fix docstring typos (agent_id → bank_id)
- Fix content_index remapping in producer-consumer batches
- Fix delta retain passing contents vs delta_contents
50MB benchmark (mock LLM): 9.2 min (was 23 min) — 2.5x faster.
BEAM 10m benchmark: zero deadlocks, zero DB errors.
* refactor(retain): remove legacy fallback code paths
- Remove process_entities_batch (legacy single-connection entity processing)
- Remove extract_entities_batch_optimized (only caller was the above)
- Remove fallback entity processing inside Phase 2 transaction
- Remove legacy ANN inline fallback in create_semantic_links_batch
- Remove fallback entity_links direct-insert path in Phase 3
- Make resolved_entity_ids/entity_to_unit/unit_to_entity_ids required params
* refactor(retain): replace tuple returns with dataclasses, remove dead code
- Add EntityResolutionResult and Phase1Result dataclasses in types.py
- Replace 4-tuple return from _pre_resolve_phase1 with Phase1Result
- Remove dead `entity_links = []` variables in retain_batch and _try_delta_retain
- Remove unused `confidence_score` parameter from orchestrator.retain_batch
and _retain_batch_async_internal (was accepted but never used)
* fix(entity-resolver): remove LIKE full-scan fallbacks, use index-only trigram matching
The entity resolution query had LIKE '%...' substring conditions that bypassed
the GIN trigram index, causing full sequential scans of the entities table.
On banks with 10k+ entities, this caused TimeoutErrors (observed in BEAM 10m).
Changes:
- Remove LIKE fallbacks, use trigram % operator only (GIN index-based)
- Lower similarity threshold from 0.3 to 0.15 to catch substring relationships
- Use LOWER() on both sides for case-insensitive matching
- Migration: recreate GIN trigram index on LOWER(canonical_name)
* fix: remove schema prefix from index names in trigram migration
* fix(delta-retain): use same chunk_size as streaming path (3000 vs 120000)
_chunk_contents_for_delta defaulted to chunk_size=120000 while the streaming
path used 3000. On retry, delta re-chunked the document with different
boundaries, found 0 matching chunks, and fell through to full re-extraction.
This wasted all LLM calls on already-committed chunks.
Fix: use the same default (3000) so chunk hashes match on recovery.
* fix(retain): persist generated document_id in operation metadata for retry recovery
When no document_id is provided, retain generates a UUID. On retry, a new UUID
was generated, making delta retain and streaming chunk-hash recovery unable to
find previously committed chunks. All LLM extraction was wasted on retry.
Fix: resolve document_id early in retain_batch (before delta), persist it to
operation result_metadata, and recover it on retry. Both delta and streaming
paths now see the same document_id across attempts.
* refactor(retain): unify into single streaming pipeline, remove non-streaming path
All retains now go through the producer-consumer streaming pipeline,
regardless of document size. Small documents are processed as a single batch.
This eliminates the maintenance burden of two separate code paths.
Also fix document upsert: compare content hash to distinguish recovery
(same content, partially committed) from update (different content, needs
cascade-delete). Previously, existing chunks always triggered recovery mode.
* refactor(retain): remove dead code, replace raw dicts with Phase3Context dataclass
- Remove dead _handle_zero_facts_documents (no callers after path unification)
- Remove unused imports: defaultdict, EntityLink
- Replace raw dict phase3_context with typed Phase3Context dataclass
- Update _build_and_insert_entity_links_phase3 to use typed parameter
390 lines
16 KiB
Python
390 lines
16 KiB
Python
"""Tests for link_utils datetime handling, temporal link computation, and semantic link splitting."""
|
|
import numpy as np
|
|
import pytest
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
from hindsight_api.engine.retain.link_utils import (
|
|
_normalize_datetime,
|
|
_cap_links_per_unit,
|
|
compute_temporal_links,
|
|
compute_temporal_query_bounds,
|
|
compute_semantic_links_within_batch,
|
|
MAX_TEMPORAL_LINKS_PER_UNIT,
|
|
)
|
|
|
|
|
|
class TestNormalizeDatetime:
|
|
"""Tests for the _normalize_datetime helper function."""
|
|
|
|
def test_none_returns_none(self):
|
|
"""Test that None input returns None."""
|
|
assert _normalize_datetime(None) is None
|
|
|
|
def test_naive_datetime_becomes_utc(self):
|
|
"""Test that naive datetimes are converted to UTC."""
|
|
naive_dt = datetime(2024, 6, 15, 10, 30, 0)
|
|
result = _normalize_datetime(naive_dt)
|
|
|
|
assert result.tzinfo is not None
|
|
assert result.tzinfo == timezone.utc
|
|
assert result.year == 2024
|
|
assert result.month == 6
|
|
assert result.day == 15
|
|
assert result.hour == 10
|
|
assert result.minute == 30
|
|
|
|
def test_aware_datetime_unchanged(self):
|
|
"""Test that timezone-aware datetimes are returned unchanged."""
|
|
aware_dt = datetime(2024, 6, 15, 10, 30, 0, tzinfo=timezone.utc)
|
|
result = _normalize_datetime(aware_dt)
|
|
|
|
assert result == aware_dt
|
|
assert result.tzinfo == timezone.utc
|
|
|
|
def test_mixed_datetimes_can_be_compared(self):
|
|
"""Test that normalized naive and aware datetimes can be compared."""
|
|
naive_dt = datetime(2024, 6, 15, 10, 30, 0)
|
|
aware_dt = datetime(2024, 6, 15, 10, 30, 0, tzinfo=timezone.utc)
|
|
|
|
normalized_naive = _normalize_datetime(naive_dt)
|
|
normalized_aware = _normalize_datetime(aware_dt)
|
|
|
|
# Should be able to compare without TypeError
|
|
assert normalized_naive == normalized_aware
|
|
|
|
|
|
class TestComputeTemporalQueryBounds:
|
|
"""Tests for compute_temporal_query_bounds function."""
|
|
|
|
def test_empty_units_returns_none(self):
|
|
"""Test that empty input returns (None, None)."""
|
|
min_date, max_date = compute_temporal_query_bounds({})
|
|
assert min_date is None
|
|
assert max_date is None
|
|
|
|
def test_single_unit_normal_date(self):
|
|
"""Test bounds for a single unit with normal date."""
|
|
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}
|
|
min_date, max_date = compute_temporal_query_bounds(units, time_window_hours=24)
|
|
|
|
assert min_date == datetime(2024, 6, 14, 12, 0, 0, tzinfo=timezone.utc)
|
|
assert max_date == datetime(2024, 6, 16, 12, 0, 0, tzinfo=timezone.utc)
|
|
|
|
def test_multiple_units(self):
|
|
"""Test bounds span across multiple units."""
|
|
units = {
|
|
"unit-1": datetime(2024, 6, 10, 12, 0, 0, tzinfo=timezone.utc),
|
|
"unit-2": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc),
|
|
"unit-3": datetime(2024, 6, 20, 12, 0, 0, tzinfo=timezone.utc),
|
|
}
|
|
min_date, max_date = compute_temporal_query_bounds(units, time_window_hours=24)
|
|
|
|
# min should be Jun 10 - 24h = Jun 9
|
|
assert min_date == datetime(2024, 6, 9, 12, 0, 0, tzinfo=timezone.utc)
|
|
# max should be Jun 20 + 24h = Jun 21
|
|
assert max_date == datetime(2024, 6, 21, 12, 0, 0, tzinfo=timezone.utc)
|
|
|
|
def test_mixed_naive_and_aware_datetimes(self):
|
|
"""Test that mixed naive/aware datetimes work correctly."""
|
|
units = {
|
|
"unit-1": datetime(2024, 6, 10, 12, 0, 0), # naive
|
|
"unit-2": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), # aware
|
|
}
|
|
# Should not raise TypeError
|
|
min_date, max_date = compute_temporal_query_bounds(units, time_window_hours=24)
|
|
|
|
assert min_date is not None
|
|
assert max_date is not None
|
|
assert min_date.tzinfo is not None
|
|
assert max_date.tzinfo is not None
|
|
|
|
def test_overflow_near_datetime_min(self):
|
|
"""Test overflow protection near datetime.min."""
|
|
units = {"unit-1": datetime(1, 1, 2, 0, 0, tzinfo=timezone.utc)}
|
|
min_date, max_date = compute_temporal_query_bounds(units, time_window_hours=48)
|
|
|
|
# Should handle overflow gracefully
|
|
assert min_date == datetime.min.replace(tzinfo=timezone.utc)
|
|
assert max_date is not None
|
|
|
|
def test_overflow_near_datetime_max(self):
|
|
"""Test overflow protection near datetime.max."""
|
|
units = {"unit-1": datetime(9999, 12, 30, 0, 0, tzinfo=timezone.utc)}
|
|
min_date, max_date = compute_temporal_query_bounds(units, time_window_hours=48)
|
|
|
|
# Should handle overflow gracefully
|
|
assert min_date is not None
|
|
assert max_date == datetime.max.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
class TestComputeTemporalLinks:
|
|
"""Tests for compute_temporal_links function."""
|
|
|
|
def test_empty_units_returns_empty(self):
|
|
"""Test that empty input returns empty list."""
|
|
links = compute_temporal_links({}, [])
|
|
assert links == []
|
|
|
|
def test_no_candidates_returns_empty(self):
|
|
"""Test that no candidates means no links."""
|
|
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}
|
|
links = compute_temporal_links(units, [])
|
|
assert links == []
|
|
|
|
def test_candidate_within_window_creates_link(self):
|
|
"""Test that candidates within time window create links."""
|
|
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}
|
|
candidates = [
|
|
{"id": "candidate-1", "event_date": datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)},
|
|
]
|
|
|
|
links = compute_temporal_links(units, candidates, time_window_hours=24)
|
|
|
|
assert len(links) == 1
|
|
assert links[0][0] == "unit-1"
|
|
assert links[0][1] == "candidate-1"
|
|
assert links[0][2] == "temporal"
|
|
assert links[0][4] is None
|
|
# Weight should be high since they're close (2 hours apart)
|
|
assert links[0][3] > 0.9
|
|
|
|
def test_candidate_outside_window_no_link(self):
|
|
"""Test that candidates outside time window don't create links."""
|
|
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}
|
|
candidates = [
|
|
{"id": "candidate-1", "event_date": datetime(2024, 6, 10, 12, 0, 0, tzinfo=timezone.utc)},
|
|
]
|
|
|
|
links = compute_temporal_links(units, candidates, time_window_hours=24)
|
|
|
|
assert len(links) == 0
|
|
|
|
def test_weight_decreases_with_distance(self):
|
|
"""Test that weight decreases as time difference increases."""
|
|
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}
|
|
candidates = [
|
|
{"id": "close", "event_date": datetime(2024, 6, 15, 11, 0, 0, tzinfo=timezone.utc)}, # 1 hour
|
|
{"id": "far", "event_date": datetime(2024, 6, 14, 18, 0, 0, tzinfo=timezone.utc)}, # 18 hours
|
|
]
|
|
|
|
links = compute_temporal_links(units, candidates, time_window_hours=24)
|
|
|
|
assert len(links) == 2
|
|
close_link = next(l for l in links if l[1] == "close")
|
|
far_link = next(l for l in links if l[1] == "far")
|
|
|
|
assert close_link[3] > far_link[3]
|
|
|
|
def test_max_10_links_per_unit(self):
|
|
"""Test that at most 10 links are created per unit."""
|
|
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}
|
|
# Create 15 candidates all within window
|
|
candidates = [
|
|
{"id": f"candidate-{i}", "event_date": datetime(2024, 6, 15, 11, 0, 0, tzinfo=timezone.utc)}
|
|
for i in range(15)
|
|
]
|
|
|
|
links = compute_temporal_links(units, candidates, time_window_hours=24)
|
|
|
|
assert len(links) == 10
|
|
|
|
def test_multiple_units_multiple_candidates(self):
|
|
"""Test with multiple units and candidates."""
|
|
units = {
|
|
"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc),
|
|
"unit-2": datetime(2024, 6, 20, 12, 0, 0, tzinfo=timezone.utc),
|
|
}
|
|
candidates = [
|
|
{"id": "c1", "event_date": datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)}, # near unit-1
|
|
{"id": "c2", "event_date": datetime(2024, 6, 20, 10, 0, 0, tzinfo=timezone.utc)}, # near unit-2
|
|
{"id": "c3", "event_date": datetime(2024, 6, 17, 12, 0, 0, tzinfo=timezone.utc)}, # between, near neither
|
|
]
|
|
|
|
links = compute_temporal_links(units, candidates, time_window_hours=24)
|
|
|
|
# unit-1 should link to c1 only
|
|
# unit-2 should link to c2 only
|
|
unit1_links = [l for l in links if l[0] == "unit-1"]
|
|
unit2_links = [l for l in links if l[0] == "unit-2"]
|
|
|
|
assert len(unit1_links) == 1
|
|
assert unit1_links[0][1] == "c1"
|
|
|
|
assert len(unit2_links) == 1
|
|
assert unit2_links[0][1] == "c2"
|
|
|
|
def test_mixed_naive_and_aware_datetimes(self):
|
|
"""Test that mixed naive/aware datetimes work correctly."""
|
|
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0)} # naive
|
|
candidates = [
|
|
{"id": "c1", "event_date": datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)}, # aware
|
|
]
|
|
|
|
# Should not raise TypeError
|
|
links = compute_temporal_links(units, candidates, time_window_hours=24)
|
|
assert len(links) == 1
|
|
|
|
def test_overflow_near_datetime_min(self):
|
|
"""Test overflow protection when unit date is near datetime.min."""
|
|
units = {"unit-1": datetime(1, 1, 2, 0, 0, tzinfo=timezone.utc)}
|
|
candidates = [
|
|
{"id": "c1", "event_date": datetime(1, 1, 1, 12, 0, 0, tzinfo=timezone.utc)},
|
|
]
|
|
|
|
# Should not raise OverflowError
|
|
links = compute_temporal_links(units, candidates, time_window_hours=48)
|
|
assert len(links) == 1
|
|
|
|
def test_overflow_near_datetime_max(self):
|
|
"""Test overflow protection when unit date is near datetime.max."""
|
|
units = {"unit-1": datetime(9999, 12, 30, 0, 0, tzinfo=timezone.utc)}
|
|
candidates = [
|
|
{"id": "c1", "event_date": datetime(9999, 12, 31, 12, 0, 0, tzinfo=timezone.utc)},
|
|
]
|
|
|
|
# Should not raise OverflowError
|
|
links = compute_temporal_links(units, candidates, time_window_hours=48)
|
|
assert len(links) == 1
|
|
|
|
def test_weight_minimum_is_0_3(self):
|
|
"""Test that weight doesn't go below 0.3."""
|
|
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}
|
|
candidates = [
|
|
# 23 hours apart - should be just within 24h window but low weight
|
|
{"id": "c1", "event_date": datetime(2024, 6, 14, 13, 0, 0, tzinfo=timezone.utc)},
|
|
]
|
|
|
|
links = compute_temporal_links(units, candidates, time_window_hours=24)
|
|
|
|
assert len(links) == 1
|
|
assert links[0][3] >= 0.3
|
|
|
|
|
|
class TestCapLinksPerUnit:
|
|
"""Tests for the _cap_links_per_unit helper function."""
|
|
|
|
def test_empty_links(self):
|
|
assert _cap_links_per_unit([]) == []
|
|
|
|
def test_under_cap_unchanged(self):
|
|
links = [
|
|
("unit_a", "unit_x", "temporal", 0.9, None),
|
|
("unit_a", "unit_y", "temporal", 0.8, None),
|
|
]
|
|
result = _cap_links_per_unit(links, max_per_unit=5)
|
|
assert len(result) == 2
|
|
|
|
def test_caps_to_max_per_unit(self):
|
|
# Create 30 links from the same unit with descending weights
|
|
links = [("unit_a", f"unit_{i}", "temporal", 1.0 - i * 0.01, None) for i in range(30)]
|
|
result = _cap_links_per_unit(links, max_per_unit=10)
|
|
assert len(result) == 10
|
|
# Should keep the highest-weight links
|
|
weights = [lnk[3] for lnk in result]
|
|
assert weights == sorted(weights, reverse=True)
|
|
assert weights[0] == 1.0 # Highest weight kept
|
|
|
|
def test_caps_independently_per_unit(self):
|
|
links_a = [("unit_a", f"target_{i}", "temporal", 0.9 - i * 0.01, None) for i in range(10)]
|
|
links_b = [("unit_b", f"target_{i}", "temporal", 0.8 - i * 0.01, None) for i in range(10)]
|
|
result = _cap_links_per_unit(links_a + links_b, max_per_unit=5)
|
|
# 5 from unit_a + 5 from unit_b
|
|
assert len(result) == 10
|
|
from_a = [lnk for lnk in result if lnk[0] == "unit_a"]
|
|
from_b = [lnk for lnk in result if lnk[0] == "unit_b"]
|
|
assert len(from_a) == 5
|
|
assert len(from_b) == 5
|
|
|
|
def test_default_max_is_temporal_constant(self):
|
|
links = [("unit_a", f"target_{i}", "temporal", 1.0 - i * 0.01, None) for i in range(50)]
|
|
result = _cap_links_per_unit(links)
|
|
assert len(result) == MAX_TEMPORAL_LINKS_PER_UNIT
|
|
|
|
def test_preserves_tuple_structure(self):
|
|
links = [("from_id", "to_id", "temporal", 0.95, "entity_id")]
|
|
result = _cap_links_per_unit(links, max_per_unit=5)
|
|
assert result[0] == ("from_id", "to_id", "temporal", 0.95, "entity_id")
|
|
|
|
|
|
class TestComputeSemanticLinksWithinBatch:
|
|
"""Tests for compute_semantic_links_within_batch.
|
|
|
|
This function computes semantic links between units in the same batch
|
|
using numpy dot product (no DB access). It runs in Phase 2 (write
|
|
transaction) while the expensive ANN search against existing units runs
|
|
in Phase 1 on a separate connection to avoid TimeoutErrors from HNSW
|
|
index contention under concurrent load.
|
|
"""
|
|
|
|
def test_empty_returns_empty(self):
|
|
assert compute_semantic_links_within_batch([], []) == []
|
|
|
|
def test_single_unit_returns_empty(self):
|
|
emb = [np.random.randn(384).tolist()]
|
|
assert compute_semantic_links_within_batch(["u1"], emb) == []
|
|
|
|
def test_identical_embeddings_produce_links(self):
|
|
"""Two identical embeddings should have similarity=1.0 (above 0.7 threshold)."""
|
|
emb = [0.1] * 384
|
|
links = compute_semantic_links_within_batch(["u1", "u2"], [emb, emb])
|
|
assert len(links) == 2 # bidirectional: u1→u2, u2→u1
|
|
from_ids = {lnk[0] for lnk in links}
|
|
to_ids = {lnk[1] for lnk in links}
|
|
assert from_ids == {"u1", "u2"}
|
|
assert to_ids == {"u1", "u2"}
|
|
for lnk in links:
|
|
assert lnk[2] == "semantic"
|
|
assert lnk[3] >= 0.99 # near-1.0 similarity
|
|
assert lnk[4] is None # no entity_id
|
|
|
|
def test_orthogonal_embeddings_no_links(self):
|
|
"""Orthogonal embeddings should have similarity=0 (below 0.7 threshold)."""
|
|
emb1 = [1.0] + [0.0] * 383
|
|
emb2 = [0.0] + [1.0] + [0.0] * 382
|
|
links = compute_semantic_links_within_batch(["u1", "u2"], [emb1, emb2])
|
|
assert len(links) == 0
|
|
|
|
def test_respects_threshold(self):
|
|
"""Links below threshold should be excluded."""
|
|
emb1 = np.random.randn(384).tolist()
|
|
# Create a slightly similar embedding (add noise)
|
|
emb2 = [x + np.random.randn() * 0.5 for x in emb1]
|
|
# Normalize both
|
|
norm1 = np.linalg.norm(emb1)
|
|
norm2 = np.linalg.norm(emb2)
|
|
emb1 = [x / norm1 for x in emb1]
|
|
emb2 = [x / norm2 for x in emb2]
|
|
|
|
links_low = compute_semantic_links_within_batch(["u1", "u2"], [emb1, emb2], threshold=0.0)
|
|
links_high = compute_semantic_links_within_batch(["u1", "u2"], [emb1, emb2], threshold=0.99)
|
|
# Low threshold should have more links than high threshold
|
|
assert len(links_low) >= len(links_high)
|
|
|
|
def test_top_k_limits_per_unit(self):
|
|
"""Each unit should link to at most top_k other units."""
|
|
n = 10
|
|
# Create similar embeddings (all close to the same vector)
|
|
base = np.random.randn(384)
|
|
base = base / np.linalg.norm(base)
|
|
embs = [(base + np.random.randn(384) * 0.01).tolist() for _ in range(n)]
|
|
unit_ids = [f"u{i}" for i in range(n)]
|
|
|
|
links = compute_semantic_links_within_batch(unit_ids, embs, top_k=3, threshold=0.5)
|
|
# Each unit should have at most 3 outgoing links
|
|
from collections import Counter
|
|
from_counts = Counter(lnk[0] for lnk in links)
|
|
for count in from_counts.values():
|
|
assert count <= 3
|
|
|
|
def test_link_tuple_structure(self):
|
|
"""Verify the tuple format matches what _bulk_insert_links expects."""
|
|
emb = [0.1] * 384
|
|
links = compute_semantic_links_within_batch(["u1", "u2"], [emb, emb])
|
|
for lnk in links:
|
|
assert len(lnk) == 5
|
|
from_id, to_id, link_type, weight, entity_id = lnk
|
|
assert isinstance(from_id, str)
|
|
assert isinstance(to_id, str)
|
|
assert link_type == "semantic"
|
|
assert 0.0 <= weight <= 1.0
|
|
assert entity_id is None
|