solvi 0.2.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solvi
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Decision systems from a catalog of Python functions and checks: a strategist plans the flow, every answer comes with a quote or formula and a verifiable trace
5
5
  Keywords: decision systems,rules,document extraction,ModernBERT,explainability,verification,audit trail
6
6
  Author: mxkuzn
@@ -54,8 +54,13 @@ script that computes everything, and 152 ms when an expired policy settles the c
54
54
 
55
55
  - [solvi playground](https://huggingface.co/spaces/solvi-ai/playground): write a decision task in Python and run it, watch the
56
56
  strategist's plan, tamper with a trace and see the replay catch it, learn rules from examples.
57
- - [solvi arcade](https://huggingface.co/spaces/solvi-ai/arcade): game agents built from small functions that explain every move.
58
- - Both run **entirely in your browser** (Pyodide): no server, no GPU, nothing you type leaves the page.
57
+ - [solvi documents](https://huggingface.co/spaces/solvi-ai/documents): cited, typed answers from contracts, invoices, receipts,
58
+ leases and more — the ModernBERT extractor (ONNX) and the decisions both run in your browser; add a field by describing it.
59
+ - [solvi arcade](https://huggingface.co/spaces/solvi-ai/arcade): game agents that explain every move — tic-tac-toe, maze,
60
+ minesweeper, 20 questions, Mafia detective, a bot arena, and "hack the trace".
61
+ - [solvi realms](https://huggingface.co/spaces/solvi-ai/realms): an endless strategy game whose factions are solvi systems —
62
+ tested for 100 000 turns: flat decision time (~0.3–0.6 ms), bounded memory and state, every sampled trace replay OK.
63
+ - All run **entirely in your browser** (Pyodide): no server, no GPU, nothing you type leaves the page.
59
64
  - Models: [solvi-ai/extract-base](https://huggingface.co/solvi-ai/extract-base) (fields by description) and
60
65
  [solvi-ai/extract-receipts](https://huggingface.co/solvi-ai/extract-receipts).
61
66
 
@@ -236,6 +241,7 @@ receipt with the one-pass extractor on an A100).
236
241
  | [examples/06_learned_rules.py](examples/06_learned_rules.py) | `learn_rule`: route parcels to delivery zones from free-form addresses with a readable if-then list learned from labels |
237
242
  | [examples/09_strategy_at_scale.py](examples/09_strategy_at_scale.py) | Insurance claim desk: the strategist generates a different plan per question set, hard checks first with early exit, slow services in parallel; timed |
238
243
  | [examples/10_learn_in_milliseconds.py](examples/10_learn_in_milliseconds.py) | `fit_fast`: a new question learned in milliseconds, then corrected one example at a time (each correction ~0.2 ms, nothing retrained) |
244
+ | [examples/11_answer_types_and_constraints.py](examples/11_answer_types_and_constraints.py) | Multi-label and ordinal answers tied by constraints between answers; contradictions in learned answers are repaired by joint decoding |
239
245
  | [examples/07_receipts_model.py](examples/07_receipts_model.py) | Expense check on a scanned receipt: a receipts-tuned extractor cites each field, rules and a hard check decide (needs `solvi[model]`) |
240
246
  | [examples/08_contracts_by_description.py](examples/08_contracts_by_description.py) | Contract review with fields defined only in words: the general extractor reads the whole contract, cites clauses or says "absent" (needs `solvi[model]`) |
241
247
 
@@ -27,8 +27,13 @@ script that computes everything, and 152 ms when an expired policy settles the c
27
27
 
28
28
  - [solvi playground](https://huggingface.co/spaces/solvi-ai/playground): write a decision task in Python and run it, watch the
29
29
  strategist's plan, tamper with a trace and see the replay catch it, learn rules from examples.
30
- - [solvi arcade](https://huggingface.co/spaces/solvi-ai/arcade): game agents built from small functions that explain every move.
31
- - Both run **entirely in your browser** (Pyodide): no server, no GPU, nothing you type leaves the page.
30
+ - [solvi documents](https://huggingface.co/spaces/solvi-ai/documents): cited, typed answers from contracts, invoices, receipts,
31
+ leases and more — the ModernBERT extractor (ONNX) and the decisions both run in your browser; add a field by describing it.
32
+ - [solvi arcade](https://huggingface.co/spaces/solvi-ai/arcade): game agents that explain every move — tic-tac-toe, maze,
33
+ minesweeper, 20 questions, Mafia detective, a bot arena, and "hack the trace".
34
+ - [solvi realms](https://huggingface.co/spaces/solvi-ai/realms): an endless strategy game whose factions are solvi systems —
35
+ tested for 100 000 turns: flat decision time (~0.3–0.6 ms), bounded memory and state, every sampled trace replay OK.
36
+ - All run **entirely in your browser** (Pyodide): no server, no GPU, nothing you type leaves the page.
32
37
  - Models: [solvi-ai/extract-base](https://huggingface.co/solvi-ai/extract-base) (fields by description) and
33
38
  [solvi-ai/extract-receipts](https://huggingface.co/solvi-ai/extract-receipts).
34
39
 
@@ -209,6 +214,7 @@ receipt with the one-pass extractor on an A100).
209
214
  | [examples/06_learned_rules.py](examples/06_learned_rules.py) | `learn_rule`: route parcels to delivery zones from free-form addresses with a readable if-then list learned from labels |
210
215
  | [examples/09_strategy_at_scale.py](examples/09_strategy_at_scale.py) | Insurance claim desk: the strategist generates a different plan per question set, hard checks first with early exit, slow services in parallel; timed |
211
216
  | [examples/10_learn_in_milliseconds.py](examples/10_learn_in_milliseconds.py) | `fit_fast`: a new question learned in milliseconds, then corrected one example at a time (each correction ~0.2 ms, nothing retrained) |
217
+ | [examples/11_answer_types_and_constraints.py](examples/11_answer_types_and_constraints.py) | Multi-label and ordinal answers tied by constraints between answers; contradictions in learned answers are repaired by joint decoding |
212
218
  | [examples/07_receipts_model.py](examples/07_receipts_model.py) | Expense check on a scanned receipt: a receipts-tuned extractor cites each field, rules and a hard check decide (needs `solvi[model]`) |
213
219
  | [examples/08_contracts_by_description.py](examples/08_contracts_by_description.py) | Contract review with fields defined only in words: the general extractor reads the whole contract, cites clauses or says "absent" (needs `solvi[model]`) |
214
220
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "solvi"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "Decision systems from a catalog of Python functions and checks: a strategist plans the flow, every answer comes with a quote or formula and a verifiable trace"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "solvi"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "Decision systems from a catalog of Python functions and checks: a strategist plans the flow, every answer comes with a quote or formula and a verifiable trace"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -21,16 +21,33 @@ class Quote:
21
21
 
22
22
  @dataclass
23
23
  class AnswerType:
24
- kind: str # yes_no | choice
24
+ kind: str # yes_no | choice | ordinal | multi
25
25
  options: list
26
+ descriptions: dict = field(default_factory=dict) # option -> what it means (for people and for zero-shot scoring)
26
27
 
27
28
  def normalize(self, v):
29
+ if self.kind == "multi":
30
+ vals = [v] if isinstance(v, str) else list(v or [])
31
+ bad = [x for x in vals if x not in self.options]
32
+ if bad:
33
+ raise ValueError(f"answer {bad!r} not among {self.options}")
34
+ return tuple(o for o in self.options if o in set(vals)) # canonical: in option order, no duplicates
28
35
  if self.kind == "yes_no" and isinstance(v, bool):
29
36
  return "yes" if v else "no"
30
37
  if v not in self.options:
31
38
  raise ValueError(f"answer {v!r} is not one of {self.options}")
32
39
  return v
33
40
 
41
+ def rank(self, v):
42
+ """Position of an ordinal (or choice) answer: 0 for the first option."""
43
+ return self.options.index(v)
44
+
45
+
46
+ def _opts(options):
47
+ if isinstance(options, dict):
48
+ return list(options), dict(options)
49
+ return list(options), {}
50
+
34
51
 
35
52
  class Answer:
36
53
  @staticmethod
@@ -39,7 +56,19 @@ class Answer:
39
56
 
40
57
  @staticmethod
41
58
  def choice(options) -> AnswerType:
42
- return AnswerType("choice", list(options))
59
+ """One of the options. `options` is a list, or a dict {option: description}."""
60
+ return AnswerType("choice", *_opts(options))
61
+
62
+ @staticmethod
63
+ def ordinal(levels) -> AnswerType:
64
+ """One of ordered levels, lowest first (e.g. ["low", "medium", "high"]). A learned head answers with the median of its
65
+ distribution instead of the most likely level, so it never jumps over the middle."""
66
+ return AnswerType("ordinal", *_opts(levels))
67
+
68
+ @staticmethod
69
+ def multi(options) -> AnswerType:
70
+ """Any subset of the options (possibly empty), returned as a tuple in option order. A rule may return a list or set."""
71
+ return AnswerType("multi", *_opts(options))
43
72
 
44
73
 
45
74
  @dataclass
@@ -69,6 +98,7 @@ class Catalog:
69
98
  def __init__(self):
70
99
  self.parts: dict[str, Part] = {}
71
100
  self.rules: dict[str, Part] = {}
101
+ self.constraints: dict[str, Part] = {} # rules between answers of different questions
72
102
 
73
103
  def _add(self, kind, f, **kw):
74
104
  sig = inspect.signature(f)
@@ -76,6 +106,8 @@ class Catalog:
76
106
  if kind == "rule":
77
107
  p.name = "answer:" + p.question
78
108
  self.rules[p.question] = p
109
+ elif kind == "constraint":
110
+ self.constraints[p.name] = p
79
111
  else:
80
112
  if p.name in self.parts:
81
113
  raise ValueError(f"part {p.name} is already in the catalog")
@@ -96,5 +128,11 @@ class Catalog:
96
128
  def rule(self, question):
97
129
  return lambda f: self._add("rule", f, question=question)
98
130
 
131
+ def constraint(self, f):
132
+ """A rule between answers: argument names are question names, it returns True when the answers fit together
133
+ (e.g. `def unsafe_if_harm(verdict, harm): return harm == "none" or verdict == "unsafe"`). When learned answers break it,
134
+ solvi picks the most probable combination that satisfies every constraint; answers from rules and hard checks stay."""
135
+ return self._add("constraint", f)
136
+
99
137
  def producer(self, fact):
100
138
  return self.parts.get(fact)
@@ -18,6 +18,8 @@ class Response:
18
18
  trace: object
19
19
  values: dict
20
20
  ms: float
21
+ feasible: bool = True # do the answers satisfy every applicable constraint?
22
+ violations: list = None # names of the constraints still broken (fixed answers that conflict)
21
23
 
22
24
  def __getitem__(self, q):
23
25
  return self.results[q]
@@ -61,7 +63,8 @@ class System:
61
63
  if q.name in self.calib and r.status == "ok":
62
64
  r.confidence = _platt(r.confidence, *self.calib[q.name])
63
65
  results[q.name] = r
64
- resp = Response(results, flow, trace, vals, now_ms() - t0)
66
+ feasible, violations = self._joint(results)
67
+ resp = Response(results, flow, trace, vals, now_ms() - t0, feasible, violations)
65
68
  if self.journal:
66
69
  self._log(init_state, resp)
67
70
  return resp
@@ -109,14 +112,86 @@ class System:
109
112
  if lost: # a head never guesses from facts that could not be computed
110
113
  return Result(None, 0.0, "head features not computed: " + ", ".join(lost), "abstain")
111
114
  p = head.predict(vals)
112
- a = max(p, key=p.get)
115
+ if q.answer.kind == "multi": # p: option -> probability it applies
116
+ a = tuple(o for o in q.answer.options if p[o] >= 0.5)
117
+ base = min(max(v, 1 - v) for v in p.values())
118
+ elif q.answer.kind == "ordinal": # median of the distribution over ordered levels
119
+ acc = 0.0
120
+ for a in q.answer.options:
121
+ acc += p[a]
122
+ if acc >= 0.5:
123
+ break
124
+ base = p[a]
125
+ else:
126
+ a = max(p, key=p.get)
127
+ base = p[a]
113
128
  contrib = head.contributions(vals)
114
129
  why = ", ".join(f"{f} = {vals.get(f)!r} ({c:+.2f})" for f, c in sorted(contrib.items(), key=lambda t: -abs(t[1]))[:4])
115
130
  if soft_failed:
116
131
  why += "; failed checks: " + ", ".join(soft_failed)
117
- conf = p[a] * path_confidence(self.catalog, trace, head.features)
132
+ conf = base * path_confidence(self.catalog, trace, head.features)
118
133
  return Result(a, conf, why, probs=p)
119
134
 
135
+ # --- constraints between answers: joint decoding
136
+ def _joint(self, results, max_combos=50_000):
137
+ import itertools
138
+ import math
139
+ cons = [c for c in self.catalog.constraints.values() if all(q in results for q in c.inputs)]
140
+ if not cons:
141
+ return True, []
142
+
143
+ def ok(assign, c):
144
+ if any(assign.get(q) is None for q in c.inputs):
145
+ return True # an abstained answer: nothing to check
146
+ try:
147
+ return bool(c.func(**{q: assign[q] for q in c.inputs}))
148
+ except Exception: # noqa: BLE001
149
+ return False
150
+ current = {q: r.answer for q, r in results.items()}
151
+ if all(ok(current, c) for c in cons):
152
+ return True, []
153
+ # candidates: learned answers may change (their distribution), rule / forced / abstained answers are fixed
154
+ qs = sorted({q for c in cons for q in c.inputs})
155
+ cands = {}
156
+ for q in qs:
157
+ r, at = results[q], self.questions[q].answer
158
+ if r.status == "ok" and r.probs:
159
+ if at.kind == "multi":
160
+ opts = []
161
+ for bits in itertools.product([0, 1], repeat=len(at.options)):
162
+ combo = tuple(o for o, b in zip(at.options, bits) if b)
163
+ lp = sum(math.log(max(1e-9, r.probs[o] if b else 1 - r.probs[o])) for o, b in zip(at.options, bits))
164
+ opts.append((combo, lp))
165
+ else:
166
+ opts = [(o, math.log(max(1e-9, pr))) for o, pr in r.probs.items()]
167
+ cands[q] = sorted(opts, key=lambda t: -t[1])
168
+ else:
169
+ cands[q] = [(r.answer, 0.0)]
170
+ k = max(len(v) for v in cands.values())
171
+ while k > 1 and math.prod(min(len(v), k) for v in cands.values()) > max_combos:
172
+ k -= 1
173
+ best = None
174
+ for combo in itertools.product(*[cands[q][:k] for q in qs]):
175
+ assign = dict(current)
176
+ assign.update({q: a for q, (a, _) in zip(qs, combo)})
177
+ if all(ok(assign, c) for c in cons):
178
+ score = sum(lp for _, lp in combo)
179
+ if best is None or score > best[0]:
180
+ best = (score, assign)
181
+ if best is None:
182
+ return False, [c.name for c in cons if not ok(current, c)]
183
+ for q in qs:
184
+ r = results[q]
185
+ if best[1][q] != r.answer:
186
+ was = r.answer
187
+ r.answer = best[1][q]
188
+ if r.probs:
189
+ r.confidence = (math.exp(dict(cands[q])[r.answer]) if self.questions[q].answer.kind == "multi"
190
+ else r.probs.get(r.answer, r.confidence))
191
+ broken = [c.name for c in cons if q in c.inputs and not ok({**current}, c)]
192
+ r.why += f"; changed from {was!r} to satisfy {', '.join(broken)}"
193
+ return True, []
194
+
120
195
  # --- task-specific training
121
196
  def facts_for(self, init_state):
122
197
  """All computable facts (for head training): a "compute everything" flow without rules."""
@@ -135,7 +210,11 @@ class System:
135
210
  ans = [q.answer.normalize(a) for _, a in examples]
136
211
  keys = set(examples[0][0].keys())
137
212
  cands = sorted(f for f in computable(self.catalog, keys) - keys)
138
- self.heads[question] = Head(q.answer.options).fit(rows, ans, cands)
213
+ if q.answer.kind == "multi":
214
+ self.heads[question] = MultiHead(q.answer.options, lambda: Head(["yes", "no"]),
215
+ lambda h, ys: h.fit(rows, ys, cands)).fit(ans)
216
+ else:
217
+ self.heads[question] = Head(q.answer.options).fit(rows, ans, cands)
139
218
  return self.heads[question]
140
219
 
141
220
  def fit_fast(self, question, examples, features=None, lam=None):
@@ -150,7 +229,12 @@ class System:
150
229
  if features is None:
151
230
  keys = set(examples[0][0].keys())
152
231
  features = sorted(f for f in computable(self.catalog, keys) - keys)
153
- head = FastHead(q.answer.options, lam=lam).fit(rows, [q.answer.normalize(a) for _, a in examples], list(features))
232
+ ans = [q.answer.normalize(a) for _, a in examples]
233
+ if q.answer.kind == "multi":
234
+ head = MultiHead(q.answer.options, lambda: FastHead(["yes", "no"], lam=lam),
235
+ lambda h, ys: h.fit(rows, ys, list(features))).fit(ans)
236
+ else:
237
+ head = FastHead(q.answer.options, lam=lam).fit(rows, ans, list(features))
154
238
  head.fit_ms = (time.perf_counter() - t0) * 1000
155
239
  self.heads[question] = head
156
240
  return head
@@ -201,7 +285,7 @@ class System:
201
285
  from .fast import FastHead
202
286
  ms = None
203
287
  head = self.heads.get(question)
204
- if isinstance(head, FastHead):
288
+ if isinstance(head, (FastHead, MultiHead)) and getattr(head, "online", True):
205
289
  ms = head.update(self.facts_for(init_state), self.questions[question].answer.normalize(correct))
206
290
  if self.journal:
207
291
  with open(self.journal, "a") as fh:
@@ -216,6 +300,40 @@ class System:
216
300
  "records": [[r.step, r.name, r.hash] for r in resp.trace.records]}, ensure_ascii=False) + "\n")
217
301
 
218
302
 
303
+ class MultiHead:
304
+ """A multi-label answer as one yes/no head per option (each learned with fit or fit_fast)."""
305
+
306
+ def __init__(self, options, make, train):
307
+ self.options, self.make, self.train = list(options), make, train
308
+ self.heads = {}
309
+
310
+ def fit(self, answers):
311
+ for o in self.options:
312
+ self.heads[o] = self.train(self.make(), ["yes" if o in a else "no" for a in answers])
313
+ h = next(iter(self.heads.values()))
314
+ self.features = sorted({f for hh in self.heads.values() for f in hh.features})
315
+ self.online = hasattr(h, "update")
316
+ self.loo_acc = getattr(h, "loo_acc", None)
317
+ return self
318
+
319
+ @property
320
+ def cv_acc(self):
321
+ return self.loo_acc
322
+
323
+ def predict(self, row):
324
+ return {o: h.predict(row)["yes"] for o, h in self.heads.items()}
325
+
326
+ def contributions(self, row):
327
+ out = {}
328
+ for h in self.heads.values():
329
+ for f, c in h.contributions(row).items():
330
+ out[f] = out.get(f, 0.0) + c
331
+ return out
332
+
333
+ def update(self, row, answer):
334
+ return sum(h.update(row, "yes" if o in answer else "no") for o, h in self.heads.items())
335
+
336
+
219
337
  def governs(part, question):
220
338
  """Does a failed hard check decide this question? Yes if the question is in its `then`, names it as a checkpoint, or the
221
339
  check has no `then` at all."""
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes