Repository navigation
Carry ndt_edge_shift through the RLSSM decision-process spec - #1358
Conversation
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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (15)
Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review. 📝 WalkthroughWalkthroughAdds optional support-edge metadata that defines a response-time edge as ChangesNDT Support-Edge Flooring
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
Suggested reviewers: Merge Risk: ⚪ Minimal · up to No identified support-edge issue remains that should prevent merging after normal checks. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
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>
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>
…brown#1360, lnccbrown#1361) into feat/register-ddm-st
Stacked on #1292: this branch contains its commits and shrinks to its own six once #1292 is merged. Review the
rlcommits 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 att − 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 att. No bundled RL backbone has such a parameter today; this closes the gap beforeddm_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_modelconfigcopies the backbone'sapprox_differentiabledeclaration into the spec;register_ssm(..., ndt_edge_shift=None)accepts one for custom SSMs and validates it againstlist_params_ssm.get_rlssm_model_configpasses it toRLSSMConfig, and raises a clearValueErrorwhen the named parameter (ort) is computed per trial by the learning process, since the support edge cannot be resolved from a parameter the distribution never sees.RLSSMConfiggains the field (from_rlssm_dictreads the optional key;from_ssms_modeltakes it from the decision-process spec; aModelConfigdecision process hands over its own);validate()applies the same rules asConfig, via a shared helper moved out ofConfig.validate(behaviour unchanged,tests/test_config.pyuntouched).RLSSM._make_model_distributionforwards it tomake_distribution.Tests. 22 new cases in
tests/rl/: the spec carries the backbone's declaration (andNonewithout one);register_ssmstores it and rejects bad declarations;get_rlssm_model_configpropagates it and rejects a computed parameter;RLSSMConfigvalidation, dict round trip,from_ssms_model,ModelConfiginheritance and explicit-override precedence;RLSSMforwards it tomake_distribution; and end to end, the compiled RL log-likelihood is exactlyLOGP_LBforrt ≤ t − stand 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/hssmclean at every commit; pyrefly shows onlymain's two pre-existing errors. In-memory merges: clean againstmainand against #1293 on top ofmain+ this head; no conflicts beyond those #1292 already has with #1344/#1345/#1346.Not in this PR. Changelog entry (written on
mainafter 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