Coverage for astrocyte/pipeline/pipeline_config.py: 97%

89 statements  

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

1"""Feature-flag configuration for :class:`PipelineOrchestrator`. 

2 

3Historically the orchestrator was built with base dependencies and *then* 

4``Astrocyte.set_pipeline`` reached in and mutated ~30 feature-flag attributes 

5one by one from ``AstrocyteConfig`` blocks — a two-phase construction where a 

6foreign class finished wiring the orchestrator by poking its internals. 

7 

8``PipelineConfig`` collapses that translation into one place. Build it once 

9with :meth:`PipelineConfig.from_config` and hand it to 

10:meth:`PipelineOrchestrator.apply_config`; the orchestrator no longer has its 

11configuration assembled by an outside method. The flag *storage* still lives on 

12the orchestrator (so the 3k-line hot paths read ``self.<flag>`` unchanged and 

13direct-construction tests keep their constructor defaults), but the *derivation* 

14of those flags from config now has a single, testable owner. 

15""" 

16 

17from __future__ import annotations 

18 

19import os 

20from dataclasses import dataclass, field 

21from typing import TYPE_CHECKING, Any 

22 

23if TYPE_CHECKING: 

24 from astrocyte.config import AstrocyteConfig, DedupConfig, ExtractionProfileConfig, RecallAuthorityConfig 

25 from astrocyte.pipeline.agentic_reflect import AgenticReflectParams 

26 from astrocyte.pipeline.cross_encoder_rerank import CrossEncoderProtocol 

27 from astrocyte.pipeline.link_expansion import LinkExpansionParams 

28 

29 

30def _temporal_expansion_flag(config_default: bool) -> bool: 

31 """Resolve query-analyzer temporal expansion, honouring the env override. 

32 

33 ``ASTROCYTE_M18_ENABLE_TEMPORAL_EXPANSION`` gives bench-time control without 

34 a code change; otherwise the per-bank config value wins. 

35 """ 

36 env = os.environ.get("ASTROCYTE_M18_ENABLE_TEMPORAL_EXPANSION", "").lower() 

37 if env in ("1", "true", "yes"): 

38 return True 

39 if env in ("0", "false", "no"): 

40 return False 

41 return config_default 

42 

43 

44@dataclass(frozen=True) 

45class PipelineConfig: 

46 """Everything ``set_pipeline`` used to mutate onto the orchestrator, resolved once. 

47 

48 Field names mirror the orchestrator attributes they populate, so 

49 :meth:`PipelineOrchestrator.apply_config` is a flat, obviously-correct 

50 assignment with no per-field translation. 

51 """ 

52 

53 # Extraction / authority. 

54 extraction_profiles: dict[str, ExtractionProfileConfig] | None 

55 recall_authority: RecallAuthorityConfig | None 

56 

57 # Cross-encoder rerank (Hindsight parity). ``cross_encoder`` is a loaded 

58 # model handle or ``None`` (heuristic fallback). 

59 cross_encoder: CrossEncoderProtocol | None 

60 cross_encoder_top_k: int 

61 

62 # Causal-link extraction at retain time. 

63 causal_links_enabled: bool 

64 causal_max_pairs_per_memory: int 

65 causal_min_confidence: float 

66 

67 # Semantic-kNN graph at retain time (C3a). 

68 semantic_link_graph_enabled: bool 

69 semantic_link_graph_top_k: int 

70 semantic_link_graph_threshold: float 

71 

72 # Structured fact extraction at retain time. 

73 structured_fact_extraction_enabled: bool 

74 structured_fact_extraction_max_facts: int 

75 structured_fact_extraction_mode: str 

76 structured_fact_extraction_chunk_strategy: str 

77 structured_fact_extraction_chunk_max_size: int | None 

78 structured_fact_extraction_parallel_chunks: bool 

79 structured_fact_extraction_parallel_chunks_max_concurrency: int 

80 

81 # Entity co-occurrence link cap. 

82 entity_cooccurrence_enabled: bool 

83 entity_cooccurrence_max_entities: int 

84 

85 # Query analyzer (temporal constraint extraction at recall time). 

86 query_analyzer_enabled: bool 

87 query_analyzer_allow_llm_fallback: bool 

88 query_analyzer_enable_temporal_expansion: bool 

89 

