personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

solstone / tests / test_entity_ambiguities.py
22 kB 670 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4"""Tests for mutation-safe entity resolution ambiguities.""" 5 6from __future__ import annotations 7 8import ast 9import hashlib 10import inspect 11import json 12from contextlib import contextmanager 13from importlib import import_module 14from pathlib import Path 15from typing import Iterator 16 17import pytest 18 19import solstone.think.entities.ambiguities as ambiguities 20import solstone.think.entities.history as entity_history 21from solstone.think.entities import ( 22 EntityAmbiguityError, 23 EntityResolutionOutcome, 24 MatchTier, 25 ResolutionOrigin, 26 ResolutionScope, 27 delete_journal_entity, 28 find_matching_entity, 29 load_ambiguities, 30 record_ambiguity_choice, 31 record_entity_resolution, 32) 33from solstone.think.journal_io import LockTimeout 34 35 36@pytest.fixture 37def journal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: 38 """Use an isolated journal for ambiguity tests.""" 39 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 40 return tmp_path 41 42 43def _entity( 44 name: str, 45 entity_id: str | None = None, 46 *, 47 aka: list[str] | None = None, 48 emails: list[str] | None = None, 49 blocked: bool = False, 50) -> dict: 51 entity = {"id": entity_id or name.lower().replace(" ", "_"), "name": name} 52 if aka is not None: 53 entity["aka"] = aka 54 if emails is not None: 55 entity["emails"] = emails 56 if blocked: 57 entity["blocked"] = True 58 return entity 59 60 61def _origin(lane: str = "test", invocation_id: str = "run-1") -> ResolutionOrigin: 62 return ResolutionOrigin(lane=lane, field="entity", invocation_id=invocation_id) 63 64 65def _ambiguity_file(journal: Path) -> Path: 66 return journal / "entities" / "ambiguities.jsonl" 67 68 69def _tree_snapshot(root: Path) -> dict[str, str]: 70 snapshot: dict[str, str] = {} 71 for path in sorted(root.rglob("*")): 72 if path.name.endswith(".lock") or path.is_dir(): 73 continue 74 rel = path.relative_to(root).as_posix() 75 if path.is_file(): 76 snapshot[rel] = hashlib.sha256(path.read_bytes()).hexdigest() 77 return snapshot 78 79 80def _record( 81 query: str, 82 entities: list[dict], 83 *, 84 scope: ResolutionScope | None = None, 85 origin: ResolutionOrigin | None = None, 86): 87 return record_entity_resolution( 88 query, 89 entities, 90 scope=scope or ResolutionScope.journal(), 91 origin=origin or _origin(), 92 ) 93 94 95def test_high_confidence_tiers_resolve_without_creating_ambiguities( 96 journal: Path, 97) -> None: 98 cases = [ 99 ("Alice Smith", [_entity("Alice Smith")], MatchTier.EXACT), 100 ("alice smith", [_entity("Alice Smith")], MatchTier.CASE_INSENSITIVE), 101 ( 102 "alice@example.com", 103 [_entity("Alice Smith", emails=["alice@example.com"])], 104 MatchTier.EMAIL, 105 ), 106 ( 107 "Alice Smith", 108 [_entity("Alice Jones", entity_id="alice_smith")], 109 MatchTier.SLUG, 110 ), 111 ] 112 113 for query, entities, expected_tier in cases: 114 resolution = _record(query, entities, origin=_origin(invocation_id=query)) 115 assert resolution.outcome == EntityResolutionOutcome.RESOLVED 116 assert resolution.entity is not None 117 assert resolution.tier == expected_tier 118 assert resolution.ambiguity_id is None 119 120 assert not _ambiguity_file(journal).exists() 121 122 123def test_tier_five_multi_candidate_legacy_none_becomes_ambiguous( 124 journal: Path, 125) -> None: 126 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 127 128 assert find_matching_entity("Sarah", entities) is None 129 resolution = _record("Sarah", entities) 130 131 assert resolution.outcome == EntityResolutionOutcome.AMBIGUOUS 132 assert resolution.entity is None 133 assert resolution.tier == MatchTier.FIRST_WORD 134 assert resolution.ambiguity_id 135 assert {candidate.id for candidate in resolution.candidates} == { 136 "sarah_connor", 137 "sarah_lee", 138 } 139 assert list(resolution.candidates) == sorted( 140 resolution.candidates, 141 key=lambda candidate: (-candidate.score, candidate.name, candidate.id), 142 ) 143 144 row = load_ambiguities()[0] 145 assert row["observed_tier"] == int(MatchTier.FIRST_WORD) 146 assert len(row["ranked_candidates"]) == 2 147 148 149def test_record_entity_resolution_read_only_skips_trust_lock_and_ambiguity_observation( 150 journal: Path, 151) -> None: 152 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 153 trust_lock = journal / "health" / "locks" / "entity-trust.lock" 154 155 resolution = record_entity_resolution( 156 "Sarah", 157 entities, 158 scope=ResolutionScope.journal(), 159 origin=_origin(), 160 read_only=True, 161 ) 162 163 assert resolution.outcome == EntityResolutionOutcome.AMBIGUOUS 164 assert resolution.ambiguity_id == "" 165 assert {candidate.id for candidate in resolution.candidates} == { 166 "sarah_connor", 167 "sarah_lee", 168 } 169 assert not _ambiguity_file(journal).exists() 170 assert not trust_lock.exists() 171 172 173def test_tier_six_multi_candidate_legacy_none_becomes_ambiguous( 174 journal: Path, 175) -> None: 176 entities = [_entity("Josh Jones Dilworth"), _entity("Mary Jones Dilworth")] 177 178 assert find_matching_entity("Jones Dilworth", entities) is None 179 resolution = _record("Jones Dilworth", entities) 180 181 assert resolution.outcome == EntityResolutionOutcome.AMBIGUOUS 182 assert resolution.entity is None 183 assert resolution.tier == MatchTier.TOKEN_SUBSET 184 assert {candidate.id for candidate in resolution.candidates} == { 185 "josh_jones_dilworth", 186 "mary_jones_dilworth", 187 } 188 189 190def test_tier_seven_multi_candidate_legacy_none_becomes_ambiguous( 191 journal: Path, 192) -> None: 193 entities = [_entity("Jonathan Dilton"), _entity("Jonas Diltmore")] 194 195 assert find_matching_entity("Jona Dilt", entities) is None 196 resolution = _record("Jona Dilt", entities) 197 198 assert resolution.outcome == EntityResolutionOutcome.AMBIGUOUS 199 assert resolution.entity is None 200 assert resolution.tier == MatchTier.PREFIX 201 assert {candidate.id for candidate in resolution.candidates} == { 202 "jonathan_dilton", 203 "jonas_diltmore", 204 } 205 206 207def test_tier_eight_fuzzy_is_ambiguous(journal: Path) -> None: 208 entities = [_entity("Robert Johnson")] 209 210 resolution = _record("Robert Jonson", entities) 211 212 assert resolution.outcome == EntityResolutionOutcome.AMBIGUOUS 213 assert resolution.entity is None 214 assert resolution.tier == MatchTier.FUZZY 215 assert [candidate.id for candidate in resolution.candidates] == ["robert_johnson"] 216 217 218def test_true_no_match_does_not_create_row(journal: Path) -> None: 219 resolution = _record("Xy", [_entity("Alice Smith")]) 220 221 assert resolution.outcome == EntityResolutionOutcome.NO_MATCH 222 assert resolution.entity is None 223 assert resolution.ambiguity_id is None 224 assert not _ambiguity_file(journal).exists() 225 226 227def test_one_open_row_per_scope_query_with_deduplicated_origins( 228 journal: Path, 229 monkeypatch: pytest.MonkeyPatch, 230) -> None: 231 times = iter( 232 [ 233 "2026-01-01T00:00:00Z", 234 "2026-01-01T00:00:01Z", 235 "2026-01-01T00:00:02Z", 236 "2026-01-01T00:00:03Z", 237 ] 238 ) 239 monkeypatch.setattr(ambiguities, "utc_now_iso", lambda: next(times)) 240 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 241 origin1 = _origin("participation", "run-1") 242 origin2 = _origin("participation", "run-2") 243 origin3 = _origin("schedule", "run-3") 244 245 first = _record("Sarah", entities, origin=origin1) 246 _record(" sarah ", entities, origin=origin1) 247 second = _record("SARAH", entities, origin=origin2) 248 third = _record("Sarah", entities, origin=origin3) 249 250 rows = load_ambiguities() 251 assert len(rows) == 1 252 row = rows[0] 253 assert first.ambiguity_id == second.ambiguity_id == third.ambiguity_id 254 assert row["first_seen"] == "2026-01-01T00:00:00Z" 255 assert row["last_seen"] == "2026-01-01T00:00:03Z" 256 assert row["occurrence_count"] == 3 257 assert len(row["origins"]) == 3 258 259 260def test_normalization_collapses_case_whitespace_and_unicode_not_punctuation( 261 journal: Path, 262) -> None: 263 assert ambiguities.normalize_resolution_query(" Sarah\tLee ") == "sarah lee" 264 assert ambiguities.normalize_resolution_query("Sarah") == "sarah" 265 assert ambiguities.normalize_resolution_query("Sarah.") == "sarah." 266 267 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 268 punct_entities = [_entity("Sarah. Connor"), _entity("Sarah. Lee")] 269 270 first = _record(" Sarah ", entities, origin=_origin("test", "run-1")) 271 second = _record("sarah", entities, origin=_origin("test", "run-2")) 272 third = _record("Sarah.", punct_entities, origin=_origin("test", "run-3")) 273 274 rows = load_ambiguities() 275 assert len(rows) == 2 276 assert first.ambiguity_id == second.ambiguity_id 277 assert third.ambiguity_id != first.ambiguity_id 278 279 280def test_scope_discriminator_separates_facet_and_journal(journal: Path) -> None: 281 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 282 283 journal_resolution = _record("Sarah", entities, scope=ResolutionScope.journal()) 284 facet_resolution = _record( 285 "Sarah", 286 entities, 287 scope=ResolutionScope.facet_scope("work"), 288 origin=_origin("test", "facet-run"), 289 ) 290 291 rows = load_ambiguities() 292 assert len(rows) == 2 293 assert journal_resolution.ambiguity_id != facet_resolution.ambiguity_id 294 assert {json.dumps(row["scope"], sort_keys=True) for row in rows} == { 295 json.dumps({"kind": "journal"}, sort_keys=True), 296 json.dumps({"kind": "facet", "facet": "work"}, sort_keys=True), 297 } 298 299 300def test_record_ambiguity_choice_returns_choice_without_reopening( 301 journal: Path, 302) -> None: 303 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 304 scope = ResolutionScope.journal() 305 306 ambiguous = _record("Sarah", entities, scope=scope) 307 row = record_ambiguity_choice("Sarah", "sarah_lee", entities, scope=scope) 308 309 assert row["ambiguity_id"] == ambiguous.ambiguity_id 310 assert row["status"] == "resolved" 311 assert row["resolved_entity_id"] == "sarah_lee" 312 313 resolved = _record("Sarah", entities, scope=scope, origin=_origin("test", "run-2")) 314 rows = load_ambiguities() 315 assert resolved.outcome == EntityResolutionOutcome.RESOLVED 316 assert resolved.entity is not None 317 assert resolved.entity["id"] == "sarah_lee" 318 assert rows[0]["occurrence_count"] == 1 319 assert rows[0]["status"] == "resolved" 320 321 322def test_invalid_ambiguity_choice_fails_with_row_byte_unchanged( 323 journal: Path, 324) -> None: 325 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 326 scope = ResolutionScope.journal() 327 _record("Sarah", entities, scope=scope) 328 path = _ambiguity_file(journal) 329 before = path.read_bytes() 330 331 with pytest.raises(EntityAmbiguityError): 332 record_ambiguity_choice("Sarah", "missing", entities, scope=scope) 333 assert path.read_bytes() == before 334 335 with pytest.raises(EntityAmbiguityError): 336 record_ambiguity_choice( 337 "Sarah", 338 "blocked_sarah", 339 [*entities, _entity("Blocked Sarah", "blocked_sarah", blocked=True)], 340 scope=scope, 341 ) 342 assert path.read_bytes() == before 343 344 with pytest.raises(EntityAmbiguityError): 345 record_ambiguity_choice( 346 "Sarah", 347 "sarah_lee", 348 entities, 349 scope=ResolutionScope.facet_scope("work"), 350 ) 351 assert path.read_bytes() == before 352 353 354def test_reresolve_retains_prior_choice_in_audit(journal: Path) -> None: 355 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 356 scope = ResolutionScope.journal() 357 358 _record("Sarah", entities, scope=scope) 359 record_ambiguity_choice("Sarah", "sarah_connor", entities, scope=scope) 360 row = record_ambiguity_choice( 361 "Sarah", 362 "sarah_lee", 363 entities, 364 scope=scope, 365 origin=_origin("repair", "repair-run"), 366 ) 367 368 assert row["resolved_entity_id"] == "sarah_lee" 369 prior_choices = row["audit"]["prior_choices"] 370 assert len(prior_choices) == 1 371 assert prior_choices[0]["resolved_entity_id"] == "sarah_connor" 372 assert prior_choices[0]["replaced_by_origin"]["lane"] == "repair" 373 374 375def test_stale_resolved_choice_fails_loudly_without_fallback(journal: Path) -> None: 376 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 377 scope = ResolutionScope.journal() 378 379 _record("Sarah", entities, scope=scope) 380 record_ambiguity_choice("Sarah", "sarah_lee", entities, scope=scope) 381 382 with pytest.raises(EntityAmbiguityError): 383 _record("Sarah", [_entity("Sarah Connor")], scope=scope) 384 385 with pytest.raises(EntityAmbiguityError): 386 _record( 387 "Sarah", 388 [_entity("Sarah Lee", entity_id="sarah_lee", blocked=True)], 389 scope=scope, 390 ) 391 392 393def test_resolved_choice_with_empty_entity_set_fails_loudly( 394 journal: Path, 395) -> None: 396 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 397 scope = ResolutionScope.journal() 398 399 _record("Sarah", entities, scope=scope) 400 record_ambiguity_choice("Sarah", "sarah_lee", entities, scope=scope) 401 402 with pytest.raises(EntityAmbiguityError): 403 resolution = _record("Sarah", [], scope=scope) 404 assert resolution.outcome != EntityResolutionOutcome.NO_MATCH 405 406 assert not (journal / "entities" / "sarah" / "entity.json").exists() 407 408 409def test_lock_timeout_during_persistence_fails_closed( 410 journal: Path, 411 monkeypatch: pytest.MonkeyPatch, 412) -> None: 413 def raise_timeout(path: Path): 414 raise LockTimeout(path, 0.01) 415 416 monkeypatch.setattr(ambiguities, "hold_lock", raise_timeout) 417 before = _tree_snapshot(journal) 418 419 with pytest.raises(LockTimeout): 420 _record("Sarah", [_entity("Sarah Connor"), _entity("Sarah Lee")]) 421 422 assert _tree_snapshot(journal) == before 423 assert not _ambiguity_file(journal).exists() 424 425 426def test_resolution_and_choice_use_trust_then_ambiguity_lock_order( 427 journal: Path, 428 monkeypatch: pytest.MonkeyPatch, 429) -> None: 430 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 431 scope = ResolutionScope.journal() 432 events: list[str] = [] 433 434 @contextmanager 435 def trust_lock(path: Path) -> Iterator[None]: 436 events.append("trust-enter") 437 try: 438 yield 439 finally: 440 events.append("trust-exit") 441 442 @contextmanager 443 def ambiguity_lock(path: Path) -> Iterator[None]: 444 events.append("ambiguity-enter") 445 try: 446 yield 447 finally: 448 events.append("ambiguity-exit") 449 450 monkeypatch.setattr(entity_history, "hold_lock", trust_lock) 451 monkeypatch.setattr(ambiguities, "hold_lock", ambiguity_lock) 452 453 _record("Sarah", entities, scope=scope) 454 assert events == [ 455 "trust-enter", 456 "ambiguity-enter", 457 "ambiguity-exit", 458 "trust-exit", 459 ] 460 461 events.clear() 462 record_ambiguity_choice("Sarah", "sarah_lee", entities, scope=scope) 463 assert events == [ 464 "trust-enter", 465 "ambiguity-enter", 466 "ambiguity-exit", 467 "trust-exit", 468 ] 469 470 471def test_entity_delete_uses_trust_boundary( 472 journal: Path, 473 monkeypatch: pytest.MonkeyPatch, 474) -> None: 475 entity_dir = journal / "entities" / "sarah_lee" 476 entity_dir.mkdir(parents=True) 477 (entity_dir / "entity.json").write_text( 478 json.dumps(_entity("Sarah Lee")), 479 encoding="utf-8", 480 ) 481 events: list[str] = [] 482 483 @contextmanager 484 def trust_lock(path: Path) -> Iterator[None]: 485 events.append("trust-enter") 486 try: 487 yield 488 finally: 489 events.append("trust-exit") 490 491 monkeypatch.setattr(entity_history, "hold_lock", trust_lock) 492 493 result = delete_journal_entity("sarah_lee") 494 495 assert result == {"success": True, "facets_deleted": []} 496 assert events == ["trust-enter", "trust-exit"] 497 assert not entity_dir.exists() 498 499 500@pytest.mark.parametrize( 501 "corrupt_line", 502 [ 503 "{not-json", 504 "[]", 505 json.dumps({"schema_version": 1}), 506 ], 507) 508@pytest.mark.parametrize("position", ["before", "after", "only"]) 509def test_mutation_resolution_fails_closed_on_corrupt_store_rows( 510 journal: Path, 511 corrupt_line: str, 512 position: str, 513) -> None: 514 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 515 _record("Sarah", entities) 516 path = _ambiguity_file(journal) 517 valid = path.read_text(encoding="utf-8").rstrip("\n") 518 if position == "before": 519 content = f"{corrupt_line}\n{valid}\n" 520 elif position == "after": 521 content = f"{valid}\n{corrupt_line}\n" 522 else: 523 content = f"{corrupt_line}\n" 524 path.write_text(content, encoding="utf-8") 525 before = path.read_bytes() 526 527 with pytest.raises(EntityAmbiguityError): 528 _record("Sarah", entities, origin=_origin("retry", "retry-1")) 529 530 assert path.read_bytes() == before 531 532 533def test_read_only_list_remains_lenient_for_malformed_lines(journal: Path) -> None: 534 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 535 _record("Sarah", entities) 536 path = _ambiguity_file(journal) 537 valid = path.read_text(encoding="utf-8") 538 path.write_text(f"{{not-json\n[]\n{valid}", encoding="utf-8") 539 before = path.read_bytes() 540 541 rows = load_ambiguities() 542 543 assert len(rows) == 1 544 assert rows[0]["normalized_query"] == "sarah" 545 assert path.read_bytes() == before 546 547 548def test_serialization_failure_is_typed_and_preserves_store( 549 journal: Path, 550 monkeypatch: pytest.MonkeyPatch, 551) -> None: 552 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 553 scope = ResolutionScope.journal() 554 _record("Sarah", entities, scope=scope) 555 path = _ambiguity_file(journal) 556 before = path.read_bytes() 557 558 def fail_serialization(*args, **kwargs): 559 raise TypeError("injected serialization failure") 560 561 monkeypatch.setattr(ambiguities.json, "dumps", fail_serialization) 562 563 with pytest.raises(EntityAmbiguityError, match="cannot write"): 564 record_ambiguity_choice("Sarah", "sarah_lee", entities, scope=scope) 565 566 assert path.read_bytes() == before 567 568 569def test_strict_choice_read_oserror_is_typed_and_preserves_store( 570 journal: Path, 571 monkeypatch: pytest.MonkeyPatch, 572) -> None: 573 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 574 path = _ambiguity_file(journal) 575 path.parent.mkdir(parents=True) 576 path.write_text("sentinel\n", encoding="utf-8") 577 before = path.read_bytes() 578 579 def fail_read(*args, **kwargs): 580 raise PermissionError("injected read failure") 581 582 monkeypatch.setattr(ambiguities, "open", fail_read, raising=False) 583 584 with pytest.raises(EntityAmbiguityError, match="cannot read"): 585 _record("Sarah", entities) 586 587 assert path.read_bytes() == before 588 589 590def test_trust_lock_timeout_fails_before_ambiguity_mutation( 591 journal: Path, 592 monkeypatch: pytest.MonkeyPatch, 593) -> None: 594 def raise_timeout(path: Path): 595 raise LockTimeout(path, 0.01) 596 597 monkeypatch.setattr(entity_history, "hold_lock", raise_timeout) 598 before = _tree_snapshot(journal) 599 600 with pytest.raises(LockTimeout): 601 _record("Sarah", [_entity("Sarah Connor"), _entity("Sarah Lee")]) 602 603 assert _tree_snapshot(journal) == before 604 assert not _ambiguity_file(journal).exists() 605 606 607def test_per_invocation_memoization_keeps_occurrence_count_one( 608 journal: Path, 609) -> None: 610 entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 611 origin = _origin("participation", "one-talent-run") 612 613 for _ in range(5): 614 resolution = _record("Sarah", entities, origin=origin) 615 assert resolution.outcome == EntityResolutionOutcome.AMBIGUOUS 616 617 rows = load_ambiguities() 618 assert len(rows) == 1 619 assert rows[0]["occurrence_count"] == 1 620 assert len(rows[0]["origins"]) == 1 621 622 623def _function_calls_record_entity_resolution( 624 module_name: str, function_name: str 625) -> bool: 626 module = import_module(module_name) 627 tree = ast.parse(inspect.getsource(module)) 628 for node in ast.walk(tree): 629 if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): 630 continue 631 if node.name != function_name: 632 continue 633 for child in ast.walk(node): 634 if isinstance(child, ast.Call): 635 func = child.func 636 if isinstance(func, ast.Name) and func.id == "record_entity_resolution": 637 return True 638 if ( 639 isinstance(func, ast.Attribute) 640 and func.attr == "record_entity_resolution" 641 ): 642 return True 643 return False 644 645 646def test_audited_mutation_owners_use_record_entity_resolution() -> None: 647 owners = [ 648 ("solstone.talent.participation", "post_process"), 649 ("solstone.talent.schedule", "post_process"), 650 ("solstone.talent.story", "_resolve_entity_id"), 651 ("solstone.talent.speaker_attribution", "post_process"), 652 ("solstone.apps.activities.routes", "_resolve_participation_entity_ids"), 653 ("solstone.apps.entities.routes", "detect_entity_route"), 654 ("solstone.apps.entities.talent.entity_observer", "_clean_relation"), 655 ("solstone.apps.import.ingest", "ingest_entities"), 656 ("solstone.apps.speakers.attribution", "attribute_segment"), 657 ("solstone.apps.speakers.bootstrap", "bootstrap_voiceprints"), 658 ("solstone.apps.speakers.bootstrap", "merge_names"), 659 ("solstone.apps.speakers.bootstrap", "seed_from_imports"), 660 ("solstone.apps.speakers.discovery", "_plan_identify"), 661 ("solstone.think.entities.seeding", "seed_entities"), 662 ("solstone.think.merge", "_merge_entities"), 663 ] 664 665 missing = [ 666 f"{module_name}:{function_name}" 667 for module_name, function_name in owners 668 if not _function_calls_record_entity_resolution(module_name, function_name) 669 ] 670 assert missing == []