Coverage for astrocyte/pipeline/_orchestrator_common.py: 83%

82 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-10-29 22:10 +0000

1from __future__ import annotations 

2 

3import json 

4import logging 

5import re 

6from typing import TYPE_CHECKING, Any 

7 

8from astrocyte.types import ( 

9 Entity, 

10 MemoryHit, 

11) 

12 

13if TYPE_CHECKING: 

14 from astrocyte.mip.schema import PipelineSpec 

15 

16 

17 

18_logger = logging.getLogger("astrocyte.mip") 

19 

20 

21def _warn_on_version_drift( 

22 bank_pipeline: PipelineSpec | None, 

23 hits: list[MemoryHit], 

24 bank_id: str, 

25) -> None: 

26 """Emit a single warning when retrieved hits were retained under a different MIP version. 

27 

28 Soft signal — does not affect recall results. Hits without a persisted 

29 ``_mip.pipeline_version`` are ignored (they were retained before MIP, by a 

30 rule with no version, or by a different rule). 

31 """ 

32 if bank_pipeline is None or bank_pipeline.version is None: 

33 return 

34 current_version = int(bank_pipeline.version) 

35 seen: set[int] = set() 

36 for hit in hits: 

37 if not hit.metadata: 

38 continue 

39 v = hit.metadata.get("_mip.pipeline_version") 

40 if v is None: 

41 continue 

42 try: 

43 v_int = int(v) 

44 except (TypeError, ValueError): 

45 continue 

46 if v_int != current_version: 

47 seen.add(v_int) 

48 if seen: 

49 _logger.warning( 

50 "MIP pipeline version drift in bank %r: current=%d, hits retained under versions %s. " 

51 "Consider re-indexing or accepting the drift.", 

52 bank_id, 

53 current_version, 

54 sorted(seen), 

55 ) 

56 

57 

58def _abstention_floor_for_skepticism(skepticism: int, base_floor: float) -> float: 

59 """Scale the configured abstention floor by a bank/call's 

60 disposition skepticism (1–5). 

61 

62 Same primitive Hindsight uses for per-bank disposition behavior: 

63 a single deployment can serve adversarial-resistant agents AND 

64 trust-the-model assistants without forking the YAML config. The 

65 bench harnesses use this to express "LME wants no abstention, 

66 LoCoMo wants aggressive abstention" via per-call ``dispositions``, 

67 not split configs. 

68 

69 Mapping (linear around the legacy default of skepticism=3): 

70 

71 - ``skepticism=1`` → ``0.0`` — never abstain. An "answer-everything" 

72 assistant. Replicates the legacy ``abstention_enabled=False`` knob. 

73 - ``skepticism=2`` → ``base_floor * 0.5`` — trusting, abstain only 

74 on extremely weak retrieval. 

75 - ``skepticism=3`` → ``base_floor`` — legacy default. Replicates 

76 the legacy ``abstention_enabled=True`` behaviour. 

77 - ``skepticism=4`` → ``base_floor * 1.5`` — moderately skeptical. 

78 - ``skepticism=5`` → ``base_floor * 2.0`` (capped at 1.0) — 

79 aggressive abstention. Use for adversarial-bucket evaluation 

80 where false answers cost more than abstentions. 

81 """ 

82 if skepticism <= 1: 

83 return 0.0 

84 return min(1.0, base_floor * 0.5 * (skepticism - 1)) 

85 

86 

87def _build_cooccurrence_pairs( 

88 entity_ids: list[str], 

89 max_entities: int, 

90) -> list[tuple[str, str]]: 

91 """Return the (a, b) pairs to create ``co_occurs`` links for. 

92 

93 Caps the input at ``max_entities`` (taking the head of the list, 

94 which preserves extraction order — typically tracks prominence in 

95 the source text), then emits the all-pairs Cartesian product over 

96 that subset. ``C(K,2)`` pairs at most. Returns ``[]`` for trivial 

97 inputs (≤1 entity) so the caller can skip the storage call entirely. 

98 

99 Extracted as a pure helper so the cap is testable without standing 

100 up the full retain pipeline. The 2026-05-06 LME profile measured 

101 the unbounded path at 34% of retain wall — capping here is the 

102 targeted fix. 

103 """ 

104 if len(entity_ids) <= 1: 

105 return [] 

106 k = max(2, max_entities) 

107 head = entity_ids[:k] 

108 return [(head[i], head[j]) for i in range(len(head)) for j in range(i + 1, len(head))] 

109 

110 

111def _resolve_skepticism_for_abstention( 

112 request_dispositions, 

113 fallback_enabled: bool, 

114) -> int: 

115 """Resolve effective skepticism for the abstention decision. 

116 

117 Precedence: 

118 1. ``request_dispositions.skepticism`` (per-call override) 

119 2. Backward compat: legacy 

120 ``adversarial_defense.abstention_enabled`` bool maps to 

121 skepticism=3 when True (legacy default behaviour) or 

122 skepticism=1 when False (never abstain). New code should pass 

123 ``dispositions`` per-call instead of toggling the bool. 

124 """ 

125 if request_dispositions is not None: 

126 return int(getattr(request_dispositions, "skepticism", 3)) 

127 return 3 if fallback_enabled else 1 

128 

129 

130def _source_ids_from_metadata(metadata: dict[str, Any] | None) -> list[str]: 

131 if not metadata: 

132 return [] 

133 raw = metadata.get("_obs_source_ids") or metadata.get("_wiki_source_ids") 

134 if raw is None: 

135 return [] 

136 if isinstance(raw, str): 

137 text = raw.strip() 

138 if not text: 

139 return [] 

140 if text.startswith("["): 

141 try: 

142 parsed = json.loads(text) 

143 except json.JSONDecodeError: 

144 parsed = [] 

145 if isinstance(parsed, list): 

146 return [str(x) for x in parsed if x] 

147 return [part.strip() for part in text.split(",") if part.strip()] 

148 if isinstance(raw, list): 

149 return [str(x) for x in raw if x] 

150 return [] 

151 

152 

153def _deterministic_names(text: str) -> set[str]: 

154 return {match.group(0).strip().lower() for match in re.finditer(r"\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)?\b", text or "")} 

155 

156 

157def _entities_from_metadata(metadata: dict[str, Any] | None) -> list[Entity]: 

158 """Build stable entities from structured retain metadata without an LLM call.""" 

159 

160 if not metadata: 

161 return [] 

162 names: set[str] = set() 

163 for key in ("locomo_persons", "locomo_speakers", "person"): 

164 value = metadata.get(key) 

165 if value is None: 

166 continue 

167 if isinstance(value, str): 

168 names.update(part.strip() for part in value.replace("|", ",").split(",") if part.strip()) 

169 elif isinstance(value, list): 

170 names.update(str(part).strip() for part in value if str(part).strip()) 

171 

172 entities: list[Entity] = [] 

173 for name in sorted(names): 

174 entity_id = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") 

175 if not entity_id: 

176 continue 

177 entities.append( 

178 Entity( 

179 id=f"person:{entity_id}", 

180 name=name, 

181 entity_type="PERSON", 

182 aliases=[], 

183 metadata={"source": "retain_metadata"}, 

184 ) 

185 ) 

186 return entities