Skip to content

Commit 850b879

Browse files
authored
Merge pull request #15 from Steake/copilot/sub-pr-14
fix: Address MII+ review feedback - hash position, bounds checks, test coverage
2 parents 4c12826 + ae99d6b commit 850b879

7 files changed

Lines changed: 206 additions & 27 deletions

File tree

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ members = [
1010
"crates/bitcell-economics",
1111
"crates/bitcell-network",
1212
"crates/bitcell-node",
13-
"crates/bitcell-admin", "crates/bitcell-simulation",
13+
"crates/bitcell-admin",
14+
"crates/bitcell-simulation",
1415
]
1516
resolver = "2"
1617

crates/bitcell-admin/src/api/blocks.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,14 @@ pub async fn get_block(
147147
));
148148
}
149149

150+
// Handle edge case of height == 0 to prevent underflow
151+
if height == 0 {
152+
return Err((
153+
StatusCode::BAD_REQUEST,
154+
Json("Invalid block height: cannot be 0".to_string()),
155+
));
156+
}
157+
150158
Ok(Json(BlockDetailResponse {
151159
height,
152160
hash: format!("0x{:016x}", height * 12345),

crates/bitcell-ca/src/battle.rs

Lines changed: 178 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,16 @@ impl Battle {
167167

168168
/// Calculate spawn position jitter from entropy seed
169169
/// Returns (x_offset, y_offset) in range [-10, 10]
170-
fn calculate_spawn_jitter(&self, seed_offset: usize) -> (isize, isize) {
170+
pub(crate) fn calculate_spawn_jitter(&self, seed_offset: usize) -> (isize, isize) {
171171
if self.entropy_seed == [0u8; 32] {
172172
return (0, 0);
173173
}
174174

175+
// Ensure seed_offset + 7 is within bounds of 32-byte array
176+
if seed_offset + 7 >= 32 {
177+
return (0, 0);
178+
}
179+
175180
// Use different parts of entropy seed for x and y
176181
let x_bytes = [
177182
self.entropy_seed[seed_offset],
@@ -186,12 +191,13 @@ impl Battle {
186191
self.entropy_seed[seed_offset + 7],
187192
];
188193

189-
let x_val = i32::from_le_bytes(x_bytes);
190-
let y_val = i32::from_le_bytes(y_bytes);
194+
// Use u32 to avoid negative modulo issues
195+
let x_val = u32::from_le_bytes(x_bytes);
196+
let y_val = u32::from_le_bytes(y_bytes);
191197

192-
// Map to [-10, 10] range
193-
let x_jitter = (x_val % 21 - 10) as isize;
194-
let y_jitter = (y_val % 21 - 10) as isize;
198+
// Map to [-10, 10] range: (x % 21) gives 0-20, subtract 10 gives -10 to 10
199+
let x_jitter = (x_val % 21) as isize - 10;
200+
let y_jitter = (y_val % 21) as isize - 10;
195201

196202
(x_jitter, y_jitter)
197203
}
@@ -227,7 +233,12 @@ impl Battle {
227233
// Random energy from entropy
228234
let energy = (self.entropy_seed[(seed_idx + 20) % 32] % 100) + 1;
229235

230-
grid.set(Position::new(x, y), Cell::alive(energy));
236+
// Skip positions that already have live cells (gliders)
237+
let pos = Position::new(x, y);
238+
if grid.get(pos).is_alive() {
239+
continue;
240+
}
241+
grid.set(pos, Cell::alive(energy));
231242
}
232243
}
233244

@@ -298,7 +309,7 @@ impl Battle {
298309
}
299310

300311
/// Calculate energy fluctuations from entropy (±5%)
301-
fn calculate_energy_fluctuations(&self) -> (f64, f64) {
312+
pub(crate) fn calculate_energy_fluctuations(&self) -> (f64, f64) {
302313
let fluct_a_byte = self.entropy_seed[24];
303314
let fluct_b_byte = self.entropy_seed[25];
304315

@@ -426,19 +437,22 @@ impl Battle {
426437
}
427438

428439
/// Lexicographic tiebreaker using hash of glider + entropy seed
429-
fn lexicographic_break(&self) -> BattleOutcome {
440+
pub(crate) fn lexicographic_break(&self) -> BattleOutcome {
430441
let hash_a = self.hash_glider(&self.glider_a);
431442
let hash_b = self.hash_glider(&self.glider_b);
432443

433444
if hash_a < hash_b {
434445
BattleOutcome::AWins
435-
} else {
446+
} else if hash_a > hash_b {
436447
BattleOutcome::BWins
448+
} else {
449+
// Hashes equal - should never happen with proper entropy, but handle gracefully
450+
BattleOutcome::AWins
437451
}
438452
}
439453

440454
/// Simple FNV-1a hash for deterministic tiebreaking
441-
fn hash_glider(&self, glider: &Glider) -> u64 {
455+
pub(crate) fn hash_glider(&self, glider: &Glider) -> u64 {
442456
let mut hash = 0xcbf29ce484222325; // FNV offset basis
443457

444458
// Mix in entropy seed
@@ -453,6 +467,12 @@ impl Battle {
453467
hash = hash.wrapping_mul(0x100000001b3);
454468
}
455469

470+
// Mix in glider position
471+
hash ^= glider.position.x as u64;
472+
hash = hash.wrapping_mul(0x100000001b3);
473+
hash ^= glider.position.y as u64;
474+
hash = hash.wrapping_mul(0x100000001b3);
475+
456476
hash
457477
}
458478

@@ -656,10 +676,155 @@ mod tests {
656676
assert!(ted_a >= 0.0);
657677
assert!(ted_b >= 0.0);
658678

659-
// Outcome should be valid
679+
// Outcome should never be Tie with MII+ tiebreaker system fully implemented
660680
assert!(matches!(
661681
outcome,
662-
BattleOutcome::AWins | BattleOutcome::BWins | BattleOutcome::Tie
682+
BattleOutcome::AWins | BattleOutcome::BWins
663683
));
664684
}
685+
686+
#[test]
687+
fn test_spawn_jitter_range() {
688+
// Test that spawn jitter stays within [-10, 10] range
689+
let glider_a = Glider::new(GliderPattern::Standard, SPAWN_A);
690+
let glider_b = Glider::new(GliderPattern::Standard, SPAWN_B);
691+
692+
// Test with various entropy seeds
693+
for seed_byte in [0u8, 1, 127, 255] {
694+
let entropy_seed = [seed_byte; 32];
695+
let battle = Battle::with_entropy(glider_a.clone(), glider_b.clone(), 10, entropy_seed);
696+
697+
let (jitter_x, jitter_y) = battle.calculate_spawn_jitter(0);
698+
assert!(jitter_x >= -10 && jitter_x <= 10, "X jitter out of range: {}", jitter_x);
699+
assert!(jitter_y >= -10 && jitter_y <= 10, "Y jitter out of range: {}", jitter_y);
700+
}
701+
}
702+
703+
#[test]
704+
fn test_spawn_jitter_determinism() {
705+
// Test that same entropy seed produces same jitter
706+
let glider_a = Glider::new(GliderPattern::Standard, SPAWN_A);
707+
let glider_b = Glider::new(GliderPattern::Standard, SPAWN_B);
708+
let entropy_seed = [42u8; 32];
709+
710+
let battle1 = Battle::with_entropy(glider_a.clone(), glider_b.clone(), 10, entropy_seed);
711+
let battle2 = Battle::with_entropy(glider_a, glider_b, 10, entropy_seed);
712+
713+
assert_eq!(battle1.calculate_spawn_jitter(0), battle2.calculate_spawn_jitter(0));
714+
assert_eq!(battle1.calculate_spawn_jitter(8), battle2.calculate_spawn_jitter(8));
715+
}
716+
717+
#[test]
718+
fn test_energy_fluctuations_range() {
719+
// Test that energy fluctuations stay within [0.95, 1.05] range
720+
let glider_a = Glider::new(GliderPattern::Standard, SPAWN_A);
721+
let glider_b = Glider::new(GliderPattern::Standard, SPAWN_B);
722+
723+
for seed_byte in [0u8, 1, 127, 255] {
724+
let entropy_seed = [seed_byte; 32];
725+
let battle = Battle::with_entropy(glider_a.clone(), glider_b.clone(), 10, entropy_seed);
726+
727+
let (fluct_a, fluct_b) = battle.calculate_energy_fluctuations();
728+
assert!(fluct_a >= 0.95 && fluct_a <= 1.05, "Fluctuation A out of range: {}", fluct_a);
729+
assert!(fluct_b >= 0.95 && fluct_b <= 1.05, "Fluctuation B out of range: {}", fluct_b);
730+
}
731+
}
732+
733+
#[test]
734+
fn test_noise_skips_existing_cells() {
735+
// Test that noise doesn't overwrite existing glider cells
736+
let glider_a = Glider::with_energy(GliderPattern::Standard, SPAWN_A, 200);
737+
let glider_b = Glider::with_energy(GliderPattern::Standard, SPAWN_B, 200);
738+
let entropy_seed = [1u8; 32];
739+
740+
let battle = Battle::with_entropy(glider_a, glider_b, 10, entropy_seed);
741+
let grid = battle.initial_grid();
742+
743+
// Glider cells should still have their original high energy (200)
744+
// Noise cells have energy between 1-100
745+
// Check that high-energy cells exist (indicating gliders weren't overwritten)
746+
let mut high_energy_count = 0;
747+
for y in 0..1024 {
748+
for x in 0..1024 {
749+
let cell = grid.get(Position::new(x, y));
750+
if cell.energy() >= 200 {
751+
high_energy_count += 1;
752+
}
753+
}
754+
}
755+
756+
// Both gliders should have their cells intact (each has 5 cells for Standard pattern)
757+
assert!(high_energy_count >= 10, "Expected at least 10 high-energy cells, got {}", high_energy_count);
758+
}
759+
760+
#[test]
761+
fn test_lexicographic_tiebreaker_determinism() {
762+
// Test that same gliders with same entropy produce same outcome
763+
let glider_a = Glider::new(GliderPattern::Standard, SPAWN_A);
764+
let glider_b = Glider::new(GliderPattern::Standard, SPAWN_B);
765+
let entropy_seed = [42u8; 32];
766+
767+
let battle1 = Battle::with_entropy(glider_a.clone(), glider_b.clone(), 10, entropy_seed);
768+
let battle2 = Battle::with_entropy(glider_a, glider_b, 10, entropy_seed);
769+
770+
let outcome1 = battle1.lexicographic_break();
771+
let outcome2 = battle2.lexicographic_break();
772+
773+
assert_eq!(outcome1, outcome2, "Same inputs should produce same lexicographic outcome");
774+
}
775+
776+
#[test]
777+
fn test_lexicographic_different_positions() {
778+
// Test that gliders with same pattern but different positions produce different hashes
779+
let glider_a1 = Glider::new(GliderPattern::Standard, Position::new(100, 100));
780+
let glider_a2 = Glider::new(GliderPattern::Standard, Position::new(200, 200));
781+
let glider_b = Glider::new(GliderPattern::Standard, SPAWN_B);
782+
let entropy_seed = [42u8; 32];
783+
784+
let battle1 = Battle::with_entropy(glider_a1, glider_b.clone(), 10, entropy_seed);
785+
let battle2 = Battle::with_entropy(glider_a2, glider_b, 10, entropy_seed);
786+
787+
let hash1 = battle1.hash_glider(&battle1.glider_a);
788+
let hash2 = battle2.hash_glider(&battle2.glider_a);
789+
790+
assert_ne!(hash1, hash2, "Same pattern at different positions should produce different hashes");
791+
}
792+
793+
#[test]
794+
fn test_lexicographic_different_entropy() {
795+
// Test that same gliders with different entropy produce different hashes
796+
let glider_a = Glider::new(GliderPattern::Standard, SPAWN_A);
797+
let glider_b = Glider::new(GliderPattern::Standard, SPAWN_B);
798+
799+
let battle1 = Battle::with_entropy(glider_a.clone(), glider_b.clone(), 10, [1u8; 32]);
800+
let battle2 = Battle::with_entropy(glider_a, glider_b, 10, [2u8; 32]);
801+
802+
let hash1 = battle1.hash_glider(&battle1.glider_a);
803+
let hash2 = battle2.hash_glider(&battle2.glider_a);
804+
805+
assert_ne!(hash1, hash2, "Different entropy seeds should produce different hashes");
806+
}
807+
808+
#[test]
809+
fn test_lexicographic_ordering() {
810+
// Test that hash ordering is consistent
811+
let glider_a = Glider::new(GliderPattern::Standard, SPAWN_A);
812+
let glider_b = Glider::new(GliderPattern::Heavyweight, SPAWN_B);
813+
let entropy_seed = [42u8; 32];
814+
815+
let battle = Battle::with_entropy(glider_a, glider_b, 10, entropy_seed);
816+
let hash_a = battle.hash_glider(&battle.glider_a);
817+
let hash_b = battle.hash_glider(&battle.glider_b);
818+
819+
let outcome = battle.lexicographic_break();
820+
821+
if hash_a < hash_b {
822+
assert_eq!(outcome, BattleOutcome::AWins);
823+
} else if hash_a > hash_b {
824+
assert_eq!(outcome, BattleOutcome::BWins);
825+
} else {
826+
// If hashes are equal, lexicographic_break returns AWins
827+
assert_eq!(outcome, BattleOutcome::AWins);
828+
}
829+
}
665830
}

crates/bitcell-consensus/src/orchestrator.rs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -288,12 +288,6 @@ impl TournamentOrchestrator {
288288
let history_matches = matches.iter().filter(|m| m.battle_config.track_history).count();
289289
let avg_rounds = (matches.last().map(|m| m.round).unwrap_or(0) + 1) as f64;
290290

291-
// No need to explicitly drop matches as it's just a reference going out of scope
292-
// But we need to ensure we don't use it after this point if we want to mutate self
293-
// The borrow checker sees that we don't use `matches` after this point, so we can mutate `self`
294-
// However, to be explicit and satisfy the compiler if it complains about overlapping borrows:
295-
// We already calculated the metrics that needed `matches`.
296-
297291
// Apply evidence updates
298292
for (miner, evidence_type) in evidence_updates {
299293
self.record_evidence(miner, evidence_type);

crates/bitcell-simulation/Cargo.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ repository.workspace = true
1010
[dependencies]
1111
bitcell-consensus = { path = "../bitcell-consensus" }
1212
bitcell-ca = { path = "../bitcell-ca" }
13-
bitcell-ebsl = { path = "../bitcell-ebsl" }
1413
bitcell-crypto = { path = "../bitcell-crypto" }
1514
serde = { version = "1.0", features = ["derive"] }
1615
rand = "0.8"
17-
thiserror = "1.0"

crates/bitcell-simulation/src/lib.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ use bitcell_ca::{Glider, GliderPattern, Position};
99

1010
use rand::Rng;
1111

12+
/// Derive a spawn position from a public key for varied positions
13+
fn derive_position_from_pubkey(pk: &PublicKey) -> Position {
14+
let pk_bytes = pk.as_bytes();
15+
let x = ((pk_bytes[0] as usize) * 4) % 512 + 50;
16+
let y = ((pk_bytes[1] as usize) * 4) % 512 + 50;
17+
Position::new(x, y)
18+
}
19+
1220
/// Trait defining a miner's behavior in the simulation
1321
pub trait MinerAgent {
1422
/// Get the miner's public key
@@ -47,8 +55,8 @@ impl MinerAgent for HonestMiner {
4755
}
4856

4957
fn generate_commitment(&mut self, height: u64) -> GliderCommitment {
50-
// Honest miner picks a standard glider
51-
let glider = Glider::new(GliderPattern::Standard, Position::new(100, 100));
58+
let position = derive_position_from_pubkey(&self.public_key());
59+
let glider = Glider::new(GliderPattern::Standard, position);
5260
let nonce = vec![0u8; 32]; // Simplified nonce
5361

5462
// Store for reveal
@@ -100,8 +108,9 @@ impl MinerAgent for TieFarmer {
100108
}
101109

102110
fn generate_commitment(&mut self, height: u64) -> GliderCommitment {
111+
let position = derive_position_from_pubkey(&self.public_key());
103112
// Tie farmer picks a symmetric pattern (e.g., Heavyweight)
104-
let glider = Glider::new(GliderPattern::Heavyweight, Position::new(100, 100));
113+
let glider = Glider::new(GliderPattern::Heavyweight, position);
105114
self.current_glider = Some(glider);
106115

107116
GliderCommitment {
@@ -145,9 +154,10 @@ impl MinerAgent for ChaosSpammer {
145154
}
146155

147156
fn generate_commitment(&mut self, height: u64) -> GliderCommitment {
157+
let position = derive_position_from_pubkey(&self.public_key());
148158
// Chaos spammer uses a custom high-entropy pattern (simulated here with Heavyweight for now)
149159
// In a real scenario, this would be a random blob
150-
let glider = Glider::new(GliderPattern::Heavyweight, Position::new(100, 100));
160+
let glider = Glider::new(GliderPattern::Heavyweight, position);
151161
self.current_glider = Some(glider);
152162

153163
GliderCommitment {
@@ -193,7 +203,8 @@ impl MinerAgent for FlakyGriefer {
193203
}
194204

195205
fn generate_commitment(&mut self, height: u64) -> GliderCommitment {
196-
let glider = Glider::new(GliderPattern::Standard, Position::new(100, 100));
206+
let position = derive_position_from_pubkey(&self.public_key());
207+
let glider = Glider::new(GliderPattern::Standard, position);
197208
self.current_glider = Some(glider);
198209

199210
GliderCommitment {

docs/MII+.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ This ensures fully deterministic resolution with no extra signalling channels.
6767

6868
## **4. Optional Mechanic: Deterministic Evolving Cell Phenotypes**
6969

70+
> **Note:** This feature is deferred to a future PR. The implementation below describes the planned design.
71+
7072
Add a phenotype field to each cell (2–4 bits). Mutation occurs when cell energy exceeds a threshold `theta`.
7173

7274
Mutation rule:

0 commit comments

Comments
 (0)