Skip to main content

asupersync/types/
id.rs

1//! Identifier types for runtime entities.
2//!
3//! These types provide type-safe identifiers for the core runtime entities:
4//! regions, tasks, and obligations. They wrap arena indices with type safety.
5
6use crate::util::ArenaIndex;
7use core::fmt;
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use std::ops::Add;
10use std::sync::atomic::{AtomicU32, Ordering};
11use std::time::Duration;
12
13/// br-asupersync-u3gsst — Process-global ephemeral counters.
14///
15/// These back the test/test-internals-gated `new_ephemeral` constructors
16/// and the runtime-internal `next_bootstrap_*` helpers used during root-Cx
17/// boot in `app.rs`. They are NOT a substitute for runtime-allocated IDs
18/// produced by `Arena::insert`; those are the only IDs that appear in
19/// per-runtime-state structures, get registered with the scheduler, and
20/// participate in deterministic replay.
21static EPHEMERAL_REGION_COUNTER: AtomicU32 = AtomicU32::new(1);
22static EPHEMERAL_TASK_COUNTER: AtomicU32 = AtomicU32::new(1);
23
24/// br-asupersync-u3gsst — Mint a new bootstrap RegionId outside the
25/// runtime's arena. **Crate-internal only**; intended for the single
26/// production call-site in `app.rs::build_app_root_cx` that needs an ID
27/// before the runtime has registered the root region. All other
28/// production paths must use the runtime-allocated ID returned by
29/// `Arena::insert`.
30#[inline]
31#[must_use]
32pub(crate) fn next_bootstrap_region_id() -> RegionId {
33    let index = EPHEMERAL_REGION_COUNTER.fetch_add(1, Ordering::Relaxed);
34    RegionId(ArenaIndex::new(index, 1))
35}
36
37/// br-asupersync-u3gsst — Mint a new bootstrap TaskId outside the
38/// runtime's arena. Same contract as `next_bootstrap_region_id`.
39#[inline]
40#[must_use]
41pub(crate) fn next_bootstrap_task_id() -> TaskId {
42    let index = EPHEMERAL_TASK_COUNTER.fetch_add(1, Ordering::Relaxed);
43    TaskId(ArenaIndex::new(index, 1))
44}
45
46/// A unique identifier for a region in the runtime.
47///
48/// Regions form a tree structure and own all work spawned within them.
49#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
50pub struct RegionId(pub(crate) ArenaIndex);
51
52impl RegionId {
53    /// Creates a new region ID from an arena index (internal use).
54    #[inline]
55    #[must_use]
56    #[cfg_attr(feature = "test-internals", visibility::make(pub))]
57    pub(crate) const fn from_arena(index: ArenaIndex) -> Self {
58        Self(index)
59    }
60
61    /// Returns a 64-bit integer representation of this RegionId.
62    #[inline]
63    #[must_use]
64    pub fn as_u64(&self) -> u64 {
65        ((self.0.generation() as u64) << 32) | (self.0.index() as u64)
66    }
67
68    /// Returns the underlying arena index (internal use).
69    #[inline]
70    #[must_use]
71    #[allow(dead_code)]
72    #[cfg(not(feature = "test-internals"))]
73    pub(crate) const fn arena_index(self) -> ArenaIndex {
74        self.0
75    }
76
77    /// Returns the underlying arena index (internal use).
78    #[inline]
79    #[must_use]
80    #[allow(dead_code)]
81    #[cfg(feature = "test-internals")]
82    pub const fn arena_index(self) -> ArenaIndex {
83        self.0
84    }
85
86    /// Creates a region ID for testing/benchmarking purposes.
87    ///
88    /// br-asupersync-bm08jx: gated behind
89    /// `cfg(any(test, feature = "test-internals"))` to prevent
90    /// downstream production crates from forging RegionIds that
91    /// match arbitrary runtime allocations. Pre-fix this was a
92    /// fully-public `pub const` constructor (only `#[doc(hidden)]`
93    /// for diagnostic discretion), so any external crate could mint
94    /// a `RegionId` with arbitrary `index`/`generation` and feed it
95    /// to runtime APIs that trust the ID shape — same threat model
96    /// as the closed asupersync-aog0xz / asupersync-wm9h2a /
97    /// asupersync-ovztin fixes for similar test-only constructors.
98    ///
99    /// Default-feature builds still see this constructor (the
100    /// `test-internals` feature is in the default set, so existing
101    /// test code keeps compiling); production crates that opt out via
102    /// `default-features = false` lose access entirely.
103    #[doc(hidden)]
104    #[cfg(any(test, feature = "test-internals"))]
105    #[inline]
106    #[must_use]
107    pub const fn new_for_test(index: u32, generation: u32) -> Self {
108        Self(ArenaIndex::new(index, generation))
109    }
110
111    /// Creates a default region ID for testing purposes.
112    ///
113    /// This creates an ID with index 0 and generation 0, suitable for
114    /// unit tests that don't care about specific ID values.
115    #[doc(hidden)]
116    #[inline]
117    #[must_use]
118    pub const fn testing_default() -> Self {
119        Self(ArenaIndex::new(0, 0))
120    }
121
122    /// Creates a new ephemeral region ID outside the runtime arena.
123    ///
124    /// br-asupersync-u3gsst — **Test / test-internals only.** Production
125    /// regions MUST be allocated by the runtime via
126    /// [`crate::runtime::RuntimeState`] so the resulting `RegionId`
127    /// appears in the region table, the lock-ordering invariants hold,
128    /// and deterministic replay through [`crate::lab::LabRuntime`]
129    /// observes the same IDs across runs. This constructor uses a
130    /// process-global atomic counter and therefore breaks both
131    /// invariants when called from production code; it is gated to
132    /// `cfg(any(test, feature = "test-internals"))`. The single
133    /// runtime-internal bootstrap call in `app.rs` uses the
134    /// `pub(crate)` [`next_bootstrap_region_id`] instead.
135    #[doc(hidden)]
136    #[cfg(any(test, feature = "test-internals"))]
137    #[inline]
138    #[must_use]
139    pub fn new_ephemeral() -> Self {
140        next_bootstrap_region_id()
141    }
142}
143
144impl fmt::Debug for RegionId {
145    #[inline]
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        write!(f, "RegionId({}:{})", self.0.index(), self.0.generation())
148    }
149}
150
151impl fmt::Display for RegionId {
152    #[inline]
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        write!(f, "R{}", self.0.index())
155    }
156}
157
158/// br-asupersync-o2oa4l — Pre-shared per-type discriminant strings.
159///
160/// `RegionId`, `TaskId`, `ObligationId`, and `DecisionId` previously
161/// shared a single `SerdeArenaIndex { index, generation }` wire shape
162/// — every (index, generation) tuple deserialised equally well as
163/// any of the four. An attacker submitting a snapshot or a peer
164/// transmitting a trace artifact could swap an ID across the type
165/// boundary by relabelling the JSON / MessagePack key, and the
166/// deserialiser would have no way to reject the cross-type
167/// confusion.
168///
169/// The new wire shape `SerdeIdEnvelope { kind, index, generation }`
170/// stamps a stable per-type tag on serialise; deserialise verifies
171/// the tag matches the target type and rejects with
172/// `serde::de::Error::custom` otherwise. The four constants below
173/// are the canonical tag values; they are stable across versions and
174/// MUST NOT be reused for any other type without coordinating a
175/// schema-version bump.
176const KIND_REGION_ID: &str = "RegionId";
177const KIND_TASK_ID: &str = "TaskId";
178const KIND_OBLIGATION_ID: &str = "ObligationId";
179// Note: DecisionId lives in `franken_kernel` and serialises as a hex
180// u128 — its wire shape is already distinct from the SerdeArenaIndex
181// triple here, so it does not need a discriminant tag.
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
184struct SerdeIdEnvelope {
185    kind: String,
186    index: u32,
187    generation: u32,
188}
189
190impl SerdeIdEnvelope {
191    #[inline]
192    fn from_arena(arena: ArenaIndex, kind: &'static str) -> Self {
193        Self {
194            kind: kind.to_string(),
195            index: arena.index(),
196            generation: arena.generation(),
197        }
198    }
199
200    #[inline]
201    fn to_arena(&self) -> ArenaIndex {
202        ArenaIndex::new(self.index, self.generation)
203    }
204
205    #[inline]
206    fn check_kind<E>(&self, expected: &'static str) -> Result<(), E>
207    where
208        E: serde::de::Error,
209    {
210        if self.kind == expected {
211            Ok(())
212        } else {
213            Err(E::custom(format!(
214                "br-asupersync-o2oa4l: ID kind mismatch — expected {expected:?}, got {:?}",
215                self.kind
216            )))
217        }
218    }
219}
220
221impl Serialize for RegionId {
222    #[inline]
223    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
224    where
225        S: Serializer,
226    {
227        SerdeIdEnvelope::from_arena(self.0, KIND_REGION_ID).serialize(serializer)
228    }
229}
230
231impl<'de> Deserialize<'de> for RegionId {
232    #[inline]
233    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
234    where
235        D: Deserializer<'de>,
236    {
237        let env = SerdeIdEnvelope::deserialize(deserializer)?;
238        env.check_kind::<D::Error>(KIND_REGION_ID)?;
239        Ok(Self(env.to_arena()))
240    }
241}
242
243/// A unique identifier for a task in the runtime.
244///
245/// Tasks are units of concurrent execution owned by regions.
246#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
247pub struct TaskId(pub(crate) ArenaIndex);
248
249impl TaskId {
250    /// Creates a new task ID from an arena index (internal use).
251    #[inline]
252    #[must_use]
253    #[allow(dead_code)]
254    #[cfg_attr(feature = "test-internals", visibility::make(pub))]
255    pub(crate) const fn from_arena(index: ArenaIndex) -> Self {
256        Self(index)
257    }
258
259    /// Returns a 64-bit integer representation of this `TaskId`.
260    #[inline]
261    #[must_use]
262    pub fn as_u64(&self) -> u64 {
263        ((self.0.generation() as u64) << 32) | (self.0.index() as u64)
264    }
265
266    /// Returns the underlying arena index (internal use).
267    #[inline]
268    #[must_use]
269    #[allow(dead_code)]
270    #[cfg(not(feature = "test-internals"))]
271    pub(crate) const fn arena_index(self) -> ArenaIndex {
272        self.0
273    }
274
275    /// Returns the underlying arena index (internal use).
276    #[inline]
277    #[must_use]
278    #[allow(dead_code)]
279    #[cfg(feature = "test-internals")]
280    pub const fn arena_index(self) -> ArenaIndex {
281        self.0
282    }
283
284    /// Creates a task ID for testing/benchmarking purposes.
285    ///
286    /// br-asupersync-bm08jx: gated behind
287    /// `cfg(any(test, feature = "test-internals"))` — same rationale
288    /// as [`RegionId::new_for_test`]. Production crates that disable
289    /// `test-internals` lose access; the runtime's task arena is the
290    /// only supported source of `TaskId`s.
291    #[doc(hidden)]
292    #[cfg(any(test, feature = "test-internals"))]
293    #[inline]
294    #[must_use]
295    pub const fn new_for_test(index: u32, generation: u32) -> Self {
296        Self(ArenaIndex::new(index, generation))
297    }
298
299    /// Creates a default task ID for testing purposes.
300    ///
301    /// This creates an ID with index 0 and generation 0, suitable for
302    /// unit tests that don't care about specific ID values.
303    #[doc(hidden)]
304    #[inline]
305    #[must_use]
306    pub const fn testing_default() -> Self {
307        Self(ArenaIndex::new(0, 0))
308    }
309
310    /// Creates a new ephemeral task ID outside the runtime arena.
311    ///
312    /// br-asupersync-u3gsst — **Test / test-internals only.** See
313    /// [`RegionId::new_ephemeral`] for the rationale: production task
314    /// IDs MUST come from the runtime's task arena. Gated to
315    /// `cfg(any(test, feature = "test-internals"))`. The single
316    /// runtime-internal bootstrap call in `app.rs` uses the
317    /// `pub(crate)` [`next_bootstrap_task_id`] instead.
318    #[doc(hidden)]
319    #[cfg(any(test, feature = "test-internals"))]
320    #[inline]
321    #[must_use]
322    pub fn new_ephemeral() -> Self {
323        next_bootstrap_task_id()
324    }
325}
326
327impl fmt::Debug for TaskId {
328    #[inline]
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        write!(f, "TaskId({}:{})", self.0.index(), self.0.generation())
331    }
332}
333
334impl fmt::Display for TaskId {
335    #[inline]
336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337        write!(f, "T{}", self.0.index())
338    }
339}
340
341impl Serialize for TaskId {
342    #[inline]
343    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
344    where
345        S: Serializer,
346    {
347        SerdeIdEnvelope::from_arena(self.0, KIND_TASK_ID).serialize(serializer)
348    }
349}
350
351impl<'de> Deserialize<'de> for TaskId {
352    #[inline]
353    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
354    where
355        D: Deserializer<'de>,
356    {
357        let env = SerdeIdEnvelope::deserialize(deserializer)?;
358        env.check_kind::<D::Error>(KIND_TASK_ID)?;
359        Ok(Self(env.to_arena()))
360    }
361}
362
363/// A unique identifier for an obligation in the runtime.
364///
365/// Obligations represent resources that must be resolved (commit, abort, ack, etc.)
366/// before their owning region can close.
367#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
368pub struct ObligationId(pub(crate) ArenaIndex);
369
370impl ObligationId {
371    /// Creates a new obligation ID from an arena index (internal use).
372    #[inline]
373    #[must_use]
374    #[allow(dead_code)]
375    pub(crate) const fn from_arena(index: ArenaIndex) -> Self {
376        Self(index)
377    }
378
379    /// Returns a 64-bit integer representation of this `ObligationId`,
380    /// suitable for hashing, sorting, and trace identity. Parity with
381    /// `RegionId::as_u64` and `TaskId::as_u64`.
382    #[inline]
383    #[must_use]
384    pub fn as_u64(&self) -> u64 {
385        ((self.0.generation() as u64) << 32) | (self.0.index() as u64)
386    }
387
388    /// Returns the underlying arena index (internal use).
389    #[inline]
390    #[must_use]
391    #[allow(dead_code)]
392    #[cfg(not(feature = "test-internals"))]
393    pub(crate) const fn arena_index(self) -> ArenaIndex {
394        self.0
395    }
396
397    /// Returns the underlying arena index (internal use).
398    #[inline]
399    #[must_use]
400    #[allow(dead_code)]
401    #[cfg(feature = "test-internals")]
402    pub const fn arena_index(self) -> ArenaIndex {
403        self.0
404    }
405
406    /// Creates an obligation ID for testing/benchmarking purposes.
407    ///
408    /// br-asupersync-bm08jx: gated behind
409    /// `cfg(any(test, feature = "test-internals"))` — same rationale
410    /// as [`RegionId::new_for_test`]. Production crates that disable
411    /// `test-internals` cannot forge `ObligationId`s; the runtime's
412    /// obligation table is the only supported source.
413    #[doc(hidden)]
414    #[cfg(any(test, feature = "test-internals"))]
415    #[inline]
416    #[must_use]
417    pub const fn new_for_test(index: u32, generation: u32) -> Self {
418        Self(ArenaIndex::new(index, generation))
419    }
420}
421
422impl fmt::Debug for ObligationId {
423    #[inline]
424    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
425        write!(
426            f,
427            "ObligationId({}:{})",
428            self.0.index(),
429            self.0.generation()
430        )
431    }
432}
433
434impl fmt::Display for ObligationId {
435    #[inline]
436    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
437        write!(f, "O{}", self.0.index())
438    }
439}
440
441impl Serialize for ObligationId {
442    #[inline]
443    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
444    where
445        S: Serializer,
446    {
447        SerdeIdEnvelope::from_arena(self.0, KIND_OBLIGATION_ID).serialize(serializer)
448    }
449}
450
451impl<'de> Deserialize<'de> for ObligationId {
452    #[inline]
453    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
454    where
455        D: Deserializer<'de>,
456    {
457        let env = SerdeIdEnvelope::deserialize(deserializer)?;
458        env.check_kind::<D::Error>(KIND_OBLIGATION_ID)?;
459        Ok(Self(env.to_arena()))
460    }
461}
462
463/// A logical timestamp for the runtime.
464///
465/// In the production runtime, this corresponds to wall-clock time.
466/// In the lab runtime, this is virtual time controlled by the scheduler.
467#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)]
468pub struct Time(u64);
469
470impl Time {
471    /// The zero instant (epoch).
472    pub const ZERO: Self = Self(0);
473
474    /// The maximum representable instant.
475    pub const MAX: Self = Self(u64::MAX);
476
477    /// Creates a new time from nanoseconds since epoch.
478    #[inline]
479    #[must_use]
480    pub const fn from_nanos(nanos: u64) -> Self {
481        Self(nanos)
482    }
483
484    /// Creates a new time from milliseconds since epoch.
485    #[inline]
486    #[must_use]
487    pub const fn from_millis(millis: u64) -> Self {
488        Self(millis.saturating_mul(1_000_000))
489    }
490
491    /// Creates a new time from seconds since epoch.
492    #[inline]
493    #[must_use]
494    pub const fn from_secs(secs: u64) -> Self {
495        Self(secs.saturating_mul(1_000_000_000))
496    }
497
498    /// Returns the time as nanoseconds since epoch.
499    #[inline]
500    #[must_use]
501    pub const fn as_nanos(self) -> u64 {
502        self.0
503    }
504
505    /// Returns the time as milliseconds since epoch (truncated).
506    #[inline]
507    #[must_use]
508    pub const fn as_millis(self) -> u64 {
509        self.0 / 1_000_000
510    }
511
512    /// Returns the time as seconds since epoch (truncated).
513    #[inline]
514    #[must_use]
515    pub const fn as_secs(self) -> u64 {
516        self.0 / 1_000_000_000
517    }
518
519    /// Adds a duration in nanoseconds, saturating on overflow.
520    #[inline]
521    #[must_use]
522    pub const fn saturating_add_nanos(self, nanos: u64) -> Self {
523        Self(self.0.saturating_add(nanos))
524    }
525
526    /// Subtracts a duration in nanoseconds, saturating at zero.
527    #[inline]
528    #[must_use]
529    pub const fn saturating_sub_nanos(self, nanos: u64) -> Self {
530        Self(self.0.saturating_sub(nanos))
531    }
532
533    /// Returns the duration between two times in nanoseconds.
534    ///
535    /// Returns 0 if `self` is before `earlier` (time travel protection).
536    /// This method uses saturating arithmetic to prevent overflow.
537    #[inline]
538    #[must_use]
539    pub const fn duration_since(self, earlier: Self) -> u64 {
540        self.0.saturating_sub(earlier.0)
541    }
542}
543
544impl Add<Duration> for Time {
545    type Output = Self;
546
547    #[inline]
548    fn add(self, rhs: Duration) -> Self::Output {
549        let nanos: u64 = rhs.as_nanos().min(u128::from(u64::MAX)) as u64;
550        self.saturating_add_nanos(nanos)
551    }
552}
553
554impl fmt::Debug for Time {
555    #[inline]
556    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
557        write!(f, "Time({}ns)", self.0)
558    }
559}
560
561impl fmt::Display for Time {
562    #[inline]
563    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
564        if self.0 >= 1_000_000_000 {
565            write!(
566                f,
567                "{}.{:03}s",
568                self.0 / 1_000_000_000,
569                (self.0 / 1_000_000) % 1000
570            )
571        } else if self.0 >= 1_000_000 {
572            write!(f, "{}ms", self.0 / 1_000_000)
573        } else if self.0 >= 1_000 {
574            write!(f, "{}us", self.0 / 1_000)
575        } else {
576            write!(f, "{}ns", self.0)
577        }
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    #![allow(
584        clippy::pedantic,
585        clippy::nursery,
586        clippy::expect_fun_call,
587        clippy::map_unwrap_or,
588        clippy::cast_possible_wrap,
589        clippy::future_not_send
590    )]
591    use super::*;
592
593    #[test]
594    fn time_conversions() {
595        assert_eq!(Time::from_secs(1).as_nanos(), 1_000_000_000);
596        assert_eq!(Time::from_millis(1).as_nanos(), 1_000_000);
597        assert_eq!(Time::from_nanos(1).as_nanos(), 1);
598
599        assert_eq!(Time::from_nanos(1_500_000_000).as_secs(), 1);
600        assert_eq!(Time::from_nanos(1_500_000_000).as_millis(), 1500);
601    }
602
603    #[test]
604    fn time_arithmetic() {
605        let t1 = Time::from_secs(1);
606        let t2 = t1.saturating_add_nanos(500_000_000);
607        assert_eq!(t2.as_millis(), 1500);
608
609        let t3 = t2.saturating_sub_nanos(2_000_000_000);
610        assert_eq!(t3, Time::ZERO);
611    }
612
613    #[test]
614    fn time_ordering() {
615        assert!(Time::from_secs(1) < Time::from_secs(2));
616        assert!(Time::from_millis(1000) == Time::from_secs(1));
617    }
618
619    // ---- RegionId ----
620
621    #[test]
622    fn region_id_debug_format() {
623        let id = RegionId::new_for_test(5, 3);
624        let dbg = format!("{id:?}");
625        assert!(dbg.contains("RegionId"), "{dbg}");
626        assert!(dbg.contains('5'), "{dbg}");
627        assert!(dbg.contains('3'), "{dbg}");
628    }
629
630    #[test]
631    fn region_id_display_format() {
632        let id = RegionId::new_for_test(42, 0);
633        assert_eq!(format!("{id}"), "R42");
634    }
635
636    #[test]
637    fn region_id_equality_and_hash() {
638        use crate::util::DetHasher;
639        use std::hash::{Hash, Hasher};
640
641        let a = RegionId::new_for_test(1, 2);
642        let b = RegionId::new_for_test(1, 2);
643        let c = RegionId::new_for_test(1, 3);
644
645        assert_eq!(a, b);
646        assert_ne!(a, c);
647
648        let mut ha = DetHasher::default();
649        let mut hb = DetHasher::default();
650        a.hash(&mut ha);
651        b.hash(&mut hb);
652        assert_eq!(ha.finish(), hb.finish());
653    }
654
655    #[test]
656    fn region_id_ordering() {
657        let a = RegionId::new_for_test(1, 0);
658        let b = RegionId::new_for_test(2, 0);
659        assert!(a < b);
660        assert!(a <= b);
661        assert!(b > a);
662    }
663
664    #[test]
665    fn region_id_copy_clone() {
666        let id = RegionId::new_for_test(1, 0);
667        let copied = id;
668        let cloned = id;
669        assert_eq!(id, copied);
670        assert_eq!(id, cloned);
671    }
672
673    #[test]
674    fn region_id_testing_default() {
675        let id = RegionId::testing_default();
676        assert_eq!(format!("{id}"), "R0");
677    }
678
679    #[test]
680    fn region_id_ephemeral_unique() {
681        let a = RegionId::new_ephemeral();
682        let b = RegionId::new_ephemeral();
683        assert_ne!(a, b);
684    }
685
686    #[test]
687    fn region_id_serde_roundtrip() {
688        let id = RegionId::new_for_test(99, 7);
689        let json = serde_json::to_string(&id).expect("serialize");
690        let deserialized: RegionId = serde_json::from_str(&json).expect("deserialize");
691        assert_eq!(id, deserialized);
692    }
693
694    // ---- TaskId ----
695
696    #[test]
697    fn task_id_debug_format() {
698        let id = TaskId::new_for_test(10, 2);
699        let dbg = format!("{id:?}");
700        assert!(dbg.contains("TaskId"), "{dbg}");
701        assert!(dbg.contains("10"), "{dbg}");
702        assert!(dbg.contains('2'), "{dbg}");
703    }
704
705    #[test]
706    fn task_id_display_format() {
707        let id = TaskId::new_for_test(7, 0);
708        assert_eq!(format!("{id}"), "T7");
709    }
710
711    #[test]
712    fn task_id_equality_and_hash() {
713        use crate::util::DetHasher;
714        use std::hash::{Hash, Hasher};
715
716        let a = TaskId::new_for_test(3, 1);
717        let b = TaskId::new_for_test(3, 1);
718        let c = TaskId::new_for_test(3, 2);
719
720        assert_eq!(a, b);
721        assert_ne!(a, c);
722
723        let mut ha = DetHasher::default();
724        let mut hb = DetHasher::default();
725        a.hash(&mut ha);
726        b.hash(&mut hb);
727        assert_eq!(ha.finish(), hb.finish());
728    }
729
730    #[test]
731    fn task_id_ordering() {
732        let a = TaskId::new_for_test(1, 0);
733        let b = TaskId::new_for_test(2, 0);
734        assert!(a < b);
735    }
736
737    #[test]
738    fn task_id_copy_clone() {
739        let id = TaskId::new_for_test(5, 1);
740        let copied = id;
741        let cloned = id;
742        assert_eq!(id, copied);
743        assert_eq!(id, cloned);
744    }
745
746    #[test]
747    fn task_id_testing_default() {
748        let id = TaskId::testing_default();
749        assert_eq!(format!("{id}"), "T0");
750    }
751
752    #[test]
753    fn task_id_ephemeral_unique() {
754        let a = TaskId::new_ephemeral();
755        let b = TaskId::new_ephemeral();
756        assert_ne!(a, b);
757    }
758
759    #[test]
760    fn task_id_serde_roundtrip() {
761        let id = TaskId::new_for_test(42, 5);
762        let json = serde_json::to_string(&id).expect("serialize");
763        let deserialized: TaskId = serde_json::from_str(&json).expect("deserialize");
764        assert_eq!(id, deserialized);
765    }
766
767    // ---- ObligationId ----
768
769    #[test]
770    fn obligation_id_debug_format() {
771        let id = ObligationId::new_for_test(8, 1);
772        let dbg = format!("{id:?}");
773        assert!(dbg.contains("ObligationId"), "{dbg}");
774        assert!(dbg.contains('8'), "{dbg}");
775    }
776
777    #[test]
778    fn obligation_id_display_format() {
779        let id = ObligationId::new_for_test(3, 0);
780        assert_eq!(format!("{id}"), "O3");
781    }
782
783    #[test]
784    fn obligation_id_equality_and_hash() {
785        use crate::util::DetHasher;
786        use std::hash::{Hash, Hasher};
787
788        let a = ObligationId::new_for_test(1, 1);
789        let b = ObligationId::new_for_test(1, 1);
790        let c = ObligationId::new_for_test(2, 1);
791
792        assert_eq!(a, b);
793        assert_ne!(a, c);
794
795        let mut ha = DetHasher::default();
796        let mut hb = DetHasher::default();
797        a.hash(&mut ha);
798        b.hash(&mut hb);
799        assert_eq!(ha.finish(), hb.finish());
800    }
801
802    #[test]
803    fn obligation_id_ordering() {
804        let a = ObligationId::new_for_test(1, 0);
805        let b = ObligationId::new_for_test(2, 0);
806        assert!(a < b);
807    }
808
809    #[test]
810    fn obligation_id_copy_clone() {
811        let id = ObligationId::new_for_test(1, 0);
812        let copied = id;
813        let cloned = id;
814        assert_eq!(id, copied);
815        assert_eq!(id, cloned);
816    }
817
818    #[test]
819    fn obligation_id_serde_roundtrip() {
820        let id = ObligationId::new_for_test(77, 3);
821        let json = serde_json::to_string(&id).expect("serialize");
822        let deserialized: ObligationId = serde_json::from_str(&json).expect("deserialize");
823        assert_eq!(id, deserialized);
824    }
825
826    // ---- Time Display ----
827
828    #[test]
829    fn time_display_seconds() {
830        let t = Time::from_secs(2);
831        let disp = format!("{t}");
832        assert_eq!(disp, "2.000s");
833    }
834
835    #[test]
836    fn time_display_seconds_with_millis() {
837        let t = Time::from_nanos(1_234_000_000);
838        let disp = format!("{t}");
839        assert_eq!(disp, "1.234s");
840    }
841
842    #[test]
843    fn time_display_milliseconds() {
844        let t = Time::from_millis(500);
845        let disp = format!("{t}");
846        assert_eq!(disp, "500ms");
847    }
848
849    #[test]
850    fn time_display_microseconds() {
851        let t = Time::from_nanos(5_000);
852        let disp = format!("{t}");
853        assert_eq!(disp, "5us");
854    }
855
856    #[test]
857    fn time_display_nanoseconds() {
858        let t = Time::from_nanos(42);
859        let disp = format!("{t}");
860        assert_eq!(disp, "42ns");
861    }
862
863    #[test]
864    fn time_display_zero() {
865        assert_eq!(format!("{}", Time::ZERO), "0ns");
866    }
867
868    // ---- Time edge cases ----
869
870    #[test]
871    fn time_debug_format() {
872        let t = Time::from_nanos(100);
873        let dbg = format!("{t:?}");
874        assert_eq!(dbg, "Time(100ns)");
875    }
876
877    #[test]
878    fn time_default_is_zero() {
879        assert_eq!(Time::default(), Time::ZERO);
880    }
881
882    #[test]
883    fn time_max_constant() {
884        assert_eq!(Time::MAX.as_nanos(), u64::MAX);
885    }
886
887    #[test]
888    fn time_saturating_add_overflow() {
889        let t = Time::MAX;
890        let result = t.saturating_add_nanos(1);
891        assert_eq!(result, Time::MAX);
892    }
893
894    #[test]
895    fn time_saturating_sub_underflow() {
896        let t = Time::ZERO;
897        let result = t.saturating_sub_nanos(100);
898        assert_eq!(result, Time::ZERO);
899    }
900
901    #[test]
902    fn time_duration_since() {
903        let t1 = Time::from_secs(5);
904        let t2 = Time::from_secs(3);
905        assert_eq!(t1.duration_since(t2), 2_000_000_000);
906        assert_eq!(t2.duration_since(t1), 0); // saturates at 0
907    }
908
909    #[test]
910    fn time_add_duration() {
911        let t = Time::from_secs(1);
912        let result = t + Duration::from_millis(500);
913        assert_eq!(result.as_millis(), 1500);
914    }
915
916    #[test]
917    fn time_from_millis_saturation() {
918        let t = Time::from_millis(u64::MAX);
919        // Should saturate, not overflow
920        assert_eq!(t, Time::MAX);
921    }
922
923    #[test]
924    fn time_from_secs_saturation() {
925        let t = Time::from_secs(u64::MAX);
926        assert_eq!(t, Time::MAX);
927    }
928
929    #[test]
930    fn time_serde_roundtrip() {
931        let t = Time::from_nanos(12345);
932        let json = serde_json::to_string(&t).expect("serialize");
933        let deserialized: Time = serde_json::from_str(&json).expect("deserialize");
934        assert_eq!(t, deserialized);
935    }
936
937    #[test]
938    fn time_hash_consistency() {
939        use crate::util::DetHasher;
940        use std::hash::{Hash, Hasher};
941
942        let a = Time::from_secs(1);
943        let b = Time::from_millis(1000);
944        assert_eq!(a, b);
945
946        let mut ha = DetHasher::default();
947        let mut hb = DetHasher::default();
948        a.hash(&mut ha);
949        b.hash(&mut hb);
950        assert_eq!(ha.finish(), hb.finish());
951    }
952
953    /// br-asupersync-u3gsst — bootstrap helpers mint distinct IDs and
954    /// keep generation pinned at 1 (the documented contract).
955    #[test]
956    fn bootstrap_helpers_mint_unique_ids() {
957        let r1 = next_bootstrap_region_id();
958        let r2 = next_bootstrap_region_id();
959        assert_ne!(r1, r2);
960        assert_eq!(r1.arena_index().generation(), 1);
961        assert_eq!(r2.arena_index().generation(), 1);
962
963        let t1 = next_bootstrap_task_id();
964        let t2 = next_bootstrap_task_id();
965        assert_ne!(t1, t2);
966        assert_eq!(t1.arena_index().generation(), 1);
967        assert_eq!(t2.arena_index().generation(), 1);
968    }
969
970    /// br-asupersync-o2oa4l — Cross-type ID confusion: a serialised
971    /// RegionId must not deserialise as TaskId / ObligationId, even
972    /// when their arena (index, generation) tuples are identical.
973    /// The discriminant tag rejects the mis-typed payload at the
974    /// envelope level.
975    #[test]
976    fn serde_rejects_cross_type_id_confusion() {
977        let region = RegionId::from_arena(ArenaIndex::new(7, 3));
978        let json = serde_json::to_string(&region).expect("serialise RegionId");
979        // Sanity: the wire form contains the discriminant tag.
980        assert!(json.contains("\"kind\""));
981        assert!(json.contains("RegionId"));
982
983        // Round-trip back to the original type works.
984        let region_back: RegionId = serde_json::from_str(&json).expect("RegionId round-trip");
985        assert_eq!(region_back, region);
986
987        // Cross-type deserialisation must fail.
988        let task_err = serde_json::from_str::<TaskId>(&json);
989        assert!(task_err.is_err(), "TaskId must reject RegionId payload");
990        let obl_err = serde_json::from_str::<ObligationId>(&json);
991        assert!(
992            obl_err.is_err(),
993            "ObligationId must reject RegionId payload"
994        );
995    }
996
997    /// br-asupersync-o2oa4l — TaskId / ObligationId must each reject
998    /// payloads tagged for the other type.
999    #[test]
1000    fn serde_rejects_task_obligation_confusion() {
1001        let task = TaskId::from_arena(ArenaIndex::new(11, 2));
1002        let task_json = serde_json::to_string(&task).expect("serialise TaskId");
1003        assert!(serde_json::from_str::<RegionId>(&task_json).is_err());
1004        assert!(serde_json::from_str::<ObligationId>(&task_json).is_err());
1005        let task_back: TaskId = serde_json::from_str(&task_json).expect("TaskId round-trip");
1006        assert_eq!(task_back, task);
1007
1008        let obl = ObligationId::from_arena(ArenaIndex::new(11, 2));
1009        let obl_json = serde_json::to_string(&obl).expect("serialise ObligationId");
1010        assert!(serde_json::from_str::<RegionId>(&obl_json).is_err());
1011        assert!(serde_json::from_str::<TaskId>(&obl_json).is_err());
1012    }
1013}