Skip to content

One implementation of model training: composable units, the engine that runs them, and the cross-validation reconciliation - #875

Draft
Felipedino wants to merge 29 commits into
developfrom
reconcile/cv-units
Draft

One implementation of model training: composable units, the engine that runs them, and the cross-validation reconciliation#875
Felipedino wants to merge 29 commits into
developfrom
reconcile/cv-units

Conversation

@Felipedino

Copy link
Copy Markdown
Collaborator

What this brings

Three bodies of work, together because the third depends on the first two:

  1. The jobs, atomized into composable units (DashAI/back/units/).
  2. The DAG engine that runs them (DashAI/back/dag/).
  3. The reconciliation with cross-validation, which is what is new on this
    branch.

It can be reviewed in two parts instead: merge the units and the engine first,
then rebase the reconciliation on top. Say the word and I will split it.

Why

ModelJob.run() was 400 lines. Atomizing it cut it into units that declare what
they read and what they produce, and that work the same inside a job or as a
node of a graph.

While that was happening, develop gained cross-validation — and it did not
add a branch to the monolith. It replaced it with a second layer of
abstraction
: BaseSplitter (10 splitters) and BaseEvaluationStrategy (4
strategies). The result was two complete implementations of model training,
each passing its own tests, and each able to give a different answer for the
same model.

This PR leaves one.

Where it landed

ModelJob composes units on all three paths — holdout, cross-validation and
nested CV — and the strategy classes are left only declaring how a run is
carved and which partitions it records.

LoadDatasetUnit
  → PrepareAndSplitUnit | PrepareAndFoldUnit      (chosen by the splitter)
  → BuildModelUnit
  → FitModelUnit | FitModelOverFoldsUnit | FitModelOverNestedFoldsUnit
  → EvaluateModelUnit
  → SaveModelUnit
DashAI/back/evaluation/ 881 → 153 lines
Registered units 33
Tests 3692 passing

The strategies stay registered even though they are emptied

That is not deference to dead code. The frontend reads these classes in four
places, and only one of them is about metrics:

  • the session wizard lists them so the user can pick one, and
    ModelSession.evaluation_strategy is NOT NULL — without that listing a
    session cannot be created at all
    ;
  • it starts on the first one whose kind is holdout;
  • kind decides the shape of the splits payload and which controls are shown;
  • scored_splits tells the metric charts which partitions exist to plot.

There is precedent in this codebase for a class that declares and does not
execute: BaseSplitter.PARTITIONING and explainable_partitions are read the
same way.

Where to start reviewing

Suggested order, most to least load-bearing:

  1. DashAI/back/models/base_model.py — the sharpest edge, below.
  2. DashAI/back/job/model_job.py — the three paths composed.
  3. DashAI/back/units/fit_scope.py and the three fitting units.
  4. tests/back/api/test_model_job_cross_validation.py — the net that pins
    the observable behaviour.
  5. The rest of units/ and dag/, which is the earlier atomization.

The conflict git does not mark

base_model.py auto-merges without a conflict and produces a class with
two compute_metrics: both branches had added a method under that name, in
different parts of the file, with different semantics.

atomization CV
default split VALIDATION TEST
nothing to score None {}
non-finite scores dropped kept

Python keeps the last one. The only thing that noticed was ruff F811, by
accident — no test did, because the CV path barely had any.

Resolved by hand: the CV copy is deleted, the extracted one is kept with its
filter for non-finite values
, and fold_index, inner_fold_index and
_epoch_reporter are kept. A test parses BaseModel and asserts a single
definition, so the next merge cannot repeat this quietly.

Decisions worth a close look

  • A trial may not score the test partition. FitModelUnit.trial_splits does
    not offer it — not as a default, but as a value that cannot be chosen.
    Scoring it once per trial lets the search see the test set, and a model picked
    that way has no honest score left to report.
  • Whether validation reaches the fit is policy, not shape. A holdout run
    hands it to the model to watch training and stop early; a fold must not,
    because those are the rows it will be scored on. Nothing raises when it goes
    wrong: the score simply comes out better than the model deserves.
  • The model is pointed at its data when it is fitted, not when it is built.
    Binding the splits at construction only worked while a model was fitted once.
  • The job aggregates the fold metrics, not the unit. A summary row carries
    std_value, and a unit does not write domain rows —
    BaseModel._save_metrics, the one sanctioned write, has nowhere to put a
    deviation.
  • Nested CV is a separate unit rather than a flag, because its inner
    splitter is a required component field, and a component field cannot be made
    optional without leaving the user with no selector.

