Skip to content

Commit f0cdd0c

Browse files
author
miranov25
committed
Phase 13.26.DF Commit 2: N-Channel Framework implementation (Phase B)
PHASE_13_26_DF_v1_0_END candidate Implements Algorithm A (Phase 13.26.DF v1.2 §3.1, §5.2): automatic visual-channel assignment for N data channels (vector, group_by, quantiles). Phase B activates the framework introduced as scaffolding in Commit 1 (0df4c00). Files changed (6 files): M UTILS/dfextensions/dfdraw/channels.py (+~310 LOC, stub→impl) M UTILS/dfextensions/dfdraw/drawer.py (+~85 LOC) M UTILS/dfextensions/dfdraw/plots/profile.py (+~145 LOC) M UTILS/dfextensions/dfdraw/tests/test_channel_assignment.py (50 stubs → 50 real tests) M UTILS/dfextensions/dfdraw/tests/test_quantiles_profile.py (1 test renamed + docstring) M UTILS/dfextensions/dfdraw/docs/STYLING_FRAMEWORK_DECISIONS.md (AD-60 + Commit 2 audit trail) Files NOT changed in Commit 2 (locked in Commit 1): - style.py, tests/feature_taxonomy.py Implementation highlights ========================= 1. channels.py: assign_channels() body — full Algorithm A resolution chain (per-call kwarg > style.default > EXPLICIT_RULES > greedy fallback) plus collision check and capacity check. build_factored_legend() implements sum-not-product legend layout (AD-59). 2. drawer.py: _draw_vector() calls assign_channels() once at top, replacing the hardcoded `vector_style = 'linestyle' if group_by else 'color'` logic. Cycle constants (_LINESTYLE_CYCLE, _MARKER_CYCLE) replaced with style-key lookups everywhere they were used. quantile_style added to _PROFILE_FORWARDED_NAMES and to profile() signature. 3. plots/profile.py: nested-band detection (Option A, AD-57) + channel-aware discrete rendering. New _render_quantile_nested_band() function with max-3 bands, alpha-stacked outer-to-inner. 4. tests/test_channel_assignment.py: 50 real test bodies across 11 classes per v1.2 §9. Class 10 production-pattern tests verify backward-compat for makeSmoothMapsWithTPC.py kwarg surface. 5. tests/test_quantiles_profile.py: test_multi_pair_returns_discrete renamed to test_multi_pair_returns_nested_band per AD-57; new PolyCollection assertion verifies nested_band actually renders. 6. docs/STYLING_FRAMEWORK_DECISIONS.md: AD-60 documents the FIX2 visual-elements channel-aware preservation rationale. Behavior-change record for nested_band auto-detection on symmetric 4+ entries. Test profile ============ Architect MacOS env (verified 2026-05-06): 627 passed, 1 skipped, 0 failed. Behavior change notice ====================== Symmetric quantile lists with >= 4 non-0.5 entries now auto-detect as 'nested_band' instead of 'discrete'. Per architect amendment chat 2026-05-05 ("We did not use quentiles yet. We do not need to be back compatible. for quentiles") and v1.2 §8.3 greenfield. To restore old behavior: pass quantile_mode='discrete' explicitly. Production usage (line 5511 of makeSmoothMapsWithTPC.py: quantiles=[0.16, 0.84]) routes to error_bars — unchanged. Provenance ========== Drafter (Commit 2): Claude49Coder Proposal: v1.2 (Approved 2026-05-05) Parent commit: 0df4c00 (PHASE_13_26_DF_v1_0_BEGIN) Decisions: docs/STYLING_FRAMEWORK_DECISIONS.md AD-44..AD-60
1 parent eea1046 commit f0cdd0c

6 files changed

Lines changed: 1086 additions & 299 deletions

File tree

UTILS/dfextensions/dfdraw/channels.py

