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.
- {coeftable-0.6.0 → coeftable-0.7.0}/.github/workflows/ci.yml +0 -1
- coeftable-0.7.0/AGENTS.md +8 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/CHANGELOG.md +24 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/PKG-INFO +121 -1
- {coeftable-0.6.0 → coeftable-0.7.0}/README.md +120 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/_version.py +2 -2
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/__init__.py +2 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/adornments.py +19 -1
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/chrome.py +2 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/fragments.py +8 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/measure.py +17 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/panel.py +2 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/regions.py +2 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/graph/__init__.py +9 -0
- coeftable-0.7.0/src/coeftable/graph/breakout.py +539 -0
- coeftable-0.7.0/src/coeftable/graph/driver_tree.py +1161 -0
- coeftable-0.7.0/src/coeftable/graph/honesty.py +353 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/graph/model.py +137 -44
- coeftable-0.7.0/src/coeftable/graph/report.py +192 -0
- coeftable-0.7.0/src/coeftable/graph/timeline.py +384 -0
- coeftable-0.7.0/tests/test_breakout.py +1511 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_cards.py +74 -1
- coeftable-0.7.0/tests/test_driver_tree.py +1606 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_graph.py +256 -1
- coeftable-0.7.0/tests/test_graph_report.py +90 -0
- coeftable-0.7.0/tests/test_honesty.py +451 -0
- coeftable-0.7.0/tests/test_timeline.py +468 -0
- coeftable-0.6.0/AGENTS.md +0 -7
- {coeftable-0.6.0 → coeftable-0.7.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/.github/release.yml +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/.github/workflows/post-release.yml +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/.github/workflows/publish.yml +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/.github/workflows/release.yml +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/.gitignore +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/.pre-commit-config.yaml +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/LICENSE +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/Makefile +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/docs/images/example.png +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/docs/images/forest-robust-autoscale.png +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/docs/images/trend-example.png +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/noxfile.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/pyproject.toml +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/__init__.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/_axis.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/annotations.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/card.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/cards/template.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/collapsible.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/errors.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/format.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/frame.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/graph/metric_tree.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/graph/render.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/graph/state.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/graph/topology.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/grid.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/plots.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/render.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/series.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/spec.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/svg.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/src/coeftable/theme.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_annotations.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_card_entry.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_collapsible.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_format.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_frame.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_package.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_panel.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_plots.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_public_api.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_regions.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_render.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_series.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_sparkline.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_spec.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_svg.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/tests/test_theme.py +0 -0
- {coeftable-0.6.0 → coeftable-0.7.0}/uv.lock +0 -0
|
@@ -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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (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
|
|
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
|
|
@@ -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
|
]
|