Allow shared evidence and bounded causal diagnosis with retry feedback

This commit is contained in:
Nico committed 2026-10-15 18:05:38 +02:00
1 parent 5082354bc3
commit c1f44e4e02
21 files changed
+727 -41

No files matched your search

+54 -2
View File
@@ -13,9 +13,9 @@ from test_investigator import LoopAdapter, CASE
class FinalAdapter:
def __init__(self, confirmed=False, change=None):
self.confirmed=confirmed; self.change=change; self.calls=0
self.confirmed=confirmed; self.change=change; self.calls=0; self.requests=[]
async def generate(self,config,request,*,max_response_bytes):
self.calls+=1
self.calls+=1; self.requests.append(copy.deepcopy(request))
c=json.loads(request['messages'][1]['content'])['input']
eid=next(iter(c['evidence']))
result={'status':'confirmed' if self.confirmed else 'inconclusive','selected_hypothesis':'H1' if self.confirmed else None,
@@ -25,6 +25,10 @@ class FinalAdapter:
'countercheck':{'adequate':True,'reason':'Alternative was compared against the scoped finding','evidence_refs':[next(ref for ref,entry in c['evidence'].items() if entry['record']['hypothesis_id']==h)]}} for h in c['investigation']['hypotheses']],
'remaining_uncertainty':['Snapshot does not establish current live behavior.'],'reason':'Evidence supports a cause' if self.confirmed else 'No causal explanation established',
'recommended_human_actions':[] if self.confirmed else ['Observe the activation under the specified build.']}
if 'decision_basis' in request['output_schema']['required']:
result['decision_basis']={'causal_explanation':'Scoped findings explain the observed activation failure.',
'scope_limits':['Only the supplied snapshot was examined.'],
'sufficient_for_change':self.confirmed,'contradictions':[]}
if self.change:self.change(result,c)
return ProviderResponse(result,'fixture-final',100,50)
@@ -171,3 +175,51 @@ def test_explicit_provider_resume_preserves_context_and_budget(investigated):
result=build(builder,retry_id='provider-restored')
assert result.status=='COMPLETED' and adapter.calls==1
assert BudgetManager(store).snapshot(*CASE).state==before
def test_retry_receives_specific_feedback_and_replays(investigated):
store,_=investigated
adapter=FinalAdapter(True)
adapter.change=lambda p,c: p.update(selected_hypothesis='H999') if adapter.calls==1 else None
builder=DiagnosisBuilder(store,gateway(adapter))
assert build(builder).status=='REJECTED'
assert build(builder).status=='COMPLETED'
first=json.loads(adapter.requests[0]['messages'][1]['content'])['input']
second=json.loads(adapter.requests[1]['messages'][1]['content'])['input']
assert 'validation_feedback' not in first
assert second['validation_feedback'][0]['code']=='DIAGNOSIS_SELECTION'
assert second['validation_feedback'][0]['llm_result_ref']
assert build(builder).reused and adapter.calls==2
def test_legacy_rejection_upgrades_next_attempt_without_rewriting_context(investigated):
from viano_bug_analysis.evidence.store import digest
from viano_bug_analysis.lifecycle.storage import json_bytes
store,_=investigated
adapter=FinalAdapter(True)
adapter.change=lambda p,c: p.update(selected_hypothesis='H999') if adapter.calls==1 else None
builder=DiagnosisBuilder(store,gateway(adapter))
budget=BudgetManager(store)
snapshot=budget.snapshot(*CASE)
with PackageReader(store.latest(*CASE)) as reader:
saved=builder.loop._load(reader)
working=builder.loop._context(saved,'H1',budget)
investigation_ref=reader.manifest['artifacts']['investigation_state']
legacy={'format':'viano-diagnosis-context','format_version':1,'case':working['case'],
'interpretation':working['interpretation'],'investigation':saved,
'investigation_ref':investigation_ref,'budget':snapshot.state,'evidence':working['evidence'],
'max_invalid_responses':3}
ref='analysis/diagnosis/0001/context.json'
raw=json_bytes(legacy)
store.advance(*CASE,State.BUILDING_DIAGNOSIS,step_id='diagnosis:v1:1:start',
details={'operation':'diagnosis-start','context_ref':ref,'context_sha256':digest(legacy)},
additions={ref:raw},artifacts={'diagnosis_context':ref})
assert build(builder).status=='REJECTED'
assert adapter.requests[0]['prompt_version']=='diagnosis_v1'
assert build(builder).status=='COMPLETED'
assert adapter.requests[1]['prompt_version']=='diagnosis_v2'
received=json.loads(adapter.requests[1]['messages'][1]['content'])['input']
assert received['validation_feedback'][0]['code']=='DIAGNOSIS_SELECTION'
assert build(builder).reused and adapter.calls==2
with PackageReader(store.latest(*CASE)) as reader:assert reader.read_bytes(ref)==raw
assert budget.snapshot(*CASE).state==snapshot.state
+93
View File
@@ -0,0 +1,93 @@
import copy
import pytest
from viano_bug_analysis.analysis.diagnosis_builder import DiagnosisError, validate_evaluation
from viano_bug_analysis.analysis.early_stop import accepts_evaluation, check_request
def example():
context = {
'evaluation_protocol': 2,
'investigation': {'early_stop': {'hypothesis_id': 'H1'}, 'hypotheses': {
h: {'disposition': 'active'} for h in ('H1', 'H2', 'H3')}},
'evidence': {'E000001': {'record': {'hypothesis_id': 'H1'},
'content': {'items': [{'finding': 'Required activation record absent in complete filtered snapshot'}],
'coverage': 'complete_filtered_rows'}}},
'budget': {'accounts': {h: {'actions': {}} for h in ('H1', 'H2', 'H3')}},
}
payload = {
'status': 'confirmed', 'selected_hypothesis': 'H1', 'confidence': 'strongly_supported',
'root_cause': {'summary': 'Required activation data missing',
'technical_description': 'The missing static activation record explains the observed failure within the checked snapshot.',
'evidence_refs': ['E000001']},
'evaluations': [{'hypothesis_id': h, 'verdict': 'supported' if h == 'H1' else 'unresolved',
'reason': 'Absence of required activation data supports H1 and weakens the hidden-static-instance alternative.',
'evidence_refs': ['E000001'], 'countercheck': {'adequate': h == 'H2',
'reason': 'The same query checks the prerequisite of an existing static instance. Dynamic paths remain untested.',
'evidence_refs': ['E000001']}} for h in ('H1', 'H2', 'H3')],
'remaining_uncertainty': ['An additional dynamic activation path has not been excluded.'],
'reason': 'A bounded correction is justified without claiming exhaustive exclusion.',
'recommended_human_actions': ['Validate the expected interaction after the bounded correction.'],
'decision_basis': {'causal_explanation': 'The required static activation input is missing.',
'scope_limits': ['Complete filtered snapshot query, no exhaustive runtime audit.'],
'sufficient_for_change': True, 'contradictions': []},
}
return payload, context
def test_shared_evidence_and_unfinished_alternatives_allow_early_completion():
payload, context = example()
validate_evaluation(payload, context)
assert accepts_evaluation(payload, context)
# A new uninvestigated possibility does not create an obligation to exclude it.
context['investigation']['hypotheses']['H4'] = {'disposition': 'active'}
evaluation = copy.deepcopy(payload['evaluations'][-1])
evaluation['hypothesis_id'] = 'H4'
payload['evaluations'].append(evaluation)
validate_evaluation(payload, context)
@pytest.mark.parametrize('change,code', [
(lambda p,c: p['decision_basis']['contradictions'].append({'evidence_refs':['E000001'], 'resolution':'unresolved', 'reason':'Observed runtime contradicts the explanation.'}), 'UNRESOLVED_CONTRADICTION'),
(lambda p,c: p['decision_basis'].update(sufficient_for_change=False), 'INSUFFICIENT_CAUSAL_SUPPORT'),
(lambda p,c: p.update(remaining_uncertainty=[]), 'UNCERTAINTY_REQUIRED'),
(lambda p,c: p['root_cause'].update(evidence_refs=['E999999']), 'EVIDENCE_REFERENCE'),
(lambda p,c: [e['countercheck'].update(adequate=False) for e in p['evaluations']], 'COUNTERCHECK'),
(lambda p,c: c['evidence']['E000001']['content'].update(coverage='schema_only'), 'UNGROUNDED'),
(lambda p,c: p.pop('decision_basis'), 'DECISION_BASIS'),
])
def test_material_validation_requirements_remain(change, code):
payload, context = example()
change(payload, context)
with pytest.raises(DiagnosisError, match=code):
validate_evaluation(payload, context)
def test_contradiction_must_have_known_references_even_if_addressed():
payload, context = example()
payload['decision_basis']['contradictions'] = [{'evidence_refs':['E000001'],
'resolution':'addressed', 'reason':'The apparent conflict concerns a different snapshot scope.'}]
validate_evaluation(payload, context)
payload['decision_basis']['contradictions'][0]['evidence_refs'] = ['E999999']
with pytest.raises(DiagnosisError, match='EVIDENCE_REFERENCE'):
validate_evaluation(payload, context)
def test_request_can_reuse_another_hypothesis_finding():
from test_early_diagnosis import gate_fixture
saved, context, decision, budget = gate_fixture()
decision['update']['evidence_refs'] = ['E000002']
decision['next']['countercheck_refs'] = ['E000002']
check_request(saved, context, decision, budget)
def test_supported_alternative_is_not_automatically_a_contradiction():
payload, context = example()
payload['evaluations'][1]['verdict'] = 'supported'
validate_evaluation(payload, context)
assert accepts_evaluation(payload, context)
from test_early_diagnosis import gate_fixture
saved, ctx, decision, budget = gate_fixture()
saved['hypotheses']['H2']['confidence'] = 'supported'
check_request(saved, ctx, decision, budget)
+1 -3
View File
@@ -154,9 +154,7 @@ def gate_fixture():
@pytest.mark.parametrize('change,code', [
(lambda s,c,d,b: d['update'].update(confidence='supported'), 'STRONG_EVIDENCE'),
(lambda s,c,d,b: d['update'].update(evidence_refs=['E000002']), 'STRONG_EVIDENCE'),
(lambda s,c,d,b: d['next'].update(countercheck_refs=['E000001']), 'COUNTERCHECK'),
(lambda s,c,d,b: s['hypotheses']['H2'].update(confidence='supported'), 'COMPETING_SUPPORT'),
(lambda s,c,d,b: d['next'].update(countercheck_refs=[]), 'COUNTERCHECK'),
(lambda s,c,d,b: s.update(early_stop_attempts=3), 'LIMIT'),
(lambda s,c,d,b: s.update(early_stop={'status':'declined','fingerprint':evidence_fingerprint(c['evidence'])}), 'NEW_EVIDENCE'),
(lambda s,c,d,b: b['accounts']['H1']['actions'].update(A={'status':'awaiting_assessment'}), 'PENDING_RESULT'),
+1 -1
View File
@@ -203,7 +203,7 @@ def test_model_loop_uses_project_tools_and_compact_previous_result(tool_case):
if previous:
assert context["evidence"][eid]["content"]["items"]
assert "inputs" not in context["case"]
assert request["prompt_version"]=="investigation_project_v3"
assert request["prompt_version"]=="investigation_project_v4"
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()}}
+1 -1
View File
@@ -28,7 +28,7 @@ class PipelineAdapter:
async def generate(self, config, request, **kwargs):
self.calls += 1
phase = {"case_interpretation_v1": "case", "hypotheses_v1": "hypotheses", "hypotheses_v2": "hypotheses",
"investigation_v1": "loop", "investigation_v2": "loop", "diagnosis_v1": "diagnosis",
"investigation_v1": "loop", "investigation_v2": "loop", "investigation_v3": "loop", "diagnosis_v1": "diagnosis", "diagnosis_v2": "diagnosis",
"change_request_v1": "request"}[request["prompt_version"]]
adapter = self.adapters[phase]
return await adapter.generate(config, request, **kwargs)