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}