From 02a7364c580ce854204e14eba388c708bc8a54ed Mon Sep 17 00:00:00 2001 From: Nitsu45 Date: Fri, 16 Oct 2026 15:55:20 +0200 Subject: [PATCH] Allow idempotent reconfirmation of resolved investigation questions --- docs/investigation-loop-v1.md | 7 ++++ .../analysis/investigator.py | 7 +++- tests/test_investigator.py | 42 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/docs/investigation-loop-v1.md b/docs/investigation-loop-v1.md index f5e0e1d..211d1e7 100644 --- a/docs/investigation-loop-v1.md +++ b/docs/investigation-loop-v1.md @@ -205,3 +205,10 @@ crashes after evidence publication, external test confirmation and byte-preservi output. The BUG-20 integration uses actual wowdiag/wowsrc data and simulated model decisions; it tests orchestration, not real model reasoning or discovery of the known fix. No paid/cloud model is called by these tests. + +Question resolution is idempotent: reconfirming an already resolved question +does not invalidate an otherwise valid decision and needs no new evidence. +Resolving any still-open question requires evidence references. Unknown question +IDs invalidate the entire decision before assessments, tools or budget changes +are applied. Mixing old confirmations with new resolutions follows the same +rules; existing question history is preserved. diff --git a/src/viano_bug_analysis/analysis/investigator.py b/src/viano_bug_analysis/analysis/investigator.py index f5e604a..0c67281 100644 --- a/src/viano_bug_analysis/analysis/investigator.py +++ b/src/viano_bug_analysis/analysis/investigator.py @@ -420,8 +420,11 @@ class Investigator: if next_action["kind"]=="reject" and update["confidence"] in {"supported","strongly_supported"}: raise InvestigationError("INVESTIGATION_CONTRADICTORY_UPDATE") questions={q["id"]:q for q in h["questions"]} - if (any(q not in questions or questions[q]["resolved"] for q in update["resolve_questions"]) - or (update["resolve_questions"] and not update["evidence_refs"])):raise InvestigationError("INVESTIGATION_QUESTION") + # Reconfirming an already resolved question is an idempotent no-op. + # Only a new resolution needs evidence; unknown IDs remain invalid. + if (any(q not in questions for q in update["resolve_questions"]) + or (any(not questions[q]["resolved"] for q in update["resolve_questions"]) + and not update["evidence_refs"])):raise InvestigationError("INVESTIGATION_QUESTION") if len(h["questions"])+len(update["add_questions"])>state["policy"]["max_questions_per_hypothesis"]: raise InvestigationError("INVESTIGATION_QUESTION_LIMIT") if any(text.casefold().strip() in {q["text"].casefold().strip() for q in h["questions"]} for text in update["add_questions"]): diff --git a/tests/test_investigator.py b/tests/test_investigator.py index 4029889..18c2078 100644 --- a/tests/test_investigator.py +++ b/tests/test_investigator.py @@ -154,6 +154,48 @@ def test_question_updates_keep_history_and_require_evidence(tool_case): assert questions[0]["resolved"] and questions[1]["id"]=="Q0002" and not questions[1]["resolved"] +@pytest.mark.parametrize('repeat_with_evidence',[False,True]) +def test_resolved_question_can_be_reconfirmed_without_blocking(tool_case,repeat_with_evidence): + def change(reply,context): + if not context['previous_result']: + return + h=context['hypotheses'][context['selected_hypothesis']] + reply['update']['resolve_questions']=['Q0001'] + if not h['questions'][0]['resolved']: + reply['update']['add_questions']=['A later question requiring evidence'] + reply['next']={'kind':'tool','operation':'source.read_context', + 'arguments':{'source_ref':'tool/code.cpp','start_line':1,'end_line':1}} + elif repeat_with_evidence: + # Mix the old confirmation with a genuinely new resolution. + reply['update']['resolve_questions'].append('Q0002') + else: + reply['update']['evidence_refs']=[] + result=run(Investigator(tool_case[0],gateway(LoopAdapter(change)))) + assert result.status=='COMPLETED' + for h in state(result)['hypotheses'].values(): + assert h['invalid_responses']==0 + assert len(h['questions'])==2 and h['questions'][0]['resolved'] + assert h['questions'][1]['resolved'] is repeat_with_evidence + snapshot=BudgetManager(tool_case[0]).snapshot(*CASE) + assert all(len(a['actions'])==2 for a in snapshot.state['accounts'].values()) + assert PackageValidator().validate(result.package).valid + + +@pytest.mark.parametrize('question',['Q0001','Q9999']) +def test_new_or_unknown_resolution_rejected_atomically_without_evidence(tool_case,question): + def change(reply,context): + if context['previous_result']: + reply['update']['resolve_questions']=[question] + reply['update']['evidence_refs']=[] + loop=Investigator(tool_case[0],gateway(LoopAdapter(change))) + before=run(loop,steps=3) + budget=BudgetManager(tool_case[0]).snapshot(*CASE).state + result=asyncio.run(loop.step(*CASE)) + assert result.status=='REJECTED' and result.code=='INVESTIGATION_QUESTION' + assert BudgetManager(tool_case[0]).snapshot(*CASE).state==budget + assert state(result)['hypotheses']['H1']['questions']==state(before)['hypotheses']['H1']['questions'] + + def test_state_pointer_deletion_and_policy_change_do_not_restart(tool_case): loop=Investigator(tool_case[0],gateway(LoopAdapter())) first=run(loop,steps=1)