What is deliberately missing

  • Per-fold progress reporting is gone. The bar sits at 20% for the whole CV
    loop. Restoring it needs a callback in a unit's contract, and a runtime
    parameter the engine cannot supply makes that unit unusable as a graph node.
    This is the one visible regression and it is unresolved on purpose — if
    the progress matters more than that property, it can be implemented.
  • Prediction.split / predicting on a split of a run (PR Enable predictions on dataset partitions and remove refitting on validation for forecasting #858) fell out
    while resolving the merge, because a coherent file was preferred to a
    half-applied one. It needs a unit that selects rows; that was not merge work.
  • FitModelOverNestedFoldsUnit has no contract tests of its own yet (it is
    covered end to end).

Other changes worth naming

  • An Alembic merge migration (82fb7a6b8ac2). The two branches left
    different heads, which made every test that builds the app fail. It is an
    empty revision: the two sides touch disjoint tables.
  • Saving a model is atomic now. A save that died partway left a truncated
    artifact that the row went on pointing at.
  • PredictJob raises JobError rather than HTTPException on the two
    branches that fail while predicting: an HTTPException stores nothing in
    args, so dill brings it back from the worker without its message. Six
    pre-flight raises still have this shape and are noted in the code.
  • DatasetJob clears file_path when a failure removes the folder it
    created.

Verification

uv run pytest tests/

3692 passing. One test was deselected during development:
tests/back/test_main.py::test_app_front builds the app against the real
~/.DashAI rather than a temporary directory, and if that database is stamped
with an Alembic revision from another branch, the migration code backs up and
recreates the user's database
. Worth fixing separately.

The new regression net — tests/back/api/test_model_job_cross_validation.py,
34 tests — was written and verified against develop with nothing modified,
before any of this. It pins the shape of the splits, which rows reach which
partition, how many fits happen and on what, the fold metrics and their
aggregation, and the verbatim text of every error branch. That it still
passes without a single assertion changed is what says the observable behaviour
did not move.

Felipedino and others added 29 commits August 13, 2026 20:35
- Introduced tests for atomic units including registration, execution, and validation.
- Added a new scratch.py module to manage temporary directories for dataset caching during tests.
- Updated existing tests to utilize the new scratch directory management to avoid cluttering the repository.
- Ensured that unit tests cover various scenarios including validation failures and context management.
- Updated PrepareAndSplitUnit to require dataset_id and provide task_name.
- Introduced SaveDatasetUnit for persisting datasets to disk.
- Added comprehensive tests for SaveDatasetUnit and LoadDatasetUnit to ensure correct functionality.
- Implemented contract tests for ApplyConverterUnit to validate context handling and converter behavior.
- Enhanced unit contract tests to ensure all context keys are declared and properly managed.
…asetUnit

- Introduced FitConverterUnit to fit a converter on a dataset without transforming it.
- Introduced TransformDatasetUnit to apply an already fitted converter to a dataset.
- Updated ApplyConverterUnit to utilize the new units for fitting and transforming.
- Added ConverterScopeMixin for shared scope resolution logic between converter units.
- Updated initial_components.py to include new units.
- Added tests to ensure correct functionality of the new units and their interactions.
Brings the fourth contract check from feat/atom-expl-predict-explor, where it
was written, so the file is byte-identical on both branches and the two PRs
cannot diverge on it.

The check matters on its own: __call__ demands every key in REQUIRES
unconditionally, so a key that is declared but never read is not harmless
documentation — it rejects any upstream that does not happen to publish it.

It also matters that this file specifically stays in sync. A mismerge here is
the one that fails silently: the audit keeps passing, it just audits less.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Introduced `test_exploration_units.py` to validate the functionality of exploration units, ensuring proper handling of datasets, explorers, and saving results.
- Added `test_prediction_units.py` to test prediction units, focusing on model loading, dataset handling, and prediction saving.
- Enhanced `test_unit_contracts.py` with a new test to ensure units do not require keys they do not read, preventing potential composability issues.
as_shap_predictor handed SHAP a closure over model.predict. The callers
(KernelShap, RegressionKernelShap, ContrastiveShap) first move the
background into the model's feature space with prepare_model_input, so
going through predict ran the model's input preparation a second time
over an already prepared matrix. SHAP then perturbs that matrix into
plain arrays, which the preparation cannot consume at all, and the
failure surfaced far from its cause as

    AttributeError: 'numpy.ndarray' object has no attribute 'types'

The module docstring and a comment above each caller already said the
model had to be queried through predict_prepared; only the call was
left behind.

test_the_wrapped_predictor_never_routes_through_predict pins it: its
stub raises if predict is reached, so a future regression fails at the
wrapper instead of five frames away inside SHAP.
- Introduced new tests for `LoadUploadedDatasetUnit`, `LoadDatafileDatasetUnit`,
  `InferDatasetTypesUnit`, `ApplyDatasetSchemaUnit`, `ComputeDatasetMetadataUnit`,
  and `SaveDatasetToPathUnit` in `test_dataset_ingest_units.py`.
- Updated expected unit schemas in `test_units_api.py` to include new dataset ingestion units.
- Enhanced validation checks and error handling in the dataset processing workflow.
…saving

- Introduced tests for tracking DAG execution in `test_tracking.py`, ensuring proper recording of node runs and artifacts.
- Updated `test_dag_engine_spike.py` to reflect changes in run_id handling, ensuring it is no longer an orphan input.
- Enhanced `test_build_model_unit.py` with tests for handling run_id and model evaluation metrics.
- Added tests in `test_evaluate_model_to_artifact_unit.py` to verify model evaluation and metric publication.
- Implemented validation checks in `test_evaluate_model_unit.py` to ensure run_id presence during evaluation.
- Created `test_save_model_unit.py` to validate model saving behavior and artifact prefix handling.
- Updated `test_fit_model_unit.py` to ensure proper handling of model state and artifact naming conventions.
- Enhanced unit contract tests in `test_unit_contracts.py` to ensure configuration keys are properly declared and classified.
Cross-validation landed on develop as a second abstraction layer for training
(BaseSplitter + BaseEvaluationStrategy) rather than as a branch inside the
monolith, so this merge reconciles two complete implementations of the same
work rather than combining two feature sets.

Six files conflicted. Where a file had been rewritten end to end by one side,
it is taken whole -- resolving those hunk by hunk produced files that belonged
to neither side and did not run:

  model_job.py      develop's, plus the guard for a run id that does not exist.
                    The units rebuild this file in a later slice.
  pipeline_job.py   this branch's; develop still carries the dead subsystem.
  predict_job.py    this branch's. Reverts develop's HTTPException -> JobError
  explainer_job.py  fix, which is restored below, and leaves two develop
                    features out; see the note at the end.
  dataset_job.py    this branch's, plus develop's on_cancel, which merged
                    cleanly on its own.
  initial_components.py   both sides: 330 components, no duplicates.

base_model.py did NOT conflict, and that is the sharp edge of this merge. Both
sides had added a method named compute_metrics to BaseModel -- this branch by
extracting the scoring body out of calculate_metrics so a caller with no Run
row could reuse it, develop by copying that body for the fold loop. They do not
overlap textually, so git merged them into one class with two definitions and
Python kept the second. That silently changed what every caller of
calculate_metrics computes, including dropping the filter for non-finite
scores. Only ruff's F811 noticed.

Resolved deliberately: develop's copy is deleted, this branch's is kept with
its non-finite filter, and develop's fold_index, inner_fold_index and
_epoch_reporter are kept on calculate_metrics. cv.py is adapted to the unified
contract -- compute_metrics returns None when there was nothing to score, which
is not the same as scoring nothing, so a fold with no validation data now
raises instead of contributing to the mean. A test parses BaseModel and asserts
a single definition, so the next merge cannot repeat this quietly.

Alembic had two heads (pipeline run tracking, and the cross-validation and
reports line), which made every test that builds the app fail. Joined by an
empty merge revision: the two sides touch disjoint tables.

Also restored or fixed while verifying:

  - DatasetJob clears file_path when a failure removes the folder it created.
    develop's on_cancel writes the path as soon as the folder exists so a
    cancelled job can clean up; without this the row survives a failure
    pointing at a folder that is gone. folder_is_ours is now bound before the
    try that reads it.
  - PredictJob raises JobError rather than HTTPException on the two branches
    that fail while predicting. An HTTPException stores nothing in args, so
    dill brings it back from the worker without its message. Six pre-flight
    raises still have this shape and are left for the slice that rebuilds this
    job.
  - Three fixtures that build a ModelSession now name their evaluation
    strategy, which is NOT NULL since cross-validation, and their splitter.

Test suite: 2 failed, 3628 passed. The two are named debts, not surprises:
test_cross_validation_run_is_explained_on_its_reserved_rows needs develop's
CV-aware explanation indexes ported into the explanation units, and
test_app_front needs a frontend build that no checkout here has.

New: tests/back/api/test_model_job_cross_validation.py, 34 tests pinning the
observable contract of a cross-validated run -- split shapes, which rows reach
which partition, how many fits happen and on what, the fold metrics and their
aggregation, and the verbatim text of every error branch. Written and verified
against develop untouched, before any of this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PrepareAndSplitUnit carried the partitioning policy inside itself, reached
through prepare_for_model_session, and took its configuration as an untyped
`splits` dictionary -- the kind of field the atomization notes admit only
because there was nothing better to declare. develop meanwhile grew ten
splitters, each a registered component with its own schema, multilingual
labels and compatibility per task. Those are the better thing: the unit now
picks one with a component field, the same way BuildModelUnit picks a model and
FitModelUnit picks an optimizer.

Two units rather than one with a flag, for two reasons that both bite:

  A component field carries a single `parent` and the front reads it directly
  off the property, so a field offering both families would leave the user
  without a selector at all -- the same wall that made the two explainer units
  siblings.

  What comes back has a different *type*. A holdout splitter returns one
  DatasetDict per side; a fold splitter returns a list of them, plus a trailing
  entry that is not a fold. Publishing that list as `x` would give one key two
  shapes, which a contract comparing key names cannot express: a graph would
  validate and then fail at run time, or quietly train on the wrong thing. So
  PrepareAndFoldUnit publishes `x_folds` and `y_folds`.

The two families are told apart with no renaming and no registry change:
component_parent matches any ancestor by name, and the hierarchy already
partitions the ten exactly -- PartitionSplitter covers the two holdout
splitters, FoldSplitter the eight fold ones.

The shared body lives in splitter_scope.py, next to converter_scope.py and for
the same reason: one implementation of resolving the task, preparing the
dataset and selecting the columns, so the siblings cannot drift into two
answers for the same dataset. It takes and returns plain values and never
touches the context -- a ctx.put hidden in a helper is invisible to the audit
that parses each unit's own source, so a broken PROVIDES would pass it.

Two details worth naming:

  BaseSplitter.__init__ takes a single `splits_data` mapping rather than
  keyword arguments, so this is the one component field in the units that is
  not expanded with **params.

  The instance state is declared on each unit and not in the mixin's __init__.
  BaseUnit.__init__ comes first in the MRO and does not chain, so a mixin
  __init__ never runs -- which surfaced as the resolved task being missing
  rather than as anything about construction. ApplyConverterUnit already does
  it this way.

The splitter's own refusal passes through undecorated: it already names the
numbers that explain it, and the caller that knows which run this was frames it
from outside, which is how the message the user reads is built today.

Contract tests build the context by hand rather than going through a job,
including that two of these units in one context do not share a resolved task.
The spike is untouched in substance: it only ever used this unit for static
validation, which reads REQUIRES and PROVIDES and never constructs anything.

969 passed in units, dag, spike and api; the one failure is the CV-aware
explanation indexes still to be ported, which is a later slice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…of it

Two changes, both policy rather than shape, and both measured by the
cross-validation net before they were made.

BuildModelUnit no longer takes the data. ModelFactory attached the splits to
the model instance at construction, which worked only because a model was
fitted once on one split. Fitted over folds it sees different data on every
iteration, so binding one partition at build time would leave the metrics
describing whichever fold happened to be built with. The unit now needs only
the label count -- a property of the dataset, not of a split -- and whoever
fits the model points it at what it is being fitted on. REQUIRES loses `x` and
`y`, which is a relaxation: every graph that fed it still validates, with two
fewer wires. The measurement in the graph test moves from fifteen to thirteen
and says why.

FitModelUnit gained `validation_during_fit`. It handed the validation partition
to `train` unconditionally, and both halves of that are wrong for folds:

  A model uses validation data to watch the fit and stop early, which is what
  an ordinary holdout run wants and exactly what a fold does not -- a fold is
  scored on the rows it held back, so letting the fit watch them measures it on
  data it was allowed to see. Nothing raises; the score just comes out better
  than the model deserves. The net recorded four fits in a cross-validated run
  and none of them receiving validation data, which is the behaviour this field
  now expresses.

  The trailing entry a fold splitter produces has no validation partition at
  all -- it holds the pooled rows and the reserved ones -- so reading
  x["validation"] is a plain KeyError on the very partition set that fits the
  model which gets kept.

Read with `.get` and the schema's own placeholder, the way the other units read
a declared optional field: a caller that builds this unit by hand should not
have to name a policy it is happy to leave alone.

Also moved the runs directory out of the top of execute and into the branch
that needs it. It is only used to name the plots a search produces, so a fit
without a search had been requiring a service of its caller for nothing --
which is what made these tests need a container before they could watch a fit.

973 passed across units, dag, spike and api; the one failure is the CV-aware
explanation indexes still to be ported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BaseOptimizer.optimize took the task as its sixth argument and did the fitting
and the scoring inline. develop replaced that argument with a callable, because
cross-validation needs a trial to mean k fits rather than one, and the
optimizer has no business knowing which. FitModelUnit was still passing the
task into that position -- a silent mismatch, since a task is not callable, so
it would have surfaced from inside a trial rather than from the call.

The objective is now built by the unit that fits: one fit of the training
partition and one score of the validation partition. That is what makes the
same search reusable over anything that can be fitted and scored, which is the
whole point of the inversion -- the fold sibling will hand it a loop instead,
and nothing in the optimizer changes.

The trial metrics move with it, and that is the part worth noticing. They were
written by the optimizer, which meant the search decided what counted as a
scored partition. It is a property of the thing being fitted: a partition with
no metrics configured writes nothing, because calculate_metrics finds nothing
to score and returns. So the objective writes them.

`task` leaves FitModelUnit.REQUIRES, since nothing reads it there any more --
the audit would have caught it otherwise. It is still produced and still
consumed, by the local explanation unit. The graph measurement drops from
thirteen wires to twelve and says which one went and why.

Covered directly rather than through a job: the graph test's model declares no
optimizable parameters, so the search branch never runs there, and the
orchestration net exercises develop's ModelJob rather than these units. Three
tests pin what the objective computes, what it logs, and -- separately -- that
it is what reaches the optimizer, because passing the wrong sixth argument is
invisible until something calls it.

976 passed across units, dag, spike and api; the one failure is the CV-aware
explanation indexes still to be ported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ExplainerJob read train_indexes, test_indexes and val_indexes straight off
Run.split_indexes. That is the shape a holdout run stores. A cross-validated
one stores an entry per fold plus the pooled rows and the reserved ones, so
explaining such a run raised KeyError inside the wrapper that reports a
preparation failure -- the user was told the dataset could not be prepared,
which is true and is not the reason.

develop had already built what this needs: explainable_indexes asks the
splitter that produced the run which partitions it has and what they are
called, and maps whichever answer it gives onto the three slots the explainers
are built from. A splitter added later needs no change here, and a fold run is
explained on the rows no fold ever saw.

Resolved in the job rather than in the unit. Unpacking the JSON column of a row
is an artifact of how the column is stored rather than part of the
transformation, and deciding which splitter wrote it is the same kind of
unpacking. The unit's contract does not change: it still requires
split_indexes, and still gets the three lists it always did.

A payload that does not match its splitter now says so instead of reaching the
generic wrapper. The old message read as a problem with the dataset rather than
with the run's own record of how it was split, so the test that pinned it is
updated with the reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SaveModelUnit called model.save straight at the destination. A save that died
partway left a truncated artifact there, and the row went on pointing at it as
if it were a model -- the failure is only visible later, when something tries
to load it.

develop had already fixed this in the job it kept, with atomic_save_path: the
model is handed a temporary sibling path, and what it leaves there is moved
into place once it returns. The temporary path is handed over rather than
derived here because only the model knows whether it writes a single file or a
directory of weights.

Two consequences worth having in the tests. The model no longer sees the final
path, so what is asserted is where the artifact ended up rather than what the
model was told -- which is what the caller and the row care about anyway. And
a double that recorded a path without writing anything now fails, correctly:
leaving nothing to move is the same thing a model that silently saved nothing
would do, and it should be reported rather than hidden.

978 passed across units, dag, spike and api.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ModelJob had its own copy of everything before the model is fitted: loading the
dataset, resolving the task, validating the dataset against it, counting the
labels, separating features from targets, resolving the metrics and the model
class, checking the downloads, and calling the splitter. The units had the same
steps. That was most of the duplication this reconciliation exists to remove,
and it goes in one piece rather than one path at a time -- holdout and
cross-validation differ only in which unit prepares the data.

_prepare_dataset_and_components now does what only it can: read the rows, unpack
the JSON columns stored on them, and choose which unit prepares the data. That
choice follows from how the splitter carves the dataset, which the splitter
declares -- it is a choice of unit rather than a flag on one, because the two
publish different shapes. The file loses eighty-four lines.

The evaluation strategy is built in run() now rather than in the helper,
because it takes the factory the build unit produced.

Two orderings are deliberate and were not obvious:

  BuildModelUnit.validate runs before the data is partitioned, and the unit
  itself after. The download gate lives in validate, and a model that cannot be
  trained should be reported as that rather than surfacing later as a splitting
  failure -- the same reason ModelJob has always resolved the optimizer before
  changing the run's status.

  The splitter class is resolved in the helper and again inside the unit. The
  helper needs it to know which unit to build, and the message a missing one
  produces belongs to the preparation step. The unit resolves its own because
  it must work for a caller that is not this job.

Both regression nets pass unchanged -- 46 tests, including the verbatim text of
every error branch, which is what says the messages did not drift. 978 across
units, dag, spike and api.

The evaluation strategy still owns the training loop. That is the next piece.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HoldoutEvaluationStrategy.execute was FitModelUnit, EvaluateModelUnit and
SaveModelUnit in sequence, written a second time. ModelJob now composes those
three for a holdout run, and the strategy is no longer called for one. Fold
runs still train through it; their loop is the next piece.

Saving moved for both paths at once. The strategy hands the model back rather
than leaving it in the context, so the job puts it there and one SaveModelUnit
serves whichever path produced it -- which also gets the fold path the atomic
replacement it did not have separately.

FitModelUnit gained trial_splits, which is the SCORED_SPLITS question answered
for the search. Which partitions a run records a score for is declared by the
strategy the session chose, and it is not the same as which ones have metrics
configured: a forecaster has training metrics and still must not be judged on
the dates it was fitted on, because an in-sample fit statistic is not
comparable with a forecast. The job reads that declaration and passes it on, so
the strategy classes keep deciding it while the units do the work.

The test partition is deliberately not an option in that field. Scoring it once
per trial would let the search see it, and a model chosen with the test set in
view has no honest score left to report -- so a trial may score the partition
it fitted on and the one it is measured against, and nothing else. It was a
default before, which is a weaker statement than a value that cannot be chosen.

The runs directory leaves the job: naming the artifact is the saving unit's, and
the job had been resolving it only to build a path the unit builds itself.

Both nets pass unchanged, 46 tests. 992 across units, dag, spike, api and
evaluation -- the strategies' own tests included, since the classes still stand
and are still what declares the scored partitions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cross-validation sibling needs the same optimizer resolution, the same
search, the same check that the optimizer gave back the model it was handed,
the same recording of the best parameters and the same plot writing. Copying
that is how the two implementations this branch is removing came to exist, so
it moves to fit_scope.py first and the sibling is written against it.

The contract audit caught a real mistake in the first attempt, and it is worth
recording because the rule reads like tidiness until it bites. The helper took
the context and did its own require and put. The audit parses each unit's own
source, so moving the reads out of the unit made four declared keys look
unread: FitModelUnit was suddenly requiring `factory` and `model_parameters`
and declaring `run_id` and `artifact_prefix` while appearing to touch none of
them. A caller reading only the declarations -- the graph validator among them
-- would have been told the truth by the declarations and contradicted by the
audit, or worse, the declarations would have been trimmed to match.

So the helper takes and returns plain values and never touches the context.
Every require and put stays in the unit, and so does reading the runtime
parameters. It is the mirror of the rule already written down for a helper that
publishes: a ctx.put hidden in one makes a broken PROVIDES pass.

992 passed across units, dag, spike, api and evaluation. No behaviour changed:
this is the same fit, moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FitModelOverFoldsUnit is FitModelUnit's sibling: it takes a list of partition
sets instead of one, which is a different REQUIRES and so a different unit.
Everything around the fit is the shared mixin; what is written here is the
objective the search measures -- the whole fold loop, so one trial costs k fits
-- and what happens once the search is over.

ModelJob composes it for a fold run that is not nested. Nested cross-validation
still trains through the strategy: its inner splitter is a required component
field, and a component field cannot be made optional without leaving the user
without a selector, so it is a further sibling rather than a flag on this one.

Three decisions worth naming.

The per-fold scores are published rather than aggregated in the unit. A summary
row carries a standard deviation, and a unit may not write domain rows -- the
one sanctioned write in the domain layer has nowhere to put one. So the unit
hands the numbers over and the job does the arithmetic and the writing, where
every other row it persists is written. A single fold gets a deviation of zero
rather than none, because none is what the reserved-rows measurement carries
and the two say different things.

A trial records one row per split holding the mean over its folds, not one per
fold: the folds of a trial measure a hyperparameter setting rather than the
model that gets kept, and recording each would bury the rows that describe it.
That write is guarded on the run, which _save_metrics does not guard for
itself -- a caller with no run would write rows against a foreign key pointing
at nothing, and they insert without complaint because nothing enforces it.

Scoring the reserved rows is not the unit's. It is an ordinary LAST metric, so
it is EvaluateModelUnit, the same one a holdout run uses, and whether there is
anything to score is the caller's to know: a session that reserved nothing
leaves that partition empty rather than absent.

calculate_metrics now returns what it wrote, so a caller that wants both the
row and the number scores the split once instead of twice.

The assertion that the optimizer gave back the model it was handed caught a
real gap while this was written: with the data attached at fit time rather than
at build time, nothing had pointed the model at anything during a fold search.
It does now, per fold, the same as the scoring loop.

1001 passed across units, dag, spike, api and evaluation; both nets unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tegy

FitModelOverNestedFoldsUnit is FitModelOverFoldsUnit plus one measurement taken
before it. Inheritance rather than a shared mixin because that is the actual
relationship: everything the sibling does still happens, and this adds a step
in front. Two units and not one with a flag because its inner splitter is a
required component field, and a component field cannot be made optional -- the
front reads `parent` straight off the property, and an anyOf buries it where it
does not look.

What the nested loop is for, since the code alone does not say it: in an
ordinary cross-validated search the same folds choose the hyperparameters and
report the score, so the score is optimistic by however much the search managed
to fit them. The nested loop measures that honestly -- for each outer fold a
search runs on folds carved out of that fold's training rows alone, and what it
chooses is scored on the outer fold's validation rows, which it never saw.

What it does not do is choose the hyperparameters: each outer fold picks its
own and they generally differ, so there is no single model to keep out of that
loop. The ordinary search still runs afterwards and produces the model that
gets saved. The nested numbers describe the procedure, not the artifact, which
is why they are kept at their own level -- LAST_OUTER against LAST -- and why
the inner trials record nothing at all.

The two fold branches in the job became one, choosing a unit rather than
repeating a body.

**The evaluation strategies are no longer called.** The job reads SCORED_SPLITS
and KIND off the class it resolves and never touches execute() on any path. The
classes are now what they always were underneath -- a declaration of how a run
is carved and what it records -- and emptying them of the code that is now
unreachable is the last piece.

1014 passed across units, dag, spike, api and evaluation, both nets unchanged,
plus thirteen contract tests for the fold unit built on a hand-made context:
the end-to-end net runs it inside a real job, which cannot show what it reads,
promises and refuses on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They ran the training: execute took the run row and the database session and
did the fitting, the search, the scoring, the aggregation and the persistence
behind one method. Every piece of that is now a unit, and nothing has called
execute since the fold paths moved -- the job reads SCORED_SPLITS and KIND off
the class it resolves and never touches it otherwise.

So the code goes. base_evaluation_strategy, cv and holdout drop from 881 lines
to 153, and what is left is what was underneath all along: how a run is carved,
and which partitions it records a score for.

They stay registered. That is not deference to dead code -- the frontend reads
these classes in four places, and only one is about metrics. The session wizard
lists them so the user can choose one, and ModelSession.evaluation_strategy is
NOT NULL, so without that listing a session cannot be created at all. It starts
on the first one whose kind is holdout. `kind` decides the shape of the splits
payload and which controls are shown. Only `scored_splits` is about the charts.
Removing the classes would not have cost two screens; it would have cost the
way sessions are made.

There is precedent for a class here that declares and does not execute:
BaseSplitter.PARTITIONING and explainable_partitions are read exactly this way,
by the backend and by the frontend, and nothing calls them to do work.

Five of the forecasting tests exercised behaviour rather than declarations --
the final fit, and which partitions a trial scores. That behaviour moved rather
than disappeared, so they are pointed at the units that carry it out now. They
stay in the same file, next to the declarations, because that is the pair that
has to stay consistent: a strategy that says it does not score the training
partition, and a fit that then does not.

1014 passed across units, dag, spike, api and evaluation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two optimizer test files built the objective they measure by reaching for
HoldoutEvaluationStrategy.evaluate. That method is gone: the strategies declare
how a run is evaluated and the fitting unit carries it out, so the objective
comes from there now. The tests themselves are unchanged -- they still check
that a bad trial is pruned, that disabling the pruner completes every trial,
and that a real model reports each epoch to its trial.

Which partitions a trial records is still read off the strategy class, the same
way the job reads it, so the declaration stays connected to what it produces.

These four failures were not caught earlier because the verification runs had
been narrowed to the directories this work was touching -- units, dag, spike,
api and evaluation -- after the full suite was dropped for containing a test
that builds the app against the real ~/.DashAI. Deselecting that one test was
the right answer; shrinking the suite to what seemed relevant was not, and it
is precisely the change that removes a caller elsewhere that this hides.

Whole suite: 3682 passed, one test deselected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five real findings, one of them hiding the others.

**A search needs a tuner, not only a target.** Both evaluation strategies
guarded on `self.optimizer and self.run_optimizable_parameters`; the units kept
only the second half. `Run.optimizer_name` is a plain string and the wizard
leaves it empty when no search is asked for, while the model may still declare
a parameter optimizable -- a combination that has always meant "fit it once
with the values given". It had become a lookup of the empty string in the
registry, surfacing as "Metric is not compatible with the Task. ''", a message
with nothing to do with what happened. Reproduced, fixed with a shared
`_will_search`, and pinned by a test.

**The nested unit was not being audited at all.** `_unit_class` matched only
classes whose direct base is literally `BaseUnit`, so a unit that extends
another unit fell out of every contract check -- and would have failed them,
because its PROVIDES are written by the parent's body rather than its own. It
is the same blindness a shared helper causes, arriving by inheritance instead:
the audit reads one class's source. It now follows the lineage for
declarations, context calls and config reads. 32 audited units became 33.

**The inner splitter was resolved unconditionally**, so a run still carrying a
nested configuration it no longer uses failed on a splitter it would never have
touched.

**The fold branch hardcoded `splits=["TEST"]`** where the holdout branch derives
it from SCORED_SPLITS. Latent today -- no strategy excludes TEST -- but it is
exactly the coupling this work exists to remove.

And a docstring describing `{split: [scores]}` for something shaped
`{split: {metric: [scores]}}`.

Two findings were left alone, deliberately. `best_parameters` is published
without being in PROVIDES, which is the already-declared limitation that there
is no way to express an optional output; the new units repeat it rather than
inventing an exception to it. And per-fold progress reporting is gone, which is
a real regression: restoring it needs a callback in a unit's contract, and a
runtime parameter the engine cannot supply makes the unit unusable as a node --
the static validator rejects it. Both are recorded rather than patched over.

Whole suite: 3692 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 11, 2026 02:44

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 marked this pull request as draft September 11, 2026 02:44
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