# SPDX-License-Identifier: CC-BY-NC-4.0
# Copyright (c) 2026 Jakub Ćwirlej
# Licensed under Creative Commons Attribution-NonCommercial 4.0 International: free for non-commercial use with attribution to the author.
"""
TetherGate — relative-anchor spring gating that keeps learned gates from collapsing.

A learnable gate scales a residual branch: out = x + g * branch(x). The branch can be an expert (MoE), an adapter
(LoRA, domain adapters), a newly added layer or block, a cross-attention / memory / retrieval path, or any scalar of
the ReZero / LayerScale kind. The gate is driven by a control signal `s` that you choose (any statistic of how much
the branch is needed — or a constant, which turns the gate into an anchored learnable scalar).
Status: experimental — tested in our own training runs, early observations promising; not a validated method.

THE PROBLEM
    When the loss does not "pay" for keeping a branch open — the branch hurts at initialization, or its benefit is
    delayed — the optimizer slowly pushes the gate to zero, and the branch stops contributing to the model.
    Two independent drifts cause it:
      1. early in training, the control signal drifts below a threshold that has not moved yet;
      2. later, the threshold itself drifts upward, because the loss-only equilibrium moves towards "closed".

THE MECHANISM
    g_raw = sigmoid( clamp( beta * (s - theta), arg_min, +8 ) )
    g     = F(t) + (1 - F(t)) * g_raw                  # affine floor, scheduled by the training STEP
    after every optimizer step (outside autograd):
        theta <- theta - k(t) * (theta - theta_star)
        theta_star = EMA(s) - logit(g_target) / beta    # RELATIVE anchor: follows the signal's own drift
    The spring removes the common one-sided drift; the gate's mean openness settles near g_target independently of
    the learning rate (the LR only sets the time constant), while the per-token selectivity driven by `s` stays.
    Gradients through the gate are real in both directions (forward == backward; the wide clamp keeps a live
    gradient even near the floor). Outside the clamp range — beta * (s - theta) below arg_min (default -8) or
    above +8 — the gradient to theta (and to s) is exactly 0; theta then moves only through the spring.

PRACTICAL NOTES
    * g_target: 0.5 is a safe default; use 0.3–0.7 to bias a branch towards closed / open.
    * beta (fixed): 4–8 for a signal of unit scale; normalise `s` (e.g. divide by its running std) if its scale is
      very different — beta * std(s) of about 1–3 keeps the gate selective without saturating.
    * Floor schedule F(t): keep 1.0 (gate fully open, "asleep") until the branch has warmed up — a few hundred to a
      few thousand steps — then let it decay; 'exp' (default) never reaches f_min, 'cos' reaches it at ramp_end.
      Never drive the floor by the loss value.
    * Spring strength: kappa_sleep while the floor is 1.0 centres theta fast; afterwards k = lr * wd removes only the
      common drift. If the signal drifts quickly, schedule k up smoothly — do not jump it.
    * Choosing wd: under a steady closing push the optimizer moves theta by about lr per step and the spring pulls it
      back by lr * wd * (theta - theta_star), so theta settles about 1/wd away from its anchor, whatever the LR. The
      gate's argument then shifts by about beta / wd: keep beta / wd well below 1 (the default wd = 20 with beta = 6
      gives 0.3 and the gate stays near g_target; wd = 0.5 would give 12 and the gate would close to the floor).
      wd = 20 is this file's default (as in the demo); our long production run used wd = 0.5, which leaves
      the loss much more freedom — with a small wd, watch the mean gate opening.
    * Reopening a gate that has closed: use a ramp (raise the floor gradually); a step change on a mature model is a
      loss shock.
    * anchor='global' (one target for all gates) lets layers / experts differentiate through their own signal;
      anchor='local' forces the same openness everywhere.
    * Keep theta in its own parameter group without weight decay (as in the demo below).
    * Monitor the variance of `s`: the anchor keeps the gate open, it cannot restore a signal that lost its
      information.
    * torch.compile: the forward pass has no Python branching on tensors; tethergate_step runs outside the graph.
"""
import math
import torch
import torch.nn as nn