Lines changed: 205 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""dfdraw.channels — N-Channel Framework (Phase 13.26.DF Phase B)
22
3-
This module implements Algorithm A from brainstorm v1.4 §3.1: automatic
4-
visual-channel assignment for N data channels (vector, group_by, quantiles,
5-
selection_delta, ...).
3+
Implements Algorithm A from brainstorm v1.4 §3.1: automatic visual-channel
4+
assignment for N data channels (vector, group_by, quantiles, selection_delta,
5+
...).
66
77
Per Phase 13.26.DF v1.2 §5.2, the API is N-channel from day one:
88
@@ -19,21 +19,15 @@
1919
- Brainstorm v1.4 §3.1 (Algorithm A), §3.2 (default-style table), §10 (style
2020
sets), §13 (default-style table deliverable), §14 (phasing plan)
2121
- AD-44 through AD-59 — see ``docs/STYLING_FRAMEWORK_DECISIONS.md``
22-
23-
Status
24-
------
25-
Phase 13.26.DF Commit 1 (scaffolding): module + dataclass + EXPLICIT_RULES
26-
table populated with the 7 Phase B entries. Function bodies raise
27-
``NotImplementedError("Phase 13.26.DF Commit 2: implementation pending")`` so
28-
that callers wired up in Commit 2 fail loudly until the algorithm lands.
29-
30-
Existing 578 tests do not call this module (Commit 1 is pure scaffolding).
3122
"""
3223

3324
from __future__ import annotations
3425

26+
import warnings
3527
from dataclasses import dataclass
36-
from typing import Optional
28+
from typing import Optional, Sequence
29+
30+
from .style import get_style_value
3731

3832

3933
# ----------------------------------------------------------------------------
@@ -80,25 +74,6 @@ class DataChannel:
8074
cost : int, default 1
8175
Visual-channel slots consumed. ``0`` for zero-cost modes; ``1`` for
8276
one-channel modes (the typical case).
83-
84-
Examples
85-
--------
86-
Phase B 3-channel call::
87-
88-
channels = [
89-
DataChannel('vector', is_categorical=True, cardinality=3),
90-
DataChannel('group_by', is_categorical=True, cardinality=5),
91-
DataChannel('quantiles', is_categorical=False, cardinality=3,
92-
cost=1),
93-
]
94-
# -> assign_channels(channels) returns:
95-
# {'group_by': 'color', 'vector': 'marker', 'quantiles': 'linestyle'}
96-
97-
Phase D extensibility (no API change)::
98-
99-
channels.append(DataChannel('selection_delta',
100-
is_categorical=False,
101-
cardinality=4, cost=0))
10277
"""
10378

10479
name: str
@@ -120,7 +95,7 @@ class DataChannel:
12095
# Source: Phase 13.26.DF v1.2 §3.2 default-style table.
12196
# Provenance: AD-56 (3-channel default), brainstorm v1.4 §13 deliverable table.
12297

