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/// Timestamp string used at Converge boundaries.
537///
538/// Runtime-visible timestamps may be wall-clock strings supplied by a host or
539/// logical Lamport clock stamps produced by the kernel. Core deterministic
540/// promotion paths use Lamport stamps instead of reading wall-clock time.
541#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
542#[serde(transparent)]
543pub struct Timestamp(String);
544
545impl Timestamp {
546    /// Create a new timestamp from an already formatted string.
547    #[must_use]
548    pub fn new(value: impl Into<String>) -> Self {
549        Self(value.into())
550    }
551
552    /// Borrow the timestamp string.
553    #[must_use]
554    pub fn as_str(&self) -> &str {
555        &self.0
556    }
557
558    /// The Unix epoch in ISO-8601 form.
559    #[must_use]
560    pub fn epoch() -> Self {
561        Self::new("1970-01-01T00:00:00Z")
562    }
563
564    /// Create a deterministic timestamp from Lamport logical time.
565    #[must_use]
566    pub fn lamport(time: u64) -> Self {
567        Self(format!("lamport:{time}"))
568    }
569
570    /// A deterministic zero logical timestamp.
571    ///
572    /// Hosts that need wall-clock timestamps should construct them explicitly at
573    /// the runtime boundary rather than letting core code read a hidden clock.
574    #[must_use]
575    pub fn now() -> Self {
576        Self::lamport(0)
577    }
578}
579
580impl Deref for Timestamp {
581    type Target = str;
582
583    fn deref(&self) -> &Self::Target {
584        self.as_str()
585    }
586}
587
588impl AsRef<str> for Timestamp {
589    fn as_ref(&self) -> &str {
590        self.as_str()
591    }
592}
593
594impl fmt::Display for Timestamp {
595    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
596        f.write_str(self.as_str())
597    }
598}
599
600impl From<&str> for Timestamp {
601    fn from(value: &str) -> Self {
602        Self::new(value)
603    }
604}
605
606impl From<String> for Timestamp {
607    fn from(value: String) -> Self {
608        Self::new(value)
609    }
610}
611
612impl From<Timestamp> for String {
613    fn from(value: Timestamp) -> Self {
614        value.0
615    }
616}
617
618impl From<&Timestamp> for String {
619    fn from(value: &Timestamp) -> Self {
620        value.as_str().to_string()
621    }
622}
623
624impl PartialEq<&str> for Timestamp {
625    fn eq(&self, other: &&str) -> bool {
626        self.as_str() == *other
627    }
628}
629
630impl PartialEq<str> for Timestamp {
631    fn eq(&self, other: &str) -> bool {
632        self.as_str() == other
633    }
634}
635
636impl PartialEq<Timestamp> for &str {
637    fn eq(&self, other: &Timestamp) -> bool {
638        *self == other.as_str()
639    }
640}
641
642impl PartialEq<Timestamp> for str {
643    fn eq(&self, other: &Timestamp) -> bool {
644        self == other.as_str()
645    }
646}
647
648impl PartialEq<String> for Timestamp {
649    fn eq(&self, other: &String) -> bool {
650        self.as_str() == other.as_str()
651    }
652}
653
654impl PartialEq<Timestamp> for String {
655    fn eq(&self, other: &Timestamp) -> bool {
656        self.as_str() == other.as_str()
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    #[test]
665    fn string_newtypes_compare_like_strings_without_erasing_type_identity() {
666        let fact_id = FactId::new("fact-1");
667        let proposal_id = ProposalId::new("fact-1");
668
669        assert_eq!(fact_id, "fact-1");
670        assert_eq!("fact-1", fact_id);
671        assert_ne!(fact_id.to_string(), "");
672        assert_ne!(fact_id.as_str(), "");
673        assert_eq!(proposal_id.as_str(), "fact-1");
674    }
675
676    #[test]
677    fn content_hash_hex_roundtrip() {
678        let hash = ContentHash::from_hex(
679            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
680        );
681        assert_eq!(
682            hash.to_hex(),
683            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
684        );
685    }
686
687    #[test]
688    fn timestamp_now_is_deterministic_logical_zero() {
689        assert_eq!(Timestamp::now().as_str(), "lamport:0");
690    }
691
692    #[test]
693    fn timestamp_can_represent_lamport_time() {
694        assert_eq!(Timestamp::lamport(42).as_str(), "lamport:42");
695    }
696
697    #[test]
698    fn unit_interval_accepts_only_finite_closed_range_values() {
699        assert_eq!(UnitInterval::new(0.0).unwrap().as_f64(), 0.0);
700        assert_eq!(UnitInterval::new(1.0).unwrap().as_f64(), 1.0);
701        assert!(UnitInterval::new(-0.1).is_err());
702        assert!(UnitInterval::new(1.1).is_err());
703        assert!(UnitInterval::new(f64::NAN).is_err());
704    }
705
706    #[test]
707    fn unit_interval_deserialization_rejects_out_of_range_values() {
708        assert!(serde_json::from_str::<UnitInterval>("0.75").is_ok());
709        assert!(serde_json::from_str::<UnitInterval>("1.75").is_err());
710    }
711
712    #[test]
713    fn basis_points_accepts_only_unit_range_basis_points() {
714        assert_eq!(BasisPoints::new(0).unwrap().get(), 0);
715        assert_eq!(BasisPoints::new(10_000).unwrap().get(), 10_000);
716        assert!(BasisPoints::new(10_001).is_err());
717        assert_eq!(BasisPoints::clamped(20_000).get(), 10_000);
718    }
719
720    #[test]
721    fn timestamp_is_transparent() {
722        let timestamp = Timestamp::epoch();
723        let json = serde_json::to_string(&timestamp).expect("timestamp should serialize");
724        assert_eq!(json, r#""1970-01-01T00:00:00Z""#);
725    }
726}