90 # Link expansion (C3) — ``None`` skips the post-fusion 3-signal expansion. 

91 link_expansion_params: LinkExpansionParams | None 

92 

93 # M9 BM25-IDF keyword strategy. 

94 bm25_idf_enabled: bool 

95 

96 # M10 source-aware retain + recall. 

97 source_store: object | None 

98 source_retain_provenance: bool 

99 source_chunk_expansion: bool 

100 source_expansion_score_multiplier: float 

101 source_expansion_max_per_hit: int 

102 

103 # Adversarial defense. 

104 adversarial_abstention_enabled: bool 

105 adversarial_abstention_floor: float 

106 adversarial_premise_verification_enabled: bool 

107 adversarial_premise_min_confidence: float 

108 adversarial_prompt_enabled: bool 

109 

110 # Agentic reflect loop — ``None`` = legacy single-shot synthesis. 

111 agentic_reflect_params: AgenticReflectParams | None 

112 

113 # Mental-model service — wires agentic reflect to the configured store. 

114 mental_model_service: object | None 

115 

116 # Retain-time dedup (``signal_quality.dedup``). A matched MIP rule's 

117 # ``dedup.threshold`` / ``dedup.action`` override these per retain. 

118 # Defaulted and last so the dataclass stays positionally compatible. 

119 dedup_enabled: bool = True 

120 dedup_similarity_threshold: float = 0.95 

121 #: In the pipeline's vocabulary (``DedupConfig.pipeline_action``). 

122 dedup_action: str = "skip_chunk" 

123 # Retain-time dedup against the store, not only this process's cache. 

124 dedup_consult_store: bool = True 

125 # Banks with their own ``banks.<id>.signal_quality`` block: the resolved 

126 # dedup settings (already merged over the top-level block) for that bank. 

127 dedup_by_bank: dict[str, DedupConfig] = field(default_factory=dict) 

128 

129 @classmethod 

130 def from_config( 

131 cls, 

132 config: AstrocyteConfig, 

133 *, 

134 source_store: object | None = None, 

135 mental_model_store: object | None = None, 

136 ) -> PipelineConfig: 

137 """Derive the full flag set from an :class:`AstrocyteConfig`. 

138 

139 Lazy service construction (cross-encoder model load, agentic-reflect and 

140 link-expansion params, mental-model service) happens here exactly as it 

141 did in the old ``set_pipeline`` body — only when the corresponding block 

142 is enabled, so disabled features stay zero-cost. 

143 """ 

144 from astrocyte.pipeline.extraction import merged_extraction_profiles 

145 

146 # Cross-encoder reranker — lazy model load, opt-in. 

147 cer_cfg = config.cross_encoder_rerank 

148 cross_encoder: CrossEncoderProtocol | None = None 

149 cross_encoder_top_k = 30 

150 if cer_cfg.enabled: 

151 from astrocyte.pipeline.cross_encoder_rerank import get_default_cross_encoder 

152 

153 cross_encoder = get_default_cross_encoder( 

154 cer_cfg.model_name, 

155 force_cpu=cer_cfg.force_cpu, 

156 ) 

157 cross_encoder_top_k = cer_cfg.top_k 

158 

159 # Link expansion (reuses the legacy ``spreading_activation:`` block). 

160 sa_cfg = config.spreading_activation 

161 link_expansion_params: LinkExpansionParams | None = None 

162 if sa_cfg.enabled: 

163 from astrocyte.pipeline.link_expansion import LinkExpansionParams 

164 

165 link_expansion_params = LinkExpansionParams( 

166 expansion_limit=sa_cfg.expansion_limit, 

167 ) 

168 

169 # Agentic reflect loop. 

170 ad_cfg = config.adversarial_defense 

171 ar_cfg = config.agentic_reflect 

172 agentic_reflect_params: AgenticReflectParams | None = None 

173 if ar_cfg.enabled: 

174 from astrocyte.pipeline.agentic_reflect import AgenticReflectParams 

175 

176 agentic_reflect_params = AgenticReflectParams( 

177 max_iterations=ar_cfg.max_iterations, 

178 recall_step_max_results=ar_cfg.recall_step_max_results, 

179 max_evidence_pool_size=ar_cfg.max_evidence_pool_size, 

180 # Mirrors the separate adversarial_defense block so enabling 

181 # adversarial defense also tightens the loop's system prompt. 

182 adversarial_defense=ad_cfg.adversarial_prompt_enabled, 

183 ) 

