Search Tech Journey

Find topics, journeys and posts

6-month learning plan110 / 130
back to blog
llmadvanced 50m read

S110 · Positional Encoding — Sinusoidal, Learned, RoPE

Attention is permutation-invariant — it doesn't know word order. Positional encoding fixes that. From the paper's sinusoidal trick to modern RoPE, the encoding that quietly powers LLaMA and every 2024 LLM.

LLMsM13 · NLP & Transformers· Session 110 of 130 90 min

🎯 Understand why Transformers need explicit position information, how sinusoidal / learned / rotary encodings differ, and why RoPE dominates 2024 LLM design.

Why this session exists

A single word missing: without positional encoding, "The dog chased the cat" and "The cat chased the dog" produce IDENTICAL Transformer outputs. Attention is permutation-invariant — Q·K^T only cares about content, not position. The way we inject position determines how far the model can extrapolate at inference time, which is the difference between LLaMA-2's 4K context and Gemini's 1M. From 2017's sinusoidal add-on to 2021's RoPE (used by every modern LLM), positional encoding has been quietly one of the most consequential architectural choices.

You will be able to
  • Explain why attention is permutation-invariant and prove it with a small example.
  • Compute a sinusoidal positional encoding by hand for the first few positions.
  • Distinguish sinusoidal, learned, and rotary (RoPE) positional encodings.
  • Understand why RoPE extrapolates to longer contexts better than the alternatives.
  • Implement RoPE and see the difference in code.

Prerequisites

  • S107 · Attention Intuition — why attention has this problem.
  • S108 · Q/K/V Math — you need to see where positional info gets injected.
  • S109 · Multi-Head Attention — positional encoding applies per-head.
  • S097 · Linear Algebra — rotations and complex numbers for RoPE.


(a) Intuition · 5 min

A jumbled book without page numbers
🌍 Real world

Imagine your favourite novel with all chapters shuffled and no page numbers. Same words, same paragraphs, same content — but you can't reconstruct the plot. Word ORDER is where meaning lives, and shuffled order destroys meaning.

Now imagine adding a small mark to each page — 'this is page 47' — using a code the reader knows. Suddenly you can reorder mentally and read in the intended sequence.

💻 Code world

Attention operates on a set of vectors — it computes Q·K^T which doesn't depend on order. To distinguish "The dog chased the cat" from "The cat chased the dog," we need to STAMP each token vector with its position before attention sees it.

Different stamps give different properties: sinusoidal (fixed function of position), learned (a lookup table you train), or rotary (rotate Q and K by angles derived from position). Modern LLMs use rotary because it extrapolates gracefully to positions never seen during training.

Why attention is permutation-invariant

The three ways to inject position
  • Sinusoidal (2017 Transformer) — fixed sine/cosine functions of position added to input embeddings. Not learned. Works for any position.
  • Learned (BERT, GPT-2) — a learned embedding per position, added to input. Simple, but can't handle positions beyond training length.
  • Rotary (2021 RoPE) — rotate Q and K by angles derived from position. Injected INSIDE attention, not into embeddings. Encodes RELATIVE position naturally. Used by LLaMA, GPT-NeoX, PaLM.

(b) Visual walkthrough · 15 min

Sinusoidal positional encoding

The formula (Vaswani et al. 2017):

PE[pos, 2i]   = sin(pos / 10000^(2i/d))
PE[pos, 2i+1] = cos(pos / 10000^(2i/d))

The three approaches compared

Sinusoidal (2017)

Fixed sine/cosine

  • Zero params
  • Extrapolates in principle
  • Empirically doesn't work great past training length
  • Original Transformer, T5
Learned absolute (BERT, GPT-2)

One embedding per position

  • Simple, expressive
  • Zero extrapolation — cutoff at max_len
  • Adds max_len × d_model params
  • BERT, GPT-2, RoBERTa
Learned relative (T5)

Bias added to attention scores

  • Encodes only relative distance
  • Extrapolates better than absolute
  • Extra bias table
  • T5, DeBERTa
Rotary (RoPE, 2021)

Rotate Q, K by position angles

  • Encodes relative position naturally
  • Extrapolates well with scaling tricks
  • No extra params
  • LLaMA, PaLM, GPT-NeoX, Mistral

