Skip to main content

lean_ctx_protocol/hive/
ctxpkg.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The ctxpkg object mapping (C0 1.9, section 7; plan O1).
4//!
5//! Knowledge maps onto content-addressed objects like a git repository:
6//! a fact is a **blob**, a page and a revision's whole document are
7//! **trees**, a revision is a **commit**, a proposal is a pull request,
8//! Hive is a remote and the engine a client. The open ctxpkg spec (O1)
9//! builds on these ids; every implementation reproduces
10//! `ctxpkg.vectors.json`.
11//!
12//! Every id is `sha256:` + hex of `canonical_json` of a typed object:
13//! - blob: `{"type":"fact","fact":<FactV1>}` (credits included);
14//! - page tree: `{"type":"page","id","title","items":[{"kind":"fact","blob":<id>}|{"kind":"prose","text"}]}`;
15//! - document tree: `{"type":"tree","format","facts":[blob ids sorted],
16//!   "relations":[…sorted by (from,to,type)],"open_gaps":[…sorted],"pages":[page tree ids sorted]}`;
17//! - commit: `{"type":"commit","revision","tree","parent":<commit id>|null,
18//!   "merged_proposals":[…ascending],"rolls_back":<n>|null}`.
19//!
20//! Order inside the document does not change the tree: two documents with
21//! the same facts, relations, gaps and pages share it.
22//!
23//! Export: an engine renders `AGENTS.md` from the released document
24//! locally; nothing about it is on the wire. Import (`origin: import`,
25//! section 5 addendum) names its sources with [`import_slug`] headings.
26
27use serde_json::{Value, json};
28use sha2::{Digest, Sha256};
29
30use super::canonical_json;
31use super::knowledge::{FactV1, PageItemV1, PageV1, ResearchContextDocumentV1};
32use crate::ValidationError;
33
34fn object_id(value: &Value) -> Result<String, ValidationError> {
35    let digest = Sha256::digest(canonical_json(value)?);
36    let mut out = String::with_capacity(71);
37    out.push_str("sha256:");
38    for byte in digest {
39        out.push_str(&format!("{byte:02x}"));
40    }
41    Ok(out)
42}
43
44fn to_value<T: serde::Serialize>(value: &T) -> Result<Value, ValidationError> {
45    serde_json::to_value(value).map_err(|error| ValidationError::new(format!("ctxpkg: {error}")))
46}
47
48pub fn fact_blob_id(fact: &FactV1) -> Result<String, ValidationError> {
49    object_id(&json!({"type": "fact", "fact": to_value(fact)?}))
50}
51
52pub fn page_tree_id(
53    page: &PageV1,
54    document: &ResearchContextDocumentV1,
55) -> Result<String, ValidationError> {
56    let mut items = Vec::with_capacity(page.items.len());
57    for item in &page.items {
58        items.push(match item {
59            PageItemV1::Fact { id } => {
60                let fact = document
61                    .facts
62                    .iter()
63                    .find(|fact| &fact.id == id)
64                    .ok_or_else(|| ValidationError::new("a page shows a missing fact"))?;
65                json!({"kind": "fact", "blob": fact_blob_id(fact)?})
66            }
67            PageItemV1::Prose { text } => json!({"kind": "prose", "text": text}),
68        });
69    }
70    object_id(&json!({"type": "page", "id": page.id, "title": page.title, "items": items}))
71}
72
73pub fn document_tree_id(document: &ResearchContextDocumentV1) -> Result<String, ValidationError> {
74    let mut facts = document
75        .facts
76        .iter()
77        .map(fact_blob_id)
78        .collect::<Result<Vec<_>, _>>()?;
79    facts.sort();
80    let mut relations: Vec<_> = document
81        .relations
82        .iter()
83        .map(|r| (r.from.as_str(), r.to.as_str(), r.kind.as_str()))
84        .collect();
85    relations.sort_unstable();
86    let relations: Vec<Value> = relations
87        .into_iter()
88        .map(|(from, to, kind)| json!({"from": from, "to": to, "type": kind}))
89        .collect();
90    let mut gaps = document.open_gaps.clone();
91    gaps.sort();
92    let mut pages = document
93        .pages
94        .iter()
95        .map(|page| page_tree_id(page, document))
96        .collect::<Result<Vec<_>, _>>()?;
97    pages.sort();
98    object_id(&json!({
99        "type": "tree", "format": document.format, "facts": facts,
100        "relations": relations, "open_gaps": gaps, "pages": pages,
101    }))
102}
103
104/// The commit of `revision`; `parent` is the previous revision's commit
105/// (none for revision 1).
106pub fn revision_commit_id(
107    revision: u64,
108    tree: &str,
109    parent: Option<&str>,
110    merged_proposals: &[u64],
111    rolls_back: Option<u64>,
112) -> Result<String, ValidationError> {
113    let mut merged = merged_proposals.to_vec();
114    merged.sort_unstable();
115    object_id(&json!({
116        "type": "commit", "revision": revision, "tree": tree, "parent": parent,
117        "merged_proposals": merged, "rolls_back": rolls_back,
118    }))
119}
120
121/// Slug of a Markdown heading for import sources and page ids: lowercase
122/// ASCII letters and digits kept, every other run of characters one `-`,
123/// no leading or trailing `-`, at most 64 characters (cut at a `-` when
124/// possible); `section` when nothing is left.
125pub fn import_slug(heading: &str) -> String {
126    let mut slug = String::new();
127    for ch in heading.chars() {
128        if ch.is_ascii_alphanumeric() {
129            slug.push(ch.to_ascii_lowercase());
130        } else if !slug.is_empty() && !slug.ends_with('-') {
131            slug.push('-');
132        }
133    }
134    if slug.len() > 64 {
135        slug.truncate(64);
136        if let Some(cut) = slug.rfind('-').filter(|cut| *cut > 0) {
137            slug.truncate(cut);
138        }
139    }
140    let slug = slug.trim_end_matches('-').to_owned();
141    if slug.is_empty() {
142        "section".into()
143    } else {
144        slug
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn slugs_are_stable_and_bounded() {
154        assert_eq!(
155            import_slug("## Retry budget (payments)"),
156            "retry-budget-payments"
157        );
158        assert_eq!(import_slug("Ünïcode — only"), "n-code-only");
159        assert_eq!(import_slug("!!!"), "section");
160        assert!(import_slug(&"word ".repeat(30)).len() <= 64);
161        assert!(!import_slug(&"word ".repeat(30)).ends_with('-'));
162    }
163
164    #[test]
165    fn document_order_does_not_change_the_tree() {
166        let fact = |id: &str| FactV1 {
167            id: id.into(),
168            statement: format!("statement {id}"),
169            kind: "decision".into(),
170            applies_to: vec![],
171            sources: vec![],
172            from_contributions: vec![],
173            anchors: vec![],
174        };
175        let a = ResearchContextDocumentV1 {
176            facts: vec![fact("a"), fact("b")],
177            ..Default::default()
178        };
179        let b = ResearchContextDocumentV1 {
180            facts: vec![fact("b"), fact("a")],
181            ..Default::default()
182        };
183        assert_eq!(document_tree_id(&a).unwrap(), document_tree_id(&b).unwrap());
184    }
185}