Make HistoryEncoder a pluggable registry, like Router/Objective (gitea #35)
CI / Lint (ruff check) (push) Successful in 30s
CI / Format (ruff format) (push) Successful in 30s
CI / Sync project version with tag (push) Has been skipped
CI / Type check (ty) (push) Successful in 34s
CI / Lint (ruff check) (pull_request) Successful in 33s
CI / Format (ruff format) (pull_request) Successful in 42s
CI / Type check (ty) (pull_request) Successful in 42s
CI / Sync project version with tag (pull_request) Has been skipped
CI / Tests (pull_request) Successful in 3m52s
CI / Tests (push) Successful in 4m0s

Stage2Autoregressive.init_history_cache and .history_step both
isinstance-checked self.history_encoder against AttentionHistory to decide
whether to use its real incremental-cache methods or a no-op fallback, so a
third history type couldn't be added without editing Stage2Autoregressive
itself. The two-value "markov"/"attention" enum was also independently
hardcoded in three places (Stage2Autoregressive's own validation,
config.py's validate_config, and AutoregressiveConfig.from_dict's default).

Mirrors the Router (giant/model/routers.py) and Objective
(giant/model/objectives.py, gitea #32) pattern: HistoryEncoder now declares
working O(1) init_cache/step defaults (init_cache -> None, step -> one
forward() call), so every registered history type satisfies the incremental
interface without opting in; AttentionHistory overrides both with its real
KV-cache versions since its forward() needs the full prefix. Added
HISTORY_REGISTRY/register_history/build_history, registered "markov" and
"attention", and deleted both isinstance checks in models.py.

Per user decision during planning, config.py's validate_config now imports
HISTORY_REGISTRY and checks membership dynamically instead of keeping its own
hardcoded tuple, making the registry the single source of truth end to end
(verified no import cycle: config.py had no prior dependency on giant.model,
and giant.model.history has none on giant.config).

No config-schema change and no checkpoint impact — this is a pure
internal-interface refactor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 10:35:46 +02:00
parent 9752ddf79c
commit f301fd98d2
5 changed files with 133 additions and 34 deletions
+5 -2
View File
@@ -15,6 +15,7 @@ import numpy as np
import torch
from giant._migration import V02_FIXED_FACTS, V02_MODEL_KEY_TO_STAGES, reject_legacy_router_expert_sizing
from giant.model.history import HISTORY_REGISTRY
class Conditioning(str, Enum):
@@ -1341,8 +1342,10 @@ def validate_config(cfg: dict) -> None:
"AutoregressiveConfig.order's docstring)"
)
history = _get_path(cfg, "stage2_model.autoregressive.history")
if history not in ("markov", "attention"):
raise ValueError(f"stage2_model.autoregressive.history = {history!r} — must be 'markov' or 'attention'")
if history not in HISTORY_REGISTRY:
raise ValueError(
f"stage2_model.autoregressive.history = {history!r} — must be one of {sorted(HISTORY_REGISTRY)}"
)
teacher_forcing = _get_path(cfg, "stage2_model.autoregressive.teacher_forcing")
if teacher_forcing not in ("always", "scheduled", "never"):
raise ValueError(
+61 -15
View File
@@ -1,5 +1,9 @@
"""History encoders — stage-2 autoregressive only. Self-contained, no
dependency on any other `giant.model` submodule (issues.md Issue 8)."""
dependency on any other `giant.model` submodule (issues.md Issue 8), except
for the `HISTORY_REGISTRY`/`build_history` factory, which mirrors
`giant.model.routers`'s `Router`/`ROUTER_REGISTRY` pattern (gitea #35)."""
import inspect
import torch
import torch.nn as nn
@@ -9,19 +13,57 @@ class HistoryEncoder(nn.Module):
"""Interface for stage-2 autoregressive per-token history summaries:
`forward(feat, has_prev) -> (B, K, out_dim)`, a single parallel pass over
a full (teacher-forced) token sequence — used by training. `MarkovHistory`
and `AttentionHistory` are the two implementations. Inference
(`giant/sample.py`) generates one token at a
time and cannot afford `forward`'s per-step cost to be O(K) (attention
would then be O(K^2) over a rollout's k_max loop); encoders that need
incremental state for that path additionally implement `init_cache`/
`step` (see `AttentionHistory`) — `MarkovHistory` doesn't need to, since
its per-step cost is already O(1) (it only ever looks at the previous
token, not the full prefix)."""
and `AttentionHistory` are the two registered implementations (see
`HISTORY_REGISTRY`/`build_history`). Inference (`giant/sample.py`)
generates one token at a time and cannot afford `forward`'s per-step cost
to be O(K) (attention would then be O(K^2) over a rollout's k_max loop),
so this interface also declares `init_cache`/`step` for that incremental
path, with working O(1) defaults here (`init_cache` -> `None`, `step` ->
one `forward` call ignoring `cache`) — correct for any encoder whose
per-step cost is already O(1) (i.e. it only ever looks at the previous
token, not the full prefix), which is what `MarkovHistory` relies on.
`AttentionHistory` overrides both with real incremental-cache versions,
since its `forward` genuinely needs the full prefix."""
def forward(self, feat: torch.Tensor, has_prev: torch.Tensor) -> torch.Tensor:
raise NotImplementedError
def init_cache(self) -> object:
return None
def step(self, feat: torch.Tensor, has_prev: torch.Tensor, cache: object) -> tuple[torch.Tensor, object]:
return self.forward(feat, has_prev), cache
HISTORY_REGISTRY: dict[str, type[HistoryEncoder]] = {}
def register_history(name: str):
def decorator(cls: type[HistoryEncoder]) -> type[HistoryEncoder]:
HISTORY_REGISTRY[name] = cls
return cls
return decorator
def build_history(name: str, in_dim: int, out_dim: int, **kwargs) -> HistoryEncoder:
"""Factory: look up a `HistoryEncoder` subclass by name from the registry.
Every registered history type is fed the same `stage2_model.autoregressive`
kwargs; kwargs not declared by that type's constructor are silently
dropped, so per-type hyperparameters (e.g. `AttentionHistory`'s
`n_heads`/`n_layers`) can coexist in one config without special-casing —
mirrors `giant.model.routers.build_router`.
"""
if name not in HISTORY_REGISTRY:
raise ValueError(f"unknown history type {name!r}; available: {sorted(HISTORY_REGISTRY)}")
cls = HISTORY_REGISTRY[name]
accepted = set(inspect.signature(cls.__init__).parameters) - {"self", "in_dim", "out_dim"}
filtered = {k: v for k, v in kwargs.items() if k in accepted}
return cls(in_dim, out_dim, **filtered)
@register_history("markov")
class MarkovHistory(HistoryEncoder):
"""Summarizes the previous secondary's own `(energy_fraction, direction,
type_representation)` through one small MLP — the "markov" history:
@@ -90,6 +132,7 @@ class _CausalAttnBlock(nn.Module):
return x, kv
@register_history("attention")
class AttentionHistory(HistoryEncoder):
"""Causal self-attention over the emitted-token prefix — the more
expressive alternative to `MarkovHistory`'s fixed previous-token-only
@@ -137,16 +180,19 @@ class AttentionHistory(HistoryEncoder):
def step(
self,
token_feat: torch.Tensor,
feat: torch.Tensor,
has_prev: torch.Tensor,
cache: list[torch.Tensor | None],
) -> tuple[torch.Tensor, list[torch.Tensor | None]]:
"""`token_feat`/`has_prev`: `(B, 1, in_dim)`/`(B, 1)` — the newest
token's own features (what would be `feat[:, k]` in `forward`).
cache: object,
) -> tuple[torch.Tensor, object]:
"""`feat`/`has_prev`: `(B, 1, in_dim)`/`(B, 1)` — the newest token's
own features (what would be `feat[:, k]` in `forward`). `cache`: the
`list[Tensor | None]` from `init_cache`/a previous `step` call (typed
`object` here to match `HistoryEncoder.step`'s base signature).
Advances every block's cache by this position and returns this
position's output (`(B, 1, out_dim)`, the correct history summary for
the NEXT slot) plus the updated cache."""
x = self._embed(token_feat, has_prev)
assert isinstance(cache, list)
x = self._embed(feat, has_prev)
new_cache: list[torch.Tensor | None] = []
for block, kv in zip(self.blocks, cache):
x, kv_new = block.step(x, kv)
+10 -16
View File
@@ -6,7 +6,7 @@ import torch.nn as nn
from giant.constants import CONT_SLOT_DIM, K_MAX, PARTICLE_PHYS_DIM, SEC_DIM, SEC_SLOT_DIM, X_DIM
from giant.model.encoders import ConditionEncoder
from giant.model.history import AttentionHistory, HistoryEncoder, MarkovHistory
from giant.model.history import HistoryEncoder, build_history
from giant.model.layers import ContextAdapter, ResBlock, SinusoidalEmbedding
from giant.model.objectives import build_objective
from giant.model.routers import Router
@@ -358,8 +358,6 @@ class Stage2Autoregressive(nn.Module):
cond_enc: ConditionEncoder | None = None,
) -> None:
super().__init__()
if history not in ("markov", "attention"):
raise ValueError(f"stage2_model.autoregressive.history={history!r} — must be 'markov' or 'attention'")
self.history_kind = history
self.generator_kind = generator
self.noise_dim = noise_dim
@@ -384,10 +382,8 @@ class Stage2Autoregressive(nn.Module):
# this, a reasonable default rather than a design-doc-specified value.
history_dim = cond_out_dim
hist_in_dim = CONT_SLOT_DIM + self.type_dim
self.history_encoder: HistoryEncoder = (
AttentionHistory(hist_in_dim, history_dim, n_heads=attn_n_heads, n_layers=attn_n_layers)
if history == "attention"
else MarkovHistory(hist_in_dim, history_dim)
self.history_encoder: HistoryEncoder = build_history(
history, hist_in_dim, history_dim, n_heads=attn_n_heads, n_layers=attn_n_layers
)
token_fuse_in = cond_out_dim + context_dim + history_dim + 2 # +2: remaining_frac, slot_idx
self.token_fuse = nn.Sequential(
@@ -462,12 +458,12 @@ class Stage2Autoregressive(nn.Module):
def init_history_cache(self):
"""Inference-only incremental-decoding state for `self.history_encoder`
(`giant/sample.py`'s AR loop): `None` under `history="markov"` (its
per-step cost is already O(1) — see `HistoryEncoder`'s docstring), or
`AttentionHistory.init_cache()` under `history="attention"`."""
if isinstance(self.history_encoder, AttentionHistory):
return self.history_encoder.init_cache()
return None
(`giant/sample.py`'s AR loop) — whatever `self.history_encoder.init_cache()`
returns for the configured `history` type: `None` under `history="markov"`
(its per-step cost is already O(1) — see `HistoryEncoder`'s docstring),
or `AttentionHistory.init_cache()`'s real per-block KV cache under
`history="attention"`."""
return self.history_encoder.init_cache()
def history_step(self, token_feat: torch.Tensor, has_prev: torch.Tensor, cache) -> tuple[torch.Tensor, object]:
"""One inference slot's worth of history encoding: advances `cache`
@@ -479,9 +475,7 @@ class Stage2Autoregressive(nn.Module):
every model call made for this slot), `new_cache` is what to pass into
the *next* slot's `history_step`. Must be called exactly once per
slot — see `AttentionHistory.step`'s docstring."""
if isinstance(self.history_encoder, AttentionHistory):
return self.history_encoder.step(token_feat, has_prev, cache)
return self.history_encoder(token_feat, has_prev), cache
return self.history_encoder.step(token_feat, has_prev, cache)
def forward(
self,
+12 -1
View File
@@ -10,7 +10,15 @@ import X` call site keeps working unchanged.
from giant.model._legacy import _migrate_legacy_model_config, migrate_legacy_state_dict
from giant.model.builders import build_critics, build_models
from giant.model.encoders import ConditionEncoder, cat_col_layout
from giant.model.history import AttentionHistory, HistoryEncoder, MarkovHistory, _CausalAttnBlock
from giant.model.history import (
HISTORY_REGISTRY,
AttentionHistory,
HistoryEncoder,
MarkovHistory,
_CausalAttnBlock,
build_history,
register_history,
)
from giant.model.layers import (
BLOCK_REGISTRY,
AdaLNResBlock,
@@ -78,6 +86,7 @@ __all__ = [
"ExpertTrunk",
"FilmResBlock",
"FlowObjective",
"HISTORY_REGISTRY",
"HistoryEncoder",
"MarkovHistory",
"OBJECTIVE_REGISTRY",
@@ -106,6 +115,7 @@ __all__ = [
"build_composed_router",
"build_critics",
"build_expert_body",
"build_history",
"build_models",
"build_objective",
"build_router",
@@ -113,6 +123,7 @@ __all__ = [
"cat_col_layout",
"migrate_legacy_state_dict",
"register_block",
"register_history",
"register_objective",
"register_router",
"register_trunk",
+45
View File
@@ -5,14 +5,17 @@ import torch
from giant import config as gconfig
from giant.constants import CONT_SLOT_DIM, COND_DIM, PARTICLE_PHYS_DIM, SEC_SLOT_DIM
from giant.model.network import (
HISTORY_REGISTRY,
AttentionHistory,
ConditionEncoder,
HistoryEncoder,
MarkovHistory,
SinusoidalEmbedding,
Stage1Model,
Stage2Autoregressive,
Stage2OneShot,
build_critics,
build_history,
build_models,
cat_col_layout,
stage2_trunk_sec_dim,
@@ -407,6 +410,48 @@ def test_attention_history_step_matches_forward():
assert torch.allclose(stepped, expected, atol=1e-5)
# --- HISTORY_REGISTRY / build_history (gitea #35) ----------------------------
def test_history_registry_has_exactly_the_two_known_histories():
assert set(HISTORY_REGISTRY) == {"markov", "attention"}
def test_build_history_returns_correct_concrete_type():
assert isinstance(build_history("markov", 4, 6), MarkovHistory)
assert isinstance(build_history("attention", 4, 8), AttentionHistory)
def test_build_history_unknown_name_raises():
with pytest.raises(ValueError):
build_history("bogus", 4, 6)
def test_build_history_filters_kwargs_by_signature():
"""Attention-only kwargs (n_heads/n_layers) must be silently dropped when
building a MarkovHistory, matching build_router's documented behavior for
per-type hyperparameters coexisting in one config."""
hist = build_history("markov", 4, 6, n_heads=2, n_layers=1)
assert isinstance(hist, MarkovHistory)
def test_history_encoder_base_default_init_cache_and_step():
"""A HistoryEncoder subclass implementing only forward() must still get
working O(1) init_cache/step defaults from the base class."""
class _StubHistory(HistoryEncoder):
def forward(self, feat, has_prev):
return feat * 2
hist = _StubHistory()
assert hist.init_cache() is None
feat = torch.randn(2, 1, 4)
has_prev = torch.ones(2, 1, dtype=torch.bool)
out, cache = hist.step(feat, has_prev, "unused-cache")
assert torch.equal(out, hist.forward(feat, has_prev))
assert cache == "unused-cache"
# --- Stage2Autoregressive (v0.3.0 step 5) -----------------------------------