Normalize legacy simulator results¶
Use normalize_simulator_result when a consumer needs an opt-in structured view of an
existing rts/choices result. The adapter returns a shallow copy, retains every legacy
key, and does not change registered simulators or their default outputs.
The caller supplies the original sample and trial counts, ordered observation schema, exact source-to-field projection, and projected source that authoritatively records omissions. The adapter never infers these from values, names, or model metadata.
Project RT and choice¶
Projection pairs use (legacy source key, schema field name) order and must cover the
schema exactly once in schema order. Deadline and RT-based RL simulators use
omission_source="rts", because an omitted RT can retain a sampled or placeholder choice.
import numpy as np
from ssms.basic_simulators import OMISSION_SENTINEL, normalize_simulator_result
boundary = np.linspace(1.0, 0.0, 100)
legacy_result = {
# n_samples=2 and n_trials=1 historically produces shape (2, 1).
"rts": np.array([[0.42], [OMISSION_SENTINEL]], dtype=np.float32),
# Deadline models retain a sampled/placeholder choice on an omitted RT row.
"choices": np.array([[1.0], [-1.0]], dtype=np.float32),
"metadata": {
"simulator": "ddm",
"possible_choices": (-1, 1),
"boundary": boundary,
},
}
validated_result = normalize_simulator_result(
legacy_result,
expected_n_samples=2,
expected_n_trials=1,
observation_schema=(
{
"name": "rt",
"kind": "continuous",
"lower": 0.0,
"lower_inclusive": False,
},
{
"name": "response",
"kind": "categorical",
"values": (-1, 1),
},
),
source_projection=(("rts", "rt"), ("choices", "response")),
omission_source="rts",
)
assert validated_result["observations"].shape == (2, 1, 2)
assert validated_result["omission_mask"].tolist() == [[False], [True]]
assert np.isnan(validated_result["observations"][1, 0]).all()
assert validated_result["rts"] is legacy_result["rts"]
assert validated_result["metadata"]["boundary"] is boundary
assert legacy_result["rts"][1, 0] == OMISSION_SENTINEL
assert legacy_result["choices"][1, 0] == -1.0
The expected counts reverse only the package's historical singleton squeezing:
| Samples | Trials | Required legacy shape | Canonical shape |
|---|---|---|---|
| 1 | 1 | (1, 1) |
(1, 1, obs_dim) |
| many | 1 | (n_samples, 1) |
(n_samples, 1, obs_dim) |
| 1 | many | (n_trials, 1) |
(1, n_trials, obs_dim) |
| many | many | (n_samples, n_trials, 1) |
(n_samples, n_trials, obs_dim) |
An equal-size array with another shape is rejected. This keeps (N, 1) deterministic:
the caller states whether N means samples or trials.
Project a response-only result¶
For a choice-only RLSSM, map only choices and set omission_source="choices". Its
compatibility rts=-1 array remains available but is neither projected nor interpreted.
import numpy as np
from ssms.basic_simulators import OMISSION_SENTINEL, normalize_simulator_result
legacy_result = {
# n_samples=1 and n_trials=3 historically produces shape (3, 1).
"rts": np.full((3, 1), -1.0, dtype=np.float32),
"choices": np.array([[0], [1], [OMISSION_SENTINEL]], dtype=np.int16),
"metadata": {
"simulator": "choice_only_rlssm",
"possible_choices": (0, 1, 2),
"placeholder_rt": -1.0,
},
}
validated_result = normalize_simulator_result(
legacy_result,
expected_n_samples=1,
expected_n_trials=3,
observation_schema=(
{
"name": "response",
"kind": "categorical",
"values": (0, 1, 2),
},
),
source_projection=(("choices", "response"),),
omission_source="choices",
)
assert validated_result["observations"].shape == (1, 3, 1)
assert validated_result["observations"].dtype == np.float64
assert validated_result["omission_mask"].tolist() == [[False, False, True]]
assert np.all(validated_result["rts"] == -1.0)
assert validated_result["rts"] is legacy_result["rts"]
An integer-only response is promoted to float64 so the canonical array can contain NaN
omissions; a floating response keeps its dtype. For two sources, NumPy selects their
combined floating dtype. Categorical labels and source values must remain exactly
representable after that promotion.
Handle omissions and metadata deliberately¶
omission_source is required and must name a projected source. Its sentinel marks the
whole canonical row omitted, replaces that row with NaNs, and makes auxiliary projected
values irrelevant. This covers both deadline output with a valid retained choice and
legacy output where every projected source contains the sentinel.
A sentinel in an auxiliary source while the authority is available is contradictory and raises an error. Unprojected sources never participate, so response-only normalization ignores a dummy RT even if it contains the sentinel.
The result and metadata are shallow-copied; legacy arrays and producer-owned values such
as boundaries and trajectories retain identity. The adapter adds observations and
omission_mask to the result, and adds only observation_schema_version and
observation_schema to metadata. Identical reserved metadata is retained, conflicts are
rejected, and existing canonical result keys are never overwritten.
Respect the compatibility boundary¶
The legacy adapter accepts one or two scalar fields. Wider new producers should emit the
native fixed-width contract and call validate_observation_result directly; see the
executable native examples
and basic simulator API.
Normalization is additive and opt-in. It does not migrate dataset generation;
KDE/LAN/CPN/OPN paths; RL trial extraction or panel assembly; HSSM simulate_data;
ssms-gui; or external consumers of the existing keys and squeezed shapes. Such consumers
can construct a normalized view alongside the untouched legacy result when they
explicitly adopt the structured contract. Contributors should also review the
integration guidance.