#!/usr/bin/env python3 """Summarize ERR PERF windows from performance logs (also accepts older console logs). Script wall_ms_per_s measures scoped elapsed time, not CPU utilization. Parent/child scopes overlap; engine values are sampled gauges, not scopes. """ import argparse import csv import math import re import sys from collections import defaultdict from pathlib import Path FIELDS = ( 'run', 'window', 'elapsed_ms', 'final', 'phase_start', 'phase_end', 'metric', 'samples', 'sum', 'min', 'max', 'zero', 'over50', 'over100', ) INTEGER_FIELDS = ('window', 'elapsed_ms', 'phase_start', 'phase_end', 'samples', 'zero', 'over50', 'over100') SCRIPT_METRICS = { 'mission_tick_ms', 'maintenance_ms', 'civilians_ms', 'forces_ms', 'observers_ms', 'hq_tick_ms', 'listeners_ms', 'hq_prep_ms', 'hq_hunters_ms', 'hq_garrisons_ms', 'hq_convoys_ms', 'hq_bodies_ms', 'hq_radio_index_ms', 'hq_reports_ms', 'hq_planner_ms', 'hq_support_ms', 'hq_journal_ms', 'garrison_work_ms', 'performance_flush_ms', } def read_rows(paths): seen = {} for path in paths: with path.open(encoding='utf-8', errors='replace') as stream: for number, line in enumerate(stream, 1): marker = line.find('ERR PERF run=') if marker < 0: continue values = dict(re.findall(r'(\w+)=([^\s]+)', line[marker:])) try: row = {key: values[key] for key in FIELDS} for key in INTEGER_FIELDS: row[key] = int(row[key]) if row[key] < 0: raise ValueError(f'negative {key}') for key in ('sum', 'min', 'max'): row[key] = float(row[key]) if not math.isfinite(row[key]) or row[key] < 0: raise ValueError(f'invalid {key}') if row['elapsed_ms'] == 0 or row['samples'] == 0: raise ValueError('empty window/sample count') if row['min'] > row['max']: raise ValueError('min exceeds max') if not 0 <= row['over100'] <= row['over50'] <= row['samples']: raise ValueError('invalid threshold counts') if row['zero'] > row['samples']: raise ValueError('invalid zero count') except (KeyError, ValueError) as error: raise ValueError(f'{path}:{number}: malformed performance row: {error}') from error key = row['run'], row['window'], row['metric'] if key in seen: if seen[key] != row: raise ValueError(f'{path}:{number}: conflicting duplicate {key}') continue seen[key] = row return list(seen.values()) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('logs', nargs='+', type=Path) parser.add_argument('--csv', action='store_true', help='output individual window rows as CSV') args = parser.parse_args() try: rows = read_rows(args.logs) if not rows: raise ValueError('no ERR PERF measurement rows found') except (OSError, ValueError) as error: parser.error(str(error)) if args.csv: writer = csv.DictWriter(sys.stdout, fieldnames=FIELDS) writer.writeheader() writer.writerows(rows) return grouped = defaultdict(list) for row in rows: grouped[row['run'], row['metric']].append(row) print('Scope times overlap: do not add mission_tick, hq_tick and HQ children.') print('Wall timings have 1 ms resolution; zero does not prove zero cost.') print('Engine metrics are ~1 Hz samples of engine-reported values, NOT AI-only time.') previous_run = None for (run, metric), items in sorted(grouped.items()): if run != previous_run: print(f'\nRun {run}') print(f'{"metric":26s} {"samples":>8s} {"mean":>10s} {"max":>10s} {"zero":>8s} {"wall_ms/s":>10s}') previous_run = run count = sum(item['samples'] for item in items) total = sum(item['sum'] for item in items) elapsed = sum(item['elapsed_ms'] for item in items) maximum = max(item['max'] for item in items) zero = sum(item['zero'] for item in items) rate = f'{1000 * total / elapsed:.3f}' if metric in SCRIPT_METRICS else '-' print(f'{metric:26s} {count:8d} {total / count:10.3f} {maximum:10.3f} {zero:8d} {rate:>10s}') print('\nMeans/rates include only windows containing that metric; use --csv to compare matching windows and player counts.') print('No native-AI timing or predicted offload speedup is inferred.') if __name__ == '__main__': main()