Skip to main content

dsfb_gpu_debug_core/
motif.rs

1//! Detector-motif registry: the canonical 16 detectors plus the registry
2//! hash that the contract pins.
3//!
4//! The detector layer is part of the deterministic execution contract.
5//! Two crucial properties live here:
6//!
7//! 1. The *order* of detector motifs. The detector cell encodes which
8//!    detectors fired in a single 16-bit mask; bit `i` always refers to
9//!    `MotifClass::variant_at(i)`. Reordering would silently flip the
10//!    meaning of every stored mask.
11//! 2. The registry *hash*. The contract carries a `registry_hash` field
12//!    that pins the detector set; if the constant exposed here changes
13//!    (new motif added, threshold table altered, name renamed) the
14//!    contract hash must be recomputed and any stored case files become
15//!    invalid. That is intentional — a detector-set change is a contract
16//!    breach by design.
17
18use crate::hash::{format_digest, sha256};
19
20/// The deterministic catalog of detector motifs. Sixteen entries, all in
21/// canonical order. Bit `i` of a `DetectorCell::detector_mask` is `1`
22/// when `MOTIF_CATALOG[i].class` fired on that cell.
23#[derive(Copy, Clone, Eq, PartialEq, Debug)]
24#[repr(u8)]
25pub enum MotifClass {
26    /// Single-cell norm above the spike threshold.
27    ResidualSpike = 0,
28    /// EWMA drift above the sustained threshold.
29    SustainedResidualElevation = 1,
30    /// Monotonically increasing drift for ≥ ramp-steps windows.
31    DriftRamp = 2,
32    /// Absolute slew above the shock threshold.
33    SlewShock = 3,
34    /// Norm above plateau threshold and slew near zero for ≥ plateau-windows.
35    Plateau = 4,
36    /// Sign of slew alternated ≥ oscillation-alternations times in last
37    /// oscillation-window cells.
38    Oscillation = 5,
39    /// Norm crossed from below `deadband` to above `deadband + hysteresis`.
40    DeadbandExit = 6,
41    /// Residual error rate above the burst threshold.
42    ErrorRateBurst = 7,
43    /// Latency residual AND error residual both above their coupling
44    /// thresholds within the same cell.
45    LatencyErrorCoupling = 8,
46    /// Cell's norm is markedly above the entity's running average — set
47    /// in the consensus stage; the per-cell evaluator records a
48    /// candidate-flag and lets the consensus pass confirm or reject it.
49    EntityLocalAnomaly = 9,
50    /// Cell's norm is markedly above the entity's average for other
51    /// routes — placeholder bit, set when route_id distribution within
52    /// the entity is uneven on this window.
53    RouteLocalAnomaly = 10,
54    /// Drift rising in an upstream entity while this cell shows elevated
55    /// error rate. v0 uses a conservative single-cell approximation.
56    FanoutPrecursor = 11,
57    /// Sample variance of norm over the variance-window cells above
58    /// `var_threshold`.
59    VarianceExpansion = 12,
60    /// Drift turning over (current drift < previous drift) while norm
61    /// still elevated — signals recovery off a peak.
62    RecoveryEdge = 13,
63    /// Norm and drift both below the deadband; no other detector fired.
64    /// Sentinel bit used by consensus to confirm "clean" cells.
65    CleanWindowStability = 14,
66    /// Single-cell spike that resolved in the very next window — used
67    /// by the confuser-suppression axis.
68    ConfuserLikeTransient = 15,
69}
70
71impl MotifClass {
72    /// Total number of motifs in the canonical catalog. Doubles as the
73    /// width of `detector_mask`.
74    pub const COUNT: usize = 16;
75
76    /// Map a class to its bit position in `detector_mask`.
77    #[must_use]
78    pub const fn bit_index(self) -> u32 {
79        self as u32
80    }
81
82    /// Map a class to a `1u32 << bit_index` mask.
83    #[must_use]
84    pub const fn bit_mask(self) -> u32 {
85        1u32 << self.bit_index()
86    }
87
88    /// Recover a class from its bit index. Returns `None` for any value
89    /// `≥ COUNT`.
90    #[must_use]
91    pub const fn from_bit_index(bit: u32) -> Option<Self> {
92        match bit {
93            0 => Some(Self::ResidualSpike),
94            1 => Some(Self::SustainedResidualElevation),
95            2 => Some(Self::DriftRamp),
96            3 => Some(Self::SlewShock),
97            4 => Some(Self::Plateau),
98            5 => Some(Self::Oscillation),
99            6 => Some(Self::DeadbandExit),
100            7 => Some(Self::ErrorRateBurst),
101            8 => Some(Self::LatencyErrorCoupling),
102            9 => Some(Self::EntityLocalAnomaly),
103            10 => Some(Self::RouteLocalAnomaly),
104            11 => Some(Self::FanoutPrecursor),
105            12 => Some(Self::VarianceExpansion),
106            13 => Some(Self::RecoveryEdge),
107            14 => Some(Self::CleanWindowStability),
108            15 => Some(Self::ConfuserLikeTransient),
109            _ => None,
110        }
111    }
112
113    /// Human-readable name. Used by the case-file serializer and any
114    /// future operator-facing renderer. Stable strings; renaming any of
115    /// them changes the registry hash and is therefore a contract
116    /// breach.
117    #[must_use]
118    pub const fn name(self) -> &'static str {
119        match self {
120            Self::ResidualSpike => "residual_spike",
121            Self::SustainedResidualElevation => "sustained_residual_elevation",
122            Self::DriftRamp => "drift_ramp",
123            Self::SlewShock => "slew_shock",
124            Self::Plateau => "plateau",
125            Self::Oscillation => "oscillation",
126            Self::DeadbandExit => "deadband_exit",
127            Self::ErrorRateBurst => "error_rate_burst",
128            Self::LatencyErrorCoupling => "latency_error_coupling",
129            Self::EntityLocalAnomaly => "entity_local_anomaly",
130            Self::RouteLocalAnomaly => "route_local_anomaly",
131            Self::FanoutPrecursor => "fanout_precursor",
132            Self::VarianceExpansion => "variance_expansion",
133            Self::RecoveryEdge => "recovery_edge",
134            Self::CleanWindowStability => "clean_window_stability",
135            Self::ConfuserLikeTransient => "confuser_like_transient",
136        }
137    }
138}
139
140/// Order-locked array of all 16 motif classes. Used by the registry hash
141/// and by iteration sites that need to walk every detector in canonical
142/// order.
143pub const MOTIF_CATALOG: [MotifClass; MotifClass::COUNT] = [
144    MotifClass::ResidualSpike,
145    MotifClass::SustainedResidualElevation,
146    MotifClass::DriftRamp,
147    MotifClass::SlewShock,
148    MotifClass::Plateau,
149    MotifClass::Oscillation,
150    MotifClass::DeadbandExit,
151    MotifClass::ErrorRateBurst,
152    MotifClass::LatencyErrorCoupling,
153    MotifClass::EntityLocalAnomaly,
154    MotifClass::RouteLocalAnomaly,
155    MotifClass::FanoutPrecursor,
156    MotifClass::VarianceExpansion,
157    MotifClass::RecoveryEdge,
158    MotifClass::CleanWindowStability,
159    MotifClass::ConfuserLikeTransient,
160];
161
162/// Canonical bytes of the detector registry: a comma-separated list of
163/// the motif names in catalog order, with no whitespace. This format
164/// produces a stable hash that changes if any name is renamed or any
165/// motif is reordered, which is exactly what we want for breach
166/// detection.
167#[must_use]
168pub fn registry_canonical_bytes() -> [u8; 16 * 64] {
169    let mut buf = [0u8; 16 * 64];
170    let mut pos = 0usize;
171    let mut i = 0usize;
172    while i < MOTIF_CATALOG.len() {
173        if i > 0 {
174            buf[pos] = b',';
175            pos += 1;
176        }
177        let name = MOTIF_CATALOG[i].name().as_bytes();
178        let mut j = 0;
179        while j < name.len() {
180            buf[pos] = name[j];
181            pos += 1;
182            j += 1;
183        }
184        i += 1;
185    }
186    // Pad the unused tail with zeros (already zeroed by construction);
187    // the hash includes only the populated prefix.
188    let _ = pos; // length is also returned by `registry_canonical_len()`.
189    buf
190}
191
192/// Length of the populated prefix of `registry_canonical_bytes()`.
193#[must_use]
194pub fn registry_canonical_len() -> usize {
195    let mut total = 0usize;
196    let mut i = 0usize;
197    while i < MOTIF_CATALOG.len() {
198        if i > 0 {
199            total += 1; // comma
200        }
201        total += MOTIF_CATALOG[i].name().len();
202        i += 1;
203    }
204    total
205}
206
207/// SHA-256 digest of the canonical detector registry. The contract's
208/// `registry_hash` field must equal this value verbatim; a mismatch is a
209/// `DetectorRegistryMismatch` verdict.
210#[must_use]
211pub fn registry_hash() -> [u8; 32] {
212    let bytes = registry_canonical_bytes();
213    let len = registry_canonical_len();
214    sha256(&bytes[..len])
215}
216
217/// Lowercased hex spelling of [`registry_hash`] prefixed with `sha256:`,
218/// suitable for direct substitution into `contract.toml`.
219#[must_use]
220pub fn registry_hash_string() -> [u8; 71] {
221    let digest = registry_hash();
222    format_digest(&digest)
223}
224
225/// R.9 — detector-axis expansion profiles. Seven profile IDs are
226/// reserved (`D16`, `D64`, `D128`, `D205`, `D512`, `D1024`, `D2000`).
227/// At HEAD `D16`, `D64`, and `D128` are fully implemented:
228/// D16 is the legacy 16-motif profile (R.9.a, audit-mode reference);
229/// D64 is the R.9.b/R.10/R.11 throughput path that drove the R.13
230/// ~55× full-pipeline campaign reduction; D128 is the R.9.d.1
231/// scaling-ladder proof (commit `99a0f3b`, 16 motifs × 8 variants,
232/// wide-digest baseline with R.10b compact-pack deferred). `D205`
233/// and the wider profiles still reserve their identity + registry-
234/// hash slots, deferred to paper §16.
235///
236/// **Why this exists.** R.7 reported 2.9× GPU Layer B at K=64 with
237/// the 16-detector court. R.8 showed the dominant cost was the
238/// host bank + digest plumbing, not the kernel math; R.8.5 + R.11
239/// cleared those bottlenecks down to ~72 ms at 256×4096 K=1. With
240/// the launch + finalize floor squeezed, **more detectors per cell
241/// is the load-bearing way to keep the GPU saturated**. The Atlas
242/// continuation calls for 2 000+ algebra-generated detectors; R.9
243/// brings 16 → 2 000 inside this prior-art crate so the R.13
244/// headline benchmark can run on the architecture's intended scale.
245///
246/// **Canonical 16-detector preservation**: `D16` derives the same
247/// `registry_hash` bytes that [`registry_hash`] returns. Audit-mode
248/// golden hashes are not touched by R.9.a. Wider profiles compose
249/// the canonical 16-motif hash with a profile-id + active-count
250/// suffix so their `detector_registry_hash` is deterministic and
251/// distinct per profile.
252#[derive(Copy, Clone, Eq, PartialEq, Debug)]
253#[repr(u32)]
254pub enum DetectorProfile {
255    /// 16 detectors. The canonical court. Byte-identical to the
256    /// pre-R.9 path; Audit golden hashes pin this profile.
257    D16 = 16,
258    /// 64 detectors. v1 expansion (4 parameter variants per family).
259    /// Scaffolded in R.9.a; wide-mask kernel lands in R.9.b.
260    D64 = 64,
261    /// 128 detectors. v1 expansion (8 variants per family).
262    D128 = 128,
263    /// 205 detectors. Mirrors the dsfb-debug taxonomy size — the
264    /// "mature 205-detector court" the user named in the campaign
265    /// brief.
266    D205 = 205,
267    /// 512 detectors. Mid-Atlas density.
268    D512 = 512,
269    /// 1024 detectors. Approaching Atlas headline.
270    D1024 = 1024,
271    /// 2000 detectors. Headline target per the user's locked R.9
272    /// scope. The R.13 ≥10× gate is measured here.
273    D2000 = 2000,
274}
275
276impl DetectorProfile {
277    /// Number of *active* detector bits this profile carries. Always
278    /// equals the enum's numeric value; surfaced as a method for
279    /// readability at call sites.
280    #[must_use]
281    pub const fn active_detector_count(self) -> u32 {
282        self as u32
283    }
284
285    /// Short stable identifier string. Used inside the canonical
286    /// per-profile registry-hash derivation; never localised or
287    /// reformatted.
288    #[must_use]
289    pub const fn name(self) -> &'static str {
290        match self {
291            Self::D16 => "D16",
292            Self::D64 => "D64",
293            Self::D128 => "D128",
294            Self::D205 => "D205",
295            Self::D512 => "D512",
296            Self::D1024 => "D1024",
297            Self::D2000 => "D2000",
298        }
299    }
300
301    /// Width of the per-cell detector bitset in 64-bit words. R.9.b
302    /// will pin `DetectorCell` to `[u64; MASK_WORDS]` so 2 048 bits
303    /// fit at the headline profile. `D16` still uses the legacy
304    /// `u32` cell field in R.9.a; the wider mask only activates
305    /// when the wide-kernel commit lands.
306    #[must_use]
307    pub const fn mask_word_count(self) -> u32 {
308        // The mask is sized for the widest profile so all profiles
309        // share a single ABI shape once R.9.b lands. 32 × 64 = 2048
310        // bits, comfortably above 2 000.
311        32
312    }
313
314    /// Returns the canonical per-profile `detector_registry_hash`
315    /// that the contract pins for this profile.
316    ///
317    /// **Byte stability**: `DetectorProfile::D16.registry_hash() ==
318    /// motif::registry_hash()` — the canonical 16-detector court's
319    /// hash is unchanged. Wider profiles compose:
320    ///
321    /// ```text
322    ///   sha256(
323    ///       "DSFB-GPU-DEBUG:detector-profile:v1\0"
324    ///       || canonical_motif_registry_hash
325    ///       || profile_name_ascii
326    ///       || 0x00
327    ///       || active_detector_count_le_u32
328    ///   )
329    /// ```
330    ///
331    /// The domain prefix prevents the wider profiles' hashes from
332    /// colliding with any other 32-byte commitment in the chain.
333    /// The active count is included so a future R.9.b that
334    /// reorganises the 64/128/etc variant table without changing
335    /// the profile-id-string would still produce a fresh hash if
336    /// the count changed. The R.9.b commit may extend this
337    /// derivation with the parameter-variant table; that's a
338    /// `v2` derivation when it lands.
339    #[must_use]
340    pub fn registry_hash(self) -> [u8; 32] {
341        // Build a small fixed-size buffer on the stack — the
342        // longest possible content is ~85 bytes (35 domain + 32
343        // canonical + 6 name + 1 null + 4 count). Sized at 128 to
344        // leave headroom; the actual hash input length is tracked
345        // separately so trailing padding bytes are excluded.
346        const DOMAIN: &[u8] = b"DSFB-GPU-DEBUG:detector-profile:v1\0";
347        if matches!(self, Self::D16) {
348            return registry_hash();
349        }
350        let canonical = registry_hash();
351        let name = self.name().as_bytes();
352        let count = self.active_detector_count().to_le_bytes();
353        let mut buf = [0u8; 128];
354        let mut pos = 0usize;
355        // Domain prefix.
356        let mut i = 0;
357        while i < DOMAIN.len() {
358            buf[pos] = DOMAIN[i];
359            pos += 1;
360            i += 1;
361        }
362        // Canonical 16-motif registry hash.
363        let mut j = 0;
364        while j < 32 {
365            buf[pos] = canonical[j];
366            pos += 1;
367            j += 1;
368        }
369        // Profile-name ASCII + null terminator.
370        let mut k = 0;
371        while k < name.len() {
372            buf[pos] = name[k];
373            pos += 1;
374            k += 1;
375        }
376        buf[pos] = 0;
377        pos += 1;
378        // Active count little-endian u32.
379        let mut m = 0;
380        while m < 4 {
381            buf[pos] = count[m];
382            pos += 1;
383            m += 1;
384        }
385        sha256(&buf[..pos])
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use std::vec::Vec;
393
394    #[test]
395    fn motif_class_round_trips_through_bit_index() {
396        for class in MOTIF_CATALOG {
397            let bit = class.bit_index();
398            let recovered = MotifClass::from_bit_index(bit).unwrap();
399            assert_eq!(class, recovered);
400        }
401    }
402
403    #[test]
404    fn bit_masks_are_disjoint_powers_of_two() {
405        let mut union = 0u32;
406        for class in MOTIF_CATALOG {
407            let mask = class.bit_mask();
408            assert_eq!(mask.count_ones(), 1, "non-power-of-two mask for {class:?}");
409            assert_eq!(union & mask, 0, "duplicate bit for {class:?}");
410            union |= mask;
411        }
412        assert_eq!(union, 0xFFFF, "expected 16 bits set, got {union:#x}");
413    }
414
415    #[test]
416    fn catalog_order_matches_bit_indices() {
417        // The catalog walk and bit_index() must agree on the canonical
418        // ordering — otherwise serialization and parsing would disagree.
419        for (i, &class) in MOTIF_CATALOG.iter().enumerate() {
420            assert_eq!(class.bit_index() as usize, i);
421        }
422    }
423
424    #[test]
425    fn names_are_unique_and_lowercase_snake_case() {
426        let mut seen: Vec<&'static str> = Vec::new();
427        for class in MOTIF_CATALOG {
428            let name = class.name();
429            assert!(!seen.contains(&name), "duplicate motif name {name}");
430            assert!(
431                name.bytes().all(|b| b.is_ascii_lowercase() || b == b'_'),
432                "motif name {name} contains non-lowercase-snake-case byte"
433            );
434            seen.push(name);
435        }
436        assert_eq!(seen.len(), MotifClass::COUNT);
437    }
438
439    #[test]
440    fn registry_canonical_length_matches_computed_bytes() {
441        let bytes = registry_canonical_bytes();
442        let len = registry_canonical_len();
443        // The first `len` bytes are the canonical content; the rest is
444        // zeroed padding.
445        let s = core::str::from_utf8(&bytes[..len]).expect("ASCII");
446        assert!(s.starts_with("residual_spike,"));
447        assert!(s.ends_with(",confuser_like_transient"));
448    }
449
450    #[test]
451    fn registry_hash_is_stable_across_calls() {
452        let a = registry_hash();
453        let b = registry_hash();
454        assert_eq!(a, b);
455    }
456
457    #[test]
458    fn registry_hash_is_what_we_expect() {
459        // Pin the expected digest. Computed once over the canonical bytes
460        // and recorded here so that any silent change to the catalog
461        // (rename, reorder, addition) fails the test deterministically.
462        let bytes = registry_canonical_bytes();
463        let len = registry_canonical_len();
464        let expected = sha256(&bytes[..len]);
465        assert_eq!(registry_hash(), expected);
466        // Also assert the hash is non-trivial (not all zeros, not the
467        // empty-string digest). This catches construction bugs that would
468        // pass the round-trip but produce a meaningless value.
469        assert_ne!(registry_hash(), [0u8; 32]);
470        assert_ne!(registry_hash(), sha256(b""));
471    }
472
473    #[test]
474    fn d16_profile_hash_equals_canonical_registry_hash() {
475        // R.9.a load-bearing invariant: the canonical 16-detector
476        // court's per-profile hash is bit-identical to the
477        // pre-R.9 `registry_hash()`. This is what keeps Audit
478        // golden hashes untouched.
479        assert_eq!(DetectorProfile::D16.registry_hash(), registry_hash());
480    }
481
482    #[test]
483    fn wider_profile_hashes_differ_from_d16() {
484        // Every wider profile must produce a distinct
485        // `detector_registry_hash`. If two profiles collided,
486        // the case-file chain would silently confuse them at
487        // replay.
488        let d16 = DetectorProfile::D16.registry_hash();
489        for p in [
490            DetectorProfile::D64,
491            DetectorProfile::D128,
492            DetectorProfile::D205,
493            DetectorProfile::D512,
494            DetectorProfile::D1024,
495            DetectorProfile::D2000,
496        ] {
497            assert_ne!(
498                p.registry_hash(),
499                d16,
500                "{} hash collides with D16",
501                p.name()
502            );
503        }
504    }
505
506    #[test]
507    fn profile_hashes_are_pairwise_distinct() {
508        // Stronger version of the above: no two profiles share a
509        // registry hash.
510        let profiles = [
511            DetectorProfile::D16,
512            DetectorProfile::D64,
513            DetectorProfile::D128,
514            DetectorProfile::D205,
515            DetectorProfile::D512,
516            DetectorProfile::D1024,
517            DetectorProfile::D2000,
518        ];
519        for (i, &a) in profiles.iter().enumerate() {
520            for &b in profiles.iter().skip(i + 1) {
521                assert_ne!(
522                    a.registry_hash(),
523                    b.registry_hash(),
524                    "{} and {} share a registry hash",
525                    a.name(),
526                    b.name()
527                );
528            }
529        }
530    }
531
532    #[test]
533    fn profile_hashes_are_deterministic() {
534        // Two consecutive calls to `registry_hash()` on the same
535        // profile produce byte-identical output. Catches any
536        // future regression that introduces non-determinism into
537        // the hash derivation (e.g. address-based ordering).
538        for p in [
539            DetectorProfile::D16,
540            DetectorProfile::D64,
541            DetectorProfile::D128,
542            DetectorProfile::D205,
543            DetectorProfile::D512,
544            DetectorProfile::D1024,
545            DetectorProfile::D2000,
546        ] {
547            assert_eq!(p.registry_hash(), p.registry_hash());
548        }
549    }
550
551    #[test]
552    fn profile_active_detector_count_matches_repr_u32() {
553        // `active_detector_count()` returns the enum's `repr(u32)`
554        // value verbatim. This is the contract that connects the
555        // profile id to the cell-mask width R.9.b will allocate.
556        assert_eq!(DetectorProfile::D16.active_detector_count(), 16);
557        assert_eq!(DetectorProfile::D64.active_detector_count(), 64);
558        assert_eq!(DetectorProfile::D128.active_detector_count(), 128);
559        assert_eq!(DetectorProfile::D205.active_detector_count(), 205);
560        assert_eq!(DetectorProfile::D512.active_detector_count(), 512);
561        assert_eq!(DetectorProfile::D1024.active_detector_count(), 1024);
562        assert_eq!(DetectorProfile::D2000.active_detector_count(), 2000);
563    }
564
565    #[test]
566    fn profile_mask_word_count_fits_all_profiles() {
567        // Every profile's active count fits inside the shared 2048-
568        // bit mask (32 × 64 bits). The mask width is uniform across
569        // profiles so the cell ABI is profile-independent once
570        // R.9.b widens it.
571        for p in [
572            DetectorProfile::D16,
573            DetectorProfile::D64,
574            DetectorProfile::D128,
575            DetectorProfile::D205,
576            DetectorProfile::D512,
577            DetectorProfile::D1024,
578            DetectorProfile::D2000,
579        ] {
580            let bits = u64::from(p.mask_word_count()) * 64;
581            assert!(
582                bits >= u64::from(p.active_detector_count()),
583                "{}: mask width {} < active count {}",
584                p.name(),
585                bits,
586                p.active_detector_count()
587            );
588        }
589    }
590}