@riddledc/riddle-proof 0.5.36 → 0.5.37

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.
@@ -47,6 +47,7 @@ VISUAL_FIRST_MODES = {
47
47
  'visual', 'render', 'interaction', 'ui', 'layout', 'screenshot',
48
48
  'canvas', 'animation',
49
49
  }
50
+ PLAYABILITY_MODES = {'playable', 'gameplay', 'game'}
50
51
  PROOF_EVIDENCE_REQUIRED_MODES = {'audio'}
51
52
  MIN_VISUAL_DELTA_PERCENT = 0.5
52
53
  MIN_VISUAL_CHANGED_PIXELS = 5000
@@ -77,11 +78,13 @@ def normalized_verification_mode(value):
77
78
 
78
79
 
79
80
  def proof_evidence_required_for_mode(verification_mode):
80
- return normalized_verification_mode(verification_mode) in PROOF_EVIDENCE_REQUIRED_MODES
81
+ mode = normalized_verification_mode(verification_mode)
82
+ return mode in PROOF_EVIDENCE_REQUIRED_MODES or mode in PLAYABILITY_MODES
81
83
 
82
84
 
83
85
  def screenshot_required_for_mode(verification_mode):
84
- return normalized_verification_mode(verification_mode) in VISUAL_FIRST_MODES
86
+ mode = normalized_verification_mode(verification_mode)
87
+ return mode in VISUAL_FIRST_MODES or mode in PLAYABILITY_MODES
85
88
 
86
89
 
87
90
  def auto_screenshot_for_mode(verification_mode):
@@ -408,6 +411,306 @@ def extract_proof_evidence(payload):
408
411
  return evidence
409
412
 
410
413
 
