Skip to content

Commit 6ae16aa

Browse files
committed
wire: accept any JSON as option/criteria descriptions (rendered to text); chains: cache state-only bind/execute phase per state so multi-question requests bind once
1 parent a930f9d commit 6ae16aa

4 files changed

Lines changed: 106 additions & 20 deletions

File tree

‎src/von/chains/runner.py‎

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import glob
2222
import os
23+
from collections import OrderedDict
2324
import re
2425
import threading
2526
import tomllib
@@ -121,6 +122,12 @@ def __init__(self, backend, chains_dir: str, min_numerals: int = 2, mode: Option
121122
self.chains = load_chains(chains_dir)
122123
self.min_numerals = min_numerals
123124
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()
124131

125132
# --- recursion guard: sub-decisions must never re-enter the chain path --
126133
def active(self) -> bool:
@@ -216,17 +223,18 @@ def _describe(self, chain: Chain, env: Dict[str, Any]) -> Optional[str]:
216223
except (KeyError, ValueError, IndexError):
217224
return None
218225

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
227234
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": {}}
228237
seen: set = set() # (chain, bindings) already executed
229-
matched: Dict[str, Tuple[str, str]] = {} # option key -> (chain, description)
230238
base_spans = propose(state)
231239
derived: List[Span] = []
232240
self._local.calls = 0
@@ -266,22 +274,43 @@ def _run_bindall(self, state: str, q: Any, tr: Trace) -> Tuple[Optional[Any], Tr
266274
prov = f"[{chain.name}: " + ", ".join(f"{k}={v}" for k, v in sub.bindings.items()) + "] "
267275
facts.append((chain.name, prov + desc))
268276
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))
277282
if len(facts) >= self.max_facts:
278283
break
279284
if exhausted or new_facts == 0 or len(facts) >= self.max_facts:
280285
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)
281311
if not facts:
282312
tr.fallback = "bindall:nothing-executed"
283313
return None, tr
284-
285314
if isinstance(q, Choice) and len(matched) == 1:
286315
(hit, (name, desc)), = matched.items()
287316
tr.chain, tr.description = name, desc

‎src/von/types.py‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,23 @@ def _stringify_instructions(v: Any) -> Any:
2222
return json.dumps(v, sort_keys=isinstance(v, dict))
2323
return v
2424

25+
26+
# Option descriptions get the same treatment: the wire format lets a criteria
27+
# value be any JSON (a chess move as {"uci", "san"}, a palette as a list of hex
28+
# colours, a number). None stays None (the key stands in for the description).
29+
def _stringify_criteria(v: Any) -> Any:
30+
if not isinstance(v, dict):
31+
return v
32+
out = {}
33+
for k, d in v.items():
34+
if d is None or isinstance(d, str):
35+
out[k] = d
36+
elif isinstance(d, (dict, list)):
37+
out[k] = json.dumps(d, sort_keys=isinstance(d, dict))
38+
else:
39+
out[k] = str(d)
40+
return out
41+
2542
class Noul(BaseModel):
2643
"""A yes/no probability question."""
2744
type: Literal["noul"] = "noul"
@@ -31,7 +48,12 @@ class Noul(BaseModel):
3148
@model_validator(mode="before")
3249
@classmethod
3350
def _fold_legacy_criteria(cls, data: Any) -> Any:
34-
if not isinstance(data, dict) or not any(k in data for k in _LEGACY_NOUL_CRITERIA):
51+
if not isinstance(data, dict):
52+
return data
53+
if not any(k in data for k in _LEGACY_NOUL_CRITERIA):
54+
if isinstance(data.get("criteria"), dict):
55+
data = dict(data)
56+
data["criteria"] = _stringify_criteria(data["criteria"])
3557
return data
3658
data = dict(data)
3759
criteria = dict(data.get("criteria") or {})
@@ -49,7 +71,7 @@ def _fold_legacy_criteria(cls, data: Any) -> Any:
4971
DeprecationWarning,
5072
stacklevel=2,
5173
)
52-
data["criteria"] = criteria or None
74+
data["criteria"] = _stringify_criteria(criteria) or None
5375
return data
5476

5577
@field_validator("instructions", mode="before")
@@ -69,6 +91,11 @@ class Choice(BaseModel):
6991
def _coerce_instructions(cls, v: Any) -> Any:
7092
return _stringify_instructions(v)
7193

94+
@field_validator("criteria", mode="before")
95+
@classmethod
96+
def _coerce_criteria(cls, v: Any) -> Any:
97+
return _stringify_criteria(v)
98+
7299

73100
class Score(BaseModel):
74101
"""A position on an ordered scale (2 to 10 levels)."""

‎tests/test_chains.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,3 +267,21 @@ def test_bindall_respects_model_call_budget():
267267
assert len(r.backend.asked) <= 3 # 2 bind calls + at most one arbitration
268268
finally:
269269
del os.environ["VON_CHAINS_MAX_CALLS"]
270+
271+
272+
def test_bindall_caches_state_only_phase_across_questions():
273+
"""A multi-question request binds once: the second question on the same
274+
state costs zero bind calls (facts come from the per-state cache), and the
275+
answer is identical."""
276+
r = _bindall({"start": "2025-03-01", "end": "2025-03-31"})
277+
state = "Annual fee is $1,200. Active from 2025-03-01 to 2025-03-31."
278+
q1 = Choice(type="choice", instructions="Prorated fee?", criteria={"a": "$101.92", "b": "$100.00"})
279+
a1, _ = r.run(state, q1)
280+
n_after_first = len(r.backend.asked)
281+
assert n_after_first > 0
282+
a2, tr2 = r.run(state, Choice(type="choice", instructions="Prorated fee, again?", criteria={"x": "$101.92", "y": "$5"}))
283+
assert len(r.backend.asked) == n_after_first # no new bind calls
284+
assert a1.choice == "a" and a2.choice == "x" and tr2.chain == "proration"
285+
# a different state is not served from the cache
286+
r.run(state.replace("1,200", "2,400"), q1)
287+
assert len(r.backend.asked) > n_after_first

‎tests/test_client.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,15 @@ async def test_async_von_client_local():
3535
},
3636
)
3737
assert res.answers["service"].choice == "database"
38+
39+
40+
def test_choice_criteria_accepts_structured_descriptions():
41+
"""Wire format allows any JSON as an option description; Von renders it to text."""
42+
from von.types import Choice, Noul
43+
c = Choice(instructions="Best move?", criteria={
44+
"c8e8": {"uci": "c8e8", "san": "Re8"}, "A": ["#ebf0ff", "#021e61"], "n": 3, "plain": "text", "none": None})
45+
assert c.criteria["c8e8"] == '{"san": "Re8", "uci": "c8e8"}'
46+
assert c.criteria["A"] == '["#ebf0ff", "#021e61"]'
47+
assert c.criteria["n"] == "3" and c.criteria["plain"] == "text" and c.criteria["none"] is None
48+
n = Noul(instructions="Hallucinated?", criteria={"true": {"means": "yes"}, "false": "no"})
49+
assert n.criteria == {"true": '{"means": "yes"}', "false": "no"}

0 commit comments

Comments
 (0)