#![deny(missing_docs)]
#![forbid(unsafe_code)]
mod v2;
use std::error::Error;
use std::fmt;
pub use v2::{
ExternalAnchor, LuminousPacket, LuminousPacketBuilder, LuminousSummary, MetadataField,
PacketDigests, Producer, RedactionRecord, SemanticEdge, SemanticGraph, SemanticNode,
SignatureRef,
};
pub const LUMINOUS_METADATA_SCHEMA_V1: &str = "slbit/luminous-metadata/v1";
pub const VIZ_PACKET_SCHEMA_V1: &str = "slbit/viz-packet/v1";
pub const VIZ_PACKET_SCHEMA_V2: &str = "slbit/viz-packet/v2";
pub const ANNOTATION_SIDECAR_SCHEMA_V1: &str = "slbit/annotation-sidecar/v1";
const SEED_DOMAIN: &[u8] = b"slbit:transcript-seed:v1\0";
const PAYLOAD_DOMAIN: &[u8] = b"slbit:round-payload:v1\0";
const TRANSCRIPT_DOMAIN: &[u8] = b"slbit:transcript:v1\0";
const PACKET_DOMAIN: &[u8] = b"slbit:viz-packet:v1\0";
pub(crate) const MAX_CLAIM_ID_BYTES: usize = 256;
pub(crate) const MAX_LABEL_BYTES: usize = 128;
pub(crate) const MAX_NOTE_BYTES: usize = 4096;
pub(crate) const MAX_ROUNDS: usize = 1_000_000;
const MAX_PAYLOAD_BYTES: usize = 16 * 1024 * 1024;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VizHints {
pub color: Option<[u8; 3]>,
pub icon: Option<String>,
pub layer_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LuminousClaim {
id: String,
bit_width: u64,
viz_hints: VizHints,
}
impl LuminousClaim {
pub fn new(id: impl Into<String>, bit_width: u64) -> Self {
Self {
id: id.into(),
bit_width,
viz_hints: VizHints::default(),
}
}
pub fn with_viz_hints(mut self, viz_hints: VizHints) -> Self {
self.viz_hints = viz_hints;
self
}
pub fn id(&self) -> &str {
&self.id
}
pub fn bit_width(&self) -> u64 {
self.bit_width
}
pub fn viz_hints(&self) -> &VizHints {
&self.viz_hints
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TranscriptRound {
round: u64,
payload: Vec<u8>,
component: String,
note: String,
}
impl TranscriptRound {
pub fn round(&self) -> u64 {
self.round
}
pub fn payload(&self) -> &[u8] {
&self.payload
}
pub fn component(&self) -> &str {
&self.component
}
pub fn note(&self) -> &str {
&self.note
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BitInteractiveTranscript {
seed_commitment: String,
rounds: Vec<TranscriptRound>,
}
impl BitInteractiveTranscript {
pub fn new(seed: &[u8]) -> Self {
Self {
seed_commitment: digest_with_domain(SEED_DOMAIN, seed),
rounds: Vec::new(),
}
}
pub fn record_round_with_note(
&mut self,
round: u64,
payload: &[u8],
component: impl Into<String>,
note: impl Into<String>,
) -> &mut Self {
self.rounds.push(TranscriptRound {
round,
payload: payload.to_vec(),
component: component.into(),
note: note.into(),
});
self
}
pub fn seed_commitment(&self) -> &str {
&self.seed_commitment
}
pub fn rounds(&self) -> &[TranscriptRound] {
&self.rounds
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SimpleLuminousSumcheck {
pub claim: LuminousClaim,
pub transcript: BitInteractiveTranscript,
}
impl SimpleLuminousSumcheck {
pub fn to_luminous_metadata(&self) -> LuminousMetadata {
let rounds = projected_rounds(&self.transcript.rounds);
LuminousMetadata {
schema: LUMINOUS_METADATA_SCHEMA_V1,
claim_id: self.claim.id.clone(),
bit_width: self.claim.bit_width,
viz_hints: self.claim.viz_hints.clone(),
round_count: rounds.len(),
transcript_digest: transcript_digest(&self.transcript.seed_commitment, &rounds),
}
}
pub fn to_viz_packet(&self) -> Result<VizPacket, SlbitError> {
validate_claim(&self.claim)?;
validate_rounds(&self.transcript.rounds)?;
let rounds = projected_rounds(&self.transcript.rounds);
let transcript_digest = transcript_digest(&self.transcript.seed_commitment, &rounds);
let mut packet = VizPacket {
schema: VIZ_PACKET_SCHEMA_V1,
claim_id: self.claim.id.clone(),
bit_width: self.claim.bit_width,
viz_hints: self.claim.viz_hints.clone(),
seed_commitment: self.transcript.seed_commitment.clone(),
transcript_digest,
packet_digest: String::new(),
rounds,
};
packet.packet_digest = digest_with_domain(PACKET_DOMAIN, packet.core_json().as_bytes());
Ok(packet)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LuminousMetadata {
pub schema: &'static str,
pub claim_id: String,
pub bit_width: u64,
pub viz_hints: VizHints,
pub round_count: usize,
pub transcript_digest: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VizRound {
pub round: u64,
pub component: String,
pub note: String,
pub payload_sha256: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VizPacket {
pub schema: &'static str,
pub claim_id: String,
pub bit_width: u64,
pub viz_hints: VizHints,
pub seed_commitment: String,
pub transcript_digest: String,
pub packet_digest: String,
pub rounds: Vec<VizRound>,
}
impl VizPacket {
pub fn to_json(&self) -> String {
self.packet_json(true)
}
pub fn verify(&self) -> Result<(), SlbitError> {
if self.schema != VIZ_PACKET_SCHEMA_V1 {
return Err(SlbitError::UnsupportedSchema(self.schema.to_string()));
}
let claim = LuminousClaim {
id: self.claim_id.clone(),
bit_width: self.bit_width,
viz_hints: self.viz_hints.clone(),
};
validate_claim(&claim)?;
validate_viz_rounds(&self.rounds)?;
validate_sha256(&self.seed_commitment)?;
validate_sha256(&self.transcript_digest)?;
validate_sha256(&self.packet_digest)?;
let expected_transcript = transcript_digest(&self.seed_commitment, &self.rounds);
if expected_transcript != self.transcript_digest {
return Err(SlbitError::TranscriptDigestMismatch {
expected: expected_transcript,
found: self.transcript_digest.clone(),
});
}
let expected = digest_with_domain(PACKET_DOMAIN, self.core_json().as_bytes());
if expected != self.packet_digest {
let legacy = digest_with_domain(PACKET_DOMAIN, self.legacy_core_json().as_bytes());
if legacy != self.packet_digest {
return Err(SlbitError::PacketDigestMismatch {
expected,
found: self.packet_digest.clone(),
});
}
}
Ok(())
}
fn core_json(&self) -> String {
self.packet_json(false)
}
fn legacy_core_json(&self) -> String {
self.packet_json_with_round_writer(false, push_legacy_round_json)
}
fn packet_json(&self, include_packet_digest: bool) -> String {
self.packet_json_with_round_writer(include_packet_digest, push_round_json)
}
fn packet_json_with_round_writer(
&self,
include_packet_digest: bool,
round_writer: fn(&mut String, &VizRound),
) -> String {
let mut json = String::from("{");
push_json_key(&mut json, "bit_width");
json.push_str(&self.bit_width.to_string());
json.push(',');
push_json_key(&mut json, "claim_id");
push_json_string(&mut json, &self.claim_id);
if include_packet_digest {
json.push(',');
push_json_key(&mut json, "packet_digest");
push_json_string(&mut json, &self.packet_digest);
}
json.push(',');
push_json_key(&mut json, "rounds");
json.push('[');
for (index, round) in self.rounds.iter().enumerate() {
if index > 0 {
json.push(',');
}
round_writer(&mut json, round);
}
json.push(']');
json.push(',');
push_json_key(&mut json, "schema");
push_json_string(&mut json, self.schema);
json.push(',');
push_json_key(&mut json, "seed_commitment");
push_json_string(&mut json, &self.seed_commitment);
json.push(',');
push_json_key(&mut json, "transcript_digest");
push_json_string(&mut json, &self.transcript_digest);
json.push(',');
push_json_key(&mut json, "viz_hints");
push_hints_json(&mut json, &self.viz_hints);
json.push('}');
json
}
}
impl fmt::Display for VizPacket {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.to_json())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SlbitError {
InvalidText {
field: &'static str,
message: String,
},
InvalidBitWidth,
TooManyRounds(usize),
NonMonotonicRound {
previous: u64,
current: u64,
},
PayloadTooLarge {
round: u64,
bytes: usize,
},
InvalidIcon(String),
UnsupportedSchema(String),
InvalidDigest(String),
TranscriptDigestMismatch {
expected: String,
found: String,
},
PacketDigestMismatch {
expected: String,
found: String,
},
TooManyItems {
field: &'static str,
count: usize,
},
DuplicateId {
field: &'static str,
id: String,
},
InvalidReference {
field: &'static str,
reference: String,
},
CycleDetected,
}
impl fmt::Display for SlbitError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidText { field, message } => {
write!(formatter, "invalid {field}: {message}")
}
Self::InvalidBitWidth => formatter.write_str("bit width must be nonzero"),
Self::TooManyRounds(count) => write!(formatter, "too many transcript rounds: {count}"),
Self::NonMonotonicRound { previous, current } => write!(
formatter,
"transcript rounds must increase strictly: {previous} then {current}"
),
Self::PayloadTooLarge { round, bytes } => {
write!(
formatter,
"round {round} payload is too large: {bytes} bytes"
)
}
Self::InvalidIcon(icon) => write!(formatter, "invalid icon identifier: {icon}"),
Self::UnsupportedSchema(schema) => write!(formatter, "unsupported schema: {schema}"),
Self::InvalidDigest(digest) => write!(formatter, "invalid SHA-256 digest: {digest}"),
Self::TranscriptDigestMismatch { expected, found } => write!(
formatter,
"transcript digest mismatch: expected {expected}, found {found}"
),
Self::PacketDigestMismatch { expected, found } => {
write!(
formatter,
"packet digest mismatch: expected {expected}, found {found}"
)
}
Self::TooManyItems { field, count } => {
write!(formatter, "too many entries in {field}: {count}")
}
Self::DuplicateId { field, id } => {
write!(formatter, "duplicate identifier in {field}: {id}")
}
Self::InvalidReference { field, reference } => {
write!(formatter, "invalid reference in {field}: {reference}")
}
Self::CycleDetected => formatter.write_str("semantic graph contains a cycle"),
}
}
}
impl Error for SlbitError {}
fn validate_claim(claim: &LuminousClaim) -> Result<(), SlbitError> {
validate_text("claim_id", &claim.id, MAX_CLAIM_ID_BYTES)?;
if claim.bit_width == 0 {
return Err(SlbitError::InvalidBitWidth);
}
if let Some(icon) = &claim.viz_hints.icon {
validate_text("viz_hints.icon", icon, MAX_LABEL_BYTES)?;
if !icon
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
{
return Err(SlbitError::InvalidIcon(icon.clone()));
}
}
if let Some(layer) = &claim.viz_hints.layer_name {
validate_text("viz_hints.layer_name", layer, MAX_LABEL_BYTES)?;
}
Ok(())
}
fn validate_rounds(rounds: &[TranscriptRound]) -> Result<(), SlbitError> {
if rounds.len() > MAX_ROUNDS {
return Err(SlbitError::TooManyRounds(rounds.len()));
}
let mut previous = None;
for round in rounds {
if let Some(previous) = previous {
if round.round <= previous {
return Err(SlbitError::NonMonotonicRound {
previous,
current: round.round,
});
}
}
previous = Some(round.round);
validate_text("round.component", &round.component, MAX_LABEL_BYTES)?;
validate_text("round.note", &round.note, MAX_NOTE_BYTES)?;
if round.payload.len() > MAX_PAYLOAD_BYTES {
return Err(SlbitError::PayloadTooLarge {
round: round.round,
bytes: round.payload.len(),
});
}
}
Ok(())
}
fn validate_viz_rounds(rounds: &[VizRound]) -> Result<(), SlbitError> {
if rounds.len() > MAX_ROUNDS {
return Err(SlbitError::TooManyRounds(rounds.len()));
}
let mut previous = None;
for round in rounds {
if let Some(previous) = previous {
if round.round <= previous {
return Err(SlbitError::NonMonotonicRound {
previous,
current: round.round,
});
}
}
previous = Some(round.round);
validate_text("round.component", &round.component, MAX_LABEL_BYTES)?;
validate_text("round.note", &round.note, MAX_NOTE_BYTES)?;
validate_sha256(&round.payload_sha256)?;
}
Ok(())
}
pub(crate) fn validate_text(
field: &'static str,
value: &str,
max: usize,
) -> Result<(), SlbitError> {
if value.trim().is_empty() {
return Err(SlbitError::InvalidText {
field,
message: "must not be empty".to_string(),
});
}
if value.len() > max {
return Err(SlbitError::InvalidText {
field,
message: format!("must be at most {max} UTF-8 bytes"),
});
}
if value.chars().any(char::is_control) {
return Err(SlbitError::InvalidText {
field,
message: "must not contain control characters".to_string(),
});
}
Ok(())
}
pub(crate) fn validate_sha256(value: &str) -> Result<(), SlbitError> {
let Some(hex) = value.strip_prefix("sha256:") else {
return Err(SlbitError::InvalidDigest(value.to_string()));
};
if hex.len() != 64
|| !hex
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(SlbitError::InvalidDigest(value.to_string()));
}
Ok(())
}
fn projected_rounds(rounds: &[TranscriptRound]) -> Vec<VizRound> {
rounds
.iter()
.map(|round| VizRound {
round: round.round,
component: round.component.clone(),
note: round.note.clone(),
payload_sha256: digest_with_domain(PAYLOAD_DOMAIN, &round.payload),
})
.collect()
}
pub(crate) fn transcript_digest(seed_commitment: &str, rounds: &[VizRound]) -> String {
let mut bytes = Vec::new();
absorb(&mut bytes, seed_commitment.as_bytes());
absorb(&mut bytes, &(rounds.len() as u64).to_be_bytes());
for round in rounds {
absorb(&mut bytes, &round.round.to_be_bytes());
absorb(&mut bytes, round.component.as_bytes());
absorb(&mut bytes, round.note.as_bytes());
absorb(&mut bytes, round.payload_sha256.as_bytes());
}
digest_with_domain(TRANSCRIPT_DOMAIN, &bytes)
}
pub(crate) fn absorb(target: &mut Vec<u8>, value: &[u8]) {
target.extend_from_slice(&(value.len() as u64).to_be_bytes());
target.extend_from_slice(value);
}
pub(crate) fn push_json_key(output: &mut String, key: &str) {
push_json_string(output, key);
output.push(':');
}
pub(crate) fn push_json_string(output: &mut String, value: &str) {
output.push('"');
for character in value.chars() {
match character {
'"' => output.push_str("\\\""),
'\\' => output.push_str("\\\\"),
'\u{08}' => output.push_str("\\b"),
'\u{0c}' => output.push_str("\\f"),
'\n' => output.push_str("\\n"),
'\r' => output.push_str("\\r"),
'\t' => output.push_str("\\t"),
value if value <= '\u{1f}' => {
output.push_str(&format!("\\u{:04x}", value as u32));
}
value => output.push(value),
}
}
output.push('"');
}
pub(crate) fn push_hints_json(output: &mut String, hints: &VizHints) {
output.push('{');
let mut wrote = false;
if let Some(color) = hints.color {
push_json_key(output, "color");
output.push('[');
output.push_str(&format!("{},{},{}", color[0], color[1], color[2]));
output.push(']');
wrote = true;
}
if let Some(icon) = &hints.icon {
if wrote {
output.push(',');
}
push_json_key(output, "icon");
push_json_string(output, icon);
wrote = true;
}
if let Some(layer) = &hints.layer_name {
if wrote {
output.push(',');
}
push_json_key(output, "layer_name");
push_json_string(output, layer);
}
output.push('}');
}
fn push_round_json(output: &mut String, round: &VizRound) {
output.push('{');
push_json_key(output, "component");
push_json_string(output, &round.component);
output.push(',');
push_json_key(output, "note");
push_json_string(output, &round.note);
output.push(',');
push_json_key(output, "payload_sha256");
push_json_string(output, &round.payload_sha256);
output.push(',');
push_json_key(output, "round");
output.push_str(&round.round.to_string());
output.push('}');
}
fn push_legacy_round_json(output: &mut String, round: &VizRound) {
output.push('{');
push_json_key(output, "component");
push_json_key(output, "component");
push_json_string(output, &round.component);
output.push(',');
push_json_key(output, "note");
push_json_string(output, &round.note);
output.push(',');
push_json_key(output, "payload_sha256");
push_json_string(output, &round.payload_sha256);
output.push(',');
push_json_key(output, "round");
output.push_str(&round.round.to_string());
output.push('}');
}
pub(crate) fn digest_with_domain(domain: &[u8], value: &[u8]) -> String {
let mut bytes = Vec::with_capacity(domain.len() + value.len());
bytes.extend_from_slice(domain);
bytes.extend_from_slice(value);
format!("sha256:{}", hex_encode(&sha256(&bytes)))
}
fn hex_encode(bytes: &[u8]) -> String {
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(char::from_digit(u32::from(byte >> 4), 16).expect("hex digit"));
output.push(char::from_digit(u32::from(byte & 0x0f), 16).expect("hex digit"));
}
output
}
fn sha256(input: &[u8]) -> [u8; 32] {
const INITIAL: [u32; 8] = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
0x5be0cd19,
];
const K: [u32; 64] = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
0xc67178f2,
];
let bit_len = (input.len() as u64).wrapping_mul(8);
let mut padded = input.to_vec();
padded.push(0x80);
while padded.len() % 64 != 56 {
padded.push(0);
}
padded.extend_from_slice(&bit_len.to_be_bytes());
let mut state = INITIAL;
for chunk in padded.chunks_exact(64) {
let mut words = [0_u32; 64];
for (index, bytes) in chunk.chunks_exact(4).enumerate() {
words[index] = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
}
for index in 16..64 {
let s0 = words[index - 15].rotate_right(7)
^ words[index - 15].rotate_right(18)
^ (words[index - 15] >> 3);
let s1 = words[index - 2].rotate_right(17)
^ words[index - 2].rotate_right(19)
^ (words[index - 2] >> 10);
words[index] = words[index - 16]
.wrapping_add(s0)
.wrapping_add(words[index - 7])
.wrapping_add(s1);
}
let mut a = state[0];
let mut b = state[1];
let mut c = state[2];
let mut d = state[3];
let mut e = state[4];
let mut f = state[5];
let mut g = state[6];
let mut h = state[7];
for index in 0..64 {
let sum1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
let choose = (e & f) ^ ((!e) & g);
let temp1 = h
.wrapping_add(sum1)
.wrapping_add(choose)
.wrapping_add(K[index])
.wrapping_add(words[index]);
let sum0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
let majority = (a & b) ^ (a & c) ^ (b & c);
let temp2 = sum0.wrapping_add(majority);
h = g;
g = f;
f = e;
e = d.wrapping_add(temp1);
d = c;
c = b;
b = a;
a = temp1.wrapping_add(temp2);
}
state[0] = state[0].wrapping_add(a);
state[1] = state[1].wrapping_add(b);
state[2] = state[2].wrapping_add(c);
state[3] = state[3].wrapping_add(d);
state[4] = state[4].wrapping_add(e);
state[5] = state[5].wrapping_add(f);
state[6] = state[6].wrapping_add(g);
state[7] = state[7].wrapping_add(h);
}
let mut output = [0_u8; 32];
for (index, word) in state.iter().enumerate() {
output[index * 4..index * 4 + 4].copy_from_slice(&word.to_be_bytes());
}
output
}
#[cfg(test)]
mod tests {
use super::*;
fn luminous() -> SimpleLuminousSumcheck {
let claim = LuminousClaim::new("drone-camera-frame-7842", 4096).with_viz_hints(VizHints {
color: Some([0, 200, 255]),
icon: Some("camera".to_string()),
layer_name: Some("perception-conv3".to_string()),
});
let mut transcript = BitInteractiveTranscript::new(b"drone-seed-7842");
transcript.record_round_with_note(
0,
&[0xde, 0xad, 0xbe, 0xef],
"sensor-processing",
"Raw sensor frame converted into features",
);
transcript.record_round_with_note(
1,
&[0x42],
"attention-head-7",
"Stop-sign feature strongly activated",
);
SimpleLuminousSumcheck { claim, transcript }
}
#[test]
fn sha256_matches_known_vector() {
assert_eq!(
hex_encode(&sha256(b"abc")),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn packets_are_deterministic_and_hide_raw_payloads() {
let first = luminous().to_viz_packet().unwrap();
let second = luminous().to_viz_packet().unwrap();
assert_eq!(first, second);
assert!(first.verify().is_ok());
assert!(!first.to_json().contains("deadbeef"));
assert!(first
.to_json()
.contains("Stop-sign feature strongly activated"));
assert!(first.to_json().contains("\",\"rounds\":"));
assert!(!first.to_json().contains("\"component\":\"component\":"));
}
#[test]
fn legacy_v1_packet_digest_still_verifies() {
let mut packet = luminous().to_viz_packet().unwrap();
packet.packet_digest =
digest_with_domain(PACKET_DOMAIN, packet.legacy_core_json().as_bytes());
packet.verify().unwrap();
}
#[test]
fn semantic_mutation_changes_digest() {
let original = luminous().to_viz_packet().unwrap();
let mut changed = luminous();
changed.transcript.rounds[1].note = "Different annotation".to_string();
let changed = changed.to_viz_packet().unwrap();
assert_ne!(original.packet_digest, changed.packet_digest);
}
#[test]
fn packet_digest_rejects_mutation() {
let mut packet = luminous().to_viz_packet().unwrap();
packet.rounds[0].note = "mutated".to_string();
assert!(matches!(
packet.verify(),
Err(SlbitError::TranscriptDigestMismatch { .. })
));
}
#[test]
fn invalid_round_order_is_rejected() {
let mut value = luminous();
value.transcript.rounds[1].round = 0;
assert!(matches!(
value.to_viz_packet(),
Err(SlbitError::NonMonotonicRound { .. })
));
}
#[test]
fn json_escaping_is_deterministic() {
let mut value = luminous();
value.transcript.rounds[0].note = "camera \"accepted\" \\\\ path".to_string();
let json = value.to_viz_packet().unwrap().to_json();
assert!(json.contains("camera \\\"accepted\\\" \\\\\\\\ path"));
}
}