use serde::{Deserialize, Serialize};
use crate::error::{HirnError, HirnResult};
use crate::id::MemoryId;
use crate::metadata::Metadata;
use crate::provenance::Provenance;
use crate::resource::EvidenceLink;
use crate::revision::{LogicalMemoryId, RevisionId, RevisionOperation, RevisionState};
use crate::timestamp::Timestamp;
use crate::types::{AgentId, Namespace, Origin};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ActionStep {
pub description: String,
pub tool: Option<String>,
pub parameters: Metadata,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepResult {
pub step_index: usize,
pub success: bool,
pub output: String,
}
#[derive(Debug, Clone)]
pub struct ProcedureResult {
pub procedure_id: MemoryId,
pub success: bool,
pub step_results: Vec<StepResult>,
}
pub trait ToolExecutor: Send + Sync {
fn execute_step(
&self,
step: &ActionStep,
) -> impl std::future::Future<Output = HirnResult<StepResult>> + Send;
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProceduralRecord {
pub id: MemoryId,
pub logical_memory_id: LogicalMemoryId,
pub revision_id: RevisionId,
pub name: String,
pub description: String,
pub steps: Vec<ActionStep>,
pub preconditions: Vec<String>,
pub embedding: Option<Vec<f32>>,
pub success_count: u64,
pub invocation_count: u64,
pub success_rate: f32,
pub source_episodes: Vec<MemoryId>,
pub observed_at: Timestamp,
pub created_at: Timestamp,
pub updated_at: Timestamp,
pub last_accessed: Timestamp,
pub access_count: u64,
pub version: u32,
pub revision_operation: RevisionOperation,
pub revision_reason: Option<String>,
pub revision_causation_id: Option<MemoryId>,
pub superseded_by: Option<MemoryId>,
pub provenance: Provenance,
pub metadata: Metadata,
pub namespace: Namespace,
pub archived: bool,
}
impl ProceduralRecord {
#[must_use]
pub fn builder() -> ProceduralRecordBuilder {
ProceduralRecordBuilder::default()
}
pub fn record_access(&mut self) {
self.access_count += 1;
self.last_accessed = Timestamp::now();
}
pub fn record_success(&mut self) {
self.invocation_count += 1;
self.success_count += 1;
self.success_rate = 0.1_f32.mul_add(1.0, 0.9 * self.success_rate).clamp(0.0, 1.0);
self.updated_at = Timestamp::now();
}
pub fn record_failure(&mut self) {
self.invocation_count += 1;
self.success_rate = (0.9 * self.success_rate).clamp(0.0, 1.0);
self.updated_at = Timestamp::now();
}
#[must_use]
pub const fn is_retracted(&self) -> bool {
matches!(self.revision_operation, RevisionOperation::Retract)
}
#[must_use]
pub const fn is_live(&self) -> bool {
!self.archived && !self.is_retracted()
}
#[must_use]
pub fn revision_state_against(&self, head: &Self) -> RevisionState {
if self.revision_id == head.revision_id {
if head.is_live() {
RevisionState::Active
} else {
RevisionState::Retracted
}
} else {
RevisionState::Superseded
}
}
}
#[derive(Debug, Default)]
pub struct ProceduralRecordBuilder {
name: Option<String>,
description: Option<String>,
steps: Vec<ActionStep>,
preconditions: Vec<String>,
embedding: Option<Vec<f32>>,
source_episodes: Vec<MemoryId>,
agent_id: Option<AgentId>,
namespace: Option<Namespace>,
evidence_links: Vec<EvidenceLink>,
metadata: Metadata,
}
impl ProceduralRecordBuilder {
#[must_use]
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
#[must_use]
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn steps(mut self, steps: Vec<ActionStep>) -> Self {
self.steps = steps;
self
}
#[must_use]
pub fn preconditions(mut self, preconditions: Vec<String>) -> Self {
self.preconditions = preconditions;
self
}
#[must_use]
pub fn embedding(mut self, embedding: Vec<f32>) -> Self {
self.embedding = Some(embedding);
self
}
#[must_use]
pub fn source_episodes(mut self, ids: Vec<MemoryId>) -> Self {
self.source_episodes = ids;
self
}
#[must_use]
pub fn agent_id(mut self, agent_id: AgentId) -> Self {
self.agent_id = Some(agent_id);
self
}
#[must_use]
pub fn namespace(mut self, namespace: Namespace) -> Self {
self.namespace = Some(namespace);
self
}
#[must_use]
pub fn evidence_link(mut self, evidence_link: EvidenceLink) -> Self {
self.evidence_links.push(evidence_link);
self
}
#[must_use]
pub fn metadata(
mut self,
key: impl Into<String>,
value: impl Into<crate::metadata::MetadataValue>,
) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
pub fn build(self) -> Result<ProceduralRecord, HirnError> {
let name = self.name.filter(|n| !n.trim().is_empty()).ok_or_else(|| {
HirnError::InvalidInput("procedural record requires non-empty name".into())
})?;
let description = self
.description
.filter(|d| !d.trim().is_empty())
.ok_or_else(|| {
HirnError::InvalidInput("procedural record requires non-empty description".into())
})?;
let agent_id = self
.agent_id
.ok_or_else(|| HirnError::InvalidInput("procedural record requires agent_id".into()))?;
let now = Timestamp::now();
let namespace = self
.namespace
.unwrap_or_else(|| Namespace::private_for(&agent_id));
let id = MemoryId::new();
let mut provenance = Provenance::with_origin(Origin::DirectObservation, agent_id);
provenance.evidence_links = self.evidence_links;
Ok(ProceduralRecord {
id,
logical_memory_id: LogicalMemoryId::from_memory_id(id),
revision_id: RevisionId::from_memory_id(id),
name,
description,
steps: self.steps,
preconditions: self.preconditions,
embedding: self.embedding,
success_count: 0,
invocation_count: 0,
success_rate: 0.0,
source_episodes: self.source_episodes,
observed_at: now,
created_at: now,
updated_at: now,
last_accessed: now,
access_count: 0,
version: 1,
revision_operation: RevisionOperation::Create,
revision_reason: None,
revision_causation_id: None,
superseded_by: None,
provenance,
metadata: self.metadata,
namespace,
archived: false,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_procedural_record() {
let agent = AgentId::new("agent_a").unwrap();
let record = ProceduralRecord::builder()
.name("deploy-to-staging")
.description("Deploy the current branch to the staging environment")
.steps(vec![ActionStep {
description: "Run tests".into(),
tool: Some("cargo_test".into()),
parameters: Metadata::new(),
}])
.preconditions(vec!["branch is clean".into()])
.agent_id(agent)
.build()
.unwrap();
assert_eq!(record.name, "deploy-to-staging");
assert!(record.success_rate.abs() < f32::EPSILON);
assert_eq!(record.steps.len(), 1);
}
#[test]
fn success_rate_tracking() {
let agent = AgentId::new("agent_a").unwrap();
let mut record = ProceduralRecord::builder()
.name("test-proc")
.description("A test procedure")
.agent_id(agent)
.build()
.unwrap();
record.record_success();
record.record_success();
record.record_failure();
assert_eq!(record.invocation_count, 3);
assert_eq!(record.success_count, 2);
assert!((record.success_rate - 0.171).abs() < 0.001);
}
#[test]
fn rejects_empty_name() {
let agent = AgentId::new("agent_a").unwrap();
let result = ProceduralRecord::builder()
.name("")
.description("desc")
.agent_id(agent)
.build();
assert!(result.is_err());
}
#[test]
fn builder_attaches_evidence_links_to_provenance() {
let agent = AgentId::new("agent_a").unwrap();
let link = EvidenceLink::new(
crate::resource::ResourceId::new(),
crate::resource::EvidenceRole::Output,
);
let record = ProceduralRecord::builder()
.name("proc")
.description("desc")
.agent_id(agent)
.evidence_link(link.clone())
.build()
.unwrap();
assert_eq!(record.provenance.evidence_links, vec![link]);
}
}