123-
EXPLICIT_RULES: dict[frozenset[str], dict[str, str]] = {
98+
EXPLICIT_RULES: "dict[frozenset, dict[str, str]]" = {
12499
# 1-channel cases
125100
frozenset({'vector'}): {'vector': 'color'},
126101
frozenset({'group_by'}): {'group_by': 'color'},
@@ -140,10 +115,10 @@ class DataChannel:
140115

141116

142117
# ----------------------------------------------------------------------------
143-
# Public API (stubs — Commit 2 implements)
118+
# Public API
144119
# ----------------------------------------------------------------------------
145120

146-
def assign_channels(channels: list[DataChannel]) -> dict[str, str]:
121+
def assign_channels(channels: Sequence[DataChannel]) -> "dict[str, str]":
147122
"""Algorithm A: assign visual channels to active data channels.
148123
149124
Resolution order per channel (per v1.2 §5.2):
@@ -154,9 +129,14 @@ def assign_channels(channels: list[DataChannel]) -> dict[str, str]:
154129
4. Greedy priority-list fallback (``channels.priority.categorical`` /
155130
``channels.priority.ordinal``)
156131
132+
Then:
133+
134+
5. Collision check (Step 4): no two channels assigned to the same visual
135+
6. Capacity check (Step 5): cardinality must not exceed cycle capacity
136+
157137
Parameters
158138
----------
159-
channels : list[DataChannel]
139+
channels : Sequence[DataChannel]
160140
All data channels for this draw call. Zero-cost channels (cost=0)
161141
are tracked in the input list but absent from the output dict.
162142
@@ -168,59 +148,218 @@ def assign_channels(channels: list[DataChannel]) -> dict[str, str]:
168148
Raises
169149
------
170150
ValueError
171-
On channel collision (Step 4) or capacity overflow (Step 5).
172-
Behaviour controlled by ``channels.overflow`` style key.
173-
174-
Notes
175-
-----
176-
Idempotency contract per v1.2 §5.5: this function is called at most once
177-
per top-level user call. The vector path (``_draw_vector()``) calls it
178-
once and forwards the resolved styles via kwargs to ``draw_profile()``;
179-
``draw_profile()`` only calls ``assign_channels()`` itself when not
180-
invoked from a vector parent (detected via ``quantile_style is None``
181-
at entry).
151+
On channel collision (Step 4) or capacity overflow (Step 5) when
152+
``channels.overflow`` style key is ``"error"`` (default).
182153
"""
183-
raise NotImplementedError(
184-
"Phase 13.26.DF Commit 2: implementation pending. "
185-
"Scaffolding only — see PHASE_13_26_DF_v1_2_Proposal_NChannelFramework.md §5.2."
186-
)
187-
188-
189-
def build_factored_legend(ax, assignment: dict[str, str], channel_entries: dict):
154+
# Step 0: filter to active channels with cost > 0 (zero-cost modes
155+
# consume no visual channel slot per brainstorm v1.4 §3.1 Step 0).
156+
active = [c for c in channels if c.cost > 0]
157+
158+
if not active:
159+
return {}
160+
161+
result: "dict[str, str]" = {}
162+
used_visual: "set[str]" = set()
163+
164+
# Priority 1: per-call kwarg (highest precedence — always wins).
165+
# Priority 2: style pinned default `channels.default.<name>`.
166+
for c in active:
167+
if c.requested_style is not None:
168+
result[c.name] = c.requested_style
169+
used_visual.add(c.requested_style)
170+
else:
171+
pinned = get_style_value(f"channels.default.{c.name}")
172+
if pinned is not None:
173+
result[c.name] = pinned
174+
used_visual.add(pinned)
175+
176+
# Priority 3: explicit-case rule for known patterns (G-7: keyed on
177+
# frozenset of all active-channel names; entries that conflict with
178+
# already-resolved channels are skipped, leaving them for Priority 4).
179+
unresolved_names = [c.name for c in active if c.name not in result]
180+
if unresolved_names:
181+
active_set = frozenset(c.name for c in active)
182+
rule = EXPLICIT_RULES.get(active_set)
183+
if rule is not None:
184+
still_unresolved = []
185+
for name in unresolved_names:
186+
visual = rule.get(name)
187+
if visual is not None and visual not in used_visual:
188+
result[name] = visual
189+
used_visual.add(visual)
190+
else:
191+
still_unresolved.append(name)
192+
unresolved_names = still_unresolved
193+
194+
# Priority 4: greedy priority-list fallback (Step 3 in §3.1).
195+
# Used when (a) the active-set isn't in EXPLICIT_RULES (e.g., a future
196+
# combination not yet covered) or (b) per-call kwargs displaced the
197+
# explicit rule's intended assignment.
198+
if unresolved_names:
199+
cat_priority = get_style_value(
200+
"channels.priority.categorical",
201+
["color", "linestyle", "marker"],
202+
)
203+
ord_priority = get_style_value(
204+
"channels.priority.ordinal",
205+
["linestyle", "marker", "color"],
206+
)
207+
active_by_name = {c.name: c for c in active}
208+
# Sort: categorical first, then by descending cardinality
209+
# (per brainstorm v1.4 §3.1 Step 1).
210+
unresolved_sorted = sorted(
211+
unresolved_names,
212+
key=lambda n: (
213+
0 if active_by_name[n].is_categorical else 1,
214+
-active_by_name[n].cardinality,
215+
),
216+
)
217+
overflow_mode = get_style_value("channels.overflow", "error")
218+
for name in unresolved_sorted:
219+
c = active_by_name[name]
220+
priority = cat_priority if c.is_categorical else ord_priority
221+
assigned = None
222+
for visual in priority:
223+
if visual not in used_visual:
224+
assigned = visual
225+
break
226+
if assigned is None:
227+
msg = (
228+
f"Cannot assign visual channel for '{name}' "
229+
f"({c.cardinality} values). "
230+
f"All channels in use: {sorted(used_visual)}. "
231+
f"Reduce active dimensions or use facet=True."
232+
)
233+
if overflow_mode == "error":
234+
raise ValueError(msg)
235+
warnings.warn(msg, UserWarning, stacklevel=2)
236+
assigned = priority[0] # graceful fallback under "warn"
237+
result[name] = assigned
238+
used_visual.add(assigned)
239+
240+
# Step 4: collision check. Two channels assigned to the same visual is
241+
# an error — typically caused by per-call kwargs or pinned defaults that
242+
# conflict with the explicit rule.
243+
inverse: "dict[str, str]" = {}
244+
for name, visual in result.items():
245+
if visual in inverse:
246+
other = inverse[visual]
247+
raise ValueError(
248+
f"Channel collision: '{name}' and '{other}' both assigned "
249+
f"to '{visual}'. "
250+
f"Override with {name}_style= or {other}_style=, "
251+
f"or change channels.default.* in style."
252+
)
253+
inverse[visual] = name
254+
255+
# Step 5: capacity check. A data channel's cardinality must fit within
256+
# its assigned visual channel's cycle length.
257+
cycle_lengths = {
258+
'color': get_style_value("channels.cycles.color_count", 10),
259+
'linestyle': len(get_style_value(
260+
"channels.cycles.linestyle", ["-", "--", "-.", ":"])),
261+
'marker': len(get_style_value(
262+
"channels.cycles.marker",
263+
["o", "s", "^", "D", "v", "<", ">", "p"])),
264+
}
265+
overflow_mode = get_style_value("channels.overflow", "error")
266+
for c in active:
267+
visual = result.get(c.name)
268+
if visual is None:
269+
continue
270+
capacity = cycle_lengths.get(visual, 10)
271+
if c.cardinality > capacity:
272+
msg = (
273+
f"{c.name} ({c.cardinality} values) exceeds "
274+
f"{visual} cycle capacity ({capacity}).\n"
275+
f" Options: top_k={capacity}, "
276+
f"facet=True, group_by_bins={capacity}"
277+
)
278+
if overflow_mode == "error":
279+
raise ValueError(msg)
280+
warnings.warn(msg, UserWarning, stacklevel=2)
281+
282+
return result
283+
284+
285+
def build_factored_legend(ax, assignment, channel_entries, **legend_kwargs):
190286
"""Build a factored legend with one section per data channel.
191287
192288
Per v1.2 §6: when ``channels.legend.factored`` is True (default) and
193289
>= 2 channels are active, render section headers per channel with
194290
proxy artists for each entry. Total entries = sum(cardinalities) not
195291
product (e.g., 5 + 3 + 3 = 11 instead of 5 * 3 * 3 = 75).
196292
197-
When ``channels.legend.factored`` is False, fall through to the existing
198-
flat deduplicated legend (``_add_vector_main_legend_dedup`` from FIX1).
199-
200293
Parameters
201294
----------
202295
ax : matplotlib.axes.Axes
203-
Target axes to attach the legend to.
296+
Target axes.
204297
assignment : dict[str, str]
205298
Output of ``assign_channels()`` — maps channel name to visual channel.
206-
channel_entries : dict
207-
Per-channel entries to render in the legend, e.g.::
299+
channel_entries : dict[str, list[tuple[str, Any]]]
300+
Per-channel entries to render::
208301
209302
{
210303
'vector': [('dy_I0', 'o'), ('dy_I1', 's'), ...],
211304
'group_by': [('mP4 in [0,2)', 'C0'), ...],
212305
'quantiles': [('q=10%', '--'), ...],
213306
}
214307
308+
Each tuple is (label, visual-style-value).
309+
**legend_kwargs
310+
Forwarded to ``ax.legend()`` (e.g., ``loc``, ``fontsize``).
311+
215312
Returns
216313
-------
217-
matplotlib.legend.Legend
218-
The factored legend artist attached to ``ax``.
314+
matplotlib.legend.Legend or None
315+
Returns None if no entries to render.
219316
"""
220-
raise NotImplementedError(
221-
"Phase 13.26.DF Commit 2: implementation pending. "
222-
"Scaffolding only — see PHASE_13_26_DF_v1_2_Proposal_NChannelFramework.md §6."
223-
)
317+
import matplotlib.pyplot as plt
318+
319+
handles = []
320+
labels = []
321+
322+
# Stable section order: keep the order in which channels appear in
323+
# the assignment dict (Python 3.7+ guarantees insertion order).
324+
for ch_name, visual in assignment.items():
325+
entries = channel_entries.get(ch_name, [])
326+
if not entries:
327+
continue
328+
# Section header — invisible artist with a section text label.
329+
handles.append(plt.Line2D([], [], color='none', label=''))
330+
labels.append(f"— {ch_name} ({visual}) —")
331+
# Per-entry proxy artist matched to the visual channel.
332+
for entry_label, style_value in entries:
333+
if visual == 'color':
334+
h = plt.Line2D(
335+
[], [], color=style_value, marker='s',
336+
linestyle='none', markersize=8,
337+
)
338+
elif visual == 'linestyle':
339+
h = plt.Line2D(
340+
[], [], color='gray', linestyle=style_value,
341+
linewidth=1.5,
342+
)
343+
elif visual == 'marker':
344+
h = plt.Line2D(
345+
[], [], color='gray', marker=style_value,
346+
linestyle='none', markersize=8,
347+
)
348+
else:
349+
# Unknown visual — neutral fallback.
350+
h = plt.Line2D([], [], color='gray')
351+
handles.append(h)
352+
labels.append(entry_label)
353+
354+
if not handles:
355+
return None
356+
357+
legend_defaults = {
358+
'loc': get_style_value("legend.loc", "best"),
359+
'frameon': get_style_value("legend.frameon", True),
360+
}
361+
legend_defaults.update(legend_kwargs)
362+
return ax.legend(handles, labels, **legend_defaults)
224363

225364

226365
__all__ = [

UTILS/dfextensions/dfdraw/docs/STYLING_FRAMEWORK_DECISIONS.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,37 @@ Extracted from Phase 13.25.DF + Phase 13.26.DF review cycles. These should bind
196196
- All 578 existing tests pass
197197
- Bundle: `reviewer.zip` for Commit 1 review (tag verification, scaffolding correctness)
198198

199+
### Phase 13.26.DF Commit 2 implementation
200+
201+
- Parent commit: `0df4c00b` (Phase 13.26.DF Commit 1 scaffolding)
202+
- Files modified: `dfdraw/channels.py` (stub → ~310 LOC implementation), `dfdraw/drawer.py` (Algorithm A wiring + cycle constants → style-key lookups + `quantile_style` forwarding), `dfdraw/plots/profile.py` (nested-band detection + channel-aware discrete rendering + `_render_quantile_nested_band`), `dfdraw/tests/test_channel_assignment.py` (50 skip stubs → 50 real test bodies)
203+
- Test count: 627 passed + 1 skipped + 0 failed (verified on architect MacOS env, 2026-05-06; +50 new tests vs Commit 1 baseline of 577)
204+
- No regressions in any pre-existing test
205+
- Pre-commit tag candidate: `PHASE_13_26_DF_v1_0_END`
206+
207+
#### AD-60: FIX2 visual elements — channel-aware preservation
208+
209+
**Decision:** Per v1.2 §11.3 directive ("Coder may preserve, redesign, or drop FIX2 elements"), Commit 2 **preserves** the FIX2 visual elements (on-line percentage annotations + linestyle cycle for discrete quantiles) as the **channel-aware default for `quantile_style='linestyle'`**, with two structural changes to align with the channel framework:
210+
211+
1. **Cycle source:** the FIX2 hardcoded local `_ls_cycle = ['--', '-.', ':', (0, (3, 1, 1, 1))]` at `profile.py:559` is replaced with `get_style_value("channels.cycles.linestyle", default)[1:]`. The `[1:]` slice preserves the FIX2 invariant that **solid linestyle remains reserved for the central line** (now controlled by the first entry of `channels.cycles.linestyle`). Default cycle is `['-', '--', '-.', ':']`, so the discrete-quantile cycle is `['--', '-.', ':']` — one entry shorter than FIX2's hardcoded 4-entry cycle (the dash-dot-dot pattern `(0, (3, 1, 1, 1))` is dropped). For 4+ symmetric quantiles users are now routed to `nested_band` mode (AD-57) anyway, so the missing 4th linestyle is rarely needed; users requiring it can `set_style({'channels.cycles.linestyle': ['-', '--', '-.', ':', (0, (3, 1, 1, 1))]})`.
212+
213+
2. **Annotation scope:** FIX2's on-line percentage annotations (`profile.py:564-577`) are preserved when `quantile_style='linestyle'` (the default), and **suppressed** when `quantile_style='marker'` or `quantile_style='color'`. Rationale: marker- and color-distinguished quantile lines need no on-line text to disambiguate; the legend handles it. Linestyle-distinguished lines benefit from on-line annotations because subtle linestyle differences are harder to read against a legend at a distance.
214+
215+
**Provenance:** v1.2 §11.3 explicit recommendation; FIX2 commit `da8895e2` (PHASE_13_25_DF_FIX2_END).
216+
217+
#### Behavior change recorded for transparency (per v1.2 §8.3 greenfield)
218+
219+
Symmetric quantile lists with **>= 4 non-0.5 entries** now auto-detect as `nested_band` mode (AD-57, Option A) instead of `discrete`. This is a deliberate change to the auto-detection rule and is per architect amendment chat 2026-05-05: *"We did not use quentiles yet. We do not need to be back compatible. for quentiles"*.
220+
221+
Affected examples:
222+
- `[0.05, 0.25, 0.5, 0.75, 0.95]` (5 entries with central): was `discrete`, now `nested_band` (2 alpha-stacked filled regions + central line)
223+
- `[0.05, 0.25, 0.75, 0.95]` (4 entries no central): was `discrete`, now `nested_band` (2 alpha-stacked filled regions)
224+
- `[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]` (3 pairs + central): was `discrete`, now `nested_band` (3 filled regions + central line)
225+
226+
**To restore old behavior** on a per-call basis, pass `quantile_mode='discrete'` explicitly. The `stats_dict['quantiles_per_bin']` signature is preserved for both modes, so existing tests that only check the stats dict signature still pass.
227+
228+
**Existing test affected:** `tests/test_quantiles_profile.py::TestQuantileMode::test_multi_pair_returns_discrete` — used to test that `[0.05, 0.25, 0.5, 0.75, 0.95]` returns discrete mode. Renamed to `test_multi_pair_returns_nested_band` and docstring updated in Commit 2 to reflect new contract (assertion still passes either way because `quantiles_per_bin` is set for both modes; only the mode name and intent change).
229+
199230
---
200231

201232
*This document is authoritative for dfdraw architectural decisions. Modifications to AD entries require architect approval; appending new ADs follows the standard phase-decision workflow per `Organization-structure.md`. Governance principles (GP-N) are extracted from review-cycle lessons and bind future phases unless explicitly overridden by architect.*
@@ -205,3 +236,4 @@ Extracted from Phase 13.25.DF + Phase 13.26.DF review cycles. These should bind
205236
| Version | Date | Author | Change |
206237
|---|---|---|---|
207238
| 1.0 | 2026-05-05 | Claude49Coder | Initial seed in Phase 13.26.DF Commit 1. AD-44 through AD-59 + 5 governance principles + drafter rotation history + Phase 13.26.DF v1.2 audit trail. |
239+
| 1.1 | 2026-05-06 | Claude49Coder | Phase 13.26.DF Commit 2 audit-trail entry. AD-60 added (FIX2 visual-elements channel-aware preservation per v1.2 §11.3). Behavior-change record for `nested_band` auto-detection on symmetric 4+ entries. |

0 commit comments

Comments
 (0)