diff --git a/tests/test_loop_turn_controller_contract.py b/tests/test_loop_turn_controller_contract.py index 7a2ad3b367..fe60fee488 100644 --- a/tests/test_loop_turn_controller_contract.py +++ b/tests/test_loop_turn_controller_contract.py @@ -325,3 +325,100 @@ def test_conjunction_does_not_depend_on_json_object_key_order(monkeypatch): for row, outcomes in _ROWS: for route, expected in zip(_ROUTES, outcomes, strict=True): test_independent_full_decision_cells(row, route, expected) + + +# The controller evaluates `rules` in order and stops at the first match, so a +# rule that refines another must stay in front of it. Key order inside one rule +# is immaterial (see the conjunction test above); this sequence is not. +_RULE_SEQUENCE = ( + "check_envelope", + "check_decision_actor", + "check_receipt_binding", + "capability", + "check_initial_terminal", + "initial_terminal", + "check_initial_todo", + "initial_route", + "check_completion_terminal", + "completion_terminal", + "check_completion_todo", + "completion_successor", + "completion_active_goal", + "check_receipt_todo", + "decision_user", + "check_progress_budget", + "progress_exhausted", + "progress_route", + "receipt_replan_required", + "receipt_repair_required", + "receipt_user_action_required", + "receipt_wait", + "receipt_iteration_failed", + "host_replan", + "host_repair", + "check_host_retry", + "host_retry", + "host_exhausted", + "failed_receipt", +) + + +def test_contract_rule_sequence_is_pinned(): + """A reordered contract is a different program; regeneration must not hide it.""" + + rules = controller._LOOP_CONTROLLER_CONTRACT["rules"] + assert tuple(rule["id"] for rule in rules) == _RULE_SEQUENCE + + +def _decisions_for(contract, monkeypatch): + """Every cell's operator-visible decision: the disposition and its reason. + + Two rules can share a disposition and still explain it differently, so the + reason is part of the observable decision, not commentary. + """ + + monkeypatch.setattr(controller, "_LOOP_CONTROLLER_CONTRACT", contract) + observed = {} + for row, outcomes in _ROWS: + for route in _ROUTES: + arguments = _cell(row, route) + try: + payload = controller.decide_loop_disposition(**arguments) + except ValueError: + observed[(row, route)] = ("reject", "reject") + else: + observed[(row, route)] = ( + payload["disposition"], + str(payload["reason"]), + ) + return observed + + +@pytest.mark.parametrize( + "specific, general", + [ + ("progress_exhausted", "progress_route"), + ("host_replan", "failed_receipt"), + ("host_repair", "failed_receipt"), + ("host_retry", "failed_receipt"), + ("host_exhausted", "failed_receipt"), + ], +) +def test_a_refining_rule_must_precede_the_rule_it_refines(monkeypatch, specific, general): + """Moving the specific rule behind the general one must change a real decision. + + This is what `_RULE_SEQUENCE` protects: each pinned precedence is load + bearing, so the pin is not a restatement of the shipped file. + """ + + contract = deepcopy(controller._LOOP_CONTROLLER_CONTRACT) + ids = [rule["id"] for rule in contract["rules"]] + assert ids.index(specific) < ids.index(general) + moved = next(rule for rule in contract["rules"] if rule["id"] == specific) + rules = [rule for rule in contract["rules"] if rule["id"] != specific] + rules.insert([rule["id"] for rule in rules].index(general) + 1, moved) + contract["rules"] = rules + + shipped = _decisions_for(deepcopy(controller._LOOP_CONTROLLER_CONTRACT), monkeypatch) + observed = _decisions_for(contract, monkeypatch) + assert observed != shipped