Skip to main content

converge_pack/
types.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Shared semantic value types for the public Converge contract.
5
6use serde::de;
7use serde::{Deserialize, Serialize};
8use std::borrow::Borrow;
9use std::fmt;
10use std::ops::Deref;
11
12macro_rules! string_newtype {
13    ($(#[$meta:meta])* $name:ident) => {
14        $(#[$meta])*
15        #[derive(
16            Debug,
17            Clone,
18            PartialEq,
19            Eq,
20            Hash,
21            PartialOrd,
22            Ord,
23            Serialize,
24            Deserialize,
25        )]
26        #[serde(transparent)]
27        pub struct $name(String);
28
29        impl $name {
30            /// Create a new typed string value.
31            #[must_use]
32            pub fn new(value: impl Into<String>) -> Self {
33                Self(value.into())
34            }
35
36            /// Borrow the underlying string.
37            #[must_use]
38            pub fn as_str(&self) -> &str {
39                &self.0
40            }
41        }
42
43        impl Deref for $name {
44            type Target = str;
45
46            fn deref(&self) -> &Self::Target {
47                self.as_str()
48            }
49        }
50
51        impl AsRef<str> for $name {
52            fn as_ref(&self) -> &str {
53                self.as_str()
54            }
55        }
56
57        impl Borrow<str> for $name {
58            fn borrow(&self) -> &str {
59                self.as_str()
60            }
61        }
62
63        impl fmt::Display for $name {
64            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65                f.write_str(self.as_str())
66            }
67        }
68
69        impl From<&str> for $name {
70            fn from(value: &str) -> Self {
71                Self::new(value)
72            }
73        }
74
75        impl From<String> for $name {
76            fn from(value: String) -> Self {
77                Self::new(value)
78            }
79        }
80
81        impl From<&$name> for $name {
82            fn from(value: &$name) -> Self {
83                value.clone()
84            }
85        }
86
87        impl From<$name> for String {
88            fn from(value: $name) -> Self {
89                value.0
90            }
91        }
92
93        impl From<&$name> for String {
94            fn from(value: &$name) -> Self {
95                value.as_str().to_string()
96            }
97        }
98
99        impl PartialEq<&str> for $name {
100            fn eq(&self, other: &&str) -> bool {
101                self.as_str() == *other
102            }
103        }
104
105        impl PartialEq<str> for $name {
106            fn eq(&self, other: &str) -> bool {
107                self.as_str() == other
108            }
109        }
110
111        impl PartialEq<$name> for &str {
112            fn eq(&self, other: &$name) -> bool {
113                *self == other.as_str()
114            }
115        }
116
117        impl PartialEq<$name> for str {
118            fn eq(&self, other: &$name) -> bool {
119                self == other.as_str()
120            }
121        }
122
123        impl PartialEq<String> for $name {
124            fn eq(&self, other: &String) -> bool {
125                self.as_str() == other.as_str()
126            }
127        }
128
129        impl PartialEq<$name> for String {
130            fn eq(&self, other: &$name) -> bool {
131                self.as_str() == other.as_str()
132            }
133        }
134
135        impl PartialEq<&$name> for $name {
136            fn eq(&self, other: &&$name) -> bool {
137                self == *other
138            }
139        }
140    };
141}
142
143/// Error returned when a unit interval value is outside `[0.0, 1.0]`.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct UnitIntervalError;
146
147impl fmt::Display for UnitIntervalError {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        f.write_str("value must be finite and in the inclusive range 0.0..=1.0")
150    }
151}
152
153impl std::error::Error for UnitIntervalError {}
154
155/// A finite value in the inclusive `[0.0, 1.0]` range.
156///
157/// Use this for confidence, normalized scores, prior weights, and thresholds.
158#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
159#[serde(transparent)]
160pub struct UnitInterval(f64);
161
162impl UnitInterval {
163    /// The minimum unit interval value.
164    pub const ZERO: Self = Self(0.0);
165    /// The maximum unit interval value.
166    pub const ONE: Self = Self(1.0);
167
168    /// Create a validated unit interval.
169    ///
170    /// Returns an error for NaN, infinity, or values outside `[0.0, 1.0]`.
171    pub fn new(value: f64) -> Result<Self, UnitIntervalError> {
172        if value.is_finite() && (0.0..=1.0).contains(&value) {
173            Ok(Self(value))
174        } else {
175            Err(UnitIntervalError)
176        }
177    }
178
179    /// Create a unit interval by clamping finite input.
180    ///
181    /// Non-finite values become `0.0`.
182    #[must_use]
183    pub fn clamped(value: f64) -> Self {
184        if value.is_finite() {
185            Self(value.clamp(0.0, 1.0))
186        } else {
187            Self::ZERO
188        }
189    }
190
191    /// Return the underlying `f64`.
192    #[must_use]
193    pub fn as_f64(self) -> f64 {
194        self.0
195    }
196
197    /// Add a delta and clamp the result back into the valid range.
198    #[must_use]
199    pub fn saturating_add(self, delta: f64) -> Self {
200        Self::clamped(self.0 + delta)
201    }
202
203    /// Multiply two unit interval values.
204    #[must_use]
205    pub fn scale_by(self, factor: Self) -> Self {
206        Self(self.0 * factor.0)
207    }
208
209    /// Convert to basis points, rounded to the nearest basis point.
210    #[must_use]
211    pub fn to_basis_points(self) -> u16 {
212        (self.0 * 10_000.0).round() as u16
213    }
214}
215
216impl Default for UnitInterval {
217    fn default() -> Self {
218        Self::ZERO
219    }
220}
221
222impl TryFrom<f64> for UnitInterval {
223    type Error = UnitIntervalError;
224
225    fn try_from(value: f64) -> Result<Self, Self::Error> {
226        Self::new(value)
227    }
228}
229
230impl From<UnitInterval> for f64 {
231    fn from(value: UnitInterval) -> Self {
232        value.as_f64()
233    }
234}
235
236impl<'de> Deserialize<'de> for UnitInterval {
237    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
238    where
239        D: serde::Deserializer<'de>,
240    {
241        let value = f64::deserialize(deserializer)?;
242        Self::new(value).map_err(de::Error::custom)
243    }
244}
245
246/// Error returned when a basis-point value is outside `0..=10_000`.
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248pub struct BasisPointsError;
249
250impl fmt::Display for BasisPointsError {
251    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252        f.write_str("basis points must be in the inclusive range 0..=10000")
253    }
254}
255
256impl std::error::Error for BasisPointsError {}
257
258/// A unit-range value represented as basis points (`0..=10_000`).
259#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
260#[serde(transparent)]
261pub struct BasisPoints(u16);
262
263impl BasisPoints {
264    /// Zero basis points.
265    pub const ZERO: Self = Self(0);
266    /// Ten thousand basis points, equivalent to `1.0`.
267    pub const FULL: Self = Self(10_000);
268
269    /// Create a validated basis-point value.
270    pub fn new(value: u16) -> Result<Self, BasisPointsError> {
271        if value <= 10_000 {
272            Ok(Self(value))
273        } else {
274            Err(BasisPointsError)
275        }
276    }
277
278    /// Create a basis-point value by clamping input to `0..=10_000`.
279    #[must_use]
280    pub fn clamped(value: u16) -> Self {
281        Self(value.min(10_000))
282    }
283
284    /// Return the raw basis-point value.
285    #[must_use]
286    pub fn get(self) -> u16 {
287        self.0
288    }
289
290    /// Convert to a unit interval.
291    #[must_use]
292    pub fn as_unit_interval(self) -> UnitInterval {
293        UnitInterval::clamped(f64::from(self.0) / 10_000.0)
294    }
295}
296
297impl Default for BasisPoints {
298    fn default() -> Self {
299        Self::ZERO
300    }
301}
302
303impl TryFrom<u16> for BasisPoints {
304    type Error = BasisPointsError;
305
306    fn try_from(value: u16) -> Result<Self, Self::Error> {
307        Self::new(value)
308    }
309}
310
311impl From<BasisPoints> for u16 {
312    fn from(value: BasisPoints) -> Self {
313        value.get()
314    }
315}
316
317impl<'de> Deserialize<'de> for BasisPoints {
318    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
319    where
320        D: serde::Deserializer<'de>,
321    {
322        let value = u16::deserialize(deserializer)?;
323        Self::new(value).map_err(de::Error::custom)
324    }
325}
326
327string_newtype!(
328    /// Unique identifier for a promoted fact.
329    FactId
330);
331string_newtype!(
332    /// Unique identifier for a proposal.
333    ProposalId
334);
335string_newtype!(
336    /// Unique identifier for a raw observation.
337    ObservationId
338);
339string_newtype!(
340    /// Unique identifier for a human approval.
341    ApprovalId
342);
343string_newtype!(
344    /// Unique identifier for a derived artifact.
345    ArtifactId
346);
347string_newtype!(
348    /// Unique identifier for a promotion gate.
349    GateId
350);
351string_newtype!(
352    /// Identifier for a recorded actor.
353    ActorId
354);
355string_newtype!(
356    /// Identifier for a named validation check.
357    ValidationCheckId
358);
359string_newtype!(
360    /// Identifier for a trace.
361    TraceId
362);
363string_newtype!(
364    /// Identifier for a trace span.
365    SpanId
366);
367string_newtype!(
368    /// Identifier for an external trace system.
369    TraceSystemId
370);
371string_newtype!(
372    /// Reference into an external trace system.
373    TraceReference
374);
375string_newtype!(
376    /// Identifier for a flow-gate principal.
377    PrincipalId
378);
379string_newtype!(
380    /// Identifier for an experience event envelope.
381    EventId
382);
383string_newtype!(
384    /// Identifier for a tenant scope.
385    TenantId
386);
387string_newtype!(
388    /// Identifier for correlating related events or runs.
389    CorrelationId
390);
391string_newtype!(
392    /// Identifier for a convergence chain or run.
393    ChainId
394);
395string_newtype!(
396    /// Identifier for a stored replay trace link.
397    TraceLinkId
398);
399string_newtype!(
400    /// Identifier for a backend, provider, or adapter.
401    BackendId
402);
403string_newtype!(
404    /// Identifier for a named pack.
405    PackId
406);
407string_newtype!(
408    /// Identifier for a truth definition.
409    TruthId
410);
411string_newtype!(
412    /// Identifier for a policy definition.
413    PolicyId
414);
415string_newtype!(
416    /// Identifier for an approval point or workflow reference.
417    ApprovalPointId
418);
419string_newtype!(
420    /// Identifier for an individual vote cast on a topic.
421    VoteId
422);
423string_newtype!(
424    /// Identifier for the topic a vote or disagreement is about.
425    VoteTopicId
426);
427string_newtype!(
428    /// Identifier for a recorded disagreement.
429    DisagreementId
430);
431string_newtype!(
432    /// Identifier for a success criterion.
433    CriterionId
434);
435string_newtype!(
436    /// Consumer-owned name for a constraint.
437    ConstraintName
438);
439string_newtype!(
440    /// Consumer-owned value for a constraint.
441    ConstraintValue
442);
443string_newtype!(
444    /// Identifier for a business or governance domain.
445    DomainId
446);
447string_newtype!(
448    /// Identifier for a bound policy version label.
449    PolicyVersionId
450);
451string_newtype!(
452    /// Identifier for a converging resource or flow.
453    ResourceId
454);
455string_newtype!(
456    /// Open identifier for a resource kind owned by the consumer domain.
457    ResourceKind
458);
459
460/// Content-addressable hash encoded as 32 raw bytes and serialized as hex.
461#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
462#[serde(transparent)]
463pub struct ContentHash(#[serde(with = "hex_bytes")] [u8; 32]);
464
465impl ContentHash {
466    /// Create a new content hash from raw bytes.
467    #[must_use]
468    pub fn new(bytes: [u8; 32]) -> Self {
469        Self(bytes)
470    }
471
472    /// Create a content hash from a hex string.
473    ///
474    /// # Panics
475    ///
476    /// Panics if the hex string is not exactly 64 hexadecimal characters.
477    #[must_use]
478    pub fn from_hex(hex: &str) -> Self {
479        let mut bytes = [0u8; 32];
480        hex::decode_to_slice(hex, &mut bytes).expect("invalid hex string");
481        Self(bytes)
482    }
483
484    /// Borrow the raw bytes.
485    #[must_use]
486    pub fn as_bytes(&self) -> &[u8; 32] {
487        &self.0
488    }
489
490    /// Convert to lowercase hex.
491    #[must_use]
492    pub fn to_hex(&self) -> String {
493        hex::encode(self.0)
494    }
495
496    /// The zero hash, useful for deterministic stubs.
497    #[must_use]
498    pub fn zero() -> Self {
499        Self([0u8; 32])
500    }
501}
502
503impl Default for ContentHash {
504    fn default() -> Self {
505        Self::zero()
506    }
507}
508
509impl fmt::Display for ContentHash {
510    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
511        f.write_str(&self.to_hex())
512    }
513}
514
515mod hex_bytes {
516    use serde::{Deserialize, Deserializer, Serializer};
517
518    pub fn serialize<S>(bytes: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
519    where
520        S: Serializer,
521    {
522        serializer.serialize_str(&hex::encode(bytes))
523    }
524
525    pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
526    where
527        D: Deserializer<'de>,
528    {
529        let raw = String::deserialize(deserializer)?;
530        let mut bytes = [0u8; 32];
531        hex::decode_to_slice(raw, &mut bytes).map_err(serde::de::Error::custom)?;
532        Ok(bytes)
533    }
534}
535
536/// ISO-8601 timestamp string.
537#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
538#[serde(transparent)]
539pub struct Timestamp(String);
540
541impl Timestamp {
542    /// Create a new timestamp from an ISO-8601 string.
543    #[must_use]
544    pub fn new(value: impl Into<String>) -> Self {
545        Self(value.into())
546    }
547
548    /// Borrow the timestamp string.
549    #[must_use]
550    pub fn as_str(&self) -> &str {
551        &self.0
552    }
553
554    /// The Unix epoch in ISO-8601 form.
555    #[must_use]
556    pub fn epoch() -> Self {
557        Self::new("1970-01-01T00:00:00Z")
558    }
559
560    /// A best-effort timestamp for "now".
561    #[must_use]
562    pub fn now() -> Self {
563        use std::time::{SystemTime, UNIX_EPOCH};
564
565        let duration = SystemTime::now()
566            .duration_since(UNIX_EPOCH)
567            .unwrap_or_default();
568        Self(format!("{}Z", duration.as_secs()))
569    }
570}
571
572impl Deref for Timestamp {
573    type Target = str;
574
575    fn deref(&self) -> &Self::Target {
576        self.as_str()
577    }
578}
579
580impl AsRef<str> for Timestamp {
581    fn as_ref(&self) -> &str {
582        self.as_str()
583    }
584}
585
586impl fmt::Display for Timestamp {
587    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
588        f.write_str(self.as_str())
589    }
590}
591
592impl From<&str> for Timestamp {
593    fn from(value: &str) -> Self {
594        Self::new(value)
595    }
596}
597
598impl From<String> for Timestamp {
599    fn from(value: String) -> Self {
600        Self::new(value)
601    }
602}
603
604impl From<Timestamp> for String {
605    fn from(value: Timestamp) -> Self {
606        value.0
607    }
608}
609
610impl From<&Timestamp> for String {
611    fn from(value: &Timestamp) -> Self {
612        value.as_str().to_string()
613    }
614}
615
616impl PartialEq<&str> for Timestamp {
617    fn eq(&self, other: &&str) -> bool {
618        self.as_str() == *other
619    }
620}
621
622impl PartialEq<str> for Timestamp {
623    fn eq(&self, other: &str) -> bool {
624        self.as_str() == other
625    }
626}
627
628impl PartialEq<Timestamp> for &str {
629    fn eq(&self, other: &Timestamp) -> bool {
630        *self == other.as_str()
631    }
632}
633
634impl PartialEq<Timestamp> for str {
635    fn eq(&self, other: &Timestamp) -> bool {
636        self == other.as_str()
637    }
638}
639
640impl PartialEq<String> for Timestamp {
641    fn eq(&self, other: &String) -> bool {
642        self.as_str() == other.as_str()
643    }
644}
645
646impl PartialEq<Timestamp> for String {
647    fn eq(&self, other: &Timestamp) -> bool {
648        self.as_str() == other.as_str()
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    #[test]
657    fn string_newtypes_compare_like_strings_without_erasing_type_identity() {
658        let fact_id = FactId::new("fact-1");
659        let proposal_id = ProposalId::new("fact-1");
660
661        assert_eq!(fact_id, "fact-1");
662        assert_eq!("fact-1", fact_id);
663        assert_ne!(fact_id.to_string(), "");
664        assert_ne!(fact_id.as_str(), "");
665        assert_eq!(proposal_id.as_str(), "fact-1");
666    }
667
668    #[test]
669    fn content_hash_hex_roundtrip() {
670        let hash = ContentHash::from_hex(
671            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
672        );
673        assert_eq!(
674            hash.to_hex(),
675            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
676        );
677    }
678
679    #[test]
680    fn unit_interval_accepts_only_finite_closed_range_values() {
681        assert_eq!(UnitInterval::new(0.0).unwrap().as_f64(), 0.0);
682        assert_eq!(UnitInterval::new(1.0).unwrap().as_f64(), 1.0);
683        assert!(UnitInterval::new(-0.1).is_err());
684        assert!(UnitInterval::new(1.1).is_err());
685        assert!(UnitInterval::new(f64::NAN).is_err());
686    }
687
688    #[test]
689    fn unit_interval_deserialization_rejects_out_of_range_values() {
690        assert!(serde_json::from_str::<UnitInterval>("0.75").is_ok());
691        assert!(serde_json::from_str::<UnitInterval>("1.75").is_err());
692    }
693
694    #[test]
695    fn basis_points_accepts_only_unit_range_basis_points() {
696        assert_eq!(BasisPoints::new(0).unwrap().get(), 0);
697        assert_eq!(BasisPoints::new(10_000).unwrap().get(), 10_000);
698        assert!(BasisPoints::new(10_001).is_err());
699        assert_eq!(BasisPoints::clamped(20_000).get(), 10_000);
700    }
701
702    #[test]
703    fn timestamp_is_transparent() {
704        let timestamp = Timestamp::epoch();
705        let json = serde_json::to_string(&timestamp).expect("timestamp should serialize");
706        assert_eq!(json, r#""1970-01-01T00:00:00Z""#);
707    }
708}