184 

185 # Mental-model service — present only when a store is configured. 

186 mental_model_service: object | None = None 

187 if mental_model_store is not None: 

188 from astrocyte.pipeline.mental_model import MentalModelService 

189 

190 mental_model_service = MentalModelService(mental_model_store) # type: ignore[arg-type] 

191 

192 cl_cfg = config.causal_links 

193 slg_cfg = config.semantic_link_graph 

194 sfe_cfg = config.structured_fact_extraction 

195 coocc_cfg = config.entity_cooccurrence 

196 qa_cfg = config.query_analyzer 

197 sar_cfg = config.source_aware_retrieval 

198 dedup_cfg = config.signal_quality.dedup 

199 

200 return cls( 

201 extraction_profiles=merged_extraction_profiles(config), 

202 recall_authority=config.recall_authority, 

203 cross_encoder=cross_encoder, 

204 cross_encoder_top_k=cross_encoder_top_k, 

205 causal_links_enabled=cl_cfg.enabled, 

206 causal_max_pairs_per_memory=cl_cfg.max_pairs_per_memory, 

207 causal_min_confidence=cl_cfg.min_confidence, 

208 dedup_enabled=dedup_cfg.enabled, 

209 dedup_similarity_threshold=dedup_cfg.similarity_threshold, 

210 dedup_action=dedup_cfg.pipeline_action, 

211 dedup_consult_store=dedup_cfg.consult_store, 

212 dedup_by_bank={ 

213 bank_id: bank.signal_quality.dedup 

214 for bank_id, bank in (config.banks or {}).items() 

215 if bank.signal_quality is not None 

216 }, 

217 semantic_link_graph_enabled=slg_cfg.enabled, 

218 semantic_link_graph_top_k=slg_cfg.top_k, 

219 semantic_link_graph_threshold=slg_cfg.similarity_threshold, 

220 structured_fact_extraction_enabled=sfe_cfg.enabled, 

221 structured_fact_extraction_max_facts=sfe_cfg.max_facts_per_call, 

222 structured_fact_extraction_mode=sfe_cfg.extraction_mode, 

223 structured_fact_extraction_chunk_strategy=sfe_cfg.chunk_strategy, 

224 structured_fact_extraction_chunk_max_size=sfe_cfg.chunk_max_size, 

225 structured_fact_extraction_parallel_chunks=sfe_cfg.parallel_chunks, 

226 structured_fact_extraction_parallel_chunks_max_concurrency=sfe_cfg.parallel_chunks_max_concurrency, 

227 entity_cooccurrence_enabled=coocc_cfg.enabled, 

228 entity_cooccurrence_max_entities=coocc_cfg.max_entities_per_memory, 

229 query_analyzer_enabled=qa_cfg.enabled, 

230 query_analyzer_allow_llm_fallback=qa_cfg.allow_llm_fallback, 

231 query_analyzer_enable_temporal_expansion=_temporal_expansion_flag(qa_cfg.enable_temporal_expansion), 

232 link_expansion_params=link_expansion_params, 

233 bm25_idf_enabled=config.bm25_idf.enabled, 

234 source_store=source_store, 

235 source_retain_provenance=sar_cfg.retain_provenance, 

236 source_chunk_expansion=sar_cfg.chunk_expansion, 

237 source_expansion_score_multiplier=sar_cfg.expansion_score_multiplier, 

238 source_expansion_max_per_hit=sar_cfg.expansion_max_per_hit, 

239 adversarial_abstention_enabled=ad_cfg.abstention_enabled, 

240 adversarial_abstention_floor=ad_cfg.abstention_floor, 

241 adversarial_premise_verification_enabled=ad_cfg.premise_verification_enabled, 

242 adversarial_premise_min_confidence=ad_cfg.premise_verification_min_confidence, 

243 adversarial_prompt_enabled=ad_cfg.adversarial_prompt_enabled, 

244 agentic_reflect_params=agentic_reflect_params, 

245 mental_model_service=mental_model_service, 

246 ) 

247 

248 def as_orchestrator_attrs(self) -> dict[str, Any]: 

249 """The flag set as a flat ``{attr_name: value}`` map for assignment.""" 

250 return dict(self.__dict__)