## Scaling & Hardcoded Limits

#### [SHL-V1.30-1]: CAGRA `itopk_size = (k * 2).clamp(itopk_min, itopk_max)` can produce `itopk_size < k` on small indexes — silent zero-result regression
- **Difficulty:** medium
- **Location:** `src/cagra.rs:359` (computation), `:166-170` (`cagra_itopk_max_default`)
- **Description:** cuVS CAGRA requires `itopk_size >= k` as a hard constraint — when violated, the call to `params.set_itopk_size(itopk_size)` followed by `Index::search` is the historical cause of the documented `topk=500 > itopk_size=480` failure mode (per `MEMORY.md`: "CAGRA fails at limit≥100 via `topk=500 > itopk_size=480` — keep eval at limit=20"). The current code computes `itopk_size = (k * 2).clamp(itopk_min, itopk_max)` where `itopk_max = (log2(n_vectors) * 32).clamp(128, 4096)`. For a small index (n_vectors = 1000 → itopk_max ≈ 320) and a user request of `k = 500` (e.g., `cqs search --limit 500`), the computation becomes `(1000).clamp(128, 320) = 320 < 500 = k`. The constraint is violated, no guard is present, and the eval-time workaround ("keep eval at limit=20") is the only thing protecting users from this. There is no `if itopk_size < k { fall back to HNSW }` branch, no error to the caller, and no clamp on `k` itself. Anyone wiring `cqs search --rerank --limit 200` (legitimate to give the reranker a 200-candidate pool) on a corpus that hasn't grown past ~13k chunks will silently produce undefined cuVS behavior.
- **Suggested fix:** Either (a) clamp `itopk_size = itopk_size.max(k)` after the existing clamp, then re-check that the result `<= itopk_max` and degrade to HNSW above that (cleanly returning a typed error from `search_impl`), or (b) clamp `k` itself to `itopk_max - 1` at search-entry and surface a `tracing::warn!` so the caller knows the limit was reduced. A `// CONSTRAINT: itopk_size >= k (cuVS hard requirement)` comment on the constant declaration would prevent regressions during refactors.

#### [SHL-V1.30-2]: `nl::generate_nl_with_template` char_budget defaults to 512 even when the active model has `max_seq_length=2048` — nomic-coderank silently truncates at 25% of capacity
- **Difficulty:** easy
- **Location:** `src/nl/mod.rs:222-229`
- **Description:** Section-chunk NL generation reads `CQS_MAX_SEQ_LENGTH` once into a `OnceLock` with default 512: `let max_seq = *MAX_SEQ.get_or_init(|| std::env::var("CQS_MAX_SEQ_LENGTH").ok().and_then(|v| v.parse().ok()).unwrap_or(512));`. The actual model's `max_seq_length` is encoded in `ModelConfig` (E5/v9-200k/BGE: 512; **nomic-coderank: 2048** per `embedder/models.rs:366`). Switching to nomic-coderank via preset/config without setting `CQS_MAX_SEQ_LENGTH` silently caps the per-section content preview at ~1800 chars instead of ~7800 chars — every section chunk gets truncated to 23% of what the model can accept. The comment at line 220-221 even says "Larger models (8192 → ~32000 chars) get more context", acknowledging the relationship but not wiring it through `ModelConfig`. This is the same "convenience wrapper hardcoded 768-dim while default model was 1024" pattern flagged in `MEMORY.md` as a historical bug.
- **Suggested fix:** Plumb the active `Embedder`'s `model_config.max_seq_length` into `generate_nl_with_template` (it's currently a free function with no embedder access). Either pass it as an argument from the caller in `pipeline/parsing.rs`, or move section-NL into an `Embedder::generate_nl` method. The env var should be a fallback override, not the source of truth.

