Skip to main content

sqlite_graphrag/commands/enrich/
args.rs

1//! CLI argument types for the `enrich` subcommand.
2//! Extracted from mod.rs (Wave C1).
3
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6
7use super::DEFAULT_BODY_ENRICH_MAX_CHARS;
8use super::DEFAULT_BODY_ENRICH_MIN_CHARS;
9use super::DEFAULT_ENRICH_CIRCUIT_BREAKER_THRESHOLD;
10use super::DEFAULT_ENRICH_GROUNDING_THRESHOLD;
11use super::DEFAULT_ENRICH_MAX_ATTEMPTS;
12use super::DEFAULT_ENRICH_PRESERVE_THRESHOLD;
13use super::DEFAULT_ENRICH_RATE_LIMIT_BUFFER_SECS;
14use super::DEFAULT_ENRICH_STALE_CLAIM_SECS;
15use crate::constants::{DEFAULT_ENRICH_REST_CONCURRENCY, DEFAULT_OPENROUTER_CHAT_TIMEOUT_SECS};
16
17// ---------------------------------------------------------------------------
18// CLI args
19// ---------------------------------------------------------------------------
20
21/// Operation to perform in the `enrich` command.
22#[derive(Debug, Clone, PartialEq, Eq, clap::ValueEnum, Serialize, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum EnrichOperation {
25    /// Add missing entity/relationship bindings to memories (fully implemented).
26    /// memory-bindings LINKS each memory to the EXISTING entities extracted from
27    /// its body — it does not invent a new graph, it only connects what is missing. Scans
28    /// only UNBOUND memories (those with zero `memory_entities`).
29    MemoryBindings,
30    /// GAP-SG-24/26: additive augmentation — re-run binding extraction over
31    /// memories that are ALREADY bound, filtered by `--names`/`--names-file`, to
32    /// merge newly-discovered entities/relationships WITHOUT removing existing
33    /// links. Requires a name filter (refuses to re-scan the whole namespace).
34    AugmentBindings,
35    /// Fill NULL/empty entity descriptions with LLM-generated summaries (fully implemented).
36    EntityDescriptions,
37    /// Expand short memory bodies into richer content (fully implemented, GAP-18).
38    BodyEnrich,
39    /// Rebuild missing memory embeddings without rewriting the memory body.
40    ReEmbed,
41    /// Calibrate relationship weights using LLM analysis (fully implemented; persists weight).
42    WeightCalibrate,
43    /// Reclassify relationship types using LLM judgment (fully implemented; persists relation).
44    RelationReclassify,
45    /// Connect isolated entities by suggesting and persisting new relationships (fully implemented).
46    EntityConnect,
47    /// Validate entity type assignments using LLM judgment (fully implemented; persists type).
48    EntityTypeValidate,
49    /// Enrich memory descriptions that are generic/auto-generated (fully implemented; persists description).
50    DescriptionEnrich,
51    /// Identify cross-domain bridges between disconnected subgraphs.
52    /// Shares the O(k) pair scan + `entity_connect_seen` drain path with
53    /// `entity-connect` (v1.1.04+ / v1.1.06); status backlog proxy remains 0.
54    CrossDomainBridges,
55    /// Classify memories into domain categories (fully implemented; persists metadata).
56    DomainClassify,
57    /// Audit the graph for quality issues (scan/report; does not mutate graph structure).
58    GraphAudit,
59    /// Synthesize deep-research findings into graph memories (fully implemented when bindings persist).
60    DeepResearchSynth,
61    /// Extract structured body from unstructured text (fully implemented; persists body).
62    BodyExtract,
63}
64
65/// v1.1.1 (P2): which embedding table the `re-embed` operation backfills.
66///
67/// `memories` is the historical behaviour (and the default, so existing
68/// invocations are unchanged); `entities` and `chunks` close the retroactive
69/// coverage gap for `entity_embeddings` / `chunk_embeddings`; `all` runs the
70/// three scans in one invocation.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Serialize, Deserialize)]
72#[serde(rename_all = "kebab-case")]
73pub enum ReEmbedTarget {
74    /// Memories without a live vector in `memory_embeddings` (default).
75    Memories,
76    /// Entities without a live vector in `entity_embeddings`.
77    Entities,
78    /// Chunks without a live vector in `chunk_embeddings`.
79    Chunks,
80    /// All three targets in a single run.
81    All,
82}
83
84/// LLM provider for enrichment.
85#[derive(Debug, Clone, PartialEq, Eq, clap::ValueEnum)]
86pub enum EnrichMode {
87    /// Use the OpenRouter chat-completions REST API (no local CLI; v1.0.95).
88    #[value(name = "openrouter")]
89    OpenRouter,
90}
91
92impl std::fmt::Display for EnrichMode {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            EnrichMode::OpenRouter => write!(f, "openrouter"),
96        }
97    }
98}
99
100/// Arguments for the `enrich` subcommand.
101#[derive(clap::Args)]
102#[command(
103    about = "Enrich graph memories and entities using an LLM provider",
104    after_long_help = "EXAMPLES:\n  \
105    # Add missing entity bindings to all unbound memories\n  \
106    sqlite-graphrag enrich --operation memory-bindings --mode openrouter \\\n    \
107      --openrouter-model deepseek/deepseek-v4-flash:nitro\n\n  \
108    # Fill entity descriptions (dry-run preview, no tokens spent)\n  \
109    sqlite-graphrag enrich --operation entity-descriptions --dry-run --json\n\n  \
110    # Expand short memory bodies (GAP-18)\n  \
111    sqlite-graphrag enrich --operation body-enrich --min-output-chars 600\n\n  \
112    # Rebuild only missing memory embeddings without rewriting bodies\n  \
113    sqlite-graphrag enrich --operation re-embed --limit 100\n\n  \
114    # Resume an interrupted body-enrich run\n  \
115    sqlite-graphrag enrich --operation body-enrich --resume --json\n\n  \
116    # Retry only failed items from a previous run\n  \
117    sqlite-graphrag enrich --operation memory-bindings --retry-failed --json\n\n  \
118    # Converge the whole backlog (internal scan+drain loop, no bash wrapper)\n  \
119    sqlite-graphrag enrich --operation memory-bindings --mode openrouter \\\n    \
120      --openrouter-model deepseek/deepseek-v4-flash:nitro --until-empty --max-runtime 600\n\n  \
121    # Inspect / resurrect dead-letter items\n  \
122    sqlite-graphrag enrich --operation memory-bindings --list-dead\n  \
123    sqlite-graphrag enrich --operation memory-bindings --requeue-dead\n\n  \
124    # Read-only status (no LLM, no singleton)\n  \
125    sqlite-graphrag enrich --operation memory-bindings --status\n\n\
126    OPERATIONS NOTE:\n  \
127    memory-bindings LINKS each memory to the EXISTING entities extracted from its\n  \
128    body — it does not invent a new graph, it connects what is missing. It scans\n  \
129    only UNBOUND memories. To re-run extraction over ALREADY-bound memories and\n  \
130    MERGE newly-found entities/relationships additively (without removing links),\n  \
131    use --operation augment-bindings with --names/--names-file.\n\n\
132    DEAD-LETTER SIDECAR (.enrich-queue.sqlite):\n  \
133    A SQLite sidecar tracks each work item across runs. Schema (table `queue`):\n  \
134    item_key (UNIQUE name/id), item_type (memory|entity), operation, memory_id,\n  \
135    status (pending|processing|done|skipped|dead), attempt, error, error_class,\n  \
136    next_retry_at (backoff cooldown). --until-empty loops scan→drain internally\n  \
137    until eligible items are exhausted; transient failures (incl. malformed/non-\n  \
138    JSON LLM output, GAP-SG-09) reschedule with backoff until --max-attempts, then\n  \
139    land in status='dead'. Use --status to see the queue, --list-dead to inspect\n  \
140    the sink, --requeue-dead to retry it, and --ignore-backoff to skip cooldowns.\n  \
141    --names/--names-file also remedy a cooldown by targeting a specific subset.\n\n\
142    EXIT CODES:\n  \
143    0  success\n  \
144    1  validation error (bad args, binary not found)\n  \
145    14 I/O error"
146)]
147#[derive(Clone)]
148pub struct EnrichArgs {
149    /// Enrichment operation to run. Required for write operations; optional for
150    /// the read-only queue inspectors (`--status` / `--list-dead` /
151    /// `--requeue-dead`), where it defaults to `memory-bindings` when omitted
152    /// (GAP-SG-31).
153    #[arg(
154        long,
155        short = 'o',
156        value_enum,
157        value_name = "OPERATION",
158        // GAP-CLI-DRY-01: dry-run is read-only (no LLM); still needs --operation.
159        // R-AN-01: --print-schema exits before any work.
160        required_unless_present_any = ["status", "list_dead", "requeue_dead", "list_skipped", "requeue_skipped", "prune_dead_orphans", "prune_dead_entity_orphans", "print_schema"]
161    )]
162    pub operation: Option<EnrichOperation>,
163
164    /// LLM provider to use. Required for write operations; not needed for the
165    /// read-only queue inspectors (`--status` / `--list-dead` /
166    /// `--requeue-dead` / `--list-skipped` / `--requeue-skipped`), which never
167    /// call the LLM (GAP-SG-31).
168    ///
169    /// GAP-CLI-DRY-01 (v1.1.8): also optional for `--dry-run` (preview only).
170    ///
171    /// When omitted on a path that allows it, the resolved provider is
172    /// `openrouter`. That default is deliberate: it is a REST call and never
173    /// spawns a headless CLI (GAP-HEADLESS-DEFAULT).
174    #[arg(
175        long,
176        value_enum,
177        required_unless_present_any = ["status", "list_dead", "requeue_dead", "list_skipped", "requeue_skipped", "prune_dead_orphans", "prune_dead_entity_orphans", "dry_run", "print_schema"]
178    )]
179    pub mode: Option<EnrichMode>,
180
181    /// Maximum number of items to process in this run. Omit for all.
182    #[arg(long, value_name = "N", value_parser = crate::parsers::parse_list_limit_range)]
183    pub limit: Option<usize>,
184
185    /// GAP-SG-185 / v1.2.4: keyset page size for the enrich SCAN phase.
186    ///
187    /// SQL row buffers and in-process candidate pages are this wide; production
188    /// enqueue walks pages without retaining the full eligible key list. The
189    /// sidecar queue still stores one row per eligible item. Precedence: this
190    /// flag > XDG `enrich.scan_page_size` > 512. Accepted range: 1..=4096
191    /// (clamped).
192    #[arg(long, value_name = "N")]
193    pub scan_page_size: Option<usize>,
194
195    /// v1.1.1 (P2): embedding table backfilled by `--operation re-embed`.
196    /// `memories` (default) preserves the historical behaviour; `entities`
197    /// and `chunks` rebuild `entity_embeddings` / `chunk_embeddings`; `all`
198    /// covers the three tables in one run. The scan also selects rows whose
199    /// stored `dim` diverges from the configured `--embedding-dim` (P10),
200    /// so legacy-dimension vectors are regenerated, not only missing ones.
201    /// Ignored (rejected) for every other operation.
202    #[arg(long, value_enum, value_name = "TARGET", default_value_t = ReEmbedTarget::Memories)]
203    pub target: ReEmbedTarget,
204
205    /// Preview items without calling the LLM (zero tokens consumed).
206    #[arg(long)]
207    pub dry_run: bool,
208
209    /// Namespace to operate on. Default: global.
210    #[arg(long)]
211    pub namespace: Option<String>,
212
213    // -- Provider flags (OpenRouter, v1.0.95) --
214    /// OpenRouter text model to use (REQUIRED with --mode openrouter; no default).
215    #[arg(long, value_name = "MODEL")]
216    pub openrouter_model: Option<String>,
217
218    /// OpenRouter API key. Prefer `config add-key --provider openrouter --from-stdin`,
219    /// which stores it at rest under XDG; this flag overrides that store for a
220    /// single invocation and is visible in the process table.
221    #[arg(long, value_name = "KEY")]
222    pub openrouter_api_key: Option<String>,
223
224    /// Timeout per item in seconds when using OpenRouter. Default: 600.
225    ///
226    /// GAP-SG-17: raised from 300 to 600 because dense bodies (close to the
227    /// ~32K-token context ceiling of the configured model) routinely take
228    /// longer than five minutes to generate via `deepseek-v4-flash:nitro`.
229    /// Raise it further for very large corpora; lower it for short snippets.
230    ///
231    // `--openrouter-timeout` USED to be declared here. It is now a GLOBAL
232    // argument on `Cli` (v1.2.3), so redeclaring it on this struct would give
233    // clap two arguments with the same id and abort at startup. The flag still
234    // accepts being written at the subcommand position — `enrich
235    // --openrouter-timeout 600` is unchanged — and the value is read through
236    // `openrouter_chat_timeout_secs()` below.
237    /// Optional OpenRouter base URL override (reserved; defaults to the public API).
238    #[arg(long, value_name = "URL")]
239    pub openrouter_base_url: Option<String>,
240
241    // -- Cost controls --
242    /// Abort when cumulative cost exceeds this USD budget (API key only; ignored for OAuth).
243    #[arg(long, value_name = "USD")]
244    pub max_cost_usd: Option<f64>,
245
246    // -- Queue controls --
247    /// Resume a previously interrupted run (skip already-done items).
248    #[arg(long)]
249    pub resume: bool,
250
251    /// Retry only items that failed in a previous run.
252    #[arg(long)]
253    pub retry_failed: bool,
254
255    /// v1.1.2 (Bug 4): reset `processing` rows whose `claimed_at` is older than
256    /// `--stale-claim-secs` (default 1800s = 30 min) back to `pending`, then exit.
257    /// Recover rows orphaned by a kill -9 mid-enrich without a full re-scan.
258    #[arg(long)]
259    pub reset_stale_claims: bool,
260
261    /// v1.1.2 (Bug 4): age threshold (seconds) for `--reset-stale-claims` and
262    /// the run-startup stale sweep. Default 1800 (30 min).
263    #[arg(long, value_name = "SECONDS", default_value_t = DEFAULT_ENRICH_STALE_CLAIM_SECS)]
264    pub stale_claim_secs: u64,
265
266    /// GAP-ENRICH-BACKLOG-CONVERGE: loop scan→drain internally until the queue
267    /// empties of eligible items or --max-runtime elapses; removes the need for
268    /// an external bash retry loop.
269    #[arg(long)]
270    pub until_empty: bool,
271
272    /// GAP-ENRICH-BACKLOG-CONVERGE: wall-clock ceiling in seconds for
273    /// --until-empty. Defaults to 3600 when omitted.
274    #[arg(long, value_name = "SECONDS")]
275    pub max_runtime: Option<u64>,
276
277    /// GAP-ENRICH-BACKLOG-CONVERGE: attempts per item before it becomes a
278    /// dead-letter (status='dead'). Range 1..=20. Default 8.
279    ///
280    /// GAP-SG-21: the default was raised from 5 to 8 because GAP-SG-09 now
281    /// reclassifies malformed / non-JSON LLM output as TRANSIENT (retryable)
282    /// rather than a permanent HardFailure. A flaky structured-output model
283    /// (e.g. deepseek-v4-flash:nitro) may emit several bad generations in a row
284    /// even after JSON repair (GAP-SG-10) recovers most of them; the extra
285    /// attempts give the backlog room to converge before an item is parked in
286    /// the dead-letter sink. Permanent faults (ProviderError, NotFound) still
287    /// dead-letter on the first attempt regardless of this value.
288    #[arg(long, value_name = "N", default_value_t = DEFAULT_ENRICH_MAX_ATTEMPTS, value_parser = clap::value_parser!(u32).range(1..=20))]
289    pub max_attempts: u32,
290
291    /// GAP-ENRICH-BACKLOG-CONVERGE: read-only mode — report queue and backlog
292    /// counts without calling the LLM or acquiring the singleton.
293    ///
294    /// Field semantics (v1.1.03 clarification):
295    /// - `scan_backlog` = candidates a fresh scan WOULD select from the database
296    ///   (REAL pending work, same WHERE predicate as the scanners).
297    /// - `queue_pending` = a COMPUTED COUNT over the sidecar queue, NOT a physical
298    ///   queue of rows to process — it stays non-zero even after a clean drain.
299    /// - `eligible_now == 0` WITH `queue_pending > 0` means COOLDOWN (rate-limit
300    ///   backoff), NOT a deadlock: items are parked on `next_retry_at`.
301    /// - `eligible_now > 0` stuck against `state: "draining"` IS a deadlock —
302    ///   stale `processing` claims hold the state. Run `--reset-stale-claims`
303    ///   to clear processing claims older than the threshold.
304    #[arg(long)]
305    pub status: bool,
306
307    /// GAP-SG-23: list every dead-letter item (status='dead') for the current
308    /// operation with its error_class, attempt count and last error message.
309    /// Read-only — no LLM, no singleton. Use it to inspect what `--requeue-dead`
310    /// would resurrect before running it.
311    #[arg(long)]
312    pub list_dead: bool,
313
314    /// GAP-SG-11/14: resurrect dead-letter items — move every `status='dead'`
315    /// row back to `pending`, zeroing `attempt`, `next_retry_at`, `error` and
316    /// `error_class`. Distinct from `--retry-failed`, which only resets the
317    /// legacy `status='failed'` rows; dead-letter rows are the terminal sink of
318    /// the v1.0.96 converge loop and are never re-selected without this flag.
319    /// No LLM call or singleton is taken — it only rewrites queue statuses.
320    #[arg(long)]
321    pub requeue_dead: bool,
322
323    /// GAP-SG-96 / G-PR-4: list every `status='skipped'` row for the operation
324    /// (preservation_failed / veto sink). Read-only — no LLM, no singleton.
325    #[arg(long)]
326    pub list_skipped: bool,
327
328    /// GAP-SG-96 / G-PR-3/4: resurrect skipped items — move every
329    /// `status='skipped'` row back to `pending`, zeroing attempt/backoff so a
330    /// lower grounding threshold or corpus fix can re-process them without SQL.
331    #[arg(long)]
332    pub requeue_skipped: bool,
333
334    /// GAP-SG-66: prune ORPHAN dead-letter rows — remove every `status='dead'`
335    /// memory row whose `item_key` (the memory name) no longer exists in the
336    /// main DB for this namespace. These are terminal "not found" failures that
337    /// `--requeue-dead` can never recover (re-processing re-fails the same way),
338    /// so they inflate `queue_dead` forever. Read-only on the main DB; deletes
339    /// only confirmed-orphan rows from the queue sidecar. Entity-keyed dead rows
340    /// are left untouched. No LLM, no singleton — like `--list-dead`.
341    #[arg(long)]
342    pub prune_dead_orphans: bool,
343
344    /// v1.1.2: prune dead ENTITY orphan rows — remove every `status='dead'`
345    /// `item_type='entity'` row from the queue sidecar. Distinct from
346    /// `--prune-dead-orphans` (memory-keyed, consults the main DB): entity dead
347    /// rows are terminal artifacts of re-extraction and have no recovery path,
348    /// so no main-DB check is needed. Required because the v1.1.1 re-embed bug
349    /// left 14680 entity-keyed dead-letter rows that the memory-scoped pruner
350    /// cannot reach. No LLM, no singleton — like `--list-dead`.
351    #[arg(long, conflicts_with = "prune_dead_orphans")]
352    pub prune_dead_entity_orphans: bool,
353
354    /// GAP-SG-16: ignore the per-item backoff cooldown (`next_retry_at`) when
355    /// selecting candidates, so items waiting on exponential backoff are
356    /// processed immediately. Use to drain a backlog whose cooldown windows are
357    /// long but the provider has recovered. Without it, `--status` reports such
358    /// items under `waiting` and they are skipped until their `next_retry_at`.
359    #[arg(long)]
360    pub ignore_backoff: bool,
361
362    /// GAP-SG-28: read-only `body-extract` — extract entities/relationships into
363    /// the graph WITHOUT rewriting (or truncating) the memory body. The default
364    /// `body-extract` restructures the stored body in place; with this flag the
365    /// body is left untouched and only graph bindings are persisted (additive,
366    /// via the same upsert path as `memory-bindings`). Ignored for every other
367    /// operation.
368    #[arg(long)]
369    pub body_extract_graph_only: bool,
370
371    /// GAP-ENRICH-BACKLOG-CONVERGE: REST concurrency for `--mode openrouter`
372    /// (clamp 1..=16). This is the ONLY flag that controls drain fan-out in
373    /// OpenRouter mode; `--llm-parallelism` is inert there.
374    #[arg(
375        long,
376        value_name = "N",
377        default_value_t = DEFAULT_ENRICH_REST_CONCURRENCY,
378        value_parser = clap::value_parser!(u32).range(
379            crate::constants::MIN_ENRICH_REST_CONCURRENCY as i64
380                ..=crate::constants::MAX_ENRICH_REST_CONCURRENCY as i64
381        )
382    )]
383    pub rest_concurrency: u32,
384
385    // -- body-enrich specific flags (GAP-18) --
386    /// Minimum output character count for body-enrich. Default: 500.
387    #[arg(long, value_name = "CHARS", default_value_t = DEFAULT_BODY_ENRICH_MIN_CHARS)]
388    pub min_output_chars: usize,
389
390    /// Maximum output character count for body-enrich. Default: 2000.
391    #[arg(long, value_name = "CHARS", default_value_t = DEFAULT_BODY_ENRICH_MAX_CHARS)]
392    pub max_output_chars: usize,
393
394    /// Accepted as a no-op: no line reads this field.
395    ///
396    /// The name promises an LLM judge that verifies the enriched body preserves
397    /// every fact of the original, and that judge was never wired. A sweep for
398    /// `preserve_check` across `src/` returns the declaration and one struct
399    /// literal that SETS it — no reader. It is doubly inert, because
400    /// `default_value_t = true` on a `bool` also means the flag cannot change
401    /// its own value.
402    ///
403    /// Kept accepted rather than removed so an existing invocation does not
404    /// start failing with exit 2, and declared here rather than described,
405    /// because a doc comment promising an effect no line produces is how this
406    /// class survives review (GAP-SG-302).
407    #[arg(
408        long,
409        default_value_t = true,
410        help = "No-op: the preservation judge is not wired; the value is never read"
411    )]
412    pub preserve_check: bool,
413
414    /// Path to a custom prompt template file for body-enrich.
415    #[arg(long, value_name = "PATH")]
416    pub prompt_template: Option<PathBuf>,
417
418    // GAP-SG-204. The help text used to end with "It applies only to the
419    // subprocess modes", which reads as a live capability sitting behind a mode
420    // the operator merely has to select. v1.2.0 deleted those backends and
421    // `EnrichMode` has had a single variant ever since, so no such selection
422    // exists. A flag that does nothing is a smaller problem than a flag that
423    // documents a way to make it work.
424    //
425    // The rationale lives in `//` and not in `///` on purpose: everything above
426    // the `#[arg]` is rendered into `--help`, and an operator reading the flag
427    // needs the rule, not the history. Release archaeology belongs in
428    // `gaps.md` and the CHANGELOG.
429    /// ALWAYS INERT; accepted only so existing scripts keep parsing.
430    ///
431    /// Use `--rest-concurrency` to size the drain fan-out. Passing this emits a
432    /// warning and changes nothing.
433    #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=32))]
434    pub llm_parallelism: Option<u32>,
435
436    // -- Output / infra --
437    /// Emit NDJSON output. Always true; flag accepted for compatibility.
438    #[arg(long)]
439    pub json: bool,
440
441    /// Database path override.
442    #[arg(long)]
443    pub db: Option<String>,
444
445    /// G30: poll for the job singleton every second for up to N seconds
446    /// when another invocation holds the lock. Default: 0 (fail fast).
447    #[arg(long, value_name = "SECONDS")]
448    pub wait_job_singleton: Option<u64>,
449
450    /// G30: force acquisition of the singleton lock by removing a stale
451    /// lock file from a previously crashed invocation. Use only when you
452    /// are certain no other `enrich`/`ingest` is running.
453    #[arg(long, default_value_t = false)]
454    pub force_job_singleton: bool,
455
456    /// Select a subset of names to enrich instead of the full candidate set.
457    /// Comma-separated, e.g. `--names a,b,c`. Semantics depend on the
458    /// operation (GAP-CLI-NAMES-01): entity-keyed ops (`entity-descriptions`,
459    /// `entity-connect`, …) treat values as **entity** names; memory-keyed
460    /// ops (`memory-bindings`, `body-enrich`, …) treat values as **memory**
461    /// names. Prefer `--entity-names` / `--memory-names` for an explicit
462    /// namespace. Empty when omitted (processes all candidates).
463    ///
464    /// GAP-SG-18: also a cooldown remedy — when `--status` shows items under
465    /// `waiting` (parked on `next_retry_at` backoff), pass the exact names here
466    /// to re-enqueue and process just that subset on the next run instead of
467    /// waiting for every cooldown to elapse. REQUIRED for `--operation
468    /// augment-bindings`, which refuses to re-scan the whole namespace.
469    #[arg(long, value_name = "NAMES", value_delimiter = ',')]
470    pub names: Vec<String>,
471
472    /// G37: read the subset of memory names from a file (one per line).
473    /// Lines starting with `#` and empty lines are ignored. Combined with
474    /// `--names` (union) when both are set.
475    #[arg(long, value_name = "PATH")]
476    pub names_file: Option<PathBuf>,
477
478    /// G35: probe the LLM provider with a 1-turn ping before processing
479    /// the batch. Aborts with a clear error if the rate-limit window is
480    /// closed (avoids burning N turns only to fail on item 1).
481    #[arg(long, default_value_t = false)]
482    pub preflight_check: bool,
483
484    /// G35: number of seconds before the OAuth rate-limit reset at which
485    /// the preflight probe should refuse to start. Default 300 (5 min).
486    #[arg(long, value_name = "SECONDS", default_value_t = DEFAULT_ENRICH_RATE_LIMIT_BUFFER_SECS)]
487    pub rate_limit_buffer: u64,
488
489    /// G28-D: refuse to start when the 1-minute load average exceeds
490    /// `2 × ncpus` (or XDG `system.max_load_per_ncpu` if set).
491    /// Set to false to skip the check on contended runners.
492    ///
493    /// The key is `system.` and not `enrich.`: this help named a key that the
494    /// registry never declared, so the documented `config set` answered exit 1
495    /// with a did-you-mean pointing at the real one.
496    ///
497    /// "Set to false" above was unreachable until v1.2.8: `default_value_t =
498    /// true` on a `bool` gives `ArgAction::SetTrue` over a default that is
499    /// already `true`, so this flag alone cannot turn the check off.
500    /// `--no-max-load-check` is what does, following the `--auto-describe` pair
501    /// in `ingest` (GAP-SG-302).
502    #[arg(long, default_value_t = true, overrides_with = "no_max_load_check")]
503    pub max_load_check: bool,
504    /// Skip the load-average refusal on a contended runner.
505    #[arg(
506        long = "no-max-load-check",
507        default_value_t = false,
508        help = "Start even when the 1-minute load average is above the ceiling"
509    )]
510    pub no_max_load_check: bool,
511
512    /// G28-D: when the system is saturated, abort the job after this
513    /// many consecutive HardFailure outcomes. Default 5.
514    #[arg(long, value_name = "N", default_value_t = DEFAULT_ENRICH_CIRCUIT_BREAKER_THRESHOLD)]
515    pub circuit_breaker_threshold: u32,
516
517    /// G29 Step 4: minimum trigram-Jaccard similarity between the
518    /// original body and the LLM-rewritten body for the rewrite to be
519    /// accepted. Scores below the threshold are rejected and emitted as
520    /// `EnrichItemResult::PreservationFailed`. Default 0.7 (per the G29
521    /// gap specification). Ignored when `--operation` is not
522    /// `body-enrich`.
523    #[arg(long, value_name = "FLOAT", default_value_t = DEFAULT_ENRICH_PRESERVE_THRESHOLD)]
524    pub preserve_threshold: f64,
525
526    /// GAP-CLI-ED-03: minimum grounding coverage of an entity description
527    /// against linked memory bodies. Uses trigram coverage (`|A∩B|/|A|`),
528    /// not symmetric Jaccard, because descriptions are short. Only applies to
529    /// `entity-descriptions`.
530    ///
531    /// Precedence: this flag, then XDG
532    /// `enrich.entity_description.grounding_threshold`, then the compiled
533    /// `DEFAULT_ENRICH_GROUNDING_THRESHOLD`. Read through the accessor of the
534    /// same name, never as a bare field.
535    ///
536    /// The constant is deliberately NOT an intra-doc link here: this field is
537    /// `pub` while the constant is `pub(crate)`, and `[lints.rustdoc]` denies
538    /// `private_intra_doc_links` since GAP-SG-211.
539    ///
540    /// Zero is a LITERAL zero — it accepts every candidate. It used to mean
541    /// "use the compiled default", and that sentinel was the defect: a
542    /// `default_value_t` here meant clap always supplied a value, so the branch
543    /// reading the compiled constant only ran when an operator typed `0`
544    /// explicitly. Raising the constant therefore changed nothing in
545    /// production, and the XDG key had no reader at all.
546    #[arg(long, value_name = "FLOAT")]
547    pub entity_description_grounding_threshold: Option<f64>,
548
549    /// GAP-CLI-ED-06 / CAPA-B: re-scan entities whose description is empty OR
550    /// matches high-precision low-quality compound markers (e.g. "is a software
551    /// component", "is a configuration file" — not bare domain phrases).
552    /// Once per invocation, reopens matching `skipped`/`done` queue rows for
553    /// those scan keys to `pending` (does not reopen `dead`; use
554    /// `--requeue-dead`). Default false (write-once for non-empty descriptions).
555    #[arg(long, default_value_t = false)]
556    pub force_redescribe: bool,
557
558    /// Wave 2 / GAP-CLI-OBS-04: sample N entities with descriptions and report
559    /// `quality_pct` / `scan_backlog_low_grounding_est` on `--status`.
560    /// `scan_backlog_low_grounding_est` is a **sample-based estimate only** —
561    /// it is not a drain backlog and is not processed by `--force-redescribe`.
562    /// Precedence: this flag > XDG `enrich.entity_description.quality_sample`
563    /// > default 50. Set 0 to disable sampling.
564    #[arg(long, value_name = "N", value_parser = crate::parsers::parse_quality_sample_range)]
565    pub quality_sample: Option<usize>,
566
567    /// GAP-CLI-NAMES-02: explicit entity name filter (entity-descriptions,
568    /// entity-connect, …). Alias of `--names` for entity-keyed ops.
569    #[arg(long, value_name = "NAMES", value_delimiter = ',')]
570    pub entity_names: Vec<String>,
571
572    /// Restrict an entity-keyed operation to entities carrying this type label.
573    ///
574    /// v1.2.8: `entity-type-validate` scanned the whole namespace at random,
575    /// with no way to aim it. That is the operation which can propose a better
576    /// label for the entities the closed vocabulary collapsed into `concept`,
577    /// and without a filter the only way to reach them was to pay for every
578    /// entity in the graph. `--entity-type concept` aims it at exactly the
579    /// bucket that needs revisiting.
580    ///
581    /// Free text, matched literally against the stored label, because the
582    /// vocabulary is open since V017 and a closed value set here would refuse
583    /// the very labels this flag exists to look for.
584    #[arg(long, value_name = "LABEL")]
585    pub entity_type: Option<String>,
586
587    /// GAP-SG-283: entity type labels this project accepts, comma separated.
588    ///
589    /// `remember --strict-entity-types` and `link --strict-relations` already
590    /// let a project declare its vocabulary on the hand-written write channels.
591    /// `enrich` declared nothing, and it is the channel that writes type labels
592    /// in VOLUME: `entity-type-validate` persists whatever the model returns.
593    ///
594    /// Precedence: this flag > XDG `enrich.entity_type.allowed_types` > the
595    /// canonical vocabulary compiled into the binary. An empty declaration
596    /// falls through to the next layer rather than refusing every label.
597    ///
598    /// Labels are shape-normalised on the way in, so `Issue-Tracker` and
599    /// `issue_tracker` declare the same member. What to do with a label outside
600    /// the set is `--on-unknown-type`.
601    #[arg(long, value_name = "LIST", value_delimiter = ',')]
602    pub allowed_types: Vec<String>,
603
604    /// GAP-SG-283: policy for a validated entity type outside `--allowed-types`.
605    ///
606    /// `keep` (the default) stores the label as written, which is byte-for-byte
607    /// the v1.2.8 behaviour — an existing caller passing no flag is unaffected.
608    /// `fallback` stores the nearest accepted label and preserves the raw one in
609    /// the entity description, so the rewrite has a declared inverse. `strict`
610    /// refuses the item with exit 1, mirroring `remember --strict-entity-types`.
611    ///
612    /// Precedence: this flag > XDG `enrich.entity_type.on_unknown_type` >
613    /// `keep`. Applies to `--operation entity-type-validate`; every other
614    /// operation ignores it.
615    ///
616    /// Parsed against the same list the policy module parses, so the flag and
617    /// the reader cannot accept different sets.
618    #[arg(
619        long,
620        value_name = "POLICY",
621        value_parser = clap::builder::PossibleValuesParser::new(super::events::UNKNOWN_TYPE_POLICIES)
622    )]
623    pub on_unknown_type: Option<String>,
624
625    /// GAP-CLI-NAMES-02: explicit memory name filter (memory-bindings,
626    /// body-enrich, …). Alias of `--names` for memory-keyed ops.
627    #[arg(long, value_name = "NAMES", value_delimiter = ',')]
628    pub memory_names: Vec<String>,
629
630    /// GAP-CLI-EC-03: limit entity-connect pair scan to the subgraph of
631    /// entities linked to this memory name.
632    #[arg(long, value_name = "NAME")]
633    pub anchor_memory: Option<String>,
634
635    /// GAP-CLI-ED-05: optional domain label for entity-descriptions
636    /// (`auto` = no hint, `none` = force neutral, or free-form e.g. `fiscal`).
637    /// Precedence: this flag > XDG `enrich.entity_description.domain` > `auto`.
638    #[arg(long, value_name = "LABEL", default_value = "auto")]
639    pub entity_description_domain: String,
640
641    /// GAP-CLI-PRIO-05: cooperative yield every N processed items so hot-set
642    /// entity-descriptions can preempt long entity-connect drains. 0 disables.
643    /// XDG key: `enrich.yield_every_n_items` (default 10).
644    #[arg(long, value_name = "N")]
645    pub yield_every_n_items: Option<usize>,
646
647    /// GAP-CLI-OBS-02 / PRIO-04: run gate ops in order
648    /// memory-bindings → entity-descriptions (before entity-connect).
649    #[arg(long, default_value_t = false)]
650    pub ops_gate: bool,
651
652    /// Emit the JSON Schema for `enrich --status` stdout and exit 0 without
653    /// opening the database or calling the LLM (agent-native R-AN-01).
654    #[arg(
655        long,
656        default_value_t = false,
657        help = "Print JSON Schema for enrich --status output and exit"
658    )]
659    pub print_schema: bool,
660}
661
662impl EnrichArgs {
663    /// GAP-SG-31: resolved enrichment operation.
664    ///
665    /// `operation` is `Option` so the read-only queue inspectors
666    /// (`--status` / `--list-dead` / `--requeue-dead`) can run without it.
667    /// Write paths always carry a value (enforced by
668    /// `required_unless_present_any` at parse time); the read-only paths fall
669    /// back to `memory-bindings`, the most common queue, when it is omitted.
670    pub(crate) fn operation(&self) -> EnrichOperation {
671        self.operation
672            .clone()
673            .unwrap_or(EnrichOperation::MemoryBindings)
674    }
675
676    /// GAP-SG-31: resolved LLM provider.
677    ///
678    /// `mode` is `Option` because clap does not require it for the read-only
679    /// inspectors, nor for `--dry-run` since GAP-CLI-DRY-01 (v1.1.8). Every
680    /// WRITE path still carries a value, enforced by `required_unless_present_any`
681    /// at parse time — omitting `--mode` on a write run exits 2.
682    ///
683    /// The fallback is [`EnrichMode::OpenRouter`], and that choice is load-bearing
684    /// rather than arbitrary: GAP-HEADLESS-DEFAULT requires that an omitted
685    /// `--mode` never reach a provider that spawns a headless CLI, and OpenRouter
686    /// is a REST call that spawns nothing. Do not change it to a subprocess mode.
687    ///
688    /// Note this doc previously claimed the fallback was "only ever observed by
689    /// read-only code". That stopped being true in v1.1.8 when `--dry-run` joined
690    /// the exemption list; dry-run observes it too, and is safe for a different
691    /// reason — binary resolution is skipped before the mode is inspected.
692    pub(crate) fn mode(&self) -> EnrichMode {
693        self.mode.clone().unwrap_or(EnrichMode::OpenRouter)
694    }
695
696    /// Effective chat-completion budget, in the documented precedence:
697    /// `--openrouter-timeout` > XDG `llm.openrouter_timeout_secs` >
698    /// [`DEFAULT_OPENROUTER_CHAT_TIMEOUT_SECS`].
699    ///
700    /// Reads through `runtime_config` rather than off this struct because the
701    /// flag is GLOBAL since v1.2.3 and therefore no longer lives here. The
702    /// middle layer is the part that used to be unreachable: with the value
703    /// read straight off the enum variant, the XDG key could never win, so an
704    /// operator who set it saw no effect and no diagnostic.
705    pub(crate) fn openrouter_chat_timeout_secs(&self) -> u64 {
706        crate::runtime_config::openrouter_chat_timeout_secs(DEFAULT_OPENROUTER_CHAT_TIMEOUT_SECS)
707    }
708
709    /// GAP-SG-185: resolved keyset page size for the SCAN phase.
710    ///
711    /// Precedence: `--scan-page-size` > XDG `enrich.scan_page_size` > default 512.
712    pub(crate) fn scan_page_size(&self) -> usize {
713        crate::runtime_config::enrich_scan_page_size(self.scan_page_size)
714    }
715
716    /// G-PR-7: resolved minimum grounding coverage for entity descriptions.
717    ///
718    /// Precedence: `--entity-description-grounding-threshold` > XDG
719    /// `enrich.entity_description.grounding_threshold` >
720    /// [`DEFAULT_ENRICH_GROUNDING_THRESHOLD`]. This is the ONLY reader of that
721    /// XDG key; the key was listed as a dead configuration channel until this
722    /// accessor existed.
723    pub(crate) fn entity_description_grounding_threshold(&self) -> f64 {
724        crate::runtime_config::resolve_f64(
725            self.entity_description_grounding_threshold,
726            "enrich.entity_description.grounding_threshold",
727            DEFAULT_ENRICH_GROUNDING_THRESHOLD,
728        )
729    }
730}