personal memory agent
0

Configure Feed

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

solstone / tests / test_matching.py
19 kB 512 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4"""Tests for entity matching and name variant resolution.""" 5 6import json 7 8from solstone.think.entities.matching import ( 9 MatchTier, 10 build_name_resolution_map, 11 find_matching_entity, 12 is_name_variant_match, 13 resolve_entity, 14 resolve_journal_entity, 15) 16 17 18def _entity(name, entity_id=None, aka=None): 19 """Helper to create entity dicts for testing.""" 20 eid = entity_id or name.lower().replace(" ", "_") 21 result = {"id": eid, "name": name} 22 if aka: 23 result["aka"] = aka 24 return result 25 26 27# --- Tier 1-3 regression tests --- 28 29 30class TestExistingTiers: 31 def test_exact_name_match(self): 32 entities = [_entity("Robert Johnson")] 33 assert ( 34 find_matching_entity("Robert Johnson", entities)["id"] == "robert_johnson" 35 ) 36 37 def test_exact_id_match(self): 38 entities = [_entity("Robert Johnson")] 39 assert ( 40 find_matching_entity("robert_johnson", entities)["id"] == "robert_johnson" 41 ) 42 43 def test_exact_aka_match(self): 44 entities = [_entity("Robert Johnson", aka=["Bob"])] 45 assert find_matching_entity("Bob", entities)["id"] == "robert_johnson" 46 47 def test_case_insensitive_match(self): 48 entities = [_entity("Robert Johnson")] 49 assert ( 50 find_matching_entity("robert johnson", entities)["id"] == "robert_johnson" 51 ) 52 53 def test_no_match_returns_none(self): 54 entities = [_entity("Robert Johnson")] 55 assert find_matching_entity("Unknown Person", entities) is None 56 57 def test_empty_inputs(self): 58 assert find_matching_entity("", []) is None 59 assert find_matching_entity("test", []) is None 60 assert find_matching_entity("", [_entity("Test")]) is None 61 62 63# --- Enhancement 1: Bidirectional first-word match --- 64 65 66class TestBidirectionalFirstWord: 67 def test_short_to_long(self): 68 """Original tier 4: detected name IS a first word of an entity.""" 69 entities = [_entity("Javier Garcia")] 70 assert find_matching_entity("Javier", entities)["id"] == "javier_garcia" 71 72 def test_long_to_short(self): 73 """New: detected name's first word matches an entity.""" 74 entities = [_entity("Javier")] 75 assert find_matching_entity("Javier Garcia", entities)["id"] == "javier" 76 77 def test_order_independent(self): 78 """Both directions work regardless of which entity exists.""" 79 entities = [_entity("Javier")] 80 assert find_matching_entity("Javier Garcia", entities)["id"] == "javier" 81 82 entities = [_entity("Javier Garcia")] 83 assert find_matching_entity("Javier", entities)["id"] == "javier_garcia" 84 85 def test_ambiguous_first_word_rejected(self): 86 """Multiple entities with same first word: no match.""" 87 entities = [_entity("Javier Garcia"), _entity("Javier Rodriguez")] 88 assert find_matching_entity("Javier", entities) is None 89 90 def test_ambiguous_first_word_long_to_short(self): 91 """Multiple entities with same first word: long→short also rejected.""" 92 entities = [_entity("Javier"), _entity("Javier Rodriguez")] 93 assert find_matching_entity("Javier Garcia", entities) is None 94 95 def test_short_name_min_length(self): 96 """First word must be >= 3 chars.""" 97 entities = [_entity("Li Wei")] 98 assert find_matching_entity("Li", entities) is None 99 100 101# --- Enhancement 2: Token-subset match --- 102 103 104class TestTokenSubset: 105 def test_subset_match_short_in_long(self): 106 """Shorter name's tokens are a subset of longer entity's tokens.""" 107 entities = [_entity("Josh Jones Dilworth")] 108 assert ( 109 find_matching_entity("Jones Dilworth", entities)["id"] 110 == "josh_jones_dilworth" 111 ) 112 113 def test_subset_match_long_detected(self): 114 """Detected name has more tokens than entity.""" 115 entities = [_entity("Jones Dilworth")] 116 assert ( 117 find_matching_entity("Josh Jones Dilworth", entities)["id"] 118 == "jones_dilworth" 119 ) 120 121 def test_single_token_not_subset(self): 122 """Single-token names don't trigger subset match (min 2 tokens).""" 123 entities = [_entity("Josh Jones Dilworth")] 124 # "Dilworth" is 1 token — not first word, not a 2-token subset 125 assert find_matching_entity("Dilworth", entities) is None 126 127 def test_ambiguous_subset_rejected(self): 128 """Multiple entities match token-subset: no match.""" 129 entities = [ 130 _entity("Josh Jones Dilworth"), 131 _entity("Mary Jones Dilworth"), 132 ] 133 assert find_matching_entity("Jones Dilworth", entities) is None 134 135 def test_subset_both_directions(self): 136 """Token-subset works regardless of which name is in entities.""" 137 entities = [_entity("Josh Jones Dilworth")] 138 assert ( 139 find_matching_entity("Jones Dilworth", entities)["id"] 140 == "josh_jones_dilworth" 141 ) 142 143 entities = [_entity("Jones Dilworth")] 144 assert ( 145 find_matching_entity("Josh Jones Dilworth", entities)["id"] 146 == "jones_dilworth" 147 ) 148 149 150# --- Enhancement 3: Prefix-token match --- 151 152 153class TestPrefixToken: 154 def test_prefix_match_nickname(self): 155 """Nickname prefix matching (Chris → Christopher).""" 156 entities = [_entity("Christopher DeWolfe")] 157 assert ( 158 find_matching_entity("Chris DeWolfe", entities)["id"] 159 == "christopher_dewolfe" 160 ) 161 162 def test_prefix_match_reverse(self): 163 """Reverse direction: full name detected, nickname entity.""" 164 entities = [_entity("Chris DeWolfe")] 165 assert ( 166 find_matching_entity("Christopher DeWolfe", entities)["id"] 167 == "chris_dewolfe" 168 ) 169 170 def test_prefix_min_length(self): 171 """Prefix must be >= 4 chars.""" 172 entities = [_entity("Jonathan Smith")] 173 # "Jon" is only 3 chars, not a valid prefix 174 assert find_matching_entity("Jon Smith", entities) is None 175 176 def test_prefix_four_chars_matches(self): 177 """Exactly 4-char prefix works.""" 178 entities = [_entity("Jonathan Smith")] 179 assert find_matching_entity("Jona Smith", entities)["id"] == "jonathan_smith" 180 181 def test_ambiguous_prefix_rejected(self): 182 """Multiple entities match prefix-token: no match.""" 183 entities = [ 184 _entity("Christopher DeWolfe"), 185 _entity("Christine DeWolfe"), 186 ] 187 assert find_matching_entity("Chris DeWolfe", entities) is None 188 189 def test_different_token_count_no_prefix(self): 190 """Different token counts don't trigger prefix match.""" 191 entities = [_entity("Christopher James DeWolfe")] 192 assert find_matching_entity("Chris DeWolfe", entities) is None 193 194 195# --- Production duplicate cases --- 196 197 198class TestProductionDuplicates: 199 """Verify the three production duplicate pairs that motivated this spec.""" 200 201 def test_chris_dewolfe(self): 202 """Chris DeWolfe ↔ Christopher DeWolfe (prefix-token match).""" 203 entities = [_entity("Christopher DeWolfe")] 204 assert ( 205 find_matching_entity("Chris DeWolfe", entities)["id"] 206 == "christopher_dewolfe" 207 ) 208 209 entities = [_entity("Chris DeWolfe")] 210 assert ( 211 find_matching_entity("Christopher DeWolfe", entities)["id"] 212 == "chris_dewolfe" 213 ) 214 215 def test_javier_garcia(self): 216 """Javier ↔ Javier Garcia (bidirectional first-word match).""" 217 entities = [_entity("Javier Garcia")] 218 assert find_matching_entity("Javier", entities)["id"] == "javier_garcia" 219 220 entities = [_entity("Javier")] 221 assert find_matching_entity("Javier Garcia", entities)["id"] == "javier" 222 223 def test_jones_dilworth(self): 224 """Jones Dilworth ↔ Josh Jones Dilworth (token-subset match).""" 225 entities = [_entity("Josh Jones Dilworth")] 226 assert ( 227 find_matching_entity("Jones Dilworth", entities)["id"] 228 == "josh_jones_dilworth" 229 ) 230 231 entities = [_entity("Jones Dilworth")] 232 assert ( 233 find_matching_entity("Josh Jones Dilworth", entities)["id"] 234 == "jones_dilworth" 235 ) 236 237 238# --- build_name_resolution_map --- 239 240 241class TestBuildNameResolutionMap: 242 def test_bidirectional_first_word(self): 243 entities = [_entity("Javier Garcia")] 244 result = build_name_resolution_map(["Javier"], entities) 245 assert result["Javier"] == "javier_garcia" 246 247 def test_long_to_short_first_word(self): 248 entities = [_entity("Javier")] 249 result = build_name_resolution_map(["Javier Garcia"], entities) 250 assert result["Javier Garcia"] == "javier" 251 252 def test_token_subset(self): 253 entities = [_entity("Josh Jones Dilworth")] 254 result = build_name_resolution_map(["Jones Dilworth"], entities) 255 assert result["Jones Dilworth"] == "josh_jones_dilworth" 256 257 def test_prefix_token(self): 258 entities = [_entity("Christopher DeWolfe")] 259 result = build_name_resolution_map(["Chris DeWolfe"], entities) 260 assert result["Chris DeWolfe"] == "christopher_dewolfe" 261 262 def test_ambiguous_subset_skipped(self): 263 entities = [ 264 _entity("Josh Jones Dilworth"), 265 _entity("Mary Jones Dilworth"), 266 ] 267 result = build_name_resolution_map(["Jones Dilworth"], entities) 268 assert "Jones Dilworth" not in result 269 270 def test_all_three_production_cases(self): 271 entities = [ 272 _entity("Christopher DeWolfe"), 273 _entity("Javier Garcia"), 274 _entity("Josh Jones Dilworth"), 275 ] 276 result = build_name_resolution_map( 277 ["Chris DeWolfe", "Javier", "Jones Dilworth"], entities 278 ) 279 assert result["Chris DeWolfe"] == "christopher_dewolfe" 280 assert result["Javier"] == "javier_garcia" 281 assert result["Jones Dilworth"] == "josh_jones_dilworth" 282 283 284# --- is_name_variant_match --- 285 286 287class TestIsNameVariantMatch: 288 def test_first_word_match(self): 289 assert is_name_variant_match("Javier", "Javier Garcia") is True 290 assert is_name_variant_match("Javier Garcia", "Javier") is True 291 292 def test_token_subset_match(self): 293 assert is_name_variant_match("Jones Dilworth", "Josh Jones Dilworth") is True 294 assert is_name_variant_match("Josh Jones Dilworth", "Jones Dilworth") is True 295 296 def test_prefix_token_match(self): 297 assert is_name_variant_match("Chris DeWolfe", "Christopher DeWolfe") is True 298 assert is_name_variant_match("Christopher DeWolfe", "Chris DeWolfe") is True 299 300 def test_no_match(self): 301 assert is_name_variant_match("Alice Smith", "Bob Jones") is False 302 303 def test_empty_strings(self): 304 assert is_name_variant_match("", "Test") is False 305 assert is_name_variant_match("Test", "") is False 306 307 def test_single_token_first_word(self): 308 """Single tokens match via first-word when they ARE the first word.""" 309 assert is_name_variant_match("Jones", "Jones Dilworth") is True 310 311 def test_single_token_not_first_word(self): 312 """Single tokens that aren't the first word don't match.""" 313 assert is_name_variant_match("Dilworth", "Jones Dilworth") is False 314 315 316# --- MatchResult and confidence tiers --- 317 318 319class TestMatchResult: 320 """Verify MatchResult is backward-compatible with dict usage.""" 321 322 def test_is_dict(self): 323 entities = [_entity("Alice Johnson")] 324 result = find_matching_entity("Alice Johnson", entities) 325 assert isinstance(result, dict) 326 327 def test_subscript_access(self): 328 entities = [_entity("Alice Johnson")] 329 result = find_matching_entity("Alice Johnson", entities) 330 assert result["id"] == "alice_johnson" 331 assert result["name"] == "Alice Johnson" 332 333 def test_get_access(self): 334 entities = [_entity("Alice Johnson")] 335 result = find_matching_entity("Alice Johnson", entities) 336 assert result.get("name") == "Alice Johnson" 337 assert result.get("missing") is None 338 339 def test_truthiness(self): 340 entities = [_entity("Alice Johnson")] 341 result = find_matching_entity("Alice Johnson", entities) 342 assert result # truthy 343 assert find_matching_entity("Nobody", entities) is None 344 345 def test_none_is_none(self): 346 """No match still returns None, not an empty MatchResult.""" 347 entities = [_entity("Alice Johnson")] 348 result = find_matching_entity("Nobody", entities) 349 assert result is None 350 351 352class TestMatchTiers: 353 """Verify each tier returns the correct MatchTier value.""" 354 355 def test_exact_name_tier(self): 356 entities = [_entity("Robert Johnson")] 357 result = find_matching_entity("Robert Johnson", entities) 358 assert result.tier == MatchTier.EXACT 359 360 def test_exact_id_tier(self): 361 entities = [_entity("Robert Johnson")] 362 result = find_matching_entity("robert_johnson", entities) 363 assert result.tier == MatchTier.EXACT 364 365 def test_exact_aka_tier(self): 366 entities = [_entity("Robert Johnson", aka=["Bob"])] 367 result = find_matching_entity("Bob", entities) 368 assert result.tier == MatchTier.EXACT 369 370 def test_case_insensitive_tier(self): 371 entities = [_entity("Robert Johnson")] 372 result = find_matching_entity("robert johnson", entities) 373 assert result.tier == MatchTier.CASE_INSENSITIVE 374 375 def test_email_tier(self): 376 entities = [{"id": "alice", "name": "Alice", "emails": ["alice@example.com"]}] 377 result = find_matching_entity("alice@example.com", entities) 378 assert result.tier == MatchTier.EMAIL 379 380 def test_slug_tier(self): 381 """Slugified query matching entity id.""" 382 entities = [{"id": "robert_johnson", "name": "Robert Johnson"}] 383 result = find_matching_entity("Robert Johnson", entities) 384 # "Robert Johnson" exact-matches the name, so it's tier 1 385 assert result.tier == MatchTier.EXACT 386 # Use a slug-form query that doesn't exact-match but slug-matches 387 entities2 = [{"id": "some_custom_id", "name": "Some Name"}] 388 result2 = find_matching_entity("Some Name", entities2) 389 # This exact-matches the name 390 assert result2.tier == MatchTier.EXACT 391 392 def test_first_word_tier(self): 393 entities = [_entity("Javier Garcia")] 394 result = find_matching_entity("Javier", entities) 395 assert result.tier == MatchTier.FIRST_WORD 396 397 def test_first_word_long_to_short_tier(self): 398 entities = [_entity("Javier")] 399 result = find_matching_entity("Javier Garcia", entities) 400 assert result.tier == MatchTier.FIRST_WORD 401 402 def test_token_subset_tier(self): 403 entities = [_entity("Josh Jones Dilworth")] 404 result = find_matching_entity("Jones Dilworth", entities) 405 assert result.tier == MatchTier.TOKEN_SUBSET 406 407 def test_prefix_tier(self): 408 entities = [_entity("Christopher DeWolfe")] 409 result = find_matching_entity("Chris DeWolfe", entities) 410 assert result.tier == MatchTier.PREFIX 411 412 def test_fuzzy_tier(self): 413 entities = [_entity("Christopher DeWolfe")] 414 # Close enough for fuzzy but not an exact/prefix match 415 result = find_matching_entity("Christoph DeWolffe", entities) 416 if result: # rapidfuzz may not be installed 417 assert result.tier == MatchTier.FUZZY 418 419 420class TestHighConfidence: 421 """Verify the is_high_confidence boundary between tiers 1-4 and 5+.""" 422 423 def test_exact_is_high(self): 424 entities = [_entity("Alice Johnson")] 425 result = find_matching_entity("Alice Johnson", entities) 426 assert result.is_high_confidence is True 427 428 def test_case_insensitive_is_high(self): 429 entities = [_entity("Alice Johnson")] 430 result = find_matching_entity("alice johnson", entities) 431 assert result.is_high_confidence is True 432 433 def test_email_is_high(self): 434 entities = [{"id": "alice", "name": "Alice", "emails": ["a@b.com"]}] 435 result = find_matching_entity("a@b.com", entities) 436 assert result.is_high_confidence is True 437 438 def test_first_word_is_low(self): 439 entities = [_entity("Javier Garcia")] 440 result = find_matching_entity("Javier", entities) 441 assert result.is_high_confidence is False 442 443 def test_token_subset_is_low(self): 444 entities = [_entity("Josh Jones Dilworth")] 445 result = find_matching_entity("Jones Dilworth", entities) 446 assert result.is_high_confidence is False 447 448 def test_prefix_is_low(self): 449 entities = [_entity("Christopher DeWolfe")] 450 result = find_matching_entity("Chris DeWolfe", entities) 451 assert result.is_high_confidence is False 452 453 def test_tier_comparison(self): 454 """MatchTier is an IntEnum — callers can compare tiers numerically.""" 455 assert MatchTier.EXACT < MatchTier.FUZZY 456 assert MatchTier.SLUG <= MatchTier.SLUG 457 assert MatchTier.FIRST_WORD > MatchTier.SLUG 458 459 460class TestResolveJournalEntity: 461 def test_resolves_journal_entity_by_name(self): 462 entity, candidates = resolve_journal_entity("Juliet Capulet") 463 464 assert candidates is None 465 assert entity is not None 466 assert entity["id"] == "juliet_capulet" 467 468 def test_returns_candidates_on_miss(self): 469 entity, candidates = resolve_journal_entity("Jliet") 470 471 assert entity is None 472 assert candidates 473 assert any(candidate["id"] == "juliet_capulet" for candidate in candidates) 474 475 def test_returns_empty_candidates_on_full_miss(self): 476 entity, candidates = resolve_journal_entity("zzzznotreal") 477 478 assert entity is None 479 assert candidates == [] 480 481 482class TestResolveEntity: 483 def test_returns_closest_candidates_on_miss(self, tmp_path, monkeypatch): 484 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 485 entities = [ 486 ("alice_johnson", "Alice Johnson"), 487 ("benvolio_montague", "Benvolio Montague"), 488 ("charlie_brown", "Charlie Brown"), 489 ("juliet_capulet", "Juliet Capulet"), 490 ("mercutio", "Mercutio"), 491 ("romeo_montague", "Romeo Montague"), 492 ] 493 for entity_id, name in entities: 494 entity_dir = tmp_path / "entities" / entity_id 495 entity_dir.mkdir(parents=True) 496 (entity_dir / "entity.json").write_text( 497 json.dumps({"id": entity_id, "name": name, "type": "Person"}) 498 ) 499 relationship_dir = tmp_path / "facets" / "work" / "entities" / entity_id 500 relationship_dir.mkdir(parents=True) 501 (relationship_dir / "entity.json").write_text( 502 json.dumps({"entity_id": entity_id, "description": name}) 503 ) 504 505 entity, candidates = resolve_entity("work", "Jliet") 506 507 assert entity is None 508 assert [candidate["id"] for candidate in candidates] == [ 509 "juliet_capulet", 510 "alice_johnson", 511 "charlie_brown", 512 ]