Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion openadapt_types/human_decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,25 @@ class HumanDecisionAction(str, Enum):
``verify_and_resume`` maps to Flow's attended ``continue`` operation. It
means that the operator prepared the live state for fresh revalidation; it
never authorizes blind repetition of a prior action.

``reject`` and ``escalate`` are deliberately separate members rather than
two labels on one action, because they do opposite things to the run.
``escalate`` *parks* it: the durable pause stays intact and a qualified
colleague can still continue it. ``reject`` *terminates* it: the operator
looked at the live application and is asserting that this run must not
proceed at all. Collapsing the two would leave the recorded answer
distribution unable to distinguish "I don't know" from "stop", which is the
only reason a disagreement action is worth its cost.

``reject`` is scoped to one run. It is not a claim that the saved workflow
is wrong; that assertion changes future runs and belongs to ``teach``,
which carries the demonstration and requalification gate such authority
requires.
"""

VERIFY_AND_RESUME = "verify_and_resume"
SKIP = "skip"
REJECT = "reject"
TEACH = "teach"
ESCALATE = "escalate"

Expand Down Expand Up @@ -279,7 +294,11 @@ class HumanDecisionTaskV1(_StrictContract):
substrate: HumanDecisionSubstrate = HumanDecisionSubstrate.UNKNOWN
question: HumanDecisionQuestionV1
evidence: HumanDecisionEvidenceSummaryV1
allowed_actions: tuple[HumanDecisionAction, ...] = Field(min_length=1, max_length=4)
#: Upper bound is the size of the closed vocabulary, not a policy: a pause
#: can legitimately offer every member at once. Widening it is what a new
#: member costs, and it is why a consumer must re-validate against the
#: current schema rather than a cached copy.
allowed_actions: tuple[HumanDecisionAction, ...] = Field(min_length=1, max_length=5)
required_authn: HumanDecisionRequiredAuthn
created_at: StrictStr = Field(
min_length=20, max_length=40, pattern=_TIMESTAMP_PATTERN
Expand Down Expand Up @@ -372,6 +391,14 @@ class HumanDecisionReceiptState(str, Enum):
``escalate`` decision is durably recorded and returns immediately without
any runner continuation pending, so folding them into "accepted, pending"
would tell an operator to wait for something that is never coming.

``REJECTED`` is the outcome of a ``reject`` decision and is distinct from
both of those and from ``HALTED``. ``ESCALATED`` and
``DEMONSTRATION_REQUESTED`` leave the run paused and resumable;
``REJECTED`` ends it. ``HALTED`` is the engine's own verdict after it acted
on an answer, whereas ``REJECTED`` records that a human ended the run
without the engine attempting anything further. A consumer that collapsed
them would tell the operator the machine stopped when in fact they did.
"""

ACCEPTED_PENDING_RUNNER = "accepted_pending_runner"
Expand All @@ -382,6 +409,7 @@ class HumanDecisionReceiptState(str, Enum):
DELIVERY_UNCERTAIN = "delivery_uncertain"
DEMONSTRATION_REQUESTED = "demonstration_requested"
ESCALATED = "escalated"
REJECTED = "rejected"


class HumanDecisionReceiptReason(str, Enum):
Expand All @@ -402,6 +430,14 @@ class HumanDecisionReceiptReason(str, Enum):
DELIVERY_UNCERTAIN = "delivery_uncertain"
DEMONSTRATION_REQUESTED = "demonstration_requested"
ESCALATION_RECORDED = "escalation_recorded"
#: The only cause of ``REJECTED``. It is a single closed member on purpose:
#: a *reason* taxonomy for disagreement would be more informative, but
#: there is no evidence yet for what its members should be -- the reject
#: rate is the data needed to design them -- and a wrong taxonomy is worse
#: than none. Adding members later is additive here and does not reopen a
#: free-text hole, which is why the cause is an enum from the start rather
#: than a string that a later change could widen.
REJECTED_BY_OPERATOR = "rejected_by_operator"


#: Which causes each terminal state may carry.
Expand Down Expand Up @@ -441,6 +477,9 @@ class HumanDecisionReceiptReason(str, Enum):
HumanDecisionReceiptState.ESCALATED: frozenset(
{HumanDecisionReceiptReason.ESCALATION_RECORDED}
),
HumanDecisionReceiptState.REJECTED: frozenset(
{HumanDecisionReceiptReason.REJECTED_BY_OPERATOR}
),
}

