@riddledc/riddle-proof 0.7.80 → 0.7.82

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,7 +7,7 @@ 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, struct, sys, time, zlib
10
+ import json, os, re, struct, sys, time, zlib
11
11
  from urllib.parse import parse_qsl, urlparse
12
12
  from urllib.request import Request, urlopen
13
13
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -18,6 +18,7 @@ from util import (
18
18
  build_visual_proof_session,
19
19
  capture_proof_session_seed,
20
20
  capture_static_preview,
21
+ capture_viewport_matrix_status,
21
22
  enrich_capture_payload,
22
23
  has_auth_context,
23
24
  invoke,
@@ -31,6 +32,9 @@ from util import (
31
32
  should_use_static_preview,
32
33
  proof_session_output_url,
33
34
  summarize_capture_artifacts,
35
+ viewport_matrix_return_js,
36
+ viewport_matrix_screenshot_js,
37
+ viewport_matrix_setup_js,
34
38
  )
35
39
  import subprocess as sp
36
40
 
@@ -55,6 +59,16 @@ PLAYABILITY_MODES = {'playable', 'gameplay', 'game'}
55
59
  PROOF_EVIDENCE_REQUIRED_MODES = {'audio'}
56
60
  MIN_VISUAL_DELTA_PERCENT = 0.5
57
61
  MIN_VISUAL_CHANGED_PIXELS = 5000
62
+ TARGETED_VISUAL_DELTA_PERCENT = 0.02
63
+ TARGETED_VISUAL_CHANGED_PIXELS = 250
64
+ TARGETED_CHANGED_REGION_MAX_AREA_PERCENT = 20
65
+ TARGETED_SEMANTIC_STOPWORDS = {
66
+ 'about', 'after', 'agent', 'again', 'before', 'browser', 'button',
67
+ 'change', 'changed', 'click', 'copy', 'delta', 'does', 'from', 'into',
68
+ 'layout', 'mode', 'page', 'proof', 'route', 'screen', 'shows', 'small',
69
+ 'target', 'that', 'this', 'until', 'update', 'user', 'verify', 'view',
70
+ 'visual', 'with',
71
+ }
58
72
  VISUAL_DELTA_PERCENT_KEYS = {
59
73
  'change_pct', 'change_percent', 'changed_percent', 'percent_changed',
60
74
  'diff_percent', 'visual_delta_percent', 'pixel_change_percent',
@@ -82,6 +96,18 @@ VISUAL_TOTAL_PIXEL_KEYS = {
82
96
  }
83
97
  VISUAL_WIDTH_KEYS = {'width', 'image_width', 'screenshot_width'}
84
98
  VISUAL_HEIGHT_KEYS = {'height', 'image_height', 'screenshot_height'}
99
+ VISUAL_CHANGED_REGION_KEYS = {
100
+ 'changed_region', 'changedregion', 'diff_region', 'diffregion',
101
+ 'difference_region', 'differenceregion', 'changed_bounds',
102
+ 'changedbounds', 'diff_bounds', 'diffbounds', 'difference_bounds',
103
+ 'differencebounds', 'bounding_box', 'boundingbox', 'bbox', 'bounds',
104
+ }
105
+ VISUAL_REGION_X_KEYS = {'x', 'left', 'min_x', 'minx'}
106
+ VISUAL_REGION_Y_KEYS = {'y', 'top', 'min_y', 'miny'}
107
+ VISUAL_REGION_WIDTH_KEYS = {'width', 'w'}
108
+ VISUAL_REGION_HEIGHT_KEYS = {'height', 'h'}
109
+ VISUAL_REGION_RIGHT_KEYS = {'right', 'max_x', 'maxx'}
110
+ VISUAL_REGION_BOTTOM_KEYS = {'bottom', 'max_y', 'maxy'}
85
111
 
86
112
 
87
113
  def capture_script_saves_screenshot(script):
@@ -133,7 +159,7 @@ def audit_current_capture_url(state, prod_url, expected_path):
133
159
 
134
160
 
135
161
  def capture_current_target(state, target_url, label, capture_script, timeout=300):
136
- script = build_capture_script(target_url, capture_script, label, state.get('wait_for_selector', ''))
162
+ script = build_capture_script(target_url, capture_script, label, state.get('wait_for_selector', ''), state.get('viewport_matrix'))
137
163
  args = {'script': script, 'timeout_sec': 60}
138
164
  apply_auth_context(state, args)
139
165
  shot = invoke_retry('riddle_script', args, retries=3, timeout=max(timeout, 120))
@@ -312,9 +338,10 @@ def abort_capture_failure(state, results, expected_path, message, raw_payload):
312
338
  raise SystemExit(summary)
313
339
 
314
340
 
315
- def build_probe_capture_script(base_script='', verification_mode='proof', proof_session_seed=None):
341
+ def build_probe_capture_script(base_script='', verification_mode='proof', proof_session_seed=None, viewport_matrix=None):
316
342
  pieces = []
317
343
  script = (base_script or '').strip()
344
+ pieces.extend(viewport_matrix_setup_js(viewport_matrix))
318
345
  pieces.append('let __riddleProofCaptureScriptError = null;')
319
346
  pieces.append('let __riddleProofCaptureScriptResult = null;')
320
347
  if script:
@@ -374,7 +401,10 @@ def build_probe_capture_script(base_script='', verification_mode='proof', proof_
374
401
  ' catch (err) { console.log(' + json.dumps(PROOF_EVIDENCE_PREFIX) + ' + JSON.stringify({ serialization_error: String(err) })); }',
375
402
  '}',
376
403
  ])
377
- if auto_screenshot_for_mode(verification_mode) and not capture_script_saves_screenshot(script):
404
+ viewport_screenshot_lines = viewport_matrix_screenshot_js('after-proof', viewport_matrix)
405
+ if auto_screenshot_for_mode(verification_mode) and viewport_screenshot_lines:
406
+ pieces.extend(viewport_screenshot_lines)
407
+ elif auto_screenshot_for_mode(verification_mode) and not capture_script_saves_screenshot(script):
378
408
  pieces.append("await saveScreenshot('after-proof');")
379
409
  if isinstance(proof_session_seed, dict):
380
410
  pieces.append(
@@ -383,7 +413,7 @@ def build_probe_capture_script(base_script='', verification_mode='proof', proof_
383
413
  '); } catch {}'
384
414
  )
385
415
  pieces.append('if (__riddleProofCaptureScriptError) throw __riddleProofCaptureScriptError;')
386
- pieces.append('return { pageState, proofEvidence: __riddleProofEvidenceValue };')
416
+ pieces.append('return { pageState, proofEvidence: __riddleProofEvidenceValue, viewportMatrix: ' + viewport_matrix_return_js() + ' };')
387
417
  return ' '.join(pieces)
388
418
 
389
419
 
@@ -885,7 +915,274 @@ def find_metric_value(value, key_names, depth=0):
885
915
  return None
886
916
 
887
917
 
888
- def extract_visual_delta(payload):
918
+ def targeted_semantic_tokens(value, limit=24):
919
+ text = ''
920
+ try:
921
+ text = json.dumps(value, sort_keys=True) if isinstance(value, (dict, list)) else str(value or '')
922
+ except Exception:
923
+ text = str(value or '')
924
+ tokens = []
925
+ for token in re.findall(r'[a-z0-9]+', text.lower()):
926
+ if len(token) < 4 or token in TARGETED_SEMANTIC_STOPWORDS or token in tokens:
927
+ continue
928
+ tokens.append(token)
929
+ if len(tokens) >= limit:
930
+ break
931
+ return tokens
932
+
933
+
934
+ def semantic_visible_text_for_delta(state=None, semantic_context=None):
935
+ chunks = []
936
+ after = (semantic_context or {}).get('after') if isinstance(semantic_context, dict) else {}
937
+ for source in [after or {}]:
938
+ if not isinstance(source, dict):
939
+ continue
940
+ for key in ('visible_text_sample', 'title'):
941
+ if source.get(key):
942
+ chunks.append(str(source.get(key)))
943
+ for key in ('headings', 'buttons', 'links', 'large_visible_elements'):
944
+ value = source.get(key)
945
+ try:
946
+ chunks.append(json.dumps(value, sort_keys=True))
947
+ except Exception:
948
+ chunks.append(str(value))
949
+ return ' '.join(chunks).lower()
950
+
951
+
952
+ def targeted_assertion_text_sources(value, depth=0):
953
+ if depth > 8:
954
+ return []
955
+ sources = []
956
+ if isinstance(value, list):
957
+ for item in value:
958
+ sources.extend(targeted_assertion_text_sources(item, depth + 1))
959
+ return sources
960
+ if not isinstance(value, dict):
961
+ return sources
962
+
963
+ assertion_type = str(value.get('type') or value.get('kind') or '').strip().lower()
964
+ text_keys = (
965
+ 'text', 'contains', 'pattern', 'label', 'copy', 'expected',
966
+ 'expected_text', 'expectedText', 'visible_text', 'visibleText',
967
+ )
968
+ text_values = [
969
+ value.get(key)
970
+ for key in text_keys
971
+ if isinstance(value.get(key), str) and value.get(key).strip()
972
+ ]
973
+ type_is_textual = any(marker in assertion_type for marker in (
974
+ 'text', 'copy', 'visible', 'contains', 'label', 'heading', 'button', 'link', 'badge',
975
+ ))
976
+ if text_values and (type_is_textual or not assertion_type):
977
+ sources.extend(text_values)
978
+
979
+ for key in ('assertions', 'checks', 'expected', 'match', 'matches'):
980
+ nested = value.get(key)
981
+ if isinstance(nested, (dict, list)):
982
+ sources.extend(targeted_assertion_text_sources(nested, depth + 1))
983
+ return sources
984
+
985
+
986
+ def visual_delta_semantic_support(state=None, semantic_context=None):
987
+ state = state if isinstance(state, dict) else {}
988
+ visible_text = semantic_visible_text_for_delta(state, semantic_context)
989
+ if not visible_text:
990
+ return {
991
+ 'supported': False,
992
+ 'matched_tokens': [],
993
+ 'reason': 'no semantic text sample was available for targeted visual-delta thresholds',
994
+ }
995
+
996
+ assertion_sources = targeted_assertion_text_sources(state.get('parsed_assertions'))
997
+ assertion_tokens = []
998
+ for source in assertion_sources:
999
+ for token in targeted_semantic_tokens(source):
1000
+ if token not in assertion_tokens:
1001
+ assertion_tokens.append(token)
1002
+ if not assertion_tokens:
1003
+ return {
1004
+ 'supported': False,
1005
+ 'matched_tokens': [],
1006
+ 'reason': 'targeted visual-delta thresholds require text/visible assertions for the intended small change',
1007
+ }
1008
+
1009
+ matched_assertion_tokens = [token for token in assertion_tokens if token in visible_text]
1010
+ context_tokens = []
1011
+ for source in (state.get('success_criteria'), state.get('change_request'), state.get('proof_plan')):
1012
+ for token in targeted_semantic_tokens(source):
1013
+ if token not in context_tokens:
1014
+ context_tokens.append(token)
1015
+ matched_context_tokens = [token for token in context_tokens if token in visible_text]
1016
+ return {
1017
+ 'supported': len(matched_assertion_tokens) >= 1,
1018
+ 'matched_tokens': (matched_assertion_tokens + [token for token in matched_context_tokens if token not in matched_assertion_tokens])[:12],
1019
+ 'matched_assertion_tokens': matched_assertion_tokens[:12],
1020
+ 'matched_context_tokens': matched_context_tokens[:12],
1021
+ 'reason': 'text assertions matched visible after evidence' if matched_assertion_tokens else 'no text assertion tokens matched visible after evidence',
1022
+ }
1023
+
1024
+
1025
+ def normalize_changed_region(value, total_pixels=None, changed_pixels=None, image_width=None, image_height=None, dimension_mismatch=False):
1026
+ if not isinstance(value, dict):
1027
+ return None
1028
+
1029
+ x = find_metric_value(value, VISUAL_REGION_X_KEYS)
1030
+ y = find_metric_value(value, VISUAL_REGION_Y_KEYS)
1031
+ width = find_metric_value(value, VISUAL_REGION_WIDTH_KEYS)
1032
+ height = find_metric_value(value, VISUAL_REGION_HEIGHT_KEYS)
1033
+ right = find_metric_value(value, VISUAL_REGION_RIGHT_KEYS)
1034
+ bottom = find_metric_value(value, VISUAL_REGION_BOTTOM_KEYS)
1035
+
1036
+ if width is None and x is not None and right is not None and right > x:
1037
+ width = right - x
1038
+ if height is None and y is not None and bottom is not None and bottom > y:
1039
+ height = bottom - y
1040
+ if x is None and width is not None and right is not None:
1041
+ x = right - width
1042
+ if y is None and height is not None and bottom is not None:
1043
+ y = bottom - height
1044
+
1045
+ if x is None or y is None or width is None or height is None:
1046
+ return None
1047
+ if width <= 0 or height <= 0:
1048
+ return None
1049
+
1050
+ x = int(round(x))
1051
+ y = int(round(y))
1052
+ width = int(round(width))
1053
+ height = int(round(height))
1054
+ area_pixels = width * height
1055
+ changed = metric_number(changed_pixels)
1056
+ total = metric_number(total_pixels)
1057
+ image_w = metric_number(image_width)
1058
+ image_h = metric_number(image_height)
1059
+ area_percent = (area_pixels / total) * 100 if total and total > 0 else None
1060
+ width_percent = (width / image_w) * 100 if image_w and image_w > 0 else None
1061
+ height_percent = (height / image_h) * 100 if image_h and image_h > 0 else None
1062
+ density = (changed / area_pixels) if changed is not None and area_pixels > 0 else None
1063
+ absolute_small_region = area_pixels <= max(TARGETED_VISUAL_CHANGED_PIXELS, int(changed or 0), 1)
1064
+ percent_localized = area_percent is not None and area_percent <= TARGETED_CHANGED_REGION_MAX_AREA_PERCENT
1065
+ localized = bool(not dimension_mismatch and (absolute_small_region or percent_localized))
1066
+ if dimension_mismatch:
1067
+ classification = 'geometry_change'
1068
+ elif absolute_small_region or (area_percent is not None and area_percent <= 1):
1069
+ classification = 'point_or_icon'
1070
+ elif localized:
1071
+ classification = 'localized'
1072
+ else:
1073
+ classification = 'broad'
1074
+
1075
+ return {
1076
+ 'present': True,
1077
+ 'x': x,
1078
+ 'y': y,
1079
+ 'width': width,
1080
+ 'height': height,
1081
+ 'right': x + width,
1082
+ 'bottom': y + height,
1083
+ 'area_pixels': area_pixels,
1084
+ 'area_percent': round(area_percent, 4) if area_percent is not None else None,
1085
+ 'width_percent': round(width_percent, 4) if width_percent is not None else None,
1086
+ 'height_percent': round(height_percent, 4) if height_percent is not None else None,
1087
+ 'changed_pixel_density': round(density, 6) if density is not None else None,
1088
+ 'localized_for_targeted_change': localized,
1089
+ 'classification': classification,
1090
+ 'dimension_mismatch': bool(dimension_mismatch),
1091
+ }
1092
+
1093
+
1094
+ def find_changed_region(value, total_pixels=None, changed_pixels=None, image_width=None, image_height=None, depth=0):
1095
+ if depth > 7:
1096
+ return None
1097
+ if isinstance(value, dict):
1098
+ for raw_key, raw_value in value.items():
1099
+ if normalize_metric_key(raw_key) in VISUAL_CHANGED_REGION_KEYS:
1100
+ region = normalize_changed_region(
1101
+ raw_value,
1102
+ total_pixels=total_pixels,
1103
+ changed_pixels=changed_pixels,
1104
+ image_width=image_width,
1105
+ image_height=image_height,
1106
+ )
1107
+ if region is not None:
1108
+ return region
1109
+ for raw_value in value.values():
1110
+ region = find_changed_region(raw_value, total_pixels, changed_pixels, image_width, image_height, depth + 1)
1111
+ if region is not None:
1112
+ return region
1113
+ elif isinstance(value, list):
1114
+ for item in value[:60]:
1115
+ region = find_changed_region(item, total_pixels, changed_pixels, image_width, image_height, depth + 1)
1116
+ if region is not None:
1117
+ return region
1118
+ return None
1119
+
1120
+
1121
+ def visual_delta_region_support(changed_region=None):
1122
+ if not isinstance(changed_region, dict) or not changed_region.get('present'):
1123
+ return {
1124
+ 'available': False,
1125
+ 'supported': None,
1126
+ 'reason': 'no changed-region metadata was available for targeted visual-delta localization',
1127
+ }
1128
+ if changed_region.get('dimension_mismatch'):
1129
+ return {
1130
+ 'available': True,
1131
+ 'supported': False,
1132
+ 'reason': 'changed-region metadata reports a screenshot geometry change',
1133
+ 'classification': changed_region.get('classification'),
1134
+ }
1135
+ if changed_region.get('localized_for_targeted_change') is True:
1136
+ return {
1137
+ 'available': True,
1138
+ 'supported': True,
1139
+ 'reason': 'changed pixels are localized enough for a targeted visual change',
1140
+ 'classification': changed_region.get('classification'),
1141
+ 'area_percent': changed_region.get('area_percent'),
1142
+ }
1143
+ return {
1144
+ 'available': True,
1145
+ 'supported': False,
1146
+ 'reason': 'changed pixels are too broad for a targeted visual change',
1147
+ 'classification': changed_region.get('classification'),
1148
+ 'area_percent': changed_region.get('area_percent'),
1149
+ }
1150
+
1151
+
1152
+ def visual_delta_thresholds_for_context(state=None, semantic_context=None, changed_region=None):
1153
+ semantic = visual_delta_semantic_support(state, semantic_context)
1154
+ localization = visual_delta_region_support(changed_region)
1155
+ if semantic.get('supported') and localization.get('supported') is not False:
1156
+ return {
1157
+ 'mode': 'targeted_semantic',
1158
+ 'min_change_percent': TARGETED_VISUAL_DELTA_PERCENT,
1159
+ 'min_changed_pixels': TARGETED_VISUAL_CHANGED_PIXELS,
1160
+ 'default_min_change_percent': MIN_VISUAL_DELTA_PERCENT,
1161
+ 'default_min_changed_pixels': MIN_VISUAL_CHANGED_PIXELS,
1162
+ 'semantic_support': semantic,
1163
+ 'localization_support': localization,
1164
+ }
1165
+ return {
1166
+ 'mode': 'default',
1167
+ 'min_change_percent': MIN_VISUAL_DELTA_PERCENT,
1168
+ 'min_changed_pixels': MIN_VISUAL_CHANGED_PIXELS,
1169
+ 'semantic_support': semantic,
1170
+ 'localization_support': localization,
1171
+ }
1172
+
1173
+
1174
+ def visual_delta_pass_reason(source_label, passed, thresholds):
1175
+ mode = (thresholds or {}).get('mode')
1176
+ if passed and mode == 'targeted_semantic':
1177
+ return source_label + ' clears the targeted-change threshold with matching semantic/text evidence.'
1178
+ if passed:
1179
+ return source_label + ' clears the legibility threshold.'
1180
+ if mode == 'targeted_semantic':
1181
+ return source_label + ' is below even the targeted-change threshold; capture success alone is not proof.'
1182
+ return source_label + ' is below the legibility threshold; capture success alone is not proof.'
1183
+
1184
+
1185
+ def extract_visual_delta(payload, state=None, semantic_context=None):
889
1186
  payload = enrich_capture_payload(payload)
890
1187
  result = payload.get('result') if isinstance(payload, dict) else {}
891
1188
  proof_json = payload.get('_proof_json') if isinstance(payload, dict) else {}
@@ -934,6 +1231,14 @@ def extract_visual_delta(payload):
934
1231
  if percent is None and changed_pixels is not None and total_pixels:
935
1232
  percent = (changed_pixels / total_pixels) * 100
936
1233
 
1234
+ changed_region = None
1235
+ for candidate in candidates:
1236
+ if candidate is None:
1237
+ continue
1238
+ changed_region = find_changed_region(candidate, total_pixels, changed_pixels, width, height)
1239
+ if changed_region is not None:
1240
+ break
1241
+
937
1242
  if percent is None and changed_pixels is None:
938
1243
  has_artifacts = bool(
939
1244
  screenshot_url
@@ -975,8 +1280,11 @@ def extract_visual_delta(payload):
975
1280
  },
976
1281
  }
977
1282
 
978
- percent_pass = percent is not None and percent >= MIN_VISUAL_DELTA_PERCENT
979
- pixel_pass = changed_pixels is not None and changed_pixels >= MIN_VISUAL_CHANGED_PIXELS
1283
+ thresholds = visual_delta_thresholds_for_context(state, semantic_context, changed_region)
1284
+ min_percent = thresholds['min_change_percent']
1285
+ min_pixels = thresholds['min_changed_pixels']
1286
+ percent_pass = percent is not None and percent >= min_percent
1287
+ pixel_pass = changed_pixels is not None and changed_pixels >= min_pixels
980
1288
  passed = percent_pass or pixel_pass
981
1289
  return {
982
1290
  'status': 'measured',
@@ -984,13 +1292,13 @@ def extract_visual_delta(payload):
984
1292
  'change_percent': round(percent, 4) if percent is not None else None,
985
1293
  'changed_pixels': int(changed_pixels) if changed_pixels is not None else None,
986
1294
  'total_pixels': int(total_pixels) if total_pixels is not None else None,
987
- 'min_change_percent': MIN_VISUAL_DELTA_PERCENT,
988
- 'min_changed_pixels': MIN_VISUAL_CHANGED_PIXELS,
989
- 'reason': (
990
- 'Measured visual delta clears the legibility threshold.'
991
- if passed else
992
- 'Measured visual delta is below the legibility threshold; capture success alone is not proof.'
993
- ),
1295
+ 'min_change_percent': min_percent,
1296
+ 'min_changed_pixels': min_pixels,
1297
+ 'threshold_mode': thresholds.get('mode'),
1298
+ 'semantic_support': thresholds.get('semantic_support'),
1299
+ 'localization_support': thresholds.get('localization_support'),
1300
+ 'changed_region': changed_region,
1301
+ 'reason': visual_delta_pass_reason('Measured visual delta', passed, thresholds),
994
1302
  }
995
1303
 
996
1304
 
@@ -1140,6 +1448,14 @@ def compare_rgba_images(before_image, after_image, threshold=10):
1140
1448
  overlap_height = min(before_height, after_height)
1141
1449
  total_pixels = max(before_width * before_height, after_width * after_height)
1142
1450
  changed_pixels = total_pixels - (overlap_width * overlap_height)
1451
+ min_x = min_y = None
1452
+ max_x = max_y = None
1453
+ dimension_mismatch = before_width != after_width or before_height != after_height
1454
+ if dimension_mismatch and total_pixels > 0:
1455
+ min_x = 0
1456
+ min_y = 0
1457
+ max_x = max(before_width, after_width) - 1
1458
+ max_y = max(before_height, after_height) - 1
1143
1459
  for y in range(overlap_height):
1144
1460
  before_row = y * before_width * 4
1145
1461
  after_row = y * after_width * 4
@@ -1153,11 +1469,31 @@ def compare_rgba_images(before_image, after_image, threshold=10):
1153
1469
  abs(before_rgba[b + 3] - after_rgba[a + 3]),
1154
1470
  ) > threshold:
