@riddledc/riddle-proof 0.7.80 → 0.7.81

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,15 @@ 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_SEMANTIC_STOPWORDS = {
65
+ 'about', 'after', 'agent', 'again', 'before', 'browser', 'button',
66
+ 'change', 'changed', 'click', 'copy', 'delta', 'does', 'from', 'into',
67
+ 'layout', 'mode', 'page', 'proof', 'route', 'screen', 'shows', 'small',
68
+ 'target', 'that', 'this', 'until', 'update', 'user', 'verify', 'view',
69
+ 'visual', 'with',
70
+ }
58
71
  VISUAL_DELTA_PERCENT_KEYS = {
59
72
  'change_pct', 'change_percent', 'changed_percent', 'percent_changed',
60
73
  'diff_percent', 'visual_delta_percent', 'pixel_change_percent',
@@ -133,7 +146,7 @@ def audit_current_capture_url(state, prod_url, expected_path):
133
146
 
134
147
 
135
148
  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', ''))
149
+ script = build_capture_script(target_url, capture_script, label, state.get('wait_for_selector', ''), state.get('viewport_matrix'))
137
150
  args = {'script': script, 'timeout_sec': 60}
138
151
  apply_auth_context(state, args)
139
152
  shot = invoke_retry('riddle_script', args, retries=3, timeout=max(timeout, 120))
@@ -312,9 +325,10 @@ def abort_capture_failure(state, results, expected_path, message, raw_payload):
312
325
  raise SystemExit(summary)
313
326
 
314
327
 
315
- def build_probe_capture_script(base_script='', verification_mode='proof', proof_session_seed=None):
328
+ def build_probe_capture_script(base_script='', verification_mode='proof', proof_session_seed=None, viewport_matrix=None):
316
329
  pieces = []
317
330
  script = (base_script or '').strip()
331
+ pieces.extend(viewport_matrix_setup_js(viewport_matrix))
318
332
  pieces.append('let __riddleProofCaptureScriptError = null;')
319
333
  pieces.append('let __riddleProofCaptureScriptResult = null;')
320
334
  if script:
@@ -374,7 +388,10 @@ def build_probe_capture_script(base_script='', verification_mode='proof', proof_
374
388
  ' catch (err) { console.log(' + json.dumps(PROOF_EVIDENCE_PREFIX) + ' + JSON.stringify({ serialization_error: String(err) })); }',
375
389
  '}',
376
390
  ])
377
- if auto_screenshot_for_mode(verification_mode) and not capture_script_saves_screenshot(script):
391
+ viewport_screenshot_lines = viewport_matrix_screenshot_js('after-proof', viewport_matrix)
392
+ if auto_screenshot_for_mode(verification_mode) and viewport_screenshot_lines:
393
+ pieces.extend(viewport_screenshot_lines)
394
+ elif auto_screenshot_for_mode(verification_mode) and not capture_script_saves_screenshot(script):
378
395
  pieces.append("await saveScreenshot('after-proof');")
379
396
  if isinstance(proof_session_seed, dict):
