Skip to main content

cortex/backend/
sandbox.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! Sandbox corpora (Arm 5) — carve a working subset out of a parent corpus by a **message-condition
9//! filter**, as a first-class corpus an agent (or human) can iterate conversion campaigns on.
10//!
11//! A sandbox is an ordinary `corpora` row with two extra columns set: `parent_corpus_id` (the
12//! corpus it was carved from) and `selection` (the filter predicate). The selection IS the
13//! provenance — the predicate applied over the parent — so no per-task origin link is kept (owner
14//! decision 2026-06-15). Sources are referenced **in place** (the sandbox shares the parent's entry
15//! paths; nothing is copied) and the carved set is a **one-time snapshot** evaluated at creation.
16//! See `docs/archive/SANDBOX_CORPORA.md`.
17
18use diesel::result::Error;
19use diesel::sql_types::Text;
20use diesel::*;
21use serde::{Deserialize, Serialize};
22
23use crate::concerns::CortexInsertable;
24use crate::helpers::TaskStatus;
25use crate::models::{Corpus, NewSandboxCorpus};
26
27/// The filter that defines a sandbox: a slice of the parent corpus addressed by independent,
28/// **intersected** task-status and message (`severity`/`category`/`what`) dimensions (Model C),
29/// plus optional entry/limit narrowing. Serialized verbatim into the sandbox corpus's `selection`
30/// JSON.
31#[derive(Clone, Debug, Serialize, Deserialize)]
32pub struct SandboxSelection {
33  /// the service whose conversion results are being filtered
34  pub service_id: i32,
35  /// optional **task-status** filter (`todo` | `no_problem` | `warning` | `error` | `fatal` |
36  /// `invalid`) — the task's status (`todo` = not yet run). Intersected with the message filter
37  /// below.
38  #[serde(default)]
39  pub status: Option<String>,
40  /// optional **message-severity** filter (`info` | `warning` | `error` | `fatal` | `invalid`) —
41  /// the carve keeps tasks that *emitted* a message of this severity (any task status).
42  /// `category`/`what` narrow within it.
43  #[serde(default)]
44  pub message_severity: Option<String>,
45  /// optional message-category narrowing (needs `message_severity`)
46  #[serde(default)]
47  pub category: Option<String>,
48  /// optional `what` narrowing within the category (needs `category`)
49  #[serde(default)]
50  pub what: Option<String>,
51  /// optional substring the parent `entry` must contain, matched at any position (`entry LIKE
52  /// '%…%'`). Include slashes for precision — `/2605/` carves exactly the arXiv month (a bare
53  /// `2605.` also matches mid-id paths like `…0302605.zip`). `None`/empty = no narrowing.
54  #[serde(default)]
55  pub entry: Option<String>,
56  /// optional hard cap on how many entries the carve captures — the first `n` by `entry` order
57  /// (deterministic). `None`/non-positive = no cap.
58  #[serde(default)]
59  pub max_entries: Option<i64>,
60  /// **Legacy** (pre-Model-C) single overloaded severity — kept only so selections stored before
61  /// the status/message split still render their provenance. Never set by new carves.
62  #[serde(default, skip_serializing_if = "Option::is_none")]
63  pub severity: Option<String>,
64}
65
66/// The message-severity → `log_*` table map (includes `info`, which has no [`TaskStatus`]). The
67/// only thing the carve interpolates into SQL, and it comes from this fixed map — never user input.
68fn message_log_table(message_severity: &str) -> Option<&'static str> {
69  match message_severity {
70    "info" => Some("log_infos"),
71    "warning" => Some("log_warnings"),
72    "error" => Some("log_errors"),
73    "fatal" => Some("log_fatals"),
74    "invalid" => Some("log_invalids"),
75    _ => None,
76  }
77}
78
79/// A validated, resolved sandbox filter: the task-status raw int to match (if any) and the message
80/// `log_*` table to look in (if any). The carve uses both; the pre-flight just checks for `Err`.
81pub struct ResolvedFilter {
82  /// raw task-status int to match `t.status` against, or `None` for "any status"
83  pub status_raw: Option<i32>,
84  /// the `log_*` table a message filter looks in, or `None` for "no message filter"
85  pub message_table: Option<&'static str>,
86}
87
88impl SandboxSelection {
89  /// A compact, human-readable summary of the carve filter — e.g.
90  /// `status=no_problem, message=error/missing_file, entry~2506., limit=100`. The single source of
91  /// truth for both the sandbox corpus's stored `description` and the provenance surfaced on its
92  /// corpus page / detail API, so the two never drift. Falls back to the legacy `severity` for
93  /// selections stored before the status/message split.
94  pub fn filter_summary(&self) -> String {
95    let mut parts: Vec<String> = Vec::new();
96    if let Some(status) = &self.status {
97      parts.push(format!("status={status}"));
98    }
99    if let Some(message_severity) = &self.message_severity {
100      let mut message = format!("message={message_severity}");
101      if let Some(category) = &self.category {
102        message.push_str(&format!("/{category}"));
103      }
104      if let Some(what) = &self.what {
105        message.push_str(&format!("/{what}"));
106      }
107      parts.push(message);
108    }
109    // Legacy stored selections only had the overloaded `severity` (+ its category/what).
110    if parts.is_empty()
111      && let Some(severity) = &self.severity
112    {
113      parts.push(format!("severity={severity}"));
114      if let Some(category) = &self.category {
115        parts.push(format!("category={category}"));
116      }
117      if let Some(what) = &self.what {
118        parts.push(format!("what={what}"));
119      }
120    }
121    if let Some(entry) = self
122      .entry
123      .as_deref()
124      .map(str::trim)
125      .filter(|e| !e.is_empty())
126    {
127      parts.push(format!("entry~{entry}"));
128    }
129    if let Some(n) = self.max_entries.filter(|n| *n > 0) {
130      parts.push(format!("limit={n}"));
131    }
132    parts.join(", ")
133  }
134
135  /// Validates the **intersecting** task-status and message filters and resolves them for the
136  /// carve, or returns a human-readable reason. They are independent dimensions (Model C):
137  /// `status` matches the task's outcome; `message_severity` (+ `category`/`what`) matches a log
138  /// message the task emitted, **at any task status** — so `info`+category collects every task
139  /// that emitted that info message. `category` needs a `message_severity`; `what` needs a
140  /// `category`; at least one of the two dimensions must be present. The **pre-flight twin** of
141  /// the carve (`start_sandbox` calls it for an immediate `422`).
142  pub fn validate(&self) -> Result<ResolvedFilter, String> {
143    let status_raw = match self.status.as_deref() {
144      Some(key) => Some(
145        TaskStatus::from_key(key)
146          .ok_or_else(|| {
147            format!(
148              "unknown task status '{key}' (use todo, no_problem, warning, error, fatal, invalid)"
149            )
150          })?
151          .raw(),
152      ),
153      None => None,
154    };
155    let message_table = match self.message_severity.as_deref() {
156      Some(key) => Some(message_log_table(key).ok_or_else(|| {
157        format!("unknown message severity '{key}' (use info, warning, error, fatal, invalid)")
158      })?),
159      None => None,
160    };
161    if message_table.is_none() && (self.category.is_some() || self.what.is_some()) {
162      return Err("category/what need a message_severity to filter on".to_string());
163    }
164    if self.what.is_some() && self.category.is_none() {
165      return Err("what needs a category".to_string());
166    }
167    if status_raw.is_none() && message_table.is_none() {
168      return Err(
169        "a sandbox needs at least a task-status or a message-severity filter".to_string(),
170      );
171    }
172    Ok(ResolvedFilter {
173      status_raw,
174      message_table,
175    })
176  }
177}
178
179/// The result of carving a sandbox: the new corpus plus how many entries it captured.
180pub struct SandboxOutcome {
181  /// the freshly-created sandbox corpus
182  pub sandbox: Corpus,
183  /// number of parent entries that matched the selection (= number of `TODO` tasks created)
184  pub entry_count: usize,
185}
186
187/// Carves a **sandbox corpus** from `parent` using `selection`, **entirely server-side**: it
188/// inserts the sandbox `corpora` row, then a single `INSERT INTO tasks (...) SELECT ... FROM tasks
189/// ...` materializes a `TODO` task per matched parent entry **without ever loading the entries into
190/// the application**. A 100k-entry carve therefore costs no client RAM and no per-row bind
191/// parameters (it sidesteps the 65535-parameter cap a client-side batch insert would hit); the
192/// whole carve is one transaction, so it is atomic (no half-built sandbox). Because the matching
193/// `SELECT` over a large parent can take minutes to an hour, this is meant to run as a **background
194/// job** (`corpus_sandbox`).
195///
196/// A severity-only selection reads `tasks` directly; a `category`/`what` narrowing joins the
197/// severity's `log_*` table (the table name comes from the fixed [`TaskStatus::to_table`] map, so
198/// it is never user-controlled; ids/`category`/`what` are bound parameters). `SELECT DISTINCT`
199/// collapses a parent task carrying several matching messages to a single carved entry — which also
200/// satisfies the `tasks` `UNIQUE(entry, service_id, corpus_id)` constraint.
201///
202/// **Output-isolation note:** the sandbox is its own `corpus_id` (own tasks, runs, reports).
203/// Running a *conversion* on it would, today, write result archives to the shared
204/// `<entry-dir>/<service>.zip` path it inherits from the parent — so isolating a sandbox's **rerun
205/// outputs** needs a follow-up (a sink output-path change), tracked in
206/// `docs/archive/SANDBOX_CORPORA.md` + `docs/KNOWN_ISSUES.md`.
207pub fn create_sandbox(
208  connection: &mut PgConnection,
209  parent: &Corpus,
210  name: &str,
211  selection: &SandboxSelection,
212) -> Result<SandboxOutcome, Error> {
213  // Validation (the intersecting status + message filters) lives in `validate`, so the carve and
214  // the `start_sandbox` pre-flight reject the identical set.
215  let ResolvedFilter {
216    status_raw,
217    message_table,
218  } = selection
219    .validate()
220    .map_err(|message| Error::QueryBuilderError(message.into()))?;
221  let selection_json = serde_json::to_value(selection).ok();
222
223  // Optional entry-substring narrowing, matched at ANY position so a filter can target any path
224  // slot (`entry LIKE '%…%'`). Include slashes for precision: `/2605/` carves exactly the arXiv
225  // month directory, whereas a bare `2605.` also matches mid-id paths like
226  // `.../cond-mat0302605/cond-mat0302605.zip` (`0302605.zip` literally contains `2605.`). Always
227  // bound (default `%` = match every entry) so the three SQL branches share one extra bind slot.
228  // Trimmed; blank = no narrowing.
229  let entry_pattern = selection
230    .entry
231    .as_deref()
232    .map(str::trim)
233    .filter(|e| !e.is_empty())
234    .map_or_else(|| "%".to_string(), |e| format!("%{e}%"));
235
236  // Optional deterministic size cap: the first `n` entries by `entry` order. `n` is a validated
237  // i64, so it is safe to inline (no bind needed; an integer has no injection surface).
238  // Non-positive caps are ignored. Appended after the WHERE clause of each branch.
239  let limit_clause = match selection.max_entries {
240    Some(n) if n > 0 => format!(" ORDER BY t.entry LIMIT {n}"),
241    _ => String::new(),
242  };
243
244  let description = format!(
245    "Sandbox of '{}' (filter: {})",
246    parent.name,
247    selection.filter_summary()
248  );
249
250  let new_sandbox = NewSandboxCorpus {
251    path: parent.path.clone(),
252    name: name.to_string(),
253    complex: parent.complex,
254    description,
255    parent_corpus_id: Some(parent.id),
256    selection: selection_json,
257  };
258
259  let todo = TaskStatus::TODO.raw();
260  let service_id = selection.service_id;
261  let parent_id = parent.id;
262
263  connection.transaction(|t_connection| {
264    new_sandbox.create(t_connection)?;
265    let sandbox = Corpus::find_by_name(name, t_connection)?;
266
267    // Server-side carve as ONE `INSERT … SELECT` (no rows cross into the app). The two filters are
268    // INTERSECTED: an optional `t.status = <status>` and, independently, an optional EXISTS over
269    // the message `log_*` table — so a message filter matches a task **at any status**.
270    // Validated ints (ids, status, limit) are inlined (no injection surface); the only user
271    // strings (category / what / entry) are bound; the only interpolated identifier is the
272    // fixed-map `log_*` table name.
273    let sandbox_id = sandbox.id;
274    let status_clause = match status_raw {
275      Some(status) => format!(" AND t.status = {status}"),
276      None => String::new(),
277    };
278    let base = format!(
279      "INSERT INTO tasks (service_id, corpus_id, status, entry) \
280       SELECT {service_id}, {sandbox_id}, {todo}, t.entry FROM tasks t \
281       WHERE t.corpus_id = {parent_id} AND t.service_id = {service_id}{status_clause}"
282    );
283    let entry_count = match (
284      message_table,
285      selection.category.as_deref(),
286      selection.what.as_deref(),
287    ) {
288      // No message filter (status-only / entry-only): a plain status + entry scan.
289      (None, _, _) => sql_query(format!("{base} AND t.entry LIKE $1{limit_clause}"))
290        .bind::<Text, _>(&entry_pattern)
291        .execute(t_connection)?,
292      // Message severity, no category: tasks that emitted ANY message of that severity.
293      (Some(table), None, _) => sql_query(format!(
294        "{base} AND EXISTS (SELECT 1 FROM {table} l WHERE l.task_id = t.id) \
295         AND t.entry LIKE $1{limit_clause}"
296      ))
297      .bind::<Text, _>(&entry_pattern)
298      .execute(t_connection)?,
299      // Message severity + category.
300      (Some(table), Some(category), None) => sql_query(format!(
301        "{base} AND EXISTS (SELECT 1 FROM {table} l WHERE l.task_id = t.id AND l.category = $1) \
302         AND t.entry LIKE $2{limit_clause}"
303      ))
304      .bind::<Text, _>(category)
305      .bind::<Text, _>(&entry_pattern)
306      .execute(t_connection)?,
307      // Message severity + category + what.
308      (Some(table), Some(category), Some(what)) => sql_query(format!(
309        "{base} AND EXISTS (SELECT 1 FROM {table} l WHERE l.task_id = t.id AND l.category = $1 \
310         AND l.what = $2) AND t.entry LIKE $3{limit_clause}"
311      ))
312      .bind::<Text, _>(category)
313      .bind::<Text, _>(what)
314      .bind::<Text, _>(&entry_pattern)
315      .execute(t_connection)?,
316    };
317
318    Ok(SandboxOutcome {
319      sandbox,
320      entry_count,
321    })
322  })
323}
324
325#[cfg(test)]
326mod tests {
327  use super::*;
328
329  fn sel(
330    status: Option<&str>,
331    message_severity: Option<&str>,
332    category: Option<&str>,
333    what: Option<&str>,
334  ) -> SandboxSelection {
335    SandboxSelection {
336      service_id: 1,
337      status: status.map(String::from),
338      message_severity: message_severity.map(String::from),
339      category: category.map(String::from),
340      what: what.map(String::from),
341      entry: None,
342      max_entries: None,
343      severity: None,
344    }
345  }
346
347  #[test]
348  fn model_c_validation_is_status_and_message_intersected() {
349    // The F-7 fix: `info`+category is now VALID (info is a real message severity, `log_infos`;
350    // `conversion` is a real info category — unlike `missing_file`, which is a *warning* category).
351    assert!(
352      sel(None, Some("info"), Some("conversion"), None)
353        .validate()
354        .is_ok()
355    );
356    // status + message intersect; status-only; message-only (any status) — all valid.
357    assert!(
358      sel(
359        Some("no_problem"),
360        Some("warning"),
361        Some("missing_file"),
362        None
363      )
364      .validate()
365      .is_ok()
366    );
367    assert!(sel(Some("warning"), None, None, None).validate().is_ok());
368    assert!(sel(None, Some("error"), None, None).validate().is_ok());
369
370    // category needs a message_severity; what needs a category.
371    assert!(
372      sel(Some("no_problem"), None, Some("x"), None)
373        .validate()
374        .is_err()
375    );
376    assert!(
377      sel(None, Some("warning"), None, Some("x"))
378        .validate()
379        .is_err()
380    );
381    // `info` is a message severity, NOT a task status — rejected in the status slot.
382    assert!(sel(Some("info"), None, None, None).validate().is_err());
383    // an empty selection (no dimension) is rejected.
384    assert!(sel(None, None, None, None).validate().is_err());
385  }
386
387  #[test]
388  fn legacy_selection_still_renders_its_provenance() {
389    // A pre-Model-C stored selection (only `severity`) must still summarise for the provenance UI.
390    let mut legacy = sel(None, None, Some("missing_file"), None);
391    legacy.severity = Some("warning".to_string());
392    let summary = legacy.filter_summary();
393    assert!(summary.contains("severity=warning"), "got {summary:?}");
394    assert!(summary.contains("category=missing_file"), "got {summary:?}");
395  }
396}