1155
1471
  changed_pixels += 1
1472
+ min_x = x if min_x is None else min(min_x, x)
1473
+ min_y = y if min_y is None else min(min_y, y)
1474
+ max_x = x if max_x is None else max(max_x, x)
1475
+ max_y = y if max_y is None else max(max_y, y)
1156
1476
  change_percent = (changed_pixels / total_pixels) * 100 if total_pixels else None
1477
+ changed_region = None
1478
+ if min_x is not None and min_y is not None and max_x is not None and max_y is not None:
1479
+ changed_region = normalize_changed_region(
1480
+ {
1481
+ 'x': min_x,
1482
+ 'y': min_y,
1483
+ 'width': max_x - min_x + 1,
1484
+ 'height': max_y - min_y + 1,
1485
+ },
1486
+ total_pixels=total_pixels,
1487
+ changed_pixels=changed_pixels,
1488
+ image_width=max(before_width, after_width),
1489
+ image_height=max(before_height, after_height),
1490
+ dimension_mismatch=dimension_mismatch,
1491
+ )
1157
1492
  return {
1158
1493
  'changed_pixels': changed_pixels,
1159
1494
  'total_pixels': total_pixels,
1160
1495
  'change_percent': change_percent,
1496
+ 'changed_region': changed_region,
1161
1497
  'before_width': before_width,
1162
1498
  'before_height': before_height,
1163
1499
  'after_width': after_width,
@@ -1165,7 +1501,7 @@ def compare_rgba_images(before_image, after_image, threshold=10):
1165
1501
  }
