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,99 @@
1
+ """CLI dispatch + path utilities + report-tail helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from typing import Callable
9
+
10
+ from .. import lint as _lint
11
+ from .. import paths as _paths
12
+ from ..manifest import now_iso # single UTC timestamp helper (H-28); re-exported via base
13
+ from .discovery import DropSet, discover_drops, resolve_drop_set
14
+
15
+
16
+ def _lint_or_raise(path: str) -> None:
17
+ hits = _lint.lint_file(path)
18
+ if hits:
19
+ msg_lines = [f'lint blocked {path}:']
20
+ for lineno, label, snippet in hits:
21
+ msg_lines.append(f' line {lineno} [{label}]: {snippet}')
22
+ raise RuntimeError('\n'.join(msg_lines))
23
+
24
+
25
+ def ab_subdir(root: str, baseline: DropSet, compare: DropSet) -> str:
26
+ d = os.path.join(root, '_reports', 'ab', f'{baseline.key}_vs_{compare.key}')
27
+ os.makedirs(d, exist_ok=True)
28
+ return d
29
+
30
+
31
+ def output_path(root: str, name: str, ab: tuple | None) -> str:
32
+ if ab is None:
33
+ d = os.path.join(root, '_reports')
34
+ os.makedirs(d, exist_ok=True)
35
+ return os.path.join(d, f'{name}.html')
36
+ baseline, compare = ab
37
+ return os.path.join(ab_subdir(root, baseline, compare), f'{name}.html')
38
+
39
+
40
+ def crumb_depth(ab) -> int:
41
+ """Depth of crumb relative-path: 3 levels deep when A/B (under _reports/ab/<pair>/), 1 otherwise."""
42
+ return 3 if ab is not None else 1
43
+
44
+
45
+ def rel_path_to_drop_index(out_dir: str, drop_dir: str, anchor: str | None = None) -> str:
46
+ """Relative path from out_dir to the per-drop browser index.html.
47
+
48
+ drop_dir is the per-drop data dir (<root>/_data/<area>/<drop>/).
49
+ The browser HTML lives at <root>/_reports/drill/<area>/<drop>/index.html.
50
+ """
51
+ drill_dir = _paths.drop_dir_to_drill_dir(drop_dir)
52
+ target = os.path.join(drill_dir, 'index.html')
53
+ p = os.path.relpath(target, out_dir).replace('\\', '/')
54
+ return p + ('#' + anchor if anchor else '')
55
+
56
+
57
+ def rel_path_to_drop_file(out_dir: str, drop_dir: str, subpath: str) -> str:
58
+ """Relative path from out_dir to a file under the per-drop data dir.
59
+
60
+ drop_dir is <root>/_data/<area>/<drop>/. Used for shader_src/<id>.glsl etc.
61
+ """
62
+ if not subpath:
63
+ return ''
64
+ target = os.path.join(drop_dir, subpath)
65
+ return os.path.relpath(target, out_dir).replace('\\', '/')
66
+
67
+
68
+ def write_report(out_path: str, parts) -> str:
69
+ """Write joined parts to out_path, lint, return out_path. Raises on lint hits."""
70
+ with open(out_path, 'w', encoding='utf-8') as f:
71
+ f.write('\n'.join(parts))
72
+ _lint_or_raise(out_path)
73
+ return out_path
74
+
75
+
76
+ def run_report(build_fn: Callable, *, module_name: str) -> int:
77
+ ap = argparse.ArgumentParser(prog=f'bobframes.reports.{module_name}')
78
+ ap.add_argument('root', nargs='?', default='.')
79
+ ap.add_argument('--baseline-label', default=None)
80
+ ap.add_argument('--compare-label', default=None)
81
+ ap.add_argument('--baseline-date', default=None)
82
+ ap.add_argument('--compare-date', default=None)
83
+ args = ap.parse_args(sys.argv[1:])
84
+ root = os.path.abspath(args.root)
85
+
86
+ if args.baseline_label or args.compare_label:
87
+ baseline = resolve_drop_set(root, label=args.baseline_label,
88
+ date=args.baseline_date)
89
+ compare = resolve_drop_set(root, label=args.compare_label,
90
+ date=args.compare_date)
91
+ if not baseline or not compare:
92
+ print(f'A/B resolve failed: baseline={args.baseline_label}, '
93
+ f'compare={args.compare_label}', file=sys.stderr)
94
+ return 2
95
+ out = build_fn(root, drops=[baseline, compare], ab=(baseline, compare))
96
+ else:
97
+ out = build_fn(root, drops=discover_drops(root), ab=None)
98
+ print(f'wrote {out}')
99
+ return 0
@@ -0,0 +1,167 @@
1
+ """Comparison cells and visualization helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html as _html
6
+
7
+ from .chrome import DRAW_CLASSES, class_color_var, h
8
+
9
+
10
+ def rank_pill(n: int) -> str:
11
+ cls = f'rank rank-{n}' if 1 <= n <= 3 else 'rank'
12
+ return f'<span class="{cls}">{n}</span>'
13
+
14
+
15
+ def inline_bar(value, max_value, *, color_var: str | None = None) -> str:
16
+ """Render a 80x6 inline progress bar. Renders empty if value/max invalid."""
17
+ if max_value in (None, 0, 0.0):
18
+ return ''
19
+ try:
20
+ v = float(value or 0)
21
+ m = float(max_value)
22
+ except (TypeError, ValueError):
23
+ return ''
24
+ if m <= 0:
25
+ return ''
26
+ pct = max(0.0, min(100.0, (v / m) * 100.0))
27
+ style = f'width: {pct:.2f}%;'
28
+ if color_var:
29
+ style += f' background: {color_var};'
30
+ return f'<div class="ibar"><div style="{style}"></div></div>'
31
+
32
+
33
+ def delta_pill(curr, prev, *, lower_is_better: bool | None = True,
34
+ fmt: str = '{:+,.0f}') -> str:
35
+ """Same logic as delta_cell but renders <span class='delta-pill'>."""
36
+ if curr is None and prev is None:
37
+ return '<span class="delta-pill flat"></span>'
38
+ if prev is None:
39
+ return '<span class="delta-pill new">new</span>'
40
+ if curr is None:
41
+ curr = 0
42
+ try:
43
+ curr_f = float(curr)
44
+ prev_f = float(prev)
45
+ except (TypeError, ValueError):
46
+ return '<span class="delta-pill flat"></span>'
47
+ diff = curr_f - prev_f
48
+ if diff == 0:
49
+ return '<span class="delta-pill flat">0</span>'
50
+ if lower_is_better is None:
51
+ cls = 'flat'
52
+ elif lower_is_better:
53
+ cls = 'pos' if diff < 0 else 'neg'
54
+ else:
55
+ cls = 'pos' if diff > 0 else 'neg'
56
+ text = fmt.format(diff).replace('−', '-')
57
+ return f'<span class="delta-pill {cls}">{_html.escape(text)}</span>'
58
+
59
+
60
+ def delta_cell(curr, prev, *,
61
+ lower_is_better: bool | None = True,
62
+ fmt: str = '{:+,.0f}',
63
+ regression_threshold_pct: float | None = None) -> str:
64
+ """Render <td> for a per-KPI delta. ASCII signs only. CSS class encodes direction."""
65
+ if curr is None and prev is None:
66
+ return '<td class="delta flat"></td>'
67
+ if prev is None:
68
+ return '<td class="delta new">new</td>'
69
+ if curr is None:
70
+ curr = 0
71
+ try:
72
+ curr_f = float(curr)
73
+ prev_f = float(prev)
74
+ except (TypeError, ValueError):
75
+ return '<td class="delta flat"></td>'
76
+
77
+ diff = curr_f - prev_f
78
+ if diff == 0:
79
+ return '<td class="delta flat">0</td>'
80
+
81
+ if lower_is_better is None:
82
+ cls = 'flat'
83
+ elif lower_is_better:
84
+ cls = 'pos' if diff < 0 else 'neg'
85
+ else:
86
+ cls = 'pos' if diff > 0 else 'neg'
87
+
88
+ alarm = ''
89
+ if regression_threshold_pct is not None and prev_f != 0:
90
+ pct = abs(diff) / abs(prev_f) * 100.0
91
+ if pct > regression_threshold_pct:
92
+ alarm = ' alarm'
93
+
94
+ text = fmt.format(diff).replace('−', '-')
95
+ return f'<td class="delta {cls}{alarm}">{_html.escape(text)}</td>'
96
+
97
+
98
+ def class_segments_bar(weights: dict, total: float | None = None) -> str:
99
+ if total is None:
100
+ total = sum(weights.values())
101
+ try:
102
+ total_f = float(total)
103
+ except (TypeError, ValueError):
104
+ total_f = 0.0
105
+ if total_f <= 0:
106
+ return '<div class="bar"></div>'
107
+ parts = ['<div class="bar">']
108
+ for cls in DRAW_CLASSES:
109
+ n = weights.get(cls, 0)
110
+ try:
111
+ n_f = float(n)
112
+ except (TypeError, ValueError):
113
+ n_f = 0.0
114
+ if n_f <= 0:
115
+ continue
116
+ pct = n_f * 100.0 / total_f
117
+ label = ''
118
+ if pct >= 8.0:
119
+ if isinstance(n, float) and not n.is_integer():
120
+ label = f'{n_f:,.2f}'
121
+ else:
122
+ label = f'{int(n_f):,}'
123
+ parts.append(
124
+ f'<div class="seg" style="width: {pct:.4f}%; background: {class_color_var(cls)};" '
125
+ f'title="{h(cls)}: {label or n_f}">{h(label)}</div>'
126
+ )
127
+ parts.append('</div>')
128
+ return ''.join(parts)
129
+
130
+
131
+ def sparkline_svg(values: list, w: int = 60, h_: int = 14) -> str:
132
+ """Inline SVG polyline. None values render as segment breaks (multi-polyline)."""
133
+ if not values or all(v is None for v in values):
134
+ return ''
135
+ finite = [float(v) for v in values if v is not None]
136
+ if not finite:
137
+ return ''
138
+ lo = min(finite)
139
+ hi = max(finite)
140
+ span = hi - lo if hi != lo else 1.0
141
+ n = len(values)
142
+ if n < 2:
143
+ return ''
144
+ step = w / (n - 1)
145
+ segments: list[list[tuple[float, float]]] = []
146
+ current: list[tuple[float, float]] = []
147
+ for i, v in enumerate(values):
148
+ if v is None:
149
+ if current:
150
+ segments.append(current)
151
+ current = []
152
+ continue
153
+ x = i * step
154
+ y = h_ - ((float(v) - lo) / span) * (h_ - 2) - 1
155
+ current.append((x, y))
156
+ if current:
157
+ segments.append(current)
158
+ parts = [f'<svg class="spark" width="{w}" height="{h_}" viewBox="0 0 {w} {h_}">']
159
+ for seg in segments:
160
+ if len(seg) == 1:
161
+ x, y = seg[0]
162
+ parts.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="1.5" fill="currentColor"/>')
163
+ else:
164
+ pts = ' '.join(f'{x:.2f},{y:.2f}' for x, y in seg)
165
+ parts.append(f'<polyline points="{pts}" stroke="currentColor" stroke-width="1.25" fill="none"/>')
166
+ parts.append('</svg>')
167
+ return ''.join(parts)
@@ -0,0 +1,118 @@
1
+ """Catalog enumeration and drop-metadata dataclasses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections import Counter
7
+ from dataclasses import dataclass, field
8
+
9
+ import pyarrow.parquet as papq
10
+
11
+ from .. import paths as _paths
12
+
13
+
14
+ @dataclass
15
+ class DropRow:
16
+ area: str
17
+ drop_date: str
18
+ drop_label: str
19
+ drop_dir: str
20
+ ok_captures: int
21
+
22
+
23
+ @dataclass
24
+ class DropSet:
25
+ """All (area, drop_date, drop_label, drop_dir) tuples for one label+date."""
26
+ label: str
27
+ date: str
28
+ rows: list[DropRow] = field(default_factory=list)
29
+
30
+ @property
31
+ def key(self) -> str:
32
+ if self.date and self.label:
33
+ return f'{self.date}_{self.label}'
34
+ return self.label or self.date or 'unknown'
35
+
36
+ @property
37
+ def n_captures(self) -> int:
38
+ return sum(r.ok_captures for r in self.rows)
39
+
40
+ @property
41
+ def areas(self) -> list[str]:
42
+ return sorted({r.area for r in self.rows})
43
+
44
+
45
+ def discover_drops(root: str) -> list[DropSet]:
46
+ """Read _catalog.parquet, group by (drop_date, drop_label), filter replay_status='ok'.
47
+
48
+ Returns list of DropSet sorted by drop_date asc then drop_label asc.
49
+ """
50
+ cat_path = _paths.catalog_parquet(root)
51
+ if not os.path.exists(cat_path):
52
+ return []
53
+ cols_wanted = ['area', 'drop_date', 'drop_label', 'capture',
54
+ 'replay_status', 'analysis_out_path']
55
+ try:
56
+ t = papq.read_table(cat_path, columns=cols_wanted)
57
+ except Exception:
58
+ return []
59
+
60
+ areas = t.column('area').to_pylist()
61
+ dates = t.column('drop_date').to_pylist()
62
+ labels = t.column('drop_label').to_pylist()
63
+ statuses = t.column('replay_status').to_pylist()
64
+ aop = t.column('analysis_out_path').to_pylist()
65
+
66
+ ok_by_drop: dict[tuple[str, str, str], int] = Counter()
67
+ path_by_drop: dict[tuple[str, str, str], str] = {}
68
+ for a, d, l, s, p in zip(areas, dates, labels, statuses, aop):
69
+ if s == 'ok':
70
+ ok_by_drop[(a, d, l)] += 1
71
+ path_by_drop.setdefault((a, d, l), p)
72
+
73
+ seen_sets: dict[tuple[str, str], DropSet] = {}
74
+ for (a, d, l), p in path_by_drop.items():
75
+ key = (d, l)
76
+ if key not in seen_sets:
77
+ seen_sets[key] = DropSet(label=l, date=d)
78
+ # New: analysis_out_path stored relative under _data/. resolve_drop_dir
79
+ # already points at <root>/_data/<area>/<drop> (the data dir).
80
+ seen_sets[key].rows.append(DropRow(
81
+ area=a, drop_date=d, drop_label=l,
82
+ drop_dir=_paths.resolve_drop_dir(root, p) if p else '',
83
+ ok_captures=ok_by_drop.get((a, d, l), 0),
84
+ ))
85
+
86
+ return sorted(seen_sets.values(), key=lambda s: (s.date, s.label))
87
+
88
+
89
+ def resolve_drop_set(root: str, *,
90
+ label: str | None,
91
+ date: str | None = None) -> DropSet | None:
92
+ drops = discover_drops(root)
93
+ for d in drops:
94
+ if label and d.label != label:
95
+ continue
96
+ if date and d.date != date:
97
+ continue
98
+ return d
99
+ return None
100
+
101
+
102
+ def ok_capture_set(root: str) -> set[tuple]:
103
+ """Return {(area, drop_date, drop_label, capture)} where replay_status='ok'."""
104
+ cat_path = _paths.catalog_parquet(root)
105
+ if not os.path.exists(cat_path):
106
+ return set()
107
+ try:
108
+ t = papq.read_table(cat_path, columns=['area', 'drop_date', 'drop_label',
109
+ 'capture', 'replay_status'])
110
+ except Exception:
111
+ return set()
112
+ cols = {c: t.column(c).to_pylist() for c in t.column_names}
113
+ out = set()
114
+ for i in range(t.num_rows):
115
+ if cols['replay_status'][i] == 'ok':
116
+ out.add((cols['area'][i], cols['drop_date'][i],
117
+ cols['drop_label'][i], cols['capture'][i]))
118
+ return out
@@ -0,0 +1,165 @@
1
+ """Draws-by-class report.
2
+
3
+ Stacked horizontal bars per (area, drop_date), proportional within the bar.
4
+ Segments colored by draw_class. Below: raw-count table.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
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 _gather_from_drops(drops: list) -> tuple[dict, list, list, int]:
19
+ """Return (counts, areas, drop_keys, total_captures).
20
+
21
+ counts[(area, drop_key)][draw_class] = int.
22
+ """
23
+ counts: dict[tuple[str, str], Counter] = defaultdict(Counter)
24
+ areas: set[str] = set()
25
+ drop_keys: list[str] = []
26
+ seen_captures: set[tuple] = set()
27
+
28
+ for d in drops:
29
+ drop_keys.append(d.key)
30
+ for r in d.rows:
31
+ p = os.path.join(r.drop_dir, 'draws.parquet')
32
+ if not os.path.exists(p):
33
+ continue
34
+ try:
35
+ t = papq.read_table(p, columns=['area', 'capture', 'draw_class'])
36
+ except Exception:
37
+ continue
38
+ if t.num_rows == 0:
39
+ continue
40
+ a = t.column('area').to_pylist()
41
+ cap = t.column('capture').to_pylist()
42
+ cl = t.column('draw_class').to_pylist()
43
+ for i in range(t.num_rows):
44
+ counts[(a[i], d.key)][cl[i] or 'other'] += 1
45
+ areas.add(a[i])
46
+ seen_captures.add((a[i], d.key, cap[i]))
47
+
48
+ return counts, sorted(areas), drop_keys, len(seen_captures)
49
+
50
+
51
+ def _build_table(counts: dict, drop_keys: list) -> str:
52
+ classes = base.DRAW_CLASSES
53
+ rows = []
54
+ rows.append('<table class="report"><thead><tr>')
55
+ rows.append('<th>area</th><th>drop</th><th class="num">total</th>')
56
+ for c in classes:
57
+ rows.append(f'<th class="num">{base.h(c)}</th>')
58
+ rows.append('<th class="num">prepass / opaque</th>')
59
+ rows.append('</tr></thead><tbody>')
60
+
61
+ keys = sorted(counts.keys(), key=lambda k: (k[1], k[0]))
62
+ for area, date in keys:
63
+ cc = counts[(area, date)]
64
+ total = sum(cc.values())
65
+ rows.append('<tr>')
66
+ rows.append(f'<td>{base.h(area)}</td>')
67
+ rows.append(f'<td>{base.h(date)}</td>')
68
+ rows.append(f'<td class="num">{base.fmt_int(total)}</td>')
69
+ for c in classes:
70
+ n = cc.get(c, 0)
71
+ rows.append(f'<td class="num">{base.fmt_int(n)}</td>')
72
+ ratio = (cc.get('prepass', 0) / cc['opaque']) if cc.get('opaque', 0) else 0.0
73
+ rows.append(f'<td class="num">{base.fmt_float(ratio, 2)}</td>')
74
+ rows.append('</tr>')
75
+ rows.append('</tbody></table>')
76
+ return '\n'.join(rows)
77
+
78
+
79
+ def _compute_kpis(counts: dict, areas: list) -> list:
80
+ """Hero KPIs: total draws, n areas, median prepass/opaque ratio, dominant class."""
81
+ total = sum(sum(cc.values()) for cc in counts.values())
82
+ ratios = []
83
+ class_totals: Counter = Counter()
84
+ for cc in counts.values():
85
+ for cls, n in cc.items():
86
+ class_totals[cls] += n
87
+ op = cc.get('opaque', 0)
88
+ if op:
89
+ ratios.append(cc.get('prepass', 0) / op)
90
+ median_ratio = (sorted(ratios)[len(ratios) // 2]
91
+ if ratios else 0.0)
92
+ dominant_cls = (class_totals.most_common(1)[0][0]
93
+ if class_totals else '')
94
+ return [
95
+ {'label': 'total draws', 'value': base.fmt_int(total)},
96
+ {'label': 'areas', 'value': base.fmt_int(len(areas))},
97
+ {'label': 'prepass/opaque (med)', 'value': base.fmt_float(median_ratio, 2)},
98
+ {'label': 'dominant class', 'value': dominant_cls or '-'},
99
+ ]
100
+
101
+
102
+ def build(root: str, *, drops: list | None = None, ab=None) -> str:
103
+ if drops is None:
104
+ drops = base.discover_drops(root)
105
+ counts, areas, drop_keys, total_captures = _gather_from_drops(drops)
106
+
107
+ parts = [base.page_open('draws by class', hdr_offset_px=120)]
108
+ parts.append(base.header(
109
+ 'draws by class',
110
+ drops=len(drops), captures=total_captures, build_ts=base.now_iso(),
111
+ crumb_depth=base.crumb_depth(ab),
112
+ ))
113
+ parts.append(base.ab_strip(ab))
114
+ parts.append(base.ab_picker_for(root, 'draws_by_class', ab=ab))
115
+
116
+ # Summary bar: dominant class
117
+ class_totals: Counter = Counter()
118
+ for cc in counts.values():
119
+ for cls, n in cc.items():
120
+ class_totals[cls] += n
121
+ grand_total = sum(class_totals.values()) or 1
122
+ top_three = class_totals.most_common(3)
123
+ if top_three:
124
+ dom_cls, dom_n = top_three[0]
125
+ dom_pct = 100.0 * dom_n / grand_total
126
+ sub_bits = [f'{cls} {base.fmt_float(100.0 * n / grand_total, 1)}%'
127
+ for cls, n in top_three[1:]]
128
+ sub_text = f'across {len(areas)} areas; next: ' + ', '.join(sub_bits) if sub_bits else f'across {len(areas)} areas'
129
+ parts.append(base.summary_bar(
130
+ 'dominant class',
131
+ f'{dom_cls} {base.fmt_float(dom_pct, 1)}%',
132
+ sub=sub_text,
133
+ tone='neutral',
134
+ ))
135
+
136
+ # Section 1: stacked share bars
137
+ parts.append('<h2 id="stacked">stacked share per area / drop</h2>')
138
+ sec1_body = ['<div class="table-wrap">', base.legend()]
139
+ keys = sorted(counts.keys(), key=lambda k: (k[1], k[0]))
140
+ for area, date in keys:
141
+ cc = counts[(area, date)]
142
+ total = sum(cc.values())
143
+ label = f'{area} / {date}'
144
+ sec1_body.append('<div class="bar-row">')
145
+ sec1_body.append(f'<span class="key" title="{base.h(label)}">{base.h(label)}</span>')
146
+ sec1_body.append(base.class_segments_bar(dict(cc), total))
147
+ sec1_body.append(f'<span class="total">{base.fmt_int(total)}</span>')
148
+ sec1_body.append('</div>')
149
+ sec1_body.append('</div>')
150
+ parts.append(''.join(sec1_body))
151
+
152
+ # Section 2: raw counts table
153
+ parts.append('<h2 id="counts">raw counts per class</h2>')
154
+ parts.append('<div class="table-wrap"><rdc-sortable-table data-default-sort="opaque" data-default-dir="desc">')
155
+ parts.append(_build_table(counts, drop_keys))
156
+ parts.append('</rdc-sortable-table></div>')
157
+
158
+ parts.append(base.page_close())
159
+
160
+ out_path = base.output_path(root, 'draws_by_class', ab)
161
+ return base.write_report(out_path, parts)
162
+
163
+
164
+ if __name__ == '__main__':
165
+ sys.exit(base.run_report(build, module_name='draws_by_class'))
@@ -0,0 +1,122 @@
1
+ """Cell renderers and text-normalization helpers used across reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html as _html
6
+ import re
7
+
8
+
9
+ _BANNED_CHROME_CHARS = re.compile(r'[—–…“”‘’→←↑↓×·]')
10
+
11
+
12
+ def fmt_int(v) -> str:
13
+ if v is None or v == '':
14
+ return ''
15
+ try:
16
+ return f'{int(v):,}'
17
+ except (TypeError, ValueError):
18
+ return ''
19
+
20
+
21
+ def fmt_float(v, prec: int = 3) -> str:
22
+ if v is None or v == '':
23
+ return ''
24
+ try:
25
+ return f'{float(v):,.{prec}f}'
26
+ except (TypeError, ValueError):
27
+ return ''
28
+
29
+
30
+ def fmt_bytes(v) -> str:
31
+ return fmt_int(v)
32
+
33
+
34
+ def fmt_pct(v, prec: int = 1) -> str:
35
+ if v is None or v == '':
36
+ return ''
37
+ try:
38
+ return f'{float(v):.{prec}f}%'
39
+ except (TypeError, ValueError):
40
+ return ''
41
+
42
+
43
+ def fmt_id_short(v, n: int = 12) -> str:
44
+ if not v:
45
+ return ''
46
+ s = str(v)
47
+ return s[:n] if len(s) > n else s
48
+
49
+
50
+ def mesh_hash_short(hsh, n: int = 12) -> str:
51
+ return fmt_id_short(hsh, n)
52
+
53
+
54
+ def safe_chrome_text(s) -> str:
55
+ """Escape + scrub banned chrome chars. Apply to all chrome strings outside <table>."""
56
+ if s is None:
57
+ return ''
58
+ scrubbed = _BANNED_CHROME_CHARS.sub('_', str(s))
59
+ return _html.escape(scrubbed)
60
+
61
+
62
+ def trunc_mid(s: str | None, max_len: int = 60) -> str:
63
+ if s is None:
64
+ return ''
65
+ s = str(s)
66
+ if len(s) <= max_len:
67
+ return s
68
+ keep = max_len - 3
69
+ head = keep // 2
70
+ tail = keep - head
71
+ return s[:head] + '...' + s[-tail:]
72
+
73
+
74
+ def trunc_left(s: str | None, max_len: int = 60) -> str:
75
+ """Truncate from the left, keep the suffix. For pass paths whose tail carries the signal."""
76
+ if s is None:
77
+ return ''
78
+ s = str(s)
79
+ if len(s) <= max_len:
80
+ return s
81
+ return '...' + s[-(max_len - 3):]
82
+
83
+
84
+ def pass_short(path: str | None) -> str:
85
+ """Reduce a UE pass path to its meaningful tail.
86
+
87
+ Strips FRDGBuilder::Execute/MobileSceneRender/ prefix.
88
+ Collapses /Engine/EngineMaterials/<Name>.<Name>/ to <Name>/.
89
+ Collapses any `/<Foo>.<Foo>` repeated-name asset pattern to `/<Foo>`.
90
+ """
91
+ if not path:
92
+ return ''
93
+ s = str(path)
94
+ if s.startswith('FRDGBuilder::Execute/'):
95
+ s = s[len('FRDGBuilder::Execute/'):]
96
+ if s.startswith('MobileSceneRender/'):
97
+ s = s[len('MobileSceneRender/'):]
98
+ # Collapse /A/B/C.../<Name>.<Name> SM_X → <Name> SM_X (UE FName redundant)
99
+ parts = s.split('/')
100
+ cleaned = []
101
+ for p in parts:
102
+ if not p:
103
+ continue
104
+ # asset prefix like "/Engine/EngineMaterials" is just noise
105
+ if p in ('Engine', 'EngineMaterials'):
106
+ continue
107
+ # collapse "Name.Name" → "Name"
108
+ if '.' in p:
109
+ head, _, rest = p.partition('.')
110
+ after = rest.split(' ', 1)
111
+ if after[0] == head:
112
+ p = head if len(after) == 1 else f'{head} {after[1]}'
113
+ cleaned.append(p)
114
+ return '/'.join(cleaned)
115
+
116
+
117
+ def pass_suffix(path: str | None) -> str:
118
+ """Last meaningful segment of a pass path, with UE noise stripped."""
119
+ short = pass_short(path)
120
+ if not short:
121
+ return ''
122
+ return short.rsplit('/', 1)[-1]