trellum 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 (379) hide show
  1. trellum/AGENTS.md +1132 -0
  2. trellum/LICENSE +661 -0
  3. trellum/README.md +1623 -0
  4. trellum/THIRD-PARTY.md +121 -0
  5. trellum/__init__.py +29 -0
  6. trellum/__main__.py +21 -0
  7. trellum/agent/__init__.py +18 -0
  8. trellum/agent/agentdoc.py +315 -0
  9. trellum/agent/agentsetup.py +547 -0
  10. trellum/assets.py +69 -0
  11. trellum/cli/__init__.py +192 -0
  12. trellum/cli/commands/__init__.py +6 -0
  13. trellum/cli/commands/data.py +234 -0
  14. trellum/cli/commands/datasource.py +247 -0
  15. trellum/cli/commands/doctor.py +153 -0
  16. trellum/cli/commands/guide.py +168 -0
  17. trellum/cli/commands/metrics.py +235 -0
  18. trellum/cli/commands/review.py +306 -0
  19. trellum/cli/commands/serve.py +130 -0
  20. trellum/cli/commands/setup.py +145 -0
  21. trellum/cli/commands/validate.py +43 -0
  22. trellum/cli/console.py +22 -0
  23. trellum/cli/describe.py +110 -0
  24. trellum/components/__init__.py +57 -0
  25. trellum/components/ab_compare.py +406 -0
  26. trellum/components/ab_methodology.py +113 -0
  27. trellum/components/base.py +214 -0
  28. trellum/components/charts/__init__.py +26 -0
  29. trellum/components/charts/bar.py +411 -0
  30. trellum/components/charts/base.py +120 -0
  31. trellum/components/charts/doughnut.py +89 -0
  32. trellum/components/charts/funnel.py +73 -0
  33. trellum/components/charts/heatmap.py +139 -0
  34. trellum/components/charts/line_area.py +254 -0
  35. trellum/components/charts/scatter.py +121 -0
  36. trellum/components/charts/treemap.py +83 -0
  37. trellum/components/controls.py +160 -0
  38. trellum/components/filterable.py +522 -0
  39. trellum/components/filters/__init__.py +46 -0
  40. trellum/components/filters/base.py +62 -0
  41. trellum/components/filters/date_range.py +196 -0
  42. trellum/components/filters/dropdown.py +76 -0
  43. trellum/components/filters/flag.py +38 -0
  44. trellum/components/filters/slider.py +169 -0
  45. trellum/components/filters/text.py +56 -0
  46. trellum/components/filters/toggle.py +66 -0
  47. trellum/components/header.py +125 -0
  48. trellum/components/kpis.py +247 -0
  49. trellum/components/layout.py +254 -0
  50. trellum/components/live_filterable.py +213 -0
  51. trellum/components/tables.py +279 -0
  52. trellum/config.yaml +23 -0
  53. trellum/data/__init__.py +32 -0
  54. trellum/data/adhoc.py +119 -0
  55. trellum/data/connections.py +175 -0
  56. trellum/data/datasource_config.py +137 -0
  57. trellum/data/drivers/__init__.py +112 -0
  58. trellum/data/drivers/bigquery.py +54 -0
  59. trellum/data/drivers/clickhouse.py +27 -0
  60. trellum/data/drivers/databricks_driver.py +32 -0
  61. trellum/data/drivers/duckdb_driver.py +25 -0
  62. trellum/data/drivers/mysql.py +28 -0
  63. trellum/data/drivers/postgres.py +27 -0
  64. trellum/data/drivers/redshift.py +21 -0
  65. trellum/data/drivers/snowflake_driver.py +35 -0
  66. trellum/data/drivers/sqlite.py +21 -0
  67. trellum/data/drivers/sqlserver.py +32 -0
  68. trellum/data/drivers/trino_driver.py +41 -0
  69. trellum/data/drivers/vertica.py +47 -0
  70. trellum/data/http_source.py +49 -0
  71. trellum/data/live_query_guard.py +276 -0
  72. trellum/data/query.py +579 -0
  73. trellum/data/resolvers.py +255 -0
  74. trellum/data/ssh_tunnel.py +194 -0
  75. trellum/data/transforms.py +90 -0
  76. trellum/datasets.py +162 -0
  77. trellum/demo/README.md +345 -0
  78. trellum/demo/__init__.py +28 -0
  79. trellum/demo/__main__.py +172 -0
  80. trellum/demo/assistant.md +38 -0
  81. trellum/demo/config.yaml +13 -0
  82. trellum/demo/data-sources/config.yaml +16 -0
  83. trellum/demo/data-sources/uploads/ua_budget.csv +325 -0
  84. trellum/demo/metrics.yaml +244 -0
  85. trellum/demo/reports/_template/__init__.py +0 -0
  86. trellum/demo/reports/_template/generator.py +72 -0
  87. trellum/demo/reports/_template/queries.py +20 -0
  88. trellum/demo/reports/_template/report.yaml +34 -0
  89. trellum/demo/reports/cart-funnel/__init__.py +0 -0
  90. trellum/demo/reports/cart-funnel/custom_sections.py +326 -0
  91. trellum/demo/reports/cart-funnel/generator.py +209 -0
  92. trellum/demo/reports/cart-funnel/queries.py +43 -0
  93. trellum/demo/reports/cart-funnel/report.yaml +40 -0
  94. trellum/demo/reports/conversion/__init__.py +0 -0
  95. trellum/demo/reports/conversion/custom_sections.py +164 -0
  96. trellum/demo/reports/conversion/generator.py +174 -0
  97. trellum/demo/reports/conversion/queries.py +13 -0
  98. trellum/demo/reports/conversion/report.yaml +47 -0
  99. trellum/demo/reports/economy-firehose/__init__.py +0 -0
  100. trellum/demo/reports/economy-firehose/generator.py +276 -0
  101. trellum/demo/reports/economy-firehose/queries.py +21 -0
  102. trellum/demo/reports/economy-firehose/report.yaml +40 -0
  103. trellum/demo/reports/experiments/__init__.py +0 -0
  104. trellum/demo/reports/experiments/generator.py +275 -0
  105. trellum/demo/reports/experiments/queries.py +25 -0
  106. trellum/demo/reports/experiments/report.yaml +61 -0
  107. trellum/demo/reports/insert-coin/__init__.py +0 -0
  108. trellum/demo/reports/insert-coin/custom_sections.py +750 -0
  109. trellum/demo/reports/insert-coin/generator.py +180 -0
  110. trellum/demo/reports/insert-coin/queries.py +22 -0
  111. trellum/demo/reports/insert-coin/report.yaml +30 -0
  112. trellum/demo/reports/metrics/__init__.py +0 -0
  113. trellum/demo/reports/metrics/generator.py +1 -0
  114. trellum/demo/reports/metrics/report.yaml +18 -0
  115. trellum/demo/reports/monetization/__init__.py +0 -0
  116. trellum/demo/reports/monetization/custom_sections.py +486 -0
  117. trellum/demo/reports/monetization/generator.py +254 -0
  118. trellum/demo/reports/monetization/queries.py +56 -0
  119. trellum/demo/reports/monetization/report.yaml +34 -0
  120. trellum/demo/reports/player-overview/__init__.py +0 -0
  121. trellum/demo/reports/player-overview/custom_sections.py +452 -0
  122. trellum/demo/reports/player-overview/generator.py +324 -0
  123. trellum/demo/reports/player-overview/queries.py +49 -0
  124. trellum/demo/reports/player-overview/report.yaml +42 -0
  125. trellum/demo/reports/store-health/__init__.py +0 -0
  126. trellum/demo/reports/store-health/generator.py +232 -0
  127. trellum/demo/reports/store-health/queries.py +23 -0
  128. trellum/demo/reports/store-health/report.yaml +32 -0
  129. trellum/demo/reports/user-event-log/__init__.py +0 -0
  130. trellum/demo/reports/user-event-log/generator.py +163 -0
  131. trellum/demo/reports/user-event-log/queries.py +77 -0
  132. trellum/demo/reports/user-event-log/report.yaml +52 -0
  133. trellum/demo/tools/__init__.py +1 -0
  134. trellum/demo/tools/list_reports.py +130 -0
  135. trellum/demo/tools/make_fixtures.py +1778 -0
  136. trellum/init/__init__.py +0 -0
  137. trellum/init/__main__.py +5 -0
  138. trellum/licensing.py +60 -0
  139. trellum/meta.py +114 -0
  140. trellum/metrics.py +598 -0
  141. trellum/metrics_report.py +188 -0
  142. trellum/new/__init__.py +0 -0
  143. trellum/new/__main__.py +5 -0
  144. trellum/output_backends/__init__.py +3 -0
  145. trellum/output_backends/backends.py +78 -0
  146. trellum/output_backends/local.py +39 -0
  147. trellum/project.py +167 -0
  148. trellum/rendering/__init__.py +5 -0
  149. trellum/rendering/artifacts.py +220 -0
  150. trellum/rendering/cdn.py +269 -0
  151. trellum/rendering/html_builder.py +531 -0
  152. trellum/rendering/js_runtime.py +128 -0
  153. trellum/rendering/sections.py +116 -0
  154. trellum/report.py +592 -0
  155. trellum/reporting/__init__.py +18 -0
  156. trellum/reporting/diagnostics/__init__.py +168 -0
  157. trellum/reporting/diagnostics/datasets.py +121 -0
  158. trellum/reporting/diagnostics/filters.py +353 -0
  159. trellum/reporting/diagnostics/types.py +63 -0
  160. trellum/reporting/diagnostics/walk.py +166 -0
  161. trellum/reporting/gallery.py +594 -0
  162. trellum/review/__init__.py +32 -0
  163. trellum/review/http.py +185 -0
  164. trellum/review/inject.py +115 -0
  165. trellum/review/state.py +216 -0
  166. trellum/run/__init__.py +0 -0
  167. trellum/run/__main__.py +5 -0
  168. trellum/runner/__init__.py +347 -0
  169. trellum/runner/console.py +35 -0
  170. trellum/runner/discovery.py +177 -0
  171. trellum/runner/events.py +263 -0
  172. trellum/runner/execute.py +443 -0
  173. trellum/runner/live_query_dev.py +261 -0
  174. trellum/runner/ports.py +244 -0
  175. trellum/runner/serve.py +270 -0
  176. trellum/scaffold/__init__.py +16 -0
  177. trellum/scaffold/init_project.py +169 -0
  178. trellum/scaffold/new_report.py +320 -0
  179. trellum/static/css/base.css +420 -0
  180. trellum/static/css/components/ab_compare.css +145 -0
  181. trellum/static/css/components/ab_methodology.css +91 -0
  182. trellum/static/css/components/chart_base.css +52 -0
  183. trellum/static/css/components/data_table.css +74 -0
  184. trellum/static/css/components/date_range_filter.css +63 -0
  185. trellum/static/css/components/filter_bar.css +202 -0
  186. trellum/static/css/components/grid.css +47 -0
  187. trellum/static/css/components/header.css +186 -0
  188. trellum/static/css/components/kpi_card.css +64 -0
  189. trellum/static/css/components/live_query.css +96 -0
  190. trellum/static/css/components/pivot_table.css +25 -0
  191. trellum/static/css/components/slider_filter.css +55 -0
  192. trellum/static/css/components/tab_group.css +51 -0
  193. trellum/static/css/components/toggle.css +29 -0
  194. trellum/static/css/review.css +82 -0
  195. trellum/static/js/components/ab_compare.js +192 -0
  196. trellum/static/js/components/chart_base.js +676 -0
  197. trellum/static/js/components/data_source.js +5 -0
  198. trellum/static/js/components/data_table.js +247 -0
  199. trellum/static/js/components/date_range_filter.js +201 -0
  200. trellum/static/js/components/doughnut.js +82 -0
  201. trellum/static/js/components/dropdown_filter.js +191 -0
  202. trellum/static/js/components/filter_bar.js +190 -0
  203. trellum/static/js/components/flag_filter.js +30 -0
  204. trellum/static/js/components/funnel.js +102 -0
  205. trellum/static/js/components/header.js +120 -0
  206. trellum/static/js/components/heatmap.js +206 -0
  207. trellum/static/js/components/kpi_card.js +17 -0
  208. trellum/static/js/components/kpi_row.js +92 -0
  209. trellum/static/js/components/live_data_source.js +12 -0
  210. trellum/static/js/components/pivot_table.js +206 -0
  211. trellum/static/js/components/scatter.js +122 -0
  212. trellum/static/js/components/scoped_data_source.js +3 -0
  213. trellum/static/js/components/slider_filter.js +235 -0
  214. trellum/static/js/components/text_filter.js +48 -0
  215. trellum/static/js/components/toggle_filter.js +35 -0
  216. trellum/static/js/components/treemap.js +141 -0
  217. trellum/static/js/data_loader.js +459 -0
  218. trellum/static/js/review/chart_target.js +180 -0
  219. trellum/static/js/review/dom.js +68 -0
  220. trellum/static/js/review/end_session.js +25 -0
  221. trellum/static/js/review/identify.js +71 -0
  222. trellum/static/js/review/keyboard.js +7 -0
  223. trellum/static/js/review/log.js +35 -0
  224. trellum/static/js/review/net.js +58 -0
  225. trellum/static/js/review/panel.js +13 -0
  226. trellum/static/js/review/popover.js +78 -0
  227. trellum/static/js/review/presence.js +14 -0
  228. trellum/static/js/review/queue.js +94 -0
  229. trellum/static/js/review/select.js +13 -0
  230. trellum/static/js/review/state.js +35 -0
  231. trellum/static/js/review/status.js +90 -0
  232. trellum/static/js/review/styles.js +6 -0
  233. trellum/static/js/runtime/aggregate.js +100 -0
  234. trellum/static/js/runtime/annotations.js +286 -0
  235. trellum/static/js/runtime/auto_refresh.js +45 -0
  236. trellum/static/js/runtime/autofill.js +40 -0
  237. trellum/static/js/runtime/chart_defaults.js +322 -0
  238. trellum/static/js/runtime/chunk_loader.js +184 -0
  239. trellum/static/js/runtime/color_registry.js +9 -0
  240. trellum/static/js/runtime/cross_filter.js +21 -0
  241. trellum/static/js/runtime/csv_download.js +18 -0
  242. trellum/static/js/runtime/export.js +62 -0
  243. trellum/static/js/runtime/filter_engine.js +541 -0
  244. trellum/static/js/runtime/formatters.js +52 -0
  245. trellum/static/js/runtime/fw_namespace.js +37 -0
  246. trellum/static/js/runtime/live_query.js +386 -0
  247. trellum/static/js/runtime/live_wrap.js +10 -0
  248. trellum/static/js/runtime/state.js +82 -0
  249. trellum/static/js/runtime/storage.js +5 -0
  250. trellum/static/js/runtime/theme.js +190 -0
  251. trellum/static/js/runtime/ui_handlers.js +152 -0
  252. trellum/static/js/runtime/url_sync.js +278 -0
  253. trellum/static/vendor/LICENSES.md +42 -0
  254. trellum/static/vendor/MANIFEST.json +134 -0
  255. trellum/static/vendor/UPDATING.md +63 -0
  256. trellum/static/vendor/chart.umd.min.js +14 -0
  257. trellum/static/vendor/chartjs-chart-funnel.umd.min.js +16 -0
  258. trellum/static/vendor/chartjs-chart-geo.umd.min.js +2 -0
  259. trellum/static/vendor/chartjs-chart-matrix.min.js +8 -0
  260. trellum/static/vendor/chartjs-chart-sankey.min.js +7 -0
  261. trellum/static/vendor/chartjs-chart-treemap.min.js +8 -0
  262. trellum/static/vendor/chartjs-chart-venn.umd.min.js +2 -0
  263. trellum/static/vendor/chartjs-plugin-annotation.min.js +7 -0
  264. trellum/static/vendor/chartjs-plugin-datalabels.min.js +7 -0
  265. trellum/static/vendor/chartjs-plugin-zoom.min.js +7 -0
  266. trellum/static/vendor/countries-110m.json +1 -0
  267. trellum/static/vendor/fonts/inter-400.ttf +0 -0
  268. trellum/static/vendor/fonts/inter-500.ttf +0 -0
  269. trellum/static/vendor/fonts/inter-600.ttf +0 -0
  270. trellum/static/vendor/fonts/inter-700.ttf +0 -0
  271. trellum/static/vendor/hammer.min.js +7 -0
  272. trellum/static/vendor/html2canvas.min.js +20 -0
  273. trellum/static/vendor/inter.css +31 -0
  274. trellum/static/vendor/jspdf.umd.min.js +398 -0
  275. trellum/static/vendor/licenses/chart.js-4.5.1-LICENSE.md +9 -0
  276. trellum/static/vendor/licenses/chartjs-adapter-date-fns-3.0.0-LICENSE.md +9 -0
  277. trellum/static/vendor/licenses/chartjs-chart-funnel-4.2.5-LICENSE.txt +21 -0
  278. trellum/static/vendor/licenses/chartjs-chart-geo-4.3.3-LICENSE.txt +21 -0
  279. trellum/static/vendor/licenses/chartjs-chart-matrix-2.0.1-LICENSE.txt +21 -0
  280. trellum/static/vendor/licenses/chartjs-chart-sankey-0.12.1-LICENSE.txt +21 -0
  281. trellum/static/vendor/licenses/chartjs-chart-treemap-2.3.1-LICENSE.txt +21 -0
  282. trellum/static/vendor/licenses/chartjs-chart-venn-4.3.7-LICENSE.txt +21 -0
  283. trellum/static/vendor/licenses/chartjs-plugin-annotation-3.1.0-LICENSE.md +9 -0
  284. trellum/static/vendor/licenses/chartjs-plugin-datalabels-2.2.0-LICENSE.md +9 -0
  285. trellum/static/vendor/licenses/chartjs-plugin-zoom-2.2.0-LICENSE.md +9 -0
  286. trellum/static/vendor/licenses/hammerjs-2.0.8-LICENSE.md +21 -0
  287. trellum/static/vendor/licenses/html2canvas-1.4.1-LICENSE.txt +22 -0
  288. trellum/static/vendor/licenses/inter-4.001-OFL.txt +82 -0
  289. trellum/static/vendor/licenses/jspdf-2.5.2-LICENSE.txt +22 -0
  290. trellum/static/vendor/licenses/natural-earth-PUBLIC-DOMAIN.md +13 -0
  291. trellum/static/vendor/licenses/nouislider-15.8.1-LICENSE.md +21 -0
  292. trellum/static/vendor/licenses/plotly.js-2.27.0-LICENSE.txt +21 -0
  293. trellum/static/vendor/licenses/slim-select-2.9.2-LICENSE.txt +21 -0
  294. trellum/static/vendor/licenses/topojson-client-3.1.0-LICENSE.txt +13 -0
  295. trellum/static/vendor/licenses/world-atlas-2.0.2-LICENSE.txt +13 -0
  296. trellum/static/vendor/nouislider.min.css +1 -0
  297. trellum/static/vendor/nouislider.min.js +1 -0
  298. trellum/static/vendor/plotly.min.js +8 -0
  299. trellum/static/vendor/plotly.min.js.LICENSE.txt +53 -0
  300. trellum/static/vendor/slimselect.css +1 -0
  301. trellum/static/vendor/slimselect.min.js +1 -0
  302. trellum/static/vendor/topojson-client.min.js +2 -0
  303. trellum/stats/__init__.py +75 -0
  304. trellum/stats/ab/__init__.py +64 -0
  305. trellum/stats/ab/aggregate.py +250 -0
  306. trellum/stats/ab/modes.py +504 -0
  307. trellum/stats/ab/users.py +292 -0
  308. trellum/stats/bootstrap.py +86 -0
  309. trellum/stats/cuped.py +78 -0
  310. trellum/stats/winsor.py +34 -0
  311. trellum/testing/__init__.py +14 -0
  312. trellum/testing/conftest.py +96 -0
  313. trellum/testing/mock_data.py +292 -0
  314. trellum/testing/runner.py +711 -0
  315. trellum/testing/test_ab_frontdoor.py +208 -0
  316. trellum/testing/test_adhoc.py +128 -0
  317. trellum/testing/test_agentdoc.py +707 -0
  318. trellum/testing/test_cli_datasource.py +24 -0
  319. trellum/testing/test_cli_query.py +153 -0
  320. trellum/testing/test_compatibility.py +633 -0
  321. trellum/testing/test_components.py +1113 -0
  322. trellum/testing/test_connections.py +1662 -0
  323. trellum/testing/test_datasets.py +199 -0
  324. trellum/testing/test_demo_journey.py +148 -0
  325. trellum/testing/test_gallery.py +308 -0
  326. trellum/testing/test_interactions.py +715 -0
  327. trellum/testing/test_js_runtime.py +1169 -0
  328. trellum/testing/test_licensing.py +107 -0
  329. trellum/testing/test_live_query.py +1221 -0
  330. trellum/testing/test_metrics.py +758 -0
  331. trellum/testing/test_module_size.py +149 -0
  332. trellum/testing/test_performance.py +797 -0
  333. trellum/testing/test_portable_output.py +113 -0
  334. trellum/testing/test_query_cache.py +118 -0
  335. trellum/testing/test_review.py +801 -0
  336. trellum/testing/test_runner.py +100 -0
  337. trellum/testing/test_ssh_tunnel.py +423 -0
  338. trellum/testing/test_stats.py +383 -0
  339. trellum/testing/test_themes.py +116 -0
  340. trellum/testing/test_update_check.py +324 -0
  341. trellum/testing/test_validation.py +531 -0
  342. trellum/testing/visual_regression.py +161 -0
  343. trellum/themes/__init__.py +107 -0
  344. trellum/themes/blossom.py +35 -0
  345. trellum/themes/classic.py +74 -0
  346. trellum/themes/dark.py +36 -0
  347. trellum/themes/default.py +3 -0
  348. trellum/themes/dracula.py +35 -0
  349. trellum/themes/midnight.py +35 -0
  350. trellum/themes/money.py +35 -0
  351. trellum/themes/monokai.py +35 -0
  352. trellum/themes/nord.py +35 -0
  353. trellum/themes/ocean.py +35 -0
  354. trellum/themes/solarized.py +35 -0
  355. trellum/themes/sunset.py +35 -0
  356. trellum/themes/theme.py +100 -0
  357. trellum/update_check.py +183 -0
  358. trellum/validation/__init__.py +129 -0
  359. trellum/validation/checks/__init__.py +6 -0
  360. trellum/validation/checks/annotations.py +113 -0
  361. trellum/validation/checks/columns.py +373 -0
  362. trellum/validation/checks/datasource.py +466 -0
  363. trellum/validation/checks/effectiveness.py +157 -0
  364. trellum/validation/checks/live_query.py +424 -0
  365. trellum/validation/checks/metrics.py +155 -0
  366. trellum/validation/checks/rawhtml.py +195 -0
  367. trellum/validation/checks/scopes.py +28 -0
  368. trellum/validation/checks/structural.py +93 -0
  369. trellum/validation/checks/theme.py +159 -0
  370. trellum/validation/checks/visibility.py +121 -0
  371. trellum/validation/checks/yaml_schema.py +50 -0
  372. trellum/validation/result.py +214 -0
  373. trellum/validation/walk.py +156 -0
  374. trellum-0.1.0.dist-info/METADATA +1668 -0
  375. trellum-0.1.0.dist-info/RECORD +379 -0
  376. trellum-0.1.0.dist-info/WHEEL +5 -0
  377. trellum-0.1.0.dist-info/entry_points.txt +2 -0
  378. trellum-0.1.0.dist-info/licenses/LICENSE +661 -0
  379. trellum-0.1.0.dist-info/top_level.txt +1 -0
