1use std::collections::HashMap;
41
42use serde_json::Value;
43use uuid::Uuid;
44
45use khive_storage::note::Note;
46use khive_storage::types::SqlValue;
47use khive_storage::{SqlStatement, StorageCapability, StorageError};
48use khive_types::SubstrateKind;
49
50use crate::atomic_plan::{AddNotePlan, AffectedRowGuard, PlanStatement, PostCommitEffect};
51use crate::atomic_runner::{AtomicOpPlan, AtomicRunOutcome};
52use crate::config::NamespaceToken;
53use crate::curation::note_fts_document;
54use crate::error::{RuntimeError, RuntimeResult};
55use crate::runtime::KhiveRuntime;
56
57pub struct AtomicNoteSpec<'a> {
63 pub token: &'a NamespaceToken,
65 pub id: Option<Uuid>,
70 pub kind: &'a str,
71 pub name: Option<&'a str>,
72 pub content: &'a str,
73 pub properties: Option<Value>,
74}
75
76#[derive(Clone, Copy, Default)]
77pub(crate) struct AtomicNoteOptions<'a> {
78 pub salience: Option<f64>,
79 pub decay_factor: Option<f64>,
80 pub embedding_model: Option<&'a str>,
81 pub embedding_content: Option<&'a str>,
82 pub embed: Option<bool>,
83 pub key: Option<&'a str>,
84 pub memory_visibility_receipt: bool,
87 pub replay_receipt: bool,
92 pub fence: Option<&'a crate::note_write::NoteFences>,
93 pub properties_already_derived: bool,
103}
104
105pub(crate) struct AtomicNoteRequest<'a> {
106 pub spec: AtomicNoteSpec<'a>,
107 pub options: AtomicNoteOptions<'a>,
108}
109
110pub(crate) struct PreparedAtomicNotes {
111 pub notes: Vec<Note>,
112 pub plans: Vec<AtomicOpPlan>,
113 pub embedding_truncation: crate::retrieval::EmbeddingTruncationReport,
114}
115
116fn f32_vec_to_bytes(data: &[f32]) -> Vec<u8> {
123 let mut bytes = Vec::with_capacity(data.len() * 4);
124 for f in data {
125 bytes.extend_from_slice(&f.to_ne_bytes());
126 }
127 bytes
128}
129
130fn non_finite_index(data: &[f32]) -> Option<usize> {
135 data.iter().position(|v| !v.is_finite())
136}
137
138pub(crate) fn non_finite_vector_error(idx: usize, value: f32) -> RuntimeError {
143 RuntimeError::Storage(StorageError::InvalidInput {
144 capability: StorageCapability::Vectors,
145 operation: "vec_insert".into(),
146 message: format!(
147 "non-finite value at index {idx}: {value} \
148 (NaN/Inf values corrupt distance computations)"
149 ),
150 })
151}
152
153#[cfg(any(test, feature = "fault-injection"))]
166fn injected_failure_statement(label: &str) -> PlanStatement {
167 PlanStatement {
168 statement: SqlStatement {
169 sql: "UPDATE notes SET updated_at = updated_at WHERE 1 = 0".to_string(),
170 params: vec![],
171 label: Some(label.to_string()),
172 },
173 guard: Some(AffectedRowGuard::exactly(1)),
174 }
175}
176
177#[cfg(any(test, feature = "fault-injection"))]
178fn maybe_inject_fts_failure(namespace: &str, label: &str) -> Option<PlanStatement> {
179 crate::operations::consume_fts_fail_fault(namespace).then(|| injected_failure_statement(label))
180}
181#[cfg(not(any(test, feature = "fault-injection")))]
182fn maybe_inject_fts_failure(_namespace: &str, _label: &str) -> Option<PlanStatement> {
183 None
184}
185
186#[cfg(any(test, feature = "fault-injection"))]
187fn maybe_inject_vector_failure(namespace: &str, label: &str) -> Option<PlanStatement> {
188 crate::operations::consume_vector_fail_fault(namespace)
189 .then(|| injected_failure_statement(label))
190}
191#[cfg(not(any(test, feature = "fault-injection")))]
192fn maybe_inject_vector_failure(_namespace: &str, _label: &str) -> Option<PlanStatement> {
193 None
194}
195
196#[allow(clippy::too_many_arguments)]
206pub(crate) fn vector_insert_statements(
207 table: &str,
208 namespace: &str,
209 subject_id: Uuid,
210 field: &str,
211 embedding_model: &str,
212 embedding: &[f32],
213 label_prefix: &str,
214) -> Vec<PlanStatement> {
215 let subject = subject_id.to_string();
216 let model_key = table
217 .strip_prefix("vec_")
218 .expect("runtime vector tables use the vec_ prefix");
219 let kind_str = SubstrateKind::Note.to_string();
220 let blob = f32_vec_to_bytes(embedding);
221 vec![
222 PlanStatement {
225 statement: SqlStatement {
226 sql: format!(
227 "INSERT INTO ann_write_log \
228 (namespace, embedding_model, kind, field, subject_id, op) \
229 SELECT namespace, embedding_model, kind, field, subject_id, 'delete' \
230 FROM {table} WHERE subject_id = ?1 AND NOT \
231 (namespace = ?2 AND embedding_model = ?3 AND kind = ?4 AND field = ?5)"
232 ),
233 params: vec![
234 SqlValue::Text(subject.clone()),
235 SqlValue::Text(namespace.to_string()),
236 SqlValue::Text(embedding_model.to_string()),
237 SqlValue::Text(kind_str.clone()),
238 SqlValue::Text(field.to_string()),
239 ],
240 label: Some(format!("{label_prefix}-log-delete")),
241 },
242 guard: None,
243 },
244 PlanStatement {
245 statement: SqlStatement {
246 sql: format!("DELETE FROM {table} WHERE subject_id = ?1"),
247 params: vec![SqlValue::Text(subject.clone())],
248 label: Some(format!("{label_prefix}-delete")),
249 },
250 guard: None,
251 },
252 PlanStatement {
255 statement: SqlStatement {
256 sql: "DELETE FROM vector_provenance \
257 WHERE model_key = ?1 AND subject_id = ?2"
258 .to_string(),
259 params: vec![
260 SqlValue::Text(model_key.to_string()),
261 SqlValue::Text(subject.clone()),
262 ],
263 label: Some(format!("{label_prefix}-clear-provenance")),
264 },
265 guard: None,
266 },
267 PlanStatement {
268 statement: SqlStatement {
269 sql: format!(
270 "INSERT INTO {table} \
271 (subject_id, namespace, kind, field, embedding_model, embedding) \
272 VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
273 ),
274 params: vec![
275 SqlValue::Text(subject.clone()),
276 SqlValue::Text(namespace.to_string()),
277 SqlValue::Text(kind_str.clone()),
278 SqlValue::Text(field.to_string()),
279 SqlValue::Text(embedding_model.to_string()),
280 SqlValue::Blob(blob),
281 ],
282 label: Some(format!("{label_prefix}-insert")),
283 },
284 guard: None,
285 },
286 PlanStatement {
287 statement: SqlStatement {
288 sql: "INSERT INTO ann_write_log \
289 (namespace, embedding_model, kind, field, subject_id, op) \
290 VALUES (?1, ?2, ?3, ?4, ?5, 'upsert')"
291 .to_string(),
292 params: vec![
293 SqlValue::Text(namespace.to_string()),
294 SqlValue::Text(embedding_model.to_string()),
295 SqlValue::Text(kind_str),
296 SqlValue::Text(field.to_string()),
297 SqlValue::Text(subject),
298 ],
299 label: Some(format!("{label_prefix}-log-upsert")),
300 },
301 guard: None,
302 },
303 ]
304}
305
306#[cfg(test)]
313pub(crate) async fn create_notes_atomic(
314 runtime: &KhiveRuntime,
315 specs: Vec<AtomicNoteSpec<'_>>,
316) -> RuntimeResult<Vec<Note>> {
317 Ok(create_notes_atomic_with_report(runtime, specs).await?.0)
318}
319
320pub async fn create_notes_atomic_with_report(
325 runtime: &KhiveRuntime,
326 specs: Vec<AtomicNoteSpec<'_>>,
327) -> RuntimeResult<(Vec<Note>, crate::retrieval::EmbeddingTruncationReport)> {
328 create_notes_atomic_with_attachments(runtime, specs, &[]).await
329}
330
331pub async fn create_notes_atomic_with_attachments(
334 runtime: &KhiveRuntime,
335 specs: Vec<AtomicNoteSpec<'_>>,
336 attachments: &[khive_storage::NewAttachment],
337) -> RuntimeResult<(Vec<Note>, crate::retrieval::EmbeddingTruncationReport)> {
338 validate_note_attachments(runtime, attachments)?;
339 let mut prepared = prepare_atomic_notes(runtime, specs, AtomicNoteOptions::default()).await?;
340 append_note_attachments(&mut prepared, attachments)?;
341 match crate::atomic_runner::run_atomic_unit_with_note_versions(
342 runtime.sql().as_ref(),
343 prepared.plans,
344 true,
345 )
346 .await
347 {
348 Ok((AtomicRunOutcome::Committed { .. }, versions)) => {
349 assert_eq!(
350 prepared.notes.len(),
351 versions.len(),
352 "one revision receipt per prepared note"
353 );
354 for (note, version) in prepared.notes.iter_mut().zip(versions) {
355 note.version = version;
356 }
357 Ok((prepared.notes, prepared.embedding_truncation))
358 }
359 Ok((
360 AtomicRunOutcome::RolledBack {
361 failed_op_index,
362 failure,
363 },
364 _,
365 )) => Err(RuntimeError::Internal(format!(
366 "atomic multi-note write rolled back at op {failed_op_index}: {failure:?}"
367 ))),
368 Err(e) => Err(RuntimeError::Storage(e.0)),
369 }
370}
371
372pub(crate) fn validate_note_attachments(
373 runtime: &KhiveRuntime,
374 attachments: &[khive_storage::NewAttachment],
375) -> RuntimeResult<()> {
376 if !attachments.is_empty() {
377 runtime.attachments()?;
380 }
381 for attachment in attachments {
382 attachment.validate()?;
383 }
384 Ok(())
385}
386
387pub(crate) fn append_note_attachments(
388 prepared: &mut PreparedAtomicNotes,
389 attachments: &[khive_storage::NewAttachment],
390) -> RuntimeResult<()> {
391 for (plan, note) in prepared.plans.iter_mut().zip(&prepared.notes) {
392 let AtomicOpPlan::AddNote(plan) = plan else {
393 return Err(RuntimeError::Internal(
394 "expected prepared attachment owner note".into(),
395 ));
396 };
397 for attachment in attachments {
398 let row = khive_storage::Attachment::from_new(
399 note.id,
400 khive_storage::AttachmentSubstrate::Note,
401 attachment.clone(),
402 note.created_at,
403 );
404 plan.statements.push(PlanStatement {
407 statement: khive_db::stores::attachment::attachment_upsert_statement(&row)?,
408 guard: Some(AffectedRowGuard::exactly(1)),
409 });
410 }
411 }
412 Ok(())
413}
414
415pub(crate) async fn prepare_atomic_notes(
416 runtime: &KhiveRuntime,
417 specs: Vec<AtomicNoteSpec<'_>>,
418 options: AtomicNoteOptions<'_>,
419) -> RuntimeResult<PreparedAtomicNotes> {
420 validate_atomic_note_options(&options)?;
421 if options.embed != Some(false) {
422 if let Some(model) = options.embedding_model {
423 runtime.resolve_embedding_model(Some(model))?;
424 }
425 }
426 prepare_atomic_note_requests(
427 runtime,
428 specs
429 .into_iter()
430 .map(|spec| AtomicNoteRequest { spec, options })
431 .collect(),
432 )
433 .await
434}
435
436fn validate_atomic_note_options(options: &AtomicNoteOptions<'_>) -> RuntimeResult<()> {
437 if let Some(value) = options.salience {
438 if !value.is_finite() || !(0.0..=1.0).contains(&value) {
439 return Err(RuntimeError::InvalidInput(
440 "salience must be a finite value in [0.0, 1.0]".into(),
441 ));
442 }
443 }
444 if let Some(value) = options.decay_factor {
445 if !value.is_finite() || value < 0.0 {
446 return Err(RuntimeError::InvalidInput(
447 "decay_factor must be a finite value >= 0.0".into(),
448 ));
449 }
450 }
451 Ok(())
452}
453
454pub(crate) async fn prepare_atomic_note_requests(
455 runtime: &KhiveRuntime,
456 requests: Vec<AtomicNoteRequest<'_>>,
457) -> RuntimeResult<PreparedAtomicNotes> {
458 let mut embed_model_names = Vec::new();
459 let mut note_models = Vec::with_capacity(requests.len());
460 for request in &requests {
461 let options = &request.options;
462 validate_atomic_note_options(options)?;
463 let models = if options.embed == Some(false) {
464 Vec::new()
465 } else if let Some(model) = options.embedding_model {
466 runtime.resolve_embedding_model(Some(model))?;
467 vec![model.to_owned()]
468 } else {
469 runtime.embedding_models_for_note_kind(request.spec.kind)
470 };
471 let mut indices = Vec::with_capacity(models.len());
472 for model in models {
473 let index = match embed_model_names.iter().position(|name| name == &model) {
474 Some(index) => index,
475 None => {
476 embed_model_names.push(model);
477 embed_model_names.len() - 1
478 }
479 };
480 indices.push(index);
481 }
482 note_models.push(indices);
483 }
484 let mut notes: Vec<Note> = Vec::with_capacity(requests.len());
487 for (index, request) in requests.iter().enumerate() {
488 let spec = &request.spec;
489 let options = &request.options;
490 let record = format!("note[{index}]");
491 runtime.validate_note_kind(spec.kind)?;
492 let properties = if options.properties_already_derived {
501 spec.properties.clone()
502 } else {
503 runtime.derive_note_write_properties(spec.kind, spec.token, spec.properties.clone())?
504 };
505 crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
506 crate::secret_gate::check_at(spec.content, &record, "content")?;
507 if let Some(n) = spec.name {
508 crate::secret_gate::check_at(n, &record, "name")?;
509 }
510 if let Some(ref p) = properties {
511 crate::secret_gate::check_json_at(p, &record, "properties")?;
512 }
513
514 let ns = spec.token.namespace().as_str();
515 let mut note = Note::new(ns, spec.kind, spec.content);
516 note.key = options.key.map(str::to_owned);
517 if let Some(salience) = options.salience {
518 note = note.with_salience(salience);
519 }
520 if let Some(decay_factor) = options.decay_factor {
521 note = note.with_decay(decay_factor);
522 }
523 if let Some(id) = spec.id {
524 note.id = id;
525 }
526 if let Some(n) = spec.name {
527 note = note.with_name(n);
528 }
529 if let Some(p) = properties {
530 note = note.with_properties(p);
531 }
532 notes.push(note);
533 }
534
535 let memory_receipts: Vec<bool> = notes
539 .iter()
540 .zip(&requests)
541 .map(|(note, request)| {
542 note.kind == "memory"
543 && (request.options.key.is_some() || request.options.memory_visibility_receipt)
544 })
545 .collect();
546 if memory_receipts.iter().any(|capture| *capture) {
547 runtime.require_visibility_cutover()?;
548 }
549
550 let mut content_group_by_text: HashMap<&str, usize> = HashMap::new();
554 let mut content_groups: Vec<Vec<usize>> = Vec::new();
555 let mut note_content_groups: Vec<usize> = Vec::with_capacity(notes.len());
556 for (note_idx, note) in notes.iter().enumerate() {
557 let text = requests[note_idx]
558 .options
559 .embedding_content
560 .unwrap_or_else(|| crate::curation::note_embedding_text_ref(note));
561 let content_group_idx = match content_group_by_text.get(text) {
562 Some(&idx) => {
563 content_groups[idx].push(note_idx);
564 idx
565 }
566 None => {
567 let idx = content_groups.len();
568 content_group_by_text.insert(text, idx);
569 content_groups.push(vec![note_idx]);
570 idx
571 }
572 };
573 note_content_groups.push(content_group_idx);
574 }
575
576 let mut embedding_outcomes: Vec<Vec<Option<crate::retrieval::DocumentEmbeddingOutcome>>> =
578 content_groups
579 .iter()
580 .map(|_| vec![None; embed_model_names.len()])
581 .collect();
582 let mut embedding_truncation = crate::retrieval::EmbeddingTruncationReport::default();
583
584 if !embed_model_names.is_empty() {
589 let models = embed_model_names
593 .iter()
594 .map(|name| {
595 runtime
596 .vector_model_metadata(name)
597 .map(|(name, dimensions)| (crate::config::sanitize_key(&name), dimensions))
598 })
599 .collect::<RuntimeResult<Vec<_>>>()?;
600 let model_specs: Vec<_> = models
601 .iter()
602 .map(|(key, dimensions)| (key.as_str(), *dimensions))
603 .collect();
604 runtime.backend().ensure_vector_tables(&model_specs)?;
605
606 let usage_ctx = crate::usage::current();
607 let mut join_set = tokio::task::JoinSet::new();
608 for (model_idx, model_name) in embed_model_names.iter().enumerate() {
609 let mut groups = Vec::new();
610 let mut texts = Vec::new();
611 let mut token = None;
612 for (group_idx, note_indices) in content_groups.iter().enumerate() {
613 let Some(¬e_idx) = note_indices
614 .iter()
615 .find(|&&index| note_models[index].contains(&model_idx))
616 else {
617 continue;
618 };
619 token.get_or_insert_with(|| requests[note_idx].spec.token.clone());
620 groups.push(group_idx);
621 texts.push(
622 requests[note_idx]
623 .options
624 .embedding_content
625 .unwrap_or_else(|| {
626 crate::curation::note_embedding_text_ref(¬es[note_idx])
627 })
628 .to_owned(),
629 );
630 }
631 let rt = runtime.clone();
632 let token = token.expect("a selected model has at least one note");
633 let name = model_name.clone();
634 let ctx = usage_ctx.clone();
635 join_set.spawn(crate::runtime::inherit_request_embedder_scope(async move {
636 let fut = async {
637 let mut outcomes = Vec::with_capacity(texts.len());
638 for chunk in texts.chunks(lattice_embed::DEFAULT_MAX_BATCH_SIZE) {
639 outcomes.extend(
640 rt.embed_document_batch_with_model_outcomes_for_token(
641 &token, &name, chunk,
642 )
643 .await?,
644 );
645 }
646 Ok::<_, RuntimeError>(outcomes)
647 };
648 let result = match ctx {
649 Some(ctx) => crate::usage::scope(ctx, fut).await,
650 None => fut.await,
651 };
652 (groups, model_idx, result)
653 }));
654 }
655
656 while let Some(joined) = join_set.join_next().await {
657 match joined {
658 Ok((groups, model_idx, Ok(outcomes))) => {
659 for (group_idx, outcome) in groups.into_iter().zip(outcomes) {
660 embedding_outcomes[group_idx][model_idx] = Some(outcome);
661 }
662 }
663 Ok((_, _, Err(e))) => {
664 join_set.abort_all();
665 return Err(e);
666 }
667 Err(join_err) => {
668 join_set.abort_all();
669 return Err(RuntimeError::Internal(format!(
670 "embed task panicked: {join_err}"
671 )));
672 }
673 }
674 }
675 }
676
677 for (note_idx, &content_group_idx) in note_content_groups.iter().enumerate() {
680 for &model_idx in ¬e_models[note_idx] {
681 let outcome = embedding_outcomes[content_group_idx][model_idx]
682 .as_ref()
683 .expect("every requested embedding was observed");
684 embedding_truncation.observe(outcome);
685 if outcome.truncated {
686 tracing::warn!(
687 model = %outcome.model_name,
688 source_bytes = outcome.source_bytes,
689 embedded_bytes = outcome.embedded_bytes,
690 "atomic note embedding input truncated; full content will be stored unchanged"
691 );
692 }
693 }
694 }
695
696 for outcomes_for_content in &embedding_outcomes {
702 for outcome in outcomes_for_content.iter().flatten() {
703 if let Some(idx) = non_finite_index(&outcome.vector) {
704 return Err(non_finite_vector_error(idx, outcome.vector[idx]));
705 }
706 }
707 }
708
709 let mut plans: Vec<AtomicOpPlan> = Vec::with_capacity(notes.len());
712 for (note_idx, note) in notes.iter().enumerate() {
713 let outcomes_for_note = &embedding_outcomes[note_content_groups[note_idx]];
714 let mut statements = vec![PlanStatement {
715 statement: if note.key.is_some() {
716 khive_db::stores::note::note_insert_keyed_statement(note)
717 } else {
718 khive_db::stores::note::note_upsert_statement(note)
719 },
720 guard: Some(AffectedRowGuard::exactly(1)),
721 }];
722
723 if memory_receipts[note_idx] {
724 statements.push(PlanStatement {
725 statement: SqlStatement {
726 sql:
727 "INSERT INTO memory_visibility_receipts (namespace, note_id, model_count) \
728 VALUES (?1, ?2, ?3)"
729 .into(),
730 params: vec![
731 SqlValue::Text(note.namespace.clone()),
732 SqlValue::Text(note.id.to_string()),
733 SqlValue::Integer(note_models[note_idx].len() as i64),
734 ],
735 label: Some("memory-visibility-receipt".into()),
736 },
737 guard: Some(AffectedRowGuard::exactly(1)),
738 });
739 }
740
741 if let Some(fault) = maybe_inject_fts_failure(¬e.namespace, "fault-injected-fts") {
742 statements.push(fault);
743 } else {
744 statements.push(PlanStatement {
754 statement: khive_db::stores::text::delete_document_statement(
755 "fts_notes",
756 ¬e.namespace,
757 note.id,
758 ),
759 guard: None,
760 });
761 for statement in khive_db::stores::text::insert_document_statements(
767 "fts_notes",
768 ¬e_fts_document(note),
769 ) {
770 statements.push(PlanStatement {
771 statement,
772 guard: None,
773 });
774 }
775 }
776
777 if let Some(fault) = maybe_inject_vector_failure(¬e.namespace, "fault-injected-vector") {
778 statements.push(fault);
779 } else {
780 for &model_idx in ¬e_models[note_idx] {
781 let model_name = &embed_model_names[model_idx];
782 let outcome = outcomes_for_note[model_idx]
783 .as_ref()
784 .expect("every model index observed exactly once");
785 let table = format!("vec_{}", crate::config::sanitize_key(model_name));
786 statements.extend(vector_insert_statements(
787 &table,
788 ¬e.namespace,
789 note.id,
790 "note.content",
791 model_name,
792 &outcome.vector,
793 &format!("atomic-message-vec-{table}-{}", note.id),
794 ));
795 if memory_receipts[note_idx] {
796 statements.push(PlanStatement {
801 statement: SqlStatement {
802 sql: "INSERT INTO memory_visibility_fences \
803 (namespace, note_id, model, ann_write_log_seq) \
804 VALUES (?1, ?2, ?3, last_insert_rowid())"
805 .into(),
806 params: vec![
807 SqlValue::Text(note.namespace.clone()),
808 SqlValue::Text(note.id.to_string()),
809 SqlValue::Text(model_name.clone()),
810 ],
811 label: Some("memory-visibility-fence".into()),
812 },
813 guard: Some(AffectedRowGuard::exactly(1)),
814 });
815 }
816 }
817 }
818
819 if memory_receipts[note_idx] {
822 statements.push(PlanStatement {
823 statement: SqlStatement {
824 sql: "INSERT INTO memory_visibility_epochs (note_id, namespace, epoch) \
825 VALUES (?1, ?2, 'modern')"
826 .into(),
827 params: vec![
828 SqlValue::Text(note.id.to_string()),
829 SqlValue::Text(note.namespace.clone()),
830 ],
831 label: Some("memory-visibility-epoch".into()),
832 },
833 guard: Some(AffectedRowGuard::exactly(1)),
834 });
835 }
836
837 plans.push(AtomicOpPlan::AddNote(Box::new(AddNotePlan {
838 note_guard: Some(crate::note_write::NoteWriteGuard {
839 namespace: requests[note_idx].spec.token.namespace().as_str().into(),
840 target_id: note.id,
841 expected_version: None,
842 fence: requests[note_idx].options.fence.cloned(),
843 create_key: note
844 .key
845 .as_ref()
846 .map(|key| crate::note_write::CreateKeyClaim {
847 kind: note.kind.clone(),
848 key: key.clone(),
849 content: note.content.clone(),
850 properties: note.properties.clone(),
851 replay_signal: requests[note_idx].options.replay_receipt,
852 }),
853 }),
854 note_id: note.id,
855 statements,
856 post_commit: PostCommitEffect::None,
857 })));
858 }
859
860 Ok(PreparedAtomicNotes {
861 notes,
862 plans,
863 embedding_truncation,
864 })
865}
866
867#[cfg(test)]
868mod tests {
869 use super::*;
870 use async_trait::async_trait;
871 use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService, MAX_TEXT_BYTES};
872 use std::sync::{Arc, Mutex};
873
874 use khive_types::Namespace;
875
876 use crate::embedder_registry::EmbedderProvider;
877
878 const NAN_MODEL: &str = "atomic-message-nan-model";
879 const NAN_DIMS: usize = 4;
880
881 struct NanService;
882 #[async_trait]
883 impl EmbeddingService for NanService {
884 async fn embed(
885 &self,
886 texts: &[String],
887 _model: EmbeddingModel,
888 ) -> Result<Vec<Vec<f32>>, EmbedError> {
889 Ok(texts.iter().map(|_| vec![f32::NAN; NAN_DIMS]).collect())
890 }
891 fn supports_model(&self, _model: EmbeddingModel) -> bool {
892 true
893 }
894 fn name(&self) -> &'static str {
895 NAN_MODEL
896 }
897 }
898 struct NanProvider;
899 #[async_trait]
900 impl EmbedderProvider for NanProvider {
901 fn name(&self) -> &str {
902 NAN_MODEL
903 }
904 fn dimensions(&self) -> usize {
905 NAN_DIMS
906 }
907 async fn build(&self) -> RuntimeResult<std::sync::Arc<dyn EmbeddingService>> {
908 Ok(std::sync::Arc::new(NanService))
909 }
910 }
911
912 struct TruncationService;
913 #[async_trait]
914 impl EmbeddingService for TruncationService {
915 async fn embed(
916 &self,
917 texts: &[String],
918 _model: EmbeddingModel,
919 ) -> Result<Vec<Vec<f32>>, EmbedError> {
920 Ok(texts
921 .iter()
922 .map(|_| vec![0.5; EmbeddingModel::MultilingualE5Base.dimensions()])
923 .collect())
924 }
925 fn supports_model(&self, _model: EmbeddingModel) -> bool {
926 true
927 }
928 fn name(&self) -> &'static str {
929 "multilingual-e5-base"
930 }
931 }
932
933 struct TruncationProvider;
934 #[async_trait]
935 impl EmbedderProvider for TruncationProvider {
936 fn name(&self) -> &str {
937 "multilingual-e5-base"
938 }
939 fn dimensions(&self) -> usize {
940 EmbeddingModel::MultilingualE5Base.dimensions()
941 }
942 async fn build(&self) -> RuntimeResult<std::sync::Arc<dyn EmbeddingService>> {
943 Ok(std::sync::Arc::new(TruncationService))
944 }
945 }
946
947 const DEDUP_DIMS: usize = 4;
948
949 struct DedupService {
950 name: &'static str,
951 }
952
953 #[async_trait]
954 impl EmbeddingService for DedupService {
955 async fn embed(
956 &self,
957 texts: &[String],
958 _model: EmbeddingModel,
959 ) -> Result<Vec<Vec<f32>>, EmbedError> {
960 Ok(texts.iter().map(|_| vec![0.25; DEDUP_DIMS]).collect())
961 }
962
963 fn supports_model(&self, _model: EmbeddingModel) -> bool {
964 true
965 }
966
967 fn name(&self) -> &'static str {
968 self.name
969 }
970 }
971
972 struct DedupProvider {
973 name: &'static str,
974 }
975
976 #[async_trait]
977 impl EmbedderProvider for DedupProvider {
978 fn name(&self) -> &str {
979 self.name
980 }
981
982 fn dimensions(&self) -> usize {
983 DEDUP_DIMS
984 }
985
986 async fn build(&self) -> RuntimeResult<std::sync::Arc<dyn EmbeddingService>> {
987 Ok(std::sync::Arc::new(DedupService { name: self.name }))
988 }
989 }
990
991 struct BatchCountingService {
992 name: &'static str,
993 calls: Arc<Mutex<Vec<Vec<String>>>>,
994 }
995
996 #[async_trait]
997 impl EmbeddingService for BatchCountingService {
998 async fn embed(
999 &self,
1000 texts: &[String],
1001 _model: EmbeddingModel,
1002 ) -> Result<Vec<Vec<f32>>, EmbedError> {
1003 self.calls.lock().unwrap().push(texts.to_vec());
1004 Ok(texts.iter().map(|_| vec![0.25; DEDUP_DIMS]).collect())
1005 }
1006
1007 fn supports_model(&self, _model: EmbeddingModel) -> bool {
1008 true
1009 }
1010
1011 fn name(&self) -> &'static str {
1012 self.name
1013 }
1014 }
1015
1016 struct BatchCountingProvider {
1017 name: &'static str,
1018 calls: Arc<Mutex<Vec<Vec<String>>>>,
1019 }
1020
1021 #[async_trait]
1022 impl EmbedderProvider for BatchCountingProvider {
1023 fn name(&self) -> &str {
1024 self.name
1025 }
1026
1027 fn dimensions(&self) -> usize {
1028 DEDUP_DIMS
1029 }
1030
1031 async fn build(&self) -> RuntimeResult<Arc<dyn EmbeddingService>> {
1032 Ok(Arc::new(BatchCountingService {
1033 name: self.name,
1034 calls: Arc::clone(&self.calls),
1035 }))
1036 }
1037 }
1038
1039 #[tokio::test]
1040 async fn heterogeneous_notes_batch_models_with_one_schema_writer() {
1041 let runtime = KhiveRuntime::memory().unwrap();
1042 let token = runtime
1043 .authorize(Namespace::parse("batch-notes").unwrap())
1044 .unwrap();
1045 let names = ["batch-notes-a", "batch-notes-b"];
1046 let mut counters = Vec::new();
1047 for name in names {
1048 let calls = Arc::new(Mutex::new(Vec::new()));
1049 runtime.register_embedder(BatchCountingProvider {
1050 name,
1051 calls: Arc::clone(&calls),
1052 });
1053 runtime.embedder_with_token(&token, name).await.unwrap();
1054 counters.push(calls);
1055 }
1056 let inputs = [
1057 ("first", "shared text", true, 0.2),
1058 ("second", "shared text", true, 0.4),
1059 ("third", "different text", true, 0.6),
1060 ("disabled", "shared text", false, 0.8),
1061 ];
1062 let requests = inputs
1063 .iter()
1064 .map(|&(key, content, embed, salience)| AtomicNoteRequest {
1065 spec: AtomicNoteSpec {
1066 token: &token,
1067 id: None,
1068 kind: "observation",
1069 name: None,
1070 content,
1071 properties: Some(serde_json::json!({"key": key})),
1072 },
1073 options: AtomicNoteOptions {
1074 key: Some(key),
1075 salience: Some(salience),
1076 embed: Some(embed),
1077 ..Default::default()
1078 },
1079 })
1080 .collect();
1081 let before = runtime.backend().pool().writer_acquisition_snapshot();
1082 let prepared = prepare_atomic_note_requests(&runtime, requests)
1083 .await
1084 .unwrap();
1085 let after = runtime.backend().pool().writer_acquisition_snapshot();
1086 assert_eq!(after.acquisitions - before.acquisitions, 1);
1087 for calls in &counters {
1088 assert_eq!(
1089 *calls.lock().unwrap(),
1090 vec![vec!["shared text".to_owned(), "different text".to_owned()]],
1091 );
1092 }
1093 for (note, &(key, content, _, salience)) in prepared.notes.iter().zip(&inputs) {
1094 assert_eq!(note.key.as_deref(), Some(key));
1095 assert_eq!(note.content, content);
1096 assert_eq!(note.salience, Some(salience));
1097 assert_eq!(note.properties.as_ref().unwrap()["key"], key);
1098 }
1099 assert!(matches!(
1100 crate::atomic_runner::run_atomic_unit(runtime.sql().as_ref(), prepared.plans)
1101 .await
1102 .unwrap(),
1103 AtomicRunOutcome::Committed { .. }
1104 ));
1105 for name in names {
1106 assert_eq!(
1107 runtime
1108 .vectors_for_model(&token, name)
1109 .unwrap()
1110 .count()
1111 .await
1112 .unwrap(),
1113 3,
1114 "disabled notes must not inherit a sibling's shared vector",
1115 );
1116 }
1117 }
1118
1119 #[tokio::test]
1120 async fn atomic_note_batch_chunks_only_above_provider_limit() {
1121 let runtime = KhiveRuntime::memory().unwrap();
1122 let token = runtime.authorize(Namespace::local()).unwrap();
1123 let calls = Arc::new(Mutex::new(Vec::new()));
1124 runtime.register_embedder(BatchCountingProvider {
1125 name: "batch-notes-large",
1126 calls: Arc::clone(&calls),
1127 });
1128 let contents: Vec<_> = (0..=lattice_embed::DEFAULT_MAX_BATCH_SIZE)
1129 .map(|index| format!("distinct document {index}"))
1130 .collect();
1131 let specs = contents
1132 .iter()
1133 .map(|content| AtomicNoteSpec {
1134 token: &token,
1135 id: None,
1136 kind: "observation",
1137 name: None,
1138 content,
1139 properties: None,
1140 })
1141 .collect();
1142 let prepared = prepare_atomic_notes(&runtime, specs, AtomicNoteOptions::default())
1143 .await
1144 .unwrap();
1145 assert_eq!(prepared.notes.len(), contents.len());
1146 let calls = calls.lock().unwrap();
1147 assert_eq!(calls.len(), 2);
1148 assert_eq!(calls[0].len(), lattice_embed::DEFAULT_MAX_BATCH_SIZE);
1149 assert_eq!(calls[1].len(), 1);
1150 assert_eq!(calls.concat(), contents);
1151 }
1152
1153 fn stamp_from_actor(
1158 _kind: &str,
1159 actor_id: &str,
1160 properties: Option<Value>,
1161 ) -> RuntimeResult<Option<Value>> {
1162 let mut props = match properties {
1163 Some(Value::Object(map)) => map,
1164 _ => serde_json::Map::new(),
1165 };
1166 props.insert(
1167 "from_actor".to_string(),
1168 Value::String(actor_id.to_string()),
1169 );
1170 Ok(Some(Value::Object(props)))
1171 }
1172
1173 async fn fts_row_count(runtime: &KhiveRuntime, namespace: &str) -> i64 {
1174 let mut reader = runtime.sql().reader().await.expect("sql reader");
1175 match reader
1176 .query_scalar(SqlStatement {
1177 sql: "SELECT COUNT(*) FROM fts_notes WHERE namespace = ?1".to_string(),
1178 params: vec![SqlValue::Text(namespace.to_string())],
1179 label: None,
1180 })
1181 .await
1182 .expect("fts count query")
1183 {
1184 Some(SqlValue::Integer(n)) => n,
1185 other => panic!("unexpected fts count result: {other:?}"),
1186 }
1187 }
1188
1189 async fn ann_write_log_count(runtime: &KhiveRuntime, namespace: &str, model: &str) -> i64 {
1190 let mut reader = runtime.sql().reader().await.expect("sql reader");
1191 match reader
1192 .query_scalar(SqlStatement {
1193 sql: "SELECT COUNT(*) FROM ann_write_log \
1194 WHERE namespace = ?1 AND embedding_model = ?2"
1195 .to_string(),
1196 params: vec![
1197 SqlValue::Text(namespace.to_string()),
1198 SqlValue::Text(model.to_string()),
1199 ],
1200 label: None,
1201 })
1202 .await
1203 .expect("ann_write_log count query")
1204 {
1205 Some(SqlValue::Integer(n)) => n,
1206 other => panic!("unexpected ann_write_log count result: {other:?}"),
1207 }
1208 }
1209
1210 #[tokio::test]
1215 async fn create_notes_atomic_rejects_non_finite_embedding_before_any_write() {
1216 let runtime = KhiveRuntime::memory().expect("in-memory runtime");
1217 runtime.register_embedder(NanProvider);
1218 let ns = "atomic-message-nan-test";
1219 let token = runtime
1220 .authorize(Namespace::parse(ns).unwrap())
1221 .expect("authorize");
1222
1223 let result = create_notes_atomic(
1224 &runtime,
1225 vec![AtomicNoteSpec {
1226 token: &token,
1227 id: None,
1228 kind: "observation",
1229 name: None,
1230 content: "nan embedding content",
1231 properties: None,
1232 }],
1233 )
1234 .await;
1235
1236 assert!(
1237 result.is_err(),
1238 "a non-finite embedding must be rejected; got {result:?}"
1239 );
1240
1241 let alive = runtime
1242 .list_notes(&token, Some("observation"), 100, 0)
1243 .await
1244 .expect("list_notes")
1245 .into_iter()
1246 .filter(|n| n.deleted_at.is_none())
1247 .count();
1248 assert_eq!(
1249 alive, 0,
1250 "no note row may be committed when an embedding is non-finite"
1251 );
1252
1253 assert_eq!(
1254 fts_row_count(&runtime, ns).await,
1255 0,
1256 "no FTS document may be committed when an embedding is non-finite"
1257 );
1258
1259 let vs = runtime
1260 .vectors_for_model(&token, NAN_MODEL)
1261 .expect("vec store");
1262 assert_eq!(
1263 vs.count().await.expect("count"),
1264 0,
1265 "no vector row may be committed when an embedding is non-finite"
1266 );
1267
1268 assert_eq!(
1269 ann_write_log_count(&runtime, ns, NAN_MODEL).await,
1270 0,
1271 "no ann_write_log row may be committed when an embedding is non-finite"
1272 );
1273 }
1274
1275 #[tokio::test]
1276 async fn create_notes_atomic_rejects_reserved_secret_gate_key() {
1277 let runtime = KhiveRuntime::memory().expect("in-memory runtime");
1278 let ns = "atomic-message-reserved-key-test";
1279 let token = runtime
1280 .authorize(Namespace::parse(ns).unwrap())
1281 .expect("authorize");
1282
1283 let result = create_notes_atomic(
1284 &runtime,
1285 vec![AtomicNoteSpec {
1286 token: &token,
1287 id: None,
1288 kind: "observation",
1289 name: None,
1290 content: "atomic message reservation target",
1291 properties: Some(
1292 serde_json::json!({"khive:secret_gate": "exempted:content-sha256-manifest-v1"}),
1293 ),
1294 }],
1295 )
1296 .await;
1297
1298 let err = result.expect_err("caller-supplied reserved key must be rejected");
1299 assert!(
1300 matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate")),
1301 "unexpected error: {err:?}"
1302 );
1303
1304 let alive = runtime
1305 .list_notes(&token, Some("observation"), 100, 0)
1306 .await
1307 .expect("list_notes")
1308 .into_iter()
1309 .filter(|n| n.deleted_at.is_none())
1310 .count();
1311 assert_eq!(
1312 alive, 0,
1313 "no note row may be committed on reservation rejection"
1314 );
1315 }
1316
1317 #[tokio::test]
1318 async fn create_notes_atomic_with_report_preserves_truncation_outcome() {
1319 let runtime = KhiveRuntime::memory().expect("in-memory runtime");
1320 runtime.register_embedder(TruncationProvider);
1321 let token = runtime
1322 .authorize(Namespace::parse("atomic-message-truncation-test").unwrap())
1323 .expect("authorize");
1324 let content = "x".repeat(MAX_TEXT_BYTES);
1325
1326 let (notes, report) = create_notes_atomic_with_report(
1327 &runtime,
1328 vec![AtomicNoteSpec {
1329 token: &token,
1330 id: None,
1331 kind: "observation",
1332 name: None,
1333 content: &content,
1334 properties: None,
1335 }],
1336 )
1337 .await
1338 .expect("atomic note write");
1339
1340 assert_eq!(notes.len(), 1);
1341 assert_eq!(report.truncated, 1);
1342 assert_eq!(
1343 report.discarded_bytes,
1344 "passage: ".len() as u64,
1345 "E5 document-prefix reservation must be reflected in the returned report"
1346 );
1347 }
1348
1349 #[tokio::test]
1350 async fn create_notes_atomic_reuses_identical_content_once_per_model() {
1351 let runtime = KhiveRuntime::memory().expect("in-memory runtime");
1352 for name in ["atomic-dedup-model-a", "atomic-dedup-model-b"] {
1353 runtime.register_embedder(DedupProvider { name });
1354 }
1355 let outbound_token = runtime
1356 .authorize(Namespace::parse("atomic-dedup-outbound").unwrap())
1357 .expect("authorize outbound");
1358 let inbound_token = runtime
1359 .authorize(Namespace::parse("atomic-dedup-inbound").unwrap())
1360 .expect("authorize inbound");
1361 let content = "byte-identical outbound and inbound message content";
1362
1363 let usage = crate::usage::UsageContext::new();
1364 let notes = crate::usage::scope(usage.clone(), async {
1365 create_notes_atomic(
1366 &runtime,
1367 vec![
1368 AtomicNoteSpec {
1369 token: &outbound_token,
1370 id: None,
1371 kind: "observation",
1372 name: Some("shared subject"),
1373 content,
1374 properties: Some(serde_json::json!({"direction": "outbound"})),
1375 },
1376 AtomicNoteSpec {
1377 token: &inbound_token,
1378 id: None,
1379 kind: "observation",
1380 name: Some("shared subject"),
1381 content,
1382 properties: Some(serde_json::json!({"direction": "inbound"})),
1383 },
1384 ],
1385 )
1386 .await
1387 })
1388 .await
1389 .expect("atomic note pair");
1390
1391 assert_eq!(notes.len(), 2);
1392 assert_eq!(notes[0].content, content);
1393 assert_eq!(notes[1].content, content);
1394 assert_eq!(
1395 usage.snapshot()["embed_calls"],
1396 2,
1397 "two identical notes across two models must issue one embed per model"
1398 );
1399 for model in ["atomic-dedup-model-a", "atomic-dedup-model-b"] {
1400 for (direction, token) in [("outbound", &outbound_token), ("inbound", &inbound_token)] {
1401 assert_eq!(
1402 runtime
1403 .vectors_for_model(token, model)
1404 .expect("vector store")
1405 .count()
1406 .await
1407 .expect("vector count"),
1408 1,
1409 "the {direction} note must retain its vector row for {model}"
1410 );
1411 }
1412 }
1413 }
1414
1415 #[tokio::test]
1420 async fn version_create_notes_atomic_upserts_fts_document_for_reused_note_id() {
1421 let runtime = KhiveRuntime::memory().expect("in-memory runtime");
1422 let ns = "atomic-message-fts-upsert-test";
1423 let token = runtime
1424 .authorize(Namespace::parse(ns).unwrap())
1425 .expect("authorize");
1426 let id = Uuid::new_v4();
1427
1428 let first = create_notes_atomic(
1429 &runtime,
1430 vec![AtomicNoteSpec {
1431 token: &token,
1432 id: Some(id),
1433 kind: "observation",
1434 name: None,
1435 content: "first content",
1436 properties: None,
1437 }],
1438 )
1439 .await
1440 .expect("first write with supplied id");
1441 assert_eq!(first[0].version, 1);
1442
1443 let second = create_notes_atomic(
1444 &runtime,
1445 vec![AtomicNoteSpec {
1446 token: &token,
1447 id: Some(id),
1448 kind: "observation",
1449 name: None,
1450 content: "second content replacing the first",
1451 properties: None,
1452 }],
1453 )
1454 .await
1455 .expect("second write reusing the same id");
1456 assert_eq!(second[0].version, 2);
1457 assert_eq!(
1458 runtime
1459 .notes(&token)
1460 .unwrap()
1461 .get_note(id)
1462 .await
1463 .unwrap()
1464 .unwrap()
1465 .version,
1466 2
1467 );
1468 let repeated = create_notes_atomic(
1469 &runtime,
1470 (0..2)
1471 .map(|_| AtomicNoteSpec {
1472 token: &token,
1473 id: Some(id),
1474 kind: "observation",
1475 name: None,
1476 content: "second content replacing the first",
1477 properties: None,
1478 })
1479 .collect(),
1480 )
1481 .await
1482 .expect("equal-value upserts in the same batch");
1483 assert_eq!(
1484 repeated.iter().map(|note| note.version).collect::<Vec<_>>(),
1485 vec![3, 4]
1486 );
1487 assert_eq!(
1488 runtime
1489 .notes(&token)
1490 .unwrap()
1491 .get_note(id)
1492 .await
1493 .unwrap()
1494 .unwrap()
1495 .version,
1496 4
1497 );
1498
1499 assert_eq!(
1500 fts_row_count(&runtime, ns).await,
1501 1,
1502 "exactly one FTS document may exist for the reused id"
1503 );
1504
1505 let text = runtime.text_for_notes(&token).expect("text store");
1506 let doc = text
1507 .get_document(ns, id)
1508 .await
1509 .expect("get_document")
1510 .expect("document exists for the reused id");
1511 assert!(
1512 doc.body.contains("second content"),
1513 "the surviving FTS document must reflect the latest content; got {:?}",
1514 doc.body
1515 );
1516 }
1517
1518 #[tokio::test]
1532 async fn create_notes_atomic_derives_from_actor_overwriting_a_forged_value() {
1533 let runtime = KhiveRuntime::memory().expect("in-memory runtime");
1534 runtime.install_note_write_validator(std::sync::Arc::new(stamp_from_actor));
1535 let ns = "atomic-message-identity-guard-test";
1536 let token = runtime
1537 .authorize(Namespace::parse(ns).unwrap())
1538 .expect("authorize");
1539 let true_actor = token.actor().id.clone();
1540
1541 let notes = create_notes_atomic(
1542 &runtime,
1543 vec![AtomicNoteSpec {
1544 token: &token,
1545 id: None,
1546 kind: "observation",
1547 name: None,
1548 content: "atomic writer identity guard probe",
1549 properties: Some(serde_json::json!({"from_actor": "forged-actor"})),
1550 }],
1551 )
1552 .await
1553 .expect("atomic note write must succeed — the guard derives, it does not refuse");
1554
1555 assert_eq!(notes.len(), 1);
1556 assert_eq!(
1557 notes[0]
1558 .properties
1559 .as_ref()
1560 .and_then(|p| p.get("from_actor"))
1561 .and_then(|v| v.as_str()),
1562 Some(true_actor.as_str()),
1563 "the atomic writer must store the token-derived from_actor, not the \
1564 caller-supplied forged value"
1565 );
1566 }
1567}