414
+ PLAYABILITY_EVIDENCE_VERSION = 'riddle-proof.playability.v1'
415
+ PLAYABILITY_ASSESSMENT_VERSION = 'riddle-proof.playability.assessment.v1'
416
+ PLAYABILITY_CONTAINER_KEYS = (
417
+ 'playability', 'playability_evidence', 'playabilityEvidence',
418
+ 'playable', 'gameplay', 'gameplay_evidence', 'gameplayEvidence',
419
+ )
420
+ PLAYABILITY_INPUT_KEYS = (
421
+ 'inputAccepted', 'inputObserved', 'inputReceived', 'controlsWorked',
422
+ 'keyboardWorked', 'pointerWorked', 'touchWorked', 'steeringInputAccepted',
423
+ 'userInputObserved',
424
+ )
425
+ PLAYABILITY_STATE_KEYS = (
426
+ 'stateChanged', 'gameStateChanged', 'playStarted', 'simulationAdvanced',
427
+ 'distanceAdvanced', 'scoreChanged', 'hudChanged', 'positionChanged',
428
+ 'speedChanged',
429
+ )
430
+ PLAYABILITY_MOTION_KEYS = (
431
+ 'motionObserved', 'visualMotion', 'canvasChanged', 'pixelChanged',
432
+ 'playfieldMoved', 'playfieldPixelsChanged', 'nonHudPixelsChanged',
433
+ 'animationAdvanced', 'frameChanged', 'framesChanged',
434
+ )
435
+ PLAYABILITY_TIME_KEYS = (
436
+ 'timeProgressed', 'clockAdvanced', 'animationAdvanced',
437
+ 'simulationAdvanced', 'playStarted',
438
+ )
439
+ PLAYABILITY_PERCENT_KEYS = (
440
+ 'changed_percent', 'change_percent', 'percent_changed', 'diff_percent',
441
+ 'motion_percent', 'pixel_change_percent',
442
+ )
443
+ PLAYABILITY_RATIO_KEYS = (
444
+ 'changed_ratio', 'change_ratio', 'diff_ratio', 'motion_ratio',
445
+ 'pixel_change_ratio',
446
+ )
447
+ PLAYABILITY_PIXEL_KEYS = (
448
+ 'changed_pixels', 'changed_pixel_count', 'diff_pixels', 'motion_pixels',
449
+ 'pixel_delta', 'changedPixels',
450
+ )
451
+ PLAYABILITY_AVG_DELTA_KEYS = (
452
+ 'average_delta', 'avg_delta', 'mean_delta', 'avg_abs_delta',
453
+ 'mean_abs_delta',
454
+ )
455
+ PLAYABILITY_TIME_DELTA_KEYS = (
456
+ 'time_delta_ms', 'elapsed_ms', 'duration_ms', 'sample_duration_ms',
457
+ 'animation_delta_ms',
458
+ )
459
+ PLAYABILITY_THRESHOLDS = {
460
+ 'min_changed_percent': 0.5,
461
+ 'min_changed_pixels': 1000,
462
+ 'min_average_delta': 1,
463
+ 'min_time_delta_ms': 250,
464
+ }
465
+
466
+
467
+ def _numeric(value):
468
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
469
+ return float(value)
470
+ if isinstance(value, str) and value.strip():
471
+ try:
472
+ return float(value)
473
+ except Exception:
474
+ return None
475
+ return None
476
+
477
+
478
+ def _numeric_from_keys(record, keys):
479
+ if not isinstance(record, dict):
480
+ return None
481
+ for key in keys:
482
+ value = _numeric(record.get(key))
483
+ if value is not None:
484
+ return value
485
+ return None
486
+
487
+
488
+ def _true_for_any_key(record, keys):
489
+ return isinstance(record, dict) and any(record.get(key) is True for key in keys)
490
+
491
+
492
+ def _percent_from(record):
493
+ percent = _numeric_from_keys(record, PLAYABILITY_PERCENT_KEYS)
494
+ if percent is not None:
495
+ return percent
496
+ ratio = _numeric_from_keys(record, PLAYABILITY_RATIO_KEYS)
497
+ if ratio is not None:
498
+ return ratio * 100 if ratio <= 1 else ratio
499
+ return None
500
+
501
+
502
+ def _parse_json_if_possible(value):
503
+ if not isinstance(value, str):
504
+ return None
505
+ text = value.strip()
506
+ if not text or text[0] not in '[{':
507
+ return None
508
+ try:
509
+ return json.loads(text)
510
+ except Exception:
511
+ return None
512
+
513
+
514
+ def _has_playability_shape(record):
515
+ if not isinstance(record, dict):
516
+ return False
517
+ if record.get('version') == PLAYABILITY_EVIDENCE_VERSION:
518
+ return True
519
+ shape_keys = (
520
+ 'input_events', 'state_delta', 'pixel_delta', 'canvas_delta',
521
+ 'motion_delta', 'playfield_delta', 'time_delta_ms',
522
+ )
523
+ if any(key in record for key in shape_keys):
524
+ return True
525
+ assertions = record.get('assertions') if isinstance(record.get('assertions'), dict) else record
526
+ return any(
527
+ key in assertions
528
+ for key in (
529
+ PLAYABILITY_INPUT_KEYS
530
+ + PLAYABILITY_STATE_KEYS
531
+ + PLAYABILITY_MOTION_KEYS
532
+ + PLAYABILITY_TIME_KEYS
533
+ )
534
+ )
535
+
536
+
537
+ def extract_playability_evidence(value, seen=None, depth=0):
538
+ if depth > 6 or value is None:
539
+ return None
540
+ if seen is None:
541
+ seen = set()
542
+ parsed = _parse_json_if_possible(value)
543
+ if parsed is not None:
544
+ return extract_playability_evidence(parsed, seen, depth + 1)
545
+ if isinstance(value, list):
546
+ ident = id(value)
547
+ if ident in seen:
548
+ return None
549
+ seen.add(ident)
550
+ for item in value:
551
+ found = extract_playability_evidence(item, seen, depth + 1)
552
+ if found is not None:
553
+ return found
554
+ return None
555
+ if not isinstance(value, dict):
556
+ return None
557
+ ident = id(value)
558
+ if ident in seen:
559
+ return None
560
+ seen.add(ident)
561
+ if _has_playability_shape(value):
562
+ return value
563
+ for key in PLAYABILITY_CONTAINER_KEYS:
564
+ if key in value:
565
+ found = extract_playability_evidence(value.get(key), seen, depth + 1)
566
+ if found is not None:
567
+ return found
568
+ if isinstance(value.get(key), bool):
569
+ return {'assertions': {key: value.get(key)}}
570
+ for item in value.values():
571
+ found = extract_playability_evidence(item, seen, depth + 1)
572
+ if found is not None:
573
+ return found
574
+ return None
575
+
576
+
577
+ def assess_playability_evidence(value):
578
+ evidence = extract_playability_evidence(value)
579
+ metrics = {}
580
+ required = {
581
+ 'input': True,
582
+ 'state_change': True,
583
+ 'motion': True,
584
+ 'time_progression': True,
585
+ }
586
+ if not isinstance(evidence, dict):
587
+ return {
588
+ 'version': PLAYABILITY_ASSESSMENT_VERSION,
589
+ 'evidence_present': False,
590
+ 'passed': False,
591
+ 'input_observed': False,
592
+ 'state_changed': False,
593
+ 'motion_observed': False,
594
+ 'time_progressed': False,
595
+ 'concerns': ['playability evidence is missing'],
596
+ 'metrics': metrics,
597
+ 'thresholds': dict(PLAYABILITY_THRESHOLDS),
598
+ 'required': required,
599
+ 'evidence_keys': [],
600
+ }
601
+
602
+ assertions = evidence.get('assertions') if isinstance(evidence.get('assertions'), dict) else evidence
603
+ input_events = []
604
+ for key in ('input_events', 'inputs', 'interactions'):
605
+ if isinstance(evidence.get(key), list):
606
+ input_events.extend(evidence.get(key))
607
+ input_count = _numeric_from_keys(evidence, ('input_event_count', 'input_count', 'interaction_count'))
608
+ input_observed = bool(
609
+ _true_for_any_key(assertions, PLAYABILITY_INPUT_KEYS)
610
+ or input_events
611
+ or (input_count is not None and input_count > 0)
612
+ )
613
+
614
+ state_delta = evidence.get('state_delta') if isinstance(evidence.get('state_delta'), dict) else evidence.get('stateDelta')
615
+ changed_keys = []
616
+ if isinstance(state_delta, dict):
617
+ raw_keys = state_delta.get('changed_keys') or state_delta.get('changedKeys') or []
618
+ if isinstance(raw_keys, list):
619
+ changed_keys = raw_keys
620
+ state_numeric_delta = _numeric_from_keys(evidence, (
621
+ 'distance_delta', 'score_delta', 'position_delta', 'speed_delta', 'hud_delta',
622
+ ))
623
+ if changed_keys:
624
+ metrics['state_changed_keys'] = changed_keys
625
+ if state_numeric_delta is not None:
626
+ metrics['state_numeric_delta'] = state_numeric_delta
627
+ state_changed = bool(
628
+ _true_for_any_key(assertions, PLAYABILITY_STATE_KEYS)
629
+ or (isinstance(state_delta, dict) and state_delta.get('changed') is True)
630
+ or changed_keys
631
+ or (state_numeric_delta is not None and abs(state_numeric_delta) > 0)
632
+ )
633
+
634
+ motion_observed = _true_for_any_key(assertions, PLAYABILITY_MOTION_KEYS)
635
+ if not motion_observed:
636
+ for source in (
637
+ evidence.get('playfield_delta'), evidence.get('playfieldDelta'),
638
+ evidence.get('non_hud_delta'), evidence.get('nonHudDelta'),
639
+ evidence.get('pixel_delta'), evidence.get('pixelDelta'),
640
+ evidence.get('canvas_delta'), evidence.get('canvasDelta'),
641
+ evidence.get('motion_delta'), evidence.get('motionDelta'),
642
+ evidence.get('visual_delta'), evidence.get('visualDelta'),
643
+ evidence,
644
+ ):
645
+ if not isinstance(source, dict):
646
+ continue
647
+ percent = _percent_from(source)
648
+ pixels = _numeric_from_keys(source, PLAYABILITY_PIXEL_KEYS)
649
+ average_delta = _numeric_from_keys(source, PLAYABILITY_AVG_DELTA_KEYS)
650
+ if percent is not None:
651
+ metrics['changed_percent'] = percent
652
+ if pixels is not None:
653
+ metrics['changed_pixels'] = pixels
654
+ if average_delta is not None:
655
+ metrics['average_delta'] = average_delta
656
+ motion_observed = bool(
657
+ (percent is not None and percent >= PLAYABILITY_THRESHOLDS['min_changed_percent'])
658
+ or (pixels is not None and pixels >= PLAYABILITY_THRESHOLDS['min_changed_pixels'])
659
+ or (average_delta is not None and average_delta >= PLAYABILITY_THRESHOLDS['min_average_delta'])
660
+ )
661
+ if motion_observed:
662
+ break
663
+
664
+ time_delta = _numeric_from_keys(evidence, PLAYABILITY_TIME_DELTA_KEYS)
665
+ if time_delta is None and isinstance(state_delta, dict):
666
+ time_delta = _numeric_from_keys(state_delta, PLAYABILITY_TIME_DELTA_KEYS)
667
+ if time_delta is not None:
668
+ metrics['time_delta_ms'] = time_delta
669
+ time_progressed = bool(
670
+ _true_for_any_key(assertions, PLAYABILITY_TIME_KEYS)
671
+ or (time_delta is not None and time_delta >= PLAYABILITY_THRESHOLDS['min_time_delta_ms'])
672
+ )
673
+
674
+ explicit_failure = bool(
675
+ evidence.get('passed') is False
676
+ or evidence.get('playable') is False
677
+ or assertions.get('playabilityPassed') is False
678
+ or any(assertions.get(key) is False for key in (
679
+ PLAYABILITY_INPUT_KEYS
680
+ + PLAYABILITY_STATE_KEYS
681
+ + PLAYABILITY_MOTION_KEYS
682
+ + PLAYABILITY_TIME_KEYS
683
+ ))
684
+ )
685
+
686
+ concerns = []
687
+ if not input_observed:
688
+ concerns.append('no accepted player input was observed')
689
+ if not state_changed:
690
+ concerns.append('game state did not measurably change')
691
+ if not motion_observed:
692
+ concerns.append('playfield/canvas pixels did not measurably change')
693
+ if not time_progressed:
694
+ concerns.append('play time or animation time did not measurably progress')
695
+ if explicit_failure:
696
+ concerns.append('playability evidence includes an explicit failed assertion')
697
+
698
+ return {
699
+ 'version': PLAYABILITY_ASSESSMENT_VERSION,
700
+ 'evidence_present': True,
701
+ 'passed': bool(input_observed and state_changed and motion_observed and time_progressed and not explicit_failure),
702
+ 'input_observed': input_observed,
703
+ 'state_changed': state_changed,
704
+ 'motion_observed': motion_observed,
705
+ 'time_progressed': time_progressed,
706
+ 'concerns': concerns,
707
+ 'metrics': metrics,
708
+ 'thresholds': dict(PLAYABILITY_THRESHOLDS),
709
+ 'required': required,
710
+ 'evidence_keys': list(evidence.keys()),
711
+ }
712
+
713
+
411
714
  def first_failed_proof_evidence(value):
