Skip to main content

oxirs_embed/
research_networks.rs

1//! Research Publication Networks - Academic Knowledge Graph Embeddings
2//!
3//! This module provides specialized embeddings and analysis for research publication networks,
4//! including author embeddings, citation analysis, collaboration networks, and impact prediction.
5
6use crate::Vector;
7use anyhow::Result;
8use chrono::{DateTime, Utc};
9use scirs2_core::random::{Random, RngExt};
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12use std::sync::{Arc, RwLock};
13use tokio::task::JoinHandle;
14use tracing::{debug, info};
15
16/// Research publication network analyzer and embedding generator
17pub struct ResearchNetworkAnalyzer {
18    /// Author embeddings cache
19    author_embeddings: Arc<RwLock<HashMap<String, AuthorEmbedding>>>,
20    /// Publication embeddings cache
21    publication_embeddings: Arc<RwLock<HashMap<String, PublicationEmbedding>>>,
22    /// Citation network graph
23    citation_network: Arc<RwLock<CitationNetwork>>,
24    /// Collaboration network
25    collaboration_network: Arc<RwLock<CollaborationNetwork>>,
26    /// Topic models
27    topic_models: Arc<RwLock<HashMap<String, TopicModel>>>,
28    /// Author profile records (name, affiliations) registered via
29    /// [`register_author_profile`](Self::register_author_profile). This is
30    /// the only source of author identity metadata: there is no external
31    /// author database wired into this analyzer, so
32    /// [`generate_author_embedding`](Self::generate_author_embedding) reads
33    /// from here rather than fabricating a name/affiliation.
34    author_profiles: Arc<RwLock<HashMap<String, AuthorProfile>>>,
35    /// Publication metadata records registered via
36    /// [`register_publication_metadata`](Self::register_publication_metadata).
37    /// The only source of publication bibliographic metadata: there is no
38    /// external publication database wired into this analyzer, so
39    /// [`generate_publication_embedding`](Self::generate_publication_embedding)
40    /// reads from here rather than fabricating a title/venue/year.
41    publication_metadata: Arc<RwLock<HashMap<String, PublicationMetadataInput>>>,
42    /// Configuration
43    config: ResearchNetworkConfig,
44    /// Background analysis tasks
45    analysis_tasks: Vec<JoinHandle<()>>,
46}
47
48/// Author identity metadata supplied by the caller (there is no external
49/// author database wired into [`ResearchNetworkAnalyzer`]).
50#[derive(Debug, Clone)]
51pub struct AuthorProfile {
52    /// Author's display name.
53    pub name: String,
54    /// Author's institutional affiliation(s).
55    pub affiliations: Vec<String>,
56}
57
58/// Publication bibliographic metadata supplied by the caller (there is no
59/// external publication database wired into [`ResearchNetworkAnalyzer`]).
60#[derive(Debug, Clone)]
61pub struct PublicationMetadataInput {
62    /// Publication title.
63    pub title: String,
64    /// Publication abstract, if available.
65    pub abstract_text: String,
66    /// Author identifiers (matching the `author_id` used elsewhere in this
67    /// analyzer), in author order.
68    pub authors: Vec<String>,
69    /// Venue (journal/conference) name.
70    pub venue: String,
71    /// Publication year.
72    pub year: u32,
73    /// DOI or other persistent identifier, if known.
74    pub doi: Option<String>,
75}
76
77/// Configuration for research network analysis
78#[derive(Debug, Clone)]
79pub struct ResearchNetworkConfig {
80    /// Maximum number of authors to track
81    pub max_authors: usize,
82    /// Maximum number of publications to track
83    pub max_publications: usize,
84    /// Citation network update interval (hours)
85    pub citation_update_interval_hours: u64,
86    /// Collaboration analysis interval (hours)
87    pub collaboration_analysis_interval_hours: u64,
88    /// Impact prediction model refresh interval (hours)
89    pub impact_prediction_refresh_hours: u64,
90    /// Enable real-time citation tracking
91    pub enable_real_time_citation_tracking: bool,
92    /// Minimum citation count for impact analysis
93    pub min_citation_threshold: u32,
94    /// Topic modeling configuration
95    pub topic_config: TopicModelingConfig,
96    /// Embedding dimension
97    pub embedding_dimension: usize,
98}
99
100impl Default for ResearchNetworkConfig {
101    fn default() -> Self {
102        Self {
103            max_authors: 100_000,
104            max_publications: 1_000_000,
105            citation_update_interval_hours: 24,
106            collaboration_analysis_interval_hours: 12,
107            impact_prediction_refresh_hours: 48,
108            enable_real_time_citation_tracking: true,
109            min_citation_threshold: 5,
110            topic_config: TopicModelingConfig::default(),
111            embedding_dimension: 512,
112        }
113    }
114}
115
116/// Topic modeling configuration
117#[derive(Debug, Clone)]
118pub struct TopicModelingConfig {
119    /// Number of topics to extract
120    pub num_topics: usize,
121    /// Minimum word frequency
122    pub min_word_freq: u32,
123    /// Maximum document frequency ratio
124    pub max_doc_freq_ratio: f64,
125    /// LDA iterations
126    pub lda_iterations: u32,
127    /// Topic coherence threshold
128    pub coherence_threshold: f64,
129}
130
131impl Default for TopicModelingConfig {
132    fn default() -> Self {
133        Self {
134            num_topics: 50,
135            min_word_freq: 5,
136            max_doc_freq_ratio: 0.8,
137            lda_iterations: 1000,
138            coherence_threshold: 0.4,
139        }
140    }
141}
142
143/// Author information and embeddings
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct AuthorEmbedding {
146    /// Author unique identifier
147    pub author_id: String,
148    /// Author name
149    pub name: String,
150    /// Author affiliations
151    pub affiliations: Vec<String>,
152    /// Research interests/topics
153    pub research_topics: Vec<String>,
154    /// H-index
155    pub h_index: f64,
156    /// Total citation count
157    pub citation_count: u64,
158    /// Publication count
159    pub publication_count: u64,
160    /// Author embedding vector
161    pub embedding: Vector,
162    /// Collaboration score
163    pub collaboration_score: f64,
164    /// Impact score
165    pub impact_score: f64,
166    /// Career stage
167    pub career_stage: CareerStage,
168    /// Last updated
169    pub last_updated: DateTime<Utc>,
170}
171
172/// Publication information and embeddings
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct PublicationEmbedding {
175    /// Publication unique identifier
176    pub publication_id: String,
177    /// Title
178    pub title: String,
179    /// Abstract
180    pub abstract_text: String,
181    /// Authors
182    pub authors: Vec<String>,
183    /// Venue (journal/conference)
184    pub venue: String,
185    /// Publication year
186    pub year: u32,
187    /// Citation count
188    pub citation_count: u64,
189    /// Topic distribution
190    pub topic_distribution: Vec<f64>,
191    /// Publication embedding vector
192    pub embedding: Vector,
193    /// Impact prediction score
194    pub predicted_impact: f64,
195    /// Publication type
196    pub publication_type: PublicationType,
197    /// DOI or other identifier
198    pub doi: Option<String>,
199    /// Last updated
200    pub last_updated: DateTime<Utc>,
201}
202
203/// Career stage classification
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub enum CareerStage {
206    EarlyCareer,
207    MidCareer,
208    SeniorCareer,
209    Emeritus,
210    Unknown,
211}
212
213/// Publication type classification
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub enum PublicationType {
216    JournalArticle,
217    ConferencePaper,
218    BookChapter,
219    Book,
220    Preprint,
221    Thesis,
222    TechnicalReport,
223    Other,
224}
225
226/// Citation network representation
227#[derive(Debug, Clone)]
228pub struct CitationNetwork {
229    /// Citation edges: (citing_paper, cited_paper, citation_context)
230    pub citations: HashMap<String, Vec<Citation>>,
231    /// Co-citation relationships
232    pub co_citations: HashMap<String, Vec<CoCitation>>,
233    /// Bibliographic coupling
234    pub bibliographic_coupling: HashMap<String, Vec<BibliographicCoupling>>,
235    /// Citation patterns over time
236    pub temporal_patterns: HashMap<String, Vec<TemporalCitation>>,
237}
238
239/// Citation information
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct Citation {
242    /// Citing paper ID
243    pub citing_paper: String,
244    /// Cited paper ID
245    pub cited_paper: String,
246    /// Citation context/sentence
247    pub context: String,
248    /// Citation type (supportive, contrasting, neutral)
249    pub citation_type: CitationType,
250    /// Position in the paper (intro, methods, results, discussion)
251    pub section: PaperSection,
252    /// Timestamp of citation
253    pub timestamp: DateTime<Utc>,
254}
255
256/// Citation type classification
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub enum CitationType {
259    Supportive,
260    Contrasting,
261    Neutral,
262    Background,
263    Methodological,
264}
265
266/// Paper section where citation occurs
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub enum PaperSection {
269    Introduction,
270    RelatedWork,
271    Methods,
272    Results,
273    Discussion,
274    Conclusion,
275    Other,
276}
277
278/// Co-citation relationship
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct CoCitation {
281    /// First paper
282    pub paper1: String,
283    /// Second paper
284    pub paper2: String,
285    /// Number of papers citing both
286    pub co_citation_count: u32,
287    /// Similarity score
288    pub similarity_score: f64,
289}
290
291/// Bibliographic coupling
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct BibliographicCoupling {
294    /// First paper
295    pub paper1: String,
296    /// Second paper
297    pub paper2: String,
298    /// Number of shared references
299    pub shared_references: u32,
300    /// Coupling strength
301    pub coupling_strength: f64,
302}
303
304/// Temporal citation pattern
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct TemporalCitation {
307    /// Paper ID
308    pub paper_id: String,
309    /// Citation timestamp
310    pub timestamp: DateTime<Utc>,
311    /// Citations at this time
312    pub citation_count: u64,
313    /// Velocity (citations per time unit)
314    pub citation_velocity: f64,
315}
316
317/// Collaboration network
318#[derive(Debug, Clone)]
319pub struct CollaborationNetwork {
320    /// Author collaborations: (author1, author2, collaboration_strength)
321    pub collaborations: HashMap<String, Vec<Collaboration>>,
322    /// Research groups/communities
323    pub research_communities: Vec<ResearchCommunity>,
324    /// Collaboration patterns over time
325    pub temporal_collaborations: HashMap<String, Vec<TemporalCollaboration>>,
326}
327
328/// Collaboration between authors
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct Collaboration {
331    /// First author
332    pub author1: String,
333    /// Second author
334    pub author2: String,
335    /// Number of joint publications
336    pub joint_publications: u32,
337    /// Collaboration strength score
338    pub strength: f64,
339    /// Shared research topics
340    pub shared_topics: Vec<String>,
341    /// First collaboration date
342    pub first_collaboration: DateTime<Utc>,
343    /// Last collaboration date
344    pub last_collaboration: DateTime<Utc>,
345}
346
347/// Research community/cluster
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct ResearchCommunity {
350    /// Community ID
351    pub community_id: String,
352    /// Community members (author IDs)
353    pub members: Vec<String>,
354    /// Community topics
355    pub topics: Vec<String>,
356    /// Central/influential members
357    pub central_members: Vec<String>,
358    /// Community coherence score
359    pub coherence_score: f64,
360    /// Community size
361    pub size: usize,
362}
363
364/// Temporal collaboration pattern
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct TemporalCollaboration {
367    /// Author ID
368    pub author_id: String,
369    /// Time period
370    pub timestamp: DateTime<Utc>,
371    /// Active collaborations in this period
372    pub active_collaborations: u32,
373    /// New collaborations formed
374    pub new_collaborations: u32,
375}
376
377/// Topic model for research areas
378#[derive(Debug, Clone)]
379pub struct TopicModel {
380    /// Topic ID
381    pub topic_id: String,
382    /// Topic name/label
383    pub topic_name: String,
384    /// Topic words with probabilities
385    pub topic_words: Vec<(String, f64)>,
386    /// Document-topic distribution
387    pub document_topics: HashMap<String, f64>,
388    /// Topic coherence score
389    pub coherence_score: f64,
390    /// Topic trend over time
391    pub temporal_trend: Vec<TopicTrend>,
392}
393
394/// Topic trend over time
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct TopicTrend {
397    /// Time period
398    pub timestamp: DateTime<Utc>,
399    /// Topic popularity/frequency
400    pub popularity: f64,
401    /// Number of publications in this topic
402    pub publication_count: u64,
403    /// Topic growth rate
404    pub growth_rate: f64,
405}
406
407/// Impact prediction model
408#[derive(Debug, Clone)]
409pub struct ImpactPredictor {
410    /// Feature weights for impact prediction
411    pub feature_weights: HashMap<String, f64>,
412    /// Model performance metrics
413    pub performance_metrics: PredictionMetrics,
414    /// Last model update
415    pub last_update: DateTime<Utc>,
416}
417
418/// Prediction performance metrics
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct PredictionMetrics {
421    /// Mean absolute error
422    pub mae: f64,
423    /// Root mean square error
424    pub rmse: f64,
425    /// R-squared score
426    pub r2_score: f64,
427    /// Precision at different thresholds
428    pub precision_at_k: HashMap<u32, f64>,
429}
430
431impl ResearchNetworkAnalyzer {
432    /// Create new research network analyzer
433    pub fn new(config: ResearchNetworkConfig) -> Self {
434        Self {
435            author_embeddings: Arc::new(RwLock::new(HashMap::new())),
436            publication_embeddings: Arc::new(RwLock::new(HashMap::new())),
437            citation_network: Arc::new(RwLock::new(CitationNetwork {
438                citations: HashMap::new(),
439                co_citations: HashMap::new(),
440                bibliographic_coupling: HashMap::new(),
441                temporal_patterns: HashMap::new(),
442            })),
443            collaboration_network: Arc::new(RwLock::new(CollaborationNetwork {
444                collaborations: HashMap::new(),
445                research_communities: Vec::new(),
446                temporal_collaborations: HashMap::new(),
447            })),
448            topic_models: Arc::new(RwLock::new(HashMap::new())),
449            author_profiles: Arc::new(RwLock::new(HashMap::new())),
450            publication_metadata: Arc::new(RwLock::new(HashMap::new())),
451            config,
452            analysis_tasks: Vec::new(),
453        }
454    }
455
456    /// Register (or replace) an author's identity metadata.
457    ///
458    /// [`generate_author_embedding`](Self::generate_author_embedding) reads
459    /// the author's name/affiliations from this registry — there is no
460    /// external author database to resolve them from otherwise, so a
461    /// profile must be registered before an embedding can be generated.
462    pub fn register_author_profile(&self, author_id: impl Into<String>, profile: AuthorProfile) {
463        self.author_profiles
464            .write()
465            .expect("rwlock should not be poisoned")
466            .insert(author_id.into(), profile);
467    }
468
469    /// Register (or replace) a publication's bibliographic metadata.
470    ///
471    /// [`generate_publication_embedding`](Self::generate_publication_embedding)
472    /// reads title/abstract/authors/venue/year/DOI from this registry —
473    /// there is no external publication database to resolve them from
474    /// otherwise, so metadata must be registered before an embedding can be
475    /// generated. Registering also makes the publication discoverable via
476    /// `get_author_publications` for every
477    /// author listed in `metadata.authors`.
478    pub fn register_publication_metadata(
479        &self,
480        publication_id: impl Into<String>,
481        metadata: PublicationMetadataInput,
482    ) {
483        self.publication_metadata
484            .write()
485            .expect("rwlock should not be poisoned")
486            .insert(publication_id.into(), metadata);
487    }
488
489    /// Record a collaboration edge between two authors.
490    ///
491    /// Stored bidirectionally so
492    /// `get_author_collaborations` can
493    /// look it up from either author's perspective. This is the real,
494    /// in-process data source backing collaboration-derived statistics
495    /// (e.g. `collaboration_score` in [`AuthorEmbedding`]) — nothing here
496    /// is synthesized.
497    pub async fn add_collaboration(&self, collaboration: Collaboration) -> Result<()> {
498        let mut network = self
499            .collaboration_network
500            .write()
501            .expect("rwlock should not be poisoned");
502        network
503            .collaborations
504            .entry(collaboration.author1.clone())
505            .or_default()
506            .push(collaboration.clone());
507        if collaboration.author2 != collaboration.author1 {
508            network
509                .collaborations
510                .entry(collaboration.author2.clone())
511                .or_default()
512                .push(collaboration);
513        }
514        Ok(())
515    }
516
517    /// Start background analysis tasks
518    pub async fn start(&mut self) -> Result<()> {
519        info!("Starting research network analysis system");
520
521        // Start citation network analysis task
522        let citation_task = self.start_citation_analysis().await;
523        self.analysis_tasks.push(citation_task);
524
525        // Start collaboration analysis task
526        let collaboration_task = self.start_collaboration_analysis().await;
527        self.analysis_tasks.push(collaboration_task);
528
529        // Start impact prediction task
530        let impact_task = self.start_impact_prediction().await;
531        self.analysis_tasks.push(impact_task);
532
533        // Start topic modeling task
534        let topic_task = self.start_topic_modeling().await;
535        self.analysis_tasks.push(topic_task);
536
537        info!("Research network analysis system started successfully");
538        Ok(())
539    }
540
541    /// Stop analysis tasks
542    pub async fn stop(&mut self) {
543        info!("Stopping research network analysis system");
544
545        for task in self.analysis_tasks.drain(..) {
546            task.abort();
547        }
548
549        info!("Research network analysis system stopped");
550    }
551
552    /// Generate author embedding based on publications and collaborations
553    pub async fn generate_author_embedding(&self, author_id: &str) -> Result<AuthorEmbedding> {
554        // Check if already computed
555        {
556            let embeddings = self
557                .author_embeddings
558                .read()
559                .expect("rwlock should not be poisoned");
560            if let Some(existing) = embeddings.get(author_id) {
561                return Ok(existing.clone());
562            }
563        }
564
565        info!("Generating author embedding for: {}", author_id);
566
567        // Author identity metadata has no external database to resolve
568        // against in this analyzer; it must have been registered via
569        // `register_author_profile` beforehand. Failing loudly here avoids
570        // fabricating a name/affiliation that looks resolved but is not.
571        let profile = {
572            let profiles = self
573                .author_profiles
574                .read()
575                .expect("rwlock should not be poisoned");
576            profiles.get(author_id).cloned()
577        }
578        .ok_or_else(|| {
579            anyhow::anyhow!(
580                "No author profile registered for '{author_id}': \
581                 ResearchNetworkAnalyzer has no external author database to resolve \
582                 name/affiliations from. Call register_author_profile({author_id}, ...) \
583                 before generating an embedding."
584            )
585        })?;
586
587        // Collect author's publications
588        let author_publications = self.get_author_publications(author_id).await?;
589
590        // Get collaboration information
591        let collaborations = self.get_author_collaborations(author_id).await?;
592
593        // Compute research topics
594        let research_topics = self
595            .extract_author_topics(author_id, &author_publications)
596            .await?;
597
598        // Calculate metrics
599        let h_index = self.calculate_h_index(&author_publications).await?;
600        let citation_count = author_publications.iter().map(|p| p.citation_count).sum();
601        let collaboration_score = self.calculate_collaboration_score(&collaborations).await?;
602        let impact_score = self.calculate_author_impact_score(author_id).await?;
603
604        // Generate embedding vector
605        let embedding = self
606            .compute_author_embedding_vector(
607                &author_publications,
608                &collaborations,
609                &research_topics,
610            )
611            .await?;
612
613        // Determine career stage
614        let career_stage = self
615            .classify_career_stage(citation_count, author_publications.len() as u64, h_index)
616            .await?;
617
618        let author_embedding = AuthorEmbedding {
619            author_id: author_id.to_string(),
620            name: profile.name,
621            affiliations: profile.affiliations,
622            research_topics,
623            h_index,
624            citation_count,
625            publication_count: author_publications.len() as u64,
626            embedding,
627            collaboration_score,
628            impact_score,
629            career_stage,
630            last_updated: Utc::now(),
631        };
632
633        // Cache the result
634        {
635            let mut embeddings = self
636                .author_embeddings
637                .write()
638                .expect("rwlock should not be poisoned");
639            embeddings.insert(author_id.to_string(), author_embedding.clone());
640        }
641
642        info!(
643            "Generated author embedding for {} with h-index: {:.2}",
644            author_id, h_index
645        );
646        Ok(author_embedding)
647    }
648
649    /// Generate publication embedding based on content and citations
650    pub async fn generate_publication_embedding(
651        &self,
652        publication_id: &str,
653    ) -> Result<PublicationEmbedding> {
654        // Check if already computed
655        {
656            let embeddings = self
657                .publication_embeddings
658                .read()
659                .expect("rwlock should not be poisoned");
660            if let Some(existing) = embeddings.get(publication_id) {
661                return Ok(existing.clone());
662            }
663        }
664
665        info!("Generating publication embedding for: {}", publication_id);
666
667        // Publication metadata has no external database to resolve against
668        // in this analyzer; it must have been registered via
669        // `register_publication_metadata` beforehand. Failing loudly here
670        // avoids fabricating a title/venue/year that looks resolved but is
671        // not.
672        let metadata = {
673            let records = self
674                .publication_metadata
675                .read()
676                .expect("rwlock should not be poisoned");
677            records.get(publication_id).cloned()
678        }
679        .ok_or_else(|| {
680            anyhow::anyhow!(
681                "No metadata registered for publication '{publication_id}': \
682                 ResearchNetworkAnalyzer has no external publication database to resolve \
683                 title/venue/year from. Call register_publication_metadata({publication_id}, ...) \
684                 before generating an embedding."
685            )
686        })?;
687        let PublicationMetadataInput {
688            title,
689            abstract_text,
690            authors,
691            venue,
692            year,
693            doi,
694        } = metadata;
695
696        // Get citation information
697        let citation_count = self.get_publication_citation_count(publication_id).await?;
698
699        // Extract topics
700        let topic_distribution = self
701            .extract_publication_topics(publication_id, &abstract_text)
702            .await?;
703
704        // Generate content embedding
705        let embedding = self
706            .compute_publication_embedding_vector(&title, &abstract_text, &topic_distribution)
707            .await?;
708
709        // Predict impact
710        let predicted_impact = self
711            .predict_publication_impact(citation_count, &topic_distribution, &embedding)
712            .await?;
713
714        let publication_embedding = PublicationEmbedding {
715            publication_id: publication_id.to_string(),
716            title,
717            abstract_text,
718            authors,
719            venue,
720            year,
721            citation_count,
722            topic_distribution,
723            embedding,
724            predicted_impact,
725            publication_type: PublicationType::JournalArticle, // Default
726            doi,
727            last_updated: Utc::now(),
728        };
729
730        // Cache the result
731        {
732            let mut embeddings = self
733                .publication_embeddings
734                .write()
735                .expect("rwlock should not be poisoned");
736            embeddings.insert(publication_id.to_string(), publication_embedding.clone());
737        }
738
739        info!(
740            "Generated publication embedding for {} with predicted impact: {:.3}",
741            publication_id, predicted_impact
742        );
743        Ok(publication_embedding)
744    }
745
746    /// Analyze citation patterns and relationships
747    pub async fn analyze_citation_patterns(&self, publication_id: &str) -> Result<Vec<Citation>> {
748        let network = self
749            .citation_network
750            .read()
751            .expect("rwlock should not be poisoned");
752
753        if let Some(citations) = network.citations.get(publication_id) {
754            Ok(citations.clone())
755        } else {
756            Ok(Vec::new())
757        }
758    }
759
760    /// Find similar authors based on research interests and collaboration patterns
761    pub async fn find_similar_authors(
762        &self,
763        author_id: &str,
764        k: usize,
765    ) -> Result<Vec<(String, f64)>> {
766        let target_embedding = self.generate_author_embedding(author_id).await?;
767        let embeddings_data: Vec<(String, AuthorEmbedding)> = {
768            let embeddings = self
769                .author_embeddings
770                .read()
771                .expect("rwlock should not be poisoned");
772            embeddings
773                .iter()
774                .filter(|(other_id, _)| *other_id != author_id)
775                .map(|(id, emb)| (id.clone(), emb.clone()))
776                .collect()
777        };
778
779        let mut similarities = Vec::new();
780
781        for (other_id, other_embedding) in embeddings_data {
782            let similarity = self
783                .calculate_author_similarity(&target_embedding, &other_embedding)
784                .await?;
785            similarities.push((other_id, similarity));
786        }
787
788        // Sort by similarity and take top k
789        similarities.sort_by(|a, b| {
790            b.1.partial_cmp(&a.1)
791                .expect("similarity scores should be comparable")
792        });
793        similarities.truncate(k);
794
795        Ok(similarities)
796    }
797
798    /// Predict research impact for a publication
799    pub async fn predict_research_impact(&self, publication_id: &str) -> Result<f64> {
800        let publication = self.generate_publication_embedding(publication_id).await?;
801        Ok(publication.predicted_impact)
802    }
803
804    /// Analyze research trends over time
805    pub async fn analyze_research_trends(
806        &self,
807        topic: &str,
808        years: u32,
809    ) -> Result<Vec<TopicTrend>> {
810        let topics = self
811            .topic_models
812            .read()
813            .expect("rwlock should not be poisoned");
814
815        if let Some(topic_model) = topics.get(topic) {
816            // Filter trends for the specified time period
817            let cutoff_date = Utc::now() - chrono::Duration::days((years * 365) as i64);
818            let recent_trends: Vec<TopicTrend> = topic_model
819                .temporal_trend
820                .iter()
821                .filter(|trend| trend.timestamp > cutoff_date)
822                .cloned()
823                .collect();
824
825            Ok(recent_trends)
826        } else {
827            Ok(Vec::new())
828        }
829    }
830
831    /// Get research communities/clusters
832    pub async fn get_research_communities(&self) -> Result<Vec<ResearchCommunity>> {
833        let network = self
834            .collaboration_network
835            .read()
836            .expect("rwlock should not be poisoned");
837        Ok(network.research_communities.clone())
838    }
839
840    /// Update citation network with new citation
841    pub async fn add_citation(&self, citation: Citation) -> Result<()> {
842        let mut network = self
843            .citation_network
844            .write()
845            .expect("rwlock should not be poisoned");
846
847        network
848            .citations
849            .entry(citation.citing_paper.clone())
850            .or_default()
851            .push(citation);
852
853        info!("Added new citation to network");
854        Ok(())
855    }
856
857    // ===== PRIVATE HELPER METHODS =====
858
859    /// Publications by `author_id`, drawn from
860    /// [`publication_metadata`](Self::publication_metadata) (real
861    /// registered data — nothing here is fabricated). An author with no
862    /// registered publications simply has none; that is a legitimate
863    /// (non-error) state, unlike missing identity metadata.
864    async fn get_author_publications(&self, author_id: &str) -> Result<Vec<PublicationEmbedding>> {
865        let publication_ids: Vec<String> = {
866            let records = self
867                .publication_metadata
868                .read()
869                .expect("rwlock should not be poisoned");
870            records
871                .iter()
872                .filter(|(_, metadata)| metadata.authors.iter().any(|a| a == author_id))
873                .map(|(id, _)| id.clone())
874                .collect()
875        };
876
877        let mut publications = Vec::with_capacity(publication_ids.len());
878        for publication_id in publication_ids {
879            publications.push(self.generate_publication_embedding(&publication_id).await?);
880        }
881        Ok(publications)
882    }
883
884    /// Collaboration edges for `author_id`, drawn from
885    /// [`collaboration_network`](Self::collaboration_network) as recorded
886    /// via [`add_collaboration`](Self::add_collaboration) (real in-process
887    /// data — nothing here is fabricated).
888    async fn get_author_collaborations(&self, author_id: &str) -> Result<Vec<Collaboration>> {
889        let network = self
890            .collaboration_network
891            .read()
892            .expect("rwlock should not be poisoned");
893        Ok(network
894            .collaborations
895            .get(author_id)
896            .cloned()
897            .unwrap_or_default())
898    }
899
900    async fn extract_author_topics(
901        &self,
902        _author_id: &str,
903        _publications: &[PublicationEmbedding],
904    ) -> Result<Vec<String>> {
905        // Placeholder - would perform topic extraction
906        Ok(vec![
907            "machine_learning".to_string(),
908            "natural_language_processing".to_string(),
909        ])
910    }
911
912    async fn calculate_h_index(&self, publications: &[PublicationEmbedding]) -> Result<f64> {
913        let mut citation_counts: Vec<u64> = publications.iter().map(|p| p.citation_count).collect();
914
915        citation_counts.sort_by(|a, b| b.cmp(a));
916
917        let mut h_index = 0;
918        for (i, &citations) in citation_counts.iter().enumerate() {
919            if citations >= (i + 1) as u64 {
920                h_index = i + 1;
921            } else {
922                break;
923            }
924        }
925
926        Ok(h_index as f64)
927    }
928
929    async fn calculate_collaboration_score(&self, collaborations: &[Collaboration]) -> Result<f64> {
930        if collaborations.is_empty() {
931            return Ok(0.0);
932        }
933
934        let total_strength: f64 = collaborations.iter().map(|c| c.strength).sum();
935        Ok(total_strength / collaborations.len() as f64)
936    }
937
938    async fn calculate_author_impact_score(&self, _author_id: &str) -> Result<f64> {
939        // Placeholder - would calculate based on citations, h-index, collaboration network position
940        Ok(0.75)
941    }
942
943    async fn compute_author_embedding_vector(
944        &self,
945        _publications: &[PublicationEmbedding],
946        _collaborations: &[Collaboration],
947        _topics: &[String],
948    ) -> Result<Vector> {
949        // Placeholder - would compute actual embedding
950        let values = (0..self.config.embedding_dimension)
951            .map(|_| {
952                let mut random = Random::default();
953                random.random::<f32>()
954            })
955            .collect();
956        Ok(Vector::new(values))
957    }
958
959    async fn classify_career_stage(
960        &self,
961        citation_count: u64,
962        publication_count: u64,
963        h_index: f64,
964    ) -> Result<CareerStage> {
965        if citation_count < 100 && publication_count < 10 && h_index < 5.0 {
966            Ok(CareerStage::EarlyCareer)
967        } else if citation_count < 1000 && publication_count < 50 && h_index < 20.0 {
968            Ok(CareerStage::MidCareer)
969        } else if citation_count >= 1000 || publication_count >= 50 || h_index >= 20.0 {
970            Ok(CareerStage::SeniorCareer)
971        } else {
972            Ok(CareerStage::Unknown)
973        }
974    }
975
976    async fn get_publication_citation_count(&self, _publication_id: &str) -> Result<u64> {
977        // Placeholder - would query citation database
978        let mut random = Random::default();
979        Ok(random.random::<u64>() % 100)
980    }
981
982    async fn extract_publication_topics(
983        &self,
984        _publication_id: &str,
985        _abstract_text: &str,
986    ) -> Result<Vec<f64>> {
987        // Placeholder - would perform topic modeling
988        let num_topics = self.config.topic_config.num_topics;
989        let mut distribution = vec![0.0; num_topics];
990
991        // Generate random distribution that sums to 1.0
992        let total: f64 = (0..num_topics)
993            .map(|_| {
994                let mut random = Random::default();
995                random.random::<f64>()
996            })
997            .sum();
998        for item in distribution.iter_mut().take(num_topics) {
999            let mut random = Random::default();
1000            *item = random.random::<f64>() / total;
1001        }
1002
1003        Ok(distribution)
1004    }
1005
1006    async fn compute_publication_embedding_vector(
1007        &self,
1008        _title: &str,
1009        _abstract_text: &str,
1010        _topic_distribution: &[f64],
1011    ) -> Result<Vector> {
1012        // Placeholder - would compute actual embedding
1013        let values = (0..self.config.embedding_dimension)
1014            .map(|_| {
1015                let mut random = Random::default();
1016                random.random::<f32>()
1017            })
1018            .collect();
1019        Ok(Vector::new(values))
1020    }
1021
1022    async fn predict_publication_impact(
1023        &self,
1024        citation_count: u64,
1025        _topic_distribution: &[f64],
1026        _embedding: &Vector,
1027    ) -> Result<f64> {
1028        // Placeholder - would use trained impact prediction model
1029        let base_impact = (citation_count as f64).ln() / 10.0;
1030        Ok(base_impact.clamp(0.0, 1.0))
1031    }
1032
1033    async fn calculate_author_similarity(
1034        &self,
1035        author1: &AuthorEmbedding,
1036        author2: &AuthorEmbedding,
1037    ) -> Result<f64> {
1038        // Calculate cosine similarity between embeddings
1039        let embedding1 = &author1.embedding.values;
1040        let embedding2 = &author2.embedding.values;
1041
1042        let dot_product: f32 = embedding1
1043            .iter()
1044            .zip(embedding2.iter())
1045            .map(|(a, b)| a * b)
1046            .sum();
1047        let norm1: f32 = embedding1.iter().map(|x| x * x).sum::<f32>().sqrt();
1048        let norm2: f32 = embedding2.iter().map(|x| x * x).sum::<f32>().sqrt();
1049
1050        let cosine_similarity = if norm1 > 0.0 && norm2 > 0.0 {
1051            dot_product / (norm1 * norm2)
1052        } else {
1053            0.0
1054        };
1055
1056        // Combine with topic similarity
1057        let topic_similarity = self
1058            .calculate_topic_similarity(&author1.research_topics, &author2.research_topics)
1059            .await?;
1060
1061        // Weighted combination
1062        let final_similarity = 0.7 * cosine_similarity as f64 + 0.3 * topic_similarity;
1063
1064        Ok(final_similarity)
1065    }
1066
1067    async fn calculate_topic_similarity(
1068        &self,
1069        topics1: &[String],
1070        topics2: &[String],
1071    ) -> Result<f64> {
1072        let set1: HashSet<_> = topics1.iter().collect();
1073        let set2: HashSet<_> = topics2.iter().collect();
1074
1075        let intersection = set1.intersection(&set2).count();
1076        let union = set1.union(&set2).count();
1077
1078        if union > 0 {
1079            Ok(intersection as f64 / union as f64)
1080        } else {
1081            Ok(0.0)
1082        }
1083    }
1084
1085    // ===== BACKGROUND ANALYSIS TASKS =====
1086
1087    async fn start_citation_analysis(&self) -> JoinHandle<()> {
1088        let _citation_network = Arc::clone(&self.citation_network);
1089        let interval =
1090            std::time::Duration::from_secs(self.config.citation_update_interval_hours * 3600);
1091
1092        tokio::spawn(async move {
1093            let mut interval_timer = tokio::time::interval(interval);
1094
1095            loop {
1096                interval_timer.tick().await;
1097
1098                // Perform citation network analysis
1099                info!("Performing citation network analysis");
1100
1101                // Placeholder for actual analysis
1102                // Would analyze citation patterns, identify influential papers, etc.
1103
1104                debug!("Citation network analysis completed");
1105            }
1106        })
1107    }
1108
1109    async fn start_collaboration_analysis(&self) -> JoinHandle<()> {
1110        let _collaboration_network = Arc::clone(&self.collaboration_network);
1111        let interval = std::time::Duration::from_secs(
1112            self.config.collaboration_analysis_interval_hours * 3600,
1113        );
1114
1115        tokio::spawn(async move {
1116            let mut interval_timer = tokio::time::interval(interval);
1117
1118            loop {
1119                interval_timer.tick().await;
1120
1121                // Perform collaboration network analysis
1122                info!("Performing collaboration network analysis");
1123
1124                // Placeholder for actual analysis
1125                // Would detect research communities, analyze collaboration patterns, etc.
1126
1127                debug!("Collaboration network analysis completed");
1128            }
1129        })
1130    }
1131
1132    async fn start_impact_prediction(&self) -> JoinHandle<()> {
1133        let interval =
1134            std::time::Duration::from_secs(self.config.impact_prediction_refresh_hours * 3600);
1135
1136        tokio::spawn(async move {
1137            let mut interval_timer = tokio::time::interval(interval);
1138
1139            loop {
1140                interval_timer.tick().await;
1141
1142                // Refresh impact prediction models
1143                info!("Refreshing impact prediction models");
1144
1145                // Placeholder for actual model training/updating
1146                // Would retrain models based on recent citation data
1147
1148                debug!("Impact prediction models refreshed");
1149            }
1150        })
1151    }
1152
1153    async fn start_topic_modeling(&self) -> JoinHandle<()> {
1154        let topic_models = Arc::clone(&self.topic_models);
1155        let _config = self.config.clone();
1156        let interval = std::time::Duration::from_secs(24 * 3600); // Daily
1157
1158        tokio::spawn(async move {
1159            let mut interval_timer = tokio::time::interval(interval);
1160
1161            loop {
1162                interval_timer.tick().await;
1163
1164                // Update topic models
1165                info!("Updating topic models");
1166
1167                // Create sample topic model
1168                let topic_model = TopicModel {
1169                    topic_id: "machine_learning".to_string(),
1170                    topic_name: "Machine Learning".to_string(),
1171                    topic_words: vec![
1172                        ("neural".to_string(), 0.1),
1173                        ("network".to_string(), 0.09),
1174                        ("learning".to_string(), 0.08),
1175                        ("algorithm".to_string(), 0.07),
1176                        ("model".to_string(), 0.06),
1177                    ],
1178                    document_topics: HashMap::new(),
1179                    coherence_score: 0.75,
1180                    temporal_trend: vec![
1181                        TopicTrend {
1182                            timestamp: Utc::now() - chrono::Duration::days(365),
1183                            popularity: 0.6,
1184                            publication_count: 1000,
1185                            growth_rate: 0.15,
1186                        },
1187                        TopicTrend {
1188                            timestamp: Utc::now(),
1189                            popularity: 0.8,
1190                            publication_count: 1500,
1191                            growth_rate: 0.25,
1192                        },
1193                    ],
1194                };
1195
1196                {
1197                    let mut models = topic_models.write().expect("rwlock should not be poisoned");
1198                    models.insert("machine_learning".to_string(), topic_model);
1199                }
1200
1201                debug!("Topic models updated");
1202            }
1203        })
1204    }
1205}
1206
1207/// Research network metrics and statistics
1208#[derive(Debug, Clone, Serialize, Deserialize)]
1209pub struct NetworkMetrics {
1210    /// Total number of authors
1211    pub total_authors: usize,
1212    /// Total number of publications
1213    pub total_publications: usize,
1214    /// Total number of citations
1215    pub total_citations: u64,
1216    /// Average citations per paper
1217    pub avg_citations_per_paper: f64,
1218    /// Network density
1219    pub network_density: f64,
1220    /// Clustering coefficient
1221    pub clustering_coefficient: f64,
1222    /// Average path length
1223    pub average_path_length: f64,
1224    /// Most influential authors
1225    pub top_authors: Vec<String>,
1226    /// Trending topics
1227    pub trending_topics: Vec<String>,
1228}
1229
1230impl ResearchNetworkAnalyzer {
1231    /// Get comprehensive network metrics
1232    pub async fn get_network_metrics(&self) -> Result<NetworkMetrics> {
1233        let author_embeddings = self
1234            .author_embeddings
1235            .read()
1236            .expect("rwlock should not be poisoned");
1237        let publication_embeddings = self
1238            .publication_embeddings
1239            .read()
1240            .expect("rwlock should not be poisoned");
1241
1242        let total_authors = author_embeddings.len();
1243        let total_publications = publication_embeddings.len();
1244        let total_citations = publication_embeddings
1245            .values()
1246            .map(|p| p.citation_count)
1247            .sum();
1248
1249        let avg_citations_per_paper = if total_publications > 0 {
1250            total_citations as f64 / total_publications as f64
1251        } else {
1252            0.0
1253        };
1254
1255        // Get top authors by impact score
1256        let mut author_scores: Vec<_> = author_embeddings
1257            .iter()
1258            .map(|(id, embedding)| (id.clone(), embedding.impact_score))
1259            .collect();
1260        author_scores.sort_by(|a, b| {
1261            b.1.partial_cmp(&a.1)
1262                .expect("similarity scores should be comparable")
1263        });
1264        let top_authors: Vec<String> = author_scores
1265            .into_iter()
1266            .take(10)
1267            .map(|(id, _)| id)
1268            .collect();
1269
1270        let (network_density, clustering_coefficient, average_path_length) =
1271            Self::compute_coauthorship_graph_metrics(&publication_embeddings);
1272
1273        Ok(NetworkMetrics {
1274            total_authors,
1275            total_publications,
1276            total_citations,
1277            avg_citations_per_paper,
1278            network_density,
1279            clustering_coefficient,
1280            average_path_length,
1281            top_authors,
1282            trending_topics: vec!["machine_learning".to_string(), "deep_learning".to_string()],
1283        })
1284    }
1285
1286    /// Compute real co-authorship graph statistics — network density, average
1287    /// local clustering coefficient, and average shortest-path length —
1288    /// from the authors actually listed on each cached publication, instead
1289    /// of hardcoded placeholder constants.
1290    fn compute_coauthorship_graph_metrics(
1291        publication_embeddings: &HashMap<String, PublicationEmbedding>,
1292    ) -> (f64, f64, f64) {
1293        use std::collections::VecDeque;
1294
1295        let mut adjacency: HashMap<String, HashSet<String>> = HashMap::new();
1296        for publication in publication_embeddings.values() {
1297            for i in 0..publication.authors.len() {
1298                for j in (i + 1)..publication.authors.len() {
1299                    let a = &publication.authors[i];
1300                    let b = &publication.authors[j];
1301                    if a == b {
1302                        continue;
1303                    }
1304                    adjacency.entry(a.clone()).or_default().insert(b.clone());
1305                    adjacency.entry(b.clone()).or_default().insert(a.clone());
1306                }
1307            }
1308        }
1309
1310        let node_count = adjacency.len();
1311        if node_count < 2 {
1312            return (0.0, 0.0, 0.0);
1313        }
1314
1315        let edge_count: usize = adjacency
1316            .values()
1317            .map(|neighbors| neighbors.len())
1318            .sum::<usize>()
1319            / 2;
1320        let max_edges = (node_count * (node_count - 1)) as f64 / 2.0;
1321        let network_density = if max_edges > 0.0 {
1322            edge_count as f64 / max_edges
1323        } else {
1324            0.0
1325        };
1326
1327        // Average local clustering coefficient: for each node, the fraction
1328        // of its neighbor-pairs that are themselves connected.
1329        let mut clustering_sum = 0.0;
1330        let mut clustering_count = 0usize;
1331        for neighbors in adjacency.values() {
1332            let degree = neighbors.len();
1333            if degree < 2 {
1334                continue;
1335            }
1336            let neighbor_list: Vec<&String> = neighbors.iter().collect();
1337            let mut links = 0usize;
1338            for i in 0..neighbor_list.len() {
1339                for j in (i + 1)..neighbor_list.len() {
1340                    if adjacency
1341                        .get(neighbor_list[i].as_str())
1342                        .is_some_and(|n| n.contains(neighbor_list[j].as_str()))
1343                    {
1344                        links += 1;
1345                    }
1346                }
1347            }
1348            let possible = (degree * (degree - 1)) / 2;
1349            clustering_sum += links as f64 / possible as f64;
1350            clustering_count += 1;
1351        }
1352        let clustering_coefficient = if clustering_count > 0 {
1353            clustering_sum / clustering_count as f64
1354        } else {
1355            0.0
1356        };
1357
1358        // Average shortest-path length via BFS, bounded to a deterministic
1359        // sample of source nodes so this stays cheap on very large networks.
1360        const MAX_BFS_SOURCES: usize = 200;
1361        let mut node_ids: Vec<&String> = adjacency.keys().collect();
1362        node_ids.sort();
1363        node_ids.truncate(MAX_BFS_SOURCES);
1364
1365        let mut total_distance = 0.0f64;
1366        let mut total_pairs = 0usize;
1367        for source in &node_ids {
1368            let mut visited: HashSet<&String> = HashSet::new();
1369            visited.insert(source);
1370            let mut queue: VecDeque<(&String, usize)> = VecDeque::new();
1371            queue.push_back((source, 0));
1372            while let Some((node, dist)) = queue.pop_front() {
1373                if dist > 0 {
1374                    total_distance += dist as f64;
1375                    total_pairs += 1;
1376                }
1377                if let Some(neighbors) = adjacency.get(node.as_str()) {
1378                    for neighbor in neighbors {
1379                        if visited.insert(neighbor) {
1380                            queue.push_back((neighbor, dist + 1));
1381                        }
1382                    }
1383                }
1384            }
1385        }
1386        let average_path_length = if total_pairs > 0 {
1387            total_distance / total_pairs as f64
1388        } else {
1389            0.0
1390        };
1391
1392        (network_density, clustering_coefficient, average_path_length)
1393    }
1394}
1395
1396#[cfg(test)]
1397mod tests {
1398    use super::*;
1399
1400    fn make_publication(id: &str, authors: &[&str]) -> PublicationEmbedding {
1401        PublicationEmbedding {
1402            publication_id: id.to_string(),
1403            title: format!("Title {id}"),
1404            abstract_text: String::new(),
1405            authors: authors.iter().map(|a| a.to_string()).collect(),
1406            venue: "Venue".to_string(),
1407            year: 2024,
1408            citation_count: 0,
1409            topic_distribution: vec![],
1410            embedding: Vector::new(vec![0.0; 4]),
1411            predicted_impact: 0.0,
1412            publication_type: PublicationType::JournalArticle,
1413            doi: None,
1414            last_updated: Utc::now(),
1415        }
1416    }
1417
1418    /// Regression test: co-authorship graph statistics must be computed for
1419    /// real from actual publication author lists, instead of the hardcoded
1420    /// (0.1, 0.3, 4.5) placeholder tuple.
1421    #[test]
1422    fn test_compute_coauthorship_graph_metrics_triangle() {
1423        // A, B, C all co-author one paper together => a complete triangle:
1424        // density = 1.0, clustering coefficient = 1.0, avg path length = 1.0.
1425        let mut publications = HashMap::new();
1426        publications.insert("p1".to_string(), make_publication("p1", &["A", "B", "C"]));
1427
1428        let (density, clustering, avg_path) =
1429            ResearchNetworkAnalyzer::compute_coauthorship_graph_metrics(&publications);
1430
1431        assert!((density - 1.0).abs() < 1e-9, "density = {density}");
1432        assert!((clustering - 1.0).abs() < 1e-9, "clustering = {clustering}");
1433        assert!((avg_path - 1.0).abs() < 1e-9, "avg_path = {avg_path}");
1434    }
1435
1436    #[test]
1437    fn test_compute_coauthorship_graph_metrics_empty() {
1438        let publications = HashMap::new();
1439        let (density, clustering, avg_path) =
1440            ResearchNetworkAnalyzer::compute_coauthorship_graph_metrics(&publications);
1441        assert_eq!(density, 0.0);
1442        assert_eq!(clustering, 0.0);
1443        assert_eq!(avg_path, 0.0);
1444    }
1445
1446    #[tokio::test]
1447    async fn test_research_network_analyzer_creation() {
1448        let config = ResearchNetworkConfig::default();
1449        let analyzer = ResearchNetworkAnalyzer::new(config);
1450
1451        // Test that analyzer is created successfully
1452        assert_eq!(
1453            analyzer
1454                .author_embeddings
1455                .read()
1456                .expect("rwlock should not be poisoned")
1457                .len(),
1458            0
1459        );
1460        assert_eq!(
1461            analyzer
1462                .publication_embeddings
1463                .read()
1464                .expect("rwlock should not be poisoned")
1465                .len(),
1466            0
1467        );
1468    }
1469
1470    /// Regression: generating an embedding for an author with no registered
1471    /// profile must fail loudly rather than fabricate a name/affiliation
1472    /// (there is no external author database to resolve them from).
1473    #[tokio::test]
1474    async fn test_author_embedding_requires_registered_profile() {
1475        let config = ResearchNetworkConfig::default();
1476        let analyzer = ResearchNetworkAnalyzer::new(config);
1477
1478        let result = analyzer
1479            .generate_author_embedding("unregistered_author")
1480            .await;
1481        assert!(result.is_err());
1482        let msg = result.expect_err("must fail loudly").to_string();
1483        assert!(msg.contains("unregistered_author"));
1484        assert!(msg.contains("register_author_profile"));
1485    }
1486
1487    #[tokio::test]
1488    async fn test_author_embedding_generation() {
1489        let config = ResearchNetworkConfig::default();
1490        let analyzer = ResearchNetworkAnalyzer::new(config);
1491        analyzer.register_author_profile(
1492            "test_author",
1493            AuthorProfile {
1494                name: "Dr. Ada Example".to_string(),
1495                affiliations: vec!["Example University".to_string()],
1496            },
1497        );
1498
1499        let result = analyzer.generate_author_embedding("test_author").await;
1500        assert!(result.is_ok());
1501
1502        let embedding = result.expect("should succeed");
1503        assert_eq!(embedding.author_id, "test_author");
1504        // Real metadata, not the old `Author_{id}` / `Unknown` fabrication.
1505        assert_eq!(embedding.name, "Dr. Ada Example");
1506        assert_eq!(
1507            embedding.affiliations,
1508            vec!["Example University".to_string()]
1509        );
1510        assert!(embedding.h_index >= 0.0);
1511        assert_eq!(embedding.embedding.values.len(), 512); // Default dimension
1512    }
1513
1514    /// Regression: generating an embedding for a publication with no
1515    /// registered metadata must fail loudly rather than fabricate a
1516    /// title/venue/year (there is no external publication database to
1517    /// resolve them from).
1518    #[tokio::test]
1519    async fn test_publication_embedding_requires_registered_metadata() {
1520        let config = ResearchNetworkConfig::default();
1521        let analyzer = ResearchNetworkAnalyzer::new(config);
1522
1523        let result = analyzer
1524            .generate_publication_embedding("unregistered_publication")
1525            .await;
1526        assert!(result.is_err());
1527        let msg = result.expect_err("must fail loudly").to_string();
1528        assert!(msg.contains("unregistered_publication"));
1529        assert!(msg.contains("register_publication_metadata"));
1530    }
1531
1532    #[tokio::test]
1533    async fn test_publication_embedding_generation() {
1534        let config = ResearchNetworkConfig::default();
1535        let analyzer = ResearchNetworkAnalyzer::new(config);
1536        analyzer.register_publication_metadata(
1537            "test_publication",
1538            PublicationMetadataInput {
1539                title: "A Study of RDF Embeddings".to_string(),
1540                abstract_text: "We study embeddings of RDF graphs.".to_string(),
1541                authors: vec!["test_author".to_string()],
1542                venue: "Journal of Semantic Web".to_string(),
1543                year: 2025,
1544                doi: Some("10.1234/example".to_string()),
1545            },
1546        );
1547
1548        let result = analyzer
1549            .generate_publication_embedding("test_publication")
1550            .await;
1551        assert!(result.is_ok());
1552
1553        let embedding = result.expect("should succeed");
1554        assert_eq!(embedding.publication_id, "test_publication");
1555        // Real metadata, not the old `Publication_{id}` / "Unknown Venue" fabrication.
1556        assert_eq!(embedding.title, "A Study of RDF Embeddings");
1557        assert_eq!(embedding.venue, "Journal of Semantic Web");
1558        assert_eq!(embedding.year, 2025);
1559        assert!(embedding.predicted_impact >= 0.0);
1560        assert!(embedding.predicted_impact <= 1.0);
1561    }
1562
1563    /// Regression: an author's publications and collaborations are drawn
1564    /// from real registered/recorded data, not fabricated.
1565    #[tokio::test]
1566    async fn test_author_publications_and_collaborations_are_real() {
1567        let config = ResearchNetworkConfig::default();
1568        let analyzer = ResearchNetworkAnalyzer::new(config);
1569
1570        analyzer.register_author_profile(
1571            "author_a",
1572            AuthorProfile {
1573                name: "Author A".to_string(),
1574                affiliations: vec!["Uni A".to_string()],
1575            },
1576        );
1577        analyzer.register_publication_metadata(
1578            "pub1",
1579            PublicationMetadataInput {
1580                title: "Paper One".to_string(),
1581                abstract_text: String::new(),
1582                authors: vec!["author_a".to_string()],
1583                venue: "Venue".to_string(),
1584                year: 2024,
1585                doi: None,
1586            },
1587        );
1588        analyzer
1589            .add_collaboration(Collaboration {
1590                author1: "author_a".to_string(),
1591                author2: "author_b".to_string(),
1592                joint_publications: 1,
1593                strength: 0.8,
1594                shared_topics: vec![],
1595                first_collaboration: Utc::now(),
1596                last_collaboration: Utc::now(),
1597            })
1598            .await
1599            .expect("should succeed");
1600
1601        let publications = analyzer
1602            .get_author_publications("author_a")
1603            .await
1604            .expect("should succeed");
1605        assert_eq!(publications.len(), 1);
1606        assert_eq!(publications[0].title, "Paper One");
1607
1608        let collaborations = analyzer
1609            .get_author_collaborations("author_a")
1610            .await
1611            .expect("should succeed");
1612        assert_eq!(collaborations.len(), 1);
1613        assert_eq!(collaborations[0].author2, "author_b");
1614
1615        // The collaboration is also visible from the other author's side.
1616        let collaborations_b = analyzer
1617            .get_author_collaborations("author_b")
1618            .await
1619            .expect("should succeed");
1620        assert_eq!(collaborations_b.len(), 1);
1621        assert_eq!(collaborations_b[0].author1, "author_a");
1622    }
1623
1624    #[tokio::test]
1625    async fn test_h_index_calculation() {
1626        let config = ResearchNetworkConfig::default();
1627        let analyzer = ResearchNetworkAnalyzer::new(config);
1628
1629        // Create test publications with different citation counts
1630        let publications = vec![
1631            PublicationEmbedding {
1632                publication_id: "p1".to_string(),
1633                title: "Test 1".to_string(),
1634                abstract_text: "Abstract 1".to_string(),
1635                authors: vec!["author1".to_string()],
1636                venue: "Venue 1".to_string(),
1637                year: 2023,
1638                citation_count: 10,
1639                topic_distribution: vec![],
1640                embedding: Vector::new(vec![]),
1641                predicted_impact: 0.5,
1642                publication_type: PublicationType::JournalArticle,
1643                doi: None,
1644                last_updated: Utc::now(),
1645            },
1646            PublicationEmbedding {
1647                publication_id: "p2".to_string(),
1648                title: "Test 2".to_string(),
1649                abstract_text: "Abstract 2".to_string(),
1650                authors: vec!["author1".to_string()],
1651                venue: "Venue 2".to_string(),
1652                year: 2023,
1653                citation_count: 5,
1654                topic_distribution: vec![],
1655                embedding: Vector::new(vec![]),
1656                predicted_impact: 0.3,
1657                publication_type: PublicationType::JournalArticle,
1658                doi: None,
1659                last_updated: Utc::now(),
1660            },
1661        ];
1662
1663        let h_index = analyzer
1664            .calculate_h_index(&publications)
1665            .await
1666            .expect("should succeed");
1667        assert_eq!(h_index, 2.0); // Both papers have at least 2 citations
1668    }
1669
1670    #[test]
1671    fn test_career_stage_classification() {
1672        // Test early career
1673        let rt = tokio::runtime::Runtime::new().expect("should succeed");
1674        let config = ResearchNetworkConfig::default();
1675        let analyzer = ResearchNetworkAnalyzer::new(config);
1676
1677        let stage = rt
1678            .block_on(analyzer.classify_career_stage(50, 5, 3.0))
1679            .expect("should succeed");
1680        assert!(matches!(stage, CareerStage::EarlyCareer));
1681
1682        // Test senior career
1683        let stage = rt
1684            .block_on(analyzer.classify_career_stage(2000, 100, 25.0))
1685            .expect("should succeed");
1686        assert!(matches!(stage, CareerStage::SeniorCareer));
1687    }
1688
1689    #[tokio::test]
1690    async fn test_network_metrics() {
1691        let config = ResearchNetworkConfig::default();
1692        let analyzer = ResearchNetworkAnalyzer::new(config);
1693
1694        // Add some test data
1695        analyzer.register_author_profile(
1696            "test_author",
1697            AuthorProfile {
1698                name: "Test Author".to_string(),
1699                affiliations: vec!["Test University".to_string()],
1700            },
1701        );
1702        analyzer.register_publication_metadata(
1703            "test_publication",
1704            PublicationMetadataInput {
1705                title: "Test Publication".to_string(),
1706                abstract_text: String::new(),
1707                authors: vec!["test_author".to_string()],
1708                venue: "Test Venue".to_string(),
1709                year: 2025,
1710                doi: None,
1711            },
1712        );
1713        let _author_embedding = analyzer
1714            .generate_author_embedding("test_author")
1715            .await
1716            .expect("should succeed");
1717        let _publication_embedding = analyzer
1718            .generate_publication_embedding("test_publication")
1719            .await
1720            .expect("should succeed");
1721
1722        let metrics = analyzer
1723            .get_network_metrics()
1724            .await
1725            .expect("should succeed");
1726        assert_eq!(metrics.total_authors, 1);
1727        assert_eq!(metrics.total_publications, 1);
1728    }
1729}