Skip to content

Forecasting prereqs - #932

Open
Felipedino wants to merge 5 commits into
developfrom
reconcile/forecasting-prereqs
Open

Felipedino wants to merge 5 commits into
developfrom
reconcile/forecasting-prereqs

Conversation

@Felipedino

Copy link
Copy Markdown
Collaborator

First PR of the stack that ends with the forecasting redesign. It does not touch forecasting. It fixes general bugs that forecasting exposes, because forecasting is the only task that reorders rows and the only one whose splitters suit no other task.

Base: develop at 88810b892 (merge of #918).

Why

Run.split_indexes are positions in the dataset as the task prepared it, since that is what training partitions. A task may reorder that dataset: ForecastingTask sorts it by date. Two consumers still indexed the loaded dataset. Separately, the session and run APIs did not check that the splitter and the model fit the task and the strategy.

# Bug Effect
U1 PredictJob selected a run's partition from the loaded rows With a task that reorders, a prediction on a partition saved other rows, with no error
U2 PrepareExplanationDataUnit cut the loaded rows and only then prepared them Same as U1, for explanations
U2b The same unit cut twice. Between the two cuts, the task merged the three parts back in alphabetical order (test, train, validation) Explainers got partitions holding rows of the other partitions, with any task. Example with 8 rows and train=[5,1,6,2]: the explainer's train held rows [1,3,6,7] instead of [1,2,5,6]
U7 A splits payload without splitter_name falls back to HoldoutSplitter, which shuffles A session for a series created through the API shuffled it, with no error
P4 The API checked the splitter against neither the task nor the strategy A holdout strategy with a fold splitter failed every run with UnitContractError. A splitter the task does not offer ran without a word. A registered name that is not a splitter caused a 500
P13 POST /run/ only checked that the model name was registered, with any type A regressor in a classification session, or a metric's name, was stored and then failed inside the fit

Changes

  • U1 (PredictJob): when the prediction names a partition, the job prepares the loaded rows first and then selects them. It prepares with the columns training used: the raw refs if the session preprocesses, input_columns otherwise.
  • U2 and U2b (PrepareExplanationDataUnit):
    • prepares first and cuts once;
    • with session preprocessing, prepares on the raw refs and then transforms each part, as ModelJob does;
    • the unit gains an optional input_column_refs field, which comes together with preprocessing_artifacts_path or not at all. ExplainerJob passes it;
    • the pairing check moves to a module function, splitter_scope.persisted_preprocessing. The partition units keep calling it through the same method as before.
  • P4 and U7 (POST/PATCH /model-session/):
    • strategies declare SPLITTER_PARTITIONING: "holdout", "folds", or None for the full-dataset strategy;
    • _validate_splits resolves the splitter, including the default, and checks, in order: that the name is registered, that the task offers it, that its PARTITIONING is the one the strategy takes, and that its schema accepts the payload;
    • a PATCH that changes the task re-validates the stored splitter;
    • the default stays, because legacy sessions rely on it. Where the task does not offer it, the 422 says that no splitter was named.
  • P13 (POST /run/, PATCH /model-session/):
    • the run's model must be one of the Model components the task offers. This check runs after the download checks, so their 409 stays;
    • a PATCH that changes the task is refused with a 422 that names the runs whose model the new task does not offer. The runs are neither deleted nor flagged.
  • api.utils.offered_components(registry, task, type) holds the relation lookup that the strategy, splitter, model and run checks share.

ModelJob and normalize_splits_payload are unchanged. Validation lives in the API, and the job trusts it. This is the same approach clustering took for the strategy.

Behavior changes

All of these are deliberate:

  • New 422s:
    • on session creation: a splitter the task does not offer (the default included), a splitter of the wrong partitioning for the strategy, or a name that is not a splitter (was a 500);
    • on a PATCH that changes the task: a stored splitter, or a run's model, that the new task does not offer;
    • on run creation: a model the task does not offer.
  • Plugin tasks must declare their splitters in COMPATIBLE_COMPONENTS, as they already must declare their strategies. Relations are stored both ways, so a task can name splitters and models it does not own.
  • Explanation values change for every session, because explainers now get the right rows. Explanations stored before this PR were computed on mixed-up partitions, and they are not recomputed. This is worth a line in the release notes.
  • PrepareExplanationDataUnit rejects an artifacts path that comes without refs, raising JobError.
  • With a task that reorders, a prediction on a partition is saved in the prepared order.

Tests

Every new test was run against the unfixed code first and failed for the expected reason.

  • tests/back/api/row_order_tasks.py: ReversingTask, moved here from the preprocessing row-order test, and SeriesOnlyTask, which offers only TemporalHoldoutSplitter. With it, U7 is covered without registering ForecastingTask.
  • test_predict_job_row_order.py: the test and val partitions, each with and without session preprocessing.
  • test_predict_job.py and test_predict_job_preprocessing.py: a prediction on a partition saves exactly what a whole-dataset prediction saves for those rows. That covers columns, declared types, the Arrow schema with its metadata, and values. These tests passed before and after the fix: they pin that preparing changes nothing but the row order.
  • test_explanation_units.py:
    • partition contents with a real RegressionTask (U2b), with a reversing task (U2), and with a persisted preprocessor;
    • the path/refs pairing.
  • test_session_splitter_validation.py: wrong partitioning, a splitter the task does not offer, a non-splitter name, the default under a task that does not offer it, and a change of task.
  • test_run_model_validation.py: a model the task does not offer, a non-model name, and a change of task with an incompatible run.
  • test_units_api.py now pins the schema of PrepareExplanationDataUnit.

Verification

  • Full suite: 4403 passed, 2 skipped, 1 xfailed. The base gives 4381; the difference is the 22 new tests. Command: uv run --frozen --extra cpu pytest tests/ --deselect tests/back/test_main.py::test_app_front.
  • The CV and clustering regression nets (test_model_job_cross_validation.py, test_model_job_orchestration.py, test_model_job_clustering_net.py, test_clustering_notebook_net.py) have no diff and pass.
  • The unit contract audit stays at 336 tests, and EXPECTED_UNITS at 37. No units were added.
  • ruff check and ruff format --check are clean on the touched files.

Out of scope

  • GenerateLocalExplanationUnit._select_instances prepares before indexing, but it transforms the whole dataset first, so a step that drops rows (NanRemover) shifts the indexes.
  • ReportJob prepares with the post-converter input columns and does not apply the session preprocessor.
  • POST /predict/ does not check the partition against predictable_splits.
  • An empty holdout payload ({}), or a fold-shaped payload with no splitter name under a holdout strategy, still resolves to HoldoutSplitter.
  • PATCH /model-session/{id} with dataset_id re-validates nothing. That was already the case before this PR.

Copilot AI balanced review requested due to automatic review settings October 21, 2026 18:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Felipedino
Felipedino added this pull request to stack #936 October 24, 2026 20:49
ReversingTask moves to its own module so the prediction and explanation
tests can use it too. It now names the splitters and the model those tests
need, and SeriesOnlyTask stands in for forecasting without registering
ForecastingTask: it offers only the temporal holdout splitter.
Run.split_indexes are positions in the dataset the task prepared, and a
task may reorder it: forecasting sorts by date. Predicting on a partition
selected those positions from the loaded dataset, so with a reordering task
it predicted and saved other rows, without an error.

The job now prepares the loaded rows with the columns training used (the
raw refs when the session preprocesses) before selecting. Preparing keeps
every column, value and type, which a test pins: with a task that does not
reorder, a partition's saved prediction is the whole dataset's for its rows.
…epared

PrepareExplanationDataUnit cut the loaded dataset with the run's indexes,
handed the three parts to the task and cut the result again with the same
indexes. The task merges a dict of parts back in alphabetical order of
their names, test first, so every explainer got partitions holding rows of
the others, with any task. A task that reorders, as forecasting does, read
the loaded order on top of that.

The unit now prepares the loaded rows first, with the columns training
prepared with, and cuts once. A session that preprocesses supplies
input_column_refs together with the artifacts path, so the task gets the
raw refs, and each part is transformed after the cut as before. The pairing
check moves to a module function in splitter_scope, shared with the
partition units.
…tegy

The session API validated a splits payload against its splitter's schema
only. A run prepares with the unit the splitter's PARTITIONING picks and
fits with the unit the strategy's KIND picks, so a holdout strategy with a
fold splitter failed every run with a missing context key. A splitter the
task does not offer ran without a word: a temporal holdout on a regression,
or the default HoldoutSplitter, which shuffles, under a payload that named
none. A registered name of another type reached the schema lookup as a 500.

Strategies now declare SPLITTER_PARTITIONING, and the API resolves the
splitter (the default included) and checks it against the splitters the task
offers and the partitioning the strategy takes, on creation and on a change
of task. The default stays for the sessions that rely on it; a task that
does not offer it gets a 422 that says no splitter was named.
POST /run/ only checked that the model name was registered, of any type,
so a regressor in a classification session, or a metric's name, was stored
and failed or misbehaved inside the fit. The endpoint now asks the registry
the question the wizard asks, after the download checks so their 409 stays.

A change of task by PATCH asks it for every run the session has. A run whose
model the new task does not offer refuses the change with a 422 that names
the runs; they are neither deleted nor flagged.

The relation lookup the three session checks share moves to
api.utils.offered_components.
@Felipedino
Felipedino force-pushed the reconcile/forecasting-prereqs branch from fc3398a to f319e05 Compare October 24, 2026 23:35

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants