personal memory agent
0

Configure Feed

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

Fix resolved ambiguity fail-closed guarantees

Consult persisted ambiguity choices before treating an empty resolution set as no-match so stale or out-of-scope choices fail loudly instead of letting create-on-miss lanes mint duplicates.

Keep ambiguity readers read-only, share tier-5 first-word predicate logic, and harden coverage for fail-closed journal snapshots, indexer rescan purity, and speaker discovery candidate side effects.

+173 -23
+28
solstone/apps/speakers/tests/test_discovery.py
··· 288 288 289 289 scan_result = discover_unknown_speakers() 290 290 cluster_id = scan_result["clusters"][0]["cluster_id"] 291 + candidate_path = env.journal / "awareness" / "speaker_candidates.json" 292 + candidate_path.parent.mkdir(parents=True, exist_ok=True) 293 + candidate_path.write_text( 294 + json.dumps( 295 + { 296 + "next_id": 2, 297 + "candidates": [ 298 + { 299 + "cand_id": 1, 300 + "centroid": embeddings[0].astype(float).tolist(), 301 + "n_segments": 2, 302 + "n_intervals": 10, 303 + "total_duration_s": 60.0, 304 + "source_segments": [], 305 + "confirmed_entity": None, 306 + "status": "pending", 307 + } 308 + ], 309 + }, 310 + indent=2, 311 + sort_keys=True, 312 + ) 313 + + "\n", 314 + encoding="utf-8", 315 + ) 316 + candidate_before = candidate_path.read_bytes() 317 + 291 318 result = identify_cluster(cluster_id, "Sarah") 292 319 293 320 assert result["status"] == "ambiguous" ··· 312 339 ) 313 340 assert not labels_path.exists() 314 341 assert not corrections_path.exists() 342 + assert candidate_path.read_bytes() == candidate_before 315 343 assert load_ambiguities()[0]["normalized_query"] == "sarah" 316 344 317 345 record_ambiguity_choice(
+6 -7
solstone/think/entities/ambiguities.py
··· 127 127 128 128 129 129 def ambiguities_dir() -> Path: 130 - """Return the entity ambiguities directory, creating it if needed.""" 131 - path = Path(get_journal()) / "entities" 132 - path.mkdir(parents=True, exist_ok=True) 133 - return path 130 + """Return the entity ambiguities directory path.""" 131 + return Path(get_journal()) / "entities" 134 132 135 133 136 134 def ambiguities_path() -> Path: ··· 192 190 fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]], 193 191 ) -> list[dict[str, Any]]: 194 192 """Apply a locked read-modify-write cycle to ambiguities.jsonl.""" 195 - with hold_lock(ambiguities_path()): 196 - rows = load_ambiguities() 193 + path = ambiguities_path() 194 + with hold_lock(path): 195 + rows = _load_jsonl_rows(path) 197 196 new_rows = fn(rows) 198 - save_ambiguities(new_rows) 197 + _save_jsonl_rows(path, new_rows) 199 198 return new_rows 200 199 201 200
+38 -15
solstone/think/entities/matching.py
··· 127 127 ) 128 128 129 129 130 + def _first_word_key(name: str) -> str: 131 + """Return a matchable first-word key for tier-5 matching.""" 132 + first_word = name.split()[0].lower() if name else "" 133 + if len(first_word) < 3: 134 + return "" 135 + return first_word 136 + 137 + 138 + def _first_word_match(query_lower: str, entity_name: str) -> bool: 139 + """True when a query equals an entity name's matchable first word.""" 140 + return len(query_lower) >= 3 and _first_word_key(entity_name) == query_lower 141 + 142 + 143 + def _single_token_first_word_match(query_first: str, entity_name: str) -> bool: 144 + """True when a query first word matches a single-token entity name.""" 145 + return ( 146 + bool(entity_name) 147 + and len(entity_name.split()) == 1 148 + and _first_word_match(query_first, entity_name) 149 + ) 150 + 151 + 130 152 def is_name_variant_match(name_a: str, name_b: str) -> bool: 131 153 """Check if two names are plausible variants of each other. 132 154 ··· 304 326 email_map[email.lower()] = entity 305 327 306 328 # Tier 5: First word 307 - first_word = name.split()[0].lower() if name else "" 308 - if first_word and len(first_word) >= 3: 329 + first_word = _first_word_key(name) 330 + if first_word: 309 331 if first_word not in first_word_map: 310 332 first_word_map[first_word] = [] 311 333 first_word_map[first_word].append(entity) ··· 352 374 # "Javier Garcia" → "Javier"). Reject when both names are 353 375 # multi-token and merely share a first word (e.g., "Person B" 354 376 # should NOT match "Person A"). 355 - if len(matched_name.split()) == 1: 377 + if _single_token_first_word_match(detected_first, str(matched_name)): 356 378 return MatchResult(fw_matches[0], MatchTier.FIRST_WORD) 357 379 358 380 # Tier 6: Token-subset match (unambiguous only) ··· 699 721 700 722 # Tier 5: First-word match without the legacy uniqueness guard. 701 723 if len(query) >= 3: 702 - first_word_matches: list[EntityDict] = [] 703 - for entity in entities: 704 - name = str(entity.get("name") or "") 705 - if not name: 706 - continue 707 - first_word = name.split()[0].lower() 708 - if len(first_word) >= 3 and first_word == query_lower: 709 - first_word_matches.append(entity) 724 + first_word_matches = [ 725 + entity 726 + for entity in entities 727 + if _first_word_match(query_lower, str(entity.get("name") or "")) 728 + ] 710 729 if first_word_matches: 711 730 return MatchTier.FIRST_WORD, _rank_resolution_candidates( 712 731 query, MatchTier.FIRST_WORD, first_word_matches ··· 717 736 long_to_short_matches = [ 718 737 entity 719 738 for entity in entities 720 - if entity.get("name") 721 - and len(str(entity.get("name")).split()) == 1 722 - and str(entity.get("name")).split()[0].lower() == query_first 739 + if _single_token_first_word_match( 740 + query_first, 741 + str(entity.get("name") or ""), 742 + ) 723 743 ] 724 744 if long_to_short_matches: 725 745 return MatchTier.FIRST_WORD, _rank_resolution_candidates( ··· 792 812 Low-confidence matches record an ambiguity row before returning, so callers 793 813 can reuse their existing unresolved path without risking a later write. 794 814 """ 795 - if not query or not query.strip() or not entities: 815 + if not query or not query.strip(): 796 816 return EntityResolution(outcome=EntityResolutionOutcome.NO_MATCH) 797 817 798 818 normalized_query = normalize_resolution_query(query) ··· 815 835 outcome=EntityResolutionOutcome.RESOLVED, 816 836 entity=entity, 817 837 ) 838 + 839 + if not entities: 840 + return EntityResolution(outcome=EntityResolutionOutcome.NO_MATCH) 818 841 819 842 match = find_matching_entity(match_query, entities, fuzzy_threshold) 820 843 if match and match.is_high_confidence:
+32
tests/test_entity_ambiguities.py
··· 6 6 from __future__ import annotations 7 7 8 8 import ast 9 + import hashlib 9 10 import inspect 10 11 import json 11 12 from importlib import import_module ··· 59 60 60 61 def _ambiguity_file(journal: Path) -> Path: 61 62 return journal / "entities" / "ambiguities.jsonl" 63 + 64 + 65 + def _tree_snapshot(root: Path) -> dict[str, str]: 66 + snapshot: dict[str, str] = {} 67 + for path in sorted(root.rglob("*")): 68 + if path.name.endswith(".lock"): 69 + continue 70 + rel = path.relative_to(root).as_posix() 71 + if path.is_dir(): 72 + snapshot[f"{rel}/"] = "<dir>" 73 + elif path.is_file(): 74 + snapshot[rel] = hashlib.sha256(path.read_bytes()).hexdigest() 75 + return snapshot 62 76 63 77 64 78 def _record( ··· 350 364 ) 351 365 352 366 367 + def test_resolved_choice_with_empty_entity_set_fails_loudly( 368 + journal: Path, 369 + ) -> None: 370 + entities = [_entity("Sarah Connor"), _entity("Sarah Lee")] 371 + scope = ResolutionScope.journal() 372 + 373 + _record("Sarah", entities, scope=scope) 374 + record_ambiguity_choice("Sarah", "sarah_lee", entities, scope=scope) 375 + 376 + with pytest.raises(EntityAmbiguityError): 377 + resolution = _record("Sarah", [], scope=scope) 378 + assert resolution.outcome != EntityResolutionOutcome.NO_MATCH 379 + 380 + assert not (journal / "entities" / "sarah" / "entity.json").exists() 381 + 382 + 353 383 def test_lock_timeout_during_persistence_fails_closed( 354 384 journal: Path, 355 385 monkeypatch: pytest.MonkeyPatch, ··· 358 388 raise LockTimeout(path, 0.01) 359 389 360 390 monkeypatch.setattr(ambiguities, "hold_lock", raise_timeout) 391 + before = _tree_snapshot(journal) 361 392 362 393 with pytest.raises(LockTimeout): 363 394 _record("Sarah", [_entity("Sarah Connor"), _entity("Sarah Lee")]) 364 395 396 + assert _tree_snapshot(journal) == before 365 397 assert not _ambiguity_file(journal).exists() 366 398 367 399
+68
tests/test_journal_index.py
··· 2119 2119 assert snap_before == snap_between == snap_after, ( 2120 2120 "scan_journal() mutated journal/entities/ — see docs/coding-standards.md § L6" 2121 2121 ) 2122 + 2123 + 2124 + def test_scan_journal_rescan_does_not_record_entity_ambiguities(journal_copy): 2125 + """Index rescans must not create domain ambiguity rows for low-confidence names.""" 2126 + from solstone.think.entities import load_ambiguities 2127 + from solstone.think.indexer.journal import scan_journal 2128 + 2129 + journal_path = Path(journal_copy) 2130 + for entity_id, name in [ 2131 + ("sarah_connor", "Sarah Connor"), 2132 + ("sarah_lee", "Sarah Lee"), 2133 + ]: 2134 + entity_dir = journal_path / "entities" / entity_id 2135 + entity_dir.mkdir(parents=True, exist_ok=True) 2136 + (entity_dir / "entity.json").write_text( 2137 + json.dumps( 2138 + { 2139 + "id": entity_id, 2140 + "name": name, 2141 + "type": "Person", 2142 + "created_at": "2026-01-01T00:00:00Z", 2143 + "updated_at": "2026-01-01T00:00:00Z", 2144 + } 2145 + ), 2146 + encoding="utf-8", 2147 + ) 2148 + 2149 + today = datetime.now().strftime("%Y%m%d") 2150 + segment_dir = ( 2151 + journal_path / "chronicle" / today / "default" / "121000_300" / "talents" 2152 + ) 2153 + segment_dir.mkdir(parents=True) 2154 + (segment_dir / "sense.json").write_text( 2155 + json.dumps( 2156 + { 2157 + "density": "active", 2158 + "content_type": "meeting", 2159 + "activity_summary": "Sarah discussed a low-confidence indexer case.", 2160 + "entities": [ 2161 + { 2162 + "name": "Sarah", 2163 + "type": "Person", 2164 + "role": "mentioned", 2165 + "source": "screen", 2166 + "context": "Ambiguous against Sarah Connor and Sarah Lee.", 2167 + } 2168 + ], 2169 + "facets": [ 2170 + {"facet": "work", "activity": "search indexing", "level": "high"} 2171 + ], 2172 + "speculative_facet": None, 2173 + "meeting_detected": False, 2174 + "speakers": [], 2175 + "recommend": {"screen_record": False, "speaker_attribution": False}, 2176 + "emotional_register": "focused", 2177 + } 2178 + ), 2179 + encoding="utf-8", 2180 + ) 2181 + 2182 + ambiguity_path = journal_path / "entities" / "ambiguities.jsonl" 2183 + assert not ambiguity_path.exists() 2184 + 2185 + scan_journal(str(journal_path), full=True) 2186 + scan_journal(str(journal_path), full=True) 2187 + 2188 + assert not ambiguity_path.exists() 2189 + assert load_ambiguities() == []
+1 -1
tests/test_schedule_schema.py
··· 171 171 172 172 173 173 def test_schedule_participation_entry_diverges_from_shared_fragment(): 174 - """Schedule omits entity_id because the hook fills it via find_matching_entity.""" 174 + """Schedule omits entity_id because the hook fills it via record_entity_resolution.""" 175 175 schedule_schema = _load_schedule_schema() 176 176 fragment = _load_json(PARTICIPATION_ENTRY_SCHEMA_PATH) 177 177