380
397
  pieces.append(
@@ -383,7 +400,7 @@ def build_probe_capture_script(base_script='', verification_mode='proof', proof_
383
400
  '); } catch {}'
384
401
  )
385
402
  pieces.append('if (__riddleProofCaptureScriptError) throw __riddleProofCaptureScriptError;')
386
- pieces.append('return { pageState, proofEvidence: __riddleProofEvidenceValue };')
403
+ pieces.append('return { pageState, proofEvidence: __riddleProofEvidenceValue, viewportMatrix: ' + viewport_matrix_return_js() + ' };')
387
404
  return ' '.join(pieces)
388
405
 
389
406
 
@@ -885,7 +902,144 @@ def find_metric_value(value, key_names, depth=0):
885
902
  return None
886
903
 
887
904
 
888
- def extract_visual_delta(payload):
905
+ def targeted_semantic_tokens(value, limit=24):
906
+ text = ''
907
+ try:
908
+ text = json.dumps(value, sort_keys=True) if isinstance(value, (dict, list)) else str(value or '')
909
+ except Exception:
910
+ text = str(value or '')
911
+ tokens = []
912
+ for token in re.findall(r'[a-z0-9]+', text.lower()):
913
+ if len(token) < 4 or token in TARGETED_SEMANTIC_STOPWORDS or token in tokens:
914
+ continue
915
+ tokens.append(token)
916
+ if len(tokens) >= limit:
917
+ break
918
+ return tokens
919
+
920
+
921
+ def semantic_visible_text_for_delta(state=None, semantic_context=None):
922
+ chunks = []
923
+ after = (semantic_context or {}).get('after') if isinstance(semantic_context, dict) else {}
924
+ for source in [after or {}]:
925
+ if not isinstance(source, dict):
926
+ continue
927
+ for key in ('visible_text_sample', 'title'):
928
+ if source.get(key):
929
+ chunks.append(str(source.get(key)))
930
+ for key in ('headings', 'buttons', 'links', 'large_visible_elements'):
931
+ value = source.get(key)
932
+ try:
933
+ chunks.append(json.dumps(value, sort_keys=True))
934
+ except Exception:
935
+ chunks.append(str(value))
936
+ return ' '.join(chunks).lower()
937
+
938
+
939
+ def targeted_assertion_text_sources(value, depth=0):
940
+ if depth > 8:
941
+ return []
942
+ sources = []
943
+ if isinstance(value, list):
944
+ for item in value:
945
+ sources.extend(targeted_assertion_text_sources(item, depth + 1))
946
+ return sources
947
+ if not isinstance(value, dict):
948
+ return sources
949
+
950
+ assertion_type = str(value.get('type') or value.get('kind') or '').strip().lower()
951
+ text_keys = (
952
+ 'text', 'contains', 'pattern', 'label', 'copy', 'expected',
953
+ 'expected_text', 'expectedText', 'visible_text', 'visibleText',
954
+ )
955
+ text_values = [
956
+ value.get(key)
957
+ for key in text_keys
958
+ if isinstance(value.get(key), str) and value.get(key).strip()
959
+ ]
960
+ type_is_textual = any(marker in assertion_type for marker in (
961
+ 'text', 'copy', 'visible', 'contains', 'label', 'heading', 'button', 'link', 'badge',
962
+ ))
963
+ if text_values and (type_is_textual or not assertion_type):
964
+ sources.extend(text_values)
965
+
966
+ for key in ('assertions', 'checks', 'expected', 'match', 'matches'):
967
+ nested = value.get(key)
968
+ if isinstance(nested, (dict, list)):
969
+ sources.extend(targeted_assertion_text_sources(nested, depth + 1))
970
+ return sources
971
+
972
+
973
+ def visual_delta_semantic_support(state=None, semantic_context=None):
974
+ state = state if isinstance(state, dict) else {}
975
+ visible_text = semantic_visible_text_for_delta(state, semantic_context)
976
+ if not visible_text:
977
+ return {
978
+ 'supported': False,
979
+ 'matched_tokens': [],
980
+ 'reason': 'no semantic text sample was available for targeted visual-delta thresholds',
981
+ }
982
+
983
+ assertion_sources = targeted_assertion_text_sources(state.get('parsed_assertions'))
984
+ assertion_tokens = []
985
+ for source in assertion_sources:
986
+ for token in targeted_semantic_tokens(source):
987
+ if token not in assertion_tokens:
988
+ assertion_tokens.append(token)
989
+ if not assertion_tokens:
990
+ return {
991
+ 'supported': False,
992
+ 'matched_tokens': [],
993
+ 'reason': 'targeted visual-delta thresholds require text/visible assertions for the intended small change',
994
+ }
995
+
996
+ matched_assertion_tokens = [token for token in assertion_tokens if token in visible_text]
997
+ context_tokens = []
998
+ for source in (state.get('success_criteria'), state.get('change_request'), state.get('proof_plan')):
999
+ for token in targeted_semantic_tokens(source):
1000
+ if token not in context_tokens:
1001
+ context_tokens.append(token)
1002
+ matched_context_tokens = [token for token in context_tokens if token in visible_text]
1003
+ return {
1004
+ 'supported': len(matched_assertion_tokens) >= 1,
1005
+ 'matched_tokens': (matched_assertion_tokens + [token for token in matched_context_tokens if token not in matched_assertion_tokens])[:12],
1006
+ 'matched_assertion_tokens': matched_assertion_tokens[:12],
1007
+ 'matched_context_tokens': matched_context_tokens[:12],
1008
+ 'reason': 'text assertions matched visible after evidence' if matched_assertion_tokens else 'no text assertion tokens matched visible after evidence',
1009
+ }
1010
+
1011
+
1012
+ def visual_delta_thresholds_for_context(state=None, semantic_context=None):
1013
+ semantic = visual_delta_semantic_support(state, semantic_context)
1014
+ if semantic.get('supported'):
1015
+ return {
1016
+ 'mode': 'targeted_semantic',
1017
+ 'min_change_percent': TARGETED_VISUAL_DELTA_PERCENT,
1018
+ 'min_changed_pixels': TARGETED_VISUAL_CHANGED_PIXELS,
1019
+ 'default_min_change_percent': MIN_VISUAL_DELTA_PERCENT,
1020
+ 'default_min_changed_pixels': MIN_VISUAL_CHANGED_PIXELS,
1021
+ 'semantic_support': semantic,
1022
+ }
1023
+ return {
1024
+ 'mode': 'default',
1025
+ 'min_change_percent': MIN_VISUAL_DELTA_PERCENT,
1026
+ 'min_changed_pixels': MIN_VISUAL_CHANGED_PIXELS,
1027
+ 'semantic_support': semantic,
1028
+ }
1029
+
1030
+
1031
+ def visual_delta_pass_reason(source_label, passed, thresholds):
1032
+ mode = (thresholds or {}).get('mode')
1033
+ if passed and mode == 'targeted_semantic':
1034
+ return source_label + ' clears the targeted-change threshold with matching semantic/text evidence.'
1035
+ if passed:
1036
+ return source_label + ' clears the legibility threshold.'
1037
+ if mode == 'targeted_semantic':
1038
+ return source_label + ' is below even the targeted-change threshold; capture success alone is not proof.'
1039
+ return source_label + ' is below the legibility threshold; capture success alone is not proof.'
1040
+
1041
+
1042
+ def extract_visual_delta(payload, state=None, semantic_context=None):
889
1043
  payload = enrich_capture_payload(payload)
890
1044
  result = payload.get('result') if isinstance(payload, dict) else {}
891
1045
  proof_json = payload.get('_proof_json') if isinstance(payload, dict) else {}
@@ -975,8 +1129,11 @@ def extract_visual_delta(payload):
975
1129
  },
