bobframes 0.1.0__py3-none-any.whl

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.
Files changed (130) hide show
  1. bobframes/__init__.py +3 -0
  2. bobframes/_version.py +1 -0
  3. bobframes/catalog.py +154 -0
  4. bobframes/cli.py +266 -0
  5. bobframes/derive_post_merge.py +365 -0
  6. bobframes/derives/__init__.py +0 -0
  7. bobframes/derives/pass_class_breakdown.py +102 -0
  8. bobframes/derives/texture_usage.py +121 -0
  9. bobframes/discovery.py +132 -0
  10. bobframes/global_entities.py +99 -0
  11. bobframes/html/__init__.py +0 -0
  12. bobframes/html/template.py +1056 -0
  13. bobframes/lint.py +114 -0
  14. bobframes/manifest.py +127 -0
  15. bobframes/parquetize.py +282 -0
  16. bobframes/parsers/__init__.py +0 -0
  17. bobframes/parsers/derive_program_transitions.py +73 -0
  18. bobframes/parsers/parse_init_state.py +675 -0
  19. bobframes/paths.py +111 -0
  20. bobframes/probes/__init__.py +0 -0
  21. bobframes/probes/whatif.py +165 -0
  22. bobframes/qrd_harness.py +119 -0
  23. bobframes/query_examples.py +222 -0
  24. bobframes/rdcmd.py +72 -0
  25. bobframes/replay/__init__.py +26 -0
  26. bobframes/replay/replay_main.py +2305 -0
  27. bobframes/reports/__init__.py +0 -0
  28. bobframes/reports/_dashboard.py +425 -0
  29. bobframes/reports/ab.py +88 -0
  30. bobframes/reports/base.py +114 -0
  31. bobframes/reports/cache.py +147 -0
  32. bobframes/reports/chrome.py +1306 -0
  33. bobframes/reports/cli.py +99 -0
  34. bobframes/reports/delta.py +167 -0
  35. bobframes/reports/discovery.py +118 -0
  36. bobframes/reports/draws_by_class.py +165 -0
  37. bobframes/reports/formatters.py +122 -0
  38. bobframes/reports/instancing_opportunities.py +276 -0
  39. bobframes/reports/orchestrator.py +59 -0
  40. bobframes/reports/overdraw.py +293 -0
  41. bobframes/reports/pass_gpu.py +190 -0
  42. bobframes/reports/shader_hotlist.py +240 -0
  43. bobframes/reports/trend_table.py +444 -0
  44. bobframes/resource_labels.py +162 -0
  45. bobframes/run.py +480 -0
  46. bobframes/schemas.py +426 -0
  47. bobframes/stable_keys.py +83 -0
  48. bobframes/tests/__init__.py +0 -0
  49. bobframes/tests/_render_util.py +84 -0
  50. bobframes/tests/data/golden/_reports/draws_by_class.html +323 -0
  51. bobframes/tests/data/golden/_reports/drill/District 01/2026-05-28_r110600/index.html +1560 -0
  52. bobframes/tests/data/golden/_reports/index.html +264 -0
  53. bobframes/tests/data/golden/_reports/instancing_opportunities.html +266 -0
  54. bobframes/tests/data/golden/_reports/overdraw.html +275 -0
  55. bobframes/tests/data/golden/_reports/pass_gpu.html +277 -0
  56. bobframes/tests/data/golden/_reports/shader_hotlist.html +265 -0
  57. bobframes/tests/data/golden/_reports/trend_table.html +390 -0
  58. bobframes/tests/data/golden/index.html +1175 -0
  59. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/_manifest.json +51 -0
  60. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/buffers.parquet +0 -0
  61. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/clears.parquet +0 -0
  62. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/counters_per_event.parquet +0 -0
  63. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/descriptor_access.parquet +0 -0
  64. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/dispatches.parquet +0 -0
  65. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draw_bindings.parquet +0 -0
  66. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draws.parquet +0 -0
  67. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/events.parquet +0 -0
  68. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/fbos.parquet +0 -0
  69. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/frame_totals.parquet +0 -0
  70. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/ibo_samples.parquet +0 -0
  71. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/indirect_args.parquet +0 -0
  72. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/passes.parquet +0 -0
  73. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/pixel_history.parquet +0 -0
  74. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/post_vs_samples.parquet +0 -0
  75. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/program_transitions.parquet +0 -0
  76. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/programs.parquet +0 -0
  77. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/render_targets.parquet +0 -0
  78. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/resource_creation.parquet +0 -0
  79. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/rt_event_timeline.parquet +0 -0
  80. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/samplers.parquet +0 -0
  81. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/shaders.parquet +0 -0
  82. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/state_change_events.parquet +0 -0
  83. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/texture_samples.parquet +0 -0
  84. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/textures.parquet +0 -0
  85. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vbo_samples.parquet +0 -0
  86. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vertex_inputs.parquet +0 -0
  87. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/_manifest.json +51 -0
  88. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/buffers.parquet +0 -0
  89. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/clears.parquet +0 -0
  90. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/counters_per_event.parquet +0 -0
  91. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/descriptor_access.parquet +0 -0
  92. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/dispatches.parquet +0 -0
  93. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draw_bindings.parquet +0 -0
  94. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draws.parquet +0 -0
  95. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/events.parquet +0 -0
  96. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/fbos.parquet +0 -0
  97. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/frame_totals.parquet +0 -0
  98. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/ibo_samples.parquet +0 -0
  99. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/indirect_args.parquet +0 -0
  100. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/passes.parquet +0 -0
  101. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/pixel_history.parquet +0 -0
  102. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/post_vs_samples.parquet +0 -0
  103. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/program_transitions.parquet +0 -0
  104. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/programs.parquet +0 -0
  105. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/render_targets.parquet +0 -0
  106. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/resource_creation.parquet +0 -0
  107. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/rt_event_timeline.parquet +0 -0
  108. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/samplers.parquet +0 -0
  109. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/shaders.parquet +0 -0
  110. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/state_change_events.parquet +0 -0
  111. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/texture_samples.parquet +0 -0
  112. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/textures.parquet +0 -0
  113. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vbo_samples.parquet +0 -0
  114. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vertex_inputs.parquet +0 -0
  115. bobframes/tests/make_synthetic.py +171 -0
  116. bobframes/tests/smoke.py +199 -0
  117. bobframes/tests/test_determinism.py +19 -0
  118. bobframes/tests/test_discovery.py +97 -0
  119. bobframes/tests/test_hardening.py +142 -0
  120. bobframes/tests/test_parity.py +22 -0
  121. bobframes/tests/test_perf.py +18 -0
  122. bobframes/tests/test_replay_drift.py +115 -0
  123. bobframes/tests/test_schemas.py +26 -0
  124. bobframes/tests/test_schemas_unit.py +55 -0
  125. bobframes/tests/test_stable_keys.py +61 -0
  126. bobframes-0.1.0.dist-info/METADATA +144 -0
  127. bobframes-0.1.0.dist-info/RECORD +130 -0
  128. bobframes-0.1.0.dist-info/WHEEL +4 -0
  129. bobframes-0.1.0.dist-info/entry_points.txt +2 -0
  130. bobframes-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,444 @@
