coeftable 0.6.0__tar.gz → 0.7.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 (79) hide show
  1. {coeftable-0.6.0 → coeftable-0.7.0}/.github/workflows/ci.yml +0 -1
  2. coeftable-0.7.0/AGENTS.md +8 -0
  3. {coeftable-0.6.0 → coeftable-0.7.0}/CHANGELOG.md +24 -0
  4. {coeftable-0.6.0 → coeftable-0.7.0}/PKG-INFO +121 -1
  5. {coeftable-0.6.0 → coeftable-0.7.0}/README.md +120 -0
  6. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/_version.py +2 -2
  7. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/__init__.py +2 -0
  8. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/adornments.py +19 -1
  9. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/chrome.py +2 -0
  10. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/fragments.py +8 -0
  11. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/measure.py +17 -0
  12. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/panel.py +2 -0
  13. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/regions.py +2 -0
  14. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/graph/__init__.py +9 -0
  15. coeftable-0.7.0/src/coeftable/graph/breakout.py +539 -0
  16. coeftable-0.7.0/src/coeftable/graph/driver_tree.py +1161 -0
  17. coeftable-0.7.0/src/coeftable/graph/honesty.py +353 -0
  18. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/graph/model.py +137 -44
  19. coeftable-0.7.0/src/coeftable/graph/report.py +192 -0
  20. coeftable-0.7.0/src/coeftable/graph/timeline.py +384 -0
  21. coeftable-0.7.0/tests/test_breakout.py +1511 -0
  22. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_cards.py +74 -1
  23. coeftable-0.7.0/tests/test_driver_tree.py +1606 -0
  24. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_graph.py +256 -1
  25. coeftable-0.7.0/tests/test_graph_report.py +90 -0
  26. coeftable-0.7.0/tests/test_honesty.py +451 -0
  27. coeftable-0.7.0/tests/test_timeline.py +468 -0
  28. coeftable-0.6.0/AGENTS.md +0 -7
  29. {coeftable-0.6.0 → coeftable-0.7.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  30. {coeftable-0.6.0 → coeftable-0.7.0}/.github/release.yml +0 -0
  31. {coeftable-0.6.0 → coeftable-0.7.0}/.github/workflows/post-release.yml +0 -0
  32. {coeftable-0.6.0 → coeftable-0.7.0}/.github/workflows/publish.yml +0 -0
  33. {coeftable-0.6.0 → coeftable-0.7.0}/.github/workflows/release.yml +0 -0
  34. {coeftable-0.6.0 → coeftable-0.7.0}/.gitignore +0 -0
  35. {coeftable-0.6.0 → coeftable-0.7.0}/.pre-commit-config.yaml +0 -0
  36. {coeftable-0.6.0 → coeftable-0.7.0}/LICENSE +0 -0
  37. {coeftable-0.6.0 → coeftable-0.7.0}/Makefile +0 -0
  38. {coeftable-0.6.0 → coeftable-0.7.0}/docs/images/example.png +0 -0
  39. {coeftable-0.6.0 → coeftable-0.7.0}/docs/images/forest-robust-autoscale.png +0 -0
  40. {coeftable-0.6.0 → coeftable-0.7.0}/docs/images/trend-example.png +0 -0
  41. {coeftable-0.6.0 → coeftable-0.7.0}/noxfile.py +0 -0
  42. {coeftable-0.6.0 → coeftable-0.7.0}/pyproject.toml +0 -0
  43. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/__init__.py +0 -0
  44. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/_axis.py +0 -0
  45. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/annotations.py +0 -0
  46. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/card.py +0 -0
  47. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/template.py +0 -0
  48. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/collapsible.py +0 -0
  49. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/errors.py +0 -0
  50. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/format.py +0 -0
  51. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/frame.py +0 -0
  52. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/graph/metric_tree.py +0 -0
  53. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/graph/render.py +0 -0
  54. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/graph/state.py +0 -0
  55. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/graph/topology.py +0 -0
  56. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/grid.py +0 -0
  57. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/plots.py +0 -0
  58. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/render.py +0 -0
  59. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/series.py +0 -0
  60. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/spec.py +0 -0
  61. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/svg.py +0 -0
  62. {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/theme.py +0 -0
  63. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_annotations.py +0 -0
  64. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_card_entry.py +0 -0
  65. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_collapsible.py +0 -0
  66. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_format.py +0 -0
  67. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_frame.py +0 -0
  68. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_package.py +0 -0
  69. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_panel.py +0 -0
  70. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_plots.py +0 -0
  71. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_public_api.py +0 -0
  72. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_regions.py +0 -0
  73. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_render.py +0 -0
  74. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_series.py +0 -0
  75. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_sparkline.py +0 -0
  76. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_spec.py +0 -0
  77. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_svg.py +0 -0
  78. {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_theme.py +0 -0
  79. {coeftable-0.6.0 → coeftable-0.7.0}/uv.lock +0 -0
@@ -4,7 +4,6 @@ on:
4
4
  push:
5
5
  branches: [main]
6
6
  pull_request:
7
- branches: [main]
8
7
 
9
8
  jobs:
10
9
  prek:
@@ -0,0 +1,8 @@
1
+ # Agent guidelines
2
+
3
+ ## Changelog
4
+
5
+ Never edit `CHANGELOG.md`. It is generated by release automation:
6
+ `.github/workflows/post-release.yml` runs the `changelog-from-release` action
7
+ against it, from release notes built out of merged PR titles and categorized by
8
+ `.github/release.yml`. Describe changes in the PR description instead.
@@ -1,3 +1,26 @@
1
+ <a id="v0.6.0"></a>
2
+ # [v0.6.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.6.0) - 2026-08-27
3
+
4
+ <!-- Release notes generated using configuration in .github/release.yml at v0.6.0 -->
5
+
6
+ ## What's Changed
7
+ ### New Features
8
+ * Add coeftable.plots public facade for SVG plot primitives by [@kylejcaron](https://github.com/kylejcaron) in [#35](https://github.com/kylejcaron/coeftable/pull/35)
9
+ * Add typed card adornment vocabulary and HTML rendering by [@kylejcaron](https://github.com/kylejcaron) in [#36](https://github.com/kylejcaron/coeftable/pull/36)
10
+ * Add exact card measurement engine (chrome geometry) by [@kylejcaron](https://github.com/kylejcaron) in [#37](https://github.com/kylejcaron/coeftable/pull/37)
11
+ * Add trend, interval, metric, diagnostics and event card regions by [@kylejcaron](https://github.com/kylejcaron) in [#38](https://github.com/kylejcaron/coeftable/pull/38)
12
+ * Add public Card and CardGrid entry points by [@kylejcaron](https://github.com/kylejcaron) in [#39](https://github.com/kylejcaron/coeftable/pull/39)
13
+ * Add the panel composition layer by [@kylejcaron](https://github.com/kylejcaron) in [#40](https://github.com/kylejcaron/coeftable/pull/40)
14
+ * Add the metric tree / driver-tree graph builder by [@kylejcaron](https://github.com/kylejcaron) in [#41](https://github.com/kylejcaron/coeftable/pull/41)
15
+ ### Maintenance
16
+ * Update changelog for v0.5.0 by [@github-actions](https://github.com/github-actions)[bot] in [#34](https://github.com/kylejcaron/coeftable/pull/34)
17
+
18
+
19
+ **Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.5.0...v0.6.0
20
+
21
+ [Changes][v0.6.0]
22
+
23
+
1
24
  <a id="v0.5.0"></a>
2
25
  # [v0.5.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.5.0) - 2026-08-19
3
26
 
@@ -124,6 +147,7 @@
124
147
  [Changes][v0.1.0]
125
148
 
126
149
 
150
+ [v0.6.0]: https://github.com/kylejcaron/coeftable/compare/v0.5.0...v0.6.0
127
151
  [v0.5.0]: https://github.com/kylejcaron/coeftable/compare/v0.4.0...v0.5.0
128
152
  [v0.4.0]: https://github.com/kylejcaron/coeftable/compare/v0.3.1...v0.4.0
129
153
  [v0.3.1]: https://github.com/kylejcaron/coeftable/compare/v0.3.0...v0.3.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coeftable
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -640,6 +640,126 @@ CSS and no JavaScript.
640
640
  A `MetricTree` left as the last notebook expression renders itself via
641
641
  `_repr_html_`; `with_theme()` follows the same conventions as cards.
642
642
 
643
+ ## Driver-tree reports (experimental)
644
+
645
+ `coeftable.graph.DriverTree` is the composition root over the metric-tree
646
+ layer: give it level series and a decomposition per parent, and it builds the
647
+ cards, wires, and layout itself, plus three opt-in honesty checks a hand-built
648
+ `MetricTree` graph would otherwise have to redo per node. The API is
649
+ experimental and may change; it is deliberately not exported from the
650
+ top-level `coeftable` namespace yet.
651
+
652
+ ```python
653
+ import coeftable as ct
654
+ from coeftable.graph import DriverTree
655
+ from coeftable.graph.breakout import Breakout
656
+ from coeftable.graph.timeline import TimelineEvent
657
+
658
+ x = (0.0, 1.0, 2.0, 3.0)
659
+ series = {
660
+ "revenue": (1000.0, 1071.0, 1144.0, 1219.0),
661
+ "users": (100.0, 105.0, 110.0, 115.0),
662
+ "aov": (10.0, 10.2, 10.4, 10.6),
663
+ "us": (600.0, 640.0, 680.0, 720.0),
664
+ "eu": (400.0, 431.0, 464.0, 499.0),
665
+ }
666
+ titles = {"revenue": "Revenue", "users": "Users", "aov": "AOV", "us": "US", "eu": "EU"}
667
+ breakouts = {
668
+ "revenue": (
669
+ Breakout(key="drivers", label="by drivers", op="x", children=("users", "aov")),
670
+ Breakout(key="region", label="by region", op="+", children=("us", "eu")),
671
+ )
672
+ }
673
+ events = (TimelineEvent(at=1.0, label="Launch", color="#4C72B0", affects=("revenue", "users")),)
674
+
675
+ report = DriverTree(
676
+ series,
677
+ titles,
678
+ breakouts,
679
+ ct.Percent(decimals=1),
680
+ x,
681
+ events=events,
682
+ level_fmt=ct.Number(decimals=1),
683
+ )
684
+ html = report.as_raw_html()
685
+ ```
686
+
687
+ Every node id in `series` and `titles` is derived from `breakouts`: each key
688
+ is a parent, and each `Breakout` names one decomposition of it (an `op="x"`
689
+ product or an `op="+"` sum) plus the child node ids it contributes. A parent
690
+ with two or more breakouts (like `revenue` above) renders a native
691
+ `<select>` that swaps its children's whole subtree in place — no JavaScript,
692
+ because the alternatives share one slotted position and pure CSS shows
693
+ exactly one of them at a time.
694
+
695
+ Each decomposition is checked against its own arithmetic: a parent that
696
+ should equal the sum or product of its children but falls short gets an
697
+ injected `"Unattributed"` residual card (additive shortfalls) or a reported
698
+ gap badge (multiplicative shortfalls, which have no subtraction fix), and a
699
+ decomposition explaining under 80% of its parent refuses to build rather than
700
+ render a misleading tree. Every wire's label role comes from its own child's
701
+ noise-aware confidence interval, so a wobbly delta renders muted with a
702
+ `· ns` marker instead of a confident color. Anti-correlated siblings (movers
703
+ that trade off against each other week to week) surface a callout hosted on
704
+ one of the participating children, so it stays visible only alongside that
705
+ alternative rather than the parent, which stays visible under every switch.
706
+ The root card renders no text of its own; pass `caption=` to add one. It
707
+ wraps to fit the card using an estimated character width, growing across
708
+ as many lines as that estimate needs. Runs of whitespace collapse to a
709
+ single space and line breaks are not preserved, and unusually wide text
710
+ 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
712
+ observed period count, and `caption` defaults to `None`, so nothing
713
+ renders unless you ask for it.
714
+ If you want a note that edge labels are accounting, not causal
715
+ claims, that wording is yours to choose — for example
716
+ `caption="Edge labels are an accounting of the realized {weeks}-week
717
+ change, not causal impact."` `events` fan out to every card named in their
718
+ `affects` tuple, both as sparkline markers and as captions, and the
719
+ report's header is a timeline strip indexing them across the whole canvas.
720
+ That strip carries no heading of its own — pass `strip_title=` if you want
721
+ one, since only you know whether your events are deploys, holidays, or
722
+ experiments. With neither events nor a `strip_title` there is nothing to
723
+ index and no heading to show, so the strip is omitted rather than reserving
724
+ space for a bare axis; supplying either one brings it back.
725
+
726
+ **Switchers can nest.** Every level of the tree may carry its own switcher at
727
+ the same time — revenue by drivers or region, and within drivers, active users
728
+ by funnel or country, and within that, sessions by platform or channel.
729
+ Switching an outer choice takes away the whole branch beneath it, including any
730
+ nested switcher and its alternatives; switching back restores it with the inner
731
+ selection intact, because a native `<select>` keeps its value while hidden.
732
+
733
+ **Limitations.** All alternatives of one switcher must have the same number of
734
+ children, since they share the same positions.
735
+
736
+ And every card must keep a visible parent under every combination of choices. A
737
+ card that disappears together with its own alternative is fine — that is the
738
+ point of a switcher. What is refused is a card whose visibility depends on
739
+ several switchers that do not subsume one another, so that some combination of
740
+ selections leaves it on the page with every route to it closed.
741
+
742
+ Nesting satisfies this on its own: an outer choice that removes a branch is
743
+ decided by the outer rule alone, and everything below goes with it. Being
744
+ reachable through more than one switcher is not by itself a problem either — if
745
+ every option of each switcher reaches the card, or if any route to it is
746
+ unconditional, it always keeps a parent and the tree is accepted.
747
+
748
+ One shape is refused: a card where *every* route can be independently closed and
749
+ none survives unconditionally. The clearest example is a card reachable through a
750
+ nested branch and also through a different alternative of the switcher above it.
751
+ Keep the outer branch that preserves the nested switcher, then pick an inner
752
+ option that does not reach the card, and both of its routes close at once. Since
753
+ each switcher's rules are emitted independently, no single one can see that. Such
754
+ a tree is rejected at construction with a `SpecError` naming the card and every
755
+ switcher implicated in it.
756
+
757
+ `DriverTree` returns a `GraphReport`: the underlying `Graph`, and above it a
758
+ measured, exact-width timeline strip whenever events or a `strip_title` call
759
+ for one. `report.measure()`,
760
+ `report.as_raw_html()`, and its `_repr_html_` notebook display all work the
761
+ same way they do for a plain `Graph`.
762
+
643
763
  ## Plot annotations
644
764
 
645
765
  `ct.Rule` draws a line and `ct.Band` shades an interval in a forest plot or
@@ -586,6 +586,126 @@ CSS and no JavaScript.
586
586
  A `MetricTree` left as the last notebook expression renders itself via
587
587
  `_repr_html_`; `with_theme()` follows the same conventions as cards.
588
588
 
589
+ ## Driver-tree reports (experimental)
590
+
591
+ `coeftable.graph.DriverTree` is the composition root over the metric-tree
592
+ layer: give it level series and a decomposition per parent, and it builds the
593
+ cards, wires, and layout itself, plus three opt-in honesty checks a hand-built
594
+ `MetricTree` graph would otherwise have to redo per node. The API is
595
+ experimental and may change; it is deliberately not exported from the
596
+ top-level `coeftable` namespace yet.
597
+
598
+ ```python
599
+ import coeftable as ct
600
+ from coeftable.graph import DriverTree
601
+ from coeftable.graph.breakout import Breakout
602
+ from coeftable.graph.timeline import TimelineEvent
603
+
604
+ x = (0.0, 1.0, 2.0, 3.0)
605
+ series = {
606
+ "revenue": (1000.0, 1071.0, 1144.0, 1219.0),
607
+ "users": (100.0, 105.0, 110.0, 115.0),
608
+ "aov": (10.0, 10.2, 10.4, 10.6),
609
+ "us": (600.0, 640.0, 680.0, 720.0),
610
+ "eu": (400.0, 431.0, 464.0, 499.0),
611
+ }
612
+ titles = {"revenue": "Revenue", "users": "Users", "aov": "AOV", "us": "US", "eu": "EU"}
613
+ breakouts = {
614
+ "revenue": (
615
+ Breakout(key="drivers", label="by drivers", op="x", children=("users", "aov")),
616
+ Breakout(key="region", label="by region", op="+", children=("us", "eu")),
617
+ )
618
+ }
619
+ events = (TimelineEvent(at=1.0, label="Launch", color="#4C72B0", affects=("revenue", "users")),)
620
+
621
+ report = DriverTree(
622
+ series,
623
+ titles,
624
+ breakouts,
625
+ ct.Percent(decimals=1),
626
+ x,
627
+ events=events,
628
+ level_fmt=ct.Number(decimals=1),
629
+ )
630
+ html = report.as_raw_html()
631
+ ```
632
+
633
+ Every node id in `series` and `titles` is derived from `breakouts`: each key
634
+ is a parent, and each `Breakout` names one decomposition of it (an `op="x"`
635
+ product or an `op="+"` sum) plus the child node ids it contributes. A parent
636
+ with two or more breakouts (like `revenue` above) renders a native
637
+ `<select>` that swaps its children's whole subtree in place — no JavaScript,
638
+ because the alternatives share one slotted position and pure CSS shows
639
+ exactly one of them at a time.
640
+
641
+ Each decomposition is checked against its own arithmetic: a parent that
642
+ should equal the sum or product of its children but falls short gets an
643
+ injected `"Unattributed"` residual card (additive shortfalls) or a reported
644
+ gap badge (multiplicative shortfalls, which have no subtraction fix), and a
645
+ decomposition explaining under 80% of its parent refuses to build rather than
646
+ render a misleading tree. Every wire's label role comes from its own child's
647
+ noise-aware confidence interval, so a wobbly delta renders muted with a
648
+ `· ns` marker instead of a confident color. Anti-correlated siblings (movers
649
+ that trade off against each other week to week) surface a callout hosted on
650
+ one of the participating children, so it stays visible only alongside that
651
+ alternative rather than the parent, which stays visible under every switch.
652
+ The root card renders no text of its own; pass `caption=` to add one. It
653
+ wraps to fit the card using an estimated character width, growing across
654
+ as many lines as that estimate needs. Runs of whitespace collapse to a
655
+ single space and line breaks are not preserved, and unusually wide text
656
+ 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
658
+ observed period count, and `caption` defaults to `None`, so nothing
659
+ renders unless you ask for it.
660
+ If you want a note that edge labels are accounting, not causal
661
+ claims, that wording is yours to choose — for example
662
+ `caption="Edge labels are an accounting of the realized {weeks}-week
663
+ change, not causal impact."` `events` fan out to every card named in their
664
+ `affects` tuple, both as sparkline markers and as captions, and the
665
+ report's header is a timeline strip indexing them across the whole canvas.
666
+ That strip carries no heading of its own — pass `strip_title=` if you want
667
+ one, since only you know whether your events are deploys, holidays, or
668
+ experiments. With neither events nor a `strip_title` there is nothing to
669
+ index and no heading to show, so the strip is omitted rather than reserving
670
+ space for a bare axis; supplying either one brings it back.
671
+
672
+ **Switchers can nest.** Every level of the tree may carry its own switcher at
673
+ the same time — revenue by drivers or region, and within drivers, active users
674
+ by funnel or country, and within that, sessions by platform or channel.
675
+ Switching an outer choice takes away the whole branch beneath it, including any
676
+ nested switcher and its alternatives; switching back restores it with the inner
677
+ selection intact, because a native `<select>` keeps its value while hidden.
678
+
679
+ **Limitations.** All alternatives of one switcher must have the same number of
680
+ children, since they share the same positions.
681
+
682
+ And every card must keep a visible parent under every combination of choices. A
683
+ card that disappears together with its own alternative is fine — that is the
684
+ point of a switcher. What is refused is a card whose visibility depends on
685
+ several switchers that do not subsume one another, so that some combination of
686
+ selections leaves it on the page with every route to it closed.
687
+
688
+ Nesting satisfies this on its own: an outer choice that removes a branch is
689
+ decided by the outer rule alone, and everything below goes with it. Being
690
+ reachable through more than one switcher is not by itself a problem either — if
691
+ every option of each switcher reaches the card, or if any route to it is
692
+ unconditional, it always keeps a parent and the tree is accepted.
693
+
694
+ One shape is refused: a card where *every* route can be independently closed and
695
+ none survives unconditionally. The clearest example is a card reachable through a
696
+ nested branch and also through a different alternative of the switcher above it.
697
+ Keep the outer branch that preserves the nested switcher, then pick an inner
698
+ option that does not reach the card, and both of its routes close at once. Since
699
+ each switcher's rules are emitted independently, no single one can see that. Such
700
+ a tree is rejected at construction with a `SpecError` naming the card and every
701
+ switcher implicated in it.
702
+
703
+ `DriverTree` returns a `GraphReport`: the underlying `Graph`, and above it a
704
+ measured, exact-width timeline strip whenever events or a `strip_title` call
705
+ for one. `report.measure()`,
706
+ `report.as_raw_html()`, and its `_repr_html_` notebook display all work the
707
+ same way they do for a plain `Graph`.
708
+
589
709
  ## Plot annotations
590
710
 
591
711
  `ct.Rule` draws a line and `ct.Band` shades an interval in a forest plot or
@@ -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.6.0'
22
- __version_tuple__ = version_tuple = (0, 6, 0)
21
+ __version__ = version = '0.7.0'
22
+ __version_tuple__ = version_tuple = (0, 7, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -7,6 +7,7 @@ resolve into; `render_adornment` is its only serializer.
7
7
  from coeftable.cards.adornments import (
8
8
  Adornment,
9
9
  Badge,
10
+ Callout,
10
11
  CaptionRow,
11
12
  InlineSvg,
12
13
  KeyValuePopover,
@@ -38,6 +39,7 @@ __all__ = [
38
39
  "Adornment",
39
40
  "Anchor",
40
41
  "Badge",
42
+ "Callout",
41
43
  "CaptionRow",
42
44
  "Card",
43
45
  "CardChrome",
@@ -1,6 +1,6 @@
1
1
  """Typed card-content vocabulary: the closed set card regions resolve into.
2
2
 
3
- The renderer (`coeftable.cards.fragments`) knows exactly these nine types
3
+ The renderer (`coeftable.cards.fragments`) knows exactly these types
4
4
  and never branches on report type. Construction is the runtime contract
5
5
  boundary for intrinsic field validity: every invalid value raises `SpecError`
6
6
  here. Layout-dependent fit, including overflow and minimum widths, raises
@@ -204,6 +204,23 @@ class Badge:
204
204
  _require_member(self.role, _ROLES, name="Badge.role")
205
205
 
206
206
 
207
+ @dataclass(frozen=True, slots=True)
208
+ class Callout:
209
+ """Role-coloured prose that wraps: a warning or caveat with severity."""
210
+
211
+ text: str
212
+ role: Role = "neutral"
213
+ max_lines: int = 3
214
+
215
+ def __post_init__(self) -> None:
216
+ """Validate fields."""
217
+ _require_str(self.text, name="Callout.text")
218
+ _require_member(self.role, _ROLES, name="Callout.role")
219
+ _require_int(self.max_lines, name="Callout.max_lines")
220
+ if self.max_lines < 1:
221
+ raise SpecError("Callout.max_lines must be >= 1")
222
+
223
+
207
224
  @dataclass(frozen=True, slots=True)
208
225
  class CaptionRow:
209
226
  """A caption line with an optional colour-matched line marker."""
@@ -248,6 +265,7 @@ type Adornment = (
248
265
  | KeyValuePopover
249
266
  | SelectControl
250
267
  | Badge
268
+ | Callout
251
269
  | CaptionRow
252
270
  | Legend
253
271
  | RuleStrip
@@ -41,6 +41,8 @@ class CardChrome:
41
41
  swatch_gap: int = 4
42
42
  chip_gap: int = 10
43
43
  value_detail_gap: int = 4
44
+ callout_accent: int = 3
45
+ callout_inset: int = 6
44
46
 
45
47
  def __post_init__(self) -> None:
46
48
  """Validate all geometry fields."""
@@ -18,6 +18,7 @@ from typing import assert_never
18
18
  from coeftable.cards.adornments import (
19
19
  Adornment,
20
20
  Badge,
21
+ Callout,
21
22
  CaptionRow,
22
23
  InlineSvg,
23
24
  KeyValuePopover,
@@ -174,6 +175,13 @@ def render_adornment(
174
175
  f'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%">'
175
176
  f"{_esc(text)}</span>"
176
177
  )
178
+ case Callout(text=text, role=role):
179
+ style = _row(chrome.body_size, theme.color(role), chrome)
180
+ return (
181
+ f'<div style="{style};border-left:{chrome.callout_accent}px solid '
182
+ f'{_esc(theme.color(role))};padding-left:{chrome.callout_inset}px">'
183
+ f"{_esc(text)}</div>"
184
+ )
177
185
  case CaptionRow(text=text, color=color, dash=dash):
178
186
  marker = (
179
187
  ""
@@ -16,6 +16,7 @@ from dataclasses import dataclass, replace
16
16
  from coeftable.cards.adornments import (
17
17
  Adornment,
18
18
  Badge,
19
+ Callout,
19
20
  CaptionRow,
20
21
  InlineSvg,
21
22
  KeyValuePopover,
@@ -64,6 +65,12 @@ def _minimum_inline_width(adornment: Adornment, chrome: CardChrome) -> float | N
64
65
  return 2 * chrome.chip_padding_x + _minimum_text_width(
65
66
  adornment.text, chrome.chip_size, chrome.char_width_ratio
66
67
  )
68
+ if isinstance(adornment, Callout):
69
+ return (
70
+ chrome.callout_accent
71
+ + chrome.callout_inset
72
+ + _minimum_text_width(adornment.text, chrome.body_size, chrome.char_width_ratio)
73
+ )
67
74
  if isinstance(adornment, CaptionRow):
68
75
  fixed = 0 if adornment.color is None else chrome.swatch_width + chrome.swatch_gap
69
76
  return fixed + _minimum_text_width(
@@ -242,6 +249,16 @@ def resolve_rows(
242
249
  replace(adornment, text=lined, max_lines=1), line_height(size, chrome)
243
250
  )
244
251
  )
252
+ case Callout(text=text, max_lines=max_lines):
253
+ inset = chrome.callout_accent + chrome.callout_inset
254
+ budget = _budget(usable - inset, chrome.body_size, chrome.char_width_ratio)
255
+ for lined in text_line_plan(text, budget=budget, max_lines=max_lines):
256
+ rows.append(
257
+ RenderRow(
258
+ replace(adornment, text=lined, max_lines=1),
259
+ line_height(chrome.body_size, chrome),
260
+ )
261
+ )
245
262
  case MetricValue():
246
263
  rows.append(_metric_row(adornment, usable, chrome, where))
247
264
  case InlineSvg(width=svg_width, height=svg_height):
@@ -9,6 +9,7 @@ from typing import cast
9
9
  from coeftable.cards.adornments import (
10
10
  Adornment,
11
11
  Badge,
12
+ Callout,
12
13
  CaptionRow,
13
14
  InlineSvg,
14
15
  KeyValuePopover,
@@ -32,6 +33,7 @@ _ADORNMENT_TYPES = (
32
33
  KeyValuePopover,
33
34
  SelectControl,
34
35
  Badge,
36
+ Callout,
35
37
  CaptionRow,
36
38
  Legend,
37
39
  RuleStrip,
@@ -19,6 +19,7 @@ from coeftable.cards.adornments import (
19
19
  _DASHES,
20
20
  Adornment,
21
21
  Badge,
22
+ Callout,
22
23
  CaptionRow,
23
24
  InlineSvg,
24
25
  KeyValuePopover,
@@ -51,6 +52,7 @@ _ADORNMENT_TYPES = (
51
52
  KeyValuePopover,
52
53
  SelectControl,
53
54
  Badge,
55
+ Callout,
54
56
  CaptionRow,
55
57
  Legend,
56
58
  RuleStrip,
@@ -1,5 +1,7 @@
1
1
  """Experimental graph value types."""
2
2
 
3
+ from coeftable.graph.breakout import Breakout
4
+ from coeftable.graph.driver_tree import DriverTree
3
5
  from coeftable.graph.metric_tree import MetricTree
4
6
  from coeftable.graph.model import (
5
7
  Atom,
@@ -11,15 +13,22 @@ from coeftable.graph.model import (
11
13
  StateRule,
12
14
  Wire,
13
15
  )
16
+ from coeftable.graph.report import GraphReport, MeasuredReport
17
+ from coeftable.graph.timeline import TimelineEvent
14
18
 
15
19
  __all__ = [
16
20
  "Atom",
21
+ "Breakout",
17
22
  "ControlRef",
23
+ "DriverTree",
18
24
  "Graph",
25
+ "GraphReport",
19
26
  "MeasuredGraph",
27
+ "MeasuredReport",
20
28
  "MetricTree",
21
29
  "Slot",
22
30
  "Slotted",
23
31
  "StateRule",
32
+ "TimelineEvent",
24
33
  "Wire",
25
34
  ]