swayfx-mosaic/tooling/probes/step20c-saved-transaction-buffer/analyze.py

188 lines
9.1 KiB
Python

"""Offline analysis; preserves raw files and never launches a compositor."""
import argparse
import hashlib
import json
from pathlib import Path
import re
from PIL import Image, ImageChops
parser = argparse.ArgumentParser()
parser.add_argument('lab', type=Path)
args = parser.parse_args()
lab = args.lab.resolve()
repo = Path(__file__).resolve().parents[3]
results = lab / 'results'
results.mkdir(exist_ok=True)
def write(name, value):
(results / name).write_text(json.dumps(value, indent=2, sort_keys=True) + '\n')
def fields(line):
return dict(re.findall(r'(\w+)=([^\s]+)', line))
def ns(line):
match = re.search(r'\bns=(\d+)', line)
return int(match.group(1)) if match else None
def count_changed(left, right):
a = Image.open(left).convert('RGB')
b = Image.open(right).convert('RGB')
diff = ImageChops.difference(a, b)
box = diff.getbbox()
n = sum(any(px) for px in diff.get_flattened_data())
return {'pixels': n, 'box': box, 'dimensions': a.size}
def color_count(path, color):
im = Image.open(path).convert('RGB')
return sum(px == color for px in im.get_flattened_data())
matrix = {}
lifecycle = {}
timeline = {}
identities = {}
buffers = {}
parentage = {}
clips = {}
renders = {}
hits = {}
delivery = {}
commits = {}
presents = {}
damage = {}
pixels = {}
timeouts = {}
for mode in ('control', 'private_standard', 'private_mosaic'):
paths = sorted((lab / 'runs').glob(f'saved-{mode}-*'))
if len(paths) != 1:
raise RuntimeError(f'{mode}: expected exactly one run, found {len(paths)}')
run = paths[0]
events = [json.loads(line) for line in (run / 'events.jsonl').read_text().splitlines()]
lines = (run / 'compositor.log').read_text().splitlines()
client = (run / 'client.log').read_text().splitlines()
exit_state = json.loads((run / 'exit-status.json').read_text())
identity = next((x for x in events if x['kind'] == 'identity'), None)
identities[mode] = {'owner': identity['owner'] if identity else None,
'workspace': identity['workspace'] if identity else None}
tree_events = [{'event': line.split()[0], **fields(line)} for line in lines
if line.startswith(('SB_TREE_CREATE', 'SB_TREE_DESTROY', 'SB_ENABLE'))]
buffer_events = [fields(line) for line in lines if line.startswith('SB_BUFFER_CREATE')]
render_events = [fields(line) for line in lines if line.startswith('SB_RENDER')]
lifecycle[mode] = tree_events
buffers[mode] = buffer_events
parentage[mode] = [{'tree': x.get('tree'), 'parent': x.get('parent'),
'content': x.get('content'), 'view': x.get('view'),
'container': x.get('con'), 'ns': x.get('ns')}
for x in tree_events if x['event'] == 'SB_TREE_CREATE']
clips[mode] = [line for line in lines if 'PI_SCENE output=HEADLESS-1' in line]
renders[mode] = render_events
hits[mode] = [x for x in events if x['kind'] in ('pointer', 'hit_sample')]
delivery[mode] = [line for line in client if line.startswith(('ENTER', 'LEAVE', 'MOTION', 'BUTTON'))]
commits[mode] = [line for line in lines if 'PI_OUTPUT_COMMIT ' in line]
presents[mode] = [line for line in lines if 'PI_OUTPUT_PRESENT ' in line]
damage[mode] = {'status': 'NOT TESTED', 'reason': 'no transaction-scoped physical damage extents captured'}
timeouts[mode] = [line for line in lines if 'timed out' in line]
timeline[mode] = {
'client': [line for line in client if line.startswith(('XDG_CONFIGURE', 'HELD', 'ACK', 'COMMIT', 'PONG', 'EXIT'))],
'episodes': [x for x in events if x['kind'] in ('episode_start', 'held', 'timeout_observed', 'failure')],
'transactions': [line for line in lines if 'Transaction ' in line and
any(word in line for word in ('committing', 'ready', 'timed out'))],
}
px = {}
for side in ('left', 'right'):
reference = run / f'{side}-reference.png'
px[side] = {}
for tag in ('ack-held', 'ack-applied', 'timeout-held', 'timeout-applied'):
path = run / f'{side}-{tag}.png'
if path.exists():
px[side][tag] = {
'full_reference_diff': count_changed(reference, path),
'initial_client_color_pixels': color_count(path, (35, 68, 204)),
'alternate_client_color_pixels': color_count(path, (32, 208, 160)),
}
pixels[mode] = px
episode_data = {}
for kind in ('ack', 'timeout'):
start = next((x['ns'] for x in events if x['kind'] == 'episode_start' and x['episode'] == kind), None)
capture = next((x['after_ns'] for x in events if x['kind'] == 'capture'
and x['tag'] == f'{kind}-held' and x['output'] == 'HEADLESS-2'), None)
created = [x for x in buffer_events if start and capture and
start < int(x['ns']) < capture]
nodes = {x['node'] for x in created}
rendered = [x for x in render_events if start and capture and
start < int(x['ns']) < capture and x.get('node') in nodes]
episode_data[kind] = {'start_ns': start, 'held_capture_end_ns': capture,
'saved_buffers_created': len(created),
'created_nodes_rendered': len({x['node'] for x in rendered}),
'render_entries': len(rendered),
'created_nodes': sorted(nodes),
'rendered_nodes': sorted({x['node'] for x in rendered})}
owner = identities[mode]['owner'] or {}
workspace = identities[mode]['workspace'] or {}
scroll = workspace.get('mosaic', {}).get('rows', [{}])[0].get('scroll_x', 0)
# IPC rect is the logical owner; applied tile_scene in PI_SCENE is the
# private camera translation. Actual render entries remain the authority.
predicted_left = owner.get('rect', {}).get('x', 0) - scroll
predicted_right = predicted_left + owner.get('rect', {}).get('width', 0)
boundary = predicted_left < 0 < predicted_right
matrix[mode] = {'exit': exit_state, 'episodes': episode_data,
'owner_id': owner.get('id'), 'workspace_id': workspace.get('id'),
'layout': workspace.get('layout'), 'observed_scroll': scroll,
'owner_camera_box_estimate': [predicted_left, predicted_right],
'target_boundary_estimate': boundary,
'target_boundary_gate': 'NOT EXERCISED' if mode == 'private_mosaic' and not boundary else 'CONTROL_OR_CANDIDATE',
'input_during_snapshot': 'NOT TESTED' if not hits[mode] else 'PARTIAL',
'damage_coverage': 'NOT TESTED'}
budget = json.loads((lab / 'saved-budget.json').read_text())
write('saved-buffer-lifecycle.json', lifecycle)
write('transaction-timeline.json', timeline)
write('view-identities.json', identities)
write('buffer-identities.json', buffers)
write('scene-parentage.json', parentage)
write('clip-observations.json', clips)
write('render-entries.json', renders)
write('pointer-hits.json', hits)
write('client-delivery.json', delivery)
write('output-commits.json', commits)
write('presentations.json', presents)
write('damage-analysis.json', damage)
write('pixel-analysis.json', pixels)
write('timeout-analysis.json', timeouts)
write('budget.json', budget)
write('matrix.json', matrix)
summary = {'result': 'INCONCLUSIVE', 'architectural_conclusion': 'UNDECIDED',
'reason': 'Mosaic target snapshot did not cross x=0; target was outside H1 after relative scroll applied to existing camera',
'standard_fixture_incident': 'pointer sampling overran the 1200 ms ACK window; its reserved ACK episode failed and no second standard run was made',
'runs': {mode: matrix[mode]['exit']['failure'] for mode in matrix},
'budget': budget,
'unexercised': ['target partial-boundary clipping', 'saved-target pixels on H1',
'node-at/input during Mosaic target snapshot', 'camera hide while target snapshot active',
'mapped-view destruction', 'transaction-scoped damage coverage',
'native output buffer identity', 'all intermediate frames']}
write('summary.json', summary)
def manifest(paths, destination):
with destination.open('w') as out:
for path in sorted(set(paths)):
digest = hashlib.sha256(path.read_bytes()).hexdigest()
out.write(f'{digest} {path}\n')
source_files = [p for root in ('standard', 'mosaic', 'scenefx')
for p in (lab / root).rglob('*') if p.is_file() and '.git' not in p.parts]
source_files += [p for p in (repo / 'tooling/probes/step20c-saved-transaction-buffer').iterdir()
if p.is_file()]
manifest(source_files, results / 'source-sha256.txt')
evidence_files = [p for p in (lab / 'runs').rglob('*') if p.is_file()]
evidence_files += [p for p in results.iterdir() if p.is_file() and p.name not in
('source-sha256.txt', 'evidence-sha256.txt')]
evidence_files += [lab / 'saved-budget.json', lab / 'saved-build-log.txt']
manifest(evidence_files, results / 'evidence-sha256.txt')
print(f'RESULT=INCONCLUSIVE RESULTS={results}')