Simplify investigation replies and derive controller bookkeeping

This commit is contained in:
Nico committed 2026-10-17 00:52:09 +02:00
1 parent 57984e5793
commit 49095ac803
26 files changed
+2072 -207

No files matched your search

+2
View File
@@ -105,6 +105,8 @@ def test_invalid_review_is_atomic_before_assessment_or_tool_spending(tool_case):
if context['previous_result']:
reply['update']['causal_review']=review(against=['E999999'])
loop=Investigator(store,gateway(LoopAdapter(change)),router=InvestigationToolRouter(project_research=service))
original=loop._context
loop._context=lambda *args:dict(original(*args),prompt_version='investigation_project_v13')
run(loop,steps=3)
before=BudgetManager(store).snapshot(*CASE).state
result=asyncio.run(loop.step(*CASE))
+2 -2
View File
@@ -45,7 +45,7 @@ def test_flags_missing_values_conflicts_and_unicode_are_lossless():
items[0]['text_truncated'] = True
items[1]['text_truncated'] = 0 # Must not conflate 0 and false.
del items[2]['text_truncated']
items[3]['text'] = '\tUnicode ß with embedded\nnewline and \\ escapes'
items[3]['text'] = '\tUnicode ß with embedded\nnewline and \\ escapes'
view = checked(c)
assert view['shared_sources']['FILE0001']['conflicting_lines'] == [41, 42, 43, 44]
@@ -68,7 +68,7 @@ def test_v6_prompt_is_unchanged_and_v7_uses_same_output_schema():
assert new['messages'][1]['content'] == model_json({'task':'investigate_hypothesis', 'input':block_view(c), 'output_schema':new['output_schema']})
@pytest.mark.parametrize('version',['investigation_project_v7','investigation_project_v10','investigation_project_v11','investigation_project_v12','investigation_project_v13'])
@pytest.mark.parametrize('version',['investigation_project_v7','investigation_project_v10','investigation_project_v11','investigation_project_v12','investigation_project_v13','investigation_project_v14'])
def test_new_file_aliases_resolve_before_the_normal_tool_validation(monkeypatch,version):
from types import SimpleNamespace
from viano_bug_analysis.analysis.investigator import Investigator
+12 -2
View File
@@ -45,10 +45,10 @@ class LoopAdapter:
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'}:
if request['prompt_version'] in {'investigation_project_v12','investigation_project_v13','investigation_project_v14'}:
result['update'].setdefault('causal_review',{'effect':'neutral','support':[],'against':[],
'reason':'This fixture gathers context without establishing a causal defect.'})
if request['prompt_version']=='investigation_project_v13':
if request['prompt_version'] in {'investigation_project_v13','investigation_project_v14'}:
existing=context.get('working_brief')
review=result['update']['causal_review']
findings=[]
@@ -62,6 +62,16 @@ class LoopAdapter:
'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']=='investigation_project_v14':
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)
return ProviderResponse(result,"fixture-investigation",100,50)
+223
View File
@@ -0,0 +1,223 @@
import asyncio
import copy
import json
import pytest
from jsonschema import Draft202012Validator
from viano_bug_analysis.analysis.investigation_decision import normalize
from viano_bug_analysis.analysis.confidence_review import effective_confidence
from viano_bug_analysis.analysis.investigation_prompt import output_schema
from viano_bug_analysis.analysis.working_brief import model_view
from viano_bug_analysis.analysis import Investigator, InvestigationError, DiagnosisBuilder
from viano_bug_analysis.budget import BudgetManager
from viano_bug_analysis.case.contracts import validate
from viano_bug_analysis.llm import LLMError, ProviderResponse
from viano_bug_analysis.llm.diagnostics import sanitize, schema_feedback
from viano_bug_analysis.package import PackageReader
from viano_bug_analysis.tools import InvestigationToolRouter
from test_investigation_consolidation import populated
from test_working_brief import brief, service_for
from test_case_builder import case_setup
from test_case_interpreter import gateway
from test_investigation_tools import tool_case
from test_investigator import LoopAdapter, CASE, run, state
from test_project_research import rows
from test_diagnosis_builder import FinalAdapter, build
def context():
return {'prompt_version':'investigation_project_v14',
'evidence':{'E000001':{},'E000002':{}},
'previous_result':{'record':{'id':'E000002'}},'working_brief':{'brief':brief()}}
def reply():
b=brief();b['format_version']=2;b.pop('changes');b['retired']=[]
return {'assessment':{'semantic_score':90,'reason':'Useful counterevidence'},
'update':{'confidence':'strongly_supported','resolve_questions':[],
'add_questions':[],'working_brief':b},
'next':{'kind':'finish','reason':'Mechanism contradicted'}}
def test_one_findings_set_derives_refs_direction_and_assessment_binding_without_mutation():
c=context();raw=reply();before=copy.deepcopy(raw)
d=normalize(c,raw)
assert raw==before
assert d['assessment']['evidence_refs']==['E000002']
assert d['update']['evidence_refs']==['E000001','E000002']
assert d['update']['causal_review']['effect']=='contradicts'
assert effective_confidence(d['update'])=='weakened'
assert d['assessment']['semantic_score']==90
assert d['update']['working_brief']['format_version']==1
validate(d['update']['working_brief'],'investigation-working-brief')
def test_derived_union_keeps_all_128_refs_without_a_second_model_list_limit():
c={'prompt_version':'investigation_project_v14','evidence':{f'E{i:06d}':{} for i in range(1,129)},'previous_result':None}
r=reply();r['assessment']=None
r['update']['working_brief']['findings']=[{
'id':f'B{i+1:04d}','direction':'support','text':'Scoped finding.', 'scope':'Snapshot A',
'evidence_refs':[f'E{j:06d}' for j in range(i*8+1,i*8+9)]} for i in range(16)]
d=normalize(c,r)
assert len(d['update']['causal_review']['support'])==128
assert len(d['update']['causal_review']['reason'])<=600
from importlib.resources import files
schema=json.loads(files('viano_bug_analysis.schemas').joinpath('investigation-state-v1.schema.json').read_text())
note={'kind':'assessment','text':'Controller-derived brief','llm_ref':'call.json',
'causal_review':d['update']['causal_review']}
assert Draft202012Validator(schema['properties']['hypotheses']['additionalProperties']['properties']['notes']['items']).is_valid(note)
def test_text_scope_precision_and_added_refs_automatically_audited():
r=reply();f=r['update']['working_brief']['findings'][0]
f.update(text='The required hook exists in the inspected source.',scope='Commit A, not deployment.',evidence_refs=['E000001','E000002'])
result=normalize(context(),r)
audit=result['brief_change_audit'][0]
assert not audit['requires_reason']
assert set(audit['fields'])=={'text','scope','evidence_refs'}
assert result['update']['working_brief']['changes'][0]['reason'].startswith('Controller')
@pytest.mark.parametrize('change',[
lambda f:f.update(direction='neutral'),lambda f:f.update(evidence_refs=['E000002'])])
def test_direction_change_or_lost_counterevidence_ref_requires_explicit_reason(change):
r=reply();f=r['update']['working_brief']['findings'][0];change(f)
with pytest.raises(InvestigationError,match='INVESTIGATION_BRIEF_CHANGE') as caught:
normalize(context(),r)
assert caught.value.feedback['ids']==['B0001']
f['revision_reason']='A newer observation changes the applicable scope.'
assert normalize(context(),r)['brief_change_audit'][0]['requires_reason']
def test_removed_counterevidence_requires_retirement_and_keeps_old_references():
r=reply();b=r['update']['working_brief'];b['findings']=[]
with pytest.raises(InvestigationError,match='INVESTIGATION_BRIEF_CHANGE'):
normalize(context(),r)
b['retired']=[{'id':'B0001','reason':'Observation belongs to an obsolete deployment.'}]
d=normalize(context(),r)
assert d['update']['working_brief']['changes'][0]['evidence_refs']==['E000001']
assert d['update']['causal_review']['effect']=='neutral'
assert effective_confidence(d['update'])=='plausible'
def test_unknown_reference_has_concrete_safe_feedback():
r=reply();r['update']['working_brief']['findings'][0]['evidence_refs']=['E999999']
with pytest.raises(InvestigationError) as caught:normalize(context(),r)
assert caught.value.feedback=={'field':'update.working_brief.findings','ids':['E999999'],
'requirement':'known_evidence_ids'}
def test_both_sides_produce_mixed_effect_and_cannot_force_high_confidence():
r=reply();f=copy.deepcopy(r['update']['working_brief']['findings'][0]);f.update(id='B0002',direction='support')
r['update']['working_brief']['findings'].append(f)
d=normalize(context(),r)
assert d['update']['causal_review']['effect']=='mixed'
assert effective_confidence(d['update'])=='plausible'
assert d['update']['evidence_refs']==['E000001','E000002']
def test_wire_does_not_accept_redundant_fields_and_old_contract_is_frozen():
c=populated();c['prompt_version']='investigation_project_v14'
schema=output_schema(c);r=reply()
assert Draft202012Validator(schema).is_valid(r)
r['update']['evidence_refs']=['E000001']
assert not Draft202012Validator(schema).is_valid(r)
old=output_schema(dict(c,prompt_version='investigation_project_v13'))['properties']['update']
assert 'evidence_refs' in old['required'] and 'causal_review' in old['required']
def test_v14_model_view_keeps_all_old_foreign_and_fresh_evidence_payloads():
c=populated()
for h in c['hypotheses'].values():h['disposition']='active'
c['prompt_version']='investigation_project_v13';old=model_view(c)
c['prompt_version']='investigation_project_v14';new=model_view(c)
for eid in old['evidence']:
assert new['evidence'][eid]['content']==old['evidence'][eid]['content']
for key in ('shared_sources','shared_results','sources','interpretation'):
assert new.get(key)==old.get(key)
assert new['previous_result']==old['previous_result']
def test_schema_feedback_never_echoes_response_values_or_unknown_property_names():
schema={'type':'object','properties':{'update':{'type':'object',
'properties':{'confidence':{'type':'integer'}},'required':['confidence']}}}
err=next(Draft202012Validator(schema).iter_errors({'update':{}}))
assert schema_feedback(err)=={'validation_path':'/update','validation_fields':['confidence']}
assert sanitize({'validation_path':'/update/secret_key_content','validation_fields':['secret_value','confidence']})=={'validation_fields':['confidence']}
def test_schema_feedback_selects_matching_action_instead_of_wrong_oneof_branch():
schema={'type':'object','properties':{'next':{'oneOf':[
{'type':'object','properties':{'kind':{'const':'finish'},'reason':{'type':'string'}},'required':['kind','reason']},
{'type':'object','properties':{'kind':{'const':'tool'},'arguments':{'type':'object',
'properties':{'start_line':{'type':'integer'}},'required':['start_line']}},'required':['kind','arguments']}]}}}
err=next(Draft202012Validator(schema).iter_errors({'next':{'kind':'tool','arguments':{}}}))
info=schema_feedback(err)
assert info=={'validation_path':'/next/arguments','validation_fields':['start_line']}
def test_controller_rejection_details_reach_retry_before_any_budget_effect(tool_case):
service=service_for(tool_case)
def change(reply,c):
reply['next']={'kind':'tool','operation':'project.query','arguments':rows()}
if c['previous_result']:
b=brief();b['format_version']=2;b.pop('changes');b['retired']=[]
b['findings'][0]['evidence_refs']=['E999999'];reply['update']['working_brief']=b
loop=Investigator(tool_case[0],gateway(LoopAdapter(change)),router=InvestigationToolRouter(project_research=service))
run(loop,steps=3);before=BudgetManager(tool_case[0]).snapshot(*CASE).state
result=asyncio.run(loop.step(*CASE))
assert result.status=='REJECTED' and BudgetManager(tool_case[0]).snapshot(*CASE).state==before
saved=state(result)
with PackageReader(result.package) as r:
c=json.loads(r.read_bytes(saved['active']['context_ref']))
assert c['previous_rejection_details']['ids']==['E999999']
def test_gateway_schema_location_persists_in_retry_without_leaking_values(tool_case):
service=service_for(tool_case)
class Bad:
async def generate(self,config,request,*,max_response_bytes):
r=reply();r['update']['working_brief'].pop('retired')
return ProviderResponse(r,'fixture',10,10)
loop=Investigator(tool_case[0],gateway(Bad()),router=InvestigationToolRouter(project_research=service))
result=asyncio.run(loop.step(*CASE));assert result.status=='REJECTED'
with PackageReader(result.package) as reader:
c=json.loads(reader.read_bytes(state(result)['active']['context_ref']))
info=c['previous_rejection_details']
assert info['validation_path']=='/update/working_brief' and info['validation_fields']==['retired']
def test_v14_positive_early_review_reaches_independent_diagnosis_with_rest_budget(tool_case):
service=service_for(tool_case)
def change(reply,c):
early=c['early_review']['request']
if early and early['status']=='preparing':
reply['next']={'kind':'defer','reason':'Settle pending evidence.'}
elif c['selected_hypothesis']=='H1' and c['previous_result']:
eid=c['previous_result']['evidence_id']
other=next(e for e,v in c['evidence'].items() if v['record']['hypothesis_id']=='H2')
reply['update'].update(confidence='strongly_supported',
causal_review={'effect':'supports','support':[eid],'against':[],'reason':'Fixture causal defect.'})
reply['next']={'kind':'request_diagnosis','reason':'Scoped defect with countercheck.','countercheck_refs':[other]}
else:reply['next']={'kind':'tool','operation':'project.query','arguments':rows()}
loop=Investigator(tool_case[0],gateway(LoopAdapter(change)),router=InvestigationToolRouter(project_research=service))
done=run(loop,steps=10)
assert done.status=='COMPLETED' and state(done)['early_stop']['status']=='requested'
budget=BudgetManager(tool_case[0]).snapshot(*CASE).state
assert all(a['remaining']>0 for a in budget['accounts'].values())
adapter=FinalAdapter(True,lambda p,c:p.update(confidence='strongly_supported'))
result=build(DiagnosisBuilder(tool_case[0],gateway(adapter)))
assert result.status=='COMPLETED' and adapter.calls==1
assert BudgetManager(tool_case[0]).snapshot(*CASE).state==budget
def test_v14_can_finish_inconclusively_without_invented_cause(tool_case):
service=service_for(tool_case)
loop=Investigator(tool_case[0],gateway(LoopAdapter()),router=InvestigationToolRouter(project_research=service))
done=run(loop,steps=10);assert done.status=='COMPLETED'
result=build(DiagnosisBuilder(tool_case[0],gateway(FinalAdapter(False))))
assert result.status=='INCONCLUSIVE'
with PackageReader(result.package) as reader:
assert reader.manifest['state']=='FAILED_TO_ANALYZE'
assert reader.manifest['artifacts']['change_request'] is None
+7 -3
View File
@@ -204,7 +204,7 @@ def test_model_loop_uses_project_tools_and_compact_previous_result(tool_case, re
if previous:
assert context["evidence"][eid]["content"]["items"]
assert "inputs" not in context["case"]
assert request["prompt_version"] in ({"investigation_project_v9","investigation_project_v13"} if recall == "upgrade" else {"investigation_project_v9" if recall else "investigation_project_v13"})
assert request["prompt_version"] in ({"investigation_project_v9","investigation_project_v14"} if recall == "upgrade" else {"investigation_project_v9" if recall else "investigation_project_v14"})
reply={"assessment":{"semantic_score":50,"reason":"Scoped fact","evidence_refs":[eid]} if previous else None,
"update":{"confidence":"plausible","evidence_refs":[eid] if previous else [],"resolve_questions":[],"add_questions":[],"note":"Query typed identity"},
"next":{"kind":"finish","reason":"Check complete"} if previous else {"kind":"tool","operation":"project.query","arguments":rows()}}
@@ -216,12 +216,16 @@ def test_model_loop_uses_project_tools_and_compact_previous_result(tool_case, re
assert context['evidence'][recalled]['content']['items']
reply['update']['evidence_refs'] = [recalled]
reply['next'] = {'kind':'finish','reason':'Recalled exact evidence; uncertainty remains'}
if request['prompt_version']=='investigation_project_v13':
if request['prompt_version']=='investigation_project_v14':
reply['update']['causal_review']={'effect':'neutral','support':[],'against':[],
'reason':'Scoped observations are not independent evidence of a defect.'}
if request['prompt_version']=='investigation_project_v13':
if request['prompt_version']=='investigation_project_v14':
reply['update']['working_brief']={'format_version':1,'findings':[],
'uncertainties':['Scoped observations only.'],'next_question':'Check remaining uncertainty.','changes':[]}
if request['prompt_version']=='investigation_project_v14':
b=reply['update']['working_brief'];b['format_version']=2;b.pop('changes');b['retired']=[]
for k in ('evidence_refs','causal_review','note'):reply['update'].pop(k,None)
if reply['assessment']:reply['assessment'].pop('evidence_refs',None)
return ProviderResponse(reply,"fixture",100,10)
adapter=Adapter()
loop=Investigator(store,gateway(adapter),router=InvestigationToolRouter(project_research=service))
+5 -5
View File
@@ -144,12 +144,12 @@ def test_instruction_only_compatibility_mode_preserves_previous_snapshot_and_wir
assert 'stable_case_context' not in json.loads(prefix[0]['parts'][0]['text'])
@pytest.mark.parametrize('version',['investigation_project_v11','investigation_project_v12','investigation_project_v13'])
@pytest.mark.parametrize('version',['investigation_project_v11','investigation_project_v12','investigation_project_v13','investigation_project_v14'])
def test_v11_aliases_and_cached_case_do_not_shift_with_new_research(version):
from test_investigation_consolidation import populated, capture
from viano_bug_analysis.analysis.investigation_prompt import build_prompt
c=populated();c['format']='viano-investigation-context';c['prompt_version']=version
if version=='investigation_project_v13':
if version in {'investigation_project_v13','investigation_project_v14'}:
for h in c['hypotheses'].values():h['disposition']='active'
c['budget']['accounts']['H2']=copy.deepcopy(c['budget']['accounts']['H1'])
c['interpretation']={'summary':'Preserve the interpretation'}
@@ -181,13 +181,13 @@ def test_v11_short_identifier_aliases_are_stable_and_reversible():
assert compact_context(first)['sources']['path'].startswith('P0001:')
@pytest.mark.parametrize('version',['investigation_project_v10','investigation_project_v12','investigation_project_v13'])
@pytest.mark.parametrize('version',['investigation_project_v10','investigation_project_v12','investigation_project_v13','investigation_project_v14'])
def test_offline_measurement_matches_saved_context_without_provider_calls(make_package,monkeypatch,version):
from test_investigation_consolidation import populated
from viano_bug_analysis.analysis.investigation_prompt import build_prompt
from viano_bug_analysis.llm.cache_measurement import measure_package
c=populated();c['format']='viano-investigation-context';c['prompt_version']=version
if version=='investigation_project_v13':
if version in {'investigation_project_v13','investigation_project_v14'}:
for h in c['hypotheses'].values():h['disposition']='active'
c['budget']['accounts']['H2']=copy.deepcopy(c['budget']['accounts']['H1'])
c['interpretation']={'summary':'Original interpretation'}
@@ -200,7 +200,7 @@ def test_offline_measurement_matches_saved_context_without_provider_calls(make_p
report=measure_package(path)
assert report['requests']==1 and report['unmatched_requests']==0
assert report['unique_prefixes']==1 and report['all_exactly_reconstructed']
assert report['samples'][0]['rebuilt_prompt']==(version if version in {'investigation_project_v12','investigation_project_v13'} else 'investigation_project_v11')
assert report['samples'][0]['rebuilt_prompt']==(version if version in {'investigation_project_v12','investigation_project_v13','investigation_project_v14'} else 'investigation_project_v11')
@pytest.mark.parametrize('failure',[None,400,404])