Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions agents/anyplot/briefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from .data.roles import DataRole
from .policy import fence
from .schemas import Binding, DatasetProfile
from .schemas import MAX_TOP_VALUES, Binding, DatasetProfile
from .session_state import SessionView


Expand Down Expand Up @@ -122,14 +122,19 @@ def adapter_profile(profile: DatasetProfile, limit: int = MAX_ADAPTER_PROFILE_BY
def trimmed_profiles(profile: DatasetProfile) -> Iterator[tuple[str, bool]]:
"""The profile JSON, then ever smaller versions of it, each with whether it was trimmed.

The order of what goes: the sample rows one by one, then every column's top values,
The order of what goes: the sample rows one by one, then the values past the
`MAX_TOP_VALUES` most frequent of a column that lists all of its values
(`schemas.listed_values`; `unique` still counts them), then every column's top values,
then the ranges and the statistics (min, max, mean, median). Every version keeps
every column name, its type and counts, so a caller with a size cap takes the first
version that fits.
"""
yield profile_json(profile), False
for keep in range(len(profile.sample) - 1, -1, -1):
yield _dump(profile.model_copy(update={"sample": profile.sample[:keep]})), True
if any(len(column.top) > MAX_TOP_VALUES for column in profile.columns):
short = [column.model_copy(update={"top": column.top[:MAX_TOP_VALUES]}) for column in profile.columns]
yield _dump(profile.model_copy(update={"sample": [], "columns": short})), True
bare = profile.model_copy(
update={"sample": [], "columns": [column.model_copy(update={"top": []}) for column in profile.columns]}
)
Expand Down
95 changes: 94 additions & 1 deletion agents/anyplot/code/readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@
argument (also inside a list or tuple, a `range`/`arange`/`linspace` of literals, or
a date built from literals). A literal 0 alone is a baseline, not a data-scale
literal, and is not flagged; `max(y) * 1.15` is computed and is not flagged.
* `color-scale`: a color mapping pinned to the catalogue's value range: `vmin`, `vmax`,
`center`, `vcenter`, `clim`, plotly's `zmin`/`zmax`/`zmid`, `range_color` and
`color_continuous_midpoint` with a literal, `levels` as a list or
range of literals (a count is not flagged), colorbar ticks (`cbar_kws={"ticks": [...]}`,
`colorbar(ticks=...)`, `cbar.set_ticks(...)`), `set_clim`/`clim` and a norm
(`Normalize`, `TwoSlopeNorm`, ...) built from literals; the same 0 rule. Live test
round 8 of 2026-10-16: heatmap-basic in seaborn kept `vmin=0, vmax=100, center=50` and
ticks 0 to 100 for data of 32 to 93, a washed-out scale with two ticks.
* `colorbar-position`: `cbar_pos` with literal figure coordinates (a seaborn
`clustermap`): the same round kept the catalogue's top-left colorbar after the adapter
dropped the dendrogram it sat beside.
* `annotations`: `annotate` (`xy` in data coordinates), `text` (not on a figure, no
`transform=` other than `transData`), `axhline`, `axvline`, `axhspan`, `axvspan`,
`hlines` and `vlines` placed by a numeric literal, with the same 0 rule.
Expand Down Expand Up @@ -126,6 +137,23 @@
"hlines": ((0, 1, 2), ("y", "xmin", "xmax")),
"vlines": ((0, 1, 2), ("x", "ymin", "ymax")),
}
COLOR_SCALE_KEYWORDS = frozenset(
{"vmin", "vmax", "center", "vcenter", "clim", "zmin", "zmax", "zmid", "range_color", "color_continuous_midpoint"}
)
"""Keywords that pin a color mapping's range or midpoint (matplotlib, seaborn, plotly). Not `cmin`/`cmax`:
matplotlib's `hist2d` uses them as count thresholds that hide bins, no color range."""
COLORBAR_KEYWORDS = frozenset({"cbar_kws", "colorbar_kws"})
NORM_CALLS: dict[str, tuple[tuple[int, ...], frozenset[str]]] = {
"Normalize": ((0, 1), frozenset({"vmin", "vmax"})),
"LogNorm": ((0, 1), frozenset({"vmin", "vmax"})),
"TwoSlopeNorm": ((0, 1, 2), frozenset({"vcenter", "vmin", "vmax"})),
"CenteredNorm": ((0, 1), frozenset({"vcenter", "halfrange"})),
"BoundaryNorm": ((0,), frozenset({"boundaries"})),
"PowerNorm": ((1, 2), frozenset({"vmin", "vmax"})),
"SymLogNorm": ((2, 3), frozenset({"vmin", "vmax"})),
}
"""Each norm's range, center or boundary arguments, by position and keyword; never an encoding parameter such
as `PowerNorm`'s `gamma` or `SymLogNorm`'s `linthresh` (review of #12206)."""
RANGE_CALLS = frozenset({"range", "arange", "linspace"})
DATE_CALLS = frozenset({"Timestamp", "datetime", "datetime64", "date", "to_datetime"})
GENERATOR_FACTORIES = frozenset({"default_rng", "RandomState", "Generator"})
Expand All @@ -138,6 +166,18 @@
# Category id, hint prefix, hint advice; the order is the order of `hints`.
_CATEGORIES: tuple[tuple[str, str, str], ...] = (
("limits", "Literal axis limits or ticks at", "derive them from df or drop them."),
(
"color-scale",
"Literal color-scale range or colorbar ticks at",
"derive them from df (min, max; a center only for diverging data) unless the values fix the scale "
"(a correlation's -1 to 1).",
),
(
"colorbar-position",
"Colorbar placed at literal figure coordinates at",
"they fit the catalogue's layout; when you change the clustering, dendrograms or figure, keep the colorbar "
"beside the plot: let the library place it (sns.heatmap's own colorbar when the clustering goes).",
),
("annotations", "Annotations at literal data coordinates at", "place them from df values or drop them."),
("statistics", "Literal statistics at", "compute them from df or drop them."),
(
Expand Down Expand Up @@ -246,7 +286,7 @@ def _coupling(code: str, tree: ast.Module) -> dict[str, list[tuple[int, str]]]:
generators = _generator_names(tree, aliases)
found: dict[str, dict[int, str]] = {category: {} for category, _, _ in _CATEGORIES}

def flag(category: str, node: ast.expr) -> None:
def flag(category: str, node: ast.expr | ast.keyword) -> None:
found[category].setdefault(node.lineno, _snippet(ast.get_source_segment(code, node) or ast.unparse(node)))

for node in ast.walk(tree):
Expand All @@ -256,6 +296,11 @@ def flag(category: str, node: ast.expr) -> None:
resolved = _resolve(callee, aliases) if callee else ""
if method in LIMIT_METHODS and _has_literal([*node.args, *(kw.value for kw in node.keywords)]):
flag("limits", node)
for keyword in _color_scale_literals(node, method):
flag("color-scale", keyword)
for keyword in node.keywords:
if keyword.arg == "cbar_pos" and _has_literal([keyword.value]):
flag("colorbar-position", keyword)
if method is not None and _annotation_literal(node, method):
flag("annotations", node)
if resolved.startswith("matplotlib.dates."):
Expand Down Expand Up @@ -369,6 +414,54 @@ def _has_literal(nodes: list[ast.expr]) -> bool:
return any(value != 0 for node in nodes for value in _literal_values(node))


def _color_scale_literals(node: ast.Call, method: str | None) -> list[ast.expr | ast.keyword]:
"""The parts of `node` that fix a color mapping to the catalogue's value range by literals, with the 0 rule.

A keyword of `COLOR_SCALE_KEYWORDS` (`vmin=0, vmax=100, center=50` on a heatmap, plotly's
`zmin`), `levels` given as a list or range (a count such as `levels=10` is no value),
colorbar `ticks` (`cbar_kws={"ticks": [...]}`, `colorbar(ticks=...)`), and the calls
`set_clim`, `clim`, a norm of `NORM_CALLS` and `set_ticks` on a colorbar or its axes.
"""
found: list[ast.expr | ast.keyword] = []
for keyword in node.keywords:
value = keyword.value
if keyword.arg in COLOR_SCALE_KEYWORDS and _has_literal([value]):
found.append(keyword)
elif keyword.arg == "levels" and _number(value) is None and _has_literal([value]):
found.append(keyword)
elif keyword.arg == "ticks" and method == "colorbar" and _has_literal([value]):
found.append(keyword)
elif keyword.arg in COLORBAR_KEYWORDS and _ticks_literal(value):
found.append(keyword)
arguments = [*node.args, *(keyword.value for keyword in node.keywords)]
if method in ("set_clim", "clim"):
if _has_literal(arguments):
found.append(node)
elif method is not None and method in NORM_CALLS:
positions, names = NORM_CALLS[method]
bounds = [node.args[index] for index in positions if index < len(node.args)]
bounds += [keyword.value for keyword in node.keywords if keyword.arg in names]
if _has_literal(bounds):
found.append(node)
elif method == "set_ticks" and isinstance(node.func, ast.Attribute):
receiver = (dotted_name(node.func.value) or "").casefold()
if any(part in receiver for part in ("cbar", "colorbar", "cax")) and _has_literal(arguments):
found.append(node)
return found


def _ticks_literal(value: ast.expr) -> bool:
"""Whether a colorbar keyword dict (`{"ticks": [0, 50, 100]}` or `dict(ticks=...)`) sets literal ticks."""
if isinstance(value, ast.Dict):
return any(
isinstance(key, ast.Constant) and key.value == "ticks" and _has_literal([item])
for key, item in zip(value.keys, value.values, strict=True)
)
if isinstance(value, ast.Call) and dotted_name(value.func) == "dict":
return any(keyword.arg == "ticks" and _has_literal([keyword.value]) for keyword in value.keywords)
return False


def _annotation_literal(node: ast.Call, method: str) -> bool:
keywords = {kw.arg: kw.value for kw in node.keywords if kw.arg}
if method == "annotate":
Expand Down
13 changes: 13 additions & 0 deletions agents/anyplot/data/bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,19 @@ def carry_bindings(
if len(kept) == len(bindings):
dated = _new_dates(kept, roles, profile, previous)
return _in_spec_order([*kept, *dated], roles, profile) if dated else list(bindings)
return filled_bindings(kept, roles, profile)


def filled_bindings(kept: list[Binding], roles: list[DataRole], profile: DatasetProfile) -> list[Binding]:
"""`kept` (valid bindings) plus the defaults for every other role on the columns `kept` leaves free.

The second half of `carry_bindings`, and how `.to_long()` binds its new columns to the roles the
suggestion made them for (`shape.LongSuggestion.bindings`) whatever their names: a German `Kategorie`
or an English `Category` must not take a spec's `category` role by its name when the melted headers
are meant for its `group` (live test round 8 of 2026-10-16). A binding that does not hold on `profile`
(`check_bindings`) is left out, and its role is bound like any other.
"""
kept = [binding for binding in kept if not check_bindings([binding], roles, profile).errors]
used = {binding.column for binding in kept}
taken = {binding.role for binding in kept}
open_roles = [role for role in roles if role.variadic or role.name not in taken]
Expand Down
9 changes: 6 additions & 3 deletions agents/anyplot/data/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,12 @@
MAX_NOTE_CHARS,
MAX_ROWS,
MAX_SAMPLE_ROWS,
MAX_TOP_VALUES,
MAX_WARNINGS,
ColumnDtype,
ColumnProfile,
DatasetProfile,
SourceFormat,
listed_values,
)
from .months import named_months

Expand Down Expand Up @@ -3582,11 +3582,14 @@ def _profile_column(column: _Column) -> ColumnProfile:
elif column.dtype == "datetime" and column.dates:
minimum, maximum = _iso(min(column.dates), column.with_time), _iso(max(column.dates), column.with_time)
elif column.dtype in ("text", "boolean"):
for value, _ in Counter(present).most_common():
# Every value of a column with few (`listed_values`): a category the root never saw is one it calls added.
counts = Counter(present)
limit = listed_values(len({_clip(value) for value in counts}))
for value, _ in counts.most_common():
clipped = _clip(value)
if clipped not in top:
top.append(clipped)
if len(top) == MAX_TOP_VALUES:
if len(top) == limit:
break
return ColumnProfile(
name=column.name,
Expand Down
4 changes: 3 additions & 1 deletion agents/anyplot/data/prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -896,7 +896,9 @@ def _pivot_longer(frame: pd.DataFrame, op: PivotLonger, carried: _Carried) -> tu
raise PrepareError("invalid_operation", "names_to and values_to must differ")
rest = [str(name) for name in frame.columns if name not in columns]
for name in (op.names_to, op.values_to):
if name in rest:
# A melted column's name too: pandas' `melt` refuses a `value_name` that names an input column with a
# ValueError, which no caller expects (review of #12206: the `.to_long()` route answered 500).
if name in rest or name in columns:
raise PrepareError("invalid_operation", f"a column named '{name}' already exists; choose another name")
rows = len(frame) * len(columns)
if rows > MAX_ROWS:
Expand Down
Loading
Loading