Skip to main content

graphforge_knowledge/
reasoning.rs

1//! Immutable epistemic reasoning records and explicit amendment chains.
2
3#[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
22/// Immutable reasoning record contract.
23pub const REASONING_CONTRACT_VERSION: u32 = 1;
24/// epistemic capability contract.
25pub const EPISTEMIC_CAPABILITY_VERSION: u32 = 1;
26/// Closed reasoning-kind registry.
27pub const REASONING_KIND_REGISTRY_VERSION: u32 = 1;
28/// Closed content-format registry.
29pub const REASONING_CONTENT_FORMAT_REGISTRY_VERSION: u32 = 1;
30/// Maximum exact reasoning payload accepted by the public API.
31pub const MAX_REASONING_CONTENT_BYTES: usize = 65_536;
32
33/// Authoritative `knowledge/reasoning.parquet` schema.
34pub 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/// Closed purpose of one reasoning record.
67#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
68pub enum ReasoningKind {
69    /// Interpretation of cited evidence.
70    EvidenceInterpretation,
71    /// Explicit logical inference.
72    LogicalInference,
73    /// Method or procedure explanation.
74    MethodologicalNote,
75    /// Rationale for a human or automated decision.
76    DecisionRationale,
77}
78
79impl ReasoningKind {
80    /// Stable persisted spelling.
81    #[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/// Closed encoding contract for exact reasoning content.
103#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
104pub enum ReasoningContentFormat {
105    /// UTF-8 plain text.
106    TextPlain,
107    /// UTF-8 Markdown, stored but never rendered or executed by the engine.
108    TextMarkdown,
109    /// Canonical caller-supplied UTF-8 JSON bytes.
110    ApplicationJson,
111}
112
113impl ReasoningContentFormat {
114    /// Stable persisted media type.
115    #[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/// One immutable reasoning record or explicit amendment.
138#[derive(Clone, Debug, Eq, PartialEq)]
139pub struct ReasoningRecord {
140    /// Caller-supplied UUIDv7 identity/idempotency key.
141    pub reasoning_uuid: Uuid,
142    /// Existing immutable knowledge assertion.
143    pub assertion_uuid: Uuid,
144    /// Closed reasoning purpose.
145    pub kind: ReasoningKind,
146    /// Closed exact-content encoding.
147    pub content_format: ReasoningContentFormat,
148    /// Exact accepted bytes.
149    pub content: Vec<u8>,
150    /// Prior reasoning record amended by this record.
151    pub supersedes_reasoning_uuid: Option<Uuid>,
152    /// Producing provenance event.
153    pub provenance_uuid: Uuid,
154    /// Mandatory transaction time.
155    pub recorded_at_micros: i64,
156    /// Frozen record contract.
157    pub contract_version: u32,
158}
159
160impl ReasoningRecord {
161    /// Validate and construct one immutable record.
162    #[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/// Validated append-only reasoning participant content.
201#[derive(Clone, Debug, Default, Eq, PartialEq)]
202pub struct ReasoningLedger {
203    /// Records ordered by `(recorded_at, reasoning_uuid)`.
204    records: Vec<ReasoningRecord>,
205}
206
207impl ReasoningLedger {
208    /// Borrow validated records in canonical order.
209    ///
210    /// ```compile_fail
211    /// use graphforge_knowledge::ReasoningLedger;
212    /// let mut ledger = ReasoningLedger::default();
213    /// ledger.records.clear();
214    /// ```
215    #[must_use]
216    pub fn records(&self) -> &[ReasoningRecord] {
217        &self.records
218    }
219
220    /// Consume this ledger and return its validated records in canonical order.
221    #[must_use]
222    pub fn into_records(self) -> Vec<ReasoningRecord> {
223        self.records
224    }
225
226    /// Validate, sort, and construct one complete participant.
227    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    /// Merge staged append-only records with idempotent exact replay.
265    ///
266    /// Both inputs are validated ledgers and the row vector is private, so
267    /// existing records cannot be mutated after validation. The only new
268    /// whole-ledger condition is the combined row limit. A staged row that
269    /// refers to a base-only predecessor is rejected while constructing the
270    /// staged ledger because its predecessor is missing there. If the staged
271    /// ledger includes that base row to satisfy its own chain, merge accepts
272    /// it only as exact replay. Base rows cannot point to newly appended rows,
273    /// since the base ledger was already validated without those identities.
274    /// Therefore a cycle spanning base and staged rows is unreachable, while
275    /// any identity reuse is exact replay or a conflict.
276    pub fn merge(&self, staged: &Self) -> Result<Self, KnowledgeError> {
277        self.merge_with_limit(staged, MAX_KNOWLEDGE_ROWS)
278    }
279
280    /// Append one record to this validated ledger without revalidating stored rows.
281    ///
282    /// The incoming record is validated because its fields are public. The
283    /// existing ledger is immutable and already validated, so only combined
284    /// identity, predecessor, assertion, cycle, and row-limit conditions are
285    /// checked against its trusted records.
286    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    /// Canonical fingerprint over the exact immutable record.
361    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    /// Resolve the current leaf without mutating or hiding branch history.
393    #[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    /// Build the authoritative Arrow batch.
411    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    /// Decode authoritative batches and revalidate the complete ledger.
454    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")]
541/// Test-only instrumentation for validating incremental API append paths.
542pub mod test_support {
543    /// Start counting reasoning record validator calls on the current thread.
544    pub fn start_record_validation_count() {
545        super::RECORD_VALIDATION_CALLS.with(|calls| calls.set(Some(0)));
546    }
547
548    /// Stop counting and return reasoning record validator calls on this thread.
549    #[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(&current) {
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(&current)
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}