976
1130
  }
977
1131
 
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
1132
+ thresholds = visual_delta_thresholds_for_context(state, semantic_context)
1133
+ min_percent = thresholds['min_change_percent']
1134
+ min_pixels = thresholds['min_changed_pixels']
1135
+ percent_pass = percent is not None and percent >= min_percent
1136
+ pixel_pass = changed_pixels is not None and changed_pixels >= min_pixels
980
1137
  passed = percent_pass or pixel_pass
981
1138
  return {
982
1139
  'status': 'measured',
@@ -984,13 +1141,11 @@ def extract_visual_delta(payload):
984
1141
  'change_percent': round(percent, 4) if percent is not None else None,
985
1142
  'changed_pixels': int(changed_pixels) if changed_pixels is not None else None,
986
1143
  '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
- ),
1144
+ 'min_change_percent': min_percent,
1145
+ 'min_changed_pixels': min_pixels,
1146
+ 'threshold_mode': thresholds.get('mode'),
1147
+ 'semantic_support': thresholds.get('semantic_support'),
1148
+ 'reason': visual_delta_pass_reason('Measured visual delta', passed, thresholds),
994
1149
  }
995
1150
 
996
1151
 
@@ -1165,7 +1320,7 @@ def compare_rgba_images(before_image, after_image, threshold=10):
1165
1320
  }