#: States that report a *successful* effect on the workflow. Everything else is
Expand Down
11 changes: 7 additions & 4 deletions openadapt_types/schemas/human-decision-receipt-v1.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"$defs": {
"HumanDecisionAction": {
"description": "Portable action names; the runtime remains authoritative.\n\n``verify_and_resume`` maps to Flow's attended ``continue`` operation. It\nmeans that the operator prepared the live state for fresh revalidation; it\nnever authorizes blind repetition of a prior action.",
"description": "Portable action names; the runtime remains authoritative.\n\n``verify_and_resume`` maps to Flow's attended ``continue`` operation. It\nmeans that the operator prepared the live state for fresh revalidation; it\nnever authorizes blind repetition of a prior action.\n\n``reject`` and ``escalate`` are deliberately separate members rather than\ntwo labels on one action, because they do opposite things to the run.\n``escalate`` *parks* it: the durable pause stays intact and a qualified\ncolleague can still continue it. ``reject`` *terminates* it: the operator\nlooked at the live application and is asserting that this run must not\nproceed at all. Collapsing the two would leave the recorded answer\ndistribution unable to distinguish \"I don't know\" from \"stop\", which is the\nonly reason a disagreement action is worth its cost.\n\n``reject`` is scoped to one run. It is not a claim that the saved workflow\nis wrong; that assertion changes future runs and belongs to ``teach``,\nwhich carries the demonstration and requalification gate such authority\nrequires.",
"enum": [
"verify_and_resume",
"skip",
"reject",
"teach",
"escalate"
],
Expand All @@ -22,13 +23,14 @@
"expired",
"delivery_uncertain",
"demonstration_requested",
"escalation_recorded"
"escalation_recorded",
"rejected_by_operator"
],
"title": "HumanDecisionReceiptReason",
"type": "string"
},
"HumanDecisionReceiptState": {
"description": "Terminal outcome of one attended decision, as the runtime reports it.\n\n``ACCEPTED_PENDING_RUNNER`` and ``DELIVERY_UNCERTAIN`` are the two\nnon-success outcomes a phone must be able to show. ``DELIVERY_UNCERTAIN``\nis the \"may have been sent\" state: it exists so that losing the network\nafter a tap can be reported honestly instead of collapsing into either a\nsuccess or a refusal.\n\n``DEMONSTRATION_REQUESTED`` and ``ESCALATED`` are separate terminal states\nrather than variants of ``ACCEPTED_PENDING_RUNNER``: a ``teach`` or\n``escalate`` decision is durably recorded and returns immediately without\nany runner continuation pending, so folding them into \"accepted, pending\"\nwould tell an operator to wait for something that is never coming.",
"description": "Terminal outcome of one attended decision, as the runtime reports it.\n\n``ACCEPTED_PENDING_RUNNER`` and ``DELIVERY_UNCERTAIN`` are the two\nnon-success outcomes a phone must be able to show. ``DELIVERY_UNCERTAIN``\nis the \"may have been sent\" state: it exists so that losing the network\nafter a tap can be reported honestly instead of collapsing into either a\nsuccess or a refusal.\n\n``DEMONSTRATION_REQUESTED`` and ``ESCALATED`` are separate terminal states\nrather than variants of ``ACCEPTED_PENDING_RUNNER``: a ``teach`` or\n``escalate`` decision is durably recorded and returns immediately without\nany runner continuation pending, so folding them into \"accepted, pending\"\nwould tell an operator to wait for something that is never coming.\n\n``REJECTED`` is the outcome of a ``reject`` decision and is distinct from\nboth of those and from ``HALTED``. ``ESCALATED`` and\n``DEMONSTRATION_REQUESTED`` leave the run paused and resumable;\n``REJECTED`` ends it. ``HALTED`` is the engine's own verdict after it acted\non an answer, whereas ``REJECTED`` records that a human ended the run\nwithout the engine attempting anything further. A consumer that collapsed\nthem would tell the operator the machine stopped when in fact they did.",
"enum": [
"accepted_pending_runner",
"completed",
Expand All @@ -37,7 +39,8 @@
"expired",
"delivery_uncertain",
"demonstration_requested",
"escalated"
"escalated",
"rejected"
],
"title": "HumanDecisionReceiptState",
"type": "string"
Expand Down
5 changes: 3 additions & 2 deletions openadapt_types/schemas/human-decision-task-v1.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"$defs": {
"HumanDecisionAction": {
"description": "Portable action names; the runtime remains authoritative.\n\n``verify_and_resume`` maps to Flow's attended ``continue`` operation. It\nmeans that the operator prepared the live state for fresh revalidation; it\nnever authorizes blind repetition of a prior action.",
"description": "Portable action names; the runtime remains authoritative.\n\n``verify_and_resume`` maps to Flow's attended ``continue`` operation. It\nmeans that the operator prepared the live state for fresh revalidation; it\nnever authorizes blind repetition of a prior action.\n\n``reject`` and ``escalate`` are deliberately separate members rather than\ntwo labels on one action, because they do opposite things to the run.\n``escalate`` *parks* it: the durable pause stays intact and a qualified\ncolleague can still continue it. ``reject`` *terminates* it: the operator\nlooked at the live application and is asserting that this run must not\nproceed at all. Collapsing the two would leave the recorded answer\ndistribution unable to distinguish \"I don't know\" from \"stop\", which is the\nonly reason a disagreement action is worth its cost.\n\n``reject`` is scoped to one run. It is not a claim that the saved workflow\nis wrong; that assertion changes future runs and belongs to ``teach``,\nwhich carries the demonstration and requalification gate such authority\nrequires.",
"enum": [
"verify_and_resume",
"skip",
"reject",
"teach",
"escalate"
],
Expand Down Expand Up @@ -255,7 +256,7 @@
"items": {
"$ref": "#/$defs/HumanDecisionAction"
},
"maxItems": 4,
"maxItems": 5,
"minItems": 1,
"title": "Allowed Actions",
"type": "array"
Expand Down
63 changes: 62 additions & 1 deletion tests/test_human_decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ def _receipt_fields() -> dict[str, object]:
("halted", "halted", "continuation_halted"),
("needs_demonstration", "demonstration_requested", "demonstration_requested"),
("escalated", "escalated", "escalation_recorded"),
("rejected", "rejected", "rejected_by_operator"),
("<admission>", "expired", "expired"),
)

Expand All @@ -349,7 +350,11 @@ def test_receipt_represents_every_real_engine_terminal_outcome(
"reason_code": reason_code,
"report_success": state == "completed",
"action": (
"skip" if reason_code == "skipped_and_resumed" else "verify_and_resume"
"skip"
if reason_code == "skipped_and_resumed"
else "reject"
if reason_code == "rejected_by_operator"
else "verify_and_resume"
),
}
)
Expand All @@ -370,6 +375,7 @@ def test_receipt_reason_code_is_closed_and_never_free_text() -> None:
"delivery_uncertain",
"demonstration_requested",
"escalation_recorded",
"rejected_by_operator",
}

