|
20 | 20 |
|
21 | 21 | import glob |
22 | 22 | import os |
| 23 | +from collections import OrderedDict |
23 | 24 | import re |
24 | 25 | import threading |
25 | 26 | import tomllib |
@@ -121,6 +122,12 @@ def __init__(self, backend, chains_dir: str, min_numerals: int = 2, mode: Option |
121 | 122 | self.chains = load_chains(chains_dir) |
122 | 123 | self.min_numerals = min_numerals |
123 | 124 | self._local = threading.local() |
| 125 | + # Binding and execution read only the state, never the question, so a |
| 126 | + # multi-question request (17 clauses of one contract) computes its facts |
| 127 | + # once. Small LRU keyed by state text; entries are immutable tuples. |
| 128 | + self._facts_cache: "OrderedDict[str, tuple]" = OrderedDict() |
| 129 | + self._facts_cache_size = int(os.environ.get("VON_CHAINS_CACHE", "64")) |
| 130 | + self._cache_lock = threading.Lock() |
124 | 131 |
|
125 | 132 | # --- recursion guard: sub-decisions must never re-enter the chain path -- |
126 | 133 | def active(self) -> bool: |
@@ -216,17 +223,18 @@ def _describe(self, chain: Chain, env: Dict[str, Any]) -> Optional[str]: |
216 | 223 | except (KeyError, ValueError, IndexError): |
217 | 224 | return None |
218 | 225 |
|
219 | | - def _run_bindall(self, state: str, q: Any, tr: Trace) -> Tuple[Optional[Any], Trace]: |
220 | | - """No routing. Every chain whose slots bind is executed; computed |
221 | | - facts are appended to the state and become spans for the next round |
222 | | - (fixpoint, bounded), so chains compose without composed definitions. |
223 | | - A chain whose value lands on an option answers through the matcher; |
224 | | - the model arbitrates only among grounded survivors, or reads the |
225 | | - state plus facts when nothing matched. Nothing reads the question.""" |
226 | | - instructions = getattr(q, "instructions", "") or "" |
| 226 | + def _facts_for(self, state: str) -> tuple: |
| 227 | + """State-only phase of bindall: propose, bind, execute, compose. |
| 228 | + Returns (facts, execs, trace_fragment); cached per state text.""" |
| 229 | + with self._cache_lock: |
| 230 | + hit = self._facts_cache.get(state) |
| 231 | + if hit is not None: |
| 232 | + self._facts_cache.move_to_end(state) |
| 233 | + return hit |
227 | 234 | facts: List[Tuple[str, str]] = [] # (chain, provenance-carrying description) |
| 235 | + execs: List[Tuple[str, Any, str]] = [] # (chain, computed value, description) for value chains |
| 236 | + frag: Dict[str, Dict[str, Any]] = {"route_probs": {}, "bindings": {}, "values": {}} |
228 | 237 | seen: set = set() # (chain, bindings) already executed |
229 | | - matched: Dict[str, Tuple[str, str]] = {} # option key -> (chain, description) |
230 | 238 | base_spans = propose(state) |
231 | 239 | derived: List[Span] = [] |
232 | 240 | self._local.calls = 0 |
@@ -266,22 +274,43 @@ def _run_bindall(self, state: str, q: Any, tr: Trace) -> Tuple[Optional[Any], Tr |
266 | 274 | prov = f"[{chain.name}: " + ", ".join(f"{k}={v}" for k, v in sub.bindings.items()) + "] " |
267 | 275 | facts.append((chain.name, prov + desc)) |
268 | 276 | new_facts += 1 |
269 | | - tr.route_probs[chain.name] = tr.route_probs.get(chain.name, 0.0) + 1.0 |
270 | | - tr.bindings.update({f"r{rnd}.{chain.name}.{k}": v for k, v in sub.bindings.items()}) |
271 | | - tr.values[f"r{rnd}.{chain.name}"] = {k: v for k, v in env.items() if k != "state" and not isinstance(v, Span)} |
272 | | - if chain.answer.get("mode") == "value" and isinstance(q, Choice): |
273 | | - val = env.get(chain.answer.get("value", "")) |
274 | | - hit = _match_any(val, q.criteria) |
275 | | - if hit and hit not in matched: |
276 | | - matched[hit] = (chain.name, prov + desc) |
| 277 | + frag["route_probs"][chain.name] = frag["route_probs"].get(chain.name, 0.0) + 1.0 |
| 278 | + frag["bindings"].update({f"r{rnd}.{chain.name}.{k}": v for k, v in sub.bindings.items()}) |
| 279 | + frag["values"][f"r{rnd}.{chain.name}"] = {k: v for k, v in env.items() if k != "state" and not isinstance(v, Span)} |
| 280 | + if chain.answer.get("mode") == "value": |
| 281 | + execs.append((chain.name, env.get(chain.answer.get("value", "")), prov + desc)) |
277 | 282 | if len(facts) >= self.max_facts: |
278 | 283 | break |
279 | 284 | if exhausted or new_facts == 0 or len(facts) >= self.max_facts: |
280 | 285 | break |
| 286 | + out = (tuple(facts), tuple(execs), frag) |
| 287 | + with self._cache_lock: |
| 288 | + self._facts_cache[state] = out |
| 289 | + while len(self._facts_cache) > self._facts_cache_size: |
| 290 | + self._facts_cache.popitem(last=False) |
| 291 | + return out |
| 292 | + |
| 293 | + def _run_bindall(self, state: str, q: Any, tr: Trace) -> Tuple[Optional[Any], Trace]: |
| 294 | + """No routing. Every chain whose slots bind is executed; computed |
| 295 | + facts are appended to the state and become spans for the next round |
| 296 | + (fixpoint, bounded), so chains compose without composed definitions. |
| 297 | + A chain whose value lands on an option answers through the matcher; |
| 298 | + the model arbitrates only among grounded survivors, or reads the |
| 299 | + state plus facts when nothing matched. Nothing reads the question.""" |
| 300 | + instructions = getattr(q, "instructions", "") or "" |
| 301 | + facts, execs, frag = self._facts_for(state) |
| 302 | + tr.route_probs.update(frag["route_probs"]) |
| 303 | + tr.bindings.update(frag["bindings"]) |
| 304 | + tr.values.update(frag["values"]) |
| 305 | + matched: Dict[str, Tuple[str, str]] = {} # option key -> (chain, description) |
| 306 | + if isinstance(q, Choice): |
| 307 | + for name, val, desc in execs: |
| 308 | + hit = _match_any(val, q.criteria) |
| 309 | + if hit and hit not in matched: |
| 310 | + matched[hit] = (name, desc) |
281 | 311 | if not facts: |
282 | 312 | tr.fallback = "bindall:nothing-executed" |
283 | 313 | return None, tr |
284 | | - |
285 | 314 | if isinstance(q, Choice) and len(matched) == 1: |
286 | 315 | (hit, (name, desc)), = matched.items() |
287 | 316 | tr.chain, tr.description = name, desc |
|
0 commit comments