Coverage for astrocyte/config.py: 93%

810 statements  

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

1"""Astrocyte configuration — YAML loading, profile resolution, env var substitution.""" 

2 

3from __future__ import annotations 

4 

5import fnmatch 

6import os 

7import re 

8from collections.abc import Callable 

9from dataclasses import dataclass, field, fields 

10from pathlib import Path 

11from typing import Any, Literal 

12 

13import yaml 

14 

15from astrocyte._text_files import read_user_text 

16from astrocyte.errors import ConfigError 

17from astrocyte.types import AccessGrant 

18 

19_ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") 

20 

21_BASE_SENSITIVE_FIELD_KEYS = ( 

22 "api_key", 

23 "password", 

24 "token", 

25 "secret", 

26) 

27 

28# --------------------------------------------------------------------------- 

29# Profile directory (shipped inside the package) 

30# --------------------------------------------------------------------------- 

31_PROFILES_DIR = Path(__file__).parent / "profiles" 

32 

33 

34# --------------------------------------------------------------------------- 

35# Configuration dataclasses 

36# --------------------------------------------------------------------------- 

37 

38 

39@dataclass 

40class RateLimitConfig: 

41 retain_per_minute: int | None = None 

42 recall_per_minute: int | None = None 

43 reflect_per_minute: int | None = None 

44 global_per_minute: int | None = None 

45 

46 

47@dataclass 

48class QuotaConfig: 

49 retain_per_day: int | None = None 

50 reflect_per_day: int | None = None 

51 

52 

53@dataclass 

54class HomeostasisConfig: 

55 recall_max_tokens: int | None = None 

56 reflect_max_tokens: int | None = None 

57 retain_max_content_bytes: int | None = None 

58 rate_limits: RateLimitConfig = field(default_factory=RateLimitConfig) 

59 quotas: QuotaConfig = field(default_factory=QuotaConfig) 

60 

61 

62@dataclass 

63class PiiConfig: 

64 mode: str = "regex" # "regex" | "ner" | "llm" | "rules_then_llm" | "disabled" 

65 action: str = "redact" # "redact" | "reject" | "warn" 

66 patterns: list[dict[str, str]] | None = None 

67 countries: list[str] | None = None # ["SG", "IN", "GB", "US", "DE", "FR", "IT", "ES", "AU", "CA", "JP", "CN"] 

68 type_overrides: dict[str, dict[str, str]] | None = None # {"credit_card": {"action": "reject"}} 

69 

70 

71@dataclass 

72class ValidationConfig: 

73 max_content_length: int = 50000 

74 reject_empty_content: bool = True 

75 reject_binary_content: bool = True 

76 allowed_content_types: list[str] | None = None 

77 

78 

79@dataclass 

80class MetadataSanitizationConfig: 

81 blocked_keys: list[str] = field(default_factory=lambda: list(_BASE_SENSITIVE_FIELD_KEYS)) 

82 max_metadata_size_bytes: int = 4096 

83 

84 

85@dataclass 

86class BarrierConfig: 

87 pii: PiiConfig = field(default_factory=PiiConfig) 

88 validation: ValidationConfig = field(default_factory=ValidationConfig) 

89 metadata: MetadataSanitizationConfig = field(default_factory=MetadataSanitizationConfig) 

90 

91 

92#: ``signal_quality.dedup.action`` values, mapped to the retain pipeline's 

93#: (MIP ``dedup.action``) vocabulary. Config ``skip`` keeps its documented 

94#: meaning — duplicates are dropped — which is the pipeline's ``skip_chunk`` 

95#: (drop duplicate chunks, store the rest); ``skip_chunk`` is accepted as an 

96#: alias. ``warn`` stores everything; ``update`` is not implemented and behaves 

97#: as ``skip_chunk``. Rejecting a whole retain when any chunk is a duplicate is 

98#: MIP's ``skip``, available per rule only. 

99DEDUP_ACTION_TO_PIPELINE = {"skip": "skip_chunk", "skip_chunk": "skip_chunk", "warn": "warn", "update": "update"} 

100DEDUP_ACTIONS = tuple(DEDUP_ACTION_TO_PIPELINE) 

101NOISY_BANK_ACTIONS = ("warn", "throttle", "reject") 

102 

103 

104@dataclass 

105class DedupConfig: 

106 enabled: bool = True 

107 similarity_threshold: float = 0.95 

108 action: str = "skip" # "skip" | "skip_chunk" | "warn" | "update" — see DEDUP_ACTION_TO_PIPELINE 

109 

110 @property 

111 def pipeline_action(self) -> str: 

112 """``action`` in the retain pipeline's vocabulary (``skip`` -> ``skip_chunk``).""" 

113 return DEDUP_ACTION_TO_PIPELINE.get(self.action, "skip_chunk") 

114 

115 #: Retain-time dedup also checks each chunk against its nearest stored 

116 #: neighbours, so it holds across processes (a restarted daemon, a CLI 

117 #: run, a gateway restart) and not only within one. One extra 

118 #: ``search_similar`` per chunk: cheap on an ANN index, a full bank scan on 

119 #: SqliteStore (~5 ms at 1k memories, ~25 ms at 10k, ~110 ms at 50k). 

120 consult_store: bool = True 

121 

122 

123@dataclass 

124class NoisyBankConfig: 

125 enabled: bool = True 

126 retain_spike_multiplier: float = 5.0 

127 min_avg_content_length: int = 20 

128 max_dedup_rate: float = 0.8 

129 action: str = "warn" # see NOISY_BANK_ACTIONS 

130 

131 

132@dataclass 

133class RecallCacheConfig: 

134 enabled: bool = False 

135 similarity_threshold: float = 0.95 

136 max_entries: int = 256 

137 ttl_seconds: float = 300.0 

138 

139 

140@dataclass 

141class Bm25IdfConfig: 

142 """BM25-with-IDF full-text recall (M9). 

143 

144 When enabled, the recall pipeline routes its keyword strategy through 

145 :meth:`PostgresStore.search_fulltext_bm25` instead of the classic 

146 ``ts_rank_cd`` path. Requires migration 013 (the materialized views) to 

147 be applied AND :meth:`PostgresStore.refresh_bm25_views` to have run at 

148 least once after ingest — otherwise the views are empty and every 

149 keyword recall returns no hits. 

150 

151 Off by default — opt-in only after the deployment has wired up a 

152 refresh schedule. See :doc:`/_plugins/bm25-idf` for the operator guide. 

153 """ 

154 

155 enabled: bool = False 

156 

157 

158@dataclass 

159class SourceAwareRetrievalConfig: 

160 """Source-aware retain + recall (M10). 

161 

162 Two switches with independent risk profiles: 

163 

164 - ``retain_provenance`` is cheap and almost-free: when a SourceStore is 

165 configured AND this flag is on, retain creates one SourceDocument 

166 and N SourceChunk rows per ingestion call and stamps each 

167 ``VectorItem.chunk_id`` with the matching chunk's id. Costs one 

168 extra DB roundtrip per retain. Required for any provenance feature 

169 downstream (citations, chunk expansion) but not sufficient — the 

170 SourceStore also has to be wired in YAML. 

171 - ``chunk_expansion`` is the recall-side bet: when on, the recall 

172 pipeline fetches sibling chunks of each top-K vector hit from the 

173 SourceStore and merges them into the candidate set with a 

174 configurable score multiplier. Helps multi-hop / split-evidence 

175 questions where the answer key is in chunk N±1 of a chunk that 

176 hit. Adds one DB roundtrip per top-K hit. **Off by default** — 

177 enable in benchmark configs and measure before flipping in prod. 

178 

179 ``expansion_score_multiplier`` controls how much weight expanded 

180 chunks get relative to the seed hit. ``1.0`` keeps them at parity 

181 (often too aggressive); ``0.5`` is a conservative starting point. 

182 ``expansion_max_per_hit`` caps fan-out so a long document doesn't 

183 flood the candidate pool. 

184 """ 

185 

186 retain_provenance: bool = False 

187 chunk_expansion: bool = False 

188 expansion_score_multiplier: float = 0.5 

189 expansion_max_per_hit: int = 4 

190 

191 

192@dataclass 

193class BenchmarkBudgetConfig: 

194 """Named benchmark budget for Hindsight-parity preset routing.""" 

195 

196 candidate_limit: int = 30 

197 graph_expansion_limit: int = 30 

198 rerank_top_k: int = 30 

199 max_tokens: int = 8192 

200 observation_weight: float = 0.0 

201 agentic_reflect_allowed: bool = False 

202 

203 

204@dataclass 

205class BenchmarkPresetConfig: 

206 """Versioned benchmark preset metadata. 

207 

208 Runtime operators can ignore this section; benchmark scripts and regression 

209 tests use it to keep preset semantics explicit across branches. 

210 """ 

211 

212 name: str = "custom" 

213 version: int = 1 

214 budget: str = "mid" 

215 low: BenchmarkBudgetConfig = field( 

216 default_factory=lambda: BenchmarkBudgetConfig( 

217 candidate_limit=12, 

218 graph_expansion_limit=12, 

219 rerank_top_k=12, 

220 max_tokens=2048, 

221 agentic_reflect_allowed=False, 

222 ) 

223 ) 

224 mid: BenchmarkBudgetConfig = field(default_factory=BenchmarkBudgetConfig) 

225 high: BenchmarkBudgetConfig = field( 

226 default_factory=lambda: BenchmarkBudgetConfig( 

227 candidate_limit=60, 

228 graph_expansion_limit=40, 

229 rerank_top_k=40, 

230 max_tokens=16384, 

231 observation_weight=0.25, 

232 agentic_reflect_allowed=True, 

233 ) 

234 ) 

235 

236 

237@dataclass 

238class AdversarialDefenseConfig: 

