graphforge_knowledge/research/
decisions.rs1use super::{
3 MAX_RESEARCH_ROWS, ResearchAuthority, ResearchDecisionKind, ResearchDecisionRecord,
4 ResearchSubjectKind,
5};
6use crate::{KnowledgeError, invalid, require_uuid, require_v7};
7use std::collections::{HashMap, HashSet};
8use uuid::Uuid;
9
10#[derive(Debug, Clone, Default, PartialEq, Eq)]
12pub struct ResearchDecisionLedger {
13 events: Vec<ResearchDecisionRecord>,
14}
15impl ResearchDecisionLedger {
16 pub fn batch(&self) -> Result<arrow::record_batch::RecordBatch, KnowledgeError> {
18 super::encoding::decision_batch(&self.events)
19 }
20 pub fn from_batches(
22 batches: &[arrow::record_batch::RecordBatch],
23 ) -> Result<Self, KnowledgeError> {
24 Self::new(super::encoding::decision_rows(batches)?)
25 }
26 pub fn new(mut events: Vec<ResearchDecisionRecord>) -> Result<Self, KnowledgeError> {
28 if events.len() > MAX_RESEARCH_ROWS {
29 return Err(KnowledgeError::Limit {
30 participant: "research_decisions",
31 observed: events.len(),
32 limit: MAX_RESEARCH_ROWS,
33 });
34 }
35 events.sort_by_key(|event| event.sequence);
36 let mut ids = HashSet::new();
37 let mut requests = HashMap::new();
38 for (index, event) in events.iter().enumerate() {
39 validate(event)?;
40 if event.sequence != index as u64 + 1 {
41 return Err(invalid(
42 "research_decision.sequence",
43 "history must have contiguous publication order",
44 ));
45 }
46 if !ids.insert(event.decision_uuid) {
47 return Err(KnowledgeError::Duplicate("decision_uuid"));
48 }
49 if requests
50 .insert(event.operation_uuid, event.request_sha256)
51 .is_some_and(|old| old != event.request_sha256)
52 {
53 return Err(KnowledgeError::Conflict("research_decision.operation_uuid"));
54 }
55 }
56 Ok(Self { events })
57 }
58
59 pub fn append(&self, staged: Vec<ResearchDecisionRecord>) -> Result<Self, KnowledgeError> {
61 let mut events = self.events.clone();
62 let by_id: HashMap<_, _> = self
63 .events
64 .iter()
65 .map(|event| (event.decision_uuid, event))
66 .collect();
67 for event in staged {
68 if let Some(existing) = by_id.get(&event.decision_uuid) {
69 if *existing != &event {
70 return Err(KnowledgeError::Conflict("decision_uuid"));
71 }
72 } else {
73 events.push(event);
74 }
75 }
76 Self::new(events)
77 }
78
79 #[must_use]
81 pub fn events(&self) -> &[ResearchDecisionRecord] {
82 &self.events
83 }
84
85 #[must_use]
88 pub fn canonical_decision(
89 &self,
90 authority: &ResearchAuthority,
91 subject_kind: ResearchSubjectKind,
92 subject_uuid: Uuid,
93 ) -> Option<&ResearchDecisionRecord> {
94 self.events.iter().rev().find(|event| {
95 &event.authority == authority
96 && event.subject_kind == subject_kind
97 && event.subject_uuid == subject_uuid
98 && event.kind != ResearchDecisionKind::Integrate
99 })
100 }
101}
102
103fn validate(event: &ResearchDecisionRecord) -> Result<(), KnowledgeError> {
104 require_v7(event.decision_uuid, "decision_uuid")?;
105 for (id, field) in [
106 (event.operation_uuid, "operation_uuid"),
107 (event.authority.project_uuid, "project_uuid"),
108 (event.authority.context_uuid, "context_uuid"),
109 (event.subject_uuid, "subject_uuid"),
110 (event.creator_uuid, "creator_uuid"),
111 ] {
112 require_uuid(id, field)?;
113 }
114 if let Some(id) = event.authority.community_uuid {
115 require_uuid(id, "community_uuid")?;
116 }
117 if let Some(id) = event.source_version_uuid {
118 require_uuid(id, "source_version_uuid")?;
119 }
120 if event.subject_kind == ResearchSubjectKind::Assertion {
121 require_v7(event.subject_uuid, "assertion_uuid")?;
122 }
123 Ok(())
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 fn event(
130 sequence: u64,
131 authority: ResearchAuthority,
132 subject_uuid: Uuid,
133 kind: ResearchDecisionKind,
134 ) -> ResearchDecisionRecord {
135 ResearchDecisionRecord {
136 sequence,
137 decision_uuid: Uuid::now_v7(),
138 operation_uuid: Uuid::now_v7(),
139 request_sha256: [1; 32],
140 authority,
141 subject_kind: ResearchSubjectKind::Assertion,
142 subject_uuid,
143 kind,
144 creator_uuid: Uuid::now_v7(),
145 source_version_uuid: None,
146 recorded_at: 100 - i64::try_from(sequence).unwrap(),
147 }
148 }
149 #[test]
150 fn integration_and_source_acceptance_do_not_promote_destination() {
151 let parent = ResearchAuthority {
152 project_uuid: Uuid::now_v7(),
153 community_uuid: None,
154 context_uuid: Uuid::now_v7(),
155 };
156 let child = ResearchAuthority {
157 context_uuid: Uuid::now_v7(),
158 ..parent.clone()
159 };
160 let subject = Uuid::now_v7();
161 let accepted = event(1, parent.clone(), subject, ResearchDecisionKind::Promote);
162 let integrated = event(2, child.clone(), subject, ResearchDecisionKind::Integrate);
163 let ledger = ResearchDecisionLedger::new(vec![accepted.clone(), integrated]).unwrap();
164 assert_eq!(
165 ledger.canonical_decision(&parent, ResearchSubjectKind::Assertion, subject),
166 Some(&accepted)
167 );
168 assert!(
169 ledger
170 .canonical_decision(&child, ResearchSubjectKind::Assertion, subject)
171 .is_none()
172 );
173 let promoted = event(3, child.clone(), subject, ResearchDecisionKind::Promote);
174 let revoked = event(4, child.clone(), subject, ResearchDecisionKind::Revoke);
175 let ledger = ledger.append(vec![promoted, revoked.clone()]).unwrap();
176 assert_eq!(
177 ledger.canonical_decision(&child, ResearchSubjectKind::Assertion, subject),
178 Some(&revoked)
179 );
180 assert_eq!(
181 ledger.canonical_decision(&parent, ResearchSubjectKind::Assertion, subject),
182 Some(&accepted)
183 );
184 let other_community = ResearchAuthority {
185 community_uuid: Some(Uuid::now_v7()),
186 ..child
187 };
188 assert!(
189 ledger
190 .canonical_decision(&other_community, ResearchSubjectKind::Assertion, subject)
191 .is_none()
192 );
193 assert_eq!(ledger.events().len(), 4);
194 }
195 #[test]
196 fn history_refuses_identity_rewrite_gaps_and_changed_operation_content() {
197 let authority = ResearchAuthority {
198 project_uuid: Uuid::now_v7(),
199 community_uuid: None,
200 context_uuid: Uuid::now_v7(),
201 };
202 let first = event(1, authority, Uuid::now_v7(), ResearchDecisionKind::Promote);
203 let ledger = ResearchDecisionLedger::new(vec![first.clone()]).unwrap();
204 assert_eq!(ledger.append(vec![first.clone()]).unwrap(), ledger);
205 let mut changed = first.clone();
206 changed.kind = ResearchDecisionKind::Revoke;
207 assert!(matches!(
208 ledger.append(vec![changed]),
209 Err(KnowledgeError::Conflict("decision_uuid"))
210 ));
211 let mut next = first.clone();
212 next.sequence = 2;
213 next.decision_uuid = Uuid::now_v7();
214 next.request_sha256 = [99; 32];
215 assert!(matches!(
216 ledger.append(vec![next]),
217 Err(KnowledgeError::Conflict("research_decision.operation_uuid"))
218 ));
219 let mut gap = first;
220 gap.sequence = 3;
221 gap.decision_uuid = Uuid::now_v7();
222 assert!(ledger.append(vec![gap]).is_err());
223 assert_eq!(ledger.events().len(), 1);
224 }
225}