1use std::collections::{HashMap, HashSet};
4use std::sync::{Arc, LazyLock};
5
6use arrow::array::{
7 Array, BinaryArray, BinaryBuilder, FixedSizeBinaryArray, FixedSizeBinaryBuilder, StringArray,
8 StringBuilder, TimestampMicrosecondArray, TimestampMicrosecondBuilder, UInt32Array,
9 UInt32Builder,
10};
11use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
12use arrow::record_batch::RecordBatch;
13use graphforge_core::canonical::{
14 CANONICAL_CONTRACT_VERSION, CanonicalDomain, CanonicalWriter, fingerprint,
15};
16use uuid::{Uuid, Version};
17
18use crate::{KnowledgeError, MAX_KNOWLEDGE_ROWS, SchemaRegistryEntry};
19
20pub const REASONING_CONTRACT_VERSION: u32 = 1;
22pub const EPISTEMIC_CAPABILITY_VERSION: u32 = 1;
24pub const REASONING_KIND_REGISTRY_VERSION: u32 = 1;
26pub const REASONING_CONTENT_FORMAT_REGISTRY_VERSION: u32 = 1;
28pub const MAX_REASONING_CONTENT_BYTES: usize = 65_536;
30
31pub static REASONING_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
33 Arc::new(Schema::new(vec![
34 uuid_field("reasoning_uuid", false),
35 uuid_field("assertion_uuid", false),
36 Field::new("kind", DataType::Utf8, false),
37 Field::new("content_format", DataType::Utf8, false),
38 Field::new("content", DataType::Binary, false),
39 uuid_field("supersedes_reasoning_uuid", true),
40 uuid_field("provenance_uuid", false),
41 Field::new(
42 "recorded_at",
43 DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
44 false,
45 ),
46 Field::new("contract_version", DataType::UInt32, false),
47 ]))
48});
49
50static REASONING_SCHEMA_FINGERPRINT: LazyLock<[u8; 32]> = LazyLock::new(|| {
51 fingerprint(
52 CanonicalDomain::Schema,
53 CANONICAL_CONTRACT_VERSION,
54 b"reasoning/1|reasoning_uuid:fixed[16]:required|assertion_uuid:fixed[16]:required|kind:utf8:required|content_format:utf8:required|content:binary:required|supersedes_reasoning_uuid:fixed[16]:nullable|provenance_uuid:fixed[16]:required|recorded_at:timestamp_us_utc:required|contract_version:u32:required",
55 )
56 .expect("registered reasoning schema is within canonical bounds")
57});
58
59#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
61pub enum ReasoningKind {
62 EvidenceInterpretation,
64 LogicalInference,
66 MethodologicalNote,
68 DecisionRationale,
70}
71
72impl ReasoningKind {
73 #[must_use]
75 pub const fn as_str(self) -> &'static str {
76 match self {
77 Self::EvidenceInterpretation => "evidence_interpretation",
78 Self::LogicalInference => "logical_inference",
79 Self::MethodologicalNote => "methodological_note",
80 Self::DecisionRationale => "decision_rationale",
81 }
82 }
83
84 fn parse(value: &str) -> Result<Self, KnowledgeError> {
85 match value {
86 "evidence_interpretation" => Ok(Self::EvidenceInterpretation),
87 "logical_inference" => Ok(Self::LogicalInference),
88 "methodological_note" => Ok(Self::MethodologicalNote),
89 "decision_rationale" => Ok(Self::DecisionRationale),
90 _ => Err(invalid("reasoning.kind", "unknown registry value")),
91 }
92 }
93}
94
95#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
97pub enum ReasoningContentFormat {
98 TextPlain,
100 TextMarkdown,
102 ApplicationJson,
104}
105
106impl ReasoningContentFormat {
107 #[must_use]
109 pub const fn as_str(self) -> &'static str {
110 match self {
111 Self::TextPlain => "text/plain",
112 Self::TextMarkdown => "text/markdown",
113 Self::ApplicationJson => "application/json",
114 }
115 }
116
117 fn parse(value: &str) -> Result<Self, KnowledgeError> {
118 match value {
119 "text/plain" => Ok(Self::TextPlain),
120 "text/markdown" => Ok(Self::TextMarkdown),
121 "application/json" => Ok(Self::ApplicationJson),
122 _ => Err(invalid(
123 "reasoning.content_format",
124 "unknown registry value",
125 )),
126 }
127 }
128}
129
130#[derive(Clone, Debug, Eq, PartialEq)]
132pub struct ReasoningRecord {
133 pub reasoning_uuid: Uuid,
135 pub assertion_uuid: Uuid,
137 pub kind: ReasoningKind,
139 pub content_format: ReasoningContentFormat,
141 pub content: Vec<u8>,
143 pub supersedes_reasoning_uuid: Option<Uuid>,
145 pub provenance_uuid: Uuid,
147 pub recorded_at_micros: i64,
149 pub contract_version: u32,
151}
152
153impl ReasoningRecord {
154 #[allow(clippy::too_many_arguments)]
156 pub fn new(
157 reasoning_uuid: Uuid,
158 assertion_uuid: Uuid,
159 kind: ReasoningKind,
160 content_format: ReasoningContentFormat,
161 content: Vec<u8>,
162 supersedes_reasoning_uuid: Option<Uuid>,
163 provenance_uuid: Uuid,
164 recorded_at_micros: i64,
165 ) -> Result<Self, KnowledgeError> {
166 require_v7(reasoning_uuid, "reasoning_uuid")?;
167 require_v7(assertion_uuid, "assertion_uuid")?;
168 require_uuid(provenance_uuid, "provenance_uuid")?;
169 if let Some(previous) = supersedes_reasoning_uuid {
170 require_v7(previous, "supersedes_reasoning_uuid")?;
171 if previous == reasoning_uuid {
172 return Err(invalid(
173 "reasoning.supersedes_reasoning_uuid",
174 "self-link is forbidden",
175 ));
176 }
177 }
178 validate_content(content_format, &content)?;
179 Ok(Self {
180 reasoning_uuid,
181 assertion_uuid,
182 kind,
183 content_format,
184 content,
185 supersedes_reasoning_uuid,
186 provenance_uuid,
187 recorded_at_micros,
188 contract_version: REASONING_CONTRACT_VERSION,
189 })
190 }
191}
192
193#[derive(Clone, Debug, Default, Eq, PartialEq)]
195pub struct ReasoningLedger {
196 pub records: Vec<ReasoningRecord>,
198}
199
200impl ReasoningLedger {
201 pub fn new(mut records: Vec<ReasoningRecord>) -> Result<Self, KnowledgeError> {
203 if records.len() > MAX_KNOWLEDGE_ROWS {
204 return Err(KnowledgeError::Limit {
205 participant: "reasoning",
206 observed: records.len(),
207 limit: MAX_KNOWLEDGE_ROWS,
208 });
209 }
210 let mut by_id = HashMap::with_capacity(records.len());
211 for record in &records {
212 validate_record(record)?;
213 if by_id.insert(record.reasoning_uuid, record).is_some() {
214 return Err(KnowledgeError::Duplicate("reasoning_uuid"));
215 }
216 }
217 for record in &records {
218 if let Some(previous_uuid) = record.supersedes_reasoning_uuid {
219 let previous = by_id
220 .get(&previous_uuid)
221 .ok_or(KnowledgeError::Dangling("supersedes_reasoning_uuid"))?;
222 if previous.assertion_uuid != record.assertion_uuid {
223 return Err(invalid(
224 "reasoning.supersedes_reasoning_uuid",
225 "cross-assertion amendment is forbidden",
226 ));
227 }
228 }
229 }
230 let mut proven_acyclic = HashSet::with_capacity(records.len());
231 for record in &records {
232 reject_cycle(record.reasoning_uuid, &by_id, &mut proven_acyclic)?;
233 }
234 records.sort_by_key(|row| (row.recorded_at_micros, row.reasoning_uuid));
235 Ok(Self { records })
236 }
237
238 pub fn merge(&self, staged: &Self) -> Result<Self, KnowledgeError> {
240 let mut records = self.records.clone();
241 let mut by_id = records
242 .iter()
243 .cloned()
244 .map(|row| (row.reasoning_uuid, row))
245 .collect::<HashMap<_, _>>();
246 for record in &staged.records {
247 if let Some(existing) = by_id.get(&record.reasoning_uuid) {
248 if existing != record {
249 return Err(KnowledgeError::Conflict("reasoning_uuid"));
250 }
251 } else {
252 records.push(record.clone());
253 by_id.insert(record.reasoning_uuid, record.clone());
254 }
255 }
256 Self::new(records)
257 }
258
259 pub fn record_fingerprint(&self, reasoning_uuid: Uuid) -> Result<[u8; 32], KnowledgeError> {
261 let row = self
262 .records
263 .iter()
264 .find(|row| row.reasoning_uuid == reasoning_uuid)
265 .ok_or(KnowledgeError::Dangling("reasoning_uuid"))?;
266 let mut writer = CanonicalWriter::new();
267 writer.raw(row.reasoning_uuid.as_bytes())?;
268 writer.raw(row.assertion_uuid.as_bytes())?;
269 writer.text(row.kind.as_str())?;
270 writer.text(row.content_format.as_str())?;
271 writer.binary(&row.content)?;
272 match row.supersedes_reasoning_uuid {
273 Some(value) => {
274 writer.u8(1)?;
275 writer.raw(value.as_bytes())?;
276 }
277 None => writer.u8(0)?,
278 }
279 writer.raw(row.provenance_uuid.as_bytes())?;
280 writer.i64(row.recorded_at_micros)?;
281 writer.u32(row.contract_version)?;
282 let bytes = writer.finish();
283 fingerprint(
284 CanonicalDomain::Reasoning,
285 CANONICAL_CONTRACT_VERSION,
286 &bytes,
287 )
288 .map_err(Into::into)
289 }
290
291 #[must_use]
293 pub fn current_for(&self, assertion_uuid: Uuid) -> Option<&ReasoningRecord> {
294 let records = self
295 .records
296 .iter()
297 .filter(|row| row.assertion_uuid == assertion_uuid)
298 .collect::<Vec<_>>();
299 let superseded = records
300 .iter()
301 .filter_map(|row| row.supersedes_reasoning_uuid)
302 .collect::<HashSet<_>>();
303 records
304 .into_iter()
305 .filter(|row| !superseded.contains(&row.reasoning_uuid))
306 .max_by_key(|row| (row.recorded_at_micros, row.reasoning_uuid))
307 }
308
309 pub fn batch(&self) -> Result<RecordBatch, KnowledgeError> {
311 let mut ids = FixedSizeBinaryBuilder::with_capacity(self.records.len(), 16);
312 let mut assertions = FixedSizeBinaryBuilder::with_capacity(self.records.len(), 16);
313 let mut kinds = StringBuilder::with_capacity(self.records.len(), 128);
314 let mut formats = StringBuilder::with_capacity(self.records.len(), 128);
315 let mut contents = BinaryBuilder::new();
316 let mut predecessors = FixedSizeBinaryBuilder::with_capacity(self.records.len(), 16);
317 let mut provenance = FixedSizeBinaryBuilder::with_capacity(self.records.len(), 16);
318 let mut times =
319 TimestampMicrosecondBuilder::with_capacity(self.records.len()).with_timezone("UTC");
320 let mut versions = UInt32Builder::with_capacity(self.records.len());
321 for row in &self.records {
322 ids.append_value(row.reasoning_uuid.as_bytes())?;
323 assertions.append_value(row.assertion_uuid.as_bytes())?;
324 kinds.append_value(row.kind.as_str());
325 formats.append_value(row.content_format.as_str());
326 contents.append_value(&row.content);
327 match row.supersedes_reasoning_uuid {
328 Some(value) => predecessors.append_value(value.as_bytes())?,
329 None => predecessors.append_null(),
330 }
331 provenance.append_value(row.provenance_uuid.as_bytes())?;
332 times.append_value(row.recorded_at_micros);
333 versions.append_value(row.contract_version);
334 }
335 RecordBatch::try_new(
336 Arc::clone(&REASONING_SCHEMA),
337 vec![
338 Arc::new(ids.finish()),
339 Arc::new(assertions.finish()),
340 Arc::new(kinds.finish()),
341 Arc::new(formats.finish()),
342 Arc::new(contents.finish()),
343 Arc::new(predecessors.finish()),
344 Arc::new(provenance.finish()),
345 Arc::new(times.finish()),
346 Arc::new(versions.finish()),
347 ],
348 )
349 .map_err(Into::into)
350 }
351
352 pub fn from_batches(batches: &[RecordBatch]) -> Result<Self, KnowledgeError> {
354 let mut records = Vec::new();
355 for batch in batches {
356 if batch.schema().as_ref() != REASONING_SCHEMA.as_ref() {
357 return Err(invalid("reasoning.schema", "schema mismatch"));
358 }
359 let ids = fixed(batch, "reasoning_uuid")?;
360 let assertions = fixed(batch, "assertion_uuid")?;
361 let kinds = string(batch, "kind")?;
362 let formats = string(batch, "content_format")?;
363 let contents = binary(batch, "content")?;
364 let predecessors = fixed(batch, "supersedes_reasoning_uuid")?;
365 let provenance = fixed(batch, "provenance_uuid")?;
366 let times = timestamp(batch, "recorded_at")?;
367 let versions = uint32(batch, "contract_version")?;
368 for row in 0..batch.num_rows() {
369 records.push(ReasoningRecord {
370 reasoning_uuid: uuid_at(ids, row, "reasoning_uuid")?,
371 assertion_uuid: uuid_at(assertions, row, "assertion_uuid")?,
372 kind: ReasoningKind::parse(kinds.value(row))?,
373 content_format: ReasoningContentFormat::parse(formats.value(row))?,
374 content: contents.value(row).to_vec(),
375 supersedes_reasoning_uuid: (!predecessors.is_null(row))
376 .then(|| uuid_at(predecessors, row, "supersedes_reasoning_uuid"))
377 .transpose()?,
378 provenance_uuid: uuid_at(provenance, row, "provenance_uuid")?,
379 recorded_at_micros: times.value(row),
380 contract_version: versions.value(row),
381 });
382 }
383 }
384 Self::new(records)
385 }
386}
387
388pub(crate) fn schema_registry_entry() -> SchemaRegistryEntry {
389 SchemaRegistryEntry {
390 capability_id: "epistemic",
391 capability_version: EPISTEMIC_CAPABILITY_VERSION,
392 record_family: "reasoning",
393 record_version: REASONING_CONTRACT_VERSION,
394 schema: Arc::clone(&REASONING_SCHEMA),
395 schema_fingerprint: *REASONING_SCHEMA_FINGERPRINT,
396 enum_registry_versions: &[
397 ("reasoning_kind", REASONING_KIND_REGISTRY_VERSION),
398 (
399 "reasoning_content_format",
400 REASONING_CONTENT_FORMAT_REGISTRY_VERSION,
401 ),
402 ],
403 sort_key: &["recorded_at", "reasoning_uuid"],
404 diff_identity_fields: &["reasoning_uuid"],
405 diff_record_uuid_field: Some("reasoning_uuid"),
406 fingerprint_domain: CanonicalDomain::Reasoning,
407 owner: "graphforge-knowledge",
408 implementation_issue: 780,
409 max_rows: MAX_KNOWLEDGE_ROWS,
410 }
411}
412
413fn validate_record(row: &ReasoningRecord) -> Result<(), KnowledgeError> {
414 if row.contract_version != REASONING_CONTRACT_VERSION {
415 return Err(invalid("reasoning.contract_version", "unsupported version"));
416 }
417 require_v7(row.reasoning_uuid, "reasoning_uuid")?;
418 require_v7(row.assertion_uuid, "assertion_uuid")?;
419 require_uuid(row.provenance_uuid, "provenance_uuid")?;
420 if row.supersedes_reasoning_uuid == Some(row.reasoning_uuid) {
421 return Err(invalid(
422 "reasoning.supersedes_reasoning_uuid",
423 "self-link is forbidden",
424 ));
425 }
426 if let Some(previous) = row.supersedes_reasoning_uuid {
427 require_v7(previous, "supersedes_reasoning_uuid")?;
428 }
429 validate_content(row.content_format, &row.content)
430}
431
432fn validate_content(
433 content_format: ReasoningContentFormat,
434 content: &[u8],
435) -> Result<(), KnowledgeError> {
436 if content.is_empty() {
437 return Err(invalid("reasoning.content", "must not be empty"));
438 }
439 if content.len() > MAX_REASONING_CONTENT_BYTES {
440 return Err(KnowledgeError::Limit {
441 participant: "reasoning.content",
442 observed: content.len(),
443 limit: MAX_REASONING_CONTENT_BYTES,
444 });
445 }
446 let text =
447 std::str::from_utf8(content).map_err(|_| invalid("reasoning.content", "must be UTF-8"))?;
448 if content_format == ReasoningContentFormat::ApplicationJson {
449 serde_json::from_str::<serde_json::Value>(text)
450 .map_err(|_| invalid("reasoning.content", "must be valid JSON"))?;
451 }
452 Ok(())
453}
454
455fn reject_cycle(
456 start: Uuid,
457 by_id: &HashMap<Uuid, &ReasoningRecord>,
458 proven_acyclic: &mut HashSet<Uuid>,
459) -> Result<(), KnowledgeError> {
460 if proven_acyclic.contains(&start) {
461 return Ok(());
462 }
463 let mut path = Vec::new();
464 let mut visited = HashSet::new();
465 let mut cursor = Some(start);
466 while let Some(current) = cursor {
467 if proven_acyclic.contains(¤t) {
468 break;
469 }
470 if !visited.insert(current) {
471 return Err(invalid(
472 "reasoning.supersedes_reasoning_uuid",
473 "amendment cycle",
474 ));
475 }
476 path.push(current);
477 cursor = by_id
478 .get(¤t)
479 .and_then(|row| row.supersedes_reasoning_uuid);
480 }
481 proven_acyclic.extend(path);
482 Ok(())
483}
484
485fn uuid_field(name: &str, nullable: bool) -> Field {
486 Field::new(name, DataType::FixedSizeBinary(16), nullable)
487}
488
489fn require_v7(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
490 if value.get_version() != Some(Version::SortRand) {
491 return Err(invalid(field, "must be UUIDv7"));
492 }
493 Ok(())
494}
495
496fn require_uuid(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
497 if value.is_nil() {
498 return Err(invalid(field, "must not be nil"));
499 }
500 Ok(())
501}
502
503const fn invalid(field: &'static str, message: &'static str) -> KnowledgeError {
504 KnowledgeError::Invalid { field, message }
505}
506
507fn fixed<'a>(
508 batch: &'a RecordBatch,
509 name: &'static str,
510) -> Result<&'a FixedSizeBinaryArray, KnowledgeError> {
511 batch
512 .column_by_name(name)
513 .and_then(|value| value.as_any().downcast_ref())
514 .ok_or_else(|| invalid(name, "column type mismatch"))
515}
516
517fn string<'a>(
518 batch: &'a RecordBatch,
519 name: &'static str,
520) -> Result<&'a StringArray, KnowledgeError> {
521 batch
522 .column_by_name(name)
523 .and_then(|value| value.as_any().downcast_ref())
524 .ok_or_else(|| invalid(name, "column type mismatch"))
525}
526
527fn binary<'a>(
528 batch: &'a RecordBatch,
529 name: &'static str,
530) -> Result<&'a BinaryArray, KnowledgeError> {
531 batch
532 .column_by_name(name)
533 .and_then(|value| value.as_any().downcast_ref())
534 .ok_or_else(|| invalid(name, "column type mismatch"))
535}
536
537fn timestamp<'a>(
538 batch: &'a RecordBatch,
539 name: &'static str,
540) -> Result<&'a TimestampMicrosecondArray, KnowledgeError> {
541 batch
542 .column_by_name(name)
543 .and_then(|value| value.as_any().downcast_ref())
544 .ok_or_else(|| invalid(name, "column type mismatch"))
545}
546
547fn uint32<'a>(
548 batch: &'a RecordBatch,
549 name: &'static str,
550) -> Result<&'a UInt32Array, KnowledgeError> {
551 batch
552 .column_by_name(name)
553 .and_then(|value| value.as_any().downcast_ref())
554 .ok_or_else(|| invalid(name, "column type mismatch"))
555}
556
557fn uuid_at(
558 values: &FixedSizeBinaryArray,
559 row: usize,
560 field: &'static str,
561) -> Result<Uuid, KnowledgeError> {
562 Uuid::from_slice(values.value(row)).map_err(|_| invalid(field, "invalid UUID bytes"))
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 fn uuid7(seed: u8) -> Uuid {
570 let mut bytes = [seed; 16];
571 bytes[6] = (bytes[6] & 0x0f) | 0x70;
572 bytes[8] = (bytes[8] & 0x3f) | 0x80;
573 Uuid::from_bytes(bytes)
574 }
575
576 fn row(id: u8, assertion: u8, predecessor: Option<u8>, time: i64) -> ReasoningRecord {
577 ReasoningRecord::new(
578 uuid7(id),
579 uuid7(assertion),
580 ReasoningKind::LogicalInference,
581 ReasoningContentFormat::TextPlain,
582 format!("reasoning-{id}").into_bytes(),
583 predecessor.map(uuid7),
584 uuid7(id.wrapping_add(100)),
585 time,
586 )
587 .unwrap()
588 }
589
590 #[test]
591 fn exact_content_chain_round_trips_and_fingerprints_stably() {
592 assert_eq!(
593 REASONING_SCHEMA_FINGERPRINT
594 .iter()
595 .map(|byte| format!("{byte:02x}"))
596 .collect::<String>(),
597 "f04a1f3f4ce2fa00fb25d56e46bf470c870090504d8b6437197ab41921ffe33e"
598 );
599 let ledger =
600 ReasoningLedger::new(vec![row(2, 20, Some(1), 20), row(1, 20, None, 10)]).unwrap();
601 let batch = ledger.batch().unwrap();
602 let decoded =
603 ReasoningLedger::from_batches(&[batch.slice(0, 1), batch.slice(1, 1)]).unwrap();
604 assert_eq!(decoded, ledger);
605 assert_eq!(
606 decoded.record_fingerprint(uuid7(2)).unwrap(),
607 ledger.record_fingerprint(uuid7(2)).unwrap()
608 );
609 assert_eq!(
610 decoded.current_for(uuid7(20)).unwrap().reasoning_uuid,
611 uuid7(2)
612 );
613 }
614
615 #[test]
616 fn replay_is_idempotent_and_conflicting_uuid_is_rejected() {
617 let base = ReasoningLedger::new(vec![row(1, 20, None, 10)]).unwrap();
618 assert_eq!(base.merge(&base).unwrap(), base);
619 let mut changed = row(1, 20, None, 10);
620 changed.content = b"different".to_vec();
621 let conflict = ReasoningLedger::new(vec![changed]).unwrap();
622 assert!(matches!(
623 base.merge(&conflict),
624 Err(KnowledgeError::Conflict("reasoning_uuid"))
625 ));
626 }
627
628 #[test]
629 fn self_cycle_missing_and_cross_assertion_predecessors_fail() {
630 let mut self_link = row(1, 20, None, 10);
631 self_link.supersedes_reasoning_uuid = Some(uuid7(1));
632 assert!(ReasoningLedger::new(vec![self_link]).is_err());
633 assert!(matches!(
634 ReasoningLedger::new(vec![row(1, 20, Some(2), 10), row(2, 20, Some(1), 20)]),
635 Err(KnowledgeError::Invalid {
636 field: "reasoning.supersedes_reasoning_uuid",
637 message: "amendment cycle",
638 })
639 ));
640 assert!(ReasoningLedger::new(vec![row(2, 20, Some(1), 20)]).is_err());
641 assert!(ReasoningLedger::new(vec![row(1, 20, None, 10), row(2, 21, Some(1), 20)]).is_err());
642 }
643
644 #[test]
645 fn amendment_branch_history_is_preserved_and_current_is_deterministic() {
646 let ledger = ReasoningLedger::new(vec![
647 row(1, 20, None, 10),
648 row(2, 20, Some(1), 20),
649 row(3, 20, Some(1), 20),
650 ])
651 .unwrap();
652 assert_eq!(ledger.records.len(), 3);
653 assert_eq!(
654 ledger.current_for(uuid7(20)).unwrap().reasoning_uuid,
655 uuid7(3)
656 );
657 }
658
659 #[test]
660 fn payload_encoding_and_size_limits_are_sanitized() {
661 assert!(matches!(
662 ReasoningRecord::new(
663 uuid7(1),
664 uuid7(2),
665 ReasoningKind::MethodologicalNote,
666 ReasoningContentFormat::TextPlain,
667 vec![0xff],
668 None,
669 uuid7(3),
670 1,
671 ),
672 Err(KnowledgeError::Invalid {
673 field: "reasoning.content",
674 ..
675 })
676 ));
677 assert!(matches!(
678 ReasoningRecord::new(
679 uuid7(1),
680 uuid7(2),
681 ReasoningKind::MethodologicalNote,
682 ReasoningContentFormat::ApplicationJson,
683 b"{not-json}".to_vec(),
684 None,
685 uuid7(3),
686 1,
687 ),
688 Err(KnowledgeError::Invalid {
689 field: "reasoning.content",
690 ..
691 })
692 ));
693 let exact_json = br#"{"explanation":"kept byte-for-byte"}"#.to_vec();
694 assert_eq!(
695 ReasoningRecord::new(
696 uuid7(1),
697 uuid7(2),
698 ReasoningKind::MethodologicalNote,
699 ReasoningContentFormat::ApplicationJson,
700 exact_json.clone(),
701 None,
702 uuid7(3),
703 1,
704 )
705 .unwrap()
706 .content,
707 exact_json
708 );
709 let oversized = vec![b'x'; MAX_REASONING_CONTENT_BYTES + 1];
710 assert!(matches!(
711 ReasoningRecord::new(
712 uuid7(1),
713 uuid7(2),
714 ReasoningKind::MethodologicalNote,
715 ReasoningContentFormat::TextPlain,
716 oversized,
717 None,
718 uuid7(3),
719 1,
720 ),
721 Err(KnowledgeError::Limit {
722 participant: "reasoning.content",
723 ..
724 })
725 ));
726 }
727
728 #[test]
729 fn reasoning_registry_values_round_trip_and_reject_unknown_tokens() {
730 for kind in [
731 ReasoningKind::EvidenceInterpretation,
732 ReasoningKind::LogicalInference,
733 ReasoningKind::MethodologicalNote,
734 ReasoningKind::DecisionRationale,
735 ] {
736 assert_eq!(ReasoningKind::parse(kind.as_str()).unwrap(), kind);
737 }
738 assert!(ReasoningKind::parse("guess").is_err());
739 for format in [
740 ReasoningContentFormat::TextPlain,
741 ReasoningContentFormat::TextMarkdown,
742 ReasoningContentFormat::ApplicationJson,
743 ] {
744 assert_eq!(
745 ReasoningContentFormat::parse(format.as_str()).unwrap(),
746 format
747 );
748 }
749 assert!(ReasoningContentFormat::parse("text/html").is_err());
750 }
751}