use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use solverforge::{
CandidateTraceExternalDigest, HardSoftScore, QualifiedCandidateTraceRunProvenance,
SolverLifecycleState, SolverSnapshot, SolverSnapshotAnalysis, SolverStatus,
SolverTelemetryDetail, SolverTerminalReason,
};
use super::telemetry::{CandidateTraceDto, TelemetryDto};
use crate::domain::Plan;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanDto {
#[serde(flatten)]
pub fields: Map<String, Value>,
#[serde(default)]
pub score: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConstraintAnalysisDto {
pub name: String,
pub weight: String,
pub score: String,
pub match_count: usize,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalyzeResponse {
pub score: String,
pub constraints: Vec<ConstraintAnalysisDto>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JobSummaryDto {
pub id: String,
pub job_id: String,
pub lifecycle_state: &'static str,
pub terminal_reason: Option<&'static str>,
pub checkpoint_available: bool,
pub event_sequence: u64,
pub snapshot_revision: Option<u64>,
pub current_score: Option<String>,
pub best_score: Option<String>,
pub telemetry: TelemetryDto,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JobSnapshotDto {
pub id: String,
pub job_id: String,
pub snapshot_revision: u64,
pub lifecycle_state: &'static str,
pub terminal_reason: Option<&'static str>,
pub current_score: Option<String>,
pub best_score: Option<String>,
pub telemetry: TelemetryDto,
pub solution: PlanDto,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JobAnalysisDto {
pub id: String,
pub job_id: String,
pub snapshot_revision: u64,
pub lifecycle_state: &'static str,
pub terminal_reason: Option<&'static str>,
pub analysis: AnalyzeResponse,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct JobTelemetryDetailDto {
pub id: String,
pub job_id: String,
pub status: JobSummaryDto,
pub candidate_trace: Option<CandidateTraceDto>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QualifiedJobRequestDto {
pub plan: PlanDto,
pub provenance: QualifiedCandidateTraceProvenanceDto,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QualifiedCandidateTraceProvenanceDto {
pub schema_digest_sha256: String,
pub instance_digest_sha256: String,
pub initial_state_digest_sha256: String,
pub core_tree_digest_sha256: String,
pub build_digest_sha256: String,
pub producer: String,
}
impl PlanDto {
pub fn from_plan(plan: &Plan) -> Self {
let mut fields = match serde_json::to_value(plan).expect("failed to serialize plan") {
Value::Object(map) => map,
_ => Map::new(),
};
let score = fields.remove("score").and_then(|value| {
if value.is_null() {
None
} else if let Some(score) = value.as_str() {
Some(score.to_string())
} else {
Some(value.to_string())
}
});
Self { fields, score }
}
pub fn to_domain(&self) -> Result<Plan, serde_json::Error> {
let mut fields = self.fields.clone();
let _ = &self.score;
fields.insert("score".to_string(), Value::Null);
serde_json::from_value(Value::Object(fields))
}
}
impl JobSummaryDto {
pub fn from_status(job_id: usize, status: &SolverStatus<HardSoftScore>) -> Self {
Self {
id: job_id.to_string(),
job_id: job_id.to_string(),
lifecycle_state: lifecycle_state_label(status.lifecycle_state),
terminal_reason: status.terminal_reason.map(terminal_reason_label),
checkpoint_available: status.checkpoint_available,
event_sequence: status.event_sequence,
snapshot_revision: status.latest_snapshot_revision,
current_score: status.current_score.map(|score| score.to_string()),
best_score: status.best_score.map(|score| score.to_string()),
telemetry: TelemetryDto::from_runtime(&status.telemetry),
}
}
}
impl JobTelemetryDetailDto {
pub fn from_runtime(detail: &SolverTelemetryDetail<HardSoftScore>) -> Self {
let job_id = detail.status.job_id;
Self {
id: job_id.to_string(),
job_id: job_id.to_string(),
status: JobSummaryDto::from_status(job_id, &detail.status),
candidate_trace: detail
.candidate_trace
.as_ref()
.map(CandidateTraceDto::from_runtime),
}
}
}
impl QualifiedCandidateTraceProvenanceDto {
pub fn to_runtime(&self) -> Result<QualifiedCandidateTraceRunProvenance, String> {
QualifiedCandidateTraceRunProvenance::externally_attested(
parse_sha256("schemaDigestSha256", &self.schema_digest_sha256)?,
parse_sha256("instanceDigestSha256", &self.instance_digest_sha256)?,
parse_sha256(
"initialStateDigestSha256",
&self.initial_state_digest_sha256,
)?,
parse_sha256("coreTreeDigestSha256", &self.core_tree_digest_sha256)?,
parse_sha256("buildDigestSha256", &self.build_digest_sha256)?,
self.producer.clone(),
)
.map_err(|error| error.to_string())
}
}
impl JobSnapshotDto {
pub fn from_snapshot(snapshot: &SolverSnapshot<Plan>) -> Self {
Self {
id: snapshot.job_id.to_string(),
job_id: snapshot.job_id.to_string(),
snapshot_revision: snapshot.snapshot_revision,
lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state),
terminal_reason: snapshot.terminal_reason.map(terminal_reason_label),
current_score: snapshot.current_score.map(|score| score.to_string()),
best_score: snapshot.best_score.map(|score| score.to_string()),
telemetry: TelemetryDto::from_runtime(&snapshot.telemetry),
solution: PlanDto::from_plan(&snapshot.solution),
}
}
}
impl JobAnalysisDto {
pub fn from_snapshot_analysis(
snapshot: &SolverSnapshotAnalysis<HardSoftScore>,
analysis: AnalyzeResponse,
) -> Self {
Self {
id: snapshot.job_id.to_string(),
job_id: snapshot.job_id.to_string(),
snapshot_revision: snapshot.snapshot_revision,
lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state),
terminal_reason: snapshot.terminal_reason.map(terminal_reason_label),
analysis,
}
}
}
pub fn analysis_response(analysis: &solverforge::ScoreAnalysis<HardSoftScore>) -> AnalyzeResponse {
AnalyzeResponse {
score: analysis.score.to_string(),
constraints: analysis
.constraints
.iter()
.map(|constraint| ConstraintAnalysisDto {
name: constraint.name.clone(),
weight: constraint.weight.to_string(),
score: constraint.score.to_string(),
match_count: constraint.match_count,
})
.collect(),
}
}
pub fn lifecycle_state_label(state: SolverLifecycleState) -> &'static str {
match state {
SolverLifecycleState::Solving => "SOLVING",
SolverLifecycleState::PauseRequested => "PAUSE_REQUESTED",
SolverLifecycleState::Paused => "PAUSED",
SolverLifecycleState::Completed => "COMPLETED",
SolverLifecycleState::Cancelled => "CANCELLED",
SolverLifecycleState::Failed => "FAILED",
}
}
pub fn terminal_reason_label(reason: SolverTerminalReason) -> &'static str {
match reason {
SolverTerminalReason::Completed => "completed",
SolverTerminalReason::TerminatedByConfig => "terminated_by_config",
SolverTerminalReason::Cancelled => "cancelled",
SolverTerminalReason::Failed => "failed",
}
}
fn parse_sha256(name: &str, value: &str) -> Result<CandidateTraceExternalDigest, String> {
if value.len() != 64 {
return Err(format!(
"{name} must contain exactly 64 hexadecimal characters"
));
}
let mut bytes = [0_u8; 32];
for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
let high = hex_nibble(pair[0]).ok_or_else(|| format!("{name} must be hexadecimal"))?;
let low = hex_nibble(pair[1]).ok_or_else(|| format!("{name} must be hexadecimal"))?;
bytes[index] = (high << 4) | low;
}
Ok(CandidateTraceExternalDigest::sha256(bytes))
}
fn hex_nibble(value: u8) -> Option<u8> {
match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
b'A'..=b'F' => Some(value - b'A' + 10),
_ => None,
}
}