coeftable 0.7.0__tar.gz → 0.9.0__tar.gz

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 (78) hide show
  1. {coeftable-0.7.0 → coeftable-0.9.0}/CHANGELOG.md +39 -0
  2. {coeftable-0.7.0 → coeftable-0.9.0}/PKG-INFO +39 -3
  3. {coeftable-0.7.0 → coeftable-0.9.0}/README.md +38 -2
  4. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/_version.py +2 -2
  5. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/frame.py +146 -61
  6. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/breakout.py +15 -5
  7. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/driver_tree.py +182 -85
  8. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/honesty.py +102 -0
  9. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/model.py +2 -2
  10. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/timeline.py +36 -16
  11. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_driver_tree.py +246 -18
  12. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_honesty.py +33 -0
  13. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_timeline.py +106 -5
  14. {coeftable-0.7.0 → coeftable-0.9.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  15. {coeftable-0.7.0 → coeftable-0.9.0}/.github/release.yml +0 -0
  16. {coeftable-0.7.0 → coeftable-0.9.0}/.github/workflows/ci.yml +0 -0
  17. {coeftable-0.7.0 → coeftable-0.9.0}/.github/workflows/post-release.yml +0 -0
  18. {coeftable-0.7.0 → coeftable-0.9.0}/.github/workflows/publish.yml +0 -0
  19. {coeftable-0.7.0 → coeftable-0.9.0}/.github/workflows/release.yml +0 -0
  20. {coeftable-0.7.0 → coeftable-0.9.0}/.gitignore +0 -0
  21. {coeftable-0.7.0 → coeftable-0.9.0}/.pre-commit-config.yaml +0 -0
  22. {coeftable-0.7.0 → coeftable-0.9.0}/AGENTS.md +0 -0
  23. {coeftable-0.7.0 → coeftable-0.9.0}/LICENSE +0 -0
  24. {coeftable-0.7.0 → coeftable-0.9.0}/Makefile +0 -0
  25. {coeftable-0.7.0 → coeftable-0.9.0}/docs/images/example.png +0 -0
  26. {coeftable-0.7.0 → coeftable-0.9.0}/docs/images/forest-robust-autoscale.png +0 -0
  27. {coeftable-0.7.0 → coeftable-0.9.0}/docs/images/trend-example.png +0 -0
  28. {coeftable-0.7.0 → coeftable-0.9.0}/noxfile.py +0 -0
  29. {coeftable-0.7.0 → coeftable-0.9.0}/pyproject.toml +0 -0
  30. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/__init__.py +0 -0
  31. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/_axis.py +0 -0
  32. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/annotations.py +0 -0
  33. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/__init__.py +0 -0
  34. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/adornments.py +0 -0
  35. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/card.py +0 -0
  36. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/chrome.py +0 -0
  37. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/fragments.py +0 -0
  38. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/measure.py +0 -0
  39. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/panel.py +0 -0
  40. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/regions.py +0 -0
  41. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/template.py +0 -0
  42. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/collapsible.py +0 -0
  43. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/errors.py +0 -0
  44. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/format.py +0 -0
  45. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/__init__.py +0 -0
  46. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/metric_tree.py +0 -0
  47. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/render.py +0 -0
  48. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/report.py +0 -0
  49. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/state.py +0 -0
  50. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/topology.py +0 -0
  51. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/grid.py +0 -0
  52. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/plots.py +0 -0
  53. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/render.py +0 -0
  54. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/series.py +0 -0
  55. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/spec.py +0 -0
  56. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/svg.py +0 -0
  57. {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/theme.py +0 -0
  58. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_annotations.py +0 -0
  59. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_breakout.py +0 -0
  60. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_card_entry.py +0 -0
  61. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_cards.py +0 -0
  62. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_collapsible.py +0 -0
  63. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_format.py +0 -0
  64. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_frame.py +0 -0
  65. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_graph.py +0 -0
  66. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_graph_report.py +0 -0
  67. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_package.py +0 -0
  68. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_panel.py +0 -0
  69. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_plots.py +0 -0
  70. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_public_api.py +0 -0
  71. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_regions.py +0 -0
  72. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_render.py +0 -0
  73. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_series.py +0 -0
  74. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_sparkline.py +0 -0
  75. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_spec.py +0 -0
  76. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_svg.py +0 -0
  77. {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_theme.py +0 -0
  78. {coeftable-0.7.0 → coeftable-0.9.0}/uv.lock +0 -0
@@ -1,3 +1,40 @@
1
+ <a id="v0.8.0"></a>
2
+ # [v0.8.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.8.0) - 2026-08-27
3
+
4
+ <!-- Release notes generated using configuration in .github/release.yml at v0.8.0 -->
5
+
6
+ ## What's Changed
7
+ ### New Features
8
+ * Label periods through one caller-supplied formatter by [@kylejcaron](https://github.com/kylejcaron) in [#49](https://github.com/kylejcaron/coeftable/pull/49)
9
+ ### Maintenance
10
+ * Update changelog for v0.7.0 by [@github-actions](https://github.com/github-actions)[bot] in [#48](https://github.com/kylejcaron/coeftable/pull/48)
11
+
12
+
13
+ **Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.7.0...v0.8.0
14
+
15
+ [Changes][v0.8.0]
16
+
17
+
18
+ <a id="v0.7.0"></a>
19
+ # [v0.7.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.7.0) - 2026-08-27
20
+
21
+ <!-- Release notes generated using configuration in .github/release.yml at v0.7.0 -->
22
+
23
+ ## What's Changed
24
+ ### New Features
25
+ * Generalize the shared-position proof for nested breakout switchers by [@kylejcaron](https://github.com/kylejcaron) in [#43](https://github.com/kylejcaron/coeftable/pull/43)
26
+ * Add DriverTree, a decomposition report with nested breakout switching by [@kylejcaron](https://github.com/kylejcaron) in [#44](https://github.com/kylejcaron/coeftable/pull/44)
27
+ ### Maintenance
28
+ * Update changelog for v0.6.0 by [@github-actions](https://github.com/github-actions)[bot] in [#42](https://github.com/kylejcaron/coeftable/pull/42)
29
+ * Run CI on pull requests to any base branch by [@kylejcaron](https://github.com/kylejcaron) in [#46](https://github.com/kylejcaron/coeftable/pull/46)
30
+ * Judge additive decompositions at their endpoints too by [@kylejcaron](https://github.com/kylejcaron) in [#47](https://github.com/kylejcaron/coeftable/pull/47)
31
+
32
+
33
+ **Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.6.0...v0.7.0
34
+
35
+ [Changes][v0.7.0]
36
+
37
+
1
38
  <a id="v0.6.0"></a>
2
39
  # [v0.6.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.6.0) - 2026-08-27
3
40
 
@@ -147,6 +184,8 @@
147
184
  [Changes][v0.1.0]
148
185
 
149
186
 
187
+ [v0.8.0]: https://github.com/kylejcaron/coeftable/compare/v0.7.0...v0.8.0
188
+ [v0.7.0]: https://github.com/kylejcaron/coeftable/compare/v0.6.0...v0.7.0
150
189
  [v0.6.0]: https://github.com/kylejcaron/coeftable/compare/v0.5.0...v0.6.0
151
190
  [v0.5.0]: https://github.com/kylejcaron/coeftable/compare/v0.4.0...v0.5.0
152
191
  [v0.4.0]: https://github.com/kylejcaron/coeftable/compare/v0.3.1...v0.4.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coeftable
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: Publication-quality summary tables for estimates with uncertainty.
5
5
  Project-URL: Homepage, https://github.com/kylejcaron/coeftable
6
6
  Project-URL: Repository, https://github.com/kylejcaron/coeftable
@@ -692,6 +692,31 @@ with two or more breakouts (like `revenue` above) renders a native
692
692
  because the alternatives share one slotted position and pure CSS shows
693
693
  exactly one of them at a time.
694
694
 
695
+ **Two different percentages appear, and they are not the same number.** Under
696
+ each card's title sits that card's *own* change across the window. On each
697
+ wire sits that child's *contribution* to its parent's change — a
698
+ size-weighted delta for an additive slice, a log share for a factorization —
699
+ and those contributions sum to the parent's own change. The two diverge
700
+ whenever a child is small relative to its parent: a region that grew 90% on
701
+ its own may have moved the total by only 6 points. The card's number answers
702
+ "did this grow?", the wire's answers "how much of the parent's move came from
703
+ here?". A card whose first level is zero or negative shows no own-change,
704
+ since a percentage from that base misleads rather than informs.
705
+
706
+ `op` is optional. Omit it and the operator is derived from the series: a
707
+ genuine sum reconciles under `+` and is off by orders of magnitude under `x`,
708
+ so the numbers already say which was meant. Supplying it asserts that intent
709
+ and is still checked. If neither reading explains the parent, the build
710
+ refuses and names the breakout rather than adopting one and blaming your
711
+ arithmetic for the gap.
712
+
713
+ A child may subtract from its parent. Growth accounting —
714
+ `DAU = retained + new + resurrected − churned`, where churn is negative —
715
+ decomposes correctly. The credibility statistics work in log space and so
716
+ need positive levels; a signed child simply gets no uncertainty ribbon and
717
+ claims no direction, exactly as an injected residual does, rather than
718
+ refusing the whole tree.
719
+
695
720
  Each decomposition is checked against its own arithmetic: a parent that
696
721
  should equal the sum or product of its children but falls short gets an
697
722
  injected `"Unattributed"` residual card (additive shortfalls) or a reported
@@ -708,12 +733,12 @@ wraps to fit the card using an estimated character width, growing across
708
733
  as many lines as that estimate needs. Runs of whitespace collapse to a
709
734
  single space and line breaks are not preserved, and unusually wide text
710
735
  can still be clipped on a line, since the wrap is an estimate rather than
711
- a font measurement. A literal `{weeks}` placeholder is substituted for the
736
+ a font measurement. A literal `{periods}` placeholder is substituted for the
712
737
  observed period count, and `caption` defaults to `None`, so nothing
713
738
  renders unless you ask for it.
714
739
  If you want a note that edge labels are accounting, not causal
715
740
  claims, that wording is yours to choose — for example
716
- `caption="Edge labels are an accounting of the realized {weeks}-week
741
+ `caption="Edge labels are an accounting of the realized {periods}-week
717
742
  change, not causal impact."` `events` fan out to every card named in their
718
743
  `affects` tuple, both as sparkline markers and as captions, and the
719
744
  report's header is a timeline strip indexing them across the whole canvas.
@@ -723,6 +748,17 @@ experiments. With neither events nor a `strip_title` there is nothing to
723
748
  index and no heading to show, so the strip is omitted rather than reserving
724
749
  space for a bare axis; supplying either one brings it back.
725
750
 
751
+ Tick labels on the strip, each event pin's trailing period text, and every
752
+ card's own sparkline x axis all come from one callable, `period_label`: it
753
+ maps a zero-based period index to display text and defaults to
754
+ `coeftable.graph.timeline.default_period_label` (`W1`, `W2`, ...), which is
755
+ why a report built with no `period_label` argument renders exactly the
756
+ weekly labels used above. Pass your own, e.g.
757
+ `period_label=lambda i: f"M{int(i) + 1}"`, and it reaches the strip's ticks,
758
+ its event pins, and every card's axis together — the one parameter that
759
+ makes a report period-neutral, so monthly, daily, or quarterly data renders
760
+ its own correct labels instead of a hardcoded week count.
761
+
726
762
  **Switchers can nest.** Every level of the tree may carry its own switcher at
727
763
  the same time — revenue by drivers or region, and within drivers, active users
728
764
  by funnel or country, and within that, sessions by platform or channel.
@@ -638,6 +638,31 @@ with two or more breakouts (like `revenue` above) renders a native
638
638
  because the alternatives share one slotted position and pure CSS shows
639
639
  exactly one of them at a time.
640
640
 
641
+ **Two different percentages appear, and they are not the same number.** Under
642
+ each card's title sits that card's *own* change across the window. On each
643
+ wire sits that child's *contribution* to its parent's change — a
644
+ size-weighted delta for an additive slice, a log share for a factorization —
645
+ and those contributions sum to the parent's own change. The two diverge
646
+ whenever a child is small relative to its parent: a region that grew 90% on
647
+ its own may have moved the total by only 6 points. The card's number answers
648
+ "did this grow?", the wire's answers "how much of the parent's move came from
649
+ here?". A card whose first level is zero or negative shows no own-change,
650
+ since a percentage from that base misleads rather than informs.
651
+
652
+ `op` is optional. Omit it and the operator is derived from the series: a
653
+ genuine sum reconciles under `+` and is off by orders of magnitude under `x`,
654
+ so the numbers already say which was meant. Supplying it asserts that intent
655
+ and is still checked. If neither reading explains the parent, the build
656
+ refuses and names the breakout rather than adopting one and blaming your
657
+ arithmetic for the gap.
658
+
659
+ A child may subtract from its parent. Growth accounting —
660
+ `DAU = retained + new + resurrected − churned`, where churn is negative —
661
+ decomposes correctly. The credibility statistics work in log space and so
662
+ need positive levels; a signed child simply gets no uncertainty ribbon and
663
+ claims no direction, exactly as an injected residual does, rather than
664
+ refusing the whole tree.
665
+
641
666
  Each decomposition is checked against its own arithmetic: a parent that
642
667
  should equal the sum or product of its children but falls short gets an
643
668
  injected `"Unattributed"` residual card (additive shortfalls) or a reported
@@ -654,12 +679,12 @@ wraps to fit the card using an estimated character width, growing across
654
679
  as many lines as that estimate needs. Runs of whitespace collapse to a
655
680
  single space and line breaks are not preserved, and unusually wide text
656
681
  can still be clipped on a line, since the wrap is an estimate rather than
657
- a font measurement. A literal `{weeks}` placeholder is substituted for the
682
+ a font measurement. A literal `{periods}` placeholder is substituted for the
658
683
  observed period count, and `caption` defaults to `None`, so nothing
659
684
  renders unless you ask for it.
660
685
  If you want a note that edge labels are accounting, not causal
661
686
  claims, that wording is yours to choose — for example
662
- `caption="Edge labels are an accounting of the realized {weeks}-week
687
+ `caption="Edge labels are an accounting of the realized {periods}-week
663
688
  change, not causal impact."` `events` fan out to every card named in their
664
689
  `affects` tuple, both as sparkline markers and as captions, and the
665
690
  report's header is a timeline strip indexing them across the whole canvas.
@@ -669,6 +694,17 @@ experiments. With neither events nor a `strip_title` there is nothing to
669
694
  index and no heading to show, so the strip is omitted rather than reserving
670
695
  space for a bare axis; supplying either one brings it back.
671
696
 
697
+ Tick labels on the strip, each event pin's trailing period text, and every
698
+ card's own sparkline x axis all come from one callable, `period_label`: it
699
+ maps a zero-based period index to display text and defaults to
700
+ `coeftable.graph.timeline.default_period_label` (`W1`, `W2`, ...), which is
701
+ why a report built with no `period_label` argument renders exactly the
702
+ weekly labels used above. Pass your own, e.g.
703
+ `period_label=lambda i: f"M{int(i) + 1}"`, and it reaches the strip's ticks,
704
+ its event pins, and every card's axis together — the one parameter that
705
+ makes a report period-neutral, so monthly, daily, or quarterly data renders
706
+ its own correct labels instead of a hardcoded week count.
707
+
672
708
  **Switchers can nest.** Every level of the tree may carry its own switcher at
673
709
  the same time — revenue by drivers or region, and within drivers, active users
674
710
  by funnel or country, and within that, sessions by platform or channel.
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.7.0'
22
- __version_tuple__ = version_tuple = (0, 7, 0)
21
+ __version__ = version = '0.9.0'
22
+ __version_tuple__ = version_tuple = (0, 9, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -3,12 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass, field
6
+ from functools import partial
6
7
  from itertools import combinations
7
8
  from typing import Any
8
9
 
9
10
  import narwhals as nw
10
11
 
11
- from coeftable.grid import assemble_rows, build_grid
12
+ from coeftable.grid import AssembledRows, Grid, assemble_rows, build_grid
12
13
  from coeftable.spec import (
13
14
  Cell,
14
15
  CoefTable,
@@ -92,30 +93,11 @@ def _check_columns(frame: nw.DataFrame, table: CoefTable) -> None:
92
93
  )
93
94
 
94
95
 
95
- def resolve(table: CoefTable) -> Resolved: # noqa: C901
96
- """Resolve `table` against its frame.
97
-
98
- Parameters
99
- ----------
100
- table
101
- The specification to resolve.
96
+ def _output_name(column: Column, split: Any) -> str:
97
+ return column.label if split is None else f"{split}{SPLIT_JOINER}{column.label}"
102
98
 
103
- Returns
104
- -------
105
- Resolved
106
- Rendered cells plus the layout metadata `render` needs.
107
-
108
- Raises
109
- ------
110
- ColumnNotFoundError
111
- When a named column is absent from the frame.
112
- TypeError
113
- When an estimate or bound column is not numeric.
114
- SpecError
115
- When the column specification is inconsistent.
116
- """
117
- validate_columns(table.columns)
118
99
 
100
+ def _validate_layout_label_collisions(table: CoefTable) -> None:
119
101
  # A column label colliding with a layout key silently overwrites the
120
102
  # layout column in the output frame. Catch it here at spec-check time.
121
103
  layout_keys = {n for n in (table.rows, table.nest, table.groups) if n is not None}
@@ -126,6 +108,8 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
126
108
  f"(rows/nest/groups key {column.label!r}); choose a different label."
127
109
  )
128
110
 
111
+
112
+ def _validate_layout_key_uniqueness(table: CoefTable) -> None:
129
113
  # Two layout roles naming the same column collide in the output frame,
130
114
  # which `resolve()` builds keyed by column name -- the later write
131
115
  # silently discards the earlier role's values, so the table renders with
@@ -139,6 +123,8 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
139
123
  f"{first_key!r}; each layout role needs its own column."
140
124
  )
141
125
 
126
+
127
+ def _validate_sparkline_series_collisions(table: CoefTable) -> None:
142
128
  # The overlaid `series` dimension cannot also be a table axis: the
143
129
  # table's row structure already spends whichever of these an
144
130
  # identity maps to, so naming the same column both ways is
@@ -156,15 +142,32 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
156
142
  "overlaid dimension cannot also be a table axis."
157
143
  )
158
144
 
159
- frame = nw.from_native(table.data, eager_only=True)
160
- _check_columns(frame, table)
161
145
 
162
- n = len(frame)
146
+ def _validate_spec(table: CoefTable) -> None:
147
+ validate_columns(table.columns)
148
+ _validate_layout_label_collisions(table)
149
+ _validate_layout_key_uniqueness(table)
150
+ _validate_sparkline_series_collisions(table)
151
+
152
+
153
+ def _extract_layout_keys(
154
+ frame: nw.DataFrame, table: CoefTable, n: int
155
+ ) -> tuple[list[Any], list[Any], list[Any], list[Any]]:
163
156
  row_keys = frame[table.rows].to_list() if table.rows else [""] * n
164
157
  nest_keys = frame[table.nest].to_list() if table.nest else [None] * n
165
158
  group_keys = frame[table.groups].to_list() if table.groups else [None] * n
166
159
  split_keys = frame[table.split_columns].to_list() if table.split_columns else [None] * n
160
+ return row_keys, nest_keys, group_keys, split_keys
161
+
167
162
 
163
+ def _prepare_columns(
164
+ table: CoefTable,
165
+ frame: nw.DataFrame,
166
+ row_keys: list[Any],
167
+ nest_keys: list[Any],
168
+ group_keys: list[Any],
169
+ split_keys: list[Any],
170
+ ) -> list[Prepared]:
168
171
  scan = Scan(
169
172
  frame=frame,
170
173
  columns=table.columns,
@@ -177,35 +180,35 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
177
180
  groups=table.groups,
178
181
  split_columns=table.split_columns,
179
182
  )
180
- prepared: list[Prepared] = [column.prepare(scan) for column in table.columns]
183
+ return [column.prepare(scan) for column in table.columns]
181
184
 
182
- grid = build_grid(
183
- row_keys,
184
- nest_keys,
185
- group_keys,
186
- split_keys,
187
- sort_rows=table.sort_rows,
188
- has_splits=table.split_columns is not None,
189
- )
190
-
191
- def output_name(column: Column, split: Any) -> str:
192
- return column.label if split is None else f"{split}{SPLIT_JOINER}{column.label}"
193
185
 
186
+ def _build_display_columns(
187
+ table: CoefTable, grid: Grid
188
+ ) -> tuple[list[str], dict[str, str], dict[str, list[str]], list[str]]:
194
189
  display_columns: list[str] = []
195
190
  labels: dict[str, str] = {}
196
191
  spanners: dict[str, list[str]] = {}
197
192
  plot_columns: list[str] = []
198
193
  for split in grid.splits:
199
194
  for column in table.columns:
200
- name = output_name(column, split)
195
+ name = _output_name(column, split)
201
196
  display_columns.append(name)
202
197
  labels[name] = column.label
203
198
  if split is not None:
204
199
  spanners.setdefault(str(split), []).append(name)
205
200
  if isinstance(column, (Forest, Sparkline)):
206
201
  plot_columns.append(name)
202
+ return display_columns, labels, spanners, plot_columns
207
203
 
208
- # Cell pass: one call to `column.cell` per (row, split, column).
204
+
205
+ def _compute_cells(
206
+ table: CoefTable,
207
+ grid: Grid,
208
+ prepared: list[Prepared],
209
+ display_columns: list[str],
210
+ ) -> dict[str, list[str]]:
211
+ # One call to `column.cell` per (row, split, column).
209
212
  cell_values: dict[str, list[str]] = {name: [] for name in display_columns}
210
213
  for position, (row_key, nest_key, identity_group) in enumerate(grid.ordered):
211
214
  direction = table.direction_for(str(row_key))
@@ -213,7 +216,7 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
213
216
  for split in grid.splits:
214
217
  index = grid.source_index.get(((row_key, nest_key, identity_group), split))
215
218
  for column, prep in zip(table.columns, prepared, strict=True):
216
- name = output_name(column, split)
219
+ name = _output_name(column, split)
217
220
  if index is None:
218
221
  cell_values[name].append("")
219
222
  continue
@@ -228,8 +231,13 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
228
231
  theme=table.theme,
229
232
  )
230
233
  cell_values[name].append(column.cell(ctx))
234
+ return cell_values
235
+
231
236
 
232
- # Footer pass: interleave axis rows wherever a column's domain closes.
237
+ def _build_footer_data(
238
+ table: CoefTable, grid: Grid, prepared: list[Prepared]
239
+ ) -> tuple[dict[str, Column], dict[str, Prepared], dict[str, list[list[Any]]], set[str]]:
240
+ # Interleave axis rows wherever a column's domain closes.
233
241
  column_by_label: dict[str, Column] = {}
234
242
  prepared_by_label: dict[str, Prepared] = {}
235
243
  footer_keys: dict[str, list[list[Any]]] = {}
@@ -250,23 +258,32 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
250
258
  shared_footer_labels = {
251
259
  label for label in footer_keys if prepared_by_label[label].shared_footer
252
260
  }
253
-
254
- def render_footer(pending: dict[str, list[Any]]) -> dict[str, str]:
255
- out: dict[str, str] = {}
256
- for label, keys in pending.items():
257
- column = column_by_label[label]
258
- prep = prepared_by_label[label]
259
- for split_idx, split in enumerate(grid.splits):
260
- ctx = Footer(prepared=prep, key=keys[split_idx], theme=table.theme)
261
- text = column.footer(ctx)
262
- if text is not None:
263
- out[output_name(column, split)] = text
264
- return out
265
-
266
- assembled = assemble_rows(
267
- grid, display_columns, cell_values, footer_keys, render_footer, shared_footer_labels
268
- )
269
-
261
+ return column_by_label, prepared_by_label, footer_keys, shared_footer_labels
262
+
263
+
264
+ def _render_footer(
265
+ pending: dict[str, list[Any]],
266
+ *,
267
+ column_by_label: dict[str, Column],
268
+ prepared_by_label: dict[str, Prepared],
269
+ grid: Grid,
270
+ theme: Any,
271
+ ) -> dict[str, str]:
272
+ out: dict[str, str] = {}
273
+ for label, keys in pending.items():
274
+ column = column_by_label[label]
275
+ prep = prepared_by_label[label]
276
+ for split_idx, split in enumerate(grid.splits):
277
+ ctx = Footer(prepared=prep, key=keys[split_idx], theme=theme)
278
+ text = column.footer(ctx)
279
+ if text is not None:
280
+ out[_output_name(column, split)] = text
281
+ return out
282
+
283
+
284
+ def _build_output_frame(
285
+ table: CoefTable, frame: nw.DataFrame, assembled: AssembledRows, display_columns: list[str]
286
+ ) -> Any:
270
287
  data: dict[str, list[Any]] = {}
271
288
  if table.groups:
272
289
  data[table.groups] = assembled.layout_group
@@ -276,13 +293,81 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
276
293
  data[table.nest] = assembled.layout_nest
277
294
  for name in display_columns:
278
295
  data[name] = assembled.cells[name]
296
+ return nw.from_dict(data, backend=nw.get_native_namespace(frame)).to_native()
279
297
 
298
+
299
+ def _build_display_and_markdown_columns(
300
+ table: CoefTable, display_columns: list[str]
301
+ ) -> tuple[list[str], list[str]]:
280
302
  leading = [c for c in (table.groups, table.rows, table.nest) if c]
281
303
  markdown = [c for c in (table.rows, table.nest) if c] + display_columns
304
+ display = [*(leading[1:] if table.groups else leading), *display_columns]
305
+ return display, markdown
306
+
307
+
308
+ def resolve(table: CoefTable) -> Resolved:
309
+ """Resolve `table` against its frame.
310
+
311
+ Parameters
312
+ ----------
313
+ table
314
+ The specification to resolve.
315
+
316
+ Returns
317
+ -------
318
+ Resolved
319
+ Rendered cells plus the layout metadata `render` needs.
320
+
321
+ Raises
322
+ ------
323
+ ColumnNotFoundError
324
+ When a named column is absent from the frame.
325
+ TypeError
326
+ When an estimate or bound column is not numeric.
327
+ SpecError
328
+ When the column specification is inconsistent.
329
+ """
330
+ _validate_spec(table)
331
+
332
+ frame = nw.from_native(table.data, eager_only=True)
333
+ _check_columns(frame, table)
334
+
335
+ n = len(frame)
336
+ row_keys, nest_keys, group_keys, split_keys = _extract_layout_keys(frame, table, n)
337
+ prepared = _prepare_columns(table, frame, row_keys, nest_keys, group_keys, split_keys)
338
+
339
+ grid = build_grid(
340
+ row_keys,
341
+ nest_keys,
342
+ group_keys,
343
+ split_keys,
344
+ sort_rows=table.sort_rows,
345
+ has_splits=table.split_columns is not None,
346
+ )
347
+
348
+ display_columns, labels, spanners, plot_columns = _build_display_columns(table, grid)
349
+ cell_values = _compute_cells(table, grid, prepared, display_columns)
350
+ column_by_label, prepared_by_label, footer_keys, shared_footer_labels = _build_footer_data(
351
+ table, grid, prepared
352
+ )
353
+ render_footer = partial(
354
+ _render_footer,
355
+ column_by_label=column_by_label,
356
+ prepared_by_label=prepared_by_label,
357
+ grid=grid,
358
+ theme=table.theme,
359
+ )
360
+
361
+ assembled = assemble_rows(
362
+ grid, display_columns, cell_values, footer_keys, render_footer, shared_footer_labels
363
+ )
364
+
365
+ native_frame = _build_output_frame(table, frame, assembled, display_columns)
366
+ display, markdown = _build_display_and_markdown_columns(table, display_columns)
282
367
 
283
368
  return Resolved(
284
- frame=nw.from_dict(data, backend=nw.get_native_namespace(frame)).to_native(),
285
- display_columns=[*(leading[1:] if table.groups else leading), *display_columns],
369
+ frame=native_frame,
370
+ display_columns=display,
286
371
  labels=labels,
287
372
  spanners=spanners,
288
373
  group_column=table.groups,
@@ -59,19 +59,29 @@ def _canonical(value: object, *, name: str) -> tuple[object, ...]:
59
59
 
60
60
  @dataclass(frozen=True, slots=True)
61
61
  class Breakout:
62
- """One alternative decomposition: a label, its operator, and its cards."""
62
+ """One alternative decomposition: a label, its cards, and optionally its operator.
63
+
64
+ ``op`` may be left as ``None``, in which case the builder derives it from
65
+ the series themselves -- a genuine sum reconciles under ``"+"`` and is off
66
+ by orders of magnitude under ``"x"``, so the data already says which one
67
+ the caller meant. Supplying it explicitly asserts that intent and is still
68
+ checked against the numbers.
69
+
70
+ ``children`` keeps its position, so both the fully positional form and the
71
+ keyword form continue to work unchanged.
72
+ """
63
73
 
64
74
  key: str
65
75
  label: str
66
- op: Op
67
- children: tuple[str, ...]
76
+ op: Op | None = None
77
+ children: tuple[str, ...] = ()
68
78
 
69
79
  def __post_init__(self) -> None:
70
80
  """Validate the breakout's identity, operator, and child cards."""
71
81
  _non_empty_str(self.key, name="Breakout.key")
72
82
  _non_empty_str(self.label, name="Breakout.label")
73
- if self.op not in _OPS:
74
- raise SpecError(f"Breakout.op must be one of {_OPS}, got {self.op!r}")
83
+ if self.op is not None and self.op not in _OPS:
84
+ raise SpecError(f"Breakout.op must be one of {_OPS} or None, got {self.op!r}")
75
85
  children = _canonical(self.children, name="Breakout.children")
76
86
  if not children:
77
87
  raise SpecError("Breakout.children must not be empty")