From 49249c1a5d98eb692d5e27d63e9023f6205973cb Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Tue, 7 Jul 2026 08:41:14 +0200 Subject: [PATCH 1/4] RecognizerParams.ner/llm: three-state Option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire: `ner: Option` / `llm: Option` on `RecognizerParams`, replacing plain `bool`. Semantics: - `None` (default, omitted on the wire): softly-on. Attaches the deployment's lineup when non-empty; skips silently when empty. Requests that used to send `{}` no longer explode on deployments without NER/LLM configured. - `Some(true)`: explicit opt-in. Attaches; errors when the lineup is empty (or has zero matches for the modality on LLM). Explicit intent surfaces the misconfiguration. - `Some(false)`: explicit opt-out. Skips. `attach_ner_lineup` and `attach_llm_lineup` take the toggle directly and own the dispatch — per-modality entries just pass `spec.recognizers.{ner,llm}` through, no more `if flag` blocks. Rename `tests/custom_patterns.rs` → `tests/patterns.rs`. --- crates/nvisy-engine/src/analyzer/audio.rs | 4 +- crates/nvisy-engine/src/analyzer/image.rs | 13 ++--- .../src/analyzer/recognizer/llm.rs | 28 +++++++---- .../src/analyzer/recognizer/ner.rs | 38 +++++++++----- crates/nvisy-engine/src/analyzer/tabular.rs | 4 +- crates/nvisy-engine/src/analyzer/text.rs | 21 ++++---- crates/nvisy-engine/tests/multimodal.rs | 4 +- .../tests/{custom_patterns.rs => patterns.rs} | 4 +- crates/nvisy-schema/src/plan/recognizer.rs | 50 ++++++++++--------- 9 files changed, 95 insertions(+), 71 deletions(-) rename crates/nvisy-engine/tests/{custom_patterns.rs => patterns.rs} (99%) diff --git a/crates/nvisy-engine/src/analyzer/audio.rs b/crates/nvisy-engine/src/analyzer/audio.rs index 584812c9..c5bbb2ee 100644 --- a/crates/nvisy-engine/src/analyzer/audio.rs +++ b/crates/nvisy-engine/src/analyzer/audio.rs @@ -41,9 +41,7 @@ pub(super) fn compile( if let Some(pattern) = &spec.recognizers.pattern { analyzer = attach_pattern(analyzer, pattern, guardrails)?; } - if spec.recognizers.ner { - analyzer = attach_ner_lineup(analyzer, ner)?; - } + analyzer = attach_ner_lineup(analyzer, ner, spec.recognizers.ner)?; Ok(attach_dedup(analyzer, &spec.deduplication)) } diff --git a/crates/nvisy-engine/src/analyzer/image.rs b/crates/nvisy-engine/src/analyzer/image.rs index e5c1ead2..a8cbb17d 100644 --- a/crates/nvisy-engine/src/analyzer/image.rs +++ b/crates/nvisy-engine/src/analyzer/image.rs @@ -46,12 +46,13 @@ pub(super) fn compile( if let Some(pattern) = &spec.recognizers.pattern { analyzer = attach_pattern(analyzer, pattern, guardrails)?; } - if spec.recognizers.ner { - analyzer = attach_ner_lineup(analyzer, ner)?; - } - if spec.recognizers.llm { - analyzer = attach_llm_lineup(analyzer, llm, LlmRecognizerModality::Image)?; - } + analyzer = attach_ner_lineup(analyzer, ner, spec.recognizers.ner)?; + analyzer = attach_llm_lineup( + analyzer, + llm, + LlmRecognizerModality::Image, + spec.recognizers.llm, + )?; Ok(attach_dedup(analyzer, &spec.deduplication)) } diff --git a/crates/nvisy-engine/src/analyzer/recognizer/llm.rs b/crates/nvisy-engine/src/analyzer/recognizer/llm.rs index ddf001c5..f0e72c9c 100644 --- a/crates/nvisy-engine/src/analyzer/recognizer/llm.rs +++ b/crates/nvisy-engine/src/analyzer/recognizer/llm.rs @@ -18,14 +18,20 @@ use nvisy_core::llm::{ }; /// Attach every LLM recognizer in `llm.recognizers` whose -/// modality list includes `modality` to `analyzer`. Errors when: +/// modality list includes `modality` to `analyzer`, dispatched on +/// the request's three-state toggle. /// -/// - The lineup has no recognizer for this modality (compile is -/// only invoked when the request toggled `llm = true`, so -/// "nothing configured for this modality" is user-visible). -/// - A recognizer's `modalities` list is empty. -/// - A Jinja2 prompt file / template fails to load or compile. -/// - A provider-client construction (rig) fails. +/// - `Some(true)`: explicit opt-in. Attaches every configured +/// recognizer whose declared modalities include `modality`; +/// errors when zero match. +/// - `Some(false)`: explicit opt-out. Returns the analyzer +/// unchanged. +/// - `None`: softly-on default. Attaches every matching +/// recognizer if any match; skips silently otherwise. +/// +/// Errors on: any recognizer whose `modalities` list is empty +/// (bad config), Jinja2 prompt load/compile failure, provider +/// client construction failure. /// /// Bound explanation: /// @@ -38,6 +44,7 @@ pub(crate) fn attach_llm_lineup( mut analyzer: Analyzer, llm: &LlmConfig, modality: LlmRecognizerModality, + toggle: Option, ) -> Result, Error> where M: LlmModality, @@ -45,6 +52,9 @@ where DefaultPrompt: Prompt, Jinja2Prompt: Prompt, { + if toggle == Some(false) { + return Ok(analyzer); + } let mut matched = 0usize; for recognizer in &llm.recognizers { if recognizer.modalities.is_empty() { @@ -63,13 +73,13 @@ where matched += 1; analyzer = attach_one(analyzer, recognizer)?; } - if matched == 0 { + if matched == 0 && toggle == Some(true) { return Err(Error::new( ErrorKind::Validation, format!( "AnalyzerParams.recognizers.llm = true but the deployment has no LLM \ recognizer configured for the {modality:?} modality; add one to \ - `[[llm.recognizers]]` in the deployment config or leave `llm = false`", + `[[llm.recognizers]]` in the deployment config or leave `llm` unset / false", ), )); } diff --git a/crates/nvisy-engine/src/analyzer/recognizer/ner.rs b/crates/nvisy-engine/src/analyzer/recognizer/ner.rs index b6df3da5..0e43dcb4 100644 --- a/crates/nvisy-engine/src/analyzer/recognizer/ner.rs +++ b/crates/nvisy-engine/src/analyzer/recognizer/ner.rs @@ -4,7 +4,8 @@ //! backend (or `MockBackend` under the `test-utils` feature). //! //! Symmetric with [`super::llm`]; called by every per-modality -//! compile function when `AnalyzerParams.recognizers.ner == true`. +//! compile function with the request's +//! `AnalyzerParams.recognizers.ner` three-state toggle. //! //! [`Analyzer`]: elide::detection::Analyzer //! [`NerConfig::recognizers`]: nvisy_core::ner::NerConfig::recognizers @@ -17,26 +18,37 @@ use elide_core::modality::TextRecognizable; use elide_core::recognition::Recognizer; use nvisy_core::ner::{NerBackendConfig, NerConfig, NerRecognizer as ConfigNerRecognizer}; -/// Attach every recognizer from the deployment's NER lineup. -/// Errors when the lineup is empty (compile is only invoked when -/// the request toggled `ner = true`, so "no recognizers -/// configured" is user-visible). Modality-generic for any -/// `M: TextRecognizable`. +/// Attach every recognizer from the deployment's NER lineup, +/// dispatched on the request's three-state toggle. +/// +/// - `Some(true)`: explicit opt-in. Attaches every configured +/// recognizer; errors if the lineup is empty. +/// - `Some(false)`: explicit opt-out. Returns the analyzer +/// unchanged. +/// - `None`: softly-on default. Attaches every configured +/// recognizer if the lineup is non-empty; skips silently +/// otherwise. pub(in crate::analyzer) fn attach_ner_lineup( mut analyzer: Analyzer, ner: &NerConfig, + toggle: Option, ) -> Result, Error> where M: TextRecognizable, NerRecognizer: Recognizer + 'static, { - if ner.recognizers.is_empty() { - return Err(Error::new( - elide_core::ErrorKind::Validation, - "AnalyzerParams.recognizers.ner = true but the deployment has no NER \ - recognizer configured; add one to `[[ner.recognizers]]` in the \ - deployment config or leave `ner = false`", - )); + match toggle { + Some(false) => return Ok(analyzer), + None if ner.recognizers.is_empty() => return Ok(analyzer), + Some(true) if ner.recognizers.is_empty() => { + return Err(Error::new( + elide_core::ErrorKind::Validation, + "AnalyzerParams.recognizers.ner = true but the deployment has no NER \ + recognizer configured; add one to `[[ner.recognizers]]` in the \ + deployment config or leave `ner` unset / false", + )); + } + _ => {} } for recognizer in &ner.recognizers { analyzer = attach_ner_one(analyzer, recognizer)?; diff --git a/crates/nvisy-engine/src/analyzer/tabular.rs b/crates/nvisy-engine/src/analyzer/tabular.rs index e74fc04e..fb257f84 100644 --- a/crates/nvisy-engine/src/analyzer/tabular.rs +++ b/crates/nvisy-engine/src/analyzer/tabular.rs @@ -35,9 +35,7 @@ pub(super) fn compile( if let Some(pattern) = &spec.recognizers.pattern { analyzer = attach_pattern(analyzer, pattern, guardrails)?; } - if spec.recognizers.ner { - analyzer = attach_ner_lineup(analyzer, ner)?; - } + analyzer = attach_ner_lineup(analyzer, ner, spec.recognizers.ner)?; Ok(attach_dedup(analyzer, &spec.deduplication)) } diff --git a/crates/nvisy-engine/src/analyzer/text.rs b/crates/nvisy-engine/src/analyzer/text.rs index 06aade23..c090f668 100644 --- a/crates/nvisy-engine/src/analyzer/text.rs +++ b/crates/nvisy-engine/src/analyzer/text.rs @@ -2,9 +2,11 @@ //! [`elide::detection::Analyzer`]. //! //! Text supports the full recognizer set: Pattern, NER, and LLM. -//! NER and LLM are opt-in via `spec.recognizers.ner = true` / -//! `spec.recognizers.llm = true`; the deployment's [`NerConfig`] -//! and [`LlmConfig`] provide the actual recognizer lineups. +//! NER and LLM are three-state toggles on +//! `spec.recognizers.{ner,llm}` (see +//! [`nvisy_schema::plan::RecognizerParams`]); the deployment's +//! [`NerConfig`] and [`LlmConfig`] provide the actual recognizer +//! lineups. //! //! Modality-foreign enrichers (`ocr`, `stt`) on `spec` are //! silently ignored; those flow through the modalities they @@ -44,12 +46,13 @@ pub(super) fn compile( if let Some(pattern) = &spec.recognizers.pattern { analyzer = attach_pattern(analyzer, pattern, guardrails)?; } - if spec.recognizers.ner { - analyzer = attach_ner_lineup(analyzer, ner)?; - } - if spec.recognizers.llm { - analyzer = attach_llm_lineup(analyzer, llm, LlmRecognizerModality::Text)?; - } + analyzer = attach_ner_lineup(analyzer, ner, spec.recognizers.ner)?; + analyzer = attach_llm_lineup( + analyzer, + llm, + LlmRecognizerModality::Text, + spec.recognizers.llm, + )?; Ok(attach_dedup(analyzer, &spec.deduplication)) } diff --git a/crates/nvisy-engine/tests/multimodal.rs b/crates/nvisy-engine/tests/multimodal.rs index b31af5d4..360aba0d 100644 --- a/crates/nvisy-engine/tests/multimodal.rs +++ b/crates/nvisy-engine/tests/multimodal.rs @@ -39,8 +39,8 @@ fn default_spec() -> AnalyzerParams { context_enhanced: true, ..Default::default() }), - ner: false, - llm: false, + ner: Some(false), + llm: Some(false), }, enrichers: nvisy_schema::plan::EnricherParams { language: None, diff --git a/crates/nvisy-engine/tests/custom_patterns.rs b/crates/nvisy-engine/tests/patterns.rs similarity index 99% rename from crates/nvisy-engine/tests/custom_patterns.rs rename to crates/nvisy-engine/tests/patterns.rs index d26a2f88..171a6989 100644 --- a/crates/nvisy-engine/tests/custom_patterns.rs +++ b/crates/nvisy-engine/tests/patterns.rs @@ -27,8 +27,8 @@ fn spec_with_pattern_params(params: PatternRecognizerParams) -> AnalyzerParams { AnalyzerParams { recognizers: nvisy_schema::plan::RecognizerParams { pattern: Some(params), - ner: false, - llm: false, + ner: Some(false), + llm: Some(false), }, ..Default::default() } diff --git a/crates/nvisy-schema/src/plan/recognizer.rs b/crates/nvisy-schema/src/plan/recognizer.rs index 47fd4939..0f8494aa 100644 --- a/crates/nvisy-schema/src/plan/recognizer.rs +++ b/crates/nvisy-schema/src/plan/recognizer.rs @@ -10,11 +10,12 @@ //! ([`CustomDictionary`]) alongside the shipped `builtins`; //! the engine compiles them per request. //! - **NER** is a **deployment-owned lineup** gated by a -//! boolean toggle. Provider, model, and (future) credentials -//! live in the deployment config; the wire only opts in or -//! out. -//! - **LLM** is the same shape as NER: a deployment-owned -//! lineup gated by a boolean toggle. +//! three-state toggle. Provider, model, and (future) +//! credentials live in the deployment config; the wire opts in +//! (`true`), opts out (`false`), or leaves it to the default +//! (`None`, softly-on: attach when the deployment has any NER +//! configured, skip otherwise). +//! - **LLM** is the same shape as NER. //! //! Rationale for the NER/LLM shape: policies stay portable //! across deployments, the operator controls model choice and @@ -31,7 +32,8 @@ use super::pattern::{CustomDictionary, CustomPatternRule}; /// Recognizer slots an analyzer can fill. /// /// Pattern is at-most-one (per-request); NER and LLM are -/// deployment-owned lineups each gated by a boolean toggle. +/// deployment-owned lineups each gated by a three-state +/// [`Option`] toggle. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct RecognizerParams { @@ -42,26 +44,26 @@ pub struct RecognizerParams { pub pattern: Option, /// Run the deployment's NER recognizer lineup. /// - /// `false` skips NER recognition entirely; `true` attaches - /// every deployment-configured recognizer. When the - /// deployment has no NER recognizers configured, `true` fails - /// the analyzer compile with a `Validation` error. - #[serde(default, skip_serializing_if = "is_false")] - pub ner: bool, + /// - `Some(true)`: explicit opt-in. Attaches every + /// deployment-configured recognizer. Fails the analyzer + /// compile with a `Validation` error when the deployment + /// has no NER recognizers configured. + /// - `Some(false)`: explicit opt-out. Skips NER entirely. + /// - `None`: softly-on default. Attaches every configured + /// recognizer if the deployment has any; skips silently + /// otherwise. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ner: Option, /// Run the deployment's LLM recognizer lineup. /// - /// `false` skips LLM recognition entirely; `true` attaches - /// every deployment-configured recognizer whose declared - /// modalities match the analyzer's modality. When the - /// deployment has no LLM recognizers configured for this - /// modality, `true` fails the analyzer compile with a - /// `Validation` error. - #[serde(default, skip_serializing_if = "is_false")] - pub llm: bool, -} - -fn is_false(b: &bool) -> bool { - !*b + /// Same three-state semantics as [`ner`]. The lineup is + /// filtered by declared modality — only recognizers whose + /// `modalities` list contains the analyzer's modality + /// attach. + /// + /// [`ner`]: RecognizerParams::ner + #[serde(default, skip_serializing_if = "Option::is_none")] + pub llm: Option, } /// Params for the `elide-pattern` recognizer. From 8a9d6e88b364d5995cccfa9d3e84d4a145fdacf0 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Tue, 7 Jul 2026 08:41:22 +0200 Subject: [PATCH 2/4] Convert 4 report.rs free fns into RecognizedGroup methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `insert_body(report, group)` → `group.insert_into_body(report)` - `insert_part(report, id, group)` → `group.insert_as_part(report, id)` - `collect_overrides_into(out, group)` → `group.collect_overrides_into(out)` - `encode_redacted(handle, body)` → `body.encode_redacted_from(handle)` Reads more naturally at the call site (the group is the natural receiver — it's the polymorphic thing dispatched on). Trims the `pipeline/mod.rs` import block: six-fn glob down to `take_body`, `take_part`. `take_body` / `take_part` stay free — receiver would be elide-owned `Report`. --- crates/nvisy-engine/src/pipeline/mod.rs | 14 +- crates/nvisy-engine/src/pipeline/report.rs | 154 +++++++++++---------- 2 files changed, 85 insertions(+), 83 deletions(-) diff --git a/crates/nvisy-engine/src/pipeline/mod.rs b/crates/nvisy-engine/src/pipeline/mod.rs index e5bc260d..9d50572e 100644 --- a/crates/nvisy-engine/src/pipeline/mod.rs +++ b/crates/nvisy-engine/src/pipeline/mod.rs @@ -99,9 +99,7 @@ use nvisy_schema::policy::{Policy, PolicyAction}; use uuid::Uuid; pub use self::analyzed::{AnalyzedDocument, EntityRecord, RecognizedGroup}; -use self::report::{ - collect_overrides_into, encode_redacted, insert_body, insert_part, take_body, take_part, -}; +use self::report::{take_body, take_part}; const COMPONENT: &str = "pipeline"; @@ -312,12 +310,12 @@ impl Engine { })?; let correlation_id = document.correlation_id; let mut handle = self.decode(document).await?; - let mut report = insert_body(Report::new(), body_group); + let mut report = body_group.insert_into_body(Report::new()); let mut overrides: Vec<(Uuid, PolicyAction)> = Vec::new(); - collect_overrides_into(&mut overrides, body_group); + body_group.collect_overrides_into(&mut overrides); for (id, group) in &analyzed.parts { - report = insert_part(report, id.as_str(), group); - collect_overrides_into(&mut overrides, group); + report = group.insert_as_part(report, id.as_str()); + group.collect_overrides_into(&mut overrides); } let orchestrator = self.build_anonymize_orchestrator( @@ -333,7 +331,7 @@ impl Engine { Error::internal("orchestrator anonymize_with failed", COMPONENT).with_source(err) })?; - encode_redacted(handle, body_group) + body_group.encode_redacted_from(handle) } async fn decode(&self, document: Document) -> Result { diff --git a/crates/nvisy-engine/src/pipeline/report.rs b/crates/nvisy-engine/src/pipeline/report.rs index d3ba4969..0169357d 100644 --- a/crates/nvisy-engine/src/pipeline/report.rs +++ b/crates/nvisy-engine/src/pipeline/report.rs @@ -7,16 +7,18 @@ //! [`Report`], [`take_body`] and [`take_part`] move each typed //! `Vec>` out of the report into the matching //! [`RecognizedGroup`] variant for the caller to hold. -//! - **Rebuild**: at anonymize time, [`insert_body`] and -//! [`insert_part`] feed a fresh [`Report`] from the returned -//! groups (cloning entities; the returned body is the source -//! of truth for re-apply idempotency). +//! - **Rebuild**: at anonymize time, +//! [`RecognizedGroup::insert_into_body`] and +//! [`RecognizedGroup::insert_as_part`] feed a fresh [`Report`] +//! from the returned groups (cloning entities; the returned +//! body is the source of truth for re-apply idempotency). //! //! Plus two byte-level helpers used at the anonymize seam: -//! [`collect_overrides_into`] walks reviewer overrides off any -//! group, and [`encode_redacted`] picks the right typed handle -//! to re-encode through after `anonymize_with` mutated the -//! document in place. +//! [`RecognizedGroup::collect_overrides_into`] walks reviewer +//! overrides off any group, and +//! [`RecognizedGroup::encode_redacted_from`] picks the right +//! typed handle to re-encode through after `anonymize_with` +//! mutated the document in place. //! //! All helpers are stateless: no [`Engine`] state, no I/O. The //! per-modality dispatch is collapsed onto one trait @@ -98,43 +100,80 @@ pub(super) fn take_part( Some(M::into_group(entities)) } -/// Insert the body group into `report` under its modality. -pub(super) fn insert_body(report: Report, group: &RecognizedGroup) -> Report { - match group { - RecognizedGroup::Text { entities } => report.insert_body::(clone_entities(entities)), - #[cfg(feature = "internal_tabular")] - RecognizedGroup::Tabular { entities } => { - report.insert_body::(clone_entities(entities)) - } - #[cfg(feature = "internal_image")] - RecognizedGroup::Image { entities } => { - report.insert_body::(clone_entities(entities)) - } - #[cfg(feature = "internal_audio")] - RecognizedGroup::Audio { entities } => { - report.insert_body::