239 """Adversarial-question defense layer. 

240 

241 Targets the LoCoMo adversarial category (negative-existence, 

242 false-premise, time-shift, cross-entity-confusion) where the LLM 

243 left to its own devices invents an answer from weak retrieval hits. 

244 

245 Three layered guards (each independently configurable): 

246 

247 1. ``abstention_enabled`` + ``abstention_floor`` — score-floor 

248 short-circuit. When all top-K recall hits score below the floor, 

249 skip the LLM and return "insufficient evidence." Default floor 

250 ``0.2`` is conservative — only fires when retrieval is genuinely 

251 disconnected from the question. 

252 

253 **DEPRECATED:** ``abstention_enabled`` (the bool) is now a 

254 fallback for callers that don't pass per-call 

255 :class:`~astrocyte.types.Dispositions`. The orchestrator 

256 derives the effective abstention floor from 

257 ``dispositions.skepticism`` (1=trust→never abstain, 

258 5=skeptical→aggressive). Migration path: set the disposition 

259 on the bank or per-request instead of toggling this bool. The 

260 legacy mapping is ``abstention_enabled=True`` ⇔ skepticism=3, 

261 ``abstention_enabled=False`` ⇔ skepticism=1. ``abstention_floor`` 

262 (the float) remains the BASE that gets scaled by skepticism — 

263 see :func:`astrocyte.pipeline.orchestrator._abstention_floor_for_skepticism`. 

264 

265 2. ``premise_verification_enabled`` — pre-loop premise extraction + 

266 per-claim verification. The question is decomposed into atomic 

267 claims; each is verified against memory before answering. Adds 

268 1 LLM call per question; targets false-premise failures. 

269 

270 3. ``adversarial_prompt_enabled`` — tightens the agentic-reflect 

271 system prompt with explicit "insufficient evidence is always a 

272 valid answer" + premise-check rules. Free, defense-in-depth. 

273 """ 

274 

275 abstention_enabled: bool = False 

276 abstention_floor: float = 0.2 

277 premise_verification_enabled: bool = False 

278 premise_verification_min_confidence: float = 0.6 

279 adversarial_prompt_enabled: bool = False 

280 

281 

282@dataclass 

283class AgenticReflectConfig: 

284 """Agentic reflect loop (Hindsight parity). 

285 

286 When enabled, ``reflect()`` runs an LLM-driven loop that selects 

287 between two tools — ``recall`` (refine and re-retrieve) and ``done`` 

288 (commit the final answer with citations) — for up to 

289 ``max_iterations`` turns. Targets multi-hop and open-domain queries 

290 where a single retrieval often misses the bridge memory. 

291 

292 Cost: each turn is one LLM call. Default cap of 3 means worst-case 

293 3× the LLM cost of single-shot reflect; typical case is 1-2 turns 

294 when initial evidence is sufficient. 

295 

296 See ``astrocyte/pipeline/agentic_reflect.py`` for protocol details. 

297 """ 

298 

299 enabled: bool = False 

300 max_iterations: int = 3 

301 recall_step_max_results: int = 10 

302 max_evidence_pool_size: int = 30 

303 

304 

305@dataclass 

306class SemanticLinkGraphConfig: 

307 """Precomputed semantic-kNN graph at retain time (Hindsight parity, C3a). 

308 

309 Each new memory is linked to its top-``k`` most-similar existing 

310 memories with cosine similarity ≥ ``similarity_threshold``. The 

311 edges feed the link-expansion retrieval CTE as a parallel signal 

312 alongside entity-overlap and causal links. 

313 

314 Costs one extra ``search_similar`` per chunk during retain (cheap 

315 against the HNSW index). Disabled by default; opt-in for 

316 benchmarks / production where multi-hop synthesis matters. 

317 """ 

318 

319 enabled: bool = False 

320 top_k: int = 5 

321 similarity_threshold: float = 0.7 

322 

323 

324@dataclass 

325class QueryAnalyzerConfig: 

326 """Query-level temporal constraint extraction. 

327 

328 When ``enabled``, recall runs the regex pre-pass to extract 

329 temporal expressions ("last week", "in March 2024", "yesterday") 

330 into a time_range filter. Free of LLM cost. 

331 

332 ``allow_llm_fallback`` opts in to a structured-JSON LLM extraction 

333 for queries that contain a temporal marker but no regex match 

334 (e.g. "last spring", "around the launch"). Adds 1 LLM call per 

335 such query; gated to keep cost predictable. 

336 

337 A caller-supplied ``RecallRequest.time_range`` always wins over 

338 the analyzer's extraction. 

339 """ 

340 

341 enabled: bool = False 

342 allow_llm_fallback: bool = True 

343 #: M18a-1 — extended relative-time patterns plus Hindsight-parity 

344 #: dateparser Pass B. The chain runs: 

345 #: 1. ``_try_iso_date`` / ``_try_relative`` — narrow exact regex 

346 #: 2. ``_try_temporal_expansion`` (Pass A) — fuzzy "a few weeks ago" 

347 #: 3. ``_try_month_year`` / ``_try_year`` — calendar references 

348 #: 4. ``_try_dateparser`` (Pass B) — wide-net for named dates, 

349 #: weekdays, ordinals, ISO refs (requires ``dateparser`` pkg — 

350 #: see ``pyproject.toml`` ``[bench]`` extras; no-ops if missing) 

351 #: The extracted date range is fed to ``fact_recall`` which RRF- 

352 #: fuses semantic + episodic + temporal as parallel siblings 

353 #: (Hindsight architecture), so false-positive dateparser hits are 

354 #: damped by cross-strategy agreement scoring. 

355 #: 

356 #: **Default True as of M18b ship** (2026-05-17): the B1-dp+RRF 

357 #: 2-run mean was 83.75% LoCoMo (+3.25pp vs B0 baseline 80.5%, 

358 #: +2.17σ above M17 mean — cleared M17+1σ ship gate by 1.75pp). 

359 #: Bench env override ``ASTROCYTE_M18_ENABLE_TEMPORAL_EXPANSION=0`` 

360 #: forces off for ablation replication. 

361 enable_temporal_expansion: bool = True 

362 

363 

364@dataclass 

365class StructuredFactExtractionConfig: 

366 """Single-pass structured fact extraction at retain time. 

367 

368 When enabled, replaces the legacy chunk + entity-extraction + 

369 fact-causal-extraction three-pass pipeline with a single LLM call 

370 that produces structured metadata (entities, causal_relations, 

371 temporal range, where/who/why annotations) per memory. 

372 

373 Two extraction modes: 

374 

375 - ``"verbatim"`` (recommended for benchmarks): pre-chunks the 

376 text, then asks the LLM to produce per-chunk metadata. The 

377 stored memory text is the ORIGINAL chunk text — preserves 

378 vocabulary for embedding-match against questions. Adds rich 

379 metadata without losing surface terms. 

380 

381 - ``"concise"``: the LLM generates atomic fact statements that 

382 replace the chunks. The stored text is the LLM's structured 

383 paraphrase (``"what | Involving: who | why"``). Higher-density 

384 knowledge representation, BUT loses surface vocabulary which 

385 can hurt recall_hit_rate when questions share words with the 

386 original text. Documented regression on LoCoMo 2026-05-02. 

387 

388 Each fact becomes ONE memory in both modes. The structured fields 

389 populate ``metadata['_fact_*']`` either way. 

390 

391 Cost approximately equal to the legacy two-pass (one LLM call 

392 replaces two); output substantially richer, especially for 

393 multi-hop and temporal questions. 

394 

395 Net: opt-in, defaults conservative. 

396 """ 

397 

398 enabled: bool = False 

399 #: Only ``"verbatim"`` is supported; the ``"concise"`` mode was removed 

400 #: in M9 because it caused severe recall_hit_rate degradation (the 

401 #: 2026-05-02 finding — LLM-paraphrased ``what`` fields lost the 

402 #: surface vocabulary that question embeddings share). The field is 

403 #: kept so existing configs parse, but ``validate_astrocyte_config`` 

404 #: raises ``ConfigError`` if any other value is set. 

405 extraction_mode: str = "verbatim" 

406 max_facts_per_call: int = 30 

407 #: Chunking strategy used by verbatim mode to pre-chunk the 

408 #: source text before LLM enrichment. SFE has different 

409 #: granularity needs than legacy retrieval — measured wins on 

410 #: LoCoMo with ``"paragraph"`` (large context per chunk → LLM 

411 #: extracts richer metadata) vs ``"dialogue"`` (legacy default 

412 #: for conversations, which fragments context and lost 2.5 pts 

413 #: overall, 8.4 pts on open-domain). Override per workload. 

414 #: Available: ``"paragraph"``, ``"dialogue"``, ``"sentence"``. 

415 chunk_strategy: str = "paragraph" 

416 #: Per-chunk character budget for verbatim SFE pre-chunking. When 

417 #: None, falls through to the orchestrator's default chunk size 

418 #: (512 chars for Astrocyte legacy compat). Bumping this reduces 

419 #: chunk count per session, which is the dominant retain throughput 

420 #: lever — the LLM produces one ``facts[]`` entry per chunk and 

421 #: gpt-4o-mini latency is roughly linear in output tokens. 2048 is 

422 #: a measured sweet spot for LongMemEval-shaped traffic 

423 #: (avg ~12.7 KB per session → ~6 chunks instead of ~25). 

424 #: 

425 #: This overrides any chunk-size set by extraction profile or MIP 

426 #: rules for the SFE path specifically; legacy chunking for non-SFE 

427 #: retain is unaffected. 

428 chunk_max_size: int | None = None 

429 #: Per-chunk parallel verbatim extraction (Phase 3 of cost-control 

430 #: port). When ``True``, sends one LLM call per chunk in parallel 

431 #: (asyncio.gather with a semaphore), instead of one batched call 

432 #: that asks for all N chunks' metadata at once. Drops cross-chunk 

433 #: causal_relations — set ``causal_links.enabled: false`` to be 

434 #: explicit. Default off so existing deployments are unaffected. 

435 parallel_chunks: bool = False 

436 #: Max in-flight LLM calls per session when ``parallel_chunks`` is 

437 #: True. Should be ≤ avg chunks/session for the workload. 6 is the 

438 #: sweet spot for LongMemEval-shaped traffic at chunk_max_size=2048 

439 #: (~6 chunks per session). 

440 parallel_chunks_max_concurrency: int = 6 

441 

442 

443@dataclass 

444class EntityCooccurrenceConfig: 

445 """Co-occurrence link creation between entities in the same memory. 

446 

447 When retain extracts N entities from a single memory, the 

448 orchestrator creates ``co_occurs`` ``EntityLink`` rows between 

449 them so spreading activation at recall time can hop across these 

450 edges (Hindsight parity). 

451 

452 The all-pairs Cartesian product is **O(N²) per retain**. The 

453 2026-05-06 LME profile measured this stage at 34% of total retain 

454 wall time, with per-call cost growing as the entity-link table 

455 grew (52s tail latency on a single retain at session 100). Capping 

456 the number of entities used to form co-occurrence pairs bounds the 

457 work to O(K²) per retain regardless of corpus size. 

458 

459 ``max_entities_per_memory=5`` (default) produces ``C(5,2)=10`` 

460 links per retain — a 43× reduction vs the unbounded 30-entity 

461 sessions LME emits. Top-K entities are selected by their order in 

462 the extraction result (typically tracks prominence in the source 

463 text). When N ≤ K the cap is a no-op. 

464 

465 Disable entirely (``enabled=False``) to skip co-occurrence link 

466 creation altogether — useful for retain-heavy bench runs where 

467 spreading activation isn't the critical recall signal. 

468 """ 

