diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..66795797f623344de9947679cb7b111c5c671a3f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +frontend/node_modules +frontend/dist +.venv +.venv_* +.git +__pycache__ +*.pyc +.uv_cache +.uv_pythons diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..00b4388e3488b98c9653a1ad88d005069a8e675f --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +airline_routes.json filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..80b8b651e1446b2108a64902badcf2e8f2e0da77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +frontend/dist/ +.venv/ +.venv_*/ +__pycache__/ +*.pyc +.uv_cache/ +.uv_pythons/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..a986bf4672c21ade49cd7ab2be0549e32674b4c1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,518 @@ +# CLAUDE.md — Rules for AI Assistants (ECMoE Project) + +## MANDATORY FIRST STEPS + +**Before taking ANY action on a task, you MUST:** + +1. Tell the user you have read CLAUDE.md and how you'll follow the THREE RULES +2. **Actually read these files** (not optional): + - **README.md** — Directory structure, setup, how to run experiments + - **JOURNAL.md** — Recent bugs, what's broken/fixed, latest results + - **description.md** — Detailed method descriptions, design choices, hyperparameters + +**Do NOT skip this to "get to work faster."** Skipping causes you to use wrong directories, miss known issues, and waste time on already-solved problems. + +--- + +## THE THREE RULES + +### 1. EDIT, NEVER REWRITE +- **ALWAYS edit existing code, NEVER rewrite from scratch** +- Find the exact file/function, make surgical changes with Edit tool +- If you're about to write 50+ lines of new code doing something similar to existing code, STOP +- Reuse existing classes: `Compressor`, `Decompressor`, `StaleDecompressor`, `train_compressor`, etc. + +### 2. VALIDATE DATA BEFORE PLOTTING +- Always load results from JSON files, never hardcode values +- If a number looks different than expected, investigate before proceeding +- Check `results/summary/all_results_summary.json` for the canonical results + +### 3. COMMIT AND DOCUMENT IMMEDIATELY +- `git commit` after every fix (no remote configured — push when available) +- Update `JOURNAL.md` right after committing +- Don't batch changes — commit as you go + +--- + +## MINDSET: NO SHORTCUTS + +- Academic rigor means doing things RIGHT, not just doing things FAST +- Be skeptical of your own first approach — question whether it could be better +- Don't simplify the requirement — solve the actual problem + +--- + +## Communication + +**When showing results or finishing tasks:** +- ALWAYS provide the **full absolute path** to any files created or modified +- Example: "View the result at: `/project/6004852/lfy/ECMoE/results/summary/ppl_vs_ratio_all.png`" + +--- + +## Project-Specific Rules + +### Environment Setup (Compute Canada) + +```bash +# Modules MUST be loaded BEFORE activating venv +module load cuda/12.6 arrow/22.0.0 +source .venv/bin/activate + +# HuggingFace cache goes to persistent project dir (home quota is small) +export HF_HOME=/home/lfy/projects/rrg-bengioy-ad/lfy/ECMoE/.cache/huggingface +``` + +### Directory Structure + +``` +src/ # Python source code +scripts/ # Bash wrappers for each experiment +results/ # ALL experiment outputs (gitignored) + 01_distribution/ # Task 1: distribution analysis + 02_quantization/ # Task 2: quantization baseline + 03_neural_compressor/ # Task 3: shared neural compressor + 03b_perlayer_compressor/ # Task 3b: per-layer neural compressor + 04a_stale_compressed/ # Task 4a: stale-conditioned (compressed stale) + 04b_stale_uncompressed/ # Task 4b: stale-conditioned (uncompressed stale) + 05a_e2e_perlayer/ # Task 5a: e2e per-layer compressor (no stale) + 05b_e2e_stale/ # Task 5b: e2e stale-conditioned compressor + 05c_e2e_baseline/ # Task 5c: baseline (no compression, same pipeline) + 05c_megatron_e2e_baseline/ # Task 5c: baseline (Megatron variant) + 06a_megatron_e2e_pretrained_perlayer/ # Task 6a: e2e with 3b init (Megatron) + 06b_megatron_e2e_pretrained_stale/ # Task 6b: e2e with 4b init (Megatron) + 07a_megatron_e2e_split_perlayer/ # Task 7a: split-mode e2e (router=original) + 07b_megatron_e2e_split_stale/ # Task 7b: split-mode e2e + stale + 08_ep_compression/ # Task 8: EP compression eval (uses 7a/7b weights) + summary/ # Cross-method comparison plots and tables +data/hidden_states/ # Cached MoE hidden states (gitignored, ~37 GB in bfloat16) +``` + +### Key Code Architecture + +- **`src/model_utils.py`** — Central library: model loading, MoE detection, hidden state + collection, ALL perplexity evaluation functions (baseline, shared, per-layer, stale) +- **`src/metrics.py`** — Reconstruction metrics: MSE, cosine similarity, relative error, SNR +- **`src/run_neural_compressor.py`** — Defines `Compressor`, `Decompressor`, `train_compressor()`. + Other scripts import from here — never duplicate these classes +- **`src/run_stale_compressor.py`** — Defines `StaleDecompressor`, `train_stale_compressor()` +- **`src/run_e2e_compressor.py`** — End-to-end training of per-layer compressors via LM loss. + Defines `E2ECompressorManager`, `SFTDataset`. Uses Dolci-Instruct-SFT with SFT mode + (response-only training). `_tokenize_sft_sample()` in `model_utils.py` handles the + response-only label masking. +- **`src/vllm_ep_compression.py`** — EP-aware compress/decompress registration for vLLM. + Sets `_ecmoe_compress_fn` / `_ecmoe_decompress_fn` on FusedMoE instances via + `apply_model()`. Supports per-layer and stale-conditioned methods. Requires patched + vLLM (`.venv_vllm_exp`). +- **`src/run_ep_compression_eval.py`** — Task 8 entry point: evaluates EP compression + with actual dispatch/combine in vLLM. Two modes: `simulation` (single-GPU) and `ep` + (multi-GPU with `enable_expert_parallel=True`). Uses Task 7a/7b weights. +- **`src/visualize_all_results.py`** — Generates all cross-method comparison plots and tables +- **`src/downstream_eval.py`** — Shared utility for downstream task evaluation via lm-eval-harness. + Provides hook registration functions (`register_quantization_hooks`, `register_perlayer_hooks`, + `register_stale_hooks`, `register_e2e_hooks`), `run_lm_eval()` wrapper, and result saving. + Imported by each task script when `--downstream-tasks` is specified. + Also provides vLLM backend support via apply_model pattern: `create_vllm_backend()`, + `register_perlayer_hooks_vllm()`, `register_stale_hooks_vllm()`, + `register_quantization_hooks_vllm()`, `remove_hooks_vllm()`. + Split (router-uncompressed) mode: `register_perlayer_hooks_split()`, + `register_stale_hooks_split()` for HF, and `register_perlayer_hooks_split_vllm()`, + `register_stale_hooks_split_vllm()` for vLLM. In split mode, the router sees original + hidden states while experts see decompressed — more realistic EP simulation. +- **`src/run_all_downstream.py`** — Standalone downstream evaluator. Loads model once, + evaluates all methods sequentially. Supports `--backend hf/vllm` and + `--router-mode compressed/uncompressed`. + +### Known Issues / Gotchas + +**Layer sorting:** Always use `sorted(keys, key=layer_index)` from `model_utils`. Lexicographic +sorting puts layer 10 before layer 2 (`model.layers.10` < `model.layers.2`). + +**Dtype mismatch:** Dequantized tensors and neural compressor outputs must match the model's +activation dtype (bfloat16). Always cast: `.to(x.dtype).to(x.device)`. + +**What went wrong (2026-02-11):** `absmax_dequantize` returned float32 but model expected +bfloat16, causing `RuntimeError` during perplexity eval. Fix: explicit `.to(scale.dtype)` cast. + +**What went wrong (2026-02-11):** When asked to remove quantization for Tasks 1–4, the agent +implemented the change (default `load_in_4bit=False`, `device="auto"`) without the user having +specified this as a hyperparameter. The model loading precision (BF16 vs 4-bit NF4) is a key +experimental parameter — changing it retroactively means old results are no longer reproducible +with default settings. **Lesson:** Treat model loading precision as a hyperparameter. Do NOT +change defaults that affect reproducibility without explicit user instruction. When the user says +"remove quantization", ASK whether they want it as a new default or as a CLI override. + +**Response-only hidden state collection:** `collect_hidden_states()` defaults to +`response_only=True` — only assistant-response tokens are captured (labels != -100). +This ensures offline compressor training (Tasks 2–4) trains on the same distribution +that PPL evaluation measures. Use `--no-response-only` in `run_distribution.py` for +legacy all-token collection. Metadata records `"response_only": true/false`. + +**Legacy Megatron script deleted:** `src/run_megatron_e2e_compressor.py` was removed because +it used `PackedTokenDataset` + `labels=input_ids` (standard LM, not SFT response-only), +did not use `get_split_indices()`, and misreported effective batch size with DP > 1. +Always use `src/megatron_e2e/train.py` for Megatron-based training. + +**Large data files:** Hidden states for 100K tokens are ~18.5 GB per file in bfloat16 +(dispatch + gather = ~37 GB). These are gitignored. Never try to `git add` them. + +**Model VRAM:** Model is loaded in full BF16 (~60 GB). Tasks 1–4 use single GPU +(`device="cuda:0"`) — the model fits on one H100 80 GB with headroom for inference. +Task 5 uses multi-GPU (`device_map="auto"`) because backprop needs extra VRAM. +4-bit NF4 loading (~15 GB) is available via `--load-in-4bit` but is NOT the default. + +**device="auto" vs tensor ops:** When `device="auto"` is used for model loading (Task 5), +`"auto"` is NOT a valid torch device for tensor operations. Scripts that do `.to(device)` or +`train_compressor(device=...)` must use `compute_device` (resolved to `"cuda:0"` when +`device="auto"`). Only `load_model_and_tokenizer()` accepts `"auto"` directly. +Tasks 1–4 default to `device="cuda:0"` so this is only relevant for Task 5. + +**Hook device safety (2026-02-17):** With `device_map="auto"`, model layers may reside on +different GPUs. PPL evaluation hooks in `model_utils.py` now explicitly call `.to(x.device)` +on compressor/decompressor outputs before returning them to the model. This is a no-op when +compressor and layer are on the same device but prevents cross-device errors when they differ. + +### vLLM Environment (downstream evaluation) + +**vLLM backend:** `src/downstream_eval.py` + `src/run_all_downstream.py` — vLLM 0.8.4+ +for downstream task evaluation with compression hooks. + +```bash +# Separate venv from HF-based experiments — CUDA 12.6 +module load cuda/12.6 arrow/22.0.0 +source .venv_vllm/bin/activate +export HF_HOME=/home/lfy/projects/rrg-bengioy-ad/lfy/ECMoE/.cache/huggingface + +# Setup (first time only): +bash scripts/vllm_setup_env.sh +``` + +**Known issues / gotchas (vLLM):** +- **vLLM V1 engine (>= 0.15):** The model runs in a **separate subprocess** (EngineCore). + You CANNOT access the model directly from the main process. The old path + `llm_engine.model_executor.driver_worker.model_runner.model` does NOT work. + Instead, use `vllm.LLM.apply_model(func)` to send functions to the worker process. + Functions are serialized via cloudpickle — they must be self-contained (include their + own imports and class definitions). Requires `VLLM_ALLOW_INSECURE_SERIALIZATION=1`. + `create_vllm_backend()` sets this automatically. +- **enforce_eager=True required:** vLLM's CUDA graph capture prevents PyTorch hooks + from being called. Always use `enforce_eager=True` when registering compression hooks. + `create_vllm_backend()` sets this automatically. +- **Hook registration pattern:** All vLLM hook functions use the apply_model pattern: + `_vllm_register_perlayer()` returns a closure → `vllm_llm.apply_model(closure)`. + The closure runs inside the worker, loads weights, creates compressor modules, + and registers PyTorch pre-hooks. Cleanup via `_vllm_remove_hooks()` → `remove_hooks_vllm()`. +- **Layer name mapping:** vLLM may use different module paths than HF. `_map_layer_name()` + maps by numeric layer index, which is robust to naming differences. +- **Two router modes (--router-mode):** + - `compressed` (default): Pre-hook compress→decompress. Router AND experts see + decompressed. Conservative lower bound — same as the original PPL evaluation hooks. + - `uncompressed`: Split forward — router sees ORIGINAL input, experts see decompressed. + More realistic EP simulation where router runs on source GPU with original data. + Both modes work for HF and vLLM backends. +- **No multi-device placement:** The plan called for `compressor_device` (attention GPU) + vs `decompressor_devices` (expert GPUs) to simulate the actual communication topology. + Current implementation puts both compressor and decompressor on the same device. This + doesn't affect quality measurement (the math is device-independent) but doesn't + demonstrate the real communication pattern or measure cross-device overhead. +- **No shared expert handling:** Split mode omits `shared_expert` / + `shared_expert_gate` logic. Qwen3-30B-A3B doesn't use shared experts so this is + correct for the current model, but reduces generality. +- **No separate E2E hooks for vLLM:** E2E and offline weights have identical format. + `register_perlayer_hooks_vllm()` works for 3b + 5a + 6a weights. + `register_stale_hooks_vllm()` works for 4a/4b + 5b + 6b weights. +- **TP > 1 with vLLM:** When using tensor parallelism, each rank has a partial model. + Hook registration should still work (hooks are on the full module), but compressor + modules stay on one device. Tested with TP=1 by default. + +**vLLM-specific directories:** +``` +.venv_vllm/ # Separate virtual environment (gitignored) +``` + +### vLLM EP Compression Environment (Task 8) + +**EP compression:** `src/vllm_ep_compression.py` — Sets compress/decompress functions +on FusedMoE instances. Patched `forward_impl()` calls compress BEFORE dispatch and +decompress AFTER, achieving real communication reduction. + +```bash +# Separate venv with patched vLLM 0.15.1 — CUDA 12.6 +module load cuda/12.6 arrow/22.0.0 +source .venv_vllm_exp/bin/activate +export HF_HOME=/home/lfy/projects/rrg-bengioy-ad/lfy/ECMoE/.cache/huggingface + +# Setup (first time only): +bash scripts/vllm_exp_setup_env.sh +``` + +**Key differences from .venv_vllm:** +- vLLM 0.15.1 pinned (for patch compatibility) +- `FusedMoE.forward_impl()` patched with 3 insertion points (~12 lines) +- Uses `_ecmoe_compress_fn` / `_ecmoe_decompress_fn` attributes (not PyTorch hooks) +- Supports `enable_expert_parallel=True` for actual EP dispatch + +**Known issues / gotchas (EP compression):** +- **allgather_reducescatter backend:** vLLM's default `all2all_backend`. After dispatch, + every rank has ALL tokens. Stale cache approach works because token ordering is + consistent across layers. +- **Router unaffected:** `router_logits` are computed at `Qwen3MoeSparseMoeBlock.forward()` + BEFORE `FusedMoE.forward_impl()`, so compression never affects routing decisions. +- **Stale piggybacking:** Reference layers concatenate `cat(compressed, stale)` before + dispatch. After dispatch, decompress_fn splits and caches stale globally. Non-reference + layers dispatch only compressed (max compression), retrieve cached stale for decompression. + +**vLLM EP compression directories:** +``` +.venv_vllm_exp/ # Patched vLLM environment (gitignored) +results/08_ep_compression/ # EP eval results +``` + +### Megatron-LM Environment (Task 5 Megatron variant) + +**Megatron implementation:** `src/megatron_e2e/` package — EP-first, CUDA 12.9, Megatron Bridge. +(Legacy `src/run_megatron_e2e_compressor.py` was deleted due to SFT/split/batch bugs.) + +```bash +# Separate venv from HF-based experiments — CUDA 12.9 required +module load cuda/12.9 nccl arrow/22.0.0 +source .venv_megatron/bin/activate +export HF_HOME=/home/lfy/projects/rrg-bengioy-ad/lfy/ECMoE/.cache/huggingface + +# Setup (first time only): +bash scripts/megatron_setup_env.sh +``` + +**Key differences from HF environment:** +- Uses `megatron-core` >=0.15.0 for model parallelism (EP, TP, DP, PP) +- Requires Transformer Engine (for Megatron Bridge and fused kernels) +- Uses `megatron-bridge` >=0.2.0 for HF→Megatron weight conversion +- Default parallelism: EP=4, TP=1, PP=1 (expert parallelism, not tensor) +- Launch via `torchrun`, not `python` + +**Megatron-specific directories:** +``` +src/megatron_e2e/ # Package-based implementation (recommended) +.venv_megatron/ # Separate virtual environment (gitignored) +.uv_cache/ # uv cache on project disk (gitignored) +.uv_pythons/ # uv Python installs (gitignored) +third_party/ # Apex, etc. (gitignored, legacy only) +data/megatron_dolci/ # Preprocessed binary dataset (gitignored) +``` + +**Known issues / gotchas (Megatron):** +- **CUDA version:** Megatron Bridge requires CUDA >= 12.8. Use `cuda/12.9` module + on Compute Canada, NOT `cuda/12.6`. +- **EP vs TP:** Default is EP=4 (expert parallelism). With EP, each GPU holds 32/128 + experts per layer. TP=4 is the legacy approach and splits attention heads across GPUs. +- **Megatron layer names** differ from HF: `decoder.layers.N.mlp` vs `model.layers.N.mlp`. + `_megatron_to_hf_layer_name()` in `compressor_manager.py` handles conversion. +- Compressor weights are replicated across all ranks (not sharded), since they + are tiny (~200M total). Saved from rank 0 only. +- With EP>1, compressor is on source GPU (attention side), decompressor on + destination GPU (expert side) — different devices. +- `MegatronModelWrapper` bridges Megatron's forward interface to HF-style + `SimpleNamespace(loss=..., logits=...)`. Uses `vocab_parallel_cross_entropy` + for correct loss with TP > 1. SFT labels (-100) are clamped to 0 before + calling `vocab_parallel_cross_entropy`, and loss is masked via + `(per_token_loss * loss_mask).sum() / num_valid`. +- DistributedSampler must use DP rank/size (via `get_dp_info()`), NOT global + world size. All ranks in a TP group must see the SAME data. +- Saved weights use HF layer names (`model.layers.N.mlp`) for compatibility + with HF `E2ECompressorManager.load_weights()`. +- **Model loading:** `train.py` tries AutoBridge → MegatronBridge → manual fallback + for HF→Megatron conversion. If Bridge is not installed, falls back to manual + weight conversion using `load_megatron_qwen3()` from legacy code. +- **Train loss DP reduction (2026-02-17):** `train.py` now all-reduces step-level and + epoch-level train loss across DP ranks before logging. Previously, only rank 0's local + shard loss was logged, which was inaccurate with DP > 1. Wandb `train/loss` and + `train/epoch_loss` now reflect the true DP-averaged loss. + +### Running Experiments + +Task 1 must run first (caches hidden states for Tasks 2–4). Task 5 is independent. +Tasks 1–4 use 1 GPU each; Task 5a/5b use 4 GPUs each. + +**Data selection:** All tasks use seed=42 for reproducible 80/10/10 train/val/test +split of dataset rows. Tasks 1–4 draw from TRAIN split, PPL evaluation from TEST +split. No data leakage between splits. + +**Task 5 config (HF):** batch_size=2, grad_accum=8 (effective=16), max_sequences=500K, +max_length=2048, val_interval=2500 steps, val_batch_size=8, SFT mode +(response-only training), wandb enabled by default. + +**Task 5/6 config (Megatron):** Same as HF except max_sequences=100K, +val_interval=1000 steps. Task 6 uses same Megatron config with `--init-weights-dir`. + +Tail micro-batches (when `len(dataloader) % grad_accum != 0`) are handled by rescaling +accumulated gradients and performing the optimizer step. + +**Two evaluation stages:** Training-time val loss uses the VAL split (50K seqs, +batch_size=8, every 2500 steps) for checkpoint selection and wandb monitoring. +Final PPL evaluation uses the TEST split (50K seqs, batch_size=1, in +`model_utils.py`) for reported results. Different code paths — `--val-batch-size` +only affects training-time eval. + +**SFT data loading:** All E2E training (Task 5) and perplexity evaluation now use +SFT mode: each sample is one conversation, tokenized independently. Labels are +-100 for non-assistant tokens (system, user, template markup) and actual token +IDs for assistant responses. Loss and perplexity are computed on response tokens +only. Data is loaded by sampling N sequences from the dataset (not packing tokens). +`_tokenize_sft_sample()` in `model_utils.py` handles the tokenization. + +```bash +# Phase 1: Megatron 5a + 5b in parallel (8 GPUs) +CUDA_VISIBLE_DEVICES=0,1,2,3 bash scripts/05_megatron_e2e.sh none & +CUDA_VISIBLE_DEVICES=4,5,6,7 bash scripts/05_megatron_e2e.sh uncompressed & +wait + +# Phase 2: Task 1 (re-cache with seed=42) +CUDA_VISIBLE_DEVICES=0 bash scripts/01_analyze_distribution.sh + +# Phase 3: Tasks 2-4 + HF 5a (parallel) +CUDA_VISIBLE_DEVICES=0 bash scripts/02_run_quantization.sh & +CUDA_VISIBLE_DEVICES=1 bash scripts/03_run_neural_compressor.sh & +CUDA_VISIBLE_DEVICES=2 bash scripts/03b_run_perlayer_compressor.sh & +CUDA_VISIBLE_DEVICES=3 bash scripts/04_run_stale_compressor.sh compressed & +CUDA_VISIBLE_DEVICES=4,5,6,7 bash scripts/05_run_e2e_compressor.sh none & +wait + +# Phase 4: Task 4b + HF 5b (parallel) +CUDA_VISIBLE_DEVICES=0 bash scripts/04_run_stale_compressor.sh uncompressed & +CUDA_VISIBLE_DEVICES=4,5,6,7 bash scripts/05_run_e2e_compressor.sh uncompressed & +wait + +# Megatron-based E2E training (alternative to HF Task 5): +CUDA_VISIBLE_DEVICES=0,1,2,3 bash scripts/05_megatron_e2e.sh none # 5a +CUDA_VISIBLE_DEVICES=0,1,2,3 bash scripts/05_megatron_e2e.sh uncompressed # 5b + +# Task 5c: Baseline evaluation (no compression, same pipeline): +CUDA_VISIBLE_DEVICES=0,1,2,3 bash scripts/05_run_e2e_compressor.sh baseline # HF +CUDA_VISIBLE_DEVICES=0,1,2,3 bash scripts/05_megatron_e2e.sh baseline # Megatron + +# Task 6a/6b: E2E with pretrained init (requires Task 3b/4b weights): +CUDA_VISIBLE_DEVICES=0,1,2,3 bash scripts/06_megatron_e2e_pretrained.sh none & # 6a (init from 3b) +CUDA_VISIBLE_DEVICES=4,5,6,7 bash scripts/06_megatron_e2e_pretrained.sh uncompressed & # 6b (init from 4b) +wait + +# Task 7a/7b: Split-mode E2E (router sees original, experts see decompressed): +CUDA_VISIBLE_DEVICES=0,1,2,3 bash scripts/07_megatron_e2e_split.sh none & # 7a (init from 3b) +CUDA_VISIBLE_DEVICES=4,5,6,7 bash scripts/07_megatron_e2e_split.sh uncompressed & # 7b (init from 4b) +wait +``` + +### Downstream Task Evaluation (lm-eval-harness) + +Downstream eval is triggered by setting `DOWNSTREAM_TASKS` before running any script. +It runs **after** the existing PPL evaluation step, using `lm-eval-harness` with the +same compression hooks active. Results saved to `downstream_results.json` in each +task's output directory. + +```bash +# Run Task 2 + PPL eval + downstream eval: +DOWNSTREAM_TASKS="gsm8k_cot" bash scripts/02_run_quantization.sh + +# Run Task 5a + PPL eval + downstream eval: +DOWNSTREAM_TASKS="gsm8k_cot" bash scripts/05_run_e2e_compressor.sh none + +# Eval-only mode + downstream: +DOWNSTREAM_TASKS="gsm8k_cot" python src/run_e2e_compressor.py \ + --skip-training --output-dir results/05a_e2e_perlayer --stale-mode none + +# Smoke test with 10 examples: +DOWNSTREAM_TASKS="gsm8k_cot" DOWNSTREAM_LIMIT=10 bash scripts/05_run_e2e_compressor.sh none +``` + +**Key code:** `src/downstream_eval.py` provides `register_*_hooks()` for each method, +`run_lm_eval()` wrapper, and `save_downstream_results()`. Each task script imports from +it when `--downstream-tasks` is specified. GSM8K variant: `gsm8k_cot` (8-shot CoT). + +**vLLM backend:** Use `--backend vllm` (or `DOWNSTREAM_BACKEND=vllm`) for vLLM-based +downstream evaluation. Two router modes (`--router-mode compressed/uncompressed`): + +```bash +# Standalone vLLM eval (all methods, default router=compressed): +source .venv_vllm/bin/activate +python src/run_all_downstream.py --backend vllm --tasks gsm8k_cot + +# Router-uncompressed mode (split: router sees original, experts see decompressed): +python src/run_all_downstream.py --backend vllm --router-mode uncompressed --method e2e_perlayer --tasks gsm8k_cot + +# With tensor parallelism: +python src/run_all_downstream.py --backend vllm --tensor-parallel-size 4 --tasks gsm8k_cot + +# Via task scripts (HF model, vLLM downstream): +DOWNSTREAM_TASKS="gsm8k_cot" DOWNSTREAM_BACKEND=vllm bash scripts/05_run_e2e_compressor.sh none +``` + +### Visualization + +Regenerate all summary plots and tables: +```bash +source .venv/bin/activate +python src/visualize_all_results.py +``` + +Outputs to `results/summary/`: +- `ppl_vs_ratio_all.png` — PPL vs compression ratio (log-log) +- `reconstruction_vs_ratio_all.png` — MSE and CosSim vs ratio +- `ppl_bar_practical.png` — Bar chart at 2x and 4x +- `all_results_summary.json` — Machine-readable summary +- `param_count_table.{csv,md,json}` — Parameter counts for all methods + +--- + +## Code Changes + +**Before changing any code:** +1. FIND the exact file that produces the current output +2. READ and understand it +3. EDIT only the specific lines needed (use Edit tool) +4. TEST that output matches except for your intended change + +**Adding new compression methods:** +- Reuse `Compressor`, `Decompressor` from `run_neural_compressor.py` +- Reuse `train_compressor()` for standard autoencoder training +- Add new perplexity evaluation functions to `model_utils.py` +- Follow the same JSON output format as existing experiments +- Update `visualize_all_results.py` to include the new method + +--- + +## NEVER GUESS SILENTLY + +**When you encounter ambiguity:** +1. **STOP** — Do not make an arbitrary choice +2. **ASK** — Present the options to the user +3. **FLAG** — Note the documentation gap +4. **FIX** — Update README.md or CLAUDE.md + +--- + +## Version Control + +- Commit after EVERY fix (don't wait) +- Check `git status` and file sizes before committing (no files >100MB) +- Update JOURNAL.md immediately after committing +- No git remote is currently configured — commits are local only + +--- + +## Investigation + +**When something seems wrong:** +1. STOP — don't patch the visible symptom +2. ASK WHY — trace back to data generation +3. VERIFY — test hypotheses with minimal examples +4. FIX ROOT — fix the source, not downstream + +--- + +## Meta-Rule: Continuous Improvement + +**When a preventable issue occurs:** +1. Identify the root cause +2. Add a "What went wrong" example to this file +3. Commit the improvement + +This file should evolve based on lessons learned. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..6942042da87f72355ef85211b11947a2e40caeac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Stage 1: Build frontend +FROM node:22-alpine AS frontend-build +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +# Stage 2: Run backend + serve frontend +FROM python:3.12-slim +WORKDIR /app + +# Install Python deps +COPY backend/requirements.txt ./backend/ +RUN pip install --no-cache-dir -r backend/requirements.txt + +# Copy backend +COPY backend/ ./backend/ +COPY airline_routes.json ./ + +# Copy built frontend +COPY --from=frontend-build /app/frontend/dist ./frontend/dist + +EXPOSE 8080 + +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/JOURNAL.md b/JOURNAL.md new file mode 100644 index 0000000000000000000000000000000000000000..b96fff7c2d4c0b45d709983261ff1ed639dc8b02 --- /dev/null +++ b/JOURNAL.md @@ -0,0 +1,1797 @@ +# Development Journal + +## 2026-02-24 — Implement EP communication compression in vLLM (Task 8) + +- **Context:** Previous vLLM implementation simulated compression via PyTorch hooks + that compress→decompress on the SAME GPU — no actual communication reduction. The + correct EP pipeline is: router computes from original → compress on attention GPU → + dispatch compressed tensor → decompress on expert GPU → experts compute. +- **Implementation:** + - `scripts/patch_vllm_fused_moe.py`: Standalone patch for vLLM's `FusedMoE.forward_impl()`. + Adds ~12 lines at three locations: compress before dispatch (EP), decompress after + dispatch (EP), single-GPU simulation fallback. Checks for `_ecmoe_compress_fn` / + `_ecmoe_decompress_fn` attributes on `FusedMoE` instances. When None (default), + behavior is identical to stock vLLM. + - `scripts/vllm_exp_setup_env.sh`: Creates `.venv_vllm_exp` with vLLM 0.15.1 (pinned) + and applies the patch. Separate from `.venv_vllm` to preserve existing environment. + - `src/vllm_ep_compression.py`: EP-aware hook registration module. Uses `apply_model()` + pattern to set compress/decompress functions on FusedMoE instances. Two methods: + - `register_ep_perlayer()`: Independent compress/decompress per MoE layer. + - `register_ep_stale()`: Stale-conditioned. Reference layers piggyback stale signal + on compressed tensor (concatenated before dispatch, split after). Non-reference + layers dispatch only compressed (maximum compression). + - `src/run_ep_compression_eval.py`: Evaluation entry point. Two modes: + - `simulation`: Single-GPU (TP=1), validates numerical correctness vs existing results. + - `ep`: Multi-GPU (TP=4 + `enable_expert_parallel=True`), real EP dispatch/combine. + - `scripts/08_ep_compression_eval.sh`: Bash wrapper. +- **Key design decisions:** + - vLLM's `all2all_backend` defaults to `allgather_reducescatter`: after dispatch, every + rank has ALL tokens. This makes the stale cache approach correct — cached stale from + reference layers has the same token ordering as subsequent non-reference layers. + - Router logits are computed BEFORE `FusedMoE.forward_impl()` (at + `Qwen3MoeSparseMoeBlock.forward()`), so compression never affects routing — this is + inherently split mode. + - Stale broadcast cost amortized over ~11 non-reference layers. Communication savings: + perlayer 4x=75%, stale(uncomp) 4x=67%. +- **Uses Task 7a/7b weights** (split-mode E2E trained). +- **Files created:** `scripts/patch_vllm_fused_moe.py`, `scripts/vllm_exp_setup_env.sh`, + `src/vllm_ep_compression.py`, `src/run_ep_compression_eval.py`, + `scripts/08_ep_compression_eval.sh` +- **Updated:** README.md (Task 8 in experiment table, setup instructions, output structure, + project structure), CLAUDE.md (new directories and files), description.md (new section). + +## 2026-02-24 — Confirm HF downstream eval with uncompressed router (7a/7b) + +- **Context:** After adding `router_mode` support to `register_e2e_hooks()` and + `run_e2e_compressor.py`, ran HF downstream GSM8K eval for all 7a/7b ratios with + `--router-mode uncompressed`. Results were identical to previous `run_all_downstream.py` + values, confirming correctness of the new code path. +- **Results (GSM8K strict-match %, HF backend, uncompressed router):** + + | Ratio | 7a (perlayer) | 7b (stale) | + |-------|--------------|------------| + | 2x | 79.5% | 83.3% | + | 4x | 51.6% | 70.7% | + | 8x | 18.5% | 47.2% | + | 16x | 2.0% | 27.1% | + +- **Validation:** All values match `run_all_downstream.py` (which also used HF backend). + This confirms `register_e2e_hooks(router_mode="uncompressed")` correctly delegates to + `register_perlayer_hooks_split()` / `register_stale_hooks_split()`. +- **Updated:** `description.md` Section 6.1 note and Section 6.4 notes to properly + describe HF uncompressed-router downstream results for 7a/7b. +- **PPL eval complete** (7a on GPUs 0-3, 7b on GPUs 4-7, `--router-mode uncompressed`): + + | Ratio | 7a (perlayer) PPL | 7b (stale) PPL | Baseline | + |-------|-------------------|----------------|----------| + | 2x | 2.38 | 2.23 | 3.89 | + | 4x | 3.08 | 2.53 | 3.89 | + | 8x | 4.18 | 2.89 | 3.89 | + | 16x | 6.64 | 3.27 | 3.89 | + +- **Validation:** All PPL values match previous `perplexity_results_uncompressed.json` from + 2026-02-23 entry. This confirms `run_e2e_compressor.py --router-mode uncompressed` produces + identical PPL results to the original evaluation code path. +- **Updated:** `description.md` Section 6.4 notes to confirm PPL via both code paths. + +## 2026-02-23 — Add uncompressed router_mode to HF downstream eval + +- **Problem:** `register_e2e_hooks()` in `downstream_eval.py` did not accept a + `router_mode` parameter, so HF downstream eval always ran in compressed mode. + `run_e2e_compressor.py` did not pass `--router-mode` to downstream eval either. + PPL eval already supported `router_mode` via `model_utils.py`. +- **Fix:** + - `src/downstream_eval.py`: Added `router_mode` param to `register_e2e_hooks()`. + When `"uncompressed"`, delegates to existing `register_perlayer_hooks_split()` / + `register_stale_hooks_split()`. Added `_SplitModeCleanup` wrapper with + `remove_hooks()` for uniform cleanup interface. + - `src/run_e2e_compressor.py`: Passes `router_mode=args.router_mode` to + `register_e2e_hooks()`. Downstream result tags now include `_uncompressed` + suffix when using uncompressed router mode. `router_mode` also saved in results. +- **Commit:** `ce3936c` +- **Re-running 7a/7b evals** with `--router-mode uncompressed` (downstream + PPL). + +## 2026-02-23 — Task 7a/7b: PPL and downstream evaluation (both router modes) + +- **PPL evaluation complete** for both 7a (per-layer split) and 7b (stale split), + with both compressed and uncompressed router modes. Each eval: 50K sequences, + batch_size=1, ~10 hours per run on 4× H100. +- **Downstream evaluation complete** (GSM8K, 8-shot CoT, 1319 examples, HF backend) + for all compression ratios (2x, 4x, 8x, 16x) × 2 router modes × 2 methods. +- **Code changes:** + - `src/run_e2e_compressor.py`: Save PPL results with router_mode suffix + (`perplexity_results_uncompressed.json`) to avoid overwriting compressed results. + - `src/run_all_downstream.py`: Added `e2e_split_perlayer` and `e2e_split_stale` + to METHODS dict, tag_prefix dict, method_name tuple checks, and help text. + - `description.md`: Added 7a/7b to Section 6.1 summary table, Section 6.2 key + findings (findings 14–17), and Section 6.4 downstream table. +- **Results (PPL, compressed / uncompressed router):** + + | Ratio | 7a comp | 7a uncomp | 7b comp | 7b uncomp | Baseline | + |-------|---------|-----------|---------|-----------|----------| + | 2x | 2.58 | 2.38 | 2.34 | 2.23 | 3.89 | + | 4x | 3.72 | 3.08 | 2.80 | 2.53 | 3.89 | + | 8x | 6.43 | 4.18 | 3.37 | 2.89 | 3.89 | + | 16x | 908.20 | 6.64 | 4.28 | 3.27 | 3.89 | + +- **Results (GSM8K strict-match %, compressed / uncompressed router):** + + | Ratio | 7a comp | 7a uncomp | 7b comp | 7b uncomp | + |-------|---------|-----------|---------|-----------| + | 2x | 79.9 | 79.5 | 80.7 | 83.3 | + | 4x | 42.1 | 51.6 | 65.8 | 70.7 | + | 8x | 4.9 | 18.5 | 35.6 | 47.2 | + | 16x | 0.0 | 2.0 | 16.5 | 27.1 | + +- **Key findings:** + - 7b uncompressed stays below baseline PPL at ALL ratios (even 16x: 3.27 < 3.89) + - 7b uncompressed 2x achieves 83.3% GSM8K — best result across all methods + - 7a 16x compressed catastrophic (PPL=908) but uncompressed fine (6.64) + - Split-mode training trades compressed-eval for uncompressed-eval quality +- **Files created:** + - `results/07a_megatron_e2e_split_perlayer/perplexity_results.json` + - `results/07a_megatron_e2e_split_perlayer/perplexity_results_uncompressed.json` + - `results/07a_megatron_e2e_split_perlayer/downstream_results.json` + - `results/07b_megatron_e2e_split_stale/perplexity_results.json` + - `results/07b_megatron_e2e_split_stale/perplexity_results_uncompressed.json` + - `results/07b_megatron_e2e_split_stale/downstream_results.json` + +## 2026-02-22 — Task 7a/7b: Split-mode E2E training implementation + +- **Motivation:** Tasks 5/6 train with compress→decompress pre-hooks where both router + AND experts see decompressed data. In real EP, the router runs on the source GPU with + original hidden states. Task 7 trains under this more realistic split mode. +- **Approach:** Two-level pre-hooks per MoE layer: + 1. MoE pre-hook saves original input, returns compress→decompress result + 2. Router/gate pre-hook restores original input for the router submodule +- **Code changes:** + - `src/megatron_e2e/compressor_manager.py`: Added `router_mode` param, + `_find_router_submodule()`, split-mode hooks (`_make_split_basic_hook`, + `_make_split_ref_hook`, `_make_split_stale_hook`), `_make_router_restore_hook`. + Commit: `f1c18ae`. + - `src/megatron_e2e/train.py`: Added `--router-mode`, auto-detect 07a/07b output dir, + pass to manager, wandb config, results JSON. Commit: `b193756`. + - `src/model_utils.py`: Added `router_mode` to `evaluate_perplexity_with_perlayer_compression` + and `evaluate_perplexity_with_stale_compression` — split-mode uses MoE pre-hook + + gate pre-hook for HF eval. `src/megatron_e2e/evaluate.py` and `src/run_e2e_compressor.py` + pass through. Commit: `b634ed7`. + - `scripts/07_megatron_e2e_split.sh`: New bash wrapper, sets + `ROUTER_MODE="uncompressed"`. Commit: `9434718`. +- **Run with:** + ``` + CUDA_VISIBLE_DEVICES=0,1,2,3 bash scripts/07_megatron_e2e_split.sh none & # 7a + CUDA_VISIBLE_DEVICES=4,5,6,7 bash scripts/07_megatron_e2e_split.sh uncompressed & # 7b + wait + ``` +- **Training complete.** Results (best val loss): + + | Ratio | 7a (perlayer) | 7b (stale) | + |-------|--------------|------------| + | 2x | 0.8545 | 0.7909 | + | 4x | 1.1086 | 0.9140 | + | 8x | 1.4101 | 1.0447 | + | 16x | 1.8686 | 1.1650 | + +- **Weights saved to:** + - `/project/6004852/lfy/ECMoE/results/07a_megatron_e2e_split_perlayer/` + - `/project/6004852/lfy/ECMoE/results/07b_megatron_e2e_split_stale/` +- **PPL evaluation** not yet run (requires HF pipeline, separate step). + +## 2026-02-22 — Full GSM8K downstream eval results (1319 examples, both router modes) + +- **Full eval complete:** All 9 methods × 2 router modes × up to 4 compression ratios. + 60 clean entries saved to `results/summary/downstream_results.json`. +- **Code fix:** Added `router_mode` field to saved entries, include mode suffix in tags + (e.g. `e2e_2x_uncompressed`), and upsert semantics (replace existing same-tag entry). + Commit: `bd4bc91`. +- **Key findings (GSM8K strict-match accuracy):** + - Baseline (no compression): 43.3% + - Best compressed-mode results: + - `e2e_pre_stale_2x`: **82.0%** (pretrained init + stale, 2x) + - `e2e_pre_2x`: **80.1%** (pretrained init, 2x) + - `e2e_2x`: 61.5% (from-scratch E2E, 2x) + - `e2e_stale_2x`: 61.3% (from-scratch stale E2E, 2x) + - Offline methods (perlayer, stale_comp, stale_uncomp) near 0% — confirms + offline-trained compressors destroy information without E2E fine-tuning. + - Uncompressed router mode shows different pattern: + - Offline perlayer_2x jumps from 0% → 22.7% (router can still route correctly) + - stale_comp_2x jumps from 0.2% → 34.1% + - E2E pretrained methods slightly different: e2e_pre_stale_2x 82.0→83.9% + - INT4 quantization (4x): 46.8% compressed mode — strong baseline + - INT8 quantization (2x): 43.7% — nearly lossless vs baseline + - INT2 quantization (8x): 0% — total collapse + +## 2026-02-22 — Fix vLLM split mode API and add eval script + +- **Bug:** vLLM's `Qwen3MoeSparseMoeBlock.gate` returns `(router_logits, _)` — 2 values, + not 3 like HF's `Qwen3MoeTopKRouter`. vLLM's `experts.forward()` takes + `(hidden_states=, router_logits=)` kwargs, not positional args. The experts also return + `(shared_out, fused_out)` tuple, requiring explicit addition. +- **Fix:** Updated `_vllm_register_perlayer_split` and `_vllm_register_stale_split` to + use vLLM's gate/expert API: 2 return values from gate, keyword args to experts, handle + `(shared_out, fused_out)` tuple return, handle TP all-reduce. +- **Eval script:** Added `scripts/05_megatron_e2e_eval.sh` — runs vLLM-based GSM8K + evaluation for all methods with both `--router-mode compressed` and `--router-mode uncompressed`. + Uses 6-7 GPUs in parallel per mode. +- **Smoke test passed** (10 examples) for all 9 methods × 2 router modes × 4 ratios. + One transient vLLM engine crash (e2e_perlayer uncompressed 4x) resolved on retry. +- **Added `e2e_pretrained_perlayer` and `e2e_pretrained_stale`** to METHODS dict in + `run_all_downstream.py` (previously missing Task 6a/6b). +- **Commits:** `513b7a3` (fix), `7ec4c09` (eval script) + +## 2026-02-21 — Simplify vLLM eval: remove Phase 2, replace with --router-mode + +- **Motivation:** The three-phase system (Phase 1/2/3) was unnecessarily complex. + Phase 2 was mathematically identical to Phase 1 (both compress→decompress the full + MoE input — router AND experts see decompressed). Phase 3 was the only genuinely + different mode (router sees original, experts see decompressed). Simplifying to + two clearly-named modes makes the code easier to understand and maintain. +- **New system — two router modes (`--router-mode`):** + - `compressed` (default): Pre-hook compress→decompress. Router AND experts see + decompressed hidden states. Conservative lower bound on quality (same as old Phase 1). + - `uncompressed`: Split forward — router sees ORIGINAL input, experts see decompressed. + More realistic EP simulation (same as old Phase 3). +- **Code changes (`src/downstream_eval.py`):** + - Removed `register_compressed_moe_forward()` and `register_stale_moe_forward()` (Phase 2) + - Renamed `register_split_compression()` → `register_perlayer_hooks_split()` + - Renamed `register_split_stale_compression()` → `register_stale_hooks_split()` + - Added vLLM apply_model versions: `_vllm_register_perlayer_split()`, + `_vllm_register_stale_split()` — both router modes now work for HF and vLLM backends + - Convenience wrappers: `register_perlayer_hooks_split_vllm()`, + `register_stale_hooks_split_vllm()` +- **Code changes (`src/run_all_downstream.py`):** + - Replaced `--phase 1/2/3` with `--router-mode compressed/uncompressed` + - Added `e2e_pretrained_perlayer` and `e2e_pretrained_stale` to METHODS dict + - Simplified `evaluate_config()` — removed Phase 2 branches, renamed Phase 3 to split_mode +- **Documentation:** Updated CLAUDE.md (vLLM gotchas, usage examples) and README.md + (vLLM setup section). +- **Commit:** `d1b78ad` + +## 2026-02-21 — Phase 2/3 limitations documented (TODO) + +- **Phase 2 is mathematically identical to Phase 1.** Both compress→decompress the full + MoE block input, so router AND experts see decompressed. Phase 2 just monkey-patches + `forward` instead of using a pre-hook — same computation, different code path. +- **Phase 3 is the only genuinely different phase.** It splits gate(original) from + experts(decompressed), simulating the realistic EP scenario where the router runs + on the source GPU with original hidden states. +- **No multi-device placement.** The plan called for compressor on attention GPU, + decompressor replicated on expert GPUs. Current implementation puts both on the same + device. Quality measurements are unaffected (device-independent math), but this + doesn't demonstrate the actual cross-GPU communication pattern. +- **No shared expert handling** in Phase 3 (Qwen3-30B-A3B has no shared experts). +- **TODO:** Add multi-device placement to Phase 3 for realistic EP simulation. + +## 2026-02-21 — Fix Phase 3 split_forward gate API + +- **Bug:** Phase 3 `split_forward` assumed `gate()` returns 2 values (`router_logits, _`). + Qwen3's `Qwen3MoeTopKRouter.forward()` actually returns 3 values: + `(router_logits, routing_weights, selected_experts)`. +- **Fix:** Updated all 4 split_forward variants (perlayer, ref-stale, stale) to: + - Unpack 3 gate return values correctly + - Reshape 3D→2D (`batch*seq, hidden`) before gate/experts (matching original forward) + - Call `experts(decompressed, selected_experts, routing_weights)` with positional args + - Reshape output back to 3D +- **Tested:** Phase 2 (perlayer, stale) and Phase 3 (perlayer, stale) all pass on 10 GSM8K examples. + Phase 2 and Phase 3 stale_uncompressed 2x both produce 20%/70% strict/flexible (consistent). + +## 2026-02-21 — Add vLLM backend for downstream evaluation + +- **Motivation:** The existing downstream evaluation (GSM8K via lm-eval-harness) uses + HuggingFace HFLM backend with PyTorch hooks for compression simulation. vLLM provides + a more realistic inference engine. Adding vLLM backend enables three phases of + increasingly realistic compression simulation. +- **New file:** `scripts/vllm_setup_env.sh` — creates `.venv_vllm` with vLLM 0.8.4+, + lm-eval[vllm], and project dependencies (CUDA 12.6, Python 3.11). +- **Core changes to `src/downstream_eval.py`:** + - `_map_layer_name()` — maps vLLM layer names to HF weight keys by layer index + - `create_vllm_backend()` — creates lm-eval VLLM wrapper with `enforce_eager=True`, + sets `VLLM_ALLOW_INSECURE_SERIALIZATION=1` for apply_model support + - **Phase 1 (vLLM, via apply_model):** + - `_vllm_register_perlayer()`, `_vllm_register_stale()`, `_vllm_register_quantization()` + — factory functions that return closures for `vllm.LLM.apply_model()`. Each closure + is self-contained (own imports, class defs) to be cloudpickle-serializable. + - `register_perlayer_hooks_vllm()`, `register_stale_hooks_vllm()`, + `register_quantization_hooks_vllm()` — convenience wrappers + - `remove_hooks_vllm()` — removes all ECMoE hooks from vLLM worker model + - **Phase 2 (HF only):** `register_compressed_moe_forward()`, `register_stale_moe_forward()` + - **Phase 3 (HF only):** `register_split_compression()`, `register_split_stale_compression()` + - `restore_original_forwards()` — undo Phase 2/3 monkey-patching + - `run_lm_eval()` now accepts `lm_eval_model=` for pre-created VLLM instance + - `add_downstream_args()` adds `--downstream-backend hf/vllm` +- **`src/run_all_downstream.py`:** Added `--backend hf/vllm`, `--phase 1/2/3`, + `--tensor-parallel-size`, `--max-model-len`, `--gpu-memory-utilization` args. + `evaluate_config()` dispatches to appropriate hook functions based on backend and phase. +- **Bash scripts:** Added `DOWNSTREAM_BACKEND` env var to 02, 03b, 04, 05 scripts. +- **Documentation:** README.md vLLM setup section, CLAUDE.md vLLM gotchas and usage. +- **Critical bug found and fixed:** vLLM V1 (>= 0.15) runs the model in a separate + subprocess (EngineCore). The original approach of extracting the model via + `llm_engine.model_executor.driver_worker.model_runner.model` fails because V1 has no + `model_executor` attribute. Solution: use `vllm.LLM.apply_model(func)` which serializes + the function via cloudpickle and executes it inside the worker process. This requires + `VLLM_ALLOW_INSECURE_SERIALIZATION=1` and all hook functions to be self-contained. +- **Key design decisions:** + - No separate `register_e2e_hooks_vllm()` — E2E and offline weights have identical + format, so `register_perlayer_hooks_vllm()` works for 3b+5a+6a and + `register_stale_hooks_vllm()` works for 4a/4b+5b+6b. + - Phase 2/3 only for HF backend. Phase 1 pre-hooks are mathematically identical to + Phase 2 for quality. Phase 3 (split) would need complex apply_model implementation. + - Phase 3 should produce slightly better quality than Phase 1/2 because the router + sees the original input — this is the most realistic simulation of EP with + compressed dispatch. +- **Smoke tests passed (2026-02-21):** + - vLLM baseline: 60%/80% strict/flexible on 5 GSM8K examples + - vLLM e2e_perlayer 2x: 60%/60% on 5 examples (hooks registered/removed correctly) + - vLLM quantization INT8/INT4/INT2: all ran successfully, INT2 at 0% (expected) + +## 2026-02-20 — Task 6a/6b: E2E training with pretrained compressor init + +- **Motivation:** Tasks 5a/5b initialize compressor/decompressor weights as near-identity + matrices (first `b` dimensions projected and reconstructed). Task 6 tests whether + starting from offline-trained weights (which already minimize reconstruction loss) + gives better E2E results. +- **Task 6a:** Like 5a (E2E per-layer, no stale) but initialized from Task 3b weights + (per-layer offline compressors). Output: `results/06a_megatron_e2e_pretrained_perlayer/` +- **Task 6b:** Like 5b (E2E stale-conditioned) but initialized from Task 4b weights + (stale-conditioned offline compressors). Output: `results/06b_megatron_e2e_pretrained_stale/` +- **Implementation:** Added `--init-weights-dir` argument to `src/megatron_e2e/train.py`. + Auto-detects weight file naming pattern (perlayer, stale_uncompressed, etc.). + Created `scripts/06_megatron_e2e_pretrained.sh` bash wrapper. +- **Weight compatibility:** Task 3b/4b weights use HF layer names (`model.layers.N.mlp`), + which is the same format used by `MegatronCompressorManager.load_weights()`. Direct + loading works because the offline and E2E architectures use identical `Compressor`, + `Decompressor`, and `StaleDecompressor` classes. +- **Training completed** (2026-02-21): Both 6a and 6b finished all 4 compression ratios + (2x, 4x, 8x, 16x). Pretrained initialization gives large improvements over near-identity, + with gains increasing at higher compression ratios. + +### Task 6a — E2E pretrained per-layer (completed) + + | Ratio | Params | Val (6a) | Val (5a) | Improvement | + |-------|-------------|----------|----------|-------------| + | 2x | 201,474,048 | 0.8670 | 0.9951 | 12.9% | + | 4x | 100,786,176 | 1.1389 | 1.4232 | 20.0% | + | 8x | 50,442,240 | 1.4872 | 1.9746 | 24.7% | + | 16x | 25,270,272 | 1.9676 | 2.3788 | 17.3% | + + Wandb: https://wandb.ai/fengyuan-liu/ecmoe-megatron-e2e/runs/7vsr7goo + Results: `results/06a_megatron_e2e_pretrained_perlayer/` + +### Task 6b — E2E pretrained stale-conditioned (completed) + + | Ratio | Params | Val (6b) | Val (5b) | Improvement | + |-------|-------------|----------|----------|-------------| + | 2x | 386,023,424 | 0.8021 | 0.9760 | 17.8% | + | 4x | 285,335,552 | 0.9310 | 1.2538 | 25.7% | + | 8x | 234,991,616 | 1.0932 | 1.5718 | 30.4% | + | 16x | 209,819,648 | 1.2242 | 1.8107 | 32.4% | + + Wandb: https://wandb.ai/fengyuan-liu/ecmoe-megatron-e2e/runs/mzsh4mck + Results: `results/06b_megatron_e2e_pretrained_stale/` + +- **Key finding:** Pretrained init consistently outperforms near-identity init across all + compression ratios. The benefit grows with compression ratio for stale-conditioned (6b): + from 17.8% at 2x to 32.4% at 16x. For per-layer (6a), the benefit peaks at 8x (24.7%) + and is slightly lower at 16x (17.3%), possibly because 16x per-layer compression is too + lossy for the pretrained weights to provide as much advantage. +- **Best overall:** 6b at 2x achieves val=0.8021, which is the lowest loss across all + E2E experiments, approaching the 5c baseline (no compression) level. + +### PPL evaluation (2026-02-21) + + Perplexity on test split (50K samples, lower is better): + + | Method | 2x | 4x | 8x | 16x | Baseline | + |---------------------------|------:|------:|------:|------:|---------:| + | 5a (per-layer, identity) | 2.77 | 4.28 | 7.49 | 11.26 | 3.89 | + | **6a (per-layer, pretrained)** | **2.41** | **3.18** | **4.52** | **7.34** | 3.89 | + | PPL improvement | 13.0% | 25.7% | 39.7% | 34.8% | | + | 5b (stale, identity) | 2.71 | 3.61 | 4.98 | 6.34 | 3.89 | + | **6b (stale, pretrained)** | **2.25** | **2.57** | **3.04** | **3.47** | 3.89 | + | PPL improvement | 17.0% | 28.8% | 39.0% | 45.3% | | + + PPL results: `results/06a_megatron_e2e_pretrained_perlayer/perplexity_results.json`, + `results/06b_megatron_e2e_pretrained_stale/perplexity_results.json` + +### GSM8K downstream evaluation (2026-02-21) + + GSM8K 8-shot CoT, strict match accuracy (higher is better): + + | Method | Baseline | 2x | 4x | 8x | 16x | + |---------------------------|:--------:|-------:|-------:|-------:|-------:| + | 5a (per-layer, identity) | 0.441 | 0.6133 | 0.2070 | 0.0182 | 0.0091 | + | **6a (per-layer, pretrained)** | 0.441 | **0.7998** | **0.5504** | **0.1698** | **0.0227** | + | 5b (stale, identity) | 0.441 | 0.6027 | 0.3154 | 0.0493 | 0.0212 | + | **6b (stale, pretrained)** | 0.441 | **0.8249** | **0.6437** | **0.4579** | **0.2585** | + + Downstream results: `results/06a_megatron_e2e_pretrained_perlayer/downstream_results.json`, + `results/06b_megatron_e2e_pretrained_stale/downstream_results.json` + +- **Key PPL finding:** Pretrained init improves PPL by 13–45% depending on method and ratio. + 6b at 4x (PPL=2.57) actually beats the uncompressed baseline (PPL=3.89), and at 16x + (PPL=3.47) is still below baseline — remarkable for 16× communication compression. +- **Key GSM8K finding:** 6b at 2x achieves 82.5% strict match, nearly double the baseline + (44.1%). Even at 8x compression, 6b (45.8%) exceeds baseline (44.1%). The stale-conditioned + pretrained approach (6b) retains meaningful accuracy out to 16x (25.9% vs 2.1% for 5b). + +## 2026-02-19 — Fix wandb logging for Task 05c baseline + +- **Bug:** Task 05c (baseline) initialized wandb but never called `wandb_run.log()`, + so only system metrics appeared in the dashboard — no train/val loss. +- **Fix:** Added `wandb_run.log({"baseline/train_loss": ..., "baseline/val_loss": ...})` + in both `src/run_e2e_compressor.py` and `src/megatron_e2e/train.py`. +- **Bonus fix:** Run name for baseline was falling through to `e2e_perlayer` (same as + 05a), making runs indistinguishable. Now correctly named `e2e_baseline` / + `megatron_e2e_baseline`. + +## 2026-02-07 — Project initialisation + +- Created repo structure: `src/`, `scripts/`, `results/`, `data/` +- Wrote core library: `model_utils.py` (model loading, MoE detection, hidden + state collection, perplexity evaluation), `metrics.py` (MSE, cosine sim, + relative error, SNR) +- Implemented three experiment scripts: + - `run_distribution.py` — Task 1: hidden state distribution analysis + - `run_quantization.py` — Task 2: quantization baseline (absmax + zeropoint, + 8/4/2 bits) + - `run_neural_compressor.py` — Task 3: learned linear autoencoder compression + at 2×/4×/8×/16× ratios +- Created bash wrappers: `scripts/01_analyze_distribution.sh`, + `02_run_quantization.sh`, `03_run_neural_compressor.sh` +- Target model: Qwen3-30B-A3B (hidden_dim=2048, 48 MoE layers, 128 experts, + top-8 routing) +- Environment: Compute Canada, 4× H100 80 GB, Python 3.11, CUDA 12.6 + +## 2026-02-11 — All three experiments completed + +### Bug fixes +- Fixed dtype mismatch in `absmax_dequantize` and `zeropoint_dequantize`: + dequantized tensors were float32 but model expected bfloat16, causing + `RuntimeError` during perplexity evaluation with compression hooks. + Fix: `(x_q.float() * scale.float()).to(scale.dtype)` +- Added `HF_HOME` export to all three bash scripts so model weights + download to project dir instead of home (small quota on CC). +- Added `.cache/` to `.gitignore`. + +### Task 1 — Distribution analysis (completed) +- Captured 10,000 tokens × 48 MoE layers (dispatch + gather) +- Key findings: std increases from 0.16 (layer 0) → 1.21 (layer 47); + very high kurtosis (up to 81,340); heavy-tailed distributions +- Results: `results/01_distribution/` + +### Task 2 — Quantization baseline (completed) +- Baseline PPL: 16.35 +- absmax INT8: MSE=0.000244, CosSim=0.9998, PPL=18.69 (+2.34) +- absmax INT4: MSE=0.073, CosSim=0.930, PPL=30.52 (+14.17) +- absmax INT2: MSE=0.385, CosSim=0.342, PPL=9653 (+9637) +- Results: `results/02_quantization/` + +### Task 3 — Neural compressor (completed) +- Trained linear autoencoders at 2×/4×/8×/16× compression +- neural_2x: MSE=0.078, CosSim=0.892, PPL=55.09 (+38.74) +- neural_4x: MSE=0.147, CosSim=0.791, PPL=36014 (+35998) +- neural_8x: MSE=0.199, CosSim=0.706, PPL=1165753 +- neural_16x: MSE=0.238, CosSim=0.638, PPL=8548583 +- Observation: naive single-layer linear compressor significantly + underperforms INT8 quantization. INT8 achieves 2× compression with + PPL=18.69, while neural 2× compression gives PPL=55.09. +- Results: `results/03_neural_compressor/` + +## 2026-02-11 — Tasks 3b, 4a, 4b implementation + +### Infrastructure changes +- `scripts/01_analyze_distribution.sh`: increased MAX_SAMPLES 128→256, + MAX_TOKENS 10000→100000 for 100K token capture +- `src/model_utils.py`: added `layer_index()` helper, + `evaluate_perplexity_with_perlayer_compression()` for per-layer compress/decompress hooks, + `evaluate_perplexity_with_stale_compression()` for stale-conditioned hooks with + shared `stale_cache` dict populated by reference layer pre-hooks + +### Task 3b — Per-layer neural compressor (COMPLETED) +- `src/run_perlayer_compressor.py`: trained 48 independent compressor/decompressor + pairs per compression ratio, one per MoE layer +- perlayer_2x: MSE=0.058, CosSim=0.928, PPL=23.48 (+7.14) +- perlayer_4x: MSE=0.119, CosSim=0.844, PPL=92.02 (+75.67) +- perlayer_8x: MSE=0.171, CosSim=0.765, PPL=956.24 (+939.90) +- perlayer_16x: MSE=0.213, CosSim=0.693, PPL=13757.99 (+13741.64) +- Huge improvement over shared neural: 2x PPL 23.48 vs 55.09 (57% delta reduction) +- Results: `results/03b_perlayer_compressor/` + +### Task 4a — Stale-conditioned compressor, compressed stale (COMPLETED) +- Reference layer grouping: stride=12, ref layers {0, 12, 24, 36} +- Stale signal compressed by ref layer's compressor (stale_dim = bottleneck_dim) +- stale_comp_2x: MSE=0.041, CosSim=0.950, PPL=20.62 (+4.28) +- stale_comp_4x: MSE=0.096, CosSim=0.877, PPL=50.52 (+34.17) +- stale_comp_8x: MSE=0.148, CosSim=0.800, PPL=467.54 (+451.19) +- stale_comp_16x: MSE=0.193, CosSim=0.727, PPL=14173.36 (+14157.01) +- Results: `results/04a_stale_compressed/` + +### Task 4b — Stale-conditioned compressor, uncompressed stale (COMPLETED) +- Stale signal sent raw (stale_dim = hidden_dim = 2048) +- stale_uncomp_2x: MSE=0.036, CosSim=0.956, PPL=20.16 (+3.81) +- stale_uncomp_4x: MSE=0.073, CosSim=0.908, PPL=32.49 (+16.15) +- stale_uncomp_8x: MSE=0.102, CosSim=0.868, PPL=98.04 (+81.70) +- stale_uncomp_16x: MSE=0.122, CosSim=0.837, PPL=262.93 (+246.59) +- Best neural method overall — uncompressed stale consistently wins +- Results: `results/04b_stale_uncompressed/` + +### Key findings +- Best 2x compression: INT8 quantization (PPL=18.69), then stale-uncompressed (PPL=20.16) +- Best 4x compression: INT4 quantization (PPL=30.52), then stale-uncompressed (PPL=32.49) +- Per-layer compressors are essential: 57% PPL delta reduction vs shared compressor at 2x +- Stale signal from nearby reference layers significantly improves reconstruction +- Uncompressed stale always beats compressed stale (more information preserved) +- At 8x, stale-uncompressed (PPL=98) dramatically outperforms per-layer (PPL=956) +- Visualization: `results/summary/` (3 plots + summary JSON) +- Parameter count table: `results/summary/param_count_table.{csv,md,json}` + +## 2026-02-11 — Documentation update + +- Rewrote `CLAUDE.md` to be ECMoE-specific (replaced VLM interp project references + with ECMoE directory structure, environment setup, known gotchas, and code architecture) +- Created `description.md` — detailed description of all methods, design choices, + hyperparameter specifications, architecture details, and complete results table + +## 2026-02-11 — Tasks 05a/05b: End-to-end compressor training + +### Motivation +- Tasks 3b/4b train compressors **offline** on cached hidden states, minimizing + local reconstruction error. Each layer's compressor is trained in isolation — + it cannot account for how its errors compound through downstream layers. +- Task 05 addresses this by training per-layer compressor/decompressor pairs + **end-to-end** using the language modeling (next-token prediction) objective. +- LLM weights are frozen; only compressor/decompressor parameters are updated. + Gradients flow through the entire frozen LLM to reach all compressors. + +### Differences from offline training (Tasks 3b/4b) +- **Loss function:** Cross-entropy (next-token prediction) instead of MSE + cosine. + The LM objective captures the true downstream impact of compression errors. +- **Joint optimization:** All 48 per-layer compressors are optimized simultaneously + through one shared loss. A compressor at layer 0 receives gradient signal about + how its reconstruction error affects layers 1–47. +- **Stale gradients flow (05b):** Unlike offline Task 4b where the stale signal is + pre-computed and frozen, e2e training does NOT detach the stale signal. Gradients + flow through the stale path, so reference layer compressors are also optimized for + how their inputs serve as stale side information for downstream layers. +- **Model:** Qwen/Qwen3-30B-A3B-Instruct-2507 (instruct variant, full BF16, + no quantization). Different model from Tasks 1–4 (base model, 4-bit NF4). +- **Data:** allenai/Dolci-Instruct-SFT (100K tokens) instead of WikiText-2. +- **Initialization:** Near-identity — `W_c` = first `b` rows of `I`, `W_d` = matching + columns. Avoids catastrophic initial loss from random projections. + +### Implementation +- `src/run_e2e_compressor.py`: `E2ECompressorManager` class handles per-layer + compressor placement (each on same GPU as its MoE layer), hook registration, + near-identity init, weight save/load, and eval function construction +- `scripts/05_run_e2e_compressor.sh`: bash wrapper, takes mode as argument +- Multi-GPU: model in full BF16 (~60 GB) distributed via `device_map="auto"` + across 4 GPUs. Gradient checkpointing enabled (`use_reentrant=False`). +- 8 GPUs available → run 05a on GPUs 0-3 and 05b on GPUs 4-7 in parallel: + ``` + CUDA_VISIBLE_DEVICES=0,1,2,3 bash scripts/05_run_e2e_compressor.sh none & + CUDA_VISIBLE_DEVICES=4,5,6,7 bash scripts/05_run_e2e_compressor.sh uncompressed & + wait + ``` + +### Training hyperparameters +- Optimizer: AdamW (lr=1e-4, weight_decay=0.01) +- LR schedule: cosine with 10% linear warmup +- Epochs: 10, early stopping patience: 5 +- Batch size: 4, gradient accumulation: 2 (effective batch: 8) +- Gradient clipping: max_norm=1.0 +- Sequence length: 512 + +### Task 05a (--stale-mode none): per-layer e2e, no stale conditioning +### Task 05b (--stale-mode uncompressed): per-layer e2e, uncompressed stale +- Results: `results/05a_e2e_perlayer/`, `results/05b_e2e_stale/` +- Perplexity evaluated on Dolci-Instruct-SFT (same dataset as all other tasks) +- Status: COMPLETED (see results in "Full re-run" section below) + +## 2026-02-11 — Remove 4-bit quantization from Tasks 1–4 + +### Motivation +- Previous experiments loaded model weights in 4-bit NF4 quantization (~15 GB VRAM). + While activations remain BF16, weight quantization subtly affects activation + distributions. For fair comparison with Task 05 (which uses full BF16), all tasks + now load the original unquantized model. + +### Changes +- **5 bash scripts** (`01`–`04`): `DEVICE` default changed from `cuda:0` to `auto`, + `LOAD_4BIT` changed from `--load-in-4bit` to `--no-load-in-4bit` +- **5 Python scripts** (`run_distribution.py`, `run_quantization.py`, + `run_neural_compressor.py`, `run_perlayer_compressor.py`, `run_stale_compressor.py`): + `--load-in-4bit` default changed from `True` to `False` +- **3 Python scripts** (Tasks 3, 3b, 4): Added `compute_device` resolution — + when `args.device="auto"` (for model loading), tensor operations use `"cuda:0"` +- **`README.md`**: Updated model loading documentation to reflect BF16 default +- **VRAM requirement**: Now requires ~60 GB (multiple GPUs via `device_map="auto"`) + +## 2026-02-11 — Unify model, dataset, dtype, device across all experiments + +### Motivation +- Previous setup used two different models (base for Tasks 1–4, instruct for Task 5), + two different datasets (WikiText-2 for 1–4, Dolci-Instruct-SFT for 5), and different + precisions. This made cross-method comparison unreliable. + +### Changes +- **Model:** All tasks now use `Qwen/Qwen3-30B-A3B-Instruct-2507` +- **Dataset:** All tasks now use `allenai/Dolci-Instruct-SFT` for both + calibration/training and perplexity evaluation +- **Dtype:** Neural compressors created in `bfloat16` (matching model activation dtype); + hidden states cached in `bfloat16` (not float32). Metrics still evaluated in float32. +- **Device:** Tasks 1–4 use single GPU (`cuda:0`); Task 5 uses 4 GPUs via `device_map="auto"` +- **Epochs:** Task 5 uses 1 epoch (per plan.md), not 10 +- Updated `README.md`, `description.md`, `CLAUDE.md` to reflect all changes +- **Commit:** `f4ae941`, `74191af`, `9b73194` + +### Status +- All code changes committed. Experiments awaiting re-execution with new configuration. +- Old results (from base model + WikiText-2 + 4-bit NF4) are no longer valid. + +## 2026-02-11 — Add tqdm progress bars and log files + +### Motivation +- Long-running HPC experiments had no way to check elapsed time or ETA +- No log files were created — all output went to terminal only +- Users could not monitor batch job progress without terminal access + +### Changes +- **7 Python scripts** (`model_utils.py`, all 6 `run_*.py`): Added `from tqdm import tqdm` + and wrapped all long-running loops (epoch training, layer iteration, data loading, + perplexity evaluation, compression ratio loops) with tqdm progress bars +- **7 bash scripts** (all 6 task scripts + `run_all.sh`): Added `exec` redirection: + - `stdout` → `${OUTPUT_DIR}/run.log` (via `tee`, also to terminal) + - `stderr` → `${OUTPUT_DIR}/progress.log` (via `tee`, also to terminal) + - Used `python -u` for unbuffered output +- tqdm writes to `sys.stderr` by default, so progress bars go to `progress.log` + while print statements go to `run.log` +- Updated `README.md` (monitoring section), `description.md` (Section 8.4) + +## 2026-02-11 — Record dataset in hidden state metadata + +### What went wrong +- `metadata.json` for cached hidden states did not record the dataset name +- After switching from WikiText-2 to Dolci-Instruct-SFT, there was no way to + verify which dataset the existing cache was collected from +- Fix: `collect_hidden_states()` now accepts `dataset_name` parameter and writes + it to `metadata.json` +- **Action required:** Re-run Task 1 to regenerate hidden states with proper metadata + +## 2026-02-11 — Full re-run with unified configuration + +### Configuration +- **Model:** Qwen/Qwen3-30B-A3B-Instruct-2507 (full BF16, ~60 GB) +- **Dataset:** allenai/Dolci-Instruct-SFT (calibration, training, and PPL eval) +- **Hidden states:** 89,882 tokens × 48 MoE layers × 2048 dim (~35 GB) +- **Hardware:** 8× H100 80 GB on Compute Canada + +### Task 1 — Distribution analysis (COMPLETED) +- 89,882 tokens captured (256 samples × max 512 tokens) +- 48 MoE layers detected, hidden_dim=2048 +- Metadata now records dataset_name +- Results: `results/01_distribution/` + +### Task 2 — Quantization baseline (COMPLETED) +- Baseline PPL: **4.225** +- absmax INT8 (~2×): MSE=0.000380, CosSim=0.9997, SNR=31.4 dB, PPL=**4.201** (−0.02) +- absmax INT4 (~4×): MSE=0.087, CosSim=0.912, SNR=5.7 dB, PPL=**5.360** (+1.13) +- absmax INT2 (~8×): MSE=high, CosSim=low, PPL=**2306** (+2302) +- Results: `results/02_quantization/` + +### Task 3b — Per-layer neural compressor (COMPLETED) +- 48 independent compressor/decompressor pairs per ratio, trained on dispatch states +- perlayer_2x: MSE=0.056, CosSim=0.921, SNR=8.41 dB, PPL=**5.922** (+1.70) +- perlayer_4x: MSE=0.114, CosSim=0.832, SNR=5.35 dB, PPL=**17.83** (+13.60) +- perlayer_8x: MSE=0.162, CosSim=0.750, SNR=3.83 dB, PPL=**179.94** (+175.72) +- perlayer_16x: MSE=0.201, CosSim=0.677, SNR=2.91 dB, PPL=**5397.72** (+5393.49) +- Results: `results/03b_perlayer_compressor/` + +### Task 4b — Stale-conditioned compressor, uncompressed stale (COMPLETED) +- Ref stride=12, ref layers {0, 12, 24, 36}, stale_dim=2048 (raw) +- stale_uncomp_2x: MSE=0.036, CosSim=0.952, SNR=10.79 dB, PPL=**5.151** (+0.93) +- stale_uncomp_4x: MSE=0.072, CosSim=0.900, SNR=7.63 dB, PPL=**7.804** (+3.58) +- stale_uncomp_8x: MSE=0.100, CosSim=0.855, SNR=6.11 dB, PPL=**12.918** (+8.69) +- stale_uncomp_16x: MSE=0.122, CosSim=0.819, SNR=5.23 dB, PPL=**25.313** (+21.09) +- Results: `results/04b_stale_uncompressed/` + +### Task 5a — E2E per-layer compressor (COMPLETED) +- End-to-end training through frozen LLM, optimizing LM cross-entropy loss +- 2 GPUs (4-5), device_map="auto", 1 epoch per ratio, ~2h per ratio +- e2e_2x: train=1.215, val=1.093, PPL=**2.645** (−1.58) +- e2e_4x: train=1.786, val=1.447, PPL=**3.687** (−0.54) +- e2e_8x: train=2.412, val=2.004, PPL=**6.371** (+2.15) +- e2e_16x: train=2.768, val=2.326, PPL=**9.157** (+4.93) +- Results: `results/05a_e2e_perlayer/` + +### Task 5b — E2E stale-conditioned compressor (COMPLETED) +- Same as 5a but with uncompressed stale conditioning (stale_dim=2048) +- 2 GPUs (6-7), device_map="auto", 1 epoch per ratio, ~2h per ratio +- e2e_stale_2x: train=1.193, val=1.070, PPL=**2.570** (−1.65) +- e2e_stale_4x: train=1.579, val=1.286, PPL=**3.102** (−1.12) +- e2e_stale_8x: train=1.921, val=1.555, PPL=**4.015** (−0.21) +- e2e_stale_16x: train=2.069, val=1.686, PPL=**4.550** (+0.32) +- Results: `results/05b_e2e_stale/` + +### Key findings (all experiments complete) +- **Baseline PPL** dropped from 16.35 (4-bit NF4 base model) to **4.225** (full BF16 instruct) +- **E2E training is transformative** — E2E methods achieve PPL *below* baseline at 2× and 4× + - E2E stale 2×: PPL=2.57 (−1.65), E2E per-layer 2×: PPL=2.64 (−1.58) + - E2E stale 4×: PPL=3.10 (−1.12), E2E per-layer 4×: PPL=3.69 (−0.54) + - E2E stale stays below baseline even at 8× (PPL=4.01, −0.21) +- **Offline vs E2E comparison (same architecture, same params):** + - At 4×: offline per-layer PPL=17.83 → E2E per-layer PPL=3.69 (4.8× improvement) + - At 8×: offline per-layer PPL=179.94 → E2E per-layer PPL=6.37 (28× improvement) + - At 16×: offline per-layer PPL=5397.72 → E2E per-layer PPL=9.16 (589× improvement) + - At 16×: offline stale PPL=25.31 → E2E stale PPL=4.55 (5.6× improvement) +- **E2E stale at 16× (PPL=4.55) is only +0.32 above baseline** — near-lossless 16× compression +- **Stale conditioning helps more at high compression:** + - At 2×: stale vs no-stale is marginal (2.57 vs 2.64) + - At 16×: stale is 2× better (4.55 vs 9.16) +- **Offline methods degrade rapidly:** per-layer collapses above 4×, stale-cond degrades gracefully + but still 5× worse than E2E stale at 16× +- **Below-baseline PPL** suggests compressors act as regularizers, filtering noise from hidden states +- INT8 quantization (PPL=4.20) is nearly free but only ~2×; INT2 (PPL=2306) is catastrophic + +## 2026-02-14 — Megatron-LM integration for Task 5 (E2E compressor training) + +### Motivation +- Task 5 currently uses HuggingFace Transformers with `device_map="auto"` for naive + layer-sharded model parallelism. This is inefficient: + - Only one GPU is active at a time during forward pass (sequential layer execution) + - No tensor parallelism (each GPU holds entire layers, not shards) + - No data parallelism (single data stream) + - Cannot scale to multi-node +- Megatron-LM provides proper tensor parallelism (TP), expert parallelism (EP), + and data parallelism (DP), enabling all 4 GPUs active simultaneously + +### Architecture: Compressor/decompressor placement +- **Key insight:** In real expert parallelism, compressor and decompressor are on DIFFERENT GPUs + - Compressor: same GPU as attention (source GPU where token originates) + - Decompressor: same GPU as MoE expert (destination GPU after dispatch) +- **Phase A (initial):** TP=4, EP=1 — both on same GPU (simple hooks, like current approach) +- **Phase B (later):** EP support — compress before dispatch, decompress on expert GPU + +### Approach +- **Training pipeline (NEW):** Megatron Bridge → Load Qwen3 with TP=4 → Freeze LLM → + Insert compressors at MoE boundaries → Train via Megatron infrastructure → Save weights +- **Evaluation pipeline (EXISTING):** Load HF model → Load trained weights → Evaluate PPL + with existing hook-based code → Compare with existing results + +### Parallelism strategies +- 4 GPUs: TP=4, EP=1, PP=1, DP=1 — all GPUs active via tensor parallelism +- 8 GPUs: TP=4, EP=1, PP=1, DP=2 — TP within 4 GPUs, DP across 2 replicas +- Multi-node: TP=4 within node (NVLink), DP=N across nodes (AllReduce) + +### New files +- `src/run_megatron_e2e_compressor.py` — Main Megatron training script +- `src/megatron_model_utils.py` — Megatron model loading and MoE detection +- `src/megatron_preprocess_data.py` — Data preprocessing for Megatron binary format +- `scripts/05_megatron_e2e.sh` — Single-node torchrun launcher +- `scripts/05_megatron_e2e_multinode.sh` — Multi-node SLURM template +- `scripts/setup_megatron.sh` — Environment setup +- `requirements_megatron.txt` — Megatron-specific dependencies + +### Implementation details +- **MegatronE2ECompressorManager:** Adapts E2ECompressorManager for Megatron model structure. + Compressors replicated across TP ranks, save from rank 0, HF-compatible weight format. +- **CompressedMoETokenDispatcher (Phase B):** Wraps Megatron's dispatcher to compress tokens + before all-to-all dispatch and decompress on destination GPU. Router sees original hidden state. +- **Manual weight conversion:** HF→Megatron with TP sharding (QKV column-split, O row-split, + experts EP-distributed). Megatron Bridge used when available, manual fallback otherwise. +- **Data preprocessing:** MegatronIndexedDatasetBuilder writes .bin + .idx format for + memory-mapped loading. Same tokenization as HF variant. + +### Commits +- `fe7b8a5`: Documentation for Megatron integration plan +- `70788b9`: Environment setup script and requirements +- `dd00773`: Data preprocessing for Megatron binary format +- `33be348`: Megatron model loading with tensor parallelism +- `db76e01`: Megatron E2E compressor training (TP only, Phase A) +- `4046204`: Expert parallelism support (CompressedMoETokenDispatcher, Phase B) +- `1b10c10`: Launch scripts (single-node torchrun + multi-node SLURM) + +### Audit & fixes (2026-02-14, post-implementation) +Audited all 7 new files and 4 doc files for hybrid parallelism correctness. Found and +fixed the following critical issues: + +- **DistributedSampler used global world instead of DP group.** With TP=4/DP=1, all 4 + ranks got different data, breaking tensor parallelism. Fixed: use `get_dp_info()` from + `megatron_model_utils.py` to get DP-only rank/size for sampling. All ranks in same TP + group now see the same data. +- **Model forward assumed HF `.loss` attribute.** Megatron GPTModel returns logits only. + Fixed: added `MegatronModelWrapper` in `megatron_model_utils.py` that provides HF-style + `SimpleNamespace(loss=..., logits=...)` return. +- **Loss computation not TP-aware.** Standard cross-entropy on vocab-parallel logits gives + wrong results with TP > 1. Fixed: `MegatronModelWrapper._compute_loss()` uses Megatron's + `vocab_parallel_cross_entropy` when TP > 1. +- **`_megatron_to_hf_layer_name` returned wrong HF name.** Was `model.layers.N.mlp.moe_gate` + but HF's `find_moe_layers()` returns `model.layers.N.mlp`. Fixed: now returns correct name + so saved weights are compatible with HF `E2ECompressorManager.load_weights()`. +- **CompressedMoETokenDispatcher had hardcoded arg list.** Broke across Megatron-Core + versions. Fixed: now uses `*args, **kwargs` for version-agnostic forwarding. +- **Val loss all-reduce used global group.** Fixed: now uses `get_dp_group()` so only DP + ranks participate (TP ranks have identical loss by construction). + +New utilities added to `megatron_model_utils.py`: +- `MegatronModelWrapper`: HF-compatible forward with TP-aware vocab-parallel cross-entropy +- `get_dp_info()`: Returns (dp_rank, dp_size) for DP-aware data sampling +- `get_dp_group()`: Returns DP process group for gradient all-reduce + +### Status +- Code implementation COMPLETE. All 7 new files created, all 4 doc files updated. +- Critical hybrid parallelism bugs fixed (DistributedSampler, loss computation, weight names). +- Reused existing classes (Compressor, Decompressor, StaleDecompressor) — not rewritten. +- Training and evaluation pending (requires Megatron-LM environment on compute cluster). +- Compressor weights saved in HF-compatible format for evaluation with existing PPL code. + +## 2026-02-14 — Megatron E2E package restructure (src/megatron_e2e/) + +### Motivation +- Previous Megatron implementation used flat files (`src/megatron_model_utils.py`, + `src/run_megatron_e2e_compressor.py`). Restructured into a proper Python package + `src/megatron_e2e/` for cleaner organization and import paths. +- Updated from TP-only (TP=4, EP=1) to EP-first (EP=4, TP=1) parallelism strategy. + EP is more natural for MoE: each GPU holds 32/128 experts per layer. +- Updated environment from CUDA 12.6 to CUDA 12.9 (required by Megatron Bridge >= 0.2.0 + and Transformer Engine). +- Added Transformer Engine as required dependency (needed for Bridge and fused kernels). + +### New package: src/megatron_e2e/ +``` +src/megatron_e2e/ +├── __init__.py # Package docstring +├── compressor.py # Imports existing Compressor/Decompressor/StaleDecompressor +├── compressor_manager.py # MegatronCompressorManager (adapted from flat files) +├── data.py # PackedTokenDataset + distributed data loading +├── train.py # Main training entry point (torchrun-compatible) +└── evaluate.py # HF-pipeline evaluation for Megatron-trained weights +``` + +### Key changes from previous flat-file implementation +- **Package structure:** All Megatron-specific code under `src/megatron_e2e/` +- **EP-first parallelism:** Default is EP=4, TP=1, PP=1 (was TP=4, EP=1, PP=1) +- **Bridge API:** Tries `AutoBridge.from_hf_pretrained()` first (megatron-bridge >= 0.2.0), + falls back to `MegatronBridge.from_pretrained()`, then manual conversion +- **CUDA 12.9:** Environment setup script uses `module load cuda/12.9` and installs + transformer-engine + megatron-bridge via pip +- **Simpler CLI:** `--tp`, `--ep`, `--pp` flags (was `--tensor-model-parallel-size` etc.) +- **Output dirs:** `results/05a_megatron_e2e_perlayer/`, `results/05b_megatron_e2e_stale/` + +### Updated files +- `scripts/megatron_setup_env.sh` — New setup script (CUDA 12.9, TE, Bridge) +- `scripts/05_megatron_e2e.sh` — Updated to use `src/megatron_e2e/train.py`, EP=4 +- `requirements_megatron.txt` — Updated for megatron-core 0.15+, TE, Bridge +- `.gitignore` — Added `.uv_cache/`, `.uv_pythons/` + +### Preserved (not modified) +- `src/megatron_model_utils.py` — Original flat-file Megatron utils (still works) +- `src/run_megatron_e2e_compressor.py` — Original flat-file training script +- `src/megatron_preprocess_data.py` — Data preprocessing for Megatron binary format +- `scripts/05_megatron_e2e_multinode.sh` — Multi-node SLURM template +- `scripts/setup_megatron.sh` — Original CUDA 12.6 setup (superseded by megatron_setup_env.sh) + +## 2026-02-15 — Megatron 5a training complete + evaluation pipeline fix + +### Megatron Task 5a training (COMPLETED) +- Trained e2e per-layer compressors at 2x/4x/8x/16x using Megatron with EP=4, TP=1, PP=1, DP=4 +- Model loaded via AutoBridge (megatron-bridge 0.2+), CUDA 12.9 +- Training data: 58.9M tokens from Dolci-Instruct-SFT (103,502 train / 11,500 val sequences) +- 1 epoch per ratio, ~50 min per ratio on 4× H100 +- Training losses (train / val): + - e2e_2x: 1.258 / 1.109 + - e2e_4x: 2.103 / 1.627 + - e2e_8x: 2.776 / 2.242 + - e2e_16x: 3.180 / 2.567 +- Weights saved in HF-compatible format at `results/05a_megatron_e2e_perlayer/` + +### Bug fix: --skip-training for evaluation-only mode +- **Problem:** Neither `run_e2e_compressor.py` (HF) nor `train.py` (Megatron) could + evaluate pre-trained weights without re-training. The Megatron script's STEP 3 only + printed instructions instead of running evaluation, and it suggested using + `python src/run_e2e_compressor.py --skip-training` which didn't exist. +- **Fix:** Added `--skip-training` flag to `run_e2e_compressor.py`. When set: + - Skips data loading and training + - Loads `training_results.json` from output-dir (or builds minimal entries from weight files) + - Goes straight to PPL evaluation using existing HF pipeline + - Summary section handles missing training metadata gracefully +- **Usage:** `python src/run_e2e_compressor.py --skip-training --output-dir results/05a_megatron_e2e_perlayer --stale-mode none` +- This enables fair comparison: same HF evaluation code for both HF-trained and Megatron-trained weights + +### Megatron Task 5a perplexity evaluation (COMPLETED) +- Evaluated using HF pipeline via `--skip-training` flag (same code as HF Task 5a) +- Baseline PPL: **4.225** (identical, same model + data) + +| Ratio | HF E2E 5a (PPL) | Megatron E2E 5a (PPL) | Delta | +|-------|------------------|-----------------------|-------| +| 2x | 2.645 (−1.58) | 2.682 (−1.54) | +0.04 | +| 4x | 3.687 (−0.54) | 4.410 (+0.19) | +0.72 | +| 8x | 6.371 (+2.15) | 8.182 (+3.96) | +1.81 | +| 16x | 9.157 (+4.93) | 11.670 (+7.44) | +2.51 | + +- **Megatron 2x is nearly identical to HF** (2.68 vs 2.64, both well below baseline) +- **At 4x, Megatron is marginally above baseline** (4.41 vs 4.23), while HF stayed below (3.69) +- **Gap grows at higher compression** — likely due to different effective optimization: + Megatron with EP=4/DP=4 trains each GPU on 1/4 of data per step, while HF uses + full data stream on a single model replica +- Both implementations produce valid, usable compressors — Megatron 2x achieves −1.54 PPL delta +- Results: `results/05a_megatron_e2e_perlayer/perplexity_results.json` + +## 2026-02-15 — Megatron 5b training + evaluation + bug fix + +### Bug fix: stale device mismatch in multi-GPU evaluation +- **Problem:** `evaluate_perplexity_with_stale_compression()` in `model_utils.py` used + `torch.cat([compressed, stale], dim=-1)` without moving `stale` to the same device as + `compressed`. With `device_map="auto"`, reference layer and non-reference layer can be + on different GPUs, causing `RuntimeError: Expected all tensors to be on the same device`. +- **Fix:** Added `stale = stale.to(compressed.device)` before the `torch.cat()` call + (line 492 of `model_utils.py`). The HF `E2ECompressorManager` already had this fix + (line 273 of `run_e2e_compressor.py`), but the standalone evaluation function did not. +- This bug was latent — it only triggers when stale evaluation uses `device_map="auto"` + (multi-GPU), which is the case for Megatron-trained weight evaluation. + +### Megatron Task 5b training (COMPLETED) +- Trained e2e stale-conditioned compressors at 2x/4x/8x/16x using Megatron with EP=4, TP=1, PP=1, DP=4 +- Model loaded via AutoBridge (megatron-bridge 0.2+), CUDA 12.9 +- Training data: 58.9M tokens from Dolci-Instruct-SFT (103,502 train / 11,500 val sequences) +- Reference layers (stride=12): {0, 12, 24, 36}, stale_dim=2048 (uncompressed) +- 1 epoch per ratio, ~50 min per ratio on 4× H100 +- Training losses (train / val): + - e2e_stale_2x: 1.210 / 1.068 + - e2e_stale_4x: 1.784 / 1.375 + - e2e_stale_8x: 2.206 / 1.724 + - e2e_stale_16x: 2.344 / 1.823 +- Weights saved in HF-compatible format at `results/05b_megatron_e2e_stale/` + +### Megatron Task 5b perplexity evaluation (COMPLETED) +- Evaluated using HF pipeline via `--skip-training` flag (same code as HF Task 5b) +- Baseline PPL: **4.225** (identical, same model + data) + +| Ratio | HF E2E 5b (PPL) | Megatron E2E 5b (PPL) | Delta (Meg−HF) | +|-------|------------------|-----------------------|----------------| +| 2x | 2.570 (−1.65) | 2.568 (−1.66) | −0.00 | +| 4x | 3.102 (−1.12) | 3.420 (−0.80) | +0.32 | +| 8x | 4.015 (−0.21) | 4.743 (+0.52) | +0.73 | +| 16x | 4.550 (+0.32) | 5.232 (+1.01) | +0.68 | + +### Full cross-implementation comparison (HF vs Megatron, 5a vs 5b) + +| Ratio | HF 5a (PPL) | Meg 5a (PPL) | HF 5b (PPL) | Meg 5b (PPL) | +|-------|-------------|--------------|-------------|--------------| +| 2x | 2.645 | 2.682 | **2.570** | **2.568** | +| 4x | 3.687 | 4.410 | **3.102** | **3.420** | +| 8x | 6.371 | 8.182 | **4.015** | **4.743** | +| 16x | 9.157 | 11.670 | **4.550** | **5.232** | + +### Key findings (Megatron 5b) +- **Megatron 5b at 2x is essentially identical to HF 5b** (2.568 vs 2.570, Δ=−0.002) + — the stale conditioning signal fully compensates for Megatron's DP-related optimization differences +- **Stale conditioning dramatically narrows the Megatron-vs-HF gap:** + - At 4x: gap shrinks from +0.72 (no stale) to +0.32 (stale) + - At 8x: gap shrinks from +1.81 (no stale) to +0.73 (stale) + - At 16x: gap shrinks from +2.51 (no stale) to +0.68 (stale) +- **Megatron 5b stays below baseline at 2x and 4x** (2.57 and 3.42 vs baseline 4.23) +- **Megatron 5b at 8x is only +0.52 above baseline** (4.74 vs 4.23) +- **Stale conditioning matters more for Megatron** than for HF — the stale signal acts + as an anchor that partially corrects for the noisier optimization from DP-sharded training +- **Megatron 5b val losses are consistently better than 5a val losses** at equivalent ratios: + - 2x: 1.068 (5b) vs 1.109 (5a), 4x: 1.375 vs 1.627, 8x: 1.724 vs 2.242, 16x: 1.823 vs 2.567 +- **Practical recommendation:** For production use with Megatron, always use stale conditioning + (5b mode) — at 4x compression the PPL is 3.42 (19% below baseline), and at 16x it's only + 5.23 (24% above baseline) +- Results: `results/05b_megatron_e2e_stale/perplexity_results.json` + +## 2026-02-15 — Data selection, logging, wandb, batch size overhaul + +### Motivation +Previous experiments had several issues: +- Sequential data selection (first N rows) — no randomization, no reproducibility +- Per-epoch-only loss logging (1 data point with --epochs 1) — no training curves +- No wandb for real-time monitoring +- batch_size=4 / effective=8, only 100K sequences for Task 5 +- Old results no longer comparable after these changes + +### Changes (5 commits) + +**Commit 1: Reproducible data splitting (seed=42)** +- Added `get_split_indices()` to `model_utils.py`: deterministic 80/10/10 + train/val/test split of all ~2.15M dataset rows +- Modified `load_calibration_data()`: new `data_split` parameter, samples from + shuffled indices in the correct split +- Modified `evaluate_perplexity()`: always uses TEST split for PPL evaluation +- Modified `load_e2e_data()` (HF + Megatron): train tokens from TRAIN split, + val tokens from VAL split — no data leakage +- Added `set_seed(42)` at start of both HF and Megatron main() +- Files: `src/model_utils.py`, `src/run_e2e_compressor.py`, + `src/megatron_e2e/data.py`, `src/megatron_e2e/train.py` + +**Commit 2: Step-level loss logging** +- Added `step_train_loss` and `step_lr` lists to training history +- Track per-optimizer-step loss (averaged over grad_accum micro-batches) +- Replaced training_curves.png: 3-panel plot with EMA-smoothed step loss, + LR schedule, and final loss bar chart +- With 1 epoch + 500K sequences: ~28K data points instead of 1 +- Files: `src/run_e2e_compressor.py`, `src/megatron_e2e/train.py` + +**Commit 3: Wandb integration** +- Added `wandb>=0.16.0` to both requirements files +- Added `--wandb/--no-wandb` and `--wandb-project` CLI args +- Logs train/loss and train/lr per optimizer step, val/loss per epoch +- Gated behind `HAS_WANDB` flag for graceful fallback +- Megatron: only rank 0 logs +- Bash scripts: WANDB_FLAG defaults to --wandb +- Files: `src/run_e2e_compressor.py`, `src/megatron_e2e/train.py`, + `requirements.txt`, `requirements_megatron.txt`, + `scripts/05_run_e2e_compressor.sh`, `scripts/05_megatron_e2e.sh` + +**Commit 4: Batch size + sequence count + HF_HOME** +- Task 5 batch_size: 4→8, effective batch: 8→16 +- Task 5 max_sequences: 100K→500K (~256M train tokens) +- Task 1 MAX_SAMPLES: 256→10000 (draws from random train split) +- All 8 bash scripts: HF_HOME → `/home/lfy/projects/rrg-bengioy-ad/lfy/ECMoE/.cache/huggingface` +- Files: all 8 scripts + +**Commit 5: Documentation** +- Updated CLAUDE.md: seed info, HF_HOME, execution plan +- Updated description.md: Section 9.3 (seeds + splits), new Section 9.4 (wandb), + batch_size=8 in hyperparameter table, 500K sequences +- Updated JOURNAL.md: this entry + +### Old results +- Previous results moved to `results_old/` (05b Megatron incomplete: 8x/16x missing) +- New results will go to fresh `results/` dirs +- Comparison document to be created after experiments complete + +### Execution plan for re-running +1. Phase 1: Megatron 5a+5b parallel (8 GPUs, ~7h) +2. Phase 2: Task 1 re-cache (1 GPU, ~1h) +3. Phase 3: Tasks 2-4 + HF 5a parallel (8 GPUs) +4. Phase 4: Task 4b + HF 5b (8 GPUs, ~18h) +5. Phase 5: Create comparison_old_vs_new.md + +## 2026-02-16 — Fix NCCL timeout in Megatron data loading + +### Bug fix +- **Root cause:** `load_e2e_data()` in `src/megatron_e2e/data.py` had rank 0 + tokenize all 1.7M train + 215K val items (~30 min) while ranks 1-3 waited at + `dist.broadcast()`. NCCL communicator init timed out after 600s (10 min). +- **Fix:** All ranks now tokenize independently (same seed → identical results). + Eliminates the broadcast entirely. Added `dist.barrier()` after tokenization + for synchronization. Progress bars shown only on rank 0. +- **Commit:** 3596f6f + +## 2026-02-16 — Re-running all experiments with new hyperparameters + +### Phase 1: Megatron 5a + 5b (IN PROGRESS) +- Both training on all 8 GPUs (4 each), EP=4, TP=1, PP=1, DP=4 +- New config: 500K sequences (294.4M tokens), effective batch=16, 35,938 steps/epoch +- Wandb enabled: 5a: `vufnrc12`, 5b: `fw9kkwx9` + +#### Megatron 5a (stale=none) — partial results + +| Ratio | Old train/val | New train/val | Δ train | Δ val | +|-------|---------------|---------------|---------|-------| +| 2x | 1.258/1.109 | 1.246/1.161 | -0.012 | +0.052 | +| 4x | 2.103/1.627 | 1.746/1.518 | **-0.357** | **-0.109** | +| 8x | 2.776/2.242 | *in progress* | — | — | +| 16x | 3.180/2.567 | *pending* | — | — | + +#### Megatron 5b (stale=uncompressed) — partial results + +| Ratio | Old train/val | New train/val | Δ train | Δ val | +|-------|---------------|---------------|---------|-------| +| 2x | 1.210/1.068 | 1.209/1.123 | -0.001 | +0.055 | +| 4x | 1.784/1.375 | 1.525/1.322 | **-0.259** | **-0.053** | +| 8x | 2.206/1.724 | *in progress* | — | — | +| 16x | 2.344/1.822 | *pending* | — | — | + +**Observation:** 4x training loss improved significantly with 5x more data (Δ train: -0.357 for 5a, +-0.259 for 5b). 2x shows mixed results: train loss slightly better but val loss slightly higher. + +### Comparison document +- Created `comparison_old_vs_new.md` with partial results +- **Commit:** f8c31a5 + +### Remaining phases +- Phase 2: HF evaluation of Megatron weights (after training completes) +- Phase 3: HF 5a + Tasks 1-4 in parallel (after Megatron frees GPUs) +- Phase 4: HF 5b + Task 4b (after HF 5a / Task 1 complete) +- Phase 5: Final comparison document update + +## 2026-02-16 — Switch to SFT data loading with response-only training + +### Motivation +Previous data loading had several issues: +- **Token-packing:** `PackedTokenDataset` concatenated all tokens into one long + sequence and chunked into fixed-length pieces, arbitrarily gluing together + tokens from different conversations. This is pretraining-style, not SFT. +- **Token-count based:** `_tokenize_items` tokenized samples one by one until + reaching a target token count. The number of sequences depended on their + lengths, not a fixed count. +- **No response masking:** Training and evaluation computed loss on ALL tokens + (system prompt, user input, template markup, AND assistant response). For SFT, + only the assistant response should contribute to the loss. +- **max_length=512:** Too short for many conversations in Dolci-Instruct-SFT. + +### Changes + +**Commit: `ddcdd9f`** + +**Core: `src/model_utils.py`** +- Added `_tokenize_sft_sample()`: tokenizes a single conversation with response-only + labels. For each assistant message, finds the token span via incremental prefix + tokenization (`apply_chat_template(messages[:i+1])`). Sets labels=-100 for all + non-assistant tokens (system, user, template markup, padding). +- Modified `load_calibration_data()`: now returns dicts with `'labels'` key + (in addition to `'input_ids'` and `'attention_mask'`). Labels use SFT masking. +- Modified `evaluate_perplexity()`: passes SFT labels to model forward (not + `labels=input_ids`). Counts response tokens via `(shift_labels != -100).sum()`. +- Updated all `evaluate_perplexity_with_*` default `max_length` from 512 to 2048. + +**HF E2E: `src/run_e2e_compressor.py`** +- Replaced `PackedTokenDataset` with `SFTDataset`: returns dict with + `input_ids`, `labels`, `attention_mask` from `__getitem__`. +- Replaced `_tokenize_items()` with `_tokenize_sft_split()`: samples N + sequences from dataset, each tokenized independently via `_tokenize_sft_sample`. +- Updated `load_e2e_data()`: sequence-based (samples N conversations, not N tokens). +- Updated `train_e2e()` and `evaluate_val_loss()`: unpack batch as dict, + pass labels and attention_mask to model forward. +- Default `--max-length` changed from 512 to 2048. + +**Megatron E2E: `src/megatron_e2e/data.py`** +- Same `SFTDataset` and `_tokenize_sft_split_megatron()` changes. +- All ranks still tokenize independently (same seed → identical results). + +**Megatron E2E: `src/megatron_e2e/train.py`** +- Updated training loop and `evaluate_val_loss()` to unpack SFT batch dict. +- Fixed `MegatronModelWrapper._compute_loss()`: for `vocab_parallel_cross_entropy` + (TP>1 path), explicitly mask -100 labels with `per_token_loss[mask].mean()`. + For standard cross_entropy (TP=1), uses `ignore_index=-100`. +- Default `--max-length` changed from 512 to 2048. + +**Bash scripts:** `MAX_LENGTH=512` → `MAX_LENGTH=2048` in both +`scripts/05_run_e2e_compressor.sh` and `scripts/05_megatron_e2e.sh`. + +### Impact +- **Baseline perplexity will change:** Now computed on response tokens only + (previously on all tokens). This is the correct metric for SFT. +- **All previous results invalidated:** Token-packed training is fundamentally + different from conversation-based SFT. Must re-run all experiments. +- **More VRAM needed:** max_length=2048 means 4× longer sequences than before. + May need to reduce batch_size for HF Task 5 if OOM occurs. + +## 2026-02-16 — Increase PPL evaluation samples to 50,000 + +### Motivation +- Previous default of 64 test samples for perplexity evaluation produced + high-variance estimates. With only 64 sequences, PPL can fluctuate + significantly between runs. +- Increased to 50,000 test sequences for stable, low-variance PPL estimates. + +### Changes +- **`src/model_utils.py`**: Changed `max_samples` default from 64 to 50000 in + all 4 `evaluate_perplexity*` functions +- **8 Python scripts**: Updated argparse `--max-samples-ppl` default from 64 + to 50000 (`run_quantization.py`, `run_neural_compressor.py`, + `run_perlayer_compressor.py`, `run_stale_compressor.py`, + `run_e2e_compressor.py`, `run_megatron_e2e_compressor.py`, + `megatron_e2e/train.py`, `megatron_e2e/evaluate.py`) +- **7 bash scripts**: Updated `MAX_SAMPLES_PPL` from 64 to 50000 + (`02_run_quantization.sh`, `03_run_neural_compressor.sh`, + `03b_run_perlayer_compressor.sh`, `04_run_stale_compressor.sh`, + `05_run_e2e_compressor.sh`, `05_megatron_e2e.sh`, + `05_megatron_e2e_multinode.sh`) +- **`description.md`**: Updated PPL evaluation sample count +- **`CLAUDE.md`**: Added PPL evaluation config note +- **Commit:** `732dc21` (code), this commit (docs) + +## 2026-02-16 — Fix SFT tokenization for transformers 5.1.0 + +### Bug fix +- **Root cause:** `transformers==5.1.0` changed `apply_chat_template(tokenize=True)` + to return a `BatchEncoding` dict (with keys `input_ids`, `attention_mask`) instead + of a plain `list[int]`. In `_tokenize_sft_sample()`, `len(full_ids)` returned 2 + (number of dict keys), which is `< 10`, causing the function to always return `None`. + This made all SFT data loading fail with `ValueError: No valid SFT sequences found`. +- **Fix:** Added `return_dict=False` to both `apply_chat_template()` calls in + `_tokenize_sft_sample()` (`model_utils.py` lines 234-236 and 247-249). +- **Verified:** 20/20 test samples tokenize successfully with response-only labels. +- **Commit:** `3c5740e` + +## 2026-02-16 — OOM fix: reduce batch size for max_length=2048 + +### Bug fix +- **Root cause:** With max_length increased from 512 to 2048 (4× longer sequences), + batch_size=8 per-GPU causes OOM during backward pass. Each GPU had ~70 GB PyTorch + allocated, tried to allocate 4.63 GiB for gradients, only ~2 GB free. +- **Fix:** Reduced batch_size from 8 to 2, increased grad_accum from 2 to 8. + Effective batch stays at 16 (2 × 4 DP × 2 accum = 16 for Megatron). +- Updated all 3 bash scripts and 2 Python script defaults. +- **Commit:** `7fb8325` + +## 2026-02-17 — Add periodic validation loss during training + +### Motivation +- With 1 epoch and ~35K optimizer steps, validation loss was only computed once + (at end of epoch). This made it impossible to monitor training progress or + detect overfitting during a run. +- Wandb showed training loss curves but no validation signal until the very end. + +### Changes +- **`src/megatron_e2e/train.py`**: Added `--val-interval` CLI arg (default 2500). + Every N optimizer steps, runs `evaluate_val_loss()` on the full validation set, + logs to wandb (`val/loss`, `val/step`), updates `best_val_loss` and saves best + checkpoint. End-of-epoch validation still runs as before. Periodic val losses + stored in `history["step_val_loss"]` as `(step, loss)` tuples. Training curves + plot now overlays val loss markers on the training loss panel. Added + `--val-batch-size` (default 8) — no backward pass during eval means we can use + 4x the training batch size, reducing eval time proportionally. Added tqdm + progress bar to `evaluate_val_loss()` (shows in `progress.log`). +- **`src/run_e2e_compressor.py`**: Same changes for HF E2E training. Added + `--val-interval` (default 2500), `--val-batch-size` (default 8), periodic + validation inside optimizer step block, val loss overlay on training curves + plot. Updated existing tqdm in `evaluate_val_loss()` with running loss postfix. +- **`scripts/05_megatron_e2e.sh`**: Added `VAL_INTERVAL=2500` and + `VAL_BATCH_SIZE=8` variables, passes both to torchrun command. +- **`scripts/05_run_e2e_compressor.sh`**: Same `VAL_INTERVAL=2500` and + `VAL_BATCH_SIZE=8` variables. +- **`description.md`**: Added "Validation interval" and "Validation batch size" + rows to training hyperparameters table (Section 5.5), updated wandb section + (Section 9.4) to note val/loss is logged every N steps. +- **`CLAUDE.md`**: Updated Task 5 config line. + +### Usage +```bash +# Default: validate every 2500 steps with val_batch_size=8 +bash scripts/05_megatron_e2e.sh none + +# Custom interval (every 500 steps) +VAL_INTERVAL=500 bash scripts/05_megatron_e2e.sh none + +# Disable periodic validation (end-of-epoch only, old behavior) +# Pass --val-interval 0 directly or set VAL_INTERVAL=0 +``` + +### Impact +- With ~31K optimizer steps and val_interval=2500: 12 periodic + 1 end-of-epoch + = **13 val data points** per run (was 1), enabling proper monitoring via wandb +- val_batch_size=8 (4x training batch=2): eval has no backward pass → less VRAM + → can use larger batches. Reduces micro-batches per val from 6,250 to 1,562 + (per DP rank), cutting eval time by ~4x +- Estimated overhead: ~13 evals × ~7 min each ≈ 1.5h on a 14.5h training run +- Best checkpoint tracks lowest val loss across all periodic and epoch-end evals + +## 2026-02-17 — Response-only hidden state collection for offline tasks + +### Motivation +- All E2E training (Task 5) and PPL evaluation already use SFT mode (response-only + loss via labels=-100 masking). But Task 1 hidden state collection captured ALL + tokens (system, user, template markup, padding, AND assistant response). +- This means offline compressor training (Tasks 2–4) trained on hidden states from + all token types, while PPL evaluation only measured response quality — a distribution + mismatch between training and evaluation. +- Fix: collect only response-token hidden states by default, so offline compressors + train on the same distribution that PPL evaluation measures. + +### Changes +- **`src/model_utils.py`**: + - `MoEHiddenStateCollector`: added `_token_mask` attribute and `set_token_mask(mask)` + method. When a boolean mask is set, dispatch and gather hooks only collect positions + where mask is `True`. + - `collect_hidden_states()`: new `response_only=True` parameter (default ON). Before + each forward pass, computes mask from `labels != -100` (from `_tokenize_sft_sample`). + Same mask applied to all 48 layers per sequence. Metadata records `"response_only"`. +- **`src/run_distribution.py`**: added `--response-only` (default on) and + `--no-response-only` CLI flags. Pass-through to `collect_hidden_states()`. + +### What does NOT change +- Tasks 2–4 scripts: unchanged. They load cached hidden states and train on whatever + is in the cache. If cache has response-only tokens, compressors train on response tokens. +- Task 5 (HF + Megatron): already SFT-aware. No changes. +- PPL evaluation: already SFT-aware. No changes. +- Token alignment across layers: preserved — same mask applied to all 48 layers. + +### Impact +- Each sequence contributes fewer tokens (~50% are response), but `max_samples=10000` + provides more than enough to reach `max_tokens=100000`. +- Offline compressors will now train on the distribution they are evaluated against. +- **All previous cached hidden states are invalidated** — must re-run Task 1. +- **Commit:** `d91499f` + +## 2026-02-17 — Delete legacy Megatron script, fix dead --max-samples-ppl flag + +### Code review findings (external review) +An external review identified the following issues: + +1. **Legacy `run_megatron_e2e_compressor.py` uses standard LM, not SFT (CONFIRMED):** + - Uses `PackedTokenDataset` (token packing, pretraining-style) instead of `SFTDataset` + - `labels=input_ids` trains on ALL tokens, not response-only + - Does not use `get_split_indices()` for deterministic data splitting + - Effective batch size log ignores DP factor (`batch_size * grad_accum` vs + actual `batch_size * grad_accum * dp_size`) + - This means legacy Megatron training is off-policy: trains on pretraining-style + data but evaluation measures SFT response-only perplexity + +2. **`--max-samples-ppl` in `train.py` is dead code (CONFIRMED):** + - The flag was accepted but never used — STEP 3 only prints CLI snippets + - Gives false impression that Megatron training handles evaluation + +3. **Token broadcast memory concern (PARTIALLY CONFIRMED):** + - Legacy `load_e2e_data()` broadcasts entire token tensor from rank 0 + - With legacy defaults (100K seq, 512 len) this is ~471 MB, manageable + - Already fixed in modular package: all ranks tokenize independently + +### Fixes (round 2 — actually delete, not just deprecate) +- **Deleted `src/run_megatron_e2e_compressor.py`:** Removed via `git rm`. + The modular `src/megatron_e2e/train.py` already has all fixes (SFT dataset, + `get_split_indices()`, DP-aware batch scaling, independent tokenization). + Deprecation warnings alone were insufficient — the buggy code was still runnable. +- **Updated `scripts/05_megatron_e2e_multinode.sh`:** Rewrote to use + `src/megatron_e2e/train.py` with `--tp`/`--ep`/`--pp` flags, SFT config + (max_length=2048, val_interval=2500, wandb), EP-first parallelism (EP=4, TP=1), + CUDA 12.9 environment. Removed `--max-samples-ppl` and legacy `--bf16` flag. +- **Removed `--max-samples-ppl` from `train.py`:** Dead code. Added comments + clarifying PPL evaluation runs separately via HF pipeline. +- **Removed `--max-samples-ppl` from `scripts/05_megatron_e2e.sh`:** Matching + the flag removal from `train.py`. +- **Updated docs** (`README.md`, `CLAUDE.md`, `description.md`): Removed all + references to deleted legacy script. + +### What did NOT need fixing (confirmed correct by review) +- Tasks 1–4: SFT-aligned (response-only hidden states, response-only PPL eval) +- HF Task 5: True SFT (SFTDataset, response-only labels, explicit effective batch) +- Modular Megatron (`src/megatron_e2e/`): SFT-aligned, DP-aware batch scaling + +## 2026-02-17 — Comprehensive audit: fix 6 issues + +Full audit of Tasks 1–5 confirmed all tasks correctly use SFT mode, +effective batch sizes match (16 for both HF and Megatron), and data +splits are consistent. Found and fixed six issues: + +### Documentation fixes (description.md) +- **A:** Batch size table said `8 (grad accum: 2)`, corrected to `2 (grad accum: 8)`. + The values were swapped after the 2026-02-16 OOM fix but docs weren't updated. +- **B:** PPL evaluation count said "64 sequences", corrected to "50,000 sequences" + (the actual default in `evaluate_perplexity()`). +- **C:** Wandb section said `val_interval` default is 1000, corrected to 2500. + +### Code fixes +- **D:** `train.py` `--batch-size` argparse help said "Micro batch size per DP rank" + but the code treats it as a global parameter and adjusts internally for DP. + Fixed help text to "Global micro batch size (adjusted for DP internally)". +- **E:** `model_utils.py:evaluate_perplexity()` now passes `use_cache=False` to the + model forward call. Saves VRAM during 50K-sample evaluation by disabling KV cache. +- **F:** `train.py:MegatronModelWrapper.forward()` now explicitly accepts `use_cache` + kwarg instead of silently swallowing it in `**kwargs`. + +## 2026-02-17 — Fix 3 grad accumulation and batch calculation bugs + +### Motivation (external review) +An external code review identified three bugs in the training loops: + +1. **HF E2E partial grad accumulation:** If `len(train_loader)` is not divisible by + `grad_accum`, the final micro-batches run forward+backward but never trigger + `optimizer.step()`. Gradients are silently zeroed at the next epoch's + `optimizer.zero_grad()`. Data and compute wasted every epoch; cosine LR schedule + based on `floor(len / accum)` ignores the dropped work. + +2. **Megatron batch calculation:** Floor division to compute `local_grad_accum` and + `local_batch_size` silently produces wrong effective batch sizes when `dp_size` + doesn't cleanly divide `target_effective`. E.g. batch=3, accum=2, dp=4 → target=6 + but runs with effective=4. Current defaults (batch=2, accum=8, dp=4) happen to + work, but other reasonable configs break silently. + +3. **Megatron partial grad accumulation:** Same issue as #1 but in the Megatron loop. + DistributedSampler provides no guarantee that `len(train_loader)` is divisible by + `local_grad_accum`. + +### Fixes +- **`src/run_e2e_compressor.py`**: Changed `steps_per_epoch` from floor to `math.ceil`. + Added final partial-accumulation optimizer step after the inner training loop: checks + `(step + 1) % grad_accum != 0`, clips gradients, steps optimizer/scheduler, logs loss. +- **`src/megatron_e2e/train.py` (batch calc)**: Replaced floor-division approach with + exact validation. Raises `ValueError` if `target_effective % dp_size != 0`. Finds + largest `local_batch_size ≤ args.batch_size` that exactly divides `per_rank_effective`. + Guarantees `local_batch * dp_size * local_grad_accum == target_effective`. +- **`src/megatron_e2e/train.py` (accumulation)**: Same ceil + partial-step fix as HF. + Includes `_allreduce_compressor_grads()` before the final step (Megatron-specific). +- **Commit:** `356bebc` + +## 2026-02-17 — Fix trailing micro-batch under-weighting in grad accumulation + +### Bug (external review) +The partial grad accumulation step added in `356bebc` had a subtle weighting +bug: every micro-batch divides its loss by the **full** `grad_accum` factor +(line 542 of HF, line 451 of Megatron). When the final optimizer step runs +on fewer than `grad_accum` micro-batches, the accumulated gradient is only +`remaining / grad_accum` of the intended magnitude. For example, with +`grad_accum=8` and `remaining=3`, the final step's gradients are 37.5% of +correct scale. The optimizer then applies this under-weighted update as if +it were a full step. + +### Fix +Removed the partial-accumulation optimizer step entirely from both +`src/run_e2e_compressor.py` and `src/megatron_e2e/train.py`. The tail +micro-batches still run forward+backward (contributing to `epoch_loss` +reporting), but their under-weighted gradients are discarded at the next +`optimizer.zero_grad()` or end of training. + +Also reverted `steps_per_epoch` from `math.ceil` to floor division (`//`), +since the partial step was the reason for using `ceil`. The cosine LR +scheduler now plans for only the full-accumulation steps. + +## 2026-02-17 — Comprehensive audit + stale default fix + +### Audit scope +Full verification of all code paths across Tasks 1–5 (8 Python scripts, 7 bash +scripts) for: +- SFT-style train/loss/eval (response-only labels, `labels=-100` masking) +- Effective batch size and hyperparameter consistency +- Hybrid parallelism correctness (EP, TP, DP) +- General code correctness + +### Findings +All SFT compliance, batch sizes, hyperparameters, and parallelism logic are +correct. One cosmetic issue found: + +### Bug fix +`load_model_and_tokenizer()` in `model_utils.py` had stale default +`load_in_4bit=True` from early development. All callers pass `False` explicitly +via argparse, so it never triggered in practice, but the function signature was +misleading and could cause accidental 4-bit loading if called without the +argument. Fixed: default changed to `load_in_4bit=False`. + +## 2026-02-17 — Fix 3 external review issues + acknowledge 2 design decisions + +### External review findings (5 items) + +**HIGH — Multi-node srun launcher missing distributed env vars (FIXED)** +- `05_megatron_e2e_multinode.sh` called `srun python ...` without exporting + `RANK`, `WORLD_SIZE`, `LOCAL_RANK`. PyTorch's `dist.init_process_group()` + with `env://` init method requires these, but srun only sets SLURM-style + vars (`SLURM_PROCID`, `SLURM_LOCALID`, `SLURM_NTASKS`). +- All processes would get `LOCAL_RANK=0` (the `os.environ.get` default), + causing all ranks to fight for GPU 0. `dist.init_process_group()` would + hang or error due to missing `RANK`/`WORLD_SIZE`. +- Fix: wrapped the python launch in `bash -c` that maps SLURM vars to + torchrun-style vars. Config vars exported so they're available inside + each srun task via `--export=ALL`. + +**MEDIUM — --skip-training crash on missing weights (FIXED)** +- `run_e2e_compressor.py` warned about missing weight files during + `--skip-training` data scan (line 778) but later unconditionally called + `manager.load_weights(weights_path)` for every ratio (line 953). + One missing `*_weights.pt` would abort the entire evaluation. +- Fix: check `os.path.exists(weights_path)` before loading; skip missing + ratios with a WARNING and `continue`. + +**MEDIUM — Tasks 2-4 don't validate response_only metadata (FIXED)** +- Tasks 2-4 load cached hidden states but never checked + `metadata["response_only"]`. If old cache (all-token collection) were + used, offline compressors would train on a different distribution than + PPL evaluation measures (response-only). +- Fix: `load_hidden_states()` now prints the `response_only` field and + warns if it's missing or False. + +**LOW — Batch comment says "per DP rank" but code uses global (FIXED)** +- `05_megatron_e2e_multinode.sh` line 46 said `BATCH_SIZE` is "micro batch + per DP rank", but `train.py` treats `--batch-size` as a global parameter + and adjusts for DP internally. Fixed the comment. + +**LOW — Both HF and Megatron drop tail micro-batches (ACKNOWLEDGED)** +- Explicitly documented in both `run_e2e_compressor.py` (line 585) and + `train.py` (line 498). This is by design: the partial-accumulation + optimizer step was tried and reverted (commit `41c3fb2`) because it + under-weights the final step's gradients. The impact is negligible: + with `grad_accum=8` and ~31K total micro-batches, at most 7 + micro-batches are dropped (0.02% of data). + +### What was confirmed correct +- All tasks correctly use SFT mode (response-only labels) +- Effective batch sizes match: 16 for both HF and Megatron +- Hybrid parallelism logic (EP, TP, DP) is correct +- Data splits are consistent across all tasks (seed=42) + +## 2026-02-17 — Fix 2 issues from second external review (5 findings analyzed) + +### External review findings (5 items, ordered by severity) + +**Finding 1: TP>1 loss path with SFT labels (-100) — NOT A BUG** +- Reviewer concern: `vocab_parallel_cross_entropy(flat_logits, flat_labels)` is + called before masking -100 labels (train.py line 230/236). +- Analysis: Megatron's `vocab_parallel_cross_entropy` handles negative labels + safely: `target_mask = (target >= vocab_start) & (target < vocab_end)` is + `False` for -100, `masked_target` is clamped to 0 (safe gather index), + `predicted_logit` is zeroed. Result is a finite (but meaningless) loss value + for -100 tokens, which is then correctly masked out at line 236-238. + Gradients only flow through valid (masked-in) tokens. No crash, no incorrect + loss. No fix needed. + +**Finding 2: Tail micro-batch dropping — ACKNOWLEDGED DESIGN DECISION** +- Already documented in JOURNAL.md (commit `41c3fb2`). Partial accumulation + step was tried and reverted due to gradient under-weighting. Impact: at + most 7 of ~31K micro-batches dropped (0.02%). No fix needed. + +**Finding 3: Hook device mismatch with --device auto — FIXED (defensive)** +- `evaluate_perplexity_with_perlayer_compression()` and + `evaluate_perplexity_with_stale_compression()` in `model_utils.py` returned + tensors on the compressor's device without moving back to the layer's device. + With `device_map="auto"` (multi-GPU), this would cause device mismatch. +- In practice, Tasks 3/3b/4 always use `device="cuda:0"` (single GPU), so + this never triggered. But added defensive `.to(x.device)` to all 4 hook + types (perlayer pre/post, stale ref/non-ref) for safety. + +**Finding 4: Megatron epoch train loss not DP-reduced — FIXED** +- `epoch_loss` was accumulated per-rank and logged from rank 0 without + DP all-reduce (train.py line 504). With DP=4, logged train loss only + represented 1/4 of data. Step-level train loss logged to wandb was also + per-rank only. +- Fix: Added DP all-reduce for both per-step train loss (before wandb + logging) and epoch-level train loss (before epoch summary). Val loss + was already correctly all-reduced. +- Only affects logging/monitoring, not training correctness (gradients + were already properly all-reduced before optimizer step). + +**Finding 5: SFT-style confirmation — ALREADY CORRECT** +- Reviewer's analysis confirmed: Tasks 1-5 correctly use SFT mode where + applicable. Tasks 2-4 use offline reconstruction loss (not SFT training + loss) but their PPL eval is SFT-style. No action needed. + +## 2026-02-17 — Document external review findings and fixes + +- Updated `CLAUDE.md`: + - Added "Hook device safety" gotcha under Known Issues (re: `.to(x.device)` in eval hooks) + - Added "Train loss DP reduction" to Megatron gotchas (re: all-reduce before logging) +- Updated `description.md`: + - Added "Device safety in evaluation hooks" paragraph in Section 8.1 + - Added Megatron train loss DP-averaging note in Section 9.4 (wandb) + +## 2026-02-17 — Fix TP loss pre-masking and tail microbatch handling + +### TP loss with -100 labels (train.py `_compute_loss`) +- **Problem:** `vocab_parallel_cross_entropy(flat_logits, flat_labels)` was called with + raw -100 labels. Megatron handles this internally (target_mask + clamping), but the + `else` branch at old line 240 computed `per_token_loss.mean()` on garbage values when + ALL tokens in a batch were -100. +- **Fix:** Clamp labels to `min=0` before calling `vocab_parallel_cross_entropy`. + Use `(per_token_loss * loss_mask).sum() / loss_mask.sum().clamp(min=1)` instead of + indexing + `else` branch. Eliminates garbage computation and handles all-masked edge case. + +### Tail microbatch handling (both HF and Megatron) +- **Problem:** When `len(train_loader) % grad_accum != 0`, leftover micro-batches ran + forward+backward with `loss/grad_accum` divisor but the optimizer step was skipped, + discarding their gradients entirely. +- **Fix:** After the main loop, if `remainder > 0`, rescale accumulated gradients by + `grad_accum / remainder` (correcting the divisor from `1/grad_accum` to `1/remainder`), + then perform the optimizer step with proper clipping and logging. +- Previous attempt (commit `41c3fb2`) failed because it stepped without rescaling, + under-weighting the tail by `remainder/grad_accum`. The rescaling approach is correct. +- Applied to both `run_e2e_compressor.py` (HF) and `megatron_e2e/train.py` (Megatron). + +### --device auto in Tasks 3/3b/4 — NOT A BUG +- `compute_device = "cuda:0"` fallback at `run_neural_compressor.py:347`, + `run_perlayer_compressor.py:67`, `run_stale_compressor.py:252` is correct. +- Tasks 3/3b/4 train compressors on cached hidden states (single-GPU operation). + The model is only loaded for PPL evaluation at the end. +- Default `--device` is `cuda:0` in all scripts and bash wrappers. +- The `auto` → `cuda:0` fallback only triggers if someone explicitly passes + `--device auto`, which is not the intended use case for these tasks. +- PPL evaluation hooks already have `.to(x.device)` for cross-device safety. + +## 2026-02-17 — Fix Task 1 max_length mismatch (512 → 2048) + +### Bug +- **Problem:** Task 1 hidden state collection used `max_length=512` while Task 5 + training and all PPL evaluation used `max_length=2048`. This created a distribution + mismatch: offline compressors (Tasks 2–4) trained on hidden states from 512-token + sequences, but PPL evaluation ran on 2048-token sequences. Hidden states at positions + 512–2047 may have different distributions due to longer attention context. +- **Affected files (all had 512):** + - `scripts/01_analyze_distribution.sh`: `MAX_LENGTH=512` + - `src/run_distribution.py`: `--max-length` default 512 + - `src/model_utils.py`: `collect_hidden_states()` default `max_length=512` + - `src/megatron_preprocess_data.py`: `--max-length` default 512 (legacy) +- **Fix:** Changed all four to 2048, matching Task 5 and PPL evaluation. +- **Impact:** Cached hidden states must be re-collected (re-run Task 1) before + re-running Tasks 2–4 to ensure train/eval distribution consistency. + +## 2026-02-18 — Fix OOM in periodic validation (Megatron 5a + 5b crash) + +### Bug +- **Problem:** Both Megatron 5a and 5b crashed with `torch.OutOfMemoryError` at + step 2500 (first periodic validation). `evaluate_val_loss()` with `val_batch_size=8` + and `max_length=2048` calls `cross_entropy(flat_logits, flat_labels)` where + `flat_logits` is `[8*2047, 151936]`. The float32 softmax requires + `8 × 2047 × 151936 × 4 bytes = 9.27 GiB` of contiguous memory. After 2500 + training steps, CUDA memory was fragmented: ~30 GiB was "reserved by PyTorch + but unallocated" (many small free blocks), with only 3–6 GiB actually free. + The 9.27 GiB contiguous allocation failed despite sufficient total capacity. +- **Why now:** The combination of `max_length=2048` (changed from 512 on 2026-02-16) + and `val_batch_size=8` (added on 2026-02-17) created a 4× larger cross_entropy + allocation than the previous `max_length=512` configuration. Training batch_size=2 + only needs ~2.3 GiB for cross_entropy, which fits even in fragmented memory. +- **Fix (two-part):** + 1. Added `torch.cuda.empty_cache()` before every `evaluate_val_loss()` call + (periodic + end-of-epoch) in both `train.py` (Megatron) and + `run_e2e_compressor.py` (HF). This returns fragmented reserved memory to + CUDA, making room for the larger validation batch. + 2. Added `PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True` to both bash + scripts (`05_megatron_e2e.sh`, `05_run_e2e_compressor.sh`) for a + fragmentation-resistant allocation strategy. +- **Also:** Reverted parallelized tokenization in `data.py` back to sequential + (datasets.map was not compatible with the environment). +- **Commit:** `34fb468` + +## 2026-02-18 — Task 5c: Baseline E2E evaluation (no compression) + +### Motivation +- Tasks 5a/5b train per-layer compressors end-to-end and report PPL relative to an + untrained baseline. The baseline PPL (3.937) comes from the raw model, but the + train/val loss context is missing. Task 5c runs the same pipeline (same data loading + via `load_e2e_data()`, same SFT loss computation, same PPL evaluation) but WITHOUT + any compressors. This provides train/val loss references for fair comparison: + if 5c train loss is ~1.0 and 5a-2x is 1.11, compression overhead is only +0.11. + +### Changes + +**HF: `src/run_e2e_compressor.py`** +- Added `"baseline"` to `--stale-mode` choices +- Added `evaluate_loss_no_hooks()` helper: same as `evaluate_val_loss()` but without + a compressor manager, used for baseline train/val loss evaluation +- When `stale_mode == "baseline"`: + - Output dir: `results/05c_e2e_baseline` + - Title: "Task 05c: Baseline E2E Evaluation (no compression)" + - Loads data, computes train/val loss via `evaluate_loss_no_hooks()`, saves results + - Skips compression ratio loop and training curves plot + - In PPL eval: only evaluates baseline PPL, skips ratio loop + +**Megatron: `src/megatron_e2e/train.py`** +- Added `"baseline"` to `--stale-mode` choices +- Added `evaluate_loss_no_hooks()` helper with DP all-reduce +- When `stale_mode == "baseline"`: + - Output dir: `results/05c_megatron_e2e_baseline` + - Title: "Task 05c (Megatron): Baseline E2E Evaluation" + - Computes train/val loss without compression, saves results + - Skips compression ratio loop + +**Bash scripts:** +- `scripts/05_run_e2e_compressor.sh`: accepts `baseline`, maps to `results/05c_e2e_baseline` +- `scripts/05_megatron_e2e.sh`: accepts `baseline`, maps to `results/05c_megatron_e2e_baseline` + +**Documentation:** +- `CLAUDE.md`: added `05c_e2e_baseline/` and `05c_megatron_e2e_baseline/` to dir structure, + added 5c running instructions +- `README.md`: added Task 5c row to experiment table, running instructions, output structure +- `description.md`: added Section 5.7 for Task 5c + +### Design decisions +- Reused existing scripts (added `baseline` as 3rd stale-mode option, not new scripts) +- New helper `evaluate_loss_no_hooks()` is identical to `evaluate_val_loss()` but without + the manager parameter, since baseline has no compressors +- Same data loading path (`load_e2e_data()`) ensures identical data pipeline +- No compression ratios — single evaluation pass with ratio=1.0 + +### Usage +```bash +# HF baseline: +bash scripts/05_run_e2e_compressor.sh baseline +# Megatron baseline: +CUDA_VISIBLE_DEVICES=0,1,2,3 bash scripts/05_megatron_e2e.sh baseline +``` + +## 2026-02-19 — Downstream task evaluation via lm-eval-harness + +### Motivation +- All evaluation has been perplexity-only (Dolci-Instruct-SFT). Downstream task + evaluation (e.g. GSM8K) provides a complementary signal about whether compression + preserves reasoning ability, not just next-token prediction quality. +- Implemented as an optional step within each existing task, not a new task number. + +### New file: `src/downstream_eval.py` +Shared utility module (~270 lines) providing: +- `register_quantization_hooks(model, bits)` — absmax hooks for Task 2 +- `register_perlayer_hooks(model, weights_path, hidden_dim, ratio)` — per-layer hooks for Task 3b +- `register_stale_hooks(model, weights_path, hidden_dim, ratio, stale_mode, ref_stride)` — stale hooks for Task 4 +- `register_e2e_hooks(model, weights_path, hidden_dim, ratio, stale_mode)` — E2E hooks for Task 5 +- `run_lm_eval(model, tokenizer, tasks, ...)` — lm-eval-harness wrapper using HFLM +- `save_downstream_results(results, output_dir, tag, ...)` — JSON result saving +- `add_downstream_args(parser)` — standard CLI args for all scripts + +### Edited files +- **`src/run_quantization.py`**: Added `--downstream-tasks` CLI args + STEP 4 after PPL eval +- **`src/run_perlayer_compressor.py`**: Same pattern +- **`src/run_stale_compressor.py`**: Same pattern +- **`src/run_e2e_compressor.py`**: Same pattern +- **`scripts/02_run_quantization.sh`**: Added DOWNSTREAM_TASKS/FEWSHOT/BATCH_SIZE/LIMIT env vars +- **`scripts/03b_run_perlayer_compressor.sh`**: Same +- **`scripts/04_run_stale_compressor.sh`**: Same +- **`scripts/05_run_e2e_compressor.sh`**: Same +- **`requirements.txt`**: Added `lm_eval[hf]>=0.4.4` +- **`CLAUDE.md`**: Added `downstream_eval.py` to code architecture, downstream eval section + +### Design decisions +- Reused existing hook patterns from `model_utils.py` evaluation functions +- Each `register_*_hooks()` returns hook handles (and module refs to prevent GC) +- `register_e2e_hooks()` reuses `E2ECompressorManager` directly +- Downstream eval is opt-in: only runs when `--downstream-tasks` is specified +- Results saved as `downstream_results.json` alongside `perplexity_results.json` +- GSM8K variant: `gsm8k_cot` (chain-of-thought, 8-shot, generate_until) + +### Usage +```bash +# Run any task with downstream eval: +DOWNSTREAM_TASKS="gsm8k_cot" bash scripts/02_run_quantization.sh + +# Smoke test with 10 examples: +DOWNSTREAM_TASKS="gsm8k_cot" DOWNSTREAM_LIMIT=10 bash scripts/05_run_e2e_compressor.sh none + +# Skip-training mode + downstream: +DOWNSTREAM_TASKS="gsm8k_cot" python src/run_e2e_compressor.py \ + --skip-training --output-dir results/05a_e2e_perlayer --stale-mode none +``` + +## 2026-02-20 — GSM8K downstream evaluation results (all methods) + +### What was done +Ran GSM8K chain-of-thought (8-shot, 1319 test examples) on all compression methods +using a standalone evaluation script that loads the model once per GPU and swaps hooks. +8 GPUs used in parallel — completed in ~3 hours wall time. + +### New files +- **`src/run_all_downstream.py`**: Standalone script, loads model once, evaluates all + methods by swapping hooks. Supports `--method` and `--ratios` for parallel GPU usage. +- **`scripts/run_all_downstream.sh`**: Bash wrapper that launches 8 parallel instances. + +### Results (GSM8K exact_match, strict / flexible) + +| Method | Ratio | Strict | Flexible | +|---|---|---|---| +| Baseline | — | 44.12% | 82.79% | +| INT8 | 2x | 48.90% | 82.26% | +| INT4 | 4x | 56.41% | 68.54% | +| INT2 | 8x | 0.00% | 0.00% | +| Perlayer | 2x | 0.00% | 1.52% | +| Perlayer | 4x-16x | 0.00% | 0.00% | +| Stale comp. | 2x | 3.41% | 62.55% | +| Stale uncomp. | 2x | 2.81% | 67.10% | +| E2E per-layer | 2x | 61.33% | 61.64% | +| E2E per-layer | 4x | 20.70% | 21.30% | +| E2E stale | 2x | 60.27% | 60.65% | +| E2E stale | 4x | 31.54% | 32.37% | +| E2E stale | 8x | 4.93% | 5.00% | + +### Key findings +1. **E2E 2x improves GSM8K by +17 pp** over baseline (61.33% vs 44.12%), confirming + the regularization effect seen in PPL. +2. **Offline methods catastrophically fail on generation** — even stale_uncomp_2x + (PPL=5.15) drops to 2.81% strict-match. But flexible-extract shows 67.10%, + meaning the model still reasons correctly but output formatting is destroyed. +3. **The strict-vs-flexible gap is a new diagnostic**: E2E methods have ~0.3 pp gap + (format preserved), offline methods have up to 64 pp gap (format destroyed). +4. **GSM8K is much more sensitive than PPL** to compression artifacts. +5. **INT4 quantization surprisingly improves strict-match** to 56.41% (+12 pp) + while flexible-extract drops only to 68.54% from 82.79%. + +### Updated files +- **`description.md`**: Added GSM8K columns to Section 6.1 summary table, added + Section 6.4 with downstream analysis, updated Section 6.2 key findings. +- **`JOURNAL.md`**: This entry. + +## 2026-02-20 — Fix description.md PPL numbers to match actual JSON results + +### Problem +The PPL numbers in description.md did not match the actual values in +`results/*/perplexity_results.json`. For example: +- Baseline was listed as 4.23 but actual value is 3.89 (Tasks 2–4) / 3.94 (Megatron 5c) +- Perlayer 2x was listed as 5.92 but actual value is 21.07 +- Stale uncomp 2x was listed as 5.15 but actual value is 6.24 + +The old numbers likely came from a previous run with different settings. + +### Fix +Updated Section 6.1 summary table, Section 6.2 key findings, Section 6.4 downstream +analysis, all with values directly from the JSON result files. Added note to Section 6.3 +that HF E2E comparison uses numbers from a previous run (weights no longer available). +Split baseline into two rows: Tasks 2–4 (PPL=3.89) and Megatron 5c (PPL=3.94). + +### Updated files +- **`description.md`**: All PPL numbers in Sections 6.1, 6.2, 6.3, 6.4 corrected. diff --git a/airline_routes.json b/airline_routes.json new file mode 100644 index 0000000000000000000000000000000000000000..6e042fee7d5427281c1c3dcb2c702484e3f0a17c --- /dev/null +++ b/airline_routes.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7e8d97548f626230927e3e38b8ba9712710612ef90292e9a0696698d20b3bac3 +size 21798276 diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/api/airports.py b/backend/api/airports.py new file mode 100644 index 0000000000000000000000000000000000000000..c41f75a3556c558b93e9560e936d9c5a917143ee --- /dev/null +++ b/backend/api/airports.py @@ -0,0 +1,47 @@ +"""Airport autocomplete and info endpoints.""" + +from fastapi import APIRouter, HTTPException, Query + +from ..data_loader import get_route_graph +from ..models import AirportInfo, AutocompleteResult + +router = APIRouter(prefix="/api/airports", tags=["airports"]) + + +@router.get("/autocomplete", response_model=list[AutocompleteResult]) +async def autocomplete(q: str = Query(..., min_length=1, max_length=50)): + graph = get_route_graph() + airports = graph.search_airports(q, limit=10) + return [ + AutocompleteResult( + iata=a.iata, + name=a.name, + city_name=a.city_name, + country=a.country, + display_name=a.display_name, + hub_score=a.hub_score, + ) + for a in airports + ] + + +@router.get("/{iata}", response_model=AirportInfo) +async def get_airport(iata: str): + graph = get_route_graph() + iata = iata.upper() + airport = graph.airports.get(iata) + if not airport: + raise HTTPException(status_code=404, detail=f"Airport {iata} not found") + return AirportInfo( + iata=airport.iata, + name=airport.name, + city_name=airport.city_name, + country=airport.country, + country_code=airport.country_code, + continent=airport.continent, + latitude=airport.latitude, + longitude=airport.longitude, + timezone=airport.timezone, + hub_score=airport.hub_score, + route_count=len(airport.routes), + ) diff --git a/backend/api/calendar.py b/backend/api/calendar.py new file mode 100644 index 0000000000000000000000000000000000000000..329a04bf60c5c03c1772582f1301421a251e4bc8 --- /dev/null +++ b/backend/api/calendar.py @@ -0,0 +1,70 @@ +"""Calendar pricing endpoint — cheapest price per day for a month.""" + +from __future__ import annotations + +import calendar +from datetime import date + +from fastapi import APIRouter, HTTPException, Query + +from ..data_loader import get_route_graph +from ..models import CalendarDay, CalendarResponse, CabinClass +from ..price_engine import compute_calendar_price +from ..seed_utils import seeded_random + +router = APIRouter(prefix="/api", tags=["calendar"]) + + +@router.get("/calendar", response_model=CalendarResponse) +async def get_calendar( + origin: str = Query(..., min_length=3, max_length=3), + destination: str = Query(..., min_length=3, max_length=3), + year: int = Query(..., ge=2025, le=2028), + month: int = Query(..., ge=1, le=12), + cabin_class: CabinClass = Query(CabinClass.economy), +): + graph = get_route_graph() + origin = origin.upper() + destination = destination.upper() + + if origin not in graph.airports: + raise HTTPException(status_code=404, detail=f"Airport {origin} not found") + if destination not in graph.airports: + raise HTTPException(status_code=404, detail=f"Airport {destination} not found") + + route = graph.get_direct_route(origin, destination) + if not route: + # Try to estimate distance for pricing + from ..route_finder import _estimate_distance + distance = _estimate_distance(graph, origin, destination) + if distance is None: + raise HTTPException(status_code=404, detail="No route found") + num_carriers = 2 # default estimate + else: + distance = route.distance_km + num_carriers = len(route.carriers) + + dest_airport = graph.airports[destination] + num_days = calendar.monthrange(year, month)[1] + + days = [] + for day in range(1, num_days + 1): + d = date(year, month, day) + rng = seeded_random(origin, destination, d.isoformat(), "calendar") + price = compute_calendar_price( + distance_km=distance, + cabin_class=cabin_class.value, + target_date=d, + num_carriers=num_carriers, + dest_continent=dest_airport.continent, + rng=rng, + ) + days.append(CalendarDay(date=d, cheapest_price=price)) + + return CalendarResponse( + origin=origin, + destination=destination, + year=year, + month=month, + days=days, + ) diff --git a/backend/api/search.py b/backend/api/search.py new file mode 100644 index 0000000000000000000000000000000000000000..c1ec6df892e75de1cd66a6e6b7847b5c01701668 --- /dev/null +++ b/backend/api/search.py @@ -0,0 +1,144 @@ +"""Flight search endpoint.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from ..config import MAX_RESULTS +from ..data_loader import get_route_graph +from ..flight_generator import generate_flights_for_route +from ..hub_detector import compute_hub_scores +from ..models import FlightOffer, SearchRequest, SearchResponse, SortBy +from ..route_finder import find_routes +from ..seed_utils import make_seed + +router = APIRouter(prefix="/api", tags=["search"]) + +# Module-level hub cache +_hub_iatas: list[str] | None = None + + +def _get_hubs() -> list[str]: + global _hub_iatas + if _hub_iatas is None: + graph = get_route_graph() + _hub_iatas = compute_hub_scores(graph) + return _hub_iatas + + +def _apply_filters(flights: list[FlightOffer], req: SearchRequest) -> list[FlightOffer]: + f = req.filters + result = flights + + if f.max_stops is not None: + result = [fl for fl in result if fl.stops <= f.max_stops] + + if f.max_price is not None: + result = [fl for fl in result if fl.price_usd <= f.max_price] + + if f.max_duration_minutes is not None: + result = [fl for fl in result if fl.total_duration_minutes <= f.max_duration_minutes] + + if f.airlines: + airline_set = set(f.airlines) + result = [ + fl for fl in result + if any(seg.airline_code in airline_set for seg in fl.segments) + ] + + if f.departure_time_min: + h, m = map(int, f.departure_time_min.split(":")) + min_minutes = h * 60 + m + result = [ + fl for fl in result + if fl.departure.hour * 60 + fl.departure.minute >= min_minutes + ] + + if f.departure_time_max: + h, m = map(int, f.departure_time_max.split(":")) + max_minutes = h * 60 + m + result = [ + fl for fl in result + if fl.departure.hour * 60 + fl.departure.minute <= max_minutes + ] + + return result + + +def _sort_flights(flights: list[FlightOffer], sort_by: SortBy) -> list[FlightOffer]: + if sort_by == SortBy.cheapest: + return sorted(flights, key=lambda f: f.price_usd) + elif sort_by == SortBy.fastest: + return sorted(flights, key=lambda f: f.total_duration_minutes) + else: # best: balance of price and duration + if not flights: + return flights + max_price = max(f.price_usd for f in flights) or 1 + max_dur = max(f.total_duration_minutes for f in flights) or 1 + return sorted( + flights, + key=lambda f: (f.price_usd / max_price) * 0.6 + (f.total_duration_minutes / max_dur) * 0.4, + ) + + +@router.post("/search", response_model=SearchResponse) +async def search_flights(req: SearchRequest): + graph = get_route_graph() + hub_iatas = _get_hubs() + + if not req.legs: + raise HTTPException(status_code=400, detail="At least one leg required") + + # Validate airports + for leg in req.legs: + if leg.origin.upper() not in graph.airports: + raise HTTPException(status_code=404, detail=f"Airport {leg.origin} not found") + if leg.destination.upper() not in graph.airports: + raise HTTPException(status_code=404, detail=f"Airport {leg.destination} not found") + + # Generate outbound flights + outbound_leg = req.legs[0] + origin = outbound_leg.origin.upper() + destination = outbound_leg.destination.upper() + + max_stops = req.filters.max_stops + route_plans = find_routes(graph, origin, destination, hub_iatas, max_stops=max_stops) + + outbound_flights: list[FlightOffer] = [] + for plan in route_plans: + flights = generate_flights_for_route( + graph, plan, outbound_leg.date, req.cabin_class, hub_iatas + ) + outbound_flights.extend(flights) + + outbound_flights = _apply_filters(outbound_flights, req) + outbound_flights = _sort_flights(outbound_flights, req.sort_by) + outbound_flights = outbound_flights[:MAX_RESULTS] + + # Generate return flights if round trip + return_flights: list[FlightOffer] = [] + if req.trip_type.value == "round_trip" and len(req.legs) >= 2: + return_leg = req.legs[1] + ret_origin = return_leg.origin.upper() + ret_dest = return_leg.destination.upper() + ret_plans = find_routes(graph, ret_origin, ret_dest, hub_iatas, max_stops=max_stops) + + for plan in ret_plans: + flights = generate_flights_for_route( + graph, plan, return_leg.date, req.cabin_class, hub_iatas + ) + return_flights.extend(flights) + + return_flights = _apply_filters(return_flights, req) + return_flights = _sort_flights(return_flights, req.sort_by) + return_flights = return_flights[:MAX_RESULTS] + + search_id = str(make_seed(origin, destination, outbound_leg.date.isoformat())) + + return SearchResponse( + outbound_flights=outbound_flights, + return_flights=return_flights, + search_id=search_id, + origin=origin, + destination=destination, + ) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000000000000000000000000000000000000..89555b53605fa297694ec91487675cb97e376491 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,93 @@ +"""Pricing constants and configuration.""" + +# Base price formula +BASE_FIXED_USD = 40 +BASE_PER_KM_USD = 0.08 + +# Cabin class multipliers +CLASS_MULTIPLIERS = { + "economy": 1.0, + "premium_economy": 1.6, + "business": 3.2, + "first": 5.5, +} + +# Day-of-week multipliers (0=Monday, 6=Sunday) +DAY_MULTIPLIERS = { + 0: 0.90, # Monday + 1: 0.90, # Tuesday + 2: 0.90, # Wednesday + 3: 1.00, # Thursday + 4: 1.15, # Friday + 5: 1.05, # Saturday + 6: 1.10, # Sunday +} + +# Season multipliers by month +SEASON_MULTIPLIERS = { + 1: 0.85, # January - off season + 2: 0.85, # February - off season + 3: 0.95, # March + 4: 1.00, # April + 5: 1.05, # May + 6: 1.15, # June - summer peak + 7: 1.20, # July - summer peak + 8: 1.15, # August - summer peak + 9: 0.90, # September - off season + 10: 0.95, # October + 11: 1.00, # November + 12: 1.40, # December - Christmas +} + +# Season bonus for EU destinations in summer +EU_SUMMER_BONUS = 0.15 # +15% on top of summer multiplier +EU_CONTINENTS = {"EU"} +EU_SUMMER_MONTHS = {6, 7, 8} + +# Demand multipliers +MONOPOLY_ROUTE_BONUS = 0.20 # +20% if only 1 carrier +HIGH_COMPETITION_DISCOUNT = 0.05 # -5% if 4+ carriers + +# Advance booking multipliers (days before departure) +ADVANCE_MULTIPLIERS = [ + (3, 1.50), # 0-3 days: +50% + (7, 1.35), # 4-7 days: +35% + (14, 1.20), # 8-14 days: +20% + (21, 1.10), # 15-21 days: +10% + (60, 1.00), # 22-60 days: base + (90, 0.90), # 61-90 days: -10% + (float("inf"), 0.95), # 91+ days: -5% +] + +# Jitter range (±8%) +JITTER_RANGE = 0.08 + +# Hub detection thresholds +HUB_MIN_ROUTES = 100 +HUB_TOP_N = 125 + +# Connecting flight constraints +MAX_1STOP_DISTANCE_RATIO = 1.8 # Max total distance vs great-circle +MAX_2STOP_DISTANCE_RATIO = 2.5 +MIN_LAYOVER_MINUTES = 60 # 1 hour +MAX_LAYOVER_MINUTES = 360 # 6 hours + +# Flight generation +MIN_FLIGHTS_PER_DAY = 1 +MAX_FLIGHTS_SINGLE_CARRIER = 3 +MAX_FLIGHTS_MULTI_CARRIER = 15 +DEPARTURE_HOUR_MIN = 5 # 05:00 +DEPARTURE_HOUR_MAX = 23 # 23:00 + +# Aircraft types by distance +AIRCRAFT_BY_DISTANCE = [ + (500, ["E190", "E175", "CRJ-900"]), + (2000, ["A320", "A321", "737-800", "737 MAX 8"]), + (5000, ["A321neo LR", "757-200", "767-300ER"]), + (10000, ["787-8", "787-9", "A330-300", "A350-900"]), + (float("inf"), ["777-300ER", "A350-1000", "787-10", "A380"]), +] + +# Search limits +MAX_RESULTS = 200 +MAX_AUTOCOMPLETE_RESULTS = 10 diff --git a/backend/data_loader.py b/backend/data_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..66a52598cd183826e9bcca587cdfe6d7a42986a0 --- /dev/null +++ b/backend/data_loader.py @@ -0,0 +1,164 @@ +"""Load airline_routes.json and build in-memory route graph + search index.""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field + + +@dataclass +class Route: + destination: str + distance_km: int + duration_min: int + carriers: list[dict] # [{"iata": "AA", "name": "American Airlines"}, ...] + + +@dataclass +class Airport: + iata: str + name: str + city_name: str + country: str + country_code: str + continent: str + latitude: float + longitude: float + timezone: str + elevation: int + icao: str + display_name: str + routes: list[Route] = field(default_factory=list) + hub_score: float = 0.0 + + +class RouteGraph: + """In-memory route graph and search index.""" + + def __init__(self) -> None: + self.airports: dict[str, Airport] = {} + # route_map[origin_iata][dest_iata] = Route + self.route_map: dict[str, dict[str, Route]] = {} + # Search index: lowercase tokens → set of IATA codes + self._search_index: dict[str, set[str]] = {} + + def load(self, filepath: str) -> None: + with open(filepath) as f: + data: dict = json.load(f) + + for iata, info in data.items(): + routes = [] + for r in info.get("routes", []): + routes.append(Route( + destination=r["iata"], + distance_km=r["km"], + duration_min=r["min"], + carriers=r["carriers"], + )) + + airport = Airport( + iata=iata, + name=info["name"], + city_name=info["city_name"], + country=info["country"], + country_code=info["country_code"], + continent=info["continent"], + latitude=float(info["latitude"]) if info.get("latitude") is not None else 0.0, + longitude=float(info["longitude"]) if info.get("longitude") is not None else 0.0, + timezone=info.get("timezone", "UTC"), + elevation=info.get("elevation", 0), + icao=info.get("icao", ""), + display_name=info.get("display_name", f"{info['city_name']} ({iata})"), + routes=routes, + ) + self.airports[iata] = airport + + # Build route map + self.route_map.setdefault(iata, {}) + for route in routes: + self.route_map[iata][route.destination] = route + + # Build search index + self._index_airport(airport) + + def _index_airport(self, airport: Airport) -> None: + tokens = set() + # IATA code + tokens.add(airport.iata.lower()) + # City name tokens + for word in airport.city_name.lower().split(): + tokens.add(word) + # Airport name tokens + for word in airport.name.lower().split(): + tokens.add(word) + # Country + for word in airport.country.lower().split(): + tokens.add(word) + # Country code + tokens.add(airport.country_code.lower()) + + for token in tokens: + # Index exact token and all prefixes ≥ 2 chars + for i in range(2, len(token) + 1): + prefix = token[:i] + self._search_index.setdefault(prefix, set()).add(airport.iata) + + def search_airports(self, query: str, limit: int = 10) -> list[Airport]: + """Search airports by IATA code, city, name, or country.""" + q = query.strip().lower() + if not q: + return [] + + # Exact IATA match first + if len(q) == 3 and q.upper() in self.airports: + exact = self.airports[q.upper()] + results = [exact] + # Add more results from prefix search + candidates = self._search_index.get(q, set()) + for iata in candidates: + if iata != exact.iata: + results.append(self.airports[iata]) + if len(results) >= limit: + break + return results[:limit] + + # Split query into tokens, intersect matches + query_tokens = q.split() + if not query_tokens: + return [] + + # Get candidates matching first token + candidates = self._search_index.get(query_tokens[0], set()).copy() + + # Intersect with additional tokens + for token in query_tokens[1:]: + token_matches = self._search_index.get(token, set()) + candidates &= token_matches + + if not candidates: + return [] + + # Sort by hub score (descending), then alphabetically + airports = [self.airports[iata] for iata in candidates if iata in self.airports] + airports.sort(key=lambda a: (-a.hub_score, a.city_name)) + return airports[:limit] + + def get_direct_route(self, origin: str, destination: str) -> Route | None: + return self.route_map.get(origin, {}).get(destination) + + def get_outbound_routes(self, origin: str) -> dict[str, Route]: + return self.route_map.get(origin, {}) + + +# Singleton +_graph: RouteGraph | None = None + + +def get_route_graph() -> RouteGraph: + global _graph + if _graph is None: + _graph = RouteGraph() + data_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "airline_routes.json") + _graph.load(data_path) + return _graph diff --git a/backend/flight_generator.py b/backend/flight_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..cb8bf523f445c3dfce038a13b1aedc078fbecc1f --- /dev/null +++ b/backend/flight_generator.py @@ -0,0 +1,270 @@ +"""Generate concrete flights for a route + date.""" + +from __future__ import annotations + +import random +from datetime import date, datetime, timedelta, timezone + +from zoneinfo import ZoneInfo + +from .config import ( + AIRCRAFT_BY_DISTANCE, + DEPARTURE_HOUR_MAX, + DEPARTURE_HOUR_MIN, + MAX_FLIGHTS_MULTI_CARRIER, + MAX_FLIGHTS_SINGLE_CARRIER, + MAX_LAYOVER_MINUTES, + MIN_FLIGHTS_PER_DAY, + MIN_LAYOVER_MINUTES, +) +from .data_loader import Route, RouteGraph +from .models import CabinClass, FlightOffer, FlightSegment +from .price_engine import compute_price +from .route_finder import RoutePlan +from .seed_utils import seeded_random + + +def _pick_aircraft(distance_km: int, rng: random.Random) -> str: + for max_dist, aircraft_list in AIRCRAFT_BY_DISTANCE: + if distance_km <= max_dist: + return rng.choice(aircraft_list) + return "777-300ER" + + +def _make_flight_number(carrier_iata: str, rng: random.Random) -> str: + return f"{carrier_iata}{rng.randint(100, 9999)}" + + +def _get_timezone(graph: RouteGraph, iata: str) -> ZoneInfo: + airport = graph.airports.get(iata) + if airport and airport.timezone: + try: + return ZoneInfo(airport.timezone) + except KeyError: + pass + return ZoneInfo("UTC") + + +def generate_flights_for_route( + graph: RouteGraph, + route_plan: RoutePlan, + departure_date: date, + cabin_class: CabinClass, + hub_iatas: list[str], +) -> list[FlightOffer]: + """Generate concrete flight offers for a route plan on a given date.""" + origin = route_plan.waypoints[0] + destination = route_plan.waypoints[-1] + + # Seed based on route + date for determinism + seed_key = f"{origin}-{destination}-{departure_date.isoformat()}-{cabin_class.value}" + rng = seeded_random(seed_key, *route_plan.waypoints) + + if route_plan.stops == 0: + return _generate_direct_flights(graph, route_plan, departure_date, cabin_class, rng) + else: + return _generate_connecting_flights(graph, route_plan, departure_date, cabin_class, rng) + + +def _generate_direct_flights( + graph: RouteGraph, + route_plan: RoutePlan, + departure_date: date, + cabin_class: CabinClass, + rng: random.Random, +) -> list[FlightOffer]: + """Generate multiple direct flight options for a single-leg route.""" + leg = route_plan.legs[0] + origin = route_plan.waypoints[0] + destination = route_plan.waypoints[1] + + # Number of flights based on carrier count + num_carriers = len(leg.carriers) + if num_carriers == 1: + num_flights = rng.randint(MIN_FLIGHTS_PER_DAY, MAX_FLIGHTS_SINGLE_CARRIER) + elif num_carriers <= 3: + num_flights = rng.randint(3, 8) + else: + num_flights = rng.randint(8, MAX_FLIGHTS_MULTI_CARRIER) + + # Generate departure times spread across the day + departure_hours = sorted([ + rng.randint(DEPARTURE_HOUR_MIN * 60, DEPARTURE_HOUR_MAX * 60) + for _ in range(num_flights) + ]) + + origin_tz = _get_timezone(graph, origin) + dest_tz = _get_timezone(graph, destination) + origin_airport = graph.airports[origin] + dest_airport = graph.airports[destination] + + flights = [] + for dep_minutes in departure_hours: + carrier = rng.choice(leg.carriers) + dep_hour = dep_minutes // 60 + dep_min = dep_minutes % 60 + + departure_dt = datetime( + departure_date.year, departure_date.month, departure_date.day, + dep_hour, dep_min, + tzinfo=origin_tz, + ) + + # Calculate arrival + arrival_dt = departure_dt + timedelta(minutes=leg.duration_min) + arrival_dt = arrival_dt.astimezone(dest_tz) + + price = compute_price( + distance_km=leg.distance_km, + cabin_class=cabin_class.value, + departure_date=departure_date, + departure_hour=dep_hour, + num_carriers=num_carriers, + dest_continent=dest_airport.continent, + rng=rng, + ) + + flight_id = f"{origin}{destination}{departure_date.isoformat()}{dep_minutes}{carrier['iata']}" + + segment = FlightSegment( + airline_code=carrier["iata"], + airline_name=carrier["name"], + flight_number=_make_flight_number(carrier["iata"], rng), + aircraft=_pick_aircraft(leg.distance_km, rng), + origin=origin, + origin_city=origin_airport.city_name, + destination=destination, + destination_city=dest_airport.city_name, + departure=departure_dt, + arrival=arrival_dt, + duration_minutes=leg.duration_min, + ) + + flights.append(FlightOffer( + id=flight_id, + segments=[segment], + total_duration_minutes=leg.duration_min, + stops=0, + price_usd=price, + cabin_class=cabin_class, + origin=origin, + destination=destination, + departure=departure_dt, + arrival=arrival_dt, + )) + + return flights + + +def _generate_connecting_flights( + graph: RouteGraph, + route_plan: RoutePlan, + departure_date: date, + cabin_class: CabinClass, + rng: random.Random, +) -> list[FlightOffer]: + """Generate connecting flight options (1-stop or 2-stop).""" + origin = route_plan.waypoints[0] + destination = route_plan.waypoints[-1] + dest_airport = graph.airports[destination] + + # Generate 2-5 options per connecting route + num_options = rng.randint(2, 5) + + flights = [] + for option_idx in range(num_options): + departure_minutes = rng.randint(DEPARTURE_HOUR_MIN * 60, DEPARTURE_HOUR_MAX * 60) + + segments = [] + current_time = datetime( + departure_date.year, departure_date.month, departure_date.day, + departure_minutes // 60, departure_minutes % 60, + tzinfo=_get_timezone(graph, origin), + ) + total_price = 0.0 + total_duration = 0 + + valid = True + for i, leg in enumerate(route_plan.legs): + leg_origin = route_plan.waypoints[i] + leg_dest = route_plan.waypoints[i + 1] + origin_tz = _get_timezone(graph, leg_origin) + dest_tz = _get_timezone(graph, leg_dest) + origin_ap = graph.airports[leg_origin] + dest_ap = graph.airports[leg_dest] + + carrier = rng.choice(leg.carriers) + departure_dt = current_time.astimezone(origin_tz) + arrival_dt = departure_dt + timedelta(minutes=leg.duration_min) + arrival_dt = arrival_dt.astimezone(dest_tz) + + # Per-leg price + leg_price = compute_price( + distance_km=leg.distance_km, + cabin_class=cabin_class.value, + departure_date=departure_date, + departure_hour=departure_dt.hour, + num_carriers=len(leg.carriers), + dest_continent=dest_ap.continent, + rng=rng, + ) + # Connecting flights get a discount + leg_price *= 0.75 + total_price += leg_price + + segments.append(FlightSegment( + airline_code=carrier["iata"], + airline_name=carrier["name"], + flight_number=_make_flight_number(carrier["iata"], rng), + aircraft=_pick_aircraft(leg.distance_km, rng), + origin=leg_origin, + origin_city=origin_ap.city_name, + destination=leg_dest, + destination_city=dest_ap.city_name, + departure=departure_dt, + arrival=arrival_dt, + duration_minutes=leg.duration_min, + )) + + # Add layover time for next leg + if i < len(route_plan.legs) - 1: + layover = rng.randint(MIN_LAYOVER_MINUTES, MAX_LAYOVER_MINUTES) + current_time = arrival_dt + timedelta(minutes=layover) + total_duration += leg.duration_min + layover + + # Check if layover pushes to next day too far + if (current_time - datetime( + departure_date.year, departure_date.month, departure_date.day, + tzinfo=origin_tz + )).days > 1: + valid = False + break + else: + total_duration += leg.duration_min + + if not valid: + continue + + total_price = round(total_price, 0) + first_departure = segments[0].departure + last_arrival = segments[-1].arrival + + flight_id = ( + f"{origin}{destination}{departure_date.isoformat()}" + f"{departure_minutes}{'-'.join(route_plan.waypoints)}{option_idx}" + ) + + flights.append(FlightOffer( + id=flight_id, + segments=segments, + total_duration_minutes=total_duration, + stops=route_plan.stops, + price_usd=total_price, + cabin_class=cabin_class, + origin=origin, + destination=destination, + departure=first_departure, + arrival=last_arrival, + )) + + return flights diff --git a/backend/hub_detector.py b/backend/hub_detector.py new file mode 100644 index 0000000000000000000000000000000000000000..dcb2b52b6f6b84a82b4adcbe35b8f9d9c1c01b46 --- /dev/null +++ b/backend/hub_detector.py @@ -0,0 +1,52 @@ +"""Compute hub scores for airports. + +Hub score = route_count * carrier_diversity * continent_reach +Used for: connecting flight search (top hubs as waypoints) and autocomplete ranking. +""" + +from __future__ import annotations + +from .config import HUB_MIN_ROUTES, HUB_TOP_N +from .data_loader import RouteGraph + + +def compute_hub_scores(graph: RouteGraph) -> list[str]: + """Compute hub scores for all airports. Returns top hub IATA codes. + + Modifies airports in-place to set hub_score. + """ + for airport in graph.airports.values(): + route_count = len(airport.routes) + if route_count < 5: + airport.hub_score = 0.0 + continue + + # Carrier diversity: unique carriers across all routes + carriers = set() + for route in airport.routes: + for c in route.carriers: + carriers.add(c["iata"]) + carrier_diversity = len(carriers) + + # Continent reach: unique continents reachable + continents = set() + for route in airport.routes: + dest = graph.airports.get(route.destination) + if dest: + continents.add(dest.continent) + continent_reach = len(continents) + + airport.hub_score = route_count * (carrier_diversity ** 0.5) * (continent_reach ** 0.3) + + # Normalize scores to 0-100 + max_score = max((a.hub_score for a in graph.airports.values()), default=1.0) + if max_score > 0: + for airport in graph.airports.values(): + airport.hub_score = round(airport.hub_score / max_score * 100, 2) + + # Return top hubs + hubs = sorted( + [a for a in graph.airports.values() if len(a.routes) >= HUB_MIN_ROUTES], + key=lambda a: -a.hub_score, + ) + return [h.iata for h in hubs[:HUB_TOP_N]] diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000000000000000000000000000000000000..c1ee7ec5e6f65f02e8005da70faa9dc2ee9ea10e --- /dev/null +++ b/backend/main.py @@ -0,0 +1,59 @@ +"""FastAPI application — flight search backend.""" + +import os +import time + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + +from .api import airports, calendar, search +from .data_loader import get_route_graph +from .hub_detector import compute_hub_scores + +app = FastAPI(title="Flight Search API", version="1.0.0") + +# CORS for development +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Register API routers +app.include_router(airports.router) +app.include_router(search.router) +app.include_router(calendar.router) + + +@app.on_event("startup") +async def startup(): + """Load data and compute hub scores on startup.""" + t0 = time.time() + graph = get_route_graph() + hubs = compute_hub_scores(graph) + elapsed = time.time() - t0 + print(f"Loaded {len(graph.airports)} airports, {len(hubs)} hubs in {elapsed:.1f}s") + + +@app.get("/api/health") +async def health(): + graph = get_route_graph() + return {"status": "ok", "airports": len(graph.airports)} + + +# Serve frontend static files (production) +STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend", "dist") +if os.path.isdir(STATIC_DIR): + app.mount("/assets", StaticFiles(directory=os.path.join(STATIC_DIR, "assets")), name="assets") + + @app.get("/{full_path:path}") + async def serve_frontend(full_path: str): + """Serve the React SPA for all non-API routes.""" + file_path = os.path.join(STATIC_DIR, full_path) + if os.path.isfile(file_path): + return FileResponse(file_path) + return FileResponse(os.path.join(STATIC_DIR, "index.html")) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000000000000000000000000000000000000..3eac4c81992b65c0493d5464b20448af14066b63 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,145 @@ +"""Pydantic models for API request/response contracts.""" + +from __future__ import annotations + +from datetime import date, datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + + +class CabinClass(str, Enum): + economy = "economy" + premium_economy = "premium_economy" + business = "business" + first = "first" + + +class TripType(str, Enum): + one_way = "one_way" + round_trip = "round_trip" + multi_city = "multi_city" + + +class SortBy(str, Enum): + best = "best" + cheapest = "cheapest" + fastest = "fastest" + + +# --- Airport --- + +class AirportInfo(BaseModel): + iata: str + name: str + city_name: str + country: str + country_code: str + continent: str + latitude: float + longitude: float + timezone: str + hub_score: float = 0.0 + route_count: int = 0 + + +# --- Flight segment --- + +class FlightSegment(BaseModel): + airline_code: str + airline_name: str + flight_number: str + aircraft: str + origin: str + origin_city: str + destination: str + destination_city: str + departure: datetime + arrival: datetime + duration_minutes: int + + +# --- Flight offer (may have multiple segments) --- + +class FlightOffer(BaseModel): + id: str + segments: list[FlightSegment] + total_duration_minutes: int + stops: int + price_usd: float + cabin_class: CabinClass + origin: str + destination: str + departure: datetime + arrival: datetime + + +# --- Search request --- + +class SearchLeg(BaseModel): + origin: str = Field(..., min_length=3, max_length=3, description="IATA code") + destination: str = Field(..., min_length=3, max_length=3, description="IATA code") + date: date + + +class Passengers(BaseModel): + adults: int = Field(1, ge=1, le=9) + children: int = Field(0, ge=0, le=9) + infants: int = Field(0, ge=0, le=4) + + @property + def total(self) -> int: + return self.adults + self.children + self.infants + + +class Filters(BaseModel): + max_stops: Optional[int] = None + max_price: Optional[float] = None + max_duration_minutes: Optional[int] = None + airlines: Optional[list[str]] = None # IATA codes to include + departure_time_min: Optional[str] = None # "06:00" + departure_time_max: Optional[str] = None # "18:00" + + +class SearchRequest(BaseModel): + trip_type: TripType = TripType.round_trip + legs: list[SearchLeg] = Field(..., min_length=1, max_length=6) + passengers: Passengers = Passengers() + cabin_class: CabinClass = CabinClass.economy + filters: Filters = Filters() + sort_by: SortBy = SortBy.best + + +class SearchResponse(BaseModel): + outbound_flights: list[FlightOffer] + return_flights: list[FlightOffer] = [] + search_id: str + origin: str + destination: str + + +# --- Calendar --- + +class CalendarDay(BaseModel): + date: date + cheapest_price: Optional[float] = None + + +class CalendarResponse(BaseModel): + origin: str + destination: str + year: int + month: int + days: list[CalendarDay] + + +# --- Autocomplete --- + +class AutocompleteResult(BaseModel): + iata: str + name: str + city_name: str + country: str + display_name: str + hub_score: float = 0.0 diff --git a/backend/price_engine.py b/backend/price_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..c3e64ce0d4a6129024bcda4faa436dcdea8264c8 --- /dev/null +++ b/backend/price_engine.py @@ -0,0 +1,113 @@ +"""Price formula: base + 8 multipliers. + +base_usd = 40 + (distance_km * 0.08) +final = base * class * day_of_week * time_of_day * season * demand * advance * jitter +""" + +from __future__ import annotations + +import random +from datetime import date, datetime, timedelta + +from .config import ( + ADVANCE_MULTIPLIERS, + BASE_FIXED_USD, + BASE_PER_KM_USD, + CLASS_MULTIPLIERS, + DAY_MULTIPLIERS, + EU_CONTINENTS, + EU_SUMMER_BONUS, + EU_SUMMER_MONTHS, + HIGH_COMPETITION_DISCOUNT, + JITTER_RANGE, + MONOPOLY_ROUTE_BONUS, + SEASON_MULTIPLIERS, +) + + +def compute_price( + distance_km: int, + cabin_class: str, + departure_date: date, + departure_hour: int, + num_carriers: int, + dest_continent: str, + rng: random.Random, + booking_date: date | None = None, +) -> float: + """Compute flight price using the full pricing formula.""" + base = BASE_FIXED_USD + (distance_km * BASE_PER_KM_USD) + + # 1. Cabin class + class_mult = CLASS_MULTIPLIERS.get(cabin_class, 1.0) + + # 2. Day of week + day_mult = DAY_MULTIPLIERS.get(departure_date.weekday(), 1.0) + + # 3. Time of day + if 6 <= departure_hour <= 8: + time_mult = 1.10 # Morning peak + elif 16 <= departure_hour <= 19: + time_mult = 1.15 # Evening peak + elif departure_hour >= 22 or departure_hour <= 5: + time_mult = 0.85 # Red-eye discount + else: + time_mult = 1.00 + + # 4. Season + season_mult = SEASON_MULTIPLIERS.get(departure_date.month, 1.0) + # EU summer bonus + if dest_continent in EU_CONTINENTS and departure_date.month in EU_SUMMER_MONTHS: + season_mult += EU_SUMMER_BONUS + + # 5. Demand (based on competition) + if num_carriers == 1: + demand_mult = 1.0 + MONOPOLY_ROUTE_BONUS + elif num_carriers >= 4: + demand_mult = 1.0 - HIGH_COMPETITION_DISCOUNT + else: + demand_mult = 1.0 + + # 6. Advance booking + if booking_date is None: + booking_date = date.today() + days_advance = (departure_date - booking_date).days + if days_advance < 0: + days_advance = 0 + advance_mult = 1.0 + for threshold, mult in ADVANCE_MULTIPLIERS: + if days_advance <= threshold: + advance_mult = mult + break + + # 7. Jitter (seeded) + jitter = 1.0 + rng.uniform(-JITTER_RANGE, JITTER_RANGE) + + price = base * class_mult * day_mult * time_mult * season_mult * demand_mult * advance_mult * jitter + + # Round to nearest dollar, minimum $25 + return max(25.0, round(price, 0)) + + +def compute_calendar_price( + distance_km: int, + cabin_class: str, + target_date: date, + num_carriers: int, + dest_continent: str, + rng: random.Random, +) -> float: + """Compute cheapest flight price for a given date (for calendar view). + + Uses noon departure and 14-day advance booking as baseline. + """ + return compute_price( + distance_km=distance_km, + cabin_class=cabin_class, + departure_date=target_date, + departure_hour=12, + num_carriers=num_carriers, + dest_continent=dest_continent, + rng=rng, + booking_date=target_date - timedelta(days=14), + ) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..4ea09f001fb148247597cdcf8f0428b79ed1d8b9 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.27.0 +pydantic>=2.6.0 diff --git a/backend/route_finder.py b/backend/route_finder.py new file mode 100644 index 0000000000000000000000000000000000000000..6236717254489d4007c3663373c3f14e057ab6ba --- /dev/null +++ b/backend/route_finder.py @@ -0,0 +1,141 @@ +"""Direct + 1-stop + 2-stop route discovery.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .config import MAX_1STOP_DISTANCE_RATIO, MAX_2STOP_DISTANCE_RATIO +from .data_loader import Route, RouteGraph + + +@dataclass +class RoutePlan: + """A planned route from origin to destination (may have multiple legs).""" + legs: list[Route] # Each leg has origin implicitly from position + waypoints: list[str] # [origin, hub1, ..., destination] + total_distance_km: int + total_duration_min: int + + @property + def stops(self) -> int: + return len(self.legs) - 1 + + +def find_routes( + graph: RouteGraph, + origin: str, + destination: str, + hub_iatas: list[str], + max_stops: int | None = None, +) -> list[RoutePlan]: + """Find all route plans from origin to destination. + + Returns direct, 1-stop, and 2-stop routes. + """ + results: list[RoutePlan] = [] + + if max_stops is not None and max_stops < 0: + return results + + # Direct route + direct = graph.get_direct_route(origin, destination) + if direct: + results.append(RoutePlan( + legs=[direct], + waypoints=[origin, destination], + total_distance_km=direct.distance_km, + total_duration_min=direct.duration_min, + )) + + if max_stops is not None and max_stops == 0: + return results + + # 1-stop routes through hubs + direct_distance = direct.distance_km if direct else _estimate_distance(graph, origin, destination) + if direct_distance is None: + return results + + origin_routes = graph.get_outbound_routes(origin) + + for hub in hub_iatas: + if hub == origin or hub == destination: + continue + + leg1 = origin_routes.get(hub) + if not leg1: + continue + + leg2 = graph.get_direct_route(hub, destination) + if not leg2: + continue + + total_dist = leg1.distance_km + leg2.distance_km + if total_dist > direct_distance * MAX_1STOP_DISTANCE_RATIO: + continue + + total_dur = leg1.duration_min + leg2.duration_min + 90 # +90 min layover estimate + results.append(RoutePlan( + legs=[leg1, leg2], + waypoints=[origin, hub, destination], + total_distance_km=total_dist, + total_duration_min=total_dur, + )) + + if max_stops is not None and max_stops <= 1: + return results + + # 2-stop routes through pairs of hubs (limit to top hubs for performance) + top_hubs = hub_iatas[:60] + for hub1 in top_hubs: + if hub1 == origin or hub1 == destination: + continue + leg1 = origin_routes.get(hub1) + if not leg1: + continue + + hub1_routes = graph.get_outbound_routes(hub1) + for hub2 in top_hubs: + if hub2 == origin or hub2 == destination or hub2 == hub1: + continue + + leg2 = hub1_routes.get(hub2) + if not leg2: + continue + + leg3 = graph.get_direct_route(hub2, destination) + if not leg3: + continue + + total_dist = leg1.distance_km + leg2.distance_km + leg3.distance_km + if total_dist > direct_distance * MAX_2STOP_DISTANCE_RATIO: + continue + + total_dur = (leg1.duration_min + leg2.duration_min + leg3.duration_min + + 90 + 90) # Two layovers + results.append(RoutePlan( + legs=[leg1, leg2, leg3], + waypoints=[origin, hub1, hub2, destination], + total_distance_km=total_dist, + total_duration_min=total_dur, + )) + + return results + + +def _estimate_distance(graph: RouteGraph, origin: str, destination: str) -> int | None: + """Estimate great-circle distance between two airports using coordinates.""" + import math + + o = graph.airports.get(origin) + d = graph.airports.get(destination) + if not o or not d: + return None + + lat1, lon1 = math.radians(o.latitude), math.radians(o.longitude) + lat2, lon2 = math.radians(d.latitude), math.radians(d.longitude) + + dlat = lat2 - lat1 + dlon = lon2 - lon1 + a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 + c = 2 * math.asin(math.sqrt(a)) + return int(c * 6371) # Earth radius in km diff --git a/backend/seed_utils.py b/backend/seed_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..b80e270243907ce4c1dfdc426509544ac2fa8d79 --- /dev/null +++ b/backend/seed_utils.py @@ -0,0 +1,20 @@ +"""Deterministic seeding utilities. + +Same search parameters always produce the same flights and prices. +Uses SHA-256 hash of search params → integer seed for random.Random. +""" + +import hashlib +import random + + +def make_seed(*parts: str | int | float) -> int: + """Create a deterministic seed from arbitrary parts.""" + key = "|".join(str(p) for p in parts) + h = hashlib.sha256(key.encode()).hexdigest() + return int(h[:16], 16) + + +def seeded_random(*parts: str | int | float) -> random.Random: + """Return a seeded Random instance for the given search params.""" + return random.Random(make_seed(*parts)) diff --git a/description.md b/description.md new file mode 100644 index 0000000000000000000000000000000000000000..dbc9615004419f397d66ccfaace1077e3ab87435 --- /dev/null +++ b/description.md @@ -0,0 +1,1122 @@ +# ECMoE — Method and Experiment Description + +## 1. Problem Statement + +In Mixture-of-Experts (MoE) models with expert parallelism, each token's hidden state must be communicated between GPUs twice per MoE layer: + +1. **Dispatch (all-to-all):** The hidden state is sent from the token's source GPU to the GPU hosting its assigned expert(s). +2. **Gather (all-to-all):** The expert output is sent back to the source GPU. + +For a model like Qwen3-30B-A3B with `hidden_dim=2048` and 48 MoE layers, each token requires transmitting `2 × 48 × 2048 × 2 bytes = 384 KB` of data per forward pass (in BF16). At scale, this communication dominates inference latency. + +This project investigates methods to **compress these hidden-state vectors** before transmission, reducing communication volume while preserving model quality. + +### Training paradigms + +This project uses two training paradigms: + +**Offline (Tasks 2–4):** Compressors are trained on **cached hidden states**, not end-to-end through the LLM: + +1. **Capture:** Run the unmodified LLM on calibration data and cache MoE layer inputs/outputs to disk. +2. **Train:** Train each compressor/decompressor pair independently on the cached data, minimizing a local reconstruction loss. No gradients flow through the LLM. +3. **Evaluate:** Insert trained compressors into the live model via forward hooks and measure perplexity. + +Each pair is trained in isolation — no joint optimization across layers, no end-to-end backpropagation. This is cheap (minutes per layer) but means compressors cannot adapt to how errors compound across layers. + +**End-to-end (Task 5):** Compressors are trained **through the live LLM** using the language modeling objective: + +1. **Insert:** Register per-layer compressor/decompressor pairs as forward pre-hooks on each MoE layer. +2. **Train:** Run standard next-token prediction. The LLM weights are frozen; only compressor parameters receive gradients. Gradients flow through the entire frozen LLM. +3. **Evaluate:** Same hook-based perplexity evaluation as offline methods. + +All 48 compressors are optimized jointly through a single global loss. This allows the system to learn how compression errors at early layers affect all downstream layers. + +--- + +## 2. Model Specification + +| Property | Value | +|---|---| +| Architecture | Qwen3-30B-A3B-Instruct-2507 | +| Total parameters | 30.53B | +| Activated parameters | 3.35B | +| Hidden dimension | 2048 | +| Number of layers | 48 (all MoE) | +| Number of experts | 128 per layer | +| Top-k routing | 8 experts per token | +| Attention heads | 32 (Q), 4 (KV) | +| Head dimension | 128 | +| MoE expert FFN intermediate size | 768 | +| Vocabulary size | 151,936 | + +All tasks use the same model variant and precision: + +| Variant | Used in | Loading | VRAM | +|---|---|---|---| +| Qwen3-30B-A3B-Instruct-2507 | All tasks (1–5) | Full BF16 | ~60 GB | + +**Tasks 1–4:** Single GPU (`device="cuda:0"`). The ~60 GB model fits on one H100 80 GB with headroom for inference activations. Using single-GPU avoids the overhead of cross-GPU communication from `device_map="auto"`. + +**Task 5:** Multi-GPU via `device_map="auto"` across 4 GPUs. Backpropagation through the frozen model during end-to-end training requires additional VRAM for activations and gradient checkpoints that exceed single-GPU capacity. + +--- + +## 3. Data Collection + +### 3.1 Calibration Data + +- **Dataset:** allenai/Dolci-Instruct-SFT (train split) +- **Format:** Chat-formatted instruction data, tokenized via `tokenizer.apply_chat_template()` +- **Sequences:** Up to 256 samples, each tokenized independently (one conversation = one sequence) +- **Max length:** 2048 tokens per sequence (configurable via `--max-length`) +- **SFT mode:** Labels mask non-assistant tokens with -100; perplexity computed on responses only +- **Response-only collection:** By default, only assistant-response tokens are captured + (positions where `labels != -100`). This ensures offline compressor training (Tasks 2–4) + trains on the same token distribution that PPL evaluation measures. Use `--no-response-only` + for legacy all-token collection. +- **Total tokens collected:** 100,000 per MoE layer (response tokens only by default) + +### 3.2 Hidden State Capture + +PyTorch forward hooks are registered on each MoE module: +- **Pre-forward hook** captures dispatch states (MoE layer inputs) +- **Post-forward hook** captures gather states (MoE layer outputs) + +**Token filtering:** `MoEHiddenStateCollector` supports a per-sequence boolean mask +(`set_token_mask(mask)`). When `response_only=True` (default), the mask is derived from +`labels != -100` before each forward pass. The same mask is applied to all 48 MoE layers +within a sequence, preserving token alignment across layers. Positions where the mask is +`False` (system, user, template markup, padding) are not collected. + +Each captured tensor has shape `[N, 2048]` where N = number of response tokens (or all +tokens if `response_only=False`). States are stored in the model's native dtype (`bfloat16`) +on CPU. + +**Implementation:** `MoEHiddenStateCollector` class in `src/model_utils.py`. + +### 3.3 Storage + +``` +data/hidden_states/ +├── dispatch_states.pt # dict {layer_name: tensor [100000, 2048]} +├── gather_states.pt # dict {layer_name: tensor [100000, 2048]} +└── metadata.json # model name, dims, token count, layer names +``` + +Total size: ~37 GB (18.5 GB dispatch + 18.5 GB gather, bfloat16 = 2 bytes/value). + +--- + +## 4. Evaluation Methodology + +### 4.1 Reconstruction Metrics (Offline) + +Computed on cached hidden states without running the full model: + +| Metric | Formula | Notes | +|---|---|---| +| MSE | `mean((x - x')²)` | Mean squared error | +| Cosine Similarity | `mean(cos(x, x'))` | Per-token, averaged | +| Relative Error | `mean(‖x - x'‖₂ / ‖x‖₂)` | Per-token L2 relative error | +| SNR (dB) | `10 · log₁₀(signal_power / noise_power)` | Signal-to-noise ratio | + +**Implementation:** `src/metrics.py` + +### 4.2 End-to-End Perplexity (Online) + +The true impact of compression is measured by evaluating cross-entropy perplexity on allenai/Dolci-Instruct-SFT (the same dataset used for calibration/training) with compression hooks active: + +- **Dispatch compression:** A pre-forward hook on each MoE block applies `compress → decompress` to the input hidden states before they enter the block. +- **Evaluation:** 50,000 sequences, max length 2048 tokens. +- **SFT mode:** Perplexity is computed on assistant response tokens only. Non-response tokens + (system, user, template markup) are labeled with -100 and excluded from the loss. + This measures the model's ability to generate correct responses, not to predict prompt tokens. + +**Caveat:** This simulation also affects the router's input. In real expert parallelism, the router runs on the original hidden state at the source node. Our simulation gives a **conservative lower bound** — the true impact would be smaller. + +**Implementation:** `evaluate_perplexity_with_compression()`, `evaluate_perplexity_with_perlayer_compression()`, `evaluate_perplexity_with_stale_compression()` in `src/model_utils.py`. + +--- + +## 5. Method Descriptions + +### 5.1 Quantization Baseline (Task 2) + +**Idea:** Reduce the bit width of hidden-state elements from BF16 (16 bits) to INT8/INT4/INT2. + +**Symmetric (absmax) quantization:** +``` +scale = max(|x|) / (2^(bits-1) - 1) # per-token +x_q = round(x / scale) # quantize +x' = x_q * scale # dequantize +``` + +**Asymmetric (zero-point) quantization:** +``` +scale = (max(x) - min(x)) / (2^bits - 1) +zero_point = round(-min(x) / scale) +x_q = round(x / scale + zero_point) +x' = (x_q - zero_point) * scale +``` + +**Compression ratios:** + +| Bits | Effective Ratio | Bytes/token (hidden_dim=2048) | +|---|---|---| +| INT8 (absmax) | ~2.0x | 2050 (2048 + 2 for scale) | +| INT4 (absmax) | ~4.0x | 1026 (1024 + 2 for scale) | +| INT2 (absmax) | ~8.0x | 514 (512 + 2 for scale) | + +**Additional parameters:** 0 (quantization is parameter-free). + +**Implementation:** `src/run_quantization.py` + +--- + +### 5.2 Shared Neural Compressor (Task 3) + +**Idea:** Train a single-layer linear autoencoder shared across all 48 MoE layers. + +**Architecture:** +``` +Compressor: Linear(2048, bottleneck_dim) + bias +Decompressor: Linear(bottleneck_dim, 2048) + bias +``` + +One compressor-decompressor pair is shared across all layers. Training data pools dispatch states from all 48 layers. Training is offline: the compressor minimizes reconstruction loss on cached hidden states, with no gradients flowing through the LLM. + +**Compression ratios:** `hidden_dim / bottleneck_dim` = {2x, 4x, 8x, 16x} corresponding to `bottleneck_dim` = {1024, 512, 256, 128}. + +**Training hyperparameters:** + +| Parameter | Value | +|---|---| +| Optimizer | Adam | +| Learning rate | 1e-3 | +| LR schedule | Cosine annealing (T_max = epochs) | +| Max epochs | 50 | +| Batch size | 2048 | +| Early stopping patience | 8 epochs | +| Validation fraction | 10% | +| Loss function | MSE + 0.1 × (1 - cosine_similarity) | + +**Loss function:** +``` +L = MSE(x', x) + λ · (1 - mean(cos_sim(x', x))) +``` +where `λ = 0.1` (cosine_weight). The cosine term encourages preserving direction, not just magnitude. + +**Parameter count:** +``` +params = (2048 × b + b) + (b × 2048 + 2048) +``` +where `b` = bottleneck_dim. + +| Ratio | Bottleneck | Parameters | % of Activated | +|---|---|---|---| +| 2x | 1024 | 4.20M | 0.125% | +| 4x | 512 | 2.10M | 0.063% | +| 8x | 256 | 1.05M | 0.031% | +| 16x | 128 | 0.53M | 0.016% | + +**Implementation:** `src/run_neural_compressor.py` + +--- + +### 5.3 Per-Layer Neural Compressor (Task 3b) + +**Motivation:** Hidden state distributions vary dramatically across layers: +- Standard deviation: 0.16 (layer 0) → 1.21 (layer 47) +- Kurtosis: 3 (near-Gaussian, early layers) → 81,340 (extremely heavy-tailed, late layers) + +A single shared compressor cannot adapt to this variation. + +**Architecture:** Same `Compressor` + `Decompressor` structure, but **48 independent pairs** — one per MoE layer. Each layer's compressor is trained independently and only on that layer's cached dispatch data. There is no joint optimization across layers. + +**Compression ratios:** Same as shared: {2x, 4x, 8x, 16x}. + +**Training:** Same hyperparameters as shared (see Section 5.2). Each layer is trained independently on its own 100K token dispatch data (90% train / 10% val). + +**Parameter count:** +``` +params = 48 × (2048 × b + b + b × 2048 + 2048) +``` + +| Ratio | Bottleneck | Parameters | % of Activated | +|---|---|---|---| +| 2x | 1024 | 201.47M | 6.008% | +| 4x | 512 | 100.79M | 3.006% | +| 8x | 256 | 50.44M | 1.504% | +| 16x | 128 | 25.27M | 0.754% | + +**Implementation:** `src/run_perlayer_compressor.py` + +--- + +### 5.4 Stale-Conditioned Compressor (Tasks 4a/4b) + +**Motivation:** Adjacent MoE layers process the same token, so their hidden states are correlated. A decompressor can exploit this by receiving a "stale" signal — the hidden state from a nearby layer that was already transmitted — as side information. + +**Reference layer grouping (stride=12):** +- Reference layers: {0, 12, 24, 36} (4 layers) +- Layer 1–11 → stale from layer 0 +- Layer 13–23 → stale from layer 12 +- Layer 25–35 → stale from layer 24 +- Layer 37–47 → stale from layer 36 + +**Architecture:** +- **Reference layers** use standard per-layer `Compressor` + `Decompressor` (no stale signal). +- **Non-reference layers** use `Compressor` + `StaleDecompressor`: + +``` +Compressor: Linear(2048, bottleneck_dim) + bias +StaleDecompressor: Linear(bottleneck_dim + stale_dim, 2048) + bias +``` + +The decompressor receives `cat(compressed_current, stale_signal)` as input. + +**Two stale modes:** + +| Mode | Task | Stale signal | StaleDecompressor input dim | +|---|---|---|---| +| Compressed (4a) | `--stale-mode compressed` | Compressed ref layer input (via ref's compressor) | `bottleneck_dim + bottleneck_dim` | +| Uncompressed (4b) | `--stale-mode uncompressed` | Raw ref layer input (full hidden dim) | `bottleneck_dim + 2048` | + +**Training:** +1. **Phase 1:** Train reference layer compressors independently (standard per-layer autoencoder, same hyperparameters as Section 5.2). +2. **Phase 2:** Train non-reference layer compressors independently. For each non-ref layer: + - Current data: that layer's cached dispatch states + - Stale data: the reference layer's cached dispatch states (compressed or raw, depending on mode) + - The stale signal is **pre-computed and frozen** — the reference layer's compressor is not jointly optimized with non-reference layers + - Token alignment is guaranteed: `dispatch[layer_0][i]` and `dispatch[layer_5][i]` correspond to the same token + +As with all neural methods in this project, training is offline on cached hidden states. No gradients flow through the LLM, and each layer's compressor is trained in isolation. + +**Stale-conditioned training loss:** Same as Section 5.2 (`MSE + 0.1 × (1 - cos_sim)`), but the decompressor receives the concatenated input. + +**Parameter count:** + +For compressed stale (`stale_dim = bottleneck_dim`): +``` +ref_pair = (2048 × b + b) + (b × 2048 + 2048) +nonref_pair = (2048 × b + b) + ((b + b) × 2048 + 2048) +total = 4 × ref_pair + 44 × nonref_pair +``` + +For uncompressed stale (`stale_dim = 2048`): +``` +ref_pair = (2048 × b + b) + (b × 2048 + 2048) +nonref_pair = (2048 × b + b) + ((b + 2048) × 2048 + 2048) +total = 4 × ref_pair + 44 × nonref_pair +``` + +| Mode | Ratio | Bottleneck | Stale dim | Parameters | % of Activated | +|---|---|---|---|---|---| +| Compressed | 2x | 1024 | 1024 | 293.75M | 8.760% | +| Compressed | 4x | 512 | 512 | 146.92M | 4.382% | +| Compressed | 8x | 256 | 256 | 73.51M | 2.192% | +| Compressed | 16x | 128 | 128 | 36.80M | 1.098% | +| Uncompressed | 2x | 1024 | 2048 | 386.02M | 11.512% | +| Uncompressed | 4x | 512 | 2048 | 285.34M | 8.509% | +| Uncompressed | 8x | 256 | 2048 | 234.99M | 7.008% | +| Uncompressed | 16x | 128 | 2048 | 209.82M | 6.257% | + +Note: The uncompressed stale method's parameter count does not scale down as aggressively because the `StaleDecompressor` input always includes the full 2048-dim stale signal, making the `(2048 × 2048)` weight block dominant. + +**Perplexity evaluation with stale hooks:** During forward pass, a shared `stale_cache` dictionary stores reference layer inputs. PyTorch processes layers 0→47 sequentially, so layer 0's pre-hook fires before layer 1's, guaranteeing the stale cache is populated in time. + +**Implementation:** `src/run_stale_compressor.py`, `evaluate_perplexity_with_stale_compression()` in `src/model_utils.py`. + +--- + +### 5.5 End-to-End Per-Layer Compressor (Tasks 5a/5b) + +**Motivation:** All offline methods (Tasks 3–4) share a fundamental limitation: each compressor is trained to minimize *local* reconstruction error in isolation. It cannot account for how its errors compound through downstream layers during a full forward pass. Additionally, the stale signal used during offline training is the *unperturbed* reference layer input, but during inference the reference layer itself is compressed, creating a train-inference mismatch. + +End-to-end training addresses both issues by optimizing compressors through the full LLM forward pass using the language modeling objective. + +**Architecture:** Same `Compressor` + `Decompressor` (5a) or `Compressor` + `StaleDecompressor` (5b) structure as Tasks 3b/4b. The compressor modules are identical — only the training objective differs. + +**Training paradigm:** +1. Load the LLM in full BF16 across 4 GPUs. Freeze all LLM weights. +2. Insert per-layer compressor/decompressor pairs as forward pre-hooks on each MoE layer. Each pair is placed on the same GPU as its MoE layer. +3. Run standard next-token prediction on training data. Only compressor/decompressor parameters receive gradients. +4. Gradients flow backward through the entire frozen LLM, from the cross-entropy loss at the output back through all 48 layers to every compressor. + +**Key difference from offline: joint optimization.** All 48 compressors share a single loss function (cross-entropy). Layer 0's compressor receives gradient signal about how its reconstruction error affects layers 1–47. The system implicitly learns to allocate more fidelity to layers where errors are most harmful to the final prediction. + +**Stale signal gradient flow (5b):** Unlike offline Task 4b where the stale signal is pre-computed and frozen, end-to-end training does **not** detach the stale signal. Gradients flow through the stale path: +- A non-reference layer's decompressor receives `cat(compressed_current, stale)` where `stale` is the raw input to the reference layer +- During backward, gradients flow from the non-ref layer through `stale` to the reference layer's input, and further back to earlier layers +- This means reference layers' compressors are optimized not just for their own reconstruction, but also for how their inputs serve as stale side information for all downstream non-reference layers +- This eliminates the train-inference mismatch: during training, the stale signal already reflects upstream compression artifacts + +**Near-identity initialization:** +- Compressor `W_c`: first `bottleneck_dim` rows of the identity matrix +- Decompressor `W_d`: first `bottleneck_dim` columns of the identity matrix +- Composition `W_d @ W_c ≈ I` (projects to first `b` dimensions and reconstructs) +- This ensures the initial forward pass is close to uncompressed, avoiding catastrophic initial loss from random projections. The optimizer then refines from this starting point. + +**Model and data:** +- **Model:** Qwen/Qwen3-30B-A3B-Instruct-2507 (full BF16, same as all tasks) +- **Training data:** allenai/Dolci-Instruct-SFT, 500K sequences (HF) / 100K sequences (Megatron) sampled from train split, + max_length=2048 tokens per sequence +- **SFT mode:** Each conversation is tokenized independently (one sample = one sequence). + Labels mask non-assistant tokens with -100; loss is computed on assistant responses only. + Data is loaded by sampling N sequences from the dataset (not by packing tokens). +- **Evaluation:** allenai/Dolci-Instruct-SFT (same dataset, response-only perplexity) + +**Two modes:** + +| Mode | Task | Stale signal | Decompressor | +|---|---|---|---| +| No stale (5a) | `--stale-mode none` | None | `Decompressor(bottleneck_dim, 2048)` | +| Uncompressed stale (5b) | `--stale-mode uncompressed` | Raw ref layer input | `StaleDecompressor(bottleneck_dim, 2048, 2048)` | + +**Training hyperparameters:** + +| Parameter | Value | +|---|---| +| Optimizer | AdamW | +| Learning rate | 1e-4 | +| Weight decay | 0.01 | +| LR schedule | Cosine with 10% linear warmup | +| Max epochs | 1 | +| Batch size | 2 (gradient accumulation: 8, effective: 16) | +| Gradient clipping | max_norm = 1.0 | +| Early stopping patience | 5 epochs | +| Validation interval | Every 2500 optimizer steps (HF) / 1000 (Megatron) (configurable via `--val-interval`) | +| Validation batch size | 8 (configurable via `--val-batch-size`; larger than train because no backward) | +| Validation fraction | 10% | +| Max sequence length | 2048 (configurable via `--max-length`) | +| Loss function | Cross-entropy (response tokens only, SFT mode) | + +Note the lower learning rate (1e-4 vs 1e-3 for offline) — the LM loss landscape propagates gradients through 48 frozen transformer layers, requiring more conservative updates. + +**Tail micro-batch handling:** When `len(dataloader) % grad_accum != 0`, the remaining micro-batches +have their accumulated gradients rescaled by `grad_accum / remainder` (correcting the divisor from +`1/grad_accum` to `1/remainder`) before performing a final optimizer step. This ensures no training +data is discarded. Applied to both HF (`run_e2e_compressor.py`) and Megatron (`train.py`). + +**Two evaluation stages (different data, different code paths):** + +| Stage | Split | Batch size | Function | Purpose | +|---|---|---|---|---| +| Training-time val | VAL (50K seqs) | `--val-batch-size` (default 8) | `evaluate_val_loss()` in training script | Checkpoint selection, wandb monitoring | +| Final PPL | TEST (50K seqs) | 1 (per-sample) | `evaluate_perplexity()` in `model_utils.py` | Reported results | + +The training-time validation runs every `--val-interval` optimizer steps and at epoch end, using the VAL split. It drives best-checkpoint selection. The final perplexity evaluation runs after training on the held-out TEST split (never seen during training or checkpoint selection) and produces the numbers reported in the results tables. These are separate code paths — `--val-batch-size` only affects the training-time evaluation. + +**Parameter count:** Same as Tasks 3b (5a) and 4b-uncompressed (5b): + +| Mode | Ratio | Bottleneck | Parameters | % of Activated | +|---|---|---|---|---| +| No stale (5a) | 2x | 1024 | 201.47M | 6.008% | +| No stale (5a) | 4x | 512 | 100.79M | 3.006% | +| No stale (5a) | 8x | 256 | 50.44M | 1.504% | +| No stale (5a) | 16x | 128 | 25.27M | 0.754% | +| Uncompressed stale (5b) | 2x | 1024 | 386.02M | 11.512% | +| Uncompressed stale (5b) | 4x | 512 | 285.34M | 8.509% | +| Uncompressed stale (5b) | 8x | 256 | 234.99M | 7.008% | +| Uncompressed stale (5b) | 16x | 128 | 209.82M | 6.257% | + +**Multi-GPU setup:** +- Model distributed across 4 GPUs via `device_map="auto"` (~15 GB/GPU) +- Gradient checkpointing enabled (`use_reentrant=False`) to reduce activation memory +- 8 GPUs available → 05a and 05b run in parallel on separate GPU sets (GPUs 0-3 and 4-7) +- Each compressor is automatically placed on the same GPU as its MoE layer + +**Implementation:** `src/run_e2e_compressor.py`, `scripts/05_run_e2e_compressor.sh`. + +--- + +### 5.6 Megatron-LM E2E Training (Task 5 — Megatron variant) + +**Motivation:** The HuggingFace-based Task 5 uses `device_map="auto"` for naive layer-sharded model parallelism. Only one GPU is active at a time during forward pass (sequential layer execution), with no tensor or data parallelism. This limits training throughput and cannot scale to multi-node. + +**Approach:** Replace HuggingFace with Megatron-LM to get proper tensor parallelism (TP), expert parallelism (EP), and data parallelism (DP): +- All 4 GPUs active simultaneously via TP (each GPU holds shards of every layer) +- Multi-node scaling via DP across nodes + TP within nodes +- Megatron's optimized kernels (fused LayerNorm, FlashAttention, etc.) + +**Compressor/decompressor placement:** + +In real expert parallelism, the compressor and decompressor are on DIFFERENT GPUs: +- **Compressor:** Same GPU as attention output (source GPU where token originates) +- **Decompressor:** Same GPU as MoE expert (destination GPU after dispatch) + +This is more realistic than the HF hook-based simulation where the router sees compressed-then-decompressed input. With Megatron, the router sees the ORIGINAL hidden state; only the dispatch is compressed. + +**Phase A (TP only, EP=1):** Compressor and decompressor on same GPU (same as current HF approach). TP=4 shards each layer across 4 GPUs. + +**Phase B (with EP):** Compressor on attention GPU, decompressor on expert GPU. MoE dispatch sends compressed tokens (reduced all-to-all volume). The `CompressedMoETokenDispatcher` wraps Megatron's dispatcher to: +1. Compress on source GPU (attention side) +2. Dispatch compressed tokens (smaller all-to-all) +3. Decompress on destination GPU (expert side) + +**Training pipeline:** +1. Convert Qwen3-30B-A3B from HF format to Megatron format via Megatron Bridge +2. Load with TP=4 (each GPU holds ~15-20 GB of sharded weights) +3. Freeze all LLM parameters +4. Insert per-layer compressor/decompressor pairs at MoE boundaries +5. Train compressors via language modeling objective (same as HF Task 5) +6. Save compressor weights (from rank 0, since all TP ranks have identical copies) + +**TP-aware loss computation:** `MegatronModelWrapper._compute_loss()` uses +`vocab_parallel_cross_entropy` when TP > 1. SFT labels (-100) are clamped to 0 before +the call (avoiding garbage per-token loss for masked positions), and loss is computed as +`(per_token_loss * loss_mask).sum() / num_valid`. The non-TP path uses PyTorch's +`cross_entropy(ignore_index=-100)` which handles masking internally. + +**Evaluation:** Uses existing HF-based evaluation code — load trained compressor weights into `E2ECompressorManager` and evaluate perplexity with hook-based simulation. + +**Parallelism strategies:** + +| Hardware | Configuration | Notes | +|---|---|---| +| 4 GPUs | TP=4, EP=1, PP=1, DP=1 | All GPUs active via tensor parallelism | +| 8 GPUs | TP=4, EP=1, PP=1, DP=2 | TP within 4 GPUs, DP across 2 replicas | +| N nodes × 4 GPUs | TP=4, DP=N | TP within node (NVLink), DP across nodes | +| EP variant | TP=2, EP=2, PP=1, DP=1 | Compressor on TP ranks, decompressor on EP ranks | + +**Compressor weights with TP:** +- Compressors are replicated on all TP ranks (not sharded) +- Input is full hidden state (post-attention all-reduce) +- Gradients identical across ranks — no extra all-reduce needed +- Save from rank 0 only + +**Implementation:** `src/megatron_e2e/` package with EP-first parallelism (EP=4, TP=1), CUDA 12.9, Megatron Bridge 0.2+, Transformer Engine. Entry point: `src/megatron_e2e/train.py`, bash wrapper: `scripts/05_megatron_e2e.sh`, setup: `scripts/megatron_setup_env.sh`. Multi-node: `scripts/05_megatron_e2e_multinode.sh`. + +--- + +### 5.7 Baseline E2E Evaluation (Task 5c) + +**Motivation:** Tasks 5a/5b report perplexity relative to an "untrained baseline" (the original model evaluated on the same test data). However, 5a/5b's training pipeline also loads and processes data through `load_e2e_data()`, computes SFT-masked loss on train/val splits, and may differ subtly from a raw model evaluation. Task 5c runs the exact same pipeline (same data loading, same loss computation, same evaluation) but WITHOUT inserting any compressors. This provides: + +1. **Train/val loss context:** If 5c's train loss is ~1.0, and 5a-2x's is 1.11, the compression overhead is only +0.11 — not the raw 1.11 value. +2. **Pipeline consistency:** Confirms that the data pipeline itself does not introduce artifacts. +3. **Fair comparison:** All three (5a, 5b, 5c) use identical code paths except for the compression hooks. + +**What it does:** +- Loads data via `load_e2e_data()` (same function as 5a/5b) +- Evaluates train and val loss using `evaluate_loss_no_hooks()` — same as `evaluate_val_loss()` but without a compressor manager +- Evaluates baseline PPL on the TEST split (same as 5a/5b) +- No training, no compression ratios, no weight files + +**Implementation:** Added as `--stale-mode baseline` to both `src/run_e2e_compressor.py` (HF) and `src/megatron_e2e/train.py` (Megatron). Output dirs: `results/05c_e2e_baseline/` (HF), `results/05c_megatron_e2e_baseline/` (Megatron). + +--- + +### 5.8 E2E with Pretrained Init (Tasks 6a/6b) + +**Motivation:** Tasks 5a/5b initialize compressor/decompressor weights with a near-identity matrix — the first `bottleneck_dim` dimensions are preserved, and the rest are zeroed out. This is a reasonable starting point but the optimizer must learn the full compression mapping from scratch using only the LM loss signal. + +Tasks 3b and 4b already train compressors to minimize reconstruction loss on cached hidden states. While this offline objective doesn't directly optimize for LM quality, the resulting weights encode the structure of hidden-state distributions and provide a potentially better starting point for E2E fine-tuning. + +Tasks 6a/6b test this hypothesis: does initializing E2E training from reconstruction-optimized weights (instead of near-identity) lead to faster convergence or better final quality? + +**Architecture:** Identical to Tasks 5a/5b — same `Compressor`, `Decompressor`, `StaleDecompressor` classes, same training objective (cross-entropy), same hyperparameters. The only difference is the initial weight values. + +**Two modes:** + +| Mode | Task | Init from | Stale signal | +|---|---|---|---| +| No stale (6a) | `--stale-mode none --init-weights-dir results/03b_perlayer_compressor` | Task 3b (per-layer offline) | None | +| Uncompressed stale (6b) | `--stale-mode uncompressed --init-weights-dir results/04b_stale_uncompressed` | Task 4b (stale offline) | Raw ref layer input | + +**Weight compatibility:** Tasks 3b/4b save weights keyed by HF layer names (`model.layers.N.mlp`) with `compressor` and `decompressor` sub-keys. The `MegatronCompressorManager.load_weights()` expects the same format (it converts Megatron names to HF names via `_megatron_to_hf_layer_name()`). The offline and E2E architectures use identical module classes, so `load_state_dict()` works directly. + +**Parameter count:** Same as Tasks 5a/5b (identical architecture). + +**Training hyperparameters:** Same as Tasks 5a/5b (same LR, warmup, epochs, etc.). + +**Implementation:** Added `--init-weights-dir` argument to `src/megatron_e2e/train.py`. Auto-detects weight file naming pattern. Bash wrapper: `scripts/06_megatron_e2e_pretrained.sh`. Output dirs: `results/06a_megatron_e2e_pretrained_perlayer/` (6a), `results/06b_megatron_e2e_pretrained_stale/` (6b). + +### 5.9 Split-Mode E2E Training (Tasks 7a/7b) + +**Motivation:** Tasks 5/6 use forward pre-hooks that compress→decompress the MoE input — both the router AND experts see the decompressed hidden state. This is a conservative lower bound on quality. In real expert parallelism, the router runs on the source GPU with the **original** hidden state (before compression), and only experts on the destination GPU see the decompressed version. Task 7 trains the compressor under this more realistic "split mode" to see whether the training signal improves when the router is not degraded by compression artifacts. + +**Approach — Two-Level Pre-Hooks:** + +Instead of monkey-patching MoE forward methods, two pre-hooks are registered per MoE layer: + +1. **MoE pre-hook:** Saves the original input, then returns the compress→decompress result. The MoE module's `forward()` receives the decompressed tensor as its input. +2. **Router pre-hook:** Registered on the router/gate submodule. When the MoE's `forward()` calls `self.gate(hidden_states)`, this hook intercepts and swaps the input back to the saved original. + +This works because: +- The MoE pre-hook changes what `forward()` receives (decompressed), so experts get decompressed data. +- The router pre-hook only affects the `gate` submodule's input, restoring the original. +- PyTorch hook execution order: MoE pre-hook runs first (on the outer module), then when `forward()` calls `self.gate(...)` internally, the gate pre-hook runs and swaps the argument. + +**Two modes:** + +| Mode | Task | Init from | Stale signal | Router input | +|---|---|---|---|---| +| No stale (7a) | `--stale-mode none --router-mode uncompressed --init-weights-dir results/03b_perlayer_compressor` | Task 3b | None | Original | +| Uncompressed stale (7b) | `--stale-mode uncompressed --router-mode uncompressed --init-weights-dir results/04b_stale_uncompressed` | Task 4b | Raw ref input | Original | + +**Architecture:** Identical to Tasks 6a/6b — same classes, same init weights, same hyperparameters. The only difference is that `router_mode="uncompressed"` activates the two-level hook pattern during training and evaluation. + +**Implementation:** Added `--router-mode` argument to `src/megatron_e2e/train.py` and `src/run_e2e_compressor.py`. Split-mode hooks added to `MegatronCompressorManager` (Megatron training) and `evaluate_perplexity_with_perlayer_compression`/`evaluate_perplexity_with_stale_compression` (HF PPL evaluation). Bash wrapper: `scripts/07_megatron_e2e_split.sh`. Output dirs: `results/07a_megatron_e2e_split_perlayer/` (7a), `results/07b_megatron_e2e_split_stale/` (7b). + +--- + +## 6. Results + +### 6.1 Summary Table — All Methods + +**Model:** Qwen3-30B-A3B-Instruct-2507 (full BF16) +**Dataset:** allenai/Dolci-Instruct-SFT + +| Method | Ratio | MSE | CosSim | PPL | PPL Delta | HF Strict | HF Flex | +|---|---|---|---|---|---|---|---| +| Baseline (Tasks 2–4) | — | — | — | 3.89 | — | 44.12% | 82.79% | +| Baseline (5c / Megatron) | — | — | — | 3.94 | — | 44.12% | 82.79% | +| Quant INT8 | 2.0x | — | — | 3.90 | +0.01 | 48.90% | 82.26% | +| Quant INT4 | 4.0x | — | — | 4.51 | +0.62 | 56.41% | 68.54% | +| Quant INT2 | 8.0x | — | — | 1532.59 | +1528.70 | 0.00% | 0.00% | +| Neural (per-layer) | 2x | 0.0535 | 0.922 | 21.07 | +17.18 | 0.00% | 1.52% | +| Neural (per-layer) | 4x | 0.1073 | 0.835 | 425.75 | +421.87 | 0.00% | 0.00% | +| Neural (per-layer) | 8x | 0.1523 | 0.755 | 7949.78 | +7945.89 | 0.00% | 0.00% | +| Neural (per-layer) | 16x | 0.1893 | 0.683 | 52440.05 | +52436.16 | 0.00% | 0.00% | +| Stale-cond. (compressed) | 2x | 0.0379 | 0.947 | 6.13 | +2.24 | 3.41% | 62.55% | +| Stale-cond. (compressed) | 4x | 0.0876 | 0.869 | 31.64 | +27.75 | 0.61% | 1.52% | +| Stale-cond. (compressed) | 8x | 0.1330 | 0.791 | 2982.23 | +2978.34 | 0.00% | 0.00% | +| Stale-cond. (compressed) | 16x | 0.1720 | 0.717 | 17486.21 | +17482.32 | 0.00% | 0.00% | +| Stale-cond. (uncompressed) | 2x | 0.0346 | 0.952 | 6.24 | +2.36 | 2.81% | 67.10% | +| Stale-cond. (uncompressed) | 4x | 0.0690 | 0.900 | 16.11 | +12.22 | 0.99% | 6.14% | +| Stale-cond. (uncompressed) | 8x | 0.0966 | 0.855 | 423.68 | +419.79 | 0.00% | 0.00% | +| Stale-cond. (uncompressed) | 16x | 0.1173 | 0.819 | 3740.41 | +3736.53 | 0.00% | 0.00% | +| Megatron E2E per-layer (5a) | 2x | — | — | 2.77 | -1.17 | 61.33% | 61.64% | +| Megatron E2E per-layer (5a) | 4x | — | — | 4.28 | +0.35 | 20.70% | 21.30% | +| Megatron E2E per-layer (5a) | 8x | — | — | 7.49 | +3.55 | 1.82% | 2.12% | +| Megatron E2E per-layer (5a) | 16x | — | — | 11.26 | +7.33 | 0.91% | 2.73% | +| Megatron E2E stale (5b) | 2x | — | — | 2.71 | -1.23 | 60.27% | 60.65% | +| Megatron E2E stale (5b) | 4x | — | — | 3.61 | -0.33 | 31.54% | 32.37% | +| Megatron E2E stale (5b) | 8x | — | — | 4.98 | +1.04 | 4.93% | 5.00% | +| Megatron E2E stale (5b) | 16x | — | — | 6.34 | +2.41 | 2.12% | 2.27% | +| Megatron E2E pretrained per-layer (6a) | 2x | — | — | 2.41 | -1.53 | 79.98% | 80.06% | +| Megatron E2E pretrained per-layer (6a) | 4x | — | — | 3.18 | -0.76 | 55.04% | 55.19% | +| Megatron E2E pretrained per-layer (6a) | 8x | — | — | 4.52 | +0.58 | 16.98% | 16.98% | +| Megatron E2E pretrained per-layer (6a) | 16x | — | — | 7.34 | +3.40 | 2.27% | 2.27% | +| Megatron E2E pretrained stale (6b) | 2x | — | — | 2.25 | -1.69 | 82.49% | 82.64% | +| Megatron E2E pretrained stale (6b) | 4x | — | — | 2.57 | -1.37 | 64.37% | 64.52% | +| Megatron E2E pretrained stale (6b) | 8x | — | — | 3.04 | -0.90 | 45.79% | 45.94% | +| Megatron E2E pretrained stale (6b) | 16x | — | — | 3.47 | -0.47 | 25.85% | 25.85% | +| Split-mode E2E per-layer (7a) | 2x | — | — | 2.58 | -1.31 | 79.91% | 79.98% | +| Split-mode E2E per-layer (7a) | 4x | — | — | 3.72 | -0.17 | 42.08% | 42.15% | +| Split-mode E2E per-layer (7a) | 8x | — | — | 6.43 | +2.54 | 4.93% | 5.46% | +| Split-mode E2E per-layer (7a) | 16x | — | — | 908.20 | +904.31 | 0.00% | 0.53% | +| Split-mode E2E stale (7b) | 2x | — | — | 2.34 | -1.55 | 80.67% | 80.67% | +| Split-mode E2E stale (7b) | 4x | — | — | 2.80 | -1.09 | 65.81% | 65.96% | +| Split-mode E2E stale (7b) | 8x | — | — | 3.37 | -0.51 | 35.63% | 35.63% | +| Split-mode E2E stale (7b) | 16x | — | — | 4.28 | +0.39 | 16.53% | 16.68% | + +Note: Tasks 2–4 and 5c baselines differ in PPL (3.89 vs 3.94) due to different evaluation +code paths (single-GPU HF vs Megatron pipeline). PPL deltas for offline methods use 3.89; +E2E methods use 3.94. HF Strict/Flex: GSM8K evaluated via HF backend (lm-eval-harness, +router-compressed mode). For Tasks 7a/7b, HF Strict/Flex is compressed-router only. +Uncompressed-router results for Tasks 7a/7b are in a dedicated table below Section 6.4. +GSM8K scores are identical for both baselines because GSM8K evaluation uses the same raw HF +model. GSM8K uses Megatron-trained weights for E2E methods. "Strict" requires exact +`#### ` format; "flexible" extracts the number from anywhere in the output. +HF-trained E2E weights (Tasks 5a/5b) were not available. + +### 6.2 Key Findings + +1. **E2E training is transformative** — E2E methods achieve PPL *below* baseline (3.94) at 2x. E2E stale stays below baseline at 4x (PPL=3.61). +2. **E2E stale at 16x is moderate** — PPL=6.34 (+2.41), 61% above baseline, with GSM8K strict-match at 2.12%. +3. **E2E dramatically outperforms offline** — Same architecture, same params: offline per-layer 4x PPL=425.75 vs E2E 4x PPL=4.28 (99x better). At 16x: 52440 vs 11.26 (4658x better). +4. **Stale conditioning matters more at high compression** — At 2x the gap is small (E2E stale 2.71 vs E2E per-layer 2.77), but at 16x it's 1.8x (6.34 vs 11.26). +5. **INT8 quantization is nearly lossless** — PPL 3.90 vs baseline 3.89 at 2x (+0.01), with GSM8K preserved (48.90% strict, 82.26% flexible). +6. **INT4 quantization is acceptable** — PPL 4.51 at ~4x (+0.62 delta). GSM8K strict-match actually improves to 56.41%. +7. **INT2 is catastrophic** — PPL 1533 at ~8x, completely unusable. +8. **Offline methods degrade rapidly** — Per-layer neural: PPL=21 at 2x, PPL=425 at 4x, PPL=7950 at 8x. Stale-conditioning (uncompressed) helps at 2x (PPL=6.24) but collapses at 8x (PPL=424). +9. **Below-baseline PPL** suggests E2E compressors act as regularizers, filtering noise from hidden states while preserving task-relevant information. Confirmed by GSM8K: E2E 2x scores 61.33% vs baseline 44.12%. +10. **Downstream tasks are more sensitive than PPL** — Offline stale_uncomp_2x has PPL=6.24 (+2.36) but GSM8K drops from 44% to 3% strict-match. E2E methods maintain both PPL and GSM8K. See Section 6.4. +11. **Offline compression destroys output format but partially preserves reasoning** — stale_uncomp_2x: 2.81% strict but 67.10% flexible-extract. E2E methods show no such gap (~0.3 pp). +12. **Pretrained init (Task 6) dramatically improves E2E training** — Initializing from offline-trained weights (Tasks 3b/4b) instead of near-identity gives 13–45% PPL improvement and massive GSM8K gains. 6b at 2x achieves PPL=2.25 and 82.5% GSM8K strict-match (vs 5b: PPL=2.71, 60.3%). Even at 16x, 6b (PPL=3.47, GSM8K 25.9%) stays below baseline PPL (3.89) and retains meaningful downstream accuracy. +13. **Pretrained init benefits grow with compression ratio** — For stale-conditioned (6b vs 5b): PPL improvement goes from 17% at 2x to 45% at 16x; GSM8K goes from +22 pp at 2x to +24 pp at 16x. The offline-trained weights provide a much better starting point for E2E optimization, especially at high compression where near-identity init struggles. +14. **Split-mode training (Task 7) matches deployment reality** — Training with split-mode (router sees original, experts see decompressed) then evaluating in the same mode yields the best uncompressed-router results. 7b uncompressed at 2x achieves 83.3% GSM8K strict-match — the best result across all methods and modes. +15. **7b uncompressed stays below baseline PPL at ALL ratios** — Even at 16x compression, 7b uncompressed PPL=3.27 remains below the no-compression baseline (3.89). This is the only method to maintain below-baseline PPL at every compression ratio, demonstrating that stale-conditioned split-mode E2E compressors can be simultaneously lossy (16x compression) and beneficial (regularization effect). +16. **Split-mode training trades compressed-eval quality for uncompressed-eval quality** — 7a/7b compressed-eval PPL is worse than 6a/6b (e.g., 7a 16x compressed: 908 vs 6a: 8.49) because the model was not trained to have the router see decompressed data. But 7a/7b uncompressed-eval is better (7a 16x uncompressed: 6.64 vs 6a compressed: 8.49). This confirms the training mode should match the deployment mode. +17. **Catastrophic collapse at extreme compression without stale** — 7a 16x compressed PPL=908 (vs 7a 16x uncompressed=6.64), showing that when per-layer compression is too lossy, correct routing (from original hidden states) becomes critical. Stale conditioning (7b) avoids this entirely: 7b 16x compressed=4.28, uncompressed=3.27. + +### 6.3 HF vs Megatron Comparison + +**Note:** HF E2E results in this section are from an earlier training run. The HF E2E +weight files are no longer available in the current `results/05a_e2e_perlayer/` and +`results/05b_e2e_stale/` directories (only logs remain). The Megatron results are +from the current run and match the JSON files. The comparison below is preserved for +historical reference but the HF numbers cannot be independently verified from current data. + +Both implementations use the same compressor architecture (Compressor + Decompressor / StaleDecompressor), the same model (Qwen3-30B-A3B-Instruct-2507), and the same training data (Dolci-Instruct-SFT). The key differences are in the distributed training strategy and model parallelism framework. + +**Implementation differences:** + +| Aspect | HuggingFace | Megatron | +|---|---|---| +| Framework | HF Transformers + `device_map="auto"` | Megatron-Core + AutoBridge | +| Parallelism | Naive layer sharding (sequential) | EP=4, TP=1, PP=1, DP=4 | +| GPU utilization | 1 GPU active at a time | All 4 GPUs active (DP) | +| Data parallelism | None (single data stream) | DP=4 (each rank sees 1/4 of data per step) | +| Optimizer | AdamW (single replica) | AdamW (replicated, gradients all-reduced) | +| CUDA | 12.6 | 12.9 | + +**Task 5a — E2E per-layer (no stale):** + +| Ratio | HF PPL | Megatron PPL | Gap (Meg−HF) | +|---|---|---|---| +| 2x | **2.645** (−1.58) | 2.682 (−1.54) | +0.04 | +| 4x | **3.687** (−0.54) | 4.410 (+0.19) | +0.72 | +| 8x | **6.371** (+2.15) | 8.182 (+3.96) | +1.81 | +| 16x | **9.157** (+4.93) | 11.670 (+7.44) | +2.51 | + +**Task 5b — E2E stale-conditioned (uncompressed stale):** + +| Ratio | HF PPL | Megatron PPL | Gap (Meg−HF) | +|---|---|---|---| +| 2x | 2.570 (−1.65) | **2.568** (−1.66) | −0.00 | +| 4x | **3.102** (−1.12) | 3.420 (−0.80) | +0.32 | +| 8x | **4.015** (−0.21) | 4.743 (+0.52) | +0.73 | +| 16x | **4.550** (+0.32) | 5.232 (+1.01) | +0.68 | + +**Training losses (train / val):** + +| Config | HF 5a | Megatron 5a | HF 5b | Megatron 5b | +|---|---|---|---|---| +| 2x | 1.215 / 1.093 | 1.258 / 1.109 | 1.193 / 1.070 | 1.210 / 1.068 | +| 4x | 1.786 / 1.447 | 2.103 / 1.627 | 1.579 / 1.286 | 1.784 / 1.375 | +| 8x | 2.412 / 2.004 | 2.776 / 2.242 | 1.921 / 1.555 | 2.206 / 1.724 | +| 16x | 2.768 / 2.326 | 3.180 / 2.567 | 2.069 / 1.686 | 2.344 / 1.823 | + +**Analysis:** + +1. **At 2x, both implementations converge to the same quality.** The gap is negligible (0.04 for 5a, −0.002 for 5b). Near-identity initialization gives a strong starting point, and 2x compression is easy enough that both optimizers find similar solutions. + +2. **Megatron's gap grows at higher compression ratios for 5a** (no stale). At 4x the gap is +0.72, at 16x it's +2.51. The likely cause is that Megatron with DP=4 provides each rank with 1/4 of the data per step — effectively a noisier gradient estimate. HF's single-replica training sees the full data stream, leading to a slightly better optimizer trajectory for harder problems (higher compression). + +3. **Stale conditioning dramatically narrows the Megatron-HF gap.** Adding stale conditioning reduces the gap by 50–73% at all ratios: + - 4x: +0.72 → +0.32 (56% reduction) + - 8x: +1.81 → +0.73 (60% reduction) + - 16x: +2.51 → +0.68 (73% reduction) + The stale signal acts as an anchor that partially corrects for the noisier optimization — it provides a strong prior about the expected hidden state, reducing the difficulty of the decompression task. + +4. **Both Megatron variants produce usable compressors.** Megatron 5b at 4x (PPL=3.42) is still 19% below baseline, and even at 16x (PPL=5.23) the degradation is only +24%. For production deployment where Megatron's scalability is needed, these results are practical. + +5. **Recommendation:** Use Megatron with stale conditioning (5b mode) for production. At 2–4x compression, results match HF quality. At 8–16x, there is a modest quality gap, but Megatron's multi-node scalability and proper expert parallelism make it the right choice for large-scale deployment. + +### 6.4 Downstream Task Evaluation (GSM8K) + +**Benchmark:** GSM8K chain-of-thought (gsm8k_cot), 8-shot, 1319 test examples. +Two metrics: **strict-match** (exact `#### ` format) and **flexible-extract** +(number extracted from anywhere in the output via regex). +Two router modes: **compressed** (router AND experts see decompressed hidden states) +and **uncompressed** (router sees original, experts see decompressed — more realistic EP +simulation). PPL, MSE, CosSim from HF-based evaluation (`model_utils.py`). +HF Strict/Flex from HF backend (lm-eval-harness, router-compressed mode). +vLLM columns from vLLM backend (`run_all_downstream.py`, both router modes). +For Tasks 7a/7b, vLLM Uncomp. columns show HF backend uncompressed-router results +(confirmed identical via both `run_all_downstream.py` and `run_e2e_compressor.py +--router-mode uncompressed`). + +| Method | Ratio | MSE | CosSim | PPL | PPL Δ | HF Strict | HF Flex | vLLM Comp. Strict | vLLM Comp. Flex | vLLM Uncomp. Strict | vLLM Uncomp. Flex | +|---|---|---|---|---|---|---|---|---|---|---|---| +| Baseline | — | — | — | 3.89 | — | 44.1% | 82.8% | 43.3% | 82.9% | — | — | +| Quant INT8 | 2x | — | — | 3.90 | +0.01 | 48.9% | 82.3% | 43.7% | 82.2% | — | — | +| Quant INT4 | 4x | — | — | 4.51 | +0.62 | 56.4% | 68.5% | 46.8% | 65.4% | — | — | +| Quant INT2 | 8x | — | — | 1532.59 | +1528.70 | 0.0% | 0.0% | 0.0% | 0.0% | — | — | +| Neural (per-layer) | 2x | 0.0535 | 0.922 | 21.07 | +17.18 | 0.0% | 1.5% | 0.0% | 1.2% | 22.7% | 42.6% | +| Neural (per-layer) | 4x | 0.1073 | 0.835 | 425.75 | +421.87 | 0.0% | 0.0% | 0.0% | 0.4% | 1.0% | 2.4% | +| Neural (per-layer) | 8x | 0.1523 | 0.755 | 7949.78 | +7945.89 | 0.0% | 0.0% | 0.0% | 0.0% | 2.0% | 1.9% | +| Neural (per-layer) | 16x | 0.1893 | 0.683 | 52440.05 | +52436.16 | 0.0% | 0.0% | 0.0% | 0.0% | 1.5% | 1.5% | +| Stale-cond. (compressed) | 2x | 0.0379 | 0.947 | 6.13 | +2.24 | 3.4% | 62.6% | 0.2% | 0.8% | 34.1% | 69.7% | +| Stale-cond. (compressed) | 4x | 0.0876 | 0.869 | 31.64 | +27.75 | 0.6% | 1.5% | 0.0% | 0.6% | 2.7% | 4.9% | +| Stale-cond. (compressed) | 8x | 0.1330 | 0.791 | 2982.23 | +2978.34 | 0.0% | 0.0% | 0.0% | 0.0% | 1.3% | 1.8% | +| Stale-cond. (compressed) | 16x | 0.1720 | 0.717 | 17486.21 | +17482.32 | 0.0% | 0.0% | 0.0% | 0.0% | 1.8% | 2.0% | +| Stale-cond. (uncompressed) | 2x | 0.0346 | 0.952 | 6.24 | +2.36 | 2.8% | 67.1% | 0.2% | 1.1% | 30.7% | 72.6% | +| Stale-cond. (uncompressed) | 4x | 0.0690 | 0.900 | 16.11 | +12.22 | 1.0% | 6.1% | 0.0% | 0.6% | 6.1% | 9.3% | +| Stale-cond. (uncompressed) | 8x | 0.0966 | 0.855 | 423.68 | +419.79 | 0.0% | 0.0% | 0.0% | 0.0% | 1.2% | 2.5% | +| Stale-cond. (uncompressed) | 16x | 0.1173 | 0.819 | 3740.41 | +3736.53 | 0.0% | 0.0% | 0.0% | 0.0% | 1.4% | 2.0% | +| E2E per-layer (5a) | 2x | — | — | 2.77 | −1.17 | 61.3% | 61.6% | 61.5% | 61.6% | 52.4% | 59.6% | +| E2E per-layer (5a) | 4x | — | — | 4.28 | +0.35 | 20.7% | 21.3% | 21.2% | 22.4% | 11.0% | 12.9% | +| E2E per-layer (5a) | 8x | — | — | 7.49 | +3.55 | 1.8% | 2.1% | 0.0% | 0.0% | 0.0% | 0.0% | +| E2E per-layer (5a) | 16x | — | — | 11.26 | +7.33 | 0.9% | 2.7% | 0.0% | 0.0% | 0.0% | 0.1% | +| E2E stale (5b) | 2x | — | — | 2.71 | −1.23 | 60.3% | 60.7% | 61.3% | 61.6% | 53.2% | 61.2% | +| E2E stale (5b) | 4x | — | — | 3.61 | −0.33 | 31.5% | 32.4% | 33.0% | 33.2% | 18.6% | 22.1% | +| E2E stale (5b) | 8x | — | — | 4.98 | +1.04 | 4.9% | 5.0% | 3.4% | 4.3% | 0.2% | 2.4% | +| E2E stale (5b) | 16x | — | — | 6.34 | +2.41 | 2.1% | 2.3% | 0.0% | 0.2% | 0.0% | 0.1% | +| E2E pretrained per-layer (6a) | 2x | — | — | 2.41 | −1.53 | 80.0% | 80.1% | 80.1% | 80.0% | 80.6% | 80.8% | +| E2E pretrained per-layer (6a) | 4x | — | — | 3.18 | −0.76 | 55.0% | 55.2% | 52.8% | 52.9% | 43.3% | 43.9% | +| E2E pretrained per-layer (6a) | 8x | — | — | 4.52 | +0.58 | 17.0% | 17.0% | 13.5% | 14.0% | 6.7% | 7.6% | +| E2E pretrained per-layer (6a) | 16x | — | — | 7.34 | +3.40 | 2.3% | 2.3% | 0.3% | 1.1% | 1.1% | 2.1% | +| E2E pretrained stale (6b) | 2x | — | — | 2.25 | −1.69 | 82.5% | 82.6% | 82.0% | 82.3% | 83.9% | 84.0% | +| E2E pretrained stale (6b) | 4x | — | — | 2.57 | −1.37 | 64.4% | 64.5% | 71.0% | 71.1% | 68.8% | 68.9% | +| E2E pretrained stale (6b) | 8x | — | — | 3.04 | −0.90 | 45.8% | 45.9% | 37.6% | 37.6% | 24.3% | 24.3% | +| E2E pretrained stale (6b) | 16x | — | — | 3.47 | −0.47 | 25.9% | 25.9% | 18.7% | 18.7% | 9.0% | 9.6% | +| Split E2E per-layer (7a) | 2x | — | — | 2.58 | −1.31 | 79.9% | 80.0% | — | — | 79.5% | 79.7% | +| Split E2E per-layer (7a) | 4x | — | — | 3.72 | −0.17 | 42.1% | 42.2% | — | — | 51.6% | 51.8% | +| Split E2E per-layer (7a) | 8x | — | — | 6.43 | +2.54 | 4.9% | 5.5% | — | — | 18.5% | 18.7% | +| Split E2E per-layer (7a) | 16x | — | — | 908.20 | +904.31 | 0.0% | 0.5% | — | — | 2.0% | 2.5% | +| Split E2E stale (7b) | 2x | — | — | 2.34 | −1.55 | 80.7% | 80.7% | — | — | 83.3% | 83.4% | +| Split E2E stale (7b) | 4x | — | — | 2.80 | −1.09 | 65.8% | 66.0% | — | — | 70.7% | 70.7% | +| Split E2E stale (7b) | 8x | — | — | 3.37 | −0.51 | 35.6% | 35.6% | — | — | 47.2% | 47.2% | +| Split E2E stale (7b) | 16x | — | — | 4.28 | +0.39 | 16.5% | 16.7% | — | — | 27.1% | 27.1% | + +Notes: HF = HF backend (router-compressed mode). vLLM Comp. = vLLM backend, router-compressed +(router+experts see decompressed). vLLM Uncomp. = vLLM backend, router-uncompressed (router sees +original, experts see decompressed — split forward). For Tasks 7a/7b, HF Strict/Flex = HF backend +with compressed router; vLLM Uncomp. = HF backend with uncompressed router (confirmed identical +results from both `run_all_downstream.py` and `run_e2e_compressor.py --router-mode uncompressed`). +Baseline and quantization have no split mode. PPL baseline: 3.89 (offline) / 3.94 (E2E). GSM8K +uses Megatron-trained weights for E2E methods. Task 7 PPL column shows compressed-router PPL. +Uncompressed-router results (confirmed identical via both original eval code path and +`run_e2e_compressor.py --router-mode uncompressed`): + +| Ratio | 7a PPL | 7b PPL | Baseline PPL | 7a Strict | 7a Flex | 7b Strict | 7b Flex | +|-------|--------|--------|--------------|-----------|---------|-----------|---------| +| 2x | 2.38 | 2.23 | 3.89 | 79.5% | 79.7% | 83.3% | 83.4% | +| 4x | 3.08 | 2.53 | 3.89 | 51.6% | 51.8% | 70.7% | 70.7% | +| 8x | 4.18 | 2.89 | 3.89 | 18.5% | 18.7% | 47.2% | 47.2% | +| 16x | 6.64 | 3.27 | 3.89 | 2.0% | 2.5% | 27.1% | 27.1% | + +**Key findings:** + +1. **E2E compression improves GSM8K over baseline.** Baseline strict-match is 44.12%. + E2E per-layer 2x achieves 61.33% (+17.2 pp) and E2E stale 2x achieves 60.27% + (+16.2 pp). This mirrors the below-baseline PPL effect — E2E compressors act as + regularizers that improve both perplexity and downstream task performance. + +2. **INT8 and INT4 quantization also improve strict-match.** INT8: 48.90% (+4.8 pp), + INT4: 56.41% (+12.3 pp). The flexible-extract gap is smaller (INT8: 82.26% vs + baseline 82.79%), suggesting quantization noise may regularize the strict output + format without hurting reasoning. + +3. **Offline methods catastrophically fail on generation tasks.** Per-layer neural + compressors score 0% strict-match at all ratios (even 2x, which has PPL=21.07). + Stale-conditioned 2x scores only 2.81% strict / 67.10% flexible. The flexible-extract + score reveals that the model still produces correct numerical answers but the output + format is destroyed — compression disrupts the learned generation patterns. + +4. **The strict-vs-flexible gap reveals a format disruption effect.** Offline methods + show huge gaps: stale_uncomp_2x has 2.81% strict but 67.10% flexible (64.3 pp gap). + E2E methods show almost no gap: e2e_2x has 61.33% strict vs 61.64% flexible (0.3 pp). + End-to-end training preserves both the model's reasoning ability AND its output + formatting, while offline compression preserves some reasoning but destroys formatting. + +5. **GSM8K is more sensitive than PPL to compression quality.** Stale_uncomp_2x has + PPL=6.24 (only +2.36 above baseline) yet scores 2.81% on GSM8K strict-match (vs + 44.12% baseline). E2E per-layer 4x has PPL=4.28 (only +0.35 above baseline) yet + drops to 20.70% GSM8K. Generation tasks amplify small distributional shifts that + PPL barely registers. + +6. **Stale conditioning matters for downstream tasks.** At 4x: E2E stale gets 31.54% + vs E2E per-layer 20.70% (+10.8 pp). At 8x: stale gets 4.93% vs per-layer 1.82%. + The stale signal helps preserve generation quality, consistent with PPL findings. + +7. **Pretrained init (Task 6) yields dramatic GSM8K improvements.** 6b stale at 2x + achieves 82.49% strict-match — nearly double baseline (44.12%) and +22 pp over 5b + (60.27%). 6a per-layer at 2x reaches 79.98% (+19 pp over 5a). Even at 8x, 6b retains + 45.79% (exceeding baseline) while 5b collapses to 4.93%. + +8. **Pretrained init enables useful compression at 16x.** 6b at 16x achieves 25.85% + GSM8K strict-match — down from baseline (44.12%) but still practically useful. Compare + with 5b at 16x (2.12%) or 5a at 16x (0.91%). Offline weights provide the optimizer + with a much better starting region of parameter space. + +9. **Best overall result: 6b at 2–4x compression.** 6b at 2x (PPL=2.25, GSM8K=82.5%) + and 4x (PPL=2.57, GSM8K=64.4%) both outperform baseline on PPL and at 4x still retain + strong downstream performance. This suggests stale-conditioned E2E compression with + pretrained init is a viable approach for reducing MoE communication by 2–4x with + minimal or even improved model quality. + +--- + +## 7. Design Choices and Trade-offs + +### 7.1 Offline Independent Training vs End-to-End + +**Offline training (Tasks 2–4)** trains compressors on cached hidden states, independently per layer: + +| Aspect | Offline | End-to-End (Task 5) | +|---|---|---| +| Loss | MSE + cosine (reconstruction) | Cross-entropy (next-token prediction) | +| Optimization scope | Per-layer, independent | Joint, all 48 layers | +| Gradient flow | None through LLM | Through entire frozen LLM | +| Stale signal | Pre-computed, frozen | Live, gradients flow through | +| Model precision | Full BF16 (~60 GB, 1 GPU) | Full BF16 (~60 GB, 4 GPUs) | +| Training cost | Minutes per layer | Hours for all layers + ratios | +| Error compounding | Not accounted for | Naturally optimized via global loss | + +**Offline advantages:** +- Fast and cheap (minutes per layer on a single GPU) +- No need to backpropagate through the full LLM +- Each layer's compressor can be trained in parallel + +**Offline limitations (addressed by e2e):** +- Compressors cannot adapt to how their reconstruction errors compound across layers. A small error at layer 0 may shift the hidden state distribution at layer 1, but layer 1's compressor was trained on the *original* layer-1 distribution. +- No joint optimization means the system cannot learn to allocate more capacity to layers where errors are most harmful. +- The stale signal used during offline training is the *unperturbed* reference input, but during inference the reference layer itself is compressed, creating a train-inference mismatch. + +**E2E advantages:** +- Compressors are optimized for the actual downstream impact of compression on model quality. +- Joint optimization: the system implicitly learns which layers need higher fidelity. +- Stale gradients flow: reference layer compressors are optimized for their dual role (own reconstruction + stale side information for downstream layers). The stale signal during training already reflects upstream compression artifacts, eliminating the train-inference mismatch. + +**E2E limitations:** +- Requires full-precision model in memory for proper gradient flow (~60 GB across 4 GPUs). +- Training is slower (full forward + backward through 48 frozen transformer layers per step). +- More hyperparameter-sensitive (LR, warmup, gradient clipping matter more). + +### 7.2 Linear vs Non-linear Compressors + +All compressors are single-layer linear networks (no activation functions). This was a deliberate choice: +- Linear compressors are equivalent to learning an optimal projection/reconstruction pair (related to PCA) +- They are fast to train and apply (single matrix multiply) +- They establish a clean baseline before trying non-linear architectures + +### 7.3 Loss Function + +The combined `MSE + 0.1 × (1 - cos_sim)` loss was chosen because: +- MSE alone can be dominated by outlier values (which are common in later layers with kurtosis up to 81K) +- Cosine similarity preserves the direction of the hidden state vector, which matters more than exact magnitude for downstream attention and expert computations +- The 0.1 weighting keeps MSE as the primary objective while regularizing directions + +### 7.4 Reference Layer Stride + +The stride of 12 (giving reference layers {0, 12, 24, 36}) was chosen as a balance: +- More reference layers (smaller stride) → better stale signals but more communication (ref layers use standard compression without stale) +- Fewer reference layers (larger stride) → stale signals become less correlated with non-ref layers +- stride=12 gives 4 reference layers covering 48 layers, with each non-ref layer at most 11 layers away from its reference + +### 7.5 Training Data Size + +100,000 tokens per layer (increased from initial 10,000). Each token produces a 2048-dim vector, so training data per layer is 100K × 2048 = 204.8M values. This is sufficient for learning a linear map with ~4M parameters (2x compression, per-layer). + +### 7.6 Model Precision + +All tasks use the same model in full BF16 precision (no weight quantization). This ensures: +- Hidden states used for offline training exactly match inference conditions +- End-to-end training has proper gradient flow through frozen layers +- All methods share the same baseline perplexity, enabling direct comparison +- 4-bit NF4 quantization is available via `--load-in-4bit` but is not the default + +--- + +## 8. Implementation Details + +### 8.1 Hook-Based Evaluation and Training + +Four hook modes are used across experiments: + +| Mode | Hook type | Used in | +|---|---|---| +| `evaluate_perplexity_with_compression` | Same compress/decompress for all layers | Shared compressor (Task 3) | +| `evaluate_perplexity_with_perlayer_compression` | Per-layer compress/decompress dicts | Per-layer compressor (Task 3b) | +| `evaluate_perplexity_with_stale_compression` | Per-layer + stale cache + ref/non-ref split | Stale-conditioned (Tasks 4a/4b) | +| `E2ECompressorManager.register_hooks()` | Per-layer, trainable, with/without stale cache | E2E training + eval (Task 5) | + +The stale evaluation maintains a `stale_cache` dictionary that is populated by reference layer pre-hooks and read by subsequent non-reference layer hooks. This works because PyTorch processes layers sequentially (layer 0 before layer 1, etc.). + +**Device safety in evaluation hooks:** With `device_map="auto"`, model layers may reside on +different GPUs. All evaluation hooks in `model_utils.py` (`evaluate_perplexity_with_perlayer_compression` +and `evaluate_perplexity_with_stale_compression`) explicitly call `.to(x.device)` on +compressor/decompressor outputs before returning them to the model. This ensures correctness +when compressor weights and MoE layers are on different devices. + +**E2E training hooks (Task 5)** differ from evaluation hooks in two ways: +1. Compressor/decompressor parameters have `requires_grad=True`, so the autograd graph is maintained through the hooks. +2. For stale mode (5b), the cached stale signal is **not detached** — gradients flow through the stale path to earlier layers, enabling true end-to-end optimization. + +### 8.2 MoE Layer Detection + +`find_moe_layers()` in `model_utils.py` detects MoE modules by: +1. Checking if the class name contains "Moe", "MoE", or "SparseMoe" +2. Checking for `experts` attribute +3. Checking for both `gate` and `experts` attributes + +This is model-agnostic and works for Qwen3, Mixtral, and other MoE architectures. + +### 8.3 File Organization + +**Offline experiments (Tasks 1–4)** follow the same pattern: +1. Load cached hidden states from `data/hidden_states/` +2. Train compressors on dispatch states +3. Evaluate reconstruction metrics (offline, on cached data) +4. Load the full model and evaluate perplexity (online, with hooks) +5. Save results to `results/{experiment}/` + +**End-to-end experiments (Task 5)** follow a different pattern: +1. Load the full model in BF16 across 4 GPUs +2. Load and tokenize training data (Dolci-Instruct-SFT) +3. For each compression ratio: create compressor manager, train e2e, save weights +4. Evaluate perplexity on Dolci-Instruct-SFT (with hooks, same as offline) +5. Save results to `results/05{a,b}_e2e_{perlayer,stale}/` + +Bash wrappers in `scripts/` handle environment setup, module loading, and argument passing. + +### 8.4 Progress Tracking and Logging + +All long-running loops use `tqdm` progress bars (written to stderr) for real-time progress monitoring with elapsed time and ETA. Key loops instrumented: + +- **Training loops:** Epoch progress with loss/cosine postfix (all training functions) +- **Layer loops:** Per-layer training iteration (Tasks 3b, 4a/4b) +- **Data loading:** Calibration data and tokenization progress +- **Evaluation:** Perplexity evaluation sequence progress, quantization config iteration +- **Ratio loops:** Outer compression ratio iteration (all tasks) + +Each bash script redirects output to two log files in the task's output directory: + +| File | Contents | Source | +|---|---|---| +| `run.log` | Full output (print statements, results, summaries) | stdout | +| `progress.log` | tqdm progress bars (elapsed time, ETA, loss metrics) | stderr | + +Monitor progress of a running experiment: `tail -f results//progress.log` + +--- + +## 9. Reproducibility + +### 9.1 Software Environment + +- Python 3.11 +- PyTorch (via `pip install torch` with CUDA 12.6) +- Transformers (HuggingFace) +- bitsandbytes (optional, for 4-bit model loading) +- datasets (for allenai/Dolci-Instruct-SFT) +- matplotlib, numpy + +### 9.2 Hardware + +- NVIDIA H100 80 GB GPUs (8 available) +- Tasks 1–4: single GPU sufficient (model in full BF16, ~60 GB on one H100 80 GB) +- Task 5: 4 GPUs per job (model in full BF16, ~60 GB + backprop memory); 05a and 05b run in parallel on GPUs 0-3 and 4-7 +- 500+ GB system RAM (required for loading ~37 GB of hidden states for offline tasks) +- Compute Canada cluster + +### 9.3 Random Seeds and Data Splitting + +All experiments use **seed=42** for reproducibility. A deterministic 80/10/10 +train/val/test split of the Dolci-Instruct-SFT dataset rows is computed via +`get_split_indices()` in `model_utils.py`: + +```python +rng = random.Random(42) +indices = list(range(dataset_size)) +rng.shuffle(indices) +# 80% train, 10% val, 10% test +``` + +**Split consistency across tasks:** +- Task 1 hidden state collection: TRAIN split (max_samples=10000) +- Tasks 2–4 offline training: uses cached hidden states from Task 1 (TRAIN split) +- Tasks 2–4 PPL evaluation: TEST split (max_samples_ppl=50000, response-only) +- Task 5 E2E training: TRAIN split (500K sequences HF / 100K Megatron, SFT mode) +- Task 5 E2E validation: VAL split sequences (SFT mode) +- Task 5 PPL evaluation: TEST split (same as tasks 2–4, response-only) + +**SFT data loading (Task 5 and PPL evaluation):** +- Each conversation is tokenized independently (one sample = one sequence) +- Labels are -100 for non-assistant tokens, actual token IDs for assistant responses +- `_tokenize_sft_sample()` in `model_utils.py` finds assistant token boundaries + via incremental prefix tokenization of the chat template +- Max sequence length: 2048 (configurable via `--max-length`) +- Loss and perplexity are computed on response tokens only + +Additional seed setting in Task 5: +- `random.seed(42)`, `np.random.seed(42)`, `torch.manual_seed(42)`, + `torch.cuda.manual_seed_all(42)` at start of main() +- DataLoader shuffling uses PyTorch's seeded RNG + +### 9.4 Experiment Tracking (Wandb) + +Both HF and Megatron E2E scripts support Weights & Biases logging: + +- **CLI:** `--wandb` / `--no-wandb`, `--wandb-project ` +- **Logged metrics:** `train/loss` and `train/lr` per optimizer step, + `val/loss` every `--val-interval` steps (default 2500) and at end of epoch, + `train/epoch_loss` per epoch +- **Projects:** `ecmoe-e2e` (HF), `ecmoe-megatron-e2e` (Megatron) +- **Default:** Enabled in bash scripts via `WANDB_FLAG`; disable with + `WANDB_FLAG="--no-wandb" bash scripts/05_run_e2e_compressor.sh none` +- Megatron: only rank 0 logs to wandb +- Megatron `train/loss` and `train/epoch_loss` are DP-averaged (all-reduced across + data-parallel ranks) before logging, so wandb values reflect the true global loss +- Graceful fallback if wandb is not installed (HAS_WANDB flag) + +--- + +## 10. Task 8: EP Communication Compression in vLLM + +### 10.1 Motivation + +Tasks 5–7 evaluate compression quality using PyTorch hooks that compress and decompress +on the **same GPU** — simulating the quality impact but not achieving actual communication +reduction. In real expert parallelism, the pipeline is: + +1. Router computes logits from **original** hidden states (attention GPU) +2. **Compressor** runs on attention GPU: `hidden_dim` → `bottleneck_dim` +3. All-to-all dispatch sends only the **compressed** tensor (reduced volume!) +4. **Decompressor** runs on expert GPU: `bottleneck_dim` → `hidden_dim` +5. Experts compute on decompressed states + +Task 8 modifies vLLM's `FusedMoE.forward_impl()` to implement this pipeline, +compressing BEFORE dispatch and decompressing AFTER. + +### 10.2 Implementation + +**Patched vLLM (`scripts/patch_vllm_fused_moe.py`):** Adds ~12 lines to +`FusedMoE.forward_impl()` at three locations: + +1. **Compress before dispatch (EP mode):** `_ecmoe_compress_fn(hidden_states)` → + dispatches compressed tensor instead of full hidden_dim. +2. **Decompress after dispatch (EP mode):** After `get_ep_group().dispatch()`, + `_ecmoe_decompress_fn(hidden_states_combined)` restores full hidden_dim. +3. **Single-GPU fallback:** When `do_naive_dispatch_combine=False` (TP=1), + applies compress→decompress in-place for simulation mode. + +When `_ecmoe_compress_fn` is None (default), behavior is identical to stock vLLM. + +**EP-aware registration (`src/vllm_ep_compression.py`):** Uses `apply_model()` +to set compress/decompress functions on each FusedMoE instance: + +- **Per-layer:** `register_ep_perlayer()` — Independent linear compress/decompress per layer. +- **Stale-conditioned:** `register_ep_stale()` — Reference layers piggyback stale signal + on compressed tensor before dispatch. Non-reference layers dispatch only compressed data. + +### 10.3 Stale Broadcast via Dispatch Piggybacking + +**Reference layers (0, 12, 24, 36):** +- compress_fn: `cat(compressed[B, bottleneck], stale[B, stale_dim])` → dispatch `[B, bottleneck + stale_dim]` +- decompress_fn: split → cache stale_part globally → decompress compressed_part + +**Non-reference layers (all others):** +- compress_fn: `compressed[B, bottleneck]` only → dispatch `[B, bottleneck]` (maximum compression!) +- decompress_fn: retrieve cached stale → `cat(compressed, cached_stale)` → StaleDecomp + +**Correctness:** vLLM's default `all2all_backend=allgather_reducescatter` means after +dispatch, every rank has ALL tokens in consistent ordering. Stale cached from reference +layers matches token ordering at non-reference layers. + +### 10.4 Communication Savings + +| Mode | Ref layers (4/48) | Non-ref layers (44/48) | Weighted avg | vs baseline 2048 | +|------|-------------------|----------------------|--------------|-------------------| +| perlayer 2x | 1024 | 1024 | 1024 | **50% saving** | +| perlayer 4x | 512 | 512 | 512 | **75% saving** | +| stale(comp) 4x | 1024 | 512 | 555 | **73% saving** | +| stale(uncomp) 4x | 2560 | 512 | 683 | **67% saving** | +| stale(uncomp) 2x | 3072 | 1024 | 1195 | **42% saving** | + +Stale broadcast cost is amortized over ~11 non-reference layers per reference layer. + +### 10.5 Evaluation Modes + +- **simulation** (`--mode simulation`): Single-GPU (TP=1), no dispatch/combine. + Validates numerical correctness against existing split-mode results. +- **ep** (`--mode ep`): Multi-GPU (TP=4, `enable_expert_parallel=True`). + Uses actual EP dispatch/combine with compressed tensors. + +Both use Task 7a/7b weights (split-mode E2E trained) from +`results/07a_megatron_e2e_split_perlayer/` and `results/07b_megatron_e2e_split_stale/`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..8ba2ea75393aa7496322fb3aa8f4ae375f10339f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,6 @@ +services: + flight-search: + build: . + ports: + - "8080:8080" + restart: unless-stopped diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..5e6b472f583e34a1cca751440d4f241495475723 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..7204c1fe2d2027fe64dcf5013fbca27850baed1c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Flight Search + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..9cb14d3d9b3a0d995b48bf0b76210af26822dd6a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3951 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.2.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.2.1", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.3", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", + "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", + "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..f610a961405e3be79841cc400800f5f3d390f49a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.2.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.2.1", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000000000000000000000000000000000000..e7b8dfb1b2a60bd50538bec9f876511b9cac21e3 --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000000000000000000000000000000000000..b9d355df2a5956b526c004531b7b0ffe412461e0 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..39e5a4795d6037ebf3e23890d40c4827bbdf46a7 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,16 @@ +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import Header from './components/shared/Header'; +import SearchPage from './pages/SearchPage'; +import ResultsPage from './pages/ResultsPage'; + +export default function App() { + return ( + +
+ + } /> + } /> + + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..9683ce74786a3fe0fcb9b434e6f192edc69e2311 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,39 @@ +import type { AutocompleteResult, CalendarResponse, SearchRequest, SearchResponse } from './types'; + +const BASE_URL = '/api'; + +async function fetchJson(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init); + if (!res.ok) { + const text = await res.text(); + throw new Error(`API error ${res.status}: ${text}`); + } + return res.json(); +} + +export async function searchAirports(query: string): Promise { + if (!query || query.length < 1) return []; + return fetchJson( + `${BASE_URL}/airports/autocomplete?q=${encodeURIComponent(query)}` + ); +} + +export async function searchFlights(req: SearchRequest): Promise { + return fetchJson(`${BASE_URL}/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); +} + +export async function getCalendar( + origin: string, + destination: string, + year: number, + month: number, + cabinClass: string = 'economy' +): Promise { + return fetchJson( + `${BASE_URL}/calendar?origin=${origin}&destination=${destination}&year=${year}&month=${month}&cabin_class=${cabinClass}` + ); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea6af187f54aca053877f06f1c06f32c6333ab2f --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,90 @@ +export type CabinClass = 'economy' | 'premium_economy' | 'business' | 'first'; +export type TripType = 'one_way' | 'round_trip' | 'multi_city'; +export type SortBy = 'best' | 'cheapest' | 'fastest'; + +export interface AutocompleteResult { + iata: string; + name: string; + city_name: string; + country: string; + display_name: string; + hub_score: number; +} + +export interface FlightSegment { + airline_code: string; + airline_name: string; + flight_number: string; + aircraft: string; + origin: string; + origin_city: string; + destination: string; + destination_city: string; + departure: string; + arrival: string; + duration_minutes: number; +} + +export interface FlightOffer { + id: string; + segments: FlightSegment[]; + total_duration_minutes: number; + stops: number; + price_usd: number; + cabin_class: CabinClass; + origin: string; + destination: string; + departure: string; + arrival: string; +} + +export interface SearchLeg { + origin: string; + destination: string; + date: string; // YYYY-MM-DD +} + +export interface Passengers { + adults: number; + children: number; + infants: number; +} + +export interface Filters { + max_stops?: number | null; + max_price?: number | null; + max_duration_minutes?: number | null; + airlines?: string[] | null; + departure_time_min?: string | null; + departure_time_max?: string | null; +} + +export interface SearchRequest { + trip_type: TripType; + legs: SearchLeg[]; + passengers: Passengers; + cabin_class: CabinClass; + filters: Filters; + sort_by: SortBy; +} + +export interface SearchResponse { + outbound_flights: FlightOffer[]; + return_flights: FlightOffer[]; + search_id: string; + origin: string; + destination: string; +} + +export interface CalendarDay { + date: string; + cheapest_price: number | null; +} + +export interface CalendarResponse { + origin: string; + destination: string; + year: number; + month: number; + days: CalendarDay[]; +} diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000000000000000000000000000000000000..6c87de9bb3358469122cc991d5cf578927246184 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/results/FilterPanel.tsx b/frontend/src/components/results/FilterPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2856ee486efb6f7c6cc846aaac53a41df2bd6dfb --- /dev/null +++ b/frontend/src/components/results/FilterPanel.tsx @@ -0,0 +1,133 @@ +import { useMemo } from 'react'; +import type { Filters, FlightOffer } from '../../api/types'; + +interface Props { + flights: FlightOffer[]; + filters: Filters; + onChange: (f: Filters) => void; +} + +export default function FilterPanel({ flights, filters, onChange }: Props) { + // Compute available filter options from flights + const airlines = useMemo(() => { + const map = new Map(); + flights.forEach(f => f.segments.forEach(s => map.set(s.airline_code, s.airline_name))); + return Array.from(map.entries()).sort((a, b) => a[1].localeCompare(b[1])); + }, [flights]); + + const maxStopsAvailable = useMemo(() => Math.max(...flights.map(f => f.stops), 0), [flights]); + + return ( +
+ {/* Stops filter */} +
+

Stops

+
+ {[null, 0, 1, 2].filter(v => v === null || v <= maxStopsAvailable).map(v => ( + + ))} +
+
+ + {/* Price filter */} +
+

Max price

+
+ $ + onChange({ ...filters, max_price: e.target.value ? Number(e.target.value) : null })} + placeholder="Any" + className="w-24 rounded border border-gray-300 px-2 py-1 text-sm focus:border-[#1a73e8] focus:outline-none" + min={0} + data-testid="filter-max-price" + /> +
+
+ + {/* Departure time filter */} +
+

Departure time

+
+ onChange({ ...filters, departure_time_min: e.target.value || null })} + className="rounded border border-gray-300 px-2 py-1 text-sm focus:border-[#1a73e8] focus:outline-none" + data-testid="filter-dep-time-min" + /> + to + onChange({ ...filters, departure_time_max: e.target.value || null })} + className="rounded border border-gray-300 px-2 py-1 text-sm focus:border-[#1a73e8] focus:outline-none" + data-testid="filter-dep-time-max" + /> +
+
+ + {/* Airlines filter */} + {airlines.length > 1 && ( +
+

Airlines

+
+ {airlines.map(([code, name]) => { + const selected = !filters.airlines || filters.airlines.includes(code); + return ( + + ); + })} +
+
+ )} + + {/* Clear all */} + +
+ ); +} diff --git a/frontend/src/components/results/FlightCard.tsx b/frontend/src/components/results/FlightCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..712982ead06a15e36c21dc09fcfd3ab8a0677fc5 --- /dev/null +++ b/frontend/src/components/results/FlightCard.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import type { FlightOffer } from '../../api/types'; +import { formatDuration, formatPrice, formatStops, formatTime } from '../../utils/format'; +import FlightSegmentView from './FlightSegment'; + +interface Props { + flight: FlightOffer; +} + +export default function FlightCard({ flight }: Props) { + const [expanded, setExpanded] = useState(false); + const firstSeg = flight.segments[0]; + + // Check if arrival is on a different day + const depDate = new Date(flight.departure).toDateString(); + const arrDate = new Date(flight.arrival).toDateString(); + const dayDiff = depDate !== arrDate; + + return ( +
flight.stops > 0 && setExpanded(!expanded)} + data-testid={`flight-card-${flight.id}`} + role="article" + aria-label={`Flight from ${flight.origin} to ${flight.destination}, ${formatPrice(flight.price_usd)}`} + > +
+ {/* Airline badge */} +
+ {firstSeg.airline_code} +
+ + {/* Main info */} +
+ {/* Times */} +
+
+
{formatTime(flight.departure)}
+
{flight.origin}
+
+ +
+
{formatDuration(flight.total_duration_minutes)}
+
+
+
+ {/* Stop indicators */} + {flight.stops > 0 && flight.segments.slice(0, -1).map((_, i) => ( +
+ ))} +
+
{formatStops(flight.stops)}
+
+ +
+
+ {formatTime(flight.arrival)} + {dayDiff && +1} +
+
{flight.destination}
+
+
+ + {/* Airline name */} +
+ {firstSeg.airline_name} +
+
+ + {/* Price */} +
+
+ {formatPrice(flight.price_usd)} +
+
{flight.cabin_class.replace('_', ' ')}
+
+ + {/* Expand icon for connecting flights */} + {flight.stops > 0 && ( + + + + )} +
+ + {/* Expanded segment details */} + {expanded && flight.stops > 0 && ( +
+ {flight.segments.map((seg, i) => ( +
+ + {i < flight.segments.length - 1 && ( +
+ + Layover at {seg.destination} ({seg.destination_city}) + +
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/results/FlightSegment.tsx b/frontend/src/components/results/FlightSegment.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6896e429ddca9d3338647e0949c7ad2b2732fedc --- /dev/null +++ b/frontend/src/components/results/FlightSegment.tsx @@ -0,0 +1,42 @@ +import type { FlightSegment as SegmentType } from '../../api/types'; +import { formatDuration, formatTime } from '../../utils/format'; + +interface Props { + segment: SegmentType; + showDetails?: boolean; +} + +export default function FlightSegmentView({ segment, showDetails }: Props) { + return ( +
+ {/* Airline badge */} +
+ {segment.airline_code} +
+ + {/* Timeline */} +
+
+
{formatTime(segment.departure)}
+
{segment.origin}
+
+ +
+
{formatDuration(segment.duration_minutes)}
+
+
+
+
+ {showDetails && ( +
{segment.airline_name} · {segment.flight_number} · {segment.aircraft}
+ )} +
+ +
+
{formatTime(segment.arrival)}
+
{segment.destination}
+
+
+
+ ); +} diff --git a/frontend/src/components/results/NoResults.tsx b/frontend/src/components/results/NoResults.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9e58026db059472851ebe242de1fdf5262621d0b --- /dev/null +++ b/frontend/src/components/results/NoResults.tsx @@ -0,0 +1,29 @@ +interface Props { + hasFilters: boolean; + onClearFilters?: () => void; +} + +export default function NoResults({ hasFilters, onClearFilters }: Props) { + return ( +
+ + + +

No flights found

+

+ {hasFilters + ? 'Try adjusting your filters or search criteria.' + : 'No direct or connecting flights available for this route.'} +

+ {hasFilters && onClearFilters && ( + + )} +
+ ); +} diff --git a/frontend/src/components/results/SortBar.tsx b/frontend/src/components/results/SortBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4efe90d6f22e06df9d851ba28b3ae4b523d83421 --- /dev/null +++ b/frontend/src/components/results/SortBar.tsx @@ -0,0 +1,40 @@ +import type { SortBy } from '../../api/types'; + +const OPTIONS: { value: SortBy; label: string }[] = [ + { value: 'best', label: 'Best' }, + { value: 'cheapest', label: 'Cheapest' }, + { value: 'fastest', label: 'Fastest' }, +]; + +interface Props { + value: SortBy; + onChange: (v: SortBy) => void; + resultCount: number; +} + +export default function SortBar({ value, onChange, resultCount }: Props) { + return ( +
+ + {resultCount} result{resultCount !== 1 ? 's' : ''} + +
+ {OPTIONS.map(o => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/search/AirportInput.tsx b/frontend/src/components/search/AirportInput.tsx new file mode 100644 index 0000000000000000000000000000000000000000..851fc77ea9d4b406228ae1964fb045f9d9ea7ca0 --- /dev/null +++ b/frontend/src/components/search/AirportInput.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from 'react'; +import { searchAirports } from '../../api/client'; +import type { AutocompleteResult } from '../../api/types'; +import { useDebounce } from '../../hooks/useDebounce'; + +interface Props { + label: string; + value: string; // IATA code + displayValue: string; // "New York (JFK)" + onChange: (iata: string, display: string) => void; + placeholder?: string; + testId?: string; +} + +export default function AirportInput({ label, value, displayValue, onChange, placeholder, testId }: Props) { + const [query, setQuery] = useState(displayValue); + const [results, setResults] = useState([]); + const [open, setOpen] = useState(false); + const [focused, setFocused] = useState(false); + const debouncedQuery = useDebounce(query, 200); + const wrapperRef = useRef(null); + + // Sync display value when parent changes it + useEffect(() => { + if (!focused) setQuery(displayValue); + }, [displayValue, focused]); + + // Fetch autocomplete results + useEffect(() => { + if (!focused) return; + if (debouncedQuery.length < 1) { + setResults([]); + return; + } + let cancelled = false; + searchAirports(debouncedQuery).then(r => { + if (!cancelled) { + setResults(r); + setOpen(r.length > 0); + } + }); + return () => { cancelled = true; }; + }, [debouncedQuery, focused]); + + // Close on click outside + useEffect(() => { + function handler(e: MouseEvent) { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + setOpen(false); + setFocused(false); + if (!value) setQuery(''); + } + } + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [value]); + + function select(r: AutocompleteResult) { + onChange(r.iata, `${r.city_name} (${r.iata})`); + setQuery(`${r.city_name} (${r.iata})`); + setOpen(false); + setFocused(false); + } + + return ( +
+ + { setQuery(e.target.value); setOpen(true); }} + onFocus={() => { setFocused(true); setQuery(''); setOpen(true); }} + placeholder={placeholder || 'City or airport'} + className="w-full rounded-md border border-gray-300 px-3 py-3 text-sm text-gray-900 placeholder-gray-400 hover:border-gray-400 focus:border-[#1a73e8] focus:outline-none" + aria-label={label} + data-testid={testId ? `${testId}-input` : undefined} + autoComplete="off" + /> + {open && results.length > 0 && ( +
    + {results.map(r => ( +
  • select(r)} + className="flex cursor-pointer items-center gap-3 px-4 py-3 hover:bg-gray-50" + role="option" + data-testid={`airport-option-${r.iata}`} + aria-selected={r.iata === value} + > + + + +
    +
    {r.city_name} ({r.iata})
    +
    {r.name}, {r.country}
    +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/search/ClassSelector.tsx b/frontend/src/components/search/ClassSelector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ba236fc0f64c1c5f947a1df20db9e3d4398ff25d --- /dev/null +++ b/frontend/src/components/search/ClassSelector.tsx @@ -0,0 +1,30 @@ +import type { CabinClass } from '../../api/types'; +import { cabinClassLabel } from '../../utils/format'; + +const OPTIONS: CabinClass[] = ['economy', 'premium_economy', 'business', 'first']; + +interface Props { + value: CabinClass; + onChange: (v: CabinClass) => void; +} + +export default function ClassSelector({ value, onChange }: Props) { + return ( +
+ + + + +
+ ); +} diff --git a/frontend/src/components/search/DatePicker.tsx b/frontend/src/components/search/DatePicker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aa1164c4a21c3e7a824a466483fe5bbab32e28fe --- /dev/null +++ b/frontend/src/components/search/DatePicker.tsx @@ -0,0 +1,22 @@ +interface Props { + label: string; + value: string; // YYYY-MM-DD + onChange: (v: string) => void; + testId?: string; +} + +export default function DatePicker({ label, value, onChange, testId }: Props) { + return ( +
+ + onChange(e.target.value)} + className="w-full rounded-md border border-gray-300 px-3 py-3 text-sm text-gray-900 hover:border-gray-400 focus:border-[#1a73e8] focus:outline-none cursor-pointer" + aria-label={label} + data-testid={testId ? `${testId}-input` : undefined} + /> +
+ ); +} diff --git a/frontend/src/components/search/PassengerSelector.tsx b/frontend/src/components/search/PassengerSelector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..723a2904d64e2468acc8f9c37848d71df67ef19b --- /dev/null +++ b/frontend/src/components/search/PassengerSelector.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef, useState } from 'react'; +import type { Passengers } from '../../api/types'; + +interface Props { + value: Passengers; + onChange: (v: Passengers) => void; +} + +export default function PassengerSelector({ value, onChange }: Props) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + function handler(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const total = value.adults + value.children + value.infants; + + function update(field: keyof Passengers, delta: number) { + const v = { ...value }; + v[field] = Math.max(field === 'adults' ? 1 : 0, Math.min(9, v[field] + delta)); + onChange(v); + } + + return ( +
+ + + {open && ( +
+ {([ + { key: 'adults' as const, label: 'Adults', sub: '' }, + { key: 'children' as const, label: 'Children', sub: 'Aged 2-11' }, + { key: 'infants' as const, label: 'Infants', sub: 'Under 2' }, + ]).map(row => ( +
+
+
{row.label}
+ {row.sub &&
{row.sub}
} +
+
+ + {value[row.key]} + +
+
+ ))} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/search/SearchForm.tsx b/frontend/src/components/search/SearchForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e4c7e5dc087abb93ec90f2e3f4de21059f6e85dc --- /dev/null +++ b/frontend/src/components/search/SearchForm.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react'; +import type { CabinClass, Passengers, TripType } from '../../api/types'; +import { getDefaultDepartureDate, getDefaultReturnDate } from '../../utils/date'; +import AirportInput from './AirportInput'; +import ClassSelector from './ClassSelector'; +import DatePicker from './DatePicker'; +import PassengerSelector from './PassengerSelector'; +import SwapButton from './SwapButton'; +import TripTypeSelector from './TripTypeSelector'; + +export interface SearchFormData { + tripType: TripType; + origin: string; + originDisplay: string; + destination: string; + destinationDisplay: string; + departureDate: string; + returnDate: string; + passengers: Passengers; + cabinClass: CabinClass; +} + +interface Props { + initial?: Partial; + onSearch: (data: SearchFormData) => void; + compact?: boolean; +} + +export default function SearchForm({ initial, onSearch, compact }: Props) { + const [tripType, setTripType] = useState(initial?.tripType || 'round_trip'); + const [origin, setOrigin] = useState(initial?.origin || ''); + const [originDisplay, setOriginDisplay] = useState(initial?.originDisplay || ''); + const [destination, setDestination] = useState(initial?.destination || ''); + const [destinationDisplay, setDestinationDisplay] = useState(initial?.destinationDisplay || ''); + const [departureDate, setDepartureDate] = useState(initial?.departureDate || getDefaultDepartureDate()); + const [returnDate, setReturnDate] = useState(initial?.returnDate || getDefaultReturnDate()); + const [passengers, setPassengers] = useState(initial?.passengers || { adults: 1, children: 0, infants: 0 }); + const [cabinClass, setCabinClass] = useState(initial?.cabinClass || 'economy'); + + function handleSwap() { + const tmpCode = origin; + const tmpDisplay = originDisplay; + setOrigin(destination); + setOriginDisplay(destinationDisplay); + setDestination(tmpCode); + setDestinationDisplay(tmpDisplay); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!origin || !destination) return; + onSearch({ + tripType, origin, originDisplay, destination, destinationDisplay, + departureDate, returnDate, passengers, cabinClass, + }); + } + + return ( +
+ {/* Row 1: Trip type, passengers, class */} +
+ + + +
+ + {/* Row 2: Airport inputs + dates + search button */} +
+ { setOrigin(iata); setOriginDisplay(display); }} + testId="origin" + /> + + + + { setDestination(iata); setDestinationDisplay(display); }} + testId="destination" + /> + + + + {tripType === 'round_trip' && ( + + )} + + +
+
+ ); +} diff --git a/frontend/src/components/search/SwapButton.tsx b/frontend/src/components/search/SwapButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e2ddb7489baebd5ff76a2a927b6a59d031c50abd --- /dev/null +++ b/frontend/src/components/search/SwapButton.tsx @@ -0,0 +1,18 @@ +interface Props { + onClick: () => void; +} + +export default function SwapButton({ onClick }: Props) { + return ( + + ); +} diff --git a/frontend/src/components/search/TripTypeSelector.tsx b/frontend/src/components/search/TripTypeSelector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dea8b66dc4d9992306ec213814e3aecdac20b230 --- /dev/null +++ b/frontend/src/components/search/TripTypeSelector.tsx @@ -0,0 +1,33 @@ +import type { TripType } from '../../api/types'; + +const OPTIONS: { value: TripType; label: string }[] = [ + { value: 'round_trip', label: 'Round trip' }, + { value: 'one_way', label: 'One way' }, + { value: 'multi_city', label: 'Multi-city' }, +]; + +interface Props { + value: TripType; + onChange: (v: TripType) => void; +} + +export default function TripTypeSelector({ value, onChange }: Props) { + return ( +
+ + + + +
+ ); +} diff --git a/frontend/src/components/shared/Header.tsx b/frontend/src/components/shared/Header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0f539ab970f4e16b4fbba04d67a83d3cb1bf4880 --- /dev/null +++ b/frontend/src/components/shared/Header.tsx @@ -0,0 +1,22 @@ +import { useNavigate } from 'react-router-dom'; + +export default function Header() { + const navigate = useNavigate(); + + return ( +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/shared/Loading.tsx b/frontend/src/components/shared/Loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..af9fbac5865c64ffb0085e743db67bf4ef2c890d --- /dev/null +++ b/frontend/src/components/shared/Loading.tsx @@ -0,0 +1,8 @@ +export default function Loading() { + return ( +
+
+

Searching flights...

+
+ ); +} diff --git a/frontend/src/hooks/useDebounce.ts b/frontend/src/hooks/useDebounce.ts new file mode 100644 index 0000000000000000000000000000000000000000..b29aba5d67ddddb05632b3823bba5b2e28219b53 --- /dev/null +++ b/frontend/src/hooks/useDebounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +} diff --git a/frontend/src/hooks/useFlightSearch.ts b/frontend/src/hooks/useFlightSearch.ts new file mode 100644 index 0000000000000000000000000000000000000000..78fd81171a5173eb63e95bfdcf65503b40eb7787 --- /dev/null +++ b/frontend/src/hooks/useFlightSearch.ts @@ -0,0 +1,70 @@ +import { useCallback, useState } from 'react'; +import { searchFlights } from '../api/client'; +import type { CabinClass, Filters, FlightOffer, Passengers, SearchRequest, SortBy, TripType } from '../api/types'; + +interface SearchState { + outboundFlights: FlightOffer[]; + returnFlights: FlightOffer[]; + loading: boolean; + error: string | null; + searched: boolean; +} + +export function useFlightSearch() { + const [state, setState] = useState({ + outboundFlights: [], + returnFlights: [], + loading: false, + error: null, + searched: false, + }); + + const search = useCallback(async (params: { + tripType: TripType; + origin: string; + destination: string; + departureDate: string; + returnDate?: string; + passengers: Passengers; + cabinClass: CabinClass; + filters: Filters; + sortBy: SortBy; + }) => { + setState(s => ({ ...s, loading: true, error: null })); + + const legs = [{ origin: params.origin, destination: params.destination, date: params.departureDate }]; + if (params.tripType === 'round_trip' && params.returnDate) { + legs.push({ origin: params.destination, destination: params.origin, date: params.returnDate }); + } + + const req: SearchRequest = { + trip_type: params.tripType, + legs, + passengers: params.passengers, + cabin_class: params.cabinClass, + filters: params.filters, + sort_by: params.sortBy, + }; + + try { + const res = await searchFlights(req); + setState({ + outboundFlights: res.outbound_flights, + returnFlights: res.return_flights, + loading: false, + error: null, + searched: true, + }); + } catch (err) { + setState({ + outboundFlights: [], + returnFlights: [], + loading: false, + error: err instanceof Error ? err.message : 'Search failed', + searched: true, + }); + } + }, []); + + return { ...state, search }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000000000000000000000000000000000000..98809b6034208e5550b8911eb9b97da561744f07 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,9 @@ +@import "tailwindcss"; + +body { + margin: 0; + font-family: 'Google Sans', 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif; + -webkit-font-smoothing: antialiased; + color: #202124; + background: #fff; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bef5202a32cbd0632c43de40f6e908532903fd42 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/pages/ResultsPage.tsx b/frontend/src/pages/ResultsPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3f66d73cbb9d54b71dd7ba37e6e41105d02a03f9 --- /dev/null +++ b/frontend/src/pages/ResultsPage.tsx @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import type { CabinClass, Filters, Passengers, SortBy, TripType } from '../api/types'; +import SearchForm from '../components/search/SearchForm'; +import type { SearchFormData } from '../components/search/SearchForm'; +import FlightCard from '../components/results/FlightCard'; +import FilterPanel from '../components/results/FilterPanel'; +import NoResults from '../components/results/NoResults'; +import SortBar from '../components/results/SortBar'; +import Loading from '../components/shared/Loading'; +import { useFlightSearch } from '../hooks/useFlightSearch'; + +const EMPTY_FILTERS: Filters = { + max_stops: null, max_price: null, max_duration_minutes: null, + airlines: null, departure_time_min: null, departure_time_max: null, +}; + +export default function ResultsPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const { outboundFlights, returnFlights, loading, error, searched, search } = useFlightSearch(); + const [sortBy, setSortBy] = useState('best'); + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [showReturn, setShowReturn] = useState(false); + + // Parse URL params + const origin = searchParams.get('origin') || ''; + const destination = searchParams.get('destination') || ''; + const departureDate = searchParams.get('departure') || ''; + const returnDate = searchParams.get('return') || ''; + const tripType = (searchParams.get('tripType') || 'round_trip') as TripType; + const cabinClass = (searchParams.get('cabinClass') || 'economy') as CabinClass; + const passengers: Passengers = { + adults: Number(searchParams.get('adults') || 1), + children: Number(searchParams.get('children') || 0), + infants: Number(searchParams.get('infants') || 0), + }; + const originDisplay = searchParams.get('originDisplay') || origin; + const destinationDisplay = searchParams.get('destinationDisplay') || destination; + + // Search on mount and param changes + const doSearch = useCallback(() => { + if (!origin || !destination || !departureDate) return; + search({ + tripType, origin, destination, departureDate, + returnDate: returnDate || undefined, + passengers, cabinClass, filters, sortBy, + }); + }, [origin, destination, departureDate, returnDate, tripType, cabinClass, + passengers.adults, passengers.children, passengers.infants, + filters, sortBy, search]); + + useEffect(() => { doSearch(); }, [doSearch]); + + // Client-side filter + sort the results + const filteredFlights = useMemo(() => { + let flights = showReturn ? returnFlights : outboundFlights; + + // Client-side additional filtering for dynamic filter panel changes + // (Backend already filters, but we re-filter for instant UI updates) + if (filters.max_stops !== null && filters.max_stops !== undefined) { + flights = flights.filter(f => f.stops <= filters.max_stops!); + } + if (filters.max_price) { + flights = flights.filter(f => f.price_usd <= filters.max_price!); + } + if (filters.airlines && filters.airlines.length > 0) { + const set = new Set(filters.airlines); + flights = flights.filter(f => f.segments.some(s => set.has(s.airline_code))); + } + if (filters.departure_time_min) { + const [h, m] = filters.departure_time_min.split(':').map(Number); + const min = h * 60 + m; + flights = flights.filter(f => { + const d = new Date(f.departure); + return d.getHours() * 60 + d.getMinutes() >= min; + }); + } + if (filters.departure_time_max) { + const [h, m] = filters.departure_time_max.split(':').map(Number); + const max = h * 60 + m; + flights = flights.filter(f => { + const d = new Date(f.departure); + return d.getHours() * 60 + d.getMinutes() <= max; + }); + } + + // Sort + if (sortBy === 'cheapest') { + flights = [...flights].sort((a, b) => a.price_usd - b.price_usd); + } else if (sortBy === 'fastest') { + flights = [...flights].sort((a, b) => a.total_duration_minutes - b.total_duration_minutes); + } else { + const maxP = Math.max(...flights.map(f => f.price_usd), 1); + const maxD = Math.max(...flights.map(f => f.total_duration_minutes), 1); + flights = [...flights].sort((a, b) => + (a.price_usd / maxP * 0.6 + a.total_duration_minutes / maxD * 0.4) + - (b.price_usd / maxP * 0.6 + b.total_duration_minutes / maxD * 0.4) + ); + } + + return flights; + }, [outboundFlights, returnFlights, showReturn, filters, sortBy]); + + const hasFilters = filters.max_stops !== null || !!filters.max_price || !!filters.airlines + || !!filters.departure_time_min || !!filters.departure_time_max; + + function handleNewSearch(data: SearchFormData) { + const params = new URLSearchParams({ + origin: data.origin, + destination: data.destination, + departure: data.departureDate, + tripType: data.tripType, + cabinClass: data.cabinClass, + adults: String(data.passengers.adults), + children: String(data.passengers.children), + infants: String(data.passengers.infants), + originDisplay: data.originDisplay, + destinationDisplay: data.destinationDisplay, + }); + if (data.tripType === 'round_trip') { + params.set('return', data.returnDate); + } + setSearchParams(params); + setFilters(EMPTY_FILTERS); + setSortBy('best'); + setShowReturn(false); + } + + return ( +
+ {/* Collapsed search form */} +
+
+ +
+
+ +
+ {/* Round trip toggle */} + {tripType === 'round_trip' && searched && returnFlights.length > 0 && ( +
+ + +
+ )} + +
+ {/* Filters sidebar */} + {searched && outboundFlights.length > 0 && ( + + )} + + {/* Main content */} +
+ {loading && } + + {error && ( +
+ {error} +
+ )} + + {searched && !loading && !error && ( + <> +
+ +
+ + {filteredFlights.length === 0 ? ( + setFilters(EMPTY_FILTERS)} /> + ) : ( +
+ {filteredFlights.map(flight => ( + + ))} +
+ )} + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bf4cbc9447cd6b94fbf04a3cf106d0088d017862 --- /dev/null +++ b/frontend/src/pages/SearchPage.tsx @@ -0,0 +1,43 @@ +import { useNavigate } from 'react-router-dom'; +import SearchForm from '../components/search/SearchForm'; +import type { SearchFormData } from '../components/search/SearchForm'; + +export default function SearchPage() { + const navigate = useNavigate(); + + function handleSearch(data: SearchFormData) { + const params = new URLSearchParams({ + origin: data.origin, + destination: data.destination, + departure: data.departureDate, + tripType: data.tripType, + cabinClass: data.cabinClass, + adults: String(data.passengers.adults), + children: String(data.passengers.children), + infants: String(data.passengers.infants), + originDisplay: data.originDisplay, + destinationDisplay: data.destinationDisplay, + }); + if (data.tripType === 'round_trip') { + params.set('return', data.returnDate); + } + navigate(`/results?${params.toString()}`); + } + + return ( +
+
+ {/* Hero */} +
+

Flights

+

Find the best deals on flights

+
+ + {/* Search Form */} +
+ +
+
+
+ ); +} diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts new file mode 100644 index 0000000000000000000000000000000000000000..a507a58df52be68a0ba1726ff33055447ff7ac39 --- /dev/null +++ b/frontend/src/utils/date.ts @@ -0,0 +1,32 @@ +export function toDateString(d: Date): string { + return d.toISOString().split('T')[0]; +} + +export function addDays(d: Date, n: number): Date { + const result = new Date(d); + result.setDate(result.getDate() + n); + return result; +} + +export function getDefaultDepartureDate(): string { + return toDateString(addDays(new Date(), 14)); +} + +export function getDefaultReturnDate(): string { + return toDateString(addDays(new Date(), 21)); +} + +export function getDaysInMonth(year: number, month: number): number { + return new Date(year, month, 0).getDate(); +} + +export function getFirstDayOfMonth(year: number, month: number): number { + return new Date(year, month - 1, 1).getDay(); +} + +export function formatMonthYear(year: number, month: number): string { + return new Date(year, month - 1).toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + }); +} diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000000000000000000000000000000000000..803dfaaaf9ad3903f449a18d8617b5bded621dc7 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,54 @@ +export function formatPrice(usd: number): string { + return `$${Math.round(usd).toLocaleString()}`; +} + +export function formatDuration(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h === 0) return `${m}m`; + if (m === 0) return `${h}h`; + return `${h}h ${m}m`; +} + +export function formatTime(isoString: string): string { + const d = new Date(isoString); + return d.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); +} + +export function formatDate(isoString: string): string { + const d = new Date(isoString); + return d.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }); +} + +export function formatStops(stops: number): string { + if (stops === 0) return 'Nonstop'; + if (stops === 1) return '1 stop'; + return `${stops} stops`; +} + +export function cabinClassLabel(cls: string): string { + const labels: Record = { + economy: 'Economy', + premium_economy: 'Premium Economy', + business: 'Business', + first: 'First', + }; + return labels[cls] || cls; +} + +export function tripTypeLabel(t: string): string { + const labels: Record = { + round_trip: 'Round trip', + one_way: 'One way', + multi_city: 'Multi-city', + }; + return labels[t] || t; +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000000000000000000000000000000000000..a9b5a59ca647ca0102d2501b56cedd83ad7b6818 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..1ffef600d959ec9e396d5a260bd3f5b927b2cef8 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000000000000000000000000000000000000..8a67f62f4ceebff3424e6e8cd4b3c25b17347546 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..7bca0fa1ed1f9341381091eb994852b0aa06d5f5 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + proxy: { + '/api': 'http://localhost:8080', + }, + }, +})