412
715
  if isinstance(value, dict):
413
716
  if value.get('proof_evidence_present') is False:
@@ -698,6 +1001,7 @@ def collect_supporting_artifacts(payload):
698
1001
  structured_result_keys = [k for k in result_keys if k not in ('pageState', 'page_state')]
699
1002
  console_entries = payload.get('console') or []
700
1003
  proof_evidence = extract_proof_evidence(payload)
1004
+ playability_assessment = assess_playability_evidence(proof_evidence)
701
1005
 
702
1006
  return {
703
1007
  'image_outputs': image_outputs,
@@ -708,6 +1012,9 @@ def collect_supporting_artifacts(payload):
708
1012
  'console_entries': len(console_entries),
709
1013
  'proof_evidence_present': proof_evidence is not None,
710
1014
  'proof_evidence_sample': compact_value(proof_evidence) if proof_evidence is not None else '',
1015
+ 'playability_evidence_present': bool(playability_assessment.get('evidence_present')),
1016
+ 'playability_ready': bool(playability_assessment.get('passed')),
1017
+ 'playability_assessment': playability_assessment,
711
1018
  'has_structured_payload': bool(data_outputs or structured_result_keys or proof_evidence is not None),
712
1019
  }
713
1020
 
@@ -721,6 +1028,7 @@ def artifact_contract_for_mode(verification_mode):
721
1028
  'route_semantics': True,