469 

470 enabled: bool = True 

471 max_entities_per_memory: int = 5 

472 

473 

474@dataclass 

475class CausalLinksConfig: 

476 """Cause→effect link extraction at retain time (Hindsight parity). 

477 

478 When enabled, retain runs an additional LLM pass that identifies 

479 causal relationships between extracted entities and persists them 

480 as ``EntityLink(link_type="causes", ...)`` rows. These edges feed 

481 temporal spreading activation's "trace reasoning chains" path. 

482 

483 Costs one extra LLM call per record. Disabled by default — opt in 

484 for benchmarks / production where causal walks add value. 

485 """ 

486 

487 enabled: bool = False 

488 max_pairs_per_memory: int = 4 

489 min_confidence: float = 0.7 

490 

491 

492@dataclass 

493class SpreadingActivationConfig: 

494 """Spreading activation through entity links (Hindsight parity). 

495 

496 When enabled, recall expands the seed RRF result set by walking 

497 entity-link edges (default: ``co_occurs``) out to ``max_hops``, 

498 decaying activation per hop. Spread hits join the candidate pool 

499 before final cross-encoder rerank, with metadata tags 

500 (``_spread_hop``, ``_spread_via_entity``) so synthesis can tell 

501 them apart from direct evidence. 

502 

503 Defaults are conservative — leave disabled until the workload is 

504 verified to benefit (multi-hop / open-domain QA categories) and 

505 monitor single-hop precision for regressions caused by spread noise. 

506 """ 

507 

508 enabled: bool = False 

509 max_hops: int = 2 

510 decay_per_hop: float = 0.6 

511 expansion_limit: int = 30 

512 activation_threshold: float = 0.2 

513 link_types: list[str] = field(default_factory=lambda: ["co_occurs", "causes"]) 

514 #: Hindsight blog 2026-03-12 — temporal proximity bonus on top of 

515 #: the entity-link spread. ``0.0`` disables (entity-link only). 

516 temporal_proximity_weight: float = 0.3 

517 temporal_half_life_days: float = 7.0 

518 

519 

520@dataclass 

521class CrossEncoderRerankConfig: 

522 """Final-stage cross-encoder reranker (Hindsight parity). 

523 

524 When ``enabled=True``, :meth:`PipelineOrchestrator._rank_reflect_context` 

525 uses a real cross-encoder (sentence-transformers backend) to rerank the 

526 top-``top_k`` recall hits before synthesis. When disabled (default), 

527 the existing ``cross_encoder_like_rerank`` heuristic runs instead. 

528 

529 The default model — ``cross-encoder/ms-marco-MiniLM-L-6-v2`` — matches 

530 Hindsight's default and is the standard MS MARCO baseline. 

531 """ 

532 

533 enabled: bool = False 

534 model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2" 

535 top_k: int = 30 

536 force_cpu: bool = False 

537 

538 

539@dataclass 

540class TieredRetrievalConfig: 

541 enabled: bool = False 

542 min_results: int = 3 

543 min_score: float = 0.3 

544 max_tier: int = 3 # 0-4 

545 #: Which recall path runs at tier 3+ (and tier 4 after reformulation). ``pipeline`` = built-in 

546 #: pipeline only (default). ``hybrid`` = :class:`~astrocyte.hybrid.HybridEngineProvider` merge 

547 #: (engine + pipeline); requires a hybrid engine provider and ``tiered_retrieval.enabled``. 

548 full_recall: Literal["pipeline", "hybrid"] = "pipeline" 

549 

550 

551@dataclass 

552class RecallAuthorityTierConfig: 

553 """One precedence band for :class:`RecallAuthorityConfig` (matches ``metadata[\"authority_tier\"]``).""" 

554 

555 id: str = "" 

556 priority: int = 1 

557 label: str = "" 

558 

559 

560@dataclass 

561class RecallAuthorityConfig: 

562 """Structured recall authority — labels fused hits for synthesis (M7).""" 

563 

564 enabled: bool = False 

565 rules_inline: str | None = None 

566 rules_path: str | None = None 

567 tiers: list[RecallAuthorityTierConfig] = field(default_factory=list) 

568 #: When True, :meth:`Astrocyte.reflect` / pipeline reflect inject ``authority_context`` into the synthesis prompt. 

569 apply_to_reflect: bool = True 

570 #: Default ``metadata[\"authority_tier\"]`` for vectors in a bank (profile ``authority_tier`` overrides). 

571 tier_by_bank: dict[str, str] = field(default_factory=dict) 

572 

573 

574@dataclass 

575class CuratedRetainConfig: 

576 enabled: bool = False 

577 model: str | None = None 

578 context_recall_limit: int = 5 

579 

580 

581@dataclass 

582class CuratedRecallConfig: 

583 enabled: bool = False 

584 freshness_weight: float = 0.3 

585 reliability_weight: float = 0.2 

586 salience_weight: float = 0.2 

587 original_score_weight: float = 0.3 

588 freshness_half_life_days: float = 30.0 

589 min_score: float | None = None 

590 

591 

592@dataclass 

593class SignalQualityConfig: 

594 dedup: DedupConfig = field(default_factory=DedupConfig) 

595 noisy_bank: NoisyBankConfig = field(default_factory=NoisyBankConfig) 

596 

597 

598@dataclass 

599class CircuitBreakerConfig: 

600 failure_threshold: int = 5 

601 recovery_timeout_seconds: float = 30.0 

602 half_open_max_calls: int = 2 

603 

604 

605@dataclass 

606class EscalationConfig: 

607 circuit_breaker: CircuitBreakerConfig = field(default_factory=CircuitBreakerConfig) 

608 degraded_mode: str = "empty_recall" # "empty_recall" | "error" | "cache" 

609 

610 

611@dataclass 

612class ObservabilityConfig: 

613 otel_enabled: bool = False 

614 prometheus_enabled: bool = False 

615 log_level: str = "info" 

616 

617 

618@dataclass 

619class AccessControlConfig: 

620 enabled: bool = False 

621 default_policy: str = "owner_only" # "owner_only" | "open" | "deny" 

622 

623 

624@dataclass 

625class JwtMiddlewareConfig: 

626 """JWT identity middleware (identity spec §3 Gap 1 wiring). 

627 

628 When ``enabled``, the MCP server extracts the ``Authorization: Bearer`` 

629 token from each inbound request, validates it against the configured 

630 JWKS, classifies the claims via :mod:`astrocyte.identity_jwt`, and 

631 populates :attr:`AstrocyteContext.actor` with a resolved 

632 :class:`ActorIdentity` for the call. See 

633 ``docs/_plugins/jwt-identity-middleware.md`` for the operator guide. 

634 

635 When ``enabled=False`` (default), the MCP server preserves pre-middleware 

636 behavior: a single static ``AstrocyteContext`` is used for all calls. 

637 """ 

638 

639 enabled: bool = False 

640 #: JWKS endpoint for signature key retrieval. Required when enabled. 

641 jwks_uri: str | None = None 

642 #: Expected token ``aud`` claim. Required when enabled — unset audience 

643 #: is a common misconfiguration that can result in cross-tenant accepts. 

644 token_audience: str | None = None 

645 #: Expected token ``iss`` claim. Validated when set; left unchecked when 

646 #: None (some IdPs rotate issuers). 

647 token_issuer: str | None = None 

648 #: Signing algorithms accepted. Defaults to asymmetric only so HS* keys 

649 #: stolen from misconfigured deployments can't forge tokens. 

650 algorithms: list[str] = field(default_factory=lambda: ["RS256", "ES256"]) 

651 #: When True, a missing or malformed Authorization header raises. 

652 #: When False (with ``allow_anonymous=True``), falls through to anonymous. 

653 fail_closed: bool = True 

654 #: Permit calls with no Authorization header. Ignored when fail_closed=True. 

655 allow_anonymous: bool = False 

656 #: JWKS cache refresh interval. Most JWKS endpoints rotate every 24h. 

657 jwks_refresh_interval_hours: int = 24 

658 

659 

660@dataclass 

661class IdentityConfig: 

662 """Identity-driven bank resolution and ACL helpers (M1–M2 / v0.5.0).""" 

663 

664 auto_resolve_banks: bool = False 

665 user_bank_prefix: str = "user-" 

666 agent_bank_prefix: str = "agent-" 

667 service_bank_prefix: str = "service-" 

668 resolver: Literal["convention", "config", "custom"] | None = None 

669 obo_enabled: bool = False 

670 #: JWT identity middleware wiring (identity spec §3 Gap 1). 

671 jwt_middleware: JwtMiddlewareConfig = field(default_factory=JwtMiddlewareConfig) 

672 

673 

674# --------------------------------------------------------------------------- 

675# M2 — Config schema evolution (ADR-003, v0.5.0 with M1) 

676# --------------------------------------------------------------------------- 

677 

678 

679@dataclass 

680class SourceConfig: 

681 """External data source definition (``astrocyte.ingest``). 

682 

683 * **webhook** — HTTP push; gateway / ASGI binds the route. 

684 * **stream** — long-running consumer — ``driver: redis`` (Redis Streams) or ``kafka`` (Kafka). 

685 Requires ``url``, ``topic``, ``consumer_group``, ``target_bank`` / ``target_bank_template``. 

686 For Redis, ``url`` is a Redis URL; for Kafka, ``url`` is bootstrap servers (e.g. ``localhost:9092``). 

687 Optional ``path``: Redis consumer name or Kafka ``client_id``. 

688 * **poll** / **api_poll** — scheduled HTTP pull — ``driver: github`` (``astrocyte-ingestion-github``). 

689 Requires ``interval_seconds``, ``path`` as ``owner/repo``, ``target_bank`` (or template), and 

690 ``auth.token`` (or env-substituted) for the GitHub API. Optional ``url`` overrides the API base 

691 (default ``https://api.github.com``; use GitHub Enterprise ``.../api/v3`` when needed). 

692 """ 

693 

694 type: str = "" 

695 extraction_profile: str | None = None 

696 target_bank: str | None = None 

697 target_bank_template: str | None = None 

698 principal: str | None = None 

699 auth: dict[str, str | int | float | bool | None] | None = None 

700 path: str | None = None 

701 driver: str | None = None 

702 topic: str | None = None 

703 consumer_group: str | None = None 

704 url: str | None = None 

705 interval_seconds: int | None = None 

706 # M4.1 proxy recall: GET (default) or POST JSON to ``url`` 

707 recall_method: str | None = None # "GET" | "POST" 

