Skip to content

Python Models

Back to Design decisions

How the Pydantic models of the Python package read their fields, check their values and compare.

Public models forbid unknown fields

Every public Pydantic class sets model_config = ConfigDict(extra="forbid"). An unknown field, such as a misspelt or an old name (window_steps), raises a ValidationError that names it, instead of being dropped with no error.

Guard: tests/building/test_unknown_fields.py.

A value of a model has its rule in the engine

An initial amount, the fractions of a stratification, an impulse and the points of a series have their rule once, in crates/commol-model/src/rules.rs (check_bin_amount, check_compartment_amount, check_fractions, check_impulse, check_series). The model compiler calls them when it compiles a model, set_initial_condition when a calibration writes an amount, and the Python object of the value when it is built: the validators of InitialConditions, Transition and SeriesValue call the bindings of commol_rs.model. A value outside its rule fails at the line that sets it, with the message of the engine. A range is a Real of commol-core after the owner of the value (Bin 'B': initial amount is a finite number of at least 0, got -1); a rule that is not a range, such as one point per position or fractions that sum to 1, has one message in the same module. update_stratification_fractions gives the one category left unnamed the rest of 1 and checks the result with check_fractions; the rule that at most one category is left unnamed stays in Python, because it is a rule of the update, not of a value, and the engine never gets an update.

Alternative Why not
A check in Python and one in the compiler Two messages for one rule, and rules that only one side has, such as the series without a point.
A check only when the model compiles The error comes far from the line that set the value.
A row of RANGES for the object (RangeChecked) A rule of RANGES has a fixed name, so the message cannot name the bin or the transition, and (period, offset) has two rules.

Rule: a new value of a model gets its rule in rules.rs, which the compiler and the validator of its Python object call.

Guards: tests/building/test_value_rules.py, crates/commol-model/tests/value_rules.rs.

Results compare by their values

Every result of commol compares with one rule, ComparedByValue (py-commol/commol/context/result_model.py). Its subclass ResultModel is the base of every result model: the result of a calibration (CalibrationResult, CalibrationHistory), of each uncertainty, identifiability and sensitivity method, the parameter sample, the bands of a predictive, the summary of a scenario, a score, and their parts (StartSummary, SeriesIntervals, …); it compares its public fields. The results of a run and of replicates (SimulationResults, a dict, and SimulationReplicates, a list, in py-commol/commol/api/results.py) compare their values or runs, positions, seed and model. Two results are equal when they are of one class and those values are the same. The comparison reads canonical, the values as nested tuples, also of a setting or a Model inside a result, such as the Threshold of a ProfileResult:

Value Rule
NaN Equal to every NaN, whatever the object
0.0 and -0.0 Equal, with one hash, as == of floats
A list or a tuple Item by item, in order; a list is not equal to a tuple
A dict Its items, in any key order
A private attribute, such as the Rust sample of ParameterSample Not compared

An object that is not a result, such as a plain dict or list with the same items, is not equal to one: __eq__ gives False, not NotImplemented, so dict.__eq__ does not answer by the values, and __ne__ is its negation. A frozen result (frozen=True) has the hash of canonical, so equal results have equal hashes. A result that can change (SimulationResults, SimulationReplicates, CalibrationResult, CalibrationHistory) has no hash. A setting keeps the == of Pydantic.

Alternative Why not
The == of Pydantic A list compares the identity of its items first, and a NaN is not equal to itself, so two runs with one seed differ when they hold a NaN: the engine gives new objects. It also compares the private attributes.
No == on results A caller could not check that a seed repeats a result.
Comparing model_dump_json() Each caller must know it, and a string is built for each comparison.
An __eq__ per result Rules that drift apart from one result to the next.
The == of dict and list for runs and replicates, with NaN equal to NaN Two rules, and runs at other positions or with other seeds and the same values stay equal.

Rule: a new result model has ResultModel among its bases, and a new method returns one.

Guards: tests/uncertainty/test_results_equality.py, tests/uncertainty/test_results_equality.py::test_every_method_of_commol_returns_a_result_model and tests/simulation/test_results_compare.py.