#[cfg(feature = "gpu")]
use std::borrow::Cow;
#[derive(Debug, Clone, PartialEq)]
pub struct LogFeatures {
pub trace_length: f32,
pub elapsed_time: f32,
pub rework_count: f32,
pub unique_activities: f32,
pub avg_inter_event_time: f32,
pub log_size_bin: f32,
pub activity_entropy: f32,
pub variant_ratio: f32,
}
impl LogFeatures {
pub fn to_f32_array(&self) -> [f32; 8] {
[
clamp01(self.trace_length),
clamp01(self.elapsed_time / 2.0), clamp01(self.rework_count),
clamp01(self.unique_activities),
clamp01(self.avg_inter_event_time),
clamp01(self.log_size_bin),
clamp01(self.activity_entropy),
clamp01(self.variant_ratio),
]
}
pub fn is_valid(&self) -> bool {
[
self.trace_length,
self.elapsed_time,
self.rework_count,
self.unique_activities,
self.avg_inter_event_time,
self.log_size_bin,
self.activity_entropy,
self.variant_ratio,
]
.iter()
.all(|v| v.is_finite())
}
}
#[derive(Debug, Clone)]
pub struct LinUcbResult {
pub action_index: u32,
pub algorithm_id: &'static str,
pub ucb_value: f32,
pub ucb_bonus: f32,
pub gpu_accelerated: bool,
}
pub const ALGORITHM_IDS: [&str; 40] = [
"process_skeleton", "dfg", "alpha_plus_plus", "heuristic_miner", "inductive_miner", "declare", "hill_climbing", "simulated_annealing", "a_star", "aco", "optimized_dfg", "pso", "genetic_algorithm", "ilp", "transition_system", "log_to_trie", "causal_graph", "performance_spectrum", "batches", "correlation_miner", "generalization", "petri_net_reduction", "etconformance_precision", "alignments", "complexity_metrics", "pnml_import", "bpmn_import", "powl_to_process_tree", "yawl_export", "playout", "monte_carlo_simulation", "ml_classify", "ml_cluster", "ml_forecast", "ml_anomaly", "ml_regress", "ml_pca", "_reserved_37", "_reserved_38", "_reserved_39", ];
const N_FEATURES: usize = 8;
const N_ACTIONS: usize = 5;
#[cfg(any(feature = "gpu", test))]
const BATCH_SIZE: usize = 2048;
pub struct LinUcbState {
pub w_matrix: Vec<f32>, pub a_inv: Vec<f32>, pub b_vector: Vec<f32>, pub alpha: f32,
}
impl Default for LinUcbState {
fn default() -> Self {
Self::new(1.0)
}
}
impl LinUcbState {
pub fn new(alpha: f32) -> Self {
let mut a_inv = vec![0.0f32; N_FEATURES * N_FEATURES];
for i in 0..N_FEATURES {
a_inv[i * N_FEATURES + i] = 1.0;
}
Self {
w_matrix: vec![0.0f32; N_ACTIONS * N_FEATURES],
a_inv,
b_vector: vec![0.0f32; N_ACTIONS * N_FEATURES],
alpha,
}
}
#[allow(clippy::needless_range_loop)]
pub fn infer_cpu(&self, features: &LogFeatures) -> (u32, f32, f32) {
let x = features.to_f32_array();
let mut a_inv_x = [0.0f32; N_FEATURES];
for i in 0..N_FEATURES {
let mut acc = 0.0f32;
for j in 0..N_FEATURES {
acc += self.a_inv[i * N_FEATURES + j] * x[j];
}
a_inv_x[i] = acc;
}
let xt_ainv_x: f32 = x.iter().zip(a_inv_x.iter()).map(|(xi, ai)| xi * ai).sum();
let ucb_bonus = self.alpha * xt_ainv_x.max(0.0).sqrt();
let mut best_action = 0u32;
let mut best_q = f32::NEG_INFINITY;
for a in 0..N_ACTIONS {
let offset = a * N_FEATURES;
let dot: f32 = self.w_matrix[offset..offset + N_FEATURES]
.iter()
.zip(x.iter())
.map(|(w, xi)| w * xi)
.sum();
let q = dot + ucb_bonus;
if q > best_q {
best_q = q;
best_action = a as u32;
}
}
(best_action, best_q, ucb_bonus)
}
#[allow(clippy::needless_range_loop)]
pub fn update(&mut self, features: &LogFeatures, action_idx: usize, reward: f32) {
if action_idx >= N_ACTIONS {
return; }
let x = features.to_f32_array();
let mut a_inv_x = [0.0f32; N_FEATURES];
for i in 0..N_FEATURES {
let mut acc = 0.0f32;
for j in 0..N_FEATURES {
acc += self.a_inv[i * N_FEATURES + j] * x[j];
}
a_inv_x[i] = acc;
}
let xt_ainv_x: f32 = x.iter().zip(a_inv_x.iter()).map(|(xi, ai)| xi * ai).sum();
let denom = 1.0 + xt_ainv_x;
if denom.abs() < 1e-8 {
return; }
for i in 0..N_FEATURES {
for j in 0..N_FEATURES {
self.a_inv[i * N_FEATURES + j] -= a_inv_x[i] * a_inv_x[j] / denom;
}
}
let b_off = action_idx * N_FEATURES;
for i in 0..N_FEATURES {
self.b_vector[b_off + i] += reward * x[i];
}
let w_off = action_idx * N_FEATURES;
for i in 0..N_FEATURES {
let mut acc = 0.0f32;
for j in 0..N_FEATURES {
acc += self.a_inv[i * N_FEATURES + j] * self.b_vector[b_off + j];
}
self.w_matrix[w_off + i] = acc;
}
}
}
#[cfg(feature = "gpu")]
pub struct GpuLinUcb {
device: wgpu::Device,
queue: wgpu::Queue,
pipeline: wgpu::ComputePipeline,
bind_group_layout: wgpu::BindGroupLayout,
buf_features: wgpu::Buffer, buf_w_matrix: wgpu::Buffer, buf_a_inv: wgpu::Buffer, buf_alpha: wgpu::Buffer, buf_actions: wgpu::Buffer, buf_ucb: wgpu::Buffer, buf_readback: wgpu::Buffer, }
#[cfg(feature = "gpu")]
impl GpuLinUcb {
pub async fn new() -> Result<Option<Self>, String> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(),
dx12_shader_compiler: wgpu::Dx12Compiler::Fxc,
..Default::default()
});
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: None,
force_fallback_adapter: false,
})
.await
{
Some(a) => a,
None => {
match instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::None,
compatible_surface: None,
force_fallback_adapter: true,
})
.await
{
Some(a) => a,
None => {
eprintln!(
"[LinUCB] No GPU adapter available — falling back to CPU inference"
);
return Ok(None);
}
}
}
};
let adapter_info = adapter.get_info();
eprintln!(
"[LinUCB] GPU adapter: {} ({:?})",
adapter_info.name, adapter_info.device_type
);
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("linucb"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
..Default::default()
},
None,
)
.await
.map_err(|e| format!("GPU device request failed: {e}"))?;
let shader_src = include_str!("linucb_kernel.wgsl");
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("linucb_shader"),
source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(shader_src)),
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("linucb_bgl"),
entries: &[
bgl_entry(0, wgpu::BufferBindingType::Storage { read_only: true }),
bgl_entry(1, wgpu::BufferBindingType::Storage { read_only: true }),
bgl_entry(2, wgpu::BufferBindingType::Storage { read_only: true }),
bgl_entry(3, wgpu::BufferBindingType::Storage { read_only: true }),
bgl_entry(4, wgpu::BufferBindingType::Storage { read_only: false }),
bgl_entry(5, wgpu::BufferBindingType::Storage { read_only: false }),
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("linucb_pl"),
bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("linucb_pipeline"),
layout: Some(&pipeline_layout),
module: &shader,
entry_point: "linucb_select",
});
let features_bytes = (BATCH_SIZE * N_FEATURES * 4) as u64;
let weights_bytes = (N_ACTIONS * N_FEATURES * 4) as u64;
let a_inv_bytes = (N_FEATURES * N_FEATURES * 4) as u64;
let alpha_bytes = 4u64;
let actions_bytes = (BATCH_SIZE * 4) as u64;
let ucb_bytes = (BATCH_SIZE * 4) as u64;
let readback_bytes = actions_bytes + ucb_bytes;
let buf_features = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("features_in"),
size: features_bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let buf_w_matrix = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("w_matrix"),
size: weights_bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let buf_a_inv = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("a_inv"),
size: a_inv_bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let buf_alpha = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("alpha_buf"),
size: alpha_bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let buf_actions = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("actions_out"),
size: actions_bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let buf_ucb = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("ucb_out"),
size: ucb_bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let buf_readback = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: readback_bytes,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Ok(Some(Self {
device,
queue,
pipeline,
bind_group_layout,
buf_features,
buf_w_matrix,
buf_a_inv,
buf_alpha,
buf_actions,
buf_ucb,
buf_readback,
}))
}
pub async fn infer_batch(
&self,
features: &[f32],
state: &LinUcbState,
) -> Result<(Vec<u32>, Vec<f32>), String> {
let n = features.len() / N_FEATURES;
if n == 0 || features.len() % N_FEATURES != 0 {
return Err(format!(
"features length {} is not a multiple of N_FEATURES={}",
features.len(),
N_FEATURES
));
}
if n > BATCH_SIZE {
return Err(format!(
"batch size {} exceeds maximum {} — split into smaller batches",
n, BATCH_SIZE
));
}
let features_bytes = bytemuck_cast_slice_f32(features);
self.queue
.write_buffer(&self.buf_features, 0, features_bytes);
let w_bytes = bytemuck_cast_slice_f32(&state.w_matrix);
self.queue.write_buffer(&self.buf_w_matrix, 0, w_bytes);
let a_inv_bytes = bytemuck_cast_slice_f32(&state.a_inv);
self.queue.write_buffer(&self.buf_a_inv, 0, a_inv_bytes);
let alpha_arr = [state.alpha];
let alpha_bytes = bytemuck_cast_slice_f32(&alpha_arr);
self.queue.write_buffer(&self.buf_alpha, 0, alpha_bytes);
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("linucb_bg"),
layout: &self.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.buf_features.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.buf_w_matrix.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.buf_a_inv.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: self.buf_alpha.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: self.buf_actions.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 5,
resource: self.buf_ucb.as_entire_binding(),
},
],
});
let workgroup_count = ((n as u32) + 31) / 32;
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("linucb_encoder"),
});
{
let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("linucb_pass"),
timestamp_writes: None,
});
cpass.set_pipeline(&self.pipeline);
cpass.set_bind_group(0, &bind_group, &[]);
cpass.dispatch_workgroups(workgroup_count, 1, 1);
}
let actions_bytes_size = (n * 4) as u64;
let ucb_bytes_size = (n * 4) as u64;
encoder.copy_buffer_to_buffer(
&self.buf_actions,
0,
&self.buf_readback,
0,
actions_bytes_size,
);
encoder.copy_buffer_to_buffer(
&self.buf_ucb,
0,
&self.buf_readback,
actions_bytes_size,
ucb_bytes_size,
);
self.queue.submit(std::iter::once(encoder.finish()));
let readback_slice = self
.buf_readback
.slice(0..(actions_bytes_size + ucb_bytes_size));
let (tx, rx) = std::sync::mpsc::channel::<Result<(), wgpu::BufferAsyncError>>();
readback_slice.map_async(wgpu::MapMode::Read, move |v| {
let _ = tx.send(v);
});
self.device.poll(wgpu::MaintainBase::Wait);
rx.recv()
.map_err(|_| "GPU readback channel error".to_string())?
.map_err(|e| format!("GPU buffer map error: {e}"))?;
let data = readback_slice.get_mapped_range();
let raw: &[u8] = &data;
let actions_raw = &raw[..actions_bytes_size as usize];
let ucb_raw = &raw[actions_bytes_size as usize..];
let actions: Vec<u32> = actions_raw
.chunks_exact(4)
.map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
.collect();
let ucb_vals: Vec<f32> = ucb_raw
.chunks_exact(4)
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
.collect();
drop(data);
self.buf_readback.unmap();
Ok((actions, ucb_vals))
}
pub async fn infer_single(
&self,
features: &LogFeatures,
state: &LinUcbState,
) -> Result<LinUcbResult, String> {
if !features.is_valid() {
return Err("Feature vector contains NaN or Inf — cannot dispatch to GPU".to_string());
}
let flat = features.to_f32_array();
let (actions, ucb_vals) = self.infer_batch(&flat, state).await?;
let action_idx = actions[0].min((N_ACTIONS - 1) as u32);
let (_, _, ucb_bonus) = state.infer_cpu(features);
Ok(LinUcbResult {
action_index: action_idx,
algorithm_id: ALGORITHM_IDS[action_idx as usize],
ucb_value: ucb_vals[0],
ucb_bonus,
gpu_accelerated: true,
})
}
}
pub fn select_algorithm(features: &LogFeatures, state: &LinUcbState) -> LinUcbResult {
#[cfg(feature = "gpu")]
{
use std::future::Future;
fn block_on<F: Future<Output = T>, T>(f: F) -> T {
pollster::block_on(f)
}
match block_on(GpuLinUcb::new()) {
Ok(Some(gpu)) => match block_on(gpu.infer_single(features, state)) {
Ok(result) => return result,
Err(e) => eprintln!("[LinUCB] GPU inference error — CPU fallback: {e}"),
},
Ok(None) => { }
Err(e) => eprintln!("[LinUCB] GPU init error — CPU fallback: {e}"),
}
}
cpu_infer(features, state)
}
pub fn cpu_infer(features: &LogFeatures, state: &LinUcbState) -> LinUcbResult {
let (action_idx, ucb_value, ucb_bonus) = state.infer_cpu(features);
let action_idx = action_idx.min((N_ACTIONS - 1) as u32);
LinUcbResult {
action_index: action_idx,
algorithm_id: ALGORITHM_IDS[action_idx as usize],
ucb_value,
ucb_bonus,
gpu_accelerated: false,
}
}
#[inline]
fn clamp01(v: f32) -> f32 {
v.clamp(0.0, 1.0)
}
#[cfg(feature = "gpu")]
fn bytemuck_cast_slice_f32(data: &[f32]) -> &[u8] {
unsafe { std::slice::from_raw_parts(data.as_ptr() as *const u8, data.len() * 4) }
}
#[cfg(feature = "gpu")]
fn bgl_entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_features() -> LogFeatures {
LogFeatures {
trace_length: 0.3,
elapsed_time: 0.5,
rework_count: 0.1,
unique_activities: 0.2,
avg_inter_event_time: 0.4,
log_size_bin: 0.6,
activity_entropy: 0.7,
variant_ratio: 0.15,
}
}
#[test]
fn cpu_infer_returns_valid_action() {
let state = LinUcbState::default();
let features = default_features();
let result = cpu_infer(&features, &state);
assert!(
(result.action_index as usize) < N_ACTIONS,
"action_index {} out of bounds (N_ACTIONS={})",
result.action_index,
N_ACTIONS
);
assert!(!result.algorithm_id.is_empty());
assert!(result.ucb_value.is_finite(), "ucb_value is not finite");
assert!(!result.gpu_accelerated);
}
#[test]
fn cpu_infer_is_deterministic() {
let state = LinUcbState::default();
let features = default_features();
let r1 = cpu_infer(&features, &state);
let r2 = cpu_infer(&features, &state);
assert_eq!(
r1.action_index, r2.action_index,
"inference not deterministic"
);
assert_eq!(r1.ucb_value, r2.ucb_value, "UCB value not deterministic");
}
#[test]
fn identity_a_inv_gives_ucb_bonus_equal_to_alpha_times_norm() {
let state = LinUcbState::new(1.0);
let features = LogFeatures {
trace_length: 1.0,
elapsed_time: 0.0,
rework_count: 0.0,
unique_activities: 0.0,
avg_inter_event_time: 0.0,
log_size_bin: 0.0,
activity_entropy: 0.0,
variant_ratio: 0.0,
};
let (_, _, bonus) = state.infer_cpu(&features);
assert!(
(bonus - 1.0).abs() < 1e-5,
"Expected UCB bonus ~1.0, got {bonus}"
);
}
#[test]
fn update_changes_weights() {
let mut state = LinUcbState::default();
let features = default_features();
let action = 0usize;
let reward = 1.0f32;
let w_before: Vec<f32> = state.w_matrix[0..N_FEATURES].to_vec();
state.update(&features, action, reward);
let w_after: Vec<f32> = state.w_matrix[0..N_FEATURES].to_vec();
let changed = w_before
.iter()
.zip(w_after.iter())
.any(|(b, a)| (b - a).abs() > 1e-10);
assert!(changed, "Weight update had no effect on w_matrix");
}
#[test]
fn update_with_out_of_range_action_does_not_panic() {
let mut state = LinUcbState::default();
let features = default_features();
state.update(&features, N_ACTIONS + 99, 1.0);
}
#[test]
fn invalid_features_detected() {
let mut f = default_features();
f.trace_length = f32::NAN;
assert!(!f.is_valid(), "NaN feature should be flagged as invalid");
f.trace_length = f32::INFINITY;
assert!(!f.is_valid(), "Inf feature should be flagged as invalid");
}
#[test]
fn algorithm_ids_length_correct() {
assert!(
ALGORITHM_IDS.len() >= N_ACTIONS,
"ALGORITHM_IDS ({}) must have at least N_ACTIONS={} entries",
ALGORITHM_IDS.len(),
N_ACTIONS
);
for id in ALGORITHM_IDS.iter() {
assert!(!id.is_empty(), "ALGORITHM_IDS entry must not be empty");
}
}
#[test]
fn features_to_f32_array_clamped() {
let f = LogFeatures {
trace_length: 2.0, elapsed_time: -1.0, rework_count: 0.5,
unique_activities: 0.5,
avg_inter_event_time: 0.5,
log_size_bin: 0.5,
activity_entropy: 0.5,
variant_ratio: 0.5,
};
let arr = f.to_f32_array();
for (i, v) in arr.iter().enumerate() {
assert!(
*v >= 0.0 && *v <= 1.0,
"feature[{i}] = {v} is out of [0,1] after clamping"
);
}
}
#[test]
fn select_algorithm_returns_interpretable_result() {
let state = LinUcbState::default();
let features = default_features();
let result = select_algorithm(&features, &state);
assert!(!result.algorithm_id.is_empty());
assert!(result.ucb_value.is_finite());
assert!((result.action_index as usize) < N_ACTIONS);
}
#[cfg(feature = "gpu")]
#[test]
fn gpu_cpu_parity() {
pollster::block_on(async {
let state = LinUcbState::new(0.5);
let features = default_features();
let gpu_opt = GpuLinUcb::new().await.expect("GPU init should not error");
if gpu_opt.is_none() {
eprintln!("[parity test] No GPU available, skipping GPU/CPU parity check");
return;
}
let gpu = gpu_opt.unwrap();
let gpu_result = gpu
.infer_single(&features, &state)
.await
.expect("GPU inference failed");
let cpu_result = cpu_infer(&features, &state);
assert_eq!(
gpu_result.action_index, cpu_result.action_index,
"GPU and CPU selected different actions: GPU={} CPU={}",
gpu_result.action_index, cpu_result.action_index
);
assert!(
(gpu_result.ucb_value - cpu_result.ucb_value).abs() < 1e-4,
"GPU UCB value {:.6} differs from CPU {:.6} by more than tolerance",
gpu_result.ucb_value,
cpu_result.ucb_value
);
});
}
#[test]
fn cpu_throughput_baseline() {
let state = LinUcbState::default();
let n = BATCH_SIZE;
let features: Vec<LogFeatures> = (0..n)
.map(|i| {
let v = (i as f32) / (n as f32);
LogFeatures {
trace_length: v,
elapsed_time: v,
rework_count: v * 0.1,
unique_activities: v * 0.5,
avg_inter_event_time: v * 0.3,
log_size_bin: v * 0.7,
activity_entropy: v * 0.8,
variant_ratio: v * 0.2,
}
})
.collect();
let start = std::time::Instant::now();
let mut total_actions = 0u64;
for f in &features {
let r = cpu_infer(f, &state);
total_actions += r.action_index as u64;
}
let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
assert!(total_actions < (n as u64) * (N_ACTIONS as u64));
let throughput = (n as f64) / (elapsed_ms / 1000.0);
eprintln!(
"[throughput] CPU LinUCB: {:.0} states/sec ({:.2} ms for {})",
throughput, elapsed_ms, n
);
assert!(
throughput > 50_000.0,
"CPU throughput {:.0} states/sec below minimum 50K",
throughput
);
}
#[test]
fn cross_module_dimension_and_output_validity() {
use crate::ml::linucb::{
LinUCBAgent, N_ACTIONS as ML_N_ACTIONS, N_FEATURES as ML_N_FEATURES,
};
assert_eq!(N_FEATURES, ML_N_FEATURES,
"Feature dimension mismatch: gpu::N_FEATURES={N_FEATURES} ml::N_FEATURES={ML_N_FEATURES}");
assert_eq!(
N_ACTIONS, ML_N_ACTIONS,
"Action count mismatch: gpu::N_ACTIONS={N_ACTIONS} ml::N_ACTIONS={ML_N_ACTIONS}"
);
let raw: [f32; 8] = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
let gpu_features = LogFeatures {
trace_length: raw[0],
elapsed_time: raw[1],
rework_count: raw[2],
unique_activities: raw[3],
avg_inter_event_time: raw[4],
log_size_bin: raw[5],
activity_entropy: raw[6],
variant_ratio: raw[7],
};
let gpu_state = LinUcbState::default();
let ml_agent = LinUCBAgent::new();
let (gpu_action, gpu_ucb, _) = gpu_state.infer_cpu(&gpu_features);
let (ml_action, ml_ucb) = ml_agent.select(&raw);
assert!(
(gpu_action as usize) < N_ACTIONS,
"gpu action {gpu_action} out of bounds"
);
assert!(
(ml_action as usize) < ML_N_ACTIONS,
"ml action {ml_action} out of bounds"
);
assert!(
gpu_ucb.is_finite(),
"gpu UCB value is not finite: {gpu_ucb}"
);
assert!(ml_ucb.is_finite(), "ml UCB value is not finite: {ml_ucb}");
assert_eq!(
gpu_action, 0,
"Fresh gpu agent should return action 0 (all tied), got {gpu_action}"
);
assert_eq!(
ml_action, 0,
"Fresh ml agent should return action 0 (all tied), got {ml_action}"
);
}
}