//! extract staging schema. //! //! Extract produces memory-candidate records, not activity-log batches. During //! the transitional `write_extracted` path the model submits an //! [`ExtractedPayload`] containing `candidates[]`; the host expands each //! candidate into one [`StagingRecord`] and attaches record ids, extract-run ids, //! record-level source, evidence snippets, and source anchors mechanically. use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::schema::{EvidenceKind, SourceEvidenceRef, SourceRef}; /// Current flat staging schema version. pub const STAGING_SCHEMA_VERSION: u32 = 2; /// Candidate kinds that extract is allowed to stage. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum CandidateKind { Preference, WorkingAssumption, Constraint, Decision, OpenQuestion, Lesson, } impl CandidateKind { pub fn as_str(&self) -> &'static str { match self { CandidateKind::Preference => "preference", CandidateKind::WorkingAssumption => "working_assumption", CandidateKind::Constraint => "constraint", CandidateKind::Decision => "decision", CandidateKind::OpenQuestion => "open_question", CandidateKind::Lesson => "lesson", } } } /// Model-submitted candidate before host-side staging metadata is attached. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ExtractedCandidate { /// Kind of candidate. This is intentionally a narrow taxonomy, not an /// activity-log category. pub kind: CandidateKind, /// Concise candidate claim. pub claim: String, /// Why this may be useful for future work or consolidation. pub why_useful: String, /// Optional invalidation / staleness hint. #[serde(default, skip_serializing_if = "Option::is_none")] pub staleness: Option, /// Evidence ids selected by the extract worker. The transitional /// `write_extracted` path may leave this empty until `session-explore` /// provides host-issued evidence ids. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub evidence_ids: Vec, } /// Transitional extract output: zero or more flat candidates. #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] pub struct ExtractedPayload { #[serde(default)] pub candidates: Vec, } impl ExtractedPayload { pub fn is_empty(&self) -> bool { self.candidates.is_empty() } } /// Bounded evidence snippet copied into a flat staging record. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct StagingEvidence { pub id: String, pub kind: EvidenceKind, #[serde(default, skip_serializing_if = "Option::is_none")] pub entry_range: Option<[u64; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] pub excerpt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option, } /// One flat staging record. One record is one consolidation decision unit. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StagingRecord { pub schema_version: u32, pub id: String, pub extract_run_id: String, pub source: SourceRef, pub kind: CandidateKind, pub claim: String, pub why_useful: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub staleness: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub evidence: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub source_refs: Vec, } impl StagingRecord { pub fn from_candidate( id: impl Into, extract_run_id: impl Into, source: SourceRef, candidate: ExtractedCandidate, evidence: Vec, source_refs: Vec, ) -> Self { Self { schema_version: STAGING_SCHEMA_VERSION, id: id.into(), extract_run_id: extract_run_id.into(), source, kind: candidate.kind, claim: candidate.claim, why_useful: candidate.why_useful, staleness: candidate.staleness, evidence, source_refs, } } } #[cfg(test)] mod tests { use super::*; fn source() -> SourceRef { SourceRef { segment_id: "segment-record".into(), range: [0, 20], } } #[test] fn extracted_payload_empty_when_no_candidates() { assert!(ExtractedPayload::default().is_empty()); let payload = ExtractedPayload { candidates: vec![ExtractedCandidate { kind: CandidateKind::Decision, claim: "Use flat staging records".into(), why_useful: "Consolidation can resolve candidates independently".into(), staleness: None, evidence_ids: Vec::new(), }], }; assert!(!payload.is_empty()); } #[test] fn flat_staging_record_roundtrips() { let evidence = StagingEvidence { id: "E001".into(), kind: EvidenceKind::new(EvidenceKind::MESSAGE), entry_range: Some([10, 12]), excerpt: Some("extract candidate taxonomy".into()), summary: Some("User and assistant discussed staging kinds".into()), }; let source_ref = SourceEvidenceRef { segment_id: Some("segment-1".into()), entry_range: Some([10, 12]), evidence_id: Some("E001".into()), evidence_kind: Some(EvidenceKind::new(EvidenceKind::MESSAGE)), label: Some("design discussion".into()), summary: Some("bounded host summary".into()), ..Default::default() }; let candidate = ExtractedCandidate { kind: CandidateKind::Constraint, claim: "Extract stages candidates but does not write Memory".into(), why_useful: "Preserves extract/consolidation boundary".into(), staleness: Some("Revisit if the execution boundary changes".into()), evidence_ids: vec!["E001".into()], }; let record = StagingRecord::from_candidate( "stg-1", "run-1", source(), candidate, vec![evidence], vec![source_ref], ); let json = serde_json::to_string_pretty(&record).unwrap(); assert!(json.contains("\"schema_version\": 2")); assert!(json.contains("\"kind\": \"constraint\"")); assert!(json.contains("source_refs")); assert!(json.contains("evidence")); let parsed: StagingRecord = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.schema_version, STAGING_SCHEMA_VERSION); assert_eq!(parsed.kind, CandidateKind::Constraint); assert_eq!(parsed.evidence[0].id, "E001"); assert_eq!(parsed.source_refs[0].evidence_id.as_deref(), Some("E001")); } #[test] fn candidate_kind_serializes_as_snake_case() { let json = serde_json::to_string(&CandidateKind::WorkingAssumption).unwrap(); assert_eq!(json, "\"working_assumption\""); let parsed: CandidateKind = serde_json::from_str("\"open_question\"").unwrap(); assert_eq!(parsed, CandidateKind::OpenQuestion); } }