Skip to content

Commit ef19b58

Browse files
committed
Enforce PaperFit repair candidate approval gates
1 parent f82f3f3 commit ef19b58

8 files changed

Lines changed: 696 additions & 107 deletions

scripts/orchestrator_runtime.py

Lines changed: 177 additions & 105 deletions
Large diffs are not rendered by default.

scripts/repair_plan_generator.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from pathlib import Path
1313
from typing import Any, Dict, List, Optional
1414

15+
from runtime_repair_risk import annotate_repair_candidates
16+
1517

1618
def _load_json(path: Optional[str]) -> Dict[str, Any]:
1719
if not path:
@@ -982,6 +984,7 @@ def generate_repair_plan(
982984
+ visual_space_candidates
983985
+ log_candidates
984986
)
987+
candidates = annotate_repair_candidates(candidates)
985988
candidates.sort(
986989
key=lambda item: (
987990
-int(item.get("priority_score") or 0),

scripts/runtime_approval.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55

66
from typing import Any, Dict
77

8+
try:
9+
from runtime_repair_risk import classify_repair_candidate_risk
10+
except ModuleNotFoundError: # package import during unit tests
11+
from .runtime_repair_risk import classify_repair_candidate_risk
12+
813

914
SOURCE_CHANGING_TASK_TYPES = {
1015
"full_vto",
@@ -146,6 +151,11 @@ def build_approval_object(
146151
requires_approval = False
147152
approval_granted = True
148153
reason = str(action.get("status"))
154+
elif action_skipped and action_reason == "approval_scope_blocked":
155+
status = "approval_required"
156+
requires_approval = True
157+
approval_granted = False
158+
reason = "approval_scope_blocked"
149159
elif action_skipped:
150160
status = "not_required"
151161
requires_approval = False
@@ -256,3 +266,77 @@ def build_approval_scope_carry_forward_check(
256266
"high_risk_operations": high_risk_operations,
257267
"checks": checks,
258268
}
269+
270+
271+
def build_candidate_approval_scope_gate(
272+
*,
273+
task: Dict[str, Any],
274+
approval: Dict[str, Any],
275+
repair_plan: Dict[str, Any],
276+
candidate_limit: int = 1,
277+
) -> Dict[str, Any]:
278+
"""Evaluate whether selected repair candidates fit the approval scope."""
279+
280+
task_type = _task_type(task)
281+
if task_type not in SOURCE_CHANGING_TASK_TYPES:
282+
return {
283+
"schema_version": "1.0",
284+
"status": "not_applicable",
285+
"reason": "task_does_not_change_source",
286+
"selected_candidate_count": 0,
287+
"candidate_risks": [],
288+
"blocked_candidates": [],
289+
}
290+
291+
policy = approval.get("policy") if isinstance(approval.get("policy"), dict) else {}
292+
allowed_surface = set(str(item) for item in (policy.get("mutation_surface") or []))
293+
high_risk_operations = set(str(item) for item in (policy.get("high_risk_operations") or []))
294+
candidates = [
295+
item
296+
for item in (repair_plan.get("candidates") or [])[: max(0, int(candidate_limit or 0))]
297+
if isinstance(item, dict)
298+
]
299+
candidate_risks = []
300+
blocked_candidates = []
301+
for index, candidate in enumerate(candidates, start=1):
302+
risk = candidate.get("risk") if isinstance(candidate.get("risk"), dict) else classify_repair_candidate_risk(candidate)
303+
surfaces = set(str(item) for item in (risk.get("mutation_surface") or []))
304+
operation = str(risk.get("operation") or "")
305+
surface_within_scope = bool(surfaces) and surfaces.issubset(allowed_surface)
306+
high_risk_operation = operation in high_risk_operations or str(risk.get("risk_level") or "") == "high"
307+
allowed = surface_within_scope and not high_risk_operation
308+
entry = {
309+
"index": index,
310+
"defect_family": candidate.get("defect_family"),
311+
"candidate_type": candidate.get("candidate_type"),
312+
"proposed_action": candidate.get("proposed_action"),
313+
"target": candidate.get("target"),
314+
"risk": risk,
315+
"checks": {
316+
"mutation_surface_within_scope": surface_within_scope,
317+
"high_risk_operation_requires_fresh_approval": high_risk_operation,
318+
},
319+
"allowed_under_current_scope": allowed,
320+
}
321+
candidate_risks.append(entry)
322+
if not allowed:
323+
blocked_candidates.append(entry)
324+
325+
status = "pass" if candidates and not blocked_candidates else "blocked" if candidates else "not_evaluated"
326+
reason = (
327+
"selected_candidates_within_approval_scope"
328+
if status == "pass"
329+
else "selected_candidate_exceeds_approval_scope"
330+
if blocked_candidates
331+
else "no_selected_candidates"
332+
)
333+
return {
334+
"schema_version": "1.0",
335+
"status": status,
336+
"reason": reason,
337+
"approval_scope": policy.get("approval_scope"),
338+
"candidate_limit": candidate_limit,
339+
"selected_candidate_count": len(candidates),
340+
"candidate_risks": candidate_risks,
341+
"blocked_candidates": blocked_candidates,
342+
}

scripts/runtime_repair_loop.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ def build_repair_loop_policy(
8888
repair_action = (runtime_actions.get("repair_plan_executor") or {}) if isinstance(runtime_actions, dict) else {}
8989
freshness = (artifact_manifest.get("freshness") or {}) if isinstance(artifact_manifest, dict) else {}
9090
approval_policy = approval.get("policy") or {}
91+
approval_scope_gate = repair_action.get("approval_scope_gate") if isinstance(repair_action, dict) else None
92+
if not isinstance(approval_scope_gate, dict):
93+
approval_scope_gate = None
9194

9295
max_rounds = max(1, _as_int(task.get("max_rounds"), 1))
9396
current_round = max(1, _as_int(state.get("current_round"), 1))
@@ -96,7 +99,9 @@ def build_repair_loop_policy(
9699
dry_run = bool(task.get("dry_run_source_mutation")) or repair_action.get("reason") == "dry_run_source_mutation"
97100

98101
stop_condition = "continue"
99-
if approval.get("status") == "approval_required":
102+
if approval_scope_gate and approval_scope_gate.get("status") != "pass":
103+
stop_condition = "approval_scope_blocked"
104+
elif approval.get("status") == "approval_required":
100105
stop_condition = "approval_required"
101106
elif str(status or "").lower() == "done" or str(gatekeeper_decision or "").upper() == "DONE":
102107
stop_condition = "done"
@@ -110,6 +115,8 @@ def build_repair_loop_policy(
110115
next_round_reason = "multi_round_apply_not_enabled_in_current_runtime"
111116
if dry_run:
112117
next_round_reason = "dry_run_source_mutation"
118+
elif stop_condition == "approval_scope_blocked":
119+
next_round_reason = "approval_scope_blocked"
113120
elif approval.get("status") == "approval_required":
114121
next_round_reason = "approval_required"
115122
elif stop_condition == "done":
@@ -132,6 +139,7 @@ def build_repair_loop_policy(
132139
"artifact_freshness_pass": freshness.get("status") == "pass",
133140
"mutation_integrity_available": bool(content_integrity.get("validation_status")),
134141
"source_mutation_executed": applied_count > 0,
142+
"candidate_approval_scope_gate_pass": approval_scope_gate is None or approval_scope_gate.get("status") == "pass",
135143
"within_round_limit": current_round < max_rounds,
136144
"runtime_execution_mode_can_auto_apply": False,
137145
}
@@ -159,6 +167,7 @@ def build_repair_loop_policy(
159167
"next_round_allowed": False,
160168
"next_round_reason": next_round_reason,
161169
"approval_scope_carry_forward": approval_scope_carry_forward,
170+
"candidate_approval_scope_gate": approval_scope_gate,
162171
"round_artifact_lineage": round_lineage,
163172
"second_round_apply_readiness": {
164173
"schema_version": "1.0",

scripts/runtime_repair_risk.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
#!/usr/bin/env python3
2+
"""Candidate-level risk classification for source-changing repair plans."""
3+
4+
from __future__ import annotations
5+
6+
from typing import Any, Dict, Iterable, List
7+
8+
9+
def _as_int(value: Any, default: int = 0) -> int:
10+
try:
11+
return int(value)
12+
except (TypeError, ValueError):
13+
return default
14+
15+
16+
def classify_repair_candidate_risk(candidate: Dict[str, Any]) -> Dict[str, Any]:
17+
"""Classify the mutation risk for one repair-plan candidate."""
18+
19+
defect_family = str(candidate.get("defect_family") or "")
20+
candidate_type = str(candidate.get("candidate_type") or "")
21+
proposed_action = str(candidate.get("proposed_action") or "")
22+
target = candidate.get("target") if isinstance(candidate.get("target"), dict) else {}
23+
target_kind = str(target.get("object_kind") or "")
24+
target_float_type = str(target.get("float_type") or "")
25+
section_distance = _as_int(candidate.get("section_distance"), 0)
26+
27+
operation = "layout_macros"
28+
mutation_surface: List[str] = ["layout_macros"]
29+
risk_level = "medium"
30+
reason = "layout_macro_adjustment"
31+
32+
if defect_family.startswith("A") or defect_family.startswith("C"):
33+
operation = "spacing"
34+
mutation_surface = ["spacing"]
35+
risk_level = "low"
36+
reason = "spacing_or_consistency_adjustment"
37+
elif defect_family == "B1":
38+
operation = "float_placement"
39+
mutation_surface = ["float_placement"]
40+
risk_level = "medium"
41+
reason = "float_placement_near_reference"
42+
if section_distance >= 1:
43+
operation = "float_movement_across_section_boundary"
44+
risk_level = "high"
45+
reason = "float_movement_may_cross_section_boundary"
46+
elif defect_family in {"B2", "B3"}:
47+
operation = "float_placement" if defect_family == "B3" else "layout_macros"
48+
mutation_surface = ["float_placement"] if defect_family == "B3" else ["layout_macros"]
49+
risk_level = "medium"
50+
reason = "float_layout_adjustment"
51+
if target_kind == "table_like" or target_float_type == "table" or candidate.get("source_table_env"):
52+
operation = "table_reconstruction"
53+
mutation_surface = ["table_environment", "table_placement"]
54+
risk_level = "high"
55+
reason = "table_layout_reconstruction_risk"
56+
elif defect_family.startswith("D"):
57+
operation = "layout_macros"
58+
mutation_surface = ["layout_macros"]
59+
risk_level = "medium"
60+
reason = "overflow_layout_repair"
61+
62+
if candidate_type == "global" and proposed_action == "review_paragraph_spacing_and_looseness":
63+
operation = "semantic_text_edit"
64+
mutation_surface = ["text_spans"]
65+
risk_level = "high"
66+
reason = "semantic_text_edit_requires_fresh_approval"
67+
if proposed_action == "template_migration":
68+
operation = "template_migration"
69+
mutation_surface = ["documentclass", "preamble", "template_macros"]
70+
risk_level = "high"
71+
reason = "template_migration_requires_fresh_approval"
72+
73+
return {
74+
"schema_version": "1.0",
75+
"risk_level": risk_level,
76+
"operation": operation,
77+
"mutation_surface": mutation_surface,
78+
"requires_fresh_approval": risk_level == "high",
79+
"reason": reason,
80+
}
81+
82+
83+
def annotate_repair_candidates(candidates: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
84+
"""Return candidates with a stable risk object attached."""
85+
86+
annotated: List[Dict[str, Any]] = []
87+
for candidate in candidates:
88+
item = dict(candidate)
89+
if not isinstance(item.get("risk"), dict):
90+
item["risk"] = classify_repair_candidate_risk(item)
91+
annotated.append(item)
92+
return annotated

scripts/test_runtime_contract.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
import unittest
44

5-
from scripts.runtime_approval import build_approval_object, build_approval_scope_carry_forward_check
5+
from scripts.runtime_approval import (
6+
build_approval_object,
7+
build_approval_scope_carry_forward_check,
8+
build_candidate_approval_scope_gate,
9+
)
10+
from scripts.runtime_repair_risk import classify_repair_candidate_risk
611
from scripts.runtime_state_machine import (
712
IllegalTransitionError,
813
SOURCE_CHANGING_STATE_MACHINE,
@@ -183,6 +188,119 @@ def test_approval_scope_carry_forward_reports_contract_match(self) -> None:
183188
self.assertTrue(check["checks"]["approval_scope_matches"])
184189
self.assertTrue(check["checks"]["fresh_approval_required_for_high_risk_operations"])
185190

191+
def test_repair_candidate_risk_classifies_same_section_float_as_medium(self) -> None:
192+
risk = classify_repair_candidate_risk(
193+
{
194+
"candidate_type": "source_anchor",
195+
"defect_family": "B1",
196+
"proposed_action": "move_float_closer_to_first_reference",
197+
"section_distance": 0,
198+
"target": {"float_type": "figure"},
199+
}
200+
)
201+
202+
self.assertEqual(risk["risk_level"], "medium")
203+
self.assertEqual(risk["operation"], "float_placement")
204+
self.assertFalse(risk["requires_fresh_approval"])
205+
206+
def test_repair_candidate_risk_classifies_cross_section_float_as_high(self) -> None:
207+
risk = classify_repair_candidate_risk(
208+
{
209+
"candidate_type": "source_anchor",
210+
"defect_family": "B1",
211+
"proposed_action": "move_float_closer_to_first_reference",
212+
"section_distance": 2,
213+
"target": {"float_type": "figure"},
214+
}
215+
)
216+
217+
self.assertEqual(risk["risk_level"], "high")
218+
self.assertEqual(risk["operation"], "float_movement_across_section_boundary")
219+
self.assertTrue(risk["requires_fresh_approval"])
220+
221+
def test_repair_candidate_risk_classifies_table_reconstruction_as_high(self) -> None:
222+
risk = classify_repair_candidate_risk(
223+
{
224+
"candidate_type": "source_anchor",
225+
"defect_family": "B2",
226+
"proposed_action": "adjust_float_width",
227+
"target": {"object_kind": "table_like", "float_type": "table"},
228+
}
229+
)
230+
231+
self.assertEqual(risk["risk_level"], "high")
232+
self.assertEqual(risk["operation"], "table_reconstruction")
233+
self.assertTrue(risk["requires_fresh_approval"])
234+
235+
def test_repair_candidate_risk_classifies_global_paragraph_review_as_high(self) -> None:
236+
risk = classify_repair_candidate_risk(
237+
{
238+
"candidate_type": "global",
239+
"defect_family": "A1",
240+
"proposed_action": "review_paragraph_spacing_and_looseness",
241+
}
242+
)
243+
244+
self.assertEqual(risk["risk_level"], "high")
245+
self.assertEqual(risk["operation"], "semantic_text_edit")
246+
self.assertTrue(risk["requires_fresh_approval"])
247+
248+
def test_candidate_approval_scope_gate_blocks_high_risk_selected_candidate(self) -> None:
249+
approval = build_approval_object(
250+
task={
251+
"task_type": "full_vto",
252+
"rollback_policy": "required",
253+
"pre_repair_snapshot_required": True,
254+
},
255+
state={"repair_plan_summary": {"total_candidates": 1}},
256+
runtime_actions={},
257+
)
258+
gate = build_candidate_approval_scope_gate(
259+
task={"task_type": "full_vto"},
260+
approval=approval,
261+
repair_plan={
262+
"candidates": [
263+
{
264+
"candidate_type": "source_anchor",
265+
"defect_family": "B1",
266+
"proposed_action": "move_float_closer_to_first_reference",
267+
"section_distance": 1,
268+
}
269+
]
270+
},
271+
)
272+
273+
self.assertEqual(gate["status"], "blocked")
274+
self.assertEqual(gate["blocked_candidates"][0]["risk"]["operation"], "float_movement_across_section_boundary")
275+
276+
def test_candidate_approval_scope_gate_allows_medium_same_section_float_candidate(self) -> None:
277+
approval = build_approval_object(
278+
task={
279+
"task_type": "full_vto",
280+
"rollback_policy": "required",
281+
"pre_repair_snapshot_required": True,
282+
},
283+
state={"repair_plan_summary": {"total_candidates": 1}},
284+
runtime_actions={},
285+
)
286+
gate = build_candidate_approval_scope_gate(
287+
task={"task_type": "full_vto"},
288+
approval=approval,
289+
repair_plan={
290+
"candidates": [
291+
{
292+
"candidate_type": "source_anchor",
293+
"defect_family": "B1",
294+
"proposed_action": "move_float_closer_to_first_reference",
295+
"section_distance": 0,
296+
}
297+
]
298+
},
299+
)
300+
301+
self.assertEqual(gate["status"], "pass")
302+
self.assertTrue(gate["candidate_risks"][0]["allowed_under_current_scope"])
303+
186304

187305
if __name__ == "__main__":
188306
unittest.main()

0 commit comments

Comments
 (0)