#### [SHL-V1.30-3]: `MAX_BATCH_SIZE = 10_000` in LLM module hardcoded — silently truncates summary/HyDE passes on large corpora
- **Difficulty:** easy
- **Location:** `src/llm/mod.rs:192`, used at `summary.rs:58,92`, `hyde.rs:39-41`, `doc_comments.rs:271`
- **Description:** `const MAX_BATCH_SIZE: usize = 10_000;` is the per-pass cap on Anthropic Batches API submissions. For cqs's own ~17k-chunk corpus this fits in one pass; for any monorepo with >10k callable chunks the user gets a silent truncation — `summary.rs:92-97` does emit a `tracing::info!("Batch size limit reached, submitting partial batch")` but downstream a user must rerun the same `cqs index --improve-docs` or `cqs index --improve-summaries` repeatedly to make progress, with no flag to raise the cap and no visible "X chunks remain unprocessed" hint at CLI exit. Anthropic's actual Batches API hard limit is 100,000 requests per batch — cqs uses 10% of that. No env override, no config section, no CLI flag. With nomic-coderank/CodeRankEmbed enabling much larger corpora to be useful, this becomes a real ceiling. Also note that `hyde.rs:41` also uses this for HyDE expansion which has very different cost characteristics.
- **Suggested fix:** Move `MAX_BATCH_SIZE` to `src/limits.rs` with an env resolver (`CQS_LLM_MAX_BATCH_SIZE` default 10000, capped at 100000 to honor Anthropic's hard limit). Surface "X chunks remain — rerun to continue" at CLI exit when the cap is hit (today's `tracing::info!` is invisible without `RUST_LOG=info`).

