@riddledc/riddle-proof 0.5.55 → 0.5.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,8 +7,9 @@ while good captures produce a structured evidence packet that the supervising ag
7
7
  must assess before the wrapper routes back into author/implement/recon work or ship.
8
8
  """
9
9
 
10
- import json, os, sys, time
10
+ import json, os, struct, sys, time, zlib
11
11
  from urllib.parse import parse_qsl, urlparse
12
+ from urllib.request import Request, urlopen
12
13
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
14
  from util import (
14
15
  append_capture_diagnostic,
@@ -935,6 +936,204 @@ def visual_delta_baseline_candidate(state, results):
935
936
  return {'label': '', 'url': ''}
936
937
 
937
938
 
939
+ def image_artifact_url(value):
940
+ text = str(value or '').strip()
941
+ if not text:
942
+ return False
943
+ parsed = urlparse(text)
944
+ if parsed.scheme != 'https':
945
+ return False
946
+ path = parsed.path.lower()
947
+ return path.endswith(IMAGE_EXTENSIONS)
948
+
949
+
950
+ def fetch_url_bytes(url, timeout=20, max_bytes=25 * 1024 * 1024):
951
+ request = Request(
952
+ url,
953
+ headers={
954
+ 'User-Agent': 'riddle-proof-visual-delta/1.0',
955
+ 'Accept': 'image/png,image/jpeg,image/webp,image/*;q=0.8,*/*;q=0.1',
956
+ },
957
+ )
958
+ with urlopen(request, timeout=timeout) as response:
959
+ content_length = response.headers.get('Content-Length')
960
+ if content_length:
961
+ try:
962
+ if int(content_length) > max_bytes:
963
+ raise ValueError('image artifact exceeds max fetch size')
964
+ except ValueError:
965
+ raise
966
+ except Exception:
967
+ pass
968
+ data = response.read(max_bytes + 1)
969
+ if len(data) > max_bytes:
970
+ raise ValueError('image artifact exceeds max fetch size')
971
+ return data
972
+
973
+
974
+ def _png_paeth(a, b, c):
975
+ p = a + b - c
976
+ pa = abs(p - a)
977
+ pb = abs(p - b)
978
+ pc = abs(p - c)
979
+ if pa <= pb and pa <= pc:
980
+ return a
981
+ if pb <= pc:
982
+ return b
983
+ return c
984
+
985
+
986
+ def decode_png_rgba(data):
987
+ if not isinstance(data, (bytes, bytearray)) or not data.startswith(b'\x89PNG\r\n\x1a\n'):
988
+ raise ValueError('unsupported image format; expected PNG artifact')
989
+ offset = 8
990
+ width = height = bit_depth = color_type = interlace = None
991
+ idat = []
992
+ while offset + 8 <= len(data):
993
+ length = struct.unpack('>I', data[offset:offset + 4])[0]
994
+ chunk_type = data[offset + 4:offset + 8]
995
+ chunk_data = data[offset + 8:offset + 8 + length]
996
+ offset += 12 + length
997
+ if chunk_type == b'IHDR':
998
+ width, height, bit_depth, color_type, _compression, _filter, interlace = struct.unpack('>IIBBBBB', chunk_data)
999
+ elif chunk_type == b'IDAT':
1000
+ idat.append(chunk_data)
1001
+ elif chunk_type == b'IEND':
1002
+ break
1003
+ if not width or not height or bit_depth != 8 or interlace != 0:
1004
+ raise ValueError('unsupported PNG artifact; requires 8-bit non-interlaced PNG')
1005
+ channels_by_type = {0: 1, 2: 3, 4: 2, 6: 4}
1006
+ channels = channels_by_type.get(color_type)
1007
+ if not channels:
1008
+ raise ValueError('unsupported PNG color type')
1009
+ raw = zlib.decompress(b''.join(idat))
1010
+ stride = width * channels
1011
+ rows = []
1012
+ pos = 0
1013
+ previous = bytearray(stride)
1014
+ for _row in range(height):
1015
+ if pos >= len(raw):
1016
+ raise ValueError('truncated PNG data')
1017
+ filter_type = raw[pos]
1018
+ pos += 1
1019
+ scanline = bytearray(raw[pos:pos + stride])
1020
+ pos += stride
1021
+ for index in range(stride):
1022
+ left = scanline[index - channels] if index >= channels else 0
1023
+ up = previous[index]
1024
+ up_left = previous[index - channels] if index >= channels else 0
1025
+ if filter_type == 1:
1026
+ scanline[index] = (scanline[index] + left) & 0xff
1027
+ elif filter_type == 2:
1028
+ scanline[index] = (scanline[index] + up) & 0xff
1029
+ elif filter_type == 3:
1030
+ scanline[index] = (scanline[index] + ((left + up) // 2)) & 0xff
1031
+ elif filter_type == 4:
1032
+ scanline[index] = (scanline[index] + _png_paeth(left, up, up_left)) & 0xff
1033
+ elif filter_type != 0:
1034
+ raise ValueError('unsupported PNG filter')
1035
+ rows.append(bytes(scanline))
1036
+ previous = scanline
1037
+ rgba = bytearray(width * height * 4)
1038
+ out = 0
1039
+ for row in rows:
1040
+ for index in range(0, len(row), channels):
1041
+ if color_type == 0:
1042
+ gray = row[index]
1043
+ rgba[out:out + 4] = bytes((gray, gray, gray, 255))
1044
+ elif color_type == 2:
1045
+ rgba[out:out + 4] = bytes((row[index], row[index + 1], row[index + 2], 255))
1046
+ elif color_type == 4:
1047
+ gray = row[index]
1048
+ rgba[out:out + 4] = bytes((gray, gray, gray, row[index + 1]))
1049
+ else:
1050
+ rgba[out:out + 4] = bytes((row[index], row[index + 1], row[index + 2], row[index + 3]))
1051
+ out += 4
1052
+ return {'width': width, 'height': height, 'rgba': bytes(rgba)}
1053
+
1054
+
1055
+ def compare_rgba_images(before_image, after_image, threshold=10):
1056
+ before_width = int(before_image.get('width') or 0)
1057
+ before_height = int(before_image.get('height') or 0)
1058
+ after_width = int(after_image.get('width') or 0)
1059
+ after_height = int(after_image.get('height') or 0)
1060
+ before_rgba = before_image.get('rgba') or b''
1061
+ after_rgba = after_image.get('rgba') or b''
1062
+ overlap_width = min(before_width, after_width)
1063
+ overlap_height = min(before_height, after_height)
1064
+ total_pixels = max(before_width * before_height, after_width * after_height)
1065
+ changed_pixels = total_pixels - (overlap_width * overlap_height)
1066
+ for y in range(overlap_height):
1067
+ before_row = y * before_width * 4
1068
+ after_row = y * after_width * 4
1069
+ for x in range(overlap_width):
1070
+ b = before_row + x * 4
1071
+ a = after_row + x * 4
1072
+ if max(
1073
+ abs(before_rgba[b] - after_rgba[a]),
1074
+ abs(before_rgba[b + 1] - after_rgba[a + 1]),
1075
+ abs(before_rgba[b + 2] - after_rgba[a + 2]),
1076
+ abs(before_rgba[b + 3] - after_rgba[a + 3]),
1077
+ ) > threshold:
1078
+ changed_pixels += 1
1079
+ change_percent = (changed_pixels / total_pixels) * 100 if total_pixels else None
1080
+ return {
1081
+ 'changed_pixels': changed_pixels,
1082
+ 'total_pixels': total_pixels,
1083
+ 'change_percent': change_percent,
1084
+ 'before_width': before_width,
1085
+ 'before_height': before_height,
1086
+ 'after_width': after_width,
1087
+ 'after_height': after_height,
1088
+ }
1089
+
1090
+
1091
+ def measure_visual_delta_from_image_artifacts(before_url, after_url):
1092
+ if not (image_artifact_url(before_url) and image_artifact_url(after_url)):
1093
+ return {
1094
+ 'status': 'skipped',
1095
+ 'reason': 'before/after URLs are not direct HTTPS image artifacts',
1096
+ }
1097
+ try:
1098
+ before_image = decode_png_rgba(fetch_url_bytes(before_url))
1099
+ after_image = decode_png_rgba(fetch_url_bytes(after_url))
1100
+ comparison = compare_rgba_images(before_image, after_image)
1101
+ except Exception as exc:
1102
+ return {
1103
+ 'status': 'error',
1104
+ 'error': type(exc).__name__ + ': ' + str(exc)[:300],
1105
+ }
1106
+ changed_pixels = comparison.get('changed_pixels')
1107
+ total_pixels = comparison.get('total_pixels')
1108
+ percent = comparison.get('change_percent')
1109
+ percent_pass = percent is not None and percent >= MIN_VISUAL_DELTA_PERCENT
1110
+ pixel_pass = changed_pixels is not None and changed_pixels >= MIN_VISUAL_CHANGED_PIXELS
1111
+ passed = bool(percent_pass or pixel_pass)
1112
+ return {
1113
+ 'status': 'measured',
1114
+ 'passed': passed,
1115
+ 'change_percent': round(percent, 4) if percent is not None else None,
1116
+ 'changed_pixels': int(changed_pixels) if changed_pixels is not None else None,
1117
+ 'total_pixels': int(total_pixels) if total_pixels is not None else None,
1118
+ 'min_change_percent': MIN_VISUAL_DELTA_PERCENT,
1119
+ 'min_changed_pixels': MIN_VISUAL_CHANGED_PIXELS,
1120
+ 'source': 'riddle_artifact_image_diff',
1121
+ 'reason': (
1122
+ 'Measured visual delta from before/after screenshot artifacts clears the legibility threshold.'
1123
+ if passed else
1124
+ 'Measured visual delta from before/after screenshot artifacts is below the legibility threshold.'
1125
+ ),
1126
+ 'comparison': {
1127
+ 'before_url': before_url,
1128
+ 'after_url': after_url,
1129
+ 'before_width': comparison.get('before_width'),
1130
+ 'before_height': comparison.get('before_height'),
1131
+ 'after_width': comparison.get('after_width'),
1132
+ 'after_height': comparison.get('after_height'),
1133
+ },
1134
+ }
1135
+
1136
+
938
1137
  def add_visual_delta_diagnostic(visual_delta, key, value):
939
1138
  updated = dict(visual_delta or {})
940
1139
  diagnostic = dict(updated.get('diagnostic') or {})
@@ -970,6 +1169,21 @@ def measure_visual_delta_against_baseline(state, results, after_payload, current
970
1169
  },
971
1170
  )
972
1171
 
1172
+ artifact_delta = measure_visual_delta_from_image_artifacts(before_url, after_url)
1173
+ append_capture_diagnostic(state, 'visual_delta', 'riddle_artifact_image_diff', {
1174
+ 'baseline_label': baseline.get('label') or '',
1175
+ 'before_url': before_url,
1176
+ 'after_url': after_url,
1177
+ }, artifact_delta)
1178
+ if isinstance(artifact_delta, dict) and artifact_delta.get('status') == 'measured':
1179
+ artifact_delta['comparison']['baseline_label'] = baseline.get('label') or ''
1180
+ return artifact_delta
1181
+ current_visual_delta = add_visual_delta_diagnostic(
1182
+ current_visual_delta,
1183
+ 'artifact_image_diff',
1184
+ artifact_delta,
1185
+ )
1186
+
973
1187
  args = {
974
1188
  'url_before': before_url,
975
1189
  'url_after': after_url,
@@ -1497,6 +1711,39 @@ def build_capture_retry_decision(after_observation, required_baseline_present, p
1497
1711
  }
1498
1712
 
1499
1713
 
1714
+ def build_visual_delta_recovery_decision(verification_mode, visual_delta, visual_delta_blocker=''):
1715
+ if not visual_delta_applies(verification_mode):
1716
+ return None
1717
+ if visual_delta_passes_ship_gate(visual_delta):
1718
+ return None
1719
+ if not isinstance(visual_delta, dict):
1720
+ status = 'missing'
1721
+ else:
1722
+ status = str(visual_delta.get('status') or 'missing')
1723
+ if status == 'measured':
1724
+ return None
1725
+ reason = visual_delta_blocker or visual_delta_blocker_for_mode(verification_mode, visual_delta)
1726
+ diagnostic = visual_delta.get('diagnostic') if isinstance(visual_delta, dict) else {}
1727
+ reasons = [
1728
+ reason or 'Required visual_delta evidence is incomplete.',
1729
+ 'Stay in verify so the same run can retry capture/comparison recovery before proof review.',
1730
+ ]
1731
+ fallback = diagnostic.get('visual_diff_fallback') if isinstance(diagnostic, dict) else None
1732
+ artifact_diff = diagnostic.get('artifact_image_diff') if isinstance(diagnostic, dict) else None
1733
+ if isinstance(artifact_diff, dict) and artifact_diff.get('error'):
1734
+ reasons.append('Artifact image diff failed: ' + str(artifact_diff.get('error'))[:300])
1735
+ if isinstance(fallback, dict) and fallback.get('error'):
1736
+ reasons.append('Riddle visual_diff fallback failed: ' + str(fallback.get('error'))[:300])
1737
+ return {
1738
+ 'decision': 'revise_capture',
1739
+ 'summary': reason or 'Verify needs measured visual_delta evidence before proof review.',
1740
+ 'recommended_stage': 'verify',
1741
+ 'continue_with_stage': 'verify',
1742
+ 'reasons': reasons,
1743
+ 'visual_delta_status': status,
1744
+ }
1745
+
1746
+
1500
1747
  def compact_semantic_list(value, limit):
1501
1748
  if not isinstance(value, list):
1502
1749
  return []
@@ -2006,7 +2253,20 @@ if proof_evidence_required_for_mode(s.get('verification_mode')):
2006
2253
  if proof_evidence_blocker:
2007
2254
  summary_lines.append('Structured proof evidence gate: ' + proof_evidence_blocker)
2008
2255
 
2009
- has_good_evidence = required_baseline_present and after_observation.get('valid') and not proof_evidence_blocker
2256
+ visual_delta_recovery = build_visual_delta_recovery_decision(
2257
+ s.get('verification_mode'),
2258
+ visual_delta,
2259
+ visual_delta_blocker,
2260
+ )
2261
+ if visual_delta_recovery:
2262
+ summary_lines.append('Visual delta recovery: ' + visual_delta_recovery['summary'])
2263
+
2264
+ has_good_evidence = (
2265
+ required_baseline_present
2266
+ and after_observation.get('valid')
2267
+ and not proof_evidence_blocker
2268
+ and not visual_delta_recovery
2269
+ )
2010
2270
 
2011
2271
  if has_good_evidence:
2012
2272
  s['capture_hint_saved'] = record_successful_capture_hint(
@@ -2044,7 +2304,7 @@ if has_good_evidence:
2044
2304
  summary_lines.append('Proof assessment: awaiting supervising agent judgment')
2045
2305
  summary_lines.append('Proof next stage: supervising agent decides after reviewing the evidence packet')
2046
2306
  else:
2047
- capture_retry = build_capture_retry_decision(after_observation, required_baseline_present, proof_evidence_blocker)
2307
+ capture_retry = visual_delta_recovery or build_capture_retry_decision(after_observation, required_baseline_present, proof_evidence_blocker)
2048
2308
  s['verify_status'] = 'capture_incomplete'
2049
2309
  s['merge_recommendation'] = 'do-not-merge'
2050
2310
  s['proof_assessment'] = {}
@@ -2061,8 +2321,9 @@ else:
2061
2321
  'continue_with_stage': capture_retry.get('continue_with_stage') or 'author',
2062
2322
  'fields_agent_may_update': ['capture_script', 'server_path', 'wait_for_selector', 'proof_plan'],
2063
2323
  'instructions': [
2064
- 'The after-proof is missing or low quality, so return to author when the capture plan itself needs revision.',
2065
- 'Adjust capture_script, server_path, wait_for_selector, and/or proof_plan before rerunning verify.',
2324
+ 'The after-proof evidence packet is incomplete, so use the recommended stage before proof review.',
2325
+ 'Adjust capture_script, server_path, wait_for_selector, and/or proof_plan only when the recommended stage is author.',
2326
+ 'If recommended_stage=verify, rerun verify so capture/comparison recovery can continue without changing proof authorship.',
2066
2327
  'If the baseline itself is wrong, return to recon instead of forcing verify to rediscover context.',
2067
2328
  ],
2068
2329
  }
@@ -3,8 +3,10 @@ import json
3
3
  import os
4
4
  import shutil
5
5
  import subprocess as sp
6
+ import struct
6
7
  import sys
7
8
  import tempfile
9
+ import zlib
8
10
  from contextlib import contextmanager
9
11
  from pathlib import Path
10
12
 
@@ -35,6 +37,28 @@ def state_console(payload):
35
37
  }
36
38
 
37
39
 
40
+ def png_rgba(width, height, pixels):
41
+ raw_rows = []
42
+ for y in range(height):
43
+ row = bytearray([0])
44
+ for x in range(width):
45
+ row.extend(pixels[(y * width + x) * 4:(y * width + x + 1) * 4])
46
+ raw_rows.append(bytes(row))
47
+ def chunk(kind, data):
48
+ return (
49
+ struct.pack('>I', len(data))
50
+ + kind
51
+ + data
52
+ + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff)
53
+ )
54
+ return (
55
+ b'\x89PNG\r\n\x1a\n'
56
+ + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0))
57
+ + chunk(b'IDAT', zlib.compress(b''.join(raw_rows)))
58
+ + chunk(b'IEND', b'')
59
+ )
60
+
61
+
38
62
  def load_module(name: str, path: Path):
39
63
  sys.modules.pop(name, None)
40
64
  spec = importlib.util.spec_from_file_location(name, path)
@@ -204,12 +228,13 @@ class FakeRiddle:
204
228
  ],
205
229
  }
206
230
  if 'after-proof' in script:
207
- outputs = [{'name': 'after.png', 'url': 'https://cdn.example.com/after.png'}]
231
+ after_url = 'https://cdn.example.com/after-artifact' if 'noVisualDelta' in script else 'https://cdn.example.com/after.png'
232
+ outputs = [{'name': 'after.png', 'url': after_url}]
208
233
  if 'proof-session' in script:
209
234
  outputs.append({'name': 'proof-session.json', 'url': 'https://cdn.example.com/proof-session.json'})
210
- return {
235
+ payload = {
211
236
  'ok': True,
212
- 'screenshots': [{'url': 'https://cdn.example.com/after.png'}],
237
+ 'screenshots': [{'url': after_url}],
213
238
  'outputs': outputs,
214
239
  'console': state_console({
215
240
  'bodyTextLength': 180,
@@ -225,6 +250,13 @@ class FakeRiddle:
225
250
  'largeVisibleElements': [{'tag': 'button', 'text': 'Buy Now'}],
226
251
  }),
227
252
  }
253
+ if 'noVisualDelta' not in script:
254
+ payload['visual_diff'] = {
255
+ 'diffPercentage': 1.2,
256
+ 'differentPixels': 12000,
257
+ 'totalPixels': 972000,
258
+ }
259
+ return payload
228
260
  if 'prod.example.com/pricing' in script:
229
261
  return {
230
262
  'ok': True,
@@ -585,6 +617,32 @@ def run_verify_quality_ignores_proof_telemetry_console_text():
585
617
  assert visual_diff_delta['change_percent'] == 1.45
586
618
  assert visual_diff_delta['changed_pixels'] == 14094
587
619
 
620
+ before_png = png_rgba(2, 1, bytes([
621
+ 0, 0, 0, 255,
622
+ 0, 0, 0, 255,
623
+ ]))
624
+ after_png = png_rgba(2, 1, bytes([
625
+ 0, 0, 0, 255,
626
+ 255, 255, 255, 255,
627
+ ]))
628
+
629
+ def fake_fetch_url_bytes(url, timeout=20, max_bytes=25 * 1024 * 1024):
630
+ if url.endswith('before.png'):
631
+ return before_png
632
+ if url.endswith('after.png'):
633
+ return after_png
634
+ raise AssertionError(f'unexpected image fetch: {url}')
635
+
636
+ namespace['fetch_url_bytes'] = fake_fetch_url_bytes
637
+ artifact_image_delta = namespace['measure_visual_delta_from_image_artifacts'](
638
+ 'https://riddle-screenshots.example/before.png',
639
+ 'https://riddle-screenshots.example/after.png',
640
+ )
641
+ assert artifact_image_delta['status'] == 'measured'
642
+ assert artifact_image_delta['source'] == 'riddle_artifact_image_diff'
643
+ assert artifact_image_delta['changed_pixels'] == 1
644
+ assert artifact_image_delta['change_percent'] == 50
645
+
588
646
  fallback_calls = []
589
647
 
590
648
  def fake_invoke_retry(tool, args, retries=3, timeout=180):
@@ -601,6 +659,7 @@ def run_verify_quality_ignores_proof_telemetry_console_text():
601
659
 
602
660
  namespace['invoke_retry'] = fake_invoke_retry
603
661
  namespace['append_capture_diagnostic'] = lambda *args, **kwargs: None
662
+ namespace['fetch_url_bytes'] = lambda *args, **kwargs: (_ for _ in ()).throw(ValueError('image fetch unavailable'))
604
663
  fallback_delta = namespace['measure_visual_delta_against_baseline'](
605
664
  {'verification_mode': 'visual', 'requested_reference': 'before', 'before_cdn': 'https://cdn.example.com/before.png'},
606
665
  {'baseline': {'before': {'url': 'https://cdn.example.com/before.png'}}},
@@ -1433,8 +1492,9 @@ def run_verify_requests_supervisor_assessment():
1433
1492
  assert after_verify['proof_assessment_source'] is None
1434
1493
  assert after_verify['proof_assessment_request']['status'] == 'needs_supervising_agent_assessment'
1435
1494
  visual_delta = after_verify['proof_assessment_request']['visual_delta']
1436
- assert visual_delta['status'] == 'unmeasured'
1437
- assert visual_delta['passed'] is None
1495
+ assert visual_delta['status'] == 'measured'
1496
+ assert visual_delta['passed'] is True
1497
+ assert visual_delta['changed_pixels'] == 12000
1438
1498
  semantic_context = after_verify['proof_assessment_request']['semantic_context']
1439
1499
  assert semantic_context['route']['expected_path'] == '/pricing'
1440
1500
  assert semantic_context['route']['after_observed_path'] == '/pricing'
@@ -1450,14 +1510,15 @@ def run_verify_requests_supervisor_assessment():
1450
1510
  assert artifact_production['image_output_count'] >= 1
1451
1511
  assert artifact_production['proof_evidence_present'] is False
1452
1512
  artifact_usage = after_verify['proof_assessment_request']['artifact_usage']
1453
- assert artifact_usage['missing_required_signals'] == ['visual_delta']
1513
+ assert artifact_usage['missing_required_signals'] == []
1454
1514
  assert 'after-capture' in artifact_usage['supervisor_review_signals']
1455
1515
  assert 'baseline_context' in artifact_usage['required_signals']
1456
1516
  assert 'route_semantics' in artifact_usage['available_signals']
1457
- assert 'visual_delta.status=unmeasured' in after_verify['proof_assessment_request']['hard_blockers'][0]
1517
+ assert 'visual_delta' in artifact_usage['available_signals']
1518
+ assert after_verify['proof_assessment_request']['hard_blockers'] == []
1458
1519
  assert after_verify['proof_assessment_request']['evidence_bundle']['artifact_contract']['required']['screenshot'] is True
1459
1520
  assert after_verify['proof_assessment_request']['evidence_bundle']['artifact_production']['image_output_count'] >= 1
1460
- assert after_verify['proof_assessment_request']['evidence_bundle']['artifact_usage']['missing_required_signals'] == ['visual_delta']
1521
+ assert after_verify['proof_assessment_request']['evidence_bundle']['artifact_usage']['missing_required_signals'] == []
1461
1522
  assert 'capture success is not proof' in '\n'.join(after_verify['proof_assessment_request']['instructions'])
1462
1523
  assert after_verify['verify_decision_request']['continue_with_stage'] is None
1463
1524
  assert after_verify['verify_results']['baseline']['before']['source'] == 'recon'
@@ -1501,6 +1562,55 @@ def run_verify_requests_supervisor_assessment():
1501
1562
  shutil.rmtree(tempdir, ignore_errors=True)
1502
1563
 
1503
1564
 
1565
+ def run_verify_routes_unmeasured_visual_delta_to_recovery():
1566
+ tempdir = Path(tempfile.mkdtemp(prefix='riddle-proof-verify-visual-recovery-'))
1567
+ state_path = tempdir / 'state.json'
1568
+ try:
1569
+ state = base_state(tempdir, reference='before')
1570
+ state.update({
1571
+ 'repo': 'example/repo',
1572
+ 'recon_status': 'ready_for_proof_plan',
1573
+ 'author_status': 'ready',
1574
+ 'proof_plan_status': 'ready',
1575
+ 'implementation_status': 'changes_detected',
1576
+ 'before_cdn': 'https://cdn.example.com/before-artifact',
1577
+ 'proof_plan': 'Use the recon-confirmed /pricing route and capture the CTA state once it stabilizes.',
1578
+ 'capture_script': "noVisualDelta(); await page.waitForSelector('[data-testid=pricing-cta]'); await saveScreenshot('after-proof');",
1579
+ 'wait_for_selector': '[data-testid=pricing-cta]',
1580
+ 'recon_results': {
1581
+ 'baselines': {
1582
+ 'before': {'path': '/pricing', 'url': 'https://cdn.example.com/before-artifact'},
1583
+ },
1584
+ },
1585
+ })
1586
+ write_state(state_path, state)
1587
+ os.environ['RIDDLE_PROOF_STATE_FILE'] = str(state_path)
1588
+
1589
+ fake = FakeRiddle()
1590
+ load_util_with_fake(fake)
1591
+ load_module('verify_unmeasured_visual_delta_recovery', VERIFY_PATH)
1592
+ after_verify = json.loads(state_path.read_text())
1593
+
1594
+ assert after_verify['verify_status'] == 'capture_incomplete'
1595
+ assert after_verify['proof_assessment_request'] == {}
1596
+ decision_request = after_verify['verify_decision_request']
1597
+ assert decision_request['capture_quality']['decision'] == 'revise_capture'
1598
+ assert decision_request['recommended_stage'] == 'verify'
1599
+ assert decision_request['continue_with_stage'] == 'verify'
1600
+ visual_delta = after_verify['evidence_bundle']['after']['visual_delta']
1601
+ assert visual_delta['status'] == 'unmeasured'
1602
+ assert visual_delta['diagnostic']['visual_diff_fallback']['status'] == 'error'
1603
+ assert 'Visual delta recovery' in after_verify['verify_summary']
1604
+
1605
+ return {
1606
+ 'ok': True,
1607
+ 'verify_status': after_verify['verify_status'],
1608
+ 'continue_with_stage': decision_request['continue_with_stage'],
1609
+ }
1610
+ finally:
1611
+ shutil.rmtree(tempdir, ignore_errors=True)
1612
+
1613
+
1504
1614
  def run_verify_structured_evidence_without_screenshot():
1505
1615
  tempdir = Path(tempfile.mkdtemp(prefix='riddle-proof-verify-structured-'))
1506
1616
  state_path = tempdir / 'state.json'
@@ -2142,6 +2252,7 @@ if __name__ == '__main__':
2142
2252
  'capture_hint_rejects_route_specific_mode_only_match': run_capture_hint_rejects_route_specific_mode_only_match(),
2143
2253
  'author_applies_supervisor_packet': run_author_applies_supervisor_packet(),
2144
2254
  'verify_requests_supervisor_assessment': run_verify_requests_supervisor_assessment(),
2255
+ 'verify_routes_unmeasured_visual_delta_to_recovery': run_verify_routes_unmeasured_visual_delta_to_recovery(),
2145
2256
  'verify_structured_evidence_without_screenshot': run_verify_structured_evidence_without_screenshot(),
2146
2257
  'verify_audio_requires_proof_evidence': run_verify_audio_requires_proof_evidence(),
2147
2258
  'verify_audio_rejects_failed_nested_proof_evidence': run_verify_audio_rejects_failed_nested_proof_evidence(),