1166
1321
 
1167
1322
 
1168
- def measure_visual_delta_from_image_artifacts(before_url, after_url):
1323
+ def measure_visual_delta_from_image_artifacts(before_url, after_url, state=None, semantic_context=None):
1169
1324
  if not (image_artifact_url(before_url) and image_artifact_url(after_url)):
1170
1325
  return {
1171
1326
  'status': 'skipped',
@@ -1183,8 +1338,11 @@ def measure_visual_delta_from_image_artifacts(before_url, after_url):
1183
1338
  changed_pixels = comparison.get('changed_pixels')
1184
1339
  total_pixels = comparison.get('total_pixels')
1185
1340
  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
1341
+ thresholds = visual_delta_thresholds_for_context(state, semantic_context)
1342
+ min_percent = thresholds['min_change_percent']
1343
+ min_pixels = thresholds['min_changed_pixels']
1344
+ percent_pass = percent is not None and percent >= min_percent
1345
+ pixel_pass = changed_pixels is not None and changed_pixels >= min_pixels
1188
1346
  passed = bool(percent_pass or pixel_pass)
1189
1347
  return {
1190
1348
  'status': 'measured',
@@ -1192,14 +1350,12 @@ def measure_visual_delta_from_image_artifacts(before_url, after_url):
1192
1350
  'change_percent': round(percent, 4) if percent is not None else None,
1193
1351
  'changed_pixels': int(changed_pixels) if changed_pixels is not None else None,
1194
1352
  '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,
1353
+ 'min_change_percent': min_percent,
1354
+ 'min_changed_pixels': min_pixels,
1355
+ 'threshold_mode': thresholds.get('mode'),
1356
+ 'semantic_support': thresholds.get('semantic_support'),
1197
1357
  '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
- ),
1358
+ 'reason': visual_delta_pass_reason('Measured visual delta from before/after screenshot artifacts', passed, thresholds),
1203
1359
  'comparison': {
1204
1360
  'before_url': before_url,
1205
1361
  'after_url': after_url,
@@ -1219,7 +1375,7 @@ def add_visual_delta_diagnostic(visual_delta, key, value):
1219
1375
  return updated
1220
1376
 
1221
1377
 
1222
- def measure_visual_delta_against_baseline(state, results, after_payload, current_visual_delta):
1378
+ def measure_visual_delta_against_baseline(state, results, after_payload, current_visual_delta, semantic_context=None):
1223
1379
  if not visual_delta_required_for_state(state):
1224
1380
  return current_visual_delta
1225
1381
  if isinstance(current_visual_delta, dict) and current_visual_delta.get('status') == 'measured':
@@ -1246,7 +1402,7 @@ def measure_visual_delta_against_baseline(state, results, after_payload, current
1246
1402
  },
1247
1403
  )
1248
1404
 
1249
- artifact_delta = measure_visual_delta_from_image_artifacts(before_url, after_url)
1405
+ artifact_delta = measure_visual_delta_from_image_artifacts(before_url, after_url, state, semantic_context)
1250
1406
  append_capture_diagnostic(state, 'visual_delta', 'riddle_artifact_image_diff', {
1251
1407
  'baseline_label': baseline.get('label') or '',
1252
1408
  'before_url': before_url,
@@ -1281,7 +1437,7 @@ def measure_visual_delta_against_baseline(state, results, after_payload, current
1281
1437
  },
1282
1438
  )
1283
1439
 
1284
- measured = extract_visual_delta(payload)
1440
+ measured = extract_visual_delta(payload, state, semantic_context)
1285
1441
  if isinstance(measured, dict) and measured.get('status') == 'measured':
1286
1442
  measured['source'] = 'riddle_visual_diff'
1287
1443
  measured['comparison'] = {
@@ -1895,6 +2051,8 @@ def build_evidence_bundle(state, results, after_payload, after_observation, requ
1895
2051
  proof_evidence = extract_proof_evidence(after_payload)
1896
2052
  playability_assessment = assess_playability_evidence(proof_evidence)
1897
2053
  playability_evidence = extract_playability_evidence(proof_evidence)
2054
+ semantic_context = build_semantic_context(state, results, after_observation, expected_path)
2055
+ viewport_matrix = (results.get('after') or {}).get('viewport_matrix') or capture_viewport_matrix_status(state, after_payload, 'after-proof')
1898
2056
  if audit_no_diff_mode(state):
1899
2057
  visual_delta = {
1900
2058
  'status': 'not_applicable',
@@ -1903,12 +2061,11 @@ def build_evidence_bundle(state, results, after_payload, after_observation, requ
1903
2061
  }
1904
2062
  else:
1905
2063
  visual_delta = (
1906
- extract_visual_delta(after_payload)
2064
+ extract_visual_delta(after_payload, state, semantic_context)
1907
2065
  if visual_delta_applies(state.get('verification_mode'))
1908
2066
  else {'status': 'not_applicable', 'passed': None, 'reason': 'Verification mode does not require visual delta gating.'}
1909
2067
  )
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)
2068
+ visual_delta = measure_visual_delta_against_baseline(state, results, after_payload, visual_delta, semantic_context)
1912
2069
  artifact_contract = artifact_contract_for_state(state)
1913
2070
  artifact_production = artifact_production_summary(after_payload, supporting)
1914
2071
  artifact_usage = artifact_usage_summary(
@@ -1948,6 +2105,7 @@ def build_evidence_bundle(state, results, after_payload, after_observation, requ
1948
2105
  'semantic_context': semantic_context,
1949
2106
  'artifact_contract': artifact_contract,
1950
2107
  'artifact_usage': artifact_usage,
2108
+ 'viewport_matrix': viewport_matrix,
1951
2109
  },
1952
2110
  status='evidence_captured' if after_observation.get('valid') else 'capture_incomplete',
1953
2111
  )
@@ -1958,6 +2116,7 @@ def build_evidence_bundle(state, results, after_payload, after_observation, requ
1958
2116
  'required_baseline_present': required_baseline_present,
1959
2117
  'baseline': results.get('baseline') or {},
1960
2118
  'semantic_context': semantic_context,
2119
+ 'viewport_matrix': viewport_matrix,
1961
2120
  'artifact_contract': artifact_contract,
1962
2121
  'artifact_production': artifact_production,
1963
2122
  'artifact_usage': artifact_usage,
@@ -2023,6 +2182,7 @@ def build_supervisor_assessment_request(state, payload, after_observation, requi
2023
2182
  semantic_context,
2024
2183
  evidence_basis,
2025
2184
  )
2185
+ viewport_matrix = (evidence_bundle or {}).get('viewport_matrix') if isinstance(evidence_bundle, dict) else None
2026
2186
  if isinstance(evidence_bundle, dict):
2027
2187
  evidence_bundle['artifact_contract'] = artifact_contract
2028
2188
  evidence_bundle['artifact_production'] = artifact_production
@@ -2071,6 +2231,7 @@ def build_supervisor_assessment_request(state, payload, after_observation, requi
2071
2231
  'supporting_artifacts': supporting,
2072
2232
  'visual_delta': visual_delta,
2073
2233
  'semantic_context': semantic_context,
2234
+ 'viewport_matrix': viewport_matrix,
2074
2235
  'evidence_bundle': evidence_bundle or {},
2075
2236
  'evidence_basis': evidence_basis,
2076
2237
  'artifact_contract': artifact_contract,
@@ -2124,7 +2285,7 @@ expected_path = (
2124
2285
  )
2125
2286
  verification_mode = normalized_verification_mode(s.get('verification_mode'))
2126
2287
  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)
2288
+ probe_capture_script = build_probe_capture_script(capture_script, verification_mode, proof_session_seed, s.get('viewport_matrix'))
2128
2289
  results = {
2129
2290
  'baseline': {
2130
2291
  'reference': reference,
@@ -2297,9 +2458,30 @@ else:
2297
2458
  results['after'] = {'screenshots': [{'url': capture.get('url', '')}] if capture.get('url') else [], 'raw': capture.get('raw')}
2298
2459
  s['after_cdn'] = capture.get('url', '')
2299
2460
 
2461
+ after_viewport_matrix = capture_viewport_matrix_status(s, after_payload, 'after-proof')
2300
2462
  after_observation = evaluate_capture_quality(after_payload, expected_path, verification_mode)
2463
+ details = after_observation.get('details') if isinstance(after_observation.get('details'), dict) else {}
2464
+ details['viewport_matrix'] = after_viewport_matrix
2465
+ after_observation['details'] = details
2466
+ if after_viewport_matrix.get('status') == 'incomplete':
2467
+ missing_names = [
2468
+ str(item.get('name') or item.get('slug') or '').strip()
2469
+ for item in after_viewport_matrix.get('missing') or []
2470
+ if str(item.get('name') or item.get('slug') or '').strip()
2471
+ ]
2472
+ missing_text = ', '.join(missing_names[:8]) or 'requested viewport screenshots'
2473
+ reason = 'missing requested viewport evidence: ' + missing_text
2474
+ after_observation['valid'] = False
2475
+ after_observation['telemetry_ready'] = False
2476
+ after_observation['reason'] = (
2477
+ (after_observation.get('reason') + '; ' + reason)
2478
+ if after_observation.get('reason') and after_observation.get('reason') != 'ok'
2479
+ else reason
2480
+ )
2481
+ s['viewport_matrix_status'] = after_viewport_matrix
2301
2482
  results['after']['observation'] = after_observation
2302
2483
  results['after']['supporting_artifacts'] = collect_supporting_artifacts(after_payload)
2484
+ results['after']['viewport_matrix'] = after_viewport_matrix
2303
2485
  record_verify_phase('capture', 'completed', 'After-proof capture completed.')
2304
2486
 
2305
2487
  # Structured proof summary
@@ -2338,6 +2520,8 @@ if existing_before:
2338
2520
  if existing_prod:
2339
2521
  summary_lines.append('Prod baseline (recon): ' + existing_prod)
2340
2522
  summary_lines.append('After screenshot: ' + (s.get('after_cdn') or '(none)'))
2523
+ if after_viewport_matrix.get('status') not in ('not_requested', ''):
2524
+ 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
2525
  summary_lines.append('Expected proof path from recon: ' + expected_path)
2342
2526
  summary_lines.append('After observation: ' + after_observation['reason'])
2343
2527
  supporting = results['after'].get('supporting_artifacts') or {}
@@ -592,6 +592,77 @@ def run_verify_quality_ignores_proof_telemetry_console_text():
592
592
  assert weak_delta['passed'] is False
593
593
  assert 'below the legibility threshold' in weak_delta['reason']
594
594
 
595
+ targeted_delta = namespace['extract_visual_delta'](
596
+ {
597
+ 'ok': True,
598
+ 'result': {
599
+ 'proofEvidence': {
600
+ 'change_pct': '0.0487',
601
+ 'changed_pixels': 2351,
602
+ 'width': 1280,
603
+ 'height': 900,
604
+ },
605
+ },
606
+ },
607
+ {
608
+ 'change_request': 'Add the Riddle Proof recovery smoke badge',
609
+ 'success_criteria': 'The recovery smoke badge is visible after verify.',
610
+ 'parsed_assertions': [{'kind': 'text_visible', 'text': 'recovery smoke badge'}],
611
+ },
612
+ {
613
+ 'after': {
614
+ 'visible_text_sample': 'Home page Riddle Proof recovery smoke badge',
615
+ 'headings': ['Home page'],
616
+ 'buttons': ['Start'],
617
+ 'links': [],
618
+ },
619
+ 'route': {'after_observed_path': '/'},
620
+ },
621
+ )
622
+ assert targeted_delta['status'] == 'measured'
623
+ assert targeted_delta['passed'] is True
624
+ assert targeted_delta['threshold_mode'] == 'targeted_semantic'
625
+ assert targeted_delta['min_changed_pixels'] == 250
626
+ assert 'targeted-change threshold' in targeted_delta['reason']
627
+
628
+ unmatched_targeted_delta = namespace['extract_visual_delta'](
629
+ {
630
+ 'ok': True,
631
+ 'result': {
632
+ 'proofEvidence': {
633
+ 'change_pct': '0.0487',
634
+ 'changed_pixels': 2351,
635
+ 'width': 1280,
636
+ 'height': 900,
637
+ },
638
+ },
639
+ },
640
+ {'change_request': 'Add the billing badge'},
641
+ {'after': {'visible_text_sample': 'Home page unchanged copy'}},
642
+ )
643
+ assert unmatched_targeted_delta['status'] == 'measured'
644
+ assert unmatched_targeted_delta['passed'] is False
645
+ assert unmatched_targeted_delta['threshold_mode'] == 'default'
646
+
647
+ generic_page_token_delta = namespace['extract_visual_delta'](
648
+ {
649
+ 'ok': True,
650
+ 'result': {
651
+ 'proofEvidence': {
652
+ 'change_pct': '0.0487',
653
+ 'changed_pixels': 2351,
654
+ 'width': 1280,
655
+ 'height': 900,
656
+ },
657
+ },
658
+ },
659
+ {'change_request': 'Polish the pricing page CTA'},
660
+ {'after': {'visible_text_sample': 'Pricing plans for teams'}},
661
+ )
662
+ assert generic_page_token_delta['status'] == 'measured'
663
+ assert generic_page_token_delta['passed'] is False
664
+ assert generic_page_token_delta['threshold_mode'] == 'default'
665
+
595
666
  unmeasured_delta = namespace['extract_visual_delta']({
596
667
  'ok': True,
597
668
  'screenshots': [{'name': 'after-proof.png', 'url': 'https://cdn.example.com/after-proof.png'}],
@@ -617,6 +688,31 @@ def run_verify_quality_ignores_proof_telemetry_console_text():
617
688
  assert visual_diff_delta['change_percent'] == 1.45
618
689
  assert visual_diff_delta['changed_pixels'] == 14094
619
690
 
691
+ viewport_status = namespace['capture_viewport_matrix_status'](
692
+ {
693
+ 'viewport_matrix': [
694
+ {'name': 'phone', 'width': 390, 'height': 844},
695
+ {'name': 'ipad', 'width': 820, 'height': 1180},
696
+ ],
697
+ },
698
+ {
699
+ 'outputs': [
700
+ {'name': 'after-proof-phone.png', 'url': 'https://cdn.example.com/phone.png'},
701
+ {'name': 'after-proof-ipad.png', 'url': 'https://cdn.example.com/ipad.png'},
702
+ ],
703
+ },
704
+ 'after-proof',
705
+ )
706
+ assert viewport_status['status'] == 'complete'
707
+ assert [item['name'] for item in viewport_status['executed']] == ['phone', 'ipad']
708
+ missing_viewport_status = namespace['capture_viewport_matrix_status'](
709
+ {'viewport_matrix': [{'name': 'phone', 'width': 390, 'height': 844}]},
710
+ {'outputs': [{'name': 'after-proof-desktop.png', 'url': 'https://cdn.example.com/desktop.png'}]},
711
+ 'after-proof',
712
+ )
713
+ assert missing_viewport_status['status'] == 'incomplete'
714
+ assert missing_viewport_status['missing'][0]['name'] == 'phone'
715
+
620
716
  before_png = png_rgba(2, 1, bytes([
621
717
  0, 0, 0, 255,
622
718
  0, 0, 0, 255,