708 recall_body: Any | None = None # POST JSON: dict/str with placeholders (see ``astrocyte.recall.proxy``) 

709 # Per-source cap on one proxy recall request, in seconds. None = the shared 

710 # deadline (``ASTROCYTE_PROXY_RECALL_DEADLINE_SECONDS``, default 0.8 s); 

711 # a value above the deadline is capped by it. See federated-sources §4. 

712 recall_timeout_seconds: float | None = None 

713 

714 

715@dataclass 

716class AgentRegistrationConfig: 

717 """Registered agent with bank access and optional rate hints (ADR-003 / v0.5.0).""" 

718 

719 principal: str | None = None 

720 banks: list[str] | None = None 

721 allowed_banks: list[str] | None = None # roadmap alias for banks; glob patterns allowed 

722 default_bank: str | None = None 

723 permissions: list[str] | None = None 

724 max_retain_per_minute: int | None = None 

725 max_recall_per_minute: int | None = None 

726 

727 

728@dataclass 

729class TlsConfig: 

730 cert_path: str | None = None 

731 key_path: str | None = None 

732 

733 

734@dataclass 

735class DeploymentConfig: 

736 """Standalone gateway settings; ignored in library mode.""" 

737 

738 mode: Literal["library", "standalone", "plugin"] = "library" 

739 host: str | None = None 

740 port: int | None = None 

741 workers: int | None = None 

742 cors_origins: list[str] | None = None 

743 tls: TlsConfig | None = None 

744 

745 

746@dataclass 

747class ExtractionProfileConfig: 

748 """Reusable extraction defaults for sources (pipeline implementation in M3).""" 

749 

750 content_type: str | None = None 

751 chunking_strategy: str | None = None 

752 entity_extraction: bool | str | None = None 

753 metadata_mapping: dict[str, str] | None = None 

754 tag_rules: list[dict[str, str | list[str]]] | None = None 

755 chunk_size: int | None = None 

756 fact_type: str | None = None # default "world"; e.g. "experience", "observation" 

757 #: Optional recall-authority band id (overrides ``recall_authority.tier_by_bank`` for this profile). 

758 authority_tier: str | None = None 

759 

760 

761@dataclass 

762class McpConfig: 

763 default_bank_id: str | None = None 

764 expose_reflect: bool = True 

765 expose_forget: bool = False 

766 expose_admin: bool = False 

767 max_results_limit: int = 50 

768 principal: str | None = None 

769 

770 

771@dataclass 

772class DefaultsConfig: 

773 """Per-profile default settings.""" 

774 

775 skepticism: int = 3 

776 literalism: int = 3 

777 empathy: int = 3 

778 preferred_fact_types: list[str] | None = None 

779 tags: list[str] | None = None 

780 

781 

782@dataclass 

783class DlpConfig: 

784 """Data Loss Prevention — output scanning for PII in recall/reflect results.""" 

785 

786 scan_recall_output: bool = False 

787 scan_reflect_output: bool = False 

788 output_pii_action: str = "warn" # "redact" | "reject" | "warn" 

789 

790 

791@dataclass 

792class LifecycleTtlConfig: 

793 archive_after_days: int = 90 # Days since last recall before archiving 

794 delete_after_days: int = 365 # Days since creation before deletion 

795 exempt_tags: list[str] | None = None # Tags that exempt from TTL 

796 fact_type_overrides: dict[str, int | None] | None = None # Override archive_after_days by fact_type 

797 

798 

799@dataclass 

800class LifecycleConfig: 

801 enabled: bool = False 

802 ttl: LifecycleTtlConfig = field(default_factory=LifecycleTtlConfig) 

803 

804 

805@dataclass 

806class WikiCompileConfig: 

807 """Configuration for M8 wiki compile and its background trigger.""" 

808 

809 enabled: bool = False 

810 auto_start: bool = False 

811 size_threshold: int = 50 

812 staleness_days: float = 7.0 

813 staleness_min_memories: int = 10 

814 max_queue_size: int = 100 

815 

816 

817@dataclass 

818class PreferenceCompileConfig: 

819 """M18a-4 follow-up — preference consolidation gate (Hindsight parity). 

820 

821 Per-document LLM pass that distills ``fact_type='preference'`` facts 

822 into ``MentalModel(kind='preference')`` rows for advisory recall. 

823 Default ON (matches pre-M18 bench behavior; was always run inline). 

824 

825 Disable to ablate the preference tier (useful for measuring its 

826 isolated contribution vs the directive tier). 

827 """ 

828 

829 enabled: bool = True 

830 

831 

832@dataclass 

833class EpisodicExtractConfig: 

834 """M18a-4 — episodic event index (tag + retrieve). 

835 

836 Two integration points (both bench-gated today; core orchestrator 

837 hook deferred to a separate cycle): 

838 

839 1. **Retain side**: after fact extraction for a document, 

840 ``tag_episodic_facts(all_facts)`` walks the fact list and tags 

841 any matching an episodic verb pattern ("attended/visited/met X 

842 at/during Y") by appending ``EPISODIC_MARKER`` ("episodic:event") 

843 to the fact's ``entities`` array. Uses the existing fact_type 

844 schema (migration 020) — no new SQL value, just a namespaced 

845 marker entity. See ``astrocyte/pipeline/episodic_extract.py``. 

846 

847 2. **Recall side**: when ``question_has_episodic_cue(query)`` matches 

848 (question contains "where did I", "when did I meet", etc.), 

849 the recall path explicitly queries 

850 ``store.search_facts_by_entity(bank_id, EPISODIC_MARKER, ...)`` 

851 to surface the episodic-tagged facts alongside generic 

852 semantic hits. 

853 

854 Both call sites are bench-gated by ``ASTROCYTE_M18_ENABLE_EPISODIC_EXTRACTION=1`` 

855 today. When the orchestrator gains a "document post-process" hook, 

856 the retain-side tagger moves there; the recall-side search becomes 

857 a new strategy in ``section_recall``. 

858 """ 

859 

860 enabled: bool = False 

861 

862 

863# NOTE: SpreadingActivationConfig is defined ~earlier in this file (line ~467). 

864# The earlier definition is the live one — it carries the Hindsight-parity 

865# link-walk fields (``max_hops``, ``decay_per_hop``, ``link_types``, 

866# ``temporal_proximity_weight``) used by ``link_expansion.py`` and read in 

867# ``_astrocyte.py:379``. The duplicate definition removed here was an 

868# M18a-3 stub with ``seed_count``/``top_k`` fields that were never wired 

869# (the actual M18a-3 entity-co-occurrence implementation takes those as 

870# function parameters in ``section_recall.py``, not config fields). 

871 

872 

873@dataclass 

874class DirectiveCompileConfig: 

875 """M18a-2 — directive-style preference compile (Hindsight parity). 

876 

877 Hindsight's ``mental_models.subtype='directive'`` rows are user- 

878 curated hard rules treated as authoritative by the answerer. We 

879 auto-distill preference facts into a tight directive tier (≤5 rows 

880 per document) via ``astrocyte.pipeline.directive_compile``. The 

881 advisory preference tier (consolidation pass) stays in the store 

882 on-demand-recallable; only the directive tier surfaces verbatim. 

883 

884 Currently called from the bench harness pipeline only — the core 

885 orchestrator does not yet have a "document post-process" hook for 

886 document-level passes (preference_compile, directive_compile, 

887 episodic_extract). Adding that hook is a separate cycle; until 

888 then this flag controls the bench-side call only. 

889 

890 Bench-time env-var override: ``ASTROCYTE_M18_ENABLE_DIRECTIVE_COMPILE=1``. 

891 

892 **DEPRECATED (M19, 2026-05-18):** the auto-compile architecture is 

893 misaligned with Hindsight's user-authored ``create_directive`` MCP 

894 tool. Bench evidence (M18b, B2 × 2 runs) showed a replicated −30pp 

895 SSP regression because the compressed directives override the 

896 answerer's access to the original preference facts' nuance. Keep 

897 ``enabled=False``; the in-tree code stays as reference for the 

898 future user-authored MCP-tool path (M20+). Setting ``enabled=True`` 

899 will emit a runtime warning when ``run_document_postprocess`` runs. 

900 """ 

901 

902 enabled: bool = False 

903 

904 

905@dataclass 

906class EntityResolutionConfig: 

907 """Configuration for M11 retain-time entity resolution.""" 

908 

909 enabled: bool = False 

910 defer_to_async: bool = False 

911 similarity_threshold: float = 0.8 

912 confirmation_threshold: float = 0.75 

913 max_candidates_per_entity: int = 3 

914 enable_llm_disambiguation: bool = True 

915 canonical_resolution: bool = False 

916 

917 

918@dataclass 

919class AsyncTasksConfig: 

920 """Configuration for durable background memory tasks.""" 

921 

922 enabled: bool = False 

923 backend: str = "pgqueuer" # "pgqueuer" | "pgqueuer_in_memory" 

924 dsn: str | None = None 

925 install_on_start: bool = False 

926 auto_start_worker: bool = False 

927 batch_size: int = 10 

928 

929 

930@dataclass 

931class BankConfig: 

932 """Per-bank override settings. 

933 

934 ``homeostasis``, ``barriers`` and ``signal_quality`` are the bank's 

935 *resolved* sections when loaded from YAML: the top-level section, then the 

936 bank's ``profile``, then the bank's own block (see ``_parse_banks``). 

937 ``None`` means the bank uses the top-level section. Read them through 

938 ``AstrocyteConfig.bank_homeostasis`` / ``bank_barriers`` / 

939 ``bank_signal_quality``. 

940 """ 

941 

942 profile: str | None = None 

943 access: list[dict[str, str | list[str]]] | None = None 

944 homeostasis: HomeostasisConfig | None = None 

945 barriers: BarrierConfig | None = None 

946 signal_quality: SignalQualityConfig | None = None 

947 

948 

949@dataclass 

950class AstrocyteConfig: 

951 """Top-level Astrocyte configuration.""" 

952 

953 # Provider tier 

954 provider_tier: Literal["storage", "engine"] = "engine" 

955 

956 # Profile 

957 profile: str | None = None 

958 

959 # Engine provider 

960 provider: str | None = None 

961 provider_config: dict[str, str | int | float | bool | None] | None = None 

962 

963 # Tier 1: Storage 

964 vector_store: str | None = None 

965 vector_store_config: dict[str, str | int | float | bool | None] | None = None 

966 graph_store: str | None = None 

967 graph_store_config: dict[str, str | int | float | bool | None] | None = None 

968 document_store: str | None = None 

969 document_store_config: dict[str, str | int | float | bool | None] | None = None 

970 wiki_store: str | None = None 

