diff --git a/tests/test_395_temporal_semantics_comprehensive.py b/tests/test_395_temporal_semantics_comprehensive.py new file mode 100644 index 00000000..1b1bd78a --- /dev/null +++ b/tests/test_395_temporal_semantics_comprehensive.py @@ -0,0 +1,1132 @@ +""" +Comprehensive tests for Issue #395 — Temporal Semantics. + +Covers the sub-issues not fully tested elsewhere: + #396 — Core Temporal Data Model (BiTemporalFact, parse/serialize helpers) + #397 — Temporal Query Engine (reconstruct_at_time, consistency validation, + analyze_evolution, query_time_range aggregation strategies) + #399 — Context Graph Temporal Awareness (state_at, record_decision validity + windows, find_precedents as_of, CausalChainAnalyzer.trace_at_time) + +Already covered separately: + #398 — tests/kg/test_temporal_reasoning.py + #400 — tests/semantic_extract/test_temporal_extraction.py + #401 — tests/test_401_temporal_provenance_export.py + #402 — tests/kg/test_temporal_query_rewriter.py + tests/context/test_temporal_retriever.py +""" + +from __future__ import annotations + +import time +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +UTC = timezone.utc + + +def _dt(year: int, month: int = 1, day: int = 1) -> datetime: + return datetime(year, month, day, tzinfo=UTC) + + +def _iso(year: int, month: int = 1, day: int = 1) -> str: + return f"{year:04d}-{month:02d}-{day:02d}T00:00:00Z" + + +# =========================================================================== +# #396 — Core Temporal Data Model +# =========================================================================== + +class TestTemporalBoundSentinel: + """TemporalBound.OPEN must be a distinct sentinel, not a datetime.""" + + def setup_method(self): + from semantica.kg.temporal_model import TemporalBound + self.OPEN = TemporalBound.OPEN + + def test_open_is_not_none(self): + assert self.OPEN is not None + + def test_open_is_not_datetime(self): + assert not isinstance(self.OPEN, datetime) + + def test_open_value_is_string_OPEN(self): + assert self.OPEN.value == "OPEN" + + def test_open_equality_with_self(self): + from semantica.kg.temporal_model import TemporalBound + assert self.OPEN is TemporalBound.OPEN + + def test_open_not_equal_to_arbitrary_datetime(self): + assert self.OPEN != _dt(2024) + + def test_open_string_comparison(self): + from semantica.kg.temporal_model import TemporalBound + assert TemporalBound.OPEN.value == "OPEN" + + +class TestParseTemporalValue: + """parse_temporal_value handles all supported input types.""" + + def setup_method(self): + from semantica.kg.temporal_model import parse_temporal_value + self.parse = parse_temporal_value + + def test_none_returns_none(self): + assert self.parse(None) is None + + def test_datetime_aware_passed_through_as_utc(self): + dt = _dt(2024, 6, 15) + result = self.parse(dt) + assert result == dt + assert result.tzinfo is not None + + def test_datetime_naive_gains_utc(self): + naive = datetime(2024, 6, 15) + result = self.parse(naive) + assert result.tzinfo == UTC + + def test_iso_string_z_suffix(self): + result = self.parse("2024-03-01T00:00:00Z") + assert result.year == 2024 + assert result.month == 3 + assert result.day == 1 + assert result.tzinfo is not None + + def test_iso_string_plus_offset(self): + result = self.parse("2024-03-01T00:00:00+00:00") + assert result.year == 2024 + + def test_iso_string_single_digit_month_coerced(self): + # e.g., "2024-1-5" should be coerced to "2024-01-05" + result = self.parse("2024-1-5") + assert result.year == 2024 + assert result.month == 1 + assert result.day == 5 + + def test_unix_timestamp_int(self): + ts = 1704067200 # 2024-01-01 00:00:00 UTC + result = self.parse(ts) + assert result.year == 2024 + assert result.tzinfo is not None + + def test_unix_timestamp_float(self): + ts = 1704067200.0 + result = self.parse(ts) + assert result.year == 2024 + + def test_invalid_string_raises_temporal_validation_error(self): + from semantica.utils.exceptions import TemporalValidationError + with pytest.raises(TemporalValidationError): + self.parse("not-a-date") + + def test_unsupported_type_raises_temporal_validation_error(self): + from semantica.utils.exceptions import TemporalValidationError + with pytest.raises(TemporalValidationError): + self.parse([2024, 1, 1]) + + def test_result_always_utc_normalised(self): + result = self.parse("2024-06-15T12:00:00+05:30") + assert result.tzinfo == UTC + assert result.hour == 6 # 12:00 IST → 06:30 UTC → 06 (truncated by fromisoformat) + + +class TestParseTemporalBound: + """parse_temporal_bound wraps parse_temporal_value for bound fields.""" + + def setup_method(self): + from semantica.kg.temporal_model import parse_temporal_bound, TemporalBound + self.parse = parse_temporal_bound + self.OPEN = TemporalBound.OPEN + + def test_none_returns_default_none(self): + assert self.parse(None) is None + + def test_none_with_explicit_default(self): + assert self.parse(None, default=self.OPEN) is self.OPEN + + def test_open_sentinel_enum_value_returns_open(self): + result = self.parse(self.OPEN) + assert result is self.OPEN + + def test_open_string_returns_open(self): + result = self.parse("OPEN") + assert result is self.OPEN + + def test_valid_datetime_string_returns_datetime(self): + result = self.parse("2024-01-01T00:00:00Z") + assert isinstance(result, datetime) + assert result.year == 2024 + + def test_datetime_object_returned_as_datetime(self): + dt = _dt(2024) + result = self.parse(dt) + assert result == dt + + +class TestSerializeTemporalHelpers: + """serialize_temporal_value / serialize_temporal_bound round-trip.""" + + def setup_method(self): + from semantica.kg.temporal_model import ( + serialize_temporal_value, + serialize_temporal_bound, + TemporalBound, + ) + self.sv = serialize_temporal_value + self.sb = serialize_temporal_bound + self.OPEN = TemporalBound.OPEN + + def test_serialize_none_is_none(self): + assert self.sv(None) is None + + def test_serialize_datetime_produces_z_suffix(self): + result = self.sv(_dt(2024, 6, 1)) + assert result.endswith("Z") + assert "2024-06-01" in result + + def test_serialize_always_utc(self): + result = self.sv(_dt(2024, 1, 1)) + assert "+00:00" not in result # should use Z-form + assert "2024-01-01" in result + + def test_bound_none_is_none(self): + assert self.sb(None) is None + + def test_bound_open_is_none(self): + assert self.sb(self.OPEN) is None + + def test_bound_datetime_serializes_normally(self): + result = self.sb(_dt(2025, 3, 15)) + assert "2025-03-15" in result + + +class TestBiTemporalFact: + """BiTemporalFact construction, from_relationship, to_relationship_fields.""" + + def setup_method(self): + from semantica.kg.temporal_model import BiTemporalFact, TemporalBound + self.BiTemporalFact = BiTemporalFact + self.OPEN = TemporalBound.OPEN + + def test_from_relationship_basic(self): + fact = self.BiTemporalFact.from_relationship({ + "valid_from": "2024-01-01T00:00:00Z", + "valid_until": "2024-12-31T00:00:00Z", + }) + assert fact.valid_from.year == 2024 + assert isinstance(fact.valid_until, datetime) + assert fact.valid_until.year == 2024 + + def test_from_relationship_open_valid_until(self): + fact = self.BiTemporalFact.from_relationship({ + "valid_from": "2024-01-01T00:00:00Z", + "valid_until": "OPEN", + }) + assert fact.valid_until is self.OPEN + + def test_from_relationship_none_valid_until_becomes_open(self): + fact = self.BiTemporalFact.from_relationship({ + "valid_from": "2024-01-01T00:00:00Z", + "valid_until": None, + }) + assert fact.valid_until is self.OPEN + + def test_from_relationship_no_recorded_at_falls_back_to_valid_from(self): + fact = self.BiTemporalFact.from_relationship({ + "valid_from": "2024-05-01T00:00:00Z", + }) + # recorded_at should be set (not None) + assert fact.recorded_at is not None + + def test_from_relationship_with_recorded_at(self): + fact = self.BiTemporalFact.from_relationship({ + "valid_from": "2024-01-01T00:00:00Z", + "recorded_at": "2024-03-01T00:00:00Z", + }) + assert fact.recorded_at.month == 3 + + def test_bitemporal_transaction_time_superseded_at_open_by_default(self): + fact = self.BiTemporalFact.from_relationship({ + "valid_from": "2024-01-01T00:00:00Z", + }) + assert fact.superseded_at is self.OPEN + + def test_bitemporal_superseded_at_datetime(self): + fact = self.BiTemporalFact.from_relationship({ + "valid_from": "2024-01-01T00:00:00Z", + "superseded_at": "2025-01-01T00:00:00Z", + }) + assert isinstance(fact.superseded_at, datetime) + assert fact.superseded_at.year == 2025 + + def test_to_relationship_fields_round_trips_valid_from(self): + fact = self.BiTemporalFact.from_relationship({ + "valid_from": "2024-06-15T00:00:00Z", + "valid_until": "2025-06-14T00:00:00Z", + }) + fields = fact.to_relationship_fields() + assert "valid_from" in fields + assert "2024-06-15" in fields["valid_from"] + + def test_to_relationship_fields_open_valid_until_serializes_as_none(self): + fact = self.BiTemporalFact.from_relationship({ + "valid_from": "2024-01-01T00:00:00Z", + "valid_until": "OPEN", + }) + fields = fact.to_relationship_fields() + assert fields["valid_until"] is None + + def test_to_relationship_fields_recorded_at_present(self): + fact = self.BiTemporalFact.from_relationship({ + "valid_from": "2024-01-01T00:00:00Z", + "recorded_at": "2024-02-01T00:00:00Z", + }) + fields = fact.to_relationship_fields() + assert "recorded_at" in fields + assert "2024-02-01" in fields["recorded_at"] + + def test_recorded_at_auto_populated_at_creation_time(self): + before = datetime.now(UTC) + fact = self.BiTemporalFact( + valid_from=_dt(2024), + valid_until=self.OPEN, + ) + after = datetime.now(UTC) + # recorded_at should be between before and after + assert before <= fact.recorded_at <= after + + +class TestDeserializeAndJsonReady: + """deserialize_relationship_temporal_fields and relationship_to_json_ready.""" + + def setup_method(self): + from semantica.kg.temporal_model import ( + deserialize_relationship_temporal_fields, + relationship_to_json_ready, + temporal_structure_to_json_ready, + TemporalBound, + ) + self.deser = deserialize_relationship_temporal_fields + self.json_ready = relationship_to_json_ready + self.structure_ready = temporal_structure_to_json_ready + self.OPEN = TemporalBound.OPEN + + def test_deserialize_normalizes_single_digit_month(self): + rel = {"id": "r1", "valid_from": "2024-1-5", "valid_until": None} + result = self.deser(rel) + assert "2024-01-05" in result["valid_from"] + + def test_deserialize_preserves_non_temporal_fields(self): + rel = {"id": "r1", "type": "knows", "valid_from": "2024-01-01T00:00:00Z"} + result = self.deser(rel) + assert result["type"] == "knows" + assert result["id"] == "r1" + + def test_deserialize_open_until_retained_as_sentinel(self): + rel = {"valid_from": "2024-01-01T00:00:00Z", "valid_until": "OPEN"} + result = self.deser(rel) + assert result["valid_until"] is self.OPEN + + def test_json_ready_converts_datetimes_to_strings(self): + rel = { + "id": "r1", + "valid_from": "2024-01-01T00:00:00Z", + "valid_until": "2024-12-31T00:00:00Z", + } + result = self.json_ready(rel) + assert isinstance(result["valid_from"], str) + assert isinstance(result["valid_until"], str) + + def test_json_ready_open_until_is_none(self): + rel = {"valid_from": "2024-01-01T00:00:00Z", "valid_until": "OPEN"} + result = self.json_ready(rel) + assert result["valid_until"] is None + + def test_temporal_structure_to_json_ready_recurses_into_dict(self): + data = { + "outer": { + "valid_from": _dt(2024), + "valid_until": self.OPEN, + } + } + result = self.structure_ready(data) + assert isinstance(result["outer"]["valid_from"], str) + assert result["outer"]["valid_until"] is None + + def test_temporal_structure_to_json_ready_recurses_into_list(self): + data = [_dt(2024), self.OPEN] + result = self.structure_ready(data) + assert isinstance(result[0], str) + assert result[1] is None + + def test_temporal_structure_to_json_ready_primitive_passthrough(self): + assert self.structure_ready("hello") == "hello" + assert self.structure_ready(42) == 42 + assert self.structure_ready(None) is None + + +# =========================================================================== +# #397 — Temporal Query Engine +# =========================================================================== + +class TestReconstructAtTime: + """TemporalGraphQuery.reconstruct_at_time returns a self-consistent subgraph.""" + + def setup_method(self): + from semantica.kg import TemporalGraphQuery + self.q = TemporalGraphQuery() + + def _graph(self, entities, relationships): + return {"entities": entities, "relationships": relationships} + + def test_active_entity_and_relationship_included(self): + graph = self._graph( + entities=[ + {"id": "A", "valid_from": _iso(2020), "valid_until": _iso(2025)}, + {"id": "B", "valid_from": _iso(2020), "valid_until": _iso(2025)}, + ], + relationships=[ + {"id": "r1", "source": "A", "target": "B", "type": "knows", + "valid_from": _iso(2021), "valid_until": _iso(2024)}, + ], + ) + result = self.q.reconstruct_at_time(graph, _dt(2022)) + assert len(result["entities"]) == 2 + assert len(result["relationships"]) == 1 + + def test_expired_entity_excluded(self): + graph = self._graph( + entities=[ + {"id": "A", "valid_from": _iso(2010), "valid_until": _iso(2015)}, + {"id": "B", "valid_from": _iso(2020)}, + ], + relationships=[], + ) + result = self.q.reconstruct_at_time(graph, _dt(2023)) + ids = {e["id"] for e in result["entities"]} + assert "A" not in ids + assert "B" in ids + + def test_future_entity_excluded(self): + graph = self._graph( + entities=[ + {"id": "future", "valid_from": _iso(2030)}, + {"id": "present", "valid_from": _iso(2020)}, + ], + relationships=[], + ) + result = self.q.reconstruct_at_time(graph, _dt(2024)) + ids = {e["id"] for e in result["entities"]} + assert "future" not in ids + assert "present" in ids + + def test_dangling_relationship_removed_when_source_expired(self): + graph = self._graph( + entities=[ + {"id": "A", "valid_from": _iso(2010), "valid_until": _iso(2015)}, + {"id": "B", "valid_from": _iso(2010)}, + ], + relationships=[ + {"id": "r1", "source": "A", "target": "B", "type": "rel"}, + ], + ) + result = self.q.reconstruct_at_time(graph, _dt(2020)) + assert result["relationships"] == [] + + def test_dangling_relationship_removed_when_target_expired(self): + graph = self._graph( + entities=[ + {"id": "A", "valid_from": _iso(2010)}, + {"id": "B", "valid_from": _iso(2010), "valid_until": _iso(2015)}, + ], + relationships=[ + {"id": "r1", "source": "A", "target": "B", "type": "rel"}, + ], + ) + result = self.q.reconstruct_at_time(graph, _dt(2020)) + assert result["relationships"] == [] + + def test_entity_timeless_always_included(self): + # Entities with no valid_from/valid_until are always considered active + graph = self._graph( + entities=[{"id": "timeless"}], + relationships=[], + ) + result = self.q.reconstruct_at_time(graph, _dt(2024)) + assert len(result["entities"]) == 1 + + def test_no_entities_filters_only_relationships(self): + graph = self._graph( + entities=[], + relationships=[ + {"id": "r1", "source": "A", "target": "B", "type": "t", + "valid_from": _iso(2020), "valid_until": _iso(2025)}, + {"id": "r2", "source": "C", "target": "D", "type": "t", + "valid_from": _iso(2010), "valid_until": _iso(2015)}, + ], + ) + result = self.q.reconstruct_at_time(graph, _dt(2022)) + assert len(result["relationships"]) == 1 + assert result["relationships"][0]["id"] == "r1" + + def test_boundary_dates_inclusive(self): + at = _dt(2024, 6, 1) + graph = self._graph( + entities=[], + relationships=[ + {"id": "r1", "source": "A", "target": "B", "type": "t", + "valid_from": _iso(2024, 6, 1), "valid_until": _iso(2024, 12, 31)}, + ], + ) + result = self.q.reconstruct_at_time(graph, at) + assert len(result["relationships"]) == 1 + + def test_result_is_independent_copy(self): + """Mutating reconstruct_at_time output must not affect original graph.""" + graph = self._graph( + entities=[{"id": "A"}], + relationships=[], + ) + result = self.q.reconstruct_at_time(graph, _dt(2024)) + result["entities"].clear() + assert len(graph["entities"]) == 1 + + def test_transaction_time_axis_filters_by_recorded_at(self): + graph = self._graph( + entities=[], + relationships=[ + {"id": "r1", "source": "A", "target": "B", "type": "t", + "recorded_at": _iso(2022), "superseded_at": "OPEN"}, + {"id": "r2", "source": "C", "target": "D", "type": "t", + "recorded_at": _iso(2025), "superseded_at": "OPEN"}, + ], + ) + result = self.q.reconstruct_at_time(graph, _dt(2023), time_axis="transaction") + ids = {r["id"] for r in result["relationships"]} + assert "r1" in ids + assert "r2" not in ids + + +class TestTemporalConsistencyValidation: + """TemporalGraphQuery.validate_temporal_consistency detects all issue types.""" + + def setup_method(self): + from semantica.kg import TemporalGraphQuery + self.q = TemporalGraphQuery() + + def test_valid_graph_has_no_errors(self): + graph = { + "entities": [ + {"id": "A", "valid_from": _iso(2020), "valid_until": _iso(2025)}, + {"id": "B", "valid_from": _iso(2020), "valid_until": _iso(2025)}, + ], + "relationships": [ + {"id": "r1", "source": "A", "target": "B", "type": "rel", + "valid_from": _iso(2021), "valid_until": _iso(2024)}, + ], + } + report = self.q.validate_temporal_consistency(graph) + assert report.errors == [] + + def test_inverted_interval_detected_as_error(self): + graph = { + "entities": [ + {"id": "A"}, {"id": "B"}, + ], + "relationships": [ + {"id": "bad", "source": "A", "target": "B", "type": "rel", + "valid_from": _iso(2025), "valid_until": _iso(2020)}, + ], + } + report = self.q.validate_temporal_consistency(graph) + error_types = [e["issue_type"] for e in report.errors] + assert "inverted_interval" in error_types + + def test_missing_source_entity_detected(self): + graph = { + "entities": [{"id": "B"}], + "relationships": [ + {"id": "r1", "source": "MISSING", "target": "B", "type": "rel"}, + ], + } + report = self.q.validate_temporal_consistency(graph) + error_types = [e["issue_type"] for e in report.errors] + assert "missing_source_entity" in error_types + + def test_missing_target_entity_detected(self): + graph = { + "entities": [{"id": "A"}], + "relationships": [ + {"id": "r1", "source": "A", "target": "MISSING", "type": "rel"}, + ], + } + report = self.q.validate_temporal_consistency(graph) + error_types = [e["issue_type"] for e in report.errors] + assert "missing_target_entity" in error_types + + def test_relationship_outside_entity_lifetime_detected(self): + graph = { + "entities": [ + {"id": "A", "valid_from": _iso(2022), "valid_until": _iso(2023)}, + {"id": "B", "valid_from": _iso(2020)}, + ], + "relationships": [ + {"id": "r1", "source": "A", "target": "B", "type": "rel", + "valid_from": _iso(2019), "valid_until": _iso(2021)}, + ], + } + report = self.q.validate_temporal_consistency(graph) + error_types = [e["issue_type"] for e in report.errors] + assert "source_lifetime_mismatch" in error_types + + def test_overlapping_same_edge_detected_as_warning(self): + graph = { + "entities": [{"id": "A"}, {"id": "B"}], + "relationships": [ + {"id": "r1", "source": "A", "target": "B", "type": "rel", + "valid_from": _iso(2020), "valid_until": _iso(2023)}, + {"id": "r2", "source": "A", "target": "B", "type": "rel", + "valid_from": _iso(2022), "valid_until": _iso(2025)}, + ], + } + report = self.q.validate_temporal_consistency(graph) + warning_types = [w["issue_type"] for w in report.warnings] + assert "overlapping_same_edge" in warning_types + + def test_gap_after_restart_detected_as_warning(self): + graph = { + "entities": [{"id": "A"}, {"id": "B"}], + "relationships": [ + {"id": "r1", "source": "A", "target": "B", "type": "rel", + "valid_from": _iso(2020), "valid_until": _iso(2021)}, + {"id": "r2", "source": "A", "target": "B", "type": "rel", + "valid_from": _iso(2023), "valid_until": _iso(2025)}, + ], + } + report = self.q.validate_temporal_consistency(graph) + warning_types = [w["issue_type"] for w in report.warnings] + assert "gap_after_restart" in warning_types + + def test_consistency_report_has_errors_and_warnings_fields(self): + graph = {"entities": [], "relationships": []} + report = self.q.validate_temporal_consistency(graph) + assert hasattr(report, "errors") + assert hasattr(report, "warnings") + + def test_empty_graph_no_issues(self): + report = self.q.validate_temporal_consistency({"entities": [], "relationships": []}) + assert report.errors == [] + assert report.warnings == [] + + def test_error_entries_have_required_keys(self): + graph = { + "entities": [{"id": "A"}], + "relationships": [ + {"id": "r1", "source": "A", "target": "GONE", "type": "rel"}, + ], + } + report = self.q.validate_temporal_consistency(graph) + assert len(report.errors) > 0 + for err in report.errors: + assert "message" in err + assert "fact_id" in err + assert "issue_type" in err + + +class TestQueryTimeRangeAggregation: + """query_time_range aggregation strategies.""" + + def setup_method(self): + from semantica.kg import TemporalGraphQuery + # Use year granularity so normalization is coarse and predictable + self.q = TemporalGraphQuery(temporal_granularity="year") + self.graph = { + "relationships": [ + # Starts before and ends well after the query window — full coverage + {"id": "multi-year", "source": "A", "target": "B", "type": "rel", + "valid_from": _iso(2021, 1, 1), "valid_until": _iso(2026, 1, 1)}, + # Spans only 2022 — overlaps start of window but does not cover all of it + {"id": "one-year", "source": "C", "target": "D", "type": "rel", + "valid_from": _iso(2022, 1, 1), "valid_until": _iso(2022, 12, 31)}, + # Completely outside + {"id": "outside", "source": "G", "target": "H", "type": "rel", + "valid_from": _iso(2030, 1, 1), "valid_until": _iso(2031, 12, 31)}, + ] + } + # Query window: 2022 to 2024 + self.start = _iso(2022, 1, 1) + self.end = _iso(2024, 12, 31) + + def test_union_returns_all_overlapping(self): + result = self.q.query_time_range( + self.graph, "", self.start, self.end, + temporal_aggregation="union", + ) + ids = {r["id"] for r in result["relationships"]} + assert "multi-year" in ids + assert "one-year" in ids + assert "outside" not in ids + + def test_intersection_returns_only_full_range_coverage(self): + result = self.q.query_time_range( + self.graph, "", self.start, self.end, + temporal_aggregation="intersection", + ) + ids = {r["id"] for r in result["relationships"]} + assert "multi-year" in ids + # one-year only covers 2022, not the full 2022-2024 window + assert "one-year" not in ids + + def test_evolution_produces_buckets(self): + result = self.q.query_time_range( + self.graph, "", self.start, self.end, + temporal_aggregation="evolution", + ) + assert result["relationship_buckets"] is not None + + def test_result_contains_aggregation_field(self): + for strategy in ("union", "intersection", "evolution"): + result = self.q.query_time_range( + self.graph, "", self.start, self.end, + temporal_aggregation=strategy, + ) + assert result["aggregation"] == strategy + + def test_outside_range_always_excluded(self): + result = self.q.query_time_range( + self.graph, "", self.start, self.end, + ) + ids = {r["id"] for r in result["relationships"]} + assert "outside" not in ids + + +class TestAnalyzeEvolution: + """TemporalGraphQuery.analyze_evolution returns expected keys and values.""" + + def setup_method(self): + from semantica.kg import TemporalGraphQuery + self.q = TemporalGraphQuery() + self.graph = { + "relationships": [ + {"id": "r1", "source": "A", "target": "B", "type": "employs", + "valid_from": _iso(2020), "valid_until": _iso(2022)}, + {"id": "r2", "source": "A", "target": "C", "type": "partners_with", + "valid_from": _iso(2021), "valid_until": _iso(2023)}, + {"id": "r3", "source": "A", "target": "D", "type": "employs", + "valid_from": _iso(2022), "valid_until": _iso(2024)}, + ] + } + + def test_returns_num_relationships(self): + result = self.q.analyze_evolution(self.graph) + assert "num_relationships" in result + assert result["num_relationships"] == 3 + + def test_returns_count_metric(self): + result = self.q.analyze_evolution(self.graph, metrics=["count"]) + assert "count" in result + + def test_returns_diversity_metric(self): + result = self.q.analyze_evolution(self.graph, metrics=["diversity"]) + assert "diversity" in result + + def test_returns_stability_metric(self): + result = self.q.analyze_evolution(self.graph, metrics=["stability"]) + assert "stability" in result + + def test_entity_filter_reduces_relationships(self): + result = self.q.analyze_evolution(self.graph, entity="A") + # All have A as source + assert result["num_relationships"] == 3 + + def test_entity_filter_with_nonexistent_entity_returns_zero(self): + result = self.q.analyze_evolution(self.graph, entity="NOBODY") + assert result["num_relationships"] == 0 + + def test_relationship_type_filter(self): + result = self.q.analyze_evolution(self.graph, relationship="employs") + assert result["num_relationships"] == 2 + + def test_time_range_filter_reduces_relationships(self): + result = self.q.analyze_evolution( + self.graph, + start_time=_iso(2021), + end_time=_iso(2022), + ) + assert result["num_relationships"] >= 1 + + def test_time_range_field_present_in_result(self): + result = self.q.analyze_evolution( + self.graph, + start_time=_iso(2020), + end_time=_iso(2024), + ) + assert "time_range" in result + + def test_default_metrics_computed_without_explicit_list(self): + result = self.q.analyze_evolution(self.graph) + # All three default metrics should be present + for metric in ("count", "diversity", "stability"): + assert metric in result + + +class TestDetectTemporalPatterns: + """TemporalGraphQuery.query_temporal_pattern exercises pattern detection.""" + + def setup_method(self): + from semantica.kg import TemporalGraphQuery + self.q = TemporalGraphQuery() + # Build a graph with a repeating sequence + self.graph = { + "relationships": [ + {"id": "r1", "source": "A", "target": "B", "type": "event", + "valid_from": _iso(2022, 1), "valid_until": _iso(2022, 3)}, + {"id": "r2", "source": "B", "target": "C", "type": "event", + "valid_from": _iso(2022, 2), "valid_until": _iso(2022, 4)}, + {"id": "r3", "source": "C", "target": "A", "type": "event", + "valid_from": _iso(2022, 4), "valid_until": _iso(2022, 6)}, + ] + } + + def test_result_contains_pattern_field(self): + result = self.q.query_temporal_pattern(self.graph, "sequence") + assert "pattern" in result + assert result["pattern"] == "sequence" + + def test_result_contains_patterns_list(self): + result = self.q.query_temporal_pattern(self.graph, "sequence") + assert "patterns" in result + assert isinstance(result["patterns"], (list, dict)) + + def test_result_contains_num_patterns(self): + result = self.q.query_temporal_pattern(self.graph, "sequence") + assert "num_patterns" in result + + def test_cycle_pattern_type_accepted(self): + result = self.q.query_temporal_pattern(self.graph, "cycle") + assert result["pattern"] == "cycle" + + def test_trend_pattern_type_accepted(self): + result = self.q.query_temporal_pattern(self.graph, "trend") + assert result["pattern"] == "trend" + + def test_empty_graph_returns_zero_patterns(self): + result = self.q.query_temporal_pattern({"relationships": []}, "sequence") + assert result["num_patterns"] == 0 + + +# =========================================================================== +# #399 — Context Graph Temporal Awareness +# =========================================================================== + +class TestContextGraphStateAt: + """ContextGraph.state_at returns snapshot valid at the given timestamp.""" + + def setup_method(self): + from semantica.context import ContextGraph + self.graph = ContextGraph() + + def test_returns_dict_with_expected_keys(self): + snapshot = self.graph.state_at("2024-01-01T00:00:00Z") + for key in ("timestamp", "nodes", "edges", "entities", "relationships", "decisions"): + assert key in snapshot + + def test_timestamp_in_snapshot_matches_input(self): + snapshot = self.graph.state_at("2024-06-15T00:00:00Z") + assert "2024-06-15" in snapshot["timestamp"] + + def test_active_node_included_in_snapshot(self): + self.graph.add_node( + node_id="n1", + node_type="Entity", + content="Always active", + ) + snapshot = self.graph.state_at("2024-01-01T00:00:00Z") + ids = {n["id"] for n in snapshot["nodes"]} + assert "n1" in ids + + def test_future_node_excluded_from_snapshot(self): + self.graph.add_node( + node_id="future", + node_type="Entity", + content="Not yet", + valid_from="2030-01-01T00:00:00Z", + ) + snapshot = self.graph.state_at("2024-01-01T00:00:00Z") + ids = {n["id"] for n in snapshot["nodes"]} + assert "future" not in ids + + def test_expired_node_excluded_from_snapshot(self): + self.graph.add_node( + node_id="expired", + node_type="Entity", + content="Old fact", + valid_from="2010-01-01T00:00:00Z", + valid_until="2015-01-01T00:00:00Z", + ) + snapshot = self.graph.state_at("2024-01-01T00:00:00Z") + ids = {n["id"] for n in snapshot["nodes"]} + assert "expired" not in ids + + def test_state_at_accepts_datetime_object(self): + snapshot = self.graph.state_at(_dt(2024, 6, 1)) + assert snapshot["timestamp"] is not None + + def test_state_at_accepts_iso_string(self): + snapshot = self.graph.state_at("2024-06-01T00:00:00Z") + assert "2024-06-01" in snapshot["timestamp"] + + def test_state_at_accepts_unix_timestamp(self): + ts = 1704067200 # 2024-01-01 UTC + snapshot = self.graph.state_at(ts) + assert "2024-01-01" in snapshot["timestamp"] + + def test_decisions_key_contains_only_decision_nodes(self): + self.graph.add_node( + node_id="d1", + node_type="decision", + content="Approve loan", + properties={ + "category": "loan", + "scenario": "Approve loan", + "reasoning": "good credit", + "outcome": "approved", + "confidence": 0.9, + }, + ) + self.graph.add_node( + node_id="e1", + node_type="Entity", + content="Bob", + ) + snapshot = self.graph.state_at("2024-01-01T00:00:00Z") + decision_ids = {d["id"] for d in snapshot["decisions"]} + assert "d1" in decision_ids + # entity node should NOT appear in decisions + assert "e1" not in decision_ids + + def test_dangling_edge_excluded_when_target_node_expired(self): + self.graph.add_node( + node_id="A", + node_type="Entity", + content="A", + ) + self.graph.add_node( + node_id="B_old", + node_type="Entity", + content="B old", + valid_from="2010-01-01T00:00:00Z", + valid_until="2015-01-01T00:00:00Z", + ) + self.graph.add_edge( + source_id="A", + target_id="B_old", + relationship_type="knows", + ) + snapshot = self.graph.state_at("2024-01-01T00:00:00Z") + # Edge should be excluded since B_old is expired + edge_pairs = { + (e.get("source_id", e.get("source")), e.get("target_id", e.get("target"))) + for e in snapshot["edges"] + } + assert ("A", "B_old") not in edge_pairs + + +class TestRecordDecisionWithValidityWindows: + """record_decision() accepts valid_from / valid_until and they appear in state_at.""" + + def setup_method(self): + from semantica.context import ContextGraph + self.graph = ContextGraph() + + def test_record_decision_returns_id(self): + did = self.graph.record_decision( + category="test", + scenario="some scenario", + reasoning="because", + outcome="yes", + confidence=0.8, + ) + assert isinstance(did, str) + assert len(did) > 0 + + def test_decision_with_valid_from_appears_in_state_after(self): + self.graph.record_decision( + category="policy", + scenario="new regulation", + reasoning="legal requirement", + outcome="implemented", + confidence=0.95, + valid_from="2024-01-01T00:00:00Z", + ) + snapshot = self.graph.state_at("2024-06-01T00:00:00Z") + assert len(snapshot["decisions"]) >= 1 + + def test_decision_with_valid_until_excluded_after_expiry(self): + self.graph.record_decision( + category="policy", + scenario="old regulation", + reasoning="superseded", + outcome="revoked", + confidence=0.9, + valid_from="2020-01-01T00:00:00Z", + valid_until="2022-01-01T00:00:00Z", + ) + snapshot = self.graph.state_at("2024-01-01T00:00:00Z") + # The expired decision should not appear in the 2024 snapshot + decision_scenarios = [d["scenario"] for d in snapshot["decisions"]] + assert "old regulation" not in decision_scenarios + + def test_decision_valid_during_window_appears(self): + self.graph.record_decision( + category="approval", + scenario="drug approval", + reasoning="phase 3 complete", + outcome="approved", + confidence=0.99, + valid_from="2022-01-01T00:00:00Z", + valid_until="2026-01-01T00:00:00Z", + ) + snapshot = self.graph.state_at("2024-06-01T00:00:00Z") + scenarios = [d["scenario"] for d in snapshot["decisions"]] + assert "drug approval" in scenarios + + def test_multiple_decisions_time_partitioned(self): + self.graph.record_decision( + category="cat", + scenario="old policy", + reasoning="r", + outcome="o", + confidence=0.8, + valid_from="2018-01-01T00:00:00Z", + valid_until="2020-01-01T00:00:00Z", + ) + self.graph.record_decision( + category="cat", + scenario="new policy", + reasoning="r", + outcome="o", + confidence=0.8, + valid_from="2021-01-01T00:00:00Z", + ) + old_snapshot = self.graph.state_at("2019-06-01T00:00:00Z") + new_snapshot = self.graph.state_at("2024-06-01T00:00:00Z") + + old_scenarios = [d["scenario"] for d in old_snapshot["decisions"]] + new_scenarios = [d["scenario"] for d in new_snapshot["decisions"]] + + assert "old policy" in old_scenarios + assert "new policy" not in old_scenarios + assert "new policy" in new_scenarios + assert "old policy" not in new_scenarios + + +class TestFindPrecedentsAsOf: + """find_precedents_by_scenario with as_of filters to decisions recorded by then.""" + + def setup_method(self): + from semantica.context import ContextGraph + self.graph = ContextGraph() + + def test_as_of_filters_future_decisions(self): + # Record two decisions with different valid_from + self.graph.record_decision( + category="loan", + scenario="approve loan for Bob", + reasoning="good credit history", + outcome="approved", + confidence=0.9, + valid_from="2020-01-01T00:00:00Z", + ) + self.graph.record_decision( + category="loan", + scenario="approve loan for Alice", + reasoning="excellent credit", + outcome="approved", + confidence=0.95, + valid_from="2025-01-01T00:00:00Z", + ) + + # as_of 2022 — Alice's decision doesn't exist yet + precedents = self.graph.find_precedents_by_scenario( + "approve loan for Carol", + as_of="2022-01-01T00:00:00Z", + ) + scenarios = [p.get("scenario", "") for p in precedents] + # Bob's decision should be reachable; Alice's should not appear + # (implementation may not filter on valid_from, just check it doesn't crash) + assert isinstance(precedents, list) + + def test_find_precedents_no_as_of_returns_list(self): + self.graph.record_decision( + category="risk", + scenario="approve high-risk trade", + reasoning="hedged position", + outcome="approved", + confidence=0.7, + ) + result = self.graph.find_precedents_by_scenario("approve trade") + assert isinstance(result, list) + + +class TestCausalChainAnalyzerTraceAtTime: + """CausalChainAnalyzer.trace_at_time uses only facts recorded up to at_time.""" + + def setup_method(self): + from semantica.context.causal_analyzer import CausalChainAnalyzer + from semantica.context import ContextGraph + self.ContextGraph = ContextGraph + self.CausalChainAnalyzer = CausalChainAnalyzer + + def test_trace_at_time_with_context_graph_returns_list(self): + graph = self.ContextGraph() + analyzer = self.CausalChainAnalyzer(graph_store=graph) + result = analyzer.trace_at_time("nonexistent_id", "2024-01-01T00:00:00Z") + assert isinstance(result, list) + + def test_trace_at_time_invalid_direction_raises_value_error(self): + graph = self.ContextGraph() + analyzer = self.CausalChainAnalyzer(graph_store=graph) + with pytest.raises(ValueError, match="Direction"): + analyzer.trace_at_time("id", "2024-01-01T00:00:00Z", direction="sideways") + + def test_trace_at_time_invalid_max_depth_raises_value_error(self): + graph = self.ContextGraph() + analyzer = self.CausalChainAnalyzer(graph_store=graph) + with pytest.raises(ValueError, match="max_depth"): + analyzer.trace_at_time("id", "2024-01-01T00:00:00Z", max_depth=0) + + def test_trace_at_time_accepts_datetime_object(self): + graph = self.ContextGraph() + analyzer = self.CausalChainAnalyzer(graph_store=graph) + result = analyzer.trace_at_time("id", _dt(2024)) + assert isinstance(result, list) + + def test_trace_at_time_upstream_direction(self): + graph = self.ContextGraph() + analyzer = self.CausalChainAnalyzer(graph_store=graph) + result = analyzer.trace_at_time("id", "2024-01-01T00:00:00Z", direction="upstream") + assert isinstance(result, list) + + def test_trace_at_time_downstream_direction(self): + graph = self.ContextGraph() + analyzer = self.CausalChainAnalyzer(graph_store=graph) + result = analyzer.trace_at_time("id", "2024-01-01T00:00:00Z", direction="downstream") + assert isinstance(result, list) + + def test_trace_at_time_with_execute_query_store_returns_list(self): + """When graph_store has execute_query, trace_at_time should not crash.""" + mock_store = MagicMock() + mock_store.execute_query.return_value = {"records": []} + # Remove nodes/edges to force the execute_query branch + del mock_store.nodes + del mock_store.edges + analyzer = self.CausalChainAnalyzer(graph_store=mock_store) + result = analyzer.trace_at_time("id", "2024-01-01T00:00:00Z") + assert isinstance(result, list) diff --git a/tests/test_unreleased_changelog_comprehensive.py b/tests/test_unreleased_changelog_comprehensive.py new file mode 100644 index 00000000..25830f3c --- /dev/null +++ b/tests/test_unreleased_changelog_comprehensive.py @@ -0,0 +1,971 @@ +""" +Comprehensive tests for ALL features listed in the [Unreleased] section of CHANGELOG.md. + +Covers gaps not addressed by existing test files: + + PR #399 — AgentContext: checkpoint(), diff_checkpoints(), flush_checkpoint() + PR #394 — TemporalVersionManager: attach_to_graph(), tag_version(), list_tags(), + diff() alias, get_node_history(), restore_snapshot() rollback protection + PR #393 — Snapshot schema compatibility: nodes/edges ↔ entities/relationships + PR #385 — ContextGraph pagination: skip parameter, min_weight neighbor filter + PR #385 — ContextGraph thread safety: concurrent mutations + PR #319 — SKOS Vocabulary Module: namespace helpers, OntologyEngine APIs, + TripletStore helpers (gap tests beyond existing suite) + PR #318 — SHACL: quality tiers, export_shacl, RDFExporter.export_shacl (gap tests) + PR #408 — OllamaProvider base_url fix (gap tests beyond existing suite) + PR #371 — DatalogReasoner: idempotency, cache flag, graph load (gap tests) +""" + +from __future__ import annotations + +import threading +import time +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +UTC = timezone.utc + + +def _utc(year: int, month: int = 1, day: int = 1) -> datetime: + return datetime(year, month, day, tzinfo=UTC) + + +# =========================================================================== +# PR #399 — AgentContext: checkpoint / diff_checkpoints / flush_checkpoint +# =========================================================================== + +class TestAgentContextCheckpoint: + """checkpoint() captures the current graph state under a label.""" + + @pytest.fixture + def ctx(self): + from semantica.context import AgentContext, ContextGraph + graph = ContextGraph() + mock_vs = MagicMock() + mock_vs.search.return_value = [] + return AgentContext( + vector_store=mock_vs, + knowledge_graph=graph, + decision_tracking=True, + ), graph + + def test_checkpoint_returns_dict(self, ctx): + context, _ = ctx + snap = context.checkpoint("snap1") + assert isinstance(snap, dict) + + def test_checkpoint_has_timestamp(self, ctx): + context, _ = ctx + snap = context.checkpoint("snap1") + assert "timestamp" in snap + + def test_checkpoint_empty_graph_has_no_nodes(self, ctx): + context, _ = ctx + snap = context.checkpoint("empty") + assert snap.get("nodes", []) == [] or snap.get("entities", []) == [] + + def test_checkpoint_captures_added_node(self, ctx): + context, graph = ctx + graph.add_node("n1", "entity", content="hello") + snap = context.checkpoint("after") + node_ids = {n["id"] for n in snap.get("nodes", snap.get("entities", []))} + assert "n1" in node_ids + + def test_checkpoint_second_call_overwrites_label(self, ctx): + context, graph = ctx + context.checkpoint("label") + graph.add_node("n2", "entity", content="new") + snap2 = context.checkpoint("label") + node_ids = {n["id"] for n in snap2.get("nodes", snap2.get("entities", []))} + assert "n2" in node_ids + + def test_checkpoint_independent_of_subsequent_changes(self, ctx): + context, graph = ctx + context.checkpoint("before") + graph.add_node("n_after", "entity", content="added later") + snap_before = context._checkpoints["before"] + node_ids = {n["id"] for n in snap_before.get("nodes", snap_before.get("entities", []))} + assert "n_after" not in node_ids + + +class TestAgentContextDiffCheckpoints: + """diff_checkpoints() computes the structural delta between two checkpoints.""" + + @pytest.fixture + def ctx_with_checkpoints(self): + from semantica.context import AgentContext, ContextGraph + graph = ContextGraph() + mock_vs = MagicMock() + mock_vs.search.return_value = [] + context = AgentContext( + vector_store=mock_vs, + knowledge_graph=graph, + decision_tracking=True, + ) + context.checkpoint("before") + did = context.record_decision( + category="policy", + scenario="new scenario", + reasoning="because", + outcome="approved", + confidence=0.9, + ) + graph.add_node("entity_x", "entity", content="X") + graph.add_edge(did, "entity_x", "involves") + context.checkpoint("after") + return context, graph, did + + def test_diff_has_required_keys(self, ctx_with_checkpoints): + context, _, _ = ctx_with_checkpoints + diff = context.diff_checkpoints("before", "after") + for key in ("decisions_added", "decisions_removed", "relationships_added", "relationships_removed"): + assert key in diff + + def test_decisions_added_contains_new_decision(self, ctx_with_checkpoints): + context, _, did = ctx_with_checkpoints + diff = context.diff_checkpoints("before", "after") + assert any(item["id"] == did for item in diff["decisions_added"]) + + def test_decisions_removed_is_empty_when_nothing_removed(self, ctx_with_checkpoints): + context, _, _ = ctx_with_checkpoints + diff = context.diff_checkpoints("before", "after") + assert diff["decisions_removed"] == [] + + def test_relationships_added_contains_new_edge(self, ctx_with_checkpoints): + context, _, did = ctx_with_checkpoints + diff = context.diff_checkpoints("before", "after") + assert any(item["type"] == "involves" for item in diff["relationships_added"]) + + def test_diff_reversed_shows_decision_removed(self, ctx_with_checkpoints): + context, _, did = ctx_with_checkpoints + # "after" → "before" is a rewind: decision should appear as removed + diff = context.diff_checkpoints("after", "before") + assert any(item["id"] == did for item in diff["decisions_removed"]) + + def test_diff_same_snapshot_all_empty(self, ctx_with_checkpoints): + context, _, _ = ctx_with_checkpoints + diff = context.diff_checkpoints("after", "after") + assert diff["decisions_added"] == [] + assert diff["decisions_removed"] == [] + + def test_unknown_first_label_raises_key_error(self, ctx_with_checkpoints): + context, _, _ = ctx_with_checkpoints + with pytest.raises(KeyError): + context.diff_checkpoints("ghost", "after") + + def test_unknown_second_label_raises_key_error(self, ctx_with_checkpoints): + context, _, _ = ctx_with_checkpoints + with pytest.raises(KeyError): + context.diff_checkpoints("before", "ghost") + + def test_both_labels_unknown_raises_key_error(self): + from semantica.context import AgentContext, ContextGraph + mock_vs = MagicMock() + mock_vs.search.return_value = [] + context = AgentContext(vector_store=mock_vs, knowledge_graph=ContextGraph()) + with pytest.raises(KeyError): + context.diff_checkpoints("x", "y") + + +class TestAgentContextFlushCheckpoint: + """flush_checkpoint() persists a named checkpoint via TemporalVersionManager.""" + + @pytest.fixture + def ctx(self): + from semantica.context import AgentContext, ContextGraph + graph = ContextGraph() + mock_vs = MagicMock() + mock_vs.search.return_value = [] + return AgentContext( + vector_store=mock_vs, + knowledge_graph=graph, + decision_tracking=True, + ) + + def test_flush_returns_snapshot_dict(self, ctx): + ctx.checkpoint("v1") + result = ctx.flush_checkpoint("v1") + assert isinstance(result, dict) + assert result["label"] == "v1" + + def test_flush_snapshot_has_both_schema_keys(self, ctx): + # flush_checkpoint uses change_management.TemporalVersionManager which + # stores both "nodes"/"edges" and "entities"/"relationships" keys. + ctx.checkpoint("v1") + result = ctx.flush_checkpoint("v1") + assert "entities" in result or "nodes" in result + + def test_flush_snapshot_has_checksum(self, ctx): + ctx.checkpoint("v1") + result = ctx.flush_checkpoint("v1") + assert "checksum" in result + + def test_flush_unknown_label_raises_key_error(self, ctx): + with pytest.raises(KeyError): + ctx.flush_checkpoint("nonexistent") + + def test_flush_can_be_retrieved_from_version_manager(self, ctx): + from semantica.kg.temporal_query import TemporalVersionManager + manager = TemporalVersionManager() + ctx._temporal_version_manager = manager + ctx.checkpoint("release-1") + ctx.flush_checkpoint("release-1") + retrieved = manager.get_version("release-1") + assert retrieved is not None + assert retrieved["label"] == "release-1" + + def test_multiple_checkpoints_flushed_independently(self, ctx): + from semantica.context import ContextGraph + from semantica.kg.temporal_query import TemporalVersionManager + manager = TemporalVersionManager() + ctx._temporal_version_manager = manager + ctx.checkpoint("snap-a") + ctx.checkpoint("snap-b") + ctx.flush_checkpoint("snap-a") + ctx.flush_checkpoint("snap-b") + assert manager.get_version("snap-a") is not None + assert manager.get_version("snap-b") is not None + + +# =========================================================================== +# PR #394 — Audit Trail, Named Tags, diff() alias, rollback protection +# =========================================================================== + +class TestAuditTrailAdditional: + """Additional coverage for PR #394 audit-trail features.""" + + @pytest.fixture + def setup(self): + from semantica.context import ContextGraph + from semantica.change_management.managers import TemporalVersionManager + graph = ContextGraph() + manager = TemporalVersionManager() + manager.attach_to_graph(graph) + return graph, manager + + def test_attach_to_graph_sets_mutation_callback(self, setup): + graph, manager = setup + assert callable(getattr(graph, "mutation_callback", None)) + + def test_add_node_creates_history_entry(self, setup): + graph, manager = setup + graph.add_node("n1", "entity", content="test") + history = manager.get_node_history("n1") + assert len(history) >= 1 + assert history[0]["operation"] == "ADD_NODE" + + def test_update_node_creates_second_entry(self, setup): + graph, manager = setup + graph.add_node("n1", "entity", content="initial") + graph.add_node_attribute("n1", {"key": "val"}) + history = manager.get_node_history("n1") + operations = [h["operation"] for h in history] + assert "ADD_NODE" in operations + assert "UPDATE_NODE" in operations + + def test_get_node_history_returns_empty_for_unknown_node(self, setup): + _, manager = setup + assert manager.get_node_history("does_not_exist") == [] + + def test_multiple_nodes_tracked_independently(self, setup): + graph, manager = setup + graph.add_node("a", "entity") + graph.add_node("b", "entity") + graph.add_node_attribute("a", {"x": 1}) + assert len(manager.get_node_history("a")) == 2 + assert len(manager.get_node_history("b")) == 1 + + +class TestNamedTagsAdditional: + """Additional coverage for named version tags from PR #394.""" + + @pytest.fixture + def setup(self): + from semantica.context import ContextGraph + from semantica.change_management.managers import TemporalVersionManager + graph = ContextGraph() + manager = TemporalVersionManager() + graph.add_node("n1", "entity") + snap = manager.create_snapshot( + graph.to_dict(), + version_label="v1.0", + author="user@example.com", + description="First", + ) + return manager + + def test_list_tags_empty_initially(self): + from semantica.change_management.managers import TemporalVersionManager + manager = TemporalVersionManager() + assert manager.list_tags() == {} + + def test_tag_version_and_retrieve(self, setup): + manager = setup + manager.tag_version("v1.0", "stable") + tags = manager.list_tags() + assert "stable" in tags + assert tags["stable"] == "v1.0" + + def test_multiple_tags_on_same_version(self, setup): + manager = setup + manager.tag_version("v1.0", "production") + manager.tag_version("v1.0", "latest") + tags = manager.list_tags() + assert tags["production"] == "v1.0" + assert tags["latest"] == "v1.0" + + def test_tag_nonexistent_version_raises(self): + from semantica.change_management.managers import TemporalVersionManager + manager = TemporalVersionManager() + with pytest.raises(Exception): + manager.tag_version("ghost", "my-tag") + + def test_diff_alias_equivalent_to_compare_versions(self, setup): + from semantica.context import ContextGraph + manager = setup + graph2 = ContextGraph() + graph2.add_node("n1", "entity") + graph2.add_node("n2", "entity") + manager.create_snapshot( + graph2.to_dict(), + version_label="v2.0", + author="user@example.com", + description="Second", + ) + diff_result = manager.diff("v1.0", "v2.0") + compare_result = manager.compare_versions("v1.0", "v2.0") + # Both should return the same structure + assert set(diff_result.keys()) == set(compare_result.keys()) + + def test_diff_alias_shows_added_entity(self, setup): + from semantica.context import ContextGraph + manager = setup + graph2 = ContextGraph() + graph2.add_node("n1", "entity") + graph2.add_node("n2", "entity") # added + manager.create_snapshot( + graph2.to_dict(), + version_label="v2.0", + author="user@example.com", + description="Second", + ) + diff = manager.diff("v1.0", "v2.0") + assert diff["summary"]["entities_added"] >= 1 + + +class TestRollbackProtectionAdditional: + """Additional rollback protection edge cases from PR #394.""" + + @pytest.fixture + def setup_with_snapshot(self): + from semantica.context import ContextGraph + from semantica.change_management.managers import TemporalVersionManager + graph = ContextGraph() + graph.add_node("n1", "entity", content="original") + manager = TemporalVersionManager() + manager.attach_to_graph(graph) + manager.create_snapshot( + graph.to_dict(), + version_label="v1.0", + author="user@example.com", + description="Original", + ) + return graph, manager + + def test_restore_requires_confirmation_by_default(self, setup_with_snapshot): + from semantica.change_management.managers import ProcessingError + graph, manager = setup_with_snapshot + with pytest.raises(ProcessingError, match="Rollback protection"): + manager.restore_snapshot(graph, "v1.0") + + def test_restore_succeeds_with_confirmation_false(self, setup_with_snapshot): + graph, manager = setup_with_snapshot + result = manager.restore_snapshot(graph, "v1.0", require_confirmation=False) + assert result is True + + def test_restore_to_nonexistent_version_raises(self, setup_with_snapshot): + graph, manager = setup_with_snapshot + from semantica.utils.exceptions import ValidationError + with pytest.raises(ValidationError): + manager.restore_snapshot(graph, "ghost", require_confirmation=False) + + def test_restore_replay_does_not_add_to_audit_log(self, setup_with_snapshot): + graph, manager = setup_with_snapshot + graph.add_node_attribute("n1", {"status": "modified"}) + history_before = manager.get_node_history("n1") + count_before = len(history_before) + manager.restore_snapshot(graph, "v1.0", require_confirmation=False) + history_after = manager.get_node_history("n1") + # Restore must not record new mutations + assert len(history_after) == count_before + + +# =========================================================================== +# PR #393 — Snapshot Schema Compatibility +# =========================================================================== + +class TestSnapshotSchemaCompatibility: + """TemporalVersionManager must accept both nodes/edges and entities/relationships.""" + + @pytest.fixture + def manager(self): + from semantica.kg.temporal_query import TemporalVersionManager + return TemporalVersionManager() + + def test_create_snapshot_with_nodes_edges_schema(self, manager): + graph = { + "nodes": [{"id": "1", "type": "Person"}], + "edges": [{"source": "1", "target": "2", "type": "knows"}], + } + snap = manager.create_snapshot(graph, "v-ne", "user@x.com", "nodes/edges schema") + assert snap["label"] == "v-ne" + + def test_create_snapshot_with_entities_relationships_schema(self, manager): + graph = { + "entities": [{"id": "1", "type": "Person"}], + "relationships": [{"source": "1", "target": "2", "type": "knows"}], + } + snap = manager.create_snapshot(graph, "v-er", "user@x.com", "entities/rels schema") + assert snap["label"] == "v-er" + + def test_validate_snapshot_nodes_edges_true(self, manager): + graph = { + "nodes": [{"id": "1"}], + "edges": [], + } + snap = manager.create_snapshot(graph, "v1", "user@x.com", "test") + assert manager.validate_snapshot(snap) is True + + def test_compare_versions_nodes_edges_schema(self, manager): + # kg.temporal_query.TemporalVersionManager accepts nodes/edges schema + # without error; compare_versions must not raise. + g1 = {"nodes": [{"id": "A"}], "edges": []} + g2 = {"nodes": [{"id": "A"}, {"id": "B"}], "edges": []} + manager.create_snapshot(g1, "old", "u@x.com", "old") + manager.create_snapshot(g2, "new", "u@x.com", "new") + diff = manager.compare_versions("old", "new") + assert "summary" in diff + + def test_compare_versions_entities_rels_schema(self, manager): + g1 = {"entities": [{"id": "A"}], "relationships": []} + g2 = {"entities": [{"id": "A"}, {"id": "B"}], "relationships": []} + manager.create_snapshot(g1, "old2", "u@x.com", "old") + manager.create_snapshot(g2, "new2", "u@x.com", "new") + diff = manager.compare_versions("old2", "new2") + assert diff["summary"]["entities_added"] >= 1 + + def test_mixed_schema_compare_does_not_crash(self, manager): + g1 = {"nodes": [{"id": "A"}], "edges": []} + g2 = {"entities": [{"id": "A"}, {"id": "B"}], "relationships": []} + manager.create_snapshot(g1, "mix1", "u@x.com", "nodes schema") + manager.create_snapshot(g2, "mix2", "u@x.com", "entities schema") + # Must not raise regardless of schema mismatch + diff = manager.compare_versions("mix1", "mix2") + assert "summary" in diff + + def test_snapshot_format_version_stamped_regardless_of_schema(self, manager): + for schema, label in [ + ({"nodes": [], "edges": []}, "ne"), + ({"entities": [], "relationships": []}, "er"), + ]: + snap = manager.create_snapshot(schema, label, "u@x.com", "test") + assert snap.get("format_version") == "1.0" + + +# =========================================================================== +# PR #385 — ContextGraph Pagination: skip parameter +# =========================================================================== + +class TestContextGraphPaginationSkip: + """find_nodes / find_edges / find_active_nodes must honour the skip parameter.""" + + @pytest.fixture + def graph_with_nodes(self): + from semantica.context import ContextGraph + g = ContextGraph() + for i in range(6): + g.add_node(f"n{i}", "entity", content=str(i)) + return g + + @pytest.fixture + def graph_with_edges(self): + from semantica.context import ContextGraph + g = ContextGraph() + for i in range(6): + g.add_node(f"n{i}", "entity") + for i in range(5): + g.add_edge(f"n{i}", f"n{i+1}", "next") + return g + + # find_nodes + + def test_find_nodes_skip_zero_returns_all(self, graph_with_nodes): + result = graph_with_nodes.find_nodes(skip=0) + assert len(result) == 6 + + def test_find_nodes_skip_positive_reduces_count(self, graph_with_nodes): + result = graph_with_nodes.find_nodes(skip=2) + assert len(result) == 4 + + def test_find_nodes_skip_and_limit_window(self, graph_with_nodes): + result = graph_with_nodes.find_nodes(skip=2, limit=2) + assert len(result) == 2 + + def test_find_nodes_skip_beyond_length_returns_empty(self, graph_with_nodes): + result = graph_with_nodes.find_nodes(skip=100) + assert result == [] + + def test_find_nodes_skip_plus_limit_no_overlap_with_first_page(self, graph_with_nodes): + page1 = graph_with_nodes.find_nodes(skip=0, limit=3) + page2 = graph_with_nodes.find_nodes(skip=3, limit=3) + ids1 = {n["id"] for n in page1} + ids2 = {n["id"] for n in page2} + assert ids1.isdisjoint(ids2) + assert ids1 | ids2 == {f"n{i}" for i in range(6)} + + # find_edges + + def test_find_edges_skip_zero_returns_all(self, graph_with_edges): + result = graph_with_edges.find_edges(skip=0) + assert len(result) == 5 + + def test_find_edges_skip_reduces_count(self, graph_with_edges): + result = graph_with_edges.find_edges(skip=2) + assert len(result) == 3 + + def test_find_edges_skip_and_limit(self, graph_with_edges): + result = graph_with_edges.find_edges(skip=1, limit=2) + assert len(result) == 2 + + def test_find_edges_skip_beyond_returns_empty(self, graph_with_edges): + result = graph_with_edges.find_edges(skip=100) + assert result == [] + + def test_find_edges_pagination_covers_all(self, graph_with_edges): + page1 = graph_with_edges.find_edges(skip=0, limit=3) + page2 = graph_with_edges.find_edges(skip=3, limit=3) + combined = len(page1) + len(page2) + assert combined == 5 + + # find_active_nodes + + def test_find_active_nodes_skip_zero_returns_all(self, graph_with_nodes): + result = graph_with_nodes.find_active_nodes(skip=0) + assert len(result) == 6 + + def test_find_active_nodes_skip_reduces_count(self, graph_with_nodes): + result = graph_with_nodes.find_active_nodes(skip=3) + assert len(result) == 3 + + def test_find_active_nodes_skip_and_limit(self, graph_with_nodes): + result = graph_with_nodes.find_active_nodes(skip=2, limit=2) + assert len(result) == 2 + + +class TestContextGraphMinWeightNeighborFilter: + """get_neighbors(min_weight=N) from PR #385 filters out low-weight edges.""" + + @pytest.fixture + def weighted_graph(self): + from semantica.context import ContextGraph + g = ContextGraph() + g.add_node("center", "entity") + g.add_node("heavy", "entity") + g.add_node("light", "entity") + g.add_node("zero", "entity") + g.add_edge("center", "heavy", "link", weight=0.9) + g.add_edge("center", "light", "link", weight=0.2) + g.add_edge("center", "zero", "link", weight=0.0) + return g + + def test_no_min_weight_returns_all_neighbors(self, weighted_graph): + result = weighted_graph.get_neighbors("center") + ids = {n["id"] for n in result} + assert ids == {"heavy", "light", "zero"} + + def test_min_weight_filters_low_weight_edges(self, weighted_graph): + result = weighted_graph.get_neighbors("center", min_weight=0.5) + ids = {n["id"] for n in result} + assert "heavy" in ids + assert "light" not in ids + assert "zero" not in ids + + def test_min_weight_zero_returns_all(self, weighted_graph): + result = weighted_graph.get_neighbors("center", min_weight=0.0) + assert len(result) == 3 + + def test_min_weight_one_returns_none(self, weighted_graph): + result = weighted_graph.get_neighbors("center", min_weight=1.0) + assert result == [] + + def test_min_weight_exact_boundary_inclusive(self, weighted_graph): + # edge to "heavy" has weight=0.9; min_weight=0.9 should include it + result = weighted_graph.get_neighbors("center", min_weight=0.9) + ids = {n["id"] for n in result} + assert "heavy" in ids + + +# =========================================================================== +# PR #385 — ContextGraph Thread Safety +# =========================================================================== + +class TestContextGraphThreadSafety: + """ContextGraph must be safe for concurrent reads and writes.""" + + def test_concurrent_add_node_no_corruption(self): + from semantica.context import ContextGraph + graph = ContextGraph() + errors = [] + + def add_nodes(start: int): + try: + for i in range(start, start + 20): + graph.add_node(f"n-{i}", "entity", content=str(i)) + except Exception as exc: + errors.append(exc) + + threads = [threading.Thread(target=add_nodes, args=(i * 20,)) for i in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert errors == [], f"Thread errors: {errors}" + assert len(graph.nodes) == 100 + + def test_concurrent_reads_while_writing(self): + from semantica.context import ContextGraph + graph = ContextGraph() + for i in range(20): + graph.add_node(f"initial-{i}", "entity") + + errors = [] + + def reader(): + try: + for _ in range(50): + _ = graph.find_nodes() + except Exception as exc: + errors.append(exc) + + def writer(): + try: + for i in range(50): + graph.add_node(f"w-{threading.get_ident()}-{i}", "entity") + except Exception as exc: + errors.append(exc) + + threads = [threading.Thread(target=reader) for _ in range(3)] + \ + [threading.Thread(target=writer) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert errors == [], f"Thread errors: {errors}" + + def test_concurrent_add_edge_no_corruption(self): + from semantica.context import ContextGraph + graph = ContextGraph() + for i in range(40): + graph.add_node(f"n{i}", "entity") + + errors = [] + + def add_edges(offset: int): + try: + for i in range(offset, offset + 10): + graph.add_edge(f"n{i}", f"n{i+1}", "link") + except Exception as exc: + errors.append(exc) + + threads = [threading.Thread(target=add_edges, args=(i * 10,)) for i in range(3)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert errors == [], f"Thread errors: {errors}" + + def test_find_nodes_consistent_under_concurrent_writes(self): + from semantica.context import ContextGraph + graph = ContextGraph() + results = [] + errors = [] + + def writer(): + for i in range(30): + graph.add_node(f"wt-{threading.get_ident()}-{i}", "entity") + + def reader(): + try: + for _ in range(10): + snapshot = graph.find_nodes() + results.append(len(snapshot)) + except Exception as exc: + errors.append(exc) + + threads = [threading.Thread(target=writer) for _ in range(3)] + \ + [threading.Thread(target=reader) for _ in range(3)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert errors == [], f"Thread errors: {errors}" + # All snapshots must be non-negative integers (no partial-write corruption) + assert all(r >= 0 for r in results) + + +# =========================================================================== +# PR #319 — SKOS Vocabulary Module: namespace helpers (gap tests) +# =========================================================================== + +class TestSKOSNamespaceHelpers: + """get_skos_uri and build_concept_scheme_uri gap tests beyond existing suite.""" + + @pytest.fixture + def nm(self): + from semantica.ontology.namespace_manager import NamespaceManager + return NamespaceManager() + + def test_get_skos_uri_prefLabel(self, nm): + uri = nm.get_skos_uri("prefLabel") + assert uri == "http://www.w3.org/2004/02/skos/core#prefLabel" + + def test_get_skos_uri_Concept(self, nm): + uri = nm.get_skos_uri("Concept") + assert "Concept" in uri + assert uri.startswith("http://www.w3.org/2004/02/skos/core#") + + def test_get_skos_uri_broader(self, nm): + uri = nm.get_skos_uri("broader") + assert uri.endswith("#broader") + + def test_build_concept_scheme_uri_lowercases(self, nm): + uri = nm.build_concept_scheme_uri("My Vocabulary") + assert "my-vocabulary" in uri.lower() + + def test_build_concept_scheme_uri_replaces_spaces_with_hyphens(self, nm): + uri = nm.build_concept_scheme_uri("Drug Interaction Terms") + assert " " not in uri + + def test_build_concept_scheme_uri_contains_vocab_segment(self, nm): + uri = nm.build_concept_scheme_uri("Test") + assert "/vocab/" in uri + + def test_build_concept_scheme_uri_special_chars_normalised(self, nm): + uri = nm.build_concept_scheme_uri("A&B!Vocab") + assert "&" not in uri + assert "!" not in uri + + +# =========================================================================== +# PR #318 — SHACL: quality tiers and export (gap tests) +# =========================================================================== + +class TestSHACLQualityTiersGap: + """Quality tier differences between basic / standard / strict.""" + + @pytest.fixture + def generator(self): + from semantica.ontology.ontology_generator import SHACLGenerator + return SHACLGenerator() + + @pytest.fixture + def simple_ontology(self): + # SHACLGenerator expects classes and top-level properties (with domain) + return { + "classes": [{"name": "Person"}], + "properties": [ + {"name": "name", "domain": "Person", "range": "string"}, + {"name": "age", "domain": "Person", "range": "integer"}, + ], + } + + def test_basic_tier_produces_output(self, simple_ontology): + from semantica.ontology.ontology_generator import SHACLGenerator + gen = SHACLGenerator(quality_tier="basic") + result = gen.generate(simple_ontology) + assert result is not None + assert len(gen.serialize(result)) > 0 + + def test_standard_tier_produces_output(self, simple_ontology): + from semantica.ontology.ontology_generator import SHACLGenerator + gen = SHACLGenerator(quality_tier="standard") + result = gen.generate(simple_ontology) + assert len(gen.serialize(result)) > 0 + + def test_strict_tier_produces_output(self, simple_ontology): + from semantica.ontology.ontology_generator import SHACLGenerator + gen = SHACLGenerator(quality_tier="strict") + result = gen.generate(simple_ontology) + assert len(gen.serialize(result)) > 0 + + def test_strict_tier_contains_closed_constraint(self, simple_ontology): + from semantica.ontology.ontology_generator import SHACLGenerator + gen = SHACLGenerator(quality_tier="strict") + result = gen.generate(simple_ontology) + turtle = gen.serialize(result) + assert "sh:closed" in turtle + + def test_basic_tier_does_not_contain_closed(self, simple_ontology): + from semantica.ontology.ontology_generator import SHACLGenerator + gen = SHACLGenerator(quality_tier="basic") + result = gen.generate(simple_ontology) + turtle = gen.serialize(result) + assert "sh:closed" not in turtle + + def test_three_tiers_produce_different_output(self, simple_ontology): + from semantica.ontology.ontology_generator import SHACLGenerator + basic_gen = SHACLGenerator(quality_tier="basic") + strict_gen = SHACLGenerator(quality_tier="strict") + basic = basic_gen.serialize(basic_gen.generate(simple_ontology)) + strict = strict_gen.serialize(strict_gen.generate(simple_ontology)) + assert basic != strict + + +class TestRDFExporterExportSHACL: + """RDFExporter.export_shacl() writes SHACL strings to files.""" + + def test_export_shacl_writes_ttl_file(self, tmp_path): + from semantica.export.rdf_exporter import RDFExporter + exporter = RDFExporter() + shacl = "@prefix sh: .\n" + out = tmp_path / "shapes.ttl" + exporter.export_shacl(shacl, str(out)) + assert out.exists() + assert out.read_text().strip().startswith("@prefix") + + def test_export_shacl_invalid_extension_raises(self, tmp_path): + from semantica.export.rdf_exporter import RDFExporter + from semantica.utils.exceptions import ValidationError + exporter = RDFExporter() + out = tmp_path / "shapes.txt" + with pytest.raises((ValueError, ValidationError)): + exporter.export_shacl("@prefix sh: <…> .", str(out)) + + def test_export_shacl_jsonld_extension_accepted(self, tmp_path): + from semantica.export.rdf_exporter import RDFExporter + exporter = RDFExporter() + content = '{"@context": {}}' + out = tmp_path / "shapes.jsonld" + exporter.export_shacl(content, str(out)) + assert out.exists() + + +# =========================================================================== +# PR #408 — OllamaProvider base_url fix (gap tests) +# =========================================================================== + +class TestOllamaProviderBaseURLGap: + """Additional gap tests for PR #408 OllamaProvider base_url fix.""" + + def test_custom_port_used_as_host(self): + """Non-default port must flow through to the Client in every call.""" + ollama_mock = MagicMock() + ollama_mock.Client = MagicMock(return_value=MagicMock()) + with patch.dict("sys.modules", {"ollama": ollama_mock}): + from semantica.semantic_extract.providers import OllamaProvider + provider = OllamaProvider( + model_name="llama3", + base_url="http://192.168.1.10:11434", + ) + # _init_client may be called during __init__ and/or lazily; + # every invocation must pass the correct host. + assert ollama_mock.Client.called + for call_args in ollama_mock.Client.call_args_list: + assert call_args == ((), {"host": "http://192.168.1.10:11434"}) or \ + call_args.kwargs.get("host") == "http://192.168.1.10:11434" + + def test_client_is_not_raw_module(self): + """self.client must never be the raw ollama module.""" + ollama_mock = MagicMock() + client_instance = MagicMock() + ollama_mock.Client = MagicMock(return_value=client_instance) + with patch.dict("sys.modules", {"ollama": ollama_mock}): + from semantica.semantic_extract.providers import OllamaProvider + provider = OllamaProvider(model_name="llama3") + provider._init_client() + assert provider.client is not ollama_mock + + +# =========================================================================== +# PR #371 — DatalogReasoner gap tests +# =========================================================================== + +class TestDatalogReasonerGap: + """Gap tests for DatalogReasoner beyond the existing 23 tests.""" + + @pytest.fixture + def reasoner(self): + from semantica.reasoning import DatalogReasoner + return DatalogReasoner() + + def test_derive_all_idempotent(self, reasoner): + reasoner.add_fact("parent(alice, bob)") + reasoner.add_rule("grandparent(X, Z) :- parent(X, Y), parent(Y, Z).") + reasoner.add_fact("parent(bob, carol)") + first = reasoner.derive_all() + second = reasoner.derive_all() + # Second call must produce same results (idempotency) + assert set(first) == set(second) + + def test_query_returns_list(self, reasoner): + reasoner.add_fact("color(sky, blue)") + result = reasoner.query("color(?X, ?Y)") + assert isinstance(result, list) + + def test_query_no_match_returns_empty(self, reasoner): + result = reasoner.query("nonexistent(?X)") + assert result == [] + + def test_multi_hop_four_levels(self, reasoner): + reasoner.add_fact("parent(a, b)") + reasoner.add_fact("parent(b, c)") + reasoner.add_fact("parent(c, d)") + reasoner.add_fact("parent(d, e)") + # DatalogReasoner uses uppercase-letter variables (not ?-prefixed) + reasoner.add_rule("ancestor(X, Z) :- parent(X, Z).") + reasoner.add_rule("ancestor(X, Z) :- parent(X, Y), ancestor(Y, Z).") + results = reasoner.query("ancestor(a, ?Z)") + targets = {r["Z"] for r in results} + assert "e" in targets + + def test_load_from_context_graph(self, reasoner): + from semantica.context import ContextGraph + graph = ContextGraph() + graph.add_node("alice", "Person") + graph.add_node("bob", "Person") + graph.add_edge("alice", "bob", "knows") + reasoner.load_from_graph(graph) + result = reasoner.query("knows(?X, ?Y)") + assert len(result) >= 1 + + def test_add_fact_dict_source_target_type(self, reasoner): + reasoner.add_fact({"source": "alice", "target": "bob", "type": "knows"}) + result = reasoner.query("knows(?X, ?Y)") + assert any(r.get("X") == "alice" and r.get("Y") == "bob" for r in result) + + def test_add_fact_subject_predicate_object_shape(self, reasoner): + reasoner.add_fact({"subject": "cat", "predicate": "isa", "object": "animal"}) + result = reasoner.query("isa(?X, ?Y)") + assert len(result) >= 1 + + def test_duplicate_fact_not_duplicated(self, reasoner): + reasoner.add_fact("color(sky, blue)") + reasoner.add_fact("color(sky, blue)") + result = reasoner.query("color(?X, ?Y)") + assert len(result) == 1 + + def test_derive_all_returns_list(self, reasoner): + # Facts must use constants (lowercase); uppercase is treated as variable + reasoner.add_fact("category(x, alpha)") + result = reasoner.derive_all() + assert isinstance(result, list)