RoPE — the geometric idea

1pair
Pair up dimensions

Take d_head-dim Q and K vectors. Split into pairs: (q_0, q_1), (q_2, q_3), ... — d_head/2 pairs.

2freq
Assign frequency per pair

Pair i gets frequency θ_i = 10000^(-2i/d). Low i = slow rotation; high i = fast rotation.

3rotate
Rotate each pair by (position × frequency)

At position m, pair i is rotated by angle m·θ_i. Standard 2D rotation matrix: [[cos, -sin], [sin, cos]].

4attend
Apply rotation to Q and K identically

Both Q at position m and K at position n get rotated. The dot product q_m · k_n then depends only on (m-n), not on m or n independently.

5attention
Compute attention as usual

Nothing else changes — the rotation is a pre-multiplication of Q and K. Downstream softmax and matmul with V are unchanged.

Sinusoidal PE at 5 positions, d=8

What sinusoidal PE actually looks like

pos=0
[sin(0), cos(0), sin(0), cos(0), ...] = [0, 1, 0, 1, 0, 1, 0, 1]. The 'origin' — always the same.
pos 0
pos=1
[sin(1), cos(1), sin(0.01), cos(0.01), ...]. First pair rotates a lot; later pairs barely.
pos 1
pos=100
First pair has cycled many times; later pairs (slow-rotating) have moved a small angle.
pos 100
pos=10000
The LAST pair has cycled exactly once (by design of the 10000 base). Middle pairs are the 'coarse position' signal.
pos 10K

Why RoPE extrapolates and learned doesn't


Common misconception
✗ What most people think

"Positional encodings tell the model where each token is. So a model trained with sinusoidal encodings should handle longer sequences fine — the formula is defined for every position."

✓ What is actually true

Being defined at position 5000 is not the same as being trained at position 5000. Attention has to learn to read positional structure from the encoding, and it only learns the regime it saw. Extrapolation beyond the training length degrades sharply for sinusoidal and rotary encodings alike — which is exactly why context extension needs interpolation or rescaling, not just a longer loop.

Why the myth is so sticky

Because the original paper argued sinusoids "may allow the model to extrapolate to longer sequences", and the formula genuinely has no upper bound. The myth survives because the failure is silent: no exception, no NaN, just quality falling off a cliff past the training length. And the low-frequency dimensions are the culprit — during training they never completed a full period, so the model learned a monotone ramp, and beyond training length that ramp enters values it has never seen.

Prove it to yourself

Look at how much of each frequency band was actually exercised during training:

import numpy as np
d, L = 128, 2048
i = np.arange(0, d, 2)
wavelen = 2*np.pi * (10000 ** (i/d))
for k in (0, 20, 40, 62):
    print(f'dim {i[k]:3d}  wavelength={wavelen[k]:12.1f}  periods seen in {L} tokens = {L/wavelen[k]:.3f}')
# top dims complete a fraction of one period -> the model never saw them wrap
From first principles
Start with the question

Why must positional information be injected at all, and why does RoPE apply a rotation rather than an addition?

  1. 1
    Self-attention computes scores from xiTW xj and sums values with those weights. Permute the inputs and both the score set and the sum permute identically.
    forced by · every operation in attention is either per-token or a symmetric sum over tokens — nothing reads an index
  2. 2
    So without added positional information a transformer is a set function: "dog bites man" and "man bites dog" are literally the same computation.
    forced by · permutation equivariance is a structural property, not a training artefact
  3. 3
    What attention actually needs is not absolute position but relative position: whether j is near i, and in which direction. Language dependencies are overwhelmingly stated in relative terms.
    forced by · the same phrase means the same thing at the start or the middle of a document
  4. 4
    Adding an absolute vector to the input makes the score a mess of cross terms — content×content, content×position, position×position — where relative distance is only implicit and must be learned.
    forced by · expanding (xi+pi)TW(xj+pj) yields four terms, and only one carries clean positional interaction
  5. 5
    Instead rotate Q and K in 2-D subspaces by an angle proportional to position. Then the dot product of a rotated pair depends on the rotation angles only through their difference: (Rmq)T(Rnk) = qTRn−mk.
    forced by · rotations compose additively and orthogonal matrices preserve inner products, so absolute angles cancel and only m−n survives