971 wiki_store_config: dict[str, str | int | float | bool | None] | None = None 

972 # M9 first-class mental-model storage (replaces wiki-piggyback path). 

973 # Optional — when unset, /v1/mental-models endpoints raise 501. 

974 mental_model_store: str | None = None 

975 mental_model_store_config: dict[str, str | int | float | bool | None] | None = None 

976 # M10 source-document / chunk store (provenance hierarchy). Optional — 

977 # when unset, vectors remain anonymous flat rows (backward compatible). 

978 source_store: str | None = None 

979 source_store_config: dict[str, str | int | float | bool | None] | None = None 

980 # M9 section recall — PageIndex tree + section graph. Optional. 

981 # When unset, section recall is disabled (the SPI is gated on this 

982 # store being configured). See docs/_design/recall.md. 

983 pageindex_store: str | None = None 

984 pageindex_store_config: dict[str, str | int | float | bool | None] | None = None 

985 

986 # LLM 

987 llm_provider: str | None = None 

988 llm_provider_config: dict[str, str | int | float | bool | None] | None = None 

989 embedding_provider: str | None = None 

990 embedding_provider_config: dict[str, str | int | float | bool | None] | None = None 

991 

992 # Fallback 

993 fallback_strategy: str = "error" # "local_llm" | "error" | "degrade" 

994 

995 # Policy 

996 homeostasis: HomeostasisConfig = field(default_factory=HomeostasisConfig) 

997 barriers: BarrierConfig = field(default_factory=BarrierConfig) 

998 signal_quality: SignalQualityConfig = field(default_factory=SignalQualityConfig) 

999 escalation: EscalationConfig = field(default_factory=EscalationConfig) 

1000 observability: ObservabilityConfig = field(default_factory=ObservabilityConfig) 

1001 access_control: AccessControlConfig = field(default_factory=AccessControlConfig) 

1002 identity: IdentityConfig = field(default_factory=IdentityConfig) 

1003 defaults: DefaultsConfig = field(default_factory=DefaultsConfig) 

1004 

1005 # MCP 

1006 mcp: McpConfig = field(default_factory=McpConfig) 

1007 

1008 # Phase 2 innovations 

1009 recall_cache: RecallCacheConfig = field(default_factory=RecallCacheConfig) 

1010 bm25_idf: Bm25IdfConfig = field(default_factory=Bm25IdfConfig) 

1011 source_aware_retrieval: SourceAwareRetrievalConfig = field( 

1012 default_factory=SourceAwareRetrievalConfig, 

1013 ) 

1014 tiered_retrieval: TieredRetrievalConfig = field(default_factory=TieredRetrievalConfig) 

1015 cross_encoder_rerank: CrossEncoderRerankConfig = field(default_factory=CrossEncoderRerankConfig) 

1016 spreading_activation: SpreadingActivationConfig = field(default_factory=SpreadingActivationConfig) 

1017 causal_links: CausalLinksConfig = field(default_factory=CausalLinksConfig) 

1018 entity_cooccurrence: EntityCooccurrenceConfig = field(default_factory=EntityCooccurrenceConfig) 

1019 structured_fact_extraction: StructuredFactExtractionConfig = field(default_factory=StructuredFactExtractionConfig) 

1020 query_analyzer: QueryAnalyzerConfig = field(default_factory=QueryAnalyzerConfig) 

1021 semantic_link_graph: SemanticLinkGraphConfig = field(default_factory=SemanticLinkGraphConfig) 

1022 agentic_reflect: AgenticReflectConfig = field(default_factory=AgenticReflectConfig) 

1023 adversarial_defense: AdversarialDefenseConfig = field(default_factory=AdversarialDefenseConfig) 

1024 recall_authority: RecallAuthorityConfig = field(default_factory=RecallAuthorityConfig) 

1025 curated_retain: CuratedRetainConfig = field(default_factory=CuratedRetainConfig) 

1026 curated_recall: CuratedRecallConfig = field(default_factory=CuratedRecallConfig) 

1027 

1028 # Compliance profile 

1029 compliance_profile: str | None = None # "gdpr" | "hipaa" | "pdpa" | None 

1030 

1031 # DLP 

1032 dlp: DlpConfig = field(default_factory=DlpConfig) 

1033 

1034 # Lifecycle 

1035 lifecycle: LifecycleConfig = field(default_factory=LifecycleConfig) 

1036 

1037 # M8 / M11 intelligence features 

1038 wiki_compile: WikiCompileConfig = field(default_factory=WikiCompileConfig) 

1039 entity_resolution: EntityResolutionConfig = field(default_factory=EntityResolutionConfig) 

1040 async_tasks: AsyncTasksConfig = field(default_factory=AsyncTasksConfig) 

1041 benchmark_preset: BenchmarkPresetConfig = field(default_factory=BenchmarkPresetConfig) 

1042 # M18a-2 — directive-style preference compile (off by default; flip via 

1043 # this field per-bank or env var ASTROCYTE_M18_ENABLE_DIRECTIVE_COMPILE=1). 

1044 directive_compile: DirectiveCompileConfig = field(default_factory=DirectiveCompileConfig) 

1045 # M18a-3 spreading_activation field already declared above (line ~969, 

1046 # alongside cross_encoder_rerank, causal_links, etc.) — duplicate 

1047 # declaration removed when the duplicate dataclass was deleted. 

1048 # M18a-4 — episodic event index (retain-side tag + recall-side search). 

1049 # Both call paths integrated via document_postprocess + fact_recall in 

1050 # core. Off by default; flip via this field per-bank or env var 

1051 # ASTROCYTE_M18_ENABLE_EPISODIC_EXTRACTION=1. 

1052 episodic_extract: EpisodicExtractConfig = field( 

1053 default_factory=EpisodicExtractConfig, 

1054 ) 

1055 # M18a-4 follow-up — preference compile gate (was always-on in bench 

1056 # before M18). Default True for back-compat; off only when ablating. 

1057 preference_compile: PreferenceCompileConfig = field( 

1058 default_factory=PreferenceCompileConfig, 

1059 ) 

1060 

1061 # MIP (Memory Intent Protocol) 

1062 mip_config_path: str | None = None # Path to mip.yaml 

1063 

1064 # Per-bank overrides 

1065 banks: dict[str, BankConfig] | None = None 

1066 

1067 # Top-level access grants (merged with banks.*.access by access_grants_for_astrocyte) 

1068 access_grants: list[AccessGrant] | None = None 

1069 

1070 # ADR-003 (v0.5.0 with M1) 

1071 sources: dict[str, SourceConfig] | None = None 

1072 agents: dict[str, AgentRegistrationConfig] | None = None 

1073 deployment: DeploymentConfig | None = None 

1074 extraction_profiles: dict[str, ExtractionProfileConfig] | None = None 

1075 

1076 def _bank(self, bank_id: str | None) -> BankConfig | None: 

1077 return (self.banks or {}).get(bank_id) if bank_id else None 

1078 

1079 def bank_homeostasis(self, bank_id: str | None) -> HomeostasisConfig: 

1080 """``homeostasis`` in effect for ``bank_id`` (its override, else top-level).""" 

1081 bank = self._bank(bank_id) 

1082 return bank.homeostasis if bank and bank.homeostasis else self.homeostasis 

1083 

1084 def bank_barriers(self, bank_id: str | None) -> BarrierConfig: 

1085 """``barriers`` in effect for ``bank_id`` (its override, else top-level).""" 

1086 bank = self._bank(bank_id) 

1087 return bank.barriers if bank and bank.barriers else self.barriers 

1088 

1089 def bank_signal_quality(self, bank_id: str | None) -> SignalQualityConfig: 

1090 """``signal_quality`` in effect for ``bank_id`` (its override, else top-level).""" 

1091 bank = self._bank(bank_id) 

1092 return bank.signal_quality if bank and bank.signal_quality else self.signal_quality 

1093 

1094 

1095# --------------------------------------------------------------------------- 

1096# Loading 

1097# --------------------------------------------------------------------------- 

1098 

1099 

1100def _substitute_env_vars(value: str) -> str: 

1101 """Replace ${VAR_NAME} with environment variable values.""" 

1102 

1103 def _replace(match: re.Match[str]) -> str: 

1104 var_name = match.group(1) 

1105 env_value = os.environ.get(var_name) 

1106 if env_value is None: 

1107 return match.group(0) # Leave unresolved 

1108 return env_value 

1109 

1110 return _ENV_VAR_PATTERN.sub(_replace, value) 

1111 

1112 

1113def _find_unresolved_env_vars( 

1114 data: dict | list | str | int | float | bool | None, 

1115 path: str = "", 

1116) -> list[str]: 

1117 """Find all unresolved ${VAR_NAME} references after env var substitution. 

1118 

1119 Returns list of strings like "vector_store_config.dsn: ${DATABASE_URL}". 

1120 """ 

1121 unresolved: list[str] = [] 

1122 if isinstance(data, str): 

1123 for match in _ENV_VAR_PATTERN.finditer(data): 

1124 unresolved.append(f"{path}: ${{{match.group(1)}}}") 

1125 elif isinstance(data, dict): 

1126 for k, v in data.items(): 

1127 child_path = f"{path}.{k}" if path else str(k) 

1128 unresolved.extend(_find_unresolved_env_vars(v, child_path)) 

1129 elif isinstance(data, list): 

1130 for i, v in enumerate(data): 

1131 unresolved.extend(_find_unresolved_env_vars(v, f"{path}[{i}]")) 

1132 return unresolved 

1133 

1134 

1135def _substitute_env_recursive( 

1136 data: dict | list | str | int | float | bool | None, 

1137) -> dict | list | str | int | float | bool | None: 

1138 """Recursively substitute env vars in a parsed YAML structure.""" 

1139 if isinstance(data, str): 

1140 return _substitute_env_vars(data) 

1141 if isinstance(data, dict): 

1142 return {k: _substitute_env_recursive(v) for k, v in data.items()} 

1143 if isinstance(data, list): 

1144 return [_substitute_env_recursive(item) for item in data] 

1145 return data 

1146 

1147 

1148def _load_profile(profile_name: str) -> dict: 

1149 """Load a profile YAML from the profiles directory or a file path.""" 

1150 if profile_name.startswith("./") or profile_name.startswith("/"): 

1151 profile_path = Path(profile_name) 

1152 else: 

1153 profile_path = _PROFILES_DIR / f"{profile_name}.yaml" 

1154 

1155 if not profile_path.exists(): 

1156 raise ConfigError(f"Profile not found: {profile_path}") 

1157 

1158 try: 

1159 with open(profile_path, encoding="utf-8") as f: 