trellum/AGENTS.md ADDED
@@ -0,0 +1,1132 @@
1
+ # BI Report Framework -- Agent Workflow Guide
2
+
3
+ This is the **workflow guide** for AI assistants (Claude Code, Cursor, Codex)
4
+ building reports with this framework. It is the only document you need resident.
5
+
6
+ `README.md` is written for people, and it is long. **Do not load it wholesale.**
7
+ Ask for the part you need instead:
8
+
9
+ ```
10
+ trellum what the framework is, in ~30 lines
11
+ trellum guide <topic> one section of depth, on demand
12
+ trellum checks every validator check id and its level
13
+ trellum validate <dir> the current validation state of a built report
14
+ ```
15
+
16
+ `trellum guide` topics: `answer`, `queries`, `format`, `generator`,
17
+ `components`, `metrics`, `filters`, `live-queries`, `rawhtml`, `validation`,
18
+ `report-yaml`, `themes`, `review`, `portal`.
19
+
20
+ **Why this shape.** Your whole context is re-read on every API round-trip, so a
21
+ large always-loaded reference is paid for dozens of times per task. A command is
22
+ paid for once, when it is actually needed. Reach for `trellum guide` freely —
23
+ it is cheaper than it looks, and far cheaper than guessing.
24
+
25
+ ## Where to look, by question
26
+
27
+ | Question | Answer |
28
+ |---|---|
29
+ | The user wants a number, not a report | `trellum guide answer` — query the source by name, reply with the number |
30
+ | What components exist? | `trellum` — the bare command lists all of them |
31
+ | What does this validator check id mean? | `trellum checks`, then `trellum guide validation` |
32
+ | How do I filter a chart? | `trellum guide filters` |
33
+ | Is this KPI already a defined business metric? | `trellum metrics` — claim it by id, don't re-derive it |
34
+ | Why is my chart empty / not reacting? | `trellum guide format` — it is almost always wide-vs-long |
35
+ | Where does aggregation belong? | `trellum guide queries` — pandas, not SQL |
36
+ | What goes in report.yaml? | `trellum guide report-yaml` |
37
+ | What did the portal publish, and did the build there pass? | `trellum guide portal` — the MCP loop, when `.mcp.json` names a `trellum` server |
38
+ | Everything else | `README.md`, read the relevant section only |
39
+
40
+ **Before writing a generator, read `trellum guide queries` and
41
+ `trellum guide format`.** Those two rules are not guessable from the API, and
42
+ getting them wrong produces a report that builds cleanly and displays nothing
43
+ useful.
44
+
45
+ ## When to use the framework
46
+
47
+ | Goal | Where it goes |
48
+ |---|---|
49
+ | Recurring, interactive report — served standalone, on a schedule, or embedded in another application | `reports/{slug}/` (the framework) |
50
+ | A number or an answer — "what was X yesterday", a one-off check, quick exploration | `trellum query "SELECT ..."` or `from trellum import query` in Python, against the configured source by name. No report. See `trellum guide answer` |
51
+ | Unsure | Answer first. Promote to a report when it needs a schedule, more than one reader, a consequential number that should be reviewed, or the same question comes back a third time |
52
+
53
+ Scaffold a new report: `python3 -m trellum.new my-report --studio my-studio --category Revenue`. Build it: `python3 -m trellum.run reports/my-report --no-serve`.
54
+
55
+ **Always pass `--no-serve` unless you have been asked to open the report in a
56
+ browser.** Without it the command starts a preview server on :8050 and never
57
+ exits — the build itself finishes in under a second, but the process stays in
58
+ the foreground until it is killed. Build first, then serve as a separate step
59
+ if a human is going to look at it.
60
+
61
+ **Read files with your file-reading tool, not with `cat`, `ls` or `grep` in a
62
+ shell.** Every shell command starts a process; your native read, glob and search
63
+ tools do not. On the machine these runs were measured on a bare `ls` cost about
64
+ twelve seconds and an identical `Read` cost none — that gap is environment-
65
+ specific, but its direction never is.
66
+
67
+ The trap is that batching looks cheaper: `cat a.py; cat b.py; cat c.py` is one
68
+ command where three reads are three calls. Under a per-command cost that
69
+ reasoning is right, and here it is exactly backwards — three native reads are
70
+ free and the one shell command is not. Use the shell for things that genuinely
71
+ need it: running the build, the validator, git.
72
+
73
+ When you do serve, two things are not guessable and both have cost real time:
74
+
75
+ - **A single report is served at `/`, not at `/<slug>`.** Only `--all --serve`
76
+ puts an index at `/` with reports beneath it. `/<slug>` on a single-report
77
+ server returns an error page.
78
+ - **Wait for the `Serving at ...` line before opening the URL.** `--serve`
79
+ rebuilds the report and binds the port last, so until that line appears the
80
+ URL may still be answered by a previous server — showing a different report
81
+ rather than failing, which is far more confusing than a refused connection.
82
+
83
+ <!-- topic: answer -->
84
+ ## Answering a question without a report
85
+
86
+ A user's ask has two shapes, and they are routed differently. **A number or
87
+ an answer** ("what was revenue yesterday?", "how many players churned in
88
+ June?") wants a reply, not a build: no `reports/<slug>/`, no generator, no
89
+ validator. **A report** wants a schedule, an audience beyond the asker, or
90
+ interactive filters — that is the framework proper. **Unsure? Answer first.**
91
+ An answer is one call; a report is a session.
92
+
93
+ How to answer:
94
+
95
+ 1. **Metric first.** `python -m trellum metrics` lists the business metrics
96
+ this project defines; `python -m trellum metrics <name>` prints one in
97
+ full, including its canonical `sql`. If the number the user wants is
98
+ defined there, use that derivation — a second hand-written definition of
99
+ "gross revenue" is how two answers come to disagree.
100
+ 2. **Query the source by NAME**, never by file path — the path breaks the day
101
+ the source is a remote warehouse; the name does not. From the shell:
102
+
103
+ ```
104
+ python -m trellum query "SELECT SUM(revenue) AS revenue FROM fact_daily WHERE event_date = :day" --param day=2026-06-15
105
+ ```
106
+
107
+ From Python — a script, a notebook, a one-off `python -c`:
108
+
109
+ ```python
110
+ from trellum import query
111
+ df = query("warehouse", "SELECT SUM(revenue) AS revenue FROM fact_daily WHERE event_date = :day", {"day": "2026-06-15"})
112
+ print(df)
113
+ ```
114
+
115
+ `sources()` lists what is configured; `connect(name)` returns the raw
116
+ connection for `pandas.read_sql`. All three work from any subdirectory of
117
+ the project: the root is found by walking up to `data-sources/config.yaml`.
118
+ `python -m trellum data` prints each table's columns and date span — query
119
+ outside the span and you get zero rows, not an error.
120
+ 3. **Read-only.** Local databases (sqlite, duckdb) are opened read-only from
121
+ both entry points; an answer cannot mutate the warehouse. Remote
122
+ warehouses rely on their own permissions.
123
+ 4. **Reply with four things:** the number, the source name it came from, the
124
+ as-of date (the latest date in the data, or the date you filtered on), and
125
+ the SQL you ran — so the user can check it and the next person can repeat
126
+ it.
127
+
128
+ Promote the answer to a report when any of these becomes true: it needs a
129
+ schedule; more than one person will read it; the number is going somewhere
130
+ consequential and should be reviewed; or the same question comes back a third
131
+ time. Then scaffold it — `python -m trellum.new <slug> --studio <studio>
132
+ --category <category>` — and read `python -m trellum guide report` for the
133
+ authoring contract.
134
+
135
+ <!-- topic: queries -->
136
+ ## How to write queries (MANDATORY — read before touching `queries.py`)
137
+
138
+ **Exploring the data first?** `python -m trellum query "SELECT ..."` runs
139
+ ad-hoc SQL against a configured source by NAME — never hunt for the database
140
+ file or hardcode its path, both of which break the day the source is a
141
+ remote warehouse. `--param day=2026-06-15` binds `:day`; sqlite sources are
142
+ opened read-only.
143
+
144
+ **The rule for report queries:** SQL pulls raw rows with a date filter.
145
+ Python does the aggregation. Never both in the same query.
146
+
147
+ ```sql
148
+ -- ✓ RIGHT — minimal SQL: date filter + column projection, no aggregation
149
+ SELECT event_date, dim_a, dim_b, metric_col
150
+ FROM <schema>.<fact_table>
151
+ WHERE event_date BETWEEN DATE :start_date AND DATE :end_date
152
+ ```
153
+
154
+ ```python
155
+ # ✓ RIGHT — aggregate in the generator with pandas
156
+ df_raw = query_df(conn, queries.MY_QUERY, params={...})
157
+ daily = df_raw.groupby("event_date")["metric_col"].sum().reset_index()
158
+ split = df_raw.groupby(["event_date", "dim_a"])["metric_col"].sum().reset_index()
159
+ ```
160
+
161
+ ```sql
162
+ -- ✗ WRONG — SQL-side GROUP BY on a fact table
163
+ SELECT event_date, dim_a, SUM(metric_col)
164
+ FROM <schema>.<fact_table>
165
+ WHERE event_date BETWEEN ... GROUP BY 1, 2
166
+ ```
167
+
168
+ Two reasons this rule is non-negotiable:
169
+
170
+ 1. **Memory footprint.** A `GROUP BY + SUM` on a wide fact table makes
171
+ the database pre-allocate several GB for the hash aggregate, and
172
+ local-dev user accounts frequently run under tight resource-pool
173
+ caps. Raw-row retrieval with a date filter stays small — a typical
174
+ 30-day window of a fact table fits in tens of MB of pandas.
175
+ 2. **Dynamic filtering is the framework's whole point.** The
176
+ client-side `FilterBar` re-aggregates every time the user changes
177
+ a filter. Pre-aggregating in SQL collapses dimensions and breaks
178
+ that interactivity — the chart freezes at whatever grain the SQL
179
+ produced. Raw-row `DataSource`s let the client slice any dimension
180
+ you included.
181
+
182
+ Code smells — triggers to rewrite the query:
183
+
184
+ - `SUM()` / `AVG()` / `COUNT(DISTINCT)` in the SELECT that collapses a
185
+ dimension the user might want to filter by.
186
+ - `GROUP BY` on only a subset of the filterable dimensions. Either
187
+ drop the GROUP BY or group by every dim the client might filter on
188
+ (denormalized grain).
189
+ - `HAVING` — do it in pandas.
190
+ - `JOIN` that explodes row count to attach a dim — better as a pandas
191
+ `.merge()` after both sides load.
192
+
193
+ Acceptable exceptions (rare):
194
+
195
+ - Querying an already-aggregated table (one whose name signals a
196
+ pre-rolled grain like `*_agg_*` / `*_daily_*`): pull columns
197
+ directly, no further SQL aggregation.
198
+ - A `GROUP BY` on the union of every filterable dimension. Preserves
199
+ filterability but compresses duplicates. Use only if raw-row
200
+ retrieval genuinely returns too much data.
201
+
202
+ Project-specific table names, memory caps, and schema particulars live
203
+ in `project_context/chat_rules/` — the chat feature loads them at
204
+ runtime; human readers can look there for the concrete tables to query.
205
+
206
+ <!-- topic: format -->
207
+ ## Long format vs wide format (MANDATORY)
208
+
209
+ **Rule:** dimensions stay in **rows**, never in **column names**. One
210
+ row per (date × every breakdown dim), one column per metric.
211
+
212
+ ```python
213
+ # ✓ RIGHT — long format
214
+ event_date | platform | spender_tier | dau | iap_revenue | ad_revenue
215
+ 2026-04-01 | ios | Whale | 12000 | 9800 | 250
216
+ 2026-04-01 | android | Whale | 8500 | 5400 | 180
217
+ ```
218
+
219
+ ```python
220
+ # ✗ WRONG — platform baked into column names ("wide format")
221
+ event_date | spender_tier | dau_ios | dau_android | iap_revenue_ios | iap_revenue_android | ...
222
+ 2026-04-01 | Whale | 12000 | 8500 | 9800 | 5400 | ...
223
+ ```
224
+
225
+ Why long format is the default:
226
+
227
+ 1. **Filter coverage.** The client filter engine matches on column
228
+ *values*, not column *names*. A filter on `platform = "ios"` only
229
+ works when there's a `platform` column carrying `"ios"` as a value.
230
+ Wide format makes the filter inert.
231
+ 2. **Smaller payload.** Dictionary encoding via `_serialize_columnar`
232
+ compresses high-repetition columns (e.g. `platform` with 8 unique
233
+ values across 50 k rows) to integer indices — typically 60–70 %
234
+ smaller than the wide equivalent with one column per platform.
235
+ 3. **Auto-discovery.** Adding a new platform just adds rows to the
236
+ data; charts using `stack_by="platform"` pick it up. Wide format
237
+ forces editing every chart's `y=[col_a, col_b, ...]` list.
238
+ 4. **Simpler SQL.** `GROUP BY event_date, platform, ...` instead of
239
+ one `SUM(CASE WHEN platform='ios' THEN dau END) AS dau_ios` per
240
+ metric per platform.
241
+
242
+ Render breakdowns with `stack_by`:
243
+
244
+ ```python
245
+ # Long format → one line per platform via stack_by
246
+ LineChart(df=df, x="event_date", y="iap_revenue",
247
+ stack_by="platform", dataset_id=ds, y_format="currency",
248
+ title="IAP Revenue by Platform")
249
+
250
+ # Long format + ratio → one line per platform, computing iap/dau per (date, platform)
251
+ LineChart(df=df, x="event_date", dataset_id=ds,
252
+ ratios=[{"numerator": "iap_revenue", "denominator": "dau",
253
+ "label": "IAP ARPDAU"}],
254
+ stack_by="platform", y_format="currency",
255
+ title="ARPDAU (IAP) by Platform")
256
+ ```
257
+
258
+ The `chart-filter-coverage` validator catches accidental wide-pivots —
259
+ when a chart's DataSource cannot react to a FilterBar filter because
260
+ the dimension was collapsed into column names. Long format is the
261
+ fix; suppression is reserved for charts that are intentionally a
262
+ fixed rollup (e.g. period-over-period reference rollups).
263
+
264
+ <!-- topic: generator -->
265
+ ## The 5-step process to write generator.py
266
+
267
+ 1. Subclass `BaseReport` and implement `generate(self, ctx)`.
268
+ 2. `conn = ctx.get_connection("primary_warehouse")` (or whatever name the project uses in `data-sources/config.yaml`); query with `query_df(conn, queries.X, params={...})`.
269
+ 3. Wrap each DataFrame in a `DataSource` + a `FilterBar` placed together in an **untitled section**: `ctx.add_section("", [DataSource(...), FilterBar(...)])`. Untitled is required for sticky positioning.
270
+ 4. Add content sections: `ctx.add_section(title, [...])`.
271
+ 5. **Every chart, KPI, and table component MUST carry `dataset_id="..."`** pointing to its DataSource. Without it the component renders statically and silently ignores filters — this is the most common mistake.
272
+
273
+ To copy from, list what this project actually has (`ls reports/`) and open one.
274
+ Report names are not named here on purpose: this file ships with the framework
275
+ and travels into every project, so any slug written down is a report somebody
276
+ else has. A measured run followed three such names and got three "file does not
277
+ exist" errors before it thought to look.
278
+
279
+ <!-- topic: report-yaml -->
280
+ ## Write the report.yaml description for discoverability (MANDATORY)
281
+
282
+ A search or assistant layer routes user questions to reports using
283
+ the `description` and `tags` in `report.yaml`, plus the dataset columns it
284
+ indexes from the built output. A vague description ("Revenue report") makes
285
+ the report invisible to it. The description MUST state:
286
+
287
+ 1. **Which business questions the report answers** ("how many payers churn
288
+ per week and why"), not just its topic.
289
+ 2. **The key metrics and dimensions** it carries (churn rate, revenue at
290
+ risk; split by platform/tier/country).
291
+ 3. **Grain and freshness** (weekly cohorts; daily; refreshes every 5 min).
292
+
293
+ The `report-description-weak` validator check WARNs on short descriptions.
294
+ Descriptive column names in DataSources matter for the same reason — the
295
+ assistant reads them from data.json to decide which dataset answers a
296
+ question.
297
+
298
+ <!-- topic: components -->
299
+ ## Component Reuse Policy
300
+
301
+ **Before writing any custom HTML, CSS, or JS, name the framework component you ruled out and why.** Most needs are already solved.
302
+
303
+ | Need | Required component |
304
+ |---|---|
305
+ | Filters / dropdowns / sticky filter bar | `DataSource` + `FilterBar` |
306
+ | Section-local filter on same data (no propagate_to wiring) | `ScopedDataSource(id, parent=...)` + section-scoped `FilterBar` |
307
+ | Cascading dropdowns (parent → child option narrowing) | Add `depends_on: "parent_col"` to a dropdown filter spec |
308
+ | KPI cards | `KpiRow(dataset_id=...)` (`agg`: `sum` / `ratio` / `count` / `abssum`) |
309
+ | Time series | `LineChart` |
310
+ | Bar / stacked bar | `BarChart` / `StackedBar` |
311
+ | Area / stacked area | `AreaChart` (`stacked=True`) |
312
+ | Doughnut / pie | `DoughnutChart` |
313
+ | Heatmap / day×hour / cohort grid | `HeatmapChart` |
314
+ | Funnel | `FunnelChart` |
315
+ | Treemap | `TreemapChart` |
316
+ | Scatter / correlation / bubble | `ScatterChart` |
317
+ | Dual-axis bar + line | `ComboChart` |
318
+ | Ratio metrics (ARPDAU, ARPPU, retention, conversion) | `LineChart(ratios=[{numerator, denominator, label}])` — never hand-roll the division in JS |
319
+ | Ratio by category (CPD per comfort_zone, ARPPU by country) | `BarChart(ratios=[...], horizontal=True, sort='desc')` — categorical ratio bars |
320
+ | Tables | `DataTable`, `ComparisonTable`, `PivotTable` |
321
+ | Layout | `Grid`, `Panel`, `SplitPane`, `TabGroup` |
322
+ | Card-panel grid | `Grid(card=True)` |
323
+ | Cross-grain filter sync | `FilterBar(propagate_to={target_ds: {src_col: tgt_col}})` |
324
+ | A/B test report | `ABCompare.from_users(df, variant_col=..., control=..., test=..., metrics=[Metric(...)])` — the front door: a per-user frame in YOUR column names, out comes the finished component with an SRM badge and raw / winsor / CUPED modes (a `Metric` that declares `pre_col` opts into CUPED). Groups fetched separately (one query per arm)? `ABCompare.from_groups({"Control": df_a, "Test": df_b}, metrics=[...])`. Self-contained — no DataSource/FilterBar needed. The raw constructor `ABCompare(rows=..., modes=...)` plus `trellum.stats.ab.{winsorize_user_df, cuped_user_df, bootstrap_ab_cis}` remains for custom row sets. See `trellum/README.md` "A/B Testing". |
325
+
326
+ `RawHTML` is the **exception, not the default**. It is permitted only when **both**:
327
+
328
+ 1. The visualization is genuinely novel (force-directed graph, Sankey, etc.) and no combination of framework components can express it.
329
+ 2. You have explicitly named the components you considered and why each was insufficient.
330
+
331
+ For complex reports prefer the **hybrid pattern**: framework components for filters/standard charts/KPIs; `RawHTML` only for the genuinely novel section. The custom JS must subscribe to the framework filter engine — `window._fwFilterEngine.subscribe(dsId, id, fn)` — instead of managing its own filter state. See `trellum/README.md` "Custom Dashboards" for the full pattern and the `fw.*` API.
332
+
333
+ ### Anti-patterns to avoid
334
+
335
+ - **Hardcoded hex colors anywhere in RawHTML JS/HTML** — the `rawhtml-hardcoded-hex` validator **FAILS** the report on any `#RGB`/`#RRGGBB` literal except `#fff` and `#000`. Use `fw.getThemeColors().chart_colors[i]` for chart palettes and CSS vars (`var(--accent-red)`, `var(--text-main)`, `var(--bg-card)`) for HTML/CSS.
336
+ - **Hardcoded `rgba?()`/`rgb()` literals in RawHTML JS** — `rawhtml-hardcoded-rgba` (WARN) flags numeric color literals that don't update on theme switch. Use `fw.getThemeColors().grid_color` for grid lines, `.tick_color` for axis ticks / legend labels, `.chart_colors[i]` for dataset colors. `rgba(0,0,0,0)` (transparent) is exempt. Suppress for intentional fixed-color semantic annotations.
337
+ - **CSS variable strings in Chart.js color properties** — `rawhtml-css-var-in-chartjs` (WARN) flags patterns like `color: 'var(--text-main)'`. Chart.js has no CSS resolver; the string is used as-is (invalid color). Use `fw.getThemeColors().tick_color` / `.grid_color`, or resolve with `getComputedStyle(document.documentElement).getPropertyValue('--name').trim()`.
338
+ - Re-implementing Slim Select dropdowns (use `FilterBar`; `ScopedDataSource` for section-local; `depends_on` for cascading)
339
+ - Writing custom `getFilteredRows()` / filter state management (use the public `fw.filterEngine.*` API)
340
+ - Polling with `setTimeout(init, 100)` to wait for `_fwFilterEngine` (use `fw.filterEngine.onReady(dsId, fn)`)
341
+ - Referencing `window._fwFilterEngine` directly in RawHTML (it's private; use `fw.filterEngine`)
342
+ - Duplicating a DataFrame to get a separate DataSource for section-local filtering (use `ScopedDataSource(id, parent=...)`)
343
+ - Duplicating KPI rendering when `KpiRow(dataset_id=...)` covers it
344
+ - Building custom layout grids when `Grid` / `Panel` / `SplitPane` suffice
345
+ - Going 100% `RawHTML` when only one or two sections need custom logic
346
+ - LEFT JOINing coarser-grain data (monthly MAU, install cohort) into a finer-grain DataFrame to avoid creating a second DataSource — duplicates rows and silently breaks ratio aggregations under filtering
347
+
348
+ ### KpiRow aggregation semantics (exact, from the runtime)
349
+
350
+ Every `agg` recomputes over the **filtered rows**, so pass raw columns and let the runtime do the arithmetic — a pre-divided or pre-summed column cannot re-aggregate. The complete set:
351
+
352
+ | `agg` | Computes | Notes |
353
+ |---|---|---|
354
+ | `sum` | `sum(column)` — or summed across a `columns` list | the default |
355
+ | `count` | number of filtered rows | no `column` needed; add a literal `df["x_n"] = 1` column when you also need a ratio denominator |
356
+ | `abssum` | `sum(abs(column))` | for signed ledgers |
357
+ | `ratio` | `sum(numerator) / abs(sum(denominator))`, ×100 **only** when `format: "percent"` | the scaling follows the FORMAT, so a currency ratio (ARPDAU, AOV, revenue per order) and a plain one (sessions per user, `format: "ratio"` → "2.98x") are ordinary ratio KPIs. Charts do the same: `ratios=[{numerator, denominator, label}]` divides raw and the axis format scales |
358
+ | `avg_by_date` | `sum(column) / count(distinct date_col)` | per-day average; `date_col` defaults to `event_date` |
359
+ | `purchase_pct` | share of `source_col` volume where `type_col` is in `match_values`, ×100 | percent-format only — unlike `ratio`, its ×100 is still unconditional |
360
+
361
+ Where the ×100 lives in `ratio` is the most re-derived fact in measured
362
+ sessions — one agent spent 15 tool calls reading `kpis.py`, `validation.py`
363
+ and the JS runtime to establish it. It is display, not aggregation, and it is
364
+ stated here so the next one does not have to look.
365
+
366
+ <!-- topic: metrics -->
367
+ ## Claimed metrics: metrics.yaml, the `{"metric": id}` claim, and `ctx.metrics()`
368
+
369
+ **Before inventing a KPI dict, run `python -m trellum metrics`.** If the
370
+ number you are about to compute is already defined there, **claim it by id**
371
+ instead of writing another `{"label", "agg", "column"}` dict — a second
372
+ hand-written definition of "gross revenue" is how two reports come to
373
+ disagree about what gross revenue is. If it is a business number and it is
374
+ NOT defined, propose adding it to the project-root `metrics.yaml` rather than
375
+ hard-coding a definition only this report knows about. Inline KPI dicts stay
376
+ fully supported — claims are for the numbers that must mean the same thing
377
+ everywhere.
378
+
379
+ A claim replaces the KPI dict with the metric's id:
380
+
381
+ ```python
382
+ KpiRow([
383
+ {"metric": "gross_revenue"}, # label, format, agg, column all
384
+ {"metric": "transactions"}, # expand from metrics.yaml
385
+ ], dataset_id="rev")
386
+
387
+ KpiCard(metric="gross_revenue", value=total) # static claim: identity only,
388
+ MiniKpi(metric="transactions", value=n) # the value must be supplied
389
+ ```
390
+
391
+ The claim expands **at build time** into the exact aggregation config the
392
+ client engine already executes — there is no new computation engine, and a
393
+ claimed KPI filters and re-aggregates exactly like an inline one. Explicit
394
+ keys on the claim dict win over the definition, but the `metric-overridden`
395
+ check WARNs when they conflict: an override means the card no longer computes
396
+ the definition it names.
397
+
398
+ ### Getting the rows: datasets and `ctx.metrics()`
399
+
400
+ A metric can **bind to a dataset** — the one place that says "these rows are
401
+ daily, over `event_date`, filterable by these dimensions". Bound metrics are
402
+ fetched with `ctx.metrics()` instead of a hand-written query:
403
+
404
+ ```python
405
+ df = ctx.metrics(["gross_revenue", "dau"], by=["platform"], window=90)
406
+ ctx.add_section("", [DataSource("rev", df),
407
+ FilterBar("rev", df, filters=[
408
+ {"column": "event_date", "type": "date_range"},
409
+ {"column": "platform"}])])
410
+ ctx.add_section("Overview", [
411
+ KpiRow([{"metric": "gross_revenue"}, {"metric": "dau"}], dataset_id="rev"),
412
+ LineChart(df, x="event_date", y="total_revenue", stack_by="platform",
413
+ dataset_id="rev", title="Gross Revenue"),
414
+ ])
415
+ ```
416
+
417
+ It returns a plain long-format DataFrame — the dataset's time column, the
418
+ `by` dimensions, one column per measure the metrics need — so a bare claim on
419
+ a `KpiRow` over it computes by construction and `metric-column-missing`
420
+ passes. **One call covers one dataset**; ids from two datasets raise with the
421
+ split spelled out (two datasets are two DataSources anyway). `by` must name
422
+ declared dimensions of the dataset; anything else raises before a query runs.
423
+ `window` is days back from `ctx.today` or an explicit `(start, end)`; the
424
+ default is the dataset's `lookback`, else 90 days. Identical requests in one
425
+ build cost one fetch, and the generated SQL has a stable column order so it
426
+ hits the query cache across reports.
427
+
428
+ The SQL shortcut selects time + dims + measures with the date filter, and
429
+ GROUPs BY time + dims with `SUM()` when every requested metric is additive
430
+ across dimensions — the one sanctioned exception to "aggregate in pandas",
431
+ because the grouping is over every dimension the FilterBar can use. A
432
+ `count` metric means rows, so its presence keeps native grain. Derived
433
+ per-row columns (`total_revenue: "iap_revenue + ad_revenue"`) live on the
434
+ dataset's `columns:` map and are derived once, there, instead of in every
435
+ report. Raw event tables that would not survive a GROUP BY get a Python
436
+ `provider: module:function` instead, called as `(ctx, req) -> DataFrame`;
437
+ the layer projects and rolls up its frame the same way.
438
+
439
+ `metrics.yaml` format (a LIST, so duplicate ids are lintable):
440
+
441
+ ```yaml
442
+ version: 1
443
+ datasets: # optional; files without it stay valid
444
+ daily:
445
+ source: demo_db # a source from data-sources/config.yaml
446
+ table: fact_daily # + table, optional where: "day_number = 1"
447
+ columns: {total_revenue: "iap_revenue + ad_revenue"} # derived per row
448
+ # provider: metrics_data:daily # OR a Python (ctx, req) -> DataFrame
449
+ time: {column: event_date, grain: day} # hour|day|week|month, or time: none
450
+ dimensions: [title, platform, region] # what the dataset can be filtered by
451
+ lookback: 90 # default window, days
452
+ metrics:
453
+ - name: gross_revenue # ^[a-z0-9][a-z0-9_]*$
454
+ label: "Gross Revenue"
455
+ description: >
456
+ IAP plus ad revenue, gross of platform fees, daily grain.
457
+ owner: finance@example.com
458
+ format: currency # any KpiCard format
459
+ agg: sum # sum | abssum | count over `column`,
460
+ column: total_revenue # or agg: ratio with numerator/denominator
461
+ dataset: daily # the binding; absent = listed, not monitored
462
+ time_agg: avg # optional: avg (per period) | last (default: sum)
463
+ sql: "iap_revenue + ad_revenue" # canonical derivation — informational
464
+ dimensions: [event_date, title] # informational
465
+ tags: [revenue]
466
+ ```
467
+
468
+ A metric **without** an `agg` spec is *descriptive*: claimable only where a
469
+ `value` is supplied (static KpiCard/MiniKpi). A metric **with** a spec is
470
+ *executable* and can be claimed bare inside a live `KpiRow(dataset_id=...)`.
471
+ `time_agg: avg` makes a bare claim expand to the client's `avg_by_date` over
472
+ the dataset's time column (a DAU card reads "average DAU over the window");
473
+ `time_agg: last` (balances) is rendered from a Python-computed `value=` for
474
+ now. `dataset` and `time_agg` are provenance, not meaning, so they do not
475
+ move `definition_hash`.
476
+
477
+ **`python -m trellum metrics --report`** scaffolds `reports/metrics/`: an
478
+ all-metrics-at-a-glance page for every bound metric — one DataSource per
479
+ dataset, then one block per metric (a KPI and a trend), grouped by tag, plus a
480
+ table of the metrics defined but not bound — from a one-line generator. Bind a
481
+ metric and rebuild; nothing else to edit.
482
+
483
+ The blocks in a tag sit **side by side**, in `Grid(min_width=340)` — an
484
+ overview is metrics read against each other, not one chart per screenful. The
485
+ grid is `auto-fill`, so the number across follows the available width (three
486
+ at the container's 1400px cap, two around 900px, one on a phone) with no
487
+ breakpoints to keep in sync, and each cell is a **tile**: `grid.css` shortens
488
+ its chart to 200px, drops the chart's own title (the block above it already
489
+ carries the name) and hides the breakdown toggle, and measures a `KpiRow`
490
+ against the tile instead of the window. `Grid(min_width=...)` is the general
491
+ primitive, not a metrics-report special case — reach for it whenever a row of
492
+ charts should reflow rather than sit at a fixed column count.
493
+
494
+ Two controls, two scopes. The **date range** is shared: one FilterBar on the
495
+ first time dataset, propagated onto every other time dataset's own time
496
+ column, because "the last 30 days" means the same thing at every grain. A
497
+ dataset with no time axis is left out of it rather than filtered on a column
498
+ it does not have. The **breakdown is per metric**: each trend carries its own
499
+ `stack_by_options` toggle over its dataset's dimensions ("Total" first, so no
500
+ split is the default) — hidden at tile width, where a row of dimension buttons
501
+ is taller than the chart it labels, and back in the single-block view below.
502
+ There is deliberately no report-wide dimension filter — datasets share a time
503
+ axis but not their dimensions.
504
+
505
+ Each block is a `Section` with `anchor="metric-<name>"`, which makes it
506
+ addressable: **`?only=<section id>`** on any report page renders that section
507
+ alone (its ancestors and the shared FilterBar with it, page chrome dropped),
508
+ which is what an `<iframe>` onto a single block loads and what `#<id>` scrolls
509
+ to. The tile grid collapses there — one block is not a grid — so the surviving
510
+ block gets the whole frame at full size, breakdown toggle included. An id the
511
+ build does not have leaves the page whole. Give any generated section an
512
+ `anchor` when its title is not a name you want in a URL.
513
+
514
+ What the build produces: `data.json` gains a top-level `_metrics` block
515
+ (claimed metrics only — label, format, agg spec, version, `definition_hash`,
516
+ claiming component ids) and `_meta.json` gains `metrics_used:
517
+ [{"id", "definition_hash", "version"}, ...]`, so anything reading built
518
+ artifacts can group KPIs by business meaning, and tell a claim's build-time
519
+ definition apart from the metric's current one without opening `data.json`
520
+ (read it via `trellum.meta.normalize_metrics_used`, which also accepts an
521
+ older build's bare-id-list shape).
522
+
523
+ Validation (`metrics` check group): `metrics-yaml-schema` (broken file —
524
+ duplicate ids, bad slugs, unknown format/agg, a `dataset` ref that does not
525
+ exist, a dataset with neither `provider` nor `source` + `table`, a dataset
526
+ without `time`), `metric-undefined` (claim of an id metrics.yaml does not
527
+ define — FAIL), `metric-column-missing` (the claim cannot compute: definition
528
+ columns absent from the DataSource, or a claim with no value where one is
529
+ required — FAIL), `metric-overridden` (WARN), `metric-description-weak`
530
+ (INFO).
531
+
532
+ CLI: `python -m trellum metrics` lists definitions, datasets and claim
533
+ coverage; `python -m trellum metrics <name>` prints one full definition;
534
+ `python -m trellum metrics --lint` reports duplicates and orphans;
535
+ `python -m trellum metrics --report` scaffolds the monitoring report.
536
+
537
+ <!-- topic: rawhtml -->
538
+ ## RawHTML chart lifecycle (mandatory when the chart lives inside a `Visible`)
539
+
540
+ If your `RawHTML` creates a Chart.js instance and the `RawHTML` is anywhere inside a toggle-driven `Visible`, the JS **must** patch BOTH `_initToggleVis` and `renderAll`, AND defer the render with `requestAnimationFrame`:
541
+
542
+ ```js
543
+ function _refresh() {
544
+ if (fw.filterEngine.isReady(dsId)) _render(canvasId, dsId, fw.filterEngine.getFiltered(dsId));
545
+ }
546
+
547
+ fw.filterEngine.subscribe(dsId, 'mychart', _render);
548
+ window.addEventListener('fw-theme-change', _refresh);
549
+
550
+ // _initToggleVis runs on initial page load (renderAll does NOT — the framework
551
+ // calls _renderComps() directly on first load). Without this hook the chart is
552
+ // blank on first load and on URL-preloaded state.
553
+ window._initToggleVis = (function (prev) {
554
+ return function () { if (prev) prev(); requestAnimationFrame(_refresh); };
555
+ })(window._initToggleVis);
556
+
557
+ // renderAll catches scope switches, theme changes, auto-refresh.
558
+ window.renderAll = (function (prev) {
559
+ return function () { if (prev) prev(); requestAnimationFrame(_refresh); };
560
+ })(window.renderAll);
561
+ ```
562
+
563
+ Why each piece:
564
+
565
+ | Hook | Catches | Without it |
566
+ |---|---|---|
567
+ | `fw.filterEngine.subscribe(dsId, ...)` | filter changes | chart never updates when user filters |
568
+ | `fw-theme-change` listener | theme switches | chart keeps old palette |
569
+ | `window._initToggleVis` patch | **initial page load + URL preload** | chart blank on first load — only renders after user clicks a toggle |
570
+ | `window.renderAll` patch | scope switches, auto-refresh, theme switches | chart stale after re-renders |
571
+ | `requestAnimationFrame` defer | layout reflow after `_updateToggleVis` flips `display` | Chart.js measures canvas at 0×0 → blank chart even though parent is visible |
572
+
573
+ For multi-canvas patterns (one canvas per per-feature DataSource), use a `_ensureSubs()` that finds canvases by attribute (e.g. `[data-split-ds]`) and lazily subscribes once each, then `requestAnimationFrame(_refreshAll)` from both lifecycle hooks.
574
+
575
+ <!-- topic: filters -->
576
+ ## DataSource + FilterBar rules
577
+
578
+ - Every report has at least one `DataSource` + `FilterBar` pair (exceptions: `ABCompare`-only reports; single-day dashboards may omit the date_range filter but should still use `DataSource` for any filterable dimension).
579
+ - `date_range` filter first when there's a time-series dimension; one filter per useful categorical dimension.
580
+ - **One grain = one DataSource.** Different grain (monthly MAU vs daily revenue) gets its own `DataSource`. Use `propagate_to` to sync shared dimensions.
581
+ - `static=True` only when data is fundamentally incompatible with the report's filters (point-in-time snapshot, external system data with different date semantics) — rare.
582
+
583
+ ### Two layout patterns: main vs section FilterBar
584
+
585
+ The framework supports two FilterBar placements, and they compose:
586
+
587
+ **Main FilterBar** (the default for most reports). Placed in an
588
+ **untitled section** at the top. Use it for filters that apply across
589
+ the whole report — typically `date_range`, `audience_segment`,
590
+ `platform`, etc. The main FilterBar reaches its primary DataSource
591
+ plus any DataSources listed in `propagate_to`. Sticks to the viewport
592
+ top.
593
+
594
+ ```python
595
+ ctx.add_section("", [ # untitled section — sticky top
596
+ DataSource("daily", df_daily, chunk_by="month"),
597
+ DataSource("monthly", df_monthly),
598
+ FilterBar("daily", df_daily, filters=[
599
+ {"column": "event_date", "type": "date_range"},
600
+ {"column": "platform"},
601
+ ], propagate_to={
602
+ "monthly": {"event_date": "event_date"},
603
+ }),
604
+ ])
605
+ ```
606
+
607
+ **Section-scoped FilterBar** (drill-downs). Placed inside a titled
608
+ section right after that section's DataSource(s). Use it when a
609
+ section has a dimension that doesn't make sense for the rest of the
610
+ report — e.g. `chest_level` only applies to chest charts. Section
611
+ FilterBars compose with the main FilterBar: a chart inside a section
612
+ is filtered by the AND of both. Sticks below the main FilterBar while
613
+ its section is in view.
614
+
615
+ Canonical layout for a section-scoped pair (validator recognizes this
616
+ exact order):
617
+
618
+ ```python
619
+ ctx.add_section("Chest Daily Trends", [
620
+ DataSource("chest_daily", df_chest),
621
+ FilterBar("chest_daily", df_chest, filters=[
622
+ {"column": "chest_level"},
623
+ {"column": "difficulty_tier"},
624
+ ]),
625
+ LineChart(..., dataset_id="chest_daily"),
626
+ LineChart(..., dataset_id="chest_daily"),
627
+ ])
628
+ ```
629
+
630
+ ### Section-local filters on the SAME data — use `ScopedDataSource`
631
+
632
+ When a section needs **another local filter** on data that's already
633
+ loaded via the main DataSource (e.g. "Price Tier" filter that only
634
+ affects one chart, not the whole report), use a `ScopedDataSource`
635
+ instead of a fresh DataSource. It:
636
+
637
+ - inherits all of the parent's filters automatically — no `propagate_to`
638
+ wiring needed
639
+ - ships no extra data — rows are derived from the parent at runtime
640
+ - composes with the section's own FilterBar (parent filters AND child
641
+ filters)
642
+
643
+ ```python
644
+ # Untitled top section: ONE base DataSource + main FilterBar
645
+ ctx.add_section("", [
646
+ DataSource("cpd", df),
647
+ FilterBar("cpd", df, filters=[
648
+ {"column": "event_date", "type": "date_range"},
649
+ {"column": "package_group"},
650
+ ]),
651
+ ])
652
+
653
+ # Drill-down section with its OWN local filter
654
+ ctx.add_section("CPD by Price Point", [
655
+ ScopedDataSource("cpd_pp", parent="cpd"),
656
+ FilterBar("cpd_pp", df, filters=[
657
+ {"column": "price_tier"}, # only affects this section
658
+ ]),
659
+ LineChart(df=df, x="event_date", dataset_id="cpd_pp",
660
+ ratios=[{"numerator": "chips", "denominator": "revenue",
661
+ "label": "CPD"}],
662
+ stack_by="price_point_display",
663
+ title="CPD by Price Point"),
664
+ ])
665
+ ```
666
+
667
+ **Cascading dropdowns** (e.g. *Package Group* → *Package Name*): add
668
+ `depends_on` to the child filter spec. The child's option list narrows
669
+ automatically when the parent selection changes:
670
+
671
+ ```python
672
+ FilterBar("cpd_pkg", df, filters=[
673
+ {"column": "package_group"},
674
+ {"column": "package_name", "depends_on": "package_group"},
675
+ ])
676
+ ```
677
+
678
+ ### Picking a filter type
679
+
680
+ A slider fits a continuous numeric range (a discount %, a price, a
681
+ quantity) -- somewhere enumerating every value as `dropdown` options
682
+ would be absurd. A dropdown fits categorical values. A toggle fits a
683
+ handful of distinct values. A date column always stays on `date_range`
684
+ -- never model it as a `slider`, even though both are range-shaped.
685
+
686
+ ```python
687
+ FilterBar("orders", df, filters=[
688
+ {"column": "discount_pct", "label": "Discount %",
689
+ "type": "slider", "format": "percent"},
690
+ ])
691
+ ```
692
+
693
+ `mode="range"` (the default) gives a two-handle min/max band; commits
694
+ land in the filter engine as its own `numrange` mode (numeric
695
+ comparison -- unlike the string-compared `range` mode `date_range`
696
+ uses, which would misorder plain numbers). `mode="single"` snaps to one
697
+ of the column's distinct values and commits through the existing
698
+ `equals` mode, same as a dropdown's single selection. The validator
699
+ flags a slider on a non-numeric column (`filter-slider-not-numeric`).
700
+
701
+ #### Ordinal sliders: sliders over ORDERED CATEGORIES
702
+
703
+ A slider also fits ORDERED categories that have no numeric value of
704
+ their own -- a spender tier, a severity level, a size class. Give it
705
+ `"values"` (the ordered list) instead of letting it derive bounds from
706
+ `df[col].min()/.max()`; the column doesn't need to be numeric:
707
+
708
+ ```python
709
+ FilterBar("players", df, filters=[
710
+ {"column": "spender_tier", "label": "Spender Tier", "type": "slider",
711
+ "values": ["non_spender", "minnow", "dolphin", "whale"],
712
+ "labels": {"non_spender": "Non-spender", "minnow": "Minnow",
713
+ "dolphin": "Dolphin", "whale": "Whale"},
714
+ "mode": "range", "default_min": "minnow", "default_max": "whale"},
715
+ ])
716
+ ```
717
+
718
+ Internally the values map to integer positions (0, 1, 2, ...) so
719
+ noUiSlider can drive them -- pips at each position show `labels`
720
+ (fallback: the raw value) if they fit the slider's width, otherwise
721
+ just the two endpoints. `mode="range"` commits the CONTIGUOUS SPAN of
722
+ selected categories as a list through the existing `'in'` mode --
723
+ exactly what a dropdown multi-select commits, so `propagate_to`,
724
+ `depends_on` cascades, and URL sync all keep working with no changes.
725
+ `mode="single"` commits the selected category through `equals`, same
726
+ as the numeric slider's single mode.
727
+
728
+ Reach for the ordinal slider only when the categories have a real
729
+ order the reader would drag through end to end; an unordered set (e.g.
730
+ `platform`, `country`) stays a `dropdown` -- a slider implies an order
731
+ that doesn't exist there.
732
+
733
+ The validator extends the same slider check for the ordinal case:
734
+ `values` must be a non-empty list of strings and any default must be
735
+ one of them (`filter-slider-not-numeric`, fail); a listed value that
736
+ never occurs in the DataFrame is flagged (`filter-slider-value-unused`,
737
+ warn, not fail) -- a tier legitimately going empty under a narrow date
738
+ filter is normal, not a broken config.
739
+
740
+ ### Custom RawHTML lifecycle
741
+
742
+ When you do need custom JS (genuinely novel visualization), always use
743
+ the public `fw.*` API:
744
+
745
+ ```js
746
+ fw.filterEngine.onReady('cpd', function() {
747
+ // engine is ready — wire your chart now
748
+ fw.filterEngine.subscribe('cpd', 'my-chart', _render);
749
+ _render(fw.filterEngine.getFiltered('cpd'));
750
+ });
751
+ ```
752
+
753
+ **Never** poll with `setTimeout(init, 100)`, **never** reference
754
+ `window._fwFilterEngine` directly, **never** instantiate `new SlimSelect(...)`
755
+ in RawHTML. Each of those is a validator WARN now. Use FilterBar +
756
+ ScopedDataSource for the dropdown UX; use `fw.filterEngine.onReady` for
757
+ ready-detection; use `fw.filterEngine.*` for everything filter-related.
758
+
759
+ Validator constraints:
760
+
761
+ - At most one main FilterBar per report (untitled top section).
762
+ - At most one section-scoped FilterBar per titled section.
763
+ - Section-scoped pair must be laid out as `[DataSource(s) or ScopedDataSource, FilterBar, ...content]` at the start of the section.
764
+ - A `ScopedDataSource`'s `parent` must reference a base `DataSource` (not another scoped child).
765
+ - A filter with `depends_on` must reference another filter on the same FilterBar with a real column in the underlying DataFrame.
766
+
767
+ Use `ScopedDataSource` when a section filters the *same* rows more narrowly. When the grain genuinely differs, give each section its own `DataSource` instead — different data is not a scope of the same data.
768
+
769
+ <!-- topic: live-queries -->
770
+ ## Live queries: per-entity lookups a snapshot cannot carry
771
+
772
+ **Compiled is the default; live is the rare exception.** Every component
773
+ you have used so far — `DataSource`, `FilterBar`, every chart, every
774
+ table — computes at build time and ships its rows baked into the artifact.
775
+ That is the right trade almost always: it is fast, it works from a share
776
+ link or an emailed copy, and it needs nothing running to keep working.
777
+ Reach for a live query ONLY when the question is genuinely per-entity and
778
+ the entity count is too large to bake in full — "type a user id, see that
779
+ user's raw events" against a table with a million users, not "show revenue
780
+ by day" or anything a `FilterBar` over a compiled `DataSource` already
781
+ answers. If a compiled component can answer it, use the compiled
782
+ component; a live query is the opt-in exception, not a habit.
783
+
784
+ **The snapshot is ONE slice, never the whole table.** Declaring a live
785
+ query still bakes something in — the build runs the declared query once,
786
+ at build time, for a single parameter set you choose
787
+ (`snapshot_params`), and stores that one result as the ordinary snapshot
788
+ every other dataset already gets. A million-user table does not become a
789
+ million-row artifact; it becomes one example user's rows, plus a promise
790
+ that a serving host can fetch any other user's on demand. Give
791
+ `snapshot_params` so the un-hosted page still shows something real — the
792
+ `live-query-no-snapshot` WARN flags the alternative (an empty control,
793
+ standalone).
794
+
795
+ **A live query is a filter-engine DATASET, not a bespoke control.**
796
+ `ctx.declare_live_query` registers the query; a `LiveDataSource` puts its
797
+ snapshot into the filter engine under a `dataset_id` exactly like an
798
+ ordinary `DataSource`; an ordinary `FilterBar` drives it by naming, on
799
+ each filter, the declared param that filter's column feeds. Every
800
+ chart/table/KPI row built against that `dataset_id` reacts exactly as it
801
+ would to a compiled dataset — it does not know its rows came from a POST
802
+ instead of a local filter.
803
+
804
+ ```python
805
+ # generator.py
806
+ events = ctx.declare_live_query(
807
+ "user_events", # slug: ^[a-z0-9][a-z0-9_-]*$
808
+ queries.USER_EVENTS, # SQL with :user_id, :since, :until
809
+ datasource="demo_db", # must be a SQL source — see below
810
+ params=[
811
+ {"name": "user_id", "type": "int", "required": True},
812
+ {"name": "since", "type": "date", "required": True},
813
+ {"name": "until", "type": "date", "required": True},
814
+ ],
815
+ snapshot_params={"user_id": 1042, "since": "2026-01-01", "until": "2026-02-01"},
816
+ )
817
+
818
+ ctx.add_section("Events", [
819
+ LiveDataSource("events", query="user_events", df=events),
820
+ FilterBar("events", events, filters=[
821
+ {"type": "text", "column": "user_id", "param": "user_id",
822
+ "label": "User ID", "placeholder": "e.g. 1042"},
823
+ {"type": "date_range", "column": "ts",
824
+ "min_param": "since", "max_param": "until", "label": "Date"},
825
+ ]),
826
+ KpiRow(dataset_id="events", kpis=[{"label": "Events", "agg": "count"}]),
827
+ DataTable(events, title="Raw events", dataset_id="events", sortable=True),
828
+ ])
829
+ ```
830
+
831
+ Param `type` is one of `int|float|str|date|enum`; `enum` carries
832
+ `values: [...]`; `str` may carry `max_length` (default 200). A filter's
833
+ `param` key (or `min_param`/`max_param` for a range-shaped filter —
834
+ `slider`, `date_range`) is what maps it to a declared query param;
835
+ `dropdown`/`toggle`/`flag` bind an `enum` param, `slider`/`date_range` bind
836
+ numeric/date scalars, `text` binds any scalar. Live-bound dropdowns are
837
+ single-select in v1 (`"multi": False`). A second `LiveDataSource` can share
838
+ the first `FilterBar`'s filters instead of rendering its own, via
839
+ `propagate_to` on the `FilterBar` — one filter change then drives two
840
+ queries; see `demo/reports/user-event-log` for a live grouped-aggregate
841
+ query fed this way.
842
+
843
+ The declaration writes `_live_queries.json` (SQL + param schema) beside
844
+ `_meta.json`, for a host to read. The SQL never enters `data.json` or
845
+ `index.html` — the page carries only the query id, the declared param
846
+ schema, and (for the FilterBar's own dataset) the snapshot rows. Commits
847
+ are auto-query for dropdown/toggle/date-preset/slider-release, explicit
848
+ (Enter/blur) for text — typing alone never fires a query.
849
+
850
+ **Combine freely with compiled components.** A live dataset and a compiled
851
+ one can sit in the same report, even the same section — nothing requires
852
+ an all-or-nothing choice. `demo/reports/user-event-log` puts a live
853
+ `FilterBar`-driven KPI row, two charts and a table above a plain compiled
854
+ `LineChart(..., static=True)` showing the same metric aggregated over the
855
+ *entire* history: the live components answer "show me this one thing in
856
+ detail, right now", the compiled chart answers "how does this look in
857
+ aggregate, over everything" — a live per-entity lookup is a poor way to
858
+ answer that, and a compiled aggregate answers it for free. Reach for
859
+ `static=True` on a chart/table sharing a section with live components
860
+ whenever what it shows is a compiled aggregate, not a per-entity slice.
861
+
862
+ **The standalone degradation.** Without a host advertising `live_query_url`
863
+ (standalone build, share link, email snapshot, an old host, or — the
864
+ common case while you are writing the report — an artifact just opened
865
+ from disk), every live `FilterBar` renders server-side disabled with the
866
+ label "Live lookup — available when served by a host; showing data from
867
+ the last build", and the baked snapshot stands. This is not a degraded
868
+ error state; it is correct behavior with nobody to ask. Two hosts
869
+ currently implement the fetch: the portal in production, and `trellum
870
+ serve` for local development — the dev server answers the same request
871
+ shape against the project's own data sources when the report you are
872
+ serving declared a live query, through the identical `coerce_params` /
873
+ `check_sql_safe` / `inject_limit` guard rails
874
+ (`trellum/data/live_query_guard.py`) the portal's production endpoint
875
+ enforces, so what works locally is a real preview of what the portal will
876
+ do. Use `trellum serve` (not a bare file open) while iterating on a live
877
+ query if you need to see it actually fetch.
878
+
879
+ **SQL sources only.** The host executes the manifest's SQL on demand, so
880
+ the datasource must have a connection driver (`sqlite`, `duckdb`,
881
+ `postgres`, `mysql`, `bigquery`, ...). File/API/sheet sources fail the
882
+ `live-query-source-not-sql` check. Referencing an undeclared query id is
883
+ the `live-query-unknown` FAIL; a bad param schema (including `required:
884
+ false`, which literal substitution cannot support) is
885
+ `live-query-param-schema`; the SQL's `:name` placeholders and the declared
886
+ params must agree in both directions (`live-query-sql-params` — an
887
+ undeclared placeholder FAILs, an unused param WARNs); a declared param no
888
+ filter binds is `live-query-param-uncovered` — it could never change from
889
+ its snapshot default. `trellum checks` lists every `live-query-*` id (and
890
+ every other check id) with its level; `trellum guide validation` explains
891
+ suppression.
892
+
893
+ <!-- topic: review -->
894
+ ## The live review loop
895
+
896
+ Review mode turns a served report into a feedback surface: the user clicks
897
+ elements in the browser, types change requests, queues them with one optional
898
+ chat message, and hits **Send** — you receive the batch in the terminal with
899
+ each item's section title, component kind and title, DOM id, and selector,
900
+ which map straight to lines of `generator.py`.
901
+
902
+ The loop, from your seat:
903
+
904
+ 1. `python -m trellum review start reports/<slug>` — builds if output is
905
+ missing (`--rebuild` to force), starts or reuses the background server,
906
+ enables review mode, and opens the browser. If a server predating review
907
+ mode holds the port, rerun with `--restart-server`.
908
+ 2. `python -m trellum review poll` — **BLOCKS** until the user sends
909
+ feedback. From an agent harness, run it with a generous timeout or as a
910
+ tracked background task; if it gets killed, just rerun it — queued
911
+ feedback is never lost. Exit codes: `0` feedback arrived, `2` your
912
+ `--timeout` elapsed, `3` the user ended the session.
913
+ 3. Edit `reports/<slug>/generator.py` and rebuild with
914
+ `python -m trellum.run reports/<slug> --no-serve`. The browser notices
915
+ the rebuild and reloads itself within ~2 seconds — never hand-edit
916
+ `output/` HTML, and never restart the server to "refresh".
917
+ 4. `python -m trellum review poll --reply "what you changed"` — the reply
918
+ appears in the browser's chat and unlocks the user's Send button, then
919
+ the command waits for the next round.
920
+ 5. Repeat until poll exits `3` (the user pressed End, or Send & End — that
921
+ final batch still arrives first). `python -m trellum review end` ends
922
+ it from your side when the user asks you to wrap up in conversation.
923
+
924
+ **Several reports, several agents, one project:** one server serves the
925
+ whole `output/` tree and one review session spans it — every report page
926
+ gets the overlay, and batches are tagged with their report's slug. When more
927
+ than one agent works the same project, each MUST poll with
928
+ `--report <slug>` (and reply with `--report <slug>`): a slug-scoped poll
929
+ drains only that report's batches, so agents never steal each other's
930
+ feedback. A slug-less poll drains everything — fine only when you are the
931
+ only agent. Different projects are automatically separate: each output
932
+ directory gets its own server on its own port (discovered via
933
+ `/_fw/server.json` identity on ports 8050–8069), with fully independent
934
+ review state.
935
+
936
+ The user can also send element-free chat messages; they arrive as a batch
937
+ whose `items` list is empty and whose message is the `note`. And on Chart.js
938
+ charts they can point at the data itself — Alt-click picks the nearest data
939
+ point, dragging marks an x-range — which arrives as a `data` field on the
940
+ item (`chart-point`: series/x/value; `chart-range`: x_from/x_to plus
941
+ per-series n/min/max and the points). That is a data investigation, not a
942
+ styling request: answer it with `trellum query` against the exact dates
943
+ before touching any code. Everything is served from the normal `trellum
944
+ serve` server — review endpoints live under `/_fw/review/`, loopback-only,
945
+ and the on-disk HTML is never modified (the overlay is injected into the
946
+ served copy only).
947
+
948
+ <!-- topic: portal -->
949
+ ## The portal loop (when `.mcp.json` names a `trellum` server)
950
+
951
+ A repository wired with `trellum setup portal` publishes to one studio on the
952
+ portal, and that studio is reachable as MCP server `trellum`: the same tools
953
+ the portal's own assistant has, under the key owner's role. The key is
954
+ `TRELLUM_API_KEY` in `.env`; the config files only reference it. Never paste
955
+ it anywhere else, and never put a password, key or token into a tool call.
956
+
957
+ The loop, in order. Local work first — nothing on the portal changes until
958
+ the repository does:
959
+
960
+ 1. **Edit** `reports/<slug>/` and build it:
961
+ `python -m trellum.run reports/<slug> --no-serve`.
962
+ 2. **Validate**: `python -m trellum validate reports/<slug>` — no FAILs.
963
+ 3. **Commit and push.** The portal fetches the branch it is configured for;
964
+ it never reads your working tree.
965
+ 4. **`check_repo_changes`** — what the portal would publish: the remote head
966
+ it last saw and when (`remote_checked_at`), the reports added, modified and
967
+ removed since the last publish, data-source declarations that changed, and
968
+ the last error. In `auto` publish mode every fetch publishes itself and
969
+ nothing stays pending; in `manual` mode this is the review step. Say what
970
+ is pending in one line before asking to publish.
971
+ 5. **`publish_repo_changes`** (manual mode) — publishes at the reviewed head.
972
+ 6. **`run_report`** queues a build; **`get_report_details`** reads the result:
973
+ last run, status, and the validator summary (fail / warn / suppressed) as
974
+ the portal saw it. A FAIL here is the same FAIL `trellum validate` shows
975
+ locally — fix it in the repository, not on the portal.
976
+ 7. **Data-source failure** (a build that fails in the driver, or a source the
977
+ portal marks blocked): **`test_data_source`** re-runs the connection test
978
+ and reports the detail line. If credentials are missing or wrong,
979
+ **`configure_data_source`** does not take them — it returns the portal's
980
+ Configure link, and a person types the secret there. Tell the user which
981
+ source, hand them the link, then re-run the test.
982
+ 8. **`create_alert`** when the user wants to be told about something: a
983
+ report, plain-language instructions ("tell me if APAC DAU drops sharply"),
984
+ a trigger (after each build, or a schedule) and recipients. `update_alert`
985
+ changes one. The portal's own agent decides at each run whether to alert.
986
+
987
+ Scope: `check_repo_changes`, `list_reports`, `get_report_details`,
988
+ `query_report_data` and `read_doc` work on a `read` key.
989
+ `publish_repo_changes`, `run_report`, `test_data_source`,
990
+ `configure_data_source`, `create_alert` and `update_alert` need `write` scope
991
+ *and* the organization's actions switch on; the harness asks before each
992
+ call, and every call is audited under the key. A viewer gets the read tools
993
+ whatever the key's scope; `check_repo_changes` needs the developer role.
994
+
995
+ The portal never edits the repository, and neither should you through it:
996
+ a fix that belongs in `report.yaml`, a suppression or `metrics.yaml` is a
997
+ commit. A missing secret is never worked around by asking for it in chat.
998
+
999
+ <!-- topic: validation -->
1000
+ ## Validator response protocol (mandatory)
1001
+
1002
+ Every report run executes `trellum/validation/` post-generation. Output goes to console + `output/<slug>/_validation.json`.
1003
+
1004
+ A report task is **not done** until you have:
1005
+
1006
+ 1. Read the validator output.
1007
+ 2. Surfaced every **FAIL** and **WARN** to the user with check ID, affected component/section, and a concrete fix.
1008
+ 3. **Fixed every FAIL.** WARNs should be fixed unless intentionally accepted (then suppress in `report.yaml` under `validation.suppress` with a one-line reason for why).
1009
+ 4. If the report has zero FAILs and zero WARNs, said so explicitly.
1010
+ 5. **Handed the user a clickable link.** The last action before reporting done is `python -m trellum serve --background` — it starts (or reuses) a detached server, prints the URLs, and returns immediately — and your final message to the user must contain the printed report URL. A finished build the user cannot click is not finished. (Keep using `--no-serve` for the build/iterate loop itself; the link is the closing move, not a per-build cost.)
1011
+
1012
+ Severity:
1013
+
1014
+ | Level | Action |
1015
+ |---|---|
1016
+ | **FAIL** | Must fix. Structural bug → broken rendering, missing interactivity, wrong data. Common: missing `dataset_id`, orphan FilterBar, missing DataFrame columns. |
1017
+ | **WARN** | Fix unless accepted + suppressed. Common: non-date x-axis with annotations enabled (silently skipped), hardcoded colors (breaks on theme switch), missing `renderAll` in custom JS. |
1018
+ | **INFO** | No action required. Just confirm intent. |
1019
+
1020
+ Full check list, suppression syntax, and how to add new checks: `trellum/README.md` "Validation".
1021
+
1022
+ ### Filter coverage check
1023
+
1024
+ The validator emits **`chart-filter-coverage`** (WARN) for every chart whose `dataset_id` is not reached by one or more FilterBar filters — the most common silent bug, where users see filter dropdowns that change nothing on certain charts. The fix is almost always to extend `FilterBar.propagate_to` with the missing `{src_col: tgt_col}` entry.
1025
+
1026
+ A sibling check, **`chart-value-grain-mismatch`** (WARN), catches a subtler failure mode: the chart's value column is constant within each x-axis value (e.g. a daily total merged on `event_date` only into a date × audience × spender DataSource). Filters reach the DataSource structurally, but the chart's plotted value never changes — ratios stay constant, absolutes inflate by the count of replicated rows. Fix by extracting the column to its own DataSource at native grain (date-only) and filtering by `event_date` only there.
1027
+
1028
+ When a filter genuinely cannot apply to a DataSource (its SQL doesn't carry that dimension and adding it would require fictional attribution — e.g. attaching a `template_type` to a purchase event), accept the gap **per (DataSource, filter_column)** in `report.yaml`:
1029
+
1030
+ ```yaml
1031
+ validation:
1032
+ accept_inactive_filters:
1033
+ tc_rev_gop3:
1034
+ - template_type # purchases don't carry a template_type — see queries.py:156
1035
+ tc_boost_gop3:
1036
+ - template_type # boosters live in economy_balance, no template dim
1037
+ - chest_tier
1038
+ ```
1039
+
1040
+ This is the **preferred** form because future dead filters on the same DataSource still fire — only the explicitly-listed columns are silenced. The broader `validation.suppress_per_dataset: {ds: [chart-filter-coverage]}` swallows ALL future inactive filters on that DS and should be reserved for DataSources that are fundamentally non-reactive (tiny static rollups feeding one chart).
1041
+
1042
+ Full per-chart, per-filter matrix lives at `output/<slug>/_details.json` (also embedded in `_meta.json` under `details.filter_matrix`) and can be rendered as a coverage table. `details.totals` carries dataset count, chart count, total row count, total serialized data size — so you can see at a glance how heavy a report is. There is also a `data_source: "real" | "mock"` flag and an amber banner when the run was a mock/test build, so synthetic row counts don't get read as production reality.
1043
+
1044
+ With the health view enabled (`?health=1` in the URL, or an embedding application that reports the viewer as an admin), the rendered report itself shows a small **filter-health badge** above each chart whose dimensions are not all reactive — a red `⚠ N/M filters dead` chip that pops a styled per-filter detail panel on hover. Hidden by default for non-admins so end-users see a clean report.
1045
+
1046
+
1047
+ ### Suppression hierarchy
1048
+
1049
+ Three layers, narrowest → broadest:
1050
+
1051
+ ```yaml
1052
+ validation:
1053
+ # Granular, PREFERRED — accept specific (DataSource, filter_column) pairs
1054
+ # as inactive. Any OTHER inactive filter on the same DS still fires.
1055
+ accept_inactive_filters:
1056
+ pf_country_gop3: [platform]
1057
+ pf_cz_gop3: [platform, spender_tier]
1058
+
1059
+ # Per-DataSource — silences the named check ID for ALL filters on that DS.
1060
+ # Use only when the DataSource is fundamentally non-reactive across every
1061
+ # dimension (tiny static rollup feeding one chart).
1062
+ suppress_per_dataset:
1063
+ economy_test_net: [ds-no-filterbar]
1064
+
1065
+ # Global — silences every instance of the named check ID across the report.
1066
+ # Last resort; use for systemic exemptions (e.g. annotations-non-date-x-axis
1067
+ # on a report that is intentionally month-bucketed).
1068
+ suppress:
1069
+ - some-systemic-check
1070
+ ```
1071
+
1072
+ Suppressed checks are NOT removed from the result — they're flagged with `suppressed: true` and grouped under **Suppressed** with rationale text. The matrix renders suppressed inactive cells as `✗ⓢ` so a reviewer can audit later. Always pair a suppression with a `# rationale:` YAML comment naming the specific reason.
1073
+
1074
+ ## After framework changes (only if explicitly asked)
1075
+
1076
+ If the task explicitly requires modifying `trellum/*`:
1077
+
1078
+ 1. **Add or update validation checks in `trellum/validation/`** for the new behavior — non-negotiable. Skipping silently breaks future reports.
1079
+ 2. Run unit tests:
1080
+ - `python3 -m pytest trellum/testing/test_components.py -v` — component changes
1081
+ - `python3 -m pytest trellum/testing/test_js_runtime.py -v` — JS runtime changes
1082
+ - `python3 -m pytest trellum/testing/test_interactions.py -v` — theme / rendering changes
1083
+ 3. Rebuild a couple of this project's existing reports to verify nothing broke —
1084
+ pick ones that between them cover charts, KPIs, filters and any RawHTML:
1085
+ ```bash
1086
+ python3 -m trellum.run reports/<slug> --no-serve
1087
+ ```
1088
+ 4. For broader coverage, `python3 -m trellum.run --all --no-serve`.
1089
+
1090
+ ## This framework ships from the monorepo
1091
+
1092
+ The framework lives at `trellum/` in the Trellum product monorepo and is also
1093
+ published as a standalone wheel. It is not a separate repository or submodule,
1094
+ so framework changes use an ordinary monorepo branch and commit.
1095
+
1096
+ The control plane and framework share one version:
1097
+ `trellum_portal/__init__.py` and `trellum/__init__.py` must agree. After changes
1098
+ merge to `main`, an annotated `vX.Y.Z` tag starts the release workflow, which
1099
+ checks both versions, publishes the product images, and creates the GitHub
1100
+ Release. Do not create releases manually or maintain a moving release branch.
1101
+ The framework workflow builds and tests the standalone wheel; publishing that
1102
+ wheel to PyPI is a separate protected dispatch at the immutable release tag.
1103
+
1104
+ The complete versioning rules, checks, and release procedure are in
1105
+ [`RELEASING.md`](RELEASING.md). Read it before tagging a release or changing
1106
+ either `__version__`.
1107
+
1108
+ ## What NOT to do
1109
+
1110
+ - **Do not edit `trellum/*` while building a report.** Reports only touch `reports/{slug}/`. If a task seems to require framework changes, stop and discuss scope with the user — framework changes affect every report.
1111
+ - **Do not run `--all` to verify a single change.** Build only the affected report (`python3 -m trellum.run reports/<slug> --no-serve`).
1112
+ - **Do not start the preview server to check whether a build worked.** The build prints its own result and the validator writes `output/<slug>/_validation.json`. A bare `trellum.run` blocks until killed; `--no-serve` exits.
1113
+ - **Do not write custom Chart.js code for any chart type listed above.** Use the framework component.
1114
+ - **Do not hardcode colors.** CSS variables for HTML/CSS, `getThemeColors()` for Chart.js.
1115
+ - **Do not import Chart.js or other JS libraries.** Bundled in `trellum/static/vendor/`.
1116
+ - **Do not write connection / credential code.** Use `ctx.get_connection()`.
1117
+ - **Do not use Chart.js native `legend: { display: true }` on non-doughnut charts.** The `fwLegend` plugin handles it.
1118
+ - **Do not open `output/<slug>/index.html` as `file://`.** `data.json` is fetched via XHR and requires HTTP — the runner serves automatically on :8050.
1119
+
1120
+ ## Licensing (applies to every change)
1121
+
1122
+ Owned Trellum code is AGPL-3.0-only. Third-party and pre-existing contributor
1123
+ notices retain their own terms.
1124
+
1125
+ - **Preserve copyright, modification, and licence notices.** `LICENSE` governs
1126
+ owned code; generated reports carry the runtime notice and exact source link.
1127
+ - **Adding a dependency means updating `THIRD-PARTY.md` in the same commit.**
1128
+ `docs/LICENSING.md` has the audit command. A new licence still needs review
1129
+ because it can change what a downstream user may do with a build.
1130
+ - **Bundling is the stricter case than depending.** Code added under
1131
+ `static/vendor/` ships inside every copy of this repository, so its notice
1132
+ travels with it — record it in `static/vendor/MANIFEST.json`.