From 9d3c9686ebed3e268274a5edc5c1f95ad43f02f9 Mon Sep 17 00:00:00 2001 From: Alex Ivliev Date: Thu, 25 Jun 2026 15:28:50 +0200 Subject: [PATCH 01/11] rule translation between orginal and normalized program --- nemo/src/execution/execution_engine.rs | 38 ++- .../execution_engine/tracing/node_query.rs | 39 ++-- .../execution_engine/tracing/tree_query.rs | 51 ++-- nemo/src/execution/tracing.rs | 1 + nemo/src/execution/tracing/resolve_origin.rs | 20 +- .../src/execution/tracing/rule_translation.rs | 221 ++++++++++++++++++ nemo/src/rule_model/pipeline.rs | 23 +- nemo/src/rule_model/programs/handle.rs | 14 ++ 8 files changed, 365 insertions(+), 42 deletions(-) create mode 100644 nemo/src/execution/tracing/rule_translation.rs diff --git a/nemo/src/execution/execution_engine.rs b/nemo/src/execution/execution_engine.rs index d3cc53c1a..bd2804773 100644 --- a/nemo/src/execution/execution_engine.rs +++ b/nemo/src/execution/execution_engine.rs @@ -14,10 +14,11 @@ use crate::{ execution::planning::{ normalization::program::NormalizedProgram, strategy::forward::StrategyForward, }, + execution::tracing::shared::RuleId, io::{formats::Export, import_manager::ImportManager}, rule_file::RuleFile, rule_model::{ - components::tag::Tag, + components::{rule::Rule, tag::Tag}, pipeline::transformations::{default::TransformationDefault, global::TransformationGlobal}, programs::{handle::ProgramHandle, program::Program}, }, @@ -26,6 +27,7 @@ use crate::{ use super::{ execution_parameters::ExecutionParameters, selection_strategy::strategy::RuleSelectionStrategy, + tracing::rule_translation::RuleIdTranslation, }; pub mod tracing; @@ -58,6 +60,10 @@ pub struct ExecutionEngine { /// Normalized program program: NormalizedProgram, + /// Translation of rule ids between the normalized and the original program, + /// used for tracing + rule_translation: RuleIdTranslation, + /// The picked selection strategy for rules selection_strategy: RuleSelectionStrategy, @@ -92,6 +98,11 @@ impl ExecutionEngine { let report = ProgramReport::new(file); let (program, report) = report.merge_program_parser_report(handle)?; + + // The parsed program (before any transformation) is what the user wrote, + // and therefore serves as the reference for tracing (rule ids, origins). + program.mark_as_original(); + let (program, report) = report.merge_validation_report( &program, program.transform(TransformationDefault::new(¶meters)), @@ -107,7 +118,10 @@ impl ExecutionEngine { program: Program, parameters: ExecutionParameters, ) -> Result { - let program = ProgramHandle::from(program) + let original = ProgramHandle::from(program); + original.mark_as_original(); + + let program = original .transform(TransformationGlobal::new(¶meters.global_variables)) .expect("TransformationGlobal does not introduce validation errors"); @@ -120,6 +134,7 @@ impl ExecutionEngine { import_manager: ImportManager, ) -> Result { let normalized_program = NormalizedProgram::normalize_program(&program_handle); + let rule_translation = RuleIdTranslation::new(&program_handle, &normalized_program); let mut table_manager = TableManager::new(); Self::register_all_predicates(&mut table_manager, &normalized_program); @@ -137,6 +152,7 @@ impl ExecutionEngine { Ok(Self { program_handle, program: normalized_program, + rule_translation, selection_strategy, table_manager, import_manager, @@ -339,6 +355,24 @@ impl ExecutionEngine { &self.program } + /// Return a reference to the [ProgramHandle] backing this engine. + /// + /// This gives access to the full program pipeline, including the original + /// program (the one written by the user) via [ProgramHandle::original_revision]. + pub fn program_handle(&self) -> &ProgramHandle { + &self.program_handle + } + + /// Given a rule id as used in tracing responses (an index into the original + /// program's list of rules), return the corresponding original [Rule], if it exists. + /// + /// The rule's source location and transformation history are available via + /// [crate::rule_model::components::ComponentSource::origin]. + pub fn rule_for_trace_id(&self, id: RuleId) -> Option<&Rule> { + let component_id = self.rule_translation.original_rule_id(id)?; + self.program_handle.rule_by_id(component_id) + } + /// Creates an [Iterator] over all facts of a predicate. pub async fn predicate_rows( &mut self, diff --git a/nemo/src/execution/execution_engine/tracing/node_query.rs b/nemo/src/execution/execution_engine/tracing/node_query.rs index 74e54f342..fb0e7eec6 100644 --- a/nemo/src/execution/execution_engine/tracing/node_query.rs +++ b/nemo/src/execution/execution_engine/tracing/node_query.rs @@ -30,7 +30,6 @@ use crate::{ TableEntriesForTreeNodesResponse, TableEntriesForTreeNodesResponseElement, TreeAddress, }, - resolve_origin::tracing_resolve_origin, shared::{ PaginationResponse, ResponseMetaInformation, Rule as TraceRule, TableEntryQuery, TableEntryResponse, @@ -114,7 +113,8 @@ impl ExecutionEngine { // Recursive call to this function for all successor nodes if let Some(successor) = &node.next { - let rule = program.rules()[successor.rule].clone(); + let successor_rule = self.rule_translation.to_normalized(successor.rule); + let rule = program.rules()[successor_rule].clone(); for (index, (atom, node_atom)) in rule .positive_all() @@ -158,7 +158,8 @@ impl ExecutionEngine { manager.add_discard(&address, discarded_columns); if let Some(successor) = &node.next { - let rule = program.rules()[successor.rule].clone(); + let successor_rule = self.rule_translation.to_normalized(successor.rule); + let rule = program.rules()[successor_rule].clone(); let order = rule.variable_order().clone(); // True if all children do not have any restrictions @@ -170,7 +171,7 @@ impl ExecutionEngine { let next_step = { self.rule_history[..before_step] .iter() - .rposition(|rule| *rule == successor.rule) + .rposition(|rule| *rule == successor_rule) .unwrap_or(self.rule_history.len()) }; @@ -211,7 +212,7 @@ impl ExecutionEngine { predicate, 0..before_step, &self.rule_history, - successor.rule, + successor_rule, ) { // We iterate over all tables of the current predicate // that was derived within the step limit @@ -340,7 +341,8 @@ impl ExecutionEngine { let discarded_columns = manager.discard(&address); if let Some(successor) = &node.next { - let rule = program.rules()[successor.rule].clone(); + let successor_rule = self.rule_translation.to_normalized(successor.rule); + let rule = program.rules()[successor_rule].clone(); let order = rule.variable_order().clone(); @@ -531,9 +533,12 @@ impl ExecutionEngine { .chase_program() .rules_with_body_predicate(predicate) .flat_map(|index| { - let chase_rule = &self.chase_program().rules()[index]; - let logical_rule = tracing_resolve_origin(&self.program_handle, chase_rule.id()); - TraceRule::all_possible_single_head_rules(index, &logical_rule).collect::>() + let logical_rule = self.rule_translation.original_rule(index); + TraceRule::all_possible_single_head_rules( + self.rule_translation.to_original(index), + &logical_rule, + ) + .collect::>() }) .collect::>(); @@ -541,11 +546,14 @@ impl ExecutionEngine { .chase_program() .rules_with_head_predicate(predicate) .flat_map(|index| { - let chase_rule = &self.chase_program().rules()[index]; - let logical_rule = tracing_resolve_origin(&self.program_handle, chase_rule.id()); - - TraceRule::possible_rules_for_head_predicate(index, &logical_rule, predicate) - .collect::>() + let logical_rule = self.rule_translation.original_rule(index); + + TraceRule::possible_rules_for_head_predicate( + self.rule_translation.to_original(index), + &logical_rule, + predicate, + ) + .collect::>() }) .collect::>(); @@ -576,7 +584,8 @@ impl ExecutionEngine { elements.push(element); if let Some(successor) = &node.next { - let rule = self.chase_program().rules()[successor.rule].clone(); + let successor_rule = self.rule_translation.to_normalized(successor.rule); + let rule = self.chase_program().rules()[successor_rule].clone(); // Call this function recursively for each successor diff --git a/nemo/src/execution/execution_engine/tracing/tree_query.rs b/nemo/src/execution/execution_engine/tracing/tree_query.rs index 37d0373bf..ddafacc8e 100644 --- a/nemo/src/execution/execution_engine/tracing/tree_query.rs +++ b/nemo/src/execution/execution_engine/tracing/tree_query.rs @@ -16,7 +16,6 @@ use crate::{ selection_strategy::strategy::RuleSelectionStrategy, tracing::{ error::TracingError, - resolve_origin::tracing_resolve_origin, shared::{ PaginationResponse, ResponseMetaInformation, Rule as TraceRule, TableEntryQuery, TableEntryResponse, @@ -113,7 +112,8 @@ impl ExecutionEngine { Box::pin(self.trace_tree_add_negation(child)).await; } - let rule = self.chase_program().rules()[next.rule.id].clone(); + let rule_index = self.rule_translation.to_normalized(next.rule.id); + let rule = self.chase_program().rules()[rule_index].clone(); for negative_atom in rule.negative() { let rows = if let Ok(Some(rows)) = self.predicate_rows(&negative_atom.predicate()).await { @@ -212,9 +212,12 @@ impl ExecutionEngine { .chase_program() .rules_with_body_predicate(&predicate) .flat_map(|index| { - let chase_rule = &self.chase_program().rules()[index]; - let logical_rule = tracing_resolve_origin(&self.program_handle, chase_rule.id()); - TraceRule::all_possible_single_head_rules(index, &logical_rule).collect::>() + let logical_rule = self.rule_translation.original_rule(index); + TraceRule::all_possible_single_head_rules( + self.rule_translation.to_original(index), + &logical_rule, + ) + .collect::>() }) .collect::>(); @@ -222,10 +225,13 @@ impl ExecutionEngine { .chase_program() .rules_with_head_predicate(&predicate) .flat_map(|index| { - let chase_rule = &self.chase_program().rules()[index]; - let logical_rule = tracing_resolve_origin(&self.program_handle, chase_rule.id()); - TraceRule::possible_rules_for_head_predicate(index, &logical_rule, &predicate) - .collect::>() + let logical_rule = self.rule_translation.original_rule(index); + TraceRule::possible_rules_for_head_predicate( + self.rule_translation.to_original(index), + &logical_rule, + &predicate, + ) + .collect::>() }) .collect::>(); @@ -284,7 +290,7 @@ impl ExecutionEngine { } let chase_rule = &self.chase_program().rules()[rule_index].clone(); - let logical_rule = tracing_resolve_origin(&self.program_handle, chase_rule.id()); + let logical_rule = self.rule_translation.original_rule(rule_index); for (head_index, _) in chase_rule.head().iter().enumerate() { let Some(combination) = facts @@ -367,7 +373,7 @@ impl ExecutionEngine { if let Some(children) = children_option { result.next = Some(TreeForTableResponseSuccessor { rule: TraceRule::from_rule_and_head( - rule_index, + self.rule_translation.to_original(rule_index), &logical_rule, head_index, &logical_rule.head()[head_index], @@ -488,11 +494,12 @@ impl ExecutionEngine { .program .rules_with_body_predicate(&predicate) .flat_map(|index| { - let chase_rule = &self.chase_program().rules()[index]; - let logical_rule = - tracing_resolve_origin(&self.program_handle, chase_rule.id()); - TraceRule::all_possible_single_head_rules(index, &logical_rule) - .collect::>() + let logical_rule = self.rule_translation.original_rule(index); + TraceRule::all_possible_single_head_rules( + self.rule_translation.to_original(index), + &logical_rule, + ) + .collect::>() }) .collect::>(); @@ -500,11 +507,13 @@ impl ExecutionEngine { .program .rules_with_head_predicate(&predicate) .flat_map(|index| { - let chase_rule = &self.chase_program().rules()[index]; - let logical_rule = - tracing_resolve_origin(&self.program_handle, chase_rule.id()); - TraceRule::possible_rules_for_head_predicate(index, &logical_rule, &predicate) - .collect::>() + let logical_rule = self.rule_translation.original_rule(index); + TraceRule::possible_rules_for_head_predicate( + self.rule_translation.to_original(index), + &logical_rule, + &predicate, + ) + .collect::>() }) .collect::>(); diff --git a/nemo/src/execution/tracing.rs b/nemo/src/execution/tracing.rs index ac90e6b6a..b6e3bb2fc 100644 --- a/nemo/src/execution/tracing.rs +++ b/nemo/src/execution/tracing.rs @@ -9,3 +9,4 @@ pub mod node_query; pub mod tree_query; pub(crate) mod resolve_origin; +pub(crate) mod rule_translation; diff --git a/nemo/src/execution/tracing/resolve_origin.rs b/nemo/src/execution/tracing/resolve_origin.rs index db854cb98..2fa934537 100644 --- a/nemo/src/execution/tracing/resolve_origin.rs +++ b/nemo/src/execution/tracing/resolve_origin.rs @@ -14,6 +14,22 @@ use crate::rule_model::{ /// this function will try to find, as best as possible, /// the original rule that should be displayed. pub fn tracing_resolve_origin(handle: &ProgramHandle, id: ProgramComponentId) -> Rule { + let resolved = tracing_resolve_origin_id(handle, id); + handle + .rule_by_id(resolved) + .expect("resolved id must point to a rule") + .clone() +} + +/// Given a [ProgramComponentId] associated with a program pipeline (of the given [ProgramHandle]) +/// return the [ProgramComponentId] of the [Rule] that should be displayed. +/// +/// This walks the chain of transformations back to the original rule, +/// as far as it can be resolved (see [tracing_resolve_origin]). +pub fn tracing_resolve_origin_id( + handle: &ProgramHandle, + id: ProgramComponentId, +) -> ProgramComponentId { let rule = handle.rule_by_id(id).expect("id must point to a rule"); match rule.origin() { @@ -22,7 +38,7 @@ pub fn tracing_resolve_origin(handle: &ProgramHandle, id: ProgramComponentId) -> | Origin::Reference(_) | Origin::Component(_) | Origin::Extern - | Origin::Substitution { .. } => rule.clone(), - Origin::Normalization(id) => tracing_resolve_origin(handle, id), + | Origin::Substitution { .. } => id, + Origin::Normalization(origin_id) => tracing_resolve_origin_id(handle, origin_id), } } diff --git a/nemo/src/execution/tracing/rule_translation.rs b/nemo/src/execution/tracing/rule_translation.rs new file mode 100644 index 000000000..92d0d031e --- /dev/null +++ b/nemo/src/execution/tracing/rule_translation.rs @@ -0,0 +1,221 @@ +//! This module defines [RuleIdTranslation], which maps rule identifiers +//! between the normalized program and the original program. + +use std::collections::HashMap; + +use crate::{ + execution::planning::normalization::program::NormalizedProgram, + rule_model::{ + components::{ComponentIdentity, rule::Rule}, + pipeline::id::ProgramComponentId, + programs::{ProgramRead, handle::ProgramHandle}, + }, +}; + +use super::resolve_origin::tracing_resolve_origin_id; + +/// Translates rule identifiers between the normalized program (used internally +/// during execution) and the original program (as seen by a frontend or library user). +/// +/// On the frontend, a rule id ([super::shared::RuleId]) is an index into the original +/// program's list of rules. Internally, rules are addressed by their index in +/// [NormalizedProgram::rules]. +/// +/// This is the single entry point for resolving normalized rules back to the +/// original rules they should be displayed as (both their index and their content); +/// the tracing modules should not resolve rule origins by other means. +/// +/// If no original revision is recorded for the program, the index translation behaves +/// as the identity, while [RuleIdTranslation::original_rule] still resolves the rule +/// content through the chain of transformations. +/// +/// TODO: the current implementation assumes that transformations are essentially +/// one-to-one on rules. Handling rules that are split into (or merged from) several +/// rules is left for when proper tree rewriting is introduced. +#[derive(Debug, Clone)] +pub(crate) struct RuleIdTranslation { + /// The (transformed) program handle the normalized program was derived from, + /// used to resolve the original rule that should be displayed. + handle: ProgramHandle, + + /// For each normalized rule index, the [ProgramComponentId] of the original + /// rule it should be displayed as. + norm_to_orig_id: Vec, + /// For each normalized rule index, the corresponding original rule index (if any). + norm_to_orig: Vec>, + /// For each original rule index, the corresponding normalized rule indices. + orig_to_norm: HashMap>, + /// For each original rule index, the [ProgramComponentId] of that rule. + orig_rule_ids: Vec, +} + +impl RuleIdTranslation { + /// Build the translation for a given (transformed) [ProgramHandle] + /// and the [NormalizedProgram] derived from it. + pub(crate) fn new(handle: &ProgramHandle, normalized: &NormalizedProgram) -> Self { + // Resolve, for each normalized rule, the original rule it should be displayed as. + let norm_to_orig_id = normalized + .rules() + .iter() + .map(|rule| tracing_resolve_origin_id(handle, rule.id())) + .collect::>(); + + // If an original revision is recorded, index its rules to map between + // a rule's [ProgramComponentId] and its position in the original program. + let mut orig_rule_ids = Vec::new(); + let mut orig_index_by_id = HashMap::new(); + if let Some(original) = handle.original_revision() { + for (index, rule) in original.rules().enumerate() { + orig_index_by_id.insert(rule.id(), index); + orig_rule_ids.push(rule.id()); + } + } + + let mut norm_to_orig = Vec::with_capacity(norm_to_orig_id.len()); + let mut orig_to_norm: HashMap> = HashMap::new(); + + for (norm_index, origin_id) in norm_to_orig_id.iter().enumerate() { + let orig_index = orig_index_by_id.get(origin_id).copied(); + + norm_to_orig.push(orig_index); + + if let Some(orig_index) = orig_index { + orig_to_norm.entry(orig_index).or_default().push(norm_index); + } + } + + Self { + handle: handle.clone(), + norm_to_orig_id, + norm_to_orig, + orig_to_norm, + orig_rule_ids, + } + } + + /// Return the original [Rule] that the normalized rule with the given index + /// should be displayed as. + /// + /// This walks the chain of transformations back to the rule the user wrote, + /// as far as it can be resolved. + /// + /// # Panics + /// Panics if `normalized` is not a valid normalized rule index. + pub(crate) fn original_rule(&self, normalized: usize) -> Rule { + let origin_id = self.norm_to_orig_id[normalized]; + + self.handle + .rule_by_id(origin_id) + .expect("resolved id must point to a rule") + .clone() + } + + /// Translate a normalized rule index into the original rule index used on the frontend. + /// + /// Falls back to the identity if no mapping is available. + pub(crate) fn to_original(&self, normalized: usize) -> usize { + self.norm_to_orig + .get(normalized) + .copied() + .flatten() + .unwrap_or(normalized) + } + + /// Translate an original (frontend) rule index into the normalized rule index used internally. + /// + /// Under the assumption that transformations are essentially one-to-one, + /// the first matching normalized rule is returned. Falls back to the identity + /// if no mapping is available. + pub(crate) fn to_normalized(&self, original: usize) -> usize { + self.orig_to_norm + .get(&original) + .and_then(|indices| indices.first().copied()) + .unwrap_or(original) + } + + /// Return the [ProgramComponentId] of the original rule with the given (frontend) index, + /// if it exists. + pub(crate) fn original_rule_id(&self, original: usize) -> Option { + self.orig_rule_ids.get(original).copied() + } +} + +#[cfg(test)] +mod test { + use crate::{ + execution::planning::normalization::program::NormalizedProgram, + rule_model::{ + components::{ComponentIdentity, ComponentSource, rule::Rule}, + error::ValidationReport, + origin::Origin, + pipeline::{ProgramPipeline, commit::ProgramCommit}, + programs::{ProgramRead, ProgramWrite, handle::ProgramHandle}, + translation::TranslationComponent, + }, + }; + + use super::RuleIdTranslation; + + /// Submit a single rule as a fresh program and return the resulting handle. + fn program_with_rule(rule: Rule) -> ProgramHandle { + let mut commit = ProgramCommit::empty(ProgramPipeline::new(), ValidationReport::default()); + commit.add_rule(rule); + commit.submit().unwrap() + } + + #[test] + fn mark_as_original_round_trip() { + let original = program_with_rule(Rule::parse("P(?x, ?y) :- Q(?y, ?x) .").unwrap()); + + // Without marking, there is no original revision. + assert!(original.original_revision().is_none()); + + original.mark_as_original(); + + // A revision later derived from the same pipeline can recover the original. + let derived = original.fork().submit().unwrap(); + let recovered = derived + .original_revision() + .expect("original revision was marked"); + + assert_eq!( + recovered.rules().next().unwrap().id(), + original.rules().next().unwrap().id() + ); + } + + #[test] + fn translation_resolves_to_original_rule() { + // The user's (original) program. + let original_rule = Rule::parse("P(?x, ?y) :- Q(?y, ?x) .").unwrap(); + let original_string = original_rule.to_string(); + + let original = program_with_rule(original_rule); + original.mark_as_original(); + let original_id = original.rules().next().unwrap().id(); + + // A transformation rewrites the rule, recording its origin. + let mut rewritten = Rule::parse("P(?x, ?y) :- Q(?y, ?x), R(?y) .").unwrap(); + rewritten.set_origin(Origin::Normalization(original_id)); + let rewritten_string = rewritten.to_string(); + assert_ne!(original_string, rewritten_string); + + let mut commit = original.fork(); + commit.add_rule(rewritten); + let transformed = commit.submit().unwrap(); + + let normalized = NormalizedProgram::normalize_program(&transformed); + let translation = RuleIdTranslation::new(&transformed, &normalized); + + assert_eq!(normalized.rules().len(), 1); + + // Index translation round-trips between the single normalized and original rule. + assert_eq!(translation.to_original(0), 0); + assert_eq!(translation.to_normalized(0), 0); + assert_eq!(translation.original_rule_id(0), Some(original_id)); + + // The displayed rule is the original one, not the rewritten (normalized) one. + assert_eq!(translation.original_rule(0).to_string(), original_string); + assert_ne!(translation.original_rule(0).to_string(), rewritten_string); + } +} diff --git a/nemo/src/rule_model/pipeline.rs b/nemo/src/rule_model/pipeline.rs index a24661d6f..4293f6243 100644 --- a/nemo/src/rule_model/pipeline.rs +++ b/nemo/src/rule_model/pipeline.rs @@ -1,6 +1,6 @@ //! This module defines [ProgramPipeline]. -use std::rc::Rc; +use std::{cell::Cell, rc::Rc}; use id::ProgramComponentId; use orx_imp_vec::{ImpVec, PinnedVec}; @@ -11,7 +11,7 @@ use crate::rule_model::{ arena::{Arena, Id}, revision::ProgramRevision, }, - programs::{ProgramWrite, program::Program}, + programs::{ProgramWrite, handle::ProgramHandle, program::Program}, }; use super::components::{IterableComponent, ProgramComponent, atom::Atom, rule::Rule}; @@ -33,6 +33,10 @@ pub struct ProgramPipeline { /// Collection of all revisions, i.e. version of nemo prorams revisions: ImpVec, + + /// Index of the "original" revision, i.e. the one that was created from + /// the user input + original_revision: Cell>, } impl ProgramPipeline { @@ -41,6 +45,21 @@ impl ProgramPipeline { Rc::new(Self::default()) } + /// Set the original revision of the [ProgramPipeline]. + /// + /// Uses interior mutability, since the [ProgramPipeline] is shared + /// (behind an [Rc]) across all revisions derived from it. + pub fn set_original_revision(&self, revision: usize) { + self.original_revision.set(Some(revision)); + } + + /// Return a [ProgramHandle] to the original revision of the [ProgramPipeline]. + pub fn original_revision(self: &Rc) -> Option { + self.original_revision + .get() + .map(|revision| ProgramHandle::new(self.clone(), revision)) + } + /// Search for the [ProgramComponent] with a given [ProgramComponentId] /// within the child components of `component`. /// diff --git a/nemo/src/rule_model/programs/handle.rs b/nemo/src/rule_model/programs/handle.rs index 47f64f4d5..966547fe7 100644 --- a/nemo/src/rule_model/programs/handle.rs +++ b/nemo/src/rule_model/programs/handle.rs @@ -35,6 +35,20 @@ impl ProgramHandle { Self { pipeline, revision } } + /// Return a [ProgramHandle] to the original revision of the [ProgramPipeline]. + pub fn original_revision(&self) -> Option { + self.pipeline.clone().original_revision() + } + + /// Mark the revision this handle points to as the "original" revision + /// of its [ProgramPipeline], i.e. the one created from the user input. + /// + /// All revisions later derived from the same pipeline can recover this + /// revision via [ProgramHandle::original_revision]. + pub fn mark_as_original(&self) { + self.pipeline.set_original_revision(self.revision); + } + /// Create a [ProgramHandle] from a [RuleFile]. pub fn from_file( file: &RuleFile, From b8ace0d47a88e84a687e49264b41def1faeb781b Mon Sep 17 00:00:00 2001 From: Alex Ivliev Date: Thu, 25 Jun 2026 17:08:18 +0200 Subject: [PATCH 02/11] expose rule/component source positions via the program handle --- Cargo.lock | 1 + nemo/Cargo.toml | 1 + nemo/src/execution/execution_engine.rs | 45 +++++++--- .../src/execution/tracing/rule_translation.rs | 16 +--- nemo/src/parser/span.rs | 88 +++++++++++++++++++ nemo/src/rule_model/pipeline.rs | 23 ++++- nemo/src/rule_model/programs/handle.rs | 38 +++++++- 7 files changed, 182 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1ebda7d8d..b33f2a43c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1666,6 +1666,7 @@ dependencies = [ "getrandom 0.3.4", "indexmap", "itertools", + "line-index", "log", "nemo-physical", "nom", diff --git a/nemo/Cargo.toml b/nemo/Cargo.toml index ea17fcedd..bf9b5f669 100644 --- a/nemo/Cargo.toml +++ b/nemo/Cargo.toml @@ -18,6 +18,7 @@ js = ["getrandom/wasm_js"] nemo-physical = { path = "../nemo-physical", default-features = false } log = { workspace = true } nom = "7.1.1" +line-index = "0.1.1" petgraph = "0.8.3" petgraph-graphml = "5.0.0" rand = { workspace = true } diff --git a/nemo/src/execution/execution_engine.rs b/nemo/src/execution/execution_engine.rs index bd2804773..5d36af0e9 100644 --- a/nemo/src/execution/execution_engine.rs +++ b/nemo/src/execution/execution_engine.rs @@ -14,11 +14,10 @@ use crate::{ execution::planning::{ normalization::program::NormalizedProgram, strategy::forward::StrategyForward, }, - execution::tracing::shared::RuleId, io::{formats::Export, import_manager::ImportManager}, rule_file::RuleFile, rule_model::{ - components::{rule::Rule, tag::Tag}, + components::tag::Tag, pipeline::transformations::{default::TransformationDefault, global::TransformationGlobal}, programs::{handle::ProgramHandle, program::Program}, }, @@ -357,20 +356,15 @@ impl ExecutionEngine { /// Return a reference to the [ProgramHandle] backing this engine. /// - /// This gives access to the full program pipeline, including the original - /// program (the one written by the user) via [ProgramHandle::original_revision]. + /// Note that this represents the transformed program and not the original. + /// To obtain the original program, use [ProgramHandle::original_revision]. pub fn program_handle(&self) -> &ProgramHandle { &self.program_handle } - /// Given a rule id as used in tracing responses (an index into the original - /// program's list of rules), return the corresponding original [Rule], if it exists. - /// - /// The rule's source location and transformation history are available via - /// [crate::rule_model::components::ComponentSource::origin]. - pub fn rule_for_trace_id(&self, id: RuleId) -> Option<&Rule> { - let component_id = self.rule_translation.original_rule_id(id)?; - self.program_handle.rule_by_id(component_id) + /// Return the [ProgramHandle] of the original program, if it exists. + pub fn original_program_handle(&self) -> Option { + self.program_handle.original_revision() } /// Creates an [Iterator] over all facts of a predicate. @@ -480,8 +474,35 @@ mod test { use crate::{ api::load_program, execution::{DefaultExecutionEngine, execution_parameters::ExecutionParameters}, + rule_file::RuleFile, }; + #[tokio::test] + #[cfg_attr(miri, ignore)] + async fn rule_source_position_reports_line() { + // A fact on line 1 and a rule on line 2. + let source = "a(1).\nb(?x) :- a(?x)."; + let file = RuleFile::new(source.to_string(), "test".to_string()); + + let engine = DefaultExecutionEngine::from_file(file, ExecutionParameters::default()) + .await + .unwrap() + .into_object(); + + // The frontend resolves a tracing rule id via the original program handle. + let original = engine + .original_program_handle() + .expect("program was loaded from a file"); + + // Rule id 0 is the first (and only) rule, which is on line 2, column 1. + let rule = original.rule_by_index(0).expect("rule id is valid"); + let position = original + .source_position(rule) + .expect("rule originates from the source"); + assert_eq!(position.line, 2); + assert_eq!(position.column, 1); + } + #[tokio::test] #[test_log::test] #[cfg_attr(miri, ignore)] diff --git a/nemo/src/execution/tracing/rule_translation.rs b/nemo/src/execution/tracing/rule_translation.rs index 92d0d031e..513b3c6d9 100644 --- a/nemo/src/execution/tracing/rule_translation.rs +++ b/nemo/src/execution/tracing/rule_translation.rs @@ -45,8 +45,6 @@ pub(crate) struct RuleIdTranslation { norm_to_orig: Vec>, /// For each original rule index, the corresponding normalized rule indices. orig_to_norm: HashMap>, - /// For each original rule index, the [ProgramComponentId] of that rule. - orig_rule_ids: Vec, } impl RuleIdTranslation { @@ -60,14 +58,12 @@ impl RuleIdTranslation { .map(|rule| tracing_resolve_origin_id(handle, rule.id())) .collect::>(); - // If an original revision is recorded, index its rules to map between - // a rule's [ProgramComponentId] and its position in the original program. - let mut orig_rule_ids = Vec::new(); + // If an original revision is recorded, index its rules to map + // a rule's [ProgramComponentId] to its position in the original program. let mut orig_index_by_id = HashMap::new(); if let Some(original) = handle.original_revision() { for (index, rule) in original.rules().enumerate() { orig_index_by_id.insert(rule.id(), index); - orig_rule_ids.push(rule.id()); } } @@ -89,7 +85,6 @@ impl RuleIdTranslation { norm_to_orig_id, norm_to_orig, orig_to_norm, - orig_rule_ids, } } @@ -132,12 +127,6 @@ impl RuleIdTranslation { .and_then(|indices| indices.first().copied()) .unwrap_or(original) } - - /// Return the [ProgramComponentId] of the original rule with the given (frontend) index, - /// if it exists. - pub(crate) fn original_rule_id(&self, original: usize) -> Option { - self.orig_rule_ids.get(original).copied() - } } #[cfg(test)] @@ -212,7 +201,6 @@ mod test { // Index translation round-trips between the single normalized and original rule. assert_eq!(translation.to_original(0), 0); assert_eq!(translation.to_normalized(0), 0); - assert_eq!(translation.original_rule_id(0), Some(original_id)); // The displayed rule is the original one, not the rewritten (normalized) one. assert_eq!(translation.original_rule(0).to_string(), original_string); diff --git a/nemo/src/parser/span.rs b/nemo/src/parser/span.rs index 0e40bffde..a87705e0d 100644 --- a/nemo/src/parser/span.rs +++ b/nemo/src/parser/span.rs @@ -4,6 +4,7 @@ use core::str; use std::ops::{Deref, Range}; +use line_index::{LineIndex, TextSize}; use nom::{Offset, Slice}; /// Locates a certain character within a file, @@ -25,6 +26,48 @@ impl CharacterPosition { } } +/// Precomputed index over a source file that converts byte offsets +/// (as stored, for example, in [crate::rule_model::origin::Origin::File]) +/// into [CharacterPosition]s, i.e. 1-based line and column numbers. +pub struct SourcePositions { + line_index: LineIndex, +} + +impl std::fmt::Debug for SourcePositions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SourcePositions").finish_non_exhaustive() + } +} + +impl SourcePositions { + /// Build an index over the given source text. + pub fn new(source: &str) -> Self { + Self { + line_index: LineIndex::new(source), + } + } + + /// Return the [CharacterPosition] (1-based line and column) of the given byte offset. + pub fn position(&self, offset: usize) -> CharacterPosition { + let line_col = self.line_index.line_col(TextSize::new(offset as u32)); + + CharacterPosition { + offset, + line: line_col.line + 1, + column: line_col.col + 1, + } + } + + /// Return the [CharacterRange] (1-based line and column for both ends) + /// of the given byte range. + pub fn range(&self, range: Range) -> CharacterRange { + CharacterRange { + start: self.position(range.start), + end: self.position(range.end), + } + } +} + impl PartialEq for CharacterPosition { fn eq(&self, other: &Self) -> bool { self.offset == other.offset @@ -358,3 +401,48 @@ impl nom::Compare<&str> for Span<'_> { self.fragment.compare_no_case(t) } } + +#[cfg(test)] +mod test { + use super::SourcePositions; + + #[test] + fn source_positions_line_and_column() { + let source = "abc\nde\n\nf"; + // offsets: a0 b1 c2 \n3 d4 e5 \n6 \n7 f8 + let positions = SourcePositions::new(source); + + // First character of the file: line 1, column 1. + let a = positions.position(0); + assert_eq!((a.line, a.column), (1, 1)); + + // Third character on the first line. + let c = positions.position(2); + assert_eq!((c.line, c.column), (1, 3)); + + // First character of the second line. + let d = positions.position(4); + assert_eq!((d.line, d.column), (2, 1)); + + // The empty third line. + let empty = positions.position(7); + assert_eq!((empty.line, empty.column), (3, 1)); + + // The single character on the fourth line. + let f = positions.position(8); + assert_eq!((f.line, f.column), (4, 1)); + assert_eq!(f.offset, 8); + } + + #[test] + fn source_positions_range() { + let source = "a(1).\nb(?x) :- a(?x)."; + let positions = SourcePositions::new(source); + + // The rule starts right after the newline at offset 6. + let range = positions.range(6..source.len()); + assert_eq!(range.start.line, 2); + assert_eq!(range.start.column, 1); + assert_eq!(range.end.line, 2); + } +} diff --git a/nemo/src/rule_model/pipeline.rs b/nemo/src/rule_model/pipeline.rs index 4293f6243..687e9b0ce 100644 --- a/nemo/src/rule_model/pipeline.rs +++ b/nemo/src/rule_model/pipeline.rs @@ -1,10 +1,14 @@ //! This module defines [ProgramPipeline]. -use std::{cell::Cell, rc::Rc}; +use std::{ + cell::{Cell, OnceCell}, + rc::Rc, +}; use id::ProgramComponentId; use orx_imp_vec::{ImpVec, PinnedVec}; +use crate::parser::span::SourcePositions; use crate::rule_model::{ components::statement::Statement, pipeline::{ @@ -37,6 +41,11 @@ pub struct ProgramPipeline { /// Index of the "original" revision, i.e. the one that was created from /// the user input original_revision: Cell>, + + /// Index over the source text the program was parsed from, + /// used to resolve byte offsets (e.g. in [crate::rule_model::origin::Origin::File]) + /// to source positions (line and column numbers). + source: OnceCell, } impl ProgramPipeline { @@ -60,6 +69,18 @@ impl ProgramPipeline { .map(|revision| ProgramHandle::new(self.clone(), revision)) } + /// Record the source text the program was parsed from. + /// + /// Has no effect if a source has already been set. + pub fn set_source(&self, source: &str) { + let _ = self.source.set(SourcePositions::new(source)); + } + + /// Return the [SourcePositions] index over the source text, if one was recorded. + pub fn source_positions(&self) -> Option<&SourcePositions> { + self.source.get() + } + /// Search for the [ProgramComponent] with a given [ProgramComponentId] /// within the child components of `component`. /// diff --git a/nemo/src/rule_model/programs/handle.rs b/nemo/src/rule_model/programs/handle.rs index 966547fe7..3d257ad8e 100644 --- a/nemo/src/rule_model/programs/handle.rs +++ b/nemo/src/rule_model/programs/handle.rs @@ -4,11 +4,12 @@ use std::rc::Rc; use crate::{ error::warned::Warned, - parser::Parser, + parser::{Parser, span::CharacterPosition}, rule_file::RuleFile, rule_model::{ - components::{ProgramComponent, rule::Rule, statement::Statement}, + components::{ComponentSource, ProgramComponent, rule::Rule, statement::Statement}, error::{TranslationReport, ValidationReport}, + origin::Origin, pipeline::{ ProgramPipeline, commit::ProgramCommit, id::ProgramComponentId, transformations::ProgramTransformation, @@ -56,7 +57,12 @@ impl ProgramHandle { let parser = Parser::initialize(file.content()); let ast = parser.parse().map_err(|(_tail, report)| report)?; - let mut commit = ProgramCommit::empty(ProgramPipeline::new(), ValidationReport::default()); + let pipeline = ProgramPipeline::new(); + // Record the source so that component origins can later be resolved + // to source positions (line and column numbers). + pipeline.set_source(file.content()); + + let mut commit = ProgramCommit::empty(pipeline, ValidationReport::default()); ASTProgramTranslation::default() .translate(&ast, &mut commit) @@ -114,6 +120,32 @@ impl ProgramHandle { pub fn rule_by_id(&self, id: ProgramComponentId) -> Option<&Rule> { self.pipeline.rule_by_id(id) } + + /// Return the [Rule] at the given index, counting rules only. + /// + /// This is the index used to refer to rules in tracing + /// (see [crate::execution::tracing::shared::RuleId]). + pub fn rule_by_index(&self, index: usize) -> Option<&Rule> { + self.rules().nth(index) + } + + /// Return the position (1-based line and column) of the given component + /// in the original source text. + /// + /// Returns `None` if the program was not parsed from a source file, or if the + /// component does not directly originate from the source (for example because + /// it was generated by a transformation). In the latter case, resolve the + /// component to its origin first. + pub fn source_position(&self, component: &Component) -> Option + where + Component: ComponentSource, + { + let Origin::File { start, .. } = component.origin() else { + return None; + }; + + Some(self.pipeline.source_positions()?.position(start)) + } } impl ProgramRead for ProgramHandle { From 77807cf26a65048023092d9642070e214551de3f Mon Sep 17 00:00:00 2001 From: monsterkrampe Date: Thu, 25 Jun 2026 18:24:23 +0200 Subject: [PATCH 03/11] Add line_number_from_rule_id to nemo-wasm --- nemo-wasm/src/lib.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/nemo-wasm/src/lib.rs b/nemo-wasm/src/lib.rs index 0302142d2..94de8cf28 100644 --- a/nemo-wasm/src/lib.rs +++ b/nemo-wasm/src/lib.rs @@ -259,6 +259,21 @@ impl NemoEngine { js_set } + #[wasm_bindgen(js_name = "getLineNumberFromRuleId")] + pub fn line_number_from_rule_id(&self, rule_id: usize) -> Option { + let prog_handle = self + .engine + .original_program_handle() + .expect("program was loaded from a (dummy) file"); + + let rule = prog_handle.rule_by_index(rule_id)?; + let position = prog_handle + .source_position(rule) + .expect("rule originates from the source"); + + Some(position.line) + } + #[wasm_bindgen] pub async fn reason(&mut self) -> Result<(), NemoError> { self.engine From 0d011bd382149413318c225678871d37b912e3e3 Mon Sep 17 00:00:00 2001 From: Alex Ivliev Date: Fri, 26 Jun 2026 12:28:03 +0200 Subject: [PATCH 04/11] build execution engine from a single program pipeline --- nemo-python/src/lib.rs | 18 ++++--- nemo-wasm/src/lib.rs | 5 +- nemo/src/api.rs | 24 +++++++-- nemo/src/execution/execution_engine.rs | 51 ++++++++----------- .../src/execution/tracing/rule_translation.rs | 32 +++++------- nemo/src/rule_model/pipeline.rs | 34 +++++-------- nemo/src/rule_model/programs/handle.rs | 15 ++---- 7 files changed, 84 insertions(+), 95 deletions(-) diff --git a/nemo-python/src/lib.rs b/nemo-python/src/lib.rs index 413a37cde..a4fe31b48 100644 --- a/nemo-python/src/lib.rs +++ b/nemo-python/src/lib.rs @@ -1,7 +1,7 @@ use std::{collections::HashSet, fs::read_to_string, time::Duration}; use nemo::{ - api::load_program, + api::load_program_handle, datavalues::{AnyDataValue, DataValue}, error::Error, execution::{ @@ -17,7 +17,7 @@ use nemo::{ tag::Tag, term::{Term, primitive::Primitive}, }, - programs::ProgramRead, + programs::{ProgramRead, handle::ProgramHandle}, substitution::Substitution, }, }; @@ -52,9 +52,10 @@ impl PythonResult for (T, Vec) { } } -#[pyclass(from_py_object)] +/// A parsed and transformed nemo program. +#[pyclass(from_py_object, unsendable)] #[derive(Clone)] -struct NemoProgram(nemo::rule_model::programs::program::Program); +struct NemoProgram(ProgramHandle); #[pyfunction] fn load_file(file: String) -> PyResult { @@ -64,11 +65,11 @@ fn load_file(file: String) -> PyResult { #[pyfunction] fn load_string(rules: String) -> PyResult { - let program = load_program(rules, String::default()) + let handle = load_program_handle(rules, String::default()) .map_err(Error::ProgramReport) .py_res()?; - Ok(NemoProgram(program)) + Ok(NemoProgram(handle)) } #[pymethods] @@ -411,8 +412,11 @@ impl NemoEngine { .build()?; let execution_parameters = ExecutionParameters::default(); + + // Build the engine from the program's existing handle, so that parsing, + // to keep the same program pipeline let engine = rt - .block_on(ExecutionEngine::from_program( + .block_on(ExecutionEngine::from_handle( program.0, execution_parameters, )) diff --git a/nemo-wasm/src/lib.rs b/nemo-wasm/src/lib.rs index 94de8cf28..a9e547d37 100644 --- a/nemo-wasm/src/lib.rs +++ b/nemo-wasm/src/lib.rs @@ -261,10 +261,7 @@ impl NemoEngine { #[wasm_bindgen(js_name = "getLineNumberFromRuleId")] pub fn line_number_from_rule_id(&self, rule_id: usize) -> Option { - let prog_handle = self - .engine - .original_program_handle() - .expect("program was loaded from a (dummy) file"); + let prog_handle = self.engine.original_program_handle(); let rule = prog_handle.rule_by_index(rule_id)?; let position = prog_handle diff --git a/nemo/src/api.rs b/nemo/src/api.rs index 2389761e3..e7564b781 100644 --- a/nemo/src/api.rs +++ b/nemo/src/api.rs @@ -65,11 +65,17 @@ pub async fn load_string(input: String) -> Result { .into_object()) } -/// Parse a program in the given `input`-string and return a [Program]. +/// Parse a program in the given `input`-string and return a [ProgramHandle] +/// for the transformed program. +/// +/// In contrast to [load_program], this keeps the program's pipeline intact, so +/// the returned handle can be used to build an [Engine] (sharing a single +/// pipeline across parsing, transformation, execution and tracing) and to +/// resolve component origins back to the original program. /// /// # Error /// Returns a [ProgramReport] if parsing or validation fails. -pub fn load_program(input: String, label: String) -> Result { +pub fn load_program_handle(input: String, label: String) -> Result { let file = RuleFile::new(input, label); let parameters = ExecutionParameters::default(); @@ -77,12 +83,24 @@ pub fn load_program(input: String, label: String) -> Result Result { + load_program_handle(input, label).map(|program| program.materialize()) } /// Validate the `input` and create a [ProgramReport] for error reporting diff --git a/nemo/src/execution/execution_engine.rs b/nemo/src/execution/execution_engine.rs index 5d36af0e9..84d3b2b1b 100644 --- a/nemo/src/execution/execution_engine.rs +++ b/nemo/src/execution/execution_engine.rs @@ -17,9 +17,8 @@ use crate::{ io::{formats::Export, import_manager::ImportManager}, rule_file::RuleFile, rule_model::{ - components::tag::Tag, - pipeline::transformations::{default::TransformationDefault, global::TransformationGlobal}, - programs::{handle::ProgramHandle, program::Program}, + components::tag::Tag, pipeline::transformations::default::TransformationDefault, + programs::handle::ProgramHandle, }, table_manager::{MemoryUsage, TableManager}, }; @@ -98,32 +97,28 @@ impl ExecutionEngine { let (program, report) = report.merge_program_parser_report(handle)?; - // The parsed program (before any transformation) is what the user wrote, - // and therefore serves as the reference for tracing (rule ids, origins). - program.mark_as_original(); - let (program, report) = report.merge_validation_report( &program, program.transform(TransformationDefault::new(¶meters)), )?; - let engine = Self::initialize(program, parameters.import_manager).await?; + let engine = Self::from_handle(program, parameters).await?; report.warned(engine) } - /// Initialize the [ExecutionEngine] starting from a [Program] - pub async fn from_program( - program: Program, + /// Initialize the [ExecutionEngine] from an already prepared [ProgramHandle] + /// (for example one obtained from [crate::api::load_program_handle]). + /// + /// This performs **no** transformation. + /// This is the entry point for callers that prepare + /// the program themselves (e.g. applying their own transformations); the + /// common case of going from a source file to an engine is covered by + /// [Self::from_file], which prepares the program and then calls this. + pub async fn from_handle( + program: ProgramHandle, parameters: ExecutionParameters, ) -> Result { - let original = ProgramHandle::from(program); - original.mark_as_original(); - - let program = original - .transform(TransformationGlobal::new(¶meters.global_variables)) - .expect("TransformationGlobal does not introduce validation errors"); - Self::initialize(program, parameters.import_manager).await } @@ -362,8 +357,9 @@ impl ExecutionEngine { &self.program_handle } - /// Return the [ProgramHandle] of the original program, if it exists. - pub fn original_program_handle(&self) -> Option { + /// Return the [ProgramHandle] of the original program, i.e. the program as + /// it was created from the user input (the first revision of the pipeline). + pub fn original_program_handle(&self) -> ProgramHandle { self.program_handle.original_revision() } @@ -472,7 +468,6 @@ mod test { use tokio; use crate::{ - api::load_program, execution::{DefaultExecutionEngine, execution_parameters::ExecutionParameters}, rule_file::RuleFile, }; @@ -490,9 +485,7 @@ mod test { .into_object(); // The frontend resolves a tracing rule id via the original program handle. - let original = engine - .original_program_handle() - .expect("program was loaded from a file"); + let original = engine.original_program_handle(); // Rule id 0 is the first (and only) rule, which is on line 2, column 1. let rule = original.rule_by_index(0).expect("rule id is valid"); @@ -509,14 +502,12 @@ mod test { async fn issue_759() { const ITERATIONS: usize = 32_768; - let program = load_program("foo(bar).".to_string(), Default::default()).unwrap(); + let file = RuleFile::new("foo(bar).".to_string(), Default::default()); for _ in 1..=ITERATIONS { - let engine = DefaultExecutionEngine::from_program( - program.clone(), - ExecutionParameters::default(), - ) - .await; + let engine = + DefaultExecutionEngine::from_file(file.clone(), ExecutionParameters::default()) + .await; assert_matches!(engine, Ok(_)); } } diff --git a/nemo/src/execution/tracing/rule_translation.rs b/nemo/src/execution/tracing/rule_translation.rs index 513b3c6d9..34974e3bc 100644 --- a/nemo/src/execution/tracing/rule_translation.rs +++ b/nemo/src/execution/tracing/rule_translation.rs @@ -25,9 +25,10 @@ use super::resolve_origin::tracing_resolve_origin_id; /// original rules they should be displayed as (both their index and their content); /// the tracing modules should not resolve rule origins by other means. /// -/// If no original revision is recorded for the program, the index translation behaves -/// as the identity, while [RuleIdTranslation::original_rule] still resolves the rule -/// content through the chain of transformations. +/// For a normalized rule whose origin cannot be mapped to a rule of the original +/// program (for example because it was generated by a transformation), the index +/// translation falls back to the identity, while [RuleIdTranslation::original_rule] +/// still resolves the rule content through the chain of transformations. /// /// TODO: the current implementation assumes that transformations are essentially /// one-to-one on rules. Handling rules that are split into (or merged from) several @@ -58,13 +59,11 @@ impl RuleIdTranslation { .map(|rule| tracing_resolve_origin_id(handle, rule.id())) .collect::>(); - // If an original revision is recorded, index its rules to map - // a rule's [ProgramComponentId] to its position in the original program. + // Index the rules of the original program to map a rule's + // [ProgramComponentId] to its position in the original program. let mut orig_index_by_id = HashMap::new(); - if let Some(original) = handle.original_revision() { - for (index, rule) in original.rules().enumerate() { - orig_index_by_id.insert(rule.id(), index); - } + for (index, rule) in handle.original_revision().rules().enumerate() { + orig_index_by_id.insert(rule.id(), index); } let mut norm_to_orig = Vec::with_capacity(norm_to_orig_id.len()); @@ -153,19 +152,13 @@ mod test { } #[test] - fn mark_as_original_round_trip() { + fn first_revision_is_original() { let original = program_with_rule(Rule::parse("P(?x, ?y) :- Q(?y, ?x) .").unwrap()); - // Without marking, there is no original revision. - assert!(original.original_revision().is_none()); - - original.mark_as_original(); - - // A revision later derived from the same pipeline can recover the original. + // A revision later derived from the same pipeline recovers the first + // (original) revision. let derived = original.fork().submit().unwrap(); - let recovered = derived - .original_revision() - .expect("original revision was marked"); + let recovered = derived.original_revision(); assert_eq!( recovered.rules().next().unwrap().id(), @@ -180,7 +173,6 @@ mod test { let original_string = original_rule.to_string(); let original = program_with_rule(original_rule); - original.mark_as_original(); let original_id = original.rules().next().unwrap().id(); // A transformation rewrites the rule, recording its origin. diff --git a/nemo/src/rule_model/pipeline.rs b/nemo/src/rule_model/pipeline.rs index 687e9b0ce..7f19ce639 100644 --- a/nemo/src/rule_model/pipeline.rs +++ b/nemo/src/rule_model/pipeline.rs @@ -1,9 +1,6 @@ //! This module defines [ProgramPipeline]. -use std::{ - cell::{Cell, OnceCell}, - rc::Rc, -}; +use std::{cell::OnceCell, rc::Rc}; use id::ProgramComponentId; use orx_imp_vec::{ImpVec, PinnedVec}; @@ -38,10 +35,6 @@ pub struct ProgramPipeline { /// Collection of all revisions, i.e. version of nemo prorams revisions: ImpVec, - /// Index of the "original" revision, i.e. the one that was created from - /// the user input - original_revision: Cell>, - /// Index over the source text the program was parsed from, /// used to resolve byte offsets (e.g. in [crate::rule_model::origin::Origin::File]) /// to source positions (line and column numbers). @@ -54,19 +47,20 @@ impl ProgramPipeline { Rc::new(Self::default()) } - /// Set the original revision of the [ProgramPipeline]. + /// Return a [ProgramHandle] to the original revision of the [ProgramPipeline], + /// i.e. the first revision. This holds the program as it was created from the + /// user input, before any transformation. /// - /// Uses interior mutability, since the [ProgramPipeline] is shared - /// (behind an [Rc]) across all revisions derived from it. - pub fn set_original_revision(&self, revision: usize) { - self.original_revision.set(Some(revision)); - } - - /// Return a [ProgramHandle] to the original revision of the [ProgramPipeline]. - pub fn original_revision(self: &Rc) -> Option { - self.original_revision - .get() - .map(|revision| ProgramHandle::new(self.clone(), revision)) + /// Every revision derived from this pipeline shares the same first revision, + /// so this is the single reference point for resolving components back to the + /// original program (see the tracing modules). + pub fn original_revision(self: &Rc) -> ProgramHandle { + debug_assert!( + self.revisions.len() > 0, + "a pipeline a handle exists for always has at least one revision" + ); + + ProgramHandle::new(self.clone(), 0) } /// Record the source text the program was parsed from. diff --git a/nemo/src/rule_model/programs/handle.rs b/nemo/src/rule_model/programs/handle.rs index 3d257ad8e..5009ececc 100644 --- a/nemo/src/rule_model/programs/handle.rs +++ b/nemo/src/rule_model/programs/handle.rs @@ -36,20 +36,13 @@ impl ProgramHandle { Self { pipeline, revision } } - /// Return a [ProgramHandle] to the original revision of the [ProgramPipeline]. - pub fn original_revision(&self) -> Option { + /// Return a [ProgramHandle] to the original revision of this handle's + /// [ProgramPipeline], i.e. the first revision (the program as it was created + /// from the user input). + pub fn original_revision(&self) -> ProgramHandle { self.pipeline.clone().original_revision() } - /// Mark the revision this handle points to as the "original" revision - /// of its [ProgramPipeline], i.e. the one created from the user input. - /// - /// All revisions later derived from the same pipeline can recover this - /// revision via [ProgramHandle::original_revision]. - pub fn mark_as_original(&self) { - self.pipeline.set_original_revision(self.revision); - } - /// Create a [ProgramHandle] from a [RuleFile]. pub fn from_file( file: &RuleFile, From 5fc5b87b961295872817c28e937547823fb774a1 Mon Sep 17 00:00:00 2001 From: Alex Ivliev Date: Fri, 26 Jun 2026 13:08:08 +0200 Subject: [PATCH 05/11] record rule provenance for all used transformations --- nemo/src/execution/tracing/resolve_origin.rs | 5 +- .../src/execution/tracing/rule_translation.rs | 128 +++++++++++++----- nemo/src/rule_model/error.rs | 5 +- nemo/src/rule_model/origin.rs | 13 ++ .../pipeline/transformations/global.rs | 10 +- .../pipeline/transformations/incremental.rs | 10 +- .../pipeline/transformations/merge_sparql.rs | 8 +- 7 files changed, 139 insertions(+), 40 deletions(-) diff --git a/nemo/src/execution/tracing/resolve_origin.rs b/nemo/src/execution/tracing/resolve_origin.rs index 2fa934537..ad050e8e3 100644 --- a/nemo/src/execution/tracing/resolve_origin.rs +++ b/nemo/src/execution/tracing/resolve_origin.rs @@ -39,6 +39,9 @@ pub fn tracing_resolve_origin_id( | Origin::Component(_) | Origin::Extern | Origin::Substitution { .. } => id, - Origin::Normalization(origin_id) => tracing_resolve_origin_id(handle, origin_id), + Origin::Normalization(origin_id) + | Origin::Global(origin_id) + | Origin::Incremental(origin_id) + | Origin::MergeSparql(origin_id) => tracing_resolve_origin_id(handle, origin_id), } } diff --git a/nemo/src/execution/tracing/rule_translation.rs b/nemo/src/execution/tracing/rule_translation.rs index 34974e3bc..5bd2b33ff 100644 --- a/nemo/src/execution/tracing/rule_translation.rs +++ b/nemo/src/execution/tracing/rule_translation.rs @@ -21,18 +21,13 @@ use super::resolve_origin::tracing_resolve_origin_id; /// program's list of rules. Internally, rules are addressed by their index in /// [NormalizedProgram::rules]. /// -/// This is the single entry point for resolving normalized rules back to the -/// original rules they should be displayed as (both their index and their content); -/// the tracing modules should not resolve rule origins by other means. +/// This relies on the transformations between the original and the normalized +/// program being one-to-one on rules and recording their provenance (each +/// derived rule points back, via its [crate::rule_model::origin::Origin], to the +/// rule it was created from). /// -/// For a normalized rule whose origin cannot be mapped to a rule of the original -/// program (for example because it was generated by a transformation), the index -/// translation falls back to the identity, while [RuleIdTranslation::original_rule] -/// still resolves the rule content through the chain of transformations. -/// -/// TODO: the current implementation assumes that transformations are essentially -/// one-to-one on rules. Handling rules that are split into (or merged from) several -/// rules is left for when proper tree rewriting is introduced. +/// TODO: handling transformations that change the structure of the derivation trees +/// (https://github.com/knowsys/nemo/issues/774) #[derive(Debug, Clone)] pub(crate) struct RuleIdTranslation { /// The (transformed) program handle the normalized program was derived from, @@ -79,6 +74,21 @@ impl RuleIdTranslation { } } + // The translation assumes that the transformations between the original + // and the normalized program are one-to-one on rules and record their + // provenance + debug_assert!( + norm_to_orig.iter().all(Option::is_some), + "tracing rule translation: a normalized rule has no corresponding \ + original rule; a transformation created a rule without recording \ + its origin" + ); + debug_assert!( + orig_to_norm.values().all(|indices| indices.len() == 1), + "tracing rule translation: several normalized rules map to the same \ + original rule; a transformation is not one-to-one on rules" + ); + Self { handle: handle.clone(), norm_to_orig_id, @@ -106,25 +116,23 @@ impl RuleIdTranslation { /// Translate a normalized rule index into the original rule index used on the frontend. /// - /// Falls back to the identity if no mapping is available. + /// # Panics + /// Panics if `normalized` is not a valid normalized rule index, or if it has + /// no corresponding original rule. pub(crate) fn to_original(&self, normalized: usize) -> usize { - self.norm_to_orig - .get(normalized) - .copied() - .flatten() - .unwrap_or(normalized) + self.norm_to_orig[normalized] + .expect("every normalized rule maps to an original rule (checked in `new`)") } /// Translate an original (frontend) rule index into the normalized rule index used internally. /// - /// Under the assumption that transformations are essentially one-to-one, - /// the first matching normalized rule is returned. Falls back to the identity - /// if no mapping is available. + /// # Panics + /// Panics if `original` has no corresponding normalized rule. pub(crate) fn to_normalized(&self, original: usize) -> usize { self.orig_to_norm .get(&original) .and_then(|indices| indices.first().copied()) - .unwrap_or(original) + .expect("original rule index has a corresponding normalized rule") } } @@ -144,26 +152,20 @@ mod test { use super::RuleIdTranslation; - /// Submit a single rule as a fresh program and return the resulting handle. + /// Program handle from single rule fn program_with_rule(rule: Rule) -> ProgramHandle { let mut commit = ProgramCommit::empty(ProgramPipeline::new(), ValidationReport::default()); commit.add_rule(rule); commit.submit().unwrap() } - #[test] - fn first_revision_is_original() { - let original = program_with_rule(Rule::parse("P(?x, ?y) :- Q(?y, ?x) .").unwrap()); - - // A revision later derived from the same pipeline recovers the first - // (original) revision. - let derived = original.fork().submit().unwrap(); - let recovered = derived.original_revision(); + /// Build a [RuleIdTranslation] for a program string + fn translation_for(program: &str) -> RuleIdTranslation { + let handle = crate::api::load_program_handle(program.to_string(), String::default()) + .expect("program should parse and validate"); + let normalized = NormalizedProgram::normalize_program(&handle); - assert_eq!( - recovered.rules().next().unwrap().id(), - original.rules().next().unwrap().id() - ); + RuleIdTranslation::new(&handle, &normalized) } #[test] @@ -198,4 +200,62 @@ mod test { assert_eq!(translation.original_rule(0).to_string(), original_string); assert_ne!(translation.original_rule(0).to_string(), rewritten_string); } + + #[test] + #[cfg_attr(miri, ignore)] + fn translation_global_variable_rule() { + // The global variable `$t` is substituted by TransformationGlobal. + let translation = translation_for( + "@parameter $t = 5 .\ndata(5) .\nresult(?x) :- data(?x), ?x = $t .\n@output result .", + ); + + assert_eq!(translation.to_original(0), 0); + assert_eq!(translation.to_normalized(0), 0); + // Resolution recovers the original rule, which still contains `$t`. + assert!(translation.original_rule(0).to_string().contains("$t")); + } + + #[test] + #[cfg_attr(miri, ignore)] + fn translation_normalized_rule() { + // The constant `2` in a positive body atom triggers TransformationNormalize. + let translation = + translation_for("data(1, 2) .\nresult(?x) :- data(?x, 2) .\n@output result ."); + + assert_eq!(translation.to_original(0), 0); + assert_eq!(translation.to_normalized(0), 0); + } + + #[test] + #[cfg_attr(miri, ignore)] + fn translation_incremental_sparql_rule() { + // A SPARQL import bound by another body atom is inlined into the rule by + // TransformationIncremental. + let translation = translation_for( + r#"@import remote :- sparql {endpoint = "http://example.org", query = "SELECT ?a ?b WHERE {?a ?b ?c.}"} . + seed(1) . + result(?a, ?b) :- seed(?a), remote(?a, ?b) . + @output result ."#, + ); + + assert_eq!(translation.to_original(0), 0); + assert_eq!(translation.to_normalized(0), 0); + } + + #[test] + #[cfg_attr(miri, ignore)] + fn translation_merge_sparql_rule() { + // Two SPARQL imports against the same endpoint in one rule are merged by + // TransformationMergeSparql (after being inlined by TransformationIncremental). + let translation = translation_for( + r#"@import ra :- sparql {endpoint = "http://example.org", query = "SELECT ?a ?b WHERE {?a ?b ?z.}"} . + @import rb :- sparql {endpoint = "http://example.org", query = "SELECT ?a ?c WHERE {?a ?c ?z.}"} . + seed(1) . + result(?a, ?b, ?c) :- seed(?a), ra(?a, ?b), rb(?a, ?c) . + @output result ."#, + ); + + assert_eq!(translation.to_original(0), 0); + assert_eq!(translation.to_normalized(0), 0); + } } diff --git a/nemo/src/rule_model/error.rs b/nemo/src/rule_model/error.rs index 789e1a791..25a56c278 100644 --- a/nemo/src/rule_model/error.rs +++ b/nemo/src/rule_model/error.rs @@ -406,7 +406,10 @@ impl ValidationReport { error.add_hint(Info::DefinedExternally); None } - Origin::Normalization(id) => Self::id_to_range(program, *id, error), + Origin::Normalization(id) + | Origin::Global(id) + | Origin::Incremental(id) + | Origin::MergeSparql(id) => Self::id_to_range(program, *id, error), } } diff --git a/nemo/src/rule_model/origin.rs b/nemo/src/rule_model/origin.rs index ef157ebaa..877501ba7 100644 --- a/nemo/src/rule_model/origin.rs +++ b/nemo/src/rule_model/origin.rs @@ -38,7 +38,20 @@ pub enum Origin { }, /// Rule that was created by normalizing a rule + /// (see [crate::rule_model::pipeline::transformations::normalize::TransformationNormalize]). Normalization(ProgramComponentId), + + /// Rule that was created by substituting global variables in a rule + /// (see [crate::rule_model::pipeline::transformations::global::TransformationGlobal]). + Global(ProgramComponentId), + + /// Rule that was created by inlining incremental imports into a rule + /// (see [crate::rule_model::pipeline::transformations::incremental::TransformationIncremental]). + Incremental(ProgramComponentId), + + /// Rule that was created by merging SPARQL imports in a rule + /// (see [crate::rule_model::pipeline::transformations::merge_sparql::TransformationMergeSparql]). + MergeSparql(ProgramComponentId), } impl Origin { diff --git a/nemo/src/rule_model/pipeline/transformations/global.rs b/nemo/src/rule_model/pipeline/transformations/global.rs index 116176f9e..1ec56a253 100644 --- a/nemo/src/rule_model/pipeline/transformations/global.rs +++ b/nemo/src/rule_model/pipeline/transformations/global.rs @@ -4,7 +4,8 @@ use std::collections::{HashMap, HashSet}; use crate::rule_model::{ components::{ - ComponentSource, IterableVariables, + ComponentIdentity, ComponentSource, IterableVariables, + statement::Statement, term::primitive::{ground::GroundTerm, variable::global::GlobalVariable}, }, error::ValidationReport, @@ -88,6 +89,13 @@ impl<'a> ProgramTransformation for TransformationGlobal<'a> { let mut new_statement = statement.clone(); substitution.apply(&mut new_statement); + // Record that this rule was derived from the original rule, so + // tracing can resolve it back and the rule-index translation + // between the original and the normalized program stays 1:1. + if let Statement::Rule(rule) = &mut new_statement { + rule.set_origin(Origin::Global(statement.id())); + } + commit.add_statement(new_statement); } else { commit.keep(statement); diff --git a/nemo/src/rule_model/pipeline/transformations/incremental.rs b/nemo/src/rule_model/pipeline/transformations/incremental.rs index b7c2f9811..99d25c3af 100644 --- a/nemo/src/rule_model/pipeline/transformations/incremental.rs +++ b/nemo/src/rule_model/pipeline/transformations/incremental.rs @@ -11,7 +11,7 @@ use crate::{ }, rule_model::{ components::{ - IterableVariables, + ComponentIdentity, ComponentSource, IterableVariables, import_export::{ImportDirective, clause::ImportLiteral}, literal::Literal, rule::Rule, @@ -24,6 +24,7 @@ use crate::{ }, }, error::ValidationReport, + origin::Origin, programs::{ProgramRead, ProgramWrite, handle::ProgramHandle}, }, }; @@ -309,13 +310,18 @@ impl ProgramTransformation for TransformationIncremental { .len() == 1 => { - let new_rule = Self::incremental_rule( + let mut new_rule = Self::incremental_rule( rule, &incremental_predicates, &derived_predicates, &mut name_id, ); + // Record that this rule was derived from the original rule, so + // tracing can resolve it back and the rule-index translation + // between the original and the normalized program stays 1:1. + new_rule.set_origin(Origin::Incremental(rule.id())); + commit.add_rule(new_rule); } Statement::Import(import) => { diff --git a/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs b/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs index 3302d0167..4e5e72daa 100644 --- a/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs +++ b/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs @@ -6,7 +6,7 @@ use itertools::Itertools; use crate::rule_model::{ components::{ - IterableVariables, + ComponentIdentity, ComponentSource, IterableVariables, atom::Atom, import_export::clause::{ImportClause, ImportLiteral}, literal::Literal, @@ -19,6 +19,7 @@ use crate::rule_model::{ }, }, error::ValidationReport, + origin::Origin, pipeline::commit::ProgramCommit, programs::{ProgramRead, ProgramWrite, handle::ProgramHandle}, }; @@ -162,7 +163,12 @@ fn update_import( }) .unwrap_or_default(); + let original_id = rule.id(); let mut rule = rule.clone(); + // Record that this rule was derived from the original rule, so tracing can + // resolve it back and the rule-index translation between the original and + // the normalized program stays 1:1. + rule.set_origin(Origin::MergeSparql(original_id)); rule.imports_mut().clear(); if let Some(positive) = positive { From 8ca332ee12018c5fdbc89d8188a8f6129344a4e4 Mon Sep 17 00:00:00 2001 From: Alex Ivliev Date: Fri, 26 Jun 2026 13:20:09 +0200 Subject: [PATCH 06/11] pull line-index dependency into workspace --- Cargo.toml | 1 + nemo-language-server/Cargo.toml | 2 +- nemo/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6372d8331..2502bdaec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,7 @@ test-log = "0.2" thiserror = "2.0" tokio = "1.47.1" urlencoding = "2.1.3" +line-index = "0.1.1" [profile.dev] panic = "unwind" diff --git a/nemo-language-server/Cargo.toml b/nemo-language-server/Cargo.toml index 94640bb92..95b40cc3d 100644 --- a/nemo-language-server/Cargo.toml +++ b/nemo-language-server/Cargo.toml @@ -22,7 +22,6 @@ tokio = ["dep:tokio"] [dependencies] anyhow = "1.0" -line-index = "0.1.1" nemo = { path = "../nemo", default-features = false } futures = { workspace = true } strum = { workspace = true } @@ -31,3 +30,4 @@ tokio = { workspace = true, features = ["macros", "io-util", "rt-multi-thread", tower-lsp = { version = "0.20.0" } tower-service = "0.3.2" lsp-document = "0.6.0" +line-index = { workspace = true } diff --git a/nemo/Cargo.toml b/nemo/Cargo.toml index bf9b5f669..4366d2c4e 100644 --- a/nemo/Cargo.toml +++ b/nemo/Cargo.toml @@ -18,7 +18,6 @@ js = ["getrandom/wasm_js"] nemo-physical = { path = "../nemo-physical", default-features = false } log = { workspace = true } nom = "7.1.1" -line-index = "0.1.1" petgraph = "0.8.3" petgraph-graphml = "5.0.0" rand = { workspace = true } @@ -57,6 +56,7 @@ delegate = { workspace = true } orx-imp-vec = "2.14" itertools = { workspace = true } indexmap = { workspace = true } +line-index = { workspace = true } [dev-dependencies] env_logger = { workspace = true } From 823398fa38f761528b839d297399ade08ad5679d Mon Sep 17 00:00:00 2001 From: Alex Ivliev Date: Fri, 26 Jun 2026 13:37:04 +0200 Subject: [PATCH 07/11] simplify RuleIdTranslation --- .../src/execution/tracing/rule_translation.rs | 113 ++++++++---------- 1 file changed, 51 insertions(+), 62 deletions(-) diff --git a/nemo/src/execution/tracing/rule_translation.rs b/nemo/src/execution/tracing/rule_translation.rs index 5bd2b33ff..cc950bcdd 100644 --- a/nemo/src/execution/tracing/rule_translation.rs +++ b/nemo/src/execution/tracing/rule_translation.rs @@ -7,7 +7,6 @@ use crate::{ execution::planning::normalization::program::NormalizedProgram, rule_model::{ components::{ComponentIdentity, rule::Rule}, - pipeline::id::ProgramComponentId, programs::{ProgramRead, handle::ProgramHandle}, }, }; @@ -30,98 +29,88 @@ use super::resolve_origin::tracing_resolve_origin_id; /// (https://github.com/knowsys/nemo/issues/774) #[derive(Debug, Clone)] pub(crate) struct RuleIdTranslation { - /// The (transformed) program handle the normalized program was derived from, - /// used to resolve the original rule that should be displayed. - handle: ProgramHandle, - - /// For each normalized rule index, the [ProgramComponentId] of the original - /// rule it should be displayed as. - norm_to_orig_id: Vec, - /// For each normalized rule index, the corresponding original rule index (if any). - norm_to_orig: Vec>, - /// For each original rule index, the corresponding normalized rule indices. - orig_to_norm: HashMap>, + /// The original program (the first revision of the pipeline), used to look up + /// the [Rule] a normalized rule should be displayed as, by its index. + original: ProgramHandle, + + /// For each normalized rule index, the corresponding original rule index. + norm_to_orig: Vec, + /// Inverse of [Self::norm_to_orig]. + orig_to_norm: HashMap, } impl RuleIdTranslation { /// Build the translation for a given (transformed) [ProgramHandle] /// and the [NormalizedProgram] derived from it. pub(crate) fn new(handle: &ProgramHandle, normalized: &NormalizedProgram) -> Self { - // Resolve, for each normalized rule, the original rule it should be displayed as. - let norm_to_orig_id = normalized + let original = handle.original_revision(); + + // Map each original rule's id to its position in the original program. + let orig_index_by_id = original + .rules() + .enumerate() + .map(|(index, rule)| (rule.id(), index)) + .collect::>(); + + // For each normalized rule, resolve the original rule it should be + // displayed as and record that rule's index in the original program. + // + // This relies on the transformations being one-to-one on rules and + // recording their provenance: if a normalized rule cannot be resolved to + // an original rule, a transformation created a rule without an origin + // back-pointer, and we fail loudly rather than return a wrong rule id. + let norm_to_orig = normalized .rules() .iter() - .map(|rule| tracing_resolve_origin_id(handle, rule.id())) - .collect::>(); - - // Index the rules of the original program to map a rule's - // [ProgramComponentId] to its position in the original program. - let mut orig_index_by_id = HashMap::new(); - for (index, rule) in handle.original_revision().rules().enumerate() { - orig_index_by_id.insert(rule.id(), index); - } - - let mut norm_to_orig = Vec::with_capacity(norm_to_orig_id.len()); - let mut orig_to_norm: HashMap> = HashMap::new(); - - for (norm_index, origin_id) in norm_to_orig_id.iter().enumerate() { - let orig_index = orig_index_by_id.get(origin_id).copied(); - - norm_to_orig.push(orig_index); - - if let Some(orig_index) = orig_index { - orig_to_norm.entry(orig_index).or_default().push(norm_index); - } + .map(|rule| { + let origin_id = tracing_resolve_origin_id(handle, rule.id()); + *orig_index_by_id.get(&origin_id).expect( + "a normalized rule has no corresponding original rule; a \ + transformation created a rule without recording its origin", + ) + }) + .collect::>(); + + // Invert the mapping. As the transformations are one-to-one on rules, no + // two normalized rules map to the same original rule, so every insert is + // into a fresh slot (checked via the resulting length below). + let mut orig_to_norm = HashMap::with_capacity(norm_to_orig.len()); + for (norm_index, &orig_index) in norm_to_orig.iter().enumerate() { + orig_to_norm.insert(orig_index, norm_index); } - - // The translation assumes that the transformations between the original - // and the normalized program are one-to-one on rules and record their - // provenance - debug_assert!( - norm_to_orig.iter().all(Option::is_some), - "tracing rule translation: a normalized rule has no corresponding \ - original rule; a transformation created a rule without recording \ - its origin" - ); - debug_assert!( - orig_to_norm.values().all(|indices| indices.len() == 1), + debug_assert_eq!( + orig_to_norm.len(), + norm_to_orig.len(), "tracing rule translation: several normalized rules map to the same \ original rule; a transformation is not one-to-one on rules" ); Self { - handle: handle.clone(), - norm_to_orig_id, + original, norm_to_orig, orig_to_norm, } } /// Return the original [Rule] that the normalized rule with the given index - /// should be displayed as. - /// - /// This walks the chain of transformations back to the rule the user wrote, - /// as far as it can be resolved. + /// should be displayed as, i.e. the rule the user wrote that this normalized + /// rule was (transitively) derived from. /// /// # Panics /// Panics if `normalized` is not a valid normalized rule index. pub(crate) fn original_rule(&self, normalized: usize) -> Rule { - let origin_id = self.norm_to_orig_id[normalized]; - - self.handle - .rule_by_id(origin_id) - .expect("resolved id must point to a rule") + self.original + .rule_by_index(self.norm_to_orig[normalized]) + .expect("normalized rule maps to an existing original rule") .clone() } /// Translate a normalized rule index into the original rule index used on the frontend. /// /// # Panics - /// Panics if `normalized` is not a valid normalized rule index, or if it has - /// no corresponding original rule. + /// Panics if `normalized` is not a valid normalized rule index. pub(crate) fn to_original(&self, normalized: usize) -> usize { self.norm_to_orig[normalized] - .expect("every normalized rule maps to an original rule (checked in `new`)") } /// Translate an original (frontend) rule index into the normalized rule index used internally. @@ -131,7 +120,7 @@ impl RuleIdTranslation { pub(crate) fn to_normalized(&self, original: usize) -> usize { self.orig_to_norm .get(&original) - .and_then(|indices| indices.first().copied()) + .copied() .expect("original rule index has a corresponding normalized rule") } } From 9729dcde27fae7a053b038983d2c1e65a7104687 Mon Sep 17 00:00:00 2001 From: Alex Ivliev Date: Fri, 26 Jun 2026 13:49:56 +0200 Subject: [PATCH 08/11] remove unneeded comment --- nemo/src/rule_model/pipeline/transformations/global.rs | 3 --- nemo/src/rule_model/pipeline/transformations/incremental.rs | 3 --- nemo/src/rule_model/pipeline/transformations/merge_sparql.rs | 4 +--- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/nemo/src/rule_model/pipeline/transformations/global.rs b/nemo/src/rule_model/pipeline/transformations/global.rs index 1ec56a253..f24ee7239 100644 --- a/nemo/src/rule_model/pipeline/transformations/global.rs +++ b/nemo/src/rule_model/pipeline/transformations/global.rs @@ -89,9 +89,6 @@ impl<'a> ProgramTransformation for TransformationGlobal<'a> { let mut new_statement = statement.clone(); substitution.apply(&mut new_statement); - // Record that this rule was derived from the original rule, so - // tracing can resolve it back and the rule-index translation - // between the original and the normalized program stays 1:1. if let Statement::Rule(rule) = &mut new_statement { rule.set_origin(Origin::Global(statement.id())); } diff --git a/nemo/src/rule_model/pipeline/transformations/incremental.rs b/nemo/src/rule_model/pipeline/transformations/incremental.rs index 99d25c3af..c8554398c 100644 --- a/nemo/src/rule_model/pipeline/transformations/incremental.rs +++ b/nemo/src/rule_model/pipeline/transformations/incremental.rs @@ -317,9 +317,6 @@ impl ProgramTransformation for TransformationIncremental { &mut name_id, ); - // Record that this rule was derived from the original rule, so - // tracing can resolve it back and the rule-index translation - // between the original and the normalized program stays 1:1. new_rule.set_origin(Origin::Incremental(rule.id())); commit.add_rule(new_rule); diff --git a/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs b/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs index 4e5e72daa..aed57f6a5 100644 --- a/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs +++ b/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs @@ -165,9 +165,7 @@ fn update_import( let original_id = rule.id(); let mut rule = rule.clone(); - // Record that this rule was derived from the original rule, so tracing can - // resolve it back and the rule-index translation between the original and - // the normalized program stays 1:1. + rule.set_origin(Origin::MergeSparql(original_id)); rule.imports_mut().clear(); From eb950375a73f43ea53688d3005d414429e5fa08e Mon Sep 17 00:00:00 2001 From: Alex Ivliev Date: Fri, 26 Jun 2026 18:26:52 +0200 Subject: [PATCH 09/11] review remarks --- nemo-python/src/lib.rs | 2 - nemo/src/execution/execution_engine.rs | 12 +- .../execution_engine/tracing/node_query.rs | 16 +-- .../execution_engine/tracing/tree_query.rs | 22 ++-- .../src/execution/tracing/rule_translation.rs | 103 +++++++++--------- nemo/src/parser/span.rs | 7 +- nemo/src/rule_model/origin.rs | 6 +- nemo/src/rule_model/pipeline.rs | 5 +- .../pipeline/transformations/global.rs | 5 +- nemo/src/rule_model/programs/handle.rs | 3 - 10 files changed, 88 insertions(+), 93 deletions(-) diff --git a/nemo-python/src/lib.rs b/nemo-python/src/lib.rs index a4fe31b48..48ad6dccd 100644 --- a/nemo-python/src/lib.rs +++ b/nemo-python/src/lib.rs @@ -413,8 +413,6 @@ impl NemoEngine { let execution_parameters = ExecutionParameters::default(); - // Build the engine from the program's existing handle, so that parsing, - // to keep the same program pipeline let engine = rt .block_on(ExecutionEngine::from_handle( program.0, diff --git a/nemo/src/execution/execution_engine.rs b/nemo/src/execution/execution_engine.rs index 84d3b2b1b..cfb7a8745 100644 --- a/nemo/src/execution/execution_engine.rs +++ b/nemo/src/execution/execution_engine.rs @@ -349,12 +349,12 @@ impl ExecutionEngine { &self.program } - /// Return a reference to the [ProgramHandle] backing this engine. + /// Return the current [ProgramHandle]. /// /// Note that this represents the transformed program and not the original. - /// To obtain the original program, use [ProgramHandle::original_revision]. - pub fn program_handle(&self) -> &ProgramHandle { - &self.program_handle + /// To obtain the original program, use [Self::original_program_handle]. + pub fn current_program_handle(&self) -> ProgramHandle { + self.program_handle.clone() } /// Return the [ProgramHandle] of the original program, i.e. the program as @@ -476,7 +476,9 @@ mod test { #[cfg_attr(miri, ignore)] async fn rule_source_position_reports_line() { // A fact on line 1 and a rule on line 2. - let source = "a(1).\nb(?x) :- a(?x)."; + let source = "a(1). +b(?x) :- a(?x)."; + let file = RuleFile::new(source.to_string(), "test".to_string()); let engine = DefaultExecutionEngine::from_file(file, ExecutionParameters::default()) diff --git a/nemo/src/execution/execution_engine/tracing/node_query.rs b/nemo/src/execution/execution_engine/tracing/node_query.rs index fb0e7eec6..b0eb986c3 100644 --- a/nemo/src/execution/execution_engine/tracing/node_query.rs +++ b/nemo/src/execution/execution_engine/tracing/node_query.rs @@ -113,7 +113,7 @@ impl ExecutionEngine { // Recursive call to this function for all successor nodes if let Some(successor) = &node.next { - let successor_rule = self.rule_translation.to_normalized(successor.rule); + let successor_rule = self.rule_translation.normalized_index(successor.rule); let rule = program.rules()[successor_rule].clone(); for (index, (atom, node_atom)) in rule @@ -158,7 +158,7 @@ impl ExecutionEngine { manager.add_discard(&address, discarded_columns); if let Some(successor) = &node.next { - let successor_rule = self.rule_translation.to_normalized(successor.rule); + let successor_rule = self.rule_translation.normalized_index(successor.rule); let rule = program.rules()[successor_rule].clone(); let order = rule.variable_order().clone(); @@ -341,7 +341,7 @@ impl ExecutionEngine { let discarded_columns = manager.discard(&address); if let Some(successor) = &node.next { - let successor_rule = self.rule_translation.to_normalized(successor.rule); + let successor_rule = self.rule_translation.normalized_index(successor.rule); let rule = program.rules()[successor_rule].clone(); let order = rule.variable_order().clone(); @@ -533,9 +533,9 @@ impl ExecutionEngine { .chase_program() .rules_with_body_predicate(predicate) .flat_map(|index| { - let logical_rule = self.rule_translation.original_rule(index); + let logical_rule = self.rule_translation.original_rule_unchecked(index); TraceRule::all_possible_single_head_rules( - self.rule_translation.to_original(index), + self.rule_translation.original_index(index), &logical_rule, ) .collect::>() @@ -546,10 +546,10 @@ impl ExecutionEngine { .chase_program() .rules_with_head_predicate(predicate) .flat_map(|index| { - let logical_rule = self.rule_translation.original_rule(index); + let logical_rule = self.rule_translation.original_rule_unchecked(index); TraceRule::possible_rules_for_head_predicate( - self.rule_translation.to_original(index), + self.rule_translation.original_index(index), &logical_rule, predicate, ) @@ -584,7 +584,7 @@ impl ExecutionEngine { elements.push(element); if let Some(successor) = &node.next { - let successor_rule = self.rule_translation.to_normalized(successor.rule); + let successor_rule = self.rule_translation.normalized_index(successor.rule); let rule = self.chase_program().rules()[successor_rule].clone(); // Call this function recursively for each successor diff --git a/nemo/src/execution/execution_engine/tracing/tree_query.rs b/nemo/src/execution/execution_engine/tracing/tree_query.rs index ddafacc8e..3426f71b5 100644 --- a/nemo/src/execution/execution_engine/tracing/tree_query.rs +++ b/nemo/src/execution/execution_engine/tracing/tree_query.rs @@ -112,7 +112,7 @@ impl ExecutionEngine { Box::pin(self.trace_tree_add_negation(child)).await; } - let rule_index = self.rule_translation.to_normalized(next.rule.id); + let rule_index = self.rule_translation.normalized_index(next.rule.id); let rule = self.chase_program().rules()[rule_index].clone(); for negative_atom in rule.negative() { let rows = @@ -212,9 +212,9 @@ impl ExecutionEngine { .chase_program() .rules_with_body_predicate(&predicate) .flat_map(|index| { - let logical_rule = self.rule_translation.original_rule(index); + let logical_rule = self.rule_translation.original_rule_unchecked(index); TraceRule::all_possible_single_head_rules( - self.rule_translation.to_original(index), + self.rule_translation.original_index(index), &logical_rule, ) .collect::>() @@ -225,9 +225,9 @@ impl ExecutionEngine { .chase_program() .rules_with_head_predicate(&predicate) .flat_map(|index| { - let logical_rule = self.rule_translation.original_rule(index); + let logical_rule = self.rule_translation.original_rule_unchecked(index); TraceRule::possible_rules_for_head_predicate( - self.rule_translation.to_original(index), + self.rule_translation.original_index(index), &logical_rule, &predicate, ) @@ -290,7 +290,7 @@ impl ExecutionEngine { } let chase_rule = &self.chase_program().rules()[rule_index].clone(); - let logical_rule = self.rule_translation.original_rule(rule_index); + let logical_rule = self.rule_translation.original_rule_unchecked(rule_index); for (head_index, _) in chase_rule.head().iter().enumerate() { let Some(combination) = facts @@ -373,7 +373,7 @@ impl ExecutionEngine { if let Some(children) = children_option { result.next = Some(TreeForTableResponseSuccessor { rule: TraceRule::from_rule_and_head( - self.rule_translation.to_original(rule_index), + self.rule_translation.original_index(rule_index), &logical_rule, head_index, &logical_rule.head()[head_index], @@ -494,9 +494,9 @@ impl ExecutionEngine { .program .rules_with_body_predicate(&predicate) .flat_map(|index| { - let logical_rule = self.rule_translation.original_rule(index); + let logical_rule = self.rule_translation.original_rule_unchecked(index); TraceRule::all_possible_single_head_rules( - self.rule_translation.to_original(index), + self.rule_translation.original_index(index), &logical_rule, ) .collect::>() @@ -507,9 +507,9 @@ impl ExecutionEngine { .program .rules_with_head_predicate(&predicate) .flat_map(|index| { - let logical_rule = self.rule_translation.original_rule(index); + let logical_rule = self.rule_translation.original_rule_unchecked(index); TraceRule::possible_rules_for_head_predicate( - self.rule_translation.to_original(index), + self.rule_translation.original_index(index), &logical_rule, &predicate, ) diff --git a/nemo/src/execution/tracing/rule_translation.rs b/nemo/src/execution/tracing/rule_translation.rs index cc950bcdd..396f1a0b4 100644 --- a/nemo/src/execution/tracing/rule_translation.rs +++ b/nemo/src/execution/tracing/rule_translation.rs @@ -45,45 +45,29 @@ impl RuleIdTranslation { pub(crate) fn new(handle: &ProgramHandle, normalized: &NormalizedProgram) -> Self { let original = handle.original_revision(); - // Map each original rule's id to its position in the original program. let orig_index_by_id = original .rules() .enumerate() .map(|(index, rule)| (rule.id(), index)) .collect::>(); - // For each normalized rule, resolve the original rule it should be - // displayed as and record that rule's index in the original program. - // - // This relies on the transformations being one-to-one on rules and - // recording their provenance: if a normalized rule cannot be resolved to - // an original rule, a transformation created a rule without an origin - // back-pointer, and we fail loudly rather than return a wrong rule id. - let norm_to_orig = normalized - .rules() - .iter() - .map(|rule| { - let origin_id = tracing_resolve_origin_id(handle, rule.id()); - *orig_index_by_id.get(&origin_id).expect( - "a normalized rule has no corresponding original rule; a \ - transformation created a rule without recording its origin", - ) - }) - .collect::>(); - - // Invert the mapping. As the transformations are one-to-one on rules, no - // two normalized rules map to the same original rule, so every insert is - // into a fresh slot (checked via the resulting length below). - let mut orig_to_norm = HashMap::with_capacity(norm_to_orig.len()); - for (norm_index, &orig_index) in norm_to_orig.iter().enumerate() { - orig_to_norm.insert(orig_index, norm_index); + let mut norm_to_orig = Vec::with_capacity(normalized.rules().len()); + let mut orig_to_norm = HashMap::with_capacity(normalized.rules().len()); + + for (normalized_index, rule) in normalized.rules().iter().enumerate() { + let origin_id = tracing_resolve_origin_id(handle, rule.id()); + let original_index = *orig_index_by_id + .get(&origin_id) + .expect("each normalized rule has a corresponding original rule"); + + norm_to_orig.push(original_index); + + let previous = orig_to_norm.insert(original_index, normalized_index); + debug_assert!( + previous.is_none(), + "each original rule maps to at most one normalized rule" + ); } - debug_assert_eq!( - orig_to_norm.len(), - norm_to_orig.len(), - "tracing rule translation: several normalized rules map to the same \ - original rule; a transformation is not one-to-one on rules" - ); Self { original, @@ -98,7 +82,7 @@ impl RuleIdTranslation { /// /// # Panics /// Panics if `normalized` is not a valid normalized rule index. - pub(crate) fn original_rule(&self, normalized: usize) -> Rule { + pub(crate) fn original_rule_unchecked(&self, normalized: usize) -> Rule { self.original .rule_by_index(self.norm_to_orig[normalized]) .expect("normalized rule maps to an existing original rule") @@ -109,7 +93,7 @@ impl RuleIdTranslation { /// /// # Panics /// Panics if `normalized` is not a valid normalized rule index. - pub(crate) fn to_original(&self, normalized: usize) -> usize { + pub(crate) fn original_index(&self, normalized: usize) -> usize { self.norm_to_orig[normalized] } @@ -117,7 +101,7 @@ impl RuleIdTranslation { /// /// # Panics /// Panics if `original` has no corresponding normalized rule. - pub(crate) fn to_normalized(&self, original: usize) -> usize { + pub(crate) fn normalized_index(&self, original: usize) -> usize { self.orig_to_norm .get(&original) .copied() @@ -182,12 +166,18 @@ mod test { assert_eq!(normalized.rules().len(), 1); // Index translation round-trips between the single normalized and original rule. - assert_eq!(translation.to_original(0), 0); - assert_eq!(translation.to_normalized(0), 0); + assert_eq!(translation.original_index(0), 0); + assert_eq!(translation.normalized_index(0), 0); // The displayed rule is the original one, not the rewritten (normalized) one. - assert_eq!(translation.original_rule(0).to_string(), original_string); - assert_ne!(translation.original_rule(0).to_string(), rewritten_string); + assert_eq!( + translation.original_rule_unchecked(0).to_string(), + original_string + ); + assert_ne!( + translation.original_rule_unchecked(0).to_string(), + rewritten_string + ); } #[test] @@ -195,24 +185,35 @@ mod test { fn translation_global_variable_rule() { // The global variable `$t` is substituted by TransformationGlobal. let translation = translation_for( - "@parameter $t = 5 .\ndata(5) .\nresult(?x) :- data(?x), ?x = $t .\n@output result .", + "@parameter $t = 5 . + data(5) . + result(?x) :- data(?x), ?x = $t . + @output result .", ); - assert_eq!(translation.to_original(0), 0); - assert_eq!(translation.to_normalized(0), 0); + assert_eq!(translation.original_index(0), 0); + assert_eq!(translation.normalized_index(0), 0); // Resolution recovers the original rule, which still contains `$t`. - assert!(translation.original_rule(0).to_string().contains("$t")); + assert!( + translation + .original_rule_unchecked(0) + .to_string() + .contains("$t") + ); } #[test] #[cfg_attr(miri, ignore)] fn translation_normalized_rule() { // The constant `2` in a positive body atom triggers TransformationNormalize. - let translation = - translation_for("data(1, 2) .\nresult(?x) :- data(?x, 2) .\n@output result ."); + let translation = translation_for( + "data(1, 2) . + result(?x) :- data(?x, 2) . + @output result .", + ); - assert_eq!(translation.to_original(0), 0); - assert_eq!(translation.to_normalized(0), 0); + assert_eq!(translation.original_index(0), 0); + assert_eq!(translation.normalized_index(0), 0); } #[test] @@ -227,8 +228,8 @@ mod test { @output result ."#, ); - assert_eq!(translation.to_original(0), 0); - assert_eq!(translation.to_normalized(0), 0); + assert_eq!(translation.original_index(0), 0); + assert_eq!(translation.normalized_index(0), 0); } #[test] @@ -244,7 +245,7 @@ mod test { @output result ."#, ); - assert_eq!(translation.to_original(0), 0); - assert_eq!(translation.to_normalized(0), 0); + assert_eq!(translation.original_index(0), 0); + assert_eq!(translation.normalized_index(0), 0); } } diff --git a/nemo/src/parser/span.rs b/nemo/src/parser/span.rs index a87705e0d..2f5415485 100644 --- a/nemo/src/parser/span.rs +++ b/nemo/src/parser/span.rs @@ -409,7 +409,8 @@ mod test { #[test] fn source_positions_line_and_column() { let source = "abc\nde\n\nf"; - // offsets: a0 b1 c2 \n3 d4 e5 \n6 \n7 f8 + + // offsets: a0 b1 c2 \n3 d4 e5 \n6 \n7 f8 let positions = SourcePositions::new(source); // First character of the file: line 1, column 1. @@ -436,7 +437,9 @@ mod test { #[test] fn source_positions_range() { - let source = "a(1).\nb(?x) :- a(?x)."; + let source = "a(1). + b(?x) :- a(?x)."; + let positions = SourcePositions::new(source); // The rule starts right after the newline at offset 6. diff --git a/nemo/src/rule_model/origin.rs b/nemo/src/rule_model/origin.rs index 877501ba7..4e07466ac 100644 --- a/nemo/src/rule_model/origin.rs +++ b/nemo/src/rule_model/origin.rs @@ -38,19 +38,15 @@ pub enum Origin { }, /// Rule that was created by normalizing a rule - /// (see [crate::rule_model::pipeline::transformations::normalize::TransformationNormalize]). Normalization(ProgramComponentId), - /// Rule that was created by substituting global variables in a rule - /// (see [crate::rule_model::pipeline::transformations::global::TransformationGlobal]). + /// Statement that was created by substituting global variables in a rule Global(ProgramComponentId), /// Rule that was created by inlining incremental imports into a rule - /// (see [crate::rule_model::pipeline::transformations::incremental::TransformationIncremental]). Incremental(ProgramComponentId), /// Rule that was created by merging SPARQL imports in a rule - /// (see [crate::rule_model::pipeline::transformations::merge_sparql::TransformationMergeSparql]). MergeSparql(ProgramComponentId), } diff --git a/nemo/src/rule_model/pipeline.rs b/nemo/src/rule_model/pipeline.rs index 7f19ce639..a50fc14a9 100644 --- a/nemo/src/rule_model/pipeline.rs +++ b/nemo/src/rule_model/pipeline.rs @@ -56,7 +56,7 @@ impl ProgramPipeline { /// original program (see the tracing modules). pub fn original_revision(self: &Rc) -> ProgramHandle { debug_assert!( - self.revisions.len() > 0, + !self.revisions.is_empty(), "a pipeline a handle exists for always has at least one revision" ); @@ -67,7 +67,8 @@ impl ProgramPipeline { /// /// Has no effect if a source has already been set. pub fn set_source(&self, source: &str) { - let _ = self.source.set(SourcePositions::new(source)); + let set = self.source.set(SourcePositions::new(source)); + debug_assert!(set.is_ok(), "source position is not already set"); } /// Return the [SourcePositions] index over the source text, if one was recorded. diff --git a/nemo/src/rule_model/pipeline/transformations/global.rs b/nemo/src/rule_model/pipeline/transformations/global.rs index f24ee7239..98a6cfc4a 100644 --- a/nemo/src/rule_model/pipeline/transformations/global.rs +++ b/nemo/src/rule_model/pipeline/transformations/global.rs @@ -5,7 +5,6 @@ use std::collections::{HashMap, HashSet}; use crate::rule_model::{ components::{ ComponentIdentity, ComponentSource, IterableVariables, - statement::Statement, term::primitive::{ground::GroundTerm, variable::global::GlobalVariable}, }, error::ValidationReport, @@ -89,9 +88,7 @@ impl<'a> ProgramTransformation for TransformationGlobal<'a> { let mut new_statement = statement.clone(); substitution.apply(&mut new_statement); - if let Statement::Rule(rule) = &mut new_statement { - rule.set_origin(Origin::Global(statement.id())); - } + new_statement.set_origin(Origin::Global(statement.id())); commit.add_statement(new_statement); } else { diff --git a/nemo/src/rule_model/programs/handle.rs b/nemo/src/rule_model/programs/handle.rs index 5009ececc..39942966f 100644 --- a/nemo/src/rule_model/programs/handle.rs +++ b/nemo/src/rule_model/programs/handle.rs @@ -115,9 +115,6 @@ impl ProgramHandle { } /// Return the [Rule] at the given index, counting rules only. - /// - /// This is the index used to refer to rules in tracing - /// (see [crate::execution::tracing::shared::RuleId]). pub fn rule_by_index(&self, index: usize) -> Option<&Rule> { self.rules().nth(index) } From 1c15ee9b32ff0fa1ed3707db49392a233fb75e44 Mon Sep 17 00:00:00 2001 From: Alex Ivliev Date: Fri, 26 Jun 2026 19:10:21 +0200 Subject: [PATCH 10/11] origin related unit tests --- .../src/execution/tracing/rule_translation.rs | 78 ------------------- .../pipeline/transformations/default.rs | 49 ++++++++++++ .../pipeline/transformations/global.rs | 34 ++++++++ .../pipeline/transformations/incremental.rs | 33 ++++++++ .../pipeline/transformations/merge_sparql.rs | 36 +++++++++ .../pipeline/transformations/normalize.rs | 32 ++++++++ 6 files changed, 184 insertions(+), 78 deletions(-) diff --git a/nemo/src/execution/tracing/rule_translation.rs b/nemo/src/execution/tracing/rule_translation.rs index 396f1a0b4..016c3e395 100644 --- a/nemo/src/execution/tracing/rule_translation.rs +++ b/nemo/src/execution/tracing/rule_translation.rs @@ -132,15 +132,6 @@ mod test { commit.submit().unwrap() } - /// Build a [RuleIdTranslation] for a program string - fn translation_for(program: &str) -> RuleIdTranslation { - let handle = crate::api::load_program_handle(program.to_string(), String::default()) - .expect("program should parse and validate"); - let normalized = NormalizedProgram::normalize_program(&handle); - - RuleIdTranslation::new(&handle, &normalized) - } - #[test] fn translation_resolves_to_original_rule() { // The user's (original) program. @@ -179,73 +170,4 @@ mod test { rewritten_string ); } - - #[test] - #[cfg_attr(miri, ignore)] - fn translation_global_variable_rule() { - // The global variable `$t` is substituted by TransformationGlobal. - let translation = translation_for( - "@parameter $t = 5 . - data(5) . - result(?x) :- data(?x), ?x = $t . - @output result .", - ); - - assert_eq!(translation.original_index(0), 0); - assert_eq!(translation.normalized_index(0), 0); - // Resolution recovers the original rule, which still contains `$t`. - assert!( - translation - .original_rule_unchecked(0) - .to_string() - .contains("$t") - ); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn translation_normalized_rule() { - // The constant `2` in a positive body atom triggers TransformationNormalize. - let translation = translation_for( - "data(1, 2) . - result(?x) :- data(?x, 2) . - @output result .", - ); - - assert_eq!(translation.original_index(0), 0); - assert_eq!(translation.normalized_index(0), 0); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn translation_incremental_sparql_rule() { - // A SPARQL import bound by another body atom is inlined into the rule by - // TransformationIncremental. - let translation = translation_for( - r#"@import remote :- sparql {endpoint = "http://example.org", query = "SELECT ?a ?b WHERE {?a ?b ?c.}"} . - seed(1) . - result(?a, ?b) :- seed(?a), remote(?a, ?b) . - @output result ."#, - ); - - assert_eq!(translation.original_index(0), 0); - assert_eq!(translation.normalized_index(0), 0); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn translation_merge_sparql_rule() { - // Two SPARQL imports against the same endpoint in one rule are merged by - // TransformationMergeSparql (after being inlined by TransformationIncremental). - let translation = translation_for( - r#"@import ra :- sparql {endpoint = "http://example.org", query = "SELECT ?a ?b WHERE {?a ?b ?z.}"} . - @import rb :- sparql {endpoint = "http://example.org", query = "SELECT ?a ?c WHERE {?a ?c ?z.}"} . - seed(1) . - result(?a, ?b, ?c) :- seed(?a), ra(?a, ?b), rb(?a, ?c) . - @output result ."#, - ); - - assert_eq!(translation.original_index(0), 0); - assert_eq!(translation.normalized_index(0), 0); - } } diff --git a/nemo/src/rule_model/pipeline/transformations/default.rs b/nemo/src/rule_model/pipeline/transformations/default.rs index 70b447f58..556167bef 100644 --- a/nemo/src/rule_model/pipeline/transformations/default.rs +++ b/nemo/src/rule_model/pipeline/transformations/default.rs @@ -64,3 +64,52 @@ impl<'a> ProgramTransformation for TransformationDefault<'a> { .transform(TransformationFilterImports::new()) } } + +#[cfg(test)] +mod test { + use crate::{ + api::load_program_handle, + execution::{ + planning::normalization::program::NormalizedProgram, + tracing::rule_translation::RuleIdTranslation, + }, + rule_model::programs::ProgramRead, + }; + + #[test] + #[cfg_attr(miri, ignore)] + fn rule_translation_default_transformations() { + let program = r#"@parameter $t = 1 . + @import ra :- sparql {endpoint = "http://example.org", query = "SELECT ?a ?b WHERE {?a ?b ?z.}"} . + @import rb :- sparql {endpoint = "http://example.org", query = "SELECT ?a ?c WHERE {?a ?c ?z.}"} . + seed(1) . + fact(2, 5) . + global_rule(?x) :- seed(?x), ?x = $t . + normalized_rule(?x) :- fact(?x, 5) . + dropped_rule(?x) :- seed(?x) . + merged_rule(?a, ?b, ?c) :- seed(?a), ra(?a, ?b), rb(?a, ?c) . + @output global_rule . + @output normalized_rule . + @output merged_rule ."#; + let handle = load_program_handle(program.to_string(), String::default()) + .expect("program parses and validates"); + let normalized = NormalizedProgram::normalize_program(&handle); + + let translation = RuleIdTranslation::new(&handle, &normalized); + + assert!(handle.original_revision().rules().count() > normalized.rules().len()); + + for (normalized_index, normalized_rule) in normalized.rules().iter().enumerate() { + let original_index = translation.original_index(normalized_index); + + assert_eq!( + translation.normalized_index(original_index), + normalized_index + ); + assert_eq!( + translation.original_rule_unchecked(normalized_index).head()[0].predicate(), + normalized_rule.head()[0].predicate() + ); + } + } +} diff --git a/nemo/src/rule_model/pipeline/transformations/global.rs b/nemo/src/rule_model/pipeline/transformations/global.rs index 98a6cfc4a..10d4ca8f7 100644 --- a/nemo/src/rule_model/pipeline/transformations/global.rs +++ b/nemo/src/rule_model/pipeline/transformations/global.rs @@ -99,3 +99,37 @@ impl<'a> ProgramTransformation for TransformationGlobal<'a> { commit.submit() } } + +#[cfg(test)] +mod test { + use std::collections::HashMap; + + use crate::{ + rule_file::RuleFile, + rule_model::{ + components::{ComponentIdentity, ComponentSource}, + origin::Origin, + programs::{ProgramRead, handle::ProgramHandle}, + }, + }; + + use super::TransformationGlobal; + + #[test] + fn records_origin_of_substituted_rule() { + let program = "@parameter $t = 5 . a(?x) :- b(?x), ?x = $t ."; + let handle = + ProgramHandle::from_file(&RuleFile::new(program.to_string(), String::default())) + .expect("program parses") + .into_object(); + + let original = handle.rules().next().unwrap().id(); + + let transformed = handle + .transform(TransformationGlobal::new(&HashMap::new())) + .unwrap(); + let rule = transformed.rules().next().unwrap(); + + assert!(matches!(rule.origin(), Origin::Global(id) if id == original)); + } +} diff --git a/nemo/src/rule_model/pipeline/transformations/incremental.rs b/nemo/src/rule_model/pipeline/transformations/incremental.rs index c8554398c..7f68b9933 100644 --- a/nemo/src/rule_model/pipeline/transformations/incremental.rs +++ b/nemo/src/rule_model/pipeline/transformations/incremental.rs @@ -335,3 +335,36 @@ impl ProgramTransformation for TransformationIncremental { commit.submit() } } + +#[cfg(test)] +mod test { + use crate::{ + rule_file::RuleFile, + rule_model::{ + components::{ComponentIdentity, ComponentSource}, + origin::Origin, + programs::{ProgramRead, handle::ProgramHandle}, + }, + }; + + use super::TransformationIncremental; + + #[test] + fn records_origin_of_inlined_rule() { + let program = r#"@import remote :- sparql {endpoint = "http://example.org", query = "SELECT ?a ?b WHERE {?a ?b ?z.}"} . + seed(1) . + out(?a, ?b) :- seed(?a), remote(?a, ?b) . + @output out ."#; + let handle = + ProgramHandle::from_file(&RuleFile::new(program.to_string(), String::default())) + .expect("program parses") + .into_object(); + + let original = handle.rules().next().unwrap().id(); + + let transformed = handle.transform(TransformationIncremental::new()).unwrap(); + let rule = transformed.rules().next().unwrap(); + + assert!(matches!(rule.origin(), Origin::Incremental(id) if id == original)); + } +} diff --git a/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs b/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs index aed57f6a5..4a579d444 100644 --- a/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs +++ b/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs @@ -272,3 +272,39 @@ fn update_import( commit.add_rule(rule); } + +#[cfg(test)] +mod test { + use crate::{ + rule_file::RuleFile, + rule_model::{ + components::{ComponentIdentity, ComponentSource}, + origin::Origin, + pipeline::transformations::incremental::TransformationIncremental, + programs::{ProgramRead, handle::ProgramHandle}, + }, + }; + + use super::TransformationMergeSparql; + + #[test] + fn records_origin_of_merged_rule() { + let program = r#"@import ra :- sparql {endpoint = "http://example.org", query = "SELECT ?a ?b WHERE {?a ?b ?z.}"} . + @import rb :- sparql {endpoint = "http://example.org", query = "SELECT ?a ?c WHERE {?a ?c ?z.}"} . + seed(1) . + out(?a, ?b, ?c) :- seed(?a), ra(?a, ?b), rb(?a, ?c) . + @output out ."#; + let handle = + ProgramHandle::from_file(&RuleFile::new(program.to_string(), String::default())) + .expect("program parses") + .into_object(); + + let inlined = handle.transform(TransformationIncremental::new()).unwrap(); + let predecessor = inlined.rules().next().unwrap().id(); + + let transformed = inlined.transform(TransformationMergeSparql).unwrap(); + let rule = transformed.rules().next().unwrap(); + + assert!(matches!(rule.origin(), Origin::MergeSparql(id) if id == predecessor)); + } +} diff --git a/nemo/src/rule_model/pipeline/transformations/normalize.rs b/nemo/src/rule_model/pipeline/transformations/normalize.rs index 91ed249cd..984802c45 100644 --- a/nemo/src/rule_model/pipeline/transformations/normalize.rs +++ b/nemo/src/rule_model/pipeline/transformations/normalize.rs @@ -148,3 +148,35 @@ impl ProgramTransformation for TransformationNormalize { commit.submit() } } + +#[cfg(test)] +mod test { + use crate::{ + rule_file::RuleFile, + rule_model::{ + components::{ComponentIdentity, ComponentSource}, + origin::Origin, + programs::{ProgramRead, handle::ProgramHandle}, + }, + }; + + use super::TransformationNormalize; + + #[test] + fn records_origin_of_normalized_rule() { + let program = "a(?x) :- b(?x, 5) ."; + let handle = + ProgramHandle::from_file(&RuleFile::new(program.to_string(), String::default())) + .expect("program parses") + .into_object(); + + let original = handle.rules().next().unwrap().id(); + + let transformed = handle + .transform(TransformationNormalize::default()) + .unwrap(); + let rule = transformed.rules().next().unwrap(); + + assert!(matches!(rule.origin(), Origin::Normalization(id) if id == original)); + } +} From 6c716af5081448e3b715c6372e41edaeeee70e62 Mon Sep 17 00:00:00 2001 From: Alex Ivliev Date: Fri, 26 Jun 2026 19:42:32 +0200 Subject: [PATCH 11/11] assert(matches) -> assert_matches --- nemo/src/rule_model/pipeline/transformations/global.rs | 4 ++-- nemo/src/rule_model/pipeline/transformations/incremental.rs | 4 +++- nemo/src/rule_model/pipeline/transformations/merge_sparql.rs | 4 +++- nemo/src/rule_model/pipeline/transformations/normalize.rs | 4 +++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/nemo/src/rule_model/pipeline/transformations/global.rs b/nemo/src/rule_model/pipeline/transformations/global.rs index 10d4ca8f7..f0497e23f 100644 --- a/nemo/src/rule_model/pipeline/transformations/global.rs +++ b/nemo/src/rule_model/pipeline/transformations/global.rs @@ -102,7 +102,7 @@ impl<'a> ProgramTransformation for TransformationGlobal<'a> { #[cfg(test)] mod test { - use std::collections::HashMap; + use std::{assert_matches, collections::HashMap}; use crate::{ rule_file::RuleFile, @@ -130,6 +130,6 @@ mod test { .unwrap(); let rule = transformed.rules().next().unwrap(); - assert!(matches!(rule.origin(), Origin::Global(id) if id == original)); + assert_matches!(rule.origin(), Origin::Global(id) if id == original); } } diff --git a/nemo/src/rule_model/pipeline/transformations/incremental.rs b/nemo/src/rule_model/pipeline/transformations/incremental.rs index 7f68b9933..81e83496c 100644 --- a/nemo/src/rule_model/pipeline/transformations/incremental.rs +++ b/nemo/src/rule_model/pipeline/transformations/incremental.rs @@ -338,6 +338,8 @@ impl ProgramTransformation for TransformationIncremental { #[cfg(test)] mod test { + use std::assert_matches; + use crate::{ rule_file::RuleFile, rule_model::{ @@ -365,6 +367,6 @@ mod test { let transformed = handle.transform(TransformationIncremental::new()).unwrap(); let rule = transformed.rules().next().unwrap(); - assert!(matches!(rule.origin(), Origin::Incremental(id) if id == original)); + assert_matches!(rule.origin(), Origin::Incremental(id) if id == original); } } diff --git a/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs b/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs index 4a579d444..c47339b7a 100644 --- a/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs +++ b/nemo/src/rule_model/pipeline/transformations/merge_sparql.rs @@ -275,6 +275,8 @@ fn update_import( #[cfg(test)] mod test { + use std::assert_matches; + use crate::{ rule_file::RuleFile, rule_model::{ @@ -305,6 +307,6 @@ mod test { let transformed = inlined.transform(TransformationMergeSparql).unwrap(); let rule = transformed.rules().next().unwrap(); - assert!(matches!(rule.origin(), Origin::MergeSparql(id) if id == predecessor)); + assert_matches!(rule.origin(), Origin::MergeSparql(id) if id == predecessor); } } diff --git a/nemo/src/rule_model/pipeline/transformations/normalize.rs b/nemo/src/rule_model/pipeline/transformations/normalize.rs index 984802c45..c44ec7d6d 100644 --- a/nemo/src/rule_model/pipeline/transformations/normalize.rs +++ b/nemo/src/rule_model/pipeline/transformations/normalize.rs @@ -151,6 +151,8 @@ impl ProgramTransformation for TransformationNormalize { #[cfg(test)] mod test { + use std::assert_matches; + use crate::{ rule_file::RuleFile, rule_model::{ @@ -177,6 +179,6 @@ mod test { .unwrap(); let rule = transformed.rules().next().unwrap(); - assert!(matches!(rule.origin(), Origin::Normalization(id) if id == original)); + assert_matches!(rule.origin(), Origin::Normalization(id) if id == original); } }