722
1029
  'screenshot': screenshot_required_for_mode(mode),
723
1030
  'proof_evidence': proof_evidence_required_for_mode(mode),
1031
+ 'playability': mode in PLAYABILITY_MODES,
724
1032
  'visual_delta': visual_delta_applies(mode),
725
1033
  },
726
1034
  'preferred': {
@@ -748,6 +1056,8 @@ def artifact_production_summary(payload, supporting):
748
1056
  'console_entries': int(supporting.get('console_entries') or 0),
749
1057
  'structured_result_keys': list(supporting.get('structured_result_keys') or []),
750
1058
  'proof_evidence_present': bool(supporting.get('proof_evidence_present')),
1059
+ 'playability_evidence_present': bool(supporting.get('playability_evidence_present')),
1060
+ 'playability_ready': bool(supporting.get('playability_ready')),
751
1061
  'has_structured_payload': bool(supporting.get('has_structured_payload')),
752
1062
  }
753
1063
 
@@ -768,6 +1078,7 @@ def artifact_signal_availability(state, after_observation, supporting, visual_de
768
1078
  ),
769
1079
  'structured_payload': bool(supporting.get('has_structured_payload')),
770
1080
  'proof_evidence': bool(supporting.get('proof_evidence_present')),
1081
+ 'playability': bool(supporting.get('playability_ready')),
771
1082
  'visual_delta': visual_delta_passes_ship_gate(visual_delta),
