mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-07 04:02:40 +00:00
* refactor(context): expose public temporal normalizer * test(context): strengthen temporal normalizer coverage --------- Co-authored-by: Sameer Kadam <sskadam6305@gmail.com> Co-authored-by: Sameer Kadam <sameerkadam@Sameers-MacBook-Air.local>
876 lines
35 KiB
Python
876 lines
35 KiB
Python
"""Tests for ContextGraph retraction and purge (issue #955).
|
|
|
|
``ContextGraph`` had 56 public methods and none that removed anything: the only
|
|
option was ``clear()``, which discards the whole graph. Two operations are
|
|
added, with deliberately different contracts.
|
|
|
|
Retraction closes an entity's validity window. The entity stops being active
|
|
going forward, but ``state_at()`` before the retraction still returns it, so
|
|
decisions recorded against it remain explainable. Purge is destructive: the
|
|
entity is gone from history too, leaving only a tombstone recording that a
|
|
purge happened and why -- never the purged content.
|
|
|
|
The audit-trail assertions run against a real ``TemporalVersionManager`` rather
|
|
than a mock callback, since the behaviour under test is precisely that these
|
|
operations reach the existing mutation-recording path.
|
|
"""
|
|
|
|
import ast
|
|
import inspect
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from semantica.change_management import TemporalVersionManager
|
|
from semantica.context import ContextEdge, ContextGraph
|
|
from semantica.context.context_graph import normalize_temporal_input
|
|
|
|
BEFORE = "2025-06-01T00:00:00Z"
|
|
BETWEEN = "2025-09-01T00:00:00Z"
|
|
CUTOFF = "2026-01-01T00:00:00Z"
|
|
AFTER = "2026-06-01T00:00:00Z"
|
|
|
|
|
|
class TestTemporalNormalization(unittest.TestCase):
|
|
"""Regression tests for the public normalize_temporal_input API (issue #1377).
|
|
|
|
The private ``_normalize_temporal_input`` was previously imported directly
|
|
by ``erasure.py``, creating fragile cross-module coupling. The public
|
|
wrapper must behave identically for every supported input type so that
|
|
context-graph timestamps and erasure-receipt timestamps always agree.
|
|
"""
|
|
|
|
# ------------------------------------------------------------------
|
|
# Basic output contract
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_none_returns_none(self):
|
|
self.assertIsNone(normalize_temporal_input(None))
|
|
|
|
def test_naive_datetime(self):
|
|
"""Naive datetime is serialized directly without tz conversion."""
|
|
self.assertEqual(
|
|
normalize_temporal_input(datetime(2026, 1, 1, 12, 0, 0)),
|
|
"2026-01-01T12:00:00",
|
|
)
|
|
|
|
def test_aware_datetime_utc_strips_timezone(self):
|
|
"""UTC-aware datetime is stripped of tzinfo before serialization."""
|
|
value = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
self.assertEqual(normalize_temporal_input(value), "2026-01-01T12:00:00")
|
|
|
|
def test_aware_datetime_positive_offset_converted_to_utc(self):
|
|
"""+05:00 aware datetime is shifted to UTC before serializing."""
|
|
value = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone(timedelta(hours=5)))
|
|
self.assertEqual(normalize_temporal_input(value), "2026-01-01T07:00:00")
|
|
|
|
def test_aware_datetime_negative_offset_converted_to_utc(self):
|
|
"""-08:00 aware datetime is shifted forward to UTC."""
|
|
value = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone(timedelta(hours=-8)))
|
|
self.assertEqual(normalize_temporal_input(value), "2026-01-01T08:00:00")
|
|
|
|
def test_epoch_int_zero(self):
|
|
"""Integer 0 maps to the Unix epoch in UTC."""
|
|
self.assertEqual(normalize_temporal_input(0), "1970-01-01T00:00:00")
|
|
|
|
def test_epoch_int_positive(self):
|
|
"""A known epoch value round-trips correctly."""
|
|
# 2026-01-01T00:00:00 UTC = 1767225600
|
|
self.assertEqual(normalize_temporal_input(1767225600), "2026-01-01T00:00:00")
|
|
|
|
def test_epoch_float_preserves_sub_second(self):
|
|
"""Float epoch retains sub-second precision in the ISO string."""
|
|
result = normalize_temporal_input(0.5)
|
|
self.assertTrue(result.startswith("1970-01-01T00:00:00"))
|
|
self.assertIn("5", result) # sub-second component present
|
|
|
|
def test_iso_string_with_z_suffix(self):
|
|
"""'Z' suffix is treated as UTC and the result is tz-naive."""
|
|
self.assertEqual(
|
|
normalize_temporal_input("2026-01-01T12:00:00Z"),
|
|
"2026-01-01T12:00:00",
|
|
)
|
|
|
|
def test_iso_string_with_positive_offset(self):
|
|
"""'+05:00' offset string is converted to UTC."""
|
|
self.assertEqual(
|
|
normalize_temporal_input("2026-01-01T12:00:00+05:00"),
|
|
"2026-01-01T07:00:00",
|
|
)
|
|
|
|
def test_iso_string_naive(self):
|
|
"""Naive ISO string is returned unchanged (treated as UTC)."""
|
|
self.assertEqual(
|
|
normalize_temporal_input("2026-01-01T12:00:00"),
|
|
"2026-01-01T12:00:00",
|
|
)
|
|
|
|
def test_year_only_string(self):
|
|
"""Year-only shorthand expands to Jan 1 midnight."""
|
|
self.assertEqual(normalize_temporal_input("2026"), "2026-01-01T00:00:00")
|
|
|
|
def test_date_only_string(self):
|
|
"""Date-only string expands to midnight of that date."""
|
|
self.assertEqual(
|
|
normalize_temporal_input("2026-03-15"),
|
|
"2026-03-15T00:00:00",
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Error cases
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_invalid_string_raises_value_error(self):
|
|
"""An unparseable string must raise ValueError, not silently produce None."""
|
|
with self.assertRaises(ValueError):
|
|
normalize_temporal_input("not-a-date")
|
|
|
|
def test_unsupported_type_raises_value_error(self):
|
|
"""A date object (not datetime) is not a supported type."""
|
|
from datetime import date
|
|
|
|
with self.assertRaises(ValueError):
|
|
normalize_temporal_input(date(2026, 1, 1)) # type: ignore[arg-type]
|
|
|
|
def test_error_message_contains_the_bad_value(self):
|
|
"""The ValueError for an invalid string names the bad input."""
|
|
with self.assertRaises(ValueError, msg="not-a-timestamp") as ctx:
|
|
normalize_temporal_input("not-a-timestamp")
|
|
self.assertIn("not-a-timestamp", str(ctx.exception))
|
|
|
|
# ------------------------------------------------------------------
|
|
# Return-type contract
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_always_returns_str_or_none(self):
|
|
"""Every non-None input must produce a str, never another type."""
|
|
inputs = [
|
|
datetime(2026, 1, 1),
|
|
datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
0,
|
|
1767225600,
|
|
0.5,
|
|
"2026-01-01T00:00:00Z",
|
|
"2026",
|
|
]
|
|
for value in inputs:
|
|
with self.subTest(value=value):
|
|
result = normalize_temporal_input(value)
|
|
self.assertIsInstance(result, str)
|
|
|
|
def test_output_is_always_tz_naive(self):
|
|
"""The returned ISO string must never carry a UTC offset or 'Z'."""
|
|
aware_inputs = [
|
|
datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
datetime(2026, 1, 1, tzinfo=timezone(timedelta(hours=3))),
|
|
"2026-01-01T00:00:00Z",
|
|
"2026-01-01T00:00:00+05:00",
|
|
]
|
|
for value in aware_inputs:
|
|
with self.subTest(value=value):
|
|
result = normalize_temporal_input(value)
|
|
self.assertNotIn("Z", result)
|
|
self.assertNotIn("+", result)
|
|
self.assertNotIn("-0", result[-6:]) # no trailing UTC offset
|
|
|
|
|
|
class TestTemporalNormalizationImportGuard(unittest.TestCase):
|
|
"""Structural regression tests that pin the fix for issue #1377.
|
|
|
|
The *point* of #1377 is that ``erasure.py`` must not import
|
|
``_normalize_temporal_input`` directly from ``context_graph``. These
|
|
tests catch a future reversion without relying on behavioral differences
|
|
(there are none — the wrapper is transparent).
|
|
"""
|
|
|
|
def test_erasure_does_not_import_private_normalizer(self):
|
|
"""``erasure.py`` source must not contain ``_normalize_temporal_input``
|
|
as a name in any import statement.
|
|
|
|
This test parses the AST rather than inspecting the live module so
|
|
that it catches the import even if the name is shadowed at runtime.
|
|
"""
|
|
import semantica.context.erasure as _erasure_module
|
|
|
|
src = inspect.getsource(_erasure_module)
|
|
tree = ast.parse(src)
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ImportFrom):
|
|
for alias in node.names:
|
|
self.assertNotEqual(
|
|
alias.name,
|
|
"_normalize_temporal_input",
|
|
"erasure.py imports the private _normalize_temporal_input; "
|
|
"it must use the public normalize_temporal_input instead "
|
|
"(issue #1377)",
|
|
)
|
|
|
|
def test_erasure_imports_public_normalizer(self):
|
|
"""``erasure.py`` must explicitly import ``normalize_temporal_input``."""
|
|
import semantica.context.erasure as _erasure_module
|
|
|
|
src = inspect.getsource(_erasure_module)
|
|
tree = ast.parse(src)
|
|
imported_names = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ImportFrom):
|
|
for alias in node.names:
|
|
imported_names.append(alias.name)
|
|
self.assertIn(
|
|
"normalize_temporal_input",
|
|
imported_names,
|
|
"erasure.py does not import normalize_temporal_input from context_graph",
|
|
)
|
|
|
|
def test_normalize_temporal_input_is_importable_as_public_api(self):
|
|
"""``normalize_temporal_input`` must be importable without underscore prefix."""
|
|
# Would raise ImportError if the name were removed or renamed.
|
|
from semantica.context.context_graph import normalize_temporal_input as fn
|
|
|
|
self.assertTrue(callable(fn))
|
|
|
|
def test_private_normalizer_not_leaked_into_erasure_namespace(self):
|
|
"""The private ``_normalize_temporal_input`` must not be reachable
|
|
through the ``erasure`` module's namespace."""
|
|
import semantica.context.erasure as _erasure_module
|
|
|
|
self.assertFalse(
|
|
hasattr(_erasure_module, "_normalize_temporal_input"),
|
|
"erasure module exposes _normalize_temporal_input in its namespace; "
|
|
"it should only hold normalize_temporal_input",
|
|
)
|
|
|
|
|
|
class TestTemporalNormalizationCrossModuleConsistency(unittest.TestCase):
|
|
"""Verify that erasure.py and context_graph.py normalize identically.
|
|
|
|
This is the core correctness requirement of issue #1377: the receipt's
|
|
``erased_at`` and the graph tombstone's ``purged_at`` must always carry
|
|
the same string because they are produced by the same normalization path.
|
|
If the two modules ever diverged (e.g. because erasure reimplemented
|
|
normalization independently), these tests would catch it.
|
|
"""
|
|
|
|
def _graph(self):
|
|
g = ContextGraph(advanced_analytics=False)
|
|
g.add_node("alice", "person")
|
|
return g
|
|
|
|
def _assert_receipt_and_tombstone_agree(self, at_value, label=""):
|
|
from semantica.context.erasure import ErasureCoordinator
|
|
|
|
g = self._graph()
|
|
receipt = ErasureCoordinator(graph=g).erase_entity("alice", at=at_value)
|
|
tombstone = g.get_tombstone("alice", "node")
|
|
self.assertEqual(
|
|
receipt.erased_at,
|
|
tombstone["purged_at"],
|
|
f"receipt.erased_at != tombstone.purged_at for input {label!r}",
|
|
)
|
|
# Also verify both agree with what normalize_temporal_input produces
|
|
# directly, so the public function is the single source of truth.
|
|
expected = normalize_temporal_input(at_value)
|
|
self.assertEqual(receipt.erased_at, expected)
|
|
|
|
def test_iso_z_string_receipt_and_tombstone_agree(self):
|
|
self._assert_receipt_and_tombstone_agree(
|
|
"2026-01-01T12:00:00Z", "ISO-Z string"
|
|
)
|
|
|
|
def test_iso_offset_string_receipt_and_tombstone_agree(self):
|
|
self._assert_receipt_and_tombstone_agree(
|
|
"2026-01-01T12:00:00+05:00", "ISO +05:00 string"
|
|
)
|
|
|
|
def test_iso_naive_string_receipt_and_tombstone_agree(self):
|
|
self._assert_receipt_and_tombstone_agree(
|
|
"2026-01-01T12:00:00", "ISO naive string"
|
|
)
|
|
|
|
def test_epoch_int_receipt_and_tombstone_agree(self):
|
|
self._assert_receipt_and_tombstone_agree(1767225600, "epoch int")
|
|
|
|
def test_aware_utc_datetime_receipt_and_tombstone_agree(self):
|
|
self._assert_receipt_and_tombstone_agree(
|
|
datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
|
"aware UTC datetime",
|
|
)
|
|
|
|
def test_aware_offset_datetime_receipt_and_tombstone_agree(self):
|
|
self._assert_receipt_and_tombstone_agree(
|
|
datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone(timedelta(hours=5))),
|
|
"aware +5h datetime",
|
|
)
|
|
|
|
def test_naive_datetime_receipt_and_tombstone_agree(self):
|
|
self._assert_receipt_and_tombstone_agree(
|
|
datetime(2026, 1, 1, 12, 0, 0), "naive datetime"
|
|
)
|
|
|
|
|
|
def _graph():
|
|
"""alice --works_at--> acme, plus an unrelated bob."""
|
|
graph = ContextGraph(advanced_analytics=False)
|
|
graph.add_node("alice", "person")
|
|
graph.add_node("acme", "org")
|
|
graph.add_node("bob", "person")
|
|
graph.add_edge("alice", "acme", "works_at")
|
|
return graph
|
|
|
|
|
|
def _ids_at(graph, when):
|
|
return {node.get("id") for node in graph.state_at(when).get("nodes", [])}
|
|
|
|
|
|
def _index_totals(graph):
|
|
return {
|
|
"nodes": len(graph.nodes),
|
|
"node_index": sum(len(v) for v in graph.node_type_index.values()),
|
|
"edges": len(graph.edges),
|
|
"edge_index": sum(len(v) for v in graph.edge_type_index.values()),
|
|
"adjacency": sum(len(v) for v in graph._adjacency.values()),
|
|
}
|
|
|
|
|
|
class TestRetractNode(unittest.TestCase):
|
|
def test_retracted_node_leaves_the_active_view(self):
|
|
graph = _graph()
|
|
self.assertTrue(graph.retract_node("alice", at=CUTOFF))
|
|
active = {node["id"] for node in graph.find_active_nodes()}
|
|
self.assertNotIn("alice", active)
|
|
self.assertIn("bob", active)
|
|
|
|
def test_history_before_the_retraction_is_preserved(self):
|
|
graph = _graph()
|
|
graph.retract_node("alice", at=CUTOFF)
|
|
self.assertIn("alice", _ids_at(graph, BEFORE))
|
|
self.assertNotIn("alice", _ids_at(graph, AFTER))
|
|
|
|
def test_retraction_record_captures_reason_and_time(self):
|
|
graph = _graph()
|
|
graph.retract_node("alice", reason="employment ended", at=CUTOFF)
|
|
record = graph.get_retraction("alice")
|
|
self.assertEqual(record["entity_id"], "alice")
|
|
self.assertEqual(record["entity_kind"], "node")
|
|
self.assertEqual(record["reason"], "employment ended")
|
|
self.assertIn("2026-01-01", record["retracted_at"])
|
|
|
|
def test_retracting_twice_is_a_no_op(self):
|
|
graph = _graph()
|
|
self.assertTrue(graph.retract_node("alice", reason="first", at=CUTOFF))
|
|
self.assertFalse(graph.retract_node("alice", reason="second"))
|
|
self.assertEqual(graph.get_retraction("alice")["reason"], "first")
|
|
|
|
def test_retracting_an_unknown_node_returns_false(self):
|
|
graph = _graph()
|
|
self.assertFalse(graph.retract_node("nobody"))
|
|
self.assertIsNone(graph.get_retraction("nobody"))
|
|
|
|
def test_cascade_retracts_incident_edges_in_both_directions(self):
|
|
graph = _graph()
|
|
graph.add_edge("bob", "alice", "knows") # inbound, not in _adjacency['alice']
|
|
graph.retract_node("alice", at=CUTOFF)
|
|
for edge in graph.edges:
|
|
self.assertIsNotNone(
|
|
graph.get_retraction(edge.edge_id),
|
|
f"edge {edge.edge_type} touching alice was not retracted",
|
|
)
|
|
|
|
def test_cascade_can_be_disabled(self):
|
|
graph = _graph()
|
|
graph.retract_node("alice", at=CUTOFF, cascade=False)
|
|
edge = graph.edges[0]
|
|
self.assertIsNone(graph.get_retraction(edge.edge_id))
|
|
|
|
def test_retraction_does_not_remove_the_record(self):
|
|
"""Retraction is a temporal change, not a deletion."""
|
|
graph = _graph()
|
|
graph.retract_node("alice", at=CUTOFF)
|
|
self.assertTrue(graph.has_node("alice"))
|
|
self.assertIsNotNone(graph.find_node("alice"))
|
|
|
|
|
|
class TestRetractEdge(unittest.TestCase):
|
|
def test_edge_is_retracted_without_touching_endpoints(self):
|
|
graph = _graph()
|
|
edge_id = graph.edges[0].edge_id
|
|
self.assertTrue(graph.retract_edge(edge_id, reason="wrong extraction"))
|
|
self.assertIsNotNone(graph.get_retraction(edge_id))
|
|
active = {node["id"] for node in graph.find_active_nodes()}
|
|
self.assertIn("alice", active)
|
|
self.assertIn("acme", active)
|
|
|
|
def test_retracting_an_unknown_edge_returns_false(self):
|
|
self.assertFalse(_graph().retract_edge("no-such-edge"))
|
|
|
|
def test_retracting_an_edge_twice_is_a_no_op(self):
|
|
graph = _graph()
|
|
edge_id = graph.edges[0].edge_id
|
|
self.assertTrue(graph.retract_edge(edge_id))
|
|
self.assertFalse(graph.retract_edge(edge_id))
|
|
|
|
|
|
class TestPurge(unittest.TestCase):
|
|
def test_purged_node_is_absent_from_history(self):
|
|
graph = _graph()
|
|
self.assertTrue(graph.purge_node("alice", reason="erasure request #1"))
|
|
self.assertNotIn("alice", _ids_at(graph, BEFORE))
|
|
self.assertFalse(graph.has_node("alice"))
|
|
|
|
def test_tombstone_records_the_purge_without_the_content(self):
|
|
graph = ContextGraph(advanced_analytics=False)
|
|
graph.add_node("alice", "person", email="alice@example.com")
|
|
graph.purge_node("alice", reason="erasure request #1")
|
|
|
|
tombstone = graph.get_tombstone("alice")
|
|
self.assertEqual(tombstone["entity_id"], "alice")
|
|
self.assertEqual(tombstone["reason"], "erasure request #1")
|
|
self.assertIn("purged_at", tombstone)
|
|
self.assertNotIn(
|
|
"alice@example.com",
|
|
str(tombstone),
|
|
"tombstone retained purged content, defeating the purpose of a purge",
|
|
)
|
|
|
|
def test_purge_cascades_to_incident_edges(self):
|
|
graph = _graph()
|
|
graph.add_edge("bob", "alice", "knows")
|
|
graph.purge_node("alice")
|
|
remaining = {(e.source_id, e.target_id) for e in graph.edges}
|
|
self.assertEqual(remaining, set())
|
|
|
|
def test_purge_keeps_every_index_consistent(self):
|
|
"""The invariant clear() already upholds must hold here too."""
|
|
graph = ContextGraph(advanced_analytics=False)
|
|
for i in range(5):
|
|
graph.add_node(f"n{i}", f"t{i % 2}")
|
|
graph.add_edge("n0", "n1", "a")
|
|
graph.add_edge("n1", "n2", "b")
|
|
graph.add_edge("n2", "n0", "a")
|
|
graph.add_edge("n3", "n0", "b")
|
|
|
|
graph.purge_node("n0")
|
|
|
|
totals = _index_totals(graph)
|
|
self.assertEqual(totals["node_index"], totals["nodes"])
|
|
self.assertEqual(totals["edge_index"], totals["edges"])
|
|
self.assertEqual(totals["adjacency"], totals["edges"])
|
|
self.assertEqual(totals["edges"], 1) # only n1->n2 survives
|
|
|
|
def test_purge_edge_leaves_endpoints_in_place(self):
|
|
graph = _graph()
|
|
edge_id = graph.edges[0].edge_id
|
|
self.assertTrue(graph.purge_edge(edge_id))
|
|
self.assertEqual(len(graph.edges), 0)
|
|
self.assertTrue(graph.has_node("alice"))
|
|
self.assertTrue(graph.has_node("acme"))
|
|
totals = _index_totals(graph)
|
|
self.assertEqual(totals["edge_index"], 0)
|
|
self.assertEqual(totals["adjacency"], 0)
|
|
|
|
def test_purging_unknown_entities_returns_false(self):
|
|
graph = _graph()
|
|
self.assertFalse(graph.purge_node("nobody"))
|
|
self.assertFalse(graph.purge_edge("no-such-edge"))
|
|
|
|
def test_purge_supersedes_an_earlier_retraction(self):
|
|
graph = _graph()
|
|
graph.retract_node("alice", reason="left", at=CUTOFF)
|
|
graph.purge_node("alice", reason="erasure request #2")
|
|
self.assertIsNone(graph.get_retraction("alice"))
|
|
self.assertIsNotNone(graph.get_tombstone("alice"))
|
|
|
|
|
|
class TestRetractionNeverWidensTheWindow(unittest.TestCase):
|
|
"""Retraction closes a validity window; it must never extend one.
|
|
|
|
An entity added with ``valid_until`` already in the past was inactive from
|
|
that point on. Overwriting the bound with a later retraction time would
|
|
make ``state_at`` report it active over a span it previously was not.
|
|
"""
|
|
|
|
def test_a_node_keeps_an_earlier_valid_until(self):
|
|
graph = ContextGraph(advanced_analytics=False)
|
|
graph.add_node("alice", "person", valid_until=BEFORE)
|
|
self.assertTrue(graph.retract_node("alice", at=AFTER))
|
|
self.assertEqual(graph.nodes["alice"].valid_until, BEFORE)
|
|
self.assertNotIn("alice", _ids_at(graph, BETWEEN))
|
|
|
|
def test_an_edge_keeps_an_earlier_valid_until(self):
|
|
graph = ContextGraph(advanced_analytics=False)
|
|
graph.add_node("alice", "person")
|
|
graph.add_node("acme", "org")
|
|
graph.add_edge("alice", "acme", "works_at", valid_until=BEFORE)
|
|
edge = graph.edges[0]
|
|
self.assertTrue(graph.retract_edge(edge.edge_id, at=AFTER))
|
|
self.assertEqual(edge.valid_until, BEFORE)
|
|
self.assertFalse(edge.is_active(datetime(2025, 9, 1)))
|
|
|
|
def test_cascade_keeps_an_earlier_edge_bound(self):
|
|
graph = ContextGraph(advanced_analytics=False)
|
|
graph.add_node("alice", "person")
|
|
graph.add_node("acme", "org")
|
|
graph.add_edge("alice", "acme", "works_at", valid_until=BEFORE)
|
|
graph.retract_node("alice", at=AFTER)
|
|
self.assertEqual(graph.edges[0].valid_until, BEFORE)
|
|
|
|
def test_an_open_window_is_still_closed_at_the_retraction_time(self):
|
|
graph = _graph()
|
|
graph.retract_node("alice", at=CUTOFF)
|
|
self.assertEqual(graph.nodes["alice"].valid_until, "2026-01-01T00:00:00")
|
|
|
|
|
|
class TestPurgeTimestamp(unittest.TestCase):
|
|
"""Purge accepts an explicit effective time, as retraction does."""
|
|
|
|
def test_node_tombstone_records_the_supplied_time(self):
|
|
graph = _graph()
|
|
graph.purge_node("alice", reason="erasure request #4", at=CUTOFF)
|
|
self.assertEqual(
|
|
graph.get_tombstone("alice")["purged_at"], "2026-01-01T00:00:00"
|
|
)
|
|
|
|
def test_edge_tombstone_records_the_supplied_time(self):
|
|
graph = _graph()
|
|
edge_id = graph.edges[0].edge_id
|
|
graph.purge_edge(edge_id, at=CUTOFF)
|
|
self.assertEqual(
|
|
graph.get_tombstone(edge_id)["purged_at"], "2026-01-01T00:00:00"
|
|
)
|
|
|
|
def test_cascaded_edge_tombstones_share_the_supplied_time(self):
|
|
graph = _graph()
|
|
edge_id = graph.edges[0].edge_id
|
|
graph.purge_node("alice", at=CUTOFF)
|
|
self.assertEqual(
|
|
graph.get_tombstone(edge_id)["purged_at"], "2026-01-01T00:00:00"
|
|
)
|
|
|
|
def test_purge_time_defaults_to_now(self):
|
|
graph = _graph()
|
|
graph.purge_node("alice")
|
|
self.assertIn("purged_at", graph.get_tombstone("alice"))
|
|
|
|
|
|
class TestIdKeyspaces(unittest.TestCase):
|
|
"""Node ids are caller-supplied and edge ids are UUIDs, so they can collide."""
|
|
|
|
def _colliding(self):
|
|
graph = _graph()
|
|
edge_id = graph.edges[0].edge_id
|
|
graph.add_node(edge_id, "person")
|
|
return graph, edge_id
|
|
|
|
def test_an_edge_retraction_does_not_block_a_colliding_node(self):
|
|
graph, edge_id = self._colliding()
|
|
self.assertTrue(graph.retract_edge(edge_id, reason="edge"))
|
|
self.assertTrue(graph.retract_node(edge_id, reason="node"))
|
|
self.assertEqual(graph.get_retraction(edge_id, "edge")["reason"], "edge")
|
|
self.assertEqual(graph.get_retraction(edge_id, "node")["reason"], "node")
|
|
|
|
def test_purging_a_node_leaves_a_colliding_edge_alone(self):
|
|
graph, edge_id = self._colliding()
|
|
self.assertTrue(graph.purge_node(edge_id))
|
|
self.assertEqual(len(graph.edges), 1)
|
|
self.assertIsNone(graph.get_tombstone(edge_id, "edge"))
|
|
self.assertIsNotNone(graph.get_tombstone(edge_id, "node"))
|
|
|
|
def test_an_unknown_entity_kind_is_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_graph().get_retraction("alice", "vertex")
|
|
|
|
|
|
class TestDuplicateEdgeId(unittest.TestCase):
|
|
"""``edge_id`` is content-derived; before #926, two identical ``add_edge``
|
|
calls produced two edge objects sharing one id. #926 stops *new*
|
|
duplicates through ``add_edge``/``add_edges``, but a graph can still carry
|
|
one from a save made before that fix, or from any other path that builds
|
|
a ``ContextEdge`` directly -- so retraction/purge must still handle it.
|
|
Every duplicate must be reached, or a retraction/tombstone record can
|
|
claim an edge is gone/inactive while a live copy remains in the graph.
|
|
"""
|
|
|
|
def _duplicated(self):
|
|
"""A graph with two distinct ``ContextEdge`` objects sharing one
|
|
edge_id, reproducing pre-#926 (or any hand-built) duplicate state
|
|
without going through the now-deduping ``add_edge``.
|
|
"""
|
|
graph = _graph()
|
|
original = graph.edges[0]
|
|
duplicate = ContextEdge(
|
|
source_id=original.source_id,
|
|
target_id=original.target_id,
|
|
edge_type=original.edge_type,
|
|
weight=original.weight,
|
|
)
|
|
self.assertEqual(duplicate.edge_id, original.edge_id)
|
|
graph.edges.append(duplicate)
|
|
graph.edge_type_index[duplicate.edge_type].append(duplicate)
|
|
graph._adjacency[duplicate.source_id].append(duplicate)
|
|
edge_id = original.edge_id
|
|
self.assertEqual({e.edge_id for e in graph.edges}, {edge_id})
|
|
self.assertEqual(len(graph.edges), 2)
|
|
return graph, edge_id
|
|
|
|
def test_retract_edge_closes_every_duplicate(self):
|
|
graph, edge_id = self._duplicated()
|
|
self.assertTrue(graph.retract_edge(edge_id, reason="dup", at=CUTOFF))
|
|
for edge in graph.edges:
|
|
self.assertEqual(edge.valid_until, "2026-01-01T00:00:00")
|
|
self.assertFalse(edge.is_active(datetime(2026, 6, 1)))
|
|
|
|
def test_retract_node_cascade_closes_every_duplicate(self):
|
|
graph, edge_id = self._duplicated()
|
|
self.assertTrue(graph.retract_node("alice", at=CUTOFF))
|
|
for edge in graph.edges:
|
|
self.assertEqual(edge.valid_until, "2026-01-01T00:00:00")
|
|
|
|
def test_purge_edge_removes_every_duplicate(self):
|
|
graph, edge_id = self._duplicated()
|
|
self.assertTrue(graph.purge_edge(edge_id, reason="dup"))
|
|
self.assertFalse(any(e.edge_id == edge_id for e in graph.edges))
|
|
|
|
def test_purge_node_cascade_removes_every_duplicate(self):
|
|
graph, edge_id = self._duplicated()
|
|
self.assertTrue(graph.purge_node("alice"))
|
|
self.assertFalse(any(e.edge_id == edge_id for e in graph.edges))
|
|
|
|
def test_repeat_purge_edge_does_not_overwrite_the_tombstone(self):
|
|
"""Once every duplicate is gone, a second call must no-op, not
|
|
silently 'complete' the purge again and clobber the original record."""
|
|
graph, edge_id = self._duplicated()
|
|
self.assertTrue(graph.purge_edge(edge_id, reason="first"))
|
|
self.assertFalse(graph.purge_edge(edge_id, reason="second"))
|
|
self.assertEqual(graph.get_tombstone(edge_id)["reason"], "first")
|
|
|
|
|
|
class TestPurgeCrossGraphLinks(unittest.TestCase):
|
|
"""link_graph() registers a link, a marker node and a bridge edge."""
|
|
|
|
def _linked(self):
|
|
graph = _graph()
|
|
other = ContextGraph(advanced_analytics=False)
|
|
other.add_node("target", "topic")
|
|
return graph, other, graph.link_graph(other, "alice", "target")
|
|
|
|
def test_purging_the_source_removes_link_marker_and_registration(self):
|
|
graph, _, link_id = self._linked()
|
|
graph.purge_node("alice", reason="erasure request #5")
|
|
self.assertFalse(graph.has_node(f"__cross_graph_{link_id}"))
|
|
with self.assertRaises(KeyError):
|
|
graph.navigate_to(link_id)
|
|
totals = _index_totals(graph)
|
|
self.assertEqual(totals["node_index"], totals["nodes"])
|
|
self.assertEqual(totals["edge_index"], totals["edges"])
|
|
self.assertEqual(totals["adjacency"], totals["edges"])
|
|
|
|
def test_the_marker_purge_is_recorded_as_cascaded(self):
|
|
graph, _, link_id = self._linked()
|
|
graph.purge_node("alice")
|
|
tombstone = graph.get_tombstone(f"__cross_graph_{link_id}")
|
|
self.assertEqual(tombstone["cascaded_from"], "alice")
|
|
|
|
def test_a_purged_link_is_not_serialized(self):
|
|
graph, _, _ = self._linked()
|
|
graph.purge_node("alice")
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = os.path.join(directory, "graph.json")
|
|
graph.save_to_file(path)
|
|
with open(path, encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
self.assertEqual(data["links"], [])
|
|
|
|
def test_cascade_disabled_still_deregisters_the_link(self):
|
|
"""The source node is gone either way, so the link cannot resolve."""
|
|
graph, _, link_id = self._linked()
|
|
graph.purge_node("alice", cascade=False)
|
|
with self.assertRaises(KeyError):
|
|
graph.navigate_to(link_id)
|
|
self.assertTrue(graph.has_node(f"__cross_graph_{link_id}"))
|
|
|
|
def test_purging_the_bridge_edge_deregisters_the_link(self):
|
|
graph, _, link_id = self._linked()
|
|
bridge = next(
|
|
edge for edge in graph.edges if edge.metadata.get("link_id") == link_id
|
|
)
|
|
self.assertTrue(graph.purge_edge(bridge.edge_id))
|
|
with self.assertRaises(KeyError):
|
|
graph.navigate_to(link_id)
|
|
|
|
def test_purging_the_marker_node_deregisters_the_link(self):
|
|
graph, _, link_id = self._linked()
|
|
self.assertTrue(graph.purge_node(f"__cross_graph_{link_id}"))
|
|
with self.assertRaises(KeyError):
|
|
graph.navigate_to(link_id)
|
|
self.assertTrue(graph.has_node("alice"))
|
|
|
|
def test_an_unrelated_link_survives(self):
|
|
graph, other, link_id = self._linked()
|
|
graph.purge_node("bob")
|
|
self.assertEqual(graph.navigate_to(link_id), (other, "target"))
|
|
|
|
|
|
class TestClearResetsRecords(unittest.TestCase):
|
|
def test_clear_drops_retractions_and_tombstones(self):
|
|
graph = _graph()
|
|
graph.retract_node("alice", at=CUTOFF)
|
|
graph.purge_node("bob")
|
|
graph.clear()
|
|
self.assertEqual(graph.list_retractions(), [])
|
|
self.assertEqual(graph.list_tombstones(), [])
|
|
|
|
def test_load_from_file_drops_records_from_the_previous_graph(self):
|
|
source = _graph()
|
|
graph = ContextGraph(advanced_analytics=False)
|
|
graph.add_node("alice", "person")
|
|
graph.add_node("carol", "person")
|
|
graph.retract_node("alice", at=CUTOFF)
|
|
graph.purge_node("carol")
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = os.path.join(directory, "graph.json")
|
|
source.save_to_file(path)
|
|
graph.load_from_file(path)
|
|
|
|
self.assertEqual(graph.list_retractions(), [])
|
|
self.assertEqual(graph.list_tombstones(), [])
|
|
# The reloaded alice is a fresh record, not one already retracted.
|
|
self.assertTrue(graph.retract_node("alice", at=CUTOFF))
|
|
|
|
|
|
class TestAuditTrailIntegration(unittest.TestCase):
|
|
"""Against the real TemporalVersionManager, not a mock callback."""
|
|
|
|
def _attached(self):
|
|
manager = TemporalVersionManager()
|
|
graph = _graph()
|
|
manager.attach_to_graph(graph)
|
|
return manager, graph
|
|
|
|
def _ops(self, manager, entity_id):
|
|
history = manager.storage.get_entity_history(entity_id) or []
|
|
return [entry.get("operation") for entry in history]
|
|
|
|
def test_retraction_is_recorded_as_an_update(self):
|
|
manager, graph = self._attached()
|
|
graph.retract_node("alice", reason="left", at=CUTOFF)
|
|
self.assertIn("UPDATE_NODE", self._ops(manager, "alice"))
|
|
|
|
def test_purge_is_recorded_as_a_removal(self):
|
|
manager, graph = self._attached()
|
|
graph.purge_node("acme", reason="erasure request #3")
|
|
self.assertIn("REMOVE_NODE", self._ops(manager, "acme"))
|
|
|
|
def test_operations_use_the_documented_mutation_vocabulary(self):
|
|
"""MutationRecord documents ADD/UPDATE/REMOVE for nodes and edges."""
|
|
manager, graph = self._attached()
|
|
graph.retract_node("alice", at=CUTOFF)
|
|
graph.purge_node("bob")
|
|
allowed = {
|
|
"ADD_NODE",
|
|
"UPDATE_NODE",
|
|
"REMOVE_NODE",
|
|
"ADD_EDGE",
|
|
"UPDATE_EDGE",
|
|
"REMOVE_EDGE",
|
|
}
|
|
seen = set()
|
|
for entity_id in ("alice", "acme", "bob"):
|
|
seen.update(self._ops(manager, entity_id))
|
|
self.assertTrue(seen)
|
|
self.assertTrue(
|
|
seen <= allowed, f"undocumented mutation operation(s): {seen - allowed}"
|
|
)
|
|
|
|
|
|
class TestMutationEmissionIsSelfContained(unittest.TestCase):
|
|
"""Audit payloads must be snapshotted before the lock is released.
|
|
|
|
The callback fires outside the lock, so anything read from
|
|
``_retractions``/``_tombstones`` at emission time can already have been
|
|
wiped by a concurrent ``clear()``. A callback that clears the graph on its
|
|
first call stands in for that interleaving deterministically.
|
|
"""
|
|
|
|
def _clearing_callback(self, graph, seen):
|
|
def callback(operation, entity_id, payload):
|
|
seen.append((operation, entity_id, payload))
|
|
if len(seen) == 1:
|
|
graph.clear()
|
|
|
|
return callback
|
|
|
|
def test_purge_emits_every_mutation_after_a_concurrent_clear(self):
|
|
graph = _graph()
|
|
graph.add_edge("bob", "alice", "knows")
|
|
seen = []
|
|
graph.mutation_callback = self._clearing_callback(graph, seen)
|
|
|
|
self.assertTrue(graph.purge_node("alice", reason="erasure request #6"))
|
|
|
|
self.assertEqual(
|
|
[operation for operation, _, _ in seen],
|
|
["REMOVE_EDGE", "REMOVE_EDGE", "REMOVE_NODE"],
|
|
)
|
|
for _, entity_id, payload in seen:
|
|
self.assertEqual(payload["entity_id"], entity_id)
|
|
self.assertEqual(payload["reason"], "erasure request #6")
|
|
|
|
def test_retraction_emits_every_mutation_after_a_concurrent_clear(self):
|
|
graph = _graph()
|
|
graph.add_edge("bob", "alice", "knows")
|
|
seen = []
|
|
graph.mutation_callback = self._clearing_callback(graph, seen)
|
|
|
|
self.assertTrue(graph.retract_node("alice", reason="left", at=CUTOFF))
|
|
|
|
self.assertEqual(
|
|
[operation for operation, _, _ in seen],
|
|
["UPDATE_NODE", "UPDATE_EDGE", "UPDATE_EDGE"],
|
|
)
|
|
for _, _, payload in seen:
|
|
self.assertEqual(payload["retraction"]["reason"], "left")
|
|
|
|
|
|
class TestConcurrency(unittest.TestCase):
|
|
def test_concurrent_purges_keep_indexes_consistent(self):
|
|
"""Post-condition, not timing: threads must finish and indexes agree."""
|
|
graph = ContextGraph(advanced_analytics=False)
|
|
for i in range(60):
|
|
graph.add_node(f"n{i}", "t")
|
|
for i in range(59):
|
|
graph.add_edge(f"n{i}", f"n{i + 1}", "rel")
|
|
|
|
errors = []
|
|
|
|
def purge(start):
|
|
try:
|
|
for i in range(start, 60, 4):
|
|
graph.purge_node(f"n{i}")
|
|
except Exception as exc: # surfaced below, never swallowed
|
|
errors.append(f"{type(exc).__name__}: {exc}")
|
|
|
|
threads = [
|
|
threading.Thread(target=purge, args=(offset,)) for offset in range(4)
|
|
]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join(timeout=30)
|
|
|
|
self.assertEqual([t.name for t in threads if t.is_alive()], [])
|
|
self.assertEqual(errors, [])
|
|
self.assertEqual(len(graph.nodes), 0)
|
|
totals = _index_totals(graph)
|
|
self.assertEqual(totals["node_index"], 0)
|
|
self.assertEqual(totals["edge_index"], 0)
|
|
self.assertEqual(totals["adjacency"], 0)
|
|
self.assertEqual(totals["edges"], 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|