for prose in (
Expand Down Expand Up @@ -550,6 +556,7 @@ def test_receipt_action_reuses_the_portable_task_vocabulary() -> None:
assert {action.value for action in HumanDecisionAction} == {
"verify_and_resume",
"skip",
"reject",
"teach",
"escalate",
}
Expand Down Expand Up @@ -714,3 +721,57 @@ def test_the_producers_real_wire_payload_validates_unchanged() -> None:
if field.is_required()
}
assert required <= supplied


def test_a_task_can_advertise_the_complete_action_vocabulary() -> None:
"""``max_length`` bounds the vocabulary, never the offer.

A pause that is skippable, re-verifiable, rejectable, teachable, and
escalatable is a legitimate pause. When ``reject`` was added, a bound left
at four would have silently made the most permissive pause unrepresentable
-- a refusal to issue the task at all, in the exact case the operator has
the most choice.
"""
fields = _task_fields()
fields["allowed_actions"] = tuple(HumanDecisionAction)
task = sign_human_decision_task_hmac(key=b"k" * 32, fields=fields)
assert len(task.allowed_actions) == len(HumanDecisionAction) == 5
assert task.verify_hmac(b"k" * 32)


def test_rejected_is_terminal_and_distinct_from_escalated() -> None:
"""Parking a run and ending it must not project to the same receipt.

``escalate`` leaves the durable pause intact for a colleague; ``reject``
ends the run. A consumer reads the pair to decide whether to tell the
operator that someone will pick this up, and the two answers are opposite.
Neither is a success, and neither may carry ``report_success``.
"""
rejected = HumanDecisionReceiptV1.model_validate(
{
**_receipt_fields(),
"action": HumanDecisionAction.REJECT,
"state": HumanDecisionReceiptState.REJECTED,
"reason_code": HumanDecisionReceiptReason.REJECTED_BY_OPERATOR,
"report_success": None,
}
)
assert rejected.state is not HumanDecisionReceiptState.ESCALATED
assert rejected.state is not HumanDecisionReceiptState.HALTED
assert not rejected.succeeded

for state in (
HumanDecisionReceiptState.ESCALATED,
HumanDecisionReceiptState.HALTED,
HumanDecisionReceiptState.COMPLETED,
):
with pytest.raises(ValidationError, match="is not a cause of state"):
HumanDecisionReceiptV1.model_validate(
{
**_receipt_fields(),
"action": HumanDecisionAction.REJECT,
"state": state,
"reason_code": HumanDecisionReceiptReason.REJECTED_BY_OPERATOR,
"report_success": None,
}
)
Loading