personal memory agent
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 ]