@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.
- package/README.md +14 -0
- package/dist/checkpoint.js +2 -2
- package/dist/{chunk-EXF7V6TJ.js → chunk-7F5LNUGR.js} +1 -1
- package/dist/{chunk-33XO42CY.js → chunk-ABQQLRTS.js} +1 -1
- package/dist/{chunk-JRFKVTKY.js → chunk-ENWYM4L7.js} +2 -2
- package/dist/{chunk-F7BNWDF6.js → chunk-MPPLZ35C.js} +4 -4
- package/dist/{chunk-4DM3OTWH.js → chunk-UYB7ABWU.js} +3 -2
- package/dist/{chunk-DUFDZJOF.js → chunk-VY4Y5U57.js} +1 -0
- package/dist/{chunk-3UHWI3FO.js → chunk-ZHEFYLII.js} +14 -2
- package/dist/cli.cjs +14 -0
- package/dist/cli.js +6 -6
- package/dist/codex-exec-agent.js +2 -2
- package/dist/engine-harness.cjs +14 -0
- package/dist/engine-harness.js +5 -5
- package/dist/index.cjs +14 -0
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -7
- package/dist/local-agent.js +2 -2
- package/dist/openclaw.js +4 -4
- package/dist/result.cjs +1 -0
- package/dist/result.js +1 -1
- package/dist/run-card.cjs +12 -0
- package/dist/run-card.js +3 -3
- package/dist/runner.cjs +1 -0
- package/dist/runner.js +5 -5
- package/dist/state.cjs +13 -0
- package/dist/state.js +4 -4
- package/dist/types.d.cts +21 -1
- package/dist/types.d.ts +21 -1
- package/package.json +1 -1
- package/runtime/lib/preflight.py +7 -0
- package/runtime/lib/recon.py +40 -6
- package/runtime/lib/util.py +151 -3
- package/runtime/lib/verify.py +400 -32
- package/runtime/tests/recon_verify_smoke.py +128 -0
package/runtime/lib/verify.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
979
|
-
|
|
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':
|
|
988
|
-
'min_changed_pixels':
|
|
989
|
-
'
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
|
|
1187
|
-
|
|
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':
|
|
1196
|
-
'min_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 {}
|