class TetherGate(nn.Module):
    """Gate for one branch (or one layer). Call `gate = g(s)`, then `out = x + gate * branch(x)`.

    s: tensor of any shape (per token / per sample / per layer) or a python float; the gate
       broadcasts like `s`. With a constant s the module reduces to an anchored learnable scalar.
    detach_signal: True (default) treats `s` as data — the branch cannot learn to manipulate
       its own gate signal. Set False only if you deliberately want gradients to flow into `s`.
    """

    def __init__(self, beta=6.0, g_target=0.5, theta_init=0.0, arg_min=-8.0, ema_momentum=0.99,
                 detach_signal=True):
        super().__init__()
        self.detach_signal = bool(detach_signal)
        self.beta = float(beta)                      # fixed
        self.g_target = float(g_target)
        self.arg_min = float(arg_min)
        self.ema_momentum = float(ema_momentum)
        self.theta = nn.Parameter(torch.tensor(float(theta_init)))
        self.register_buffer('floor', torch.tensor(1.0))     # F(t); 1.0 = gate "asleep" (full pass)
        self.register_buffer('ema_s', torch.tensor(float('nan')))

    def forward(self, s):
        s = torch.as_tensor(s, dtype=self.theta.dtype, device=self.theta.device)
        if self.detach_signal:
            s = s.detach()
        if self.training:
            with torch.no_grad():                    # statistics only — no Python branching (torch.compile-friendly)
                m = s.detach().float().mean()
                ema = self.ema_momentum * self.ema_s + (1.0 - self.ema_momentum) * m
                self.ema_s.copy_(torch.where(torch.isnan(self.ema_s), m, ema))
        g_raw = torch.sigmoid((self.beta * (s - self.theta)).clamp(self.arg_min, 8.0))
        return self.floor + (1.0 - self.floor) * g_raw

    def theta_star(self):
        return float(self.ema_s) - math.log(self.g_target / (1.0 - self.g_target)) / self.beta


def floor_schedule(step, ramp_start=3000, f_min=0.05, tau=1800.0, mode='exp', ramp_end=4500):
    """F(t): 1.0 until ramp_start, then decays to f_min. 'exp' approaches f_min asymptotically
    (never reaches it); 'cos' reaches f_min at ramp_end. Driven by the STEP, never by the loss."""
    if step < ramp_start:
        return 1.0
    if mode == 'exp':
        return f_min + (1.0 - f_min) * math.exp(-(step - ramp_start) / tau)
    x = min(1.0, (step - ramp_start) / max(1, ramp_end - ramp_start))
    return f_min + (1.0 - f_min) * 0.5 * (1.0 + math.cos(math.pi * x))


@torch.no_grad()
def tethergate_step(gates, step, lr, wd=20.0, kappa_sleep=5e-3, anchor='global', **floor_kw):
    """Call once per training step, AFTER optimizer.step().
    Sets the floor F(t) and pulls every theta towards its relative anchor.
    k = kappa_sleep while the floor is 1.0 (theta gets no loss gradient then — center it fast),
    afterwards k = lr * wd (slow mode: only the common drift is removed).
    anchor='global': one target from the mean EMA over all gates (keeps differentiation);
    anchor='local' : one target per gate."""
    f = floor_schedule(step, **floor_kw)
    gates = [g for g in gates if not torch.isnan(g.ema_s)]
    for g in gates:
        g.floor.fill_(f)
    if not gates:
        return f
    k = kappa_sleep if f >= 1.0 else lr * wd
    if anchor == 'global':
        t_star = sum(g.theta_star() for g in gates) / len(gates)
        for g in gates:
            g.theta.sub_(k * (g.theta - t_star))
    else:
        for g in gates:
            g.theta.sub_(k * (g.theta - g.theta_star()))
    return f


if __name__ == '__main__':
    # Minimal demo: two layers; each new branch is gated. `s` can be any statistic you choose.
    torch.manual_seed(0)
    d = 16
    blocks = nn.ModuleList(nn.Linear(d, d) for _ in range(2))
    gates = nn.ModuleList(TetherGate(g_target=0.5) for _ in range(2))
    opt = torch.optim.AdamW([{'params': blocks.parameters(), 'weight_decay': 0.1},
                             {'params': gates.parameters(), 'weight_decay': 0.0}], lr=1e-3)
    for step in range(400):
        x = torch.randn(32, d); h = x; opened = []
        for blk, gt in zip(blocks, gates):
            u = blk(h)
            s = u.abs().mean(-1, keepdim=True)        # example signal: branch activity per token
            g = gt(s); opened.append(float(g.detach().mean()))
            h = h + g * u
        loss = (h - x.flip(-1)).pow(2).mean()
        opt.zero_grad(); loss.backward(); opt.step()
        tethergate_step(gates, step, lr=1e-3, wd=20.0, kappa_sleep=0.05, ramp_start=100, tau=100.0)
        if step % 100 == 99:
            print(f'step {step+1}: floor {float(gates[0].floor):.3f} | mean gate {[round(o, 3) for o in opened]} '
                  f'| theta {[round(g.theta.item(), 3) for g in gates]} | anchor {[round(g.theta_star(), 3) for g in gates]}')
