188 lines
9.1 KiB
Python
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}')
|