saorsa_core/identity/
targeting.rs

1// Copyright (c) 2025 Saorsa Labs Limited
2
3// This file is part of the Saorsa P2P network.
4
5// Licensed under the AGPL-3.0 license:
6// <https://www.gnu.org/licenses/agpl-3.0.html>
7
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU Affero General Public License for more details.
12
13// You should have received a copy of the GNU Affero General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16// Copyright 2024 P2P Foundation
17// SPDX-License-Identifier: AGPL-3.0-or-later
18
19//! Targeted identity generation for avoiding rejected keyspace regions.
20//!
21//! This module provides mechanisms to generate new node identities that are
22//! more likely to be accepted by the network by targeting less saturated
23//! regions of the XOR keyspace.
24//!
25//! # Targeting Strategy
26//!
27//! When generating a new identity, the targeter considers:
28//! - Rejected keyspace prefixes from previous attempts
29//! - Network-suggested target regions
30//! - Known saturation levels
31//! - XOR distance from previous rejected positions
32//!
33//! # Example
34//!
35//! ```ignore
36//! use saorsa_core::identity::targeting::{IdentityTargeter, TargetingConfig};
37//!
38//! let config = TargetingConfig::default();
39//! let targeter = IdentityTargeter::new(config);
40//!
41//! // Add rejected prefixes from previous attempts
42//! targeter.add_rejected_prefix(vec![0xAB], 8);
43//!
44//! // Generate targeted identity
45//! let identity = targeter.generate_targeted_identity(
46//!     Some(&suggested_target),
47//!     100 // max attempts
48//! ).await?;
49//! ```
50
51use serde::{Deserialize, Serialize};
52use std::collections::HashSet;
53use std::sync::Arc;
54
55use parking_lot::RwLock;
56
57use super::node_identity::{NodeId, NodeIdentity};
58use super::rejection::{KeyspaceRegion, TargetRegion};
59use crate::Result;
60
61/// Configuration for identity targeting.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct TargetingConfig {
64    /// Maximum attempts to generate a suitable identity (default: 100).
65    pub max_generation_attempts: u32,
66
67    /// Minimum XOR distance from rejected prefixes (bits, default: 4).
68    pub min_distance_from_rejected: u8,
69
70    /// Weight for XOR distance in candidate scoring (default: 0.4).
71    pub distance_weight: f64,
72
73    /// Weight for target region alignment in scoring (default: 0.3).
74    pub target_weight: f64,
75
76    /// Weight for avoiding rejected regions in scoring (default: 0.3).
77    pub avoidance_weight: f64,
78
79    /// Number of candidates to generate per round (default: 10).
80    pub candidates_per_round: usize,
81}
82
83impl Default for TargetingConfig {
84    fn default() -> Self {
85        Self {
86            max_generation_attempts: 100,
87            min_distance_from_rejected: 4,
88            distance_weight: 0.4,
89            target_weight: 0.3,
90            avoidance_weight: 0.3,
91            candidates_per_round: 10,
92        }
93    }
94}
95
96/// A candidate identity being evaluated.
97struct IdentityCandidate {
98    /// The generated identity.
99    identity: NodeIdentity,
100
101    /// Score (0.0 to 1.0, higher is better).
102    score: f64,
103}
104
105/// A rejected keyspace prefix to avoid.
106#[derive(Debug, Clone, Hash, PartialEq, Eq)]
107pub struct RejectedPrefix {
108    /// The prefix bytes.
109    pub prefix: Vec<u8>,
110
111    /// Number of significant bits.
112    pub prefix_len: u8,
113}
114
115impl RejectedPrefix {
116    /// Create a new rejected prefix.
117    #[must_use]
118    pub fn new(prefix: Vec<u8>, prefix_len: u8) -> Self {
119        Self { prefix, prefix_len }
120    }
121
122    /// Check if a NodeId matches this rejected prefix.
123    #[must_use]
124    pub fn matches(&self, node_id: &NodeId) -> bool {
125        let node_bytes = node_id.to_bytes();
126        let full_bytes = self.prefix_len as usize / 8;
127        let remaining_bits = self.prefix_len as usize % 8;
128
129        // Check full bytes using zip
130        let prefix_slice = &self.prefix[..full_bytes.min(self.prefix.len())];
131        let node_slice = &node_bytes[..full_bytes.min(node_bytes.len())];
132        for (p, n) in prefix_slice.iter().zip(node_slice.iter()) {
133            if p != n {
134                return false;
135            }
136        }
137
138        // Check remaining bits
139        if remaining_bits > 0 && full_bytes < self.prefix.len() && full_bytes < node_bytes.len() {
140            let mask = 0xFF << (8 - remaining_bits);
141            if (self.prefix[full_bytes] & mask) != (node_bytes[full_bytes] & mask) {
142                return false;
143            }
144        }
145
146        true
147    }
148
149    /// Calculate XOR distance to a NodeId in the first `prefix_len` bits.
150    #[must_use]
151    pub fn xor_distance_bits(&self, node_id: &NodeId) -> u32 {
152        let node_bytes = node_id.to_bytes();
153        let mut distance = 0u32;
154
155        let full_bytes = self.prefix_len as usize / 8;
156        let remaining_bits = self.prefix_len as usize % 8;
157
158        // Count differing bits in full bytes using zip
159        let prefix_slice = &self.prefix[..full_bytes.min(self.prefix.len())];
160        let node_slice = &node_bytes[..full_bytes.min(node_bytes.len())];
161        for (p, n) in prefix_slice.iter().zip(node_slice.iter()) {
162            let xor = p ^ n;
163            distance += xor.count_ones();
164        }
165
166        // Count differing bits in partial byte
167        if remaining_bits > 0 && full_bytes < self.prefix.len() && full_bytes < node_bytes.len() {
168            let mask = 0xFF << (8 - remaining_bits);
169            let xor = (self.prefix[full_bytes] ^ node_bytes[full_bytes]) & mask;
170            distance += xor.count_ones();
171        }
172
173        distance
174    }
175}
176
177/// Internal state for the identity targeter.
178#[derive(Default)]
179struct TargeterState {
180    /// Rejected prefixes to avoid.
181    rejected_prefixes: HashSet<RejectedPrefix>,
182
183    /// Previously generated NodeIds that were rejected.
184    rejected_node_ids: Vec<NodeId>,
185
186    /// Last target region suggested by network.
187    last_target: Option<TargetRegion>,
188
189    /// Statistics on generation attempts.
190    total_attempts: u64,
191
192    /// Statistics on successful generations.
193    successful_generations: u64,
194}
195
196/// Identity targeter for generating identities in suitable keyspace regions.
197pub struct IdentityTargeter {
198    /// Configuration.
199    config: TargetingConfig,
200
201    /// Internal state.
202    state: RwLock<TargeterState>,
203}
204
205impl IdentityTargeter {
206    /// Create a new identity targeter.
207    #[must_use]
208    pub fn new(config: TargetingConfig) -> Self {
209        Self {
210            config,
211            state: RwLock::new(TargeterState::default()),
212        }
213    }
214
215    /// Add a rejected prefix to avoid.
216    pub fn add_rejected_prefix(&self, prefix: Vec<u8>, prefix_len: u8) {
217        let rejected = RejectedPrefix::new(prefix, prefix_len);
218        self.state.write().rejected_prefixes.insert(rejected);
219    }
220
221    /// Add multiple rejected prefixes from raw bytes.
222    pub fn add_rejected_prefixes(&self, prefixes: &[Vec<u8>], prefix_len: u8) {
223        let mut state = self.state.write();
224        for prefix in prefixes {
225            let rejected = RejectedPrefix::new(prefix.clone(), prefix_len);
226            state.rejected_prefixes.insert(rejected);
227        }
228    }
229
230    /// Record a rejected NodeId.
231    pub fn record_rejected_node_id(&self, node_id: NodeId) {
232        let mut state = self.state.write();
233        state.rejected_node_ids.push(node_id);
234
235        // Keep bounded list
236        if state.rejected_node_ids.len() > 100 {
237            state.rejected_node_ids.remove(0);
238        }
239    }
240
241    /// Update the target region suggestion.
242    pub fn set_target(&self, target: Option<TargetRegion>) {
243        self.state.write().last_target = target;
244    }
245
246    /// Clear all rejected prefixes.
247    pub fn clear_rejected(&self) {
248        let mut state = self.state.write();
249        state.rejected_prefixes.clear();
250        state.rejected_node_ids.clear();
251    }
252
253    /// Generate a targeted identity that avoids rejected regions.
254    ///
255    /// This generates multiple candidate identities and selects the best one
256    /// based on scoring criteria.
257    pub fn generate_targeted_identity(
258        &self,
259        suggested_target: Option<&TargetRegion>,
260    ) -> Result<NodeIdentity> {
261        let mut state = self.state.write();
262        state.total_attempts += 1;
263
264        // Update target if provided
265        if let Some(target) = suggested_target {
266            state.last_target = Some(target.clone());
267        }
268
269        let target = state.last_target.clone();
270        let rejected_prefixes: Vec<_> = state.rejected_prefixes.iter().cloned().collect();
271        let rejected_ids: Vec<_> = state.rejected_node_ids.clone();
272
273        drop(state); // Release lock before generation
274
275        let mut best_candidate: Option<IdentityCandidate> = None;
276        let mut attempts = 0u32;
277
278        while attempts < self.config.max_generation_attempts {
279            // Generate a batch of candidates
280            let candidates: Vec<_> = (0..self.config.candidates_per_round)
281                .filter_map(|_| {
282                    attempts += 1;
283                    if attempts > self.config.max_generation_attempts {
284                        return None;
285                    }
286                    NodeIdentity::generate().ok()
287                })
288                .collect();
289
290            // Score each candidate
291            for identity in candidates {
292                let node_id = identity.node_id();
293
294                // Check if this matches any rejected prefix
295                let matches_rejected = rejected_prefixes.iter().any(|p| p.matches(node_id));
296                if matches_rejected {
297                    continue;
298                }
299
300                // Calculate score
301                let score = self.score_candidate(
302                    node_id,
303                    target.as_ref(),
304                    &rejected_prefixes,
305                    &rejected_ids,
306                );
307
308                // Update best if this is better
309                match &best_candidate {
310                    None => {
311                        best_candidate = Some(IdentityCandidate { identity, score });
312                    }
313                    Some(best) if score > best.score => {
314                        best_candidate = Some(IdentityCandidate { identity, score });
315                    }
316                    _ => {}
317                }
318
319                // If we have a good enough candidate, stop early
320                if score > 0.9 {
321                    break;
322                }
323            }
324
325            // If we have a decent candidate, we can stop
326            if let Some(ref best) = best_candidate
327                && best.score > 0.7
328            {
329                break;
330            }
331        }
332
333        // Return the best candidate or generate a fallback
334        let identity = match best_candidate {
335            Some(candidate) => {
336                let mut state = self.state.write();
337                state.successful_generations += 1;
338                candidate.identity
339            }
340            None => {
341                // Fallback: generate random identity
342                NodeIdentity::generate()?
343            }
344        };
345
346        Ok(identity)
347    }
348
349    /// Score a candidate NodeId based on targeting criteria.
350    fn score_candidate(
351        &self,
352        node_id: &NodeId,
353        target: Option<&TargetRegion>,
354        rejected_prefixes: &[RejectedPrefix],
355        rejected_ids: &[NodeId],
356    ) -> f64 {
357        let mut score = 0.0;
358
359        // Distance from rejected prefixes (higher distance = better)
360        let avoidance_score = if rejected_prefixes.is_empty() {
361            1.0
362        } else {
363            let min_distance = rejected_prefixes
364                .iter()
365                .map(|p| p.xor_distance_bits(node_id))
366                .min()
367                .unwrap_or(u32::MAX);
368
369            // Normalize: min_distance_from_rejected bits = 1.0
370            let threshold = u32::from(self.config.min_distance_from_rejected);
371            (min_distance as f64 / threshold as f64).min(1.0)
372        };
373        score += avoidance_score * self.config.avoidance_weight;
374
375        // Target region alignment
376        let target_score = if let Some(target) = target {
377            if target.region.contains(node_id) {
378                target.confidence
379            } else {
380                // Partial score for being close to target
381                let distance = self.xor_distance_to_region(node_id, &target.region);
382                (1.0 - (distance as f64 / 256.0)).max(0.0) * target.confidence
383            }
384        } else {
385            0.5 // Neutral when no target
386        };
387        score += target_score * self.config.target_weight;
388
389        // Distance from previously rejected NodeIds
390        let id_distance_score = if rejected_ids.is_empty() {
391            1.0
392        } else {
393            let min_distance = rejected_ids
394                .iter()
395                .map(|id| self.leading_zero_distance(node_id, id))
396                .min()
397                .unwrap_or(256);
398
399            // Higher distance from rejected IDs = better
400            (min_distance as f64 / 32.0).min(1.0)
401        };
402        score += id_distance_score * self.config.distance_weight;
403
404        score
405    }
406
407    /// Calculate XOR distance to a keyspace region.
408    fn xor_distance_to_region(&self, node_id: &NodeId, region: &KeyspaceRegion) -> u32 {
409        let node_bytes = node_id.to_bytes();
410        let mut distance = 0u32;
411
412        let full_bytes = region.prefix_len as usize / 8;
413        let remaining_bits = region.prefix_len as usize % 8;
414
415        // Count differing bits in full bytes using zip
416        let prefix_slice = &region.prefix[..full_bytes.min(region.prefix.len())];
417        let node_slice = &node_bytes[..full_bytes.min(node_bytes.len())];
418        for (p, n) in prefix_slice.iter().zip(node_slice.iter()) {
419            let xor = p ^ n;
420            distance += xor.count_ones();
421        }
422
423        // Count differing bits in partial byte
424        if remaining_bits > 0 && full_bytes < region.prefix.len() && full_bytes < node_bytes.len() {
425            let mask = 0xFF << (8 - remaining_bits);
426            let xor = (region.prefix[full_bytes] ^ node_bytes[full_bytes]) & mask;
427            distance += xor.count_ones();
428        }
429
430        distance
431    }
432
433    /// Count leading zero bits in XOR distance between two NodeIds.
434    fn leading_zero_distance(&self, a: &NodeId, b: &NodeId) -> u32 {
435        let distance = a.xor_distance(b);
436
437        let mut leading_zeros = 0u32;
438        for byte in &distance {
439            if *byte == 0 {
440                leading_zeros += 8;
441            } else {
442                leading_zeros += byte.leading_zeros();
443                break;
444            }
445        }
446
447        leading_zeros
448    }
449
450    /// Get statistics on targeting attempts.
451    #[must_use]
452    pub fn stats(&self) -> TargetingStats {
453        let state = self.state.read();
454        TargetingStats {
455            total_attempts: state.total_attempts,
456            successful_generations: state.successful_generations,
457            rejected_prefix_count: state.rejected_prefixes.len(),
458            rejected_id_count: state.rejected_node_ids.len(),
459        }
460    }
461
462    /// Check if we have any rejected prefixes.
463    #[must_use]
464    pub fn has_rejected_prefixes(&self) -> bool {
465        !self.state.read().rejected_prefixes.is_empty()
466    }
467
468    /// Get the number of rejected prefixes.
469    #[must_use]
470    pub fn rejected_prefix_count(&self) -> usize {
471        self.state.read().rejected_prefixes.len()
472    }
473}
474
475/// Statistics on identity targeting.
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct TargetingStats {
478    /// Total targeting attempts.
479    pub total_attempts: u64,
480
481    /// Number of successful generations.
482    pub successful_generations: u64,
483
484    /// Number of rejected prefixes being avoided.
485    pub rejected_prefix_count: usize,
486
487    /// Number of rejected NodeIds being avoided.
488    pub rejected_id_count: usize,
489}
490
491impl TargetingStats {
492    /// Calculate success rate.
493    #[must_use]
494    pub fn success_rate(&self) -> f64 {
495        if self.total_attempts == 0 {
496            1.0
497        } else {
498            self.successful_generations as f64 / self.total_attempts as f64
499        }
500    }
501}
502
503/// Shared identity targeter wrapped in Arc.
504pub type SharedIdentityTargeter = Arc<IdentityTargeter>;
505
506/// Builder for IdentityTargeter with custom configuration.
507pub struct IdentityTargeterBuilder {
508    config: TargetingConfig,
509    initial_rejected: Vec<RejectedPrefix>,
510}
511
512impl IdentityTargeterBuilder {
513    /// Create a new builder with default configuration.
514    #[must_use]
515    pub fn new() -> Self {
516        Self {
517            config: TargetingConfig::default(),
518            initial_rejected: Vec::new(),
519        }
520    }
521
522    /// Set maximum generation attempts.
523    #[must_use]
524    pub fn max_attempts(mut self, max: u32) -> Self {
525        self.config.max_generation_attempts = max;
526        self
527    }
528
529    /// Set minimum distance from rejected prefixes.
530    #[must_use]
531    pub fn min_distance_from_rejected(mut self, bits: u8) -> Self {
532        self.config.min_distance_from_rejected = bits;
533        self
534    }
535
536    /// Set scoring weights.
537    #[must_use]
538    pub fn weights(mut self, distance: f64, target: f64, avoidance: f64) -> Self {
539        self.config.distance_weight = distance;
540        self.config.target_weight = target;
541        self.config.avoidance_weight = avoidance;
542        self
543    }
544
545    /// Add initial rejected prefixes.
546    #[must_use]
547    pub fn reject_prefix(mut self, prefix: Vec<u8>, prefix_len: u8) -> Self {
548        self.initial_rejected
549            .push(RejectedPrefix::new(prefix, prefix_len));
550        self
551    }
552
553    /// Build the identity targeter.
554    #[must_use]
555    pub fn build(self) -> IdentityTargeter {
556        let targeter = IdentityTargeter::new(self.config);
557
558        // Add initial rejected prefixes
559        for rejected in self.initial_rejected {
560            targeter.state.write().rejected_prefixes.insert(rejected);
561        }
562
563        targeter
564    }
565}
566
567impl Default for IdentityTargeterBuilder {
568    fn default() -> Self {
569        Self::new()
570    }
571}
572
573#[cfg(test)]
574#[allow(clippy::field_reassign_with_default)]
575mod tests {
576    use super::*;
577
578    fn test_node_id() -> NodeId {
579        NodeId([0x42; 32])
580    }
581
582    #[test]
583    fn test_rejected_prefix_matches() {
584        let prefix = RejectedPrefix::new(vec![0xAB], 8);
585
586        // Should match
587        let matching_id = NodeId([0xAB; 32]);
588        assert!(prefix.matches(&matching_id));
589
590        // Should not match
591        let non_matching_id = NodeId([0x12; 32]);
592        assert!(!prefix.matches(&non_matching_id));
593    }
594
595    #[test]
596    fn test_rejected_prefix_partial_byte() {
597        // Prefix 0xF0 with only 4 bits significant
598        let prefix = RejectedPrefix::new(vec![0xF0], 4);
599
600        // 0xFF starts with 1111, should match
601        let matching_id = NodeId([0xFF; 32]);
602        assert!(prefix.matches(&matching_id));
603
604        // 0x00 starts with 0000, should not match
605        let non_matching_id = NodeId([0x00; 32]);
606        assert!(!prefix.matches(&non_matching_id));
607    }
608
609    #[test]
610    fn test_rejected_prefix_xor_distance() {
611        let prefix = RejectedPrefix::new(vec![0xFF], 8);
612
613        // Same prefix = 0 distance
614        let same = NodeId([0xFF; 32]);
615        assert_eq!(prefix.xor_distance_bits(&same), 0);
616
617        // All bits different = 8 distance
618        let opposite = NodeId([0x00; 32]);
619        assert_eq!(prefix.xor_distance_bits(&opposite), 8);
620
621        // Half bits different = 4 distance
622        let half = NodeId([0xF0; 32]);
623        assert_eq!(prefix.xor_distance_bits(&half), 4);
624    }
625
626    #[test]
627    fn test_identity_targeter_creation() {
628        let config = TargetingConfig::default();
629        let targeter = IdentityTargeter::new(config);
630
631        assert!(!targeter.has_rejected_prefixes());
632        assert_eq!(targeter.rejected_prefix_count(), 0);
633    }
634
635    #[test]
636    fn test_add_rejected_prefix() {
637        let config = TargetingConfig::default();
638        let targeter = IdentityTargeter::new(config);
639
640        targeter.add_rejected_prefix(vec![0xAB], 8);
641
642        assert!(targeter.has_rejected_prefixes());
643        assert_eq!(targeter.rejected_prefix_count(), 1);
644    }
645
646    #[test]
647    fn test_generate_targeted_identity() {
648        let config = TargetingConfig::default();
649        let targeter = IdentityTargeter::new(config);
650
651        // Generate without any constraints
652        let identity = targeter.generate_targeted_identity(None);
653        assert!(identity.is_ok());
654    }
655
656    #[test]
657    fn test_generate_targeted_identity_with_rejected() {
658        let mut config = TargetingConfig::default();
659        config.max_generation_attempts = 50;
660        let targeter = IdentityTargeter::new(config);
661
662        // Add some rejected prefixes
663        targeter.add_rejected_prefix(vec![0x00], 4);
664        targeter.add_rejected_prefix(vec![0x10], 4);
665
666        let identity = targeter.generate_targeted_identity(None);
667        assert!(identity.is_ok());
668
669        // The generated identity should not start with rejected prefixes
670        let node_id = identity.unwrap();
671        let id_bytes = node_id.node_id().to_bytes();
672
673        // Check it doesn't start with 0000 or 0001 (first nibbles)
674        let first_nibble = id_bytes[0] >> 4;
675        // It's random, so we can't guarantee, but we can check generation worked
676        assert!(first_nibble <= 15);
677    }
678
679    #[test]
680    fn test_targeting_stats() {
681        let config = TargetingConfig::default();
682        let targeter = IdentityTargeter::new(config);
683
684        // Initial stats
685        let stats = targeter.stats();
686        assert_eq!(stats.total_attempts, 0);
687        assert_eq!(stats.successful_generations, 0);
688
689        // Generate some identities
690        for _ in 0..3 {
691            let _ = targeter.generate_targeted_identity(None);
692        }
693
694        let stats = targeter.stats();
695        assert_eq!(stats.total_attempts, 3);
696        assert!(stats.successful_generations <= 3);
697    }
698
699    #[test]
700    fn test_record_rejected_node_id() {
701        let config = TargetingConfig::default();
702        let targeter = IdentityTargeter::new(config);
703
704        targeter.record_rejected_node_id(test_node_id());
705
706        let stats = targeter.stats();
707        assert_eq!(stats.rejected_id_count, 1);
708    }
709
710    #[test]
711    fn test_clear_rejected() {
712        let config = TargetingConfig::default();
713        let targeter = IdentityTargeter::new(config);
714
715        targeter.add_rejected_prefix(vec![0xAB], 8);
716        targeter.record_rejected_node_id(test_node_id());
717
718        assert!(targeter.has_rejected_prefixes());
719
720        targeter.clear_rejected();
721
722        assert!(!targeter.has_rejected_prefixes());
723        assert_eq!(targeter.stats().rejected_id_count, 0);
724    }
725
726    #[test]
727    fn test_builder() {
728        let targeter = IdentityTargeterBuilder::new()
729            .max_attempts(50)
730            .min_distance_from_rejected(6)
731            .weights(0.5, 0.3, 0.2)
732            .reject_prefix(vec![0xAB], 8)
733            .build();
734
735        assert!(targeter.has_rejected_prefixes());
736        assert_eq!(targeter.rejected_prefix_count(), 1);
737    }
738
739    #[test]
740    fn test_targeting_stats_success_rate() {
741        let stats = TargetingStats {
742            total_attempts: 10,
743            successful_generations: 8,
744            rejected_prefix_count: 2,
745            rejected_id_count: 5,
746        };
747
748        assert!((stats.success_rate() - 0.8).abs() < f64::EPSILON);
749    }
750
751    #[test]
752    fn test_targeting_stats_zero_attempts() {
753        let stats = TargetingStats {
754            total_attempts: 0,
755            successful_generations: 0,
756            rejected_prefix_count: 0,
757            rejected_id_count: 0,
758        };
759
760        assert!((stats.success_rate() - 1.0).abs() < f64::EPSILON);
761    }
762}