#### [SHL-V1.30-4]: `serve::ABS_MAX_GRAPH_NODES = 50_000` and `ABS_MAX_CLUSTER_NODES = 50_000` hardcoded — graph/cluster views silently cap at arbitrary 25% subset of large monorepos
- **Difficulty:** easy
- **Location:** `src/serve/data.rs:17,24`
- **Description:** Both `pub(crate) const`. The cluster query (`build_cluster`) sorts by `id ASC LIMIT effective_cap` — at 200k chunks, the cluster view shows 25% of the corpus chosen by id-string lexical order, which is essentially random with respect to topology or coverage. The graph view has the same arbitrary truncation. The comment at line 14-16 ("Prevents a single unauth request from materialising the full chunks table (millions of rows)") is a SEC-3 motivation, but the *value* 50k is a guess, not derived from anything (RAM ceiling, JSON serialization budget, browser rendering capacity). No env override; no config; no `?max_nodes` ceiling for trusted operators. A user running `cqs serve` on a 500k-chunk monorepo opens the graph view and sees an unspecified slice with no warning. Cytoscape's practical render ceiling is around 5k-10k nodes anyway, so 50k is too high for the UI side and too low for a "show me everything" power-user query.
- **Suggested fix:** Keep the security cap, but add `CQS_SERVE_MAX_GRAPH_NODES` and `CQS_SERVE_MAX_CLUSTER_NODES` env overrides (with a hard ceiling, e.g., `1_000_000`, that even env can't exceed). Better: derive from `chunk_count` so small projects ship the whole graph and large projects ship the top-N by `n_callers_global` (already an indexed column). The graph endpoint also needs a documented "show me a focused subgraph around node X" mode for monorepos.

#### [SHL-V1.30-5]: `build_chunk_detail` callers/callees/tests `LIMIT 50/50/20` hardcoded SQL constants — silent truncation in the chunk-detail UI on hot functions
- **Difficulty:** easy
- **Location:** `src/serve/data.rs:505,542,571`
- **Description:** Three SQL queries inside `build_chunk_detail` — `callers_rows` (`LIMIT 50`), `callees_rows` (`LIMIT 50`), `tests_rows` (`LIMIT 20`) — pin the chunk-detail sidebar to fixed truncation. A heavily-called function on a large corpus (e.g., `Store::query` with 200+ callers) shows the first 50 by `(origin, line_start)` and silently drops the rest with no "showing 50 of 247" indicator. The 20-test cap is even more painful for popular utilities — `cqs::Embedding::new` may have 100+ test references. No env override, no SQL parameter binding, no JSON `truncated: true` flag in the response. Inconsistent with `build_graph` which honors `max_nodes` from the request.
- **Suggested fix:** Bind these as `?` parameters (already done via `LIMIT ?` for `build_graph`, just not here), accept `?max_callers` / `?max_callees` / `?max_tests` query params, and emit `truncated: true` in the response when the cap is hit. Source the defaults from `src/limits.rs` so the same value drives the UI, the test, and any future CLI consumer.

#### [SHL-V1.30-6]: `embed_batch_size()` default 64 doesn't scale with model dim or available VRAM — RTX 4060 OOMs while A6000 idles
- **Difficulty:** medium
- **Location:** `src/cli/pipeline/types.rs:143-160`, `src/embedder/mod.rs:685-689`
- **Description:** Embedding batch size defaults to 64 regardless of model (768-dim E5 vs 1024-dim BGE-large), regardless of `max_seq_length` (512 BGE vs 2048 nomic), regardless of GPU VRAM. Forward-pass activations scale roughly linearly in `batch * seq_len * hidden_dim`. With BGE-large + 512 seq + 64 batch on the embedder-default ORT path: ~64 × 512 × 1024 × 4 bytes ≈ 130 MB just for one tensor — fits a 4060 8GB. With nomic-coderank + 2048 seq + 64 batch: ~512 MB per tensor × multiple intermediate states → OOM on consumer GPUs. The comment at line 139-141 even says: "Was 32 (backed off from 64 after an undiagnosed crash at 2%). Restored to 64 with debug logging" — meaning we've already had a silent crash on this exact knob, and the resolution was "raise it back and add tracing" rather than make it dim/seq-aware. The eval-only `CQS_EMBED_BATCH_SIZE` env override exists but no auto-tuning.
- **Suggested fix:** When `CQS_EMBED_BATCH_SIZE` is unset, compute `64 * (768 / model_config.dim) * (512 / model_config.max_seq_length).max(0.25)` rounded to a power of 2. Or query GPU VRAM via `nvml-wrapper` (already a transitive dep via cuVS) and target ~25% of free VRAM. At minimum, document the dim/seq sensitivity in the const's docstring so future operators know to tune.

#### [SHL-V1.30-7]: `diff::EMBEDDING_BATCH_SIZE = 1000` doesn't scale with model dim — 2.6× memory variance between presets
- **Difficulty:** easy
- **Location:** `src/diff.rs:158`
- **Description:** Comment at line 156-157: "For 20k pairs at ~12 bytes/dim * model_dim, each batch is ~9-12 MB instead of ~240 MB total." The math is correct only for ~1024-dim BGE-large. For 384-dim presets, batch is ~4.6 MB; for 1024-dim it's ~12 MB. More critically, the batch *count* scales with model dim because the 1000 figure was set assuming ~1KB/embedding. With future presets shipping 1536-dim or 2048-dim (or the common case of stacking SPLADE sparse vectors alongside dense), the same batch is 18 MB or 24 MB — still OK, but not what the comment promises. No env override. Worse: the user-visible behavior of `cqs diff` (latency, memory) silently changes when you swap the model preset, with no log or doc.
- **Suggested fix:** Compute `EMBEDDING_BATCH_SIZE = max(100, 12_000_000 / (model_dim * 12))` (target 12 MB per batch regardless of dim). Or expose `CQS_DIFF_EMBEDDING_BATCH_SIZE` env override and document the dim relationship.

#### [SHL-V1.30-8]: `CagraIndex::gpu_available()` checks only that cuVS resources can be created — no VRAM ceiling, OOMs on 8GB GPUs at ~200k chunks
- **Difficulty:** medium
- **Location:** `src/cagra.rs:262-264`
- **Description:** `pub fn gpu_available() -> bool { cuvs::Resources::new().is_ok() }` returns true on any CUDA-capable GPU. Once `chunk_count >= 5000` (the CAGRA threshold), the build path runs `Index::build(&resources, &build_params, &dataset)` where dataset is `Array2::from_shape_vec((n_vectors, dim), flat_data)`. For a 200k-chunk × 1024-dim corpus this is 200k × 1024 × 4 = 819 MB on host, then copied to GPU plus graph overhead (~64 edges × n_vectors × 4 = 51 MB) plus working memory. On an 8GB GPU shared with the embedder model (~1.3 GB BGE-large) and OS/window overhead (~1-2 GB), the build OOMs. The CPU-side `cagra_max_bytes()` (default 2GB) gates host array allocation, but doesn't model GPU VRAM at all. The user reads the `MEMORY.md` line "GPUs: A6000 48GB (training), RTX 4000 8GB (inference)" — cqs is shipped for an A6000 workstation but published to crates.io for everyone else.
- **Suggested fix:** Query free GPU VRAM via cuVS / cuda-rs / nvml at `gpu_available()` time. Compute estimated build memory (`n_vectors * dim * 4 + graph_degree * n_vectors * 4 + slack`) and return false if it exceeds 80% of free VRAM. Surface as a `tracing::warn!("GPU has X MB free, CAGRA build needs Y MB — falling back to HNSW")`. Add a `CQS_CAGRA_MAX_GPU_BYTES` override for users who want to force-try.

#### [SHL-V1.30-9]: Daemon `worker_threads = min(num_cpus, 4)` hardcoded with no env override — caps shared-runtime parallelism on large machines
- **Difficulty:** easy
- **Location:** `src/cli/watch.rs:115-119`
- **Description:** `let worker_threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1).min(4);` — the shared tokio runtime that powers Store, EmbeddingCache, and QueryCache caps at 4 worker threads. On a 24-core workstation (per `MEMORY.md`), this means the daemon's SQLx pool can only meaningfully drive 4 connections worth of concurrent work even if the SQLite pool is sized larger. The hardcoded ceiling came from "the heaviest of the three" pre-#968 default. With an 8-core laptop on battery, `min(8, 4) = 4` is fine; on a 32-core EPYC server, `min(32, 4) = 4` leaves 28 cores idle. No env override.
- **Suggested fix:** Read `CQS_DAEMON_WORKER_THREADS` (default `min(num_cpus, 4)` to preserve existing behavior). Document at the constant's docstring that this is the shared-runtime size for the daemon process specifically.

#### [SHL-V1.30-10]: `train_data::MAX_SHOW_SIZE = 50 MB` for `git show` hardcoded — silently drops large files from training-data extraction
- **Difficulty:** easy
- **Location:** `src/train_data/git.rs:167`
- **Description:** `const MAX_SHOW_SIZE: usize = 50 * 1024 * 1024;` — `git_show` returns `Ok(None)` (treated as "skip this file") when stdout exceeds 50 MB. Used during training-data extraction (referenced in `~/training-data/` per memory). Generated SQL bundles, vendored deps, large docs, and minified web assets routinely exceed 50 MB. There's no log line at the call site; the caller (`pub fn git_show -> Result<Option<String>>`) treats `None` as "binary or too large", losing the distinction. No env override, no `--max-show-size` flag. The `crate::limits` module already houses similar caps (`PARSER_MAX_FILE_SIZE`, `parser_max_file_size()`) — this one was missed.
- **Suggested fix:** Move `MAX_SHOW_SIZE` to `src/limits.rs` with `train_data_git_show_max_bytes()` reading `CQS_TRAIN_GIT_SHOW_MAX_BYTES`. Distinguish "too large" from "binary" in the return type so callers can warn-log truncations explicitly.