1160 return yaml.safe_load(f) or {} 

1161 except yaml.YAMLError as exc: 

1162 raise ConfigError(f"Invalid YAML in {profile_path}: {exc}") from exc 

1163 

1164 

1165_COMPLIANCE_PROFILES_DIR = _PROFILES_DIR / "compliance" 

1166 

1167 

1168def _load_compliance_profile(name: str) -> dict: 

1169 """Load a compliance profile YAML (gdpr, hipaa, pdpa).""" 

1170 if name.startswith("./") or name.startswith("/"): 

1171 profile_path = Path(name) 

1172 else: 

1173 profile_path = _COMPLIANCE_PROFILES_DIR / f"{name}.yaml" 

1174 

1175 if not profile_path.exists(): 

1176 raise ConfigError(f"Compliance profile not found: {profile_path}") 

1177 

1178 try: 

1179 with open(profile_path, encoding="utf-8") as f: 

1180 return yaml.safe_load(f) or {} 

1181 except yaml.YAMLError as exc: 

1182 raise ConfigError(f"Invalid YAML in {profile_path}: {exc}") from exc 

1183 

1184 

1185def _deep_merge(base: dict, override: dict) -> dict: 

1186 """Deep merge override into base. Override values win.""" 

1187 result = base.copy() 

1188 for key, value in override.items(): 

1189 if key in result and isinstance(result[key], dict) and isinstance(value, dict): 

1190 result[key] = _deep_merge(result[key], value) 

1191 else: 

1192 result[key] = value 

1193 return result 

1194 

1195 

1196def _filter_dataclass_fields(cls: type, data: dict, *, drop_none: bool = False) -> dict: 

1197 """Filter dict to valid dataclass fields; optionally drop ``None`` values.""" 

1198 valid = {f.name for f in fields(cls)} 

1199 return {k: v for k, v in data.items() if k in valid and (not drop_none or v is not None)} 

1200 

1201 

1202_SENSITIVE_FIELD_PATTERNS = frozenset( 

1203 _BASE_SENSITIVE_FIELD_KEYS 

1204 + ( 

1205 "dsn", 

1206 "connection_string", 

1207 "credentials", 

1208 "auth", 

1209 "jwks_url", 

1210 "issuer", 

1211 "audience", 

1212 ) 

1213) 

1214 

1215 

1216def _is_sensitive_field(ref: str) -> bool: 

1217 """Return True if an unresolved env-var reference is in a security-sensitive field.""" 

1218 # ref format: "path.to.field: ${VAR}" — extract field name before the colon 

1219 field_path = ref.split(":")[0].strip().lower() 

1220 return any(pat in field_path for pat in _SENSITIVE_FIELD_PATTERNS) 

1221 

1222 

1223def _safe_sub_dict(data: dict, key: str) -> dict: 

1224 """Safely extract a nested dict from *data*, defaulting to ``{}``.""" 

1225 val = data.get(key) 

1226 return val if isinstance(val, dict) else {} 

1227 

1228 

1229def _parse_homeostasis(data: dict) -> HomeostasisConfig: 

1230 """Parse a homeostasis config block (used at top-level and per-bank).""" 

1231 rl = _safe_sub_dict(data, "rate_limits") 

1232 q = _safe_sub_dict(data, "quotas") 

1233 return HomeostasisConfig( 

1234 recall_max_tokens=data.get("recall_max_tokens"), 

1235 reflect_max_tokens=data.get("reflect_max_tokens"), 

1236 retain_max_content_bytes=data.get("retain_max_content_bytes"), 

1237 rate_limits=RateLimitConfig(**_filter_dataclass_fields(RateLimitConfig, rl, drop_none=True)), 

1238 quotas=QuotaConfig(**_filter_dataclass_fields(QuotaConfig, q, drop_none=True)), 

1239 ) 

1240 

1241 

1242def _parse_benchmark_preset(data: dict) -> BenchmarkPresetConfig: 

1243 """Parse versioned benchmark preset metadata with nested budget blocks.""" 

1244 preset = BenchmarkPresetConfig(**_filter_dataclass_fields(BenchmarkPresetConfig, data)) 

1245 for budget_name in ("low", "mid", "high"): 

1246 budget_data = data.get(budget_name) 

1247 if isinstance(budget_data, dict): 

1248 setattr( 

1249 preset, 

1250 budget_name, 

1251 BenchmarkBudgetConfig(**_filter_dataclass_fields(BenchmarkBudgetConfig, budget_data)), 

1252 ) 

1253 return preset 

1254 

1255 

1256def _parse_barriers(data: dict) -> BarrierConfig: 

1257 """Parse a barriers config block (used at top-level and per-bank).""" 

1258 return BarrierConfig( 

1259 pii=PiiConfig(**_filter_dataclass_fields(PiiConfig, _safe_sub_dict(data, "pii"))), 

1260 validation=ValidationConfig(**_filter_dataclass_fields(ValidationConfig, _safe_sub_dict(data, "validation"))), 

1261 metadata=MetadataSanitizationConfig( 

1262 **_filter_dataclass_fields(MetadataSanitizationConfig, _safe_sub_dict(data, "metadata")) 

1263 ), 

1264 ) 

1265 

1266 

1267def _parse_signal_quality(data: dict) -> SignalQualityConfig: 

1268 """Parse a signal_quality config block (used at top-level and per-bank).""" 

1269 return SignalQualityConfig( 

1270 dedup=DedupConfig(**_filter_dataclass_fields(DedupConfig, _safe_sub_dict(data, "dedup"))), 

1271 noisy_bank=NoisyBankConfig(**_filter_dataclass_fields(NoisyBankConfig, _safe_sub_dict(data, "noisy_bank"))), 

1272 ) 

1273 

1274 

1275def _parse_escalation(data: dict) -> EscalationConfig: 

1276 """Parse an ``escalation:`` config block.""" 

1277 cb = data.get("circuit_breaker", {}) 

1278 return EscalationConfig( 

1279 circuit_breaker=CircuitBreakerConfig(**_filter_dataclass_fields(CircuitBreakerConfig, cb)), 

1280 degraded_mode=data.get("degraded_mode", "empty_recall"), 

1281 ) 

1282 

1283 

1284def _parse_recall_authority(data: dict) -> RecallAuthorityConfig: 

1285 """Parse a ``recall_authority:`` config block.""" 

1286 tiers_raw = data.get("tiers") or [] 

1287 tiers: list[RecallAuthorityTierConfig] = [] 

1288 if isinstance(tiers_raw, list): 

1289 for row in tiers_raw: 

1290 if isinstance(row, dict): 

1291 tiers.append(RecallAuthorityTierConfig(**_filter_dataclass_fields(RecallAuthorityTierConfig, row))) 

1292 tb = data.get("tier_by_bank") 

1293 tier_by_bank: dict[str, str] = {} 

1294 if isinstance(tb, dict): 

1295 tier_by_bank = {str(k): str(v) for k, v in tb.items()} 

1296 return RecallAuthorityConfig( 

1297 enabled=bool(data.get("enabled", False)), 

1298 rules_inline=data.get("rules_inline"), 

1299 rules_path=data.get("rules_path"), 

1300 apply_to_reflect=bool(data.get("apply_to_reflect", True)), 

1301 tier_by_bank=tier_by_bank, 

1302 tiers=tiers, 

1303 ) 

1304 

1305 

1306def _parse_access_grants(data: list) -> list[AccessGrant]: 

1307 """Parse an ``access_grants:`` list, validating required fields.""" 

1308 grants: list[AccessGrant] = [] 

1309 for idx, row in enumerate(data): 

1310 if not isinstance(row, dict): 

1311 continue 

1312 required_keys = ("bank_id", "principal", "permissions") 

1313 missing = [k for k in required_keys if k not in row] 

1314 if missing: 

1315 raise ConfigError( 

1316 f"Invalid access_grants entry at index {idx}: missing required field(s): {', '.join(missing)}" 

1317 ) 

1318 if not isinstance(row["permissions"], list): 

1319 raise ConfigError(f"Invalid access_grants entry at index {idx}: 'permissions' must be a list.") 

1320 grants.append( 

1321 AccessGrant( 

1322 bank_id=str(row["bank_id"]), 

1323 principal=str(row["principal"]), 

1324 permissions=[str(p) for p in row["permissions"]], 

1325 ) 

1326 ) 

1327 return grants 

1328 

1329 

1330def _parse_lifecycle(data: dict) -> LifecycleConfig: 

1331 """Parse a ``lifecycle:`` config block.""" 

1332 ttl_data = data.get("ttl", {}) 

1333 return LifecycleConfig( 

1334 enabled=data.get("enabled", False), 

1335 ttl=LifecycleTtlConfig(**_filter_dataclass_fields(LifecycleTtlConfig, ttl_data)), 

1336 ) 

1337 

1338 

1339#: Sections a bank can override, with their parsers. 

1340_BANK_SECTION_PARSERS: dict[str, Callable[[dict], Any]] = { 

1341 "homeostasis": _parse_homeostasis, 

1342 "barriers": _parse_barriers, 

1343 "signal_quality": _parse_signal_quality, 

1344} 

1345 

1346 

1347def _parse_banks(data: dict, top_level: dict | None = None) -> dict[str, BankConfig]: 

1348 """Parse a ``banks:`` config block with per-bank overrides. 

1349 

1350 Each overridable section resolves as top-level section (``top_level``, 

1351 already merged with the top-level profile) → the bank's ``profile`` → 

1352 the bank's own block, later layers winning key by key. A bank therefore 

1353 states only what differs: one that sets ``dedup.similarity_threshold`` 

1354 keeps the top-level ``enabled`` rather than resetting it to the dataclass 

1355 default. A section neither the bank nor its profile mentions stays ``None`` 

1356 (the bank uses the top-level section). Only these three sections are 

1357 per-bank; a bank profile's other sections (``defaults``, ``escalation``, 

1358 ...) do not apply. 

1359 """ 

1360 top_level = top_level or {} 

1361 banks: dict[str, BankConfig] = {} 

1362 for bid, bdata in data.items(): 

1363 if not isinstance(bdata, dict): 

1364 continue 

1365 bc = BankConfig( 

1366 profile=bdata.get("profile"), 

1367 access=bdata.get("access"), 

1368 ) 

1369 profile_data = _load_profile(bc.profile) if bc.profile else {} 

1370 for section, parse in _BANK_SECTION_PARSERS.items(): 

1371 layers = [layer.get(section) for layer in (profile_data, bdata)] 

1372 layers = [layer for layer in layers if isinstance(layer, dict)] 

1373 if not layers: 

1374 continue 

1375 base = top_level.get(section) 

