Skip to main content

web4_core/
t3.rs

1// Copyright (c) 2026 MetaLINXX Inc.
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//
4// This software is covered by US Patents 11,477,027 and 12,278,913,
5// and pending application 19/178,619. See PATENTS.md for details.
6
7//! Trust Tensor (T3) Implementation
8//!
9//! T3 is a 3-dimensional trust tensor whose root dimensions are nodes in an
10//! open-ended RDF sub-graph. Each root can have any number of sub-dimensions
11//! linked via `web4:subDimensionOf`. The scalar value at each root is the
12//! aggregate of its sub-graph.
13//!
14//! Root Dimensions:
15//! 1. Talent - natural aptitude and capability for a specific role
16//! 2. Training - acquired expertise, certifications, and experience
17//! 3. Temperament - behavioral consistency, reliability, ethical disposition
18//!
19//! Formal ontology: `web4-standard/ontology/t3v3-ontology.ttl`
20
21use crate::error::{Result, Web4Error};
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use uuid::Uuid;
25
26/// Number of root dimensions in the trust tensor
27pub const T3_DIMENSIONS: usize = 3;
28
29/// The three root dimensions of trust
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
31#[repr(usize)]
32pub enum TrustDimension {
33    /// Natural aptitude and capability for a specific role
34    Talent = 0,
35    /// Acquired expertise, certifications, and experience
36    Training = 1,
37    /// Behavioral consistency, reliability, ethical disposition
38    Temperament = 2,
39}
40
41impl TrustDimension {
42    /// Get all root dimensions in order
43    pub fn all() -> [TrustDimension; T3_DIMENSIONS] {
44        [
45            TrustDimension::Talent,
46            TrustDimension::Training,
47            TrustDimension::Temperament,
48        ]
49    }
50
51    /// Get the dimension name
52    pub fn name(&self) -> &'static str {
53        match self {
54            TrustDimension::Talent => "talent",
55            TrustDimension::Training => "training",
56            TrustDimension::Temperament => "temperament",
57        }
58    }
59}
60
61/// Score data for a sub-dimension
62#[derive(Clone, Debug, Serialize, Deserialize)]
63pub struct SubDimensionScore {
64    /// Current score (0.0 to 1.0)
65    pub score: f64,
66    /// Confidence weight
67    pub weight: f64,
68    /// Number of observations
69    pub observation_count: u64,
70    /// Which root dimension this is under
71    pub parent: TrustDimension,
72}
73
74/// A 3-dimensional trust tensor with fractal sub-dimension support
75#[derive(Clone, Debug, Serialize, Deserialize)]
76pub struct T3 {
77    /// Trust scores for each root dimension (0.0 to 1.0)
78    dimensions: [f64; T3_DIMENSIONS],
79
80    /// Confidence weights for each root dimension (0.0 to 1.0)
81    /// Higher weight = more observations/evidence
82    weights: [f64; T3_DIMENSIONS],
83
84    /// Number of observations contributing to each root dimension
85    observation_counts: [u64; T3_DIMENSIONS],
86
87    /// Sub-dimensions keyed by name, linked to root via parent field.
88    /// Anyone can extend the dimension tree without modifying the core.
89    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
90    sub_dimensions: HashMap<String, SubDimensionScore>,
91}
92
93impl Default for T3 {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl T3 {
100    /// Create a new T3 with neutral trust (0.5) and zero confidence
101    pub fn new() -> Self {
102        Self {
103            dimensions: [0.5; T3_DIMENSIONS],
104            weights: [0.0; T3_DIMENSIONS],
105            observation_counts: [0; T3_DIMENSIONS],
106            sub_dimensions: HashMap::new(),
107        }
108    }
109
110    /// Create a T3 with specific initial root scores
111    pub fn with_scores(scores: [f64; T3_DIMENSIONS]) -> Result<Self> {
112        for score in scores {
113            if !(0.0..=1.0).contains(&score) {
114                return Err(Web4Error::InvalidInput(
115                    "Trust scores must be in range [0.0, 1.0]".into(),
116                ));
117            }
118        }
119        Ok(Self {
120            dimensions: scores,
121            weights: [0.0; T3_DIMENSIONS],
122            observation_counts: [0; T3_DIMENSIONS],
123            sub_dimensions: HashMap::new(),
124        })
125    }
126
127    /// Reconstruct a T3 from persisted parts: root scores + per-dimension
128    /// observation counts. Weights are recomputed from the counts using the
129    /// same logarithmic formula as [`T3::apply_delta`]/[`T3::observe`], so a
130    /// tensor that is saved and re-loaded keeps its confidence without having
131    /// to replay its observation history. Scores are clamped to `[0, 1]`.
132    ///
133    /// This is the persistence-round-trip constructor used by at-rest stores
134    /// (e.g. sealed `EntityTrust` files): the serialized form carries the three
135    /// root scores plus their observation counts, and this rebuilds the exact
136    /// tensor state. Sub-dimensions are not part of the persisted parts and are
137    /// initialized empty.
138    pub fn from_parts(
139        scores: [f64; T3_DIMENSIONS],
140        observation_counts: [u64; T3_DIMENSIONS],
141    ) -> Self {
142        let mut dimensions = [0.0; T3_DIMENSIONS];
143        let mut weights = [0.0; T3_DIMENSIONS];
144        for i in 0..T3_DIMENSIONS {
145            dimensions[i] = scores[i].clamp(0.0, 1.0);
146            weights[i] =
147                ((1.0 + observation_counts[i] as f64).ln() / 10.0_f64.ln()).min(1.0);
148        }
149        Self {
150            dimensions,
151            weights,
152            observation_counts,
153            sub_dimensions: HashMap::new(),
154        }
155    }
156
157    /// Get the score for a root dimension
158    pub fn score(&self, dimension: TrustDimension) -> f64 {
159        self.dimensions[dimension as usize]
160    }
161
162    /// Get the weight (confidence) for a root dimension
163    pub fn weight(&self, dimension: TrustDimension) -> f64 {
164        self.weights[dimension as usize]
165    }
166
167    /// Get all per-dimension observation counts.
168    ///
169    /// Exposes the raw evidence counts so persistence layers can serialize and
170    /// later restore confidence via [`T3::from_parts`]. Weights are a pure
171    /// function of these counts, so this is sufficient to round-trip state.
172    pub fn observation_counts(&self) -> &[u64; T3_DIMENSIONS] {
173        &self.observation_counts
174    }
175
176    /// Get all root dimension scores
177    pub fn scores(&self) -> &[f64; T3_DIMENSIONS] {
178        &self.dimensions
179    }
180
181    /// Get all weights
182    pub fn weights(&self) -> &[f64; T3_DIMENSIONS] {
183        &self.weights
184    }
185
186    /// Get sub-dimensions map
187    pub fn sub_dimensions(&self) -> &HashMap<String, SubDimensionScore> {
188        &self.sub_dimensions
189    }
190
191    /// Record an observation for a root dimension
192    ///
193    /// Uses exponential moving average with decay factor based on observation count
194    /// Apply a signed reputation delta directly to a dimension, clamped to
195    /// `[0, 1]`, counting it as one observation. This is how a
196    /// [`ReputationDelta`](crate::r6::ReputationDelta) from an R7 action outcome
197    /// — e.g. a missed deadline debiting Temperament — folds into the tensor.
198    /// Returns the realized change after clamping.
199    pub fn apply_delta(&mut self, dimension: TrustDimension, delta: f64) -> f64 {
200        let idx = dimension as usize;
201        let before = self.dimensions[idx];
202        self.dimensions[idx] = (before + delta).clamp(0.0, 1.0);
203        self.observation_counts[idx] += 1;
204        self.weights[idx] =
205            ((1.0 + self.observation_counts[idx] as f64).ln() / 10.0_f64.ln()).min(1.0);
206        self.dimensions[idx] - before
207    }
208
209    pub fn observe(&mut self, dimension: TrustDimension, observed_score: f64) -> Result<()> {
210        if !(0.0..=1.0).contains(&observed_score) {
211            return Err(Web4Error::InvalidInput(
212                "Observed score must be in range [0.0, 1.0]".into(),
213            ));
214        }
215
216        let idx = dimension as usize;
217        let count = self.observation_counts[idx];
218
219        // Exponential moving average: new = α * observed + (1-α) * old
220        // α starts high (0.5) and decreases as more observations accumulate
221        let alpha = 0.5 / (1.0 + (count as f64 / 10.0));
222        self.dimensions[idx] = alpha * observed_score + (1.0 - alpha) * self.dimensions[idx];
223
224        // Weight increases logarithmically with observations, capped at 1.0
225        self.observation_counts[idx] += 1;
226        self.weights[idx] = (1.0 + self.observation_counts[idx] as f64).ln() / 10.0_f64.ln();
227        self.weights[idx] = self.weights[idx].min(1.0);
228
229        Ok(())
230    }
231
232    /// Record an observation for a sub-dimension
233    ///
234    /// Sub-dimensions are keyed by name and linked to a root dimension.
235    /// Uses the same EMA math as root dimensions.
236    pub fn observe_sub_dimension(
237        &mut self,
238        name: &str,
239        parent: TrustDimension,
240        observed_score: f64,
241    ) -> Result<()> {
242        if !(0.0..=1.0).contains(&observed_score) {
243            return Err(Web4Error::InvalidInput(
244                "Observed score must be in range [0.0, 1.0]".into(),
245            ));
246        }
247
248        let entry = self.sub_dimensions.entry(name.to_string()).or_insert(
249            SubDimensionScore {
250                score: 0.5,
251                weight: 0.0,
252                observation_count: 0,
253                parent,
254            },
255        );
256
257        let alpha = 0.5 / (1.0 + (entry.observation_count as f64 / 10.0));
258        entry.score = alpha * observed_score + (1.0 - alpha) * entry.score;
259        entry.observation_count += 1;
260        entry.weight = (1.0 + entry.observation_count as f64).ln() / 10.0_f64.ln();
261        entry.weight = entry.weight.min(1.0);
262
263        Ok(())
264    }
265
266    /// Compute the aggregate trust score (weighted geometric mean)
267    ///
268    /// Geometric mean ensures that a zero in any dimension zeros the total,
269    /// reflecting that trust requires all dimensions to be positive.
270    pub fn aggregate(&self) -> f64 {
271        let total_weight: f64 = self.weights.iter().sum();
272        if total_weight == 0.0 {
273            return 0.5; // No observations, return neutral
274        }
275
276        // Weighted geometric mean
277        let log_sum: f64 = self
278            .dimensions
279            .iter()
280            .zip(self.weights.iter())
281            .map(|(score, weight)| {
282                // Add small epsilon to avoid log(0)
283                weight * (score + 1e-10).ln()
284            })
285            .sum();
286
287        (log_sum / total_weight).exp()
288    }
289
290    /// Compute Euclidean distance to another T3
291    pub fn distance(&self, other: &T3) -> f64 {
292        let sum_sq: f64 = self
293            .dimensions
294            .iter()
295            .zip(other.dimensions.iter())
296            .map(|(a, b)| (a - b).powi(2))
297            .sum();
298        sum_sq.sqrt()
299    }
300
301    /// Merge with another T3 using weighted average
302    ///
303    /// The merge is weighted by the observation counts in each tensor
304    pub fn merge(&self, other: &T3) -> Self {
305        let mut result = Self::new();
306
307        for i in 0..T3_DIMENSIONS {
308            let total_count = self.observation_counts[i] + other.observation_counts[i];
309            if total_count == 0 {
310                continue;
311            }
312
313            let self_weight = self.observation_counts[i] as f64 / total_count as f64;
314            let other_weight = other.observation_counts[i] as f64 / total_count as f64;
315
316            result.dimensions[i] =
317                self_weight * self.dimensions[i] + other_weight * other.dimensions[i];
318            result.observation_counts[i] = total_count;
319            result.weights[i] = (1.0 + total_count as f64).ln() / 10.0_f64.ln();
320            result.weights[i] = result.weights[i].min(1.0);
321        }
322
323        // Merge sub-dimensions from both tensors
324        for (name, sub) in &self.sub_dimensions {
325            result.sub_dimensions.insert(name.clone(), sub.clone());
326        }
327        for (name, other_sub) in &other.sub_dimensions {
328            if let Some(existing) = result.sub_dimensions.get_mut(name) {
329                let total = existing.observation_count + other_sub.observation_count;
330                if total > 0 {
331                    let w1 = existing.observation_count as f64 / total as f64;
332                    let w2 = other_sub.observation_count as f64 / total as f64;
333                    existing.score = w1 * existing.score + w2 * other_sub.score;
334                    existing.observation_count = total;
335                    existing.weight = (1.0 + total as f64).ln() / 10.0_f64.ln();
336                    existing.weight = existing.weight.min(1.0);
337                }
338            } else {
339                result.sub_dimensions.insert(name.clone(), other_sub.clone());
340            }
341        }
342
343        result
344    }
345
346    /// Apply time decay to the tensor
347    ///
348    /// Trust that isn't reinforced decays toward neutral (0.5) over time.
349    /// The decay_factor should be in (0, 1), where smaller = faster decay.
350    pub fn decay(&mut self, decay_factor: f64) {
351        for i in 0..T3_DIMENSIONS {
352            // Move score toward neutral (0.5)
353            let distance_from_neutral = self.dimensions[i] - 0.5;
354            self.dimensions[i] = 0.5 + distance_from_neutral * decay_factor;
355
356            // Also decay weights
357            self.weights[i] *= decay_factor;
358        }
359
360        // Decay sub-dimensions too
361        for sub in self.sub_dimensions.values_mut() {
362            let distance = sub.score - 0.5;
363            sub.score = 0.5 + distance * decay_factor;
364            sub.weight *= decay_factor;
365        }
366    }
367
368    /// Check if trust meets minimum thresholds
369    pub fn meets_thresholds(&self, min_scores: &[f64; T3_DIMENSIONS]) -> bool {
370        self.dimensions
371            .iter()
372            .zip(min_scores.iter())
373            .all(|(score, min)| score >= min)
374    }
375}
376
377/// A trust observation to be recorded
378#[derive(Clone, Debug, Serialize, Deserialize)]
379pub struct TrustObservation {
380    /// The observer's LCT ID
381    pub observer_id: Uuid,
382
383    /// The observed entity's LCT ID
384    pub subject_id: Uuid,
385
386    /// The dimension being observed
387    pub dimension: TrustDimension,
388
389    /// The observed score (0.0 to 1.0)
390    pub score: f64,
391
392    /// Context/reason for the observation
393    pub context: String,
394
395    /// Timestamp of the observation
396    pub timestamp: chrono::DateTime<chrono::Utc>,
397}
398
399impl TrustObservation {
400    /// Create a new trust observation
401    pub fn new(
402        observer_id: Uuid,
403        subject_id: Uuid,
404        dimension: TrustDimension,
405        score: f64,
406        context: impl Into<String>,
407    ) -> Result<Self> {
408        if !(0.0..=1.0).contains(&score) {
409            return Err(Web4Error::InvalidInput(
410                "Score must be in range [0.0, 1.0]".into(),
411            ));
412        }
413        Ok(Self {
414            observer_id,
415            subject_id,
416            dimension,
417            score,
418            context: context.into(),
419            timestamp: chrono::Utc::now(),
420        })
421    }
422}
423
424/// Trust relationship between two entities
425#[derive(Clone, Debug, Serialize, Deserialize)]
426pub struct TrustRelation {
427    /// The trusting entity's LCT ID
428    pub from_id: Uuid,
429
430    /// The trusted entity's LCT ID
431    pub to_id: Uuid,
432
433    /// The trust tensor
434    pub tensor: T3,
435
436    /// When this relation was established
437    pub established_at: chrono::DateTime<chrono::Utc>,
438
439    /// Last update timestamp
440    pub updated_at: chrono::DateTime<chrono::Utc>,
441}
442
443impl TrustRelation {
444    /// Create a new trust relation with neutral trust
445    pub fn new(from_id: Uuid, to_id: Uuid) -> Self {
446        let now = chrono::Utc::now();
447        Self {
448            from_id,
449            to_id,
450            tensor: T3::new(),
451            established_at: now,
452            updated_at: now,
453        }
454    }
455
456    /// Record an observation in this relation
457    pub fn observe(&mut self, dimension: TrustDimension, score: f64) -> Result<()> {
458        self.tensor.observe(dimension, score)?;
459        self.updated_at = chrono::Utc::now();
460        Ok(())
461    }
462
463    /// Get the aggregate trust score
464    pub fn trust_score(&self) -> f64 {
465        self.tensor.aggregate()
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn test_new_t3_is_neutral() {
475        let t3 = T3::new();
476        assert_eq!(t3.aggregate(), 0.5);
477        for dim in TrustDimension::all() {
478            assert_eq!(t3.score(dim), 0.5);
479            assert_eq!(t3.weight(dim), 0.0);
480        }
481    }
482
483    #[test]
484    fn test_observation_updates_score() {
485        let mut t3 = T3::new();
486        t3.observe(TrustDimension::Talent, 1.0).unwrap();
487
488        // Score should move toward 1.0
489        assert!(t3.score(TrustDimension::Talent) > 0.5);
490        // Weight should be non-zero
491        assert!(t3.weight(TrustDimension::Talent) > 0.0);
492    }
493
494    #[test]
495    fn test_multiple_observations_stabilize() {
496        let mut t3 = T3::new();
497
498        // Observe high talent many times
499        for _ in 0..20 {
500            t3.observe(TrustDimension::Talent, 0.9).unwrap();
501        }
502
503        // Score should approach 0.9
504        assert!(t3.score(TrustDimension::Talent) > 0.8);
505        // Weight should be high
506        assert!(t3.weight(TrustDimension::Talent) > 0.5);
507    }
508
509    #[test]
510    fn test_invalid_scores_rejected() {
511        let mut t3 = T3::new();
512        assert!(t3.observe(TrustDimension::Talent, 1.5).is_err());
513        assert!(t3.observe(TrustDimension::Talent, -0.1).is_err());
514    }
515
516    #[test]
517    fn test_decay_moves_toward_neutral() {
518        let mut t3 = T3::with_scores([0.9, 0.9, 0.9]).unwrap();
519        t3.decay(0.5);
520
521        for dim in TrustDimension::all() {
522            // Score should be closer to 0.5
523            assert!(t3.score(dim) < 0.9);
524            assert!(t3.score(dim) > 0.5);
525        }
526    }
527
528    #[test]
529    fn test_merge_combines_tensors() {
530        let mut t1 = T3::new();
531        let mut t2 = T3::new();
532
533        // t1 has many observations of high talent
534        for _ in 0..10 {
535            t1.observe(TrustDimension::Talent, 0.9).unwrap();
536        }
537
538        // t2 has fewer observations of low talent
539        for _ in 0..2 {
540            t2.observe(TrustDimension::Talent, 0.3).unwrap();
541        }
542
543        let merged = t1.merge(&t2);
544
545        // Merged should be closer to t1's score (more observations)
546        assert!(merged.score(TrustDimension::Talent) > 0.7);
547    }
548
549    #[test]
550    fn test_distance_calculation() {
551        let t1 = T3::with_scores([0.0, 0.0, 0.0]).unwrap();
552        let t2 = T3::with_scores([1.0, 1.0, 1.0]).unwrap();
553
554        let dist = t1.distance(&t2);
555        // Maximum distance in 3D unit cube
556        let expected = (3.0_f64).sqrt();
557        assert!((dist - expected).abs() < 0.001);
558    }
559
560    #[test]
561    fn test_threshold_checking() {
562        let t3 = T3::with_scores([0.8, 0.7, 0.6]).unwrap();
563
564        assert!(t3.meets_thresholds(&[0.8, 0.7, 0.6]));
565        assert!(t3.meets_thresholds(&[0.7, 0.6, 0.5]));
566        assert!(!t3.meets_thresholds(&[0.9, 0.7, 0.6]));
567    }
568
569    #[test]
570    fn test_from_parts_roundtrips_scores_and_confidence() {
571        // Build a tensor via real observations, capture its state, then
572        // reconstruct from (scores, counts) and assert exact equality of both
573        // scores and weights — this is the persistence round-trip contract.
574        let mut t3 = T3::new();
575        for _ in 0..5 {
576            t3.apply_delta(TrustDimension::Talent, 0.05);
577        }
578        t3.apply_delta(TrustDimension::Training, -0.1);
579
580        let scores = *t3.scores();
581        let counts = *t3.observation_counts();
582        let rebuilt = T3::from_parts(scores, counts);
583
584        for dim in TrustDimension::all() {
585            assert_eq!(rebuilt.score(dim), t3.score(dim));
586            assert_eq!(rebuilt.observation_counts()[dim as usize], counts[dim as usize]);
587            assert!((rebuilt.weight(dim) - t3.weight(dim)).abs() < 1e-12);
588        }
589    }
590
591    #[test]
592    fn test_from_parts_zero_counts_have_zero_weight() {
593        // Honest "unmeasured": scores set, but no observations -> zero weight.
594        let t3 = T3::from_parts([0.8, 0.75, 0.9], [0, 0, 0]);
595        for dim in TrustDimension::all() {
596            assert_eq!(t3.weight(dim), 0.0);
597        }
598        assert_eq!(t3.score(TrustDimension::Talent), 0.8);
599    }
600
601    #[test]
602    fn test_sub_dimension_observation() {
603        let mut t3 = T3::new();
604
605        // Observe a sub-dimension of Talent
606        t3.observe_sub_dimension("surgical_precision", TrustDimension::Talent, 0.9)
607            .unwrap();
608        t3.observe_sub_dimension("diagnostic_intuition", TrustDimension::Talent, 0.7)
609            .unwrap();
610
611        let subs = t3.sub_dimensions();
612        assert_eq!(subs.len(), 2);
613        assert!(subs["surgical_precision"].score > 0.5);
614        assert_eq!(subs["surgical_precision"].parent, TrustDimension::Talent);
615    }
616}