@riddledc/riddle-proof 0.7.81 → 0.7.83

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 CHANGED
@@ -59,7 +59,7 @@ without depending on another plugin runtime.
59
59
  ## Durable Loop CLI
60
60
 
61
61
  The package publishes `riddle-proof-loop` as a host-agnostic runner surface for
62
- Codex/CLI-style testing:
62
+ CLI-style and sole-agent testing:
63
63
 
64
64
  ```sh
65
65
  riddle-proof-loop run --request-json request.json --checkpoint-mode yield
@@ -88,9 +88,9 @@ Flag-based `respond` refuses to submit generated placeholder payloads. If the
88
88
  checkpoint template includes `TODO` fields, provide a real `--payload-json`
89
89
  file/object before resuming the run.
90
90
 
91
- `--agent local` is the generic CLI executor slot. The current implementation
92
- uses the local Codex CLI adapter underneath, but the loop contract and CLI
93
- surface are intentionally not Codex-specific.
91
+ `--agent local` is the generic CLI executor slot. A host can wire that slot to
92
+ Codex, Claude Code, another local CLI, or a managed worker without changing the
93
+ checkpoint, evidence, or proof-assessment contract.
94
94
 
95
95
  Visual/UI proof runs can pass `viewport_matrix` / `viewport_matrix_json` to the
96
96
  base loop. The runtime records the requested matrix, captures per-viewport
@@ -100,9 +100,11 @@ viewport evidence as incomplete capture evidence.
100
100
 
101
101
  Measured visual deltas still require numeric before/after evidence. For small
102
102
  targeted copy or UI changes, the visual gate can use a lower targeted threshold
103
- only when route/text semantics match the requested change. Missing or unmeasured
104
- visual deltas continue through evidence recovery instead of being treated as a
105
- passing proof.
103
+ only when route/text semantics match the requested change. When before/after
104
+ image artifacts can be compared directly, the runtime also records the changed
105
+ region bounding box and refuses the targeted threshold if that exact region is
106
+ too broad for a localized UI change. Missing or unmeasured visual deltas
107
+ continue through evidence recovery instead of being treated as a passing proof.
106
108
 
107
109
  ## CI / Profile Mode
108
110
 
@@ -674,7 +676,7 @@ terminal proof failure. If `--wait` exhausts its attempts before a terminal job
674
676
  status, the command exits non-zero and the result explains the last observed
675
677
  status and `submitted_at` state.
676
678
 
677
- ## OpenClaw Adapter Boundary
679
+ ## Base vs OpenClaw Wrapper Boundary
678
680
 
679
681
  `@riddledc/riddle-proof/openclaw` translates OpenClaw Riddle Proof tool params
680
682
  into generic `RiddleProofRunParams`.
@@ -687,6 +689,27 @@ Generic authenticated proof inputs are preserved as pass-through JSON strings:
687
689
  those for public integrations; reserve `use_auth` for a configured, site-specific
688
690
  auth helper.
689
691
 
692
+ Keep behavior in base `@riddledc/riddle-proof` when it changes the portable run
693
+ contract or evidence semantics:
694
+
695
+ - checkpoint packets and responses
696
+ - profile, audit/no-diff, and proof-of-change run modes
697
+ - viewport matrices and per-viewport artifact metadata
698
+ - visual-delta thresholds, changed-region evidence, and evidence recovery gates
699
+ - run state, run cards, result objects, proof sessions, and evidence bundles
700
+ - generic auth/header/local storage inputs
701
+
702
+ Keep behavior in the OpenClaw wrapper when it only concerns OpenClaw hosting or
703
+ presentation:
704
+
705
+ - OpenClaw tool registration and schema wording
706
+ - Discord thread/status formatting
707
+ - OpenClaw workflow labels such as interactive, background PR, and continuous
708
+ - PR handoff/status phrasing such as `ship_mode=none`
709
+ - checkpoint packet display and review UX
710
+ - configured site-specific auth helpers
711
+ - wrapper deployment, hot reload, and notification adapters
712
+
690
713
  The adapter does not invoke another OpenClaw plugin and does not supply a
691
714
  coding agent. It is the reusable mapping layer a future OpenClaw wrapper can
692
715
  call before handing the request to its configured implementation, judge, ship,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riddledc/riddle-proof",
3
- "version": "0.7.81",
3
+ "version": "0.7.83",
4
4
  "description": "Reusable Riddle Proof contracts and helpers for evidence-backed agent changes.",
5
5
  "license": "MIT",
6
6
  "author": "RiddleDC",
@@ -61,6 +61,7 @@ MIN_VISUAL_DELTA_PERCENT = 0.5
61
61
  MIN_VISUAL_CHANGED_PIXELS = 5000
62
62
  TARGETED_VISUAL_DELTA_PERCENT = 0.02
63
63
  TARGETED_VISUAL_CHANGED_PIXELS = 250
64
+ TARGETED_CHANGED_REGION_MAX_AREA_PERCENT = 20
64
65
  TARGETED_SEMANTIC_STOPWORDS = {
65
66
  'about', 'after', 'agent', 'again', 'before', 'browser', 'button',
66
67
  'change', 'changed', 'click', 'copy', 'delta', 'does', 'from', 'into',
@@ -95,6 +96,18 @@ VISUAL_TOTAL_PIXEL_KEYS = {
95
96
  }
96
97
  VISUAL_WIDTH_KEYS = {'width', 'image_width', 'screenshot_width'}
97
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'}
98
111
 
99
112
 
100
113
  def capture_script_saves_screenshot(script):
@@ -1009,9 +1022,137 @@ def visual_delta_semantic_support(state=None, semantic_context=None):
1009
1022
  }
1010
1023
 
1011
1024
 
1012
- def visual_delta_thresholds_for_context(state=None, semantic_context=None):
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):
1013
1153
  semantic = visual_delta_semantic_support(state, semantic_context)
1014
- if semantic.get('supported'):
1154
+ localization = visual_delta_region_support(changed_region)
1155
+ if semantic.get('supported') and localization.get('supported') is not False:
1015
1156
  return {
1016
1157
  'mode': 'targeted_semantic',
1017
1158
  'min_change_percent': TARGETED_VISUAL_DELTA_PERCENT,
@@ -1019,12 +1160,14 @@ def visual_delta_thresholds_for_context(state=None, semantic_context=None):
1019
1160
  'default_min_change_percent': MIN_VISUAL_DELTA_PERCENT,
1020
1161
  'default_min_changed_pixels': MIN_VISUAL_CHANGED_PIXELS,
1021
1162
  'semantic_support': semantic,
1163
+ 'localization_support': localization,
1022
1164
  }
1023
1165
  return {
1024
1166
  'mode': 'default',
1025
1167
  'min_change_percent': MIN_VISUAL_DELTA_PERCENT,
1026
1168
  'min_changed_pixels': MIN_VISUAL_CHANGED_PIXELS,
1027
1169
  'semantic_support': semantic,
1170
+ 'localization_support': localization,
1028
1171
  }
1029
1172
 
1030
1173
 
@@ -1088,6 +1231,14 @@ def extract_visual_delta(payload, state=None, semantic_context=None):
1088
1231
  if percent is None and changed_pixels is not None and total_pixels:
1089
1232
  percent = (changed_pixels / total_pixels) * 100
1090
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
+
1091
1242
  if percent is None and changed_pixels is None:
1092
1243
  has_artifacts = bool(
1093
1244
  screenshot_url
@@ -1129,7 +1280,7 @@ def extract_visual_delta(payload, state=None, semantic_context=None):
1129
1280
  },
1130
1281
  }
1131
1282
 
1132
- thresholds = visual_delta_thresholds_for_context(state, semantic_context)
1283
+ thresholds = visual_delta_thresholds_for_context(state, semantic_context, changed_region)
1133
1284
  min_percent = thresholds['min_change_percent']
1134
1285
  min_pixels = thresholds['min_changed_pixels']
1135
1286
  percent_pass = percent is not None and percent >= min_percent
@@ -1145,6 +1296,8 @@ def extract_visual_delta(payload, state=None, semantic_context=None):
1145
1296
  'min_changed_pixels': min_pixels,
1146
1297
  'threshold_mode': thresholds.get('mode'),
1147
1298
  'semantic_support': thresholds.get('semantic_support'),
1299
+ 'localization_support': thresholds.get('localization_support'),
1300
+ 'changed_region': changed_region,
1148
1301
  'reason': visual_delta_pass_reason('Measured visual delta', passed, thresholds),
1149
1302
  }
1150
1303
 
@@ -1295,6 +1448,14 @@ def compare_rgba_images(before_image, after_image, threshold=10):
1295
1448
  overlap_height = min(before_height, after_height)
1296
1449
  total_pixels = max(before_width * before_height, after_width * after_height)
1297
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
1298
1459
  for y in range(overlap_height):
1299
1460
  before_row = y * before_width * 4
1300
1461
  after_row = y * after_width * 4
@@ -1308,11 +1469,31 @@ def compare_rgba_images(before_image, after_image, threshold=10):
1308
1469
  abs(before_rgba[b + 3] - after_rgba[a + 3]),
1309
1470
  ) > threshold:
1310
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)
1311
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
+ )
1312
1492
  return {
1313
1493
  'changed_pixels': changed_pixels,
1314
1494
  'total_pixels': total_pixels,
1315
1495
  'change_percent': change_percent,
1496
+ 'changed_region': changed_region,
1316
1497
  'before_width': before_width,
1317
1498
  'before_height': before_height,
1318
1499
  'after_width': after_width,
@@ -1338,7 +1519,8 @@ def measure_visual_delta_from_image_artifacts(before_url, after_url, state=None,
1338
1519
  changed_pixels = comparison.get('changed_pixels')
1339
1520
  total_pixels = comparison.get('total_pixels')
1340
1521
  percent = comparison.get('change_percent')
1341
- thresholds = visual_delta_thresholds_for_context(state, semantic_context)
1522
+ changed_region = comparison.get('changed_region')
1523
+ thresholds = visual_delta_thresholds_for_context(state, semantic_context, changed_region)
1342
1524
  min_percent = thresholds['min_change_percent']
1343
1525
  min_pixels = thresholds['min_changed_pixels']
1344
1526
  percent_pass = percent is not None and percent >= min_percent
@@ -1354,6 +1536,8 @@ def measure_visual_delta_from_image_artifacts(before_url, after_url, state=None,
1354
1536
  'min_changed_pixels': min_pixels,
1355
1537
  'threshold_mode': thresholds.get('mode'),
1356
1538
  'semantic_support': thresholds.get('semantic_support'),
1539
+ 'localization_support': thresholds.get('localization_support'),
1540
+ 'changed_region': changed_region,
1357
1541
  'source': 'riddle_artifact_image_diff',
1358
1542
  'reason': visual_delta_pass_reason('Measured visual delta from before/after screenshot artifacts', passed, thresholds),
1359
1543
  'comparison': {
@@ -663,6 +663,33 @@ def run_verify_quality_ignores_proof_telemetry_console_text():
663
663
  assert generic_page_token_delta['passed'] is False
664
664
  assert generic_page_token_delta['threshold_mode'] == 'default'
665
665
 
666
+ broad_region_targeted_delta = namespace['extract_visual_delta'](
667
+ {
668
+ 'ok': True,
669
+ 'result': {
670
+ 'proofEvidence': {
671
+ 'change_pct': '0.0487',
672
+ 'changed_pixels': 2351,
673
+ 'total_pixels': 1152000,
674
+ 'width': 1280,
675
+ 'height': 900,
676
+ 'changed_region': {'x': 0, 'y': 0, 'width': 1280, 'height': 720},
677
+ },
678
+ },
679
+ },
680
+ {
681
+ 'change_request': 'Add the Riddle Proof recovery smoke badge',
682
+ 'success_criteria': 'The recovery smoke badge is visible after verify.',
683
+ 'parsed_assertions': [{'kind': 'text_visible', 'text': 'recovery smoke badge'}],
684
+ },
685
+ {'after': {'visible_text_sample': 'Home page Riddle Proof recovery smoke badge'}},
686
+ )
687
+ assert broad_region_targeted_delta['status'] == 'measured'
688
+ assert broad_region_targeted_delta['passed'] is False
689
+ assert broad_region_targeted_delta['threshold_mode'] == 'default'
690
+ assert broad_region_targeted_delta['changed_region']['classification'] == 'broad'
691
+ assert broad_region_targeted_delta['localization_support']['supported'] is False
692
+
666
693
  unmeasured_delta = namespace['extract_visual_delta']({
667
694
  'ok': True,
668
695
  'screenshots': [{'name': 'after-proof.png', 'url': 'https://cdn.example.com/after-proof.png'}],
@@ -738,6 +765,11 @@ def run_verify_quality_ignores_proof_telemetry_console_text():
738
765
  assert artifact_image_delta['source'] == 'riddle_artifact_image_diff'
739
766
  assert artifact_image_delta['changed_pixels'] == 1
740
767
  assert artifact_image_delta['change_percent'] == 50
768
+ assert artifact_image_delta['changed_region']['x'] == 1
769
+ assert artifact_image_delta['changed_region']['y'] == 0
770
+ assert artifact_image_delta['changed_region']['width'] == 1
771
+ assert artifact_image_delta['changed_region']['height'] == 1
772
+ assert artifact_image_delta['changed_region']['localized_for_targeted_change'] is True
741
773
 
742
774
  fallback_calls = []
743
775