370 lines
22 KiB
Python
370 lines
22 KiB
Python
import asyncio
|
|
import copy
|
|
import json
|
|
import os
|
|
from dataclasses import replace
|
|
from datetime import datetime,timedelta,timezone
|
|
from importlib.resources import files
|
|
|
|
import pytest
|
|
from jsonschema import Draft202012Validator
|
|
|
|
from viano_bug_analysis.analysis import Investigator,InvestigationConfig,InvestigationError,InvestigationLimits
|
|
from viano_bug_analysis.budget import BudgetConfig,BudgetManager
|
|
from viano_bug_analysis.lifecycle import CheckpointStore,Workspace,State
|
|
from viano_bug_analysis.lifecycle.async_tests import AsyncTestCoordinator
|
|
from viano_bug_analysis.lifecycle.errors import LockBusy
|
|
from viano_bug_analysis.lifecycle.locking import FileLock
|
|
from viano_bug_analysis.lifecycle.storage import key
|
|
from viano_bug_analysis.llm import LLMGateway,ProviderResponse,ProviderUnavailable,GatewayConfig,ProviderConfig,LLMError
|
|
from viano_bug_analysis.package import PackageReader,PackageValidator
|
|
from test_case_builder import case_setup
|
|
from test_investigation_tools import tool_case
|
|
from test_case_interpreter import gateway
|
|
from test_llm_gateway import http_endpoint
|
|
|
|
CASE=("case-A","stable-package")
|
|
|
|
|
|
class LoopAdapter:
|
|
def __init__(self,change=None,test=False,unavailable=False):
|
|
self.contexts=[];self.change=change;self.test=test;self.unavailable=unavailable
|
|
|
|
async def generate(self,config,request,*,max_response_bytes):
|
|
context=json.loads(request["messages"][1]["content"])["input"]
|
|
self.contexts.append(copy.deepcopy(context))
|
|
if self.unavailable:raise ProviderUnavailable("unavailable")
|
|
previous=context["previous_result"]
|
|
eid=(previous.get('evidence_id') or previous["record"]["id"]) if previous else None
|
|
result={"assessment":{"semantic_score":50,"reason":"Scoped useful evidence","evidence_refs":[eid]} if previous else None,
|
|
"update":{"confidence":"plausible" if previous else "unassessed","evidence_refs":[eid] if previous else [],
|
|
"resolve_questions":[],"add_questions":[],"note":"Check the packaged observation before drawing conclusions"},
|
|
"next":{"kind":"finish","reason":"Initial question checked; preserve uncertainty"} if previous else
|
|
{"kind":"tool","operation":"source.read_context","arguments":{"source_ref":"tool/code.cpp","start_line":1,"end_line":2}}}
|
|
if self.test and previous is None and context["selected_hypothesis"]=="H1":
|
|
result["next"]={"kind":"test","executor":"human","question":"What occurs after interaction?","steps":["Interact once"],
|
|
"prerequisites":["Use the specified build"],"requested_observations":["Record the observed state"],"environment":"build-A"}
|
|
if self.change:self.change(result,context)
|
|
if request['prompt_version'] in {'investigation_project_v12','investigation_project_v13','investigation_project_v14','investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'}:
|
|
result['update'].setdefault('causal_review',{'effect':'neutral','support':[],'against':[],
|
|
'reason':'This fixture gathers context without establishing a causal defect.'})
|
|
if request['prompt_version'] in {'investigation_project_v13','investigation_project_v14','investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'}:
|
|
existing=context.get('working_brief')
|
|
review=result['update']['causal_review']
|
|
findings=[]
|
|
for direction in ('support','against'):
|
|
refs=review[direction]
|
|
if refs:findings.append({'id':'B0001' if direction=='support' else 'B0002',
|
|
'direction':direction,'text':'Fixture scoped finding','scope':'Fixture snapshot', 'evidence_refs':refs})
|
|
old={f['id']:f for f in existing['findings']} if existing else {}
|
|
new={f['id']:f for f in findings}
|
|
result['update'].setdefault('working_brief',{'format_version':1,'findings':findings,
|
|
'uncertainties':['Causal defect not established.'],'next_question':'Check the selected mechanism.',
|
|
'changes':[{'id':fid,'reason':'Fixture revises the finding.',
|
|
'evidence_refs':result['update']['evidence_refs']} for fid,f in old.items() if new.get(fid)!=f]})
|
|
if request['prompt_version'] in {'investigation_project_v14','investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'}:
|
|
b=result['update']['working_brief']
|
|
if b['format_version']==1:
|
|
reasons={v['id']:v['reason'] for v in b.pop('changes')}
|
|
b['format_version']=2
|
|
for f in b['findings']:
|
|
if f['id'] in reasons:f['revision_reason']=reasons[f['id']]
|
|
b['retired']=[{'id':fid,'reason':reason} for fid,reason in reasons.items() if fid not in {f['id'] for f in b['findings']}]
|
|
for k in ('evidence_refs','causal_review','note'):result['update'].pop(k,None)
|
|
if result['assessment']:result['assessment'].pop('evidence_refs',None)
|
|
if request['prompt_version']=='investigation_project_v18' and not context.get('review_task'):
|
|
result['update'].setdefault('next_step',{'goal':'Check the selected mechanism.',
|
|
'supports_if':'Scoped causal defect observed.','weakens_if':'Required path is working.',
|
|
'investigate_if':'Scope or deployment is unknown.'} if result['next']['kind'] in {'tool','recall','test'} else None)
|
|
return ProviderResponse(result,"fixture-investigation",100,50)
|
|
|
|
|
|
def run(loop,steps=20,**kwargs):return asyncio.run(loop.run(*CASE,max_steps=steps,**kwargs))
|
|
|
|
|
|
def state(result):
|
|
with PackageReader(result.package) as reader:return json.loads(reader.read_bytes(result.state_ref))
|
|
|
|
|
|
def test_full_loop_round_robin_assessment_budget_evidence_and_preservation(tool_case):
|
|
adapter=LoopAdapter();loop=Investigator(tool_case[0],gateway(adapter))
|
|
result=run(loop)
|
|
assert result.status=="COMPLETED"
|
|
assert [c["selected_hypothesis"] for c in adapter.contexts]==["H1","H2","H3","H1","H2","H3"]
|
|
snapshot=BudgetManager(tool_case[0]).snapshot(*CASE)
|
|
assert {a["status"] for a in snapshot.state["accounts"].values()}=={"investigation_finished"}
|
|
assert snapshot.state["accounts"]["H1"]["remaining"]==94
|
|
assert snapshot.state["accounts"]["H2"]["remaining"]==90
|
|
assert snapshot.ready_for_evaluation
|
|
assert state(result)["countercheck_complete"]
|
|
assert {h["confidence"] for h in state(result)["hypotheses"].values()}=={"plausible"}
|
|
with PackageReader(tool_case[1]) as old,PackageReader(result.package) as new:
|
|
assert new.manifest["state"]==State.BUILDING_DIAGNOSIS
|
|
assert new.manifest["artifacts"]["diagnosis"] is None
|
|
assert b"Outstanding questions" in new.read_bytes(new.manifest["artifacts"]["investigation_summary"])
|
|
for name in old.files:
|
|
if name!="manifest.json":assert old.read_bytes(name)==new.read_bytes(name)
|
|
assert PackageValidator().validate(result.package).valid
|
|
assert run(loop).package==result.package and len(adapter.contexts)==6
|
|
|
|
|
|
def test_yield_resume_and_portable_state(tool_case,tmp_path):
|
|
adapter=LoopAdapter();first=run(Investigator(tool_case[0],gateway(adapter)),steps=1)
|
|
assert first.status=="YIELDED"
|
|
portable=CheckpointStore(Workspace(tmp_path/"portable"));portable.intake(first.package)
|
|
result=run(Investigator(portable,gateway(adapter)))
|
|
assert result.status=="COMPLETED" and len(adapter.contexts)==6
|
|
|
|
|
|
@pytest.mark.parametrize("change",[
|
|
lambda r,c:r.update(budget=100000),
|
|
lambda r,c:r["next"].update(operation="shell.exec"),
|
|
lambda r,c:r["next"]["arguments"].update(source_ref="../outside"),
|
|
lambda r,c:r["update"].update(confidence="strongly_supported"),
|
|
lambda r,c:r["update"].update(evidence_refs=["E999999"]),
|
|
lambda r,c:r["update"].update(resolve_questions=["Q9999"]),
|
|
lambda r,c:r["update"].update(add_questions=[c["hypotheses"][c["selected_hypothesis"]]["questions"][0]["text"]]),
|
|
lambda r,c:r.update(assessment={"semantic_score":100,"reason":"Invented prior result","evidence_refs":["E000001"]}),
|
|
])
|
|
def test_invalid_entire_reply_rejected_before_tools_or_spending(tool_case,change):
|
|
adapter=LoopAdapter(change);loop=Investigator(tool_case[0],gateway(adapter))
|
|
result=asyncio.run(loop.step(*CASE))
|
|
assert result.status=="REJECTED"
|
|
snapshot=BudgetManager(tool_case[0]).snapshot(*CASE)
|
|
assert {a["remaining"] for a in snapshot.state["accounts"].values()}=={100}
|
|
assert not any(a["actions"] for a in snapshot.state["accounts"].values())
|
|
|
|
|
|
def test_valid_assessment_with_invalid_next_action_is_not_partially_applied(tool_case):
|
|
def change(reply,context):
|
|
if context["previous_result"]:reply["next"]={"kind":"tool","operation":"source.read_context","arguments":{"source_ref":"missing","start_line":1,"end_line":1}}
|
|
loop=Investigator(tool_case[0],gateway(LoopAdapter(change)))
|
|
run(loop,steps=3)
|
|
before=BudgetManager(tool_case[0]).snapshot(*CASE)
|
|
result=asyncio.run(loop.step(*CASE))
|
|
assert result.status=="REJECTED"
|
|
after=BudgetManager(tool_case[0]).snapshot(*CASE)
|
|
assert after.state==before.state
|
|
|
|
|
|
def test_invalid_response_limit_is_bounded_and_no_free_infinite_loop(tool_case):
|
|
adapter=LoopAdapter(lambda r,c:r.update(forbidden=True))
|
|
result=run(Investigator(tool_case[0],gateway(adapter),InvestigationConfig(max_invalid_responses=2)))
|
|
assert result.status=="BLOCKED" and len(adapter.contexts)==2
|
|
assert {h["disposition"] for h in state(result)["hypotheses"].values()}=={"active"}
|
|
snapshot=BudgetManager(tool_case[0]).snapshot(*CASE)
|
|
assert all(a["remaining"]==100 and not a["actions"] for a in snapshot.state["accounts"].values())
|
|
assert not snapshot.ready_for_evaluation
|
|
|
|
|
|
def test_query_iteration_limit_allows_final_assessment_only(tool_case):
|
|
def change(reply,context):
|
|
if context["previous_result"]:
|
|
reply["next"]={"kind":"tool","operation":"source.read_context","arguments":{"source_ref":"tool/code.cpp","start_line":1,"end_line":1}}
|
|
result=run(Investigator(tool_case[0],gateway(LoopAdapter(change)),InvestigationConfig(max_iterations_per_hypothesis=1,max_invalid_responses=1)))
|
|
assert result.status=="BLOCKED"
|
|
assert all(len(a["actions"])==1 for a in BudgetManager(tool_case[0]).snapshot(*CASE).state["accounts"].values())
|
|
|
|
|
|
def test_strong_support_does_not_skip_alternative_hypotheses(tool_case):
|
|
def change(reply,context):
|
|
if context["previous_result"] and context["selected_hypothesis"]=="H1":reply["update"]["confidence"]="strongly_supported"
|
|
adapter=LoopAdapter(change);result=run(Investigator(tool_case[0],gateway(adapter)))
|
|
assert result.status=="COMPLETED"
|
|
assert state(result)["hypotheses"]["H1"]["confidence"]=="strongly_supported"
|
|
assert {c["selected_hypothesis"] for c in adapter.contexts}=={"H1","H2","H3"}
|
|
|
|
|
|
def test_question_updates_keep_history_and_require_evidence(tool_case):
|
|
def change(reply,context):
|
|
if context["previous_result"]:
|
|
reply["update"]["resolve_questions"]=["Q0001"]
|
|
reply["update"]["add_questions"]=["Which later transition still needs checking?"]
|
|
result=run(Investigator(tool_case[0],gateway(LoopAdapter(change))))
|
|
questions=state(result)["hypotheses"]["H1"]["questions"]
|
|
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)
|
|
before=BudgetManager(tool_case[0]).snapshot(*CASE).state
|
|
changed=Investigator(tool_case[0],gateway(LoopAdapter()),InvestigationConfig(max_iterations_per_hypothesis=100))
|
|
with pytest.raises(InvestigationError,match="INVESTIGATION_POLICY_CHANGED"):asyncio.run(changed.step(*CASE))
|
|
assert BudgetManager(tool_case[0]).snapshot(*CASE).state==before
|
|
tool_case[0].advance(*CASE,State.INVESTIGATING_HYPOTHESES,step_id="deleted-loop-pointer",artifacts={"investigation_state":None})
|
|
with pytest.raises(InvestigationError,match="INVESTIGATION_POINTER_CHANGED"):asyncio.run(loop.step(*CASE))
|
|
|
|
|
|
def test_async_test_wait_confirmation_and_budget_preservation(tool_case):
|
|
config=BudgetConfig(action_costs={"testing.request_human":40})
|
|
adapter=LoopAdapter(test=True);loop=Investigator(tool_case[0],gateway(adapter),budget_config=config)
|
|
blocked=run(loop)
|
|
assert blocked.status=="BLOCKED" and blocked.code=="AWAITING_TEST_FEEDBACK"
|
|
before=BudgetManager(tool_case[0],config).snapshot(*CASE)
|
|
assert before.state["accounts"]["H1"]["remaining"]==60
|
|
assert run(loop).status=="BLOCKED" and len(adapter.contexts)==1
|
|
with PackageReader(blocked.package) as reader:
|
|
request=json.loads(reader.read_bytes(reader.manifest["artifacts"]["async_test_request"]))
|
|
feedback={f:request[f] for f in ("format_version","case_id","package_id","analysis_revision","request_id","hypothesis_id","action_id","executor")}
|
|
feedback.update(format="viano-async-test-feedback",outcome="completed",observations=["Observed missing transition"],environment="build-A",tester_id="tester",
|
|
attachments=[],confirmation={"confirmed":True,"confirmed_by":"reviewer","confirmed_at":"2026-10-14T12:00:00Z"})
|
|
AsyncTestCoordinator(tool_case[0]).confirm_feedback(feedback)
|
|
assert BudgetManager(tool_case[0],config).snapshot(*CASE).state==before.state
|
|
result=run(Investigator(tool_case[0],gateway(adapter)))
|
|
assert result.status=="COMPLETED" and state(result)["analysis_revision"]==1
|
|
assert BudgetManager(tool_case[0],config).snapshot(*CASE).state["accounts"]["H1"]["remaining"]==76
|
|
|
|
|
|
def test_provider_retry_defer_exhaustion_and_explicit_retry(tool_case):
|
|
now=[datetime.now(timezone.utc)-timedelta(days=1)]
|
|
adapter=LoopAdapter(unavailable=True)
|
|
base=gateway(adapter);cfg=replace(base.config,max_attempts=2,retry_delay_seconds=1,blocked_retry_seconds=1)
|
|
gw=LLMGateway(cfg,{"fixture":adapter})
|
|
loop=Investigator(tool_case[0],gw,clock=lambda:now[0])
|
|
first=asyncio.run(loop.step(*CASE));assert first.status=="RETRY_PENDING"
|
|
assert asyncio.run(loop.step(*CASE)).status=="DEFERRED"
|
|
now[0]+=timedelta(seconds=2)
|
|
blocked=asyncio.run(loop.step(*CASE));assert blocked.status=="BLOCKED"
|
|
assert {a["remaining"] for a in BudgetManager(tool_case[0]).snapshot(*CASE).state["accounts"].values()}=={100}
|
|
now[0]+=timedelta(seconds=2)
|
|
# CheckpointStore uses wall clock for eligibility; make the retry timestamp due.
|
|
with PackageReader(blocked.package) as reader:due=reader.manifest["routing"]["retry_not_before"]
|
|
assert datetime.fromisoformat(due)<=datetime.now(timezone.utc)
|
|
tool_case[0].advance(*CASE,State.INVESTIGATING_HYPOTHESES,step_id="manual-provider-resume")
|
|
adapter.unavailable=False
|
|
result=asyncio.run(loop.step(*CASE,retry_id="provider-restored"))
|
|
assert result.status=="PROGRESSED"
|
|
|
|
|
|
def test_after_evidence_publication_crash_no_duplicate_calls_costs_or_ids(tool_case,monkeypatch):
|
|
adapter=LoopAdapter();loop=Investigator(tool_case[0],gateway(adapter))
|
|
original=BudgetManager.record_evidence;once=[True]
|
|
def crash(self,*args,**kwargs):
|
|
if once[0]:once[0]=False;raise OSError("Crash after evidence publication")
|
|
return original(self,*args,**kwargs)
|
|
monkeypatch.setattr(BudgetManager,"record_evidence",crash)
|
|
with pytest.raises(InvestigationError):asyncio.run(loop.step(*CASE))
|
|
result=run(loop)
|
|
assert result.status=="COMPLETED" and len(adapter.contexts)==6
|
|
assert all(len(a["actions"])==1 for a in BudgetManager(tool_case[0]).snapshot(*CASE).state["accounts"].values())
|
|
|
|
|
|
def test_same_case_busy_and_configuration_limits(tool_case):
|
|
loop=Investigator(tool_case[0],gateway(LoopAdapter()))
|
|
with FileLock(tool_case[0].workspace.runtime/"locks"/f"investigation-{key(CASE[0])}.lock"):
|
|
with pytest.raises(LockBusy):asyncio.run(loop.step(*CASE))
|
|
with pytest.raises(InvestigationError):InvestigationConfig(max_invalid_responses=True)
|
|
with pytest.raises(InvestigationError):InvestigationLimits(max_context_bytes=0)
|
|
with pytest.raises(InvestigationError):run(loop,steps=0)
|
|
|
|
|
|
def test_validation_cache_uses_archive_bytes_not_timestamps(tool_case):
|
|
from viano_bug_analysis.lifecycle import LifecycleError
|
|
store=tool_case[0]
|
|
store.latest(*CASE)
|
|
old=store.latest(*CASE)
|
|
stat=old.stat()
|
|
old.write_bytes(b"x"*stat.st_size)
|
|
os.utime(old,ns=(stat.st_atime_ns,stat.st_mtime_ns))
|
|
with pytest.raises(LifecycleError):store.latest(*CASE)
|
|
|
|
|
|
def test_unchanged_generations_do_not_repeat_zip_validation(tool_case,monkeypatch):
|
|
store=tool_case[0];expected=store.latest(*CASE)
|
|
def unexpected(*args):raise AssertionError("Unchanged archive was unnecessarily revalidated")
|
|
monkeypatch.setattr(PackageReader,"__enter__",unexpected)
|
|
assert store.latest(*CASE)==expected
|
|
|
|
|
|
def test_loop_through_actual_http_adapter(tool_case,http_endpoint):
|
|
url,seen,reply=http_endpoint
|
|
reply["body"]["choices"][0]["message"]["content"]=json.dumps({
|
|
"assessment":None,"update":{"confidence":"unassessed","evidence_refs":[],"resolve_questions":[],"add_questions":[],"note":"Transport regression"},
|
|
"next":{"kind":"tool","operation":"source.read_context","arguments":{"source_ref":"tool/code.cpp","start_line":1,"end_line":2}}})
|
|
cfg=GatewayConfig((ProviderConfig(id="http",model="configured",base_url=url,response_format="json_schema"),))
|
|
result=asyncio.run(Investigator(tool_case[0],LLMGateway(cfg)).step(*CASE))
|
|
assert result.status=="PROGRESSED"
|
|
assert seen[0]["body"]["response_format"]["json_schema"]["schema"]["additionalProperties"] is False
|
|
assert state(result)["hypotheses"]["H1"]["last_record_ref"] is not None
|
|
|
|
|
|
def test_provider_configuration_error_blocks_without_abandoning_hypotheses(tool_case):
|
|
class BrokenProvider:
|
|
async def generate(self,*args,**kwargs):raise LLMError("LLM_CREDENTIALS")
|
|
result=run(Investigator(tool_case[0],gateway(BrokenProvider())))
|
|
assert result.status=="BLOCKED" and result.code=="LLM_CREDENTIALS"
|
|
assert {h["disposition"] for h in state(result)["hypotheses"].values()}=={"active"}
|
|
assert {a["remaining"] for a in BudgetManager(tool_case[0]).snapshot(*CASE).state["accounts"].values()}=={100}
|
|
|
|
|
|
@pytest.mark.parametrize("name",["investigation-output","investigation-state","investigation-context"])
|
|
def test_loop_schemas_valid(name):
|
|
Draft202012Validator.check_schema(json.loads(files("viano_bug_analysis.schemas").joinpath(name+'-v1.schema.json').read_text()))
|
|
|
|
|
|
def test_confidence_scheduler_keeps_growing_candidate_then_switches_after_weakening(tool_case):
|
|
def change(reply,context):
|
|
hid=context['selected_hypothesis']
|
|
turns=sum(c['selected_hypothesis']==hid for c in adapter.contexts)
|
|
if hid=='H1' and turns in (2,3):
|
|
reply['update']['confidence']='supported' if turns==2 else 'weakened'
|
|
reply['next']={'kind':'tool','operation':'source.read_context',
|
|
'arguments':{'source_ref':'tool/code.cpp','start_line':1,'end_line':2}}
|
|
adapter=LoopAdapter(change)
|
|
loop=Investigator(tool_case[0],gateway(adapter))
|
|
first=run(loop,steps=4)
|
|
assert first.status=='YIELDED'
|
|
assert [c['selected_hypothesis'] for c in adapter.contexts]==['H1','H2','H3','H1']
|
|
# Resume the persisted confidence state; neither cursor nor a restart forces rotation.
|
|
result=run(Investigator(tool_case[0],gateway(adapter)),steps=2)
|
|
assert [c['selected_hypothesis'] for c in adapter.contexts]==['H1','H2','H3','H1','H1','H2']
|
|
saved=state(result)
|
|
assert saved['hypotheses']['H1']['confidence']=='weakened'
|
|
assert saved['hypotheses']['H1']['disposition']=='active'
|
|
account=BudgetManager(tool_case[0]).snapshot(*CASE).state['accounts']['H1']
|
|
assert account['remaining']>0 and account['status']=='investigating'
|
|
assert PackageValidator().validate(result.package).valid
|