⇒ Therefore

Therefore RoPE gives exactly relative position, with no extra parameters, no added terms, and no change to vector norms — the property the task needs, obtained by construction rather than by learning.

And note what this predicts: since position enters only as an angle , you can extend context by shrinking θ so that a longer range maps into the angle range the model was trained on. That is precisely what position interpolation and NTK-aware scaling do, and it is why they work with only brief fine-tuning while naive extrapolation does not work at all.

Mental modelClock hands at many speeds

Give every token a bank of clock hands: some sweeping fast (one revolution every few tokens), some barely moving (one revolution every thousands). The token's position is the joint reading of all the hands — fast hands resolve fine local offsets, slow hands disambiguate coarse document-scale location.

RoPE says: instead of writing the clock reading down next to the token, physically rotate the query and key by it. Then comparing two tokens automatically compares only the angle between their clocks, which is their distance.

  • Without positional information a transformer is permutation-invariant — a bag of tokens. This is structural.
  • Sinusoidal: fixed, parameter-free, absolute. Learned: flexible, but hard-capped at max length with no meaning beyond it. RoPE/ALiBi: relative, and the modern default.
  • RoPE is applied to Q and K only, inside every layer — never to V and never once at the embedding.
  • Long-context extension is angle rescaling (position interpolation / NTK / YaRN), not a bigger table. The low-frequency dimensions are what break first.
🔔 Fires when you see

Fire this model the moment you see: a model degrading past a specific sequence length · a "context extension" claim · a bag-of-words failure where order is ignored · rope_theta or rope_scaling in a config file · an attention implementation where RoPE was applied before rather than after the QK projection.

The tradeoff

Which positional scheme for a model you intend to serve with variable and possibly growing context: learned absolute, sinusoidal absolute, RoPE, or ALiBi?

Learned absolute embeddings
+ you gain maximum flexibility — the model discovers whatever positional structure the task actually needs, with no assumption baked in; simplest possible implementation
− you pay a hard wall at the trained max length, since position 4097 has no row; parameters scale with max length; and nothing about the representation encodes that positions 5 and 6 are adjacent
pick when fixed-length inputs you fully control — classification over padded sequences, tabular-ish encoders
RoPE
+ you gain relative by construction, zero parameters, norm-preserving, and — decisively — extensible after training by rescaling θ, which is the only cheap path to longer context on an existing checkpoint
− you pay extension is not free quality-wise and usually needs a fine-tuning pass; and the frequency base is a hyperparameter that couples to your intended max length in a non-obvious way
pick when any decoder-only LLM, and specifically when you expect to extend context later — which you always do
ALiBi
+ you gain a linear distance penalty added to scores: trivially simple, no embeddings at all, and it extrapolates to longer sequences more gracefully than the alternatives
− you pay it imposes a fixed monotone recency bias, so genuinely long-range retrieval is structurally discouraged — bad for tasks where the needed token is far away
pick when workloads dominated by local context where you want length robustness for free and rarely need distant lookups
What a senior engineer actually does

RoPE is the default for good reason: it encodes the property the task actually has (relativity) as a structural invariant rather than something to be learned, and it leaves a knob for extension. The senior instinct is to look at rope_theta and the intended max length together at design time — choosing the base for the context you will eventually want is nearly free, while retrofitting is a fine-tuning project.

Whatever you pick, do not trust length extrapolation without measuring it. Evaluate at your real target length with a retrieval-style probe, because the failure mode is silent quality loss, not an error.


(c) Hands-on · 25 min

Implement sinusoidal PE and RoPE from scratch, visualise their properties, and see how each affects a small attention layer.

# positional_encoding.py — sinusoidal, learned, and RoPE from scratch.
# Run: uv run positional_encoding.py
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
 
 
def sinusoidal_pe(max_len: int, d_model: int) -> torch.Tensor:
    """Standard 2017 Transformer positional encoding. Returns [max_len, d_model]."""
    pe = torch.zeros(max_len, d_model)
    position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)  # [T, 1]
    # div_term shape [d_model/2]: 10000^(2i/d) for i=0..d/2
    div_term = torch.exp(
        torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)
    )
    pe[:, 0::2] = torch.sin(position * div_term)   # even indices
    pe[:, 1::2] = torch.cos(position * div_term)   # odd indices
    return pe
 
 
