diff --git a/docs/joint-case-planning-v2.md b/docs/joint-case-planning-v2.md index 0a324bd..e40df0e 100644 --- a/docs/joint-case-planning-v2.md +++ b/docs/joint-case-planning-v2.md @@ -122,3 +122,33 @@ verification is not silently filled: the scoped statement correction must provid The original 2026-10-20 replies and a bounded validation context are preserved in the curated rejection corpus. Offline regression checks hashes, reference union, sibling preservation and rejection of invented references. No paid API request was needed. + + +## Structural recovery revision 2 + +New contexts freeze `recovery_version = 2`; revision 1 and unmarked historical +contexts keep their original request/projection behavior. This revision includes +revision 1 dependency completion and sibling preservation. + +One exact misplaced wrapper is recoverable: a sole top-level `hypotheses` object +containing exactly an `interpretation` object and a `hypotheses` array. These are +copied to the two intended blocks, without changing or dropping any content. +Competing top-level interpretations (even null), extra sibling keys and malformed +wrappers are never guessed away. Both recovered blocks still undergo all existing +schema, source, dependency and semantic contract checks. The original call stays +unchanged and remains the usage/provenance source during replay and import. + +During a scoped entity correction, existing eligible identity/mention citations +are restored only when BOTH entity_id and reason are unchanged. Eligible references +come from the original statement and the frozen entity's matching capture/mention +provenance. New evidence is never invented. Changed claims receive no restoration; +unknown replacement sources remain present and invalid. Requests explicitly explain +which existing citations to preserve. When no reusable hypotheses exist, the request +explicitly requires an empty reviews array instead of asking to reconstruct them. + +The curated `joint-nested-envelope` corpus preserves the actual rejected replies, +their hashes and bounded context. Offline tests confirm that the initial reply passes +full interpretation and hypothesis validation after lossless wrapper recovery and +existing dependency normalization. A separate regression confirms the later scoped +patch passes interpretation validation after preserving unchanged identity evidence. +No paid API request was executed to establish these results. diff --git a/src/viano_bug_analysis/analysis/hypothesis_prompt.py b/src/viano_bug_analysis/analysis/hypothesis_prompt.py index 63249fd..8639a62 100644 --- a/src/viano_bug_analysis/analysis/hypothesis_prompt.py +++ b/src/viano_bug_analysis/analysis/hypothesis_prompt.py @@ -32,5 +32,5 @@ class HypothesisPromptBuilder(PromptBuilder): from .json_consolidation import consolidate model_context = consolidate(model_context) return {"prompt_version": version, "privacy": "configured", "output_schema": schema, - "messages": [{"role": "system", "content": files("viano_bug_analysis.llm.prompts").joinpath(version + ".txt").read_text(encoding="utf-8") + ("\nPreserve joint_retained.proposals exactly at their zero-based positions. Generate ONLY the other positions; return the full array for deterministic merging/validation. Retained proposals were explicitly reviewed; do not rewrite or reorder them." if context.get("joint_retained") else "") + ("\nRepair only invalid hypotheses at unlocked positions. Preserve their valid claims and mechanisms; correct the reported dependency/source defect. Do not regenerate accepted interpretation or locked candidates." if context.get("joint_retained") and context.get("case_context",{}).get("joint_generation",{}).get("recovery_version")==1 else "")}, + "messages": [{"role": "system", "content": files("viano_bug_analysis.llm.prompts").joinpath(version + ".txt").read_text(encoding="utf-8") + ("\nPreserve joint_retained.proposals exactly at their zero-based positions. Generate ONLY the other positions; return the full array for deterministic merging/validation. Retained proposals were explicitly reviewed; do not rewrite or reorder them." if context.get("joint_retained") else "") + ("\nRepair only invalid hypotheses at unlocked positions. Preserve their valid claims and mechanisms; correct the reported dependency/source defect. Do not regenerate accepted interpretation or locked candidates." if context.get("joint_retained") and context.get("case_context",{}).get("joint_generation",{}).get("recovery_version") in (1,2) else "")}, {"role": "user", "content": encode({"task": "generate_hypotheses", "input": model_context, "output_schema": schema})}]} diff --git a/src/viano_bug_analysis/analysis/interpreter.py b/src/viano_bug_analysis/analysis/interpreter.py index e3f9301..805f325 100644 --- a/src/viano_bug_analysis/analysis/interpreter.py +++ b/src/viano_bug_analysis/analysis/interpreter.py @@ -134,7 +134,7 @@ class CaseInterpreter: from .hypothesis_builder import HypothesisBuilder prior = HypothesisBuilder(self.store, self.gateway, self.joint_config)._history(reader, checkpoint_chain(reader), revision) context["joint_generation"] = {"policy": self.joint_config.policy(), "prior_generations": prior} - if self.joint_version==2:context["joint_generation"].update(version=2, recovery_version=1) + if self.joint_version==2:context["joint_generation"].update(version=2, recovery_version=2) prompt_version = self.prompt.version(context) chain = checkpoint_chain(reader) complete_id = f"case-interpreter:v1:revision:{revision}:complete" diff --git a/src/viano_bug_analysis/analysis/joint_planning.py b/src/viano_bug_analysis/analysis/joint_planning.py index 13860fe..25218cc 100644 --- a/src/viano_bug_analysis/analysis/joint_planning.py +++ b/src/viano_bug_analysis/analysis/joint_planning.py @@ -51,7 +51,7 @@ def build_request(context): return {"prompt_version": version, "privacy": "configured", "output_schema": {"type": "object", "properties": {"interpretation": {}, "hypotheses": {}}, "additionalProperties": False}, - "messages": [{"role": "system", "content": files("viano_bug_analysis.llm.prompts").joinpath(version + ".txt").read_text(encoding="utf-8") + ("\nFor every described_behavior statement include verification=not_independently_verified. Each premise reference must also occur in its proposal dependency lists; declare the union.\n" if settings.get("recovery_version")==1 else "")}, + "messages": [{"role": "system", "content": files("viano_bug_analysis.llm.prompts").joinpath(version + ".txt").read_text(encoding="utf-8") + ("\nFor every described_behavior statement include verification=not_independently_verified. Each premise reference must also occur in its proposal dependency lists; declare the union.\n" if settings.get("recovery_version") in (1,2) else "")}, {"role": "user", "content": model_json({"task": "interpret_and_propose", "input": consolidate(compact_context(view)), "policy": settings["policy"], "prior_generations": settings["prior_generations"], "block_contracts": {"interpretation": interpretation, "hypotheses": hypotheses}})}]} @@ -73,6 +73,8 @@ def interpretation_payload(call, context, reader=None, depth=0): from .joint_v2 import project return project(reader,call,context,depth) if is_joint(call): + from .joint_v2 import joint_document + payload = joint_document(payload,context) payload = payload.get("interpretation") if isinstance(payload, dict) else None payload = normalize_block(payload, output_schema(context)) return payload diff --git a/src/viano_bug_analysis/analysis/joint_v2.py b/src/viano_bug_analysis/analysis/joint_v2.py index 7eacd60..5e4d2d2 100644 --- a/src/viano_bug_analysis/analysis/joint_v2.py +++ b/src/viano_bug_analysis/analysis/joint_v2.py @@ -60,7 +60,53 @@ def get(value, path): def recovery_enabled(context): - return enabled(context) and context['joint_generation'].get('recovery_version') == 1 + return enabled(context) and context['joint_generation'].get('recovery_version') in (1,2) + + +def structural_recovery(context): + return enabled(context) and context['joint_generation'].get('recovery_version') == 2 + + +def joint_document(document, context): + """Recover one exact, unambiguous wrapper error; keep the captured answer intact.""" + if not structural_recovery(context) or not isinstance(document,dict):return document + inner=document.get('hypotheses') + if not isinstance(inner,dict) or 'interpretation' not in inner:return document + # Never select between competing blocks, including null or malformed duplicates. + if 'interpretation' in document:raise InterpretationError('INTERPRETATION_SCHEMA') + if set(document)!={'hypotheses'} or set(inner)!={'interpretation','hypotheses'}:return document + if not isinstance(inner['interpretation'],dict) or not isinstance(inner['hypotheses'],list):return document + return {'interpretation':copy.deepcopy(inner['interpretation']), + 'hypotheses':{'hypotheses':copy.deepcopy(inner['hypotheses'])}} + + +def entity_citations(statement, context): + """Existing identity/mention citations eligible for preservation, not new evidence.""" + from .contracts import entity_binding_sources + if not isinstance(statement,dict):return [] + sid=statement.get('entity_id') + if not isinstance(sid,str):return [] + source=context['sources'].get(sid,{}) + if source.get('kind')!='entity':return [] + data=source['data'] + if data.get('citation_policy') not in {'linked_capture_v2','grouped_capture_v3'}:return [] + eligible=entity_binding_sources(sid,source,context['sources']) | set(data['mention_source_ids']) + eligible.update(b['source_id'] for b in data.get('name_bindings',[])) + refs=statement.get('source_ids') + if not isinstance(refs,list) or not all(isinstance(s,str) for s in refs):return [] + return [s for s in refs if s in eligible and s in context['sources']] + + +def preserve_entity_citations(original, replacement, context): + """Restore only existing citations for the identical entity and identical claim.""" + result=copy.deepcopy(replacement) + if not structural_recovery(context) or not isinstance(original,dict) or not isinstance(result,dict):return result + if any(original.get(k)!=result.get(k) for k in ('entity_id','reason')):return result + refs=result.get('source_ids') + if not isinstance(refs,list) or not all(isinstance(s,str) for s in refs):return result + for sid in entity_citations(original,context): + if sid not in refs:refs.append(sid) + return result def complete_dependencies(raw, interpretation, context): @@ -222,6 +268,16 @@ def repair_request(request,reader,failure,feedback): "statements":selected,"hypotheses":raw,"declared_interpretation_inputs":dependencies, "sources":{s:context["sources"][s] for s in sorted(source_ids) if s in context["sources"]}, "interpretation_contract":schemas(context)[0],"review_contract":review} + extra_instruction='' + if structural_recovery(context): + keep={path:entity_citations(value,context) for path,value in selected.items() if path.startswith('/relevant_entities/')} + keep={path:refs for path,refs in keep.items() if refs} + if keep: + body['preserve_identity_citations']=keep + extra_instruction+='\nFor an unchanged entity_id and reason, retain listed preserve_identity_citations. They already support that exact identity statement. Do not replace identity evidence with report-only references.' + if raw is None: + body['review_contract']={'type':'array','maxItems':0} + extra_instruction+='\nNo reusable hypotheses are present. Return reviews=[]; repair only the listed interpretation statements. Do not reconstruct or review missing hypotheses.' from .model_context import compact_context,model_json from .json_consolidation import consolidate binding={k:body.pop(k) for k in ('source_ref','source_sha256')} @@ -231,7 +287,7 @@ def repair_request(request,reader,failure,feedback): "output_schema":{"type":"object","required":["patches"],"properties":{ "patches":{"type":"array","minItems":len(paths),"maxItems":len(paths),"items":patch}, "reviews":{}},"additionalProperties":False}, - "messages":[{"role":"system","content":files("viano_bug_analysis.llm.prompts").joinpath(REPAIR+'.txt').read_text(encoding="utf-8")+"\n"+encoding}, + "messages":[{"role":"system","content":files("viano_bug_analysis.llm.prompts").joinpath(REPAIR+'.txt').read_text(encoding="utf-8")+"\n"+encoding+extra_instruction}, {"role":"user","content":encoded}]} @@ -269,11 +325,19 @@ def project(reader,call,context,depth=0): result=copy.deepcopy(original) for patch in sorted(patches,key=lambda p:(p["path"].split('/')[1], int(p["path"].split('/')[-1]) if p["path"].split('/')[-1].isdigit() else -1),reverse=True): parts=patch["path"].strip('/').split('/') - if len(parts)==1:result[parts[0]]=copy.deepcopy(patch["replacement"]) + if len(parts)==1: + value=copy.deepcopy(patch['replacement']) + if parts[0]=='relevant_entities' and isinstance(value,list) and isinstance(original.get(parts[0]),list): + for i,entity in enumerate(value): + if i