1
+ """Trend table: per-area KPI matrices across drop_dates.
2
+
3
+ One <h2> per KPI, rows = area, columns = drop_dates (+ delta cols + sparkline).
4
+ Plus per-area class-count matrix at the end.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ from collections import defaultdict
13
+
14
+ import pyarrow.parquet as papq
15
+
16
+ from . import base
17
+
18
+
19
+ # (col_name, label, fmt, lower_is_better, regression_pct)
20
+ KPIS = [
21
+ ('total_gpu_duration_s', 'gpu (s)', '{:+,.3f}', True, 10.0),
22
+ ('n_draws', 'draws', '{:+,.0f}', True, 10.0),
23
+ ('vbo_bytes_bound_derived', 'vbo bytes', '{:+,.0f}', True, 15.0),
24
+ ('ibo_bytes_bound_derived', 'ibo bytes', '{:+,.0f}', True, 15.0),
25
+ ('program_switches', 'prog switches', '{:+,.0f}', True, 20.0),
26
+ ]
27
+
28
+ _INT_KPIS = {'n_draws', 'vbo_bytes_bound_derived', 'ibo_bytes_bound_derived',
29
+ 'program_switches'}
30
+
31
+
32
+ def _aggregate_frame_totals(drop: base.DropSet, ok_caps: set) -> dict:
33
+ """Return {area: {kpi: sum}} from frame_totals.parquet."""
34
+ out: dict = defaultdict(lambda: defaultdict(float))
35
+ for r in drop.rows:
36
+ p = os.path.join(r.drop_dir, 'frame_totals.parquet')
37
+ if not os.path.exists(p):
38
+ continue
39
+ try:
40
+ t = papq.read_table(p)
41
+ except Exception:
42
+ continue
43
+ cols = {c: t.column(c).to_pylist() for c in t.column_names}
44
+ for i in range(t.num_rows):
45
+ cap = cols['capture'][i]
46
+ key = (r.area, r.drop_date, r.drop_label, cap)
47
+ if ok_caps and key not in ok_caps:
48
+ continue
49
+ for kpi, *_ in KPIS:
50
+ if kpi in cols:
51
+ v = cols[kpi][i]
52
+ if v is not None:
53
+ out[r.area][kpi] += v
54
+ return out
55
+
56
+
57
+ def _aggregate_buffer_bytes(drop: base.DropSet, ok_caps: set) -> dict:
58
+ """Return {area: {vbo_bytes_bound_derived, ibo_..., ubo_...}}.
59
+
60
+ Buffer used_as_* flags are unpopulated (parser limitation).
61
+ Derive via joins:
62
+ vbo_bytes = sum allocated_size_bytes of distinct buffer_ids in vertex_inputs
63
+ ibo_bytes = sum allocated_size_bytes of distinct ibo_ids in draws
64
+ ubo_bytes = sum allocated_size_bytes of distinct resource_ids in
65
+ draw_bindings where slot_kind='ubo'
66
+ """
67
+ out: dict = defaultdict(lambda: defaultdict(int))
68
+
69
+ for r in drop.rows:
70
+ ao = r.drop_dir
71
+
72
+ bufs_p = os.path.join(ao, 'buffers.parquet')
73
+ if not os.path.exists(bufs_p):
74
+ continue
75
+ try:
76
+ bt = papq.read_table(bufs_p,
77
+ columns=['capture', 'buffer_id', 'allocated_size_bytes'])
78
+ except Exception:
79
+ continue
80
+ bc = {n: bt.column(n).to_pylist() for n in bt.column_names}
81
+ size_by: dict[tuple, int] = {}
82
+ for i in range(bt.num_rows):
83
+ size_by[(bc['capture'][i], bc['buffer_id'][i])] = bc['allocated_size_bytes'][i] or 0
84
+
85
+ # vbo via vertex_inputs
86
+ vi_p = os.path.join(ao, 'vertex_inputs.parquet')
87
+ if os.path.exists(vi_p):
88
+ try:
89
+ vi = papq.read_table(vi_p, columns=['capture', 'buffer_id'])
90
+ except Exception:
91
+ vi = None
92
+ if vi is not None:
93
+ seen: set = set()
94
+ vc = {n: vi.column(n).to_pylist() for n in vi.column_names}
95
+ for i in range(vi.num_rows):
96
+ cap = vc['capture'][i]
97
+ bid = vc['buffer_id'][i]
98
+ if not bid:
99
+ continue
100
+ key = (r.area, r.drop_date, r.drop_label, cap)
101
+ if ok_caps and key not in ok_caps:
102
+ continue
103
+ pk = (r.area, cap, bid)
104
+ if pk in seen:
105
+ continue
106
+ seen.add(pk)
107
+ out[r.area]['vbo_bytes_bound_derived'] += size_by.get((cap, bid), 0)
108
+
109
+ # ibo via draws.ibo_id
110
+ draws_p = os.path.join(ao, 'draws.parquet')
111
+ if os.path.exists(draws_p):
112
+ try:
113
+ schema_cols = set(papq.read_schema(draws_p).names)
114
+ want = ['capture', 'ibo_id'] if 'ibo_id' in schema_cols else None
115
+ if want:
116
+ dt = papq.read_table(draws_p, columns=want)
117
+ else:
118
+ dt = None
119
+ except Exception:
120
+ dt = None
121
+ if dt is not None:
122
+ seen = set()
123
+ dc = {n: dt.column(n).to_pylist() for n in dt.column_names}
124
+ for i in range(dt.num_rows):
125
+ cap = dc['capture'][i]
126
+ ibo = dc['ibo_id'][i]
127
+ if not ibo:
128
+ continue
129
+ key = (r.area, r.drop_date, r.drop_label, cap)
130
+ if ok_caps and key not in ok_caps:
131
+ continue
132
+ pk = (r.area, cap, ibo)
133
+ if pk in seen:
134
+ continue
135
+ seen.add(pk)
136
+ out[r.area]['ibo_bytes_bound_derived'] += size_by.get((cap, ibo), 0)
137
+
138
+ # ubo via draw_bindings where slot_kind='ubo'
139
+ db_p = os.path.join(ao, 'draw_bindings.parquet')
140
+ if os.path.exists(db_p):
141
+ try:
142
+ db = papq.read_table(db_p,
143
+ columns=['capture', 'slot_kind', 'resource_id'])
144
+ except Exception:
145
+ db = None
146
+ if db is not None:
147
+ seen = set()
148
+ dbc = {n: db.column(n).to_pylist() for n in db.column_names}
149
+ for i in range(db.num_rows):
150
+ if dbc['slot_kind'][i] != 'ubo':
151
+ continue
152
+ cap = dbc['capture'][i]
153
+ rid = dbc['resource_id'][i]
154
+ if not rid:
155
+ continue
156
+ key = (r.area, r.drop_date, r.drop_label, cap)
157
+ if ok_caps and key not in ok_caps:
158
+ continue
159
+ pk = (r.area, cap, rid)
160
+ if pk in seen:
161
+ continue
162
+ seen.add(pk)
163
+ out[r.area]['ubo_bytes_bound_derived'] += size_by.get((cap, rid), 0)
164
+
165
+ return out
166
+
167
+
168
+ def _aggregate_class_counts(drop: base.DropSet, ok_caps: set) -> dict:
169
+ """Return {area: {draw_class: n_draws}}."""
170
+ out: dict = defaultdict(lambda: defaultdict(int))
171
+ for r in drop.rows:
172
+ p = os.path.join(r.drop_dir, 'pass_class_breakdown.parquet')
173
+ if not os.path.exists(p):
174
+ continue
175
+ try:
176
+ t = papq.read_table(p, columns=['capture', 'draw_class', 'n_draws'])
177
+ except Exception:
178
+ continue
179
+ c = {n: t.column(n).to_pylist() for n in t.column_names}
180
+ for i in range(t.num_rows):
181
+ cap = c['capture'][i]
182
+ key = (r.area, r.drop_date, r.drop_label, cap)
183
+ if ok_caps and key not in ok_caps:
184
+ continue
185
+ cls = c['draw_class'][i] or 'other'
186
+ out[r.area][cls] += c['n_draws'][i] or 0
187
+ return out
188
+
189
+
190
+ def _device_string(drop: base.DropSet) -> str:
191
+ for r in drop.rows:
192
+ p = os.path.join(r.drop_dir, 'frame_metadata.jsonl')
193
+ if not os.path.exists(p):
194
+ continue
195
+ try:
196
+ with open(p, 'r', encoding='utf-8') as f:
197
+ for line in f:
198
+ try:
199
+ o = json.loads(line)
200
+ except json.JSONDecodeError:
201
+ continue
202
+ s = o.get('gl_renderer_string') or o.get('gl_renderer') or ''
203
+ if s:
204
+ return s
205
+ except OSError:
206
+ continue
207
+ return ''
208
+
209
+
210
+ def _kpi_matrix(kpi: str, label: str, fmt: str, lower_is_better, threshold,
211
+ per_drop_area_data: list, areas: list, drops: list) -> str:
212
+ """Render one KPI matrix as h2 + table-wrap (one per KPI when n_drops >= 2)."""
213
+ is_int = kpi in _INT_KPIS
214
+
215
+ parts = []
216
+ parts.append(f'<rdc-sticky-h2><h2 id="{base.h(kpi)}">{base.h(label)}</h2></rdc-sticky-h2>')
217
+ parts.append('<div class="table-wrap"><rdc-sortable-table>')
218
+ parts.append('<table class="report"><thead><tr>')
219
+ parts.append('<th>area</th>')
220
+ n_drops = len(drops)
221
+ for i, d in enumerate(drops):
222
+ parts.append(f'<th class="num">{base.h(d.key)}</th>')
223
+ if i > 0:
224
+ latest_cls = ' delta-latest' if i == n_drops - 1 else ''
225
+ parts.append(f'<th class="num{latest_cls}">delta</th>')
226
+ if n_drops >= 3:
227
+ parts.append('<th class="num">trend</th>')
228
+ parts.append('</tr></thead><tbody>')
229
+
230
+ for area in areas:
231
+ parts.append('<tr>')
232
+ parts.append(f'<td>{base.h(area)}</td>')
233
+ series: list = []
234
+ prev = None
235
+ for i, d in enumerate(drops):
236
+ v = per_drop_area_data[i].get(area, {}).get(kpi)
237
+ series.append(v)
238
+ if is_int:
239
+ parts.append(f'<td class="num">{base.fmt_int(v)}</td>')
240
+ else:
241
+ parts.append(f'<td class="num">{base.fmt_float(v, 3)}</td>')
242
+ if i > 0:
243
+ cell = base.delta_cell(
244
+ v, prev,
245
+ lower_is_better=lower_is_better,
246
+ fmt=fmt,
247
+ regression_threshold_pct=threshold,
248
+ )
249
+ if i == n_drops - 1:
250
+ cell = cell.replace('class="delta', 'class="delta delta-latest', 1)
251
+ parts.append(cell)
252
+ prev = v
253
+ if n_drops >= 3:
254
+ parts.append(f'<td class="num">{base.sparkline_svg(series)}</td>')
255
+ parts.append('</tr>')
256
+ parts.append('</tbody></table></rdc-sortable-table></div>')
257
+ return '\n'.join(parts)
258
+
259
+
260
+ def _single_drop_matrix(per_drop_ft: list, areas: list, drops: list) -> str:
261
+ """Render single wide matrix (rows=area, cols=KPI) when n_drops==1."""
262
+ parts = []
263
+ parts.append('<h2 id="matrix">per-area kpi matrix</h2>')
264
+ parts.append('<div class="table-wrap"><rdc-sortable-table>')
265
+ parts.append('<table class="report"><thead><tr>')
266
+ parts.append('<th>area</th>')
267
+ for kpi, label, *_ in KPIS:
268
+ parts.append(f'<th class="num">{base.h(label)}</th>')
269
+ parts.append('</tr></thead><tbody>')
270
+ data = per_drop_ft[0]
271
+ # Precompute per-column max for heatmap normalization
272
+ col_max: dict = {}
273
+ for kpi, _, _, _, _ in KPIS:
274
+ vals = [float(data.get(a, {}).get(kpi) or 0) for a in areas]
275
+ col_max[kpi] = max(vals) if vals else 0.0
276
+ for area in areas:
277
+ parts.append('<tr>')
278
+ parts.append(f'<td>{base.h(area)}</td>')
279
+ for kpi, _, _, _, _ in KPIS:
280
+ v = data.get(area, {}).get(kpi)
281
+ cmax = col_max[kpi]
282
+ if kpi in _INT_KPIS:
283
+ val_str = base.fmt_int(v)
284
+ else:
285
+ val_str = base.fmt_float(v, 3)
286
+ if v is not None and cmax > 0:
287
+ parts.append(
288
+ f'<td class="num"><rdc-heatmap-cell data-value="{v}" '
289
+ f'data-min="0" data-max="{cmax}" data-direction="hot">'
290
+ f'{val_str}</rdc-heatmap-cell></td>'
291
+ )
292
+ else:
293
+ parts.append(f'<td class="num">{val_str}</td>')
294
+ parts.append('</tr>')
295
+ parts.append('</tbody></table></rdc-sortable-table></div>')
296
+ return '\n'.join(parts)
297
+
298
+
299
+ def _class_count_matrix(per_drop_area_class: list, areas: list,
300
+ drops: list) -> str:
301
+ parts = []
302
+ parts.append('<rdc-sticky-h2><h2 id="class_counts">draws by class</h2></rdc-sticky-h2>')
303
+ parts.append('<div class="table-wrap"><rdc-sortable-table>')
304
+ parts.append('<table class="report"><thead><tr>')
305
+ parts.append('<th>area</th>')
306
+ single = len(drops) == 1
307
+ for d in drops:
308
+ for cls in base.DRAW_CLASSES:
309
+ head = base.h(cls) if single else f'{base.h(d.key)}/{base.h(cls)}'
310
+ parts.append(f'<th class="num">{head}</th>')
311
+ parts.append('</tr></thead><tbody>')
312
+ for area in areas:
313
+ parts.append('<tr>')
314
+ parts.append(f'<td>{base.h(area)}</td>')
315
+ for i in range(len(drops)):
316
+ cc = per_drop_area_class[i].get(area, {})
317
+ for cls in base.DRAW_CLASSES:
318
+ parts.append(f'<td class="num">{base.fmt_int(cc.get(cls, 0))}</td>')
319
+ parts.append('</tr>')
320
+ parts.append('</tbody></table></rdc-sortable-table></div>')
321
+ return '\n'.join(parts)
322
+
323
+
324
+ def build(root: str, *, drops: list | None = None, ab=None) -> str:
325
+ if drops is None:
326
+ drops = base.discover_drops(root)
327
+ if not drops:
328
+ out_path = base.output_path(root, 'trend_table', ab)
329
+ with open(out_path, 'w', encoding='utf-8') as f:
330
+ f.write(base.page_open('trend table'))
331
+ f.write(base.header('trend table', drops=0, captures=0,
332
+ build_ts=base.now_iso()))
333
+ f.write('<p class="note">no drops found in catalog</p>')
334
+ f.write(base.page_close())
335
+ base._lint_or_raise(out_path)
336
+ return out_path
337
+
338
+ ok_caps = base.ok_capture_set(root)
339
+
340
+ per_drop_ft = []
341
+ per_drop_bytes = []
342
+ per_drop_class = []
343
+ device_strings = []
344
+ for d in drops:
345
+ ft = _aggregate_frame_totals(d, ok_caps)
346
+ bb = _aggregate_buffer_bytes(d, ok_caps)
347
+ for area in list(ft.keys()) + list(bb.keys()):
348
+ ft.setdefault(area, {})
349
+ ft[area].update(bb.get(area, {}))
350
+ per_drop_ft.append(ft)
351
+ per_drop_bytes.append(bb)
352
+ per_drop_class.append(_aggregate_class_counts(d, ok_caps))
353
+ device_strings.append(_device_string(d))
354
+
355
+ all_areas = sorted({a for d in per_drop_ft for a in d.keys()})
356
+ drop_keys_l = [d.key for d in drops]
357
+
358
+ body_attrs = {'data-multi-section': 'true'} if len(drops) > 1 else None
359
+ parts = [base.page_open('trend table', hdr_offset_px=120, body_attrs=body_attrs)]
360
+ parts.append(base.header(
361
+ 'trend table',
362
+ drops=len(drops),
363
+ captures=sum(d.n_captures for d in drops),
364
+ build_ts=base.now_iso(),
365
+ crumb_depth=base.crumb_depth(ab),
366
+ ))
367
+ if ab is not None:
368
+ baseline, compare = ab
369
+ parts.append(base.ab_strip(
370
+ ab,
371
+ baseline_suffix=f' ({baseline.n_captures} captures)',
372
+ compare_suffix=f' ({compare.n_captures} captures)',
373
+ ))
374
+ parts.append(base.ab_picker_for(root, 'trend_table', ab=ab))
375
+
376
+ # Summary bar: worst KPI (n=1) or biggest regression (n>1)
377
+ if len(drops) == 1:
378
+ ft = per_drop_ft[0]
379
+ worst_area, worst_gpu = None, 0.0
380
+ for area, kpis in ft.items():
381
+ g = float(kpis.get('total_gpu_duration_s', 0) or 0)
382
+ if g > worst_gpu:
383
+ worst_gpu, worst_area = g, area
384
+ if worst_area is not None:
385
+ parts.append(base.summary_bar(
386
+ 'worst gpu area',
387
+ worst_area,
388
+ sub=f'rank 1 of {len(all_areas)} areas',
389
+ tone='neutral',
390
+ ))
391
+ else:
392
+ worst_pct = 0.0
393
+ worst_tuple = None
394
+ for kpi, label, *_rest in KPIS:
395
+ for area in all_areas:
396
+ prev = None
397
+ for di, drop in enumerate(drops):
398
+ cur = per_drop_ft[di].get(area, {}).get(kpi)
399
+ if cur is None:
400
+ continue
401
+ if prev is not None and prev > 0:
402
+ pct = 100.0 * (float(cur) - float(prev)) / float(prev)
403
+ if pct > worst_pct:
404
+ worst_pct = pct
405
+ worst_tuple = (area, label, pct, drops[di-1].key, drop.key)
406
+ prev = cur
407
+ if worst_tuple is not None:
408
+ area, label, pct, prev_key, cur_key = worst_tuple
409
+ tone = 'alarm' if pct >= 10.0 else 'warn'
410
+ parts.append(base.summary_bar(
411
+ 'biggest regression',
412
+ f'{area} / {label} +{base.fmt_float(pct, 1)}%',
413
+ sub=f'{prev_key} to {cur_key}',
414
+ tone=tone,
415
+ ))
416
+
417
+ if any(device_strings):
418
+ chips = []
419
+ for d, dev in zip(drops, device_strings):
420
+ chips.append(f'{base.h(d.key)}: {base.safe_chrome_text(dev) or "no metadata"}')
421
+ parts.append(f'<div class="device-strip">{" | ".join(chips)}</div>')
422
+
423
+ if len(drops) == 1:
424
+ parts.append(_single_drop_matrix(per_drop_ft, all_areas, drops))
425
+ else:
426
+ parts.append('<nav class="toc">')
427
+ for kpi, label, *_ in KPIS:
428
+ parts.append(f'<a href="#{base.h(kpi)}" data-link-kind="crumb">{base.h(label)}</a>')
429
+ parts.append('<a href="#class_counts" data-link-kind="crumb">draws by class</a>')
430
+ parts.append('</nav>')
431
+ for kpi, label, fmt, lib, thr in KPIS:
432
+ parts.append(_kpi_matrix(kpi, label, fmt, lib, thr,
433
+ per_drop_ft, all_areas, drops))
434
+
435
+ parts.append(_class_count_matrix(per_drop_class, all_areas, drops))
436
+
437
+ parts.append(base.page_close())
438
+
439
+ out_path = base.output_path(root, 'trend_table', ab)
440
+ return base.write_report(out_path, parts)
441
+
442
+
443
+ if __name__ == '__main__':
444
+ sys.exit(base.run_report(build, module_name='trend_table'))
@@ -0,0 +1,162 @@
1
+ """Build _resource_labels.json sidecar from per-capture stage labels + Parquet.
2
+
3
+ The HTML browser reads this file and enriches ID columns at render time so
4
+ e.g. `tex_id=2184` shows as `2184 SceneDepthZ`.
5
+
6
+ Structure on disk:
7
+ {
8
+ "by_capture": {
9
+ "1": {
10
+ "texture": {"2184": "SceneDepthZ", ...},
11
+ "shader": {"2192": "compute 224B hash:39013910"},
12
+ "program": {"2193": "vs:2192 fs:0"},
13
+ "sampler": {"116": "..."},
14
+ "fbo": {"...": "..."},
15
+ "buffer": {"...": "..."}
16
+ }
17
+ }
18
+ }
19
+
20
+ `build_from_stage` runs at merge time when _stage/<cap>/labels.json + the
21
+ already-merged Parquet files are both present.
22
+
23
+ `build_from_outdir` rebuilds from an existing _analysis_out/ (when no
24
+ stage is available, e.g. --render-only mode).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ from typing import Iterable
32
+
33
+ import pyarrow.parquet as papq
34
+
35
+
36
+ def _short_name_for_shader(shader_row: dict) -> str:
37
+ stype = shader_row.get('shader_type', '') or ''
38
+ src_len = int(shader_row.get('src_len', 0) or 0)
39
+ h = shader_row.get('src_hash', '') or ''
40
+ samples = int(shader_row.get('total_texture_samples', 0) or 0)
41
+ branches = int(shader_row.get('total_branches', 0) or 0)
42
+ loops = int(shader_row.get('total_loops', 0) or 0)
43
+ bits = []
44
+ if stype: bits.append(stype)
45
+ if src_len: bits.append(f'{src_len}B')
46
+ if h: bits.append(f'h:{h[:8]}')
47
+ if samples: bits.append(f'tex={samples}')
48
+ if branches: bits.append(f'br={branches}')
49
+ if loops: bits.append(f'lo={loops}')
50
+ return ' '.join(bits)
51
+
52
+
53
+ def _short_name_for_program(prog_row: dict, shader_names: dict[int, str]) -> str:
54
+ vs = int(prog_row.get('vs_shader_id', 0) or 0)
55
+ fs = int(prog_row.get('fs_shader_id', 0) or 0)
56
+ cs = int(prog_row.get('cs_shader_id', 0) or 0)
57
+ bits = []
58
+ if vs: bits.append(f'vs:{vs}')
59
+ if fs: bits.append(f'fs:{fs}')
60
+ if cs: bits.append(f'cs:{cs}')
61
+ n_uniforms = int(prog_row.get('num_active_uniforms', 0) or 0)
62
+ if n_uniforms: bits.append(f'u={n_uniforms}')
63
+ return ' '.join(bits)
64
+
65
+
66
+ def _per_capture_from_parquet(out_dir: str) -> dict[str, dict[str, dict[str, str]]]:
67
+ """Read the drop's Parquet files and build per-capture label maps.
68
+
69
+ Pulls glObjectLabel-supplied names from textures/programs/samplers/fbos
70
+ columns, and synthesizes names for shaders + programs without labels.
71
+ """
72
+ by_cap: dict[str, dict[str, dict[str, str]]] = {}
73
+
74
+ def _bag(cap: str) -> dict[str, dict[str, str]]:
75
+ return by_cap.setdefault(cap, {
76
+ 'texture': {}, 'shader': {}, 'program': {},
77
+ 'sampler': {}, 'fbo': {}, 'buffer': {},
78
+ })
79
+
80
+ def _read(table: str, id_col: str, label_col: str, kind: str) -> None:
81
+ p = os.path.join(out_dir, f'{table}.parquet')
82
+ if not os.path.exists(p):
83
+ return
84
+ cols = list(papq.read_schema(p).names)
85
+ want = ['capture', id_col]
86
+ if label_col in cols:
87
+ want.append(label_col)
88
+ try:
89
+ t = papq.read_table(p, columns=want)
90
+ except Exception:
91
+ return
92
+ cap_arr = t.column('capture').to_pylist()
93
+ id_arr = t.column(id_col).to_pylist()
94
+ lab_arr = t.column(label_col).to_pylist() if label_col in cols else [''] * t.num_rows
95
+ for c, i, l in zip(cap_arr, id_arr, lab_arr):
96
+ if not i:
97
+ continue
98
+ sid = str(i)
99
+ if l:
100
+ _bag(c)[kind][sid] = l
101
+
102
+ _read('textures', 'tex_id', 'label', 'texture')
103
+ _read('render_targets', 'rt_id', 'label', 'texture') # RTs go into texture bucket
104
+ _read('programs', 'program_id', 'label', 'program')
105
+ _read('samplers', 'sampler_id', 'label', 'sampler')
106
+ _read('fbos', 'fbo_id', 'label', 'fbo')
107
+
108
+ # Synthesize shader names from shaders.parquet
109
+ sh_path = os.path.join(out_dir, 'shaders.parquet')
110
+ if os.path.exists(sh_path):
111
+ t = papq.read_table(sh_path)
112
+ cols = t.column_names
113
+ idx = {c: t.column(c).to_pylist() for c in cols}
114
+ n = t.num_rows
115
+ for i in range(n):
116
+ cap = idx['capture'][i]
117
+ sid = idx['shader_id'][i]
118
+ if not sid:
119
+ continue
120
+ row = {c: idx[c][i] for c in cols}
121
+ name = _short_name_for_shader(row)
122
+ if name:
123
+ _bag(cap)['shader'][str(sid)] = name
124
+
125
+ # Synthesize program "name" (vs:N fs:M) if no glObjectLabel name set
126
+ pr_path = os.path.join(out_dir, 'programs.parquet')
127
+ if os.path.exists(pr_path):
128
+ t = papq.read_table(pr_path)
129
+ cols = t.column_names
130
+ idx = {c: t.column(c).to_pylist() for c in cols}
131
+ n = t.num_rows
132
+ for i in range(n):
133
+ cap = idx['capture'][i]
134
+ pid = idx['program_id'][i]
135
+ if not pid:
136
+ continue
137
+ existing = _bag(cap)['program'].get(str(pid), '')
138
+ if existing:
139
+ continue
140
+ row = {c: idx[c][i] for c in cols}
141
+ sname = _short_name_for_program(row, {})
142
+ if sname:
143
+ _bag(cap)['program'][str(pid)] = sname
144
+
145
+ return by_cap
146
+
147
+
148
+ def write_resource_labels(out_dir: str) -> str:
149
+ """Read _analysis_out/*.parquet, produce _resource_labels.json sidecar."""
150
+ by_cap = _per_capture_from_parquet(out_dir)
151
+ path = os.path.join(out_dir, '_resource_labels.json')
152
+ with open(path, 'w', encoding='utf-8') as f:
153
+ json.dump({'by_capture': by_cap}, f, separators=(',', ':'))
154
+ return path
155
+
156
+
157
+ if __name__ == '__main__':
158
+ import sys
159
+ p = sys.argv[1] if len(sys.argv) > 1 else '.'
160
+ out = write_resource_labels(p)
161
+ sz = os.path.getsize(out)
162
+ print(f'wrote {out} ({sz} bytes)')