Releases: flyingriverhorse/Skyulf
Release list
v0.8.13
-
Ruff's
F401(unused-import) rule is now enabled repo-wide: the lint set
previously covered only theF63/F7/F82subsets of pyflakes, so orphaned
imports went unreported byruff check,ruff formatandty— two left
behind by the OC-152 fix below passed every gate and were caught only by an
external linter. 22 dead imports were removed across tests, examples and
notebooks; 4 intentional availability/side-effect probes (polars, h3,
sentence_transformers) keep a per-site# noqa: F401 - <reason>waiver.
Contributor-visible consequence: the pre-commit ruff hook runs with
--fix, so an unused import added deliberately is now deleted at commit time
unless it carries a waiver — and ruff honours# noqaonly on the import
statement line, not on a preceding comment.F841, the docstring rules and
unused-args remain out of scope (OC-09). -
A misspelled
FASTAPI_ENVno longer silently boots the development security
posture (OC-130): the value selected a settings profile through a lookup that
fell back toDevelopmentSettingsfor anything unrecognized, soprod,prd,
staging— or a trailing space picked up from a YAML/CI variable — started the
server withDEBUG=True,CORS_ORIGINS=["*"], no security headers and the
productionSECRET_KEYcheck skipped. The wildcard origin is worse than it
looks: the app also setsallow_credentials=True, and Starlette in that
combination reflects the caller's origin instead of sending a literal*,
so the browser's normal refusal never fires and any site can make credentialed
requests. A second channel was dead as well —FASTAPI_ENVis not aSettings
field and pydantic-settings never exports dotenv values into the process
environment, so setting it in.env, the documented configuration file, had
no effect at all and the server came up in development either way. Both now
go through one resolver that reads the same channels as every other setting,
normalizes case and surrounding whitespace, and raises at startup for any
other value, naming the accepted ones (development,production,testing).
An empty value raises too, since that is what an unset CI variable renders to.
Behaviour change: a server with a typo'd or blankFASTAPI_ENVnow refuses
to start instead of starting insecurely. Fix the value rather than removing it
— the absence of an error was the bug. -
AWS credentials no longer reach the log through S3 errors (OC-150): both S3
modules carried their own copy of a_sanitize_errorhelper that matched on
credential key names, and in practice it did nothing at all — an S3 403
response body, a presigned URL and an s3fs options dictionary all passed
through byte-identical, exposing not just access key IDs and signatures but the
secret access key itself. Presigned URLs are bearer credentials: anyone who
obtains one can fetch the object, so logging one publishes it. The same helper
was simultaneously too aggressive elsewhere, replacing an ordinary
key=reports/2026/q3.csv not foundmessage wholesale withredacted sensitive S3 errorand destroying the only useful part of the diagnostic. Both copies
are replaced by oneredact_credentials()helper beside the existing log
injection guard, which matches on value shape rather than setting name:
20-character AWS access key IDs,name=valueandname: valueassignments for
the known credential options, and the XML tags S3 uses in its 403 body. Only the
secret is replaced, so the surrounding message survives intact, and redacting an
already-redacted message changes nothing. The S3 connector's startup log line
now also redacts the path it was given, since a caller may pass a presigned URL
directly. Exception messages raised back to the caller are deliberately left
readable — they carry your own input, and a redacted error is no use to you. -
Removed four unused raw-SQL executor methods (OC-152):
execute_query/execute_updateon both the SQLite and PostgreSQL async
connection managers accepted an arbitrary query string and passed it straight
to the driver. Nothing in the codebase called them, but their presence offered
the next contributor an unconstrained injection sink that bypasses every
parameterisation convention the rest of the database layer follows. Use
SQLAlchemy constructs, ortext()with bound parameters, instead. -
Deployed-model predictions no longer silently use misaligned or invented
features (OC-154, OC-155): two serving paths degraded quietly instead of
failing. The bundled path skipped its training-feature-order reindex exactly
when alignment could not be confirmed — the one case the reindex existed
for — and handed a positional model whatever columns the feature engineer
happened to produce; reproduced returning 9921.0 from a frame whose
columns did not match what the bundle was trained on, with no error and no
warning. The legacy path imputed absent features with the literal constant
0, which for income, age, price or any scaled feature is an extreme
out-of-distribution input, returned the result as a normal prediction behind
a server-side log line the caller never saw, and wrote the fabricated column
into the caller's own DataFrame. Both now raise an error naming the offending
columns, which the API surfaces as HTTP 400 with the column list and the
canvas displays directly. Behaviour change: a deployment whose recorded
feature columns no longer match what its own feature engineer emits, or a
request that omits a feature the model was trained on, now fails loudly
instead of returning a number. Such a bundle needs retraining and
redeploying — the old response was wrong, not merely convenient. -
Merging branches with different row counts no longer duplicates data
silently (OC-153, OC-157): wiring two branches into a merge node expresses
a feature union, but the engine picked its merge mode purely from row
counts — so when either branch changed the row count (outlier removal,
deduplication, anydropna) it silently switched to a row-wise concat and
returned a taller frame of stacked rows instead of a wider one.
Reproduced: a 5-row dataset merged with its own filtered branch produced 9
rows containing 4 duplicates, and because the two branches had identical
column sets the one condition that emitted a UI advisory was false — the
canvas showed zero warnings and the only trace was a line in the job log.
Duplicated rows silently reweight those observations during training and,
when the merge feeds a split node, place identical rows on both sides of the
train/test boundary. Row-wise stacking is still supported, since appending
separate datasets is a legitimate use of a merge node, but it now always
emits arow_count_mismatchadvisory carrying the per-input row counts and
rendered in the canvas merge banner with the remedy. Separately, the
first_winsstrategy reversed the output column order: it was
implemented by iterating the inputs backwards, and the accumulator dict's
insertion order is the merged frame's column order, so the same two
branches came out['a','b','c','d']underlast_winsand
['c','d','a','b']underfirst_wins— contradicting its own docstring and
handing positional consumers a different layout per configuration. Ownership
is now resolved by declining to overwrite an already-claimed column while
walking the inputs in their own order, so both strategies emit columns in
input order with unchanged winners. -
Recursive Feature Elimination now selects the number of features you asked
for (OC-25, also closes OC-143): the RFE panel's "K (Number of Features)"
field was ignored. The backend readn_features_to_select— a key nothing in
the entire codebase ever wrote — so it was always unset and scikit-learn fell
back to its own default of keeping half the candidate features. Setting
K=2 over 6 features selected 3, with no error and no warning, while the node
summary still readrfe · k=2. It hid because RFE's other field (step)
is read correctly, so the panel looked fully wired up, and the test fixture
exercised only the internaln_features_to_selectspelling, leaving thek
path the UI actually sends with no coverage at all. RFE now acceptskas an
alias, with an explicitn_features_to_selecttaking precedence, so the
canvas, direct API calls and notebooks all agree. Added 3 regression tests
including an end-to-end case over 6 features.
v0.8.12
-
Binary
pr_aucscored the wrong class on{1, n}labels (OC-146, also
closes OC-37):_add_roc_pr_auc_metricscalledaverage_precision_score
withoutpos_label, and that function defaults topos_label=1— unlike
roc_auc_score, which infers the positive class from the sorted uniques.
With labels{1, 2}or{1, 5}(survey exports, Likert-derived targets,
R-style factor codes,1=no, 2=yesCSVs) the literal1is the negative
class whileproba[:, 1]isP(classes_[1]), so PR-AUC was computed for
the inverted problem: 0.32 reported for a model whose true PR-AUC is
0.97, with no warning. For every other non-{0,1}label set (strings,
{2,3},{0,2},{10,20}) sklearn raised instead and the metric silently
vanished from the report. The same report then contradicted itself on
screen: the PR curve is built withpos_label=classes[1], so a chart
hugging the top-right corner carried "0.32" beside it. The binary branch now
resolves the positive class frommodel.classes_, the pattern
_add_binary_unweighted_metricsand the curve builder already used.
pr_aucis now identical across{0,1},{1,2},{1,5},{-1,1}and
{"no","yes"}encodings of the same data. Added 7 regression tests. -
Threshold tuning picked a pathological cutoff on single-class validation
(OC-36):_grid_search_binarybroke score ties with a strict>, keeping
whichever candidate the scan reached first. When the validation split held
only one class, every candidate scored identically, so tuning returned the
first grid point —{0: 0.990, 1: 0.0098}— which classified almost
everything positive (measured: 49/50 rows at F1 0.0) and was persisted as a
tuned threshold. Metrics like F1 are piecewise constant in the cutoff, so
tied plateaus are routine even on healthy splits, and the same tie-break
pinned whichever plateau edge came first. Ties now break toward the default
0.5cut; a single-class split warns and keeps{0.5, 0.5}; NaN scores
from a caller-supplied metric are still skipped._tune_decision_thresholds
gains a matching gate so a degenerate split leaves
decision_thresholds=None(predictions keep the model's default rule)
instead of reporting a threshold nothing was tuned against. Documented in
docs/user_guide/threshold_tuning.md. Added 4 regression tests. -
optimize_thresholds' binary result bypassed its own documented decision
rule (OC-147):apply_thresholdsdocuments "predicts the positive class
wheny_proba[:, 1] >= threshold" and special-cased a bare float and a
one-entry dict — but_grid_search_binaryreturns a two-entry dict,
which fell through to the multiclass scaled argmax, wherenp.argmaxbreaks
exact ties toward the first column. That silently turned>=into a strict
>, so the search scored tied rows as positive while apply-time scoring
counted them negative and the tuned score was not quite the score you get.
Reachable in practice: the grid includes0.5and decision trees routinely
emitp1of exactly0.5. The two-class case now compares the scaled
scores directly, keeping>=authoritative — verified identical to argmax
on every non-tied row for complementary and non-complementary threshold
pairs, so a user-saved set still has both entries honored. Added 2
regression tests. -
The profiler reported NaN statistics — and a silently wrong median — for
any float column containing a NaN (OC-39): polars keeps NaN distinct from
null, soEDAAnalyzer'sfill_null-based cleaning was a no-op on it.
Aggregations then propagated the NaN instead of skipping it:std,
skewnessandkurtosiscame back NaN, the histogram builder died with
ComputeError: breaks cannot be NaN(itsmin_val == max_valguard cannot
see thatnan == nanis False), and worst of all the median was simply
wrong —[1, 2, nan, 4]reported3.0where pandas reports2.0, with
no error and no warning. Nothing caught this because tests build frames with
pl.from_pandas, which converts NaN to null on the way in; the bug is only
reachable frompl.read_csv(literalNaNtokens) or direct construction.
Fixed once at the boundary rather than at six call sites: a_nan_to_null
pass inEDAAnalyzer.__init__rewrites NaN to null in every float column, so
downstream type checks, aggregations andcut()all see the missing-value
semantics the rest of the profiler already assumed. Added 6 regression tests. -
Quartiles, skewness and kurtosis used polars' estimator defaults, not
pandas' (OC-41, OC-42): three silent divergences in the same expression
list.Expr.quantile()defaults tointerpolation="nearest"where
pandas/NumPy use linear, so Q1/Q3 were reported as actual observed data
points rather than interpolated positions — and_compute_boxplot_stats
disagreed withNumericStatsabout the same column's hinges.skew()and
kurtosis()default tobias=Truewhere pandas reports the bias-corrected
estimator, and polars'kurtosis()is not Fisher excess kurtosis by default
while pandas'.kurt()is. The skewness error was user-visible, not just
numerical:[1, 2, 3, 4, 10]reports1.30biased against1.70unbiased,
so the "High skewness — consider a transform" recommendation stayed silent on
a column that clears the 1.5 threshold. Both quantile sites now pin
interpolation="linear", and skew/kurtosis pinbias=Falsewith
fisher=True. Added 6 regression tests (shared with OC-39). -
PCA and clustering "mean imputation" was actually zero-filling NaN (OC-40):
_impute_matrixcalledfill_null(strategy="mean"), which — same root cause
as OC-39 — is a no-op on NaN, so the values fell through to the
np.nan_to_num(nan=0.0)guard. Components were fitted with0.0exactly
whereSimpleImputerwould have put the column mean, which for a
mean-centered feature is the most distorting value available. Its sibling
_impute_matrix_drop_emptyalready normalized first;_impute_matrixnow
mirrors it. Added 2 regression tests. -
Correlation dropped entire columns and returned nothing at all (OC-43):
calculate_correlationsuseddrop_nulls()— which keeps NaN rows — and
thenDataFrame.corr(), which is listwise: a single surviving null made
the whole matrix NaN, the broadexceptswallowed it, and the profile
silently lost its correlation section. Two fixes: NaN is normalized to null
up front, and the matrix is now built pairwise withpl.corr(which
already does pairwise deletion, matching pandas'.corr()), so a column with
a few gaps correlates on its overlapping rows instead of taking the matrix
down with it. Pairs with fewer than 3 overlapping observations are reported
as0.0with a single aggregated warning, because at n=2 a Pearson r is
always exactly ±1.0 — a schema limitation, sinceCorrelationMatrix.values
islist[list[float]]and has no "unknown" cell. Added 4 regression tests. -
Wasserstein drift reported a number its own threshold contradicted
(OC-44): the drift decision has always been made on the distance normalized
by the reference standard deviation, butvaluecarried the raw distance in
the column's own units. A large-scale column that barely moved therefore
reportedwasserstein_distance=50.0, threshold=0.1, has_drift=False— and
every consumer that re-derives the verdict fromvalueinherited the
contradiction: the UI's threshold sliders flipped it to "drifted", the CSV
export published 50.0, the alert modal showed it beside a 0.1 threshold, and
_build_drift_column_summarypersisted it intodrift_check_results.summary
where it is later compared againstthreshold_wasserstein. The published
example notebook shows the failure outright —784.7832 (Thresh: 0.1) [PASS]. Rather than teach each consumer a metric-specific exception, the
invariant now lives in the schema:valueis the numberthresholdapplies
to for every metric that decides its own verdict, and the untransformed
distance moved to a newraw_value. A constant reference has no scale to
normalize by and falls back to the raw distance rather than emittinginf.
The one documented exception isks_test_p_value, which is diagnostics only
and borrows the KS statistic's threshold. No backend change was needed —
EnrichedDriftReport.column_driftsisdict[str, Any]. Added 5 core, 1
backend and 3 frontend regression tests. -
Schema drift was never counted as drift (OC-45):
drifted_columns_countwas built from the per-column metric flags alone, so
a feature that vanished between training and production left it at0—
while_classify_drift_severityclassified that same report"critical",
and the drift-status dashboard counts drifted jobs by that field, so a
critical job was reported as having no drift. The count now includes
missing_columnsandnew_columns; the frontend's client-side
re-evaluation adds them too, since rebuilding the count from metric flags
alone dropped them the moment a slider moved. Added 2 core and 2 frontend
regression tests. -
A profile could serialize an invalid
NaNtoken (OC-46):
NumericStatsaccepted NaN and infinity into its float fields.
backend/eda/tasks.pypersists the profile via
profile.model_dump(mode="json"), which retains a Pythonnan, and stdlib
json.dumpsthen emits a bareNaN— not valid JSON, and rejected by the
browser'sJSON.parse, so the whole EDA result failed to load. The ten
optional float fields now run through aFiniteFloatannotated type that maps
any non-finite float toNonebefore validation.BoxPlotStatswas left
alone: its fields are required, and making them optional would be a real
contract change rather than a fix. Added 1 regression test (shared with
OC-39). -
**Every cross-origin error resp...
v0.8.11
-
topological_orderno longer nondeterministic: the Kahn's-algorithm
helpers inbackend/ml_pipeline/_execution/graph_utils.pywere seeded from
asetof node ids, whose iteration order depends on insertion history —
sotopological_order(topological_order(nodes))could return a different
(still valid) order thantopological_order(nodes), breaking idempotency
and making execution order depend on how the node list happened to be
built. The helpers now take alist[str]in deterministic order:
topological_orderpasses input-list order (deduped via
dict.fromkeys), and_collect_ancestorspassessorted()BFS results.
Among simultaneously ready nodes the input-list order is preserved, so the
result is deterministic and idempotent. -
SonarCloud PR analysis flagged Vite build output: all 46 open issues on
PR #146 were instatic/ml_canvas/assets/*.js— minified frontend build
artifacts that SonarCloud's PR analysis picked up because the files changed
in the PR.static/ml_canvas/**is now insonar.exclusionsin
sonar-project.properties, so build output is no longer analyzed (the
scanner job in.github/workflows/pr_check.ymlreads this file directly). -
Drop-Rows canvas settings ignored (OC-13): the Drop Rows node's UI
stores a 0–100% "drop rows missing more than X%" slider, but the converter
sent keys the backend never read, so every canvas run silently ran as
"drop any missing".DropMissingRowsnow accepts amissing_threshold
percentage param (pandas + polars parity, mirroringDropMissingColumns):
rows with more than X% of the subset missing are dropped, and the
converter maps the checkbox / null / 0% tohow="any". The
DropMissingRowsArtifactgains the new field. -
Iterative Imputer canvas estimator choices ignored (OC-14): the
Imputation node's UI emits lowercase estimator aliases (decision_tree,
extra_trees,knn,bayesian_ridge) and the converter forwards them
verbatim, but_build_iterative_estimatorin
preprocessing/imputation/_common.pymatched only the exact documented
strings (DecisionTree,ExtraTrees,KNeighbors) — so every canvas run
silently fell back toBayesianRidgeregardless of the user's choice. The
alias is now normalized (lowercased, non-alphanumerics stripped) before
dispatch, so both the UI values and the documented aliases resolve to the
same regressor; unknown names still fall back toBayesianRidge. Added
JSON-driven alias cases for the four UI values. -
MinMax/Robust scaler range controls ignored on canvas (OC-15): the
Scaling node's UI stores the ranges as scalar fields
(feature_range_min/feature_range_max,quantile_range_min/
quantile_range_max), but the converter forwarded them verbatim while the
backend reads tuple keys (feature_range/quantile_range) — so every
canvas run silently used the defaults (0/1 and 25/75). The converter's
scale_numeric_featuresbranch now assembles the tuple keys from the
scalar fields (with the same defaults); no backend change was needed.
Added 4 vitest cases topipelineConverter.test.ts. -
Alias Replacement
punctuationmode was a silent no-op (OC-19): the
Standardize Values node's UI offers apunctuationmode ("Removes common
punctuation characters from text"), but the backend applier in
preprocessing/cleaning/alias.pyonly handled the alias-mapping modes —
punctuationresolved to an empty mapping, so the node did nothing. Both
engine paths now stripstring.punctuationonly (case and spaces preserved,
matching the UI wording; NaN passes through unchanged). Added JSON-driven
cases plus a pandas/polars NaN-passthrough parity test. -
Value Replacement UI help text contradicted actual behavior (OC-20): the
Standardize Values node's UI help text promised "If empty, applies to all
compatible columns," but the backend no-ops on an emptycolumnslist — the
intended repo convention (user_picked_no_columns: unchecked boxes mean "do
nothing for this node"). The help text now reads "Select columns to apply
replacements to. If no columns are selected, this node does nothing." No
backend change: the empty-columns no-op was already correct. Added a
contract-locking test (test_apply_empty_columns_is_noop, pandas/polars)
asserting emptycolumns+ a mapping leaves data unchanged. -
select_from_model'smax_featureswas Python-only (OC-53): the
backend readsconfig.get("max_features")and passes it to sklearn's
SelectFromModel, but the Feature Selection node's UI rendered only a
thresholdfield for theselect_from_modelmethod — so the feature-count
cap was unreachable from the canvas. The UI now offers an optional
"Max Features" numeric input for that method (empty = no cap, matching the
backend'sNonedefault). No converter change: thefeature_selection
branch already forwardsnode.dataunchanged. Added 2 vitest cases to
pipelineConverter.test.ts. -
Binning's "Precision (Decimals)" was UI-unreachable (OC-61): the
backend (bucketing.py) readsconfig.get("precision", 3)and the canvas
Binning node rendered a "Precision (Decimals)" input, but
pipelineConverter.tslisted theGeneralBinningparams explicitly and
omittedprecision, so the value was silently dropped. The converter now
forwardsprecision(omitted when unset, matching the backend's default of
3). Added 2 vitest cases topipelineConverter.test.ts. -
CalibratedClassifierCV base estimator discarded during tuning (OC-66):
the tuning engine builds the meta-estimator from
model_calculator.default_params, which hardcoded
estimator=LogisticRegression— so the user'sbase_estimatorselection
was silently ignored whenever the node was tuned (it only took effect in
plainfit).CalibratedClassifierCalculatornow routes the selection
through the structural-tuning hook (same mechanism as the ensemble
calculators):prepare_tuning_paramscapturesbase_estimator(flat or
nestedparamsshape) anddefault_paramsresolves it via the
BASE_ESTIMATORSfactory, with a warn-and-fallback to
logistic_regressionfor unknown keys. No backend change: both the
fixed-run and tuned paths already callprepare_tuning_paramsand exclude
STRUCTURAL_TUNING_KEYSfrom the search space. Added unit tests
(capture, resolution, fallback) and an integration test asserting the tuned
pipeline fits aRandomForestClassifierinsideCalibratedClassifierCV. -
KNN/Iterative imputers crashed on all-missing fitted columns (OC-16):
sklearn'sKNNImputer/IterativeImputersilently drop all-missing columns
fromtransform()output when the column was all-missing at fit time,
desyncing the artifact'scolumnslist from the imputer's width and
crashing_sklearn_transform_subset(IndexError in the polars branch,
ValueError in the pandas branch). Both calculators now call the new
drop_all_missing_columnshelper inpreprocessing/imputation/_common.py
duringfit: all-missing columns are dropped from the fit matrix (with a
warning naming them) so the artifact stays in lockstep with the imputer's
width, andfitreturns an empty artifact when every configured column is
all-missing (appliers pass that through as a no-op). Added 5 regression
tests (parametrized over KNN + Iterative, pandas + polars). -
SimpleImputer polars mean/median crashed on all-null columns (OC-17):
fitting aSimpleImputerwithmean/medianon an entirely-null column
storedfill_values[col] = Nonein the polars path (polarsmean()/
median()over an all-null column returnsNone), and_apply_polars
then calledpl.col(col).fill_null(None)— which raisesValueError: must specify either a fill value or strategy. The pandas path already skipped
Nonefills, so the engines diverged (pandas left the column all-null;
polars crashed)._apply_polarsnow passes the column through unchanged
when its fill value isNone, mirroring_apply_pandas. Added 3 regression
tests (polars no-crash for mean and median, plus a pandas/polars
engine-parity test). -
Engine trusted
config.nodeslist order without verifying topological
sort (OC-69): bothpredict_schemasand the engine's node loop iterated
config.nodesas-is, butvalidate_no_cycles()only detects cycles — it
never restores order. The canvas converter's BFS can emit an
acyclic-but-misordered list (a merge node is enqueued when any parent is
dequeued, not all), so a diamond merge fed by unequal-depth branches failed
with a cryptic "Artifact not found" error and silentNoneschema
degradation. A new publictopological_order()ingraph_utils.py(reusing
the existing Kahn's-algorithm helpers) now re-sorts the node list at both
consumer sites. Added 5 regression tests including a full engine e2e run of
a misordered diamond. -
Multiclass splits missing a class emitted binary-only metrics + null
curve points (OC-35): three places decided binary-vs-multiclass from the
labels present iny_trueinstead of the model's trained label set, so a
3-class model evaluated on a split containing only two classes was
misclassified as binary — it gained unweightedprecision/recall/f1
keys, lost a computablelog_loss(sklearn raised "2 vs 3. Please provide
labels"), and emitted ROC/PR curve points withnullcoordinates (NaN from
roc_curveon the absent class's all-zero one-vs-rest target). The binary
gate in_add_binary_unweighted_metricsnow resolvesmodel.classes_and
is binary ifflen(classes_) == 2(falling back to the unique-label count
only when the model exposes noclasses_);log_lossis now called with
labels=classes(the full trained label set); and the per-class curve loop
inevaluate_classification_modelskips any class absent from the split.
...
v0.8.10
🔒 Security — mistune 3.3.2 → 3.3.4 (dev dependency)
- Bumped the transitive
mistunedev dependency (pulled in via
jupyter→nbconvert) from3.3.2to3.3.4via
uv lock --upgrade-package mistune, resolvingGHSA-6m44-fpc8-c3rq
(HIGH). Dev-only; no runtime dependency change.
🐛 Bug Fixes — Core
-
X/y desync on duplicate index labels (OC-12): the pandas paths of
DropMissingRowsandDeduplicateselectedyby label
(y.loc[X_clean.index]); with duplicate index labels.locreturns all
matching rows, soycame back longer thanXwith misaligned labels — a
silent wrong-labels bug. Both paths now compute a positional keep mask
(notnathreshold /duplicated), selectX.iloc[kept], and filtery
positionally via the new_pandas_filter_y_by_kept_positionshelper in
preprocessing/drop_and_missing/_common.py, mirroring the already-correct
polars paths. Added duplicate-index regression tests in
tests/integration/test_drop_rows.pyand
tests/integration/test_drop_and_missing_gaps.py. -
Numeric→boolean cast divergence on polars (OC-58): polars' default
numeric→Boolean cast is C-style truthiness (x != 0) and never raises, so a
value like2.0silently becameTrueon polars while pandas
(astype("boolean")) produced<NA>— and strict mode raised on pandas but
not on polars. The polars path inpreprocessing/casting.pynow mirrors the
pandas reference: only exact 0/1 values map toFalse/True, everything
else (including non-integer floats) becomes null, and strict mode raises
ValueErroron those nulls. Added regression tests in
tests/integration/test_casting.py(coerce, strict, pure 0/1, and
engine-parity cases). -
Non-reproducible
fingerprint()for object-dtype arrays (OC-62):
artifact_digestinpipeline/seal.pydigestednp.ndarrayvia
arr.tobytes(); fordtype=objectarrays that serialises rawPyObject*
pointers, which are allocator/ASLR dependent — so the fingerprint of any
artifact holding an object-dtype array (e.g. OneHotEncoder/LabelEncoder
categories_) changed across processes and was useless for caching or
comparison. The ndarray branch now digests the shape plus each element
recursively, so the digest reflects values. Added regression tests in
tests/unit/test_pipeline_coverage.py.
v0.8.9
🔒 Security — tornado 6.5.7 → 6.5.8 (dev dependency)
- Bumped the transitive
tornadodev dependency (pulled in via thejupyter
dev group) from6.5.7to6.5.8viauv lock --upgrade-package tornado,
resolvingGHSA-mpf4-983q-p7j4(HIGH),GHSA-8423-8fgw-73vq(MED), and
GHSA-wwv5-g3v4-889x(LOW). Dev-only; no runtime dependency change.
🧠 Core — F-08: strict DataFrame protocol split (finding closed)
- Type-safety restored, zero runtime change:
SkyulfDataFrame
(skyulf/engines/protocol.py) no longer declares__getattr__ -> Any,
which had silently disabled type checking on every
pd.DataFrame | SkyulfDataFrameunion. The protocol is now strict, and two
@runtime_checkablesub-protocols carry the engine-specific surface:
PandasBackedFrame(.loc,.iloc,.select_dtypes) and
PolarsBackedFrame(.with_columns,.filter,.to_polars). - Call sites migrated: pandas/polars-specific attribute access now goes
throughcast(PandasBackedFrame, df)/cast(PolarsBackedFrame, df)(or a
pd.DataFramecast) so the checker sees the real attributes — applied in
preprocessing/_helpers.py,modeling/base.py,
modeling/sklearn_wrapper.py,modeling/_tuning/engine.py, and the
integration tests.dtypeswas added to the base protocol (both engines
expose it). - Why it matters: the ~41
.iloc/.loc/.select_dtypessites that were
previously invisible to the type checker are now checked; a wrong attribute
name on a frame is a compile-time error instead of a runtimeAttributeError.
Protocols are erased at runtime, so behavior is unchanged — verified by the
fullskyulf-coresuite (3584 passed).
🧠 Core — F-09: engine-keyed dispatch mapping (finding closed)
- Breaking (internal API):
apply_dual_engine,fit_dual_engine, and
fit_transform_train_dual_engine(skyulf/preprocessing/dispatcher.py)
now take a single mapping keyed by engine name —
{"polars": fn_pl, "pandas": fn_pd}— instead of two positional
callables. All 63 node call sites migrated. - Loud failure replaces the silent pandas catch-all: an engine with no
registered implementation raisesNotImplementedError(naming the
available keys) before any frame conversion, and an engine with an
implementation but no input-preparation path raises as well — a third
engine (Spark/Dask) can never again be silently collected to the driver.
Adding a third engine is now an additiveO(1)dispatcher change. - Guard test repaired:
test_no_inline_engine_dispatchpointed at a
nonexistent directory and passed vacuously; it now scans the real
skyulf/preprocessingtree (87 files). - Behavior preserved: polars wrapper unwrap/re-wrap, pandas
to_pandas()
conversion, failure-log format, and mixed-engine(X, y)rejection.
Deliberately untouched:vectorization/_common.py::apply_text_dual_engine
(intentional pandas-first text path) and the non-dispatcher inline engine
branches (F-08 territory).
v0.8.8 — Tuning Engine Split & Import Hygiene
📦 Release
- Version sync to 0.8.8 (app + frontend + core): root
pyproject.toml,
frontend/ml-canvas/package.json/package-lock.json, and
skyulf-core/setup.pybumped to0.8.8.
🧠 Core — F-18: _tuning/engine.py split (finding closed)
- The 1,830-line
skyulf/modeling/_tuning/engine.py(largest file in the
library) is now a ~700-line orchestrator plus six leaf modules:
params.py(search-space cleaning, param splitting, model instantiation,
seed overlays),splitters.py(CV splitter builder family),metrics.py
(metric validation/aliases,resolve_metric/resolve_scorerwith
pos_label pinning),grid_random.py(candidate generation + per-fold
scoring),refit.py(best-model refit + decision-threshold tuning), and a
strategies/package (halving.py,optuna.pywith the F-14 lazy
loader,runner.py). - Pure code movement — the public
TuningCalculator/TuningAppliersurface
is untouched, test-pinned private methods remain as one-line delegates,
and stale test pins were retargeted to the new modules with the same
coverage.
🧹 Core — F-11: import cycles & deferred imports (finding closed)
- One real import cycle broken (
modeling/base → _evaluation → sklearn_wrapper → base): the four_evaluationmodules now import
SklearnBridgefrom the leafengines.sklearn_bridge, sobase.py
imports its evaluation/cross-validation dependencies at module level. - 173 function-level imports eliminated (ruff
PLC0415): 144 hoisted to
module level (internal, stdlib, hard dependencies incl. all 93 deferred
polarsimports), 29 genuinely optional extras (matplotlib, rich, shap,
optuna, imblearn, sentence_transformers, vaderSentiment, causallearn, h3,
scatter_matrix) kept deferred with documented per-site
# noqa: PLC0415 - <reason>waivers. - Enforcement:
PLC0415is now in the ruffselectset for
skyulf-core/skyulf/(backend/tests/entry points exempt), with the ruff
pin bumped to>=0.15,<1.0to match the pre-commit hook. - Monkeypatch-safe: third-party calls patched by tests keep
module-attribute form (sklearn_metrics.*,scipy_stats.*,
stattools.adfuller); degradation gates (SKLEARN_AVAILABLEetc.)
untouched. Failures now surface at import time, and the real module graph
is visible for the upcoming F-09/F-08 structural work.
🧾 Housekeeping
- Findings tracker: with F-18 + F-11 closed, 3 findings remain open
(F-30 deferred pending a compat call; structural F-09/F-08). - README refresh.
v0.8.7 — Pipeline Diagrams, Semantic Seal & Pipeline Package
📦 Release
- Version sync to 0.8.7 (app + frontend + core): root
pyproject.toml,
frontend/ml-canvas/package.json/package-lock.json, and
skyulf-core/setup.pybumped to0.8.7.
🗺️ New — Mermaid pipeline diagrams, end to end
- Single source of truth in core:
skyulf/pipeline/diagram.pyrenders a
top-downflowchart TD(data -> preprocessing steps -> model) from
config parts. Every label is double-quoted (mermaid rejects unquoted
parentheses/brackets inside labels), with a human-readable head line and an
optional second detail line — an explicit runtime summary or a compact
digest of the step params (max 3 keys,_-prefixed/None/mapping values
skipped, long values truncated). Algorithm names are humanized
(random_forest_classifier→Random Forest Classifier); no internal
node ids leak into labels. - Core API surface:
SkyulfPipeline.to_mermaid()and
to_mermaid_markdown()plusmermaid_markdown()for fenced export;
export_model_card()["diagram"]carries the same diagram; the notebook
export cell renders a## Pipeline topologyfenced block using node
display names, not uuids. - Backend persistence: successful jobs now persist
metrics.pipeline_diagramat completion, built from live node results —
display names captured from the canvas (params._display_name→
metadata["display_name"]), runtime summaries preferred with a
params-digest fallback for nodes without one, model node typed from the
job'smodel_type. - Frontend: the Experiments "Pipeline Diagram" tab renders one card per
selected run (legacy runs without a diagram are listed, not dropped), and
each card has a Copy mermaid button that copies a ready-to-paste
```mermaidfenced block. - Render hardening: mermaid's htmlLabels output is HTML, not XML — the
rendered SVG is now parsed viaDOMParser('text/html')and mounted with
replaceChildren, fixing the browser "Opening and ending tag mismatch:
br and p" error page; regression tests pin both the parse and the mount. - Regression guards: core describe/humanize/fence tests, backend diagram
adapter tests (display names, summaries, no-uuid assertions, notebook
cell), and frontend real-mermaid parse tests plus copy-button tests.
🧠 Core — findings closeout
- F-07:
to_native()converts engine frames back to pandas for
interchange consumers. - F-15: semantic fingerprint seals pipeline identity for reuse checks.
- F-19/F-19b: pipeline split into the
skyulf/pipeline/package —
diagram generation extracted from the fit path,describe()/to_mermaid()
live on the pipeline object.
🔧 Fixes & hardening
- Engine-agnostic node summaries:
summary.pynow handles both pandas
and polars frames (shape, dtype breakdown, split renderings), pinned by
tests/unit/test_summary_polars.py— previously polars-engine runs lost
their diagram detail lines. - Docs:
pipeline_quickstart.mdgained an "Inspecting a pipeline" section
covering the diagram labels and export paths.
v0.8.6 — Audit Closeout, Landing Redesign & SonarCloud CI
📦 Release
- Version sync to 0.8.6 (app + frontend + core): root
pyproject.toml,
frontend/ml-canvas/package.json/package-lock.json, and
skyulf-core/setup.pybumped to0.8.6.
🧠 Core — findings closeout
- F-21 single seed owner: one
DEFAULT_RANDOM_STATEconstant injected
at fit resolution; seeds surfaced as non-tunable hyperparameter fields and
canvas inputs (Random State / Fold Split Seed on training + ensemble
nodes), with per-node seed docs. - F-13 decision-threshold tuning in
TuningConfigend-to-end:
tune_thresholdgrid-searches the binary cutoff after refit (gated on
predict_proba + 2 classes + validation split), results rideTuningResult
and are applied byTuningApplier; binary string-label tuning fixed by
pinningpos_labelin f1/precision/recall scorers. Backend forwards the
flag in fixed/tuned modes and seeds the threshold store from training
metrics (GET /thresholdsexposessource); canvas gained the "Tune
decision threshold" checkbox and seeded-at-training badges. - F-12/F-21/F-23 follow-ups: drift pages relabelled to the KS
statistic (threshold input, table sort, alert evidence, CSV export —
p-value kept as diagnostic only); legacy graphs omit the IterativeImputer
seed so the core default stays the single owner; BLE001 staged rollout
finished (per-file ignores dropped, 121 deliberate broad catches waived
with per-site reasons). - F-31/F-14 closeout: lint hygiene (
.values→.to_numpy(), named
statistical threshold constants, sorted__all__), backend compute and
model-serializer seams moved toContextVarwith scoped context managers,
optuna import cache onto a locked state object, and scanner re-export
fixes (explicit lists replace star imports in the preprocessing shims).
🎨 Landing page — redesign, live stats & hero video
- New landing page and theme/color redesign around the hero line "Stop
trusting your pipeline. Verify it."; badge auto-updates from PyPI,
example accordion open by default, gallery lightbox (prev/next, counter),
hardened mobile menu and init script. - Hero badge images replaced by themed live stat pills — GitHub stars,
downloads/month (pypistats) and total downloads (parsed from pepy's
CORS-enabled badge SVG) — fed by keyless live APIs with static fallbacks;
flaky shields.io badges swapped out of the README too. - Hero background is a muted looping video (
static/video/wolf.mp4) over
the.hero-bgfallback image, paused for reduced-motion users. - SEO foundations: root sitemap deployed via the docs workflow, robots.txt
listing both sitemaps, FAQPage + sameAs structured data, website badge
in the README.
🧪 Tests — six rounds of Codecov patch coverage
- Six red-green rounds exercise every defensive except/fallback branch
flagged by the Codecov patch reports: serializer capability probes,
catalog/S3 cache fallbacks, degraded health/readiness endpoints,
DataService polars→pandas fallbacks, isolated metric failures, tuning
loader fallback chains and trial-error paths (incl. both directions of
thelen(fold_errors) > 1branch), threshold-tuning gates, drift
enrichment andks_statisticplumbing, SHAP except path, and the
balance-recommendation directions.
🚀 CI & quality — SonarCloud in PR Check
- SonarCloud folded into
pr_check.yml: a token-gatedsonar-gate
job detectsSONAR_TOKENpresence (job-levelif:cannot reference the
secretscontext — doing so fails the whole workflow file at validation)
and drives two parallel coverage jobs (backend-coverage,
skyulf-core-coverage) that upload Cobertura XML as artifacts;
sonarcloud-scandownloads both and never re-runs the suites. Without
the token everything skips silently; the dedicated test workflows stay
the authoritative gates + Codecov source. Third-party scan action pinned
to a full commit SHA;index.htmlexcluded from analysis. - CodeQL fixes: readiness probe returns a generic error
(py/stack-trace-exposure); pepy badge count parsed with a
capture-group regex (js/incomplete-multi-character-sanitization). - ty 0.0.75: optional-import narrowing fixed with the
TYPE_CHECKING-first idiom; CI pin raised to<0.0.76.
📚 Docs
- Completed dual-engine-correctness initiative docs archived — all waves
shipped (audit fixes, leakage enforcement, Polars migration, F-15
per-fold refit, merged-branch refit, holdout/validation-split refit).
Kept open: the fallback-shapes plan (Phase 0 telemetry done, Phases 1–4
gated on demand) and the parked SplitDataset ownership design; the
initiative README is rewritten around the archived/open split.
v0.8.5 — Fail-Fast Training Guard, Refit Telemetry & Canvas Help Guide
📦 Release
- Version sync to 0.8.5 (app + frontend + core): root
pyproject.toml,
frontend/ml-canvas/package.json/package-lock.json, and
skyulf-core/setup.pybumped to0.8.5.
🧠 Core — legible tuning failures
grid/randomsearch no longer discards the original per-fold error when
every candidate fails: the first trial error is threaded through the
evaluation chain and appended to theAll trials failedexception as
First trial error: …— mirroring the detailoptuna/halvingalready
had, so a cryptic "All trials failed" now names the root cause (e.g. a
non-numeric column reaching sklearn).
🔧 App — fail-fast guard & fold-refit telemetry
- Non-numeric training-frame guard: before tuning starts, the training
node preflights its frame and raises an actionableValueErrorlisting any
leftover object/string/category/datetime columns — instead of dying deep
inside sklearn with every fold error swallowed. The target column and the
time-series CV time column are legitimately non-numeric and excluded. The
message explains the most common cause: after a Split, merged branches
resolve overlapping columns by pure merge order, so the last connected
branch may have overwritten an earlier branch's encoding. fold_refit_fallbackmetric: graphs that can't use per-fold refit and
fall back to pre-transformed scoring now stamp a stable reason code into
the node metrics (nested_merge,fork_not_splitter,
learner_before_split,row_changing_branch_step,unsupported_graph,
payload_reconstruction_failed) — demand telemetry for which unsupported
shapes users actually hit.fold_refit_auditmetric: a newAuditedFoldPreprocessor
(skyulf-core) records the input row count of every per-fold
fit/transform;isolation_okproves no preprocessing fit saw more rows
than the train split, i.e. no held-out row leaked into a fit.
🎨 App — job details, help guide & canvas UX
- Score Advisory amber tile + modal in Job Details: flags runs that fell
back to pre-transformed scoring (optimistically biased scores) with a
plain-language explanation mapped from thefold_refit_fallbackcode; a
new Fold Refit Audit detail modal shows the per-fold isolation verdict.
The placeholder Progress tile was removed. - In-app help guide: the navbar's round book button opens "How pipelines
work" — ten sections covering linear chains, branches, merge ownership,
post-split merge order, row alignment, Run Preview vs Run All Experiments,
where results live, the Leakage Gate and Fold Refit Audit, the Score
Advisory, and badge/edge-color semantics. - Canvas UX fixes: Preview Results gained an always-visible X close
(dismissed until new results or validation issues arrive); Run Preview is
always visible and explains its blockers on click; the legend button uses
a tag icon with updated post-split merge-ownership copy and renders above
the results panel; fixed stacking so canvas buttons no longer float over
modals or the maximized panel, and the sidebar expand button no longer
overlays everything; zoom controls lift above an expanded results panel.
📚 Docs
multi_path_pipelines.md: after a Split, merge ownership is inert —
overlapping columns resolve by pure merge order (last connected branch
wins every shared column); keep post-split branches disjoint or fully
numeric.troubleshooting.md: new entry for"All trials failed"/
non-numeric-column errors after a Split; PropertiesPanel shows a matching
post-split note under the merge-strategy select;leakage_proof_pandas.md
cross-links to the guide.
v0.8.4 — Cyclic-Pipeline Guard & Live Tuning Charts
📦 Release
- Version sync to 0.8.4 (app + frontend + core): root
pyproject.toml,
frontend/ml-canvas/package.json/package-lock.json, and
skyulf-core/setup.pybumped to0.8.4.
🧠 Core — leakage-free searcher tuning
optuna/halving_*tuning now refits preprocessing per fold for every
chain — including the ones the old Pipeline wrap refused: resampling
(SMOTE/over-/undersampling), row drops/outlier removal, and target
re-encoding (LabelEncoder). The transformer-step wrap could only handX
forward, so such chains fell back to applying the chain once on the full
training set — leaking synthetic rows / fitted target stats into the
validation folds (measured: optuna 0.978 vs per-fold random 0.914 on the
same SMOTE data).- New fit-time meta-estimator
FoldAwareModelStep
(skyulf/modeling/_tuning/fold_pipeline.py): the searcher wraps
preprocessing + model in one estimator (fitruns the chain on the fold's
training rows only, then fits the model), so the searcher's internal CV
drives a true per-fold refit with no sklearn patching. When the chain
re-encodes the target, predictions andclasses_are mapped back to the
original label space (built from paired uniques over the whole fold — no
sampling risk for rare classes), andpredict_probacolumns stay aligned
for roc_auc-style scorers; regressors pass through untouched. - The runtime alignment probe and the one-shot "apply once to the full
split" fallback are retired — the constraint they guarded no longer
exists. The fallback narrows to frameless SDK calls (numpy-only, no named
frames to run the chain on), which keep scoring the raw payload with an
explicit log. Backend runs always have frames, so the app path is always
per-fold now;grid/random(already per-fold) and fork-join
merged-branch screening are unchanged. - Holdout tuning with a validation split is leakage-free now — the last
remaining optimistic path. Before, the backend skipped per-fold refit
entirely wheneverdata.validationexisted and the engine refused
preprocessing+validation_datatogether, so candidates trained on the
already-preprocessed full train frame (SMOTE/WOE statistics fitted with
held-out rows included). Now all five strategies refit the chain on the
train rows only and score candidates against the untouched validation
split: the pre-transform train and validation frames are concatenated into
one search frame with a singlePredefinedSplitfold (train masked-1,
validation = the one scoring fold). The backend reconstructs the
pre-transform validation payload alongside the train one and threads it
throughfit_predict(..., preprocessing_validation=...)→
TuningCalculator.fit/tune(..., validation_frames=...); post-tuning CV
gets the adapter too instead of scoring raw train. Note: on
leakage-dominated graphs (e.g. target-aware encoders) reported holdout
best_scores will drop — same dynamic as the v0.8.2 note; the drop is the
bias leaving. SDK callers without named validation frames keep today's
logged raw-payload fallback. - Fold-aware wrap dtype fix: polars payloads handed to the searcher now
convert throughto_pandas()insideFoldAwareModelStep; the old
np.asarrayrebuild collapsed mixed-type frames to object dtype and
silently disabled numeric steps (a SimpleImputer + WOE chain left NaNs
untouched and crashed the candidate fits). - Search-space routing moved to
model__estimator__<param>;best_params
and trial params are stripped back to the caller's original keys. - Red-green coverage: meta-estimator unit tests (label-space round trip,
SMOTE-style row shaping, deepcopy isolation forn_jobs > 1, regressor
passthrough, array-input frame rebuild, f1/roc_auc through a real
searcher), a fold-leakage proof (spy adapter asserts every
fit_transformsees only its fold's training rows and every validation
fold is scored throughtransform) for optuna and halving_random, and an
end-to-end XGBoost + string-target optuna run matching the grid score on
the same folds.
🔧 App — cyclic graphs fail fast at both layers
- Connect-time checks already blocked cycles one edge at a time, but bulk
graph loads (saved projects) applied nodes/edges verbatim and the backend
never validated topology — a loop died late mid-run with a cryptic
"Artifact not found". Both layers now reject whole-graph cycles up front:- Backend:
_cycle_validation.pyruns Kahn's algorithm over node inputs,
prunes the stuck set down to the exact loop members (nodes merely
downstream of the loop are not blamed), and raisesPipelineCycleError
naming the loop. Called inengine.run()before the leakage check,
whose descendant-map build bails out on cycles. - Frontend:
pipelineCycleValidation.tsmirrors the guard and feeds a new
'cycle'category intocollectGraphValidationIssues, so the validation
panel flags loops from bulk-loaded graphs instantly and preview/training
submission stays blocked until the loop is removed.
- Backend:
- Red-green coverage: 8 backend unit tests, 1 engine integration test
(nothing runs, no artifacts written), 8 frontend tests.
📈 App — live tuning trial chart
- Tuning jobs now draw their trial progress while they run: the training
runner publishes a structuredtrialjob event per completed trial
(grid/random and Optuna paths) over the existing/ws/jobschannel, and
the job details view renders an updating per-trial score + best-so-far
chart (useTuningTrials+TuningTrialsChart) with a liveTrial x/y
progress tile. - Completed jobs redraw the same chart from the already-persisted
metrics.trials— no new persistence. Fixed runs (single trial) and
callback-less strategies (halving) degrade honestly: no live curve,
completed halving jobs still redraw from their recorded trials. - Privacy-safe by construction: the channel broadcasts to every client
without auth, so trial events carry aggregate scalars only (trial index,
total, score, metric) — never hyperparameters or data. Emission failures
are swallowed; training is never impacted. - Late openers are backfilled: the WebSocket only reaches already-connected
clients, so the runner also records each trial in a bounded in-memory
per-job buffer (LRU-evicted, capped) served by
GET /api/pipeline/jobs/{id}/trials; opening a running job fetches that
snapshot and the curve starts at trial 1, with live events merged on top
(deduped by trial number). Once a job turns terminal the complete
persistedmetrics.trialslist always wins over the partial live tail,
so watching a job to completion no longer leaves a truncated curve.
📈 App — live boosting iteration chart
- Fixed boosting runs (XGBoost / LightGBM classifiers and regressors) now
stream a per-iteration live chart exactly like tuning runs stream their
trial curve: the calculators attach a native callback
(XgboostIterationAdapter/LightGBMIterationAdapterin core) and an
eval_set=[(X_train, X_train)]used purely for display — no early
stopping is configured, and a test verifiespredict_probais identical
with and without the callback. XGBoost 3.x droppedfit(callbacks=...),
so the adapter is set as the estimator'scallbacksattribute and
detached again after fit so pickled artifacts never carry live closures. - Per explicit decision, the final refit pass of multi-trial tuning runs
streams its iterations too, after the trial curve completes; candidate
fits inside the search stay silent. Fixed runs route through the same
engine (n_trials=1), so both paths share one code path. - New
iterationjob event carriesiteration_direction
("minimize"/"maximize") because boosting scores are usually losses — the
chart's best-so-far runs a running min for minimize metrics (LightGBM's
is_higher_betteris used verbatim; XGBoost metric names fall back to a
lower-is-better heuristic). - The runner persists
metrics["iterations"](plusiteration_metric/
iteration_direction) post-fit, mirroringmetrics["trials"], so
completed jobs redraw without new storage; the live backfill buffer and
GET /jobs/{id}/trialssnapshot grew a paralleliterationsfield. - Scope halved on purpose: XGBoost + LightGBM only. CatBoost is not
registered anywhere and HistGradientBoosting has no callback API; both
remain non-goals alongside DL epoch curves.
📈 App — Trials/Iterations tabs for dual-series jobs
- A tuned boosting job runs two series — the search's trials, then the
refit's boosting iterations — and used to collapse to one chart
(iterations outranked trials). The job details view now keeps both:
useTuningTrialsreturns two independent slices plus the actively
streamingactiveKind, and the view renders a small Trials / Iterations
tab row whenever both series have chartable points. - The view auto-follows the streaming series (trials during the search,
passing to iterations when the refit starts) until the user clicks a tab,
which pins that series; the pin resets when switching jobs. The progress
tile (Trial x/yvsIteration x/y) tracks the visible series. - Single-series jobs are unchanged: no tabs, chart behaves as before
(trials for any tuned job; iterations for fixed boosting runs). - Coverage: trials appear for every tuned job regardless of problem type
or algorithm; iterations only forxgboost_*/lgbm_*(the only
calculators with native callback APIs); segmentation never tunes, so it
shows neither.
🧠 Core — console trial progress for SDK-only use
TuningConfig(progress=True)gives core-only users a tidy console read of
a tuning run without hand-writing aprogress_callback: on a TTY a single
self-updating line (Tuning trial 12/60 | score 0.8530 | best 0.8710 (#8)),
and on completion a compact summary (best score + params, top-...