1#[cfg(any(test, feature = "test-support"))]
4use std::cell::Cell;
5use std::collections::{HashMap, HashSet};
6use std::sync::{Arc, LazyLock};
7
8use arrow::array::{
9 Array, BinaryArray, BinaryBuilder, FixedSizeBinaryArray, FixedSizeBinaryBuilder, StringArray,
10 StringBuilder, TimestampMicrosecondArray, TimestampMicrosecondBuilder, UInt32Array,
11 UInt32Builder,
12};
13use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
14use arrow::record_batch::RecordBatch;
15use graphforge_core::canonical::{
16 CANONICAL_CONTRACT_VERSION, CanonicalDomain, CanonicalWriter, fingerprint,
17};
18use uuid::{Uuid, Version};
19
20use crate::{KnowledgeError, MAX_KNOWLEDGE_ROWS, SchemaRegistryEntry};
21
22pub const REASONING_CONTRACT_VERSION: u32 = 1;
24pub const EPISTEMIC_CAPABILITY_VERSION: u32 = 1;
26pub const REASONING_KIND_REGISTRY_VERSION: u32 = 1;
28pub const REASONING_CONTENT_FORMAT_REGISTRY_VERSION: u32 = 1;
30pub const MAX_REASONING_CONTENT_BYTES: usize = 65_536;
32
33pub static REASONING_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
35 Arc::new(Schema::new(vec![
36 uuid_field("reasoning_uuid", false),
37 uuid_field("assertion_uuid", false),
38 Field::new("kind", DataType::Utf8, false),
39 Field::new("content_format", DataType::Utf8, false),
40 Field::new("content", DataType::Binary, false),
41 uuid_field("supersedes_reasoning_uuid", true),
42 uuid_field("provenance_uuid", false),
43 Field::new(
44 "recorded_at",
45 DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
46 false,
47 ),
48 Field::new("contract_version", DataType::UInt32, false),
49 ]))
50});
51
52static REASONING_SCHEMA_FINGERPRINT: LazyLock<[u8; 32]> = LazyLock::new(|| {
53 fingerprint(
54 CanonicalDomain::Schema,
55 CANONICAL_CONTRACT_VERSION,
56 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",
57 )
58 .expect("registered reasoning schema is within canonical bounds")
59});
60
61#[cfg(any(test, feature = "test-support"))]
62std::thread_local! {
63 static RECORD_VALIDATION_CALLS: Cell<Option<usize>> = const { Cell::new(None) };
64}
65
66#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
68pub enum ReasoningKind {
69 EvidenceInterpretation,
71 LogicalInference,
73 MethodologicalNote,
75 DecisionRationale,
77}
78
79impl ReasoningKind {
80 #[must_use]
82 pub const fn as_str(self) -> &'static str {
83 match self {
84 Self::EvidenceInterpretation => "evidence_interpretation",
85 Self::LogicalInference => "logical_inference",
86 Self::MethodologicalNote => "methodological_note",
87 Self::DecisionRationale => "decision_rationale",
88 }
89 }
90
91 fn parse(value: &str) -> Result<Self, KnowledgeError> {
92 match value {
93 "evidence_interpretation" => Ok(Self::EvidenceInterpretation),
94 "logical_inference" => Ok(Self::LogicalInference),
95 "methodological_note" => Ok(Self::MethodologicalNote),
96 "decision_rationale" => Ok(Self::DecisionRationale),
97 _ => Err(invalid("reasoning.kind", "unknown registry value")),
98 }
99 }
100}
101
102#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
104pub enum ReasoningContentFormat {
105 TextPlain,
107 TextMarkdown,
109 ApplicationJson,
111}
112
113impl ReasoningContentFormat {
114 #[must_use]
116 pub const fn as_str(self) -> &'static str {
117 match self {
118 Self::TextPlain => "text/plain",
119 Self::TextMarkdown => "text/markdown",
120 Self::ApplicationJson => "application/json",
121 }
122 }
123
124 fn parse(value: &str) -> Result<Self, KnowledgeError> {
125 match value {
126 "text/plain" => Ok(Self::TextPlain),
127 "text/markdown" => Ok(Self::TextMarkdown),
128 "application/json" => Ok(Self::ApplicationJson),
129 _ => Err(invalid(
130 "reasoning.content_format",
131 "unknown registry value",
132 )),
133 }
134 }
135}
136
137#[derive(Clone, Debug, Eq, PartialEq)]
139pub struct ReasoningRecord {
140 pub reasoning_uuid: Uuid,
142 pub assertion_uuid: Uuid,
144 pub kind: ReasoningKind,
146 pub content_format: ReasoningContentFormat,
148 pub content: Vec<u8>,
150 pub supersedes_reasoning_uuid: Option<Uuid>,
152 pub provenance_uuid: Uuid,
154 pub recorded_at_micros: i64,
156 pub contract_version: u32,
158}
159
160impl ReasoningRecord {
161 #[allow(clippy::too_many_arguments)]
163 pub fn new(
164 reasoning_uuid: Uuid,
165 assertion_uuid: Uuid,
166 kind: ReasoningKind,
167 content_format: ReasoningContentFormat,
168 content: Vec<u8>,
169 supersedes_reasoning_uuid: Option<Uuid>,
170 provenance_uuid: Uuid,
171 recorded_at_micros: i64,
172 ) -> Result<Self, KnowledgeError> {
173 require_v7(reasoning_uuid, "reasoning_uuid")?;
174 require_v7(assertion_uuid, "assertion_uuid")?;
175 require_uuid(provenance_uuid, "provenance_uuid")?;
176 if let Some(previous) = supersedes_reasoning_uuid {
177 require_v7(previous, "supersedes_reasoning_uuid")?;
178 if previous == reasoning_uuid {
179 return Err(invalid(
180 "reasoning.supersedes_reasoning_uuid",
181 "self-link is forbidden",
182 ));
183 }
184 }
185 validate_content(content_format, &content)?;
186 Ok(Self {
187 reasoning_uuid,
188 assertion_uuid,
189 kind,
190 content_format,
191 content,
192 supersedes_reasoning_uuid,
193 provenance_uuid,
194 recorded_at_micros,
195 contract_version: REASONING_CONTRACT_VERSION,
196 })
197 }
198}
199
200#[derive(Clone, Debug, Default, Eq, PartialEq)]
202pub struct ReasoningLedger {
203 records: Vec<ReasoningRecord>,
205}
206
207impl ReasoningLedger {
208 #[must_use]
216 pub fn records(&self) -> &[ReasoningRecord] {
217 &self.records
218 }
219
220 #[must_use]
222 pub fn into_records(self) -> Vec<ReasoningRecord> {
223 self.records
224 }
225
226 pub fn new(records: Vec<ReasoningRecord>) -> Result<Self, KnowledgeError> {
228 Self::new_with_limit(records, MAX_KNOWLEDGE_ROWS)
229 }
230
231 fn new_with_limit(
232 mut records: Vec<ReasoningRecord>,
233 row_limit: usize,
234 ) -> Result<Self, KnowledgeError> {
235 validate_record_count(records.len(), row_limit)?;
236 let mut by_id = HashMap::with_capacity(records.len());
237 for record in &records {
238 validate_record(record)?;
239 if by_id.insert(record.reasoning_uuid, record).is_some() {
240 return Err(KnowledgeError::Duplicate("reasoning_uuid"));
241 }
242 }
243 for record in &records {
244 if let Some(previous_uuid) = record.supersedes_reasoning_uuid {
245 let previous = by_id
246 .get(&previous_uuid)
247 .ok_or(KnowledgeError::Dangling("supersedes_reasoning_uuid"))?;
248 if previous.assertion_uuid != record.assertion_uuid {
249 return Err(invalid(
250 "reasoning.supersedes_reasoning_uuid",
251 "cross-assertion amendment is forbidden",
252 ));
253 }
254 }
255 }
256 let mut proven_acyclic = HashSet::with_capacity(records.len());
257 for record in &records {
258 reject_cycle(record.reasoning_uuid, &by_id, &mut proven_acyclic)?;
259 }
260 records.sort_by_key(|row| (row.recorded_at_micros, row.reasoning_uuid));
261 Ok(Self { records })
262 }
263
264 pub fn merge(&self, staged: &Self) -> Result<Self, KnowledgeError> {
277 self.merge_with_limit(staged, MAX_KNOWLEDGE_ROWS)
278 }
279
280 pub fn append(&self, record: ReasoningRecord) -> Result<Self, KnowledgeError> {
287 self.append_with_limit(record, MAX_KNOWLEDGE_ROWS)
288 }
289
290 fn append_with_limit(
291 &self,
292 record: ReasoningRecord,
293 row_limit: usize,
294 ) -> Result<Self, KnowledgeError> {
295 validate_record(&record)?;
296 let existing = self
297 .records
298 .iter()
299 .find(|row| row.reasoning_uuid == record.reasoning_uuid);
300 if let Some(existing) = existing {
301 return if existing == &record {
302 Ok(self.clone())
303 } else {
304 Err(KnowledgeError::Conflict("reasoning_uuid"))
305 };
306 }
307
308 validate_record_count(self.records.len().saturating_add(1), row_limit)?;
309 let mut by_id = self
310 .records
311 .iter()
312 .map(|row| (row.reasoning_uuid, row))
313 .collect::<HashMap<_, _>>();
314 if let Some(previous_uuid) = record.supersedes_reasoning_uuid {
315 let previous = by_id
316 .get(&previous_uuid)
317 .ok_or(KnowledgeError::Dangling("supersedes_reasoning_uuid"))?;
318 if previous.assertion_uuid != record.assertion_uuid {
319 return Err(invalid(
320 "reasoning.supersedes_reasoning_uuid",
321 "cross-assertion amendment is forbidden",
322 ));
323 }
324 }
325 by_id.insert(record.reasoning_uuid, &record);
326 reject_cycle(record.reasoning_uuid, &by_id, &mut HashSet::new())?;
327
328 let mut records = self.records.clone();
329 records.push(record);
330 records.sort_by_key(|row| (row.recorded_at_micros, row.reasoning_uuid));
331 Ok(Self { records })
332 }
333
334 fn merge_with_limit(&self, staged: &Self, row_limit: usize) -> Result<Self, KnowledgeError> {
335 let mut records = self.records.clone();
336 let mut by_id = records
337 .iter()
338 .cloned()
339 .map(|row| (row.reasoning_uuid, row))
340 .collect::<HashMap<_, _>>();
341 let mut appended = false;
342 for record in &staged.records {
343 if let Some(existing) = by_id.get(&record.reasoning_uuid) {
344 if existing != record {
345 return Err(KnowledgeError::Conflict("reasoning_uuid"));
346 }
347 } else {
348 validate_record_count(records.len().saturating_add(1), row_limit)?;
349 records.push(record.clone());
350 by_id.insert(record.reasoning_uuid, record.clone());
351 appended = true;
352 }
353 }
354 if appended {
355 records.sort_by_key(|row| (row.recorded_at_micros, row.reasoning_uuid));
356 }
357 Ok(Self { records })
358 }
359
360 pub fn record_fingerprint(&self, reasoning_uuid: Uuid) -> Result<[u8; 32], KnowledgeError> {
362 let row = self
363 .records
364 .iter()
365 .find(|row| row.reasoning_uuid == reasoning_uuid)
366 .ok_or(KnowledgeError::Dangling("reasoning_uuid"))?;
367 let mut writer = CanonicalWriter::new();
368 writer.raw(row.reasoning_uuid.as_bytes())?;
369 writer.raw(row.assertion_uuid.as_bytes())?;
370 writer.text(row.kind.as_str())?;
371 writer.text(row.content_format.as_str())?;
372 writer.binary(&row.content)?;
373 match row.supersedes_reasoning_uuid {
374 Some(value) => {
375 writer.u8(1)?;
376 writer.raw(value.as_bytes())?;
377 }
378 None => writer.u8(0)?,
379 }
380 writer.raw(row.provenance_uuid.as_bytes())?;
381 writer.i64(row.recorded_at_micros)?;
382 writer.u32(row.contract_version)?;
383 let bytes = writer.finish();
384 fingerprint(
385 CanonicalDomain::Reasoning,
386 CANONICAL_CONTRACT_VERSION,
387 &bytes,
388 )
389 .map_err(Into::into)
390 }
391
392 #[must_use]
394 pub fn current_for(&self, assertion_uuid: Uuid) -> Option<&ReasoningRecord> {
395 let records = self
396 .records
397 .iter()
398 .filter(|row| row.assertion_uuid == assertion_uuid)
399 .collect::<Vec<_>>();
400 let superseded = records
401 .iter()
402 .filter_map(|row| row.supersedes_reasoning_uuid)
403 .collect::<HashSet<_>>();
404 records
405 .into_iter()
406 .filter(|row| !superseded.contains(&row.reasoning_uuid))
407 .max_by_key(|row| (row.recorded_at_micros, row.reasoning_uuid))
408 }
409
410 pub fn batch(&self) -> Result<RecordBatch, KnowledgeError> {
412 let mut ids = FixedSizeBinaryBuilder::with_capacity(self.records.len(), 16);
413 let mut assertions = FixedSizeBinaryBuilder::with_capacity(self.records.len(), 16);
414 let mut kinds = StringBuilder::with_capacity(self.records.len(), 128);
415 let mut formats = StringBuilder::with_capacity(self.records.len(), 128);
416 let mut contents = BinaryBuilder::new();
417 let mut predecessors = FixedSizeBinaryBuilder::with_capacity(self.records.len(), 16);
418 let mut provenance = FixedSizeBinaryBuilder::with_capacity(self.records.len(), 16);
419 let mut times =
420 TimestampMicrosecondBuilder::with_capacity(self.records.len()).with_timezone("UTC");
421 let mut versions = UInt32Builder::with_capacity(self.records.len());
422 for row in &self.records {
423 ids.append_value(row.reasoning_uuid.as_bytes())?;
424 assertions.append_value(row.assertion_uuid.as_bytes())?;
425 kinds.append_value(row.kind.as_str());
426 formats.append_value(row.content_format.as_str());
427 contents.append_value(&row.content);
428 match row.supersedes_reasoning_uuid {
429 Some(value) => predecessors.append_value(value.as_bytes())?,
430 None => predecessors.append_null(),
431 }
432 provenance.append_value(row.provenance_uuid.as_bytes())?;
433 times.append_value(row.recorded_at_micros);
434 versions.append_value(row.contract_version);
435 }
436 RecordBatch::try_new(
437 Arc::clone(&REASONING_SCHEMA),
438 vec![
439 Arc::new(ids.finish()),
440 Arc::new(assertions.finish()),
441 Arc::new(kinds.finish()),
442 Arc::new(formats.finish()),
443 Arc::new(contents.finish()),
444 Arc::new(predecessors.finish()),
445 Arc::new(provenance.finish()),
446 Arc::new(times.finish()),
447 Arc::new(versions.finish()),
448 ],
449 )
450 .map_err(Into::into)
451 }
452
453 pub fn from_batches(batches: &[RecordBatch]) -> Result<Self, KnowledgeError> {
455 let mut records = Vec::new();
456 for batch in batches {
457 if batch.schema().as_ref() != REASONING_SCHEMA.as_ref() {
458 return Err(invalid("reasoning.schema", "schema mismatch"));
459 }
460 let ids = fixed(batch, "reasoning_uuid")?;
461 let assertions = fixed(batch, "assertion_uuid")?;
462 let kinds = string(batch, "kind")?;
463 let formats = string(batch, "content_format")?;
464 let contents = binary(batch, "content")?;
465 let predecessors = fixed(batch, "supersedes_reasoning_uuid")?;
466 let provenance = fixed(batch, "provenance_uuid")?;
467 let times = timestamp(batch, "recorded_at")?;
468 let versions = uint32(batch, "contract_version")?;
469 for row in 0..batch.num_rows() {
470 records.push(ReasoningRecord {
471 reasoning_uuid: uuid_at(ids, row, "reasoning_uuid")?,
472 assertion_uuid: uuid_at(assertions, row, "assertion_uuid")?,
473 kind: ReasoningKind::parse(kinds.value(row))?,
474 content_format: ReasoningContentFormat::parse(formats.value(row))?,
475 content: contents.value(row).to_vec(),
476 supersedes_reasoning_uuid: (!predecessors.is_null(row))
477 .then(|| uuid_at(predecessors, row, "supersedes_reasoning_uuid"))
478 .transpose()?,
479 provenance_uuid: uuid_at(provenance, row, "provenance_uuid")?,
480 recorded_at_micros: times.value(row),
481 contract_version: versions.value(row),
482 });
483 }
484 }
485 Self::new(records)
486 }
487}
488
489pub(crate) fn schema_registry_entry() -> SchemaRegistryEntry {
490 SchemaRegistryEntry {
491 capability_id: "epistemic",
492 capability_version: EPISTEMIC_CAPABILITY_VERSION,
493 record_family: "reasoning",
494 record_version: REASONING_CONTRACT_VERSION,
495 schema: Arc::clone(&REASONING_SCHEMA),
496 schema_fingerprint: *REASONING_SCHEMA_FINGERPRINT,
497 enum_registry_versions: &[
498 ("reasoning_kind", REASONING_KIND_REGISTRY_VERSION),
499 (
500 "reasoning_content_format",
501 REASONING_CONTENT_FORMAT_REGISTRY_VERSION,
502 ),
503 ],
504 sort_key: &["recorded_at", "reasoning_uuid"],
505 diff_identity_fields: &["reasoning_uuid"],
506 diff_record_uuid_field: Some("reasoning_uuid"),
507 fingerprint_domain: CanonicalDomain::Reasoning,
508 owner: "graphforge-knowledge",
509 implementation_issue: 780,
510 max_rows: MAX_KNOWLEDGE_ROWS,
511 }
512}
513
514fn validate_record(row: &ReasoningRecord) -> Result<(), KnowledgeError> {
515 #[cfg(any(test, feature = "test-support"))]
516 RECORD_VALIDATION_CALLS.with(|calls| {
517 if let Some(count) = calls.get() {
518 calls.set(Some(count + 1));
519 }
520 });
521
522 if row.contract_version != REASONING_CONTRACT_VERSION {
523 return Err(invalid("reasoning.contract_version", "unsupported version"));
524 }
525 require_v7(row.reasoning_uuid, "reasoning_uuid")?;
526 require_v7(row.assertion_uuid, "assertion_uuid")?;
527 require_uuid(row.provenance_uuid, "provenance_uuid")?;
528 if row.supersedes_reasoning_uuid == Some(row.reasoning_uuid) {
529 return Err(invalid(
530 "reasoning.supersedes_reasoning_uuid",
531 "self-link is forbidden",
532 ));
533 }
534 if let Some(previous) = row.supersedes_reasoning_uuid {
535 require_v7(previous, "supersedes_reasoning_uuid")?;
536 }
537 validate_content(row.content_format, &row.content)
538}
539
540#[cfg(feature = "test-support")]
541pub mod test_support {
543 pub fn start_record_validation_count() {
545 super::RECORD_VALIDATION_CALLS.with(|calls| calls.set(Some(0)));
546 }
547
548 #[must_use]
550 pub fn finish_record_validation_count() -> usize {
551 super::RECORD_VALIDATION_CALLS.with(|calls| calls.take().unwrap_or_default())
552 }
553}
554
555fn validate_record_count(count: usize, limit: usize) -> Result<(), KnowledgeError> {
556 if count > limit {
557 return Err(KnowledgeError::Limit {
558 participant: "reasoning",
559 observed: count,
560 limit,
561 });
562 }
563 Ok(())
564}
565
566fn validate_content(
567 content_format: ReasoningContentFormat,
568 content: &[u8],
569) -> Result<(), KnowledgeError> {
570 if content.is_empty() {
571 return Err(invalid("reasoning.content", "must not be empty"));
572 }
573 if content.len() > MAX_REASONING_CONTENT_BYTES {
574 return Err(KnowledgeError::Limit {
575 participant: "reasoning.content",
576 observed: content.len(),
577 limit: MAX_REASONING_CONTENT_BYTES,
578 });
579 }
580 let text =
581 std::str::from_utf8(content).map_err(|_| invalid("reasoning.content", "must be UTF-8"))?;
582 if content_format == ReasoningContentFormat::ApplicationJson {
583 serde_json::from_str::<serde_json::Value>(text)
584 .map_err(|_| invalid("reasoning.content", "must be valid JSON"))?;
585 }
586 Ok(())
587}
588
589fn reject_cycle(
590 start: Uuid,
591 by_id: &HashMap<Uuid, &ReasoningRecord>,
592 proven_acyclic: &mut HashSet<Uuid>,
593) -> Result<(), KnowledgeError> {
594 if proven_acyclic.contains(&start) {
595 return Ok(());
596 }
597 let mut path = Vec::new();
598 let mut visited = HashSet::new();
599 let mut cursor = Some(start);
600 while let Some(current) = cursor {
601 if proven_acyclic.contains(¤t) {
602 break;
603 }
604 if !visited.insert(current) {
605 return Err(invalid(
606 "reasoning.supersedes_reasoning_uuid",
607 "amendment cycle",
608 ));
609 }
610 path.push(current);
611 cursor = by_id
612 .get(¤t)
613 .and_then(|row| row.supersedes_reasoning_uuid);
614 }
615 proven_acyclic.extend(path);
616 Ok(())
617}
618
619fn uuid_field(name: &str, nullable: bool) -> Field {
620 Field::new(name, DataType::FixedSizeBinary(16), nullable)
621}
622
623fn require_v7(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
624 if value.get_version() != Some(Version::SortRand) {
625 return Err(invalid(field, "must be UUIDv7"));
626 }
627 Ok(())
628}
629
630fn require_uuid(value: Uuid, field: &'static str) -> Result<(), KnowledgeError> {
631 if value.is_nil() {
632 return Err(invalid(field, "must not be nil"));
633 }
634 Ok(())
635}
636
637const fn invalid(field: &'static str, message: &'static str) -> KnowledgeError {
638 KnowledgeError::Invalid { field, message }
639}
640
641fn fixed<'a>(
642 batch: &'a RecordBatch,
643 name: &'static str,
644) -> Result<&'a FixedSizeBinaryArray, KnowledgeError> {
645 batch
646 .column_by_name(name)
647 .and_then(|value| value.as_any().downcast_ref())
648 .ok_or_else(|| invalid(name, "column type mismatch"))
649}
650
651fn string<'a>(
652 batch: &'a RecordBatch,
653 name: &'static str,
654) -> Result<&'a StringArray, KnowledgeError> {
655 batch
656 .column_by_name(name)
657 .and_then(|value| value.as_any().downcast_ref())
658 .ok_or_else(|| invalid(name, "column type mismatch"))
659}
660
661fn binary<'a>(
662 batch: &'a RecordBatch,
663 name: &'static str,
664) -> Result<&'a BinaryArray, KnowledgeError> {
665 batch
666 .column_by_name(name)
667 .and_then(|value| value.as_any().downcast_ref())
668 .ok_or_else(|| invalid(name, "column type mismatch"))
669}
670
671fn timestamp<'a>(
672 batch: &'a RecordBatch,
673 name: &'static str,
674) -> Result<&'a TimestampMicrosecondArray, KnowledgeError> {
675 batch
676 .column_by_name(name)
677 .and_then(|value| value.as_any().downcast_ref())
678 .ok_or_else(|| invalid(name, "column type mismatch"))
679}
680
681fn uint32<'a>(
682 batch: &'a RecordBatch,
683 name: &'static str,
684) -> Result<&'a UInt32Array, KnowledgeError> {
685 batch
686 .column_by_name(name)
687 .and_then(|value| value.as_any().downcast_ref())
688 .ok_or_else(|| invalid(name, "column type mismatch"))
689}
690
691fn uuid_at(
692 values: &FixedSizeBinaryArray,
693 row: usize,
694 field: &'static str,
695) -> Result<Uuid, KnowledgeError> {
696 Uuid::from_slice(values.value(row)).map_err(|_| invalid(field, "invalid UUID bytes"))
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702 use std::time::{Duration, Instant};
703
704 fn uuid7(seed: u8) -> Uuid {
705 let mut bytes = [seed; 16];
706 bytes[6] = (bytes[6] & 0x0f) | 0x70;
707 bytes[8] = (bytes[8] & 0x3f) | 0x80;
708 Uuid::from_bytes(bytes)
709 }
710
711 fn row(id: u8, assertion: u8, predecessor: Option<u8>, time: i64) -> ReasoningRecord {
712 ReasoningRecord::new(
713 uuid7(id),
714 uuid7(assertion),
715 ReasoningKind::LogicalInference,
716 ReasoningContentFormat::TextPlain,
717 format!("reasoning-{id}").into_bytes(),
718 predecessor.map(uuid7),
719 uuid7(id.wrapping_add(100)),
720 time,
721 )
722 .unwrap()
723 }
724
725 fn unvalidated_row(id: u8) -> ReasoningRecord {
726 ReasoningRecord {
727 reasoning_uuid: uuid7(id),
728 assertion_uuid: uuid7(id.wrapping_add(20)),
729 kind: ReasoningKind::LogicalInference,
730 content_format: ReasoningContentFormat::TextPlain,
731 content: b"reasoning".to_vec(),
732 supersedes_reasoning_uuid: None,
733 provenance_uuid: uuid7(id.wrapping_add(40)),
734 recorded_at_micros: i64::from(id),
735 contract_version: REASONING_CONTRACT_VERSION,
736 }
737 }
738
739 fn benchmark_uuid(seed: u64) -> Uuid {
740 let mut bytes = [0; 16];
741 bytes[8..].copy_from_slice(&seed.to_be_bytes());
742 bytes[6] = 0x70;
743 bytes[8] = (bytes[8] & 0x3f) | 0x80;
744 Uuid::from_bytes(bytes)
745 }
746
747 fn benchmark_row(seed: u64) -> ReasoningRecord {
748 ReasoningRecord {
749 reasoning_uuid: benchmark_uuid(seed + 1),
750 assertion_uuid: benchmark_uuid(10_000_000),
751 kind: ReasoningKind::LogicalInference,
752 content_format: ReasoningContentFormat::TextPlain,
753 content: b"bounded reasoning measurement".to_vec(),
754 supersedes_reasoning_uuid: None,
755 provenance_uuid: benchmark_uuid(seed + 20_000),
756 recorded_at_micros: i64::try_from(seed).unwrap(),
757 contract_version: REASONING_CONTRACT_VERSION,
758 }
759 }
760
761 fn merge_with_full_revalidation(
762 existing: &ReasoningLedger,
763 staged: &ReasoningLedger,
764 ) -> Result<ReasoningLedger, KnowledgeError> {
765 let mut records = existing.records.clone();
766 let mut by_id = records
767 .iter()
768 .cloned()
769 .map(|row| (row.reasoning_uuid, row))
770 .collect::<HashMap<_, _>>();
771 for row in staged.records() {
772 if let Some(previous) = by_id.get(&row.reasoning_uuid) {
773 if previous != row {
774 return Err(KnowledgeError::Conflict("reasoning_uuid"));
775 }
776 } else {
777 records.push(row.clone());
778 by_id.insert(row.reasoning_uuid, row.clone());
779 }
780 }
781 ReasoningLedger::new(records)
782 }
783
784 fn input_digest(existing: &ReasoningLedger, staged: &ReasoningLedger) -> String {
785 let mut writer = CanonicalWriter::new();
786 for ledger in [existing, staged] {
787 writer.u64(ledger.records().len() as u64).unwrap();
788 for row in ledger.records() {
789 writer.raw(row.reasoning_uuid.as_bytes()).unwrap();
790 writer.raw(row.assertion_uuid.as_bytes()).unwrap();
791 writer.text(row.kind.as_str()).unwrap();
792 writer.text(row.content_format.as_str()).unwrap();
793 writer.binary(&row.content).unwrap();
794 match row.supersedes_reasoning_uuid {
795 Some(previous) => {
796 writer.u8(1).unwrap();
797 writer.raw(previous.as_bytes()).unwrap();
798 }
799 None => writer.u8(0).unwrap(),
800 }
801 writer.raw(row.provenance_uuid.as_bytes()).unwrap();
802 writer.i64(row.recorded_at_micros).unwrap();
803 writer.u32(row.contract_version).unwrap();
804 }
805 }
806 let bytes = fingerprint(
807 CanonicalDomain::Reasoning,
808 CANONICAL_CONTRACT_VERSION,
809 &writer.finish(),
810 )
811 .unwrap();
812 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
813 }
814
815 fn median(mut values: Vec<Duration>) -> Duration {
816 values.sort_unstable();
817 values[values.len() / 2]
818 }
819
820 #[test]
821 #[ignore = "manual quiet-host before/after merge-cost measurement"]
822 fn quiet_host_merge_cost_measurement() {
823 for existing_count in [1_000_u64, 10_000] {
824 let existing =
825 ReasoningLedger::new((0..existing_count).map(benchmark_row).collect()).unwrap();
826 let staged = ReasoningLedger::new(vec![benchmark_row(existing_count)]).unwrap();
827 let baseline_result = merge_with_full_revalidation(&existing, &staged).unwrap();
828 let incremental_result = existing.merge(&staged).unwrap();
829 assert_eq!(
830 baseline_result, incremental_result,
831 "baseline and incremental ledgers must match before timing"
832 );
833 let repetitions = 9;
834 let mut before = Vec::with_capacity(repetitions);
835 let mut after = Vec::with_capacity(repetitions);
836 for repetition in 0..repetitions {
837 let baseline = || {
838 let start = Instant::now();
839 std::hint::black_box(merge_with_full_revalidation(&existing, &staged).unwrap());
840 start.elapsed()
841 };
842 let incremental = || {
843 let start = Instant::now();
844 std::hint::black_box(existing.merge(&staged).unwrap());
845 start.elapsed()
846 };
847 if repetition % 2 == 0 {
848 before.push(baseline());
849 after.push(incremental());
850 } else {
851 after.push(incremental());
852 before.push(baseline());
853 }
854 }
855 let before_median = median(before.clone());
856 let after_median = median(after.clone());
857 let divisor = existing_count as f64;
858 println!(
859 "existing_rows={existing_count} staged_rows=1 repetitions={repetitions} input_sha256={} baseline_then_candidate_on_even_pairs=true baseline_ns={:?} baseline_median_ns={} baseline_ns_per_existing_row={:.3} incremental_ns={:?} incremental_median_ns={} incremental_ns_per_existing_row={:.3}",
860 input_digest(&existing, &staged),
861 before.iter().map(Duration::as_nanos).collect::<Vec<_>>(),
862 before_median.as_nanos(),
863 before_median.as_nanos() as f64 / divisor,
864 after.iter().map(Duration::as_nanos).collect::<Vec<_>>(),
865 after_median.as_nanos(),
866 after_median.as_nanos() as f64 / divisor,
867 );
868 }
869 }
870
871 #[test]
872 fn exact_content_chain_round_trips_and_fingerprints_stably() {
873 assert_eq!(
874 REASONING_SCHEMA_FINGERPRINT
875 .iter()
876 .map(|byte| format!("{byte:02x}"))
877 .collect::<String>(),
878 "f04a1f3f4ce2fa00fb25d56e46bf470c870090504d8b6437197ab41921ffe33e"
879 );
880 let ledger =
881 ReasoningLedger::new(vec![row(2, 20, Some(1), 20), row(1, 20, None, 10)]).unwrap();
882 let batch = ledger.batch().unwrap();
883 let decoded =
884 ReasoningLedger::from_batches(&[batch.slice(0, 1), batch.slice(1, 1)]).unwrap();
885 assert_eq!(decoded, ledger);
886 assert_eq!(
887 decoded.record_fingerprint(uuid7(2)).unwrap(),
888 ledger.record_fingerprint(uuid7(2)).unwrap()
889 );
890 assert_eq!(
891 decoded.current_for(uuid7(20)).unwrap().reasoning_uuid,
892 uuid7(2)
893 );
894 }
895
896 #[test]
897 fn reasoning_ledger_and_merge_sort_by_time_then_uuid() {
898 let constructed = ReasoningLedger::new(vec![
899 row(3, 20, None, 10),
900 row(2, 21, None, 10),
901 row(1, 22, None, 5),
902 ])
903 .unwrap();
904 assert_eq!(
905 constructed
906 .records()
907 .iter()
908 .map(|record| record.reasoning_uuid)
909 .collect::<Vec<_>>(),
910 vec![uuid7(1), uuid7(2), uuid7(3)]
911 );
912
913 let base = ReasoningLedger::new(vec![row(4, 23, None, 10)]).unwrap();
914 let backdated = ReasoningLedger::new(vec![row(5, 24, None, 5)]).unwrap();
915 let merged = base.merge(&backdated).unwrap();
916 assert_eq!(
917 merged
918 .records()
919 .iter()
920 .map(|record| record.reasoning_uuid)
921 .collect::<Vec<_>>(),
922 vec![uuid7(5), uuid7(4)]
923 );
924 }
925
926 #[test]
927 fn replay_is_idempotent_and_conflicting_uuid_is_rejected() {
928 let base = ReasoningLedger::new(vec![row(1, 20, None, 10)]).unwrap();
929 assert_eq!(base.merge(&base).unwrap(), base);
930 let mut changed = row(1, 20, None, 10);
931 changed.content = b"different".to_vec();
932 let conflict = ReasoningLedger::new(vec![changed]).unwrap();
933 assert!(matches!(
934 base.merge(&conflict),
935 Err(KnowledgeError::Conflict("reasoning_uuid"))
936 ));
937 }
938
939 #[test]
940 fn ledger_rows_cannot_be_mutated_through_the_public_api() {
941 let ledger = ReasoningLedger::new(vec![row(1, 20, None, 10)]).unwrap();
942 assert_eq!(ledger.records().len(), 1);
943 assert_eq!(ledger.clone().into_records().len(), 1);
944 }
945
946 #[test]
947 fn incremental_merge_accepts_a_valid_new_amendment_without_revalidating_base() {
948 let base_records = vec![
949 row(1, 20, None, 10),
950 row(2, 20, Some(1), 20),
951 row(3, 20, Some(2), 30),
952 ];
953 let base = ReasoningLedger::new(base_records.clone()).unwrap();
954 RECORD_VALIDATION_CALLS.with(|calls| calls.set(Some(0)));
955 let staged = ReasoningLedger::new(vec![
956 base_records[0].clone(),
957 base_records[1].clone(),
958 base_records[2].clone(),
959 row(4, 20, Some(3), 40),
960 ])
961 .unwrap();
962 RECORD_VALIDATION_CALLS.with(|calls| {
963 assert_eq!(calls.get(), Some(4));
964 calls.set(Some(0));
965 });
966 let merged = base.merge(&staged).unwrap();
967 RECORD_VALIDATION_CALLS.with(|calls| {
968 assert_eq!(calls.get(), Some(0));
969 calls.set(None);
970 });
971
972 assert_eq!(merged.records().len(), 4);
973 assert_eq!(
974 merged
975 .records()
976 .iter()
977 .map(|record| record.reasoning_uuid)
978 .collect::<Vec<_>>(),
979 vec![uuid7(1), uuid7(2), uuid7(3), uuid7(4)]
980 );
981 }
982
983 #[test]
984 fn incremental_append_accepts_base_predecessor_without_revalidating_base() {
985 let base = ReasoningLedger::new(vec![row(1, 20, None, 10)]).unwrap();
986 let amendment = row(2, 20, Some(1), 20);
987 RECORD_VALIDATION_CALLS.with(|calls| calls.set(Some(0)));
988 let appended = base.append(amendment.clone()).unwrap();
989 RECORD_VALIDATION_CALLS.with(|calls| {
990 assert_eq!(calls.get(), Some(1));
991 calls.set(None);
992 });
993
994 assert_eq!(appended.records().len(), 2);
995 assert_eq!(appended.records()[1], amendment);
996 assert_eq!(base.records().len(), 1);
997 assert_eq!(base.append(amendment).unwrap(), appended);
998 }
999
1000 #[test]
1001 fn incremental_append_rechecks_combined_identity_reference_and_cap_invariants() {
1002 let base = ReasoningLedger::new(vec![row(1, 20, None, 10)]).unwrap();
1003 assert!(matches!(
1004 base.append(row(1, 20, None, 10)),
1005 Ok(ref ledger) if ledger == &base
1006 ));
1007 assert!(matches!(
1008 base.append(row(1, 20, None, 11)),
1009 Err(KnowledgeError::Conflict("reasoning_uuid"))
1010 ));
1011 assert!(matches!(
1012 base.append(row(2, 20, Some(9), 20)),
1013 Err(KnowledgeError::Dangling("supersedes_reasoning_uuid"))
1014 ));
1015 assert!(matches!(
1016 base.append(row(2, 21, Some(1), 20)),
1017 Err(KnowledgeError::Invalid {
1018 field: "reasoning.supersedes_reasoning_uuid",
1019 ..
1020 })
1021 ));
1022 assert!(matches!(
1023 base.append_with_limit(row(2, 20, Some(1), 20), 1),
1024 Err(KnowledgeError::Limit {
1025 participant: "reasoning",
1026 observed: 2,
1027 limit: 1,
1028 })
1029 ));
1030 }
1031
1032 #[test]
1033 fn merge_enforces_the_combined_row_limit_after_replays() {
1034 let base = ReasoningLedger::new(vec![row(1, 20, None, 10), row(2, 21, None, 20)]).unwrap();
1035 let staged =
1036 ReasoningLedger::new(vec![row(3, 22, None, 30), row(4, 23, None, 40)]).unwrap();
1037 assert!(matches!(
1038 base.merge_with_limit(&staged, 3),
1039 Err(KnowledgeError::Limit {
1040 participant: "reasoning",
1041 observed: 4,
1042 limit: 3,
1043 })
1044 ));
1045 assert_eq!(base.merge_with_limit(&base, 2).unwrap(), base);
1046 }
1047
1048 #[test]
1049 fn reasoning_record_count_limit_accepts_the_limit_and_rejects_the_first_excess() {
1050 assert!(validate_record_count(MAX_KNOWLEDGE_ROWS, MAX_KNOWLEDGE_ROWS).is_ok());
1051 assert!(matches!(
1052 validate_record_count(MAX_KNOWLEDGE_ROWS + 1, MAX_KNOWLEDGE_ROWS),
1053 Err(KnowledgeError::Limit {
1054 participant: "reasoning",
1055 observed,
1056 limit: MAX_KNOWLEDGE_ROWS,
1057 }) if observed == MAX_KNOWLEDGE_ROWS + 1
1058 ));
1059 let rows = vec![row(1, 20, None, 10), row(2, 21, None, 20)];
1060 assert!(ReasoningLedger::new_with_limit(rows.clone(), 2).is_ok());
1061 assert!(matches!(
1062 ReasoningLedger::new_with_limit(rows, 1),
1063 Err(KnowledgeError::Limit {
1064 participant: "reasoning",
1065 observed: 2,
1066 limit: 1,
1067 })
1068 ));
1069 }
1070
1071 #[test]
1072 fn reasoning_record_contract_version_must_be_supported() {
1073 let mut record = unvalidated_row(1);
1074 record.contract_version = REASONING_CONTRACT_VERSION + 1;
1075 assert!(matches!(
1076 ReasoningLedger::new(vec![record]),
1077 Err(KnowledgeError::Invalid {
1078 field: "reasoning.contract_version",
1079 ..
1080 })
1081 ));
1082 }
1083
1084 #[test]
1085 fn reasoning_uuid_must_be_uuidv7() {
1086 let mut record = unvalidated_row(1);
1087 record.reasoning_uuid = Uuid::from_u128(1);
1088 assert!(matches!(
1089 ReasoningLedger::new(vec![record]),
1090 Err(KnowledgeError::Invalid {
1091 field: "reasoning_uuid",
1092 ..
1093 })
1094 ));
1095 }
1096
1097 #[test]
1098 fn reasoning_assertion_uuid_must_be_uuidv7() {
1099 let mut record = unvalidated_row(1);
1100 record.assertion_uuid = Uuid::from_u128(1);
1101 assert!(matches!(
1102 ReasoningLedger::new(vec![record]),
1103 Err(KnowledgeError::Invalid {
1104 field: "assertion_uuid",
1105 ..
1106 })
1107 ));
1108 }
1109
1110 #[test]
1111 fn reasoning_provenance_uuid_must_not_be_nil() {
1112 let mut record = unvalidated_row(1);
1113 record.provenance_uuid = Uuid::nil();
1114 assert!(matches!(
1115 ReasoningLedger::new(vec![record]),
1116 Err(KnowledgeError::Invalid {
1117 field: "provenance_uuid",
1118 ..
1119 })
1120 ));
1121 }
1122
1123 #[test]
1124 fn superseded_reasoning_uuid_must_be_uuidv7() {
1125 let mut record = unvalidated_row(2);
1126 record.supersedes_reasoning_uuid = Some(Uuid::from_u128(1));
1127 assert!(matches!(
1128 ReasoningLedger::new(vec![record]),
1129 Err(KnowledgeError::Invalid {
1130 field: "supersedes_reasoning_uuid",
1131 ..
1132 })
1133 ));
1134 }
1135
1136 #[test]
1137 fn reasoning_record_must_not_amend_itself() {
1138 let mut record = unvalidated_row(1);
1139 record.supersedes_reasoning_uuid = Some(record.reasoning_uuid);
1140 assert!(matches!(
1141 ReasoningLedger::new(vec![record]),
1142 Err(KnowledgeError::Invalid {
1143 field: "reasoning.supersedes_reasoning_uuid",
1144 message: "self-link is forbidden",
1145 })
1146 ));
1147 }
1148
1149 #[test]
1150 fn reasoning_content_must_not_be_empty() {
1151 let mut record = unvalidated_row(1);
1152 record.content.clear();
1153 assert!(matches!(
1154 ReasoningLedger::new(vec![record]),
1155 Err(KnowledgeError::Invalid {
1156 field: "reasoning.content",
1157 message: "must not be empty",
1158 })
1159 ));
1160 }
1161
1162 #[test]
1163 fn reasoning_content_limit_is_enforced_by_ledger_validation() {
1164 let mut record = unvalidated_row(1);
1165 record.content = vec![b'x'; MAX_REASONING_CONTENT_BYTES + 1];
1166 assert!(matches!(
1167 ReasoningLedger::new(vec![record]),
1168 Err(KnowledgeError::Limit {
1169 participant: "reasoning.content",
1170 observed,
1171 limit: MAX_REASONING_CONTENT_BYTES,
1172 }) if observed == MAX_REASONING_CONTENT_BYTES + 1
1173 ));
1174 }
1175
1176 #[test]
1177 fn reasoning_text_content_must_be_utf8() {
1178 let mut record = unvalidated_row(1);
1179 record.content = vec![0xff];
1180 assert!(matches!(
1181 ReasoningLedger::new(vec![record]),
1182 Err(KnowledgeError::Invalid {
1183 field: "reasoning.content",
1184 message: "must be UTF-8",
1185 })
1186 ));
1187 }
1188
1189 #[test]
1190 fn application_json_content_must_be_valid_json() {
1191 let mut record = unvalidated_row(1);
1192 record.content_format = ReasoningContentFormat::ApplicationJson;
1193 record.content = b"{not-json}".to_vec();
1194 assert!(matches!(
1195 ReasoningLedger::new(vec![record]),
1196 Err(KnowledgeError::Invalid {
1197 field: "reasoning.content",
1198 message: "must be valid JSON",
1199 })
1200 ));
1201 }
1202
1203 #[test]
1204 fn reasoning_uuid_must_be_unique() {
1205 assert!(matches!(
1206 ReasoningLedger::new(vec![row(1, 20, None, 10), row(1, 21, None, 20)]),
1207 Err(KnowledgeError::Duplicate("reasoning_uuid"))
1208 ));
1209 }
1210
1211 #[test]
1212 fn amendment_predecessor_must_be_present_and_share_assertion() {
1213 assert!(matches!(
1214 ReasoningLedger::new(vec![row(2, 20, Some(1), 20)]),
1215 Err(KnowledgeError::Dangling("supersedes_reasoning_uuid"))
1216 ));
1217 assert!(matches!(
1218 ReasoningLedger::new(vec![row(1, 20, None, 10), row(2, 21, Some(1), 20)]),
1219 Err(KnowledgeError::Invalid {
1220 field: "reasoning.supersedes_reasoning_uuid",
1221 message: "cross-assertion amendment is forbidden",
1222 })
1223 ));
1224 }
1225
1226 #[test]
1227 fn amendment_graph_must_be_acyclic() {
1228 assert!(matches!(
1229 ReasoningLedger::new(vec![row(1, 20, Some(2), 10), row(2, 20, Some(1), 20)]),
1230 Err(KnowledgeError::Invalid {
1231 field: "reasoning.supersedes_reasoning_uuid",
1232 message: "amendment cycle",
1233 })
1234 ));
1235 }
1236
1237 #[test]
1238 fn self_cycle_missing_and_cross_assertion_predecessors_fail() {
1239 let mut self_link = row(1, 20, None, 10);
1240 self_link.supersedes_reasoning_uuid = Some(uuid7(1));
1241 assert!(ReasoningLedger::new(vec![self_link]).is_err());
1242 assert!(matches!(
1243 ReasoningLedger::new(vec![row(1, 20, Some(2), 10), row(2, 20, Some(1), 20)]),
1244 Err(KnowledgeError::Invalid {
1245 field: "reasoning.supersedes_reasoning_uuid",
1246 message: "amendment cycle",
1247 })
1248 ));
1249 assert!(ReasoningLedger::new(vec![row(2, 20, Some(1), 20)]).is_err());
1250 assert!(ReasoningLedger::new(vec![row(1, 20, None, 10), row(2, 21, Some(1), 20)]).is_err());
1251 }
1252
1253 #[test]
1254 fn amendment_branch_history_is_preserved_and_current_is_deterministic() {
1255 let ledger = ReasoningLedger::new(vec![
1256 row(1, 20, None, 10),
1257 row(2, 20, Some(1), 20),
1258 row(3, 20, Some(1), 20),
1259 ])
1260 .unwrap();
1261 assert_eq!(ledger.records.len(), 3);
1262 assert_eq!(
1263 ledger.current_for(uuid7(20)).unwrap().reasoning_uuid,
1264 uuid7(3)
1265 );
1266 }
1267
1268 #[test]
1269 fn payload_encoding_and_size_limits_are_sanitized() {
1270 assert!(matches!(
1271 ReasoningRecord::new(
1272 uuid7(1),
1273 uuid7(2),
1274 ReasoningKind::MethodologicalNote,
1275 ReasoningContentFormat::TextPlain,
1276 vec![0xff],
1277 None,
1278 uuid7(3),
1279 1,
1280 ),
1281 Err(KnowledgeError::Invalid {
1282 field: "reasoning.content",
1283 ..
1284 })
1285 ));
1286 assert!(matches!(
1287 ReasoningRecord::new(
1288 uuid7(1),
1289 uuid7(2),
1290 ReasoningKind::MethodologicalNote,
1291 ReasoningContentFormat::ApplicationJson,
1292 b"{not-json}".to_vec(),
1293 None,
1294 uuid7(3),
1295 1,
1296 ),
1297 Err(KnowledgeError::Invalid {
1298 field: "reasoning.content",
1299 ..
1300 })
1301 ));
1302 let exact_json = br#"{"explanation":"kept byte-for-byte"}"#.to_vec();
1303 assert_eq!(
1304 ReasoningRecord::new(
1305 uuid7(1),
1306 uuid7(2),
1307 ReasoningKind::MethodologicalNote,
1308 ReasoningContentFormat::ApplicationJson,
1309 exact_json.clone(),
1310 None,
1311 uuid7(3),
1312 1,
1313 )
1314 .unwrap()
1315 .content,
1316 exact_json
1317 );
1318 let oversized = vec![b'x'; MAX_REASONING_CONTENT_BYTES + 1];
1319 assert!(matches!(
1320 ReasoningRecord::new(
1321 uuid7(1),
1322 uuid7(2),
1323 ReasoningKind::MethodologicalNote,
1324 ReasoningContentFormat::TextPlain,
1325 oversized,
1326 None,
1327 uuid7(3),
1328 1,
1329 ),
1330 Err(KnowledgeError::Limit {
1331 participant: "reasoning.content",
1332 ..
1333 })
1334 ));
1335 }
1336
1337 #[test]
1338 fn reasoning_registry_values_round_trip_and_reject_unknown_tokens() {
1339 for kind in [
1340 ReasoningKind::EvidenceInterpretation,
1341 ReasoningKind::LogicalInference,
1342 ReasoningKind::MethodologicalNote,
1343 ReasoningKind::DecisionRationale,
1344 ] {
1345 assert_eq!(ReasoningKind::parse(kind.as_str()).unwrap(), kind);
1346 }
1347 assert!(ReasoningKind::parse("guess").is_err());
1348 for format in [
1349 ReasoningContentFormat::TextPlain,
1350 ReasoningContentFormat::TextMarkdown,
1351 ReasoningContentFormat::ApplicationJson,
1352 ] {
1353 assert_eq!(
1354 ReasoningContentFormat::parse(format.as_str()).unwrap(),
1355 format
1356 );
1357 }
1358 assert!(ReasoningContentFormat::parse("text/html").is_err());
1359 }
1360}