1376 resolved = base if isinstance(base, dict) else {} 

1377 for layer in layers: 

1378 resolved = _deep_merge(resolved, layer) 

1379 setattr(bc, section, parse(resolved)) 

1380 banks[str(bid)] = bc 

1381 return banks 

1382 

1383 

1384def _parse_agents(data: dict) -> dict[str, AgentRegistrationConfig]: 

1385 """Parse an ``agents:`` config block.""" 

1386 agents: dict[str, AgentRegistrationConfig] = {} 

1387 for aid, adata in data.items(): 

1388 if not isinstance(adata, dict): 

1389 continue 

1390 row = dict(adata) 

1391 if row.get("banks") is None and row.get("allowed_banks") is not None: 

1392 row["banks"] = list(row["allowed_banks"]) 

1393 agents[str(aid)] = AgentRegistrationConfig(**_filter_dataclass_fields(AgentRegistrationConfig, row)) 

1394 return agents 

1395 

1396 

1397def _parse_deployment(data: dict) -> DeploymentConfig: 

1398 """Parse a ``deployment:`` config block.""" 

1399 tls: TlsConfig | None = None 

1400 if isinstance(data.get("tls"), dict): 

1401 tls = TlsConfig(**_filter_dataclass_fields(TlsConfig, data["tls"])) 

1402 dep_no_tls = {k: v for k, v in data.items() if k != "tls"} 

1403 return DeploymentConfig( 

1404 **_filter_dataclass_fields(DeploymentConfig, dep_no_tls), 

1405 tls=tls, 

1406 ) 

1407 

1408 

1409# Fields copied verbatim from the YAML dict onto AstrocyteConfig. 

1410_SCALAR_CONFIG_FIELDS = ( 

1411 "provider_tier", 

1412 "profile", 

1413 "provider", 

1414 "provider_config", 

1415 "vector_store", 

1416 "vector_store_config", 

1417 "graph_store", 

1418 "graph_store_config", 

1419 "document_store", 

1420 "document_store_config", 

1421 "wiki_store", 

1422 "wiki_store_config", 

1423 "mental_model_store", 

1424 "mental_model_store_config", 

1425 "source_store", 

1426 "source_store_config", 

1427 "pageindex_store", 

1428 "pageindex_store_config", 

1429 "llm_provider", 

1430 "llm_provider_config", 

1431 "embedding_provider", 

1432 "embedding_provider_config", 

1433 "fallback_strategy", 

1434) 

1435 

1436# Sections whose value is passed through ``_filter_dataclass_fields`` directly. 

1437_SIMPLE_SECTION_MAP: dict[str, type] = { 

1438 "observability": ObservabilityConfig, 

1439 "access_control": AccessControlConfig, 

1440 "identity": IdentityConfig, 

1441 "defaults": DefaultsConfig, 

1442 "mcp": McpConfig, 

1443 "recall_cache": RecallCacheConfig, 

1444 "bm25_idf": Bm25IdfConfig, 

1445 "source_aware_retrieval": SourceAwareRetrievalConfig, 

1446 "tiered_retrieval": TieredRetrievalConfig, 

1447 "cross_encoder_rerank": CrossEncoderRerankConfig, 

1448 "spreading_activation": SpreadingActivationConfig, 

1449 "causal_links": CausalLinksConfig, 

1450 "entity_cooccurrence": EntityCooccurrenceConfig, 

1451 "structured_fact_extraction": StructuredFactExtractionConfig, 

1452 "query_analyzer": QueryAnalyzerConfig, 

1453 "semantic_link_graph": SemanticLinkGraphConfig, 

1454 "agentic_reflect": AgenticReflectConfig, 

1455 "adversarial_defense": AdversarialDefenseConfig, 

1456 "curated_retain": CuratedRetainConfig, 

1457 "curated_recall": CuratedRecallConfig, 

1458 "wiki_compile": WikiCompileConfig, 

1459 "entity_resolution": EntityResolutionConfig, 

1460 "async_tasks": AsyncTasksConfig, 

1461 "dlp": DlpConfig, 

1462 "directive_compile": DirectiveCompileConfig, 

1463 # spreading_activation already in the map above (line ~1358) — duplicate 

1464 # key removed when the M18a-3 duplicate dataclass was consolidated. 

1465 "episodic_extract": EpisodicExtractConfig, 

1466 "preference_compile": PreferenceCompileConfig, 

1467} 

1468 

1469 

1470def _dict_to_config(data: dict) -> AstrocyteConfig: 

1471 """Convert a flat/nested dict to AstrocyteConfig with nested dataclasses.""" 

1472 config = AstrocyteConfig() 

1473 

1474 # ── Scalar fields ── 

1475 for field_name in _SCALAR_CONFIG_FIELDS: 

1476 if field_name in data: 

1477 setattr(config, field_name, data[field_name]) 

1478 

1479 # ── Simple nested sections (filter + construct) ── 

1480 for section, cls in _SIMPLE_SECTION_MAP.items(): 

1481 if section in data: 

1482 setattr(config, section, cls(**_filter_dataclass_fields(cls, data[section]))) 

1483 

1484 # ── Complex nested sections (dedicated parsers) ── 

1485 if "homeostasis" in data: 

1486 config.homeostasis = _parse_homeostasis(data["homeostasis"]) 

1487 

1488 if "barriers" in data: 

1489 config.barriers = _parse_barriers(data["barriers"]) 

1490 

1491 if "escalation" in data: 

1492 config.escalation = _parse_escalation(data["escalation"]) 

1493 

1494 if "signal_quality" in data: 

1495 config.signal_quality = _parse_signal_quality(data["signal_quality"]) 

1496 

1497 if "benchmark_preset" in data and isinstance(data["benchmark_preset"], dict): 

1498 config.benchmark_preset = _parse_benchmark_preset(data["benchmark_preset"]) 

1499 

1500 if "recall_authority" in data and isinstance(data["recall_authority"], dict): 

1501 config.recall_authority = _parse_recall_authority(data["recall_authority"]) 

1502 

1503 if "access_grants" in data and data["access_grants"]: 

1504 config.access_grants = _parse_access_grants(data["access_grants"]) 

1505 

1506 if "lifecycle" in data: 

1507 config.lifecycle = _parse_lifecycle(data["lifecycle"]) 

1508 

1509 if "banks" in data and data["banks"]: 

1510 config.banks = _parse_banks(data["banks"], data) 

1511 

1512 if "extraction_profiles" in data and isinstance(data["extraction_profiles"], dict): 

1513 profiles: dict[str, ExtractionProfileConfig] = {} 

1514 for pname, pdata in data["extraction_profiles"].items(): 

1515 if isinstance(pdata, dict): 

1516 profiles[str(pname)] = ExtractionProfileConfig( 

1517 **_filter_dataclass_fields(ExtractionProfileConfig, pdata) 

1518 ) 

1519 config.extraction_profiles = profiles 

1520 

1521 if "sources" in data and isinstance(data["sources"], dict): 

1522 sources: dict[str, SourceConfig] = {} 

1523 for sid, sdata in data["sources"].items(): 

1524 if isinstance(sdata, dict): 

1525 sources[str(sid)] = SourceConfig(**_filter_dataclass_fields(SourceConfig, sdata)) 

1526 config.sources = sources 

1527 

1528 if "agents" in data and isinstance(data["agents"], dict): 

1529 config.agents = _parse_agents(data["agents"]) 

1530 

1531 if "deployment" in data and isinstance(data["deployment"], dict): 

1532 config.deployment = _parse_deployment(data["deployment"]) 

1533 

1534 # ── Scalar fallbacks ── 

1535 if "compliance_profile" in data: 

1536 config.compliance_profile = data["compliance_profile"] 

1537 

1538 if "mip_config_path" in data: 

1539 config.mip_config_path = data["mip_config_path"] 

1540 elif "mip" in data and isinstance(data["mip"], str): 

1541 config.mip_config_path = data["mip"] 

1542 

1543 return config 

1544 

1545 

1546def _agent_bank_list(ar: AgentRegistrationConfig) -> list[str]: 

1547 if ar.banks: 

1548 return list(ar.banks) 

1549 if ar.allowed_banks: 

1550 return list(ar.allowed_banks) 

1551 return [] 

1552 

1553 

1554def _resolve_agent_bank_ids( 

1555 patterns: list[str], 

1556 declared: set[str] | None, 

1557 *, 

1558 label: str, 

1559) -> list[str]: 

1560 """Expand glob patterns against declared bank ids; validate literals when banks: is present.""" 

1561 if not patterns: 

1562 return [] 

1563 out: list[str] = [] 

1564 for p in patterns: 

1565 has_glob = any(c in p for c in "*?[") 

1566 if has_glob: 

1567 if not declared: 

1568 raise ConfigError(f"{label}: bank pattern {p!r} uses wildcards but no banks: section is declared.") 

1569 matches = sorted(bid for bid in declared if fnmatch.fnmatch(bid, p)) 

1570 if not matches: 

1571 raise ConfigError(f"{label}: bank pattern {p!r} matches no declared banks.") 

1572 out.extend(matches) 

1573 elif declared is not None and p not in declared: 

1574 raise ConfigError(f"{label}: bank id {p!r} is not listed under banks:.") 

1575 else: 

1576 out.append(p) 

1577 return out 

1578 

1579 

1580def _validate_signal_quality(label: str, sq: SignalQualityConfig) -> None: 

1581 if sq.dedup.action not in DEDUP_ACTIONS: 

1582 raise ConfigError(f"{label}.dedup.action={sq.dedup.action!r} must be one of {', '.join(DEDUP_ACTIONS)}") 

1583 if sq.noisy_bank.action not in NOISY_BANK_ACTIONS: 

1584 raise ConfigError( 

1585 f"{label}.noisy_bank.action={sq.noisy_bank.action!r} must be one of {', '.join(NOISY_BANK_ACTIONS)}" 

1586 ) 

1587 

1588 

1589def validate_astrocyte_config(config: AstrocyteConfig) -> None: 

1590 """Cross-field checks for ADR-003 sections (v0.5.0 with M1).""" 

1591 _validate_signal_quality("signal_quality", config.signal_quality) 

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

1593 if bank.signal_quality is not None: 

1594 _validate_signal_quality(f"banks.{bank_id}.signal_quality", bank.signal_quality) 

1595 

1596 # M9 / ADR-008: Apache AGE was removed entirely. Existing configs that 

1597 # still set ``graph_store: age`` must be migrated. There is no migration 

1598 # tool — operators rebuild banks from raw memory_units. See 

1599 # docs/_design/adr/adr-008-section-graph-replaces-age.md. 

1600 if (config.graph_store or "").strip().lower() == "age": 

