Skip to content

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, and res[name];
  • text: human-readable wording;
  • answer: Answer.yes_no() (options ["yes", "no"]) or Answer.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 raises solvi.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 (status abstain, 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. fit learns one yes/no head per option; teach updates all of them.
  • Any option list may be a dict {option: description}; descriptions are kept in answer_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 between verdict and harm, so a learned answer may differ from the one ask(state) gives.
  • Its argument names are question names. System(...) raises ValueError for 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.feasible is False, res.violations names the constraints and each answer's reason says not 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 with solvi.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 hold answer (default "yes"); key is a name in Item.keys, a function of the item, or None for one group of all; Exclusive([(id1, id2), ...]) is mutual exclusion between listed items; answer=EACH makes 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 through scipy.optimize.milp): a proven optimum unless time_limit (seconds per component, default 10) stops it — out.exact is 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 of why): "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.feasible is False, out.violations names 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).