Skip to main content

oxirs_embed/models/
rotate.rs

1//! RotatE: Rotation-based Knowledge Graph Embeddings
2//!
3//! RotatE models relations as rotations in complex space, which allows it to
4//! handle symmetric, antisymmetric, inverse, and compositional relation patterns.
5//! Each relation is represented as a rotation from head to tail entity.
6//!
7//! Reference: Sun et al. "RotatE: Knowledge Graph Embedding by Relational Rotation in Complex Space" (2019)
8
9use crate::models::serialization::{BaseModelSnapshot, MatrixF64};
10use crate::models::{common::*, BaseModel};
11use crate::{EmbeddingModel, ModelConfig, ModelStats, TrainingStats, Triple, Vector};
12use anyhow::{anyhow, Result};
13use async_trait::async_trait;
14use scirs2_core::ndarray_ext::Array2;
15#[allow(unused_imports)]
16use scirs2_core::random::{Random, RngExt};
17use serde::{Deserialize, Serialize};
18use std::fs::File;
19use std::io::{BufReader, BufWriter};
20use std::path::Path;
21use std::time::Instant;
22use tracing::{debug, info};
23use uuid::Uuid;
24
25/// Serializable representation of a RotatE model for persistence.
26#[derive(Debug, Serialize, Deserialize)]
27struct RotatESerializable {
28    base: BaseModelSnapshot,
29    entity_embeddings_real: MatrixF64,
30    entity_embeddings_imag: MatrixF64,
31    relation_phases: MatrixF64,
32    embeddings_initialized: bool,
33    adversarial_temperature: f64,
34    modulus_constraint: bool,
35}
36
37/// RotatE embedding model using complex rotations
38#[derive(Debug)]
39pub struct RotatE {
40    /// Base model functionality
41    base: BaseModel,
42    /// Real part of entity embeddings (num_entities × dimensions)
43    entity_embeddings_real: Array2<f64>,
44    /// Imaginary part of entity embeddings (num_entities × dimensions)
45    entity_embeddings_imag: Array2<f64>,
46    /// Relation phases/angles (num_relations × dimensions) - angles in [0, 2π]
47    relation_phases: Array2<f64>,
48    /// Whether embeddings have been initialized
49    embeddings_initialized: bool,
50    /// Adversarial temperature for negative sampling
51    adversarial_temperature: f64,
52    /// Modulus constraint for entity embeddings
53    modulus_constraint: bool,
54}
55
56impl RotatE {
57    /// Create a new RotatE model
58    pub fn new(config: ModelConfig) -> Self {
59        let base = BaseModel::new(config.clone());
60
61        // Get RotatE-specific parameters
62        let adversarial_temperature = config
63            .model_params
64            .get("adversarial_temperature")
65            .copied()
66            .unwrap_or(1.0);
67
68        let modulus_constraint = config
69            .model_params
70            .get("modulus_constraint")
71            .map(|&x| x > 0.0)
72            .unwrap_or(true);
73
74        Self {
75            base,
76            entity_embeddings_real: Array2::zeros((0, config.dimensions)),
77            entity_embeddings_imag: Array2::zeros((0, config.dimensions)),
78            relation_phases: Array2::zeros((0, config.dimensions)),
79            embeddings_initialized: false,
80            adversarial_temperature,
81            modulus_constraint,
82        }
83    }
84
85    /// Initialize embeddings with proper constraints
86    fn initialize_embeddings(&mut self) {
87        if self.embeddings_initialized {
88            return;
89        }
90
91        let num_entities = self.base.num_entities();
92        let num_relations = self.base.num_relations();
93        let dimensions = self.base.config.dimensions;
94
95        if num_entities == 0 || num_relations == 0 {
96            return;
97        }
98
99        let mut rng = Random::default();
100
101        // Initialize entity embeddings with uniform distribution
102        self.entity_embeddings_real = uniform_init((num_entities, dimensions), -1.0, 1.0, &mut rng);
103
104        self.entity_embeddings_imag = uniform_init((num_entities, dimensions), -1.0, 1.0, &mut rng);
105
106        // Initialize relation phases uniformly in [0, 2π]
107        self.relation_phases = uniform_init(
108            (num_relations, dimensions),
109            0.0,
110            2.0 * std::f64::consts::PI,
111            &mut rng,
112        );
113
114        // Apply modulus constraint to entity embeddings (normalize to unit circle)
115        if self.modulus_constraint {
116            self.apply_modulus_constraint();
117        }
118
119        self.embeddings_initialized = true;
120        debug!(
121            "Initialized RotatE embeddings: {} entities, {} relations, {} dimensions",
122            num_entities, num_relations, dimensions
123        );
124    }
125
126    /// Apply modulus constraint to entity embeddings
127    fn apply_modulus_constraint(&mut self) {
128        for i in 0..self.entity_embeddings_real.nrows() {
129            let mut real_row = self.entity_embeddings_real.row_mut(i);
130            let mut imag_row = self.entity_embeddings_imag.row_mut(i);
131
132            for j in 0..real_row.len() {
133                let real = real_row[j];
134                let imag = imag_row[j];
135                let modulus = (real * real + imag * imag).sqrt();
136
137                if modulus > 1e-10 {
138                    real_row[j] = real / modulus;
139                    imag_row[j] = imag / modulus;
140                }
141            }
142        }
143    }
144
145    /// Score a triple using RotatE scoring function
146    /// Score = ||h ○ r - t||, where ○ denotes complex multiplication (rotation)
147    fn score_triple_ids(
148        &self,
149        subject_id: usize,
150        predicate_id: usize,
151        object_id: usize,
152    ) -> Result<f64> {
153        if !self.embeddings_initialized {
154            return Err(anyhow!("Model not trained"));
155        }
156
157        let h_real = self.entity_embeddings_real.row(subject_id);
158        let h_imag = self.entity_embeddings_imag.row(subject_id);
159        let r_phases = self.relation_phases.row(predicate_id);
160        let t_real = self.entity_embeddings_real.row(object_id);
161        let t_imag = self.entity_embeddings_imag.row(object_id);
162
163        // Compute h ○ r (rotation of h by r)
164        // r is represented as e^(i*θ) = cos(θ) + i*sin(θ)
165        // h ○ r = (h_real + i*h_imag) * (cos(θ) + i*sin(θ))
166        //       = (h_real*cos(θ) - h_imag*sin(θ)) + i*(h_real*sin(θ) + h_imag*cos(θ))
167
168        let mut distance_squared = 0.0;
169
170        for ((((&h_r, &h_i), &phase), &t_r), &t_i) in h_real
171            .iter()
172            .zip(h_imag.iter())
173            .zip(r_phases.iter())
174            .zip(t_real.iter())
175            .zip(t_imag.iter())
176        {
177            let cos_phase = phase.cos();
178            let sin_phase = phase.sin();
179
180            // Rotated head entity
181            let rotated_real = h_r * cos_phase - h_i * sin_phase;
182            let rotated_imag = h_r * sin_phase + h_i * cos_phase;
183
184            // Distance components
185            let diff_real = rotated_real - t_r;
186            let diff_imag = rotated_imag - t_i;
187
188            distance_squared += diff_real * diff_real + diff_imag * diff_imag;
189        }
190
191        // Return negative distance as score (higher is better)
192        Ok(-distance_squared.sqrt())
193    }
194
195    /// Compute gradients for RotatE model
196    fn compute_gradients(
197        &self,
198        pos_triple: (usize, usize, usize),
199        neg_triple: (usize, usize, usize),
200        pos_score: f64,
201        neg_score: f64,
202    ) -> Result<(Array2<f64>, Array2<f64>, Array2<f64>)> {
203        let mut entity_grads_real = Array2::zeros(self.entity_embeddings_real.raw_dim());
204        let mut entity_grads_imag = Array2::zeros(self.entity_embeddings_imag.raw_dim());
205        let mut relation_grads = Array2::zeros(self.relation_phases.raw_dim());
206
207        // Margin-based ranking loss gradients
208        let margin = self
209            .base
210            .config
211            .model_params
212            .get("margin")
213            .copied()
214            .unwrap_or(6.0);
215        let loss = margin + (-pos_score) - (-neg_score); // Convert back to distances
216
217        if loss > 0.0 {
218            // Compute gradients for positive triple (increase distance)
219            self.add_triple_gradients(
220                pos_triple,
221                1.0,
222                &mut entity_grads_real,
223                &mut entity_grads_imag,
224                &mut relation_grads,
225            );
226
227            // Compute gradients for negative triple (decrease distance)
228            self.add_triple_gradients(
229                neg_triple,
230                -1.0,
231                &mut entity_grads_real,
232                &mut entity_grads_imag,
233                &mut relation_grads,
234            );
235        }
236
237        Ok((entity_grads_real, entity_grads_imag, relation_grads))
238    }
239
240    /// Add gradients for a single triple
241    fn add_triple_gradients(
242        &self,
243        triple: (usize, usize, usize),
244        grad_coeff: f64,
245        entity_grads_real: &mut Array2<f64>,
246        entity_grads_imag: &mut Array2<f64>,
247        relation_grads: &mut Array2<f64>,
248    ) {
249        let (s, p, o) = triple;
250
251        let h_real = self.entity_embeddings_real.row(s);
252        let h_imag = self.entity_embeddings_imag.row(s);
253        let r_phases = self.relation_phases.row(p);
254        let t_real = self.entity_embeddings_real.row(o);
255        let t_imag = self.entity_embeddings_imag.row(o);
256
257        for (i, ((((&h_r, &h_i), &phase), &t_r), &t_i)) in h_real
258            .iter()
259            .zip(h_imag.iter())
260            .zip(r_phases.iter())
261            .zip(t_real.iter())
262            .zip(t_imag.iter())
263            .enumerate()
264        {
265            let cos_phase = phase.cos();
266            let sin_phase = phase.sin();
267
268            // Rotated head entity
269            let rotated_real = h_r * cos_phase - h_i * sin_phase;
270            let rotated_imag = h_r * sin_phase + h_i * cos_phase;
271
272            // Distance components
273            let diff_real = rotated_real - t_r;
274            let diff_imag = rotated_imag - t_i;
275
276            let distance = (diff_real * diff_real + diff_imag * diff_imag).sqrt();
277
278            if distance > 1e-10 {
279                let norm_factor = grad_coeff / distance;
280                let grad_real = diff_real * norm_factor;
281                let grad_imag = diff_imag * norm_factor;
282
283                // Gradients w.r.t. head entity (subject)
284                entity_grads_real[[s, i]] += grad_real * cos_phase + grad_imag * sin_phase;
285                entity_grads_imag[[s, i]] += -grad_real * sin_phase + grad_imag * cos_phase;
286
287                // Gradients w.r.t. tail entity (object)
288                entity_grads_real[[o, i]] -= grad_real;
289                entity_grads_imag[[o, i]] -= grad_imag;
290
291                // Gradients w.r.t. relation phases
292                let phase_grad = grad_real * (-h_r * sin_phase - h_i * cos_phase)
293                    + grad_imag * (h_r * cos_phase - h_i * sin_phase);
294                relation_grads[[p, i]] += phase_grad;
295            }
296        }
297    }
298
299    /// Generate adversarial negative samples
300    fn generate_adversarial_negatives(
301        &self,
302        positive_triple: (usize, usize, usize),
303        num_samples: usize,
304        rng: &mut Random,
305    ) -> Vec<(usize, usize, usize)> {
306        let mut negatives = Vec::new();
307        let num_entities = self.base.num_entities();
308
309        for _ in 0..num_samples {
310            // Choose to corrupt either head or tail
311            let corrupt_head = rng.random_f64() < 0.5;
312
313            if corrupt_head {
314                // Sample entity according to adversarial distribution
315                let mut candidate_scores = Vec::new();
316                for entity_id in 0..num_entities {
317                    if entity_id != positive_triple.0 {
318                        let neg_triple = (entity_id, positive_triple.1, positive_triple.2);
319                        if let Ok(score) =
320                            self.score_triple_ids(neg_triple.0, neg_triple.1, neg_triple.2)
321                        {
322                            candidate_scores.push((entity_id, score));
323                        }
324                    }
325                }
326
327                if !candidate_scores.is_empty() {
328                    // Use adversarial sampling based on scores
329                    let weights: Vec<f64> = candidate_scores
330                        .iter()
331                        .map(|(_, score)| (-score / self.adversarial_temperature).exp())
332                        .collect();
333
334                    let total_weight: f64 = weights.iter().sum();
335                    let mut cumulative = 0.0;
336                    let threshold = rng.random_f64() * total_weight;
337
338                    for (i, &weight) in weights.iter().enumerate() {
339                        cumulative += weight;
340                        if cumulative >= threshold {
341                            let entity_id = candidate_scores[i].0;
342                            negatives.push((entity_id, positive_triple.1, positive_triple.2));
343                            break;
344                        }
345                    }
346                }
347            } else {
348                // Similar logic for corrupting tail
349                let mut candidate_scores = Vec::new();
350                for entity_id in 0..num_entities {
351                    if entity_id != positive_triple.2 {
352                        let neg_triple = (positive_triple.0, positive_triple.1, entity_id);
353                        if let Ok(score) =
354                            self.score_triple_ids(neg_triple.0, neg_triple.1, neg_triple.2)
355                        {
356                            candidate_scores.push((entity_id, score));
357                        }
358                    }
359                }
360
361                if !candidate_scores.is_empty() {
362                    let weights: Vec<f64> = candidate_scores
363                        .iter()
364                        .map(|(_, score)| (-score / self.adversarial_temperature).exp())
365                        .collect();
366
367                    let total_weight: f64 = weights.iter().sum();
368                    let mut cumulative = 0.0;
369                    let threshold = rng.random_f64() * total_weight;
370
371                    for (i, &weight) in weights.iter().enumerate() {
372                        cumulative += weight;
373                        if cumulative >= threshold {
374                            let entity_id = candidate_scores[i].0;
375                            negatives.push((positive_triple.0, positive_triple.1, entity_id));
376                            break;
377                        }
378                    }
379                }
380            }
381        }
382
383        // Fall back to uniform sampling if adversarial sampling fails
384        while negatives.len() < num_samples {
385            let corrupt_head = rng.random_f64() < 0.5;
386            let negative_triple = if corrupt_head {
387                let new_head = rng.random_range(0..num_entities);
388                (new_head, positive_triple.1, positive_triple.2)
389            } else {
390                let new_tail = rng.random_range(0..num_entities);
391                (positive_triple.0, positive_triple.1, new_tail)
392            };
393
394            if !self
395                .base
396                .has_triple(negative_triple.0, negative_triple.1, negative_triple.2)
397            {
398                negatives.push(negative_triple);
399            }
400        }
401
402        negatives
403    }
404
405    /// Perform one training epoch
406    async fn train_epoch(&mut self, learning_rate: f64) -> Result<f64> {
407        let mut rng = Random::default();
408
409        let mut total_loss = 0.0;
410        let num_batches = (self.base.triples.len() + self.base.config.batch_size - 1)
411            / self.base.config.batch_size;
412
413        let mut shuffled_triples = self.base.triples.clone();
414        // Manual Fisher-Yates shuffle using scirs2-core
415        for i in (1..shuffled_triples.len()).rev() {
416            let j = rng.random_range(0..i + 1);
417            shuffled_triples.swap(i, j);
418        }
419
420        for batch_triples in shuffled_triples.chunks(self.base.config.batch_size) {
421            let mut batch_entity_grads_real = Array2::zeros(self.entity_embeddings_real.raw_dim());
422            let mut batch_entity_grads_imag = Array2::zeros(self.entity_embeddings_imag.raw_dim());
423            let mut batch_relation_grads = Array2::zeros(self.relation_phases.raw_dim());
424            let mut batch_loss = 0.0;
425
426            for &pos_triple in batch_triples {
427                // Use adversarial negative sampling
428                let neg_samples = self.generate_adversarial_negatives(
429                    pos_triple,
430                    self.base.config.negative_samples,
431                    &mut rng,
432                );
433
434                for neg_triple in neg_samples {
435                    let pos_score =
436                        self.score_triple_ids(pos_triple.0, pos_triple.1, pos_triple.2)?;
437                    let neg_score =
438                        self.score_triple_ids(neg_triple.0, neg_triple.1, neg_triple.2)?;
439
440                    // Convert scores back to distances for loss computation
441                    let pos_distance = -pos_score;
442                    let neg_distance = -neg_score;
443
444                    let margin = self
445                        .base
446                        .config
447                        .model_params
448                        .get("margin")
449                        .copied()
450                        .unwrap_or(6.0);
451                    // margin_loss(positive_score, negative_score, margin)
452                    // = max(0, margin + negative_score - positive_score). RotatE's hinge loss is
453                    // max(0, margin + pos_distance - neg_distance), so pass neg_distance in the
454                    // positive-score slot and pos_distance in the negative-score slot to match
455                    // compute_gradients' `margin + (-pos_score) - (-neg_score)`.
456                    let loss = margin_loss(neg_distance, pos_distance, margin);
457                    batch_loss += loss;
458
459                    if loss > 0.0 {
460                        let (entity_grads_real, entity_grads_imag, relation_grads) =
461                            self.compute_gradients(pos_triple, neg_triple, pos_score, neg_score)?;
462
463                        batch_entity_grads_real += &entity_grads_real;
464                        batch_entity_grads_imag += &entity_grads_imag;
465                        batch_relation_grads += &relation_grads;
466                    }
467                }
468            }
469
470            // Apply gradients with regularization
471            gradient_update(
472                &mut self.entity_embeddings_real,
473                &batch_entity_grads_real,
474                learning_rate,
475                self.base.config.l2_reg,
476            );
477
478            gradient_update(
479                &mut self.entity_embeddings_imag,
480                &batch_entity_grads_imag,
481                learning_rate,
482                self.base.config.l2_reg,
483            );
484
485            gradient_update(
486                &mut self.relation_phases,
487                &batch_relation_grads,
488                learning_rate,
489                0.0, // No regularization on phases
490            );
491
492            // Apply modulus constraint
493            if self.modulus_constraint {
494                self.apply_modulus_constraint();
495            }
496
497            // Constrain relation phases to [0, 2π]
498            self.relation_phases.mapv_inplace(|x| {
499                let mut angle = x % (2.0 * std::f64::consts::PI);
500                if angle < 0.0 {
501                    angle += 2.0 * std::f64::consts::PI;
502                }
503                angle
504            });
505
506            total_loss += batch_loss;
507        }
508
509        Ok(total_loss / num_batches as f64)
510    }
511
512    /// Get entity embedding as concatenated real/imaginary vector
513    fn get_entity_embedding_vector(&self, entity_id: usize) -> Vector {
514        let real_part = self.entity_embeddings_real.row(entity_id);
515        let imag_part = self.entity_embeddings_imag.row(entity_id);
516
517        let mut values = Vec::with_capacity(real_part.len() * 2);
518        for &val in real_part.iter() {
519            values.push(val as f32);
520        }
521        for &val in imag_part.iter() {
522            values.push(val as f32);
523        }
524
525        Vector::new(values)
526    }
527
528    /// Get relation embedding as phase vector
529    fn get_relation_embedding_vector(&self, relation_id: usize) -> Vector {
530        let phases = self.relation_phases.row(relation_id);
531        let values: Vec<f32> = phases.iter().copied().map(|x| x as f32).collect();
532        Vector::new(values)
533    }
534}
535
536#[async_trait]
537impl EmbeddingModel for RotatE {
538    fn config(&self) -> &ModelConfig {
539        &self.base.config
540    }
541
542    fn model_id(&self) -> &Uuid {
543        &self.base.model_id
544    }
545
546    fn model_type(&self) -> &'static str {
547        "RotatE"
548    }
549
550    fn add_triple(&mut self, triple: Triple) -> Result<()> {
551        self.base.add_triple(triple)
552    }
553
554    async fn train(&mut self, epochs: Option<usize>) -> Result<TrainingStats> {
555        let start_time = Instant::now();
556        let max_epochs = epochs.unwrap_or(self.base.config.max_epochs);
557
558        self.initialize_embeddings();
559
560        if !self.embeddings_initialized {
561            return Err(anyhow!("No training data available"));
562        }
563
564        let mut loss_history = Vec::new();
565        let learning_rate = self.base.config.learning_rate;
566
567        info!("Starting RotatE training for {} epochs", max_epochs);
568
569        for epoch in 0..max_epochs {
570            let epoch_loss = self.train_epoch(learning_rate).await?;
571            loss_history.push(epoch_loss);
572
573            if epoch % 100 == 0 {
574                debug!("Epoch {}: loss = {:.6}", epoch, epoch_loss);
575            }
576
577            if epoch > 10 && epoch_loss < 1e-6 {
578                info!("Converged at epoch {} with loss {:.6}", epoch, epoch_loss);
579                break;
580            }
581        }
582
583        self.base.mark_trained();
584        let training_time = start_time.elapsed().as_secs_f64();
585
586        Ok(TrainingStats {
587            epochs_completed: loss_history.len(),
588            final_loss: loss_history.last().copied().unwrap_or(0.0),
589            training_time_seconds: training_time,
590            convergence_achieved: loss_history.last().copied().unwrap_or(f64::INFINITY) < 1e-6,
591            loss_history,
592        })
593    }
594
595    fn get_entity_embedding(&self, entity: &str) -> Result<Vector> {
596        if !self.embeddings_initialized {
597            return Err(anyhow!("Model not trained"));
598        }
599
600        let entity_id = self
601            .base
602            .get_entity_id(entity)
603            .ok_or_else(|| anyhow!("Entity not found: {}", entity))?;
604
605        Ok(self.get_entity_embedding_vector(entity_id))
606    }
607
608    fn get_relation_embedding(&self, relation: &str) -> Result<Vector> {
609        if !self.embeddings_initialized {
610            return Err(anyhow!("Model not trained"));
611        }
612
613        let relation_id = self
614            .base
615            .get_relation_id(relation)
616            .ok_or_else(|| anyhow!("Relation not found: {}", relation))?;
617
618        Ok(self.get_relation_embedding_vector(relation_id))
619    }
620
621    fn score_triple(&self, subject: &str, predicate: &str, object: &str) -> Result<f64> {
622        let subject_id = self
623            .base
624            .get_entity_id(subject)
625            .ok_or_else(|| anyhow!("Subject not found: {}", subject))?;
626        let predicate_id = self
627            .base
628            .get_relation_id(predicate)
629            .ok_or_else(|| anyhow!("Predicate not found: {}", predicate))?;
630        let object_id = self
631            .base
632            .get_entity_id(object)
633            .ok_or_else(|| anyhow!("Object not found: {}", object))?;
634
635        self.score_triple_ids(subject_id, predicate_id, object_id)
636    }
637
638    fn predict_objects(
639        &self,
640        subject: &str,
641        predicate: &str,
642        k: usize,
643    ) -> Result<Vec<(String, f64)>> {
644        if !self.embeddings_initialized {
645            return Err(anyhow!("Model not trained"));
646        }
647
648        let subject_id = self
649            .base
650            .get_entity_id(subject)
651            .ok_or_else(|| anyhow!("Subject not found: {}", subject))?;
652        let predicate_id = self
653            .base
654            .get_relation_id(predicate)
655            .ok_or_else(|| anyhow!("Predicate not found: {}", predicate))?;
656
657        let mut scores = Vec::new();
658
659        for object_id in 0..self.base.num_entities() {
660            let score = self.score_triple_ids(subject_id, predicate_id, object_id)?;
661            let object_name = self
662                .base
663                .get_entity(object_id)
664                .expect("entity should exist for valid id")
665                .clone();
666            scores.push((object_name, score));
667        }
668
669        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
670        scores.truncate(k);
671
672        Ok(scores)
673    }
674
675    fn predict_subjects(
676        &self,
677        predicate: &str,
678        object: &str,
679        k: usize,
680    ) -> Result<Vec<(String, f64)>> {
681        if !self.embeddings_initialized {
682            return Err(anyhow!("Model not trained"));
683        }
684
685        let predicate_id = self
686            .base
687            .get_relation_id(predicate)
688            .ok_or_else(|| anyhow!("Predicate not found: {}", predicate))?;
689        let object_id = self
690            .base
691            .get_entity_id(object)
692            .ok_or_else(|| anyhow!("Object not found: {}", object))?;
693
694        let mut scores = Vec::new();
695
696        for subject_id in 0..self.base.num_entities() {
697            let score = self.score_triple_ids(subject_id, predicate_id, object_id)?;
698            let subject_name = self
699                .base
700                .get_entity(subject_id)
701                .expect("entity should exist for valid id")
702                .clone();
703            scores.push((subject_name, score));
704        }
705
706        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
707        scores.truncate(k);
708
709        Ok(scores)
710    }
711
712    fn predict_relations(
713        &self,
714        subject: &str,
715        object: &str,
716        k: usize,
717    ) -> Result<Vec<(String, f64)>> {
718        if !self.embeddings_initialized {
719            return Err(anyhow!("Model not trained"));
720        }
721
722        let subject_id = self
723            .base
724            .get_entity_id(subject)
725            .ok_or_else(|| anyhow!("Subject not found: {}", subject))?;
726        let object_id = self
727            .base
728            .get_entity_id(object)
729            .ok_or_else(|| anyhow!("Object not found: {}", object))?;
730
731        let mut scores = Vec::new();
732
733        for predicate_id in 0..self.base.num_relations() {
734            let score = self.score_triple_ids(subject_id, predicate_id, object_id)?;
735            let predicate_name = self
736                .base
737                .get_relation(predicate_id)
738                .expect("relation should exist for valid id")
739                .clone();
740            scores.push((predicate_name, score));
741        }
742
743        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
744        scores.truncate(k);
745
746        Ok(scores)
747    }
748
749    fn get_entities(&self) -> Vec<String> {
750        self.base.get_entities()
751    }
752
753    fn get_relations(&self) -> Vec<String> {
754        self.base.get_relations()
755    }
756
757    fn get_stats(&self) -> ModelStats {
758        self.base.get_stats("RotatE")
759    }
760
761    fn save(&self, path: &str) -> Result<()> {
762        info!("Saving RotatE model to {}", path);
763
764        let serializable = RotatESerializable {
765            base: BaseModelSnapshot::capture(&self.base),
766            entity_embeddings_real: MatrixF64::from_array(&self.entity_embeddings_real),
767            entity_embeddings_imag: MatrixF64::from_array(&self.entity_embeddings_imag),
768            relation_phases: MatrixF64::from_array(&self.relation_phases),
769            embeddings_initialized: self.embeddings_initialized,
770            adversarial_temperature: self.adversarial_temperature,
771            modulus_constraint: self.modulus_constraint,
772        };
773
774        let file = File::create(path)
775            .map_err(|e| anyhow!("Failed to create model file {}: {}", path, e))?;
776        let writer = BufWriter::new(file);
777        oxicode::serde::encode_into_std_write(&serializable, writer, oxicode::config::standard())
778            .map_err(|e| anyhow!("Failed to serialize RotatE model: {}", e))?;
779
780        info!("RotatE model saved successfully");
781        Ok(())
782    }
783
784    fn load(&mut self, path: &str) -> Result<()> {
785        info!("Loading RotatE model from {}", path);
786
787        if !Path::new(path).exists() {
788            return Err(anyhow!("Model file not found: {}", path));
789        }
790
791        let file =
792            File::open(path).map_err(|e| anyhow!("Failed to open model file {}: {}", path, e))?;
793        let reader = BufReader::new(file);
794        let (serializable, _): (RotatESerializable, _) =
795            oxicode::serde::decode_from_std_read(reader, oxicode::config::standard())
796                .map_err(|e| anyhow!("Failed to deserialize RotatE model: {}", e))?;
797
798        self.entity_embeddings_real = serializable.entity_embeddings_real.to_array()?;
799        self.entity_embeddings_imag = serializable.entity_embeddings_imag.to_array()?;
800        self.relation_phases = serializable.relation_phases.to_array()?;
801        self.embeddings_initialized = serializable.embeddings_initialized;
802        self.adversarial_temperature = serializable.adversarial_temperature;
803        self.modulus_constraint = serializable.modulus_constraint;
804        serializable.base.restore_into(&mut self.base);
805
806        info!("RotatE model loaded successfully");
807        Ok(())
808    }
809
810    fn clear(&mut self) {
811        self.base.clear();
812        self.entity_embeddings_real = Array2::zeros((0, self.base.config.dimensions));
813        self.entity_embeddings_imag = Array2::zeros((0, self.base.config.dimensions));
814        self.relation_phases = Array2::zeros((0, self.base.config.dimensions));
815        self.embeddings_initialized = false;
816    }
817
818    fn is_trained(&self) -> bool {
819        self.base.is_trained
820    }
821
822    async fn encode(&self, _texts: &[String]) -> Result<Vec<Vec<f32>>> {
823        Err(anyhow!(
824            "Knowledge graph embedding model does not support text encoding"
825        ))
826    }
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832
833    #[tokio::test]
834    async fn test_rotate_basic() -> Result<()> {
835        let config = ModelConfig::default()
836            .with_dimensions(10)
837            .with_max_epochs(5)
838            .with_seed(42);
839
840        let mut model = RotatE::new(config);
841
842        let alice = crate::NamedNode::new("http://example.org/alice")?;
843        let knows = crate::NamedNode::new("http://example.org/knows")?;
844        let bob = crate::NamedNode::new("http://example.org/bob")?;
845
846        model.add_triple(crate::Triple::new(
847            alice.clone(),
848            knows.clone(),
849            bob.clone(),
850        ))?;
851
852        let stats = model.train(Some(3)).await?;
853        assert!(stats.epochs_completed > 0);
854
855        let alice_emb = model.get_entity_embedding("http://example.org/alice")?;
856        assert_eq!(alice_emb.dimensions, 20); // 2 * 10 (real + imaginary)
857
858        let score = model.score_triple(
859            "http://example.org/alice",
860            "http://example.org/knows",
861            "http://example.org/bob",
862        )?;
863
864        assert!(score.is_finite());
865
866        Ok(())
867    }
868
869    /// Regression: RotatE shared the inverted-margin-loss bug with TransE. The
870    /// hinge loss must be `max(0, margin + pos_distance - neg_distance)` so that
871    /// violated triples produce a positive loss and trigger a gradient step.
872    #[test]
873    fn regression_rotate_margin_loss_orientation() {
874        use crate::models::common::margin_loss;
875        let margin = 6.0;
876        let pos_distance = 9.0;
877        let neg_distance = 2.0;
878        let loss = margin_loss(neg_distance, pos_distance, margin);
879        assert!(
880            loss > 0.0,
881            "violated triple must yield positive hinge loss, got {loss}"
882        );
883        assert!((loss - (margin + pos_distance - neg_distance)).abs() < 1e-9);
884
885        let good = margin_loss(20.0, 0.0, margin);
886        assert_eq!(good, 0.0, "well-separated triple must yield zero loss");
887    }
888
889    /// Regression: RotatE save()/load() were no-ops; a disk round-trip must
890    /// reproduce identical entity embeddings.
891    #[tokio::test]
892    async fn regression_rotate_save_load_roundtrip() -> Result<()> {
893        let config = ModelConfig::default()
894            .with_dimensions(8)
895            .with_max_epochs(4)
896            .with_seed(11);
897        let mut model = RotatE::new(config);
898
899        let alice = crate::NamedNode::new("http://example.org/alice")?;
900        let knows = crate::NamedNode::new("http://example.org/knows")?;
901        let bob = crate::NamedNode::new("http://example.org/bob")?;
902        model.add_triple(Triple::new(alice.clone(), knows.clone(), bob.clone()))?;
903        model.add_triple(Triple::new(bob.clone(), knows.clone(), alice.clone()))?;
904        model.train(Some(4)).await?;
905
906        let before = model.get_entity_embedding("http://example.org/alice")?;
907
908        let path = std::env::temp_dir().join(format!("rotate-roundtrip-{}.bin", Uuid::new_v4()));
909        let path_str = path.to_string_lossy().to_string();
910        model.save(&path_str)?;
911
912        let mut restored = RotatE::new(ModelConfig::default());
913        restored.load(&path_str)?;
914        assert!(restored.is_trained());
915        let after = restored.get_entity_embedding("http://example.org/alice")?;
916        assert_eq!(before.dimensions, after.dimensions);
917        for (x, y) in before.values.iter().zip(after.values.iter()) {
918            assert!((x - y).abs() < 1e-9, "embedding mismatch after load");
919        }
920
921        let _ = std::fs::remove_file(&path);
922        Ok(())
923    }
924}