Skip to content

Carry ndt_edge_shift through the RLSSM decision-process spec - #1358

Merged
AlexanderFengler merged 17 commits into
mainfrom
feat/rlssm-ndt-edge-shift
Oct 6, 2026
Merged

AlexanderFengler merged 17 commits into
mainfrom
feat/rlssm-ndt-edge-shift

Conversation

@AlexanderFengler

@AlexanderFengler AlexanderFengler commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Stacked on #1292: this branch contains its commits and shrinks to its own six once #1292 is merged. Review the rl commits only.

Purpose. #1292 lets a likelihood config declare which parameter shifts the lower edge of the response-time support ("ndt_edge_shift": {"param": ..., "scale": ...}, edge at t − scale·param). The RL layer built its distribution without that declaration, so an RLSSM whose decision process has non-decision-time variability would have floored its likelihood at t. No bundled RL backbone has such a parameter today; this closes the gap before ddm_st / ddm_normal_st (#1346, #1345) exist.

Design. The declaration is a property of the decision process, so it travels with the decision-process spec and the RL layer only forwards it:

  • _build_ssm_spec_from_modelconfig copies the backbone's approx_differentiable declaration into the spec; register_ssm(..., ndt_edge_shift=None) accepts one for custom SSMs and validates it against list_params_ssm.
  • get_rlssm_model_config passes it to RLSSMConfig, and raises a clear ValueError when the named parameter (or t) is computed per trial by the learning process, since the support edge cannot be resolved from a parameter the distribution never sees.
  • RLSSMConfig gains the field (from_rlssm_dict reads the optional key; from_ssms_model takes it from the decision-process spec; a ModelConfig decision process hands over its own); validate() applies the same rules as Config, via a shared helper moved out of Config.validate (behaviour unchanged, tests/test_config.py untouched).
  • RLSSM._make_model_distribution forwards it to make_distribution.
  • aDDM is untouched (no ndt variability, separate config class).

Tests. 22 new cases in tests/rl/: the spec carries the backbone's declaration (and None without one); register_ssm stores it and rejects bad declarations; get_rlssm_model_config propagates it and rejects a computed parameter; RLSSMConfig validation, dict round trip, from_ssms_model, ModelConfig inheritance and explicit-override precedence; RLSSM forwards it to make_distribution; and end to end, the compiled RL log-likelihood is exactly LOGP_LB for rt ≤ t − st and finite above with a declared {"param": "st", "scale": 1.0}. Each test fails with its source change reverted.

Checks run. tests/rl (244 passed, 4 skipped), tests/test_config.py, tests/distribution_utils (slow included), fast suite (1274 passed); ruff check, ruff format --check, mypy src/hssm clean at every commit; pyrefly shows only main's two pre-existing errors. In-memory merges: clean against main and against #1293 on top of main + this head; no conflicts beyond those #1292 already has with #1344/#1345/#1346.

