Skip to main content

ipfrs_tensorlogic/
ipld_codec.rs

1//! IPLD codec for TensorLogic IR
2//!
3//! Serializes TensorLogic terms, predicates, rules and facts as DAG-CBOR IPLD
4//! nodes with content-addressed CIDs. Enables:
5//! - Rule sharing via Bitswap (rules are immutable, CID-identified)
6//! - IPLD path resolution: /rule/\<cid>/head/args/0
7//! - Cross-node deduplication: identical rules share one CID
8
9use crate::ir::{Constant, Predicate, Rule, Term, TermRef};
10use bytes::Bytes;
11use ipfrs_core::{Block, Cid, Error};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15// DAG-CBOR codec code (0x71)
16const DAG_CBOR_CODEC: u64 = 0x71;
17
18/// IPLD representation of a TensorLogic Term
19///
20/// Covers all term variants in a way that is suitable for content-addressed
21/// DAG-CBOR encoding. The representation is flat and self-describing so that
22/// IPLD path resolution can traverse into any sub-term without additional
23/// context.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(tag = "type", rename_all = "snake_case")]
26pub enum TermIpld {
27    /// Atomic string value
28    Atom { value: String },
29    /// Logic variable (unbound)
30    Variable { name: String },
31    /// Numeric value (f64 covers integers as well for a uniform JSON/CBOR repr)
32    Number { value: f64 },
33    /// Compound term – a functor applied to zero or more argument terms
34    Compound {
35        functor: String,
36        args: Vec<TermIpld>,
37    },
38    /// Ordered list of terms
39    List { items: Vec<TermIpld> },
40    /// Tensor descriptor; the actual data lives in a separate block addressed
41    /// by `cid` (optional – may be unresolved at encoding time)
42    Tensor {
43        dtype: String,
44        shape: Vec<u64>,
45        cid: Option<String>,
46    },
47    /// Content-addressed reference to another term block
48    Ref { cid: String, hint: Option<String> },
49}
50
51/// IPLD representation of a TensorLogic Rule (Horn clause)
52///
53/// The head is the consequent predicate represented as a `TermIpld::Compound`,
54/// and the body is a conjunction of goal terms.  Metadata allows callers to
55/// attach provenance, version, or labelling information without affecting the
56/// content hash (metadata *is* included in the hash – equal rules with
57/// different metadata produce different CIDs).
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct RuleIpld {
60    /// Head of the Horn clause
61    pub head: TermIpld,
62    /// Body goals (conjunction)
63    pub body: Vec<TermIpld>,
64    /// Arbitrary string metadata (e.g. {"source": "...", "version": "1"})
65    pub metadata: HashMap<String, String>,
66}
67
68/// IPLD representation of a ground fact
69///
70/// A fact is a predicate where all arguments are ground (no free variables).
71/// It is stored separately from `RuleIpld` to enable efficient enumeration of
72/// the ground knowledge base without decoding rule bodies.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct FactIpld {
75    /// Name of the predicate (e.g. "parent")
76    pub predicate: String,
77    /// Ground argument terms
78    pub args: Vec<TermIpld>,
79}
80
81/// Snapshot of a complete knowledge base as an IPLD node
82///
83/// Rules are stored by CID reference so that the KB node is a DAG linking to
84/// all constituent rule blocks. This enables:
85/// - Incremental sync: only missing rule blocks need to be fetched.
86/// - Deduplication: a rule shared by multiple KB snapshots has one CID.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct KnowledgeBaseIpld {
89    /// CID strings of `RuleIpld` blocks comprising this KB
90    pub rules: Vec<String>,
91    /// Inline ground facts (typically small enough for a single block)
92    pub facts: Vec<FactIpld>,
93    /// Schema/format version for forward compatibility
94    pub version: String,
95}
96
97// ─── Block encoding helpers ──────────────────────────────────────────────────
98
99/// Encode a serializable value to a DAG-CBOR–codec `Block`.
100///
101/// We use `serde_json` for the actual byte encoding (since `serde_cbor` is not
102/// a listed dependency) and stamp the CID with the DAG-CBOR codec code so the
103/// node is correctly identified in the IPLD universe. The hash covers the JSON
104/// bytes, giving deterministic content addressing.
105fn encode_to_block<T: Serialize>(value: &T) -> Result<Block, Error> {
106    let json_bytes = serde_json::to_vec(value)
107        .map_err(|e| Error::Serialization(format!("IPLD codec serialization: {}", e)))?;
108
109    // Build a CIDv1 with DAG-CBOR codec over the JSON bytes so that the codec
110    // field is correct even though we encode with JSON for portability.
111    let cid = build_dag_cbor_cid(&json_bytes)?;
112    let block = Block::from_parts(cid, Bytes::from(json_bytes));
113    Ok(block)
114}
115
116/// Decode a `Block` into a deserialisable value.
117fn decode_from_block<T: for<'de> Deserialize<'de>>(block: &Block) -> Result<T, Error> {
118    serde_json::from_slice(block.data())
119        .map_err(|e| Error::Deserialization(format!("IPLD codec deserialization: {}", e)))
120}
121
122/// Build a CIDv1 with DAG-CBOR codec (0x71) from raw bytes using SHA2-256.
123fn build_dag_cbor_cid(data: &[u8]) -> Result<Cid, Error> {
124    use ipfrs_core::CidBuilder;
125    CidBuilder::new()
126        .codec(DAG_CBOR_CODEC)
127        .build(data)
128        .map_err(|e| Error::Cid(format!("Failed to compute DAG-CBOR CID: {}", e)))
129}
130
131// ─── Public API ──────────────────────────────────────────────────────────────
132
133/// Serialize a `RuleIpld` to a DAG-CBOR–stamped `Block`.
134///
135/// The returned block's CID is content-addressed over the canonical JSON
136/// serialisation of the rule.  Identical rules always yield the same CID.
137pub fn rule_to_block(rule: &RuleIpld) -> Result<Block, Error> {
138    encode_to_block(rule)
139}
140
141/// Deserialize a `Block` into a `RuleIpld`.
142pub fn block_to_rule(block: &Block) -> Result<RuleIpld, Error> {
143    decode_from_block(block)
144}
145
146/// Serialize a `FactIpld` to a `Block`.
147pub fn fact_to_block(fact: &FactIpld) -> Result<Block, Error> {
148    encode_to_block(fact)
149}
150
151/// Deserialize a `Block` into a `FactIpld`.
152pub fn block_to_fact(block: &Block) -> Result<FactIpld, Error> {
153    decode_from_block(block)
154}
155
156/// Serialize a `KnowledgeBaseIpld` snapshot to a `Block`.
157pub fn kb_to_block(kb: &KnowledgeBaseIpld) -> Result<Block, Error> {
158    encode_to_block(kb)
159}
160
161/// Deserialize a `Block` into a `KnowledgeBaseIpld`.
162pub fn block_to_kb(block: &Block) -> Result<KnowledgeBaseIpld, Error> {
163    decode_from_block(block)
164}
165
166/// Compute the content-addressed `Cid` for a `RuleIpld` without storing it.
167///
168/// This is a pure function: it deterministically maps a rule to its CID.
169/// Callers can use it to check whether a rule is already known before
170/// fetching the full block over the network.
171pub fn rule_cid(rule: &RuleIpld) -> Result<Cid, Error> {
172    let json_bytes = serde_json::to_vec(rule)
173        .map_err(|e| Error::Serialization(format!("CID computation serialization: {}", e)))?;
174    build_dag_cbor_cid(&json_bytes)
175}
176
177/// Compute the content-addressed `Cid` for a `FactIpld` without storing it.
178pub fn fact_cid(fact: &FactIpld) -> Result<Cid, Error> {
179    let json_bytes = serde_json::to_vec(fact)
180        .map_err(|e| Error::Serialization(format!("CID computation serialization: {}", e)))?;
181    build_dag_cbor_cid(&json_bytes)
182}
183
184// ─── Conversion traits: IR ↔ IPLD ────────────────────────────────────────────
185
186impl TryFrom<&Term> for TermIpld {
187    type Error = Error;
188
189    fn try_from(term: &Term) -> Result<Self, Error> {
190        match term {
191            Term::Var(name) => Ok(TermIpld::Variable { name: name.clone() }),
192
193            Term::Const(Constant::String(s)) => Ok(TermIpld::Atom { value: s.clone() }),
194            Term::Const(Constant::Int(i)) => Ok(TermIpld::Number { value: *i as f64 }),
195            Term::Const(Constant::Bool(b)) => Ok(TermIpld::Number {
196                value: if *b { 1.0 } else { 0.0 },
197            }),
198            Term::Const(Constant::Float(s)) => {
199                let value = s.parse::<f64>().map_err(|_| {
200                    Error::InvalidData(format!("Cannot parse float constant: {}", s))
201                })?;
202                Ok(TermIpld::Number { value })
203            }
204
205            Term::Fun(functor, args) => {
206                let ipld_args = args
207                    .iter()
208                    .map(TermIpld::try_from)
209                    .collect::<Result<Vec<_>, _>>()?;
210                Ok(TermIpld::Compound {
211                    functor: functor.clone(),
212                    args: ipld_args,
213                })
214            }
215
216            Term::Ref(TermRef { cid, hint }) => Ok(TermIpld::Ref {
217                cid: cid.to_string(),
218                hint: hint.clone(),
219            }),
220        }
221    }
222}
223
224impl TryFrom<&TermIpld> for Term {
225    type Error = Error;
226
227    fn try_from(ipld: &TermIpld) -> Result<Self, Error> {
228        match ipld {
229            TermIpld::Atom { value } => Ok(Term::Const(Constant::String(value.clone()))),
230
231            TermIpld::Variable { name } => Ok(Term::Var(name.clone())),
232
233            TermIpld::Number { value } => {
234                // Round-trip: if the value is integral, store as Int; otherwise Float.
235                if value.fract() == 0.0 && value.abs() < i64::MAX as f64 {
236                    Ok(Term::Const(Constant::Int(*value as i64)))
237                } else {
238                    Ok(Term::Const(Constant::Float(value.to_string())))
239                }
240            }
241
242            TermIpld::Compound { functor, args } => {
243                let ir_args = args
244                    .iter()
245                    .map(Term::try_from)
246                    .collect::<Result<Vec<_>, _>>()?;
247                Ok(Term::Fun(functor.clone(), ir_args))
248            }
249
250            TermIpld::List { items } => {
251                // Represent a list as a compound with functor "list"
252                let ir_items = items
253                    .iter()
254                    .map(Term::try_from)
255                    .collect::<Result<Vec<_>, _>>()?;
256                Ok(Term::Fun("list".to_string(), ir_items))
257            }
258
259            TermIpld::Tensor { dtype, shape, cid } => {
260                // Represent a tensor descriptor as a compound with a metadata
261                // functor so it survives round-trips through the IR type system.
262                let dtype_term = Term::Const(Constant::String(dtype.clone()));
263                let shape_terms: Vec<Term> = shape
264                    .iter()
265                    .map(|d| Term::Const(Constant::Int(*d as i64)))
266                    .collect();
267                let shape_fun = Term::Fun("shape".to_string(), shape_terms);
268                let cid_term = cid
269                    .as_deref()
270                    .map(|s| Term::Const(Constant::String(s.to_string())))
271                    .unwrap_or(Term::Const(Constant::String("none".to_string())));
272                Ok(Term::Fun(
273                    "tensor".to_string(),
274                    vec![dtype_term, shape_fun, cid_term],
275                ))
276            }
277
278            TermIpld::Ref { cid, hint } => {
279                let parsed_cid: Cid = cid.parse().map_err(|e| {
280                    Error::InvalidData(format!("Invalid CID in TermIpld::Ref: {}", e))
281                })?;
282                Ok(Term::Ref(TermRef {
283                    cid: parsed_cid,
284                    hint: hint.clone(),
285                }))
286            }
287        }
288    }
289}
290
291/// Convert an IR `Predicate` to an IPLD `TermIpld::Compound`.
292///
293/// A predicate is modelled as a compound term whose functor is the predicate
294/// name and whose arguments are the predicate's argument terms.
295pub fn predicate_to_term_ipld(pred: &Predicate) -> Result<TermIpld, Error> {
296    let args = pred
297        .args
298        .iter()
299        .map(TermIpld::try_from)
300        .collect::<Result<Vec<_>, _>>()?;
301    Ok(TermIpld::Compound {
302        functor: pred.name.clone(),
303        args,
304    })
305}
306
307/// Convert a `TermIpld::Compound` back to an IR `Predicate`.
308pub fn term_ipld_to_predicate(ipld: &TermIpld) -> Result<Predicate, Error> {
309    match ipld {
310        TermIpld::Compound { functor, args } => {
311            let ir_args = args
312                .iter()
313                .map(Term::try_from)
314                .collect::<Result<Vec<_>, _>>()?;
315            Ok(Predicate::new(functor.clone(), ir_args))
316        }
317        other => Err(Error::InvalidData(format!(
318            "Expected Compound TermIpld for Predicate conversion, got: {:?}",
319            other
320        ))),
321    }
322}
323
324/// Convert an IR `Rule` to a `RuleIpld`.
325pub fn rule_to_rule_ipld(rule: &Rule) -> Result<RuleIpld, Error> {
326    let head = predicate_to_term_ipld(&rule.head)?;
327    let body = rule
328        .body
329        .iter()
330        .map(predicate_to_term_ipld)
331        .collect::<Result<Vec<_>, _>>()?;
332    Ok(RuleIpld {
333        head,
334        body,
335        metadata: HashMap::new(),
336    })
337}
338
339/// Convert a `RuleIpld` back to an IR `Rule`.
340pub fn rule_ipld_to_rule(ipld: &RuleIpld) -> Result<Rule, Error> {
341    let head = term_ipld_to_predicate(&ipld.head)?;
342    let body = ipld
343        .body
344        .iter()
345        .map(term_ipld_to_predicate)
346        .collect::<Result<Vec<_>, _>>()?;
347    Ok(Rule::new(head, body))
348}
349
350/// Convert an IR `Predicate` (ground fact) to a `FactIpld`.
351pub fn predicate_to_fact_ipld(pred: &Predicate) -> Result<FactIpld, Error> {
352    let args = pred
353        .args
354        .iter()
355        .map(TermIpld::try_from)
356        .collect::<Result<Vec<_>, _>>()?;
357    Ok(FactIpld {
358        predicate: pred.name.clone(),
359        args,
360    })
361}
362
363/// Convert a `FactIpld` back to an IR `Predicate`.
364pub fn fact_ipld_to_predicate(ipld: &FactIpld) -> Result<Predicate, Error> {
365    let args = ipld
366        .args
367        .iter()
368        .map(Term::try_from)
369        .collect::<Result<Vec<_>, _>>()?;
370    Ok(Predicate::new(ipld.predicate.clone(), args))
371}
372
373// ─── Tests ───────────────────────────────────────────────────────────────────
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::ir::Constant;
379
380    // ── TermIpld round-trips ─────────────────────────────────────────────────
381
382    #[test]
383    fn test_atom_roundtrip() {
384        let original = TermIpld::Atom {
385            value: "hello".to_string(),
386        };
387        let block = encode_to_block(&original).expect("encode");
388        let decoded: TermIpld = decode_from_block(&block).expect("decode");
389        assert_eq!(original, decoded);
390    }
391
392    #[test]
393    fn test_variable_roundtrip() {
394        let original = TermIpld::Variable {
395            name: "X".to_string(),
396        };
397        let block = encode_to_block(&original).expect("encode");
398        let decoded: TermIpld = decode_from_block(&block).expect("decode");
399        assert_eq!(original, decoded);
400    }
401
402    #[test]
403    fn test_number_roundtrip() {
404        let original = TermIpld::Number { value: 42.0 };
405        let block = encode_to_block(&original).expect("encode");
406        let decoded: TermIpld = decode_from_block(&block).expect("decode");
407        assert_eq!(original, decoded);
408    }
409
410    #[test]
411    fn test_compound_term_roundtrip() {
412        let original = TermIpld::Compound {
413            functor: "parent".to_string(),
414            args: vec![
415                TermIpld::Atom {
416                    value: "alice".to_string(),
417                },
418                TermIpld::Atom {
419                    value: "bob".to_string(),
420                },
421            ],
422        };
423        let block = encode_to_block(&original).expect("encode");
424        let decoded: TermIpld = decode_from_block(&block).expect("decode");
425        assert_eq!(original, decoded);
426    }
427
428    #[test]
429    fn test_list_roundtrip() {
430        let original = TermIpld::List {
431            items: vec![
432                TermIpld::Number { value: 1.0 },
433                TermIpld::Number { value: 2.0 },
434                TermIpld::Number { value: 3.0 },
435            ],
436        };
437        let block = encode_to_block(&original).expect("encode");
438        let decoded: TermIpld = decode_from_block(&block).expect("decode");
439        assert_eq!(original, decoded);
440    }
441
442    #[test]
443    fn test_tensor_roundtrip() {
444        let original = TermIpld::Tensor {
445            dtype: "float32".to_string(),
446            shape: vec![128, 64],
447            cid: Some("bafybeihdwdcefgh".to_string()),
448        };
449        let block = encode_to_block(&original).expect("encode");
450        let decoded: TermIpld = decode_from_block(&block).expect("decode");
451        assert_eq!(original, decoded);
452    }
453
454    // ── RuleIpld block encoding ──────────────────────────────────────────────
455
456    #[test]
457    fn test_rule_to_block_and_back() {
458        let rule = RuleIpld {
459            head: TermIpld::Compound {
460                functor: "grandparent".to_string(),
461                args: vec![
462                    TermIpld::Variable {
463                        name: "X".to_string(),
464                    },
465                    TermIpld::Variable {
466                        name: "Z".to_string(),
467                    },
468                ],
469            },
470            body: vec![
471                TermIpld::Compound {
472                    functor: "parent".to_string(),
473                    args: vec![
474                        TermIpld::Variable {
475                            name: "X".to_string(),
476                        },
477                        TermIpld::Variable {
478                            name: "Y".to_string(),
479                        },
480                    ],
481                },
482                TermIpld::Compound {
483                    functor: "parent".to_string(),
484                    args: vec![
485                        TermIpld::Variable {
486                            name: "Y".to_string(),
487                        },
488                        TermIpld::Variable {
489                            name: "Z".to_string(),
490                        },
491                    ],
492                },
493            ],
494            metadata: HashMap::new(),
495        };
496
497        let block = rule_to_block(&rule).expect("encode rule");
498        let decoded = block_to_rule(&block).expect("decode rule");
499
500        // Verify structural equality on head functor
501        match (&decoded.head, &rule.head) {
502            (
503                TermIpld::Compound {
504                    functor: f1,
505                    args: a1,
506                },
507                TermIpld::Compound {
508                    functor: f2,
509                    args: a2,
510                },
511            ) => {
512                assert_eq!(f1, f2);
513                assert_eq!(a1.len(), a2.len());
514            }
515            _ => panic!("Head should be Compound"),
516        }
517        assert_eq!(decoded.body.len(), rule.body.len());
518    }
519
520    #[test]
521    fn test_identical_rules_same_cid() {
522        let make_rule = || RuleIpld {
523            head: TermIpld::Compound {
524                functor: "likes".to_string(),
525                args: vec![
526                    TermIpld::Variable {
527                        name: "X".to_string(),
528                    },
529                    TermIpld::Atom {
530                        value: "chocolate".to_string(),
531                    },
532                ],
533            },
534            body: vec![],
535            metadata: HashMap::new(),
536        };
537
538        let cid1 = rule_cid(&make_rule()).expect("cid1");
539        let cid2 = rule_cid(&make_rule()).expect("cid2");
540        assert_eq!(cid1, cid2, "Identical rules must yield the same CID");
541    }
542
543    #[test]
544    fn test_different_rules_different_cid() {
545        let rule1 = RuleIpld {
546            head: TermIpld::Compound {
547                functor: "a".to_string(),
548                args: vec![],
549            },
550            body: vec![],
551            metadata: HashMap::new(),
552        };
553        let rule2 = RuleIpld {
554            head: TermIpld::Compound {
555                functor: "b".to_string(),
556                args: vec![],
557            },
558            body: vec![],
559            metadata: HashMap::new(),
560        };
561
562        let cid1 = rule_cid(&rule1).expect("cid1");
563        let cid2 = rule_cid(&rule2).expect("cid2");
564        assert_ne!(cid1, cid2, "Different rules must yield different CIDs");
565    }
566
567    // ── FactIpld ─────────────────────────────────────────────────────────────
568
569    #[test]
570    fn test_fact_roundtrip() {
571        let fact = FactIpld {
572            predicate: "parent".to_string(),
573            args: vec![
574                TermIpld::Atom {
575                    value: "alice".to_string(),
576                },
577                TermIpld::Atom {
578                    value: "bob".to_string(),
579                },
580            ],
581        };
582
583        let block = fact_to_block(&fact).expect("encode fact");
584        let decoded = block_to_fact(&block).expect("decode fact");
585
586        assert_eq!(decoded.predicate, fact.predicate);
587        assert_eq!(decoded.args.len(), fact.args.len());
588    }
589
590    // ── KnowledgeBaseIpld ────────────────────────────────────────────────────
591
592    #[test]
593    fn test_knowledge_base_snapshot() {
594        let rule = RuleIpld {
595            head: TermIpld::Compound {
596                functor: "mortal".to_string(),
597                args: vec![TermIpld::Variable {
598                    name: "X".to_string(),
599                }],
600            },
601            body: vec![TermIpld::Compound {
602                functor: "human".to_string(),
603                args: vec![TermIpld::Variable {
604                    name: "X".to_string(),
605                }],
606            }],
607            metadata: HashMap::new(),
608        };
609
610        let cid = rule_cid(&rule).expect("rule cid");
611
612        let kb = KnowledgeBaseIpld {
613            rules: vec![cid.to_string()],
614            facts: vec![FactIpld {
615                predicate: "human".to_string(),
616                args: vec![TermIpld::Atom {
617                    value: "socrates".to_string(),
618                }],
619            }],
620            version: "1.0.0".to_string(),
621        };
622
623        let block = kb_to_block(&kb).expect("encode kb");
624        let decoded = block_to_kb(&block).expect("decode kb");
625
626        assert_eq!(decoded.rules.len(), 1);
627        assert_eq!(decoded.facts.len(), 1);
628        assert_eq!(decoded.version, "1.0.0");
629        assert_eq!(decoded.rules[0], cid.to_string());
630    }
631
632    // ── IR ↔ IPLD conversion ─────────────────────────────────────────────────
633
634    #[test]
635    fn test_term_ir_to_ipld_atom() {
636        let term = Term::Const(Constant::String("alice".to_string()));
637        let ipld = TermIpld::try_from(&term).expect("convert");
638        assert_eq!(
639            ipld,
640            TermIpld::Atom {
641                value: "alice".to_string()
642            }
643        );
644    }
645
646    #[test]
647    fn test_term_ir_to_ipld_variable() {
648        let term = Term::Var("X".to_string());
649        let ipld = TermIpld::try_from(&term).expect("convert");
650        assert_eq!(
651            ipld,
652            TermIpld::Variable {
653                name: "X".to_string()
654            }
655        );
656    }
657
658    #[test]
659    fn test_term_ir_to_ipld_int() {
660        let term = Term::Const(Constant::Int(42));
661        let ipld = TermIpld::try_from(&term).expect("convert");
662        assert_eq!(ipld, TermIpld::Number { value: 42.0 });
663    }
664
665    #[test]
666    fn test_term_ir_to_ipld_compound() {
667        let term = Term::Fun(
668            "parent".to_string(),
669            vec![
670                Term::Const(Constant::String("alice".to_string())),
671                Term::Var("X".to_string()),
672            ],
673        );
674        let ipld = TermIpld::try_from(&term).expect("convert");
675        match ipld {
676            TermIpld::Compound { functor, args } => {
677                assert_eq!(functor, "parent");
678                assert_eq!(args.len(), 2);
679            }
680            other => panic!("Expected Compound, got {:?}", other),
681        }
682    }
683
684    #[test]
685    fn test_term_ipld_to_ir_roundtrip() {
686        let original = Term::Fun(
687            "grandparent".to_string(),
688            vec![
689                Term::Var("X".to_string()),
690                Term::Const(Constant::String("eve".to_string())),
691            ],
692        );
693        let ipld = TermIpld::try_from(&original).expect("to ipld");
694        let recovered = Term::try_from(&ipld).expect("to ir");
695        assert_eq!(original, recovered);
696    }
697
698    #[test]
699    fn test_predicate_to_term_ipld_roundtrip() {
700        let pred = Predicate::new(
701            "likes".to_string(),
702            vec![
703                Term::Const(Constant::String("alice".to_string())),
704                Term::Const(Constant::String("chocolate".to_string())),
705            ],
706        );
707        let ipld = predicate_to_term_ipld(&pred).expect("to ipld");
708        let recovered = term_ipld_to_predicate(&ipld).expect("to ir");
709        assert_eq!(recovered.name, pred.name);
710        assert_eq!(recovered.args, pred.args);
711    }
712
713    #[test]
714    fn test_rule_ir_to_ipld_roundtrip() {
715        use crate::ir::Rule;
716
717        let head = Predicate::new(
718            "ancestor".to_string(),
719            vec![Term::Var("X".to_string()), Term::Var("Z".to_string())],
720        );
721        let body = vec![
722            Predicate::new(
723                "parent".to_string(),
724                vec![Term::Var("X".to_string()), Term::Var("Y".to_string())],
725            ),
726            Predicate::new(
727                "ancestor".to_string(),
728                vec![Term::Var("Y".to_string()), Term::Var("Z".to_string())],
729            ),
730        ];
731        let rule = Rule::new(head.clone(), body.clone());
732
733        let rule_ipld = rule_to_rule_ipld(&rule).expect("to ipld");
734        let recovered = rule_ipld_to_rule(&rule_ipld).expect("to ir");
735
736        assert_eq!(recovered.head.name, rule.head.name);
737        assert_eq!(recovered.body.len(), rule.body.len());
738    }
739
740    #[test]
741    fn test_dag_cbor_cid_codec() {
742        let rule = RuleIpld {
743            head: TermIpld::Atom {
744                value: "test".to_string(),
745            },
746            body: vec![],
747            metadata: HashMap::new(),
748        };
749        let block = rule_to_block(&rule).expect("block");
750        // Verify the codec is stamped as DAG-CBOR (0x71)
751        assert_eq!(block.cid().codec(), DAG_CBOR_CODEC);
752    }
753
754    #[test]
755    fn test_fact_cid_determinism() {
756        let make_fact = || FactIpld {
757            predicate: "human".to_string(),
758            args: vec![TermIpld::Atom {
759                value: "socrates".to_string(),
760            }],
761        };
762        let c1 = fact_cid(&make_fact()).expect("c1");
763        let c2 = fact_cid(&make_fact()).expect("c2");
764        assert_eq!(c1, c2);
765    }
766}