swayfx-mosaic/tooling/probes/step20c-saved-target-timeout-input/analyze.py

203 lines
11 KiB
Python

#!/usr/bin/env python3
"""Derive bounded, reproducible facts from the single retained timeout run."""
import hashlib
import json
import re
import sys
from pathlib import Path
from PIL import Image, ImageChops
lab = Path(sys.argv[1]).resolve()
repo = Path(__file__).resolve().parents[3]
run = next((lab / 'runs').glob('timeout-mosaic-*'))
out = lab / 'results'
events = [json.loads(line) for line in (run / 'events.jsonl').read_text().splitlines()]
log = (run / 'compositor.log').read_text().splitlines()
client = (run / 'client.log').read_text().splitlines()
reference = (run / 'reference.log').read_text().splitlines()
preflight = json.loads((out / 'target-preflight.json').read_text())
stability = json.loads((out / 'reference-stability.json').read_text())
budget = json.loads((lab / 'timeout-budget.json').read_text())
status = json.loads((run / 'exit-status.json').read_text())
def write(name, obj):
(out / name).write_text(json.dumps(obj, indent=2, sort_keys=True) + '\n')
def first(kind):
return next(e for e in events if e['kind'] == kind)
def fields(line):
return dict(re.findall(r'([A-Za-z_]+)=([^ ]+)', line))
def rgb(name):
return Image.open(run / name).convert('RGB')
def diff(a, b):
image = ImageChops.difference(rgb(a), rgb(b))
return {'changed_pixels': sum(p != (0, 0, 0) for p in image.get_flattened_data()),
'box': image.getbbox()}
def color(name, triplet):
return sum(p == triplet for p in rgb(name).get_flattened_data())
def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
saved = first('saved_target')
node = saved['node']
tree = saved['tree']
view = saved['view']
source = saved['source']
start_ns = int(fields(saved['saved_trace'])['ns'])
created = next(x for x in log if x.startswith('SB_BUFFER_CREATE ') and
f'node={node}' in x and f'parent={tree}' in x and
0 <= start_ns - int(fields(x)['ns']) < 1_000_000_000)
enabled = next(x for x in log if x.startswith('SB_ENABLE ') and
f'view={view}' in x and f'tree={tree}' in x and
int(fields(x)['ns']) >= start_ns)
destroyed = next(x for x in log if x.startswith('SB_TREE_DESTROY ') and
f'view={view}' in x and f'tree={tree}' in x and
int(fields(x)['ns']) > start_ns)
render = [x for x in log if x.startswith('SB_RENDER ') and f'node={node}' in x
and int(fields(x)['ns']) >= start_ns and
int(fields(x)['ns']) < int(fields(destroyed)['ns'])]
timeout = next(x for x in log if 'timed out (1 waiting)' in x)
timeout_event = first('timeout_observed')
held = next(x for x in client if x.startswith('HELD ') and 'serial=50' in x)
hits = [x for x in log if x.startswith('TARGET_HIT ') and
int(fields(x)['ns']) < int(fields(destroyed)['ns']) and
int(fields(x)['ns']) > int(fields(created)['ns'])]
surfaces = [next((log[i + 1] for i, line in enumerate(log) if line == hit and
i + 1 < len(log) and
log[i + 1].startswith('TARGET_HIT_SURFACE ')), None)
for hit in hits]
right_names = ['right-reference-a.png', 'right-reference-b.png',
'right-held.png', 'right-timeout-applied.png']
right = {name: {**diff(right_names[0], name), 'sha256': sha(run / name),
'old_target_pixels': color(name, (32, 208, 160)),
'new_target_pixels': color(name, (35, 68, 204))}
for name in right_names}
left = {name: {'old_target_pixels': color(name, (32, 208, 160)),
'new_target_pixels': color(name, (35, 68, 204))}
for name in ['left-reference-a.png', 'left-reference-b.png',
'left-held.png', 'left-timeout-applied.png']}
reference_events = [x for x in events if x['kind'] == 'capture' and
x['path'].startswith('right-reference')]
client_exit_ns = int(fields(next(x for x in client if x.startswith('EXIT ')))['ns'])
post_destroy_same_node = [x for x in log if x.startswith('SB_RENDER ') and
f'node={node}' in x and
int(fields(destroyed)['ns']) < int(fields(x)['ns']) <
client_exit_ns]
write('reference-pixel-diff.json', {'preflight': stability,
'capture_gap_seconds': (reference_events[1]['before_ns'] -
reference_events[0]['before_ns']) / 1e9,
'full_rgb': right})
write('camera-applied.json', {k: preflight[k] for k in
('current_scroll', 'desired_scroll', 'delta')})
write('source-buffer.json', {k: source[k] for k in
('view', 'container', 'source_node', 'source_buffer', 'left', 'top',
'width', 'right', 'source_size', 'dest', 'src_box', 'transform')})
write('saved-target.json', {'view': view, 'tree': tree, 'node': node,
'buffer': saved['buffer'], 'box': saved['box'],
'source_buffer_equal': saved['buffer'] == source['source_buffer'],
'created': created, 'enabled': enabled, 'destroyed': destroyed})
write('scene-parentage.json', {'source_parent': fields(source['raw'])['parent'],
'saved_parent': fields(created)['parent'], 'saved_tree': tree,
'tree_parent': fields(saved['tree_trace'])['parent'],
'source_node': source['source_node'], 'saved_node': node,
'target_owner_id': source['owner']['id'],
'target_workspace_id': source['workspace']['id']})
write('render-entries.json', {'target_node': node, 'held_entries': render,
'same_node_entries_between_destroy_and_client_exit': post_destroy_same_node,
'pointer_reuse_after_client_exit_must_not_be_counted_as_same_tree': True})
scene = [x for x in log if 'PI_SCENE output=HEADLESS-1' in x and
int(fields(x)['ns']) >= int(fields(created)['ns']) and
int(fields(x)['ns']) < int(fields(destroyed)['ns'])]
write('clip-analysis.json', {'saved_global_box': saved['box'],
'held_render_visible': fields(render[0])['visible'],
'outer_tiled_clip': scene[0] if scene else None,
'post_timeout_clip': next((x for x in log if
'PI_SCENE output=HEADLESS-1' in x and 'clip=160,0,740,540' in x), None),
'interpretation': 'Held visible right edge x=0 matches outer Mosaic clip; '
'after timeout x=-30 matches gap-adjusted outer clip. No independent '
'content-clip causality is inferred.'})
write('target-pixel-analysis.json', {'full_h1_target_color_counts': left,
'saved_held_pixels': left['left-held.png']['old_target_pixels']})
write('whole-h2-comparison.json', {'full_rgb': right,
'all_equal': all(v['changed_pixels'] == 0 for v in right.values()),
'no_target_color': all(v['old_target_pixels'] == 0 and
v['new_target_pixels'] == 0 for v in right.values())})
write('input-hits.json', {'held_hits': hits, 'surface_associations': surfaces,
'seat_notification': 'not directly traced',
'h2_reference_motion_received': any(x.startswith('MOTION ') and
'x=98.00 y=75.00' in x for x in reference),
'target_motion_during_hold': [x for x in client if x.startswith('MOTION ') and
int(fields(x)['ns']) >= int(fields(held)['ns']) and
int(fields(x)['ns']) < int(fields(destroyed)['ns'])]})
write('client-delivery.json', {'held_configure': held,
'target_ack_serial_50': [x for x in client if x.startswith('ACK ') and
'serial=50' in x], 'reference_motion': [x for x in reference if
x.startswith('MOTION ')], 'target_exit': [x for x in client if
x.startswith('EXIT ')]})
write('transaction-timeline.json', {'held': held, 'saved_created': created,
'render': render[0], 'timeout': timeout,
'saved_destroyed': destroyed,
'hold_to_destroy_seconds': (int(fields(destroyed)['ns']) -
int(fields(held)['ns'])) / 1e9,
'other_transactions_after_controlled_client_cleanup': True})
write('timeout.json', {'natural_timeout': timeout, 'event': timeout_event,
'no_target_ack': not any(x.startswith('ACK ') and 'serial=50' in x
for x in client), 'client_alive_at_timeout': timeout_event['client_alive'],
'target_tree_destroyed': destroyed})
write('lifetime.json', {'source_buffer': source['source_buffer'],
'saved_buffer': saved['buffer'], 'saved_tree_destroyed': destroyed,
'no_same_node_render_between_destroy_and_client_exit':
not bool(post_destroy_same_node),
'gpu_texture_release': 'NOT TESTED'})
write('damage-analysis.json', {'held_target_render': render[0],
'held_damage_extent': fields(render[0])['damage'],
'post_timeout_h1_target_pixels': left['left-timeout-applied.png'],
'h2_changed_pixels': right['right-timeout-applied.png']['changed_pixels'],
'all_intermediate_frames': 'NOT TESTED'})
write('budget.json', {'reserved': budget, 'limits': {'run': 1, 'episode': 1,
'camera': 1, 'capture': 12, 'seconds': 150},
'duration_seconds': status['duration_seconds'], 'failure': status['failure']})
core = (stability['stable'] and saved['buffer'] == source['source_buffer'] and
saved['box'][0] < 0 < saved['box'][2] and bool(render) and
left['left-held.png']['old_target_pixels'] > 0 and
all(v['changed_pixels'] == 0 for v in right.values()) and
bool(destroyed) and timeout_event['client_alive'])
verdict = 'PARTIALLY_FEASIBLE' if core else 'INCONCLUSIVE'
write('matrix.json', {'ack_e1_historical': 'PARTIALLY_FEASIBLE',
'new_timeout_core': 'PASS' if core else 'FAIL',
'input_sampled': len(hits) == 2, 'damage_complete': False,
'native_gpu_lifetime_complete': False})
write('summary.json', {'verdict': verdict, 'core_pass': core,
'architecture': 'SAVED_TARGET_TIMEOUT_CLIPPING_FEASIBLE_FOR_TESTED_MATRIX'
if core else 'UNDECIDED', 'run': str(run),
'limits': ['full transactional damage', 'all GPU/texture references',
'intermediate frames', 'seat notification on saved node',
'unmeasured input paths']})
# Source manifest covers exact executed fixture and private source trees; no
# build products enter this source list. Evidence manifest covers all retained
# raw run inputs, captures and derived JSON without a self-hash cycle.
fixture = repo / 'tooling/probes/step20c-saved-target-timeout-input'
sources = [p for p in fixture.iterdir() if p.is_file()] + [run / 'fixture-source.py']
for root in (lab / 'mosaic', lab / 'standard', lab / 'scenefx-git'):
sources.extend(p for p in root.rglob('*') if p.is_file() and
'.git' not in p.parts and 'subprojects/packagecache' not in str(p))
sources.extend(p for p in (lab / 'nested-client.c', lab / 'pointer-daemon.c',
lab / 'virtual-pointer-client.h', lab / 'virtual-pointer-protocol.c',
lab / 'xdg-shell-client.h', lab / 'xdg-shell-protocol.c')
if p.is_file())
sources = sorted(set(sources))
evidence = sorted(set(p for p in run.iterdir() if p.is_file()) |
set(p for p in out.iterdir() if p.is_file() and
not p.name.endswith('-sha256.txt')) |
{lab / 'timeout-budget.json', lab / 'saved-timeout-build-log.txt'})
for name, paths in [('source-sha256.txt', sources), ('evidence-sha256.txt', evidence)]:
(out / name).write_text(''.join(f'{sha(p)} {p}\n' for p in paths))
print(f'{verdict} source={len(sources)} evidence={len(evidence)}')