1601 raise ConfigError( 

1602 "graph_store: age was removed in M9. Apache AGE is no longer " 

1603 "supported; the recall path now uses flat tables + SQL CTEs " 

1604 "(Hindsight pattern). To upgrade: remove the `graph_store: age` " 

1605 "line from your config, then rebuild any AGE-backed banks from " 

1606 "raw memory_units. See docs/_design/adr/adr-008-section-graph-" 

1607 "replaces-age.md for rationale and the rebuild command." 

1608 ) 

1609 

1610 # M9: ``extraction_mode: concise`` was removed. The legacy concise path 

1611 # paraphrased chunk text, which lost the surface vocabulary question 

1612 # embeddings rely on; recall_hit_rate dropped sharply (2026-05-02 

1613 # finding). Verbatim is now the only supported mode. 

1614 sfe_mode = (config.structured_fact_extraction.extraction_mode or "verbatim").strip().lower() 

1615 if sfe_mode != "verbatim": 

1616 raise ConfigError( 

1617 f"structured_fact_extraction.extraction_mode={sfe_mode!r} is not " 

1618 "supported. The 'concise' path was removed in M9 (it caused severe " 

1619 "recall_hit_rate degradation — see the 2026-05-02 finding). Drop " 

1620 "the extraction_mode line entirely (verbatim is the default), or " 

1621 "set extraction_mode: verbatim explicitly." 

1622 ) 

1623 

1624 if config.sources: 

1625 from astrocyte.pipeline.extraction import merged_extraction_profiles 

1626 

1627 profiles = merged_extraction_profiles(config) 

1628 for name, src in config.sources.items(): 

1629 if not (src.type or "").strip(): 

1630 raise ConfigError(f"sources.{name}: type is required") 

1631 st = (src.type or "").strip().lower() 

1632 if st == "proxy": 

1633 if not (src.url or "").strip(): 

1634 raise ConfigError(f"sources.{name}: type proxy requires url") 

1635 if not (src.target_bank or "").strip(): 

1636 raise ConfigError(f"sources.{name}: type proxy requires target_bank") 

1637 if st in ("poll", "api_poll"): 

1638 driver = (src.driver or "").strip().lower() 

1639 if driver != "github": 

1640 raise ConfigError( 

1641 f"sources.{name}: poll driver {driver!r} is not supported (use github; " 

1642 "install astrocyte-ingestion-github)" 

1643 ) 

1644 if not src.interval_seconds or int(src.interval_seconds) < 60: 

1645 raise ConfigError( 

1646 f"sources.{name}: type poll requires interval_seconds >= 60 (GitHub API rate limits)" 

1647 ) 

1648 pr = (src.path or "").strip() 

1649 if not pr or "/" not in pr or pr.count("/") != 1: 

1650 raise ConfigError( 

1651 f"sources.{name}: type poll with driver github requires path: owner/repo (got {pr!r})" 

1652 ) 

1653 if not (src.target_bank or "").strip() and not (src.target_bank_template or "").strip(): 

1654 raise ConfigError(f"sources.{name}: type poll requires target_bank or target_bank_template") 

1655 tok = (src.auth or {}).get("token") if src.auth else None 

1656 if not (str(tok).strip() if tok is not None else ""): 

1657 raise ConfigError( 

1658 f"sources.{name}: type poll with driver github requires auth.token " 

1659 "(GitHub personal access token or fine-grained token)" 

1660 ) 

1661 if st == "stream": 

1662 driver = (src.driver or "redis").strip().lower() 

1663 if driver not in ("redis", "kafka"): 

1664 raise ConfigError(f"sources.{name}: stream driver {driver!r} is not supported (use redis or kafka)") 

1665 if not (src.url or "").strip(): 

1666 raise ConfigError( 

1667 f"sources.{name}: type stream requires url (Redis URL or Kafka bootstrap servers)" 

1668 ) 

1669 if not (src.topic or "").strip(): 

1670 tlabel = "Redis stream key / Kafka topic" 

1671 raise ConfigError(f"sources.{name}: type stream requires topic ({tlabel})") 

1672 if not (src.consumer_group or "").strip(): 

1673 raise ConfigError(f"sources.{name}: type stream requires consumer_group") 

1674 if not (src.target_bank or "").strip() and not (src.target_bank_template or "").strip(): 

1675 raise ConfigError(f"sources.{name}: type stream requires target_bank or target_bank_template") 

1676 if src.extraction_profile: 

1677 if src.extraction_profile not in profiles: 

1678 raise ConfigError( 

1679 f"sources.{name}: extraction_profile {src.extraction_profile!r} not found under extraction_profiles" 

1680 ) 

1681 

1682 if config.agents: 

1683 declared = set(config.banks.keys()) if config.banks else None 

1684 for agent_id, ar in config.agents.items(): 

1685 label = f"agents.{agent_id}" 

1686 _resolve_agent_bank_ids(_agent_bank_list(ar), declared, label=label) 

1687 

1688 ident = config.identity 

1689 if ident.resolver is not None and ident.resolver not in ("convention", "config", "custom"): 

1690 raise ConfigError(f"identity.resolver must be 'convention', 'config', or 'custom', got {ident.resolver!r}") 

1691 

1692 if config.recall_authority.enabled and config.recall_authority.tiers: 

1693 ra = config.recall_authority 

1694 ids: list[str] = [] 

1695 for t in ra.tiers: 

1696 tid = (t.id or "").strip() 

1697 if not tid: 

1698 raise ConfigError("recall_authority.tiers: each tier must have a non-empty id") 

1699 ids.append(tid) 

1700 if len(ids) != len(set(ids)): 

1701 raise ConfigError("recall_authority.tiers: duplicate id") 

1702 

1703 

1704def _grants_from_agents(config: AstrocyteConfig) -> list[AccessGrant]: 

1705 if not config.agents: 

1706 return [] 

1707 declared = set(config.banks.keys()) if config.banks else None 

1708 out: list[AccessGrant] = [] 

1709 for agent_id, ar in config.agents.items(): 

1710 principal = ar.principal or f"agent:{agent_id}" 

1711 perms = ar.permissions or ["read", "write"] 

1712 label = f"agents.{agent_id}" 

1713 for bid in _resolve_agent_bank_ids(_agent_bank_list(ar), declared, label=label): 

1714 out.append(AccessGrant(bank_id=bid, principal=principal, permissions=list(perms))) 

1715 return out 

1716 

1717 

1718def _dedupe_grants(grants: list[AccessGrant]) -> list[AccessGrant]: 

1719 seen: set[tuple[str, str, tuple[str, ...]]] = set() 

1720 out: list[AccessGrant] = [] 

1721 for g in grants: 

1722 key = (g.bank_id, g.principal, tuple(sorted(g.permissions))) 

1723 if key in seen: 

1724 continue 

1725 seen.add(key) 

1726 out.append(g) 

1727 return out 

1728 

1729 

1730def access_grants_for_astrocyte(config: AstrocyteConfig) -> list[AccessGrant]: 

1731 """Flatten ``access_grants``, ``banks.*.access``, and ``agents:``-derived grants into one list for ``Astrocyte.set_access_grants``.""" 

1732 out: list[AccessGrant] = [] 

1733 if config.access_grants: 

1734 out.extend(config.access_grants) 

1735 if config.banks: 

1736 for bank_id, bc in config.banks.items(): 

1737 if not bc.access: 

1738 continue 

1739 for idx, row in enumerate(bc.access): 

1740 if not isinstance(row, dict): 

1741 continue 

1742 label = f"banks.{bank_id}.access[{idx}]" 

1743 if "principal" not in row: 

1744 raise ConfigError(f"{label} missing required key: principal") 

1745 if "permissions" not in row or not isinstance(row["permissions"], list): 

1746 raise ConfigError(f"{label} missing or invalid 'permissions' (must be a list)") 

1747 out.append( 

1748 AccessGrant( 

1749 bank_id=bank_id, 

1750 principal=str(row["principal"]), 

1751 permissions=[str(p) for p in row["permissions"]], 

1752 ) 

1753 ) 

1754 out.extend(_grants_from_agents(config)) 

1755 return _dedupe_grants(out) 

1756 

1757 

1758def load_config(path: str | Path) -> AstrocyteConfig: 

1759 """Load Astrocyte configuration from a YAML file. 

1760 

1761 Resolution order: compliance profile → behavior profile → user config → per-bank overrides. 

1762 Environment variables are substituted (${VAR_NAME}). 

1763 """ 

1764 config_path = Path(path) 

1765 if not config_path.exists(): 

1766 raise ConfigError(f"Config file not found: {config_path}") 

1767 

1768 try: 

1769 raw = yaml.safe_load(read_user_text(config_path)) or {} 

1770 except yaml.YAMLError as exc: 

1771 raise ConfigError(f"Invalid YAML in {config_path}: {exc}") from exc 

1772 

1773 # Substitute environment variables 

1774 raw = _substitute_env_recursive(raw) 

1775 

1776 # Check for unresolved env vars — fail on sensitive fields, warn on others 

1777 unresolved = _find_unresolved_env_vars(raw) 

1778 if unresolved: 

1779 import logging 

1780 

1781 _cfg_logger = logging.getLogger("astrocyte.config") 

1782 sensitive = [r for r in unresolved if _is_sensitive_field(r)] 

1783 if sensitive: 

1784 raise ConfigError("Unresolved environment variables in sensitive config fields: " + "; ".join(sensitive)) 

1785 for ref in unresolved: 

1786 _cfg_logger.warning("Unresolved environment variable in config: %s", ref) 

1787 

1788 # Merge order: compliance (lowest) → behavior profile → user config (highest). 

1789 # _deep_merge(base, override) → override wins. 

1790 # Build base from lowest priority, then let higher priority layers override. 

1791 base: dict = {} 

1792 

1793 compliance_name = raw.get("compliance_profile") 

1794 if compliance_name: 

1795 compliance_data = _load_compliance_profile(compliance_name) 

1796 base = _deep_merge(base, compliance_data) 

1797 

1798 profile_name = raw.get("profile") 

1799 if profile_name: 

1800 profile_data = _load_profile(profile_name) 

1801 base = _deep_merge(base, profile_data) 

1802 

1803 # User config wins over everything 

1804 merged = _deep_merge(base, raw) 

1805 

1806 cfg = _dict_to_config(merged) 

1807 if cfg.mip_config_path: 

1808 mip = Path(cfg.mip_config_path) 

1809 if not mip.is_absolute(): 

1810 cfg.mip_config_path = str((config_path.parent / mip).resolve()) 

1811 validate_astrocyte_config(cfg) 

1812 return cfg