class LearnedPE(nn.Module):
    """Learned absolute positional embeddings — one vector per position."""
 
    def __init__(self, max_len: int, d_model: int):
        super().__init__()
        self.pe = nn.Embedding(max_len, d_model)
 
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # x: [B, T, d_model]
        T = x.size(1)
        positions = torch.arange(T, device=x.device)  # [T]
        return x + self.pe(positions).unsqueeze(0)     # broadcast
 
 
def precompute_rope_freqs(d_head: int, max_len: int, base: float = 10000.0) -> tuple[torch.Tensor, torch.Tensor]:
    """Precompute cos/sin tables for RoPE. Called once at model init."""
    # d_head must be even.
    theta = 1.0 / (base ** (torch.arange(0, d_head, 2).float() / d_head))  # [d_head/2]
    positions = torch.arange(max_len).float()                               # [max_len]
    freqs = torch.outer(positions, theta)                                   # [max_len, d_head/2]
    cos = freqs.cos()
    sin = freqs.sin()
    return cos, sin
 
 
def apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
    """Apply rotary position embedding to a Q or K tensor of shape [B, T, N, d_head]."""
    # Split last dim into two halves: even indices and odd indices.
    x_even = x[..., 0::2]  # [B, T, N, d_head/2]
    x_odd = x[..., 1::2]   # [B, T, N, d_head/2]
 
    # cos, sin: [T, d_head/2]. Broadcast against [B, T, N, d_head/2].
    T = x.size(1)
    cos_t = cos[:T].unsqueeze(0).unsqueeze(2)  # [1, T, 1, d_head/2]
    sin_t = sin[:T].unsqueeze(0).unsqueeze(2)
 
    # 2D rotation applied to each (even, odd) pair.
    rotated_even = x_even * cos_t - x_odd * sin_t
    rotated_odd = x_even * sin_t + x_odd * cos_t
 
    # Interleave back into a tensor of the original shape.
    out = torch.empty_like(x)
    out[..., 0::2] = rotated_even
    out[..., 1::2] = rotated_odd
    return out
 
 
class AttentionWithRoPE(nn.Module):
    """Multi-head attention with RoPE applied to Q and K before the dot product."""
 
    def __init__(self, d_model: int, n_heads: int, max_len: int = 2048):
        super().__init__()
        assert d_model % n_heads == 0
        self.n_heads = n_heads
        self.d_head = d_model // n_heads
        self.W_q = nn.Linear(d_model, d_model, bias=False)
        self.W_k = nn.Linear(d_model, d_model, bias=False)
        self.W_v = nn.Linear(d_model, d_model, bias=False)
        self.W_o = nn.Linear(d_model, d_model, bias=False)
        cos, sin = precompute_rope_freqs(self.d_head, max_len)
        # Non-learnable buffers — travel with the model on .to(device).
        self.register_buffer("cos", cos)
        self.register_buffer("sin", sin)
 
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        B, T, D = x.shape
        Q = self.W_q(x).view(B, T, self.n_heads, self.d_head)
        K = self.W_k(x).view(B, T, self.n_heads, self.d_head)
        V = self.W_v(x).view(B, T, self.n_heads, self.d_head)
 
        # Apply RoPE to Q and K only (NOT to V — value doesn't need positional encoding).
        Q = apply_rope(Q, self.cos, self.sin)
        K = apply_rope(K, self.cos, self.sin)
 
        # Standard MHA from here on.
        Q = Q.transpose(1, 2)  # [B, N, T, d_head]
        K = K.transpose(1, 2)
        V = V.transpose(1, 2)
        scores = Q @ K.transpose(-2, -1) / math.sqrt(self.d_head)
        weights = F.softmax(scores, dim=-1)
        out = (weights @ V).transpose(1, 2).contiguous().view(B, T, D)
        return self.W_o(out)
 
 
