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
File without changes
@@ -0,0 +1,425 @@
1
+ """Cumulative dashboard: hero strip + card grid of report summaries.
2
+
3
+ Lives at <root>/_reports/index.html. Linked from root index.html.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import statistics
10
+ import sys
11
+ from collections import Counter, defaultdict
12
+
13
+ import pyarrow.parquet as papq
14
+
15
+ from . import base
16
+
17
+
18
+ def _top_meshes(root: str, drops: list, n: int = 3) -> list:
19
+ """Return [(label, repeat, indices_med)] where label is a human-readable synthetic."""
20
+ cache = base.cache_path(root, 'draws_summary')
21
+ if not os.path.exists(cache):
22
+ return []
23
+ try:
24
+ t = papq.read_table(cache, columns=[
25
+ 'mesh_hash', 'num_indices', 'program_id',
26
+ 'draw_class', 'parent_pass_path_norm'])
27
+ except Exception:
28
+ return []
29
+ cols = {c: t.column(c).to_pylist() for c in t.column_names}
30
+ counts: Counter = Counter()
31
+ indices: dict = defaultdict(list)
32
+ cls_by_mesh: dict = {}
33
+ pass_by_mesh: dict = {}
34
+ for i in range(t.num_rows):
35
+ mh = cols['mesh_hash'][i]
36
+ prog = cols['program_id'][i] or 0
37
+ n_idx = cols['num_indices'][i] or 0
38
+ if not mh or n_idx <= 0 or prog == 0:
39
+ continue
40
+ counts[mh] += 1
41
+ indices[mh].append(n_idx)
42
+ cls_by_mesh.setdefault(mh, cols['draw_class'][i] or 'other')
43
+ pass_by_mesh.setdefault(mh, cols['parent_pass_path_norm'][i] or '')
44
+ out = []
45
+ for mh, c in counts.most_common(n):
46
+ try:
47
+ med = int(statistics.median(indices[mh])) if indices[mh] else 0
48
+ except statistics.StatisticsError:
49
+ med = 0
50
+ cls = cls_by_mesh.get(mh, 'other')
51
+ suffix = base.pass_suffix(pass_by_mesh.get(mh, '')) or '?'
52
+ hash_tag = str(mh)[-4:] if mh else ''
53
+ label = f'{cls}/{suffix}/{med}v#{hash_tag}'
54
+ out.append((label, c, med))
55
+ return out
56
+
57
+
58
+ def _top_passes(drops: list, n: int = 3) -> list:
59
+ """Return [(area, pass_label, gpu_s)] where pass_label is the suffix only."""
60
+ agg: dict = defaultdict(float)
61
+ for d in drops:
62
+ for r in d.rows:
63
+ p = os.path.join(r.drop_dir, 'pass_class_breakdown.parquet')
64
+ if not os.path.exists(p):
65
+ continue
66
+ try:
67
+ t = papq.read_table(p, columns=['area', 'marker_path_norm',
68
+ 'sum_gpu_duration_s'])
69
+ except Exception:
70
+ continue
71
+ cols = {c: t.column(c).to_pylist() for c in t.column_names}
72
+ for i in range(t.num_rows):
73
+ key = (cols['area'][i], cols['marker_path_norm'][i] or '')
74
+ agg[key] += cols['sum_gpu_duration_s'][i] or 0.0
75
+ ranked = sorted(agg.items(), key=lambda kv: kv[1], reverse=True)[:n]
76
+ return [(a, base.pass_suffix(m) or m, g) for (a, m), g in ranked]
77
+
78
+
79
+ def _top_shaders(root: str, n: int = 3) -> list:
80
+ """Return [(label, complexity, cost_proxy)] where label is `frag-cplx-{int(cplx)}`."""
81
+ cache = base.cache_path(root, 'shader_summary')
82
+ if not os.path.exists(cache):
83
+ return []
84
+ try:
85
+ t = papq.read_table(cache,
86
+ columns=['stable_key', 'shader_type', 'complexity_score',
87
+ 'used_by_draw_count'])
88
+ except Exception:
89
+ return []
90
+ cols = {c: t.column(c).to_pylist() for c in t.column_names}
91
+ cost: dict = defaultdict(float)
92
+ cplx: dict = {}
93
+ stype: dict = {}
94
+ for i in range(t.num_rows):
95
+ if cols['shader_type'][i] != 'fragment':
96
+ continue
97
+ sk = cols['stable_key'][i] or ''
98
+ if not sk:
99
+ continue
100
+ c_val = float(cols['complexity_score'][i] or 0)
101
+ uses = int(cols['used_by_draw_count'][i] or 0)
102
+ cost[sk] += c_val * uses
103
+ cplx[sk] = max(cplx.get(sk, 0), c_val)
104
+ stype[sk] = cols['shader_type'][i]
105
+ ranked = sorted(cost.items(), key=lambda kv: kv[1], reverse=True)[:n]
106
+ out = []
107
+ for sk, c in ranked:
108
+ cval = cplx[sk]
109
+ label = f'{stype[sk][:4]}-cplx-{int(cval)}'
110
+ out.append((label, cval, c))
111
+ return out
112
+
113
+
114
+ def _per_area_draws(drops: list) -> dict:
115
+ """Return {area: {n_draws, dominant_class}}."""
116
+ per: dict = defaultdict(lambda: {'n_draws': 0, 'by_class': Counter()})
117
+ for d in drops:
118
+ for r in d.rows:
119
+ p = os.path.join(r.drop_dir, 'pass_class_breakdown.parquet')
120
+ if not os.path.exists(p):
121
+ continue
122
+ try:
123
+ t = papq.read_table(p, columns=['area', 'draw_class', 'n_draws'])
124
+ except Exception:
125
+ continue
126
+ cols = {c: t.column(c).to_pylist() for c in t.column_names}
127
+ for i in range(t.num_rows):
128
+ a = cols['area'][i]
129
+ cls = cols['draw_class'][i] or 'other'
130
+ n = cols['n_draws'][i] or 0
131
+ per[a]['n_draws'] += n
132
+ per[a]['by_class'][cls] += n
133
+ res: dict = {}
134
+ for a, v in per.items():
135
+ dom = v['by_class'].most_common(1)[0][0] if v['by_class'] else '-'
136
+ res[a] = {'n_draws': v['n_draws'], 'dominant_class': dom}
137
+ return res
138
+
139
+
140
+ def _top_areas_gpu(drops: list, n: int = 3) -> list:
141
+ """Return [(area, gpu_s, draws)] top by gpu."""
142
+ agg: dict = defaultdict(lambda: {'gpu': 0.0, 'draws': 0})
143
+ for d in drops:
144
+ for r in d.rows:
145
+ p = os.path.join(r.drop_dir, 'frame_totals.parquet')
146
+ if not os.path.exists(p):
147
+ continue
148
+ try:
149
+ t = papq.read_table(p, columns=['total_gpu_duration_s', 'n_draws'])
150
+ except Exception:
151
+ continue
152
+ gpu_vals = t.column('total_gpu_duration_s').to_pylist()
153
+ draw_vals = t.column('n_draws').to_pylist()
154
+ for g, dr in zip(gpu_vals, draw_vals):
155
+ agg[r.area]['gpu'] += float(g or 0)
156
+ agg[r.area]['draws'] += int(dr or 0)
157
+ rows = [(a, v['gpu'], v['draws']) for a, v in agg.items()]
158
+ rows.sort(key=lambda x: x[1], reverse=True)
159
+ return rows[:n]
160
+
161
+
162
+ def _worst_overdraw(drops: list, n: int = 3) -> list:
163
+ """Return [(area, rt_label, reject_pct, n_samples)]. Rejection = 1 - passed%.
164
+
165
+ depth_test_failed is usually 0 in mobile (early-z handled by hardware);
166
+ real signal is shadow/backface/discard rejection ratio.
167
+ """
168
+ agg: dict = defaultdict(lambda: {'n_samples': 0, 'n_passed': 0,
169
+ 'drop_dir': '', 'capture': None})
170
+ for d in drops:
171
+ for r in d.rows:
172
+ p = os.path.join(r.drop_dir, 'pixel_history.parquet')
173
+ if not os.path.exists(p):
174
+ continue
175
+ try:
176
+ t = papq.read_table(p,
177
+ columns=['area', 'rt_id', 'passed', 'capture'])
178
+ except Exception:
179
+ continue
180
+ if t.num_rows == 0:
181
+ continue
182
+ cols = {c: t.column(c).to_pylist() for c in t.column_names}
183
+ for i in range(t.num_rows):
184
+ key = (cols['area'][i], cols['rt_id'][i])
185
+ agg[key]['n_samples'] += 1
186
+ if cols['passed'][i]:
187
+ agg[key]['n_passed'] += 1
188
+ if not agg[key]['drop_dir']:
189
+ agg[key]['drop_dir'] = r.drop_dir
190
+ agg[key]['capture'] = cols['capture'][i]
191
+ rows = []
192
+ for (area, rt_id), v in agg.items():
193
+ if v['n_samples'] < 20:
194
+ continue
195
+ reject_pct = (1.0 - v['n_passed'] / v['n_samples']) * 100.0
196
+ rt_label = base.label_for(v['drop_dir'], v['capture'], 'rt', rt_id) \
197
+ or f'rt_{rt_id}'
198
+ rows.append((area, rt_label, reject_pct, v['n_samples']))
199
+ rows.sort(key=lambda x: x[2], reverse=True)
200
+ return rows[:n]
201
+
202
+
203
+ def _global_kpis(drops: list) -> list:
204
+ """Cheap-to-compute global numbers from frame_totals across drops."""
205
+ total_gpu = 0.0
206
+ total_draws = 0
207
+ captures = 0
208
+ areas: set = set()
209
+ for d in drops:
210
+ captures += d.n_captures
211
+ areas.update(d.areas)
212
+ for r in d.rows:
213
+ p = os.path.join(r.drop_dir, 'frame_totals.parquet')
214
+ if not os.path.exists(p):
215
+ continue
216
+ try:
217
+ t = papq.read_table(p, columns=['total_gpu_duration_s', 'n_draws'])
218
+ except Exception:
219
+ continue
220
+ for v in t.column('total_gpu_duration_s').to_pylist():
221
+ if v is not None:
222
+ total_gpu += float(v)
223
+ for v in t.column('n_draws').to_pylist():
224
+ if v is not None:
225
+ total_draws += int(v)
226
+ return [
227
+ {'label': 'total gpu (s)', 'value': base.fmt_float(total_gpu, 3)},
228
+ {'label': 'total draws', 'value': base.fmt_int(total_draws)},
229
+ {'label': 'areas', 'value': base.fmt_int(len(areas))},
230
+ ]
231
+
232
+
233
+ def _card_table(rows: list, columns: list) -> str:
234
+ parts = ['<table class="report"><thead><tr>']
235
+ for col_name, _, num in columns:
236
+ cls = ' class="num"' if num else ''
237
+ parts.append(f'<th{cls}>{base.h(col_name)}</th>')
238
+ parts.append('</tr></thead><tbody>')
239
+ for row in rows:
240
+ parts.append('<tr>')
241
+ for col_name, fn, num in columns:
242
+ cls = ' class="num"' if num else ''
243
+ val = fn(row)
244
+ parts.append(f'<td{cls}>{val}</td>')
245
+ parts.append('</tr>')
246
+ parts.append('</tbody></table>')
247
+ return ''.join(parts)
248
+
249
+
250
+ def build(root: str, *, drops: list | None = None, ab=None) -> str:
251
+ if drops is None:
252
+ drops = base.discover_drops(root)
253
+
254
+ out_dir = os.path.join(root, '_reports')
255
+ os.makedirs(out_dir, exist_ok=True)
256
+ out_path = os.path.join(out_dir, 'index.html')
257
+
258
+ parts = [base.page_open('reports dashboard', hdr_offset_px=120)]
259
+ parts.append(base.header(
260
+ 'reports dashboard',
261
+ drops=len(drops),
262
+ captures=sum(d.n_captures for d in drops),
263
+ build_ts=base.now_iso(),
264
+ crumb_depth=1,
265
+ current_page='dashboard',
266
+ ))
267
+
268
+ # Summary bar: worst area by GPU rank + global counts
269
+ cards = []
270
+
271
+ top_a = _top_areas_gpu(drops, n=999)
272
+ n_areas = len(top_a)
273
+ total_draws = sum(t[2] for t in top_a)
274
+ if top_a:
275
+ worst_area, worst_gpu, worst_draws = top_a[0]
276
+ parts.append(base.summary_bar(
277
+ 'worst gpu area',
278
+ worst_area,
279
+ sub=(f'rank 1 of {n_areas} areas; this area {base.fmt_int(worst_draws)} draws; '
280
+ f'all areas {base.fmt_int(total_draws)} draws'),
281
+ link_href=f'trend_table.html#gpu',
282
+ link_text='trend',
283
+ tone='neutral',
284
+ ))
285
+ top_a = top_a[:3]
286
+ body_tt = _card_table(
287
+ top_a,
288
+ [
289
+ ('area', lambda r: base.h(r[0]), False),
290
+ ('gpu (s)', lambda r: base.fmt_float(r[1], 3), True),
291
+ ('draws', lambda r: base.fmt_int(r[2]), True),
292
+ ]
293
+ )
294
+ cards.append(
295
+ '<a class="dash-card" href="trend_table.html">'
296
+ '<h3>trend table</h3>'
297
+ f'{body_tt}'
298
+ '</a>'
299
+ )
300
+
301
+ # Card: instancing
302
+ top_m = _top_meshes(root, drops)
303
+ body_im = _card_table(
304
+ top_m,
305
+ [
306
+ ('mesh', lambda r: base.h(r[0]), False),
307
+ ('repeat', lambda r: base.fmt_int(r[1]), True),
308
+ ('indices typ', lambda r: base.fmt_int(r[2]), True),
309
+ ]
310
+ )
311
+ cards.append(
312
+ '<a class="dash-card" href="instancing_opportunities.html">'
313
+ '<h3>instancing opportunities</h3>'
314
+ f'{body_im}'
315
+ '</a>'
316
+ )
317
+
318
+ # Card: pass gpu
319
+ top_p = _top_passes(drops)
320
+ body_pg = _card_table(
321
+ top_p,
322
+ [
323
+ ('area', lambda r: base.h(r[0]), False),
324
+ ('marker', lambda r: base.safe_chrome_text(base.trunc_left(r[1], 32)), False),
325
+ ('gpu (s)', lambda r: base.fmt_float(r[2], 3), True),
326
+ ]
327
+ )
328
+ cards.append(
329
+ '<a class="dash-card" href="pass_gpu.html">'
330
+ '<h3>pass gpu</h3>'
331
+ f'{body_pg}'
332
+ '</a>'
333
+ )
334
+
335
+ # Card: shader hotlist
336
+ top_s = _top_shaders(root)
337
+ body_sh = _card_table(
338
+ top_s,
339
+ [
340
+ ('shader', lambda r: base.h(r[0]), False),
341
+ ('complexity', lambda r: base.fmt_float(r[1], 2), True),
342
+ ('cost proxy', lambda r: base.fmt_float(r[2], 1), True),
343
+ ]
344
+ )
345
+ cards.append(
346
+ '<a class="dash-card" href="shader_hotlist.html">'
347
+ '<h3>shader hotlist</h3>'
348
+ f'{body_sh}'
349
+ '</a>'
350
+ )
351
+
352
+ # Card: overdraw — by rejection ratio (1 - passed%)
353
+ wo = _worst_overdraw(drops)
354
+ body_od = _card_table(
355
+ wo,
356
+ [
357
+ ('area', lambda r: base.h(r[0]), False),
358
+ ('rt', lambda r: base.h(r[1]), False),
359
+ ('rejected %', lambda r: base.fmt_pct(r[2]), True),
360
+ ]
361
+ )
362
+ cards.append(
363
+ '<a class="dash-card" href="overdraw.html">'
364
+ '<h3>overdraw</h3>'
365
+ f'{body_od}'
366
+ '</a>'
367
+ )
368
+
369
+ # Card: draws by class — top 5 areas by draw count, dominant class
370
+ pa = _per_area_draws(drops)
371
+ pa_rows = sorted(pa.items(), key=lambda kv: kv[1]['n_draws'], reverse=True)[:5]
372
+ body_dc = _card_table(
373
+ pa_rows,
374
+ [
375
+ ('area', lambda r: base.h(r[0]), False),
376
+ ('draws', lambda r: base.fmt_int(r[1]['n_draws']), True),
377
+ ('dominant', lambda r: base.h(r[1]['dominant_class']), False),
378
+ ]
379
+ )
380
+ cards.append(
381
+ '<a class="dash-card" href="draws_by_class.html">'
382
+ '<h3>draws by class</h3>'
383
+ f'{body_dc}'
384
+ '</a>'
385
+ )
386
+
387
+ parts.append(
388
+ '<rdc-search-cards data-target=".dash-grid">'
389
+ '<label for="rdc-search">filter</label>'
390
+ '<input id="rdc-search" type="search" placeholder="filter cards">'
391
+ '<span class="rdc-count"></span>'
392
+ '</rdc-search-cards>'
393
+ )
394
+ parts.append(f'<div class="dash-grid">{"".join(cards)}</div>')
395
+
396
+ # A/B section
397
+ ab_root = os.path.join(out_dir, 'ab')
398
+ if os.path.isdir(ab_root):
399
+ ab_pairs = sorted(d for d in os.listdir(ab_root)
400
+ if os.path.isdir(os.path.join(ab_root, d)))
401
+ if ab_pairs:
402
+ parts.append(f'<h2 id="ab">a/b comparisons</h2>')
403
+ parts.append('<div class="pair-list">')
404
+ for pair in ab_pairs:
405
+ files = sorted(f for f in os.listdir(os.path.join(ab_root, pair))
406
+ if f.endswith('.html'))
407
+ chips = ''.join(
408
+ f'<a href="ab/{base.h(pair)}/{base.h(f)}" data-link-kind="primary">{base.h(f[:-5])}</a>'
409
+ for f in files
410
+ )
411
+ parts.append(
412
+ f'<div class="pair-group">'
413
+ f'<h3>{base.h(pair)}</h3>'
414
+ f'<div class="chip-cluster">{chips}</div>'
415
+ f'</div>'
416
+ )
417
+ parts.append('</div>')
418
+
419
+ parts.append(base.page_close())
420
+
421
+ return base.write_report(out_path, parts)
422
+
423
+
424
+ if __name__ == '__main__':
425
+ sys.exit(base.run_report(build, module_name='_dashboard'))
@@ -0,0 +1,88 @@
1
+ """Unified A/B entry point. Generates all 6 reports for one drop pair.
2
+
3
+ Usage:
4
+ python -m bobframes.reports.ab \\
5
+ --baseline-label r110565 \\
6
+ --compare-label r110600 \\
7
+ [--baseline-date 2026-05-27] \\
8
+ [--compare-date 2026-06-15] \\
9
+ [--root .]
10
+
11
+ Writes _reports/ab/<labelA>_vs_<labelB>/<name>.html for each report.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import os
18
+ import sys
19
+
20
+ from . import base
21
+ from . import draws_by_class as report_draws_by_class
22
+ from . import trend_table as report_trend
23
+ from . import instancing_opportunities as report_instancing
24
+ from . import pass_gpu as report_pass_gpu
25
+ from . import shader_hotlist as report_shader
26
+ from . import overdraw as report_overdraw
27
+
28
+
29
+ _MODULES = [
30
+ report_draws_by_class,
31
+ report_trend,
32
+ report_instancing,
33
+ report_pass_gpu,
34
+ report_shader,
35
+ report_overdraw,
36
+ ]
37
+
38
+
39
+ def main(argv: list[str]) -> int:
40
+ ap = argparse.ArgumentParser(prog='bobframes.reports.ab')
41
+ ap.add_argument('root', nargs='?', default='.')
42
+ # Hidden one-release alias for the old --root flag (positional is canonical, §4).
43
+ ap.add_argument('--root', dest='root', default=argparse.SUPPRESS, help=argparse.SUPPRESS)
44
+ ap.add_argument('--baseline-label', required=True)
45
+ ap.add_argument('--compare-label', required=True)
46
+ ap.add_argument('--baseline-date', default=None)
47
+ ap.add_argument('--compare-date', default=None)
48
+ args = ap.parse_args(argv)
49
+
50
+ root = os.path.abspath(args.root)
51
+ baseline = base.resolve_drop_set(root, label=args.baseline_label,
52
+ date=args.baseline_date)
53
+ compare = base.resolve_drop_set(root, label=args.compare_label,
54
+ date=args.compare_date)
55
+ if not baseline:
56
+ print(f'baseline not found: label={args.baseline_label}, '
57
+ f'date={args.baseline_date}', file=sys.stderr)
58
+ return 2
59
+ if not compare:
60
+ print(f'compare not found: label={args.compare_label}, '
61
+ f'date={args.compare_date}', file=sys.stderr)
62
+ return 2
63
+
64
+ print(f'a/b: {baseline.key} ({baseline.n_captures} captures) '
65
+ f'vs {compare.key} ({compare.n_captures} captures)')
66
+
67
+ drops = [baseline, compare]
68
+ ab = (baseline, compare)
69
+ for mod in _MODULES:
70
+ try:
71
+ out = mod.build(root, drops=drops, ab=ab)
72
+ print(f' wrote {out}')
73
+ except Exception as e:
74
+ print(f' {mod.__name__} FAILED: {e}', file=sys.stderr)
75
+ return 1
76
+ # Rebuild dashboard so its a/b table picks up new pair
77
+ from . import _dashboard as report_dashboard
78
+ try:
79
+ out = report_dashboard.build(root)
80
+ print(f' refreshed dashboard {out}')
81
+ except Exception as e:
82
+ print(f' dashboard refresh FAILED: {e}', file=sys.stderr)
83
+ return 1
84
+ return 0
85
+
86
+
87
+ if __name__ == '__main__':
88
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,114 @@
1
+ """Shared chrome for Layer 2 reports.
2
+
3
+ This module is now a thin facade re-exporting from the topic modules below.
4
+ Existing reports keep `from . import base` working unchanged.
5
+
6
+ Topic modules:
7
+ - chrome: CSS tokens, page open/close, header, KPI strip, section card, legend, footer, ab_strip
8
+ - formatters: fmt_int/float/pct/bytes/id_short, mesh_hash_short, trunc_mid/left, safe_chrome_text
9
+ - delta: delta_cell, delta_pill, rank_pill, inline_bar, class_segments_bar, sparkline_svg
10
+ - discovery: DropRow, DropSet, discover_drops, resolve_drop_set, ok_capture_set
11
+ - cache: load_global_entities, load_labels, label_for, cache_dir, cache_path, build_per_drop_cache
12
+ - cli: run_report, ab_subdir, output_path, now_iso, _lint_or_raise, write_report, crumb_depth, rel_path_to_drop_index, rel_path_to_drop_file
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .chrome import (
18
+ DRAW_CLASSES,
19
+ _FAVICON_HREF,
20
+ ab_picker,
21
+ ab_picker_for,
22
+ ab_strip,
23
+ chrome_css,
24
+ class_color_var,
25
+ components_js,
26
+ design_tokens_css,
27
+ footer_legend,
28
+ h,
29
+ header,
30
+ icon,
31
+ kpi_chip,
32
+ kpi_strip,
33
+ legend,
34
+ link,
35
+ page_close,
36
+ page_open,
37
+ section_card,
38
+ summary_bar,
39
+ )
40
+ from .formatters import (
41
+ _BANNED_CHROME_CHARS,
42
+ fmt_bytes,
43
+ fmt_float,
44
+ fmt_id_short,
45
+ fmt_int,
46
+ fmt_pct,
47
+ mesh_hash_short,
48
+ pass_short,
49
+ pass_suffix,
50
+ safe_chrome_text,
51
+ trunc_left,
52
+ trunc_mid,
53
+ )
54
+ from .delta import (
55
+ class_segments_bar,
56
+ delta_cell,
57
+ delta_pill,
58
+ inline_bar,
59
+ rank_pill,
60
+ sparkline_svg,
61
+ )
62
+ from .discovery import (
63
+ DropRow,
64
+ DropSet,
65
+ discover_drops,
66
+ ok_capture_set,
67
+ resolve_drop_set,
68
+ )
69
+ from .cache import (
70
+ _read_drop_parquet,
71
+ build_per_drop_cache,
72
+ cache_dir,
73
+ cache_path,
74
+ label_for,
75
+ load_global_entities,
76
+ load_labels,
77
+ )
78
+ from .cli import (
79
+ _lint_or_raise,
80
+ ab_subdir,
81
+ crumb_depth,
82
+ now_iso,
83
+ output_path,
84
+ rel_path_to_drop_file,
85
+ rel_path_to_drop_index,
86
+ run_report,
87
+ write_report,
88
+ )
89
+
90
+
91
+ __all__ = [
92
+ # chrome
93
+ 'DRAW_CLASSES', 'ab_picker', 'ab_picker_for', 'ab_strip',
94
+ 'chrome_css', 'class_color_var',
95
+ 'components_js', 'design_tokens_css', 'footer_legend', 'h', 'header',
96
+ 'icon', 'kpi_chip', 'kpi_strip', 'legend', 'link',
97
+ 'page_close', 'page_open', 'section_card', 'summary_bar',
98
+ # formatters
99
+ 'fmt_bytes', 'fmt_float', 'fmt_id_short', 'fmt_int', 'fmt_pct',
100
+ 'mesh_hash_short', 'pass_short', 'pass_suffix', 'safe_chrome_text',
101
+ 'trunc_left', 'trunc_mid',
102
+ # delta
103
+ 'class_segments_bar', 'delta_cell', 'delta_pill', 'inline_bar',
104
+ 'rank_pill', 'sparkline_svg',
105
+ # discovery
106
+ 'DropRow', 'DropSet', 'discover_drops', 'ok_capture_set', 'resolve_drop_set',
107
+ # cache
108
+ 'build_per_drop_cache', 'cache_dir', 'cache_path', 'label_for',
109
+ 'load_global_entities', 'load_labels',
110
+ # cli
111
+ '_lint_or_raise', 'ab_subdir', 'crumb_depth', 'now_iso',
112
+ 'output_path', 'rel_path_to_drop_file', 'rel_path_to_drop_index',
113
+ 'run_report', 'write_report',
114
+ ]