From 72ec208caa8afb61d36932235569d0f431558632 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Tue, 19 May 2026 17:45:50 +0200 Subject: [PATCH 1/5] feat(ontology, engine, server): explicit policy precedence at the run boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the implicit "Vec position = precedence" rule for run-time policy layering with an explicit, typed precedence on each ref. - New nvisy_ontology::policy::PolicyRef { id: Uuid, precedence: u32 }. Lower precedence wins (0 = most authoritative override; higher numbers layer underneath as defaults). Required, not optional — callers explicitly declare the layering for every ref. - EngineInput.policy_ids: Vec → EngineInput.policies: Vec. NewRun (POST /runs) request body matches. - Pipeline.execute sorts the refs by precedence (stable; ties preserve input order) before resolving from the policy cache, so Policies.policies ends up in true precedence order: index 0 is highest precedence. - Policies::all_strategies now documents the layered tiebreaker explicitly: strategy `priority` first, then policy precedence (via the now-meaningful Vec position), then within-policy insertion order. Previously the precedence was hidden inside whatever order the caller happened to pass IDs in — `vec.sort()` on the input would silently reorder governance. Now the precedence number travels with each ref and is load-bearing in the type. Co-Authored-By: Claude Opus 4.7 --- crates/nvisy-engine/src/pipeline/default.rs | 7 +++- crates/nvisy-engine/src/pipeline/run.rs | 14 ++++--- crates/nvisy-engine/tests/fixtures/mod.rs | 4 +- crates/nvisy-ontology/src/policy/mod.rs | 37 ++++++++++++++++++- .../nvisy-server/src/handler/request/runs.rs | 11 +++--- crates/nvisy-server/src/handler/runs.rs | 2 +- 6 files changed, 57 insertions(+), 18 deletions(-) diff --git a/crates/nvisy-engine/src/pipeline/default.rs b/crates/nvisy-engine/src/pipeline/default.rs index b48801cc..6e17634c 100644 --- a/crates/nvisy-engine/src/pipeline/default.rs +++ b/crates/nvisy-engine/src/pipeline/default.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use std::{fmt, mem}; use nvisy_core::Error; +use nvisy_ontology::policy::PolicyRef; use nvisy_ontology::provenance::Audit; use nvisy_ontology::workflow::Graph; use nvisy_provider::http::HttpClient; @@ -34,8 +35,10 @@ use crate::utility::encryption::SharedKeyProvider; pub struct EngineInput { /// Identity of the human or service account initiating the run. pub actor_id: Uuid, - /// Previously uploaded policy IDs to apply. - pub policy_ids: Vec, + /// Previously uploaded policies to apply, each tagged with the + /// precedence at which it should layer (lower = higher precedence). + /// See [`PolicyRef`]. + pub policies: Vec, /// Execution graph defining the pipeline DAG. /// /// Content identifiers live on [`ImportFile`] nodes within the diff --git a/crates/nvisy-engine/src/pipeline/run.rs b/crates/nvisy-engine/src/pipeline/run.rs index f6776a4e..976f8cfe 100644 --- a/crates/nvisy-engine/src/pipeline/run.rs +++ b/crates/nvisy-engine/src/pipeline/run.rs @@ -144,16 +144,18 @@ impl Pipeline { let actor_id = input.actor_id; + // Sort policy refs by precedence (lower first); the stable sort + // preserves insertion order for equal-precedence refs. + let mut policy_refs = input.policies.clone(); + policy_refs.sort_by_key(|r| r.precedence); + let policy_ids: Vec = policy_refs.iter().map(|r| r.id).collect(); + // Acquire contexts and policies into the registry caches. let (_context_guard, _policy_guard) = self - .acquire_resources(actor_id, &compiled.context_ids, &input.policy_ids) + .acquire_resources(actor_id, &compiled.context_ids, &policy_ids) .await; - let cached_policies = self - .registry - .policy_cache() - .resolve(&input.policy_ids) - .await; + let cached_policies = self.registry.policy_cache().resolve(&policy_ids).await; let mut policies = Policies::default(); for policy in cached_policies { policies.push(Arc::unwrap_or_clone(policy)); diff --git a/crates/nvisy-engine/tests/fixtures/mod.rs b/crates/nvisy-engine/tests/fixtures/mod.rs index 42709978..d1302702 100644 --- a/crates/nvisy-engine/tests/fixtures/mod.rs +++ b/crates/nvisy-engine/tests/fixtures/mod.rs @@ -69,7 +69,7 @@ pub fn import_export_graph(content_id: Uuid) -> Graph { pub fn engine_input(actor_id: Uuid, graph: Graph) -> EngineInput { EngineInput { actor_id, - policy_ids: Vec::new(), + policies: Vec::new(), graph, config: None, dry_run: false, @@ -80,7 +80,7 @@ pub fn engine_input(actor_id: Uuid, graph: Graph) -> EngineInput { pub fn dry_run_input(actor_id: Uuid, graph: Graph) -> EngineInput { EngineInput { actor_id, - policy_ids: Vec::new(), + policies: Vec::new(), graph, config: None, dry_run: true, diff --git a/crates/nvisy-ontology/src/policy/mod.rs b/crates/nvisy-ontology/src/policy/mod.rs index 33e2bc3c..1b0dd9ba 100644 --- a/crates/nvisy-ontology/src/policy/mod.rs +++ b/crates/nvisy-ontology/src/policy/mod.rs @@ -70,21 +70,54 @@ impl Policy { } } +/// A reference to a stored [`Policy`] tagged with the precedence it +/// should take when applied alongside other policies. +/// +/// Lower [`precedence`] wins: a ref with `precedence: 0` is the most +/// authoritative ("override"), higher numbers are layered underneath +/// (org defaults, etc.). Ties are resolved by insertion order (stable). +/// +/// [`precedence`]: PolicyRef::precedence +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PolicyRef { + /// Identifier of the previously uploaded policy. + pub id: Uuid, + /// Application precedence (lower = higher precedence). + pub precedence: u32, +} + /// A collection of policies to apply during a pipeline run. +/// +/// The inner `Vec` is held in **precedence order**: index `0` +/// is the highest-precedence policy (lowest [`PolicyRef::precedence`] +/// value). Callers should construct via the engine's resolution path +/// rather than building this directly, so the order matches the +/// precedence declared at the run boundary. #[derive(Debug, Clone, Default, Deref, DerefMut)] #[derive(Serialize, Deserialize, JsonSchema)] pub struct Policies { - /// The policies to evaluate, in order. + /// The policies to evaluate, in precedence order (index 0 is highest). #[deref] #[deref_mut] pub policies: Vec, } impl Policies { - /// All strategy policies across all policies, sorted by priority. + /// All strategy policies across all policies, sorted by + /// [`StrategyPolicy::priority`] (lower first). + /// + /// Ties are broken by policy precedence: since [`policies`] is held + /// in precedence order, equal-priority strategies from a + /// higher-precedence policy come before those from a + /// lower-precedence policy. Within a single policy, ties follow + /// the [`strategies`] insertion order. /// /// Returns tuples of `(policy_id, strategy)` so callers can trace /// which policy a matched strategy belongs to. + /// + /// [`policies`]: Self::policies + /// [`strategies`]: Policy::strategies pub fn all_strategies(&self) -> Vec<(Uuid, &StrategyPolicy)> { let mut result: Vec<_> = self .policies diff --git a/crates/nvisy-server/src/handler/request/runs.rs b/crates/nvisy-server/src/handler/request/runs.rs index d94285e9..7c52a850 100644 --- a/crates/nvisy-server/src/handler/request/runs.rs +++ b/crates/nvisy-server/src/handler/request/runs.rs @@ -1,25 +1,26 @@ //! Run request types. use nvisy_engine::pipeline::{RunStatus, RuntimeConfig}; +use nvisy_ontology::policy::PolicyRef; use nvisy_ontology::workflow::Graph; use schemars::JsonSchema; use serde::Deserialize; -use uuid::Uuid; use super::Pagination; /// Request body for `POST /runs`. /// /// Content identifiers are specified on [`Import`] nodes within the -/// graph, not as a top-level field. Policy identifiers reference -/// previously uploaded policies. +/// graph, not as a top-level field. Each [`PolicyRef`] references a +/// previously uploaded policy and carries the precedence at which it +/// should layer relative to other refs in the same run. /// /// [`Import`]: nvisy_ontology::workflow::ImportFile #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct NewRun { - /// Previously uploaded policy IDs to apply. - pub policy_ids: Vec, + /// Previously uploaded policies to apply, tagged with precedence. + pub policies: Vec, /// Execution graph defining the pipeline DAG. pub graph: Graph, /// Per-request configuration overrides (optional). diff --git a/crates/nvisy-server/src/handler/runs.rs b/crates/nvisy-server/src/handler/runs.rs index c6745ebe..c31322b9 100644 --- a/crates/nvisy-server/src/handler/runs.rs +++ b/crates/nvisy-server/src/handler/runs.rs @@ -45,7 +45,7 @@ async fn create_run( ) -> Result<(StatusCode, Json)> { let input = EngineInput { actor_id, - policy_ids: req.policy_ids, + policies: req.policies, graph: req.graph, config: req.config, dry_run: req.dry_run, From 67bfd0fce3d6d2bb823f39fa83b17355b5bb4a14 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Wed, 20 May 2026 00:04:51 +0200 Subject: [PATCH 2/5] feat(ontology, engine): implement Action::Suppress; slim RedactionMapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppress is now a first-class action that records suppressed entities in the audit trail and wins over equal-or-lower-priority Redact rules. - New AuditEntryStatus::Suppressed marks entries for entities matched by an Action::Suppress rule. The entry carries the suppressing policy_id and the original value but no replacement. - Evaluator rewrite: collect all matching strategies per entity, then Suppress wins iff suppress.priority() <= best matching Redact's priority (or no Redact matches). Compares the priority field directly rather than slice index so ties go to Suppress regardless of insertion order. - Applicator skips Suppressed entries at the top of each build_*_redactions loop — they never reach the codec. - Four unit tests cover the precedence rules. - Review/Alert/Block remain stubs that log and fall through to the default-threshold path; tracking them separately. Also slimmed RedactionMapping to { entity_id, location }, dropping the non-functional `original: String` / `replacement: Option` fields that couldn't hold image/audio payloads. Audit values stay on AuditEntry.value as before. A future blob-backed reversibility extension is tracked in #151. Also dropped #[non_exhaustive] from the four *Strategy / Action enums. The codec matches them exhaustively; non_exhaustive forced silent catch-all arms that hid new variants from compile-time checks. Cleared three dead `_ =>` arms in apply.rs after the change. Co-Authored-By: Claude Opus 4.7 --- .../src/operation/redaction/apply.rs | 100 ++------ .../src/operation/redaction/evaluate.rs | 234 ++++++++++++++++-- .../src/policy/strategy/audio.rs | 1 - .../src/policy/strategy/image.rs | 1 - .../nvisy-ontology/src/policy/strategy/mod.rs | 1 - .../src/policy/strategy/text.rs | 1 - crates/nvisy-ontology/src/provenance/entry.rs | 7 + .../src/provenance/redaction_map.rs | 70 +++--- 8 files changed, 277 insertions(+), 138 deletions(-) diff --git a/crates/nvisy-engine/src/operation/redaction/apply.rs b/crates/nvisy-engine/src/operation/redaction/apply.rs index 4e392cde..73a1c295 100644 --- a/crates/nvisy-engine/src/operation/redaction/apply.rs +++ b/crates/nvisy-engine/src/operation/redaction/apply.rs @@ -17,6 +17,7 @@ use nvisy_ontology::entity::{ AudioLocation, Entity, EntityKind, ImageLocation, Location, TabularLocation, TextLocation, }; use nvisy_ontology::policy::{AudioStrategy, ImageStrategy, TextStrategy}; +use nvisy_ontology::provenance::AuditEntryStatus; use sha2::{Digest, Sha256}; use uuid::Uuid; @@ -41,16 +42,11 @@ impl<'a> RedactionApplicator<'a> { /// Build and apply all redaction instructions. pub async fn apply(mut self) -> Result<()> { let entity_map = entity_map(&self.envelope.audit.entities); - let mut mapping_index = mapping_index(&self.envelope.redaction_map.entries); - let text = self - .build_text_redactions(&entity_map, &mut mapping_index) - .await?; - let tabular = self - .build_tabular_redactions(&entity_map, &mut mapping_index) - .await?; - let image = self.build_image_redactions(&entity_map, &mut mapping_index)?; - let audio = self.build_audio_redactions(&entity_map, &mut mapping_index)?; + let text = self.build_text_redactions(&entity_map).await?; + let tabular = self.build_tabular_redactions(&entity_map).await?; + let image = self.build_image_redactions(&entity_map)?; + let audio = self.build_audio_redactions(&entity_map)?; if !text.is_empty() { self.envelope.document.apply_text_redactions(text).await?; @@ -74,12 +70,14 @@ impl<'a> RedactionApplicator<'a> { async fn build_text_redactions( &mut self, entity_map: &HashMap, - mapping_index: &mut HashMap, ) -> Result> { let mut redactions = Redactions::new(ConflictPolicy::Reject); for i in 0..self.envelope.audit.entries.len() { let record = &self.envelope.audit.entries[i]; + if record.status == AuditEntryStatus::Suppressed { + continue; + } let Some(entity) = entity_map.get(&record.entity_id) else { continue; }; @@ -100,10 +98,7 @@ impl<'a> RedactionApplicator<'a> { let entity_id = record.entity_id; let replacement = output.replacement_value().map(String::from); - self.envelope.audit.entries[i].value.replacement = replacement.clone(); - if let Some(&idx) = mapping_index.get(&entity_id) { - self.envelope.redaction_map.entries[idx].replacement = replacement; - } + self.envelope.audit.entries[i].value.replacement = replacement; tracing::trace!( target: TARGET, @@ -124,12 +119,14 @@ impl<'a> RedactionApplicator<'a> { async fn build_tabular_redactions( &mut self, entity_map: &HashMap, - mapping_index: &mut HashMap, ) -> Result> { let mut redactions = Redactions::new(ConflictPolicy::Reject); for i in 0..self.envelope.audit.entries.len() { let record = &self.envelope.audit.entries[i]; + if record.status == AuditEntryStatus::Suppressed { + continue; + } let Some(entity) = entity_map.get(&record.entity_id) else { continue; }; @@ -150,10 +147,7 @@ impl<'a> RedactionApplicator<'a> { let entity_id = record.entity_id; let replacement = output.replacement_value().map(String::from); - self.envelope.audit.entries[i].value.replacement = replacement.clone(); - if let Some(&idx) = mapping_index.get(&entity_id) { - self.envelope.redaction_map.entries[idx].replacement = replacement; - } + self.envelope.audit.entries[i].value.replacement = replacement; tracing::trace!( target: TARGET, @@ -176,12 +170,14 @@ impl<'a> RedactionApplicator<'a> { fn build_image_redactions( &mut self, entity_map: &HashMap, - mapping_index: &mut HashMap, ) -> Result> { let mut redactions = Redactions::new(ConflictPolicy::Reject); for i in 0..self.envelope.audit.entries.len() { let record = &self.envelope.audit.entries[i]; + if record.status == AuditEntryStatus::Suppressed { + continue; + } let Some(entity) = entity_map.get(&record.entity_id) else { continue; }; @@ -201,10 +197,7 @@ impl<'a> RedactionApplicator<'a> { }; let entity_id = record.entity_id; - self.envelope.audit.entries[i].value.replacement = Some(placeholder.clone()); - if let Some(&idx) = mapping_index.get(&entity_id) { - self.envelope.redaction_map.entries[idx].replacement = Some(placeholder); - } + self.envelope.audit.entries[i].value.replacement = Some(placeholder); tracing::trace!( target: TARGET, @@ -223,12 +216,14 @@ impl<'a> RedactionApplicator<'a> { fn build_audio_redactions( &mut self, entity_map: &HashMap, - mapping_index: &mut HashMap, ) -> Result> { let mut redactions = Redactions::new(ConflictPolicy::Reject); for i in 0..self.envelope.audit.entries.len() { let record = &self.envelope.audit.entries[i]; + if record.status == AuditEntryStatus::Suppressed { + continue; + } let Some(entity) = entity_map.get(&record.entity_id) else { continue; }; @@ -248,10 +243,7 @@ impl<'a> RedactionApplicator<'a> { }; let entity_id = record.entity_id; - self.envelope.audit.entries[i].value.replacement = Some(placeholder.clone()); - if let Some(&idx) = mapping_index.get(&entity_id) { - self.envelope.redaction_map.entries[idx].replacement = Some(placeholder); - } + self.envelope.audit.entries[i].value.replacement = Some(placeholder); tracing::trace!( target: TARGET, @@ -275,21 +267,6 @@ fn entity_map(entities: &nvisy_ontology::entity::Entities) -> HashMap HashMap { - mappings - .iter() - .enumerate() - .map(|(i, m)| (m.entity_id, i)) - .collect() -} - /// Compute the codec [`TextOutput`] for a value + entity + strategy. fn text_output(value: &str, entity: &Entity, strategy: &TextStrategy) -> TextOutput { match strategy { @@ -323,18 +300,6 @@ fn text_output(value: &str, entity: &Entity, strategy: &TextStrategy) -> TextOut // TODO: real tokenization — placeholder until the vault is wired. TextOutput::replace(format!("[TOKEN:{}]", entity.entity_kind)) } - // `TextStrategy` is `#[non_exhaustive]`; new variants need - // explicit handling. Surface a generic placeholder so the - // pipeline doesn't silently drop entities. - _ => { - tracing::warn!( - target: TARGET, - entity_id = %entity.id, - strategy = ?strategy, - "unhandled text strategy, using generic placeholder", - ); - TextOutput::replace(format!("[REDACTED:{}]", entity.entity_kind)) - } } } @@ -361,9 +326,6 @@ fn image_output(strategy: &ImageStrategy) -> Option<(ImageOutput, String)> { }, format!("[IMAGE_PIXELATE:{block_size}]"), )), - // `ImageStrategy` is `#[non_exhaustive]`; new variants without - // a defined codec output are skipped. - _ => None, } } @@ -375,9 +337,6 @@ fn audio_output(strategy: &AudioStrategy) -> Option<(AudioOutput, String)> { match strategy { AudioStrategy::Silence => Some((AudioOutput::Silence, "[AUDIO_SILENCE]".to_string())), AudioStrategy::Remove => Some((AudioOutput::Remove, "[AUDIO_REMOVE]".to_string())), - // `AudioStrategy` is `#[non_exhaustive]`; new variants without - // a defined codec output are skipped. - _ => None, } } @@ -460,12 +419,10 @@ mod tests { .unwrap() } - fn test_mapping(entity_id: Uuid, location: Location, original: &str) -> RedactionMapping { + fn test_mapping(entity_id: Uuid, location: Location) -> RedactionMapping { RedactionMapping { entity_id, location, - original: original.to_owned(), - replacement: None, } } @@ -582,11 +539,10 @@ mod tests { let entities: Entities = vec![entity.clone()].into(); let mut envelope = test_envelope_csv(entities, "name,ssn\nAlice,123-45-6789\n").await; envelope.audit.entries.push(record); - envelope.redaction_map.entries.push(test_mapping( - entity_id, - entity.location.clone(), - "123-45-6789", - )); + envelope + .redaction_map + .entries + .push(test_mapping(entity_id, entity.location.clone())); RedactionApplicator::new(&mut envelope) .apply() @@ -597,10 +553,6 @@ mod tests { envelope.audit.entries[0].value.replacement.as_deref(), Some("***********"), ); - assert_eq!( - envelope.redaction_map.entries[0].replacement.as_deref(), - Some("***********"), - ); let value = envelope .document .read_tabular(entity.location.as_tabular().unwrap()) diff --git a/crates/nvisy-engine/src/operation/redaction/evaluate.rs b/crates/nvisy-engine/src/operation/redaction/evaluate.rs index 6d491e33..22161490 100644 --- a/crates/nvisy-engine/src/operation/redaction/evaluate.rs +++ b/crates/nvisy-engine/src/operation/redaction/evaluate.rs @@ -11,7 +11,7 @@ use nvisy_core::Result; use nvisy_core::content::ContentMetadata; use nvisy_ontology::entity::{Entities, Entity}; use nvisy_ontology::policy::{Action, Condition, Strategy, StrategyPolicy}; -use nvisy_ontology::provenance::{AuditEntry, RedactionMapping}; +use nvisy_ontology::provenance::{AuditEntry, AuditEntryStatus, RedactionMapping}; use nvisy_ontology::workflow::Redaction; use uuid::Uuid; @@ -92,23 +92,85 @@ async fn evaluate( let mut mappings = Vec::new(); for entity in entities { - let matched = find_matching_strategy(strategies, entity, document_labels, metadata); + // Collect every strategy that matches this entity (selector + + // conditions + enabled). `strategies` is already sorted by rank + // (`StrategyPolicy::priority` ascending, then policy + // precedence, then insertion order — see + // [`Policies::all_strategies`]), so the first matching Redact + // and the first matching Suppress are each best in rank order. + let matching = matching_strategies(strategies, entity, document_labels, metadata); + let best_redact_idx = matching + .iter() + .position(|(_, rule)| matches!(rule.action, Action::Redact { .. })); + let best_suppress_idx = matching + .iter() + .position(|(_, rule)| matches!(rule.action, Action::Suppress)); + + // Suppress wins when its priority is at least as high (lower + // numeric value) as the best matching Redact's, or when no + // Redact matches at all. We compare `StrategyPolicy::priority` + // directly rather than slice index, so ties go to Suppress + // regardless of the order the strategies were inserted. + let suppress_wins = match (best_suppress_idx, best_redact_idx) { + (Some(s), Some(r)) => matching[s].1.priority() <= matching[r].1.priority(), + (Some(_), None) => true, + _ => false, + }; + + if suppress_wins { + let (policy_id, _) = matching[best_suppress_idx.expect("suppress_wins implies Some")]; + let entity_id = entity.id; + let original_value = document + .value_at(&entity.location) + .await + .unwrap_or_else(|| format!("[{}]", entity.location)); + + let entry = AuditEntry::builder() + .for_entity( + entity_id, + Strategy::default(), + original_value.clone(), + &entity.location, + ) + .with_policy_id(policy_id) + .with_status(AuditEntryStatus::Suppressed) + .build() + .expect("all required fields set"); + + tracing::debug!( + target: TARGET, + %entity_id, + %policy_id, + "entity suppressed by policy", + ); + + entries.push(entry); + // No `redaction_map` entry: nothing to apply, nothing to + // map. The audit entry is the sole record. + continue; + } - let (mut strategy, policy_id) = match matched { - Some((policy_id, rule)) => match &rule.action { - Action::Redact { strategy } => (strategy.clone(), Some(policy_id)), - _ => { + let (mut strategy, policy_id) = match best_redact_idx { + Some(idx) => { + let (policy_id, rule) = matching[idx]; + match &rule.action { + Action::Redact { strategy } => (strategy.clone(), Some(policy_id)), + _ => unreachable!("best_redact_idx is filtered to Action::Redact"), + } + } + None => { + // No matching Redact and no winning Suppress. Other + // matched actions (Review/Alert/Block) currently fall + // through to the default path; they get their own + // dedicated handling in a follow-up. + if !matching.is_empty() { tracing::debug!( target: TARGET, entity_id = %entity.id, - %policy_id, - action = ?rule.action, - "non-redact policy action", + actions = ?matching.iter().map(|(_, r)| &r.action).collect::>(), + "matched non-redact, non-suppress action; falling through to default", ); - continue; } - }, - None => { if entity.confidence < default_threshold { continue; } @@ -150,8 +212,6 @@ async fn evaluate( mappings.push(RedactionMapping { entity_id, location: entity.location.clone(), - original: original_value, - replacement: None, }); } @@ -165,15 +225,24 @@ async fn evaluate( (entries, mappings) } -fn find_matching_strategy<'a>( +/// All strategies that apply to `entity`, in rank order (best first). +/// +/// `strategies` is assumed to already be sorted by rank +/// (`StrategyPolicy::priority` ascending, then policy precedence, then +/// insertion order — see [`Policies::all_strategies`]); this function +/// filters by enabled + selector + conditions but preserves the input +/// ordering. +/// +/// [`Policies::all_strategies`]: nvisy_ontology::policy::Policies::all_strategies +fn matching_strategies<'a>( strategies: &[(Uuid, &'a StrategyPolicy)], entity: &Entity, document_labels: &[&str], metadata: &ContentMetadata, -) -> Option<(Uuid, &'a StrategyPolicy)> { +) -> Vec<(Uuid, &'a StrategyPolicy)> { strategies .iter() - .find(|(_, strategy)| { + .filter(|(_, strategy)| { if !strategy.enabled { return false; } @@ -188,6 +257,7 @@ fn find_matching_strategy<'a>( true }) .map(|&(id, s)| (id, s)) + .collect() } /// Extension trait for evaluating [`Condition`]s against document context. @@ -219,7 +289,7 @@ impl ConditionExt for Condition { #[cfg(test)] mod tests { use nvisy_ontology::entity::Entity; - use nvisy_ontology::policy::TextStrategy; + use nvisy_ontology::policy::{EntitySelector, TextStrategy}; use super::*; use crate::operation::Document; @@ -234,6 +304,29 @@ mod tests { Strategy::text(TextStrategy::Mask { mask_char: '*' }) } + fn rule(action: Action, priority: Option) -> StrategyPolicy { + StrategyPolicy { + selector: EntitySelector::all(), + action, + priority, + conditions: Vec::new(), + enabled: true, + } + } + + fn redact_rule(priority: Option) -> StrategyPolicy { + rule( + Action::Redact { + strategy: Strategy::text(TextStrategy::Remove), + }, + priority, + ) + } + + fn suppress_rule(priority: Option) -> StrategyPolicy { + rule(Action::Suppress, priority) + } + #[tokio::test] async fn skips_below_threshold() { let doc = Document::from_text("John").await; @@ -306,4 +399,109 @@ mod tests { .await; assert_eq!(entries[0].value.original, "secret-value"); } + + #[tokio::test] + async fn suppress_only_emits_suppressed_entry_no_mapping() { + let doc = Document::from_text("john").await; + let entities: Entities = vec![test_entity("john", 0.9)].into(); + let policy_id = Uuid::now_v7(); + let suppress = suppress_rule(Some(0)); + let strategies = vec![(policy_id, &suppress)]; + + let (entries, mappings) = evaluate( + &entities, + &strategies, + &defaults(), + 0.5, + &[], + &ContentMetadata::new(), + &doc, + ) + .await; + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].status, AuditEntryStatus::Suppressed); + assert_eq!(entries[0].policy_id, Some(policy_id)); + assert_eq!(entries[0].value.original, "john"); + assert!(entries[0].value.replacement.is_none()); + assert!(mappings.is_empty(), "no redaction_map entry for suppressed"); + } + + #[tokio::test] + async fn suppress_at_higher_priority_beats_redact() { + // Suppress at priority 0 (best) vs Redact at priority 10. + let doc = Document::from_text("john").await; + let entities: Entities = vec![test_entity("john", 0.9)].into(); + let suppress = suppress_rule(Some(0)); + let redact = redact_rule(Some(10)); + let strategies = vec![(Uuid::now_v7(), &suppress), (Uuid::now_v7(), &redact)]; + // Sort by priority so the rank order matches Policies::all_strategies. + let mut sorted = strategies.clone(); + sorted.sort_by_key(|(_, s)| s.priority()); + + let (entries, mappings) = evaluate( + &entities, + &sorted, + &defaults(), + 0.5, + &[], + &ContentMetadata::new(), + &doc, + ) + .await; + + assert_eq!(entries[0].status, AuditEntryStatus::Suppressed); + assert!(mappings.is_empty()); + } + + #[tokio::test] + async fn suppress_at_equal_priority_wins_tiebreak() { + // Suppress and Redact both at priority 0 — Suppress wins by the + // "same or higher" rule. + let doc = Document::from_text("john").await; + let entities: Entities = vec![test_entity("john", 0.9)].into(); + let suppress = suppress_rule(Some(0)); + let redact = redact_rule(Some(0)); + // Suppress listed second to prove rank, not insertion, decides. + let strategies = vec![(Uuid::now_v7(), &redact), (Uuid::now_v7(), &suppress)]; + + let (entries, _mappings) = evaluate( + &entities, + &strategies, + &defaults(), + 0.5, + &[], + &ContentMetadata::new(), + &doc, + ) + .await; + + assert_eq!(entries[0].status, AuditEntryStatus::Suppressed); + } + + #[tokio::test] + async fn redact_at_higher_priority_beats_suppress() { + // Redact at priority 0 (best) vs Suppress at priority 10: + // Suppress's rank > Redact's, so Redact wins. + let doc = Document::from_text("john").await; + let entities: Entities = vec![test_entity("john", 0.9)].into(); + let redact = redact_rule(Some(0)); + let suppress = suppress_rule(Some(10)); + let mut strategies = vec![(Uuid::now_v7(), &redact), (Uuid::now_v7(), &suppress)]; + strategies.sort_by_key(|(_, s)| s.priority()); + + let (entries, mappings) = evaluate( + &entities, + &strategies, + &defaults(), + 0.5, + &[], + &ContentMetadata::new(), + &doc, + ) + .await; + + assert_ne!(entries[0].status, AuditEntryStatus::Suppressed); + assert_eq!(mappings.len(), 1); + } } diff --git a/crates/nvisy-ontology/src/policy/strategy/audio.rs b/crates/nvisy-ontology/src/policy/strategy/audio.rs index 53bb0cf6..5bb0a45d 100644 --- a/crates/nvisy-ontology/src/policy/strategy/audio.rs +++ b/crates/nvisy-ontology/src/policy/strategy/audio.rs @@ -12,7 +12,6 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Default, PartialEq)] #[derive(Serialize, Deserialize, JsonSchema)] #[serde(tag = "method", rename_all = "snake_case")] -#[non_exhaustive] pub enum AudioStrategy { /// Replace with silence. #[default] diff --git a/crates/nvisy-ontology/src/policy/strategy/image.rs b/crates/nvisy-ontology/src/policy/strategy/image.rs index e315fe46..b67ea36b 100644 --- a/crates/nvisy-ontology/src/policy/strategy/image.rs +++ b/crates/nvisy-ontology/src/policy/strategy/image.rs @@ -25,7 +25,6 @@ fn default_block_size() -> u32 { #[derive(Debug, Clone, PartialEq)] #[derive(Serialize, Deserialize, JsonSchema)] #[serde(tag = "method", rename_all = "snake_case")] -#[non_exhaustive] pub enum ImageStrategy { /// Apply a gaussian blur. Blur { diff --git a/crates/nvisy-ontology/src/policy/strategy/mod.rs b/crates/nvisy-ontology/src/policy/strategy/mod.rs index 767041df..3f392fcd 100644 --- a/crates/nvisy-ontology/src/policy/strategy/mod.rs +++ b/crates/nvisy-ontology/src/policy/strategy/mod.rs @@ -144,7 +144,6 @@ impl Strategy { /// The action a strategy policy performs when it matches an entity. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "action", rename_all = "snake_case")] -#[non_exhaustive] pub enum Action { /// Apply a redaction to the matched entity. Redact { diff --git a/crates/nvisy-ontology/src/policy/strategy/text.rs b/crates/nvisy-ontology/src/policy/strategy/text.rs index 20ca0f16..7d5903aa 100644 --- a/crates/nvisy-ontology/src/policy/strategy/text.rs +++ b/crates/nvisy-ontology/src/policy/strategy/text.rs @@ -19,7 +19,6 @@ fn default_mask_char() -> char { #[derive(Debug, Clone, PartialEq)] #[derive(Serialize, Deserialize, JsonSchema)] #[serde(tag = "method", rename_all = "snake_case")] -#[non_exhaustive] pub enum TextStrategy { /// Replace characters with a mask character. Mask { diff --git a/crates/nvisy-ontology/src/provenance/entry.rs b/crates/nvisy-ontology/src/provenance/entry.rs index 2355f057..7bc6d822 100644 --- a/crates/nvisy-ontology/src/provenance/entry.rs +++ b/crates/nvisy-ontology/src/provenance/entry.rs @@ -26,6 +26,13 @@ pub enum AuditEntryStatus { Partial, /// Redaction is pending (not yet applied). Pending, + /// Entity was deliberately not redacted because a matching + /// [`Action::Suppress`] rule won out over any matching Redact rule + /// (or no Redact rule matched). The entry records the suppressing + /// policy and the original value so the suppression is auditable. + /// + /// [`Action::Suppress`]: crate::policy::Action::Suppress + Suppressed, } /// A per-entity audit entry: what strategy was chosen, what the diff --git a/crates/nvisy-ontology/src/provenance/redaction_map.rs b/crates/nvisy-ontology/src/provenance/redaction_map.rs index e881f145..10f25daf 100644 --- a/crates/nvisy-ontology/src/provenance/redaction_map.rs +++ b/crates/nvisy-ontology/src/provenance/redaction_map.rs @@ -1,11 +1,16 @@ -//! Redaction map: entity-to-value mapping for tracking original and -//! replacement content across all modalities. +//! Redaction map: entity-to-location index for the redaction phase. //! -//! The [`RedactionMap`] is created during the redaction phase and -//! contains the sensitive original values that are stripped from the -//! [`Audit`] response. It is stored separately and access-controlled. +//! The [`RedactionMap`] records which entities the pipeline touched +//! and where they were located. Original and replacement *values* +//! live on the corresponding [`AuditEntry::value`] (see +//! [`RedactionValue`]) — the map is a thin index, not a value store. //! -//! [`Audit`]: super::Audit +//! A future extension may pair this index with a separate blob store +//! to support reversibility for image/audio modalities — see +//! [issue #151](https://github.com/nvisycom/runtime/issues/151). +//! +//! [`AuditEntry::value`]: super::AuditEntry::value +//! [`RedactionValue`]: super::RedactionValue use derive_more::{Deref, DerefMut}; use schemars::JsonSchema; @@ -14,8 +19,13 @@ use uuid::Uuid; use crate::entity::Location; -/// A single entry in the redaction map, tracking the original and -/// replacement values for one entity. +/// One entry in the redaction map: the entity touched and where it +/// was located in the document. +/// +/// Values (original / replacement) are not stored here; consult the +/// corresponding [`AuditEntry`] by `entity_id`. +/// +/// [`AuditEntry`]: super::AuditEntry #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct RedactionMapping { @@ -23,21 +33,16 @@ pub struct RedactionMapping { pub entity_id: Uuid, /// Where in the document the entity was found. pub location: Location, - /// The original sensitive value at this location. - pub original: String, - /// The replacement value after redaction, if applied. - #[serde(skip_serializing_if = "Option::is_none")] - pub replacement: Option, } -/// Maps entity IDs to their original and replacement values. +/// Per-entity redaction lineage index. /// /// Created during the redaction phase (phase 4) by the redaction -/// evaluator and applicator. Contains sensitive data — must not be -/// included in the public [`Audit`] response. Stored separately -/// under access control. +/// evaluator. Records which entities were considered for redaction +/// and where they lived in the document. Sensitive values are not +/// duplicated here — they live on the matching [`AuditEntry`]. /// -/// [`Audit`]: super::Audit +/// [`AuditEntry`]: super::AuditEntry #[derive(Debug, Clone, Default, Deref, DerefMut)] #[derive(Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -53,22 +58,6 @@ impl RedactionMap { pub fn new() -> Self { Self::default() } - - /// Look up the original value for a given entity. - pub fn original(&self, entity_id: Uuid) -> Option<&str> { - self.entries - .iter() - .find(|e| e.entity_id == entity_id) - .map(|e| e.original.as_str()) - } - - /// Look up the replacement value for a given entity. - pub fn replacement(&self, entity_id: Uuid) -> Option<&str> { - self.entries - .iter() - .find(|e| e.entity_id == entity_id) - .and_then(|e| e.replacement.as_deref()) - } } #[cfg(test)] @@ -76,26 +65,23 @@ mod tests { use super::*; use crate::entity::{Location, TextLocation}; - fn mapping(id: Uuid, original: &str, replacement: Option<&str>) -> RedactionMapping { + fn mapping(id: Uuid) -> RedactionMapping { RedactionMapping { entity_id: id, location: Location::from(TextLocation { start_offset: 0, - end_offset: original.len(), + end_offset: 4, ..Default::default() }), - original: original.to_string(), - replacement: replacement.map(String::from), } } #[test] - fn push_and_lookup() { + fn push_and_count() { let id = Uuid::now_v7(); let mut map = RedactionMap::new(); - map.push(mapping(id, "John", Some("[NAME]"))); + map.push(mapping(id)); assert_eq!(map.len(), 1); - assert_eq!(map.original(id), Some("John")); - assert_eq!(map.replacement(id), Some("[NAME]")); + assert_eq!(map.entries[0].entity_id, id); } } From 602c7302e75488fabe734050bc40fc1ad7e5af59 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Wed, 20 May 2026 00:05:37 +0200 Subject: [PATCH 3/5] chore: lint-style doc fixup Co-Authored-By: Claude Opus 4.7 --- crates/nvisy-ontology/src/provenance/redaction_map.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/nvisy-ontology/src/provenance/redaction_map.rs b/crates/nvisy-ontology/src/provenance/redaction_map.rs index 10f25daf..79d56234 100644 --- a/crates/nvisy-ontology/src/provenance/redaction_map.rs +++ b/crates/nvisy-ontology/src/provenance/redaction_map.rs @@ -6,7 +6,7 @@ //! [`RedactionValue`]) — the map is a thin index, not a value store. //! //! A future extension may pair this index with a separate blob store -//! to support reversibility for image/audio modalities — see +//! to support reversibility for image/audio modalities: see //! [issue #151](https://github.com/nvisycom/runtime/issues/151). //! //! [`AuditEntry::value`]: super::AuditEntry::value From c9d61715aa869ee363e6211ac05764237f227217 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Wed, 20 May 2026 02:15:46 +0200 Subject: [PATCH 4/5] =?UTF-8?q?refactor(engine):=20operation/=20reshape=20?= =?UTF-8?q?=E2=80=94=20detection=20folder,=20*Op=20=E2=86=92=20bare=20name?= =?UTF-8?q?s,=20file=20rename=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure of operation/ for consistent naming and clearer module layout. No behavioural change. Detection module: - Combine entity_recognition and pattern_recognition under operation/detection/{entity_recognition,pattern_recognition,pattern_engine,rebase_entities}.rs - New private RebaseEntities extension trait on Entities (formerly EntitiesExt) carries the per-span → document-relative offset shift shared by NER and pattern detection. - New PatternEngineRef wraps the static singleton vs config-built engine resolution that PatternRecognition::new used to inline; PatternRecognition::new now just calls PatternEngineRef::new(cfg). - Document::collect_text_spans hides the locations-then-read loop both detectors used to inline. Deduplication file renames (verb_noun pattern): - grouping.rs → group_entities.rs + group_key.rs (split the trait and the hash-key type). - strategy.rs → fuse_entities.rs (DeduplicationStrategyExt::fuse) - conflict.rs → resolve_conflicts.rs (ConflictResolutionExt::resolve) - calibration.rs → calibrate_entities.rs (CalibrationExt) Engine-wide *Op suffix removed: - ImportFileOp, ExportFileOp, ExtractionOp, GenerateContextOp, ValidationOp, DeduplicationOp, RedactionOp → ImportFile, ExportFile, Extraction, GenerateContext, Validation, Deduplication, Redaction. - Workflow config types of the same name aliased at import sites (e.g. `use nvisy_ontology::workflow::Validation as ValidationConfig`). - validation.rs renamed to validate.rs to match verb pattern. Visibility tightening: - `pub mod envelope` → `mod envelope` (SharedData re-exported at operation::SharedData; consumers switched to the shorter path). - `pub(crate) mod redaction` → `mod redaction` (Redaction re-exported at operation::Redaction). Net: the operation/ tree now has consistent naming (verb_noun.rs for trait helpers, verb.rs for ops, noun.rs for plain types), private submodules with curated re-exports, and a detection/ folder mirroring the deduplication/ shape. Co-Authored-By: Claude Opus 4.7 --- .../{calibration.rs => calibrate_entities.rs} | 0 .../{strategy.rs => fuse_entities.rs} | 2 +- .../{grouping.rs => group_entities.rs} | 34 +------ .../src/operation/deduplication/group_key.rs | 41 +++++++++ .../src/operation/deduplication/mod.rs | 40 +++++---- .../{conflict.rs => resolve_conflicts.rs} | 0 .../operation/detection/entity_recognition.rs | 38 +++----- .../src/operation/detection/mod.rs | 16 ++-- .../src/operation/detection/pattern_engine.rs | 49 ++++++++++ .../detection/pattern_recognition.rs | 89 +++++-------------- .../operation/detection/rebase_entities.rs | 39 ++++++++ .../src/operation/envelope/document.rs | 17 +++- .../src/operation/envelope/mod.rs | 4 +- .../envelope/{shared.rs => shared_data.rs} | 0 .../nvisy-engine/src/operation/export_file.rs | 4 +- .../src/operation/extraction/mod.rs | 24 ++--- .../src/operation/extraction/speech.rs | 6 +- .../src/operation/extraction/vision.rs | 6 +- .../src/operation/generate_context.rs | 14 +-- .../nvisy-engine/src/operation/import_file.rs | 6 +- crates/nvisy-engine/src/operation/mod.rs | 23 ++--- .../src/operation/redaction/evaluate.rs | 14 +-- .../src/operation/redaction/mod.rs | 2 +- .../operation/{validation.rs => validate.rs} | 12 +-- .../nvisy-engine/src/pipeline/orchestrator.rs | 29 +++--- crates/nvisy-engine/src/pipeline/run.rs | 2 +- .../src/utility/compression/mod.rs | 4 +- .../src/utility/encryption/service.rs | 7 +- 28 files changed, 287 insertions(+), 235 deletions(-) rename crates/nvisy-engine/src/operation/deduplication/{calibration.rs => calibrate_entities.rs} (100%) rename crates/nvisy-engine/src/operation/deduplication/{strategy.rs => fuse_entities.rs} (99%) rename crates/nvisy-engine/src/operation/deduplication/{grouping.rs => group_entities.rs} (84%) create mode 100644 crates/nvisy-engine/src/operation/deduplication/group_key.rs rename crates/nvisy-engine/src/operation/deduplication/{conflict.rs => resolve_conflicts.rs} (100%) create mode 100644 crates/nvisy-engine/src/operation/detection/pattern_engine.rs create mode 100644 crates/nvisy-engine/src/operation/detection/rebase_entities.rs rename crates/nvisy-engine/src/operation/envelope/{shared.rs => shared_data.rs} (100%) rename crates/nvisy-engine/src/operation/{validation.rs => validate.rs} (95%) diff --git a/crates/nvisy-engine/src/operation/deduplication/calibration.rs b/crates/nvisy-engine/src/operation/deduplication/calibrate_entities.rs similarity index 100% rename from crates/nvisy-engine/src/operation/deduplication/calibration.rs rename to crates/nvisy-engine/src/operation/deduplication/calibrate_entities.rs diff --git a/crates/nvisy-engine/src/operation/deduplication/strategy.rs b/crates/nvisy-engine/src/operation/deduplication/fuse_entities.rs similarity index 99% rename from crates/nvisy-engine/src/operation/deduplication/strategy.rs rename to crates/nvisy-engine/src/operation/deduplication/fuse_entities.rs index c50d29a9..b7c94110 100644 --- a/crates/nvisy-engine/src/operation/deduplication/strategy.rs +++ b/crates/nvisy-engine/src/operation/deduplication/fuse_entities.rs @@ -10,7 +10,7 @@ use std::collections::HashSet; use nvisy_ontology::entity::{Entities, Entity, RefinementMethod}; use nvisy_ontology::workflow::{DeduplicationStrategy, GroupingCriteria}; -use super::grouping::GroupEntities; +use super::group_entities::GroupEntities; use super::span_size::SpanSize; use crate::operation::Document; diff --git a/crates/nvisy-engine/src/operation/deduplication/grouping.rs b/crates/nvisy-engine/src/operation/deduplication/group_entities.rs similarity index 84% rename from crates/nvisy-engine/src/operation/deduplication/grouping.rs rename to crates/nvisy-engine/src/operation/deduplication/group_entities.rs index 7a538805..90644449 100644 --- a/crates/nvisy-engine/src/operation/deduplication/grouping.rs +++ b/crates/nvisy-engine/src/operation/deduplication/group_entities.rs @@ -17,40 +17,10 @@ use std::mem; use nvisy_ontology::entity::{Entities, Entity, EntityKind, Overlap}; use nvisy_ontology::workflow::GroupingCriteria; +use super::group_key::GroupKey; use crate::operation::Document; -const TARGET: &str = "nvisy_engine::op::deduplication::grouping"; - -/// Hash key for the first grouping phase. -/// -/// For [`Strict`] and [`Narrowing`]/[`Widening`] this stores the exact -/// value; for [`Normalized`] it stores the lowercased, trimmed form. -/// -/// [`Strict`]: GroupingCriteria::Strict -/// [`Narrowing`]: GroupingCriteria::Narrowing -/// [`Widening`]: GroupingCriteria::Widening -/// [`Normalized`]: GroupingCriteria::Normalized -#[derive(Hash, PartialEq, Eq)] -struct GroupKey { - kind: EntityKind, - value: String, -} - -impl GroupKey { - async fn new(entity: &Entity, criteria: GroupingCriteria, document: &Document) -> Self { - // Entities without a text value (e.g. image bounding boxes) - // get a unique sentinel so they don't all bucket together. - // They will still be grouped by location overlap in phase 2. - let value = match document.value_at(&entity.location).await { - Some(v) => criteria.bucket_value(&v), - None => entity.id.to_string(), - }; - Self { - kind: entity.entity_kind, - value, - } - } -} +const TARGET: &str = "nvisy_engine::op::deduplication::group_entities"; /// Extension trait that groups entities for deduplication. pub(super) trait GroupEntities { diff --git a/crates/nvisy-engine/src/operation/deduplication/group_key.rs b/crates/nvisy-engine/src/operation/deduplication/group_key.rs new file mode 100644 index 00000000..2f66e8d5 --- /dev/null +++ b/crates/nvisy-engine/src/operation/deduplication/group_key.rs @@ -0,0 +1,41 @@ +//! Hash key for the first grouping phase of deduplication. + +use nvisy_ontology::entity::{Entity, EntityKind}; +use nvisy_ontology::workflow::GroupingCriteria; + +use crate::operation::Document; + +/// Hash key for the first grouping phase. +/// +/// For [`Strict`] and [`Narrowing`]/[`Widening`] this stores the exact +/// value; for [`Normalized`] it stores the lowercased, trimmed form. +/// +/// [`Strict`]: GroupingCriteria::Strict +/// [`Narrowing`]: GroupingCriteria::Narrowing +/// [`Widening`]: GroupingCriteria::Widening +/// [`Normalized`]: GroupingCriteria::Normalized +#[derive(Hash, PartialEq, Eq)] +pub(super) struct GroupKey { + pub(super) kind: EntityKind, + pub(super) value: String, +} + +impl GroupKey { + pub(super) async fn new( + entity: &Entity, + criteria: GroupingCriteria, + document: &Document, + ) -> Self { + // Entities without a text value (e.g. image bounding boxes) + // get a unique sentinel so they don't all bucket together. + // They will still be grouped by location overlap in phase 2. + let value = match document.value_at(&entity.location).await { + Some(v) => criteria.bucket_value(&v), + None => entity.id.to_string(), + }; + Self { + kind: entity.entity_kind, + value, + } + } +} diff --git a/crates/nvisy-engine/src/operation/deduplication/mod.rs b/crates/nvisy-engine/src/operation/deduplication/mod.rs index c35f48a1..fb99506b 100644 --- a/crates/nvisy-engine/src/operation/deduplication/mod.rs +++ b/crates/nvisy-engine/src/operation/deduplication/mod.rs @@ -21,23 +21,25 @@ //! [`DeduplicationStrategy`]: nvisy_ontology::workflow::DeduplicationStrategy //! [`ConflictResolution`]: nvisy_ontology::workflow::ConflictResolution -mod calibration; -mod conflict; -mod grouping; +mod calibrate_entities; +mod fuse_entities; +mod group_entities; +mod group_key; +mod resolve_conflicts; pub(crate) mod span_size; -mod strategy; use std::mem; use nvisy_core::Result; use nvisy_ontology::entity::Entities; use nvisy_ontology::workflow::{ - CalibrationMap, ConflictResolution, Deduplication, DeduplicationStrategy, GroupingCriteria, + CalibrationMap, ConflictResolution, Deduplication as DeduplicationConfig, + DeduplicationStrategy, GroupingCriteria, }; -use self::calibration::CalibrationExt; -use self::conflict::ConflictResolutionExt; -use self::strategy::DeduplicationStrategyExt; +use self::calibrate_entities::CalibrationExt; +use self::fuse_entities::DeduplicationStrategyExt; +use self::resolve_conflicts::ConflictResolutionExt; use crate::operation::{DocumentEnvelope, Operation}; const TARGET: &str = "nvisy_engine::op::deduplication"; @@ -46,7 +48,7 @@ const TARGET: &str = "nvisy_engine::op::deduplication"; /// threshold filtering operation. /// /// Created from the [`Deduplication`] graph node configuration. -pub struct DeduplicationOp { +pub struct Deduplication { grouping: GroupingCriteria, strategy: DeduplicationStrategy, calibration: CalibrationMap, @@ -54,9 +56,9 @@ pub struct DeduplicationOp { conflict_resolution: ConflictResolution, } -impl DeduplicationOp { - /// Create from a [`Deduplication`] graph node config. - pub fn new(cfg: &Deduplication) -> Self { +impl Deduplication { + /// Create from a [`DeduplicationConfig`] graph node config. + pub fn new(cfg: &DeduplicationConfig) -> Self { tracing::debug!( target: TARGET, grouping = ?cfg.grouping, @@ -118,7 +120,7 @@ impl DeduplicationOp { } } -impl Operation for DeduplicationOp { +impl Operation for Deduplication { async fn execute(&self, envelope: &mut DocumentEnvelope) -> Result<()> { if !envelope.audit.entities.is_empty() { tracing::debug!( @@ -460,11 +462,11 @@ mod tests { #[tokio::test] async fn confidence_threshold_filters() { let doc = Document::from_text("John......Jane").await; - let cfg = Deduplication { + let cfg = DeduplicationConfig { confidence_threshold: Some(0.85), ..Default::default() }; - let op = DeduplicationOp::new(&cfg); + let op = Deduplication::new(&cfg); let entities: Entities = vec![ Entity::test_builder(0, 4).test_build(), Entity::test_builder(10, 14) @@ -481,11 +483,11 @@ mod tests { #[tokio::test] async fn full_pipeline() { let doc = Document::from_text(TEST_TEXT).await; - let cfg = Deduplication { + let cfg = DeduplicationConfig { strategy: DeduplicationStrategy::MaxConfidence, ..Default::default() }; - let op = DeduplicationOp::new(&cfg); + let op = Deduplication::new(&cfg); let entities: Entities = vec![ Entity::test_builder(0, 4).with_confidence(0.7).test_build(), Entity::test_builder(0, 4).with_confidence(0.8).test_build(), @@ -507,8 +509,8 @@ mod tests { #[tokio::test] async fn empty_input() { let doc = Document::from_text("").await; - let cfg = Deduplication::default(); - let op = DeduplicationOp::new(&cfg); + let cfg = DeduplicationConfig::default(); + let op = Deduplication::new(&cfg); let result = op.deduplicate(Entities::new(), &doc).await; assert!(result.is_empty()); } diff --git a/crates/nvisy-engine/src/operation/deduplication/conflict.rs b/crates/nvisy-engine/src/operation/deduplication/resolve_conflicts.rs similarity index 100% rename from crates/nvisy-engine/src/operation/deduplication/conflict.rs rename to crates/nvisy-engine/src/operation/deduplication/resolve_conflicts.rs diff --git a/crates/nvisy-engine/src/operation/detection/entity_recognition.rs b/crates/nvisy-engine/src/operation/detection/entity_recognition.rs index c382bebc..e5dd05dc 100644 --- a/crates/nvisy-engine/src/operation/detection/entity_recognition.rs +++ b/crates/nvisy-engine/src/operation/detection/entity_recognition.rs @@ -6,11 +6,12 @@ use nvisy_codec::Span; use nvisy_codec::handler::TextData; use nvisy_core::{Error, ErrorKind, Result}; -use nvisy_ontology::entity::{Entity, TextLocation}; +use nvisy_ontology::entity::{Entities, TextLocation}; use nvisy_ontology::workflow::NerDetection; use nvisy_provider::agent::{DetectionConfig, NerAgent}; use nvisy_provider::http::HttpClient; +use super::rebase_entities::RebaseEntities; use crate::operation::{DocumentEnvelope, Operation}; use crate::pipeline::RuntimeConfig; @@ -18,12 +19,12 @@ const TARGET: &str = "nvisy_engine::op::entity_recognition"; /// NER-based entity recognition. Wraps an [`NerAgent`] which manages /// coreference state internally between successive text spans. -pub struct EntityRecognitionOp { +pub struct EntityRecognition { agent: NerAgent, config: DetectionConfig, } -impl EntityRecognitionOp { +impl EntityRecognition { /// Build from graph config and runtime dependencies. pub async fn new( cfg: &NerDetection, @@ -47,43 +48,28 @@ impl EntityRecognitionOp { Ok(Self { agent, config }) } - async fn detect(&self, spans: &[Span]) -> Result> { + async fn detect(&self, spans: &[Span]) -> Result { tracing::debug!(target: TARGET, span_count = spans.len(), "running NER"); - let mut entities = Vec::new(); + let mut entities = Entities::new(); for span in spans { - let detected = self + let detected: Entities = self .agent .detect_entities(span.data.as_str(), &self.config) .await - .map_err(|e| Error::runtime(e.to_string(), "ner-agent", e.is_retryable()))?; + .map_err(|e| Error::runtime(e.to_string(), "ner-agent", e.is_retryable()))? + .into(); - for mut entity in detected { - // Adjust entity's text location offsets to be relative - // to the document (not the span) by adding the span's - // start offset. - if let nvisy_ontology::entity::Location::Text(ref mut elem) = entity.location { - elem.start_offset += span.location.start_offset; - elem.end_offset += span.location.start_offset; - } - - entities.push(entity); - } + entities.extend(detected.rebase_offsets(span)); } Ok(entities) } } -impl Operation for EntityRecognitionOp { +impl Operation for EntityRecognition { async fn execute(&self, envelope: &mut DocumentEnvelope) -> Result<()> { - let locations = envelope.document.collect_text_locations().await; - let mut spans: Vec> = Vec::with_capacity(locations.len()); - for located in locations { - if let Some(data) = envelope.document.read_text(&located.location).await { - spans.push(Span::from_located(located, data)); - } - } + let spans = envelope.document.collect_text_spans().await; if !spans.is_empty() { let detected = self.detect(&spans).await?; tracing::debug!( diff --git a/crates/nvisy-engine/src/operation/detection/mod.rs b/crates/nvisy-engine/src/operation/detection/mod.rs index 89116689..4907e777 100644 --- a/crates/nvisy-engine/src/operation/detection/mod.rs +++ b/crates/nvisy-engine/src/operation/detection/mod.rs @@ -1,11 +1,15 @@ -//! Detection operations: NER (language model) and pattern matching. +//! Entity recognition operations: NER (language-model) and pattern +//! matching. //! -//! Both methods detect entities in extracted text. They are logically -//! independent and run sequentially per document within the detection -//! phase. +//! Both methods detect entities in extracted text and run sequentially +//! within the detection phase. They share the [`RebaseEntities`] +//! extension trait for shifting per-span offsets onto document +//! coordinates. mod entity_recognition; +mod pattern_engine; mod pattern_recognition; +mod rebase_entities; -pub(crate) use self::entity_recognition::EntityRecognitionOp; -pub(crate) use self::pattern_recognition::PatternRecognitionOp; +pub(crate) use self::entity_recognition::EntityRecognition; +pub(crate) use self::pattern_recognition::PatternRecognition; diff --git a/crates/nvisy-engine/src/operation/detection/pattern_engine.rs b/crates/nvisy-engine/src/operation/detection/pattern_engine.rs new file mode 100644 index 00000000..1d8a0520 --- /dev/null +++ b/crates/nvisy-engine/src/operation/detection/pattern_engine.rs @@ -0,0 +1,49 @@ +//! [`PatternEngineRef`]: thin wrapper that holds either a borrowed reference +//! to the global [`PatternEngine`] singleton or an owned engine built +//! from custom config, exposed uniformly via [`Deref`]. +//! +//! [`PatternEngine`]: nvisy_pattern::PatternEngine + +use std::ops::Deref; + +use nvisy_ontology::workflow::PatternDetection; + +/// Holds either a borrowed reference to the global singleton or an +/// owned engine built from custom config. +pub(super) enum PatternEngineRef { + Shared(&'static nvisy_pattern::PatternEngine), + Owned(nvisy_pattern::PatternEngine), +} + +impl PatternEngineRef { + /// Resolve a [`PatternDetection`] config into either a borrowed + /// reference to the shared singleton (when the config is the + /// default empty shape) or a freshly built owned engine (when the + /// config names patterns or sets a confidence threshold). + pub(super) fn new(cfg: &PatternDetection) -> Self { + let needs_custom = !cfg.patterns.is_empty() || cfg.confidence_threshold.is_some(); + if !needs_custom { + return Self::Shared(nvisy_pattern::PatternEngine::instance()); + } + let mut builder = nvisy_pattern::PatternEngine::builder(); + if !cfg.patterns.is_empty() { + let names: Vec<&str> = cfg.patterns.iter().map(String::as_str).collect(); + builder = builder.with_patterns(&names); + } + if let Some(threshold) = cfg.confidence_threshold { + builder = builder.with_confidence_threshold(threshold); + } + Self::Owned(builder.build().expect("pattern engine must compile")) + } +} + +impl Deref for PatternEngineRef { + type Target = nvisy_pattern::PatternEngine; + + fn deref(&self) -> &Self::Target { + match self { + Self::Shared(e) => e, + Self::Owned(e) => e, + } + } +} diff --git a/crates/nvisy-engine/src/operation/detection/pattern_recognition.rs b/crates/nvisy-engine/src/operation/detection/pattern_recognition.rs index 7b3b0f06..d76b8eda 100644 --- a/crates/nvisy-engine/src/operation/detection/pattern_recognition.rs +++ b/crates/nvisy-engine/src/operation/detection/pattern_recognition.rs @@ -1,94 +1,51 @@ //! Pattern recognition operation. //! -//! Runs at **phase 2** alongside [`EntityRecognitionOp`]. Detects entities +//! Runs at **phase 2** alongside [`EntityRecognition`]. Detects entities //! using deterministic rules: regular expressions, checksums, and //! dictionary lookups. //! -//! [`EntityRecognitionOp`]: crate::operation::EntityRecognitionOp - -use std::ops::Deref; +//! [`EntityRecognition`]: crate::operation::EntityRecognition use nvisy_codec::Span; use nvisy_codec::handler::TextData; use nvisy_core::Result; -use nvisy_ontology::entity::{Entity, TextLocation}; +use nvisy_ontology::entity::{Entities, TextLocation}; use nvisy_ontology::workflow::PatternDetection; +use super::pattern_engine::PatternEngineRef; +use super::rebase_entities::RebaseEntities; use crate::operation::{DocumentEnvelope, Operation}; const TARGET: &str = "nvisy_engine::op::pattern_recognition"; -/// Holds either a borrowed reference to the global singleton or an -/// owned engine built from custom config. -enum EngineRef { - Shared(&'static nvisy_pattern::PatternEngine), - Owned(nvisy_pattern::PatternEngine), -} - -impl Deref for EngineRef { - type Target = nvisy_pattern::PatternEngine; - - fn deref(&self) -> &Self::Target { - match self { - Self::Shared(e) => e, - Self::Owned(e) => e, - } - } -} - /// Pattern-based entity recognition using regex and dictionary matching. -pub struct PatternRecognitionOp { - engine: EngineRef, +pub struct PatternRecognition { + engine: PatternEngineRef, } -impl PatternRecognitionOp { - /// Create from graph config. - /// - /// When the config specifies pattern names or a confidence threshold, - /// a custom engine is built. Otherwise the default singleton is used. +impl PatternRecognition { + /// Create from graph config. Resolution between the shared default + /// engine and a custom-built one lives on [`PatternEngineRef::new`]. pub fn new(cfg: &PatternDetection) -> Self { - let needs_custom = !cfg.patterns.is_empty() || cfg.confidence_threshold.is_some(); - - let engine = if needs_custom { - let mut builder = nvisy_pattern::PatternEngine::builder(); - if !cfg.patterns.is_empty() { - let names: Vec<&str> = cfg.patterns.iter().map(String::as_str).collect(); - builder = builder.with_patterns(&names); - } - if let Some(threshold) = cfg.confidence_threshold { - builder = builder.with_confidence_threshold(threshold); - } - EngineRef::Owned(builder.build().expect("pattern engine must compile")) - } else { - EngineRef::Shared(nvisy_pattern::PatternEngine::instance()) - }; - + let engine = PatternEngineRef::new(cfg); tracing::debug!( target: TARGET, - custom = needs_custom, patterns = cfg.patterns.len(), "created pattern recognition operation", ); - Self { engine } } - fn scan(&self, spans: &[Span]) -> Vec { + fn scan(&self, spans: &[Span]) -> Entities { let scan_ctx = nvisy_pattern::ScanContext::default(); - let mut entities = Vec::new(); + let mut entities = Entities::new(); for span in spans { - let detected = self.engine.scan_entities(span.data.as_str(), &scan_ctx); - - for mut entity in detected { - // Adjust offsets to be document-relative. - if let nvisy_ontology::entity::Location::Text(ref mut elem) = entity.location { - elem.start_offset += span.location.start_offset; - elem.end_offset += span.location.start_offset; - } - - entities.push(entity); - } + let detected: Entities = self + .engine + .scan_entities(span.data.as_str(), &scan_ctx) + .into(); + entities.extend(detected.rebase_offsets(span)); } tracing::info!( @@ -102,15 +59,9 @@ impl PatternRecognitionOp { } } -impl Operation for PatternRecognitionOp { +impl Operation for PatternRecognition { async fn execute(&self, envelope: &mut DocumentEnvelope) -> Result<()> { - let locations = envelope.document.collect_text_locations().await; - let mut spans: Vec> = Vec::with_capacity(locations.len()); - for located in locations { - if let Some(data) = envelope.document.read_text(&located.location).await { - spans.push(Span::from_located(located, data)); - } - } + let spans = envelope.document.collect_text_spans().await; if !spans.is_empty() { let detected = self.scan(&spans); tracing::debug!( diff --git a/crates/nvisy-engine/src/operation/detection/rebase_entities.rs b/crates/nvisy-engine/src/operation/detection/rebase_entities.rs new file mode 100644 index 00000000..5e09ba17 --- /dev/null +++ b/crates/nvisy-engine/src/operation/detection/rebase_entities.rs @@ -0,0 +1,39 @@ +//! [`RebaseEntities`]: extension trait that shifts per-span byte +//! offsets on every text-located entity into document-relative offsets. +//! +//! Detection backends (NER agent, pattern engine) receive a span's +//! text in isolation, so the entities they emit carry offsets relative +//! to the span (`0..span.len()`). The pipeline stores entities at +//! document-relative offsets, so we shift before appending. + +use nvisy_codec::Span; +use nvisy_codec::handler::TextData; +use nvisy_ontology::entity::{Entities, Location, TextLocation}; + +/// Extension trait on [`Entities`] for the per-span → document-relative +/// offset shift detection ops apply to backend output. +pub(crate) trait RebaseEntities { + /// Translate per-span byte offsets on every text-located entity + /// into document-relative offsets by adding + /// `span.location.start_offset`. + /// + /// Non-text locations pass through unchanged: detection backends + /// only emit text entities today, but the helper is total so + /// future image or audio backends compose cleanly. + fn rebase_offsets(self, span: &Span) -> Self; +} + +impl RebaseEntities for Entities { + fn rebase_offsets(self, span: &Span) -> Self { + let shift = span.location.start_offset; + self.into_iter() + .map(|mut entity| { + if let Location::Text(ref mut loc) = entity.location { + loc.start_offset += shift; + loc.end_offset += shift; + } + entity + }) + .collect() + } +} diff --git a/crates/nvisy-engine/src/operation/envelope/document.rs b/crates/nvisy-engine/src/operation/envelope/document.rs index 7f5faf31..6b60b85a 100644 --- a/crates/nvisy-engine/src/operation/envelope/document.rs +++ b/crates/nvisy-engine/src/operation/envelope/document.rs @@ -12,7 +12,7 @@ use nvisy_codec::handler::{ AudioData, AudioRedaction, ImageData, ImageRedaction, Redactions, TabularRedaction, TextData, TextRedaction, }; -use nvisy_codec::{ContentHandle, Located}; +use nvisy_codec::{ContentHandle, Located, Span}; use nvisy_core::Error; use nvisy_core::content::{ContentData, ContentMetadata, ContentSource}; use nvisy_core::media::DocumentType; @@ -93,6 +93,21 @@ impl Document { self.handle.read_text(location).await } + /// Collect every text location together with its data, skipping + /// locations the handler can't read. Used by detection ops that + /// scan extracted text spans without caring about the underlying + /// streaming machinery. + pub async fn collect_text_spans(&self) -> Vec> { + let locations = self.collect_text_locations().await; + let mut spans = Vec::with_capacity(locations.len()); + for located in locations { + if let Some(data) = self.read_text(&located.location).await { + spans.push(Span::from_located(located, data)); + } + } + spans + } + /// Read the cell value at the given tabular location. pub async fn read_tabular(&self, location: &TabularLocation) -> Option { self.handle.read_tabular(location).await diff --git a/crates/nvisy-engine/src/operation/envelope/mod.rs b/crates/nvisy-engine/src/operation/envelope/mod.rs index 798896c6..bed48fe4 100644 --- a/crates/nvisy-engine/src/operation/envelope/mod.rs +++ b/crates/nvisy-engine/src/operation/envelope/mod.rs @@ -32,10 +32,10 @@ use nvisy_ontology::entity::{Annotations, Entity}; use nvisy_ontology::provenance::{Audit, RedactionMap}; mod document; -mod shared; +mod shared_data; pub use self::document::Document; -pub use self::shared::SharedData; +pub use self::shared_data::SharedData; /// Per-document state that flows through the entire pipeline. /// diff --git a/crates/nvisy-engine/src/operation/envelope/shared.rs b/crates/nvisy-engine/src/operation/envelope/shared_data.rs similarity index 100% rename from crates/nvisy-engine/src/operation/envelope/shared.rs rename to crates/nvisy-engine/src/operation/envelope/shared_data.rs diff --git a/crates/nvisy-engine/src/operation/export_file.rs b/crates/nvisy-engine/src/operation/export_file.rs index 10c28b2f..b0ce6c27 100644 --- a/crates/nvisy-engine/src/operation/export_file.rs +++ b/crates/nvisy-engine/src/operation/export_file.rs @@ -29,13 +29,13 @@ const TARGET: &str = "nvisy_engine::op::export_file"; /// /// [`Operation`]: crate::operation::Operation #[derive(Default)] -pub struct ExportFileOp { +pub struct ExportFile { encryption: Option, compression: Option, content_ids: Vec, } -impl ExportFileOp { +impl ExportFile { pub fn new() -> Self { Self::default() } diff --git a/crates/nvisy-engine/src/operation/extraction/mod.rs b/crates/nvisy-engine/src/operation/extraction/mod.rs index fefb0f90..db8d4c7c 100644 --- a/crates/nvisy-engine/src/operation/extraction/mod.rs +++ b/crates/nvisy-engine/src/operation/extraction/mod.rs @@ -1,6 +1,6 @@ //! Extraction operations: visual (OCR), audial (STT), and text. //! -//! [`ExtractionOp`] is the single entry point for the extraction phase. +//! [`Extraction`] is the single entry point for the extraction phase. //! It runs all applicable modalities based on the document type, //! using configuration from the [`Extraction`] graph node. //! @@ -15,11 +15,11 @@ mod vision; use nvisy_codec::ContentHandle; use nvisy_core::Result; -use nvisy_ontology::workflow::Extraction; +use nvisy_ontology::workflow::Extraction as ExtractionConfig; use nvisy_provider::http::HttpClient; -use self::speech::AudialExtractionOp; -use self::vision::VisualExtractionOp; +use self::speech::AudialExtraction; +use self::vision::VisualExtraction; use crate::operation::{DocumentEnvelope, Operation}; use crate::pipeline::RuntimeConfig; @@ -31,27 +31,27 @@ const TARGET: &str = "nvisy_engine::op::extraction"; /// document's content type. Both modalities are attempted — errors /// from missing providers are silently skipped (the document may /// not need that modality). -pub struct ExtractionOp { - visual: Option, - audial: Option, +pub struct Extraction { + visual: Option, + audial: Option, } -impl ExtractionOp { +impl Extraction { /// Build from extraction config and runtime dependencies. /// /// Each modality is constructed independently. Missing providers /// result in that modality being `None` (skipped at runtime), /// not an error. - pub fn new(cfg: &Extraction, config: &RuntimeConfig, http_client: &HttpClient) -> Self { + pub fn new(cfg: &ExtractionConfig, config: &RuntimeConfig, http_client: &HttpClient) -> Self { let visual_cfg = cfg.visual.clone().unwrap_or_default(); - let visual = VisualExtractionOp::new(&visual_cfg, config, http_client) + let visual = VisualExtraction::new(&visual_cfg, config, http_client) .inspect_err(|e| { tracing::debug!(target: TARGET, error = %e, "visual extraction unavailable"); }) .ok(); let audial_cfg = cfg.audial.clone().unwrap_or_default(); - let audial = AudialExtractionOp::new(&audial_cfg, config, http_client) + let audial = AudialExtraction::new(&audial_cfg, config, http_client) .inspect_err(|e| { tracing::debug!(target: TARGET, error = %e, "audial extraction unavailable"); }) @@ -61,7 +61,7 @@ impl ExtractionOp { } } -impl Operation for ExtractionOp { +impl Operation for Extraction { async fn execute(&self, envelope: &mut DocumentEnvelope) -> Result<()> { match &envelope.document.handle { ContentHandle::Image(_) | ContentHandle::Rich(_) => { diff --git a/crates/nvisy-engine/src/operation/extraction/speech.rs b/crates/nvisy-engine/src/operation/extraction/speech.rs index c067269b..5f546d0a 100644 --- a/crates/nvisy-engine/src/operation/extraction/speech.rs +++ b/crates/nvisy-engine/src/operation/extraction/speech.rs @@ -17,11 +17,11 @@ use crate::pipeline::RuntimeConfig; const TARGET: &str = "nvisy_engine::op::extraction::audial"; /// Audial extraction: transcribes audio documents via STT. -pub(super) struct AudialExtractionOp { +pub(super) struct AudialExtraction { stt: SttService, } -impl AudialExtractionOp { +impl AudialExtraction { pub fn new( cfg: &AudialExtractionCfg, config: &RuntimeConfig, @@ -52,7 +52,7 @@ impl AudialExtractionOp { } } -impl Operation for AudialExtractionOp { +impl Operation for AudialExtraction { async fn execute(&self, envelope: &mut DocumentEnvelope) -> Result<()> { if let ContentHandle::Audio(ref handler) = envelope.document.handle { tracing::debug!(target: TARGET, "transcribing audio"); diff --git a/crates/nvisy-engine/src/operation/extraction/vision.rs b/crates/nvisy-engine/src/operation/extraction/vision.rs index 06622757..265267af 100644 --- a/crates/nvisy-engine/src/operation/extraction/vision.rs +++ b/crates/nvisy-engine/src/operation/extraction/vision.rs @@ -20,11 +20,11 @@ use crate::pipeline::RuntimeConfig; const TARGET: &str = "nvisy_engine::op::extraction::visual"; /// Visual extraction operation: OCR + optional verification + optional CV. -pub(super) struct VisualExtractionOp { +pub(super) struct VisualExtraction { agent: OcrAgent, } -impl VisualExtractionOp { +impl VisualExtraction { /// Build from graph config and runtime dependencies. pub fn new( cfg: &VisualExtractionCfg, @@ -142,7 +142,7 @@ impl VisualExtractionOp { } } -impl Operation for VisualExtractionOp { +impl Operation for VisualExtraction { async fn execute(&self, envelope: &mut DocumentEnvelope) -> Result<()> { let spans = Self::collect_spans(&envelope.document).await; if spans.is_empty() { diff --git a/crates/nvisy-engine/src/operation/generate_context.rs b/crates/nvisy-engine/src/operation/generate_context.rs index aa8ba59d..b5d487f3 100644 --- a/crates/nvisy-engine/src/operation/generate_context.rs +++ b/crates/nvisy-engine/src/operation/generate_context.rs @@ -3,21 +3,21 @@ //! Runs at **phase 4** alongside [`Redaction`]. Will eventually support //! summarization, translation, and audit context generation. //! -//! [`Redaction`]: crate::operation::RedactionOp +//! [`Redaction`]: crate::operation::Redaction use nvisy_core::Result; -use nvisy_ontology::workflow::GenerateContext; +use nvisy_ontology::workflow::GenerateContext as GenerateContextConfig; use crate::operation::{DocumentEnvelope, Operation}; /// Generates contexts from detection results and content data. /// /// Currently a passthrough stub. -pub struct GenerateContextOp; +pub struct GenerateContext; -impl GenerateContextOp { +impl GenerateContext { /// Create from graph config. - pub fn new(cfg: &GenerateContext) -> Self { + pub fn new(cfg: &GenerateContextConfig) -> Self { if cfg.summarization { tracing::warn!("summarization not yet implemented, skipping"); } @@ -31,13 +31,13 @@ impl GenerateContextOp { } } -impl Default for GenerateContextOp { +impl Default for GenerateContext { fn default() -> Self { Self } } -impl Operation for GenerateContextOp { +impl Operation for GenerateContext { async fn execute(&self, _envelope: &mut DocumentEnvelope) -> Result<()> { Ok(()) } diff --git a/crates/nvisy-engine/src/operation/import_file.rs b/crates/nvisy-engine/src/operation/import_file.rs index 4fd7775e..3b7b85fb 100644 --- a/crates/nvisy-engine/src/operation/import_file.rs +++ b/crates/nvisy-engine/src/operation/import_file.rs @@ -35,12 +35,12 @@ const TARGET: &str = "nvisy_engine::op::import_file"; /// /// [`Operation`]: crate::operation::Operation #[derive(Default)] -pub struct ImportFileOp { +pub struct ImportFile { decompression: Option, decryption: Option, } -impl ImportFileOp { +impl ImportFile { pub fn new() -> Self { Self::default() } @@ -119,6 +119,6 @@ mod tests { let registry = crate::registry::Registry::open(dir.path()).unwrap(); let shared = SharedData::new(uuid::Uuid::new_v4(), uuid::Uuid::new_v4(), registry); let content = Content::new(ContentData::from("plain text has no magic bytes")); - assert!(ImportFileOp::new().import(content, &shared).await.is_err()); + assert!(ImportFile::new().import(content, &shared).await.is_err()); } } diff --git a/crates/nvisy-engine/src/operation/mod.rs b/crates/nvisy-engine/src/operation/mod.rs index 1d9412d2..c784b3a6 100644 --- a/crates/nvisy-engine/src/operation/mod.rs +++ b/crates/nvisy-engine/src/operation/mod.rs @@ -12,25 +12,26 @@ mod deduplication; mod detection; -pub mod envelope; +mod envelope; mod export_file; mod extraction; mod generate_context; mod import_file; -pub(crate) mod redaction; -mod validation; +mod redaction; +mod validate; use nvisy_core::Result; -pub(crate) use self::deduplication::DeduplicationOp; -pub(crate) use self::detection::{EntityRecognitionOp, PatternRecognitionOp}; +pub(crate) use self::deduplication::Deduplication; +pub(crate) use self::detection::{EntityRecognition, PatternRecognition}; +pub(crate) use self::envelope::SharedData; pub use self::envelope::{Document, DocumentEnvelope}; -pub(crate) use self::export_file::ExportFileOp; -pub(crate) use self::extraction::ExtractionOp; -pub(crate) use self::generate_context::GenerateContextOp; -pub(crate) use self::import_file::ImportFileOp; -pub(crate) use self::redaction::RedactionOp; -pub(crate) use self::validation::ValidationOp; +pub(crate) use self::export_file::ExportFile; +pub(crate) use self::extraction::Extraction; +pub(crate) use self::generate_context::GenerateContext; +pub(crate) use self::import_file::ImportFile; +pub(crate) use self::redaction::Redaction; +pub(crate) use self::validate::Validation; /// A single unit of work in the redaction pipeline. /// diff --git a/crates/nvisy-engine/src/operation/redaction/evaluate.rs b/crates/nvisy-engine/src/operation/redaction/evaluate.rs index 22161490..0b635ef9 100644 --- a/crates/nvisy-engine/src/operation/redaction/evaluate.rs +++ b/crates/nvisy-engine/src/operation/redaction/evaluate.rs @@ -1,18 +1,18 @@ //! Redaction operation. //! -//! Runs at **phase 4** alongside [`GenerateContextOp`]. Evaluates policy +//! Runs at **phase 4** alongside [`GenerateContext`]. Evaluates policy //! rules against detected entities to produce redaction records, then //! builds and applies redaction instructions across all modalities //! (text, image, audio) via [`RedactionApplicator`]. //! -//! [`GenerateContextOp`]: crate::operation::GenerateContextOp +//! [`GenerateContext`]: crate::operation::GenerateContext use nvisy_core::Result; use nvisy_core::content::ContentMetadata; use nvisy_ontology::entity::{Entities, Entity}; use nvisy_ontology::policy::{Action, Condition, Strategy, StrategyPolicy}; use nvisy_ontology::provenance::{AuditEntry, AuditEntryStatus, RedactionMapping}; -use nvisy_ontology::workflow::Redaction; +use nvisy_ontology::workflow::Redaction as RedactionConfig; use uuid::Uuid; use super::apply::RedactionApplicator; @@ -21,20 +21,20 @@ use crate::operation::{Document, DocumentEnvelope, Operation}; const TARGET: &str = "nvisy_engine::op::redaction"; /// Redaction operation: evaluates policies and applies redaction instructions. -pub struct RedactionOp { +pub struct Redaction { default_threshold: f64, } -impl RedactionOp { +impl Redaction { /// Build from graph config. - pub fn new(cfg: &Redaction) -> Self { + pub fn new(cfg: &RedactionConfig) -> Self { Self { default_threshold: cfg.confidence_threshold.unwrap_or(0.5), } } } -impl Operation for RedactionOp { +impl Operation for Redaction { async fn execute(&self, envelope: &mut DocumentEnvelope) -> Result<()> { if envelope.audit.entities.is_empty() { return Ok(()); diff --git a/crates/nvisy-engine/src/operation/redaction/mod.rs b/crates/nvisy-engine/src/operation/redaction/mod.rs index 7d23b7fa..ee67acb9 100644 --- a/crates/nvisy-engine/src/operation/redaction/mod.rs +++ b/crates/nvisy-engine/src/operation/redaction/mod.rs @@ -13,4 +13,4 @@ mod apply; mod evaluate; -pub use self::evaluate::RedactionOp; +pub use self::evaluate::Redaction; diff --git a/crates/nvisy-engine/src/operation/validation.rs b/crates/nvisy-engine/src/operation/validate.rs similarity index 95% rename from crates/nvisy-engine/src/operation/validation.rs rename to crates/nvisy-engine/src/operation/validate.rs index 875e75b6..987f50f1 100644 --- a/crates/nvisy-engine/src/operation/validation.rs +++ b/crates/nvisy-engine/src/operation/validate.rs @@ -3,12 +3,12 @@ //! Runs at **phase 5**, after [`Redaction`]. Re-scans redacted content //! to verify that no originally detected values remain visible. //! -//! [`Redaction`]: crate::operation::RedactionOp +//! [`Redaction`]: crate::operation::Redaction use nvisy_core::{Error, Result}; use nvisy_ontology::entity::Entities; use nvisy_ontology::provenance::AuditEntry; -use nvisy_ontology::workflow::Validation; +use nvisy_ontology::workflow::Validation as ValidationConfig; use uuid::Uuid; use crate::operation::{DocumentEnvelope, Operation}; @@ -29,13 +29,13 @@ pub struct ValidationResult { } /// Post-redaction validator that checks for leaked sensitive values. -pub struct ValidationOp { +pub struct Validation { fail_on_leak: bool, } -impl ValidationOp { +impl Validation { /// Create from graph config. - pub fn new(cfg: &Validation) -> Self { + pub fn new(cfg: &ValidationConfig) -> Self { Self { fail_on_leak: cfg.fail_on_leak, } @@ -89,7 +89,7 @@ impl ValidationOp { } } -impl Operation for ValidationOp { +impl Operation for Validation { async fn execute(&self, envelope: &mut DocumentEnvelope) -> Result<()> { tracing::debug!(target: TARGET, "running post-redaction validation"); diff --git a/crates/nvisy-engine/src/pipeline/orchestrator.rs b/crates/nvisy-engine/src/pipeline/orchestrator.rs index 9b1b39f6..bde9a1ce 100644 --- a/crates/nvisy-engine/src/pipeline/orchestrator.rs +++ b/crates/nvisy-engine/src/pipeline/orchestrator.rs @@ -13,7 +13,7 @@ use std::future::Future; use std::sync::Arc; use nvisy_core::Error; -use nvisy_ontology::workflow::{ConcurrencyPolicy, Detection, Extraction}; +use nvisy_ontology::workflow::{ConcurrencyPolicy, Detection, Extraction as ExtractionConfig}; use nvisy_provider::http::HttpClient; use tokio::sync::Semaphore; use tokio::task::JoinSet; @@ -22,10 +22,9 @@ use tokio_util::sync::CancellationToken; use super::config::RuntimeConfig; use super::plan::{ExecutionPlan, ExportStep, ImportStep, PhasePolicy}; use crate::graph::TimeoutExt; -use crate::operation::envelope::SharedData; use crate::operation::{ - DeduplicationOp, DocumentEnvelope, EntityRecognitionOp, ExportFileOp, ExtractionOp, - GenerateContextOp, ImportFileOp, Operation, PatternRecognitionOp, RedactionOp, ValidationOp, + Deduplication, DocumentEnvelope, EntityRecognition, ExportFile, Extraction, GenerateContext, + ImportFile, Operation, PatternRecognition, Redaction, SharedData, Validation, }; const TARGET: &str = "nvisy_engine::pipeline::orchestrator"; @@ -147,7 +146,7 @@ impl Orchestrator { let mut envelopes = Vec::new(); for step in imports { - let import = ImportFileOp::new() + let import = ImportFile::new() .with_decompression(step.config.decompression) .with_decryption(step.config.decryption.clone()); @@ -198,7 +197,7 @@ impl DocumentPipeline { self.check_cancelled()?; // Phase 3: deduplication. - DeduplicationOp::new(&plan.deduplication) + Deduplication::new(&plan.deduplication) .execute(&mut envelope) .await?; self.check_cancelled()?; @@ -206,14 +205,12 @@ impl DocumentPipeline { // Phase 4: redaction + generate context. if !self.ctx.dry_run { self.run_phase(&plan.redaction_policy, async { - RedactionOp::new(&plan.redaction) - .execute(&mut envelope) - .await + Redaction::new(&plan.redaction).execute(&mut envelope).await }) .await?; } if plan.generate_context { - GenerateContextOp::new(&Default::default()) + GenerateContext::new(&Default::default()) .execute(&mut envelope) .await?; } @@ -221,7 +218,7 @@ impl DocumentPipeline { // Phase 5: validation (skipped in dry-run). if !self.ctx.dry_run { - ValidationOp::new(&plan.validation) + Validation::new(&plan.validation) .execute(&mut envelope) .await?; } @@ -248,10 +245,10 @@ impl DocumentPipeline { /// Run extraction for all applicable modalities. async fn run_extraction( &self, - cfg: &Extraction, + cfg: &ExtractionConfig, envelope: &mut DocumentEnvelope, ) -> Result<(), Error> { - ExtractionOp::new(cfg, &self.ctx.config, &self.ctx.http_client) + Extraction::new(cfg, &self.ctx.config, &self.ctx.http_client) .execute(envelope) .await } @@ -269,13 +266,13 @@ impl DocumentPipeline { ) -> Result<(), Error> { let ner_cfg = cfg.ner.clone().unwrap_or_default(); if let Ok(op) = - EntityRecognitionOp::new(&ner_cfg, &self.ctx.config, &self.ctx.http_client).await + EntityRecognition::new(&ner_cfg, &self.ctx.config, &self.ctx.http_client).await { op.execute(envelope).await?; } let pat_cfg = cfg.pattern.clone().unwrap_or_default(); - let op = PatternRecognitionOp::new(&pat_cfg); + let op = PatternRecognition::new(&pat_cfg); op.execute(envelope).await?; Ok(()) @@ -288,7 +285,7 @@ impl DocumentPipeline { envelope: &DocumentEnvelope, ) -> Result<(), Error> { for step in exports { - let export = ExportFileOp::new() + let export = ExportFile::new() .with_encryption(step.config.encryption.clone()) .with_compression(step.config.compression) .with_content_ids(step.config.content_ids.clone()); diff --git a/crates/nvisy-engine/src/pipeline/run.rs b/crates/nvisy-engine/src/pipeline/run.rs index 976f8cfe..ff5b8c14 100644 --- a/crates/nvisy-engine/src/pipeline/run.rs +++ b/crates/nvisy-engine/src/pipeline/run.rs @@ -25,7 +25,7 @@ use super::orchestrator::{Orchestrator, RunContext}; use super::plan::{self, ExecutionPlan}; use super::runs::RunStatus; use super::runs::state::{RunRecord, RunState}; -use crate::operation::envelope::SharedData; +use crate::operation::SharedData; use crate::registry::Registry; use crate::utility::encryption::SharedKeyProvider; diff --git a/crates/nvisy-engine/src/utility/compression/mod.rs b/crates/nvisy-engine/src/utility/compression/mod.rs index d81af7ca..36fb39b9 100644 --- a/crates/nvisy-engine/src/utility/compression/mod.rs +++ b/crates/nvisy-engine/src/utility/compression/mod.rs @@ -1,7 +1,7 @@ //! Content compression and decompression. //! -//! Used as pre/post-processing steps within `ImportFileOp` and -//! `ExportFileOp` operations, not as standalone pipeline operations. +//! Used as pre/post-processing steps within `ImportFile` and +//! `ExportFile` operations, not as standalone pipeline operations. //! //! Gzip and Zstd are recognized but not yet implemented — selecting //! them returns a runtime error. diff --git a/crates/nvisy-engine/src/utility/encryption/service.rs b/crates/nvisy-engine/src/utility/encryption/service.rs index 306124b6..6512d019 100644 --- a/crates/nvisy-engine/src/utility/encryption/service.rs +++ b/crates/nvisy-engine/src/utility/encryption/service.rs @@ -156,11 +156,8 @@ mod tests { let doc = ContentHandle::decode(&content).await.expect("decode text"); let dir = tempfile::tempdir().unwrap(); let registry = crate::registry::Registry::open(dir.path()).unwrap(); - let shared = crate::operation::envelope::SharedData::new( - uuid::Uuid::new_v4(), - uuid::Uuid::new_v4(), - registry, - ); + let shared = + crate::operation::SharedData::new(uuid::Uuid::new_v4(), uuid::Uuid::new_v4(), registry); DocumentEnvelope::new( doc, ContentMetadata::new().with_content_type("text/plain"), From bee7d3d45ea295fa8e9bcacd7f38d6643b88453a Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Wed, 20 May 2026 03:07:48 +0200 Subject: [PATCH 5/5] chore: file renames to match the *_ext / *_handle / *_store conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - graph/{retry,timeout,petgraph}.rs → graph/{retry_ext,timeout_ext,petgraph_ext}.rs (the files contain extension traits/impls; the `_ext` suffix matches the trait names RetryExt / TimeoutExt and reads better at import sites.) - registry/{cache,content,key,store}.rs → registry/{resource_cache,content_handle,composite_key,registry_store}.rs (each file's primary type is the noun in the new name; the prefixed variants disambiguate from same-named types elsewhere.) - detection/pattern_engine.rs: document the two PatternEngineRef variants (Shared = process-wide singleton via PatternEngine::instance, Owned = freshly compiled per-config engine). Manual Deref impl stays — derive_more::Deref doesn't support enums. Co-Authored-By: Claude Opus 4.7 --- crates/nvisy-engine/src/graph/mod.rs | 12 ++++++------ .../src/graph/{petgraph.rs => petgraph_ext.rs} | 0 .../src/graph/{retry.rs => retry_ext.rs} | 0 .../src/graph/{timeout.rs => timeout_ext.rs} | 0 .../src/operation/detection/pattern_engine.rs | 18 +++++++++++++----- .../src/registry/{key.rs => composite_key.rs} | 0 .../registry/{content.rs => content_handle.rs} | 2 +- crates/nvisy-engine/src/registry/fjall_ext.rs | 2 +- crates/nvisy-engine/src/registry/mod.rs | 14 +++++++------- .../registry/{store.rs => registry_store.rs} | 6 +++--- .../registry/{cache.rs => resource_cache.rs} | 0 11 files changed, 31 insertions(+), 23 deletions(-) rename crates/nvisy-engine/src/graph/{petgraph.rs => petgraph_ext.rs} (100%) rename crates/nvisy-engine/src/graph/{retry.rs => retry_ext.rs} (100%) rename crates/nvisy-engine/src/graph/{timeout.rs => timeout_ext.rs} (100%) rename crates/nvisy-engine/src/registry/{key.rs => composite_key.rs} (100%) rename crates/nvisy-engine/src/registry/{content.rs => content_handle.rs} (98%) rename crates/nvisy-engine/src/registry/{store.rs => registry_store.rs} (99%) rename crates/nvisy-engine/src/registry/{cache.rs => resource_cache.rs} (100%) diff --git a/crates/nvisy-engine/src/graph/mod.rs b/crates/nvisy-engine/src/graph/mod.rs index bf9d0671..2f0370f9 100644 --- a/crates/nvisy-engine/src/graph/mod.rs +++ b/crates/nvisy-engine/src/graph/mod.rs @@ -7,11 +7,11 @@ //! - [`RetryExt`]: automatic retry with configurable backoff. //! - [`TimeoutExt`]: wall-clock deadline enforcement for pipeline phases. -mod petgraph; -mod retry; -mod timeout; +mod petgraph_ext; +mod retry_ext; +mod timeout_ext; -pub(crate) use self::petgraph::GraphExt; +pub(crate) use self::petgraph_ext::GraphExt; #[allow(unused_imports)] // wired when operations gain internal retry -pub(crate) use self::retry::RetryExt; -pub(crate) use self::timeout::TimeoutExt; +pub(crate) use self::retry_ext::RetryExt; +pub(crate) use self::timeout_ext::TimeoutExt; diff --git a/crates/nvisy-engine/src/graph/petgraph.rs b/crates/nvisy-engine/src/graph/petgraph_ext.rs similarity index 100% rename from crates/nvisy-engine/src/graph/petgraph.rs rename to crates/nvisy-engine/src/graph/petgraph_ext.rs diff --git a/crates/nvisy-engine/src/graph/retry.rs b/crates/nvisy-engine/src/graph/retry_ext.rs similarity index 100% rename from crates/nvisy-engine/src/graph/retry.rs rename to crates/nvisy-engine/src/graph/retry_ext.rs diff --git a/crates/nvisy-engine/src/graph/timeout.rs b/crates/nvisy-engine/src/graph/timeout_ext.rs similarity index 100% rename from crates/nvisy-engine/src/graph/timeout.rs rename to crates/nvisy-engine/src/graph/timeout_ext.rs diff --git a/crates/nvisy-engine/src/operation/detection/pattern_engine.rs b/crates/nvisy-engine/src/operation/detection/pattern_engine.rs index 1d8a0520..7f11a0f4 100644 --- a/crates/nvisy-engine/src/operation/detection/pattern_engine.rs +++ b/crates/nvisy-engine/src/operation/detection/pattern_engine.rs @@ -7,12 +7,20 @@ use std::ops::Deref; use nvisy_ontology::workflow::PatternDetection; +use nvisy_pattern::PatternEngine; /// Holds either a borrowed reference to the global singleton or an /// owned engine built from custom config. pub(super) enum PatternEngineRef { - Shared(&'static nvisy_pattern::PatternEngine), - Owned(nvisy_pattern::PatternEngine), + /// The process-wide [`PatternEngine::instance`] singleton, used + /// when the [`PatternDetection`] config carries default settings + /// (no custom patterns, no custom threshold) so every run can + /// share the same compiled regex/dictionary automata. + Shared(&'static PatternEngine), + /// A freshly compiled engine carrying this run's custom + /// configuration. Owned because no other run will see the same + /// pattern set / threshold combination. + Owned(PatternEngine), } impl PatternEngineRef { @@ -23,9 +31,9 @@ impl PatternEngineRef { pub(super) fn new(cfg: &PatternDetection) -> Self { let needs_custom = !cfg.patterns.is_empty() || cfg.confidence_threshold.is_some(); if !needs_custom { - return Self::Shared(nvisy_pattern::PatternEngine::instance()); + return Self::Shared(PatternEngine::instance()); } - let mut builder = nvisy_pattern::PatternEngine::builder(); + let mut builder = PatternEngine::builder(); if !cfg.patterns.is_empty() { let names: Vec<&str> = cfg.patterns.iter().map(String::as_str).collect(); builder = builder.with_patterns(&names); @@ -38,7 +46,7 @@ impl PatternEngineRef { } impl Deref for PatternEngineRef { - type Target = nvisy_pattern::PatternEngine; + type Target = PatternEngine; fn deref(&self) -> &Self::Target { match self { diff --git a/crates/nvisy-engine/src/registry/key.rs b/crates/nvisy-engine/src/registry/composite_key.rs similarity index 100% rename from crates/nvisy-engine/src/registry/key.rs rename to crates/nvisy-engine/src/registry/composite_key.rs diff --git a/crates/nvisy-engine/src/registry/content.rs b/crates/nvisy-engine/src/registry/content_handle.rs similarity index 98% rename from crates/nvisy-engine/src/registry/content.rs rename to crates/nvisy-engine/src/registry/content_handle.rs index fac7fdad..596f317b 100644 --- a/crates/nvisy-engine/src/registry/content.rs +++ b/crates/nvisy-engine/src/registry/content_handle.rs @@ -9,7 +9,7 @@ use nvisy_core::content::{Content, ContentData, ContentMetadata, ContentSource}; use uuid::Uuid; use super::fjall_ext::{FjallKeyspaceExt, blocking, not_found}; -use super::key::CompositeKey; +use super::composite_key::CompositeKey; /// Lightweight handle to a content entry stored in the registry. /// diff --git a/crates/nvisy-engine/src/registry/fjall_ext.rs b/crates/nvisy-engine/src/registry/fjall_ext.rs index 1600e10d..bc6fe372 100644 --- a/crates/nvisy-engine/src/registry/fjall_ext.rs +++ b/crates/nvisy-engine/src/registry/fjall_ext.rs @@ -11,7 +11,7 @@ use fjall::{Database, Keyspace, KeyspaceCreateOptions, KvSeparationOptions}; use nvisy_core::{Error, ErrorKind, Result}; use uuid::Uuid; -use super::key::CompositeKey; +use super::composite_key::CompositeKey; const COMPONENT: &str = "registry"; diff --git a/crates/nvisy-engine/src/registry/mod.rs b/crates/nvisy-engine/src/registry/mod.rs index b3b7d593..1977ca10 100644 --- a/crates/nvisy-engine/src/registry/mod.rs +++ b/crates/nvisy-engine/src/registry/mod.rs @@ -7,12 +7,12 @@ //! [`ResourceCache`] provides generic ref-counted caching on top of //! the store, used for contexts and policies. -mod cache; -mod content; +mod resource_cache; +mod content_handle; mod fjall_ext; -mod key; -mod store; +mod composite_key; +mod registry_store; -pub use self::cache::{ResourceCache, ResourceGuard}; -pub use self::content::ContentHandle; -pub use self::store::Registry; +pub use self::resource_cache::{ResourceCache, ResourceGuard}; +pub use self::content_handle::ContentHandle; +pub use self::registry_store::Registry; diff --git a/crates/nvisy-engine/src/registry/store.rs b/crates/nvisy-engine/src/registry/registry_store.rs similarity index 99% rename from crates/nvisy-engine/src/registry/store.rs rename to crates/nvisy-engine/src/registry/registry_store.rs index 444696ec..984068c9 100644 --- a/crates/nvisy-engine/src/registry/store.rs +++ b/crates/nvisy-engine/src/registry/registry_store.rs @@ -13,10 +13,10 @@ use nvisy_ontology::provenance::Audit; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use super::cache::ResourceCache; -use super::content::ContentHandle; +use super::resource_cache::ResourceCache; +use super::content_handle::ContentHandle; use super::fjall_ext::{FjallDatabaseExt, FjallKeyspaceExt, blocking, not_found}; -use super::key::CompositeKey; +use super::composite_key::CompositeKey; const TARGET: &str = "nvisy_engine::registry"; diff --git a/crates/nvisy-engine/src/registry/cache.rs b/crates/nvisy-engine/src/registry/resource_cache.rs similarity index 100% rename from crates/nvisy-engine/src/registry/cache.rs rename to crates/nvisy-engine/src/registry/resource_cache.rs