refactor(per-horizon-cfc): atomically remove MTER scaffolding

Per spec §2.5 and feedback_no_partial_refactor. Removes:
- LoaderMode enum (single-variant after Sequential removal → deleted entirely)
- next_sequence_sequential + sequential_file_idx + sequential_anchor_idx + last_call_was_file_boundary + is_file_boundary
- last_seen_file_boundary, train_graph_boundary_state fields
- notify_file_boundary method + boundary-state graph recapture logic
- need_attn_pool_bootstrap match — always true now (random mode always bootstraps)
- --loader-mode CLI flag + notify_file_boundary() call
- loader-mode Argo template param

Additional consumers migrated atomically (beyond the 4 files listed in
the plan): ml-alpha/tests/perception_overfit.rs,
ml-alpha/tests/multi_horizon_loader.rs, ml-backtesting/src/harness.rs,
ml-backtesting/tests/trainer_parity.rs,
ml-backtesting/tests/ring3_replay.rs — all referenced LoaderMode or the
removed config fields.

Workspace builds clean at this commit (pre-existing cudarc-cupti example
and ml-crate test errors are unrelated to MTER removal — they fail at
HEAD too). New per-horizon CfC arch lands in subsequent tasks of the
same plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-21 15:36:28 +02:00
parent ed34d356a8
commit f13d6c6dcf
9 changed files with 15 additions and 199 deletions

View File

