Questions and answer types¶
from solvi import Answer, Question
Question("ship", "Ship now?", Answer.yes_no(), requires=["paid"])
Question("risk", "Risk level", Answer.choice(["low", "medium", "high"]))
Question("route", "Which team?", Answer.choice(["a", "b"]), uses=["country", "total"])
Question(name, text, answer=None, requires=[], uses=None, min_confidence=None, require_evidence=False):
name: the key used for rules,fit, andres[name];text: human-readable wording;answer:Answer.yes_no()(options["yes", "no"]) orAnswer.choice(options)(and the types below); leave it out when the question's rule has a return type — the answer type then comes from it (see Types);requires(checkpoints=in 0.7, deprecated): parts that must be in this question's flow in every request (a missing name raisessolvi.strategist.PlanError). In the flow is not the same as run: when a hard check settles the question first, a required part after it is skipped —ask(state, early_exit=False)runs it anyway (see Early exit);uses: a hint for the strategist, the facts that matter when the question has neither a rule nor a trained head;min_confidence: an answer below this confidence abstains (statusabstain, the reason says what it would have answered);require_evidence: an answer without a supporting quote abstains (safeguard "evidence missing"; see Answer primitives).
An answer outside the options is never returned: a rule that produces one makes the question abstain
(safeguard outside_options). A rule may also return None on purpose to abstain (safeguard rule_abstained).
Ordinal and multi-label answers, option descriptions¶
Answer.ordinal(["low", "medium", "high"])— ordered levels, lowest first. A learned head answers with the median of its distribution rather than the most likely level, so a split between "low" and "high" gives "medium", not a jump.answer_type.options.index(v)gives the position.Answer.multi(["pii", "abuse", "prompt_injection"])— any subset, returned as a tuple in option order (empty tuple for none). A rule may return a list or set.fitlearns one yes/no head per option;teachupdates all of them.- Any option list may be a dict
{option: description}; descriptions are kept inanswer_type.descriptions.
Constraints between answers¶
@cat.constraint
def unsafe_if_harm(verdict, harm): # argument names are question names
return harm == "none" or verdict == "unsafe"
After all questions are answered, solvi checks every constraint whose questions were asked. If learned answers break one, it
searches for the most probable combination of learned answers (from their distributions; multi-label answers per option) that
satisfies every constraint, and appends "changed from … to satisfy …" to the reason. Answers from rules, hard checks and
abstentions never change. res.feasible says whether the final answers satisfy every constraint; if fixed answers conflict,
it is False and res.violations names the constraints.
What to know about constraints:
- A constraint applies only when all its questions are asked in the request:
ask(state, ["verdict"])does not apply a constraint betweenverdictandharm, so a learned answer may differ from the oneask(state)gives. - Its argument names are question names.
System(...)raisesValueErrorfor a constraint that reads a name that is not one of its questions (a typo would otherwise mean the constraint never applies), and a second constraint with the name of an earlier one raises when it is declared. - A constraint that raises counts as broken; the exception is in the reason of every answer it reads (
constraint one_owner raised TypeError: …). - The search is exhaustive only up to 50,000 combinations of the candidate answers — 15 yes/no answers under one
constraint. Above that only the most probable answers of each question are tried (with 16 or more yes/no answers:
the answers as given), so nothing may be repaired:
res.feasibleisFalse,res.violationsnames the constraints and each answer's reason saysnot repaired: … joint decoding tried only the 1 most probable answer(s) of each question (65,536 combinations of 16 answers exceed its limit of 50,000). Split such a request into groups of questions that share constraints — or, when the answers are of many items (one request each) and the rule is a count over groups of them, decide the set withsolvi.sets(below).
Decisions over a set: solvi.sets¶
A constraint between answers works inside one request. Many tasks need a rule across many requests: one counterpart per product when matching two catalogs, one owner per record when deduplicating, at most N tasks per shift. Ask each item as usual, then decide the set:
from solvi import Answer, Catalog, Decision, Question, System
from solvi.sets import AtMostOne, Item, decide_set
cat = Catalog()
@cat.rule("match")
def match(p): # any answer with probabilities: a head, a model, a Decision
return Decision("yes" if p >= 0.5 else "no", {"yes": p, "no": 1 - p})
system = System(cat, [Question("match", "The same product?", Answer.yes_no())])
pairs = [("a1", "b1", 0.95), ("a2", "b1", 0.90), ("a1", "b2", 0.90), ("a3", "b3", 0.80)]
items = [Item.of(system.ask({"p": p}), "match", id=(a, b), keys={"a": a, "b": b}) for a, b, p in pairs]
out = decide_set(items, [AtMostOne("a"), AtMostOne("b")]) # one counterpart per offer, on both sides
print(out)
# 4 items, 1 changed by at_most_one(a), at_most_one(b) (exact); 1 component(s) solved
# ('a1', 'b1'): changed from 'yes' to 'no' to satisfy at_most_one(a) on a=a1: ('a1', 'b2') holds 'yes' (0.90);
# at_most_one(b) on b=b1: ('a2', 'b1') holds 'yes' (0.90)
out[("a2", "b1")].answer, out.changed, out.feasible, out.exact
out.replay() # {"ok": True, "mismatches": [], ...}
What is chosen is the most probable combination of answers that satisfies every constraint — the product of the items' probabilities, taken as independent, as joint decoding does inside a request. Above, keeping a1–b1 (0.95) alone is less probable than keeping a2–b1 and a1–b2 (0.90 each), so a1–b1 is the one that changes.
- Items.
Item.of(response, question, id=, keys=, tie=)takes a yes/no, choice or ordinal answer with probabilities and status "ok" as free; anything else — a rule's answer without probabilities, a forced answer, an abstention — is fixed: it never changes and counts as given (an abstention counts nowhere).Item(id, probs, answer, keys)builds one from your own numbers.tie=settles combinations of equal probability: the one keeping the items with the larger tie at their answer wins (a second model's probability, say). - Constraints.
AtMostOne(key),ExactlyOne(key),Capacity(key, max=, min=)count the items of each group that holdanswer(default "yes");keyis a name inItem.keys, a function of the item, or None for one group of all;Exclusive([(id1, id2), ...])is mutual exclusion between listed items;answer=EACHmakes every answer value a group of its own (Capacity(max=3, answer=EACH): at most 3 items per shift). - How, and when it is exact. Groups that can bind link items into connected components; a component already
consistent as given keeps its answers, the others are solved.
method="exact"(default) solves each by an integer program (HiGHS throughscipy.optimize.milp): a proven optimum unlesstime_limit(seconds per component, default 10) stops it —out.exactis then False and the component's status says "time limit".method="greedy"is the stated approximation: from the surest item down, each takes its most probable answer whose groups have room, then groups below their minimum take the item that loses least. In the example it keeps a1–b1 and drops the other two. - What each answer says. A changed item cites the group that changed it and the items that hold it (
cited, and the end ofwhy): "changed from 'yes' to 'no' to satisfy at_most_one(a) on a=a1: ('a1', 'b2') holds 'yes' (0.90)". A component that cannot be satisfied (fixed answers that conflict, a minimum nobody can meet) keeps its answers as given:out.feasibleis False,out.violationsnames each broken group and its items say "not repaired". - Record and replay.
out.to_dict()/SetDecision.from_dict(d)hold every item's probabilities, the given and final answers and the groups as evaluated.out.replay()checks that the final answers satisfy every group not reported broken, fixed answers are unchanged, every change is cited, and re-solves: under "exact" a more probable combination than the recorded one is a mismatch.
Use it where the rule really is a count over groups. Compare the set decision with the items' own answers, and
method="exact" with "greedy", on held-out data of your own before choosing.
Not done here: rules that are not counts over groups — transitivity of matches (a~b and b~c → a~c), "if a then b", sums of weights; soft constraints with a cost; errors that are not independent (the objective treats them as such).