def visualise_sinusoidal() -> None:
    pe = sinusoidal_pe(max_len=50, d_model=16)
    print(f"Sinusoidal PE shape: {pe.shape}")
    print(f"\nFirst 3 dims across positions 0..9:")
    for pos in range(10):
        print(f"  pos {pos}: [{pe[pos, 0]:+.3f}, {pe[pos, 1]:+.3f}, {pe[pos, 2]:+.3f}, ...]")
    print(f"\nSimilarity between positions (dot product):")
    for p in [0, 1, 5, 20, 49]:
        print(f"  PE[0] · PE[{p}] = {(pe[0] * pe[p]).sum().item():+.3f}")
 
 
def demonstrate_rope_relative() -> None:
    """Prove that RoPE-transformed Q·K depends only on the position difference."""
    torch.manual_seed(0)
    d_head = 8
    cos, sin = precompute_rope_freqs(d_head, 100)
 
    # Two random vectors, at different absolute positions but same difference.
    q = torch.randn(1, 1, 1, d_head)  # Q at position 0 (pre-rotation)
    k = torch.randn(1, 1, 1, d_head)  # K at position 0 (pre-rotation)
 
    print("\nTest: rotate Q at pos m, K at pos n. Check that Q·K depends only on (n-m).")
    for m, n in [(0, 5), (10, 15), (50, 55), (0, 10), (20, 30)]:
        q_ = q.clone()
        k_ = k.clone()
        # Rotate at absolute positions.
        q_rot = apply_rope(q_, cos[m:m+1], sin[m:m+1])
        k_rot = apply_rope(k_, cos[n:n+1], sin[n:n+1])
        dot = (q_rot * k_rot).sum().item()
        print(f"  m={m:2d}, n={n:2d}, diff={n-m:2d} → q_rot·k_rot = {dot:+.4f}")
    print("(Notice: same difference → same dot product, regardless of absolute positions.)")
 
 
def main() -> None:
    print("=== Sinusoidal PE ===")
    visualise_sinusoidal()
 
    print("\n=== Learned PE ===")
    x = torch.randn(1, 5, 16)
    learned = LearnedPE(max_len=100, d_model=16)
    out = learned(x)
    print(f"Learned PE: input {x.shape} → output {out.shape}")
    print(f"Params: {sum(p.numel() for p in learned.parameters()):,}")
 
    print("\n=== RoPE ===")
    demonstrate_rope_relative()
 
    # Full attention layer with RoPE.
    print("\n=== AttentionWithRoPE end-to-end ===")
    attn = AttentionWithRoPE(d_model=64, n_heads=4)
    x = torch.randn(2, 10, 64)
    out = attn(x)
    print(f"Input: {x.shape} → Output: {out.shape}")
 
 
if __name__ == "__main__":
    main()

Anatomy of the script

What the interesting lines do

sinusoidal_pe formula with 10000^(2i/d)
The 10000 base is arbitrary but sets the max wavelength. Vaswani et al. chose 10000 to give the last dim ~1 full cycle at position 10K. Longer contexts → increase this base.
sinusoid
pe[:, 0::2] = sin, pe[:, 1::2] = cos
Interleave sines and cosines. Even dims are sines, odd dims are cosines, both at the same frequency for that pair.
interleave
nn.Embedding(max_len, d_model)
Learned PE is a lookup table. Same architecture as word embeddings but indexed by position, not by token.
learned
theta = 1.0 / (base ** (arange(0, d_head, 2)/d_head))
RoPE frequency per pair. Low pairs = slow rotation (fine position info), high pairs = fast rotation (coarse position info).
rope theta
rotated_even = x_even*cos - x_odd*sin
The 2D rotation formula. Standard trig: rotating (x, y) by angle a gives (x·cos - y·sin, x·sin + y·cos).
rotate
register_buffer('cos', cos)
Not a learnable parameter, but travels with model.to(device) and is saved/loaded with the state dict. Perfect for precomputed constants.
buffer
Apply RoPE to Q, K — NOT V
Only Q and K need position for the dot product. V just carries content — rotating V would just scramble it needlessly.
rope apply
Try itExtrapolation experiment — see how each PE handles positions beyond training

Add code to compute cross-position similarities beyond max_len for each encoding type. You'll see learned PE catastrophically failing while sinusoidal and RoPE degrade gracefully. This is the entire reason RoPE dominates modern LLMs — it's the only one that reliably extends.