Not in this PR. Changelog entry (written on main after the merge wave, with #1292's); any ssm-simulators-level declaration of the support edge (under discussion separately).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added configurable support-edge adjustments for response-time likelihoods. Models can specify a parameter and scale to set the edge below non-decision time; responses at or below the edge receive the likelihood floor, while missing responses remain unaffected.
    • Applied the adjustment to the full-DDM model and made it available for registered models and reinforcement-learning models.
  • Documentation
    • Clarified how support-edge adjustments work and how the full-DDM model’s non-decision-time parameter affects its response-time support.

EItanm1999 and others added 17 commits September 22, 2026 15:13
ensure_positive_ndt floors the log-likelihood wherever rt - t <= 1e-15. That is
the correct support edge for a fixed non-decision time, but not for the LAN
`*_st` models, which follow the half-width st convention of ssms: there t is
drawn per trial from Uniform(t - st, t + st), so the fastest admissible response
time is t - st and the band [t - st, t] carries real density. The guard replaced
that entire band with LOGP_LB regardless of what the likelihood returned.
Applied unconditionally at both call sites, affecting every LAN `*_st` model.

Measured on ddm_st: compiling HSSM's observed-RV logp for a model whose
likelihood IS the exact ddm_st quadrature and comparing against the same
quadrature called directly gave differences of -5.3 to -12586.8 nats, varying
with theta. With the guard skipped the two agree to exactly 0.0000 on 7 of 8
parameter vectors (the 8th had a subject z outside its bound). So this guard
accounts for the entire discrepancy while parameters stay in bounds.

p_outlier partially masks it: the floored value is wrapped in the lapse mixture,
so affected trials emerge at log(0.05/20) = -5.99 rather than at LOGP_LB, which
is why this presented as bad geometry rather than an obvious -inf.

The t - st edge is stated as correct under the half-width convention of
ssms/cssm, which every LAN *_st model follows, rather than as a universal law.
full_ddm is the one bundled model on the other convention: its only likelihood
is the blackbox wrapper around hddm_wfpt, which reads st as a full width and
puts its own edge at t - st/2. No separate factor is needed, because that edge
sits above t - st and hddm_wfpt already returns zero density across
[t - st, t - st/2), which the wrapper maps to the same lower bound. Verified:
across a 0.28-0.46 RT scan the raw likelihood's highest floored rt is exactly
t - st/2, and the guard changes no value at any rt (max |delta| 0.0).

Only st moves the response-time support edge. sz (starting point) and sv (drift)
do not, and are deliberately not consulted; a regression test pins that down.

Tests are one parametrized case per convention (fixed t, st moves the edge,
sz/sv leave it) over an explicit RT vector that straddles all three edges, so
the in-band assertion no longer depends on an unseeded draw. They compare
against the imported LOGP_LB rather than a -66.1 literal: under
PYTENSOR_FLAGS=floatX=float32 the bound is -66.0999984741211, and wrapping it in
np.array defeats NumPy's weak promotion, so the literal form failed there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Unreleased entries for this stack are written on main once the PRs are
merged, so the PR carries no docs/changelog.md change. The entry also said
full_ddm is "unaffected in value", while full_ddm is the registered model
whose log-likelihood this fix changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
full_ddm is the one registered model with st, so it is the model whose
values this fix changes: hddm_wfpt reads st as the full width and has real
density on (t - st / 2, t], which the old rt <= t guard replaced with
LOGP_LB. The existing st tests use a fake likelihood and are slow-marked,
so nothing in PR CI covered the registered model.

Compares the blackbox distribution with the raw hddm_wfpt wrapper on a
response-time grid from below t - st to above t. With the old guard 8 of
the 21 values differ, by up to 63.7 nats.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A likelihood whose support starts below the non-decision time can now
say so with an ``ndt_edge_shift`` entry, ``{"param": <name>, "scale":
<float>}``, meaning the edge sits at ``t - scale * <param>``. The key is
read from a registry entry through ``Config.from_defaults`` and from a
user ``ModelConfig`` through ``update_config`` (the user's wins, like rv
and backend); ``validate`` rejects a parameter missing from
``list_params``, a model without ``t``, and a scale that is not a finite
non-negative number. Nothing reads the field yet.

Inferring the shift from a parameter name is not an option: "st" is a
half-width for the ssms LAN models, the full width for full_ddm's
hddm_wfpt likelihood and a standard deviation for a Normal kernel, so
the likelihood has to declare its own edge (#1292).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
hddm_wfpt reads st as the full width of the non-decision-time window,
so its likelihood assigns real density down to t - st/2. Declaring the
shift on the blackbox entry lets the guard floor only below that edge
instead of at t, once it reads the declaration (#1292).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ensure_positive_ndt becomes a pure floor: it takes the lower edge of the
response-time support and knows no parameter names. A new resolver,
_ndt_lower_edge, is the one place that reads the "t" convention and a
likelihood's ndt_edge_shift declaration, returning None (no floor) when
there is no t, t when nothing is declared, and t - scale * param
otherwise. make_distribution takes the declaration and applies the
resolved edge at both call sites, lapse and non-lapse.

This removes the "st" inference introduced earlier on this branch: a
parameter called st shifts nothing unless a config declares it, so
models without a declaration floor at t exactly as on main. The two
existing st tests are adapted to the new contract; the full_ddm
regression test stays red until the declaration is forwarded (#1292).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
HSSM._make_model_distribution and make_distribution_for_supported_model
now pass the config's declaration through, so a registry entry, a user
model_config override and the supported-model helper all reach the
guard. This is what makes full_ddm's declaration effective: its
log-likelihood again equals raw logp_full_ddm everywhere, and the
regression test pinned on this branch goes green (#1292).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Covers the resolver (no t, t only, a declared parameter whether or not
it is called st, trial-wise values), the guard with an explicit edge,
make_distribution at both the lapse and non-lapse call sites, and the
three routes a declaration takes to the compiled logp: a register_model
entry, a user model_config override and
make_distribution_for_supported_model. Each wiring test uses a flat
stand-in likelihood on a fixed grid, where t = 0.5 and st = 0.1 under
scale 1.0 versus 3.0 floor visibly different sets. Config tests pin the
registry read (full_ddm carries its declaration as a deep copy), the
override precedence and the three validation rules, with numpy scalars
accepted as scales and bool refused (#1292).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
register_model's likelihoods entry and the HSSM model_config field list
now say what the key declares and which direction the override goes
(#1292).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A declaration missing "param" or "scale" (or not a dict at all) raised a
bare KeyError/TypeError from Config.validate; it now raises a ValueError
that names the key and the expected shape, like the other three rules.
Refs #1292.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The regression test's docstring still named the t - st edge of the
earlier name-keyed version; the declared edge is t - st / 2. The
logp_full_ddm docstring now states the fact behind that scale (hddm_wfpt
reads st as the full width), and the module docstring lists full_ddm.
Refs #1292.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The checks Config.validate ran on a likelihood's ndt_edge_shift
declaration move to a module-level helper so the RL decision-process
registry can apply the same rules, with the same messages, to the
declaration it carries. Config's behaviour is unchanged.
Refs #1292.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The declaration that #1292 added to Config had no place in the RL
config, so an RLSSM could not floor its log-likelihood at the edge its
decision process declares. RLSSMConfig gains the field with the same
meaning as Config.ndt_edge_shift, from_rlssm_dict reads it as an
optional key, and validate() applies the shared checks against the
sampled parameters.
Refs #1292.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The support-edge declaration is a property of the SSM backbone, so it
travels with the decision-process spec: _build_ssm_spec_from_modelconfig
copies it from the backbone's approx_differentiable entry, register_ssm
accepts it (validated with the shared helper against the SSM's own
parameters) and stores it, and get_rlssm_model_config and
from_ssms_model hand it to RLSSMConfig. A declaration whose "t" or named
parameter is computed by the learning process is rejected with an error
that names the decision process and the parameter, since a per-trial
computed parameter is not among the distribution's parameters and the
edge cannot be resolved from it.
Refs #1292.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The RL distribution was built without the declaration, so the guard
kept flooring every response time at or below t even when the decision
process declares an earlier edge. The config's value now reaches
make_distribution like the other RL-side arguments.
Refs #1292.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Covers each hop of the declaration: the spec built from a backbone's
modelconfig carries it (and None without one), register_ssm stores a
copy and applies the shared rules, get_rlssm_model_config hands it to
RLSSMConfig and rejects a declaration that leans on a parameter the
learning process computes, RLSSMConfig round-trips it through
from_rlssm_dict, validates it and reads it from the decision-process
spec in from_ssms_model, and RLSSM forwards it to make_distribution.
One end-to-end test evaluates the compiled logp on a small balanced
panel and checks the floor sits at t - st rather than at t.
Refs #1292.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
RLSSMConfig accepts a ModelConfig as decision_process; that object can
carry its own declaration, so the RLSSM config inherits it when none is
given explicitly, the same way the string and dict routes read it from
the decision-process spec.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: eba11853-4e5f-4b93-b535-c0775ca78680
📥 Commits

Reviewing files that changed from the base of the PR and between cd26b0d and 406bf33.

📒 Files selected for processing (15)
  • src/hssm/_types.py
  • src/hssm/config.py
  • src/hssm/distribution_utils/dist.py
  • src/hssm/hssm.py
  • src/hssm/likelihoods/blackbox.py
  • src/hssm/modelconfig/full_ddm_config.py
  • src/hssm/register.py
  • src/hssm/rl/config.py
  • src/hssm/rl/registry.py
  • src/hssm/rl/rlssm.py
  • tests/distribution_utils/test_distribution_utils.py
  • tests/rl/test_registry.py
  • tests/rl/test_rlssm.py
  • tests/rl/test_rlssm_config.py
  • tests/test_config.py

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.


📝 Walkthrough

Walkthrough

Adds optional support-edge metadata that defines a response-time edge as t - scale * param. Standard and RLSSM distributions use that edge to determine when to floor log-probabilities. The full_ddm configuration declares st with a scale of 0.5.

Changes

NDT Support-Edge Flooring

Layer / File(s) Summary
Define and validate support-edge declarations
src/hssm/_types.py, src/hssm/config.py, src/hssm/modelconfig/full_ddm_config.py, src/hssm/likelihoods/blackbox.py, src/hssm/register.py, tests/test_config.py
Adds the NDTEdgeShift type and configuration fields. Validation requires t and the named parameter in list_params, and a finite, non-negative numeric scale. The full_ddm configuration declares st with scale 0.5.
Resolve and apply the response-time edge
src/hssm/distribution_utils/dist.py, src/hssm/hssm.py, tests/distribution_utils/test_distribution_utils.py
Resolves the edge from t and the optional declaration, then applies the floor in both likelihood paths. HSSM passes the declaration to distribution construction. Tests cover edge boundaries, missing responses, and the full_ddm declaration.
Carry declarations through RLSSM configuration
src/hssm/rl/config.py, src/hssm/rl/registry.py, tests/rl/test_registry.py, tests/rl/test_rlssm_config.py
Copies support-edge metadata through RL registry and configuration paths. Validation checks SSM parameters and rejects declarations that refer to parameters computed by the learning process.
Apply support-edge flooring in RLSSM
src/hssm/rl/rlssm.py, tests/rl/test_rlssm.py
Passes the configured declaration into distribution construction. Tests check forwarding and compiled log-probabilities around the declared edge.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant HSSM
  participant make_distribution
  participant HSSMDistribution
  HSSM->>make_distribution: pass ndt_edge_shift
  make_distribution->>HSSMDistribution: configure distribution
  HSSMDistribution->>HSSMDistribution: resolve edge from t and shift declaration
  HSSMDistribution->>HSSMDistribution: floor logp at or below edge
Loading

Suggested reviewers: krishnbera

Merge Risk: ⚪ Minimal · up to 406bf

No identified support-edge issue remains that should prevent merging after normal checks.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the RLSSM decision-process propagation of ndt_edge_shift, which is a central part of the stated objective and changeset.
Docstring Coverage ✅ Passed Docstring coverage is 82.80% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 93 functions across 15 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@AlexanderFengler
AlexanderFengler merged commit 0310aff into main Oct 6, 2026
7 checks passed
AlexanderFengler added a commit that referenced this pull request Oct 7, 2026
Changelog entries for #1292, #1293, #1358 and docs for ndt_edge_shift
AlexanderFengler added a commit to EItanm1999/HSSM that referenced this pull request Oct 8, 2026
lnccbrown#1358

The maintainer writes the Unreleased entries on main after each merge
wave; lnccbrown#1292, lnccbrown#1358 and lnccbrown#1360 merged without one, and lnccbrown#1293 is next in
the queue. lnccbrown#1360 is test-only and the changelog has no internal section,
so it gets no entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AlexanderFengler added a commit to EItanm1999/HSSM that referenced this pull request Oct 8, 2026
Brings in lnccbrown#1292, lnccbrown#1293, lnccbrown#1358 and lnccbrown#1361. The only conflict was
docs/changelog.md: main gained an Unreleased section while this branch
added its own entry at the top. Resolved by taking main's file entirely;
PRs carry no changelog hunk, the entry is written on main later.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
EItanm1999 pushed a commit to EItanm1999/HSSM that referenced this pull request Oct 8, 2026
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