1use axum::Json;
22use axum::extract::{Path, Query, State};
23use axum::http::header::CONTENT_TYPE;
24use axum::response::IntoResponse;
25use ipld_core::ipld::Ipld;
26use mnem_core::codec::{from_canonical_bytes, json_to_ipld};
27use mnem_core::id::{EdgeId, NodeId};
28use mnem_core::index::PropPredicate;
29use mnem_core::objects::{Commit, Edge, Node, Operation};
30use mnem_core::retrieve::Lane;
31use mnem_core::{HEADS_PREFIX, TAGS_PREFIX};
32use mnem_embed_providers::Embedder as _;
36use serde::{Deserialize, Serialize};
37use serde_json::{Map, Value, json};
38
39use crate::auth::RequireBearer;
40use crate::error::Error;
41use crate::state::AppState;
42
43const fn lane_name(lane: Lane) -> &'static str {
49 match lane {
50 Lane::Vector => "vector",
51 Lane::Sparse => "sparse",
52 Lane::GraphExpand => "graph_expand",
53 Lane::Rerank => "rerank",
54 _ => "unknown",
59 }
60}
61
62pub(crate) const MAX_RETRIEVE_LIMIT: usize = 1_000;
83
84pub(crate) const MAX_VECTOR_CAP: usize = 100_000;
89
90pub(crate) const MAX_RERANK_TOP_K: usize = 500;
95
96fn clamp_or_reject(name: &'static str, value: Option<usize>, cap: usize) -> Result<(), Error> {
100 if let Some(n) = value
101 && n > cap
102 {
103 return Err(Error::bad_request(format!(
104 "{name}={n} exceeds max of {cap}; lower the value or split the request"
105 )));
106 }
107 Ok(())
108}
109
110pub(crate) async fn healthz() -> Json<Value> {
111 Json(json!({
112 "schema": "mnem.v1.healthz",
113 "ok": true,
114 "service": "mnem http",
115 "version": env!("CARGO_PKG_VERSION"),
116 }))
117}
118
119pub(crate) async fn stats(State(s): State<AppState>) -> Result<Json<Value>, Error> {
122 let repo = s.repo.lock().map_err(|_| Error::locked())?;
123 let op_id = repo.op_id().to_string();
124 let head = repo.view().heads.first().map(ToString::to_string);
125 let refs = repo.view().refs.len();
126 Ok(Json(json!({
127 "schema": "mnem.v1.stats",
128 "op_id": op_id,
129 "head_commit": head,
130 "refs": refs,
131 })))
132}
133
134#[derive(Deserialize)]
137pub(crate) struct PostNodeBody {
138 #[serde(default)]
143 pub label: String,
144 pub summary: Option<String>,
145 pub props: Option<Map<String, Value>>,
146 pub content: Option<String>,
147 #[serde(default)]
153 pub author: Option<String>,
154 #[serde(default)]
155 pub message: Option<String>,
156 #[serde(default)]
163 pub id: Option<String>,
164}
165
166#[derive(Serialize)]
167pub(crate) struct PostNodeResp {
168 schema: &'static str,
169 id: String,
170 label: String,
171 op_id: String,
172}
173
174pub(crate) async fn post_node(
175 State(s): State<AppState>,
176 Json(body): Json<PostNodeBody>,
177) -> Result<Json<PostNodeResp>, Error> {
178 let label = if s.allow_labels && !body.label.trim().is_empty() {
186 body.label.clone()
187 } else {
188 Node::DEFAULT_NTYPE.to_string()
189 };
190 let author = body
191 .author
192 .as_deref()
193 .map(str::trim)
194 .filter(|a| !a.is_empty())
195 .map(str::to_string);
196 let author = match author {
197 Some(a) => a,
198 None => return Err(Error::bad_request("author is required")),
199 };
200
201 let node_id = match body.id.as_deref() {
202 Some(s) => NodeId::parse_uuid(s)
203 .map_err(|e| Error::bad_request(format!("invalid caller-supplied id: {e}")))?,
204 None => NodeId::new_v7(),
205 };
206 let mut node = Node::new(node_id, &label);
207 if let Some(sum) = &body.summary {
208 node = node.with_summary(sum);
209 }
210 if let Some(props) = body.props {
211 for (k, v) in props {
212 node = node.with_prop(
213 k,
214 json_to_ipld(&v).map_err(|e| Error::bad_request(e.to_string()))?,
215 );
216 }
217 }
218 if let Some(c) = body.content {
219 node = node.with_content(bytes::Bytes::from(c.into_bytes()));
220 }
221
222 let text_for_embed: Option<String> = node
230 .summary
231 .as_ref()
232 .filter(|t| !t.trim().is_empty())
233 .cloned();
234 let mut pending_dense: Option<(String, mnem_core::objects::Embedding)> = None;
235 let mut pending_sparse: Option<(String, mnem_core::sparse::SparseEmbed)> = None;
236 if let Some(text) = text_for_embed {
237 if let Some(pc) = &s.embed_cfg
238 && let Ok(embedder) = mnem_embed_providers::open(pc)
239 && let Ok(v) = embedder.embed(&text)
240 {
241 let emb = mnem_embed_providers::to_embedding(embedder.model(), &v);
242 pending_dense = Some((embedder.model().to_string(), emb));
243 }
244 if let Some(sc) = &s.sparse_cfg
245 && let Ok(sparser) = mnem_sparse_providers::open(sc)
246 && let Ok(se) = sparser.encode(&text)
247 {
248 pending_sparse = Some((sparser.vocab_id().to_string(), se));
249 }
250 }
252
253 let id = node.id;
254
255 let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
256 let mut tx = guard.start_transaction();
257 let cid = tx.add_node(&node)?;
258 if let Some((model, emb)) = pending_dense {
259 tx.set_embedding(cid.clone(), model, emb)?;
260 }
261 if let Some((vocab_id, se)) = pending_sparse {
262 tx.set_sparse_embedding(cid, vocab_id, se)?;
263 }
264 let commit_start = std::time::Instant::now();
265 let new_repo = tx.commit(
266 &author,
267 body.message.as_deref().unwrap_or("mnem http add node"),
268 )?;
269 s.metrics
270 .commit_duration
271 .observe(commit_start.elapsed().as_secs_f64());
272 let op_id = new_repo.op_id().to_string();
273 *guard = new_repo;
274
275 Ok(Json(PostNodeResp {
276 schema: "mnem.v1.post-node",
277 id: id.to_uuid_string(),
278 label: body.label,
279 op_id,
280 }))
281}
282
283pub(crate) async fn get_node(
286 State(s): State<AppState>,
287 Path(id_str): Path<String>,
288) -> Result<Json<Value>, Error> {
289 let id = NodeId::parse_uuid(&id_str)
290 .map_err(|e| Error::bad_request(format!("invalid UUID: {e}")))?;
291 let repo = s.repo.lock().map_err(|_| Error::locked())?;
292 let node = repo
293 .lookup_node(&id)?
294 .ok_or_else(|| Error::not_found(format!("no node with id={id_str}")))?;
295
296 let mut props_map = Map::new();
297 for (k, v) in &node.props {
298 props_map.insert(k.clone(), ipld_to_json(v));
299 }
300
301 let has_embedding = match s.embed_cfg.as_ref() {
308 Some(pc) => {
309 let model = model_fq_of(pc);
310 let (_, node_cid) = mnem_core::codec::hash_to_cid(&node)
311 .map_err(|e| Error::internal(format!("hash node: {e}")))?;
312 repo.embedding_for(&node_cid, &model)?.is_some()
313 }
314 None => false,
315 };
316
317 Ok(Json(json!({
318 "schema": "mnem.v1.node",
319 "id": node.id.to_uuid_string(),
320 "label": node.ntype,
321 "summary": node.summary,
322 "props": Value::Object(props_map),
323 "content_bytes": node.content.as_ref().map_or(0, bytes::Bytes::len),
324 "has_embedding": has_embedding,
325 })))
326}
327
328fn model_fq_of(pc: &mnem_embed_providers::ProviderConfig) -> String {
332 use mnem_embed_providers::ProviderConfig as PC;
333 match pc {
334 PC::Openai(c) => format!("openai:{}", c.model),
335 PC::Ollama(c) => format!("ollama:{}", c.model),
336 PC::Onnx(c) => format!("onnx:{}", c.model),
337 }
338}
339
340#[derive(Deserialize)]
344pub(crate) struct GetNodeEmbeddingQuery {
345 model: String,
347}
348
349pub(crate) async fn get_node_embedding(
354 State(s): State<AppState>,
355 Path(id_str): Path<String>,
356 Query(q): Query<GetNodeEmbeddingQuery>,
357) -> Result<Json<Value>, Error> {
358 let id = NodeId::parse_uuid(&id_str)
359 .map_err(|e| Error::bad_request(format!("invalid UUID: {e}")))?;
360 let repo = s.repo.lock().map_err(|_| Error::locked())?;
361 let node = repo
362 .lookup_node(&id)?
363 .ok_or_else(|| Error::not_found(format!("no node with id={id_str}")))?;
364
365 let (_, node_cid) = mnem_core::codec::hash_to_cid(&node)
366 .map_err(|e| Error::internal(format!("hash node: {e}")))?;
367
368 let emb = repo.embedding_for(&node_cid, &q.model)?.ok_or_else(|| {
369 Error::not_found(format!(
370 "no embedding for model={} on node {}",
371 q.model, id_str
372 ))
373 })?;
374
375 let bytes = emb.vector.as_ref();
376 let vector: Vec<f32> = bytes
377 .chunks_exact(4)
378 .map(|c| f32::from_le_bytes(c.try_into().unwrap()))
379 .collect();
380
381 let dtype_str = match emb.dtype {
382 mnem_core::objects::Dtype::F32 => "f32",
383 mnem_core::objects::Dtype::F16 => "f16",
384 mnem_core::objects::Dtype::F64 => "f64",
385 mnem_core::objects::Dtype::I8 => "i8",
386 };
387
388 Ok(Json(json!({
389 "schema": "mnem.v1.node_embedding",
390 "node_id": id_str,
391 "model": emb.model,
392 "dim": emb.dim,
393 "dtype": dtype_str,
394 "vector": vector,
395 })))
396}
397
398#[derive(Deserialize)]
401pub(crate) struct DeleteQuery {
402 pub author: String,
405 #[serde(default)]
406 pub message: Option<String>,
407}
408
409pub(crate) async fn delete_node(
410 _auth: RequireBearer,
411 State(s): State<AppState>,
412 Path(id_str): Path<String>,
413 Query(q): Query<DeleteQuery>,
414) -> Result<Json<Value>, Error> {
415 let id = NodeId::parse_uuid(&id_str)
416 .map_err(|e| Error::bad_request(format!("invalid UUID: {e}")))?;
417 if q.author.trim().is_empty() {
418 return Err(Error::bad_request("author is required"));
419 }
420
421 let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
422 let existed = guard.lookup_node(&id)?.is_some();
423 if !existed {
424 return Err(Error::not_found(format!(
425 "no node with id={id_str} in current view"
426 )));
427 }
428 let mut tx = guard.start_transaction();
429 tx.remove_node(id);
430 let commit_start = std::time::Instant::now();
431 let new_repo = tx.commit(
432 &q.author,
433 q.message.as_deref().unwrap_or("mnem http delete node"),
434 )?;
435 s.metrics
436 .commit_duration
437 .observe(commit_start.elapsed().as_secs_f64());
438 let op_id = new_repo.op_id().to_string();
439 *guard = new_repo;
440
441 Ok(Json(json!({
442 "schema": "mnem.v1.delete-node",
443 "id": id_str,
444 "existed": true,
445 "op_id": op_id,
446 })))
447}
448
449#[derive(Deserialize)]
452pub(crate) struct TombstoneBody {
453 #[serde(default)]
455 pub reason: String,
456 pub author: String,
458}
459
460pub(crate) async fn tombstone_node(
461 State(s): State<AppState>,
462 Path(id_str): Path<String>,
463 Json(body): Json<TombstoneBody>,
464) -> Result<Json<Value>, Error> {
465 let id = NodeId::parse_uuid(&id_str)
466 .map_err(|e| Error::bad_request(format!("invalid UUID: {e}")))?;
467 if body.author.trim().is_empty() {
468 return Err(Error::bad_request("author is required"));
469 }
470 let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
471 if guard.lookup_node(&id)?.is_none() {
475 return Err(Error::not_found(format!("no node with id={id_str}")));
476 }
477 if guard.is_tombstoned(&id) {
482 return Err(Error::conflict(format!(
483 "node {id_str} is already tombstoned"
484 )));
485 }
486 let mut tx = guard.start_transaction();
487 tx.tombstone_node(id, body.reason.clone())?;
488 let commit_start = std::time::Instant::now();
489 let new_repo = tx.commit(&body.author, "mnem http tombstone node")?;
490 s.metrics
491 .commit_duration
492 .observe(commit_start.elapsed().as_secs_f64());
493 let op_id = new_repo.op_id().to_string();
494 *guard = new_repo;
495
496 Ok(Json(json!({
497 "schema": "mnem.v1.tombstone",
498 "op_id": op_id,
499 "node_id": id_str,
500 })))
501}
502
503#[derive(Deserialize)]
507pub(crate) struct PostEdgeBody {
508 pub src: String,
510 pub dst: String,
512 pub etype: String,
514 #[serde(default)]
516 pub props: Option<Map<String, Value>>,
517 pub author: String,
519 #[serde(default)]
521 pub message: Option<String>,
522}
523
524pub(crate) async fn post_edge(
530 _auth: RequireBearer,
531 State(s): State<AppState>,
532 Json(body): Json<PostEdgeBody>,
533) -> Result<Json<Value>, Error> {
534 let author = body.author.trim();
536 if author.is_empty() {
537 return Err(Error::bad_request("author is required"));
538 }
539 if body.etype.trim().is_empty() {
540 return Err(Error::bad_request("etype is required"));
541 }
542
543 let src = NodeId::parse_uuid(&body.src)
545 .map_err(|e| Error::bad_request(format!("invalid src UUID: {e}")))?;
546 let dst = NodeId::parse_uuid(&body.dst)
547 .map_err(|e| Error::bad_request(format!("invalid dst UUID: {e}")))?;
548
549 let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
550
551 if guard.lookup_node(&src)?.is_none() {
553 return Err(Error::not_found(format!(
554 "no node with id={} (src)",
555 body.src
556 )));
557 }
558 if guard.lookup_node(&dst)?.is_none() {
559 return Err(Error::not_found(format!(
560 "no node with id={} (dst)",
561 body.dst
562 )));
563 }
564
565 let edge_id = EdgeId::new_v7();
566 let mut edge = Edge::new(edge_id, &body.etype, src, dst);
567 if let Some(props) = body.props {
568 for (k, v) in props {
569 edge = edge.with_prop(
570 k,
571 json_to_ipld(&v).map_err(|e| Error::bad_request(e.to_string()))?,
572 );
573 }
574 }
575
576 let mut tx = guard.start_transaction();
577 tx.add_edge(&edge)?;
578 let commit_start = std::time::Instant::now();
579 let new_repo = tx.commit(
580 author,
581 body.message.as_deref().unwrap_or("mnem http add edge"),
582 )?;
583 s.metrics
584 .commit_duration
585 .observe(commit_start.elapsed().as_secs_f64());
586 let op_id = new_repo.op_id().to_string();
587 *guard = new_repo;
588
589 Ok(Json(json!({
590 "schema": "mnem.v1.post-edge",
591 "edge_id": edge_id.to_uuid_string(),
592 "op_id": op_id,
593 })))
594}
595
596#[derive(Deserialize)]
608pub(crate) struct BulkNodeBody {
609 pub nodes: Vec<PostNodeBody>,
610 pub author: String,
611 #[serde(default)]
612 pub message: Option<String>,
613 #[serde(default = "default_true")]
616 pub auto_embed: bool,
617}
618
619const fn default_true() -> bool {
620 true
621}
622
623#[derive(Serialize)]
624pub(crate) struct BulkNodeResp {
625 schema: &'static str,
626 op_id: String,
627 results: Vec<BulkNodeEntry>,
629 embedded: u32,
631 skipped_embed: u32,
632}
633
634#[derive(Serialize)]
635pub(crate) struct BulkNodeEntry {
636 id: String,
637 label: String,
638}
639
640pub(crate) async fn post_nodes_bulk(
641 State(s): State<AppState>,
642 Json(body): Json<BulkNodeBody>,
643) -> Result<Json<BulkNodeResp>, Error> {
644 if body.author.trim().is_empty() {
645 return Err(Error::bad_request("author is required"));
646 }
647 if body.nodes.is_empty() {
648 return Err(Error::bad_request("nodes must not be empty"));
649 }
650
651 let embedder = if body.auto_embed {
657 match s.embed_cfg.as_ref() {
658 Some(pc) => Some(mnem_embed_providers::open(pc).map_err(|e| {
659 Error::internal(format!(
660 "embed provider configured but open failed: {e}; bulk aborted to avoid silent no-embed commit"
661 ))
662 })?),
663 None => None,
664 }
665 } else {
666 None
667 };
668 let sparser = if body.auto_embed {
669 match s.sparse_cfg.as_ref() {
670 Some(sc) => Some(mnem_sparse_providers::open(sc).map_err(|e| {
671 Error::internal(format!(
672 "sparse provider configured but open failed: {e}; bulk aborted to avoid silent no-sparse commit"
673 ))
674 })?),
675 None => None,
676 }
677 } else {
678 None
679 };
680
681 type BuiltBulkNode = (
688 Node,
689 Option<(String, mnem_core::objects::Embedding)>,
690 Option<(String, mnem_core::sparse::SparseEmbed)>,
691 );
692 let mut built: Vec<BuiltBulkNode> = Vec::with_capacity(body.nodes.len());
693 let mut results: Vec<BulkNodeEntry> = Vec::with_capacity(body.nodes.len());
694 let mut embedded = 0u32;
695 let mut skipped_embed = 0u32;
696
697 for nb in body.nodes {
698 let label = if s.allow_labels && !nb.label.trim().is_empty() {
703 nb.label.clone()
704 } else {
705 Node::DEFAULT_NTYPE.to_string()
706 };
707 let node_id = match nb.id.as_deref() {
708 Some(s) => NodeId::parse_uuid(s)
709 .map_err(|e| Error::bad_request(format!("invalid caller-supplied id: {e}")))?,
710 None => NodeId::new_v7(),
711 };
712 let mut node = Node::new(node_id, &label);
713 if let Some(sum) = &nb.summary {
714 node = node.with_summary(sum);
715 }
716 if let Some(props) = nb.props {
717 for (k, v) in props {
718 node = node.with_prop(
719 k,
720 json_to_ipld(&v).map_err(|e| Error::bad_request(e.to_string()))?,
721 );
722 }
723 }
724 if let Some(c) = nb.content {
725 node = node.with_content(bytes::Bytes::from(c.into_bytes()));
726 }
727 let text_for_embed: Option<String> = node
732 .summary
733 .as_ref()
734 .filter(|t| !t.trim().is_empty())
735 .cloned();
736 let mut pending_dense: Option<(String, mnem_core::objects::Embedding)> = None;
737 let mut pending_sparse_item: Option<(String, mnem_core::sparse::SparseEmbed)> = None;
738 if let Some(text) = text_for_embed {
739 if let Some(embedder) = embedder.as_ref() {
740 match embedder.embed(&text) {
741 Ok(v) => {
742 let emb = mnem_embed_providers::to_embedding(embedder.model(), &v);
743 pending_dense = Some((embedder.model().to_string(), emb));
744 embedded += 1;
745 }
746 Err(_) => {
747 skipped_embed += 1;
748 }
749 }
750 }
751 if let Some(sparser) = sparser.as_ref()
752 && let Ok(se) = sparser.encode(&text)
753 {
754 pending_sparse_item = Some((sparser.vocab_id().to_string(), se));
755 }
756 }
757 results.push(BulkNodeEntry {
758 id: node.id.to_uuid_string(),
759 label: nb.label,
760 });
761 built.push((node, pending_dense, pending_sparse_item));
762 }
763
764 let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
766 let mut tx = guard.start_transaction();
767 for (node, pending_dense, pending_sparse_item) in &built {
768 let cid = tx.add_node(node)?;
769 if let Some((model, emb)) = pending_dense {
770 tx.set_embedding(cid.clone(), model.clone(), emb.clone())?;
771 }
772 if let Some((vocab_id, se)) = pending_sparse_item {
773 tx.set_sparse_embedding(cid, vocab_id.clone(), se.clone())?;
774 }
775 }
776 let commit_start = std::time::Instant::now();
777 let new_repo = tx.commit(
778 &body.author,
779 body.message.as_deref().unwrap_or("mnem http bulk add"),
780 )?;
781 s.metrics
782 .commit_duration
783 .observe(commit_start.elapsed().as_secs_f64());
784 let op_id = new_repo.op_id().to_string();
785 *guard = new_repo;
786
787 Ok(Json(BulkNodeResp {
788 schema: "mnem.v1.post-nodes-bulk",
789 op_id,
790 results,
791 embedded,
792 skipped_embed,
793 }))
794}
795
796#[derive(Deserialize)]
799pub(crate) struct RetrieveQuery {
800 pub text: Option<String>,
801 pub label: Option<String>,
802 #[serde(default)]
803 pub budget: Option<u32>,
804 #[serde(default)]
805 pub limit: Option<usize>,
806 pub where_eq: Option<String>,
808}
809
810pub(crate) async fn retrieve(
811 State(s): State<AppState>,
812 Query(q): Query<RetrieveQuery>,
813) -> Result<Json<Value>, Error> {
814 clamp_or_reject("limit", q.limit, MAX_RETRIEVE_LIMIT)?;
818
819 let repo = s.repo.lock().map_err(|_| Error::locked())?;
820 let mut ret = repo.retrieve();
821 if s.allow_labels
826 && let Some(l) = &q.label
827 {
828 ret = ret.label(l.clone());
829 }
830 if let Some(w) = &q.where_eq {
831 let (k, v) = parse_kv(w).map_err(Error::bad_request)?;
832 ret = ret.where_prop(k, PropPredicate::Eq(v));
833 }
834 if let Some(b) = q.budget {
835 ret = ret.token_budget(b);
836 }
837 if let Some(n) = q.limit {
838 ret = ret.limit(n);
839 }
840 let mut vector_model: Option<String> = None;
845 let mut sparse_vocab: Option<String> = None;
846 if let Some(text) = q.text.as_deref()
847 && !text.trim().is_empty()
848 {
849 ret = ret.query_text(text.to_string());
850 if let Some(pc) = &s.embed_cfg {
852 let embedder = mnem_embed_providers::open(pc)
853 .map_err(|e| Error::internal(format!("embed provider open failed: {e}")))?;
854 let qvec = embedder
855 .embed(text)
856 .map_err(|e| Error::internal(format!("embed call failed: {e}")))?;
857 vector_model = Some(embedder.model().to_string());
858 ret = ret.vector(embedder.model().to_string(), qvec);
859 }
860 if let Some(sc) = &s.sparse_cfg {
862 let sparser = mnem_sparse_providers::open(sc)
863 .map_err(|e| Error::bad_request(format!("sparse open failed: {e}")))?;
864 let sq = sparser
865 .encode_query(text)
866 .map_err(|e| Error::bad_request(format!("sparse encode failed: {e}")))?;
867 sparse_vocab = Some(sq.vocab_id.clone());
868 ret = ret.sparse_query(sq);
869 }
870 if vector_model.is_none() && sparse_vocab.is_none() {
880 let mock = mnem_embed_providers::MockEmbedder::new("mock:cold-start-384", 384);
881 let qvec = mock
882 .embed(text)
883 .map_err(|e| Error::internal(format!("mock embed failed: {e}")))?;
884 vector_model = Some(mock.model().to_string());
885 ret = ret.vector(mock.model().to_string(), qvec);
886 tracing::warn!(
887 "retrieve: no [embed]/[sparse] configured; using deterministic \
888 MockEmbedder fallback (cold-start). Configure a real provider \
889 in config.toml for production retrieval quality."
890 );
891 }
892 }
893 {
894 let mut cache = s.indexes.lock().map_err(|_| Error::locked())?;
895 if let Some(model) = &vector_model {
896 let idx = cache.vector_index(&repo, model)?;
897 ret = ret.with_vector_index(idx);
898 }
899 if let Some(vocab) = &sparse_vocab {
900 let idx = cache.sparse_index(&repo, vocab)?;
901 ret = ret.with_sparse_index(idx);
902 }
903 }
904 let retrieve_start = std::time::Instant::now();
908 let result = ret.execute()?;
909 s.metrics
910 .retrieve_latency
911 .observe(retrieve_start.elapsed().as_secs_f64());
912
913 let items: Vec<Value> = result
914 .items
915 .iter()
916 .map(|item| {
917 let mut lane_obj = Map::new();
921 for (lane, score) in &item.lane_scores {
922 lane_obj.insert(lane_name(*lane).to_string(), json!(score));
923 }
924 json!({
925 "id": item.node.id.to_uuid_string(),
926 "label": item.node.ntype,
927 "score": item.score,
928 "tokens": item.tokens,
929 "summary": item.node.summary,
930 "rendered": item.rendered,
931 "lane_scores": Value::Object(lane_obj),
932 })
933 })
934 .collect();
935
936 let score_dist = {
945 let scores: Vec<f32> = result.items.iter().map(|it| it.score).collect();
946 mnem_graphrag::distribution_shape(&scores, mnem_graphrag::K_MIN)
947 };
948
949 Ok(Json(json!({
950 "schema": "mnem.v1.retrieve",
951 "items": items,
952 "tokens_used": result.tokens_used,
953 "tokens_budget": if result.tokens_budget == u32::MAX {
954 Value::Null
955 } else {
956 Value::from(result.tokens_budget)
957 },
958 "dropped": result.dropped,
959 "candidates_seen": result.candidates_seen,
960 "score_distribution": score_dist,
961 })))
962}
963
964#[derive(Deserialize, Default)]
980pub(crate) struct RetrieveRequest {
981 #[serde(default)]
983 pub text: Option<String>,
984 #[serde(default)]
985 pub label: Option<String>,
986 #[serde(default)]
987 pub where_eq: Option<String>,
988 #[serde(default)]
989 pub budget: Option<u32>,
990 #[serde(default)]
991 pub limit: Option<usize>,
992
993 #[serde(default)]
995 pub vector_cap: Option<usize>,
996
997 #[serde(default)]
1000 pub vector_model: Option<String>,
1001 #[serde(default)]
1002 pub vector: Option<Vec<f32>>,
1003
1004 #[serde(default)]
1006 pub rerank: Option<String>,
1007 #[serde(default)]
1008 pub rerank_top_k: Option<usize>,
1009
1010 #[serde(default)]
1019 pub community_filter: Option<bool>,
1020 #[serde(default)]
1024 pub community_min_coverage: Option<f32>,
1025 #[serde(default)]
1028 pub community_expand_seeds: Option<usize>,
1029 #[serde(default)]
1032 pub community_max_per: Option<usize>,
1033 #[serde(default)]
1036 pub community_decay: Option<f32>,
1037
1038 #[serde(default)]
1040 pub graph_expand: Option<usize>,
1041 #[serde(default)]
1042 pub graph_decay: Option<f32>,
1043 #[serde(default)]
1044 pub graph_etype: Option<Vec<String>>,
1045 #[serde(default)]
1049 pub graph_depth: Option<usize>,
1050 #[serde(default)]
1054 pub graph_max_per_seed: Option<usize>,
1055 #[serde(default)]
1060 pub graph_mode: Option<String>,
1061 #[serde(default)]
1064 pub ppr_damping: Option<f32>,
1065 #[serde(default)]
1068 pub ppr_iter: Option<u32>,
1069 #[serde(default)]
1073 pub ppr_opt_in: Option<bool>,
1074 #[serde(default)]
1078 pub summarize: Option<bool>,
1079 #[serde(default)]
1082 pub summarize_k: Option<usize>,
1083}
1084
1085pub(crate) async fn retrieve_full(
1086 State(s): State<AppState>,
1087 Json(body): Json<RetrieveRequest>,
1088) -> Result<Json<Value>, Error> {
1089 clamp_or_reject("limit", body.limit, MAX_RETRIEVE_LIMIT)?;
1093 clamp_or_reject("vector_cap", body.vector_cap, MAX_VECTOR_CAP)?;
1094 clamp_or_reject("rerank_top_k", body.rerank_top_k, MAX_RERANK_TOP_K)?;
1095
1096 let repo = s.repo.lock().map_err(|_| Error::locked())?;
1097 let mut ret = repo.retrieve();
1098 let mut skipped: Vec<String> = Vec::new();
1099 let mut warnings: Vec<mnem_core::retrieve::Warning> = Vec::new();
1106
1107 if s.allow_labels
1110 && let Some(l) = &body.label
1111 {
1112 ret = ret.label(l.clone());
1113 }
1114 if let Some(w) = &body.where_eq {
1115 let (k, v) = parse_kv(w).map_err(Error::bad_request)?;
1116 ret = ret.where_prop(k, PropPredicate::Eq(v));
1117 }
1118 if let Some(b) = body.budget {
1119 ret = ret.token_budget(b);
1120 }
1121 if let Some(n) = body.limit {
1122 ret = ret.limit(n);
1123 }
1124 if let Some(n) = body.vector_cap {
1125 ret = ret.vector_cap(n);
1126 }
1127
1128 let mut vector_model: Option<String> = None;
1138 let mut sparse_vocab: Option<String> = None;
1139 if let Some(text) = body.text.as_deref()
1140 && !text.trim().is_empty()
1141 {
1142 ret = ret.query_text(text.to_string());
1143 }
1144 if let (Some(m), Some(v)) = (&body.vector_model, &body.vector) {
1146 vector_model = Some(m.clone());
1147 ret = ret.vector(m.clone(), v.clone());
1148 } else if let Some(text) = body.text.as_deref()
1149 && !text.trim().is_empty()
1150 && let Some(pc) = &s.embed_cfg
1151 {
1152 let embedder = mnem_embed_providers::open(pc)
1153 .map_err(|e| Error::bad_request(format!("embed open failed: {e}")))?;
1154 let qvec = embedder
1155 .embed(text)
1156 .map_err(|e| Error::bad_request(format!("embed call failed: {e}")))?;
1157 vector_model = Some(embedder.model().to_string());
1158 ret = ret.vector(embedder.model().to_string(), qvec);
1159 }
1160 if let Some(text) = body.text.as_deref()
1164 && !text.trim().is_empty()
1165 && let Some(sc) = &s.sparse_cfg
1166 {
1167 let sparser = mnem_sparse_providers::open(sc)
1168 .map_err(|e| Error::internal(format!("sparse provider open failed: {e}")))?;
1169 let sq = sparser
1170 .encode_query(text)
1171 .map_err(|e| Error::internal(format!("sparse encode failed: {e}")))?;
1172 sparse_vocab = Some(sq.vocab_id.clone());
1173 ret = ret.sparse_query(sq);
1174 }
1175 if body.text.as_deref().is_some_and(|t| !t.trim().is_empty())
1183 && vector_model.is_none()
1184 && sparse_vocab.is_none()
1185 && body.vector.is_none()
1186 {
1187 if let Some(text) = body.text.as_deref() {
1188 let mock = mnem_embed_providers::MockEmbedder::new("mock:cold-start-384", 384);
1189 let qvec = mock
1190 .embed(text)
1191 .map_err(|e| Error::internal(format!("mock embed failed: {e}")))?;
1192 vector_model = Some(mock.model().to_string());
1193 ret = ret.vector(mock.model().to_string(), qvec);
1194 skipped.push(
1195 "embed: cold-start MockEmbedder fallback (no [embed]/[sparse] configured)"
1196 .to_string(),
1197 );
1198 tracing::warn!(
1199 "retrieve_full: no [embed]/[sparse] configured; using deterministic \
1200 MockEmbedder fallback (cold-start). Configure a real provider in \
1201 config.toml for production retrieval quality."
1202 );
1203 }
1204 }
1205
1206 let mut vector_idx_for_graph: Option<std::sync::Arc<mnem_core::index::BruteForceVectorIndex>> =
1215 None;
1216 {
1217 let mut cache = s.indexes.lock().map_err(|_| Error::locked())?;
1218 if let Some(model) = &vector_model {
1219 let idx = cache.vector_index(&repo, model)?;
1220 vector_idx_for_graph = Some(idx.clone());
1221 ret = ret.with_vector_index(idx);
1222 }
1223 if let Some(vocab) = &sparse_vocab {
1224 let idx = cache.sparse_index(&repo, vocab)?;
1225 ret = ret.with_sparse_index(idx);
1226 }
1227 }
1228
1229 if let Some(spec) = &body.rerank {
1231 match parse_rerank_spec(spec) {
1232 Ok(cfg) => match mnem_rerank_providers::open(&cfg) {
1233 Ok(rr) => {
1234 ret = ret.with_reranker(rr);
1235 if let Some(k) = body.rerank_top_k {
1236 ret = ret.rerank_top_k(k);
1237 }
1238 }
1239 Err(e) => {
1240 skipped.push(format!("rerank: {e}"));
1241 warnings.push(mnem_core::retrieve::Warning::for_code(
1246 mnem_core::retrieve::WarningCode::NoReranker,
1247 ));
1248 }
1249 },
1250 Err(e) => {
1251 skipped.push(format!("rerank spec: {e}"));
1252 warnings.push(mnem_core::retrieve::Warning::for_code(
1253 mnem_core::retrieve::WarningCode::NoReranker,
1254 ));
1255 }
1256 }
1257 }
1258
1259 if body.community_filter.unwrap_or(false) {
1270 let has_vectors = vector_idx_for_graph
1277 .as_deref()
1278 .is_some_and(|v| !v.is_empty());
1279 let has_authored_edges = match s.graph_cache.lock() {
1280 Ok(gc) => gc.adjacency.as_ref().is_some_and(|a| !a.edges.is_empty()),
1281 Err(_) => false,
1282 };
1283 if !has_vectors && !has_authored_edges {
1284 warnings.push(mnem_core::retrieve::Warning::for_code(
1285 mnem_core::retrieve::WarningCode::CommunityFilterNoop,
1286 ));
1287 }
1288 let assignment = {
1289 let mut gc = s.graph_cache.lock().map_err(|_| Error::locked())?;
1290 gc.hybrid_community_for(&repo, vector_idx_for_graph.as_deref())?
1291 };
1292 let expand_seeds = body.community_expand_seeds.unwrap_or(3);
1293 let max_per_community = body.community_max_per.unwrap_or(10);
1294 let decay = body.community_decay.unwrap_or(0.85).clamp(0.0, 1.0);
1295 let min_coverage = body.community_min_coverage.unwrap_or(0.5).clamp(0.0, 1.0);
1299 let cfg = mnem_core::retrieve::CommunityFilterCfg {
1300 enabled: true,
1301 expand_seeds,
1302 max_per_community,
1303 decay,
1304 min_coverage,
1305 };
1306 let lookup_handle_fwd = assignment.clone();
1307 let lookup_handle_inv = assignment.clone();
1308 let lookup = std::sync::Arc::new(mnem_core::retrieve::CommunityLookup::new_with_members(
1309 move |nid| lookup_handle_fwd.community_of(*nid),
1310 move |cid| lookup_handle_inv.members_of(cid).to_vec(),
1311 ));
1312 ret = ret.with_community_filter(cfg, lookup);
1313 }
1314
1315 let want_ppr = body
1321 .graph_mode
1322 .as_deref()
1323 .is_some_and(|m| m.eq_ignore_ascii_case("ppr"));
1324 if want_ppr {
1325 let has_vectors = vector_idx_for_graph
1329 .as_deref()
1330 .is_some_and(|v| !v.is_empty());
1331 let has_authored_edges = match s.graph_cache.lock() {
1332 Ok(gc) => gc.adjacency.as_ref().is_some_and(|a| !a.edges.is_empty()),
1333 Err(_) => false,
1334 };
1335 if !has_vectors && !has_authored_edges {
1336 warnings.push(mnem_core::retrieve::Warning::for_code(
1337 mnem_core::retrieve::WarningCode::PprNoSubstrate,
1338 ));
1339 }
1340 let adj = {
1341 let mut gc = s.graph_cache.lock().map_err(|_| Error::locked())?;
1342 gc.hybrid_adjacency_for(&repo, vector_idx_for_graph.as_deref())?
1343 };
1344 ret = ret.with_adjacency_index(adj);
1345 }
1346
1347 if let Some(max_expand) = body.graph_expand {
1349 let has_authored_edges = match s.graph_cache.lock() {
1354 Ok(gc) => gc.adjacency.as_ref().is_some_and(|a| !a.edges.is_empty()),
1355 Err(_) => false,
1356 };
1357 if !has_authored_edges {
1358 warnings.push(mnem_core::retrieve::Warning::for_code(
1359 mnem_core::retrieve::WarningCode::AuthoredAdjacencyEmpty,
1360 ));
1361 }
1362 let mut cfg = mnem_core::retrieve::GraphExpand {
1363 max_expand,
1364 decay: body
1365 .graph_decay
1366 .unwrap_or(mnem_core::retrieve::GraphExpand::DEFAULT_DECAY),
1367 etype_filter: body.graph_etype.clone(),
1368 ..Default::default()
1369 };
1370 if let Some(depth) = body.graph_depth {
1371 cfg = cfg.with_depth(depth);
1372 }
1373 if let Some(cap) = body.graph_max_per_seed {
1374 cfg = cfg.with_max_per_seed(cap);
1375 }
1376 if let Some(mode) = body.graph_mode.as_deref()
1378 && mode == "ppr"
1379 {
1380 let damping = body.ppr_damping.unwrap_or(mnem_core::ppr::DEFAULT_DAMPING);
1381 let iter = body.ppr_iter.unwrap_or(mnem_core::ppr::DEFAULT_MAX_ITER);
1382 cfg = cfg.with_ppr(damping, iter, mnem_core::ppr::DEFAULT_EPS);
1383 }
1384 ret = ret.with_graph_expand(cfg);
1385 }
1386
1387 ret = ret.with_ppr_opt_in(body.ppr_opt_in.unwrap_or(false));
1392
1393 let retrieve_start = std::time::Instant::now();
1395 let result = ret.execute()?;
1396 s.metrics
1397 .retrieve_latency
1398 .observe(retrieve_start.elapsed().as_secs_f64());
1399
1400 if result.ppr_size_gate_skipped {
1405 warnings.push(mnem_core::retrieve::Warning::for_code(
1406 mnem_core::retrieve::WarningCode::PprSizeGateSkipped,
1407 ));
1408 s.metrics
1409 .ppr_size_gate_skipped
1410 .get_or_create(&crate::metrics::PprSizeGateLabels {
1411 reason: "above_threshold".into(),
1412 })
1413 .inc();
1414 }
1415 let items: Vec<Value> = result
1416 .items
1417 .iter()
1418 .map(|item| {
1419 let mut lane_obj = Map::new();
1423 for (lane, score) in &item.lane_scores {
1424 lane_obj.insert(lane_name(*lane).to_string(), json!(score));
1425 }
1426 json!({
1427 "id": item.node.id.to_uuid_string(),
1428 "label": item.node.ntype,
1429 "score": item.score,
1430 "tokens": item.tokens,
1431 "summary": item.node.summary,
1432 "rendered": item.rendered,
1433 "lane_scores": Value::Object(lane_obj),
1434 })
1435 })
1436 .collect();
1437
1438 let score_dist = {
1444 let scores: Vec<f32> = result.items.iter().map(|it| it.score).collect();
1445 mnem_graphrag::distribution_shape(&scores, mnem_graphrag::K_MIN)
1446 };
1447
1448 let warnings = mnem_core::retrieve::cap_warnings(warnings);
1457 let warnings_json: Vec<Value> = warnings
1458 .iter()
1459 .map(|w| {
1460 json!({
1461 "code": w.code.as_str(),
1462 "knob": w.knob,
1463 "message": w.message,
1464 "remediation_ref": w.remediation_ref,
1465 })
1466 })
1467 .collect();
1468 let gap01_confidence = gap01_compute_confidence(&result.items);
1488 let gap01_neighbors = gap01_suggested_neighbors(&result.items);
1489 let gap01_community_density = 0.0_f32;
1490 let gap01_session_reservoir_ttl_s = mnem_core::retrieve::session_reservoir::IDLE_TTL.as_secs();
1491
1492 let mut response = json!({
1493 "schema": "mnem.v1.retrieve",
1494 "items": items,
1495 "tokens_used": result.tokens_used,
1496 "tokens_budget": if result.tokens_budget == u32::MAX {
1497 Value::Null
1498 } else {
1499 Value::from(result.tokens_budget)
1500 },
1501 "dropped": result.dropped,
1502 "score_distribution": score_dist,
1503 "candidates_seen": result.candidates_seen,
1504 "skipped": skipped,
1505 "confidence": gap01_confidence,
1506 "suggested_neighbors": gap01_neighbors,
1507 "community_density": gap01_community_density,
1508 "session_reservoir_ttl_s": gap01_session_reservoir_ttl_s,
1509 });
1510 if !warnings_json.is_empty() {
1511 response["warnings"] = Value::Array(warnings_json);
1512 }
1513
1514 if body.summarize.unwrap_or(false) {
1515 let k = body.summarize_k.unwrap_or(3).min(MAX_RETRIEVE_LIMIT);
1516 let mut sentences: Vec<String> = Vec::new();
1523 let mut centrality_weights: Vec<f32> = Vec::new();
1524 let degree_map: Option<std::collections::HashMap<NodeId, u32>> = if want_ppr {
1526 if let Ok(gc) = s.graph_cache.lock() {
1530 gc.adjacency.as_ref().map(|adj| {
1531 let mut m: std::collections::HashMap<NodeId, u32> =
1532 std::collections::HashMap::new();
1533 for (src, dst) in &adj.edges {
1534 *m.entry(*src).or_insert(0) += 1;
1535 *m.entry(*dst).or_insert(0) += 1;
1536 }
1537 m
1538 })
1539 } else {
1540 None
1541 }
1542 } else {
1543 None
1544 };
1545 for it in &result.items {
1546 if let Some(summary) = it.node.summary.clone() {
1547 sentences.push(summary);
1548 let w = if want_ppr {
1549 it.score.max(0.0)
1552 } else if let Some(m) = °ree_map {
1553 m.get(&it.node.id).copied().unwrap_or(0) as f32
1554 } else {
1555 1.0_f32
1556 };
1557 centrality_weights.push(w);
1558 }
1559 }
1560 if sentences.is_empty() {
1565 response["summary"] = json!([]);
1566 } else if let Some(pc) = &s.embed_cfg {
1567 match mnem_embed_providers::open(pc) {
1568 Ok(embedder) => {
1569 let centrality_vec = centrality_weights.clone();
1570 let centrality =
1571 move |i: usize| centrality_vec.get(i).copied().unwrap_or(1.0_f32);
1572 match mnem_graphrag::summarize_community(
1573 &sentences,
1574 embedder.as_ref(),
1575 None, ¢rality,
1577 k,
1578 0.5,
1579 ) {
1580 Ok(summary) => {
1581 let arr: Vec<Value> = summary
1582 .sentences
1583 .iter()
1584 .zip(summary.scores.iter())
1585 .map(|(s, score)| json!({"sentence": s, "score": score}))
1586 .collect();
1587 response["summary"] = Value::Array(arr);
1588 }
1589 Err(e) => {
1590 response["summary"] = json!([]);
1591 response["summarize_skipped"] = json!(format!("summarize failed: {e}"));
1592 }
1593 }
1594 }
1595 Err(e) => {
1596 response["summary"] = json!([]);
1597 response["summarize_skipped"] =
1598 json!(format!("embed provider open failed: {e}"));
1599 }
1600 }
1601 } else {
1602 response["summary"] = json!([]);
1603 response["summarize_skipped"] = json!("no [embed] provider configured on server");
1604 }
1605 }
1606
1607 Ok(Json(response))
1608}
1609
1610fn parse_rerank_spec(spec: &str) -> Result<mnem_rerank_providers::ProviderConfig, String> {
1616 let (prov, model) = spec
1617 .split_once(':')
1618 .ok_or_else(|| format!("expected PROVIDER:MODEL, got `{spec}`"))?;
1619 if model.is_empty() {
1620 return Err(format!("empty model in `{spec}`"));
1621 }
1622 match prov {
1623 "cohere" => Ok(mnem_rerank_providers::ProviderConfig::Cohere(
1624 mnem_rerank_providers::CohereConfig {
1625 model: model.into(),
1626 ..Default::default()
1627 },
1628 )),
1629 "voyage" => Ok(mnem_rerank_providers::ProviderConfig::Voyage(
1630 mnem_rerank_providers::VoyageConfig {
1631 model: model.into(),
1632 ..Default::default()
1633 },
1634 )),
1635 "jina" => Ok(mnem_rerank_providers::ProviderConfig::Jina(
1636 mnem_rerank_providers::JinaConfig {
1637 model: model.into(),
1638 ..Default::default()
1639 },
1640 )),
1641 other => Err(format!(
1642 "unknown rerank provider `{other}`; want cohere|voyage|jina"
1643 )),
1644 }
1645}
1646
1647fn ipld_to_json(v: &Ipld) -> Value {
1656 match v {
1657 Ipld::Null => Value::Null,
1658 Ipld::Bool(b) => Value::Bool(*b),
1659 Ipld::Integer(i) => serde_json::Number::from_i128(*i).map_or(Value::Null, Value::Number),
1660 Ipld::Float(f) => serde_json::Number::from_f64(*f).map_or(Value::Null, Value::Number),
1661 Ipld::String(s) => Value::String(s.clone()),
1662 Ipld::Bytes(b) => Value::String(format!("<{} bytes>", b.len())),
1663 Ipld::List(xs) => Value::Array(xs.iter().map(ipld_to_json).collect()),
1664 Ipld::Map(m) => {
1665 let mut out = Map::new();
1666 for (k, v) in m {
1667 out.insert(k.clone(), ipld_to_json(v));
1668 }
1669 Value::Object(out)
1670 }
1671 Ipld::Link(cid) => Value::String(cid.to_string()),
1672 }
1673}
1674
1675fn parse_kv(s: &str) -> Result<(String, Ipld), String> {
1676 let (k, v) = s
1677 .split_once('=')
1678 .ok_or_else(|| format!("expected KEY=VALUE, got `{s}`"))?;
1679 let val = match serde_json::from_str::<Value>(v) {
1680 Ok(json) => json_to_ipld(&json).map_err(|e| e.to_string())?,
1681 Err(_) => Ipld::String(v.to_string()),
1682 };
1683 Ok((k.to_string(), val))
1684}
1685
1686pub(crate) const GAP01_TOP_SEEDS: usize = 3;
1705
1706pub(crate) const GAP01_MAX_NEIGHBOURS: usize = 3;
1712
1713pub(crate) const GAP01_PREVIEW_CHARS: usize = 200;
1718
1719pub(crate) fn gap01_compute_confidence(items: &[mnem_core::retrieve::RetrievedItem]) -> f32 {
1731 if items.len() < 2 {
1732 return 0.0;
1733 }
1734 let top = items[0].score;
1735 if !top.is_finite() || top <= 0.0 {
1736 return 0.0;
1737 }
1738 let tail = items[items.len() - 1].score.max(0.0);
1742 (1.0 - (tail / top)).clamp(0.0, 1.0)
1743}
1744
1745pub(crate) fn gap01_suggested_neighbors(
1759 items: &[mnem_core::retrieve::RetrievedItem],
1760) -> Vec<Value> {
1761 items
1762 .iter()
1763 .skip(GAP01_TOP_SEEDS)
1764 .take(GAP01_MAX_NEIGHBOURS)
1765 .map(|it| {
1766 let preview: String = it.rendered.chars().take(GAP01_PREVIEW_CHARS).collect();
1767 json!({
1768 "id": it.node.id.to_uuid_string(),
1769 "preview": preview,
1770 "via": "adjacency",
1771 })
1772 })
1773 .collect()
1774}
1775
1776pub(crate) const DEFAULT_SERIALIZATION_RATE_BYTES_PER_MS: u64 = 4_096;
1781
1782pub(crate) const DEFAULT_LATENCY_BUDGET_MS: u32 = 256;
1784
1785pub(crate) const EXPLAIN_ADJACENCY_CAP: usize = 256;
1788
1789pub(crate) const EXPLAIN_MAX_DEPTH: u16 = 8;
1792
1793#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
1795#[serde(rename_all = "snake_case")]
1796pub(crate) enum ExplainMode {
1797 #[default]
1799 Compact,
1800 CompactFull,
1803}
1804
1805#[derive(Deserialize, Debug)]
1807pub(crate) struct ExplainRequest {
1808 pub node_id: String,
1810 #[serde(default = "default_explain_depth")]
1812 pub depth: u16,
1813 #[serde(default)]
1815 pub mode: ExplainMode,
1816 #[serde(default)]
1818 pub latency_budget_ms: Option<u32>,
1819 #[serde(default)]
1821 pub serialization_rate_bytes_per_ms: Option<u64>,
1822}
1823
1824fn default_explain_depth() -> u16 {
1825 3
1826}
1827
1828#[must_use]
1834pub fn derive_max_path_bytes(remaining_ms: u32, serialization_rate_bytes_per_ms: u64) -> usize {
1835 u64::from(remaining_ms)
1836 .saturating_mul(serialization_rate_bytes_per_ms)
1837 .try_into()
1838 .unwrap_or(usize::MAX)
1839}
1840
1841pub(crate) async fn explain(
1844 State(s): State<AppState>,
1845 Json(body): Json<ExplainRequest>,
1846) -> Result<Json<Value>, Error> {
1847 let seed = NodeId::parse_uuid(&body.node_id)
1848 .map_err(|e| Error::bad_request(format!("invalid node_id UUID: {e}")))?;
1849 let depth = body.depth.min(EXPLAIN_MAX_DEPTH);
1850
1851 let rate = body
1855 .serialization_rate_bytes_per_ms
1856 .filter(|&r| r > 0)
1857 .unwrap_or(DEFAULT_SERIALIZATION_RATE_BYTES_PER_MS);
1858 let budget_ms = body
1859 .latency_budget_ms
1860 .filter(|&m| m > 0)
1861 .unwrap_or(DEFAULT_LATENCY_BUDGET_MS);
1862 let max_bytes = derive_max_path_bytes(budget_ms, rate);
1863
1864 let (effective_mode, mode_warning): (ExplainMode, Option<&'static str>) = match body.mode {
1866 ExplainMode::Compact => (ExplainMode::Compact, None),
1867 ExplainMode::CompactFull => (
1868 ExplainMode::Compact,
1869 Some("compact_full requested but no ACL is configured; falling back to compact"),
1870 ),
1871 };
1872
1873 let repo = s.repo.lock().map_err(|_| Error::locked())?;
1874
1875 let mut nodes: Vec<NodeId> = vec![seed];
1878 let mut visited: std::collections::HashMap<NodeId, u32> = std::collections::HashMap::new();
1879 visited.insert(seed, 0);
1880 let mut steps: Vec<(u16, u32)> = Vec::new();
1881 let mut truncated_reason: Option<&'static str> = None;
1882
1883 let mut frontier: Vec<u32> = vec![0];
1884 'bfs: for _hop in 0..depth {
1885 let mut next_frontier: Vec<u32> = Vec::new();
1886 for &parent_idx in &frontier {
1887 let parent_node = nodes[parent_idx as usize];
1888 let edges = repo
1889 .incoming_edges_capped(&parent_node, None, EXPLAIN_ADJACENCY_CAP)
1890 .map_err(Error::from)?;
1891 for edge in edges {
1892 let from = edge.src;
1893 if visited.contains_key(&from) {
1894 continue;
1895 }
1896 let projected =
1898 steps.len().saturating_mul(32) + nodes.len().saturating_mul(40) + 32;
1899 if projected > max_bytes {
1900 truncated_reason = Some("response_budget");
1901 break 'bfs;
1902 }
1903 let new_idx: u32 = nodes.len().try_into().unwrap_or(u32::MAX);
1904 nodes.push(from);
1905 visited.insert(from, new_idx);
1906 steps.push((u16::try_from(parent_idx).unwrap_or(u16::MAX), new_idx));
1907 next_frontier.push(new_idx);
1908 }
1909 }
1910 if next_frontier.is_empty() {
1911 break;
1912 }
1913 frontier = next_frontier;
1914 }
1915 if truncated_reason.is_none() && depth == EXPLAIN_MAX_DEPTH && !frontier.is_empty() {
1916 truncated_reason = Some("depth");
1917 }
1918 drop(repo);
1919
1920 let nodes_wire: Vec<Value> = nodes
1921 .iter()
1922 .map(|n| Value::String(n.to_uuid_string()))
1923 .collect();
1924 let steps_wire: Vec<Value> = steps
1925 .iter()
1926 .map(|(p, t)| {
1927 json!({
1928 "parent_idx": p,
1929 "to_idx": t,
1930 })
1931 })
1932 .collect();
1933
1934 let mut warnings: Vec<Value> = Vec::new();
1935 if let Some(w) = mode_warning {
1936 warnings.push(json!({
1937 "code": "explain.mode_downgraded",
1938 "message": w,
1939 }));
1940 }
1941
1942 let mode_str = match effective_mode {
1943 ExplainMode::Compact => "compact",
1944 ExplainMode::CompactFull => "compact_full",
1945 };
1946
1947 Ok(Json(json!({
1948 "schema": "mnem.v1.explain",
1949 "seed": seed.to_uuid_string(),
1950 "mode": mode_str,
1951 "path_source":
1952 format!("bfs.v1:graph_depth={depth}:edge_source=adjacency.v1"),
1953 "max_path_bytes_total": max_bytes,
1954 "latency_budget_ms": budget_ms,
1955 "serialization_rate_bytes_per_ms": rate,
1956 "nodes": nodes_wire,
1957 "steps": steps_wire,
1958 "path_truncated": truncated_reason.is_some(),
1959 "path_truncated_reason": truncated_reason,
1960 "warnings": warnings,
1961 })))
1962}
1963
1964pub(crate) const MAX_LOG_LIMIT: usize = 500;
1975
1976fn default_log_limit() -> usize {
1978 50
1979}
1980
1981#[derive(serde::Deserialize, Default, Clone, Copy, Debug)]
1983#[serde(rename_all = "lowercase")]
1984pub(crate) enum LogFormat {
1985 #[default]
1987 Json,
1988 Oneline,
1990 Full,
1992}
1993
1994#[derive(serde::Deserialize)]
1996pub(crate) struct LogParams {
1997 #[serde(default = "default_log_limit")]
1999 pub limit: usize,
2000 #[serde(default)]
2002 pub format: LogFormat,
2003}
2004
2005#[derive(serde::Serialize)]
2007struct LogEntry {
2008 op_id: String,
2009 timestamp: String,
2010 author: String,
2011 message: String,
2012 parents: Vec<String>,
2013 #[serde(skip_serializing_if = "Option::is_none")]
2014 agent_id: Option<String>,
2015 #[serde(skip_serializing_if = "Option::is_none")]
2016 task_id: Option<String>,
2017}
2018
2019fn short_cid_str(full: &str) -> String {
2022 if full.len() <= 10 {
2023 full.to_string()
2024 } else {
2025 full.chars().skip(2).take(8).collect()
2026 }
2027}
2028
2029fn micros_to_rfc3339(micros: u64) -> String {
2032 use std::time::{Duration, UNIX_EPOCH};
2033 let secs = micros / 1_000_000;
2034 let nanos = ((micros % 1_000_000) * 1_000) as u32;
2035 match UNIX_EPOCH.checked_add(Duration::new(secs, nanos)) {
2036 Some(_t) => {
2037 let total_secs = secs;
2040 let s = total_secs % 60;
2041 let m = (total_secs / 60) % 60;
2042 let h = (total_secs / 3600) % 24;
2043 let days = total_secs / 86400;
2044 let (year, month, day) = days_to_ymd(days);
2046 format!(
2047 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
2048 year,
2049 month,
2050 day,
2051 h,
2052 m,
2053 s,
2054 micros % 1_000_000,
2055 )
2056 }
2057 None => micros.to_string(),
2058 }
2059}
2060
2061fn days_to_ymd(days: u64) -> (u64, u8, u8) {
2064 let z = days as i64 + 719_468;
2067 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
2068 let doe = z - era * 146_097;
2069 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
2070 let y = yoe + era * 400;
2071 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2072 let mp = (5 * doy + 2) / 153;
2073 let d = doy - (153 * mp + 2) / 5 + 1;
2074 let m = if mp < 10 { mp + 3 } else { mp - 9 };
2075 let y = if m <= 2 { y + 1 } else { y };
2076 (y as u64, m as u8, d as u8)
2077}
2078
2079fn read_op(
2082 bs: &dyn mnem_core::store::Blockstore,
2083 cid: &mnem_core::id::Cid,
2084) -> Result<(Operation, Option<mnem_core::id::Cid>), Error> {
2085 let bytes = bs
2086 .get(cid)
2087 .map_err(|e| Error::internal(format!("blockstore read: {e}")))?
2088 .ok_or_else(|| Error::internal(format!("op {cid} missing from store")))?;
2089 let op: Operation = from_canonical_bytes(&bytes)
2090 .map_err(|e| Error::internal(format!("decode op {cid}: {e}")))?;
2091 let next = op.parents.first().cloned();
2092 Ok((op, next))
2093}
2094
2095pub(crate) async fn get_log(
2104 State(s): State<AppState>,
2105 Query(params): Query<LogParams>,
2106) -> Result<impl IntoResponse, Error> {
2107 let limit = params.limit.min(MAX_LOG_LIMIT);
2109 if limit == 0 {
2110 return Err(Error::bad_request("limit must be >= 1"));
2111 }
2112
2113 let repo = s.repo.lock().map_err(|_| Error::locked())?;
2114 let bs = repo.blockstore().clone();
2115 let mut cur = repo.op_id().clone();
2116
2117 match params.format {
2118 LogFormat::Json => {
2119 let mut entries: Vec<LogEntry> = Vec::with_capacity(limit);
2120 for _ in 0..limit {
2121 let (op, next) = read_op(bs.as_ref(), &cur)?;
2122 entries.push(LogEntry {
2123 op_id: cur.to_string(),
2124 timestamp: micros_to_rfc3339(op.time),
2125 author: op.author.clone(),
2126 message: op.description.clone(),
2127 parents: op.parents.iter().map(ToString::to_string).collect(),
2128 agent_id: op.agent_id.clone(),
2129 task_id: op.task_id.clone(),
2130 });
2131 match next {
2132 Some(p) => cur = p,
2133 None => break,
2134 }
2135 }
2136 let count = entries.len();
2137 Ok(Json(serde_json::json!({
2138 "schema": "mnem.v1.log",
2139 "entries": entries,
2140 "count": count,
2141 }))
2142 .into_response())
2143 }
2144
2145 LogFormat::Oneline => {
2146 let mut lines = String::new();
2147 for _ in 0..limit {
2148 let short = short_cid_str(&cur.to_string());
2149 let (op, next) = read_op(bs.as_ref(), &cur)?;
2150 lines.push_str(&format!("{short} {}\n", op.description));
2151 match next {
2152 Some(p) => cur = p,
2153 None => break,
2154 }
2155 }
2156 Ok(([(CONTENT_TYPE, "text/plain; charset=utf-8")], lines).into_response())
2157 }
2158
2159 LogFormat::Full => {
2160 let mut text = String::new();
2161 for _ in 0..limit {
2162 let op_id_str = cur.to_string();
2163 let (op, next) = read_op(bs.as_ref(), &cur)?;
2164 text.push_str(&format!("op {op_id_str}\n"));
2165 text.push_str(&format!(" time {}us\n", op.time));
2166 if !op.author.is_empty() {
2167 text.push_str(&format!(" author {}\n", op.author));
2168 }
2169 if let Some(agent) = &op.agent_id {
2170 text.push_str(&format!(" agent {agent}\n"));
2171 }
2172 if let Some(task) = &op.task_id {
2173 text.push_str(&format!(" task {task}\n"));
2174 }
2175 text.push_str(&format!(" message {}\n", op.description));
2176 text.push('\n');
2177 match next {
2178 Some(p) => cur = p,
2179 None => break,
2180 }
2181 }
2182 Ok(([(CONTENT_TYPE, "text/plain; charset=utf-8")], text).into_response())
2183 }
2184 }
2185}
2186
2187const MAX_EXPORT_OPS: usize = 10_000;
2192
2193#[derive(serde::Deserialize)]
2195pub(crate) struct ExportParams {
2196 #[serde(default)]
2198 pub limit: Option<usize>,
2199}
2200
2201pub(crate) async fn get_export(
2216 State(s): State<AppState>,
2217 Query(params): Query<ExportParams>,
2218) -> Result<impl IntoResponse, Error> {
2219 let limit = params.limit.unwrap_or(MAX_EXPORT_OPS).min(MAX_EXPORT_OPS);
2220
2221 let repo = s.repo.lock().map_err(|_| Error::locked())?;
2222 let bs = repo.blockstore().clone();
2223 let mut cur_op = repo.op_id().clone();
2224 drop(repo); let mut seen: std::collections::HashSet<mnem_core::id::Cid> = std::collections::HashSet::new();
2230 let mut blocks: Vec<(mnem_core::id::Cid, bytes::Bytes)> = Vec::new();
2231 let mut ops_walked = 0usize;
2232
2233 loop {
2234 if ops_walked >= limit {
2235 break;
2236 }
2237 ops_walked += 1;
2238
2239 let op_bytes = bs
2244 .get(&cur_op)
2245 .map_err(|e| Error::internal(format!("blockstore read: {e}")))?
2246 .ok_or_else(|| Error::internal(format!("op {cur_op} missing from store")))?;
2247 let op: Operation = from_canonical_bytes(&op_bytes)
2248 .map_err(|e| Error::internal(format!("decode op {cur_op}: {e}")))?;
2249
2250 if seen.insert(cur_op.clone()) {
2252 blocks.push((cur_op.clone(), op_bytes));
2253 }
2254
2255 for result in bs.iter_from_root(&op.view) {
2259 let (cid, data) =
2260 result.map_err(|e| Error::internal(format!("blockstore walk: {e}")))?;
2261 if seen.insert(cid.clone()) {
2262 blocks.push((cid, data));
2263 }
2264 }
2265
2266 match op.parents.first() {
2268 Some(parent) => cur_op = parent.clone(),
2269 None => break, }
2271 }
2272
2273 let mut ndjson = String::new();
2275 for (cid, data) in &blocks {
2276 let hex: String = data.iter().map(|b| format!("{b:02x}")).collect();
2278 ndjson.push_str(&format!("{{\"cid\":\"{cid}\",\"hex\":\"{hex}\"}}\n",));
2279 }
2280
2281 Ok(([(CONTENT_TYPE, "application/x-ndjson")], ndjson).into_response())
2282}
2283
2284pub(crate) async fn post_import(
2305 State(s): State<AppState>,
2306 headers: axum::http::HeaderMap,
2307 body: axum::body::Bytes,
2308) -> Result<Json<Value>, Error> {
2309 use mnem_core::store::blockstore::recompute_cid;
2310
2311 if let Some(ct) = headers.get(axum::http::header::CONTENT_TYPE) {
2314 let ct_str = ct.to_str().unwrap_or("").trim();
2315 let ct_base = ct_str.split(';').next().unwrap_or("").trim();
2317 if ct_base != "application/x-ndjson" && ct_base != "text/plain" {
2318 return Err(Error::status(
2319 axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
2320 format!(
2321 "unsupported Content-Type '{ct_base}'; expected application/x-ndjson or text/plain"
2322 ),
2323 ));
2324 }
2325 }
2326
2327 let text = std::str::from_utf8(&body)
2328 .map_err(|e| Error::bad_request(format!("request body is not valid UTF-8: {e}")))?;
2329
2330 let repo = s.repo.lock().map_err(|_| Error::locked())?;
2331 let bs = repo.blockstore().clone();
2332 drop(repo);
2333
2334 let mut imported: usize = 0;
2335 let mut errors: Vec<Value> = Vec::new();
2336
2337 for (line_no, line) in text.lines().enumerate() {
2338 let line = line.trim();
2339 if line.is_empty() {
2340 continue;
2341 }
2342
2343 let obj: Value = match serde_json::from_str(line) {
2345 Ok(v) => v,
2346 Err(e) => {
2347 errors.push(json!({
2348 "line": line_no + 1,
2349 "error": format!("JSON parse error: {e}"),
2350 }));
2351 continue;
2352 }
2353 };
2354
2355 let cid_str = match obj.get("cid").and_then(Value::as_str) {
2356 Some(s) => s,
2357 None => {
2358 errors.push(json!({
2359 "line": line_no + 1,
2360 "error": "missing or non-string \"cid\" field",
2361 }));
2362 continue;
2363 }
2364 };
2365
2366 let hex_str = match obj.get("hex").and_then(Value::as_str) {
2367 Some(s) => s,
2368 None => {
2369 errors.push(json!({
2370 "line": line_no + 1,
2371 "error": "missing or non-string \"hex\" field",
2372 }));
2373 continue;
2374 }
2375 };
2376
2377 let claimed_cid = match mnem_core::id::Cid::parse_str(cid_str) {
2379 Ok(c) => c,
2380 Err(e) => {
2381 errors.push(json!({
2382 "line": line_no + 1,
2383 "cid": cid_str,
2384 "error": format!("invalid CID: {e}"),
2385 }));
2386 continue;
2387 }
2388 };
2389
2390 if hex_str.len() % 2 != 0 {
2392 errors.push(json!({
2393 "line": line_no + 1,
2394 "cid": cid_str,
2395 "error": "hex string has odd length",
2396 }));
2397 continue;
2398 }
2399 let mut raw: Vec<u8> = Vec::with_capacity(hex_str.len() / 2);
2400 let mut parse_ok = true;
2401 for chunk in hex_str.as_bytes().chunks(2) {
2402 let hi = (chunk[0] as char).to_digit(16);
2403 let lo = (chunk[1] as char).to_digit(16);
2404 match (hi, lo) {
2405 (Some(h), Some(l)) => raw.push((h * 16 + l) as u8),
2406 _ => {
2407 errors.push(json!({
2408 "line": line_no + 1,
2409 "cid": cid_str,
2410 "error": "invalid hex character",
2411 }));
2412 parse_ok = false;
2413 break;
2414 }
2415 }
2416 }
2417 if !parse_ok {
2418 continue;
2419 }
2420
2421 let data = bytes::Bytes::from(raw);
2422
2423 if let Some(computed) = recompute_cid(&claimed_cid, &data) {
2427 if computed != claimed_cid {
2428 errors.push(json!({
2429 "line": line_no + 1,
2430 "cid": cid_str,
2431 "error": format!("CID mismatch: claimed {claimed_cid} but data hashes to {computed}"),
2432 }));
2433 continue;
2434 }
2435 }
2436
2437 match bs.put(claimed_cid, data) {
2439 Ok(()) => imported += 1,
2440 Err(e) => {
2441 errors.push(json!({
2442 "line": line_no + 1,
2443 "cid": cid_str,
2444 "error": format!("blockstore write: {e}"),
2445 }));
2446 }
2447 }
2448 }
2449
2450 let ok = errors.is_empty();
2451 Ok(Json(json!({
2452 "schema": "mnem.v1.import",
2453 "imported": imported,
2454 "errors": errors,
2455 "ok": ok,
2456 })))
2457}
2458
2459pub(crate) async fn get_branches(State(s): State<AppState>) -> Result<Json<Value>, Error> {
2472 let repo = s.repo.lock().map_err(|_| Error::locked())?;
2473 let view = repo.view();
2474 let current_head = view.heads.first().cloned();
2475
2476 let branches: Vec<Value> = view
2477 .refs
2478 .iter()
2479 .filter_map(|(name, target)| {
2480 let short = name.strip_prefix(HEADS_PREFIX)?;
2481 let (head_str, is_current) = match target {
2482 mnem_core::objects::RefTarget::Normal { target } => {
2483 let is_cur = Some(target) == current_head.as_ref();
2484 (target.to_string(), is_cur)
2485 }
2486 mnem_core::objects::RefTarget::Conflicted { .. } => {
2487 (String::new(), false)
2490 }
2491 };
2492 Some(json!({
2493 "name": short,
2494 "head": head_str,
2495 "is_current": is_current,
2496 }))
2497 })
2498 .collect();
2499
2500 Ok(Json(json!({
2501 "schema": "mnem.v1.branches",
2502 "branches": branches,
2503 })))
2504}
2505
2506#[derive(Deserialize)]
2510pub(crate) struct CreateBranchBody {
2511 pub name: String,
2514 #[serde(default)]
2517 pub at: Option<String>,
2518 pub author: String,
2520}
2521
2522pub(crate) async fn post_branch(
2532 State(s): State<AppState>,
2533 Json(body): Json<CreateBranchBody>,
2534) -> Result<Json<Value>, Error> {
2535 if body.name.trim().is_empty() {
2536 return Err(Error::bad_request("name is required"));
2537 }
2538 if body.name.len() > 255 {
2539 return Err(Error::bad_request(
2540 "branch name exceeds maximum length of 255 characters",
2541 ));
2542 }
2543 if body.author.trim().is_empty() {
2544 return Err(Error::bad_request("author is required"));
2545 }
2546 let n = &body.name;
2548 if n.contains(' ')
2549 || n.contains('\t')
2550 || n.contains('\n')
2551 || n.contains('\x00')
2552 || n.contains('~')
2553 || n.contains('^')
2554 || n.contains(':')
2555 || n.contains('?')
2556 || n.contains('*')
2557 || n.contains('[')
2558 || n.contains('\\')
2559 || n.contains("@{")
2560 || n.contains("..")
2561 || n.contains("//")
2562 || n.starts_with('/')
2563 || n.ends_with('/')
2564 || n.ends_with('.')
2565 || n.ends_with(".lock")
2566 {
2567 return Err(Error::bad_request(format!(
2568 "invalid branch name `{n}`: may not contain spaces, control characters, \
2569 `~`, `^`, `:`, `?`, `*`, `[`, `\\`, `@{{`, `..`, `//`, \
2570 or start/end with `/`, or end with `.` or `.lock`"
2571 )));
2572 }
2573
2574 let full = format!("{HEADS_PREFIX}{}", body.name);
2575
2576 let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
2577
2578 if guard.view().refs.contains_key(&full) {
2579 return Err(Error::conflict(format!(
2580 "branch `{}` already exists",
2581 body.name
2582 )));
2583 }
2584
2585 let target_cid = match body.at.as_deref() {
2587 Some(cid_str) => {
2588 let cid = mnem_core::id::Cid::parse_str(cid_str)
2589 .map_err(|e| Error::bad_request(format!("invalid CID `{cid_str}`: {e}")))?;
2590 let bs = guard.blockstore().clone();
2592 let bytes = bs
2593 .get(&cid)
2594 .map_err(|e| Error::internal(format!("blockstore error: {e}")))?
2595 .ok_or_else(|| {
2596 Error::not_found(format!("block {cid_str} not found in blockstore"))
2597 })?;
2598 if from_canonical_bytes::<Commit>(&bytes).is_err() {
2599 return Err(Error::bad_request(format!(
2600 "`{cid_str}` does not decode as a commit; \
2601 use a commit CID (not an op CID)"
2602 )));
2603 }
2604 cid
2605 }
2606 None => guard.view().heads.first().cloned().ok_or_else(|| {
2607 Error::bad_request(
2608 "repository has no commits yet; pass `at` with a commit CID".to_string(),
2609 )
2610 })?,
2611 };
2612
2613 let head_str = target_cid.to_string();
2614 let new_repo = guard
2615 .update_ref(
2616 &full,
2617 None,
2618 Some(mnem_core::objects::RefTarget::normal(target_cid)),
2619 &body.author,
2620 )
2621 .map_err(Error::from)?;
2622 let op_id = new_repo.op_id().to_string();
2623 *guard = new_repo;
2624
2625 Ok(Json(json!({
2626 "schema": "mnem.v1.branch-create",
2627 "name": body.name,
2628 "head": head_str,
2629 "op_id": op_id,
2630 "created": true,
2631 })))
2632}
2633
2634pub(crate) async fn delete_branch(
2647 State(s): State<AppState>,
2648 Path(name): Path<String>,
2649 Query(q): Query<DeleteQuery>,
2650) -> Result<Json<Value>, Error> {
2651 if name.trim().is_empty() {
2652 return Err(Error::bad_request("branch name must not be empty"));
2653 }
2654 if q.author.trim().is_empty() {
2655 return Err(Error::bad_request("author is required"));
2656 }
2657
2658 let full = format!("{HEADS_PREFIX}{name}");
2659
2660 let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
2661 let view = guard.view();
2662
2663 let prev = view
2664 .refs
2665 .get(&full)
2666 .cloned()
2667 .ok_or_else(|| Error::not_found(format!("branch `{name}` does not exist")))?;
2668
2669 let current_head = view.heads.first().cloned();
2671 if let mnem_core::objects::RefTarget::Normal { target } = &prev {
2672 if Some(target) == current_head.as_ref() {
2673 return Err(Error::conflict(format!(
2674 "cannot delete branch `{name}`: it is the current branch (points at HEAD)"
2675 )));
2676 }
2677 }
2678
2679 let new_repo = guard
2680 .update_ref(&full, Some(&prev), None, &q.author)
2681 .map_err(Error::from)?;
2682 let op_id = new_repo.op_id().to_string();
2683 *guard = new_repo;
2684
2685 Ok(Json(json!({
2686 "schema": "mnem.v1.branch-delete",
2687 "deleted": name,
2688 "op_id": op_id,
2689 })))
2690}
2691
2692pub(crate) async fn get_tags(State(s): State<AppState>) -> Result<Json<Value>, Error> {
2704 let repo = s.repo.lock().map_err(|_| Error::locked())?;
2705 let view = repo.view();
2706
2707 let tags: Vec<Value> = view
2708 .refs
2709 .iter()
2710 .filter_map(|(name, target)| {
2711 let short = name.strip_prefix(TAGS_PREFIX)?;
2712 let target_str = match target {
2713 mnem_core::objects::RefTarget::Normal { target } => target.to_string(),
2714 mnem_core::objects::RefTarget::Conflicted { .. } => String::new(),
2715 };
2716 Some(json!({
2717 "name": short,
2718 "target": target_str,
2719 }))
2720 })
2721 .collect();
2722
2723 Ok(Json(json!({
2724 "schema": "mnem.v1.tags",
2725 "tags": tags,
2726 })))
2727}
2728
2729#[derive(Deserialize)]
2733pub(crate) struct CreateTagBody {
2734 pub name: String,
2736 #[serde(default)]
2739 pub target: Option<String>,
2740 pub author: String,
2742}
2743
2744pub(crate) async fn post_tag(
2756 State(s): State<AppState>,
2757 Json(body): Json<CreateTagBody>,
2758) -> Result<Json<Value>, Error> {
2759 if body.name.trim().is_empty() {
2760 return Err(Error::bad_request("name is required"));
2761 }
2762 if body.name.len() > 255 {
2763 return Err(Error::bad_request(
2764 "tag name exceeds maximum length of 255 characters",
2765 ));
2766 }
2767 if body.author.trim().is_empty() {
2768 return Err(Error::bad_request("author is required"));
2769 }
2770 let n = &body.name;
2772 if n.contains(' ')
2773 || n.contains('\t')
2774 || n.contains('\n')
2775 || n.contains('\x00')
2776 || n.contains('~')
2777 || n.contains('^')
2778 || n.contains(':')
2779 || n.contains('?')
2780 || n.contains('*')
2781 || n.contains('[')
2782 || n.contains('\\')
2783 || n.contains("@{")
2784 || n.contains("..")
2785 || n.contains("//")
2786 || n.starts_with('/')
2787 || n.ends_with('/')
2788 || n.ends_with('.')
2789 || n.ends_with(".lock")
2790 {
2791 return Err(Error::bad_request(format!(
2792 "invalid tag name `{n}`: may not contain spaces, control characters, \
2793 `~`, `^`, `:`, `?`, `*`, `[`, `\\`, `@{{`, `..`, `//`, \
2794 or start/end with `/`, or end with `.` or `.lock`"
2795 )));
2796 }
2797
2798 let full = format!("{TAGS_PREFIX}{}", body.name);
2799
2800 let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
2801
2802 if guard.view().refs.contains_key(&full) {
2803 return Err(Error::conflict(format!(
2804 "tag `{}` already exists",
2805 body.name
2806 )));
2807 }
2808
2809 let target_cid = match body.target.as_deref() {
2811 Some(cid_str) => {
2812 let cid = mnem_core::id::Cid::parse_str(cid_str)
2813 .map_err(|e| Error::bad_request(format!("invalid CID `{cid_str}`: {e}")))?;
2814 let bs = guard.blockstore().clone();
2816 let bytes = bs
2817 .get(&cid)
2818 .map_err(|e| Error::internal(format!("blockstore error: {e}")))?
2819 .ok_or_else(|| {
2820 Error::not_found(format!("block `{cid}` not found in blockstore"))
2821 })?;
2822 if from_canonical_bytes::<Commit>(&bytes).is_err() {
2823 return Err(Error::bad_request(format!(
2824 "`{cid_str}` does not decode as a commit; \
2825 use a commit CID (not an op CID)"
2826 )));
2827 }
2828 cid
2829 }
2830 None => guard.view().heads.first().cloned().ok_or_else(|| {
2831 Error::bad_request(
2832 "repository has no commits yet; pass `target` with a commit CID".to_string(),
2833 )
2834 })?,
2835 };
2836
2837 let target_str = target_cid.to_string();
2838 let new_repo = guard
2839 .update_ref(
2840 &full,
2841 None,
2842 Some(mnem_core::objects::RefTarget::normal(target_cid)),
2843 &body.author,
2844 )
2845 .map_err(Error::from)?;
2846 let op_id = new_repo.op_id().to_string();
2847 *guard = new_repo;
2848
2849 Ok(Json(json!({
2850 "schema": "mnem.v1.tag-create",
2851 "name": body.name,
2852 "target": target_str,
2853 "op_id": op_id,
2854 "created": true,
2855 })))
2856}
2857
2858pub(crate) async fn delete_tag(
2870 State(s): State<AppState>,
2871 Path(name): Path<String>,
2872 Query(q): Query<DeleteQuery>,
2873) -> Result<Json<Value>, Error> {
2874 if name.trim().is_empty() {
2875 return Err(Error::bad_request("tag name must not be empty"));
2876 }
2877 if q.author.trim().is_empty() {
2878 return Err(Error::bad_request("author is required"));
2879 }
2880
2881 let full = format!("{TAGS_PREFIX}{name}");
2882
2883 let mut guard = s.repo.lock().map_err(|_| Error::locked())?;
2884 let view = guard.view();
2885
2886 let prev = view
2887 .refs
2888 .get(&full)
2889 .cloned()
2890 .ok_or_else(|| Error::not_found(format!("tag `{name}` does not exist")))?;
2891
2892 let new_repo = guard
2893 .update_ref(&full, Some(&prev), None, &q.author)
2894 .map_err(Error::from)?;
2895 let op_id = new_repo.op_id().to_string();
2896 *guard = new_repo;
2897
2898 Ok(Json(json!({
2899 "schema": "mnem.v1.tag-delete",
2900 "deleted": name,
2901 "op_id": op_id,
2902 })))
2903}
2904
2905const DIFF_DEFAULT_LIMIT: usize = 500;
2910
2911const DIFF_MAX_LIMIT: usize = 2_000;
2913
2914#[derive(Deserialize, Default)]
2916pub(crate) struct DiffQueryParams {
2917 #[serde(default)]
2920 pub limit: Option<usize>,
2921}
2922
2923#[derive(Deserialize)]
2925pub(crate) struct DiffBody {
2926 pub from: String,
2928 pub to: String,
2930}
2931
2932fn resolve_cid_to_commit(
2940 bs: &dyn mnem_core::store::Blockstore,
2941 cid_str: &str,
2942) -> Result<(mnem_core::id::Cid, Commit), Error> {
2943 let cid = mnem_core::id::Cid::parse_str(cid_str)
2944 .map_err(|e| Error::bad_request(format!("invalid CID `{cid_str}`: {e}")))?;
2945 let bytes = bs
2946 .get(&cid)
2947 .map_err(|e| Error::internal(format!("blockstore error: {e}")))?
2948 .ok_or_else(|| Error::not_found(format!("block `{cid_str}` not found in blockstore")))?;
2949
2950 if let Ok(op) = from_canonical_bytes::<Operation>(&bytes) {
2952 let view_bytes = bs
2954 .get(&op.view)
2955 .map_err(|e| Error::internal(format!("blockstore error reading view: {e}")))?
2956 .ok_or_else(|| {
2957 Error::internal(format!("view block {} missing from blockstore", op.view))
2958 })?;
2959 let view: mnem_core::objects::View = from_canonical_bytes(&view_bytes)
2960 .map_err(|e| Error::internal(format!("decode view: {e}")))?;
2961 let commit_cid = view
2962 .heads
2963 .into_iter()
2964 .next()
2965 .ok_or_else(|| Error::bad_request(format!("op `{cid_str}` has no head commits")))?;
2966 let commit_bytes = bs
2967 .get(&commit_cid)
2968 .map_err(|e| Error::internal(format!("blockstore error reading commit: {e}")))?
2969 .ok_or_else(|| {
2970 Error::not_found(format!(
2971 "commit block {} (from op `{cid_str}`) not found in blockstore",
2972 commit_cid
2973 ))
2974 })?;
2975 let commit: Commit = from_canonical_bytes(&commit_bytes)
2976 .map_err(|e| Error::internal(format!("decode commit: {e}")))?;
2977 return Ok((commit_cid, commit));
2978 }
2979
2980 if let Ok(commit) = from_canonical_bytes::<Commit>(&bytes) {
2982 return Ok((cid, commit));
2983 }
2984
2985 Err(Error::bad_request(format!(
2986 "`{cid_str}` does not decode as an op or commit CID"
2987 )))
2988}
2989
2990pub(crate) async fn post_diff(
3000 State(s): State<AppState>,
3001 Query(params): Query<DiffQueryParams>,
3002 Json(body): Json<DiffBody>,
3003) -> Result<Json<Value>, Error> {
3004 let limit = params
3006 .limit
3007 .unwrap_or(DIFF_DEFAULT_LIMIT)
3008 .min(DIFF_MAX_LIMIT);
3009 if limit == 0 {
3010 return Err(Error::bad_request("limit must be >= 1"));
3011 }
3012
3013 let repo = s.repo.lock().map_err(|_| Error::locked())?;
3014 let bs = repo.blockstore().clone();
3015 drop(repo); let (from_cid, from_commit) = resolve_cid_to_commit(bs.as_ref(), &body.from)?;
3018 let (to_cid, to_commit) = resolve_cid_to_commit(bs.as_ref(), &body.to)?;
3019
3020 let node_changes = mnem_core::prolly::diff(bs.as_ref(), &from_commit.nodes, &to_commit.nodes)
3022 .map_err(|e| Error::internal(format!("node diff failed: {e}")))?;
3023
3024 let edge_changes = mnem_core::prolly::diff(bs.as_ref(), &from_commit.edges, &to_commit.edges)
3026 .map_err(|e| Error::internal(format!("edge diff failed: {e}")))?;
3027
3028 let mut nodes_added: Vec<Value> = Vec::new();
3030 let mut nodes_removed: Vec<Value> = Vec::new();
3031 let mut nodes_changed: Vec<Value> = Vec::new();
3032
3033 for entry in &node_changes {
3034 match entry {
3035 mnem_core::prolly::DiffEntry::Added { value, .. } => {
3036 if nodes_added.len() < limit {
3037 if let Some(node) = node_from_bs(bs.as_ref(), value) {
3038 nodes_added.push(json!({
3039 "id": node.id.to_uuid_string(),
3040 "ntype": node.ntype,
3041 "summary": node.summary,
3042 }));
3043 }
3044 }
3045 }
3046 mnem_core::prolly::DiffEntry::Removed { value, .. } => {
3047 if nodes_removed.len() < limit {
3048 if let Some(node) = node_from_bs(bs.as_ref(), value) {
3049 nodes_removed.push(json!({
3050 "id": node.id.to_uuid_string(),
3051 "ntype": node.ntype,
3052 "summary": node.summary,
3053 }));
3054 }
3055 }
3056 }
3057 mnem_core::prolly::DiffEntry::Changed { before, after, .. } => {
3058 if nodes_changed.len() < limit {
3059 if let Some(after_node) = node_from_bs(bs.as_ref(), after) {
3060 let before_val = node_from_bs(bs.as_ref(), before).map(|n| {
3061 json!({
3062 "id": n.id.to_uuid_string(),
3063 "ntype": n.ntype,
3064 "summary": n.summary,
3065 })
3066 });
3067 nodes_changed.push(json!({
3068 "id": after_node.id.to_uuid_string(),
3069 "before": before_val,
3070 "after": {
3071 "id": after_node.id.to_uuid_string(),
3072 "ntype": after_node.ntype,
3073 "summary": after_node.summary,
3074 },
3075 }));
3076 }
3077 }
3078 }
3079 }
3080 }
3081
3082 let mut edges_added: Vec<Value> = Vec::new();
3084 let mut edges_removed: Vec<Value> = Vec::new();
3085
3086 for entry in &edge_changes {
3087 match entry {
3088 mnem_core::prolly::DiffEntry::Added { value, .. } => {
3089 if edges_added.len() < limit {
3090 if let Some(edge) = edge_from_bs(bs.as_ref(), value) {
3091 edges_added.push(json!({
3092 "id": edge.id.to_uuid_string(),
3093 "etype": edge.etype,
3094 "src": edge.src.to_uuid_string(),
3095 "dst": edge.dst.to_uuid_string(),
3096 }));
3097 }
3098 }
3099 }
3100 mnem_core::prolly::DiffEntry::Removed { value, .. } => {
3101 if edges_removed.len() < limit {
3102 if let Some(edge) = edge_from_bs(bs.as_ref(), value) {
3103 edges_removed.push(json!({
3104 "id": edge.id.to_uuid_string(),
3105 "etype": edge.etype,
3106 "src": edge.src.to_uuid_string(),
3107 "dst": edge.dst.to_uuid_string(),
3108 }));
3109 }
3110 }
3111 }
3112 mnem_core::prolly::DiffEntry::Changed { .. } => {
3113 }
3117 }
3118 }
3119
3120 Ok(Json(json!({
3121 "schema": "mnem.v1.diff",
3122 "from": from_cid.to_string(),
3123 "to": to_cid.to_string(),
3124 "nodes": {
3125 "added": nodes_added,
3126 "removed": nodes_removed,
3127 "changed": nodes_changed,
3128 },
3129 "edges": {
3130 "added": edges_added,
3131 "removed": edges_removed,
3132 "changed": [],
3133 },
3134 })))
3135}
3136
3137#[derive(Deserialize, Default)]
3141#[serde(rename_all = "lowercase")]
3142pub(crate) enum BlockFormat {
3143 #[default]
3145 Json,
3146 Raw,
3148 Cbor,
3150}
3151
3152#[derive(Deserialize, Default)]
3154pub(crate) struct BlockParams {
3155 #[serde(default)]
3157 pub format: BlockFormat,
3158}
3159
3160pub(crate) async fn get_block(
3176 State(s): State<AppState>,
3177 Path(cid_str): Path<String>,
3178 Query(params): Query<BlockParams>,
3179) -> Result<impl IntoResponse, Error> {
3180 let cid = mnem_core::id::Cid::parse_str(&cid_str)
3182 .map_err(|e| Error::bad_request(format!("invalid CID `{cid_str}`: {e}")))?;
3183
3184 let repo = s.repo.lock().map_err(|_| Error::locked())?;
3187 let bs = repo.blockstore().clone();
3188 drop(repo);
3189
3190 let data = bs
3192 .get(&cid)
3193 .map_err(|e| Error::internal(format!("blockstore read: {e}")))?
3194 .ok_or_else(|| Error::not_found(format!("block `{cid_str}` not found in store")))?;
3195
3196 match params.format {
3197 BlockFormat::Cbor => {
3198 Ok(([(CONTENT_TYPE, "application/cbor")], data.to_vec()).into_response())
3200 }
3201 BlockFormat::Raw => {
3202 let hex: String = data.iter().map(|b| format!("{b:02x}")).collect();
3204 Ok(Json(json!({
3205 "schema": "mnem.v1.block",
3206 "cid": cid.to_string(),
3207 "format": "raw",
3208 "hex": hex,
3209 }))
3210 .into_response())
3211 }
3212 BlockFormat::Json => {
3213 match from_canonical_bytes::<Ipld>(&data) {
3215 Ok(ipld) => Ok(Json(json!({
3216 "schema": "mnem.v1.block",
3217 "cid": cid.to_string(),
3218 "format": "json",
3219 "data": ipld_to_json(&ipld),
3220 }))
3221 .into_response()),
3222 Err(e) => Ok(Json(json!({
3223 "schema": "mnem.v1.block",
3224 "cid": cid.to_string(),
3225 "format": "json",
3226 "data": Value::Null,
3227 "error": format!("decode failed: {e}"),
3228 }))
3229 .into_response()),
3230 }
3231 }
3232 }
3233}
3234
3235fn node_from_bs(bs: &dyn mnem_core::store::Blockstore, cid: &mnem_core::id::Cid) -> Option<Node> {
3238 let bytes = bs.get(cid).ok()??;
3239 from_canonical_bytes::<Node>(&bytes).ok()
3240}
3241
3242fn edge_from_bs(
3245 bs: &dyn mnem_core::store::Blockstore,
3246 cid: &mnem_core::id::Cid,
3247) -> Option<mnem_core::objects::Edge> {
3248 let bytes = bs.get(cid).ok()??;
3249 from_canonical_bytes::<mnem_core::objects::Edge>(&bytes).ok()
3250}
3251
3252#[derive(Deserialize, Default)]
3256#[serde(rename_all = "snake_case")]
3257pub(crate) enum MergeStrategyParam {
3258 #[default]
3259 Manual,
3260 Ours,
3261 Theirs,
3262}
3263
3264#[derive(Deserialize)]
3266pub(crate) struct MergeBody {
3267 pub left: String,
3269 pub right: String,
3271 #[serde(default)]
3273 pub strategy: MergeStrategyParam,
3274}
3275
3276pub(crate) async fn post_merge(
3287 State(s): State<AppState>,
3288 Json(body): Json<MergeBody>,
3289) -> Result<Json<Value>, Error> {
3290 use mnem_core::repo::merge::{MergeOutcome, MergeStrategy, merge_three_way};
3291 use mnem_core::store::MemoryOpHeadsStore;
3292
3293 let repo = s.repo.lock().map_err(|_| Error::locked())?;
3294 let bs = repo.blockstore().clone();
3295 drop(repo); if body.left == body.right {
3300 return Err(Error::bad_request(
3301 "left and right must be different commit CIDs",
3302 ));
3303 }
3304
3305 let (left_cid, _) = resolve_cid_to_commit(bs.as_ref(), &body.left)?;
3306 let (right_cid, _) = resolve_cid_to_commit(bs.as_ref(), &body.right)?;
3307
3308 let strategy = match body.strategy {
3309 MergeStrategyParam::Manual => MergeStrategy::Manual,
3310 MergeStrategyParam::Ours => MergeStrategy::Ours,
3311 MergeStrategyParam::Theirs => MergeStrategy::Theirs,
3312 };
3313
3314 let dummy_ohs: std::sync::Arc<dyn mnem_core::store::OpHeadsStore> =
3316 std::sync::Arc::new(MemoryOpHeadsStore::new());
3317 let outcome = merge_three_way(&bs, &dummy_ohs, left_cid, right_cid, strategy).map_err(|e| {
3318 use mnem_core::error::RepoError;
3321 match &e {
3322 mnem_core::error::Error::Repo(RepoError::NoCommonAncestor) => Error::bad_request(
3323 "left and right commits share no common ancestor; \
3324 cannot merge unrelated histories",
3325 ),
3326 _ => Error::internal(format!("merge failed: {e}")),
3327 }
3328 })?;
3329
3330 let response = match outcome {
3331 MergeOutcome::FastForward(cid) => json!({
3332 "status": "fast_forward",
3333 "commit": cid.to_string(),
3334 }),
3335 MergeOutcome::Clean(cid) => json!({
3336 "status": "clean",
3337 "commit": cid.to_string(),
3338 }),
3339 MergeOutcome::Conflicts(conflicts) => json!({
3340 "status": "conflicts",
3341 "conflicts": conflicts,
3342 }),
3343 };
3344
3345 Ok(Json(response))
3346}
3347
3348#[cfg(test)]
3349mod gap01_tests {
3350 use super::*;
3351 use mnem_core::id::NodeId;
3352 use mnem_core::objects::Node;
3353 use mnem_core::retrieve::RetrievedItem;
3354 use proptest::prelude::*;
3355
3356 fn fake_item(score: f32) -> RetrievedItem {
3357 let node = Node::new(NodeId::new_v7(), "Gap01Probe");
3360 RetrievedItem::new(node, "rendered preview".to_string(), 4, score)
3361 }
3362
3363 #[test]
3364 fn confidence_zero_on_empty() {
3365 assert_eq!(gap01_compute_confidence(&[]), 0.0);
3366 }
3367
3368 #[test]
3369 fn confidence_zero_on_singleton() {
3370 assert_eq!(gap01_compute_confidence(&[fake_item(1.0)]), 0.0);
3371 }
3372
3373 #[test]
3374 fn confidence_high_when_tail_far_below_top() {
3375 let items = vec![fake_item(1.0), fake_item(0.9), fake_item(0.01)];
3376 let c = gap01_compute_confidence(&items);
3377 assert!(c > 0.9, "expected >0.9, got {c}");
3378 }
3379
3380 #[test]
3381 fn confidence_low_when_flat() {
3382 let items = vec![fake_item(1.0), fake_item(0.99), fake_item(0.98)];
3383 let c = gap01_compute_confidence(&items);
3384 assert!(c < 0.1, "expected <0.1, got {c}");
3385 }
3386
3387 #[test]
3388 fn suggested_neighbors_empty_below_top_seeds() {
3389 let items = vec![fake_item(1.0), fake_item(0.9), fake_item(0.8)];
3390 assert!(gap01_suggested_neighbors(&items).is_empty());
3391 }
3392
3393 #[test]
3394 fn suggested_neighbors_skips_top_seeds() {
3395 let items = vec![
3396 fake_item(1.0),
3397 fake_item(0.9),
3398 fake_item(0.8),
3399 fake_item(0.7),
3400 fake_item(0.6),
3401 ];
3402 let n = gap01_suggested_neighbors(&items);
3403 assert_eq!(n.len(), 2);
3404 for entry in &n {
3406 assert_eq!(entry["via"], "adjacency");
3407 }
3408 }
3409
3410 #[test]
3411 fn suggested_neighbors_bounded_by_max() {
3412 let items: Vec<_> = (0..100).map(|i| fake_item(1.0 - i as f32 * 0.01)).collect();
3413 let n = gap01_suggested_neighbors(&items);
3414 assert!(n.len() <= GAP01_MAX_NEIGHBOURS);
3415 }
3416
3417 proptest! {
3418 #[test]
3426 fn suggested_neighbors_always_subset_of_adjacency(
3427 scores in proptest::collection::vec(-1.0f32..1.0f32, 0..32),
3428 ) {
3429 let items: Vec<_> = scores.iter().map(|&s| fake_item(s)).collect();
3430 let neighbours = gap01_suggested_neighbors(&items);
3431 let ids: Vec<String> = items
3434 .iter()
3435 .map(|it| it.node.id.to_uuid_string())
3436 .collect();
3437 for entry in &neighbours {
3438 let nid = entry["id"].as_str().expect("id field");
3439 prop_assert!(
3440 ids.iter().any(|i| i == nid),
3441 "neighbour id {nid} not in adjacency"
3442 );
3443 }
3444 prop_assert!(neighbours.len() <= GAP01_MAX_NEIGHBOURS);
3446 }
3447 }
3448}
3449
3450#[cfg(test)]
3451mod tests {
3452 use super::*;
3453
3454 fn check(days: u64, expected_year: u64, expected_month: u8, expected_day: u8) {
3455 let (y, m, d) = days_to_ymd(days);
3456 assert_eq!(
3457 (y, m, d),
3458 (expected_year, expected_month, expected_day),
3459 "days_to_ymd({days}) = ({y},{m},{d}), want ({expected_year},{expected_month},{expected_day})"
3460 );
3461 }
3462
3463 #[test]
3464 fn epoch() {
3465 check(0, 1970, 1, 1);
3466 }
3467
3468 #[test]
3469 fn epoch_plus_one() {
3470 check(1, 1970, 1, 2);
3471 }
3472
3473 #[test]
3474 fn start_of_february_1970() {
3475 check(31, 1970, 2, 1);
3476 }
3477
3478 #[test]
3479 fn start_of_march_1970_non_leap() {
3480 check(59, 1970, 3, 1);
3481 }
3482
3483 #[test]
3484 fn second_year() {
3485 check(365, 1971, 1, 1);
3486 }
3487
3488 #[test]
3489 fn year_2000_leap_day() {
3490 check(11016, 2000, 2, 29);
3491 }
3492
3493 #[test]
3494 fn year_2000_day_before_leap() {
3495 check(11015, 2000, 2, 28);
3496 }
3497
3498 #[test]
3499 fn year_2000_day_after_leap() {
3500 check(11017, 2000, 3, 1);
3501 }
3502
3503 #[test]
3504 fn year_2024_leap_day() {
3505 check(19782, 2024, 2, 29);
3506 }
3507
3508 #[test]
3509 fn year_1972_leap_day() {
3510 check(789, 1972, 2, 29);
3511 }
3512
3513 #[test]
3514 fn year_2024_day_before_leap() {
3515 check(19781, 2024, 2, 28);
3516 }
3517
3518 #[test]
3519 fn year_2024_day_after_leap() {
3520 check(19783, 2024, 3, 1);
3521 }
3522
3523 #[test]
3524 fn december_year_end_1970() {
3525 check(364, 1970, 12, 31);
3526 }
3527
3528 #[test]
3529 fn year_2100_is_not_leap_feb_28() {
3530 check(47540, 2100, 2, 28);
3531 }
3532
3533 #[test]
3534 fn year_2100_is_not_leap_next_day_is_march() {
3535 check(47541, 2100, 3, 1);
3536 }
3537
3538 #[test]
3539 fn year_2100_start() {
3540 check(47482, 2100, 1, 1);
3541 }
3542}