Changelog¶
All notable changes to Ferrum are documented here.
Unreleased¶
No unreleased changes.
0.22.0 — 2026-09-07¶
Two correctness campaigns land at once: the transform-engine batch (#168 — one dtype policy across ~20 transforms, live groupby= for regression/loess/impute/top_k, tie-aware ranks and peer-aware window frames, an expression grammar that parses its own documented forms, real glm/gam smoothers, and a Rust-owned transform_registry that Python derives from instead of mirroring) and the mark-geometry batch (#177 — real monotone/basis/cardinal curves on line/area/ribbon, arc corner fillets, mark_function(clip=False), standalone bin=True with per-bin aggregation, facet strip titles, coord guards, and a boxen/violin/boxplot/contour truth pack). Silent parameter drops die loudly throughout.
Added¶
-
Breaking (close-review hardening):
mark_smooth(method="gam")gainsk=(basis dimension, default 10, clamped to[4, distinct x count]— the knob for under-smoothing on high-frequency data; refusesk < 4andk=on non-gam methods). Explicitbandwidth=on a non-loessmark_smoothmethod now raises typed instead of being silently ignored (matchingdegree='s policy).ferrum._core.transform_registry(kind)gains a fourth key,"secondary_outputs"— the named secondary batches (qq reference line, letter-value bands/outliers, linkage dendrogram) are now declared schema, not unchecked literals; the name-prefix rule is uniform across all three, which changes one runtime key: aferrum.QQ(name=...)spec's reference-line batch is now published as"{name}_qq_line"instead of the bare"qq_line"(unnamed specs are unchanged). Two formerly-silent aggregate-name fallbacks inpivotandtop_k(an unknown aggregate quietly summing) now raise typed errors. -
Breaking:
rankanddense_rankwritten without afieldare now tie-aware (behavior change). A window op written the way the guide writes it,{"op": "rank", "as": "rank"}, used to fall back torow_number, so tied sort values drew distinct, order-dependent ranks. Ranks now come from thesortcolumns: rows equal on every sort column share a rank, competition-style forrank(1, 1, 3) and dense-style fordense_rank(1, 1, 2), matching SQL and Vega. An explicitfield=keeps its field-keyed behavior unchanged, and a spec with nosort=still ranks by row order. -
Breaking: Rolling window frames are peer-aware by default (behavior change).
transform_window(..., frame=...)now treats rows tied on thesortkey as a unit: the frame is anchored on the unit's own row-offset bounds and then both ends of that range grow outward to whole peer groups, so tied rows always share one frame and therefore one aggregate value and a frame boundary never splits a peer group. Vega'signorePeers: falsedefault also expands both ends outward viaWindow.jsadjustRange(bisect.left/bisect.right), but diverges in its anchor: Vega anchors each row's frame on the current row (i0 = max(0, i - preceding),i1 = min(n, i + following + 1)), whereas ferrum anchors on the peer run's bounds. This guarantees ferrum's tied rows always share one frame and one value; Vega's need not. Example: on a sort keyt=[1,1,2,3,3]with valuesx=[10,20,30,40,50]andframe=(-1,0), ferrum outputs[30,30,60,120,120]while Vega outputs[30,30,60,120,90]— only the last row differs. Pass the newtransform_window(..., ignore_peers=True)to restore the previous row-offset frames. Only rolling aggregates with an explicitframeover a sort key that actually has ties are affected; all other window output is byte-identical. -
The expression grammar (
transform_filter/transform_calculate) gains array literals ([e1, e2, …]), function-call syntax, and one builtin,indexof(array, value)— the documented membership formindexof([1, 2, 3], datum.id) >= 0now actually parses; it previously raised on the,inside the array.transform_filter's dict-list shorthand ({"field": [...]}) compiles to exactly this form. Fixed: a dict/scalar filter value that was a PythonboolorNonepreviously leaked Python's capitalizedrepr()(True,None) into the generated predicate, which the grammar cannot parse (unexpected identifier: 'True') —fm.transform_filter({"active": True})now works. All three dict-filter branches also normalize numpy/pandas scalars (np.int64,np.float64,np.bool_) like their Python-typed equivalents and refuse non-finite floats and unsupported types by name. -
ferrum._core.transform_registry(kind: str) -> dict— a new Rust-owned registry giving the method vocabulary, output schema, and accepted parameters for any transform kind (ValueErrornaming the known kinds otherwise). Several previously hand-maintained Python mirrors (mark_smooth's method validation, KDE kernel and QQ distribution vocabularies, output-column names used by figure/desugar consumers) now derive from it instead of risking drift from the Rust implementation. -
transform_top_k(groupby=...)— top-k per category: the aggregate (opoverfield) is computed per groupby key, the keys are ranked, and every row belonging to a top-nkey survives. Withoutgroupby=the behavior is byte-identical to before (the field's own distinct values double as the group key — a documented historical quirk, not the per-category use case). A row whose groupby key is null belongs to no group and is dropped from grouped output; a bare-stringgroupbyraises (iterating a string yields characters, not column names). -
Breaking:
lmplot/regplotnow draw real fit lines and CI bands formethod="logistic","glm", and"robust"— previously those branches referenced a nonexistentfittedcolumn and raised (or rendered no fit line withscatter=True).lmplot'smethod=vocabulary widened from 5 to 10 names: the smooth-family set (lm,loess,quadratic,cubic,log,sqrt,gam) now derives live from the transform registry, withglmstill mapped to the dedicated GLM transform (its ownfamily=/link=surface). Forlogistic/glm/robustwith any group-color encoding (hue=, or any channel the encoding alias table resolves to color —color=/fill=/stroke=, as keywords or viaencode=), the fit is pooled across groups (those transforms have no per-group fitting yet) and is drawn in a neutral theme stroke rather than a group color. Four formerly-silent parameter drops now act:order='s default changed1→None("the method's own default degree") and any explicit value — includingorder=1— forwards into every smooth-routed method's degree-acceptance matrix (quadratic/cubicaccept their own degree and raise on conflicts;log/sqrt/gamraise since they have no degree concept;loesstakes it as the local-fit degree, soorder=1now gives the linear local fit it asks for; negative values raise typed),truncate=forwards torobustand raises typed forlogistic/glm(which have no extent concept),show_metrics=works for every smooth-routed method plusrobustinstead of onlylm, andx_bins=/x_estimator=now bin/estimate for every smooth-routed method instead of onlylm— a chart passing them with e.g.method="loess"renders differently (previously they were silently dropped); forlogistic/glm/robust(whose dedicated transforms take no binning parameters) an explicitx_bins=/x_estimator=now raises typed liketruncate=does, instead of being silently ignored. -
KDE kernels
"exponential"(exp(−|u|)/2) and"linear"(triangular,1−|u|) join the one shared kernel dispatch, somark_density(kernel=...)'s documented list now computes end to end;mark_qq(distribution="lognormal")produces real lognormal theoretical quantiles (it was documented but refused by a stale validation tuple); andtransform_densitygainskernel=(default"gaussian", sharing the same dispatch — its gaussian output moves on the order of 1e-15 relative from a summation-order change, invisible at SVG precision). -
Breaking:
mark_smooth(method="glm")is newly accepted and fits a real GLM (IRLS via the sharedglmcore, Gaussian/Identity default), andmark_smooth(method="gam")is newly accepted and fits a real generalized additive model — a penalized cubic B-spline (P-spline) with a second-order difference penalty and a smoothing parameter chosen by generalized cross-validation, with the spline's Bayesian posterior confidence band. Both strings previously refused as unknown methods, so no existing chart changes.gambends to follow the data the wayloessdoes but is fully deterministic: it uses no randomness at all, soseed=has no effect on its band. It takes nodegree=(the spline degree is fixed at 3; smoothness comes from the penalty), and it holds its boundary value outside the observed x range rather than extrapolating a cubic.method="lm"withdegree>=2now routes to genuine polynomial regression. Formerly-silent paths now raise:degree=passed tolog/sqrt(previously ignored;glmrefuses it too), adegree=that conflicts withquadratic/cubic's own degree (previously ignored — the error namesmethod="lm", degree=das the escape hatch; a redundant matching degree is still accepted), and a whole-frame polynomialdegreeat or above the usable point count (previously rendered an empty plot with no warning — this includes barequadratic/cubicon 2-/3-row frames). A degenerate group in agroupbyfit, or a degenerate facet panel, still degrades to a NaN range instead of raising. TheSmoothconstructor'sdegreedefault changed from2toNoneso a baremethod="lm"stays linear. All other existinglm/loess/quadratic/cubicoutput is byte-identical. -
Breaking:
mark_line,mark_area, andmark_ribbonall render realinterpolate="monotone"(Fritsch–Carlson),"basis"(uniform cubic B-spline), and"cardinal"(Catmull–Rom) curves through one shared Rust curve module. Previously"monotone"/"basis"/"cardinal"silently fell back to a straight line onmark_line/mark_area, andmark_ribbonrefused anyinterpolate=other than the default outright. An interpolate value outside the seven-name vocabulary (linear,step,step-before,step-after,monotone,basis,cardinal) now raises on every mark that accepts the kwarg, via one shared typed error. Two disclosed byte-family changes on charts that already passinterpolate=(never on charts that don't): a banded/stackedmark_area/mark_ribbonunder a step variant now steps both boundary edges instead of only the top one; and a series whose independent axis isn't the vertical screen axis (Stack(value_axis="x"), or any chart underCoordFlip) now curves along its true axis instead of always assuming vertical. -
mark_arc(corner_radius=...)— previously accepted by the mark-kwargs allowlist but never read — now clips each wedge corner with an exact tangent-circle fillet, clamped per-corner to fit; the same wedge path renders a rounded polarmark_bar.corner_radius=0(the default) is byte-identical to every arc/polar-bar render from before this change. Interactive-only, disclosed: polar wedge scenes (arc and polar bar) gain apolar_spanskey per node, so a full-circle wedge (a one-slice pie, a single-grouppad_angle=0donut) is now hittable at every angle instead of only across a half-ring; static SVG is unaffected. -
a rug
mark_tick(band_size=...)— one quantitative axis encoded, no categorical channel — is no longer silently ignored. It now scales the rendered tick length by the panel's cross-axis extent (height for an x-rug, width for a y-rug). An unsetband_sizekeeps the old fixed length exactly, not the ordinal default of0.6(which would turn an untouched rug into panel-spanning bars). Ordinal-tickband_sizeis unchanged, including a strip-plot-style tick that declares an ordinal axis alongside its own quantitative one; only a tick whose x resolves to a quantitative scale while y is also separately declared (the boxplot-cap/errorbar-cap idiom, not a rug) keeps its fixed length and ignoresband_size, by design. -
Breaking:
mark_geoshapewith geometry present under any coordinate system other thanCoordGeo, andmark_arcunder any coordinate system other thanCoordPolar, now raise a typed error naming the mark and the required.coord()fix — instead ofmark_geoshaperendering a silently empty panel (axis furniture only, no geometry), ormark_arcfailing later with a generic missing-channel error that didn't name the real cause.mark_image's existing documented empty-scene behavior under a non-Cartesian coord is unaffected. Interactive-only, disclosed:fm.layer(cartesian_chart, polar_arc_chart).interactive()now refuses with the same typed error (the merged-flat interactive lowering drops the secondary layer's own coord, so the arc mark genuinely has none to satisfy its guard by the time it resolves); the identical chart's staticto_svg()/to_png()is unaffected, since the composite overlay tree renders each layer against its own coord. -
Breaking:
X(field, bin=True)/Y(field, bin=True)finally render on a standalone chart. Previously the encoding kept pointing at the original, unbinned field after theBintransform replaced the batch schema, so the chart either raised an unknown-column error or rendered garbage. TheBintransform gains optional per-bin aggregation of a second column (agg_field/agg, drawn from the standard aggregate vocabulary) emitted as a newagg_valueoutput; the encode path automatically foldsX(v, bin=True)+Y("w", aggregate="mean")into oneBin(the "standard histogram pattern" the docs already claimed works). Every rewritten channel's axis title recovers the user's own field name instead ofBin's internalbin_start/agg_valuecolumn names. A layered binned chart also gains anx2/y2span so binned bars draw their true bin width instead of the mark's default width — the one disclosed byte-family change on charts that already passedbin=True. Six new typed refusals replace what used to be either a silent no-op or a confusing downstream error: more than onebin=in an encoding; more than one channel carryingaggregate=; the same channel carrying bothbin=andaggregate=; a fieldless non-countaggregate; a pre-builtbin=Bin(...)asked to also absorb an opposingaggregate=; and an unknown aggregate method name. -
mark_function(clip=False)is a real toggle instead of a hard raise. The curve now genuinely extends past the panel edge for out-of-domain evaluation points, via a new per-batch scene-graph clip escape (MarkBatch.clip) rendered by both the SVG and WASM walkers.clip=True(the default) is byte-identical to every render from before this escape existed.
Fixed¶
-
Breaking: Every value column a statistical/window/flatten/aggregate transform reads now accepts any integer, unsigned, or float dtype (widened to
Float64), instead of hard-rejecting anything butFloat64(about 20 transforms, includingmark_errorbar,mark_smooth,mark_boxplot/boxen/violin,mark_raster,mark_swarm,transform_regression/loess/impute, anddata_bin/data_stack/pivot) or silently emitting nulls/NaN/an unsorted result for a dtype it couldn't handle. A dtype it genuinely cannot read now raises, naming the transform, column, and dtype, instead of the two silent failure modes:transform_filter/transform_calculateover aUInt8/UInt16/UInt32/Float16column used to silently evaluate toNullfor every row (anInt64column with identical values worked fine) — the expression field reader now reads every numeric width plusLargeUtf8;transform_timeunitwith an unrecognized unit string used to emit an all-NaNcolumn instead of raising;transform_top_kwith an unsupportedfielddtype used to synthesize arow_{n}key per row (silently defeating grouping) instead of raising.transform_flattenon a list-of-Utf8 column now emits real strings (it used to emit an all-nullFloat64column for any non-numeric list element type); list-of-int/float widens toFloat64; a carried (non-flattened) column now preserves its exact dtype via Arrow'stakekernel, including aFloat64column's nulls staying null instead of becomingNaN. The Python-sidecast_columns_to_float64shim ahead ofboxplot/boxen/violin/swarmis deleted — it silently coerced non-numeric-looking columns (including numeric strings) before the engine could refuse them by type; the engine's own typed refusal now applies uniformly. Every dtype that worked before renders byte-identically. -
Window
sort=andfield=columns accept every numeric dtype, and refuse the rest. An integer, unsigned, float or temporal column insort=used to compare as "equal" and leave the window unsorted, so the guide's own cumulative-sum-over-integer-dayexample accumulated in row order. An integerfield=used to read as NaN, solag,leadand the rolling aggregates returned an all-null column. Both now coerce once per transform. A dtype the window can neither order nor aggregate (Boolean,Binary, list types) raises aValueErrornaming the transform, the column and the dtype instead of silently producing an unsorted window or an all-NaN column, and a text column used as a numericfield=raises rather than aggregating to NaN. Afield=is now interned (and validated) even for ops that never read it, such asrow_number—{"op": "row_number", "field": "ghost"}now raisesdata_window: column 'ghost' not foundinstead of silently ignoring the field. -
Nulls in a window
sort=column order last. A numericsort=column previously compared a null equal to every other value, which left the sort order dependent on input order and made "tied" intransitive; nulls now order last and tie only with each other. A text (Utf8/LargeUtf8)sort=column changes in the opposite direction: a null string previously coerced to""and sorted first (and made null a peer of a genuine""); it now orders last, consistent with the numeric case. -
A window frame lying entirely before its own row is now empty.
frame=(a, b)with a negativeb(a window that ends before the current row) used to wrap around an unsigned cast and silently aggregate the whole partition. It now selects no rows, so the aggregate isNaN, or0forcount. -
transform_regression(groupby=...)andtransform_loess(groupby=...)were declared on the wire and accepted by Python, but silently ignored by the Rust transform — every group's fit collapsed into one global curve, and the output never carried the groupby column, socolor=against it raisedunknown column. Both now fit independently per group, appending the groupby column at its original dtype; each group's output grid spans that group's own x-range, matchingmark_smooth's existing grouped behavior. Ungrouped output is byte-identical. -
Breaking:
transform_impute(groupby=...)computed one fill value across the whole column regardless of group; it now computes each group's fill value from that group's own rows. A group with no non-null values of its own falls back to the whole column's statistic rather than degrading to0.0(which would silently distort the domain) or±inf(which would drop the group's rows from the render).transform_impute(key=..., keyvals=...)is newly implemented with Vega semantics: within each group (or the whole batch, with nogroupby), the key universe iskeyvals=when given, else the distinctkeyvalues observed across the batch; a key missing from a group gets one row appended with the key and group columns set and every other column null.keyvals=withoutkey=raises; akeyvalselement outside the key column's own dtype width (e.g.3000000000against anInt32key) raises instead of silently wrapping to a corrupted or duplicate key. -
mark_swarm(size=/spacing=)andmark_raster()'s defaultresolution="screen"were silently inert in every real render — the transform pipeline never received the chart's pixel dimensions, so swarm always used a fixed collision radius in raw data units (not pixels) andresolution="screen"always produced a fixed 256×256 grid regardless of chart size. Both now respond to the chart's actual dimensions via a pre-layout size estimate — raster's grid genuinely scales up with a bigger panel. Swarm's pixel spread responds only to its value-axis panel dimension, inversely (height when vertical, width whenorient="horizontal"): a longer value axis produces a smaller spread at the samesize=, since less cross-axis displacement is needed to avoid collisions. The cross-axis panel dimension has no effect. This is an approximation (it excludes axis/legend/facet-strip gutters, unknowable before layout), not an exact match for the rendered plot area — see the dated note inferrum-spec.md§3.3 (Marks) for the full contract. An explicit numericresolution=is unaffected.mark_raster()/mark_violincharts change by a small amount (the estimate is close to the true panel size);mark_swarmcharts change qualitatively, not slightly — since the pre-fix radius was a constant in the value column's own units, a column ranging over thousands produced no collisions at all and the cloud collapsed to a single line, regardless ofsize=/spacing=. No other chart's output changes. -
mark_ticknow renders per-rowcolor=/fill=/stroke_opacity=/opacity=encodings — previously every tick rendered in the theme's flat default color and full opacity regardless of an encoded channel. Interactive-only, disclosed: one-channel tick layers in the merged-flat interactive assembly now dispatch by their declared shape (extending the existingmark_ruleown-channel normalization tomark_tick), converging interactive tick geometry onto what static SVG already rendered — zero static byte movement. -
mark_contour/bivariatemark_densityfill mode no longer leaves a permanent unfilled hole at the density peak. The highest band now extends to the surface's true maximum instead of stopping at the second-highest threshold, sothresholds=1renders a real band instead of nothing, and the color legend/colorbar (keyed off the same value) now reports the true peak instead of the second-highest threshold. Gaussian smoothing (smooth=True, the default) now runs before level computation instead of after, so isolines/bands derive from the same surface being contoured — asmooth=Truechart's contour lines/bands may shift visibly;smooth=Falsecharts are unaffected. -
mark_violin(inner="quartile")'sq1/median/q3ticks now confine to the violin's own body width instead of spanning the full panel — the same "a bare{x, y}mark="rule"draws a full-panel span" root causemark_boxen's median line had. A hue split (color_fielddistinct from the categorical axis) still isn't per-hue dodged for these ticks — an existing, unchanged limitation, not new drift from this fix. -
mark_boxplot(extent="min-max")no longer flags points beyond a fixed 1.5×IQR fence as outliers when the whiskers already reach the data's true min/max. The outlier point layer is now suppressed entirely for"min-max", regardless ofoutliers=. -
Breaking:
mark_boxen's depth cap unifies at 12, raised from two independent, disagreeing caps (6 in Python, 8 in Rust) — datasets large enough to compute deeper letter-value bands (k_depth="full"at a few hundred rows;"tukey"/"trustworthy"in the thousands) now render those previously-dropped bands instead of going visually inert once both caps saturated.palette=now samples 11 colorable slots instead of 5 — a continuous scheme's sampled colors shift on every existingpalette=chart; a categorical palette's first 5 colors are unaffected. The default opacity ramp renormalizes against each chart's own real materialized depth rather than the configured cap, keeping adjacent-band contrast visible on typical (shallow-depth) charts instead of flattening it. The median layer is now a band-confinedmark="tick"instead of a full-panel-spanningmark="rule". Every layer's axis titles recover the user's field names instead ofLetterValue's internalgroup/lower/upper/valuecolumn names. The"outliers"secondary batch now emits only the true fenced-out rows instead of every observation — and, because the raised depth cap means a large-enough dataset (n ≳ 4096under Tukey's own formula) now computes one or more letter-value depths deeper than before as the dataset grows (exactly one depth deeper just past that threshold, more at larger sizes, clamped once the new cap of 12 is reached), the outlier fence itself also tightens for those large datasets, on top of the pure filtering fix; smaller datasets see only the filtering change, not a fence shift. -
Facet/FacetRow/FacetCol'stitle=kwarg — accepted silently but completely discarded — now reaches the rendered strip and the serializedfacetdict, rendering"{title}: {value}"when a title is set.Chart.facet(title=...)sets the primary/wrap-dimension strip's title. Facets with no title set remain byte-identical.
Breaking changes¶
Concise index of the behavior changes; each has its full disclosure in the Added/Fixed bullet it names.
- Transform dtype policy: a column dtype a transform genuinely cannot read now raises a typed error naming the transform, column, and dtype, where about 20 transforms previously emitted silent nulls, NaN, or unsorted output; the Python-side
cast_columns_to_float64shim is deleted. - Window semantics:
rank/dense_rankwithoutfield=are tie-aware; rolling frames are peer-aware by default (transform_window(..., ignore_peers=True)restores row-offset frames); nulls in asort=column order last, including text columns where a null previously coerced to""and sorted first. - Registry hardening: a named
ferrum.QQ(name=...)spec's reference-line batch key is now"{name}_qq_line"; an unknown aggregate name inpivot/top_kraises instead of silently summing; explicitbandwidth=on non-loess anddegree=on degree-lessmark_smoothmethods raise. lmplot/regplot:order='s default changed1→Noneand every explicit value now forwards;truncate=,show_metrics=, andx_bins=/x_estimator=act (or raise typed) on every method instead of being silently dropped for all butlm;logistic/glm/robustfits pool across group-color encodings and draw in a neutral stroke.mark_smooth: conflicting or degeneratedegree=values raise instead of being ignored or rendering an empty plot; theSmoothconstructor'sdegreedefault changed2→None.- Panel-size awareness:
mark_swarmclouds that previously collapsed to a line now spread (a qualitative change — the old collision radius was in raw data units);mark_raster(resolution="screen")grids scale with the panel. - Curves: charts already passing
interpolate=step both boundary edges of banded/stacked areas under step variants, and curve along their true independent axis underStack(value_axis="x")/CoordFlip. - Binning: layered charts already passing
bin=Truedraw true bin-width bars (x2/y2span); six formerly-silent bin/aggregate misuse patterns raise typed errors. - Contours:
smooth=Truecharts derive isolines/bands from the smoothed surface (a visible shift); the peak band now exists and the colorbar reports the true maximum. mark_boxen: the depth cap unifies at 12 (deeper bands render; the outlier fence tightens forn ≳ 4096),palette=samples 11 colorable slots, and the opacity ramp renormalizes per chart.mark_boxplot(extent="min-max")suppresses the outlier point layer entirely.- Coord guards:
mark_geoshapeoffCoordGeoandmark_arcoffCoordPolarraise typed errors naming the fix, instead of a silently empty panel or a generic downstream error;fm.layer(cartesian, polar).interactive()refuses with the same error.
Changed¶
- The interactive WASM bundle is rebuilt with the batch-E scene contract:
polar_spanshit-testing for full-circle wedges, shared curve tessellation, and the per-batch clip escape.
Documentation¶
- Batch D and E behavior is documented where users read it: the data-transforms guide (registry,
groupby=, window frames, kernels), the marks-encodings guide, and datedferrum-spec.mdnotes — every claim probe-verified against live renders.
0.21.0 — 2026-09-04¶
Four correctness campaigns land at once: the appearance-resolution batch (color/opacity/dash scales resolve end to end, one CSS color parser), the config-plumbing batch (every parsed configure_*/format/padding/legend field honored or typed-refused), the scale-variants batch (reverse= on every continuous positional scale, real d3 band/point padding+align geometry closing #67, TimeScale datetime domains and a pinned UTC contract, a wire-level scale-key gate), and the 2026-08 residuals/findings batches (total overlay chrome dedup, keyed interactive transitions, typed panel chrome slots). Silent parameter drops die loudly throughout.
Added¶
reverse=TrueonLinearScale,LogScale,PowScale,SqrtScale,SymlogScale, andTimeScale— domain-swap sugar applied at render time; the raw-dictscale={"type": ..., "reverse": true}spelling is honored identically.PointScale.reverse(a different mechanism, #65) is unaffected.TimeScale(domain=None)constructs with an inferred domain like its continuous siblings;domain=also acceptsdatetime.date,datetime.datetime(naive or aware), and ISO-8601 strings, in addition to epoch-ms floats- Unknown raw-dict
scale={...}keys refuse at the wire boundary, naming the offending key, the scale type, and the accepted list — closing the last#[serde(flatten)]silent-drop carve-out; the accepted-key table is published asferrum._core.scale_accepted_keys(scale_type) Chart.overridescale paths now derive from the wire schema itself: seven wire keys gained override paths (nice,zero,stops,domainParam,paddingInner,paddingOuter,domainMid), snake_case aliases (padding_inner,padding_outer,domain_mid,domain_param) are accepted alongside the wire spellings, and five previously advertised-but-broken leaves now work;utcandquantiles(never functional as overrides) refuse with the registry's own guided error.ferrum._core.validate_scale_dict(scale)validates any raw scale dict through the same gate that enforces it.scale=honored forStrokeOpacityandStrokeDash; opacity and dash scales resolve end to end with auxiliary legends- One full-CSS color parser — 148 named colors,
rgb()/rgba(), strict boundaries; literal colors validate at mark construction (loud, sentinel-aware) - Discretizing color scales (
Quantile/Quantize/Threshold) resolve — buckets, swatched colorbars, loud configuration;ContinuousSchemeis constructible andGradientrenders underscale= - Heatmap appearance kwargs take real effect; inert continuous color on line and ribbon now fails loudly instead of silently flattening
- Config wire-key gate,
axis_y2, and a field-disposition manifest; format presets resolve on all five Python surfaces;format_typethreads end to end (time presets, real ordinal, typed spec refusals) - Padding validates at every Python entry point with typed pixel-contract refusals;
padding.autois real and opt-in, andPaddingExceedsViewporttells the truth - Axis label culling is reachable with a real pixel-gap semantic; the legend
orient/direction/valuescontract is real; axis config plumbing — every parsed field honored or typed-refused raster_aggregatehonest surface — construction refusals,raster_fieldthreaded- Typed chrome-vs-content
Panelslots (below_marks,chrome_above); total overlay chrome dedup — one shared plot rect, suppression gate retired (#89) mark_boxen(palette=)colors the depth bands (#91); keyed interactive transitions with object constancy — enter/exit fades at any batch size (#93)label_anglehonored on the y axis (full transpose); theta-gated single-axis exemption for arc marks
Fixed¶
BandScale(padding=/padding_inner=/padding_outer=/align=)andPointScale(padding=)observably change bar/box/tick/heatmap-cell geometry, following d3's own layout model; categorical axis tick labels now land exactly on bar/box/tick centers for a padded scale with no explicitrange=— previously the two disagreed by several pixels (#67)TimeScale(utc=True)/utc=Falserender byte-identical SVG, and theutcwire tag survives resolution (previously silently dropped tofalseregardless of the source tag).override(<channel>_scale_domain=...)on amark_bar()chart no longer includes a spurious zero-anchor — it now matches the equivalent explicitscale={"domain": [...], "zero": False}spelling (behavior change; seeferrum-spec.md§3.6's 2026-09-04 dated note)BandScale(range=...)combined with an explicitAxis(values=...)tick-value override no longer panics — it now renders, landing the relabeled ticks on uniform slotsLayerChartgains|and&composition operators; one canonicalto_polarscoercion; user-facing messages name channels as the user wrote them underCoordFlipencode()is a total function — five channel buckets,keyhonored, silent drops killed; the 17 accept-and-delete mark parameters resolved (implemented, removed, or documented); closed vocabularies enforce at construction- Deprecated axis
x/yoverride spellings refuse with an accurate typed hint - One axis label-band quantity, gated on labels actually drawing (#97); degenerate-domain guards across continuous scales (#99, #104)
- Non-Utf8 color columns group line and ribbon series; literal-paint layers keep their paint under inherited color channels; categorical-intent
color_fieldcolumns type Nominal at the desugar - Phase-10 diagnostic charts (ROC/PR family) gained their legend titles; heatmap default cell borders now parse — goldens re-blessed and visually verified
- Temporal scale domains accept
numpyscalars andDecimalidentically on chart-level and layer routes
Breaking changes¶
BandScale.scale()returns d3's band leading edge, which moves forpadding_inner > 0:BandScale(domain=list("abcd"), range=[40, 260]).scale("a")is now45.366, was48.049— correcting a placement that let the last band extend past the declared range end.bandwidth()andPointScale.scale()are unaffected. Default (unpadded) output is byte-identical.- Naming
BandScale()explicitly now carries d3's ownpadding = 0.1default (on both sides), where an auto-inferred categorical axis still constructs unpadded — a chart withscale=fm.BandScale()(or any explicitBandScale(...)with nopadding=argument) renders narrower bands than before, with no padding argument anywhere in user code (four categories,mark_bar():112.327px auto-inferred vs.98.629px forscale=fm.BandScale()). - The label-collision budget (
cascade_slot_w/cascade_slot_h) now derives from the true adjacent-tick spacing (min_adjacent_gap(centers)) instead of a scale-blind quarter-slot heuristic, changing when axis labels rotate or wrap on non-default categorical scales: about 2.4% tighter (w/4 → w/4.1, more rotation/wrapping) underpadding=0.1, and about 33% looser (w/4 → w/3, less) underPointScale(padding=0). Default (unpadded) categorical axes are unaffected (the change is ulp-scale only there). mark_tick(band_size=)andmark_rect(band_size=)unify on full-length semantics (#85): an explicitband_size=value that previously measured a half-length extent now measures the full slot, so explicit values render up to twice their previous extent (defaults unchanged).
Changed¶
config_applyextracted fromrender/mod.rs— configuration-application ordering is a test-pinned API property (#143);render/mod.rsroughly halves- Structural refactor waves:
StructuralOutputcollapse,SlotRescaleCtx/PanelResolveCtxbundles, typedRangeProvenance, slot-index accessors,BatchPositionMeta(#41, #57, #61, #63, #64, #79, #86) - ~38 closed-choice validation sites route through one
validate_choice; the five-bucket channel policy homed inencoding/_channel_policy.py(#103); inverted marks/diagnostics → plots dependency edges deleted;coord_flippedis a typed state
Documentation¶
- Corrected two falsified claims below: the
0.10.0(2026-05-20) entry claimingreverse=on continuous positional scales had shipped (it hadn't — not until this release), and the0.20.2(2026-07-23) entry claimingBandScale(padding_inner=)was confirmed geometrically inert on the dodge path (it no longer is, as of this release)
0.20.2 — 2026-07-23¶
A patch release closing out every open bug issue: annotation overlay, dodge geometry, tooltip provenance, and stacked-rendering fixes (#58, #66, #77, #78, #84). Two fixes are visibly rendering-correcting: boxplot median lines previously drew at twice their box width, and stacked histograms previously relied on paint order rather than true base-to-value segment geometry.
Added¶
None
Fixed¶
- Labeled
annotate_arrowstays a plainChartand survives+overlay — previously the labeled form returned aVConcatChart, raisingTypeErroron overlay and splitting standalone rendering into two panels (#84) - Dodged mark widths (bar/rect/tick) clamp to their sub-band, so
Dodge(padding > 0.1)can no longer render overlapping groups;BandScale(padding_inner=)was confirmed geometrically inert on the dodge path (#66)
Correction (2026-09-04, batch C):
BandScale(padding_inner=)is no longer inert on the dodge path — the Unreleased batch above made scale padding real render geometry system-wide (closing #67), andDodgecomposes with it: scale padding defines the drawn band, andDodgesubdivides that band and applies its own padding within it. - Boxplot median lines span exactly their box body instead of twice its width — a tick-vs-rectband_sizesemantics drift in the boxplot desugar, exposed by the dodge clamp; affected goldens regenerated and visually verified (#66) - Tooltip provenance is carried by an explicit promotion marker instead of re-derived from structure: a genuine chart-wide.encode(tooltip=)on a merged chart now suppresses per-layer auto-injection even with mixed per-layer tooltips, and field-based linked selections get per-layer tooltip fields (own auto fields ∪ selection fields) instead of one shared set (#78, #58) - Horizontal stacked histograms and densities render correctly —mark_histogram(orient="horizontal", multiple="stack")previously raisedValueErrorfor every input. The stack's value axis is now carried on the wire (Stack.value_axis), mark drawers and domain widening are axis-aware, and all four bar drawers draw true base-to-value stacked segments — fixing the default vertical stacked histogram and real coord-flip stacked bars, whose segments previously overlapped and only looked stacked via opaque paint order (#77) - Labeled arrow+-overlay renders the same headless segment as every other arrow path (cross-path parity)
Other¶
- Violin facet goldens regenerated for the median-width fix; lock-file version sync; docstring reference conversions and new-user docs audit fixes
0.20.1 — 2026-07-15¶
A patch release of interactive-rendering and annotation fixes found after v0.20.0, plus a documentation pass (#80–#83).
Added¶
None
Fixed¶
- Interactive charts: bold text (figure title, axis titles, legend title) no longer renders blurry. The overlay
@font-facenow embeds the Inter variable font (font-weight:100 900) instead of a single-weight Regular face that forced the browser to synthesize faux-bold (#80) - Composite charts'
.show()(hconcat/vconcat/concat/jointplot/clustermap/layer) displays inline in Jupyter instead of printing raw SVG markup to stdout (#81) annotate_hline/annotate_vlinelabel=now renders the label and is preserved under+overlay onto a chart, where previously it was a documented no-op (#82)
Other¶
- The accepted mark-style
**kwargsare now documented in a canonical "Mark style kwargs" guide section generated from the enforced allowlist, with docstring cross-references replacing vague "…, etc." boilerplate (#83) - API reference: the config value-classes (
Configure,AxisConfig, …),resolve_format, andFerrumOverrideErrorare homed on a newconfigurepage, and the Altair comparison's stalemark_geoshape"not yet implemented" rows are corrected
0.20.0 — 2026-07-12¶
Two secondary-axis and composite-legend feature streams (#52, #16), the dodge-by-model compare= diagnostics (#42), and Band/Point range handling (#39/#65/#40) land alongside a release-scoped bug sweep that consolidated 23 edge-case defects into eight remediating issues (#69–#76).
Added¶
chart + SecondaryY(...)secondary y-axis: per-layer independent y-scale slots, dual-side stacked axis bands, and a per-(panel, slot) interactive runtime for dual-axis charts (#52)- One figure-level legend for a composition:
fm.Resolve(scale=, legend=), a compositor legend band for shared color/size resolve, andpairplot/jointplotrendering a single legend onhue(#16) - Shared color/size resolve now spans nested composites via a leaf-span union, so an outer
resolve={"color": "shared"}reaches leaves inside nested concats and layered overlays (#74) configure_legend(orient="none")suppresses a chart's legends and the figure-level band (#74)importance_chart/shap_bar_chart/cv_scores_chartrender dodge-by-model panels undercompare=; dodge eligibility extended to importance/shap_bar/cv_scores/text marks (#42)SecondaryYvalidates itsmarkagainst the primitive-mark set with a typed error (#71)- Axis-tick text nodes carry an explicit slot tag on the scene wire (#60, #73)
Fixed¶
- Scale constructors no longer silently fabricate placeholder ranges or accept non-finite values:
BandScale/PointScale/DivergingScale/SequentialScaleand the continuous scales raise typed errors on short or non-finite input (closing aLinearScale(range=[5.0])panic), and inverted band ranges yield non-negative bandwidth (#69) - An explicit Band/Point pixel range is re-anchored per facet panel, so marks and ticks stay within each panel instead of pinning to one absolute window (#70)
- Independent-y conflict detection is spelling-independent — both the
LayerChart(resolve={"y": "independent"})andchart + SecondaryYforms raise the same conflict underresolve={"y": "shared"}(#71) - The internal column-collision rename is deterministic and never leaks a
__rhs_sentinel into axis titles or tooltips; the original column name is shown (#71) - An explicit tooltip on the primary layer of a layered chart no longer leaks to other layers; a scale-domain
paramdeclared on a layer reaches the wire so the secondary axis reflects its declared domain (#71, #72) - Interactive dual-axis charts: hit-testing finds rescaled secondary-layer marks at their displayed position, and a single-tick secondary axis relabels under a slot rescale (#73)
jointplot(kind="reg")andjointplot(marginal_kind="box")render instead of crashing;pairplot/jointplotaccept an encoding object forhue;hist/hex+huerender one figure legend (#75)compare=diagnostics: a reserved"base"key raises instead of overwriting the primary model,top_k=0gives a legible error, infinite/NaN importances no longer corrupt the domain or ranking, and tied scores order deterministically (#76)- Dodge selects its band axis by resolved scale kind, fixing overlapping/desynced boxes for natively-horizontal marks (#75)
PointScale(reverse=)is honored at the positional resolver; categorical axis ticks sit at explicit-range band centers; a 3-elementDivergingdomain no longer truncates positional axes (#65, #39, #40)
Changed¶
- Per-layer y-domain resolution is unified: one param-aware resolver feeds both axis ticks and mark placement, and the layer→slot mapping is computed once at prepare time (
YSlotPlan) (#72) - The nested-resolve effective-mode gate is a single shared function across the domain-union and legend-band walks; the legacy secondary-axis silo and the dead spatial-index API were removed (#74, #52)
- Diagnostic domain/ranking/field-name helpers were de-duplicated into shared functions across the
plotsbuilders
0.19.0 — 2026-07-05¶
Breaking changes¶
compose_svg_horizontal/compose_svg_vertical/compose_svg_gridremoved from the public API — every composition renders through the unified Rust composite path; there is no public low-level SVG-stitching surface- All-empty compositions (e.g. zero-row
pairplot/jointplot) raise a typedValueErrorinstead of rendering a blank grid JointChart.share_scale()/ClusterMapChart.share_scale()raise a typedValueError— their panels share axes by construction (#53 tracks nativeresolve=support)LayerChartwith explicitresolve={"x"/"y": "independent"}raises a typedValueError(overlay one-panel contract; #52 tracks per-layer scales) — previously silently ignoredshare_scale()now resolves at render time through the composite tree with transform-aware unions (box whisker reach, KDE support); it no longer injects explicitscale=dicts onto child charts
Added¶
- composite spec tree types for the Rust composite render path (#45)
- per-panel LayoutScale for ratio-fitted composite cells (#45, D4a as amended)
- composite scale-resolution pass (#45, pass 1 of 3)
- thread D4b resolved-domain context through the scale seam (#45, Task 5a)
- composite render core — layout + scene passes (#45, passes 2+3)
- PyO3 composite entries + WASM baked-geometry single source of truth (#45, Task 5c)
- route HConcat/VConcat through the Rust composite render path (#45, Task 6)
- ConcatChart wrap grids through the Rust composite path — compare= rides automatically (#45, Task 7)
- per-leaf binding inputs + themed per-child panel labels (#45, Task 5d)
- residuals compare= shares axes position-wise — the #45 headline
- grid/wrap hole children on the composite wire (#45, Task 8a)
- JointChart + ClusterMapChart through the Rust composite path as ratio grids (#45, Task 8b)
- RepeatChart + LayerChart through the Rust composite path — all forms unified (#45, Task 9)
- color/size channels in the composite resolve pass (#45)
- Python composite-path gate drops ahead of legacy deletion (#45)
- root chrome config + sized linear holes on the composite wire (#45)
- delete the Python legacy composition paths (#45)
- compose_svg_* no longer exist (#45)
Fixed¶
- clear ValueError for leverage-only charts on no-hat-matrix models (#44)
- per-class cumulative walk + facet for shap_waterfall per_class=True (#46)
- chart-level explicit x/y scales survive composite-mark desugar (#45)
- sanitize zero/non-finite log-domain endpoints at construction (#49)
- split render passes at scissor boundaries — wgpu 29.0.3 workaround: interactive scenes with 3+ mark-bearing panels drew only one panel's marks (#51 tracks upstream)
- full 72h-review findings burn-down — every logged item resolved; interactive renders now emit RenderWarnings like static output (#50)
Changed¶
- share_scale unified onto resolve= — one sharing mechanism (#45 close)
- collapse whole-change gate findings (#45)
Other¶
- per-class waterfall golden + SHAP compare= shared-x hardening (#46)
- remediation spec + plan for issues #44/#45/#46; Phase B spec, plan, and sub-decision records
- Phase B close — composite rendering documented, W4/W5 retired
- track Phase A design-review S2 deferrals
- 72h full-span review close — exclusion semantics documented, findings logged
- ruff-format the wrap_svg_with_chrome stub
0.18.2¶
2026-06-27
Added¶
- render compare= small multiples for 4 clustering charts; refine 2 sweep-chart rejections (#35)
- render compare= small multiples for regression charts + fix pooled-residual band (#35)
- render compare= small multiples for 4 model-selection charts (#35)
- render compare= small multiples for 6 explanation charts (#35)
- add
_compose_comparehelper for multi-model small multiples (#35) - add Quantile/Threshold ScaleSpec wire variants (SPEC-04, #38)
Fixed¶
- pdp compare= must use independent per-feature x, not shared (#35)
- order-preserving ordinal scale sharing (#35)
- positional Quantile/Threshold use data extent, not domain-as-extent (SPEC-04)
- single-source
_scale_to_dictvia Rust delegation; fix Quantile/Threshold encode (SPEC-04)
Changed¶
- de-duplicate compare= gate kwargs + public ComparedModelSource accessor (#35)
- single-source the scale wire form via Rust
to_scale_spec(SPEC-04)
Other¶
- ferrum-spec compare= note + representative compose goldens (#35)
- align ComparedModelSource import order in explanation.py (#35)
- design spec + plan for compare= aggregate rendering (#35)
0.18.1¶
2026-06-25
Added¶
- None
Fixed¶
- Unify cmap-vocabulary holdouts on the canonical
scheme=keyword. (#32) - Offset inset
<svg>and data-anchored<image>raw nodes in composed interactive renders. (#34)
0.18.0¶
2026-06-23
This release lands the cohesion campaign: a 20-agent audit of the whole
codebase surfaced 193 findings (sibling drift, duplicated logic, silent-failure
seams, naming drift, dead code), and every one is now addressed across the Python
declaration layer and the Rust compute/render engine. The refactors are
overwhelmingly byte-identical — the full SVG/PNG golden suite is unchanged — so
the bulk of the work is invisible at the output level. The user-visible changes
below are correctness and no-silent-failure improvements, API-vocabulary
unification (old spellings keep working as deprecated aliases), and one breaking
module-path change (ferrum._diagnostics → ferrum.diagnostics).
Added¶
compare=andrandom_stateare now uniform across the model-diagnostic figure families.roc_chart,pr_chart,prediction_error_chart,residuals_chart, and their siblings acceptcompare={"name": model, ...}for multi-model overlays and arandom_state=for reproducible resampling, where before only one module exposed them. The 17 single-model-aggregate paths that cannot overlay raise a documentedValueErrorinstead of silently ignoringcompare=. (PLOT-01, XSIB-08)
Fixed¶
-
Degenerate hand-authored
BinJSON now errors instead of silently coercing (behavior change). AChartSpec.from_jsoncarrying a hand-built bin spec withbin_count: 0or a non-finite / non-positivebin_widthpreviously fell back to a self-inconsistent Sturges default; it now raises. Unreachable through theBin/transform_binconstructors (which already validate) — only hand-authored JSON is affected. (XFORM-04) -
A mistyped data-transform key now raises instead of being silently dropped (behavior change). The
transform_*family (transform_bin,transform_aggregate,transform_window, ...) serializes to a dict that crosses into the Rust engine. Previously an unknown key on that dict — e.g.transform_bin("x", maxbin=3)with themaxbinstypo, or a hand-built{"type": "data_bin", "field": "x", "maxbin": 3}— was dropped without warning, so the transform silently ran with defaults. Each per-transform spec now carries#[serde(deny_unknown_fields)], matching the strictness the theme and per-channel paths already enforce. An unrecognized key now raises aValueErrorthat names the bad field and lists the valid ones (e.g.unknown field `maxbin`, expected one of `field`, `as_`, `maxbins`, `step`, `nice`, `name`, `extent`). Valid transforms are unchanged and render byte-identically. (SEAM-04) - A mistyped encoding-channel or top-level chart key now raises instead of being silently dropped (behavior change). Completing the no-silent-drop seam,
EncodingSpecandChartSpecnow carry#[serde(deny_unknown_fields)]. A hand-authored spec with a typo'd channel key (e.g.{"field": "a", "typ": "Q"}instead of"type") or a typo'd top-level key (e.g.{"marrk": "point", ...}instead of"mark") fed toChartSpec.from_jsonnow raises aValueErrornaming the unknown field, rather than dropping it and rendering with the value silently missing. Only reachable via hand-authored JSON — ferrum's ownto_json/to_specnever emits an unknown key, so every valid chart round-trips byte-identically. The one documented exception is ascalesub-dict:ScaleSpecis an internally-tagged enum whose variants flattenContinuousScaleCommon, and serde cannot enforcedeny_unknown_fieldsthrough a flattened/internally-tagged shape, so a typo'd scale key (e.g.clammp) is still tolerated and dropped. This structural constraint is documented on theScaleSpecdoc comment. (SEAM-04) Bin2D(2-D histogram binning) now errors on a reversed or degenerate explicit extent instead of silently dropping every row (behavior change). A reversedextent_x=(hi, lo)(orextent_y) made the in-cell row filter exclude every in-range value, sojointplot(kind="hist")and other 2-D histograms produced an empty grid with no error or warning.Bin2Dnow validates each explicit extent (lo < hi, both finite) at both construction and apply, raising aValueErrorthat names the offending axis — exactly as the 1-DBinalready did. Fixes a pre-existing sibling-drift between the 1-D and 2-D binners. Valid extents are unchanged and render byte-identically.
Changed¶
- Render-warning text is now a stable Display contract instead of the Rust enum's Debug shape (behavior change). Warnings emitted during rendering (palette overflow, out-of-domain rows, empty/collapsed panels, dropped facet panels, ignored sort specs, color-range parse failures, elided tick labels, overflowed legends) cross into Python via
warnings.warn. Previously the message text was the RustRenderWarning/LayoutWarningDebug representation (e.g.SortSpecIgnored { reason: "missing field" }), which leaked the internal variant/field shape and was not a stable contract — any field rename silently changed the string. Each variant now has an intentionalDisplaymessage (e.g.sort spec could not be applied (missing field); categories fall back to insertion order), so the user-facing warning text is a deliberate, stable sentence. Code that string-matches on warning messages should match on the new wording. SVG/PNG output is unaffected — warnings are not part of the rendered document. (SEAM-07) - The unused typed
Data*transform classes were removed from the Rust extension (internal). Every Phase-12 data transform existed twice across the Python/Rust seam: the public dict-emittingtransform_*functions (the only path inferrum.__all__) and a parallel set of typed PyO3 classes (DataAggregate,DataBin,DataCalculate,DataFilter,DataFold,DataPivot,DataStack,DataWindow) that were registered intoferrum._coreand declared in the type stub but never imported, exported, or constructed anywhere. The dead typed classes are gone, leaving one transform-construction API (thetransform_*functions). Thetransform_*public surface and behavior are unchanged. (SEAM-02) - Diagnostic visualizer
score()now delegates to the wrapped estimator (behavior change). TheFerrumVisualizerbasescore(X, y)previously returned a meaningless0.0stub for every visualizer that did not hand-override it. It now returnsfloat(self.model.score(X, y))whenever the wrapped model exposes a.scoremethod, and keeps the0.0fallback only for genuinely no-model visualizers (rank / parallel-coordinates / class-balance / elbow, all constructed withmodel=None). This means model-backed visualizers that previously inherited the stub —PRVisualizer,ConfusionMatrixVisualizer,ClassificationReportVisualizer,ClassPredictionErrorVisualizer,DiscriminationThresholdVisualizer,CalibrationVisualizer,CooksDistanceVisualizer,FeatureImportancesVisualizer,PCAVarianceVisualizer,InterclusterDistanceVisualizer,SilhouetteVisualizer,ManifoldVisualizer, the SHAP visualizers, and the deprecatedSHAPVisualizer— now report the estimator's own metric instead of0.0.ROCVisualizerkeeps itsroc_auc_scoreoverride; the four model-selection visualizers and the two regression visualizers shed their identical copy-pasted overrides and inherit the base. Thehas_scoreclass flag is replaced by a derived read-only property:viz.has_scoreisTrueexactly whenscore()returns a real metric (i.e. the wrapped model exposes.score), so the flag can no longer drift from the method. Two derivation gaps in that property are now closed so it holds for the non-standard subclass shapes too: (1)ROCVisualizer.has_scoremirrors its ownscore()condition (a model withpredict_probabut no.score— a valid sklearn shape — now reportshas_score=Trueandscore()returns the AUC, where before the inherited flag saidFalse); and (2) a multi-modelCalibrationVisualizeroverlay (two or more positional models, or a single dict-of-models like{"a": m_a, "b": m_b}) now reportshas_score=Falseandscore()returns the documented0.0no-single-score fallback, instead of silently scoring only the first model (positional) or returning the stub (dict). Single-model calibration is unchanged. No SVG output changes —score()andhas_scoreare not part of rendering. annotate_textanchor/align vocabulary reconciled (additive, non-breaking).annotate_textnow accepts the canonicalanchor=keyword in the SVG vocabulary ("start"/"middle"/"end"), matching [annotation.text][]. The formeralign=keyword ("left"/"center"/"right") keeps working as a deprecated alias, mapped via{left: start, center: middle, right: end}. With neither supplied, the anchor defaults to"middle"(identical render to the oldalign="center"default). Supplying bothanchor=andalign=raisesValueError. Internally, the coordinate-coercion logic that was duplicated acrossannotations.pyandannotation/primitives.pyis consolidated into one home (annotation/coords.py), and eachannotate_*now captures user-supplied style once and derives both the mark and the annotation primitive from it so they cannot drift. Render output is byte-identical (verified against the annotation golden suite).- Dodge position adjustment now accepts non-string grouping columns and signals an unresolvable grouping channel (behavior change).
Dodgeresolves its grouping channel (theby=field, else thecolorencoding) through the same category-coercion the rest of the renderer uses, so an integer, float, or boolean grouping column now dodges correctly. Previously only string columns worked; a non-stringby/color column raised an error. The two contradictory failure policies are unified: a grouping channel that cannot yield categories — a missing/typo'd named column, or an un-categorizable dtype such as a timestamp — now emits one warning (dodge could not be applied (...); marks were not offset) and leaves the marks un-offset, instead of either hard-crashing (non-string) or silently producing an un-dodged plot (missing column). The one remaining silent no-op is a dodge requested with no grouping channel at all (nobyand no color), which has nothing to group by. Existing string-column charts render byte-identically. (RSUP-05) - Breaking (defining-module path only): the model-diagnostics implementation package was renamed from the private
ferrum._diagnosticsto the publicferrum.diagnostics. The public import surface is unchanged —from ferrum import ROCVisualizer,import ferrum; ferrum.ModelSource, and every otherferrum.<Name>spelling work exactly as before. What changes is the defining module path: each diagnostic class's__module__(and any direct submodule import such asfrom ferrum._diagnostics.source import ModelSource) now readsferrum.diagnostics.*instead offerrum._diagnostics.*. Code that imported a diagnostic class from the canonicalferrumnamespace is unaffected; only code reaching into the privateferrum._diagnostics.*path needs to update toferrum.diagnostics.*. Heavy sklearn-boundary internals moved underferrum.diagnostics._internal, thevisualizers/submodules were underscore-prefixed to matchsources/, and the visualizer surface is otherwise identical. - The deprecated dispatcher shims
shap_chartandrank_chartare no longer in__all__(advertised-surface reduction, non-breaking for direct imports). Both have long been deprecated dispatchers that warn and forward:shap_chart(kind=...)toshap_beeswarm_chart/shap_bar_chart/shap_waterfall_chart, andrank_chart(rank=...)torank1d_chart/rank2d_chart. They are dropped fromferrum.__all__andferrum.plots.__all__so the canonical split functions are the only advertised surface (from ferrum.plots import *and the API docs no longer list the two shims). The shims remain fully importable (from ferrum import shap_chart, rank_chart) and still warn-and-forward unchanged; their removal target is the next major release. The duplicated warn idiom shared by both shims was factored into one internal helper, leaving render output byte-identical. (PLOT-09) - Color-set vocabulary unified on
scheme=, validated at construction (behavior change).scheme=is the canonical color-palette keyword onColor/Fill/Stroke(withcmap,continuous_palette,sequential, anddivergingas deprecated aliases), and a bogus palette name now raises at construction instead of at render time. The Rust palette registry is the single source of truth:color.palette()/sequential()/diverging()return the true rendered colors for the colorous-backed palettes (viridis,plasma,magma,inferno,cividis,blues,rdbu) instead of hand-picked approximations. (ENC-06, XNAME-02, XSIB-07, ENC-11) color.to_hexauto-detects byte vs. unit by value range and warns on an ambiguous overshoot (behavior change). A tuple with a component> 1is read as byte ([0, 255]), so(1, 0, 0)→#ff0000; the previous type-based heuristic is gone. A unit color that overshoots1.0from float color math (e.g.(0.9, 0.9, 1.1)) is still read as byte but now emits aUserWarningpointing atscale="unit"/"byte"to disambiguate, instead of silently producing a near-black color. Passscale=explicitly to suppress. (ENC-05)apply_stack(stacking) now accepts non-string grouping columns and warns on an unresolvable channel — matchingDodge(behavior change). An integer, float, or booleanby/colorgrouping column now stacks correctly (was silently un-stacked), and a missing or un-categorizable grouping channel emits one warning and leaves marks un-stacked instead of silently no-op'ing. Existing string-column charts render byte-identically. (RSUP-05)format_typeis the canonical number/date format keyword on every channel.formatTypeis honored as an alias on positional channels, fixing an asymmetry where text channels droppedformat_typeand positional channels droppedformatType. (ENC-03)mark_textnow emits valid SVG forbaseline="top","bottom", and"alphabetic"(behavior change). The three text-baseline parsers were unified; those spellings previously produced an invaliddominant-baselineattribute, which adjusts the vertical placement of text marks that used them. (RSUP-04)- Annotations honor per-annotation z-ordering (draw above or below the marks layer), and the dead
curveflag on arrow annotations was removed. (XDEAD-03) - Precomputed 1-D binary gain/lift curves now rank the negative class by
1 − p, consistent with the precomputed roc/pr path. (DIAG-01) - Interactive (WASM) click-selection uses typed field comparison, so a numeric selection co-selects
42and42.0, consistent with conditional highlighting. Browser-only; no static-output change. (WASM-04) - numpy-array figure inputs name columns
col_0,col_1, … (the ferrum-wide convention) instead off0,f1, …. (PLOT-03)
Deprecated¶
min_extent=/max_extent=→min_band=/max_band=onAxis,AxisConfig, andconfigure_axis(axis-band sizing). The old names warn and forward; "extent" is reserved for data-domain bounds. (XNAME-01)extent=→method=(errorbar / errorband) andwhisker_mult=(boxplot). The overloadedextent=keyword is split by mark; old usage warns and forwards. (XNAME-01)align=→anchor=onannotate_text,cmap=/continuous_palette=→scheme=, andshap_chart/rank_chartdropped from__all__— see the corresponding entries above. All keep working as before.
0.17.1¶
2026-06-20
Closes the remaining known-gap and bug issues from the v0.17.0 review and lands a large internal cohesion pass (full Python + Rust review → 13 findings, all behavior-preserving). The user-visible changes are a handful of default-behavior and channel fixes; the bulk of the release is internal refactoring that leaves render output byte-identical (verified against the golden suite). No breaking API changes.
Added¶
silhouette_chartandpca_scree_chartgained an opt-insubtitle=parameter (defaultNone, fully backward-compatible), matching the classification/regression chart family.EncodingSpec.conditionis now a readable attribute (previously write-only at construction).
Fixed¶
facet(col=X)without an explicitncolsnow lays panels side-by-side (a single horizontal row) instead of stacking them vertically, matching Altair/seaborn.facet(row=X), the genericfacet(field=X)wrap, and any explicitncols=are unchanged. (#24)mark_linenow honors thefill_opacityencoding channel (previously silently dropped). (#5)- The
shapeencoding now honorssort(alphabetical, data-aware"x"/"-x"/"y"/"-y", sort-field object, and explicit array). (#26) - A conditional-only color encoding (base
colorunset + awhen(Color(...))selection) now builds a categorical legend, so abind="legend"toggle has entries to toggle. (#9) - Interactive data-label z-order no longer flips on the first zoom/pan tick — labels keep their paint order relative to annotations/axes (the placement now uses a DOM-order-preserving update). (#10)
- Setting both
color=andstroke=now emits a one-time warning thatstrokeis ignored (previously dropped silently).
Changed¶
- Large internal cohesion refactor across the Python and Rust layers, all behavior-preserving (byte-identical output): a shared opacity resolver across the five core marks; one serde→Python getter helper and one appearance-channel honored-kwargs contract;
chart.pyfacet machinery extracted to_facet.pywith a type-safe_Facet;prepare.rssplit intoprepare/{mod,legend,extent}; shared figure-family annotation/facet helpers; and the symmetric concat composites deduped onto a parameterized_CompositeBase. No public API or render-output change.
Known gaps¶
ShapeKind::Squareglyphs can exceed the panel clip in facets (#25, investigated as not-reproducible/cosmetic); completing thechart.pydecomposition needs_NamedTransformco-relocation (#28).
0.17.0¶
2026-06-20
This release lands the archaeology #6/#7/#8 remediation to convergence (a multi-round review→remediate loop that ended with a 5-agent sweep finding zero in-class defects) plus two heavyweight-review cohesion refactors. The headline behavior change: faceted charts now share scales correctly across every data-driven channel, composite figure titles render, and the interactive (WASM) packed-instance path's metadata/positioning bugs are fixed. The work is predominantly behavior fixes; the new public surface is internal PyO3/transform plumbing. All output is byte-verified against the golden suite (5774 tests).
Added¶
MarkNodesnode+index accumulator with a 5-channel alignment guard (tooltips, hrefs, descriptions, data_indices, keys), so every mark builder emits metadata aligned to its source rows.ViolinSpecextent fields and a per-transformglobal_extentso faceted density/violin panels can pin a shared value-axis extent.figure_title_nodesPyO3 helper backing interactive composite figure titles.
Fixed¶
- Faceted shared scales. Faceted marks now match the global axis/legend across panels for every data-driven channel — positional x/y (raw fields under
resolve="shared"), categorical data-aware sort, continuous and categorical color, size, opacity, and shape. Previously each panel resolved its own domain, so a mark could render with the wrong position, color, glyph, or normalization relative to the shared legend. - Faceted transform extents.
Kde/Bin/Violin/Kde2D/Bin2D/DensityDataand the extent-derivingHex/Raster/DataBintransforms now pin a shared value-axis extent across panels when one is not given explicitly (previously KDE-only). - Composite figure titles (
title/subtitle/caption) render in both SVG and interactive output across all composite families;LayerCharttitles reach the document<title>; packed GPU marks are offset under the title band. - Interactive (WASM) packed path. Packed instances are offset by the per-panel
(dx, dy)composition translation and the figure-title band; the tooltip string table is parsed field-by-field; conditional/crossfilter instances index from the packed-first base; andscene_loadhardens its tooltip-scan and instance-count handling against malformed buffers. - Mark metadata alignment (
bar/rect/point/segment/text/tick/rule/group/geoshape/image): tooltips, hrefs, descriptions, data_indices, and keys now align to the true source row when rows are skipped or reordered. _core.pyistub parity. CorrectedEncodingSpec/TimeScale/hat_matrix_statssignature drift and added a programmatic live-vs-stub signature-parity test so the class cannot silently recur.
Changed¶
- Cohesion refactors (behavior-preserving, byte-identical): collapsed the duplicated packed/scene merge tail into a
_PlacedChildrecord +_assemble_placed_children; extractedbuild_auxiliary_scalesto dedup the triplicated scale dispatch; dedupedshare_scalemode validation; consolidated figure-chrome handling into_CompositeBase; single-sourced bin extent/nice logic. - Packed GPU wire-format is now enforced: named stride/offset consts + a producer stride test (ferrum-core) and compile-time
size_of/offset_ofassertions (ferrum-wasm), so any future layout drift fails the build instead of silently corrupting the interactive render.
Packaging¶
- Broader wheel matrix: manylinux2014 + aarch64 + musllinux + Intel macOS, with macOS built as universal2 on Apple Silicon.
Known gaps¶
- Four pre-existing, out-of-scope gaps surfaced during the review were filed as issues rather than fixed here: #24 (
facet(col=)defaults to a single column), #25 (square-shape glyphs can exceed the panel clip in facets), #26 (shape encoding ignoressort), #27 (PyO3 stub fidelity nits).
0.16.2¶
2026-06-15
A focused patch fixing figure-level title/caption positioning on concatenated charts and single-chart captions (#1). No new public API.
Fixed¶
- Figure-level title, subtitle, and caption on concatenated charts (
a | b,a & b, andconcat(...)) and single-chart captions no longer render flush against the left edge. They now default to a 16 px left inset matching the single-chart title, andconfigure_padding(left=…, right=…)andconfigure_title(anchor="start"|"middle"|"end")reposition them (the anchor governs title, subtitle, and caption together). Previously the chrome was pinned tox=0and both knobs were silently ignored at the figure level.
0.16.1¶
2026-06-15
This release makes several documented-but-broken features actually render. No new public API was added; methods and parameters that previously stored their values without effect now take effect, and silently-dropped inputs now fail loud.
Added¶
Chart.override(**kwargs)now applies at render. It is a fail-loud escape hatch that injects presentation-spec values (per-axis/legend config, per-channel scales, mark style, coord,width/height) and wins the cascade overconfigure_*/theme. Previously the overrides were stored but never read. Unknown or misspelled paths now raiseFerrumOverrideErrorwith a closest-match suggestion.- Per-channel axis and legend styling now renders. Every documented field on
fm.Axisandfm.Legend— label/grid/domain/title colors and sizes,orient,translate,min_extent/max_extent,tick_extra/tick_min_step,grid_opacity,title_orient,zindex,label_flush,label_overlap; legendsymbol_size/symbol_stroke_width,label_color,label_limit,clip_height,row_padding/column_padding,padding/title_padding,offset— now takes effect when set on an encoding, matchingconfigure_axis/configure_legend. Previously most of these silently dropped. A misspelled per-channel key now raises. configure_axis(...)andconfigure_legend(...)gained the same orphan styling fields, so they can be set chart-wide too.
Fixed¶
- Rotated x-axis tick labels are now end-anchored (were center-anchored and overlapped the plot) with an angle-aware gap, and the x-axis title clears them.
configure(axis_x=…)per-axis styling is no longer clobbered by a generalconfigure(axis=…); a per-axis/legendlabel_font_sizenow sizes the reserved layout (not just the drawn text);fm.Legend(label_color=…)applies to continuous colorbars;configure_title(subtitle_font_size=…, subtitle_color=…)styling is honored.clustermapaccepts the documented integerz_score/standard_scalevalues (0/1); they previously raisedTypeError.
Changed¶
- Per-channel axis/legend options are now typed structs with
deny_unknown_fields(replacing an opaque pass-through map), so typos fail at render instead of silently dropping. Internal: axis style overrides were unified into one struct and theorientprecedence moved to anOptionsentinel.
Deprecated¶
AxisConfig.x/configure_axis(x=…, y=…)are deprecated and emit aDeprecationWarning— they never had any effect. UseChart.axis(x=False)/Chart.axis(y=False)to show or hide an axis.
0.16.0¶
2026-06-04
Added¶
Chart.to_html()returns the self-contained interactive HTML page as a string, byte-identical to whatsave("out.html")writes. It accepts the sameembed_wasm=,toolbar=, andraster=keywords as the HTML save path. This completes theto_*converter family (to_svg,to_png,to_html).
Changed¶
- Renamed the output converters
Chart.show_svg()→Chart.to_svg()andChart.show_png()→Chart.to_png(), establishing a clear convention across the export surface:to_*returns an in-memory value,save(path)writes to disk, andshow()displays. The oldshow_svg/show_pngnames remain as deprecated aliases that emit aDeprecationWarningand will be removed after 0.16.0.
Fixed¶
- Statistical transforms now accept integer columns.
mark_density(1-D and 2-D KDE),mark_qq,mark_hex, and 2-D binning previously raisedcolumn '<x>' must be Float64on integer data (counts, ages, years, ranks); they now coerce numeric columns to floating point. Genuinely non-numeric columns still raise a clear error. jointplot(kind="hist")now renders a 2-D histogram instead of raisingunknown column 'bin_x_start'. The center panel is encoded against the correctBin2Doutput columns (x_lo/x_hi/y_lo/y_hi/count) so the bins tile the plane instead of collapsing.- Removed two panic-prone internal downcasts in the 2-D binning transform; bad input now returns a normal error rather than risking a panic.
0.15.3¶
2026-06-03
Fixed¶
- Interactive (WASM) reactive domain-rescale (focus+context brushing) no longer shears sibling panels or makes the overview line disappear during the brush. Each panel's marks now render with their own transform, so rescaling one panel leaves the others untouched.
- Interactive box-zoom (magnifying-glass tool) on a focus+context overview now rescales the detail panel instead of making it vanish. Wheel, pan, and box-zoom operate on the focus panel; a box-zoom drawn on the overview drives the detail's domain like a brush.
- Interactive axis lines and stroked marks are antialiased via 4× MSAA on the render pass, removing the occasional 1px axis-line seam/gap where ticks cross the baseline or adjacent facet panels meet. Silently falls back to the previous (non-MSAA) path on GPUs/browsers without 4× support — no regression there.
0.15.2¶
2026-06-03
Fixed¶
- Interactive (WASM) line and area stroke width is now constant under reactive domain-rescale — the "ribbon"/variable-width artifact when a focus+context brush rescaled an axis non-uniformly is gone. Stroke width is applied in screen space and is invariant to the zoom/rescale transform. As part of this, interactive line joins are now beveled (the static SVG renderer is unchanged and keeps miter joins).
Known limitations¶
- A few interactive-renderer issues remain tracked for a follow-up pass: the reactive-rescale brush-mode default, sibling panels shearing during a cross-panel rescale, and occasional axis-line segment breaks. These do not affect the static SVG renderer.
0.15.1¶
2026-06-03
A post-v0.15.0 audit (bug-hunters, seam auditors, and heavyweight cohesion reviews) found a cluster of incomplete-unification, sibling-drift, and silent-failure bugs in the flexibility-campaign surface. All are fixed here, each with a regression test.
Fixed¶
- Finished the group-by-key unification:
transform_window,data_stack,stat_aggregate's join/pivot/aggregate paths now accept integer/uint/bool groupby columns (they previously collapsed every row into one group). A null group key now stays distinct from a real0/false/empty (FA-9), which also fixes a crash insummary/boxplot/violin/errorbandwhen a groupby column contained a null - Cross-panel reactive rescale (focus+context brushing) now zooms the detail panel in-bounds — the rescale is reprojected through the shared data domain instead of the source panel's pixels, which previously sent the detail marks off-screen
- Polar bars, ranged rects, and arc marks now honor the per-row
opacity/stroke_width/stroke_opacity/stroke_dash/fill_opacity/angle/tooltip/href/descriptionchannels their sibling marks already supported Axis(title=None)andLegend(title=None)truly suppress the title (no phantom text node), matching the channel-levelfm.X(title=None)add_params/add_selectionraise a clearTypeErroron wrong-typed arguments, and a selection colliding with a same-named parameter raises instead of silently shadowing it- A non-finite (Inf/NaN) value in an
fm.paramdomain raises a legible error naming the parameter; abind="legend"selection registered viaadd_paramsis now wired correctly; theChartSpectype stub covers all constructor arguments mark_areawith anx2channel, andmark_barwith bothx2andy2, now raise a clear error pointing tomark_rectinstead of silently dropping the second extent
Known limitations¶
- A single-panel reactive-rescale chart may still default to pan mode; click the box-select tool to brush (FA-17). The line "ribbon" appearance under a non-uniform rescale (FA-16) is tracked separately.
0.15.0¶
2026-06-02
Added¶
- Reactive parameters:
fm.param,fm.when, and unifiedselection_interval/selection_point, withbind="legend"toggles. Parameters drivescale.domain,transform_filter, and conditional encodings; they statically resolve to their initial value for SVG export and power a live WASM/JS runtime in interactive HTML (domain rescale, crossfilter, legend toggling) - Polar
theta2/radius2second extents with radial stacking, enabling annular wedges, coxcombs, and sunburst-style charts - Figure-level
title/subtitle/captionon composite charts via.properties(), rendered once around the composite rather than per panel - Typed continuous scales (
LinearScale,LogScale, etc.) auto-infer their domain from the data, matching the dict form resolve=onvconcat/hconcat;pairplotshares a single color domain across panels- 2-D density (
kde_2d/mark_contour) splits by categorical hue, drawing one contour set per group
Changed¶
- Breaking:
transform_window(..., frame=(preceding, following))now follows the documented Vega/Altair convention, where a negativeprecedingcounts rows before the current row. A trailing window isframe=(-k, 0)(thekpreceding rows through the current row). The sign was previously inverted, so trailing rolling aggregates silently produced all-null or forward-looking results. Pipelines that compensated for the inverted sign must drop the workaround.
Fixed¶
- Continuous color scales bound through
SequentialScale/DivergingScalenow honor the named scheme,domain, andclamp(every scheme rendered blue before); addedreds/greens/oranges/purples; diverging midpoint placed via piecewise normalization mark_bar/mark_areahonor a second positional extent (y2/x2) for candlestick, floating, and diverging bars;mark_bar(zero=False)opts out of the zero-anchored y-domain- Integer and nominal columns render on categorical channels (integer-keyed heatmaps, nominal bar
y) and stack correctly; integer storage still defaults to quantitative - Annotation span
label_position(top/middle/bottom) is honored; unsupportedstack=on non-stackable marks warns instead of silently dropping marks; empty facet partitions name the dropped key in the warning - Two-way faceting (
.facet(row=, col=)) renders a true grid with per-partition transforms, layered marks, and independent/shared scales — the row dimension was previously dropped - Silent failures across the render pipeline now surface as warnings or errors instead of producing blank or wrong output
mark_linedetail=groups by non-Utf8 columns; axis titles use the source field name rather than the internal transform columnmark_violinandmark_areahonor color/hue instead of collapsing groups; per-layeraggregate=andbin=are no longer dropped on layered charts;transform_top_kaggregates integer columns instead of counting rowsfm.whennumeric conditionals apply instead of silently no-op'ing; radial bars stack outward andstack=is normalized acrossx/y/theta/radiusmark_arcwith nominal theta renders a Nightingale coxcomb; polar bars render equal full-circle angular bands;stat_aggregateand all stat transforms accept integer/uint/bool groupby keys; ordinal-color area legend swatches match fills; box-inner layers color-encode by huetitle=NoneandAxis(title="")truly suppress axis titles; annotations anchor to categorical/ordinal axes- errorbar/errorband compute per-hue extents instead of pooling across groups; unsupported mark shapes and stack offsets raise instead of silently defaulting
- Zoomed lines and areas stay clipped within their panel in interactive exports; two-way facet row-strip labels render on the right edge
Other¶
- Expanded the docs-site Showcase with composed charts unlocked by the flexibility work; flattened the Gallery/Showcase navigation
0.14.0¶
2026-05-31
Added¶
catplot(height=, aspect=)for per-panel facet sizing, matchingdisplot- Automatic temporal scale inference from
Datetime/Datedtypes (no explicit:Tneeded); microsecond/nanosecond timestamps normalized to milliseconds - Size and shape legends, with multi-legend stacking and same-field legend merging
Fixed¶
- Float ordinal domains, displot facet width, and boxen sort on pandas/pyarrow inputs
- Explicit constant stroke now beats an inherited color encoding on rule/segment marks
- 3- and 4-digit hex colors,
~e/~sscientific-notation trimming, ranged-mark per-row color, and raster Y-axis orientation - Deterministic batch selection in
locate_fieldfor named transform outputs - Integer/float ordinal columns render correctly; per-panel facet sizing on
displot - Channel-level
axis=Nonehides the axis - Date/datetime/ISO coordinates accepted in annotations
- Color routes to stroke on line-family marks
- Value-sort and the full sort spec honored on axes and composite marks
- Order-independent color-scale merge on layered charts
- Per-channel axis
label_formatwith the full d3-format grammar and chrono time formatting - Categorical color-string ranges; rect/heatmap scheme routing
Changed¶
- Boxen sort moved into the Rust
LetterValuetransform (newsort=kwarg); Python shim removed - Typed ordinal
scale.rangewire format - Unified categorical-axis sort resolution; removed the dead errorbar
y_sort - New
ChannelBase.option()accessor; dropped private_kwargsreach-ins - Deduplicated inferred-type application; reconstruct the promoted color channel on layer merge
- Shared fill/stroke color-resolution helpers across marks
- Colorbar tick formatting routes through the full d3-format grammar
- Extracted
build_default_categorical_scale
Other¶
- Fixed docstring parameter drift across the public API; added a docstring-drift auditor (
scripts/audit_docstring_drift.py) - Added a capabilities showcase docs page for power-user chart designs
- Regenerated configure goldens for corrected 3-digit hex colors
0.13.0¶
2026-05-30
Added¶
ferrum.Gridvalue class for theme-level gridline control: major/minor levels with per-level styling (major_color/minor_color,major_width/minor_width, dash, opacity) plus barecolor/width/dash/opacityshorthand that sets both levels- Minor gridlines on continuous axes (linear, log, time, pow, sqrt, symlog); log axes place minors at the 2–9 intra-decade multiples; categorical/discretizing axes have no minors
- Constant mark-style kwargs on composite/statistical marks —
mark_density(opacity=0.4),mark_smooth(stroke_width=2),mark_boxplot(fill=...), etc. now work like simple marks, applied to every emitted layer mark_hex(stroke=, stroke_width=)renders hex-cell borders- Native Rust diagnostic curve kernels (ROC, precision-recall, calibration, confusion matrix, threshold sweep); raw-array (
y_true=/y_pred=) diagnostics are now fully scikit-learn-free SceneNode::Rawrendering in the WASM interactive renderer (chrome-vs-data anchoring) and a dedicatedMarkBatchKind::Labelscene-graph kind
Fixed¶
- Continuous-axis gridlines and ticks now coincide with the data marks they label (previously used uniform-slot placement, causing visible misalignment on continuous scales)
- WASM colorbar gradient id collision when a colorbar chart was placed in an inset (outer and inset colorbars collapsed to one id)
- Diagnostic kernels reject null-containing Arrow inputs instead of silently mishandling them
- Degenerate single-class ROC matches scikit-learn's NaN convention
Changed¶
- Diagnostic chart paths (precomputed and model-backed) route through the shared Rust kernels; scikit-learn is now required only when a fitted model is passed
- Internal cohesion refactors (no API change): post-review cleanups in the scale-projection/grid code, consolidated diagnostic frame assembly into a shared
_curve_framesmodule, and unified precision/recall cores in the PR kernel
Other¶
- Documented
ferrum.Grid, minor gridlines, and composite mark-style kwargs across the guides and API reference; clarified that raw-array diagnostics need no scikit-learn; regenerated guide PNGs for the gridline-coincidence rendering
0.12.0¶
2026-05-24
Added¶
- Declarative configuration surface:
configure_axis,configure_legend,configure_title,configure_grid,configure_padding,configure_color, and unifiedconfigure()methods onChartand all composition types - Annotation rendering module with coordinate resolution (text, arrow, rect, line, span, bracket, callout, image)
- Structural feature rendering:
SecondaryY,BreakAxis,Insetwith full composition support - ChartConfig domain fields wired into render pipeline
label_paddingtheme key, per-side padding support
Fixed¶
- Break-axis x-label disappearance and y2 right-margin clipping
- Inset viewBox scaling and span fill_opacity
- Format-presets revenue chart uses nominal encoding for monthly bars
- Break-axis remaps same-axis ticks, SecondaryY bar marks with nominal x
- Inset recipe PNG referenced wrong image
- ColorConfigSpec.domain accepts string and float values
- Annotation text font-family rendering
- 10 bug-hunt R3 bugs (26 failing tests) and 21 bug-hunt bugs across coercion, composition, figures, interactive, scale, projection, render, and layout
- 31 bug-hunt bugs from configure field wiring
Changed¶
- Major internal refactoring (no API changes):
chart.pysplit from 5,779 to 2,794 lines viaStatisticalMarksMixin(15 methods) andDiagnosticMarksMixin(26 methods)configure_*methods unified intoConfigureMixin, eliminating ~635 lines of duplication betweenChartand_ChartLikeChart._cloneauto-generated from__slots__(prevents silent data loss)to_spec()decomposed into 3 focused helpers- Rust
ThemeInputsdecomposed from 42 flat fields into 9 sub-structs - Rust
PreparedInputslegend overrides grouped intoLegendPreparedOverridessub-struct - Rust
render_svg/render_scene_jsondeduplicated via sharedprepare_and_layoutpipeline (fixes missing secondary-Y padding in interactive renders) - Rust
scale_resolve.rs(2,205 lines) split into 6 sub-modules - Rust
ScaleSpeccommon fields factored intoContinuousScaleCommon - Rust
Encoding::inherit_*duplication eliminated - Added
cargo_testnox session with correct macOS DYLD paths
Other¶
- Cohesion/complexity audit report and refactor plan
- Docs: customization guide, 7 concept pages, 12 recipes, 16 golden SVGs
- Regression tests for break-axis labels, y2 right-margin, font-family, and auditor-found bugs
0.11.2¶
2026-05-23
Added¶
- Graduated axis label collision cascade — replaces flat→-45°→elide with wrap → font shrink → graduated rotate → tick cull → elide, keeping categorical labels legible without user intervention
- Multi-line label wrapping: snake_case, space, and camelCase labels split at natural boundaries instead of truncating
- Dynamic bottom margin: rotation-aware gutter estimation prevents rotated labels from clipping at the viewport boundary
- Theme-configurable
cull_thresholdfor tick label density reduction (Theme(cull_threshold=N))
Fixed¶
- Y-axis title duplicated across all facet columns — now suppressed on non-leftmost columns (mirrors existing x-axis bottom-row suppression)
- Panel annotations silently dropped from SVG output — now emitted after marks, matching WASM z-order
0.11.1¶
2026-05-22
Fixed¶
- PNG export: DPR-aware canvas with pHYs DPI metadata replaces the old DPR-upscale approach, fixing gridline artifacts in composed charts (HConcat) caused by stale pixel-snap positions after canvas resize
- PNG export: full Retina resolution via 2D offscreen canvas — wide charts clamped by GPU max texture size now export at correct @2x dimensions with proper DPI metadata
- Canvas initialization: safe three-phase init (CSS size → GPU create → DPR-clamped resize) prevents
Surface::configurecrash on wide HConcat charts exceeding GPU max texture size - Mouse coordinate mapping uses scene dimensions instead of canvas backing store, fixing hit-test and tooltip positioning on DPR-aware canvases
0.11.0¶
2026-05-22
Added¶
- R-tree spatial indexing (
rstarcrate) for O(log n) hit-testing on large point clouds - Interactive toolbar: Pan, Box Zoom, Box Select, Reset, Save PNG buttons (Bokeh-style)
Chart.interactive(toolbar=True/False)andChart.save(toolbar=True/False)kwargs- Auto-tooltips: interactive renders automatically inject tooltip fields from encoded channels
WasmRenderer.maxTextureSize()WASM export for adaptive DPR capture- Keyboard shortcuts for toolbar modes (P/Z/S/R/Escape)
Fixed¶
- Opacity semantics:
fill_opacityused correctly for packed batches (>1000 marks),stroke_opacityapplied in tessellated paths/polygons, no double-apply on strokes - Grid pixel-snap in WASM renderer eliminates sub-pixel aliasing
- Mark clip region: GPU scissor rect clips zoomed marks to panel plot area (DPR-aware)
- Tick label clipping: zoomed tick labels filtered by panel plot area
- Save PNG: adaptive DPR clamping to GPU max texture size (prevents crash on wide charts)
- Save PNG: ResizeObserver disconnected during capture (prevents race condition)
- Save PNG: SVG viewBox uses actual canvas dimensions (not stale scene dimensions)
- Annotation z-order: reference lines drawn above data marks (matching SVG renderer)
- Reset button forwards selection state to Jupyter kernel
- Cursor CSS targets SVG overlay in select/boxzoom modes
- HTML title XSS:
html.escape()applied to<title>tag - Hex color shorthand: 3-char (
#ccc) and 4-char (#abcd) correctly expanded - Malformed hex colors now emit a warning instead of silently returning black
_render_scene_jsoncollapsed into_render_scene(eliminated duplication)
Changed¶
SceneCollectoraccumulator replaces 8-parametercollect_nodes/emit_draw_commands- Uniform clip vec4 removed from shaders — GPU scissor rect handles clipping (32-byte uniforms)
text_json.rsextracted fromlib.rswith deduplicated text serialization helperslib.rssplit: tooltip formatting, conditional render, transform upload moved to focused modules- Partial GPU buffer update for conditional re-render (only re-uploads instance data)
- Dead packed-batch linear fallback removed from hit-test (spatial index covers it)
- Hand-rolled JSON replaced with
serde_jsonin hit-test and tooltip paths _auto_tooltipsremoved from publicChart.to_spec()signature_ChartLike.save()uses explicittoolbarparameter instead ofkwargs.popResizeObserverreads CSS layout size (not backing-store size)- Transition RAF loop cancels stale animations on rapid scene changes
0.10.0¶
2026-05-20
Added¶
.labs()fluent method for post-hoc axis labels and titles.xlim()/.ylim()convenience methods for axis limits.to_dict()on Chart (returns parsed JSON spec as a Python dict)mark_circle()/mark_square()convenience methods (aliases formark_point(shape=...))mark_line(point=True)overlays points on lines (altair parity)color=andalpha=mark kwarg aliases (maps tofillandopacity)linetype=mark kwarg alias with named values ("dashed","dotted","dashdot","longdash","solid")Axis(label_map={"a": "Group A"})categorical label remappingannotate_abline(slope, intercept)for slope+intercept reference lines- PNG
scale=parameter onshow_png()andsave()for DPI control - PDF export via
save("chart.pdf")(zero-dependency minimal PDF writer) reverse=on continuous positional scales
Correction (2026-09-04): this line was inaccurate —
reverse=was never actually implemented on any continuous positional scale (LinearScale,LogScale,PowScale,SqrtScale,SymlogScale,TimeScale) at this release, or for several releases after it. It shipped only in the batch documented in the Unreleased section above (2026-09-04).PointScale.reverse— a different, unrelated mechanism — shipped separately in 0.20.0 (#65). - 5 new aggregate functions:variance,stdev,q1,q3,distinct- 4 KDE kernels:epanechnikov,tophat,cosine(plus existinggaussian) - Smooth method aliases:"linear","quadratic","cubic","log","sqrt"-"|"/"-"(vline/hline) point shapes - 148 CSS named colors in theme overrides (e.g.Theme(mark_color="steelblue")) - Altair migration guide (docs/site/comparison/altair.md) - Pandas/polarsSeriesaccepted as data input - PolarsCategorical/Enumcolumns auto-cast to string - PolarsDurationcolumns auto-cast to int64 nanoseconds - PyArrowDate32/Date64columns auto-cast to timestamp - Composite mark kwargs forwarding (errorbar, boxplot, errorband, ribbon, boxen) - Mark tick rendering for ordinal-y-only and ordinal-x-only encodings
Fixed¶
type_="Q"kwarg on channel constructors was silently droppedcount():Qaggregate shorthand crashed (empty field rejected by Rust)- Aggregate shorthand
mean(val):Qmissing auto-groupby from sibling channels nice=Trueon Scale constructors silently dropped during serializationmark_smooth(method="linear")documented but crashed (Rust only accepted"lm")- Non-Int64 integer columns failed when encoded as nominal (pyarrow/pandas users)
- Config defaults (640x480) didn't match render defaults (was hardcoded 600x400)
- Shorthand parser rejected hyphens, dots, and spaces in column names
ChannelNameenum missingStrokeOpacity/FillOpacity/Anglevariants (conditional encodings on extended channels were broken)- WASM conditional resolver missing match arms for 3 extended channels
mark_density(kernel=...)never forwarded kernel to Kde transform_apply_label_mapsbroadexcept Exceptionnarrowed to specific types with warning
Other¶
- Plotnine/Altair migration gap audit (
design-docs/superpowers/audits/) - 226 new regression tests across 6 test files
- Overhaul README with hero strip, benchmarks, comparison table, and theme grid
- Embed interactive demos in docs
- Add plotnine to benchmarks, comparison table, and migration guides
- 10M-row stress tests for scale ceiling validation
0.9.1¶
2026-05-18
Added¶
- Auto-inject selection fields into tooltip for cross-panel linking
- D3 interaction layer, SVG text rendering, and cross-panel linked selection
- Interactive HTML export with interval selection, composition support, and adapter pattern
- Nox
docssession for docs site build verification
Fixed¶
- Resolve 13 scene-pipeline audit findings across all 4 stages
- Include tooltip field names in SVG output and escape Group attrs
- Wire
strip_text_sizeandstrip_paddingtheme keys end-to-end - Resolve 4 PyO3 binding audit findings (B1, B2, W1, W4)
- Bug-hunt across 5 interactive subsystems — 189 tests, 7 bugs fixed
- Resolve 9 wiring-audit findings in interactive HTML export (B1-B5, W1, W3, W6, W8)
- Deduplicate and harden interactive HTML export pipeline (Rust + Python)
- Eagerly populate
LAYER_NAME_CATALOGfrom all mark modules - Embed Inter font in shared CSS for Jupyter and HTML export
- sRGB-to-linear color conversion for correct GPU rendering
- Rebuild WASM artifacts with B4
startTransitionfix
Changed¶
- Rename audit skills/agents to prefix pattern (
audit-*,auditor-*) - Docs site: add classification meta tags, update author metadata, plain display names for API nav
0.9.0¶
2026-05-17
Added¶
- Phase 12: Spec completeness — full implementation of all remaining
ferrum-spec.mdfeatures - Data transforms API — 17 transform constructors (
FilterTransform,AggregateTransform,WindowTransform,BinTransform,CalculateTransform,FoldTransform,PivotTransform,StackTransform,FlattenTransform,SampleTransform,ImputeTransform,DensityTransform,RegressionTransform,LoessTransform,QuantileTransform,LookupTransform,TimeUnitTransform) with Rust-backed execution and expression evaluator - New scale types —
PowScale,BandScale,PointScale,SequentialScale,DivergingScale,QuantizeScale,BinOrdinalScale - Power/sqrt transform —
PowScale(exponent=0.5)for square-root position resolution AxisandLegendvalue classes — full configuration objects for axis and legend customizationferrum.colorandferrum.configmodules — color utilities and configuration APILayerChartandConcatChart— first-class composition types for programmatic multi-view construction- 9 infinity domain regression tests — continuous-x bar width, infinity filtering across mark types
- Docs: 4 recipe pages — transforms, ConcatChart, Axis/Legend, PowScale with rendered PNGs
- Docs: API reference pages — transforms, scales, color, config, axis, legend
- Docs: data transforms guide
Fixed¶
- Infinity domain filtering —
±infvalues no longer corrupt scale domain auto-computation - Continuous-x bar width — bars with continuous x-axis and no
x2now auto-compute width - Safe Rust downcasts — temporal type handling and Time bar rendering hardened
- Python type annotations and dead code — review-lite findings addressed across Python surface
Changed¶
- Design-docs reorganization —
design-docs/split intoarchitecture/andnarrative/subdirectories - Code archaeology updated — Phase 12 items marked resolved, deferred marks inventory refreshed
- Documentation expanded — Phase 12 pages, cross-reference links, missing docstrings added
0.8.3¶
Added¶
- pytest-xdist for parallel test execution (
-n auto) - 132 new tests from 5-round test-sweep campaign (scale, transform, composition, facet, theme, coord, encoding dimensions)
- Scale tests for SHAP, ICE, and PDP figure functions
Fixed¶
- Multi-layer scale domain union — layers now merge domains correctly instead of using only the first layer's range
- TickLevel infinity serde — tick-level values no longer panic on serialize/deserialize with infinity bounds
- Coord system × position adjustment interaction (dodge offsets under CoordFlip)
- Figure function × data shapes gap (sweep-1)
- Missing
packed_instancesfield in scale-stat test fixture - Stale
docs/superpowerspaths updated todesign-docs/superpowersin CLAUDE.md
Changed¶
- Test-sweep skill: parallel Rust test track, coding agent dispatch enforcement
0.8.2¶
Added¶
/test-sweepskill — iterative combinatorial test-and-fix campaignpython-coderandrust-codercoding agents for language-specific dispatch- Plotly added to scatter benchmark suite
- Social card generation script with iris lmplot
Fixed¶
- Per-instance channel rendering —
opacity,stroke_width,fill_opacity,size, andangleencoding channels now affect SVG output for all 9 primitive marks (previously onlymark_pointhad full wiring) - All-null data handling — charts with entirely null/NaN columns now render an empty chart gracefully instead of crashing with
ValueError - Histogram auto-groupby —
displot(hue=)andjointplot(hue=)no longer crash; the Bin transform now preserves the color-groupby column - RepeatPlaceholder guard —
pairplotmark overrides no longer crash when encodings contain unresolved repeat placeholders - Chained layer column-overlap —
mark_rulelayering with overlapping column names resolved - Scale constructors —
rangeparameter now optional on all scale constructors - License packaging — NOTICE file declared in
license-filesfor sdist inclusion
Changed¶
- Benchmark tables updated with Plotly results (4-library comparison)
- Documentation expanded: 25 new-user gaps addressed, API reflinks, stale gallery PNGs regenerated
0.8.1¶
Fixed¶
- Scale
rangenow optional —LinearScale,LogScale,SymlogScale,TimeScale, andOrdinalScaleno longer requirerange=in their constructors. The renderer auto-fills from the plot-area dimensions, so users can writefm.LogScale(domain=[100, 100000], base=10)without pixel math. mark_rulelayering with overlapping columns — layering a rule on a scatter (scatter + hline) no longer draws a line per scatter row when both DataFrames share a column name (e.g."y").- Chained
+layering —scatter + hline + vline + labelnow renders correctly. AddedIdentitytransform andinherit_non_positionalto prevent chart-level positional encoding from polluting routed layers. - Docs code examples — fixed
CoordFlipencoding order,stroke_dashstring→list,StackInt64→Float64,to_theme_inputs_dict()→to_spec_dict(),chart.render_config()→chart.properties(render_config=),feature_importances_chart→importance_chart,ferrum[...]→ferrum-viz[...].
Added¶
- 25 new documentation sections — type suffix explanation, position adjustments (Dodge/Stack/Jitter), axis customization (log scale, limits, reversed), smooth method table, legend control, palette cycling, shared scales, regplot, multi-model comparison, precomputed scores, output methods summary, DPI note, PDF note, interactive compatibility, CoordFlip recipe, annotation recipe, chart sizing recipe, category sorting recipe, time-series recipe.
- 13 guide PNGs — visuals for every new section (position adjustments, axis customization, legend suppression, shared scales, regplot, multi-model ROC, CoordFlip, annotations, sizing, category order, time-series).
- 12-theme visual grid — side-by-side comparison of all built-in themes on the same chart.
- Complete 54-mark table — all mark methods listed with reflinks in marks-encodings guide.
- Consistent API reflinks — every public symbol mentioned in prose now links to its API reference across all doc pages.
Identitytransform — Rust pass-through transform for named-output routing in layered compositions.- 11 regression tests — covering scale range, rule layering, and chained layer fixes.
- PyPI metadata — keywords, classifiers, and project URLs added to
pyproject.toml.
Changed¶
- README — updated install section with
ferrum-viz[all], added docs link, interactive rendering feature, corrected dev commands.
0.8.0¶
Grammar-of-graphics core with Rust rendering engine.
Added¶
- 12 built-in themes — Paper Ink (default), Slate Citrus, Arctic Signal, Observable, Minimal, Dark, Publication, Economist, FiveThirtyEight, Solarized Light, Solarized Dark, plus explicit
paper_inkfor derivation. - 3 original categorical palettes —
paper_ink,slate_citrus,arctic_signal(8 colors each). - 9 continuous color schemes —
cool_blue,warm_ochre,night_blue,electric_lime,signal_blue,ember_orange,blue_to_red,cyan_to_amber,blue_to_violet. fm.hconcat()/fm.vconcat()— top-level convenience functions for multi-chart concatenation.Chart.axis()— method for suppressing or restoring axis decorations.- Facet-before-transform — faceting now partitions data before statistical transforms run, so each panel gets its own transform subset.
- Grouped smooth —
mark_smoothsupports group-by; no explicit per-group layering needed. - Continuous colorbar — rendered alongside heatmaps and continuous-color charts.
- Full Theme key wiring — all
ferrum-spec.md§3.13 keys plumbed end-to-end from Python through Rust renderer. Unknown keys raiseValueErrorat construction. - Model diagnostics (Phase 10) —
ModelSource,ComparedModelSource, 32 visualizer classes, 31 figure-level helpers covering classification, regression, feature explanation, model selection, and clustering. - 9 figure-level helpers —
displot,catplot,relplot,lmplot,residplot,pairplot,heatmap,clustermap,jointplot. - 54 mark methods — primitives, statistical, distribution, uncertainty, scale-aware, and diagnostic marks.
- DataFrame pluralism — polars, pandas, modin, cuDF, dask, ibis, and pyarrow all accepted via
Chart(data). - Title rendering —
Chart.properties(title="...")rendered in the SVG with theme-controlled typography. - Grid lines — theme-controlled grid rendering with configurable color, width, dash, and opacity.
- Legend — categorical and continuous legends with theme-controlled positioning and styling.
- Interactive rendering (Phase 11) —
Chart.interactive()switches to a GPU-backed WASM renderer with selections, zoom/pan, linked views, and tooltips. Backed byanywidgetfor Jupyter integration. - Selection API —
selection_point,selection_interval,selection_single,selection_multifor declaring interactive state. Conditional encodings viasel.when(...).otherwise(...). SelectionMark— configurable brush styling for interval selections.InteractiveChart— anywidget-based Jupyter widget withon_selection_changecallback and self-contained HTML export via.save().- Scene graph renderer — Rust-side
render_interactiveproduces a SceneGraph JSON consumed by the WASM GPU renderer. compose_svg_horizontal/compose_svg_vertical/compose_svg_grid— low-level Rust SVG composition helpers.- t-SNE and UMAP in pure Rust —
ManifoldVisualizerruns both viamanifolds-rs, no Pythonumap-learndependency. - 7 new diagnostic helpers —
classification_report_chart,class_balance_chart,cooks_distance_chart,prediction_error_chart,silhouette_chart,elbow_chart,manifold_chart. mark_label— text labels with collision avoidance (avoid_overlap=True).mark_image— image tiles from URL fields.RenderConfig— per-chart auto-raster policy configuration (threshold, behavior, aggregate, colormap).raster=keyword — one-off auto-raster override on.show(),.save(),.show_svg(),.show_png().score()on visualizers — all Group A visualizers implement.score()for sklearn-protocol compatibility.width=onmark_boxplot— API symmetry with other marks.stroke,angle,fill_opacitychannels — wired end-to-end to SVG attribute emission and WASM renderer.- Legend
format=andcolumns=— kwargs now wired through to legend rendering. - Packed tooltips — field-level tooltip content sent via binary buffer for interactive performance.
- Binary instance bridge — GPU data bypasses JSON serialization in interactive rendering.
Fixed¶
- Facet encoding —
facet=,facet_col=,facet_row=now correctly partition data into panels. mark_ticky-rug — single-axis tick marks render correctly in both x and y orientations.- CoordFlip rendering — no longer drops violin paths or boxplot rects under coordinate flip.
- Histogram
multiple='stack'/'fill'— bin edges now align across groups. calibration_chartrendering — layering wiring gap resolved; renders correctly via.show_svg().- Chart decomposition —
chart.pysplit into rendering, encoding helpers, and composition helpers for maintainability.
Changed¶
+operator always layers — no longer ambiguous between layering and concatenation. Use|,&,fm.hconcat(), orfm.vconcat()for concatenation.- Default theme identity is Paper Ink — warm cream background, blue marks, warm grid. Previously was Observable-style white background.
- Default categorical palette is
paper_ink— previouslyokabe_ito.