1166
1502
 
1167
1503
 
1168
- def measure_visual_delta_from_image_artifacts(before_url, after_url):
1504
+ def measure_visual_delta_from_image_artifacts(before_url, after_url, state=None, semantic_context=None):
1169
1505
  if not (image_artifact_url(before_url) and image_artifact_url(after_url)):
1170
1506
  return {
1171
1507
  'status': 'skipped',
@@ -1183,8 +1519,12 @@ def measure_visual_delta_from_image_artifacts(before_url, after_url):
1183
1519
  changed_pixels = comparison.get('changed_pixels')
1184
1520
  total_pixels = comparison.get('total_pixels')
1185
1521
  percent = comparison.get('change_percent')
1186
- percent_pass = percent is not None and percent >= MIN_VISUAL_DELTA_PERCENT
1187
- pixel_pass = changed_pixels is not None and changed_pixels >= MIN_VISUAL_CHANGED_PIXELS
1522
+ changed_region = comparison.get('changed_region')
1523
+ thresholds = visual_delta_thresholds_for_context(state, semantic_context, changed_region)
1524
+ min_percent = thresholds['min_change_percent']
1525
+ min_pixels = thresholds['min_changed_pixels']
1526
+ percent_pass = percent is not None and percent >= min_percent
1527
+ pixel_pass = changed_pixels is not None and changed_pixels >= min_pixels
1188
1528
  passed = bool(percent_pass or pixel_pass)
1189
1529
  return {
1190
1530
  'status': 'measured',
@@ -1192,14 +1532,14 @@ def measure_visual_delta_from_image_artifacts(before_url, after_url):
1192
1532
  'change_percent': round(percent, 4) if percent is not None else None,
1193
1533
  'changed_pixels': int(changed_pixels) if changed_pixels is not None else None,
1194
1534
  'total_pixels': int(total_pixels) if total_pixels is not None else None,
1195
- 'min_change_percent': MIN_VISUAL_DELTA_PERCENT,
1196
- 'min_changed_pixels': MIN_VISUAL_CHANGED_PIXELS,
1535
+ 'min_change_percent': min_percent,
1536
+ 'min_changed_pixels': min_pixels,
1537
+ 'threshold_mode': thresholds.get('mode'),
1538
+ 'semantic_support': thresholds.get('semantic_support'),
1539
+ 'localization_support': thresholds.get('localization_support'),
1540
+ 'changed_region': changed_region,
1197
1541
  'source': 'riddle_artifact_image_diff',
1198
- 'reason': (
1199
- 'Measured visual delta from before/after screenshot artifacts clears the legibility threshold.'
1200
- if passed else
1201
- 'Measured visual delta from before/after screenshot artifacts is below the legibility threshold.'
1202
- ),
1542
+ 'reason': visual_delta_pass_reason('Measured visual delta from before/after screenshot artifacts', passed, thresholds),
1203
1543
  'comparison': {
1204
1544
  'before_url': before_url,
1205
1545
  'after_url': after_url,
@@ -1219,7 +1559,7 @@ def add_visual_delta_diagnostic(visual_delta, key, value):
1219
1559
  return updated
1220
1560
 
1221
1561
 
1222
- def measure_visual_delta_against_baseline(state, results, after_payload, current_visual_delta):
1562
+ def measure_visual_delta_against_baseline(state, results, after_payload, current_visual_delta, semantic_context=None):
1223
1563
  if not visual_delta_required_for_state(state):
1224
1564
  return current_visual_delta
1225
1565
  if isinstance(current_visual_delta, dict) and current_visual_delta.get('status') == 'measured':
@@ -1246,7 +1586,7 @@ def measure_visual_delta_against_baseline(state, results, after_payload, current
1246
1586
  },
1247
1587
  )
1248
1588
 
1249
- artifact_delta = measure_visual_delta_from_image_artifacts(before_url, after_url)
1589
+ artifact_delta = measure_visual_delta_from_image_artifacts(before_url, after_url, state, semantic_context)
1250
1590
  append_capture_diagnostic(state, 'visual_delta', 'riddle_artifact_image_diff', {
1251
1591
  'baseline_label': baseline.get('label') or '',
1252
1592
  'before_url': before_url,
@@ -1281,7 +1621,7 @@ def measure_visual_delta_against_baseline(state, results, after_payload, current
1281
1621
  },
1282
1622
  )
1283
1623
 
1284
- measured = extract_visual_delta(payload)
1624
+ measured = extract_visual_delta(payload, state, semantic_context)
1285
1625
  if isinstance(measured, dict) and measured.get('status') == 'measured':
1286
1626
  measured['source'] = 'riddle_visual_diff'
1287
1627
  measured['comparison'] = {
@@ -1895,6 +2235,8 @@ def build_evidence_bundle(state, results, after_payload, after_observation, requ
1895
2235
  proof_evidence = extract_proof_evidence(after_payload)
1896
2236
  playability_assessment = assess_playability_evidence(proof_evidence)
1897
2237
  playability_evidence = extract_playability_evidence(proof_evidence)
2238
+ semantic_context = build_semantic_context(state, results, after_observation, expected_path)
2239
+ viewport_matrix = (results.get('after') or {}).get('viewport_matrix') or capture_viewport_matrix_status(state, after_payload, 'after-proof')
1898
2240
  if audit_no_diff_mode(state):
1899
2241
  visual_delta = {
1900
2242
  'status': 'not_applicable',
@@ -1903,12 +2245,11 @@ def build_evidence_bundle(state, results, after_payload, after_observation, requ
1903
2245
  }
1904
2246
  else:
1905
2247
  visual_delta = (
1906
- extract_visual_delta(after_payload)
2248
+ extract_visual_delta(after_payload, state, semantic_context)
1907
2249
  if visual_delta_applies(state.get('verification_mode'))
1908
2250
  else {'status': 'not_applicable', 'passed': None, 'reason': 'Verification mode does not require visual delta gating.'}
1909
2251
  )
1910
- visual_delta = measure_visual_delta_against_baseline(state, results, after_payload, visual_delta)
1911
- semantic_context = build_semantic_context(state, results, after_observation, expected_path)
2252
+ visual_delta = measure_visual_delta_against_baseline(state, results, after_payload, visual_delta, semantic_context)
1912
2253
  artifact_contract = artifact_contract_for_state(state)
1913
2254
  artifact_production = artifact_production_summary(after_payload, supporting)
1914
2255
  artifact_usage = artifact_usage_summary(
@@ -1948,6 +2289,7 @@ def build_evidence_bundle(state, results, after_payload, after_observation, requ
1948
2289
  'semantic_context': semantic_context,
1949
2290
  'artifact_contract': artifact_contract,
1950
2291
  'artifact_usage': artifact_usage,
2292
+ 'viewport_matrix': viewport_matrix,
1951
2293
  },
1952
2294
  status='evidence_captured' if after_observation.get('valid') else 'capture_incomplete',
1953
2295
  )
@@ -1958,6 +2300,7 @@ def build_evidence_bundle(state, results, after_payload, after_observation, requ
1958
2300
  'required_baseline_present': required_baseline_present,
1959
2301
  'baseline': results.get('baseline') or {},
1960
2302
  'semantic_context': semantic_context,
2303
+ 'viewport_matrix': viewport_matrix,
1961
2304
  'artifact_contract': artifact_contract,
1962
2305
  'artifact_production': artifact_production,
1963
2306
  'artifact_usage': artifact_usage,
@@ -2023,6 +2366,7 @@ def build_supervisor_assessment_request(state, payload, after_observation, requi
2023
2366
  semantic_context,
2024
2367
  evidence_basis,
2025
2368
  )
2369
+ viewport_matrix = (evidence_bundle or {}).get('viewport_matrix') if isinstance(evidence_bundle, dict) else None
2026
2370
  if isinstance(evidence_bundle, dict):
2027
2371
  evidence_bundle['artifact_contract'] = artifact_contract
2028
2372
  evidence_bundle['artifact_production'] = artifact_production
@@ -2071,6 +2415,7 @@ def build_supervisor_assessment_request(state, payload, after_observation, requi
2071
2415
  'supporting_artifacts': supporting,
2072
2416
  'visual_delta': visual_delta,
2073
2417
  'semantic_context': semantic_context,
2418
+ 'viewport_matrix': viewport_matrix,
2074
2419
  'evidence_bundle': evidence_bundle or {},
2075
2420
  'evidence_basis': evidence_basis,
2076
2421
  'artifact_contract': artifact_contract,
@@ -2124,7 +2469,7 @@ expected_path = (
2124
2469
  )
2125
2470
  verification_mode = normalized_verification_mode(s.get('verification_mode'))
2126
2471
  proof_session_seed = capture_proof_session_seed(s, expected_path)
2127
- probe_capture_script = build_probe_capture_script(capture_script, verification_mode, proof_session_seed)
2472
+ probe_capture_script = build_probe_capture_script(capture_script, verification_mode, proof_session_seed, s.get('viewport_matrix'))
2128
2473
  results = {
2129
2474
  'baseline': {
2130
2475
  'reference': reference,
@@ -2297,9 +2642,30 @@ else:
2297
2642
  results['after'] = {'screenshots': [{'url': capture.get('url', '')}] if capture.get('url') else [], 'raw': capture.get('raw')}
2298
2643
  s['after_cdn'] = capture.get('url', '')
2299
2644
 
2645
+ after_viewport_matrix = capture_viewport_matrix_status(s, after_payload, 'after-proof')
2300
2646
  after_observation = evaluate_capture_quality(after_payload, expected_path, verification_mode)
2647
+ details = after_observation.get('details') if isinstance(after_observation.get('details'), dict) else {}
2648
+ details['viewport_matrix'] = after_viewport_matrix
2649
+ after_observation['details'] = details
2650
+ if after_viewport_matrix.get('status') == 'incomplete':
2651
+ missing_names = [
2652
+ str(item.get('name') or item.get('slug') or '').strip()
2653
+ for item in after_viewport_matrix.get('missing') or []
2654
+ if str(item.get('name') or item.get('slug') or '').strip()
2655
+ ]
2656
+ missing_text = ', '.join(missing_names[:8]) or 'requested viewport screenshots'
2657
+ reason = 'missing requested viewport evidence: ' + missing_text
2658
+ after_observation['valid'] = False
2659
+ after_observation['telemetry_ready'] = False
2660
+ after_observation['reason'] = (
2661
+ (after_observation.get('reason') + '; ' + reason)
2662
+ if after_observation.get('reason') and after_observation.get('reason') != 'ok'
2663
+ else reason
2664
+ )
2665
+ s['viewport_matrix_status'] = after_viewport_matrix
2301
2666
  results['after']['observation'] = after_observation
2302
2667
  results['after']['supporting_artifacts'] = collect_supporting_artifacts(after_payload)
2668
+ results['after']['viewport_matrix'] = after_viewport_matrix
2303
2669
  record_verify_phase('capture', 'completed', 'After-proof capture completed.')
2304
2670
 
2305
2671
  # Structured proof summary
@@ -2338,6 +2704,8 @@ if existing_before:
2338
2704
  if existing_prod:
2339
2705
  summary_lines.append('Prod baseline (recon): ' + existing_prod)
2340
2706
  summary_lines.append('After screenshot: ' + (s.get('after_cdn') or '(none)'))
2707
+ if after_viewport_matrix.get('status') not in ('not_requested', ''):
2708
+ summary_lines.append('Viewport matrix: ' + after_viewport_matrix.get('status', 'unknown') + ' (' + str(len(after_viewport_matrix.get('executed') or [])) + '/' + str(len(after_viewport_matrix.get('requested') or [])) + ' captured)')
2341
2709
  summary_lines.append('Expected proof path from recon: ' + expected_path)
2342
2710
  summary_lines.append('After observation: ' + after_observation['reason'])
2343
2711
  supporting = results['after'].get('supporting_artifacts') or {}