crtx-reflect 0.1.1

Reflection orchestration, prompts, candidate parsing, and schema validation.
Documentation
//! Reflection JSON contracts from BUILD_SPEC §13.

use cortex_core::{EventId, MemoryId, TraceId};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// Post-hoc reflection output for one trace.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SessionReflection {
    /// Trace being reflected.
    pub trace_id: TraceId,
    /// Candidate episodes extracted from source events.
    pub episode_candidates: Vec<EpisodeCandidate>,
    /// Candidate memories proposed from the reflected session.
    pub memory_candidates: Vec<MemoryCandidate>,
    /// Opaque contradiction candidates until the store-backed lane defines them.
    pub contradictions: Vec<serde_json::Value>,
    /// Opaque doctrine suggestions; reflection must never promote these directly.
    pub doctrine_suggestions: Vec<serde_json::Value>,
}

/// Candidate episode summary produced by reflection.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EpisodeCandidate {
    /// Human-readable summary of the episode.
    pub summary: String,
    /// Source events supporting the episode.
    pub source_event_ids: Vec<EventId>,
    /// Domains observed in the episode.
    pub domains: Vec<String>,
    /// Entities mentioned in the episode.
    pub entities: Vec<String>,
    /// Optional interpretation of why the episode matters.
    pub candidate_meaning: Option<String>,
    /// Model confidence in `[0.0, 1.0]`.
    #[schemars(range(min = 0.0, max = 1.0))]
    pub confidence: f64,
}

/// Candidate memory type taxonomy from BUILD_SPEC §13.1.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum MemoryType {
    /// Semantic fact or concept.
    Semantic,
    /// Episodic recollection.
    Episodic,
    /// Procedure or process.
    Procedural,
    /// Strategy or preference over action.
    Strategic,
    /// Affective signal.
    Affective,
    /// Correction learned from feedback.
    Correction,
}

/// Salience dimensions assigned before durable memory acceptance.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct InitialSalience {
    /// Expected reuse value in `[0.0, 1.0]`.
    #[schemars(range(min = 0.0, max = 1.0))]
    pub reusability: f64,
    /// Expected consequence of getting this wrong in `[0.0, 1.0]`.
    #[schemars(range(min = 0.0, max = 1.0))]
    pub consequence: f64,
    /// Emotional or preference intensity in `[0.0, 1.0]`.
    #[schemars(range(min = 0.0, max = 1.0))]
    pub emotional_charge: f64,
}

/// Candidate memory proposed by reflection.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct MemoryCandidate {
    /// Type of memory proposed.
    pub memory_type: MemoryType,
    /// Atomic claim to be considered by the human-gated memory path.
    pub claim: String,
    /// Zero-based indexes into [`SessionReflection::episode_candidates`].
    pub source_episode_indexes: Vec<usize>,
    /// Conditions where the claim applies.
    pub applies_when: Vec<String>,
    /// Conditions where the claim should not be applied.
    pub does_not_apply_when: Vec<String>,
    /// Model confidence in `[0.0, 1.0]`.
    #[schemars(range(min = 0.0, max = 1.0))]
    pub confidence: f64,
    /// Initial salience proposal.
    pub initial_salience: InitialSalience,
}

/// Batch wrapper for extracted principle candidates.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PrincipleCandidateBatch {
    /// Proposed principles.
    pub candidate_principles: Vec<PrincipleCandidate>,
}

/// Principle candidate from BUILD_SPEC §13.2.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PrincipleCandidate {
    /// Principle statement.
    pub statement: String,
    /// Supporting accepted memories.
    pub supporting_memory_ids: Vec<MemoryId>,
    /// Contradicting accepted memories.
    pub contradicting_memory_ids: Vec<MemoryId>,
    /// Domains where the pattern was observed.
    pub domains_observed: Vec<String>,
    /// Conditions where the principle applies.
    pub applies_when: Vec<String>,
    /// Conditions where the principle should not be applied.
    pub does_not_apply_when: Vec<String>,
    /// Plausible narrower or competing explanations.
    pub alternative_interpretations: Vec<String>,
    /// Model confidence in `[0.0, 1.0]`.
    #[schemars(range(min = 0.0, max = 1.0))]
    pub confidence: f64,
    /// Risk score for overgeneralisation in `[0.0, 1.0]`.
    #[schemars(range(min = 0.0, max = 1.0))]
    pub overgeneralisation_risk: f64,
}

impl SessionReflection {
    /// Validate invariants that serde shape checks cannot express.
    pub(crate) fn validate(&self) -> Result<(), String> {
        for (idx, episode) in self.episode_candidates.iter().enumerate() {
            validate_score(
                episode.confidence,
                &format!("episode_candidates[{idx}].confidence"),
            )?;
        }

        let episode_len = self.episode_candidates.len();
        for (idx, memory) in self.memory_candidates.iter().enumerate() {
            validate_score(
                memory.confidence,
                &format!("memory_candidates[{idx}].confidence"),
            )?;
            validate_score(
                memory.initial_salience.reusability,
                &format!("memory_candidates[{idx}].initial_salience.reusability"),
            )?;
            validate_score(
                memory.initial_salience.consequence,
                &format!("memory_candidates[{idx}].initial_salience.consequence"),
            )?;
            validate_score(
                memory.initial_salience.emotional_charge,
                &format!("memory_candidates[{idx}].initial_salience.emotional_charge"),
            )?;
            for source_idx in &memory.source_episode_indexes {
                if *source_idx >= episode_len {
                    return Err(format!(
                        "memory_candidates[{idx}].source_episode_indexes contains {source_idx}, but only {episode_len} episode candidates exist"
                    ));
                }
            }
        }

        Ok(())
    }
}

impl PrincipleCandidateBatch {
    /// Validate invariants that serde shape checks cannot express.
    pub(crate) fn validate(&self) -> Result<(), String> {
        for (idx, candidate) in self.candidate_principles.iter().enumerate() {
            validate_score(
                candidate.confidence,
                &format!("candidate_principles[{idx}].confidence"),
            )?;
            validate_score(
                candidate.overgeneralisation_risk,
                &format!("candidate_principles[{idx}].overgeneralisation_risk"),
            )?;
        }

        Ok(())
    }
}

fn validate_score(value: f64, field: &str) -> Result<(), String> {
    if value.is_finite() && (0.0..=1.0).contains(&value) {
        Ok(())
    } else {
        Err(format!("{field} must be between 0.0 and 1.0"))
    }
}