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
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
1from __future__ import annotations
3import json
4import logging
5import re
6from typing import TYPE_CHECKING, Any
8from astrocyte.types import (
9 Entity,
10 MemoryHit,
11)
13if TYPE_CHECKING:
14 from astrocyte.mip.schema import PipelineSpec
18_logger = logging.getLogger("astrocyte.mip")
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.
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 )
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).
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.
69 Mapping (linear around the legacy default of skepticism=3):
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))
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.
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.
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))]
111def _resolve_skepticism_for_abstention(
112 request_dispositions,
113 fallback_enabled: bool,
114) -> int:
115 """Resolve effective skepticism for the abstention decision.
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
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 []
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 "")}
157def _entities_from_metadata(metadata: dict[str, Any] | None) -> list[Entity]:
158 """Build stable entities from structured retain metadata without an LLM call."""
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())
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