summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYurenHao0426 <Blackhao0426@gmail.com>2026-07-22 19:55:05 -0500
committerYurenHao0426 <Blackhao0426@gmail.com>2026-07-22 19:55:05 -0500
commit661496c172a53d09c59951c642f1a468abb7f07e (patch)
tree8555fcd5fa14bb619f4be843a96c3ae22e173020
parent0008f2cd96c06bea98f43560867d93967c597711 (diff)
mechanism: separate BCI role from performance velocity
-rw-r--r--ORAL_B_RECOVERY.md74
-rw-r--r--THEORY.md57
-rw-r--r--experiments/bci_smoke.py50
-rw-r--r--experiments/verify_theory.py41
-rw-r--r--sdil/bci.py38
5 files changed, 255 insertions, 5 deletions
diff --git a/ORAL_B_RECOVERY.md b/ORAL_B_RECOVERY.md
new file mode 100644
index 0000000..5880101
--- /dev/null
+++ b/ORAL_B_RECOVERY.md
@@ -0,0 +1,74 @@
+# Oral-B recovery: temporal-difference somato-dendritic innovation
+
+This branch follows the complete negative result in `ORAL_B.md`; it does not
+reinterpret or overwrite that preregistration. It is motivated by Fig. 5 and
+Extended Data Fig. 13 of Francioni et al. (2026): P+ versus P- SD residuals
+separate epochs by the sign of recent error change, whereas absolute error
+magnitude alone does not separate the populations.
+
+## Structural diagnosis
+
+The failed model always calibrated its vectorizer against the instantaneous
+squared-loss descent direction `e_t s_i`. Its P+ minus P- residual contrast is
+therefore proportional to current error magnitude. Conditional on lower
+current error during improving epochs, the preregistered sign-inversion index
+must be negative. Supplying error velocity as a second regressor cannot alter
+the target being regressed. All 36 negative signs are consistent with this
+structural mismatch.
+
+## Candidate mechanism
+
+The recovery factorizes the instruction into two locally obtainable terms:
+
+1. a slowly learned per-cell causal-role coefficient `m_i`, estimated from
+ sparse antithetic perturbations and the scalar BCI cursor difference;
+2. the within-episode performance innovation
+ `delta_t = |e_(t-1)| - |e_t|`, reset at episode boundaries.
+
+The task instruction is `m_i delta_t`. Ordinary soma-predictable apical
+traffic is added before the same per-cell neutral predictor, so the plasticity
+signal remains the somato-dendritic innovation rather than a directly supplied
+role label. Forward updates use only current presynaptic context, local
+postsynaptic gain, and that innovation. The first recovery is plasticity-only
+(`kappa=0`): the empirical residual points along observed performance change,
+not necessarily a corrective online-control direction.
+
+For perturbation vector `xi`, the role target is
+
+```text
+q_i = [(z(h + sigma xi) - z(h - sigma xi)) / (2 sigma)] xi_i,
+```
+
+whose expectation is the unknown causal role `s_i`. This consumes scalar
+cursor observations rather than gradients or reverse-mode differentiation.
+
+## R0 mechanics gate
+
+R0 generates no task endpoint and touches no development or confirmation
+environment. It passes only if deterministic checks establish all of:
+
+- the mean perturbation role target has cosine above 0.99 with the analytic
+ role used only by the diagnostic;
+- the local vectorizer update moves toward that role without reading it;
+- the neutral predictor exactly removes affine soma traffic in the controlled
+ synthetic check;
+- episode-initial performance velocity is zero;
+- perfect role identification gives a strictly positive P+/P- error-change
+ sign-inversion index; and
+- all original BCI pairing, lesion-mask, decoder, and finite checks still pass.
+
+R0 is implemented by `performance_velocity` in `sdil/bci.py` and audited by
+`experiments/bci_smoke.py` plus `experiments/verify_theory.py`. Passing R0
+only permits a separately committed training-only structural screen after the
+D4 accept confirmation closes. No success-rate endpoint, hyperparameter
+selection, or oral-B score change is authorized by R0.
+
+## Boundary for the next gate
+
+Before any recovery task run, R1 must freeze its development-only task seeds,
+forward/vectorizer rates, role-estimator cadence, cost accounting, and a stop
+rule requiring both positive derivative sign and actual learning. A sign that
+is positive merely because `delta_t` was inserted is not evidence of credit
+assignment. Confirmation seeds 10--15 from the original oral-B protocol
+remain untouched unless an R1 candidate passes every frozen learning,
+innovation, role-vectorization, and plasticity-lesion requirement.
diff --git a/THEORY.md b/THEORY.md
index b8416a3..4aecc96 100644
--- a/THEORY.md
+++ b/THEORY.md
@@ -632,6 +632,59 @@ the loss-producing part of training. Endpoint alignment must be reported with
the alignment trajectory and task loss. This is a standard tracking fact used
to audit the mirror baseline, not an SDIL novelty claim.
+### Why instantaneous gradient feedback cannot reproduce the BCI derivative sign
+
+The failed oral-B model calibrated its apical vectorizer to the instantaneous
+negative loss gradient. Let `e_t>0` be the target-minus-cursor error during an
+active trial and let `s_i` be the signed causal role of cell `i` (`s_i>0` for
+P+, `s_i<0` for P-). With squared loss, the perfect calibration target is
+
+```text
+r_i(t) = e_t s_i.
+```
+
+Consequently the P+ minus P- residual contrast is a positive constant times
+`e_t`. Whenever current error is smaller in error-reduction epochs than in
+error-increase epochs, the Harnett separability statistic
+
+```text
+0.5 * [E(P+ - P- | error decreases)
+ - E(P+ - P- | error increases)]
+```
+
+is negative. Adding error velocity as an input feature does not fix this:
+regression against `e_t s_i` still assigns the causal coefficient to error
+magnitude and has no reason to assign it to velocity. The complete negative
+36-run screen is therefore a structural falsification of that target, not a
+learning-rate failure.
+
+A derivative-faithful candidate separates causal-role identification from the
+temporal performance modulator. With Rademacher perturbations `xi`, observe
+only the scalar cursor under antithetic state interventions:
+
+```text
+z+ = s^T (h + sigma xi), z- = s^T (h - sigma xi),
+q_i = ((z+ - z-) / (2 sigma)) xi_i.
+```
+
+Then `E[q_i]=s_i`; no task loss, gradient, transpose weight, or reverse graph
+is used. Define the within-episode performance innovation
+
+```text
+delta_t = |e_(t-1)| - |e_t|,
+r_i(t) = delta_t * m_i, m_i approximately s_i.
+```
+
+Now the same separability statistic is positive by construction when both
+improving and worsening epochs occur. More importantly, this is not merely a
+metric relabelling: `delta_t` is the scalar outcome that modulates the local
+eligibility update, while sparse causal perturbations amortize the
+neuron-specific role vector `m`. Whether that three-factor temporal rule
+actually learns, preserves residual decorrelation, and survives a plasticity
+lesion remains an empirical gate. Direct online control is not implied; in
+fact applying `r` as an immediate state correction would reinforce both good
+and bad velocity signs and is therefore excluded from the first recovery.
+
## 4. Hardware-independent complexity
Let `H` be the number of hidden layers, `K` the perturbation directions per
@@ -675,4 +728,6 @@ quadratic finite-sigma bias law; the smooth-descent step threshold; the
conditional-projection Pythagorean identity and norm-matching direction
invariance; neutral predictor convergence across `eta_P` and update counts;
the multiplicative residual-coupling operator and its unstable positive mode;
-and the task-fit teaching-signal absorption predicted above.
+the task-fit teaching-signal absorption predicted above; and the BCI
+instantaneous-gradient sign conflict versus the temporal-difference role
+factorization.
diff --git a/experiments/bci_smoke.py b/experiments/bci_smoke.py
index 5cd5b65..4724d26 100644
--- a/experiments/bci_smoke.py
+++ b/experiments/bci_smoke.py
@@ -93,6 +93,55 @@ def check_phase_masks_and_velocity_reset():
print("BCI online/plasticity phase masks and episode velocity reset: exact")
+def check_temporal_difference_role_vectorizer():
+ """Role perturbations times performance change give the Harnett sign."""
+ cfg = BCIConfig(
+ days=2, episodes_per_day=4096, steps_per_episode=2,
+ feedback="performance_velocity", vectorizer_eta=0.5)
+ model = BCISDIL(cfg, model_seed=12)
+ generator = torch.Generator().manual_seed(13)
+ soma = 0.2 * torch.randn(
+ cfg.episodes_per_day, cfg.n_neurons, generator=generator)
+ xi = torch.empty_like(soma).bernoulli_(
+ 0.5, generator=generator).mul_(2).sub_(1)
+ target = model.causal_role_targets(soma, xi)
+ mean_target = target.mean(0)
+ cosine = torch.nn.functional.cosine_similarity(
+ mean_target, model.role, dim=0).item()
+ assert cosine > 0.99
+
+ active = torch.ones(cfg.episodes_per_day, dtype=torch.bool)
+ model.A.zero_()
+ model.update_vectorizer(
+ torch.ones(cfg.episodes_per_day, 1),
+ torch.zeros(cfg.episodes_per_day, cfg.n_neurons),
+ target, active)
+ learned_cosine = torch.nn.functional.cosine_similarity(
+ model.A[:, 0], model.role, dim=0).item()
+ assert learned_cosine > 0.99
+
+ # With the neutral predictor exact, r_i = role_i * delta_error. Hence the
+ # P+ minus P- residual is positive in improving epochs and negative in
+ # worsening epochs, independent of instantaneous error magnitude.
+ model.A[:, 0].copy_(model.role)
+ model.P.copy_(model.coupling)
+ model.P_bias.zero_()
+ previous = torch.tensor([0.8, 0.4, 0.9, 0.3])
+ current = torch.tensor([0.6, 0.5, 0.5, 0.6])
+ delta = model.feedback_features(current, previous)
+ _, innovation, _, _ = model.apical_components(soma[:4], delta)
+ difference = (innovation[:, :cfg.n_plus].mean(1)
+ - innovation[:, cfg.n_plus:cfg.n_plus + cfg.n_minus].mean(1))
+ improving = delta[:, 0] > 0
+ worsening = delta[:, 0] < 0
+ sign_index = 0.5 * (
+ difference[improving].mean() - difference[worsening].mean())
+ assert sign_index > 0
+ print(
+ "BCI temporal-difference role vectorizer: "
+ f"cos={cosine:.6f}, sign_index={sign_index.item():.6f}")
+
+
def check_grouped_decoders_and_metrics():
generator = torch.Generator().manual_seed(9)
groups = torch.arange(200)
@@ -136,5 +185,6 @@ if __name__ == "__main__":
check_causal_estimator()
check_predictor_identification()
check_phase_masks_and_velocity_reset()
+ check_temporal_difference_role_vectorizer()
check_grouped_decoders_and_metrics()
print("ALL BCI MECHANICS CHECKS PASSED")
diff --git a/experiments/verify_theory.py b/experiments/verify_theory.py
index 21a7c06..c00a091 100644
--- a/experiments/verify_theory.py
+++ b/experiments/verify_theory.py
@@ -348,6 +348,46 @@ def check_kolen_pollack_difference_dynamics():
assert maximum_error < 3e-15
+def check_bci_error_derivative_structure():
+ """Separate causal role from temporal performance innovation."""
+ rng = np.random.default_rng(641)
+ role = np.concatenate((np.full(5, 0.2), np.full(5, -0.2),
+ np.zeros(30)))
+ examples = 200_000
+ xi = 2.0 * rng.integers(0, 2, size=(examples, role.size)) - 1.0
+ cursor_direction = xi @ role
+ role_estimate = (cursor_direction[:, None] * xi).mean(axis=0)
+ role_error = float(np.abs(role_estimate - role).max())
+ role_cosine = float(role_estimate @ role / (
+ np.linalg.norm(role_estimate) * np.linalg.norm(role)))
+
+ # Positive target error throughout the BCI trial. Improving events have
+ # smaller current error; worsening events have larger current error.
+ previous = np.asarray((0.8, 0.7, 0.6, 0.5, 0.4, 0.3))
+ current = np.asarray((0.6, 0.5, 0.4, 0.7, 0.6, 0.5))
+ improvement = previous - current
+ improving = improvement > 0
+ worsening = improvement < 0
+
+ def sign_index(modulator):
+ residual = modulator[:, None] * role[None, :]
+ difference = residual[:, :5].mean(1) - residual[:, 5:10].mean(1)
+ return 0.5 * (difference[improving].mean()
+ - difference[worsening].mean())
+
+ instantaneous_index = float(sign_index(current))
+ temporal_difference_index = float(sign_index(improvement))
+ print("\nBCI ERROR-DERIVATIVE STRUCTURE")
+ print(f"role_estimator_cosine={role_cosine:.9f} "
+ f"max_error={role_error:.3e} "
+ f"instantaneous_index={instantaneous_index:+.6f} "
+ f"td_index={temporal_difference_index:+.6f}")
+ assert role_cosine > 0.999
+ assert role_error < 0.005
+ assert instantaneous_index < 0.0
+ assert temporal_difference_index > 0.0
+
+
def main():
check_simultaneous_variance()
check_sigma_bias()
@@ -359,6 +399,7 @@ def main():
check_dynamic_neutral_projection()
check_intermittent_feedback_tracking()
check_kolen_pollack_difference_dynamics()
+ check_bci_error_derivative_structure()
print("\nALL THEORY CHECKS PASSED")
diff --git a/sdil/bci.py b/sdil/bci.py
index c8408e3..2978e5a 100644
--- a/sdil/bci.py
+++ b/sdil/bci.py
@@ -37,10 +37,11 @@ class BCIConfig:
@property
def feedback_dim(self):
- return 1 if self.feedback == "error" else 2
+ return 1 if self.feedback in ("error", "performance_velocity") else 2
def validate(self):
- if self.feedback not in ("error", "error_velocity"):
+ if self.feedback not in (
+ "error", "error_velocity", "performance_velocity"):
raise ValueError(f"unknown BCI feedback: {self.feedback}")
if self.n_plus < 1 or self.n_minus < 1 or self.n_background < 1:
raise ValueError("all BCI populations must be nonempty")
@@ -141,6 +142,8 @@ class BCISDIL:
return error.unsqueeze(1)
improvement_velocity = (torch.zeros_like(error) if previous_abs_error is None
else previous_abs_error - error.abs())
+ if self.cfg.feedback == "performance_velocity":
+ return improvement_velocity.unsqueeze(1)
return torch.stack((error, improvement_velocity), dim=1)
def apical_components(self, soma, feedback):
@@ -162,6 +165,20 @@ class BCISDIL:
directional_derivative = (loss_plus - loss_minus) / (2.0 * sigma)
return -directional_derivative.unsqueeze(1) * perturbations
+ def causal_role_targets(self, soma, perturbations):
+ """Estimate each cell's signed causal effect on the BCI cursor.
+
+ Unlike ``causal_targets``, this target contains no instantaneous error
+ magnitude. A scalar antithetic cursor difference is tagged by the
+ locally available perturbation at each cell; its expectation is the
+ experimenter-unknown causal role vector.
+ """
+ sigma = self.cfg.perturb_sigma
+ plus = (soma + sigma * perturbations) @ self.role
+ minus = (soma - sigma * perturbations) @ self.role
+ directional_derivative = (plus - minus) / (2.0 * sigma)
+ return directional_derivative.unsqueeze(1) * perturbations
+
def exact_causal_direction(self, soma, target):
"""Analytic diagnostic only; never used by a learning update."""
error = target - soma @ self.role
@@ -185,6 +202,15 @@ class BCISDIL:
def update_vectorizer(self, feedback, innovation, causal_target, active):
if not bool(active.any()):
return
+ if self.cfg.feedback == "performance_velocity":
+ # Role identification and performance modulation are deliberately
+ # separated. The former is an amortized node-perturbation estimate
+ # of dz/dh_i; the latter enters apical activity only through the
+ # within-episode performance innovation in ``feedback``.
+ target = causal_target[active].mean(0)
+ self.A[:, 0] += self.cfg.vectorizer_eta * (
+ target - self.A[:, 0])
+ return
c = feedback[active]
calibration_error = causal_target[active] - innovation[active]
self.A += self.cfg.vectorizer_eta * (
@@ -228,8 +254,12 @@ class BCISDIL:
and global_step % self.cfg.perturb_every == 0)
causal_target = None
if did_perturb:
- causal_target = self.causal_targets(
- base_soma, self.cfg.target, perturbations)
+ if self.cfg.feedback == "performance_velocity":
+ causal_target = self.causal_role_targets(
+ base_soma, perturbations)
+ else:
+ causal_target = self.causal_targets(
+ base_soma, self.cfg.target, perturbations)
# All updates see the same pre-update state. The order below only
# mutates parameters after every required local quantity is stored.