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 response was invisible to the frontend (S8414,
SonarCloud BLOCKER):add_middlewarewraps, so the middleware added last
is outermost._add_middlewareadded TrustedHost → CORS → Logging →
ErrorHandler, which put CORS insideErrorHandlerMiddleware. Any
exception that handler converted to a JSON error response was therefore
produced outside the CORS layer and carried noAccess-Control-Allow-Origin
header, so the browser blocked the frontend from reading the status or body
of every cross-origin 4xx/5xx — the exact responses a UI most needs to
explain. Successes worked, which is why this survived: only failures were
opaque, and they presented as generic network errors. CORS is now added last
(verified outermost:CORSMiddleware → ErrorHandlerMiddleware → LoggingMiddleware → TrustedHostMiddleware), with a comment recording the
wrap-order invariant so it is not "tidied" back. -
Ten integration tests reported assertion failures with an empty reason
(S5779 ×10):tests/integration/test_frontend_nodes.py(9 sites) and
tests/unit/verify_polars_preprocessing.py(1) wrapped their assertions
insidetry: ... except Exception as e: pytest.fail(str(e)).pytest.fail
raisesFailed, which derives fromOutcomeException(BaseException)— not
Exception— so it does propagate out of the handler and the tests do fail.
The damage was quieter: when the failing assertion was a bare
assert result.status == "success",str(AssertionError())is'', so the
suite failed with no message at all, and pytest's assertion-introspection
diff was lost because the assert ran inside a function call frame. A node
contract regression in this suite — the one guarding every canvas node type —
was therefore indistinguishable from an engine crash. Each block now keeps
only the call under test inside thetryand asserts after it. Four stale
"thinking out loud" comments were replaced with one accurate note about where
node data actually lands (the artifact store, not the return value). -
kmeans-strategy binning was not reproducible run-to-run (S6709 ×2):
KBinsDiscretizer(strategy="kmeans")runs k-means internally, and both
_fit_kmeansand_fit_kbinsinpreprocessing/bucketing.pyconstructed it
withoutrandom_state— so the same data could yield different bin edges on
each fit, breaking the library's stated reproducibility guarantee and
invalidatingSkyulfPipeline.fingerprint()as a seal (fitted edges feed the
digest, so an identical pipeline hashed differently across runs). Both now
injectDEFAULT_RANDOM_STATEfromskyulf/types.py, the single owner of the
seed, matchingpreprocessing/split.py. Two further S6709 hits in
profiling/_analyzer/multivariate.pywere already fixed on this branch. -
_first_finitecould return infinity (S1764): the helper in
_execution/summary.pyfiltered non-finite values withif f == f, which
rejects NaN but happily accepts±inf— so a metric ofinf(a divide-by-zero
in a ratio metric, or an unbounded loss) was rendered into the node summary
shown on canvas cards and in the pipeline diagram. The name promised finite;
the check delivered not-NaN. Nowmath.isfinite(f), in both_first_finite
and its unflagged sibling_train_only, which shared the same idiom. A
second S1764 indata_ingestion/serialization.py(obj != obj) was already
narrowed tofloatand is exactly equivalent, but now reads as
math.isnan(obj). The leak was reachable in three distinct renderings, all
now pinned:test_accuracy=infprintedacc infinstead of falling through
to the next finite candidate; an all-infinite metric set printed
acc inf · f1 -infinstead of no headline; and an infinitetrain_accuracy
made the overfit gapinf - 0.80 = inf, which fails thediff < 0.05guard
and printedacc 0.80 · ▲inf. Added 4 regression tests (3 locking those
down, 1 pinning the NaN rejection that already worked, sinceisfiniteis a
strictly wider filter). -
User-controlled identifiers were interpolated raw into log lines (S5145 ×3,
CWE-117):job_id(an unvalidatedstrpath param onPOST /deploy/{job_id}) inml_pipeline/deployment/service.py, the same in
monitoring/router.py, andfile_pathindata_ingestion/service.pyall
reached a logger without sanitization. A value containing CR/LF forges
additional log lines, which is how log-injection attacks hide an intrusion or
frame another user. Added a sharedsanitize_for_loghelper in
backend/utils/logging_utils.pythat escapes the C0 control block plus DEL to
a visible\xNNform — escaping rather than deleting, so the attempt stays
visible in the record. SonarCloud under-reported the rule: two of the
three flagged statements also interpolated a second tainted value it did not
report, so sanitizing only the reported ones would have left them forgeable —
artifact_uriindeployment/service.pyis alwaysstr(db_job.node_id), and
node_idreaches the DB from the client-submitted graph through the
NodeConfigdataclass, which declares it as a barestrwith no validation;
andsource_idindata_ingestion/service.pyis declared
delete_source(source_id: str)at the router, not anint. All five values
are now sanitized. A fourth S5145 oneda/router.py:145is a false positive:
that param is anint, validated by the framework. -
Two
asyncpipeline routes blocked the event loop on disk I/O (S7493 ×2):
the JSON save and load paths in_internal/_routers/pipelines_io.pyused
synchronousopen()/json.load/json.dumpinsideasync defhandlers,
stalling every other in-flight request on that worker for the duration of the
read/write. Both now useaiofileswithawait, matching the convention
already established atdata_ingestion/service.py:441. -
CodeQL kept calling the pipeline JSON path injectable
(py/path-injection×2, High):_pipeline_json_pathalready rejected any
dataset_idoutside^[A-Za-z0-9_-]+$before constructing aPath. That
allowlist is sufficient on its own — the charset excludes., both separators
and NUL, so the join appends exactly one segment andstorage_dircannot be
escaped. But CodeQL does not model a character allowlist as a sanitizer, so
theaiofiles.opensinks in/save/{dataset_id}and/load/{dataset_id}
stayed reported scan after scan, even thoughd0c02371had added that very
allowlist to close this finding. Added the containment layer the rest of the
backend already uses (LocalFileConnector.resolve_safe_path,
LocalArtifactStore._get_path,
ArtifactFactory._resolve_local_artifact_path): resolve the joined path and
require it to beis_relative_tothe resolved storage root. Verified with
layer 1 disabled — widening the regex to.*still rejects../../evil,
../eviland..\..\evilwith HTTP 400 and writes nothing outside the
storage dir. The helper now returns an absolute path; the default relative
PIPELINE_STORAGE_PATH(exports/pipelines) resolves against the process CWD
exactly as before, only earlier. -
Selected text ran underneath the dropdown arrow in every
<select>
(css:S4657, CRITICAL):styles/components.cssdeclaredpadding-right: 2.5remto clear the custom arrow background-image, thenpadding: .55rem .8rembelow it — and the shorthand resets all four sides, silently
discarding the longhand above it. Every select using this rule had its right
padding collapse to.8rem, so text overlapped the arrow — and that is all
110<select>elements in the codebase, since no otherselectrule in
src/styles/declares padding and none of the 110 carries a Tailwindp*-n
utility that would outrank it. The longhand is now ordered after the
shorthand, with a comment recording why the order matters. Separately, a dead
color: #f8fafcinstyles/layout.css(css:S4656) was removed from
.feature-canvas-navbar__brand--gradient: the latercolor: transparent
always won at equal specificity, so it never acted as the
background-clip: textfallback it appears to have been written for — a real
fallback needs@supports. -
Three number-matching regexes froze the tab for ~2 seconds on long input
(S8786 ×3):InferencePage.tsx'sNUMERIC_REand two of the
syntax-highlight patterns inJobDetailsView.tsxall used the shape
\d+\.?\d*— two digit quantifiers separated by an optional dot, so on a
failing long digit run the engine can split the run between them in O(n) ways
and backtracks quadratically. Measured on a 60,001-char input:
1723.74 ms → 0.11 ms after the rewrite (a repeat run gave 2029.77 ms →
0.09 ms), four orders of magnitude. This is user-reachable, not theoretical:
InferencePagerunsNUMERIC_REover pasted CSV cell values, so a long
numeric-looking paste hung the main thread per cell. Rewritten as
\d+(?:\.\d*)?, which accepts the identical language (re-verified over 21
inputs, zero mismatches) but is linear, since digits after a dot become
reachable only through the now-mandatory dot. A fourth S8786-shaped pattern
(\d+\.\d+) was left alone — a mandatory dot is already unambiguous. -
Converted 62 global number coercions to their
Number.*statics across 27
frontend files (S7773 ×62): 41parseFloat, 11parseInt, 7isNaN,
2isFinite, 1 bareNaN. Cosmetic in intent, but this needed auditing
rather than a blind find/replace:Number.parseInt/Number.parseFloat/
Number.NaNare the identical objects and values (always safe), whereas
Number.isNaN/Number.isFinitedo not coerce —isNaN("abc")istrue
butNumber.isNaN("abc")isfalse, so a naive sweep over a string argument
silently changes behaviour. All 9 coercion-sensitive sites were checked
individually; every argument was already statically anumber(aNumber(...)
wrapper, aparseFloatreturn, aMath.max/minresult, or a
typeof x === 'number'narrowing), so all 62 convert with zero behaviour
change and none needed theNumber.isNaN(Number(x))coercion-preserving form.
The codebase already used the statics extensively, so this removes an
inconsistency rather than adding one. -
Two smaller correctness cleanups:
(job.metrics or {}).get("summary") if job.metrics else Nonein_execution/jobs.pyhad a deador {}branch — the
guard already provedmetricstruthy, so the fallback was unreachable
(S2583); andnp.where(preds == -1)[0]in
profiling/_analyzer/multivariate.pybecamenp.nonzero(preds == -1)[0], the
direct single-argument form that does not build and discard a tuple (S6729). -
Triage record: all 118 open
masterissues were read in the source tree
and verified — running code where a verdict hinged on runtime behaviour —
giving 89 genuine / 29 false positive. Findings, per-finding evidence, and
the false positives worth marking upstream are in
initiatives/analysis/sonarcloud-master-issues.md.
Notably the 6python:S5863"identical assertion arguments" hits are not
copy/paste slips: five are deliberate determinism and idempotence assertions
onartifact_digest(two distinct empty classes digest differently, so
"strengthening" them would be wrong) and one is the canonicalx != xNaN
test. Only the NaN site got a clarity rewrite topd.isna.