Skip to main content

graphforge_knowledge/
reasoning.rs

1//! Immutable M21 reasoning records and explicit amendment chains.
2
3use 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
20/// Immutable reasoning record contract.
21pub const REASONING_CONTRACT_VERSION: u32 = 1;
22/// M21 epistemic capability contract.
23pub const EPISTEMIC_CAPABILITY_VERSION: u32 = 1;
24/// Closed reasoning-kind registry.
25pub const REASONING_KIND_REGISTRY_VERSION: u32 = 1;
26/// Closed content-format registry.
27pub const REASONING_CONTENT_FORMAT_REGISTRY_VERSION: u32 = 1;
28/// Maximum exact reasoning payload accepted by the public API.
29pub const MAX_REASONING_CONTENT_BYTES: usize = 65_536;
30
31/// Authoritative `knowledge/reasoning.parquet` schema.
32pub 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/// Closed purpose of one reasoning record.
60#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
61pub enum ReasoningKind {
62    /// Interpretation of cited evidence.
63    EvidenceInterpretation,
64    /// Explicit logical inference.
65    LogicalInference,
66    /// Method or procedure explanation.
67    MethodologicalNote,
68    /// Rationale for a human or automated decision.
69    DecisionRationale,
70}
71
72impl ReasoningKind {
73    /// Stable persisted spelling.
74    #[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/// Closed encoding contract for exact reasoning content.
96#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
97pub enum ReasoningContentFormat {
98    /// UTF-8 plain text.
99    TextPlain,
100    /// UTF-8 Markdown, stored but never rendered or executed by the engine.
101    TextMarkdown,
102    /// Canonical caller-supplied UTF-8 JSON bytes.
103    ApplicationJson,
104}
105
106impl ReasoningContentFormat {
107    /// Stable persisted media type.
108    #[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/// One immutable reasoning record or explicit amendment.
131#[derive(Clone, Debug, Eq, PartialEq)]
132pub struct ReasoningRecord {
133    /// Caller-supplied UUIDv7 identity/idempotency key.
134    pub reasoning_uuid: Uuid,
135    /// Existing immutable M20 assertion.
136    pub assertion_uuid: Uuid,
137    /// Closed reasoning purpose.
138    pub kind: ReasoningKind,
139    /// Closed exact-content encoding.
140    pub content_format: ReasoningContentFormat,
141    /// Exact accepted bytes.
142    pub content: Vec<u8>,
143    /// Prior reasoning record amended by this record.
144    pub supersedes_reasoning_uuid: Option<Uuid>,
145    /// Producing provenance event.
146    pub provenance_uuid: Uuid,
147    /// Mandatory transaction time.
148    pub recorded_at_micros: i64,
149    /// Frozen record contract.
150    pub contract_version: u32,
151}
152
153impl ReasoningRecord {
154    /// Validate and construct one immutable record.
155    #[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/// Validated append-only reasoning participant content.
194#[derive(Clone, Debug, Default, Eq, PartialEq)]
195pub struct ReasoningLedger {
196    /// Records ordered by `(recorded_at, reasoning_uuid)`.
197    pub records: Vec<ReasoningRecord>,
198}
199
200impl ReasoningLedger {
201    /// Validate, sort, and construct one complete participant.
202    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    /// Merge staged append-only records with idempotent exact replay.
239    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    /// Canonical fingerprint over the exact immutable record.
260    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    /// Resolve the current leaf without mutating or hiding branch history.
292    #[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    /// Build the authoritative Arrow batch.
310    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    /// Decode authoritative batches and revalidate the complete ledger.
353    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(&current) {
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(&current)
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}