💡 Hint · Create a learned PE with max_len=512, then query positions 100 and 1000. Position 1000 embedding is random (never trained). For sinusoidal and RoPE, the formula extends. Compute PE[0]·PE[100] and PE[0]·PE[1000] for each — sinusoidal and RoPE give sensible decay, learned gives noise past 512.

(d) Production reality · 15 min

War story Meta · LLaMA context extension· 2023production
🔥 What broke

LLaMA-1 was trained with 2048 context and RoPE. Users wanted 4K or 8K context for long documents. Naively extending — just feeding 8K tokens — degraded quality sharply. The RoPE frequencies had never been trained for those large positions and the attention patterns became noisy.

🧯 The fix
Multiple techniques emerged: (1) Position Interpolation (kaiokendev, 2023) — scale down position indices so 8K maps to the 0-2K trained range. Cheap, works with just a bit of fine-tuning. (2) NTK-aware scaling — modify base frequency to preserve high-frequency (local) info. (3) YaRN (Peng et al. 2023) — combines both plus attention scaling. LLaMA-2 shipped with these techniques baked in, giving 4K context. LLaMA-3 uses them plus larger training context to get 8K.
🎓 Lesson to steal
Positional encoding limits context length. Every 'longer context' claim is really a positional encoding trick. Understanding RoPE is a prerequisite to understanding modern long-context research.
Post-mortem
War story Google · T5 relative position bias· 2019T5, mT5, UL2
🔥 What broke
Google's T5 team found that absolute positional embeddings (BERT-style) hurt on long-input tasks. Training with max_len=512 meant the model couldn't handle 1024-token inputs at inference without retraining. And even within training length, absolute encoding couldn't encode 'this word came right before that word' cleanly.
🧯 The fix
T5 introduced RELATIVE position bias — a learned scalar added directly to attention scores based on the distance between query and key positions. Buckets group similar distances (e.g. all distances 10-15 share one bucket). Extrapolates further than absolute, works well up to ~2x training length.
🎓 Lesson to steal
Absolute vs relative positional encoding is a fundamental architectural choice. Relative encodings (T5 bias, RoPE) dominate modern models because they extrapolate better and encode 'distance' naturally.
Post-mortem
War story Anthropic · Claude context length· 2023200K token context
🔥 What broke
When Anthropic launched Claude 100K (and later 200K), the community expected it to be as slow as O(N²) attention would suggest — 200K² = 40 billion operations per attention layer. Turned out Claude was fast at 200K context and handled 'needle in haystack' retrieval well.
🧯 The fix
Anthropic didn't publish exact techniques but likely combines: (1) RoPE with scaling / interpolation, (2) FlashAttention 2 for O(N) memory, (3) sparse or windowed attention for some layers, (4) massive continued pretraining on long documents to teach the positional encoding to work at extreme lengths. Every one of these depends on the underlying positional encoding being extendable — which is why RoPE won.
🎓 Lesson to steal
Long context is not one technique — it's a stack. But the foundation is a positional encoding that works beyond training. Learned absolute PE = you can't extend, period.
Post-mortem

Where this shows up in the rest of the plan

Positional encoding decisions ripple through every LLM design
S111 · Full Transformer
Sinusoidal or RoPE is applied here — either at input embedding (sinusoidal) or inside attention (RoPE).
S115 · Long-Context Tricks
Every context-length extension paper works on positional encoding. RoPE scaling, YaRN, position interpolation.
S127 · Vision Transformer
ViTs use 2D positional encoding for image patches — same ideas, extended to 2D.
S068 · Model Serving
RoPE frequencies must be precomputed and cached correctly for inference. KV cache stores rotated K.
S117 · Diffusion Models
Diffusion U-Nets use time embeddings — same sinusoidal idea, encoding the diffusion step instead of position.
S120 · Fine-tuning LLMs
Fine-tuning with a different max_len than pretrained requires positional-encoding tricks to work.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. Why does a Transformer NEED positional encoding?
  2. What is RoPE, and why does it encode relative position naturally?
  3. Why can LLaMA extend context but GPT-2 can't?

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.