772
1083
  'console_summary': bool(supporting.get('console_entries')),
773
1084
  'json_artifacts': bool(supporting.get('data_outputs')),
@@ -799,6 +1110,8 @@ def artifact_usage_summary(state, after_observation, supporting, visual_delta, r
799
1110
  capture_quality.append('structured_payload')
800
1111
  if available.get('proof_evidence'):
801
1112
  capture_quality.append('proof_evidence')
1113
+ if available.get('playability'):
1114
+ capture_quality.append('playability')
802
1115
  if available.get('visual_delta'):
803
1116
  capture_quality.append('visual_delta')
804
1117
 
@@ -1048,6 +1361,8 @@ def build_semantic_context(state, results, after_observation, expected_path):
1048
1361
  def build_evidence_bundle(state, results, after_payload, after_observation, required_baseline_present, expected_path):
1049
1362
  supporting = collect_supporting_artifacts(after_payload)
1050
1363
  proof_evidence = extract_proof_evidence(after_payload)
1364
+ playability_assessment = assess_playability_evidence(proof_evidence)
1365
+ playability_evidence = extract_playability_evidence(proof_evidence)
1051
1366
  visual_delta = (
1052
1367
  extract_visual_delta(after_payload)
1053
1368
  if visual_delta_applies(state.get('verification_mode'))
@@ -1089,6 +1404,7 @@ def build_evidence_bundle(state, results, after_payload, after_observation, requ
1089
1404
  },
1090
1405
  evidence={
1091
1406
  'visual_delta': visual_delta,
1407
+ 'playability_assessment': playability_assessment,
1092
1408
  'semantic_context': semantic_context,
1093
1409
  'artifact_contract': artifact_contract,
1094
1410
  'artifact_usage': artifact_usage,
@@ -1110,10 +1426,14 @@ def build_evidence_bundle(state, results, after_payload, after_observation, requ
1110
1426
  'observation': after_observation,
1111
1427
  'supporting_artifacts': supporting,
1112
1428
  'proof_evidence': proof_evidence,
1429
+ 'playability_evidence': playability_evidence,
1430
+ 'playability_assessment': playability_assessment,
1113
1431
  'proof_evidence_sample': compact_value(proof_evidence) if proof_evidence is not None else '',
1114
1432
  'visual_delta': visual_delta,
1115
1433
  },
1116
1434
  'proof_evidence': proof_evidence,
1435
+ 'playability_evidence': playability_evidence,
1436
+ 'playability_assessment': playability_assessment,
1117
1437
  'proof_evidence_sample': compact_value(proof_evidence) if proof_evidence is not None else '',
1118
1438
  'success_criteria': (state.get('success_criteria') or '').strip(),
1119
1439
  'assertions': state.get('parsed_assertions') or None,
@@ -1135,6 +1455,8 @@ def build_supervisor_assessment_request(state, payload, after_observation, requi
1135
1455
  evidence_basis.append('screenshots')
1136
1456
  if supporting['has_structured_payload']:
1137
1457
  evidence_basis.append('structured-artifacts')
1458
+ if supporting.get('playability_ready'):
1459
+ evidence_basis.append('playability')
1138
1460
  visual_delta = ((evidence_bundle or {}).get('after') or {}).get('visual_delta') or {}
1139
1461
  if visual_delta.get('status') == 'measured':
1140
1462
  evidence_basis.append('visual-delta')
@@ -1167,6 +1489,14 @@ def build_supervisor_assessment_request(state, payload, after_observation, requi
1167
1489
  evidence_bundle['artifact_usage'] = artifact_usage
1168
1490
  visual_delta_blocker = visual_delta_blocker_for_mode(verification_mode, visual_delta)
1169
1491
  hard_blockers = [visual_delta_blocker] if visual_delta_blocker else []
1492
+ if verification_mode in PLAYABILITY_MODES and not supporting.get('playability_ready'):
1493
+ assessment = supporting.get('playability_assessment') or {}
1494
+ concerns = assessment.get('concerns') if isinstance(assessment, dict) else []
1495
+ detail = '; '.join(str(item) for item in concerns[:4]) if isinstance(concerns, list) else ''
1496
+ hard_blockers.append(
1497
+ 'playability evidence blocks ready_to_ship for playable/gameplay proof'
1498
+ + (f': {detail}' if detail else '.')
1499
+ )
1170
1500
 
1171
1501
  return {
1172
1502
  'status': 'needs_supervising_agent_assessment',
@@ -1191,6 +1521,7 @@ def build_supervisor_assessment_request(state, payload, after_observation, requi
1191
1521
  'Use semantic_context.route plus headings/buttons/text anchors to ground route and content judgment before treating a screenshot as wrong-route.',
1192
1522
  'For visual/UI modes, use screenshots plus after_observation.details.visible_text_sample, headings, buttons, links, canvas_count, and large_visible_elements to explain what the proof actually shows.',
1193
1523
  'For visual/UI polish, capture success is not proof. If visual_delta.status is unmeasured, missing, not_applicable, or measured with passed=false, choose needs_implementation or needs_richer_proof instead of ready_to_ship.',
1524
+ 'For playable/gameplay proof, screenshots are supporting evidence only. Do not mark ready_to_ship unless playability_assessment.passed is true and the proof shows accepted input, state/time progression, and playfield/canvas pixel motion.',
1194
1525
  'For data/audio/log/metrics/custom modes, judge the structured evidence bundle and proof_evidence_sample directly; screenshots are optional supporting context.',
1195
1526
  'The summary must name the concrete change, the target route/UI, what changed in after evidence, and why the stop condition is satisfied.',
1196
1527
  'Only set escalation_target=human when you conclude the workflow has hit a real wall or is not converging.',
@@ -6,9 +6,6 @@ import {
6
6
  normalizeRunParams,
7
7
  setRunStatus
8
8
  } from "./chunk-OASB3CYU.js";
9
- import {
10
- visualDeltaShipGateReason
11
- } from "./chunk-4YCWZVBN.js";
12
9
  import {
13
10
  applyTerminalMetadata,
14
11
  compactRecord,
@@ -17,6 +14,9 @@ import {
17
14
  normalizeTerminalMetadata,
18
15
  recordValue
19
16
  } from "./chunk-J2MERROF.js";
17
+ import {
18
+ visualDeltaShipGateReason
19
+ } from "./chunk-4YCWZVBN.js";
20
20
 
21
21
  // src/engine-harness.ts
22
22
  import { execFileSync } from "child_process";