@@ -24,7 +24,7 @@
use anyhow::{Context, Result};
use clap::Parser;
use ml_alpha::cfc::snap_features::Mbp10RawInput;
use ml_alpha::data::loader::{LoaderMode, MultiHorizonLoader, MultiHorizonLoaderConfig};
use ml_alpha::data::loader::{MultiHorizonLoader, MultiHorizonLoaderConfig};
use ml_alpha::eval::auc::{compute_auc, AucInput};
use ml_alpha::heads::N_HORIZONS;
use ml_alpha::trainer::perception::{auto_horizon_weights, PerceptionTrainer, PerceptionTrainerConfig};
@@ -153,11 +153,6 @@ struct Cli {
#[arg(long, default_value_t = 0)]
cv_train_window: usize,
/// Sequence-sampling mode. `random` = legacy; `sequential` = required for MTER (CRT.train intervention B).
/// See docs/superpowers/specs/2026-05-21-crt-train-intervention-b-multi-timescale-readout.md.
#[arg(long, default_value = "random")]
loader_mode: String,
/// CRT.train intervention A — output-smoothness regularizer.
///
/// λ[h] = base × (HORIZONS[h] / HORIZONS[0]). Default 0.0 disables
@@ -282,8 +277,6 @@ fn main() -> Result<()> {
);
anyhow::ensure!(cli.batch_size >= 1, "batch_size must be >= 1");
let loader_mode: LoaderMode = cli.loader_mode.parse()
.map_err(|e: String| anyhow::anyhow!("invalid --loader-mode={}: {e}", cli.loader_mode))?;
let trainer_cfg = PerceptionTrainerConfig {
seq_len: cli.seq_len,
mamba2_state_dim: cli.mamba2_state_dim,
@@ -294,7 +287,6 @@ fn main() -> Result<()> {
n_batch: cli.batch_size,
smoothness_base_lambda: cli.smoothness_base_lambda,
kernel_step_trace_path: cli.kernel_step_trace.clone(),
loader_mode,
};
let mut trainer = PerceptionTrainer::new(&dev, &trainer_cfg).context("trainer init")?;
@@ -425,7 +417,6 @@ fn main() -> Result<()> {
n_max_sequences: cli.n_train_seqs,
seed: cli.seed,
inference_only: false,
mode: loader_mode,
})
.context("train loader")?;
let mut val_loader = MultiHorizonLoader::new(&MultiHorizonLoaderConfig {
@@ -436,7 +427,6 @@ fn main() -> Result<()> {
n_max_sequences: cli.n_val_seqs,
seed: cli.seed.wrapping_add(0xC0FFEE),
inference_only: false,
mode: loader_mode,
})
.context("val loader")?;
tracing::info!(
@@ -480,8 +470,6 @@ fn main() -> Result<()> {
let snap_refs: Vec<&[Mbp10RawInput]> = snap_batch.iter().map(|v| v.as_slice()).collect();
let label_refs: Vec<&[[f32; N_HORIZONS]]> = label_batch.iter().map(|v| v.as_slice()).collect();
let is_boundary = train_loader.is_file_boundary();
trainer.notify_file_boundary(is_boundary);
let loss = trainer.step_batched(&snap_refs, &label_refs).context("train step_batched")?;
epoch_train_loss += loss;
epoch_train_steps += 1;

View File

@@ -21,35 +21,6 @@ use rand_chacha::ChaCha8Rng;
use crate::cfc::snap_features::{Mbp10RawInput, ES_TICK_SIZE, REGIME_DIM};
use crate::multi_horizon_labels::generate_labels;
/// Sequence-sampling mode for the loader.
///
/// - `Random`: original behaviour. `next_sequence()` picks a random file and random anchor.
/// - `Sequential`: each file is consumed as consecutive K-position chunks in temporal order.
/// Required for MTER (CRT.train intervention B) so training-time h_view distribution
/// matches inference-time geometry.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LoaderMode {
Random,
Sequential,
}
impl Default for LoaderMode {
fn default() -> Self {
Self::Random
}
}
impl std::str::FromStr for LoaderMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"random" => Ok(Self::Random),
"sequential" => Ok(Self::Sequential),
other => Err(format!("unknown loader mode: {other} (expected 'random' or 'sequential')")),
}
}
}
/// EMA smoothing factors. Half-life ≈ ln(2)/α snapshots.
const ALPHA_MED: f32 = 0.02; // half-life ≈ 35 snapshots ≈ 9s @ 250ms
const ALPHA_SLOW: f32 = 0.0005; // half-life ≈ 1400 snapshots ≈ 6 min
@@ -142,8 +113,6 @@ pub struct MultiHorizonLoaderConfig {
/// LOB backtest harness; trainer always passes `false`. See
/// `docs/superpowers/specs/2026-05-18-real-lob-integration-design.md` §1.
pub inference_only: bool,
/// Sequence-sampling mode. Default `Random` for backward compat. MTER training requires `Sequential`.
pub mode: LoaderMode,
}
/// Discover MBP-10 files under `root` and return them sorted by filename.
@@ -206,13 +175,6 @@ pub struct MultiHorizonLoader {
/// when `cfg.inference_only == true`.
inference_file_idx: usize,
inference_snap_idx: usize,
/// Sequential-mode cursor: current file index in `files_loaded`.
sequential_file_idx: usize,
/// Sequential-mode cursor: current anchor position WITHIN the current file.
sequential_anchor_idx: usize,
/// `true` if the most recent `next_sequence()` call crossed a file boundary.
/// Cleared on each `next_sequence()` call; consulted by trainer via `is_file_boundary()`.
last_call_was_file_boundary: bool,
}
impl MultiHorizonLoader {
@@ -282,9 +244,6 @@ impl MultiHorizonLoader {
files_loaded,
inference_file_idx: 0,
inference_snap_idx: 0,
sequential_file_idx: 0,
sequential_anchor_idx: 0,
last_call_was_file_boundary: false,
})
}
@@ -360,17 +319,7 @@ impl MultiHorizonLoader {
}
pub fn next_sequence(&mut self) -> Result<Option<LabeledSequence>> {
match self.cfg.mode {
LoaderMode::Random => self.next_sequence_random(),
LoaderMode::Sequential => self.next_sequence_sequential(),
}
}
/// `true` if the most recent `next_sequence()` call crossed a file boundary in sequential mode.
/// Trainer consults this to decide whether to re-bootstrap stateful buffers.
/// Always returns `false` in `Random` mode.
pub fn is_file_boundary(&self) -> bool {
self.last_call_was_file_boundary
self.next_sequence_random()
}
fn next_sequence_random(&mut self) -> Result<Option<LabeledSequence>> {
@@ -416,61 +365,6 @@ impl MultiHorizonLoader {
self.yielded += 1;
Ok(Some(LabeledSequence { snapshots: sequence, labels }))
}
fn next_sequence_sequential(&mut self) -> Result<Option<LabeledSequence>> {
if self.yielded >= self.cfg.n_max_sequences {
return Ok(None);
}
let max_horizon = *self.cfg.horizons.iter().max().expect("non-empty horizons");
let needed = self.cfg.seq_len + max_horizon;
// Advance cursor: find the next file with enough remaining snapshots from current anchor.
let mut file_boundary = false;
loop {
if self.sequential_file_idx >= self.files_loaded.len() {
// Wrap around to the start of the file list. Treat the wrap as a file boundary
// so the trainer re-bootstraps state.
self.sequential_file_idx = 0;
self.sequential_anchor_idx = 0;
file_boundary = true;
}
let lf = &self.files_loaded[self.sequential_file_idx];
if self.sequential_anchor_idx + needed <= lf.snapshots.len() {
break;
}
// This file has no more room; advance.
self.sequential_file_idx += 1;
self.sequential_anchor_idx = 0;
file_boundary = true;
}
self.last_call_was_file_boundary = file_boundary;
let lf = &self.files_loaded[self.sequential_file_idx];
let anchor = self.sequential_anchor_idx;
// Build the labelled sequence (mirror logic from `next_sequence_random`).
let mut labels: [Vec<f32>; 5] = Default::default();
for h in 0..5 {
let mut row = Vec::with_capacity(self.cfg.seq_len);
for k in 0..self.cfg.seq_len {
row.push(lf.labels_full[h][anchor + k]);
}
labels[h] = row;
}
let mut sequence = Vec::with_capacity(self.cfg.seq_len);
for k in 0..self.cfg.seq_len {
let idx = anchor + k;
let cur = &lf.snapshots[idx];
let prev_idx = if idx == 0 { 0 } else { idx - 1 };
let prev = &lf.snapshots[prev_idx];
sequence.push(convert(cur, prev, lf.regime_full[idx]));
}
// Advance cursor by seq_len (no overlap between sequences).
self.sequential_anchor_idx += self.cfg.seq_len;
self.yielded += 1;
Ok(Some(LabeledSequence { snapshots: sequence, labels }))
}
}
fn mid_price_f32(s: &Mbp10Snapshot) -> f32 {
@@ -648,7 +542,6 @@ mod inference_mode_tests {
n_max_sequences: 1,
seed: 0xCAFEF00D,
inference_only,
mode: LoaderMode::default(),
})
}
@@ -729,7 +622,6 @@ mod inference_mode_tests {
n_max_sequences: 1,
seed: 0,
inference_only: false,
mode: LoaderMode::default(),
};
if let Ok(mut loader) = MultiHorizonLoader::new(&cfg) {
let err = loader.next_inference_input();

View File

@@ -46,7 +46,6 @@ use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
use crate::cfc::snap_features::{Mbp10RawInput, ES_TICK_SIZE, FEATURE_DIM, REGIME_DIM};
use crate::data::loader::LoaderMode;
use crate::heads::{HEAD_MID_DIM, HIDDEN_DIM, N_HORIZONS};
use crate::mamba2_block::{
Mamba2AdamW, Mamba2AdamWConfig, Mamba2BackwardGradsBuffers, Mamba2BackwardScratch,
@@ -102,10 +101,6 @@ pub struct PerceptionTrainerConfig {
/// Per `feedback_no_feature_flags`: gated by the compile-time
/// `kernel-step-trace` feature; specific name justifies the gate.
pub kernel_step_trace_path: Option<std::path::PathBuf>,
/// Sequence-sampling mode. Affects whether attn_pool resets at every sequence
/// (Random) or only at file boundaries (Sequential).
pub loader_mode: LoaderMode,
}
impl Default for PerceptionTrainerConfig {
@@ -120,7 +115,6 @@ impl Default for PerceptionTrainerConfig {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
loader_mode: LoaderMode::Random,
}
}
}
@@ -152,10 +146,6 @@ pub fn auto_horizon_weights(_seq_len: usize, _horizons: &[usize; N_HORIZONS]) ->
pub struct PerceptionTrainer {
cfg: PerceptionTrainerConfig,
stream: Arc<CudaStream>,
/// Set by `notify_file_boundary()` BEFORE each `step_batched` call in sequential mode.
/// Trainer uses this to gate attn_pool reset (and later, stateful CfC + MTER bootstrap).
/// Always treated as `true` in Random mode (every sequence is a boundary).
last_seen_file_boundary: bool,
// Modules + cached function handles
_snap_module: Arc<CudaModule>,
@@ -548,14 +538,6 @@ pub struct PerceptionTrainer {
/// call runs uncaptured (warmup); second call captures; third+
/// replays. Eliminates ~155 individual kernel launches per step.
train_graph: Option<CudaGraph>,
/// Snapshot of `last_seen_file_boundary` at the time `train_graph` was
/// captured. The captured graph records the attn_pool decision at
/// capture time (per `pearl_no_host_branches_in_captured_graph`); if
/// the current boundary state diverges from the captured one, the
/// graph is dropped and re-captured on the next step. In Random mode
/// this never changes (always `true`), so no re-capture. In Sequential
/// mode this triggers a re-capture per file boundary (~8 per epoch).
train_graph_boundary_state: Option<bool>,
/// Captured CUDA Graph for the inference (`forward_only`) path. Same
/// three-state machine as `train_graph`: first call eager (warmup),
/// second call captures, third+ replays. Mirrors the forward chain
@@ -1221,7 +1203,6 @@ impl PerceptionTrainer {
let k = cfg.seq_len;
Ok(Self {
cfg: cfg.clone(),
last_seen_file_boundary: true,
h_new_per_k_d: stream.alloc_zeros::<f32>(k * cfg.n_batch * n_hid)?,
probs_per_k_d: stream.alloc_zeros::<f32>(k * cfg.n_batch * N_HORIZONS)?,
labels_per_k_d: stream.alloc_zeros::<f32>(k * cfg.n_batch * N_HORIZONS)?,
@@ -1347,7 +1328,6 @@ impl PerceptionTrainer {
logit_per_k_d: stream.alloc_zeros::<f32>(k * cfg.n_batch * N_HORIZONS)?,
stg_labels: unsafe { MappedF32Buffer::new(k * cfg.n_batch * N_HORIZONS) }.map_err(|e| anyhow::anyhow!("stg_labels: {e}"))?,
train_graph: None,
train_graph_boundary_state: None,
forward_graph: None,
forward_warmed: false,
cublas_warmed: false,
@@ -1658,22 +1638,10 @@ impl PerceptionTrainer {
}
}
// ── 2. Four-state machine: warmup (first), capture (second),
// replay (third+), recapture-on-boundary-change.
// The captured graph records the attn_pool decision at
// capture time (per `pearl_no_host_branches_in_captured_graph`).
// In Sequential mode the decision toggles between sequences
// within vs at file boundaries, so the cached graph is
// invalidated whenever `last_seen_file_boundary` diverges
// from the captured value. Random mode never diverges
// (always `true`), so no recapture.
if let Some(captured_state) = self.train_graph_boundary_state {
if captured_state != self.last_seen_file_boundary {
// Boundary state changed — drop cached graph, fall through to recapture.
self.train_graph = None;
self.train_graph_boundary_state = None;
}
}
// ── 2. Three-state machine: warmup (first), capture (second),
// replay (third+). The captured graph records all
// in-graph kernel decisions at capture time per
// `pearl_no_host_branches_in_captured_graph`.
if self.train_graph.is_some() {
self.train_graph
.as_ref()
@@ -1710,7 +1678,6 @@ impl PerceptionTrainer {
"train end_capture returned None — no work captured"
))?;
self.train_graph = Some(graph);
self.train_graph_boundary_state = Some(self.last_seen_file_boundary);
}
// ── 3. Sync + read mapped-pinned loss.
@@ -1929,18 +1896,11 @@ impl PerceptionTrainer {
// Shared mem: K floats (scores) + BLOCK floats (reduce) +
// HIDDEN_DIM floats (context) = (K + 128 + 128) * 4 bytes.
//
// MTER (CRT.train intervention B) commit 1: gate the bootstrap
// launch on `last_seen_file_boundary`. In Random mode the host
// bool is unconditionally true so the launch is always recorded
// into the captured graph — bit-identical to pre-gate behavior.
// In Sequential mode mid-file, the gate skips re-bootstrap so
// the previous step's pooled context carries forward. (Future
// commits in this intervention introduce stateful CfC + MTER
// bootstrap that consume the same boundary signal.)
let need_attn_pool_bootstrap = match self.cfg.loader_mode {
LoaderMode::Random => true,
LoaderMode::Sequential => self.last_seen_file_boundary,
};
// Random-sampling training always re-bootstraps the pooled
// context at sequence start — sequences are independent draws,
// so the previous step's h_old/attn context carries no useful
// information across the boundary.
let need_attn_pool_bootstrap = true;
if need_attn_pool_bootstrap {
let k_i32 = k_seq as i32;
let n_batch_attn = b_sz as i32;
@@ -2880,13 +2840,6 @@ impl PerceptionTrainer {
&self.cfg
}
/// Trainer-orchestrator API. Call this BEFORE `step()`/`step_batched()` to signal whether
/// the sequence we're about to consume crosses a file boundary in sequential mode.
/// Loaders provide this via `MultiHorizonLoader::is_file_boundary()`.
pub fn notify_file_boundary(&mut self, is_boundary: bool) {
self.last_seen_file_boundary = is_boundary;
}
/// X11 inference entry point: forward-only pass over `snapshots`
/// (length `cfg.n_batch * cfg.seq_len` per the trainer's batching).
/// Returns per-horizon probabilities flattened as `[K, B, N_HORIZONS]`

View File

@@ -3,7 +3,7 @@
//! Real-data path is `--ignored`; runs only with FOXHUNT_TEST_DATA set.
use ml_alpha::data::loader::{
discover_mbp10_files_sorted, LoaderMode, MultiHorizonLoader, MultiHorizonLoaderConfig,
discover_mbp10_files_sorted, MultiHorizonLoader, MultiHorizonLoaderConfig,
};
use std::path::PathBuf;
@@ -23,7 +23,6 @@ fn cfg_from_env() -> Option<MultiHorizonLoaderConfig> {
n_max_sequences: 100,
seed: 0xA1A2_A3A4,
inference_only: false,
mode: LoaderMode::default(),
})
}
@@ -66,7 +65,6 @@ fn loader_errors_on_empty_files() {
n_max_sequences: 10,
seed: 0,
inference_only: false,
mode: LoaderMode::default(),
};
let res = MultiHorizonLoader::new(&cfg);
assert!(res.is_err(), "expected error for empty file list");

View File

@@ -7,7 +7,6 @@
//! end-to-end and that all 6 AdamW optimizers actually move weights.
use ml_alpha::cfc::snap_features::Mbp10RawInput;
use ml_alpha::data::loader::LoaderMode;
use ml_alpha::trainer::perception::{PerceptionTrainer, PerceptionTrainerConfig};
use ml_core::device::MlDevice;
@@ -75,7 +74,6 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
loader_mode: LoaderMode::Random,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -151,7 +149,6 @@ fn stacked_trainer_loss_shrinks_with_stride_4() {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
loader_mode: LoaderMode::Random,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -207,7 +204,6 @@ fn stacked_trainer_loss_shrinks_at_batch_32() {
n_batch: 32,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
loader_mode: LoaderMode::Random,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -307,7 +303,6 @@ fn evaluate_alone_succeeds() {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
loader_mode: LoaderMode::Random,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let ts = 1_000_000u64;
@@ -337,7 +332,6 @@ fn evaluate_works_after_captured_training_step() {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
loader_mode: LoaderMode::Random,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -374,7 +368,6 @@ fn evaluate_works_after_capture_no_replay() {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
loader_mode: LoaderMode::Random,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let ts = 1_000_000u64;
@@ -406,7 +399,6 @@ fn horizon_ema_and_lambda_track_after_training() {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
loader_mode: LoaderMode::Random,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
@@ -464,7 +456,6 @@ fn evaluate_works_after_warmup_only() {
n_batch: 1,
smoothness_base_lambda: 0.0,
kernel_step_trace_path: None,
loader_mode: LoaderMode::Random,
};
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
let ts = 1_000_000u64;

View File

@@ -13,7 +13,7 @@
use anyhow::{Context, Result};
use ml_alpha::cfc::snap_features::Mbp10RawInput;
use ml_alpha::data::loader::{
discover_mbp10_files_sorted, LoaderMode, MultiHorizonLoader, MultiHorizonLoaderConfig,
discover_mbp10_files_sorted, MultiHorizonLoader, MultiHorizonLoaderConfig,
};
use ml_alpha::trainer::perception::PerceptionTrainer;
use ml_core::device::MlDevice;
@@ -149,7 +149,6 @@ impl BacktestHarness {
seed: 0,
// A1: decision_stride removed; inference mode walks every event at stride=1.
inference_only: true,
mode: LoaderMode::default(),
};
let loader = MultiHorizonLoader::new(&loader_cfg)?;

View File

@@ -25,7 +25,7 @@
use anyhow::Result;
use ml_alpha::data::loader::{
discover_mbp10_files_sorted, LoaderMode, MultiHorizonLoader, MultiHorizonLoaderConfig,
discover_mbp10_files_sorted, MultiHorizonLoader, MultiHorizonLoaderConfig,
};
use ml_backtesting::sim::LobSimCuda;
use ml_core::device::MlDevice;
@@ -48,7 +48,6 @@ fn try_loader() -> Option<MultiHorizonLoader> {
n_max_sequences: 0,
seed: 0xCAFE_F00D,
inference_only: true,
mode: LoaderMode::default(),
};
match MultiHorizonLoader::new(&cfg) {
Ok(l) => Some(l),

View File

@@ -16,7 +16,7 @@
use anyhow::Result;
use ml_alpha::data::loader::{
discover_mbp10_files_sorted, LoaderMode, MultiHorizonLoader, MultiHorizonLoaderConfig,
discover_mbp10_files_sorted, MultiHorizonLoader, MultiHorizonLoaderConfig,
};
use std::path::PathBuf;
@@ -35,7 +35,6 @@ fn try_loader(inference_only: bool) -> Option<MultiHorizonLoader> {
n_max_sequences: 1,
seed: 0xCAFEF00D,
inference_only,
mode: LoaderMode::default(),
};
match MultiHorizonLoader::new(&cfg) {
Ok(l) => Some(l),

View File

@@ -76,8 +76,6 @@ spec:
value: "1"
- name: cv-train-window
value: "0"
- name: loader-mode
value: "random"
- name: smoothness-base-lambda
value: "0.0"
- name: kernel-step-trace-enable
@@ -471,7 +469,6 @@ spec:
--cv-fold {{workflow.parameters.cv-fold}} \
--cv-n-folds {{workflow.parameters.cv-n-folds}} \
--cv-train-window {{workflow.parameters.cv-train-window}} \
--loader-mode "{{workflow.parameters.loader-mode}}" \
--smoothness-base-lambda {{workflow.parameters.smoothness-base-lambda}} \
${TRACE_FLAG} \
$EXTRA_FLAGS