use crate::runtime::{BufferBinding, ComputeDispatch, KernelId, PushConstant, SemanticDispatch};
use crate::{DType, Error, Matrix, OpAttribute, OperationContract, Result, matrix};
use super::mse::mse;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SacLossConfig {
pub discount: f32,
pub entropy_coefficient: f32,
}
impl Default for SacLossConfig {
fn default() -> Self {
Self {
discount: 0.99,
entropy_coefficient: 0.2,
}
}
}
pub struct SacCriticLossResult {
pub target_q: Matrix,
pub q1_loss: Matrix,
pub q2_loss: Matrix,
pub total_loss: Matrix,
}
#[allow(
clippy::too_many_arguments,
reason = "the SAC critic contract keeps every transition and target field explicit"
)]
pub fn sac_critic(
q1: &Matrix,
q2: &Matrix,
reward: &Matrix,
next_q1: &Matrix,
next_q2: &Matrix,
next_log_probability: &Matrix,
terminated: &Matrix,
truncated: &Matrix,
config: SacLossConfig,
) -> Result<SacCriticLossResult> {
const CONTRACT: OperationContract = crate::core::operation::ml::SAC_CRITIC;
let [batch] = q1.shape() else {
return Err(Error::invalid_argument(format!(
"{} requires vector Q values",
CONTRACT.name()
)));
};
let numerical = [q1, q2, reward, next_q1, next_q2, next_log_probability];
let valid = *batch > 0
&& numerical.into_iter().all(|input| {
input.shape() == q1.shape()
&& input.dtype() == DType::F32
&& q1.engine_handle().same_as(input.engine_handle())
}) && [terminated, truncated].into_iter().all(|input| {
input.shape() == q1.shape()
&& input.dtype() == DType::U8
&& q1.engine_handle().same_as(input.engine_handle())
}) && config.discount.is_finite()
&& (0.0..=1.0).contains(&config.discount)
&& config.entropy_coefficient.is_finite()
&& config.entropy_coefficient >= 0.0;
if !valid {
return Err(Error::invalid_argument(format!(
"{} requires matching same-engine F32 vectors, U8 boundaries, discount in [0, 1], and a non-negative entropy coefficient",
CONTRACT.name()
)));
}
let batch_u32 = u32::try_from(*batch)
.map_err(|_| Error::invalid_argument(format!("{} batch exceeds u32", CONTRACT.name())))?;
let lowering = q1.engine_handle().begin_semantic_lowering()?;
let target_q = sac_target_impl(
&lowering,
reward,
next_q1,
next_q2,
next_log_probability,
terminated,
truncated,
batch_u32,
config.discount,
config.entropy_coefficient,
)?;
let q1_loss = mse(q1, &target_q)?;
let q2_loss = mse(q2, &target_q)?;
let total_loss = matrix::add(&q1_loss, &q2_loss)?;
let result = SacCriticLossResult {
target_q,
q1_loss,
q2_loss,
total_loss,
};
let attributes = [
OpAttribute::Float {
name: "discount".into(),
value: f64::from(config.discount),
},
OpAttribute::Float {
name: "entropy_coefficient".into(),
value: f64::from(config.entropy_coefficient),
},
];
lowering.commit(SemanticDispatch {
contract: CONTRACT,
inputs: &[
q1,
q2,
reward,
next_q1,
next_q2,
next_log_probability,
terminated,
truncated,
],
outputs: &[
&result.target_q,
&result.q1_loss,
&result.q2_loss,
&result.total_loss,
],
attributes: &attributes,
})?;
Ok(result)
}
pub fn sac_actor(
q1: &Matrix,
q2: &Matrix,
log_probability: &Matrix,
entropy_coefficient: f32,
) -> Result<Matrix> {
const CONTRACT: OperationContract = crate::core::operation::ml::SAC_ACTOR;
let [batch] = q1.shape() else {
return Err(Error::invalid_argument(format!(
"{} requires vector Q values",
CONTRACT.name()
)));
};
let valid = *batch > 0
&& [q1, q2, log_probability].into_iter().all(|input| {
input.shape() == q1.shape()
&& input.dtype() == DType::F32
&& q1.engine_handle().same_as(input.engine_handle())
}) && entropy_coefficient.is_finite()
&& entropy_coefficient >= 0.0;
if !valid {
return Err(Error::invalid_argument(format!(
"{} requires matching nonempty same-engine F32 vectors and a non-negative entropy coefficient",
CONTRACT.name()
)));
}
let lowering = q1.engine_handle().begin_semantic_lowering()?;
let difference = matrix::sub(q1, q2)?;
let minimum = matrix::scale(
&matrix::sub(&matrix::add(q1, q2)?, &matrix::abs(&difference)?)?,
0.5,
)?;
let per_sample = matrix::sub(
&matrix::scale(log_probability, entropy_coefficient)?,
&minimum,
)?;
let loss = matrix::reshape_semantic_output(
&matrix::scale(&matrix::sum(&per_sample, -1)?, 1.0 / *batch as f32)?,
Vec::new(),
)?;
let attributes = [OpAttribute::Float {
name: "entropy_coefficient".into(),
value: f64::from(entropy_coefficient),
}];
lowering.commit(SemanticDispatch {
contract: CONTRACT,
inputs: &[q1, q2, log_probability],
outputs: &[&loss],
attributes: &attributes,
})?;
Ok(loss)
}
#[allow(
clippy::too_many_arguments,
reason = "the private SAC target lowering retains all six donor fields and its scalar ABI"
)]
fn sac_target_impl(
lowering: &crate::runtime::SemanticLoweringScope,
reward: &Matrix,
next_q1: &Matrix,
next_q2: &Matrix,
next_log_probability: &Matrix,
terminated: &Matrix,
truncated: &Matrix,
batch: u32,
discount: f32,
entropy_coefficient: f32,
) -> Result<Matrix> {
let output = Matrix::allocate(
reward.engine_handle(),
reward.shape().to_vec(),
reward.num_elements(),
DType::F32,
)?;
let buffers = [
BufferBinding::read(reward.storage()),
BufferBinding::read(next_q1.storage()),
BufferBinding::read(next_q2.storage()),
BufferBinding::read(next_log_probability.storage()),
BufferBinding::read(terminated.storage()),
BufferBinding::read(truncated.storage()),
BufferBinding::write(output.storage()),
];
let push_constants = [
PushConstant::U32(batch),
PushConstant::F32(discount),
PushConstant::F32(entropy_coefficient),
];
let kernel = KernelId::MlSacTargetF32;
lowering.record_physical(ComputeDispatch {
kernel,
buffers: &buffers,
push_constants: &push_constants,
workgroups: kernel.linear_workgroups(batch),
})?;
Ok(output)
}