Align checkpoint recall validation and resume lean investigation
This commit is contained in:
1 parent
e92476517c
commit
7b7779b769
8 files changed
+246
-7
No files matched your search
@@ -0,0 +1,38 @@
|
||||
# Konsolidierung mit anschließendem Belegabruf
|
||||
|
||||
Der Bug17-Lauf wurde an Generation 84 technisch blockiert. Entgegen der ersten
|
||||
Auswertung war `recall` im äußeren begrenzten v20-Checkpoint-Vertrag bereits erlaubt.
|
||||
Die zweite Prüfung der normalisierten kanonischen Antwort verwendete jedoch den
|
||||
alten Vertrag ohne Recall-Aktion und verwarf alle drei Antworten an `/next`.
|
||||
Die Erklärung in den bisherigen Testberichten bleibt als historischer Befund
|
||||
stehen; dieser Nachtrag präzisiert die tatsächliche Ursache.
|
||||
|
||||
Beide Prüfschichten verwenden jetzt dieselbe Aktion für begrenzte Checkpoints:
|
||||
eine bekannte E-Referenz und eine nichtnegative Seitennummer. Unbekannte Belege
|
||||
und nicht vorhandene Seiten werden weiter vom Controller abgelehnt. Die gesonderte
|
||||
frühe Diagnosebewertung bleibt auf ihre erlaubten Aktionen beschränkt.
|
||||
|
||||
Bei erfolgreicher Konsolidierung mit Recall wird unter Fenster-Version 2 der
|
||||
aktuelle Arbeitsstand gespeichert, die Session konsolidiert und der Checkpoint
|
||||
beendet. Die angeforderte Seite bleibt im Zustand erhalten; der nächste Aufruf ist
|
||||
wieder `investigate_one_step`. Es erfolgt keine automatische Tool-Recherche und
|
||||
keine Umdeutung der Aktionsabsicht. Bereits gespeicherte Antworten werden nicht
|
||||
verändert. Budgetabrechnung, Gegenbelege, Hypothesenwahl und Zeichenobergrenze
|
||||
bleiben erhalten. Fenster-Version 1 behält ihr bisheriges Sessionverhalten.
|
||||
|
||||
Bei ungültigen Next-Aktionen nennt das Feedback jetzt `/next/kind` und die aus dem
|
||||
maßgeblichen Schema abgeleiteten zulässigen Aktionen. Keine ungeprüften Nutzwerte
|
||||
werden in den Hinweis übernommen.
|
||||
|
||||
Der Offline-Bericht `test-results/token-usage/bug17-checkpoint-recall-offline-fix-20261020.json`
|
||||
belegt, dass sämtliche drei kuratierten Antworten gegen ihre exakten ursprünglichen
|
||||
Vollkontexte bestehen. Messages, Antwortschema und Prompt-Version der ursprünglichen
|
||||
Anfragen bleiben identisch. Kein neuer API-Aufruf war dafür notwendig.
|
||||
|
||||
Regressionstests prüfen Originalantworten, beide Prüfschichten, gespeicherten
|
||||
Arbeitsstand, Rückkehr zur schlanken Schleife, portable Wiederaufnahme, unveränderte
|
||||
Budgets beim reinen Recall sowie Ablehnung unbekannter Belegreferenzen und Seiten.
|
||||
|
||||
Prüfergebnis: 89 lokale Tests bestanden (158,35 Sekunden). Der bestehende
|
||||
Bug17-Fall wurde ohne Budget- oder Artefaktänderung von Generation 84 auf 85
|
||||
manuell wieder freigegeben. Keine kostenpflichtigen API-Aufrufe.
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"format": "viano-offline-regression",
|
||||
"date": "2026-10-20",
|
||||
"run": "bug17-bounded-full-20261020",
|
||||
"source_generation": 84,
|
||||
"scope": "All three rejected checkpoint replies validated unchanged against their exact saved full contexts; no tool execution, budget mutation or paid LLM request.",
|
||||
"results": [
|
||||
{
|
||||
"call_ref": "llm/operations/6849c974442865e5ca3e8f146ef4520c275f2e37a25fb7caa498f17089ed6dc1/round-0001.json",
|
||||
"reply_sha256": "44b25b401d7019b6da566658147d28c93e7aeaf22644497200afdb7b3ceae386",
|
||||
"full_saved_context_validation": "passed",
|
||||
"request_messages_schema_version_unchanged": true
|
||||
},
|
||||
{
|
||||
"call_ref": "llm/operations/dfceb150480e764106de4d51501b6ed0b2daa32533888a9dabcc88114203726d/round-0001.json",
|
||||
"reply_sha256": "7ab36e7d2604dc8c4537eef2516f2ed68cdeaa6864d1f4a98b5cd41d16933250",
|
||||
"full_saved_context_validation": "passed",
|
||||
"request_messages_schema_version_unchanged": true
|
||||
},
|
||||
{
|
||||
"call_ref": "llm/operations/a641cb5c51a47e1e2228d4a99c508446cfa14e6d4f1c8de3f639b6acb4851fb7/round-0001.json",
|
||||
"reply_sha256": "5546ff27268c2a0a699280edd3b8d713f0387730499db12ea94e97051e4cdf03",
|
||||
"full_saved_context_validation": "passed",
|
||||
"request_messages_schema_version_unchanged": true
|
||||
}
|
||||
],
|
||||
"local_tests": {
|
||||
"passed": 89,
|
||||
"elapsed_seconds": 158.35,
|
||||
"scope": "checkpoint recall, bounded v1/v2, lean loop, canonical decisions and startup"
|
||||
},
|
||||
"source_sha256": {
|
||||
"src\\viano_bug_analysis\\analysis\\investigation_compact.py": "2522b63dd5b4943213afa659b1531aea64231a7a4777f5113f5f8f17f0f7290f",
|
||||
"src\\viano_bug_analysis\\analysis\\investigation_lean_v20.py": "f6373c0cf6c619129c127784c0ef088f14480f892c9817c12ce6a48e7ffc6d09",
|
||||
"src\\viano_bug_analysis\\analysis\\investigator.py": "5993ad05bc8a971f167e7056ba66fc2b928f26c7299da744a832d1dd4069cabd"
|
||||
},
|
||||
"manual_resume": {
|
||||
"from_generation": 84,
|
||||
"to_generation": 85,
|
||||
"state": "INVESTIGATING_HYPOTHESES",
|
||||
"unchanged_artifact_sha256": {
|
||||
"investigation_state": "f469f7431a46880da4ee34ae8912d20977607665bc6985607903086718a48a68",
|
||||
"budget_ledger": "c501e306fafc85bc63f443c72376d99f5e77311d3fb15cd8b7b3cedcca66282d",
|
||||
"case_interpretation": "0fd3c2a1efe0f229592b3038b66db26e704f2fb1a03b7321ceb76450e3f98cb4",
|
||||
"hypotheses": "30494a06ce373f4c497b3964524cee21bdf5350eac515f7a0022619879d125c8"
|
||||
},
|
||||
"budgets_reset": false,
|
||||
"invalid_responses_reset": false,
|
||||
"paid_api_calls": 0
|
||||
}
|
||||
}
|
||||
@@ -238,8 +238,19 @@ def validate_tool_shape(context,decision):
|
||||
if 'lean_session' in context:
|
||||
from .investigation_lean import schema
|
||||
from jsonschema import Draft202012Validator
|
||||
error=next(Draft202012Validator(schema(context,strict=True)).iter_errors(decision),None)
|
||||
contract = schema(context,strict=True)
|
||||
from .investigation_window import enabled, checkpoint_recall
|
||||
from .investigation_lean import checkpoint
|
||||
if enabled(context) and checkpoint(context) and not preparing_review(context):
|
||||
# Validate the canonical checkpoint wire with the same recall action
|
||||
# already advertised by the bounded v20 model-facing contract.
|
||||
contract = checkpoint_recall(contract)
|
||||
error=next(Draft202012Validator(contract).iter_errors(decision),None)
|
||||
if error:
|
||||
if context.get('policy',{}).get('prompt_window_version') == 2:
|
||||
from .investigation_contract import feedback
|
||||
from .investigation_config import InvestigationError
|
||||
raise InvestigationError('INVESTIGATION_OUTPUT',feedback=feedback(error,decision))
|
||||
from viano_bug_analysis.llm.diagnostics import schema_feedback
|
||||
from .investigation_config import InvestigationError
|
||||
from .investigation_lean import repair_feedback
|
||||
|
||||
@@ -98,6 +98,21 @@ def feedback(error, document):
|
||||
result = filter_feedback(repair_feedback(schema_feedback(error, include_keyword=True)))
|
||||
path = result.get('validation_path', '')
|
||||
action = document.get('next') if isinstance(document,dict) else None
|
||||
if path in {'/next','/next/kind'}:
|
||||
def permitted(rule):
|
||||
if not isinstance(rule,dict): return set()
|
||||
kind = rule.get('properties',{}).get('kind',{})
|
||||
names = {kind['const']} if isinstance(kind.get('const'),str) else set(kind.get('enum',[]))
|
||||
for key in ('oneOf','anyOf'):
|
||||
for branch in rule.get(key,[]): names.update(permitted(branch))
|
||||
return names
|
||||
names = permitted(error.schema)
|
||||
if path == '/next/kind':
|
||||
names.update(error.schema.get('enum',[]))
|
||||
if isinstance(error.schema.get('const'),str): names.add(error.schema['const'])
|
||||
if names:
|
||||
result.update(validation_path='/next/kind',validation_fields=['kind'],
|
||||
requirement='choose one permitted action with its required fields: '+', '.join(sorted(names)))
|
||||
if isinstance(action,dict) and isinstance(action.get('kind'),dict):
|
||||
result.update(validation_path='/next/kind', validation_fields=['kind'],
|
||||
requirement='next.kind must be a permitted literal string, never a const/enum object; do not invent actions')
|
||||
|
||||
@@ -20,11 +20,8 @@ def schema(context, *, strict=False):
|
||||
if enabled(context):
|
||||
result = extend_recall(result)
|
||||
if previous.checkpoint(context):
|
||||
result['properties']['next'] = {'oneOf': [result['properties']['next'], {
|
||||
'type':'object','additionalProperties':False,'required':['kind','evidence_refs'],
|
||||
'properties':{'kind':{'const':'recall'},'page':{'type':'integer','minimum':0},
|
||||
'evidence_refs':{'type':'array','minItems':1,'maxItems':1,
|
||||
'items':{'type':'string','pattern':'^E[0-9]{6,}$'}}}}]}
|
||||
from .investigation_window import checkpoint_recall
|
||||
result = checkpoint_recall(result)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -127,6 +127,17 @@ def evidence_page(entry, index=0):
|
||||
'fragment': raw[start:start + PAGE_CHARACTERS]}
|
||||
|
||||
|
||||
def checkpoint_recall(schema):
|
||||
"""Same literal action for model-facing and canonical checkpoint validation."""
|
||||
result = copy.deepcopy(schema)
|
||||
result['properties']['next'] = {'oneOf': [result['properties']['next'], {
|
||||
'type':'object','additionalProperties':False,'required':['kind','evidence_refs'],
|
||||
'properties':{'kind':{'const':'recall'},'page':{'type':'integer','minimum':0},
|
||||
'evidence_refs':{'type':'array','minItems':1,'maxItems':1,
|
||||
'items':{'type':'string','pattern':'^E[0-9]{6,}$'}}}}]}
|
||||
return result
|
||||
|
||||
|
||||
def extend_recall(schema):
|
||||
schema = copy.deepcopy(schema)
|
||||
def visit(value):
|
||||
|
||||
@@ -872,7 +872,7 @@ class Investigator:
|
||||
if 'lean_session' in context:
|
||||
from .investigation_lean import checkpoint
|
||||
if checkpoint(context):
|
||||
if kind != 'recall':
|
||||
if kind != 'recall' or context['policy'].get('prompt_window_version') == 2:
|
||||
h['lean_session']={'turns':[],'checkpoint':False}
|
||||
if call['outcome']['document']['next']['kind']=='switch':
|
||||
state['lean_preferred_hypothesis']=call['outcome']['document']['next']['hypothesis_id']
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from test_bounded_contract_v2 import context, loop_for
|
||||
from test_case_builder import case_setup
|
||||
from test_investigation_tools import tool_case
|
||||
from test_investigation_startup import Adapter
|
||||
from test_investigator import CASE, run, state
|
||||
from viano_bug_analysis.analysis import Investigator, InvestigationError
|
||||
from viano_bug_analysis.analysis.investigation_lean_v20 import validate_reply, normalize
|
||||
from viano_bug_analysis.analysis.investigation_prompt import build_prompt
|
||||
from viano_bug_analysis.budget import BudgetManager
|
||||
from viano_bug_analysis.lifecycle import CheckpointStore, Workspace
|
||||
from viano_bug_analysis.llm import ProviderResponse
|
||||
from viano_bug_analysis.package import PackageReader, PackageValidator
|
||||
from test_case_interpreter import gateway
|
||||
|
||||
|
||||
CORPUS = Path(__file__).parent/'fixtures/llm_rejections/20261020/bug17-checkpoint-recall'
|
||||
|
||||
|
||||
def checkpoint_context():
|
||||
c = context()
|
||||
c['lean_session'] = {'checkpoint':True,'intent':'review','turns':[{
|
||||
'evidence_id':None,'observation':'Read the next page.','llm_ref':'fixture',
|
||||
'read_ids':list(c['evidence'])}]}
|
||||
return c
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name',['6849c974','dfceb150','a641cb5c'])
|
||||
def test_actual_checkpoint_answers_pass_both_validation_layers(name):
|
||||
c = checkpoint_context()
|
||||
answer = json.loads((CORPUS/name/'parsed-answer.json').read_text(encoding='utf-8'))
|
||||
before = copy.deepcopy(answer)
|
||||
validate_reply(c,answer)
|
||||
normalized = normalize(c,answer)
|
||||
assert normalized['next'] == answer['next']
|
||||
assert answer == before
|
||||
assert normalized['update']['working_brief']['findings']
|
||||
|
||||
|
||||
def test_invalid_action_feedback_names_allowed_checkpoint_actions():
|
||||
c = checkpoint_context()
|
||||
answer = json.loads((CORPUS/'6849c974/parsed-answer.json').read_text(encoding='utf-8'))
|
||||
answer['next'] = {'kind':'invented'}
|
||||
with pytest.raises(InvestigationError) as error:
|
||||
validate_reply(c,answer)
|
||||
info = error.value.feedback
|
||||
assert info['validation_path'] == '/next/kind'
|
||||
assert 'recall' in info['requirement'] and 'finish' in info['requirement']
|
||||
assert len(json.dumps(info)) < 500
|
||||
|
||||
|
||||
class RecallCheckpoint(Adapter):
|
||||
async def generate(self,config,request,**kwargs):
|
||||
body = json.loads(request['messages'][1]['content'])
|
||||
if body['task'] == 'investigate_one_step':
|
||||
self.calls.append(body)
|
||||
return ProviderResponse({'assessment':None,'observation':'Consolidate before reading another page.',
|
||||
'next':{'kind':'checkpoint','intent':'review'}},'fixture',100,50)
|
||||
if body['task'] == 'consolidate_investigation_session':
|
||||
self.calls.append(body)
|
||||
return ProviderResponse({'assessment':None,'confidence':'plausible','findings':[],
|
||||
'uncertainties':['More pages needed.'],'next_question':'Read the next evidence page.',
|
||||
'retire':[],'next':{'kind':'recall','evidence_refs':['E000001'],'page':0}},'fixture',100,50)
|
||||
return await super().generate(config,request,**kwargs)
|
||||
|
||||
|
||||
def test_checkpoint_recall_persists_brief_then_returns_to_short_loop(tool_case,tmp_path):
|
||||
adapter = RecallCheckpoint()
|
||||
loop = loop_for(tool_case,adapter)
|
||||
run(loop,steps=5)
|
||||
before = BudgetManager(tool_case[0]).snapshot(*CASE).state
|
||||
result = run(loop,steps=1)
|
||||
saved = state(result)
|
||||
assert result.status != 'BLOCKED'
|
||||
assert saved['hypotheses']['H1']['lean_session'] == {'turns':[],'checkpoint':False}
|
||||
assert saved['recalled_pages']['H1'] == {'E000001':0}
|
||||
assert saved['hypotheses']['H1']['working_brief_ref']
|
||||
assert BudgetManager(tool_case[0]).snapshot(*CASE).state == before
|
||||
assert PackageValidator().validate(result.package).valid
|
||||
recipient = CheckpointStore(Workspace(tmp_path/'recipient'))
|
||||
recipient.intake(result.package)
|
||||
restored = Investigator(recipient,gateway(adapter),router=loop.router)
|
||||
run(restored,steps=1)
|
||||
assert adapter.calls[-1]['task'] == 'investigate_one_step'
|
||||
assert 'E000001' in adapter.calls[-1]['input']['evidence']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change,code',[('unknown','INVESTIGATION_EVIDENCE_UNKNOWN'),('page','INVESTIGATION_EVIDENCE_PAGE')])
|
||||
def test_checkpoint_recall_cannot_read_unknown_evidence_or_page(tool_case,change,code):
|
||||
class Invalid(RecallCheckpoint):
|
||||
async def generate(self,config,request,**kwargs):
|
||||
response = await super().generate(config,request,**kwargs)
|
||||
if json.loads(request['messages'][1]['content'])['task'] == 'consolidate_investigation_session':
|
||||
response.document['next']['evidence_refs'] = ['E999999'] if change == 'unknown' else ['E000001']
|
||||
response.document['next']['page'] = 99999 if change == 'page' else 0
|
||||
return response
|
||||
loop = loop_for(tool_case,Invalid())
|
||||
run(loop,steps=5)
|
||||
before = BudgetManager(tool_case[0]).snapshot(*CASE).state
|
||||
result = run(loop,steps=1)
|
||||
assert state(result)['hypotheses']['H1']['notes'][-1]['code'] == code
|
||||
assert BudgetManager(tool_case[0]).snapshot(*CASE).state == before
|
||||
assert 'H1' not in state(result).get('recalled_pages',{})
|
||||
|
||||
|
||||
def test_early_diagnosis_review_still_forbids_recall():
|
||||
c = checkpoint_context()
|
||||
c['early_review'] = {'request':{'status':'preparing'}}
|
||||
answer = json.loads((CORPUS/'6849c974/parsed-answer.json').read_text(encoding='utf-8'))
|
||||
with pytest.raises(InvestigationError):
|
||||
validate_reply(c,answer)
|
||||
Reference in new issue
Block a user