popoto.recipes.question_queue¶
popoto.recipes.question_queue
¶
M7 question queue: a rationed clarifying-question channel (#566).
The memory layer can detect that it is uncertain -- an M5 disjunct pair, a
confidence-gate refusal, an M4 evidence gap -- and until now could do nothing
about it except refuse. This module gives that uncertainty one shared, strictly
rationed outlet. Every subsystem that wants to ask the person something writes
a :class:QuestionCandidate with :func:propose instead of asking directly,
and the host application asks the queue, once per turn, whether there is
anything worth asking with :func:next_question.
The contract ends at "the next question to ask, if any." Nothing here writes to a transport or addresses a person. How a rider question attaches to an assistant reply is host-application territory.
Five rules shape every function below:
- Turns are host-supplied. Every public call takes a monotonic
turn: int. The library owns no turn counter: the host is the only party that actually knows what a turn is, and a library-side counter would be wrong under concurrency. - The value-of-information gate is a deterministic boolean computed from
stored metadata (:func:
passes_voi_gate): the candidate carries a knownambiguity_signaland was recently used. There is no probabilistic VOI score, on purpose. - The budget is structural. At most one delivery per
QUESTION_BUDGET_TURNSturns per agent, enforced by a Lua script (:data:_DELIVER_LUA) that grants the budget and claims a candidate in one step, so budget is spent only when a question is actually delivered -- never an in-process counter, never reset on restart, never rewound by a regressed turn. - Answers are evidence, never overwrites. An
answeredreply is mapped onto the existing closed outcome vocabulary (acted/contradicted) and applied throughObservationProtocol.on_context_usedplusQUESTION_ANSWER_WEIGHT - 1extra capped-Bayesian observations. It never sets_superseded_by, so it never closes a validity interval. A later contradiction still moves confidence. - Fail closed.
propose*, :func:next_questionand :func:record_answerlog and returnNone/ a non-applied result on any Redis error; they never raise into the caller. Invalid input (an unknown kind, an option whoseactedandcontradictedlists overlap) is a producer bug and still raisesValueError.
The free-text answer is never stored or logged -- only the matched option
index (answer_option) -- because a reply can echo sensitive content.
Extension seam for producers¶
A producer whose source of ambiguity can disappear before the question is
asked (an M5 disjoin annotation that gets retracted) registers a staleness
check with :func:register_staleness_check. :func:expire_stale -- which
:func:next_question runs before every delivery -- expires any open candidate
of that kind for which the check returns True, so a stale question is
never asked. The M5 disjunction producer registers exactly such a check at
import time (:func:_disjunction_is_stale).
Producers¶
Three thin adapters turn an existing ambiguity signal into a :func:propose
call. Each is a separate function the host calls when, and only when, it wants
that source -- so each is independently disableable by not calling it, and the
queue works with any subset (including none):
- :func:
propose_from_disjunctions-- M5disjoinannotations (#564). Importsreconciliationlazily, so the queue works without M5. - :func:
propose_from_gate-- a confidence-gate refusal inassemble()metadata. Dormant unless the host configuresContextAssembler(confidence_gate_threshold=...): without a threshold there is nometadata["gate"]and the producer has no input. - :func:
propose_from_resolution-- M4evidence_gapreferences on aResolutionRecord(#563).
All three fail closed: on any error they log and return 0 / None and
never raise into the caller.
Example::
from popoto.recipes import question_queue as qq
qq.propose(
agent_id="a1",
question_text="Does Dana prefer morning or afternoon meetings?",
kind="disjunction",
source_module="reconciliation",
target_keys=[key_a, key_b],
options=[
{"label": "morning", "acted": [key_a], "contradicted": [key_b]},
{"label": "afternoon", "acted": [key_b], "contradicted": [key_a]},
],
ambiguity_signal="disjunction",
turn=12,
)
q = qq.next_question("a1", turn=13, query_cues="morning meetings with Dana")
if q is not None:
... # host phrases and delivers q.question_text
qq.record_answer(q, "morning", turn=14)
QuestionCandidate
¶
Bases: Model
One persisted clarifying question awaiting the gate, the budget and a relevant moment.
A real Model rather than a ZSET because the question is persisted state
with a payload and a status (RecallProposal is payload-free and
TTL-expiring, and cannot be retrofitted).
status, ask_count, delivered_turn, answer_option,
cooldown_until and resolved_turn are deliberately unindexed
plain fields: delivery and :func:record_answer write them with Lua
directly on the model hash, which an index would not see. Reads go through the indexed agent_id.
Fields
question_text: The question, phrased by the producer (or by the host
later). Echoes content, so retention is bounded by prune().
kind: One of :data:KINDS.
source_module: Free-form producer name (e.g. "reconciliation").
target_keys: Redis keys of the facts the answer would resolve.
options: [{"label": str, "acted": [keys], "contradicted": [keys]}].
Answering applies exactly the chosen option's two lists.
ambiguity_signal: One of :data:AMBIGUITY_SIGNALS.
status: One of :data:STATUSES.
ask_count: Times delivered. Re-asking after cooldown is legal.
created_turn / expires_turn: Proposal turn and created + N.
last_seen_turn: Most recent turn the ambiguity was re-observed or the
targets were used -- the VOI gate's impact factor.
cooldown_until: Not deliverable before this turn (after a
deflected/unrecognized reply).
answer_option: 0-based index of the chosen option. The free-text reply
itself is never stored.
resolved_turn: Turn the candidate left the open set (answered, cooled,
expired), used as the retention reference by prune().
cue_tokens: See :func:cue_tokens_for.
delivered_turn: Turn of the most recent delivery, written by the
delivery Lua. A delivered candidate that gets no reply within
QUESTION_COOLDOWN_TURNS of it is treated like a non-answer and
moved to cooled (see :func:expire_stale).
disjunction_id: Set by the M5 producer so a retracted disjoin can
expire its candidate via a registered staleness check.
agent_id: Owning agent.
Source code in src/popoto/recipes/question_queue.py
AnswerResult
dataclass
¶
Outcome of :func:record_answer.
Attributes:
| Name | Type | Description |
|---|---|---|
applied |
bool
|
True only when an |
reason |
str
|
|
classification |
Optional[str]
|
|
option_index |
Optional[int]
|
0-based chosen option for an |
Source code in src/popoto/recipes/question_queue.py
normalize_text(text)
¶
Lowercase, strip punctuation, collapse whitespace.
The single normalization used for dedup, option matching and the deflection set, so the three can never disagree about what "the same text" means.
Source code in src/popoto/recipes/question_queue.py
cue_tokens_for(*texts)
¶
Relevance-timing tokens: lowercased word tokens of length >= 3 from
texts, minus :data:_CUE_STOPWORDS, sorted and de-duplicated.
Source code in src/popoto/recipes/question_queue.py
classify_answer(options, answer_text)
¶
Deterministic three-way answer recognition.
After :func:normalize_text, in order:
- In :data:
DEFLECTION_PHRASES->("deflected", None). - Equal to exactly one option's normalized label, or its 1-based index
->
("answered", i)withi0-based. - Anything else (including a reply matching more than one option)
->
("unrecognized", None).
Source code in src/popoto/recipes/question_queue.py
passes_voi_gate(candidate, turn)
¶
The value-of-information gate: a deterministic boolean.
ambiguity_signal in AMBIGUITY_SIGNALS and recently_used, where
recently used means turn - last_seen_turn <= QUESTION_RECENT_USE_TURNS.
Both factors are stored on the candidate, so every delivered question is
traceable to the evidence that let it through.
Source code in src/popoto/recipes/question_queue.py
register_staleness_check(kind, check)
¶
Register check for candidates of kind (idempotent).
Used by producers whose source can vanish between proposal and delivery
-- e.g. the M5 disjunction producer expiring a candidate whose
disjunction_id no longer appears among the live disjoins.
Source code in src/popoto/recipes/question_queue.py
unregister_staleness_check(kind, check)
¶
Remove a previously registered check (no-op if absent).
Source code in src/popoto/recipes/question_queue.py
expire_stale(agent_id, turn)
¶
Silently expire the agent's not-yet-answered candidates that are past
expires_turn or that a registered staleness check reports stale, and
move delivered candidates ignored for QUESTION_COOLDOWN_TURNS to
cooled.
Expired candidates are status-marked and retained (prune() deletes).
Returns the number expired; 0 on a Redis error (fail closed).
Source code in src/popoto/recipes/question_queue.py
propose(agent_id, question_text, kind, source_module, target_keys, ambiguity_signal, turn, options=None, disjunction_id=None)
¶
Write a question candidate -- producers never ask directly.
Dedup: an incoming proposal is a duplicate of an existing candidate that
is open (pending/delivered/cooled) or answered within
QUESTION_RETENTION_TURNS when kind matches and the
target_keys sets intersect, or when the normalized question text
matches exactly. A duplicate is not re-created: an open duplicate is
touched (last_seen_turn bumped, feeding the gate's impact factor)
and returned; an answered one is returned unchanged.
The dedup check and the create run under a short per-agent SET NX
lock, so concurrent proposals of one question yield one candidate. If the
lock cannot be taken within a brief retry window the proposal is dropped
(fail closed): a producer re-observes its ambiguity on a later turn.
Returns:
| Type | Description |
|---|---|
Optional[QuestionCandidate]
|
The new or existing candidate; |
Optional[QuestionCandidate]
|
the propose lock stays busy, or on a Redis error. |
Raises:
| Type | Description |
|---|---|
ValueError
|
unknown |
Source code in src/popoto/recipes/question_queue.py
992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 | |
note_use(agent_id, keys, turn)
¶
Record that the host just injected keys into context at turn.
Bumps last_seen_turn (never backwards) on every open candidate whose
target_keys intersect keys -- the "recently used" half of the VOI
gate. Returns the number of candidates touched; 0 when disabled or on
a Redis error.
Source code in src/popoto/recipes/question_queue.py
next_question(agent_id, turn, query_cues=None)
¶
The whole delivery contract: the next question to ask, if any.
Pipeline, in order: expiry and registered staleness checks
(:func:expire_stale); cooldown; the VOI gate (:func:passes_voi_gate);
relevance timing (query_cues must share at least one cue token with
the candidate; None disables timing); ordering by impact; and only
then one atomic script that grants the token bucket and claims the
first still-deliverable candidate (:data:_DELIVER_LUA), so the budget is
never spent when nothing is actually delivered. The delivered candidate's
ask_count is incremented and delivered_turn recorded.
Returns None -- never raises -- when disabled, when nothing passes,
when the budget is exhausted (the bucket is not reset), or on a Redis
error.
Source code in src/popoto/recipes/question_queue.py
record_answer(candidate, answer_text, turn, instances=None)
¶
Record the person's reply to a delivered question.
The reply is classified by :func:classify_answer and then:
answered-- claim, then apply. A Lua compare-and-set flipsstatusfromdelivered/pendingtoansweredand writesanswer_option. Only on a successful claim are the chosen option's effects applied (:func:_apply_option_effects). A crash between the two loses this answer's evidence; it never double-counts, because a second call fails the claim (reason="not_open"). Onlydeliveredandpendingare claimable: a late reply to a question the expiry pass already cooled as ignored also getsnot_openand writes nothing -- the question is re-asked after its cooldown instead.deflected/unrecognized-- the candidate becomescooledwithcooldown_until = turn + QUESTION_COOLDOWN_TURNS; no evidence is written, so stored confidence is bit-identical.
answer_text is never stored or logged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
candidate
|
QuestionCandidate
|
The candidate returned by :func: |
required |
answer_text
|
str
|
The person's free-text reply. |
required |
turn
|
int
|
Host turn of the reply. |
required |
instances
|
Optional[Sequence[Model]]
|
Optional target instances, used only to tell which model class each target key belongs to; fresh copies are always loaded. |
None
|
Returns:
| Type | Description |
|---|---|
AnswerResult
|
class: |
Source code in src/popoto/recipes/question_queue.py
1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 | |
prune(agent_id, current_turn)
¶
Delete the agent's non-pending candidates older than
QUESTION_RETENTION_TURNS (measured from resolved_turn, else
last_seen_turn). Pending candidates are never pruned here -- they
expire first. Returns the number deleted; 0 on a Redis error.
Source code in src/popoto/recipes/question_queue.py
propose_from_disjunctions(agent_id, turn)
¶
Propose one disjunction question per live M5 disjunct pair.
Reads reconciliation.merge_log_entries(agent_id) filtered to
kind="disjoin" and decodes each payload for the pair's two sides.
Options are exclusive by construction: A = acted:[a],
contradicted:[b], B = the mirror. disjunction_id is set so the
registered staleness check expires the candidate if the annotation is
later retracted. Calling this again re-observes the same pairs, which
dedup folds into a touch (bumping last_seen_turn).
The answer is recorded on the candidate (answer_option); the
JournalEntry sides carry no ConfidenceField, so nothing about them
changes. Feeding the answer back so the disjunction resolves is out of
scope (an M1/M5 change).
Returns:
| Type | Description |
|---|---|
int
|
The number of pairs proposed or touched; |
int
|
the kill switch is set, or on any error. Never raises. |
Source code in src/popoto/recipes/question_queue.py
propose_from_gate(agent_id, metadata, turn, query_text)
¶
Propose a confirmation question from a confidence-gate refusal.
metadata is assemble()'s metadata dict (an AssemblyResult is
accepted too). Acts only when metadata["gate"] reports gated,
mode == "refuse" and a non-empty refused_keys; k is the top
(rank-0) refused key, the one whose confidence fell below threshold.
Options: "yes" = acted:[k], "no" = contradicted:[k].
Only "refuse" mode produces a question. The assembler reports
refused_keys in "flag" mode too, but there the records were
injected into context anyway: nothing was withheld, so there is no
refusal to clarify, and asking would spend the shared budget on a fact
the agent is already using.
The question text names k as well as the query, so two refusals of
different facts under the same query stay two candidates instead of
being folded together by normalized-text dedup.
Dormant unless confidence_gate_threshold is configured on the
ContextAssembler: without it assemble() emits no gate metadata
and this producer has nothing to read.
Returns:
| Type | Description |
|---|---|
Optional[QuestionCandidate]
|
The new or existing candidate; |
Optional[QuestionCandidate]
|
when disabled, or on any error. Never raises. |
Source code in src/popoto/recipes/question_queue.py
1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 | |
propose_from_resolution(record, turn)
¶
Propose one referent question per M4 evidence_gap reference.
Reads record.references_json (the shape _serialise_reference in
extraction/resolution_log.py writes) and, for each entry whose
status == "evidence_gap", proposes its question with one option
per entry in candidates. Options carry empty acted /
contradicted lists: the answer is recorded (answer_option), never
applied as evidence.
Each reference gets its own target key,
"{record_key}#ref:{start}:{end}", so two gaps in one sentence are two
questions rather than one swallowed by key-intersection dedup.
Returns:
| Type | Description |
|---|---|
int
|
The number of references proposed or touched; |
int
|
none, when disabled, or on any error. Never raises. |
Source code in src/popoto/recipes/question_queue.py
1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 | |