Allow shared evidence and bounded causal diagnosis with retry feedback
This commit is contained in:
1 parent
5082354bc3
commit
c1f44e4e02
21 files changed
+727
-41
No files matched your search
@@ -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
|
||||
@@ -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)
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user