flybrain/services/flysim/crates/flybrain-core/tests/envelope.rs
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

442 lines
14 KiB
Rust

//! Unit tests ported from the envelope half of `packages/brain/tests/agent.test.ts`.
//!
//! The envelope validates only its own structure: magic, truncation, chunk names, trailing bytes
//! and the checksum. Every one of those rejections is exercised here, because a checkpoint from
//! disk is the one input the service cannot assume is well-formed.
mod common;
use std::sync::Arc;
use common::{frame_pool, synthetic_dataset};
use flybrain_core::agent::{AgentConfig, FrameSize, NeuralAgent, RewardEvent, TickOptions};
use flybrain_core::decoder::gameboy::gameboy_decoder_config;
use flybrain_core::envelope::{
agent_from_chunks, agent_to_chunks, checksum, decode_envelope, encode_envelope,
AGENT_CHUNK_NAMES,
};
use flybrain_core::json::JsonValue;
const MAGIC: &str = "FLYBRAIN";
fn chunk(name: &str, bytes: &[u8]) -> (String, Vec<u8>) {
(name.to_string(), bytes.to_vec())
}
fn manifest(fields: &[(&str, JsonValue)]) -> JsonValue {
let mut json = JsonValue::object();
for (key, value) in fields {
json.set(key, value.clone());
}
json
}
/// Encodes a manifest verbatim, so decode's own structural checks can be exercised.
fn encode_raw(magic: &str, manifest: &JsonValue, chunks: &[Vec<u8>]) -> Vec<u8> {
let manifest_bytes = manifest.stringify().into_bytes();
let mut out = Vec::new();
out.extend_from_slice(magic.as_bytes());
out.extend_from_slice(&(manifest_bytes.len() as u32).to_le_bytes());
out.extend_from_slice(&manifest_bytes);
for bytes in chunks {
out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
out.extend_from_slice(bytes);
}
let crc = checksum(&out);
out.extend_from_slice(&crc.to_le_bytes());
out
}
/// An independent CRC32, so the test does not trust the implementation it is checking.
fn crc32(bytes: &[u8]) -> u32 {
let mut crc: u32 = 0xffff_ffff;
for byte in bytes {
crc ^= u32::from(*byte);
for _ in 0..8 {
crc = if crc & 1 != 0 {
(crc >> 1) ^ 0xedb8_8320
} else {
crc >> 1
};
}
}
crc ^ 0xffff_ffff
}
#[test]
fn an_envelope_round_trips_its_manifest_and_chunks() {
let chunks = vec![
chunk("alpha", &[1, 2, 3]),
chunk("beta", &[]),
chunk(
"gamma",
&(0..300u32)
.map(|index| (index & 0xff) as u8)
.collect::<Vec<_>>(),
),
];
let bytes = encode_envelope(
MAGIC,
&manifest(&[("note", "hello".into()), ("count", 7.0.into())]),
&chunks,
)
.expect("encode");
let decoded = decode_envelope(&bytes, MAGIC).expect("decode");
assert_eq!(
decoded
.manifest
.get("schemaVersion")
.and_then(JsonValue::as_f64),
Some(2.0)
);
assert_eq!(
decoded.manifest.get("note").and_then(JsonValue::as_str),
Some("hello")
);
assert_eq!(
decoded.manifest.get("count").and_then(JsonValue::as_f64),
Some(7.0)
);
assert_eq!(
decoded
.manifest
.get("chunks")
.and_then(JsonValue::as_array)
.map(|names| names
.iter()
.filter_map(JsonValue::as_str)
.collect::<Vec<_>>()),
Some(vec!["alpha", "beta", "gamma"])
);
assert_eq!(decoded.chunks, chunks);
// The footer is a CRC32 over everything before it.
let footer = u32::from_le_bytes(bytes[bytes.len() - 4..].try_into().unwrap());
assert_eq!(footer, crc32(&bytes[..bytes.len() - 4]));
assert_eq!(&bytes[..8], MAGIC.as_bytes());
}
#[test]
fn an_envelope_rejects_a_foreign_magic_a_bad_schema_and_a_bad_chunk_name() {
let bytes =
encode_envelope(MAGIC, &JsonValue::object(), &[chunk("alpha", &[9])]).expect("encode");
assert_eq!(
decode_envelope(&bytes, "OTHERMAG").unwrap_err().message(),
"Not a OTHERMAG envelope"
);
assert_eq!(
decode_envelope(&[0, 0, 0], MAGIC).unwrap_err().message(),
"Not a FLYBRAIN envelope"
);
assert_eq!(
encode_envelope("", &JsonValue::object(), &[])
.unwrap_err()
.message(),
"Envelope magic must be a non-empty ASCII string"
);
assert_eq!(
encode_envelope(MAGIC, &JsonValue::object(), &[chunk("bad-name", &[1])])
.unwrap_err()
.message(),
"Invalid envelope chunk name"
);
let schema_one = encode_raw(
MAGIC,
&manifest(&[
("schemaVersion", 1.0.into()),
("chunks", JsonValue::Array(vec![])),
]),
&[],
);
assert_eq!(
decode_envelope(&schema_one, MAGIC).unwrap_err().message(),
"Unsupported envelope schema: 1"
);
for (label, chunks_field, payloads) in [
(
"a non-letter name",
JsonValue::Array(vec!["bad-name".into()]),
vec![vec![1u8]],
),
(
"a duplicate name",
JsonValue::Array(vec!["a".into(), "a".into()]),
vec![vec![1u8], vec![2u8]],
),
(
"a non-string name",
JsonValue::Array(vec![7.0.into()]),
vec![vec![1u8]],
),
("a non-array chunk list", "alpha".into(), vec![]),
] {
let bytes = encode_raw(
MAGIC,
&manifest(&[("schemaVersion", 2.0.into()), ("chunks", chunks_field)]),
&payloads,
);
assert_eq!(
decode_envelope(&bytes, MAGIC).unwrap_err().message(),
"Invalid envelope chunks",
"{label}"
);
}
}
#[test]
fn an_envelope_rejects_corruption_truncation_and_trailing_bytes() {
let chunks = vec![
chunk("alpha", &(0..64u8).collect::<Vec<_>>()),
chunk("beta", &[5, 6]),
];
let original =
encode_envelope(MAGIC, &manifest(&[("note", "x".into())]), &chunks).expect("encode");
let mut flipped = original.clone();
let at = flipped.len() - 10;
flipped[at] ^= 0x01;
assert_eq!(
decode_envelope(&flipped, MAGIC).unwrap_err().message(),
"Envelope checksum mismatch"
);
let mut flipped_manifest = original.clone();
flipped_manifest[20] ^= 0x20;
let message = decode_envelope(&flipped_manifest, MAGIC)
.unwrap_err()
.message()
.to_string();
assert!(
message.contains("checksum mismatch")
|| message.contains("JSON")
|| message.contains("Unsupported")
|| message.contains("Invalid"),
"unexpected message: {message}"
);
let mut trailing = original.clone();
trailing.extend_from_slice(&[0, 0, 0]);
assert_eq!(
decode_envelope(&trailing, MAGIC).unwrap_err().message(),
"Envelope checksum mismatch"
);
// Trailing data with a valid footer: only the length bookkeeping can catch it. Four extra
// payload bytes, then a footer recomputed over all of them.
let mut padded = original[..original.len() - 4].to_vec();
padded.extend_from_slice(&[0; 8]);
assert_eq!(padded.len(), original.len() + 4);
let crc = crc32(&padded[..padded.len() - 4]);
let at = padded.len() - 4;
padded[at..].copy_from_slice(&crc.to_le_bytes());
assert_eq!(
decode_envelope(&padded, MAGIC).unwrap_err().message(),
"Envelope contains trailing data"
);
let short = &original[..original.len() - 8];
let message = decode_envelope(short, MAGIC)
.unwrap_err()
.message()
.to_string();
assert!(
message.contains("truncated") || message.contains("checksum mismatch"),
"unexpected message: {message}"
);
// A manifest length that runs past the end of the file.
let manifest_bytes = manifest(&[
("schemaVersion", 2.0.into()),
("chunks", JsonValue::Array(vec!["alpha".into()])),
])
.stringify()
.into_bytes();
let mut truncated = vec![0u8; MAGIC.len() + 4 + manifest_bytes.len() - 5];
truncated[..MAGIC.len()].copy_from_slice(MAGIC.as_bytes());
truncated[MAGIC.len()..MAGIC.len() + 4]
.copy_from_slice(&(manifest_bytes.len() as u32).to_le_bytes());
assert_eq!(
decode_envelope(&truncated, MAGIC).unwrap_err().message(),
"Envelope manifest is truncated"
);
// A chunk header that claims more bytes than the file holds.
let overlong = encode_raw(
MAGIC,
&manifest(&[
("schemaVersion", 2.0.into()),
(
"chunks",
JsonValue::Array(vec!["alpha".into(), "beta".into()]),
),
]),
&[vec![1, 2, 3]],
);
assert!(decode_envelope(&overlong, MAGIC)
.unwrap_err()
.message()
.contains("truncated"),);
}
// --- The agent checkpoint through a real envelope ------------------------------------------------
#[test]
fn agent_chunks_survive_a_full_envelope_round_trip() {
let (data, _) = synthetic_dataset();
let frames = frame_pool(8, 5150, 160, 144);
let mut config = AgentConfig::with_decoder(gameboy_decoder_config());
config.frame = Some(FrameSize {
width: 160,
height: 144,
});
let mut source = NeuralAgent::new(Arc::clone(&data), config.clone()).expect("an agent");
source.warmup(Some(&frames[0])).expect("warmup");
for frame in 1..=150usize {
let rewards = if frame.is_multiple_of(50) {
vec![RewardEvent::new(0.6)]
} else {
Vec::new()
};
source
.tick(
&frames[frame % frames.len()],
&TickOptions {
rewards: &rewards,
boot: (frame / 120).is_multiple_of(2),
learn: !frame.is_multiple_of(90),
},
)
.expect("tick");
}
let parts = agent_to_chunks(&source.export_state());
assert_eq!(
parts
.chunks
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>(),
AGENT_CHUNK_NAMES
);
let chunk = |name: &str| {
parts
.chunks
.iter()
.find(|(key, _)| key == name)
.map(|(_, bytes)| bytes.len())
.expect("a chunk")
};
assert_eq!(chunk("membrane"), data.meta.neurons * 4);
assert_eq!(chunk("lastSpikeMs"), data.meta.neurons * 8);
assert_eq!(chunk("visualDrive"), data.visual_indices.len() * 4);
let mut with_compatibility = parts.manifest.clone();
with_compatibility.set("compatibility", source.compatibility().as_str().into());
let bytes = encode_envelope(MAGIC, &with_compatibility, &parts.chunks).expect("encode");
let decoded = decode_envelope(&bytes, MAGIC).expect("decode");
assert_eq!(
decoded
.manifest
.get("compatibility")
.and_then(JsonValue::as_str),
Some(source.compatibility().as_str())
);
let state = agent_from_chunks(&decoded.manifest, &decoded).expect("rebuild");
let mut restored = NeuralAgent::new(Arc::clone(&data), config).expect("an agent");
restored.import_state(&state).expect("import");
assert_eq!(restored.export_state(), source.export_state());
// And the two run on identically from there.
for frame in 151..=200usize {
let options = TickOptions {
rewards: &[],
boot: (frame / 120).is_multiple_of(2),
learn: true,
};
let image = &frames[frame % frames.len()];
assert_eq!(
restored.tick(image, &options).expect("tick"),
source.tick(image, &options).expect("tick"),
"diverged at frame {frame}"
);
}
assert_eq!(restored.export_state(), source.export_state());
}
#[test]
fn agent_from_chunks_rejects_an_incomplete_or_misaligned_checkpoint() {
let (data, _) = synthetic_dataset();
let mut config = AgentConfig::with_decoder(gameboy_decoder_config());
config.frame = Some(FrameSize {
width: 160,
height: 144,
});
let mut agent = NeuralAgent::new(Arc::clone(&data), config).expect("an agent");
agent.warmup(None).expect("warmup");
let parts = agent_to_chunks(&agent.export_state());
let envelope = flybrain_core::envelope::EnvelopeParts {
manifest: parts.manifest.clone(),
chunks: parts.chunks.clone(),
};
let mut wrong_version = parts.manifest.clone();
wrong_version.set("agentVersion", 2.0.into());
assert_eq!(
agent_from_chunks(&wrong_version, &envelope)
.unwrap_err()
.message(),
"Unsupported agent checkpoint version"
);
let mut no_decoder = parts.manifest.clone();
no_decoder.set("decoder", JsonValue::Null);
assert_eq!(
agent_from_chunks(&no_decoder, &envelope)
.unwrap_err()
.message(),
"Agent checkpoint manifest is incomplete"
);
for name in AGENT_CHUNK_NAMES {
let missing = flybrain_core::envelope::EnvelopeParts {
manifest: parts.manifest.clone(),
chunks: parts
.chunks
.iter()
.filter(|(key, _)| key != name)
.cloned()
.collect(),
};
assert_eq!(
agent_from_chunks(&parts.manifest, &missing)
.unwrap_err()
.message(),
format!("Checkpoint is missing {name}")
);
}
let partial = flybrain_core::envelope::EnvelopeParts {
manifest: parts.manifest.clone(),
chunks: parts
.chunks
.iter()
.map(|(key, bytes)| {
if key == "membrane" {
(key.clone(), bytes[..bytes.len() - 1].to_vec())
} else {
(key.clone(), bytes.clone())
}
})
.collect(),
};
assert_eq!(
agent_from_chunks(&parts.manifest, &partial)
.unwrap_err()
.message(),
"Checkpoint chunk membrane has a partial element"
);
}