From d3c7bcf271f844b06b29dcb766df1d4b6783f4ee Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 30 Jun 2026 16:05:15 -0400 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Split large service test modules by responsibility","authority":"manual"} --- packages/elf-service/src/knowledge/tests.rs | 722 +----------------- .../elf-service/src/knowledge/tests_core.rs | 241 ++++++ .../src/knowledge/tests_helpers.rs | 170 +++++ .../elf-service/src/knowledge/tests_memory.rs | 59 ++ .../elf-service/src/knowledge/tests_search.rs | 296 +++++++ .../elf-service/src/recall_debug/tests.rs | 554 +------------- .../src/recall_debug/tests_helpers.rs | 251 ++++++ .../src/recall_debug/tests_layers.rs | 109 +++ .../src/recall_debug/tests_replay.rs | 37 + .../src/recall_debug/tests_visibility.rs | 174 +++++ .../tests/acceptance/graph_ingestion.rs | 705 +---------------- .../graph_ingestion/single_predicate.rs | 13 +- .../acceptance/graph_ingestion/tests_event.rs | 73 ++ .../graph_ingestion/tests_helpers.rs | 317 ++++++++ .../acceptance/graph_ingestion/tests_note.rs | 174 +++++ .../acceptance/graph_ingestion/tests_query.rs | 81 ++ 16 files changed, 2001 insertions(+), 1975 deletions(-) create mode 100644 packages/elf-service/src/knowledge/tests_core.rs create mode 100644 packages/elf-service/src/knowledge/tests_helpers.rs create mode 100644 packages/elf-service/src/knowledge/tests_memory.rs create mode 100644 packages/elf-service/src/knowledge/tests_search.rs create mode 100644 packages/elf-service/src/recall_debug/tests_helpers.rs create mode 100644 packages/elf-service/src/recall_debug/tests_layers.rs create mode 100644 packages/elf-service/src/recall_debug/tests_replay.rs create mode 100644 packages/elf-service/src/recall_debug/tests_visibility.rs create mode 100644 packages/elf-service/tests/acceptance/graph_ingestion/tests_event.rs create mode 100644 packages/elf-service/tests/acceptance/graph_ingestion/tests_helpers.rs create mode 100644 packages/elf-service/tests/acceptance/graph_ingestion/tests_note.rs create mode 100644 packages/elf-service/tests/acceptance/graph_ingestion/tests_query.rs diff --git a/packages/elf-service/src/knowledge/tests.rs b/packages/elf-service/src/knowledge/tests.rs index 73850c94..475a3a98 100644 --- a/packages/elf-service/src/knowledge/tests.rs +++ b/packages/elf-service/src/knowledge/tests.rs @@ -1,718 +1,4 @@ -use std::{ - collections::{BTreeSet, HashSet}, - slice, -}; - -use serde_json::Value; - -use crate::{ - access::SharedSpaceGrantKey, - knowledge::{ - self, DraftSection, KnowledgeDeltaMemoryCandidate, KnowledgePage, KnowledgePageKind, - KnowledgePageResponse, KnowledgePageSearchRow, KnowledgePageSection, - KnowledgePageSectionResponse, KnowledgePageSourceRef, KnowledgePageSourceRefResponse, - KnowledgePageSummary, KnowledgeSourceKind, LintDraft, OffsetDateTime, SourceSnapshot, Uuid, - }, -}; -use elf_domain::consolidation::ConsolidationApplyIntent; - -fn test_source(kind: KnowledgeSourceKind, raw_id: u128, line: &str) -> SourceSnapshot { - let id = Uuid::from_u128(raw_id); - let content_hash = knowledge::hash_text(line); - - SourceSnapshot { - kind, - id, - status: Some("active".to_string()), - updated_at: Some(OffsetDateTime::UNIX_EPOCH), - content_hash: Some(content_hash.clone()), - snapshot: serde_json::json!({ - "kind": kind.as_str(), - "id": id, - "status": "active", - "updated_at": OffsetDateTime::UNIX_EPOCH, - "content_hash": content_hash, - }), - citation_metadata: serde_json::json!({ "fixture": "knowledge_unit" }), - line: line.to_string(), - } -} - -fn test_rebuild_request(page_kind: KnowledgePageKind) -> knowledge::KnowledgePageRebuildRequest { - knowledge::KnowledgePageRebuildRequest { - tenant_id: "tenant".to_string(), - project_id: "project".to_string(), - agent_id: "agent".to_string(), - page_kind, - page_key: "elf".to_string(), - title: Some("ELF".to_string()), - doc_ids: Vec::new(), - doc_chunk_ids: Vec::new(), - note_ids: Vec::new(), - event_ids: Vec::new(), - relation_ids: Vec::new(), - proposal_ids: Vec::new(), - provider_metadata: knowledge::empty_object(), - } -} - -#[test] -fn build_sections_preserves_citations_and_deterministic_hashes() { - let sources = vec![ - test_source(KnowledgeSourceKind::Doc, 1, "A source document supports the page."), - test_source(KnowledgeSourceKind::DocChunk, 2, "A source span supports the page."), - test_source(KnowledgeSourceKind::Note, 3, "A source note supports the page."), - test_source(KnowledgeSourceKind::Event, 4, "An event audit supports the page."), - test_source(KnowledgeSourceKind::Relation, 5, "A relation supports the page."), - test_source(KnowledgeSourceKind::Proposal, 6, "An applied proposal supports the page."), - ]; - let mut first_sections = knowledge::build_sections(&sources).expect("sections should build"); - - for section in &mut first_sections { - section.citations = knowledge::citations_value(section, &sources); - section.content_hash = knowledge::hash_json(&knowledge::section_hash_payload(section)) - .expect("section hash should serialize"); - } - - assert_eq!(first_sections.len(), 6); - assert!(first_sections.iter().all(|section| { - section.citations.as_array().is_some_and(|citations| !citations.is_empty()) - })); - - let coverage = knowledge::source_coverage_value( - KnowledgePageKind::Project, - "elf", - &first_sections, - &sources, - ); - let request = test_rebuild_request(KnowledgePageKind::Project); - let metadata = knowledge::rebuild_metadata("source-hash", &knowledge::empty_object(), &request); - let first_hash = knowledge::page_content_hash("ELF", &first_sections, &coverage, &metadata) - .expect("page hash should serialize"); - let second_hash = knowledge::page_content_hash("ELF", &first_sections, &coverage, &metadata) - .expect("page hash should serialize"); - - assert_eq!(coverage["coverage_complete"], true); - assert_eq!(metadata["deterministic"], true); - assert_eq!(metadata["memory_candidate_policy"]["direct_memory_ledger_mutation_allowed"], false); - assert_eq!(first_hash, second_hash); -} - -#[test] -fn rebuild_metadata_records_llm_variance() { - let metadata = knowledge::rebuild_metadata( - "source-hash", - &serde_json::json!({ - "llm_derived": true, - "provider_id": "fixture", - "model": "fixture-model", - }), - &test_rebuild_request(KnowledgePageKind::Timeline), - ); - - assert_eq!(metadata["deterministic"], false); - assert!(metadata["allowed_variance"].as_array().is_some_and(|items| !items.is_empty())); - assert_eq!(metadata["provider_metadata"]["provider_id"], "fixture"); - assert_eq!(metadata["generated_by"]["actor_agent_id"], "agent"); -} - -#[test] -fn generated_titles_cover_author_and_timeline_pages() { - assert_eq!( - knowledge::generated_title(KnowledgePageKind::Author, "ada"), - "Author Knowledge Page: ada" - ); - assert_eq!( - knowledge::generated_title(KnowledgePageKind::Timeline, "release-plan"), - "Timeline Knowledge Page: release-plan" - ); -} - -#[test] -fn previous_version_diff_records_delta_without_changing_content_hash() { - let previous = test_page(); - let previous_section = - test_section(Uuid::from_u128(10), "source-notes", serde_json::json!([]), None); - let sections = vec![DraftSection { - section_id: Uuid::from_u128(12), - section_key: "source-notes".to_string(), - heading: "source-notes".to_string(), - role: "current_truth".to_string(), - content: "Updated section content.".to_string(), - ordinal: 0, - source_indexes: vec![0], - unsupported_reason: None, - content_hash: "new-section-hash".to_string(), - citations: serde_json::json!([{ "source_kind": "note" }]), - }]; - let request = test_rebuild_request(KnowledgePageKind::Project); - let base_metadata = - knowledge::rebuild_metadata("new-source-hash", &knowledge::empty_object(), &request); - let coverage = serde_json::json!({ "coverage_complete": true }); - let hash_without_diff = - knowledge::page_content_hash("ELF", §ions, &coverage, &base_metadata) - .expect("stable hash should serialize"); - let diff = knowledge::previous_version_diff_value( - Some(&previous), - &[previous_section], - "ELF", - "new-source-hash", - hash_without_diff.as_str(), - §ions, - ); - let version_identity = knowledge::version_identity_value( - KnowledgePageKind::Project, - "elf", - "new-source-hash", - hash_without_diff.as_str(), - §ions, - ); - let metadata_with_diff = knowledge::rebuild_metadata_with_previous_version_diff( - base_metadata, - diff.clone(), - version_identity, - ); - let hash_with_diff = - knowledge::page_content_hash("ELF", §ions, &coverage, &metadata_with_diff) - .expect("hash should ignore previous-version diff metadata"); - - assert_eq!(hash_without_diff, hash_with_diff); - assert_eq!(diff["schema"], "elf.knowledge_page.version_diff/v1"); - assert_eq!(diff["available"], true); - assert_eq!(diff["source_mutation_allowed"], false); - assert_eq!(diff["section_changed_count"], 1); - assert_eq!( - knowledge::previous_version_diff_from_metadata(&metadata_with_diff) - .expect("diff should be extractable")["section_changed_count"], - 1 - ); - assert_eq!( - metadata_with_diff["version_identity"]["schema"], - "elf.knowledge_page.version_identity/v1" - ); -} - -#[test] -fn stale_source_comparison_detects_changed_snapshot() { - let source_id = Uuid::from_u128(42); - let stored = KnowledgePageSourceRef { - ref_id: Uuid::from_u128(1), - page_id: Uuid::from_u128(2), - section_id: Some(Uuid::from_u128(3)), - source_kind: "note".to_string(), - source_id, - source_status: Some("active".to_string()), - source_updated_at: Some(OffsetDateTime::UNIX_EPOCH), - source_content_hash: Some("old-hash".to_string()), - source_snapshot: serde_json::json!({}), - citation_metadata: serde_json::json!({}), - created_at: OffsetDateTime::UNIX_EPOCH, - }; - let current = SourceSnapshot { - kind: KnowledgeSourceKind::Note, - id: source_id, - status: Some("active".to_string()), - updated_at: Some(OffsetDateTime::UNIX_EPOCH), - content_hash: Some("new-hash".to_string()), - snapshot: serde_json::json!({}), - citation_metadata: serde_json::json!({}), - line: "Updated note source.".to_string(), - }; - let finding = knowledge::stale_source_finding(&stored, ¤t); - - assert!(knowledge::source_changed(&stored, ¤t)); - assert_eq!(finding.finding_type, "stale_source_ref"); - assert_eq!(finding.source_kind, Some(KnowledgeSourceKind::Note)); - assert_eq!(finding.source_id, Some(source_id)); -} - -#[test] -fn watch_rebuild_outputs_cover_source_update_and_stale_page() { - let section_id = Uuid::from_u128(50); - let source_id = Uuid::from_u128(51); - let section = test_section( - section_id, - "source-notes", - serde_json::json!([{ "source_kind": "note", "source_id": source_id }]), - None, - ); - let source_ref = test_source_ref_for(section_id, source_id, "old-hash"); - let lint = vec![LintDraft { - section_id: Some(section_id), - finding_type: "stale_source_ref".to_string(), - severity: "warning".to_string(), - source_kind: Some(KnowledgeSourceKind::Note), - source_id: Some(source_id), - message: "Knowledge page source reference snapshot is stale.".to_string(), - details: serde_json::json!({ "stored": "old", "current": "new" }), - }]; - let diff = serde_json::json!({ - "available": true, - "content_changed": true, - "changed_section_keys": ["source-notes"] - }); - let changed_sources = vec![knowledge::KnowledgePageChangedSource { - source_kind: KnowledgeSourceKind::Note, - source_id, - }]; - let outputs = - knowledge::rebuild_outputs(&[section], &[source_ref], &lint, Some(&diff), &changed_sources); - let output_types = outputs.iter().map(|output| output.output_type.as_str()).collect::>(); - - assert!(output_types.contains(&"stale_section")); - assert!(output_types.contains(&"changed_claim")); - assert!(output_types.contains(&"conflict")); - assert!(output_types.contains(&"changed_source")); -} - -#[test] -fn memory_candidate_uses_reviewable_consolidation_proposal_contract() { - let section_id = Uuid::from_u128(60); - let source_id = Uuid::from_u128(61); - let page = test_page_response(section_id, source_id); - let outputs = vec![knowledge::KnowledgePageRebuildOutput { - output_type: "changed_claim".to_string(), - severity: "info".to_string(), - section_key: Some("source-notes".to_string()), - source_kind: Some("note".to_string()), - source_id: Some(source_id), - message: "Changed section.".to_string(), - details: serde_json::json!({ "reason": "source_update" }), - }]; - let candidates = knowledge::memory_candidates_for_page(&page, &outputs); - - assert_eq!(candidates.len(), 1); - - assert_candidate_is_reviewable(&candidates[0]); - - let proposal = knowledge::candidate_proposal_input(&candidates[0]); - - assert_eq!(proposal.apply_intent, ConsolidationApplyIntent::CreateDerivedNote); - assert_eq!(proposal.source_refs.len(), 1); - assert_eq!(proposal.proposed_payload["source_ref"]["source_mutation_allowed"], false); - assert_eq!(proposal.proposed_payload["source_ref"]["reason"], "changed_claim"); - assert!(!proposal.markers.staleness.is_empty()); -} - -#[test] -fn lint_page_sections_detects_unsupported_missing_and_low_coverage() { - let page = test_page(); - let unsupported = test_section( - Uuid::from_u128(10), - "unsupported", - serde_json::json!([]), - Some("No source supports this claim.".to_string()), - ); - let missing = test_section(Uuid::from_u128(11), "missing", serde_json::json!([]), None); - let findings = knowledge::lint_page_sections(&page, &[unsupported, missing], &[]); - let finding_types = - findings.iter().map(|finding| finding.finding_type.as_str()).collect::>(); - - assert!(finding_types.contains(&"unsupported_claim")); - assert!(finding_types.contains(&"missing_citation")); - assert!(finding_types.contains(&"missing_source_ref")); - assert!(finding_types.contains(&"low_source_coverage")); - assert!(findings.iter().all(|finding| { - finding - .details - .get("repair_guidance") - .and_then(serde_json::Value::as_str) - .is_some_and(|guidance| !guidance.is_empty()) - })); -} - -#[test] -fn search_item_marks_derived_page_snippet_with_provenance() { - let section_id = Uuid::from_u128(20); - let source_ref = test_source_ref(section_id); - let row = KnowledgePageSearchRow { - page_id: Uuid::from_u128(21), - page_kind: "project".to_string(), - page_key: "elf".to_string(), - title: "ELF Knowledge".to_string(), - status: "active".to_string(), - source_coverage: serde_json::json!({ - "source_count": 1, - "cited_source_count": 1, - "coverage_complete": true - }), - rebuild_metadata: serde_json::json!({ "deterministic": true }), - page_updated_at: OffsetDateTime::UNIX_EPOCH, - rebuilt_at: OffsetDateTime::UNIX_EPOCH, - section_id, - section_key: "source-notes".to_string(), - heading: "Source Notes".to_string(), - role: "current_truth".to_string(), - content: "Derived knowledge pages cite source notes before they are trusted.".to_string(), - ordinal: 0, - citations: serde_json::json!([{ "source_kind": "note", "source_id": source_ref.source_id }]), - unsupported_reason: None, - lint_error_count: 0, - lint_warning_count: 1, - lint_info_count: 0, - section_source_ref_count: 1, - }; - let item = knowledge::knowledge_page_search_item(row, vec![source_ref], "source notes"); - - assert_eq!(item.result_kind, "knowledge_page_section"); - assert_eq!(item.trust_state, "derived_warning"); - assert_eq!(item.citation_count, 1); - assert_eq!(item.source_ref_count, 1); - assert_eq!(item.source_refs.len(), 1); - assert!(item.derived_notice.contains("Derived knowledge page snippet")); - assert!(item.repair_guidance.is_some()); - assert!(item.snippet.contains("source notes")); -} - -#[test] -fn search_source_refs_suppress_deleted_and_unreviewed_sources() { - let section_id = Uuid::from_u128(70); - let mut active = test_source_ref(section_id); - let mut deleted = test_source_ref(section_id); - let mut ignored = test_source_ref(section_id); - let current_keys = current_source_keys_for(&[&active, &deleted, &ignored]); - - deleted.source_status = Some("deleted".to_string()); - ignored.source_status = Some("ignore".to_string()); - - assert!(knowledge::recallable_source_refs(slice::from_ref(&active), ¤t_keys)); - assert!(!knowledge::recallable_source_refs(&[deleted], ¤t_keys)); - assert!(!knowledge::recallable_source_refs(&[ignored], ¤t_keys)); - - active.source_status = None; - - assert!(!knowledge::recallable_source_refs(&[active], ¤t_keys)); -} - -#[test] -fn search_source_refs_suppress_non_captured_spans() { - let section_id = Uuid::from_u128(71); - let mut excluded = test_source_ref(section_id); - let mut source_ref_span = test_source_ref(section_id); - let mut policy_span = test_source_ref(section_id); - let mut malformed_span = test_source_ref(section_id); - let current_keys = - current_source_keys_for(&[&excluded, &source_ref_span, &policy_span, &malformed_span]); - - excluded.source_snapshot = serde_json::json!({ - "source_span": { - "schema": "doc_source_span/v1", - "status": "excluded", - "reason_code": "WRITE_POLICY_EXCLUSION" - } - }); - source_ref_span.source_snapshot = serde_json::json!({ - "source_ref": { - "source_spans": [ - { - "schema": "doc_source_span/v1", - "status": "redacted", - "reason_code": "WRITE_POLICY_REDACTION" - } - ] - } - }); - policy_span.source_snapshot = serde_json::json!({ - "source_ref": { - "policy_spans": [ - { - "schema": "doc_source_span/v1", - "status": "excluded", - "reason_code": "WRITE_POLICY_EXCLUSION" - } - ] - } - }); - malformed_span.source_snapshot = serde_json::json!({ - "source_span": { - "schema": "doc_source_span/v1", - "reason_code": "WRITE_POLICY_REDACTION" - } - }); - - assert!(!knowledge::recallable_source_refs(&[excluded], ¤t_keys)); - assert!(!knowledge::recallable_source_refs(&[source_ref_span], ¤t_keys)); - assert!(!knowledge::recallable_source_refs(&[policy_span], ¤t_keys)); - assert!(!knowledge::recallable_source_refs(&[malformed_span], ¤t_keys)); -} - -#[test] -fn search_source_refs_suppress_nested_proposal_non_captured_spans() { - let section_id = Uuid::from_u128(73); - let mut proposal = test_source_ref_for(section_id, Uuid::from_u128(74), "proposal-hash"); - - proposal.source_kind = KnowledgeSourceKind::Proposal.as_str().to_string(); - proposal.source_status = Some("applied".to_string()); - proposal.source_snapshot = serde_json::json!({ - "kind": "proposal", - "proposal_id": proposal.source_id, - "source_refs": [ - { - "kind": "doc_chunk", - "source_ref": { - "policy_spans": [ - { - "schema": "doc_source_span/v1", - "status": "excluded", - "reason_code": "WRITE_POLICY_EXCLUSION" - } - ] - } - } - ], - "source_snapshot": { - "sources": [ - { - "source_snapshot": { - "source_span": { - "schema": "doc_source_span/v1", - "status": "redacted", - "reason_code": "WRITE_POLICY_REDACTION" - } - } - } - ] - }, - "diff": { - "after": { - "source_ref": { - "source_spans": [ - { - "schema": "doc_source_span/v1", - "status": "excluded", - "reason_code": "WRITE_POLICY_EXCLUSION" - } - ] - } - } - } - }); - - let current_keys = current_source_keys_for(&[&proposal]); - - assert!(!knowledge::recallable_source_refs(&[proposal], ¤t_keys)); -} - -#[test] -fn search_item_sanitizes_proposal_citations_and_source_refs() { - let section_id = Uuid::from_u128(75); - let mut source_ref = test_source_ref_for(section_id, Uuid::from_u128(76), "proposal-hash"); - - source_ref.source_kind = KnowledgeSourceKind::Proposal.as_str().to_string(); - source_ref.source_status = Some("applied".to_string()); - source_ref.source_snapshot = serde_json::json!({ - "kind": "proposal", - "proposal_id": source_ref.source_id, - "proposal_kind": "create_derived_note", - "source_refs": [{ "kind": "doc", "source_id": Uuid::from_u128(77) }], - "source_snapshot": { "sources": [{ "source_snapshot": { "text": "private raw source" } }] }, - "lineage": { "parents": ["private"] }, - "diff": { "summary": "private raw diff" }, - "unsupported_claim_flags": [{ "quote": "private raw flag" }], - "target_ref": { "text": "private raw target" } - }); - - let row = KnowledgePageSearchRow { - page_id: Uuid::from_u128(78), - page_kind: "project".to_string(), - page_key: "elf".to_string(), - title: "ELF Knowledge".to_string(), - status: "active".to_string(), - source_coverage: serde_json::json!({ - "source_count": 1, - "cited_source_count": 1, - "coverage_complete": true - }), - rebuild_metadata: serde_json::json!({ "deterministic": true }), - page_updated_at: OffsetDateTime::UNIX_EPOCH, - rebuilt_at: OffsetDateTime::UNIX_EPOCH, - section_id, - section_key: "reviewed-proposals".to_string(), - heading: "Reviewed Proposals".to_string(), - role: "proposals".to_string(), - content: "Applied proposal create_derived_note".to_string(), - ordinal: 0, - citations: serde_json::json!([{ - "source_kind": "proposal", - "source_id": source_ref.source_id, - "source_snapshot": source_ref.source_snapshot.clone() - }]), - unsupported_reason: None, - lint_error_count: 0, - lint_warning_count: 0, - lint_info_count: 0, - section_source_ref_count: 1, - }; - let item = knowledge::knowledge_page_search_item(row, vec![source_ref], "proposal"); - let citation_snapshot = &item.citations[0]["source_snapshot"]; - let source_ref_snapshot = &item.source_refs[0].source_snapshot; - - assert_eq!(citation_snapshot["sanitized"], true); - assert_eq!(source_ref_snapshot["sanitized"], true); - assert!(citation_snapshot.get("source_refs").is_none()); - assert!(citation_snapshot.get("source_snapshot").is_none()); - assert!(citation_snapshot.get("diff").is_none()); - assert!(source_ref_snapshot.get("source_refs").is_none()); - assert!(source_ref_snapshot.get("source_snapshot").is_none()); - assert!(source_ref_snapshot.get("diff").is_none()); -} - -#[test] -fn search_source_refs_suppress_missing_current_sources() { - let section_id = Uuid::from_u128(72); - let source_ref = test_source_ref(section_id); - - assert!(!knowledge::recallable_source_refs(&[source_ref], &BTreeSet::new())); -} - -#[test] -fn source_row_read_allowed_requires_shared_grant_for_other_agent_sources() { - let allowed_scopes = vec!["agent_private".to_string(), "project_shared".to_string()]; - let shared_grants = HashSet::new(); - - assert!(knowledge::source_row_read_allowed( - "owner-agent", - "project_shared", - Some("owner-agent"), - &allowed_scopes, - &shared_grants - )); - assert!(!knowledge::source_row_read_allowed( - "owner-agent", - "project_shared", - Some("reader-agent"), - &allowed_scopes, - &shared_grants - )); - - let shared_grants = HashSet::from([SharedSpaceGrantKey { - scope: "project_shared".to_string(), - space_owner_agent_id: "owner-agent".to_string(), - }]); - - assert!(knowledge::source_row_read_allowed( - "owner-agent", - "project_shared", - Some("reader-agent"), - &allowed_scopes, - &shared_grants - )); -} - -fn test_page() -> KnowledgePage { - KnowledgePage { - page_id: Uuid::from_u128(1), - tenant_id: "tenant".to_string(), - project_id: "project".to_string(), - page_kind: "project".to_string(), - page_key: "elf".to_string(), - title: "ELF".to_string(), - contract_schema: "elf.knowledge_page/v1".to_string(), - status: "active".to_string(), - rebuild_source_hash: "source-hash".to_string(), - content_hash: "content-hash".to_string(), - source_coverage: serde_json::json!({ - "source_count": 2, - "cited_source_count": 1, - "coverage_complete": false - }), - source_snapshot: serde_json::json!({}), - rebuild_metadata: serde_json::json!({}), - created_at: OffsetDateTime::UNIX_EPOCH, - updated_at: OffsetDateTime::UNIX_EPOCH, - rebuilt_at: OffsetDateTime::UNIX_EPOCH, - } -} - -fn test_section( - section_id: Uuid, - section_key: &str, - citations: Value, - unsupported_reason: Option, -) -> KnowledgePageSection { - KnowledgePageSection { - section_id, - page_id: Uuid::from_u128(1), - section_key: section_key.to_string(), - heading: section_key.to_string(), - role: "current_truth".to_string(), - content: "Section content.".to_string(), - ordinal: 0, - citations, - unsupported_reason, - content_hash: "section-hash".to_string(), - created_at: OffsetDateTime::UNIX_EPOCH, - updated_at: OffsetDateTime::UNIX_EPOCH, - } -} - -fn test_source_ref(section_id: Uuid) -> KnowledgePageSourceRef { - test_source_ref_for(section_id, Uuid::from_u128(31), "source-hash") -} - -fn test_source_ref_for( - section_id: Uuid, - source_id: Uuid, - source_hash: &str, -) -> KnowledgePageSourceRef { - KnowledgePageSourceRef { - ref_id: Uuid::from_u128(30), - page_id: Uuid::from_u128(21), - section_id: Some(section_id), - source_kind: "note".to_string(), - source_id, - source_status: Some("active".to_string()), - source_updated_at: Some(OffsetDateTime::UNIX_EPOCH), - source_content_hash: Some(source_hash.to_string()), - source_snapshot: serde_json::json!({ - "schema": "test_source/v1", - "source_id": source_id, - "content_hash": source_hash, - }), - citation_metadata: serde_json::json!({}), - created_at: OffsetDateTime::UNIX_EPOCH, - } -} - -fn current_source_keys_for(source_refs: &[&KnowledgePageSourceRef]) -> BTreeSet { - source_refs - .iter() - .map(|source_ref| { - knowledge::current_key(source_ref.source_kind.as_str(), source_ref.source_id) - }) - .collect() -} - -fn test_page_response(section_id: Uuid, source_id: Uuid) -> KnowledgePageResponse { - let page = test_page(); - let section = test_section( - section_id, - "source-notes", - serde_json::json!([{ "source_kind": "note", "source_id": source_id }]), - None, - ); - let source_ref = test_source_ref_for(section_id, source_id, "new-hash"); - - KnowledgePageResponse { - page: KnowledgePageSummary::from(page), - sections: vec![KnowledgePageSectionResponse { - citation_count: 1, - source_ref_count: 1, - coverage_complete: true, - source_backlinks: Vec::new(), - ..KnowledgePageSectionResponse::from(section) - }], - source_refs: vec![KnowledgePageSourceRefResponse::from(source_ref)], - lint_findings: Vec::new(), - } -} - -fn assert_candidate_is_reviewable(candidate: &KnowledgeDeltaMemoryCandidate) { - assert_eq!(candidate.reason, "changed_claim"); - assert_eq!(candidate.source_refs.len(), 1); - assert_eq!(candidate.source_refs[0].kind.as_str(), "note"); - assert_eq!(candidate.source_snapshot["source_mutation_allowed"], false); - assert_eq!(candidate.diff.after["reason"], "changed_claim"); - assert_eq!(candidate.proposed_payload["type"], "plan"); - assert_eq!(candidate.proposed_payload["source_ref"]["schema"], "elf.knowledge_delta/v1"); -} +mod tests_core; +mod tests_helpers; +mod tests_memory; +mod tests_search; diff --git a/packages/elf-service/src/knowledge/tests_core.rs b/packages/elf-service/src/knowledge/tests_core.rs new file mode 100644 index 00000000..52daa60a --- /dev/null +++ b/packages/elf-service/src/knowledge/tests_core.rs @@ -0,0 +1,241 @@ +use crate::knowledge::{ + self, DraftSection, KnowledgePageKind, KnowledgePageSourceRef, KnowledgeSourceKind, LintDraft, + OffsetDateTime, SourceSnapshot, Uuid, tests::tests_helpers, +}; + +#[test] +fn build_sections_preserves_citations_and_deterministic_hashes() { + let sources = vec![ + tests_helpers::test_source( + KnowledgeSourceKind::Doc, + 1, + "A source document supports the page.", + ), + tests_helpers::test_source( + KnowledgeSourceKind::DocChunk, + 2, + "A source span supports the page.", + ), + tests_helpers::test_source( + KnowledgeSourceKind::Note, + 3, + "A source note supports the page.", + ), + tests_helpers::test_source( + KnowledgeSourceKind::Event, + 4, + "An event audit supports the page.", + ), + tests_helpers::test_source( + KnowledgeSourceKind::Relation, + 5, + "A relation supports the page.", + ), + tests_helpers::test_source( + KnowledgeSourceKind::Proposal, + 6, + "An applied proposal supports the page.", + ), + ]; + let mut first_sections = knowledge::build_sections(&sources).expect("sections should build"); + + for section in &mut first_sections { + section.citations = knowledge::citations_value(section, &sources); + section.content_hash = knowledge::hash_json(&knowledge::section_hash_payload(section)) + .expect("section hash should serialize"); + } + + assert_eq!(first_sections.len(), 6); + assert!(first_sections.iter().all(|section| { + section.citations.as_array().is_some_and(|citations| !citations.is_empty()) + })); + + let coverage = knowledge::source_coverage_value( + KnowledgePageKind::Project, + "elf", + &first_sections, + &sources, + ); + let request = tests_helpers::test_rebuild_request(KnowledgePageKind::Project); + let metadata = knowledge::rebuild_metadata("source-hash", &knowledge::empty_object(), &request); + let first_hash = knowledge::page_content_hash("ELF", &first_sections, &coverage, &metadata) + .expect("page hash should serialize"); + let second_hash = knowledge::page_content_hash("ELF", &first_sections, &coverage, &metadata) + .expect("page hash should serialize"); + + assert_eq!(coverage["coverage_complete"], true); + assert_eq!(metadata["deterministic"], true); + assert_eq!(metadata["memory_candidate_policy"]["direct_memory_ledger_mutation_allowed"], false); + assert_eq!(first_hash, second_hash); +} + +#[test] +fn rebuild_metadata_records_llm_variance() { + let metadata = knowledge::rebuild_metadata( + "source-hash", + &serde_json::json!({ + "llm_derived": true, + "provider_id": "fixture", + "model": "fixture-model", + }), + &tests_helpers::test_rebuild_request(KnowledgePageKind::Timeline), + ); + + assert_eq!(metadata["deterministic"], false); + assert!(metadata["allowed_variance"].as_array().is_some_and(|items| !items.is_empty())); + assert_eq!(metadata["provider_metadata"]["provider_id"], "fixture"); + assert_eq!(metadata["generated_by"]["actor_agent_id"], "agent"); +} + +#[test] +fn generated_titles_cover_author_and_timeline_pages() { + assert_eq!( + knowledge::generated_title(KnowledgePageKind::Author, "ada"), + "Author Knowledge Page: ada" + ); + assert_eq!( + knowledge::generated_title(KnowledgePageKind::Timeline, "release-plan"), + "Timeline Knowledge Page: release-plan" + ); +} + +#[test] +fn previous_version_diff_records_delta_without_changing_content_hash() { + let previous = tests_helpers::test_page(); + let previous_section = tests_helpers::test_section( + Uuid::from_u128(10), + "source-notes", + serde_json::json!([]), + None, + ); + let sections = vec![DraftSection { + section_id: Uuid::from_u128(12), + section_key: "source-notes".to_string(), + heading: "source-notes".to_string(), + role: "current_truth".to_string(), + content: "Updated section content.".to_string(), + ordinal: 0, + source_indexes: vec![0], + unsupported_reason: None, + content_hash: "new-section-hash".to_string(), + citations: serde_json::json!([{ "source_kind": "note" }]), + }]; + let request = tests_helpers::test_rebuild_request(KnowledgePageKind::Project); + let base_metadata = + knowledge::rebuild_metadata("new-source-hash", &knowledge::empty_object(), &request); + let coverage = serde_json::json!({ "coverage_complete": true }); + let hash_without_diff = + knowledge::page_content_hash("ELF", §ions, &coverage, &base_metadata) + .expect("stable hash should serialize"); + let diff = knowledge::previous_version_diff_value( + Some(&previous), + &[previous_section], + "ELF", + "new-source-hash", + hash_without_diff.as_str(), + §ions, + ); + let version_identity = knowledge::version_identity_value( + KnowledgePageKind::Project, + "elf", + "new-source-hash", + hash_without_diff.as_str(), + §ions, + ); + let metadata_with_diff = knowledge::rebuild_metadata_with_previous_version_diff( + base_metadata, + diff.clone(), + version_identity, + ); + let hash_with_diff = + knowledge::page_content_hash("ELF", §ions, &coverage, &metadata_with_diff) + .expect("hash should ignore previous-version diff metadata"); + + assert_eq!(hash_without_diff, hash_with_diff); + assert_eq!(diff["schema"], "elf.knowledge_page.version_diff/v1"); + assert_eq!(diff["available"], true); + assert_eq!(diff["source_mutation_allowed"], false); + assert_eq!(diff["section_changed_count"], 1); + assert_eq!( + knowledge::previous_version_diff_from_metadata(&metadata_with_diff) + .expect("diff should be extractable")["section_changed_count"], + 1 + ); + assert_eq!( + metadata_with_diff["version_identity"]["schema"], + "elf.knowledge_page.version_identity/v1" + ); +} + +#[test] +fn stale_source_comparison_detects_changed_snapshot() { + let source_id = Uuid::from_u128(42); + let stored = KnowledgePageSourceRef { + ref_id: Uuid::from_u128(1), + page_id: Uuid::from_u128(2), + section_id: Some(Uuid::from_u128(3)), + source_kind: "note".to_string(), + source_id, + source_status: Some("active".to_string()), + source_updated_at: Some(OffsetDateTime::UNIX_EPOCH), + source_content_hash: Some("old-hash".to_string()), + source_snapshot: serde_json::json!({}), + citation_metadata: serde_json::json!({}), + created_at: OffsetDateTime::UNIX_EPOCH, + }; + let current = SourceSnapshot { + kind: KnowledgeSourceKind::Note, + id: source_id, + status: Some("active".to_string()), + updated_at: Some(OffsetDateTime::UNIX_EPOCH), + content_hash: Some("new-hash".to_string()), + snapshot: serde_json::json!({}), + citation_metadata: serde_json::json!({}), + line: "Updated note source.".to_string(), + }; + let finding = knowledge::stale_source_finding(&stored, ¤t); + + assert!(knowledge::source_changed(&stored, ¤t)); + assert_eq!(finding.finding_type, "stale_source_ref"); + assert_eq!(finding.source_kind, Some(KnowledgeSourceKind::Note)); + assert_eq!(finding.source_id, Some(source_id)); +} + +#[test] +fn watch_rebuild_outputs_cover_source_update_and_stale_page() { + let section_id = Uuid::from_u128(50); + let source_id = Uuid::from_u128(51); + let section = tests_helpers::test_section( + section_id, + "source-notes", + serde_json::json!([{ "source_kind": "note", "source_id": source_id }]), + None, + ); + let source_ref = tests_helpers::test_source_ref_for(section_id, source_id, "old-hash"); + let lint = vec![LintDraft { + section_id: Some(section_id), + finding_type: "stale_source_ref".to_string(), + severity: "warning".to_string(), + source_kind: Some(KnowledgeSourceKind::Note), + source_id: Some(source_id), + message: "Knowledge page source reference snapshot is stale.".to_string(), + details: serde_json::json!({ "stored": "old", "current": "new" }), + }]; + let diff = serde_json::json!({ + "available": true, + "content_changed": true, + "changed_section_keys": ["source-notes"] + }); + let changed_sources = vec![knowledge::KnowledgePageChangedSource { + source_kind: KnowledgeSourceKind::Note, + source_id, + }]; + let outputs = + knowledge::rebuild_outputs(&[section], &[source_ref], &lint, Some(&diff), &changed_sources); + let output_types = outputs.iter().map(|output| output.output_type.as_str()).collect::>(); + + assert!(output_types.contains(&"stale_section")); + assert!(output_types.contains(&"changed_claim")); + assert!(output_types.contains(&"conflict")); + assert!(output_types.contains(&"changed_source")); +} diff --git a/packages/elf-service/src/knowledge/tests_helpers.rs b/packages/elf-service/src/knowledge/tests_helpers.rs new file mode 100644 index 00000000..4cd39bcb --- /dev/null +++ b/packages/elf-service/src/knowledge/tests_helpers.rs @@ -0,0 +1,170 @@ +use std::collections::BTreeSet; + +use serde_json::Value; + +use crate::knowledge::{ + self, KnowledgeDeltaMemoryCandidate, KnowledgePage, KnowledgePageKind, KnowledgePageResponse, + KnowledgePageSection, KnowledgePageSectionResponse, KnowledgePageSourceRef, + KnowledgePageSourceRefResponse, KnowledgePageSummary, KnowledgeSourceKind, OffsetDateTime, + SourceSnapshot, Uuid, +}; + +pub(super) fn test_source(kind: KnowledgeSourceKind, raw_id: u128, line: &str) -> SourceSnapshot { + let id = Uuid::from_u128(raw_id); + let content_hash = knowledge::hash_text(line); + + SourceSnapshot { + kind, + id, + status: Some("active".to_string()), + updated_at: Some(OffsetDateTime::UNIX_EPOCH), + content_hash: Some(content_hash.clone()), + snapshot: serde_json::json!({ + "kind": kind.as_str(), + "id": id, + "status": "active", + "updated_at": OffsetDateTime::UNIX_EPOCH, + "content_hash": content_hash, + }), + citation_metadata: serde_json::json!({ "fixture": "knowledge_unit" }), + line: line.to_string(), + } +} + +pub(super) fn test_rebuild_request( + page_kind: KnowledgePageKind, +) -> knowledge::KnowledgePageRebuildRequest { + knowledge::KnowledgePageRebuildRequest { + tenant_id: "tenant".to_string(), + project_id: "project".to_string(), + agent_id: "agent".to_string(), + page_kind, + page_key: "elf".to_string(), + title: Some("ELF".to_string()), + doc_ids: Vec::new(), + doc_chunk_ids: Vec::new(), + note_ids: Vec::new(), + event_ids: Vec::new(), + relation_ids: Vec::new(), + proposal_ids: Vec::new(), + provider_metadata: knowledge::empty_object(), + } +} + +pub(super) fn test_page() -> KnowledgePage { + KnowledgePage { + page_id: Uuid::from_u128(1), + tenant_id: "tenant".to_string(), + project_id: "project".to_string(), + page_kind: "project".to_string(), + page_key: "elf".to_string(), + title: "ELF".to_string(), + contract_schema: "elf.knowledge_page/v1".to_string(), + status: "active".to_string(), + rebuild_source_hash: "source-hash".to_string(), + content_hash: "content-hash".to_string(), + source_coverage: serde_json::json!({ + "source_count": 2, + "cited_source_count": 1, + "coverage_complete": false + }), + source_snapshot: serde_json::json!({}), + rebuild_metadata: serde_json::json!({}), + created_at: OffsetDateTime::UNIX_EPOCH, + updated_at: OffsetDateTime::UNIX_EPOCH, + rebuilt_at: OffsetDateTime::UNIX_EPOCH, + } +} + +pub(super) fn test_section( + section_id: Uuid, + section_key: &str, + citations: Value, + unsupported_reason: Option, +) -> KnowledgePageSection { + KnowledgePageSection { + section_id, + page_id: Uuid::from_u128(1), + section_key: section_key.to_string(), + heading: section_key.to_string(), + role: "current_truth".to_string(), + content: "Section content.".to_string(), + ordinal: 0, + citations, + unsupported_reason, + content_hash: "section-hash".to_string(), + created_at: OffsetDateTime::UNIX_EPOCH, + updated_at: OffsetDateTime::UNIX_EPOCH, + } +} + +pub(super) fn test_source_ref(section_id: Uuid) -> KnowledgePageSourceRef { + test_source_ref_for(section_id, Uuid::from_u128(31), "source-hash") +} + +pub(super) fn test_source_ref_for( + section_id: Uuid, + source_id: Uuid, + source_hash: &str, +) -> KnowledgePageSourceRef { + KnowledgePageSourceRef { + ref_id: Uuid::from_u128(30), + page_id: Uuid::from_u128(21), + section_id: Some(section_id), + source_kind: "note".to_string(), + source_id, + source_status: Some("active".to_string()), + source_updated_at: Some(OffsetDateTime::UNIX_EPOCH), + source_content_hash: Some(source_hash.to_string()), + source_snapshot: serde_json::json!({ + "schema": "test_source/v1", + "source_id": source_id, + "content_hash": source_hash, + }), + citation_metadata: serde_json::json!({}), + created_at: OffsetDateTime::UNIX_EPOCH, + } +} + +pub(super) fn current_source_keys_for(source_refs: &[&KnowledgePageSourceRef]) -> BTreeSet { + source_refs + .iter() + .map(|source_ref| { + knowledge::current_key(source_ref.source_kind.as_str(), source_ref.source_id) + }) + .collect() +} + +pub(super) fn test_page_response(section_id: Uuid, source_id: Uuid) -> KnowledgePageResponse { + let page = test_page(); + let section = test_section( + section_id, + "source-notes", + serde_json::json!([{ "source_kind": "note", "source_id": source_id }]), + None, + ); + let source_ref = test_source_ref_for(section_id, source_id, "new-hash"); + + KnowledgePageResponse { + page: KnowledgePageSummary::from(page), + sections: vec![KnowledgePageSectionResponse { + citation_count: 1, + source_ref_count: 1, + coverage_complete: true, + source_backlinks: Vec::new(), + ..KnowledgePageSectionResponse::from(section) + }], + source_refs: vec![KnowledgePageSourceRefResponse::from(source_ref)], + lint_findings: Vec::new(), + } +} + +pub(super) fn assert_candidate_is_reviewable(candidate: &KnowledgeDeltaMemoryCandidate) { + assert_eq!(candidate.reason, "changed_claim"); + assert_eq!(candidate.source_refs.len(), 1); + assert_eq!(candidate.source_refs[0].kind.as_str(), "note"); + assert_eq!(candidate.source_snapshot["source_mutation_allowed"], false); + assert_eq!(candidate.diff.after["reason"], "changed_claim"); + assert_eq!(candidate.proposed_payload["type"], "plan"); + assert_eq!(candidate.proposed_payload["source_ref"]["schema"], "elf.knowledge_delta/v1"); +} diff --git a/packages/elf-service/src/knowledge/tests_memory.rs b/packages/elf-service/src/knowledge/tests_memory.rs new file mode 100644 index 00000000..82d3fea1 --- /dev/null +++ b/packages/elf-service/src/knowledge/tests_memory.rs @@ -0,0 +1,59 @@ +use crate::knowledge::{self, Uuid, tests::tests_helpers}; +use elf_domain::consolidation::ConsolidationApplyIntent; + +#[test] +fn memory_candidate_uses_reviewable_consolidation_proposal_contract() { + let section_id = Uuid::from_u128(60); + let source_id = Uuid::from_u128(61); + let page = tests_helpers::test_page_response(section_id, source_id); + let outputs = vec![knowledge::KnowledgePageRebuildOutput { + output_type: "changed_claim".to_string(), + severity: "info".to_string(), + section_key: Some("source-notes".to_string()), + source_kind: Some("note".to_string()), + source_id: Some(source_id), + message: "Changed section.".to_string(), + details: serde_json::json!({ "reason": "source_update" }), + }]; + let candidates = knowledge::memory_candidates_for_page(&page, &outputs); + + assert_eq!(candidates.len(), 1); + + tests_helpers::assert_candidate_is_reviewable(&candidates[0]); + + let proposal = knowledge::candidate_proposal_input(&candidates[0]); + + assert_eq!(proposal.apply_intent, ConsolidationApplyIntent::CreateDerivedNote); + assert_eq!(proposal.source_refs.len(), 1); + assert_eq!(proposal.proposed_payload["source_ref"]["source_mutation_allowed"], false); + assert_eq!(proposal.proposed_payload["source_ref"]["reason"], "changed_claim"); + assert!(!proposal.markers.staleness.is_empty()); +} + +#[test] +fn lint_page_sections_detects_unsupported_missing_and_low_coverage() { + let page = tests_helpers::test_page(); + let unsupported = tests_helpers::test_section( + Uuid::from_u128(10), + "unsupported", + serde_json::json!([]), + Some("No source supports this claim.".to_string()), + ); + let missing = + tests_helpers::test_section(Uuid::from_u128(11), "missing", serde_json::json!([]), None); + let findings = knowledge::lint_page_sections(&page, &[unsupported, missing], &[]); + let finding_types = + findings.iter().map(|finding| finding.finding_type.as_str()).collect::>(); + + assert!(finding_types.contains(&"unsupported_claim")); + assert!(finding_types.contains(&"missing_citation")); + assert!(finding_types.contains(&"missing_source_ref")); + assert!(finding_types.contains(&"low_source_coverage")); + assert!(findings.iter().all(|finding| { + finding + .details + .get("repair_guidance") + .and_then(serde_json::Value::as_str) + .is_some_and(|guidance| !guidance.is_empty()) + })); +} diff --git a/packages/elf-service/src/knowledge/tests_search.rs b/packages/elf-service/src/knowledge/tests_search.rs new file mode 100644 index 00000000..6ba37424 --- /dev/null +++ b/packages/elf-service/src/knowledge/tests_search.rs @@ -0,0 +1,296 @@ +use std::{ + collections::{BTreeSet, HashSet}, + slice, +}; + +use crate::{ + access::SharedSpaceGrantKey, + knowledge::{ + self, KnowledgePageSearchRow, KnowledgeSourceKind, OffsetDateTime, Uuid, + tests::tests_helpers, + }, +}; + +#[test] +fn search_item_marks_derived_page_snippet_with_provenance() { + let section_id = Uuid::from_u128(20); + let source_ref = tests_helpers::test_source_ref(section_id); + let row = KnowledgePageSearchRow { + page_id: Uuid::from_u128(21), + page_kind: "project".to_string(), + page_key: "elf".to_string(), + title: "ELF Knowledge".to_string(), + status: "active".to_string(), + source_coverage: serde_json::json!({ + "source_count": 1, + "cited_source_count": 1, + "coverage_complete": true + }), + rebuild_metadata: serde_json::json!({ "deterministic": true }), + page_updated_at: OffsetDateTime::UNIX_EPOCH, + rebuilt_at: OffsetDateTime::UNIX_EPOCH, + section_id, + section_key: "source-notes".to_string(), + heading: "Source Notes".to_string(), + role: "current_truth".to_string(), + content: "Derived knowledge pages cite source notes before they are trusted.".to_string(), + ordinal: 0, + citations: serde_json::json!([{ "source_kind": "note", "source_id": source_ref.source_id }]), + unsupported_reason: None, + lint_error_count: 0, + lint_warning_count: 1, + lint_info_count: 0, + section_source_ref_count: 1, + }; + let item = knowledge::knowledge_page_search_item(row, vec![source_ref], "source notes"); + + assert_eq!(item.result_kind, "knowledge_page_section"); + assert_eq!(item.trust_state, "derived_warning"); + assert_eq!(item.citation_count, 1); + assert_eq!(item.source_ref_count, 1); + assert_eq!(item.source_refs.len(), 1); + assert!(item.derived_notice.contains("Derived knowledge page snippet")); + assert!(item.repair_guidance.is_some()); + assert!(item.snippet.contains("source notes")); +} + +#[test] +fn search_source_refs_suppress_deleted_and_unreviewed_sources() { + let section_id = Uuid::from_u128(70); + let mut active = tests_helpers::test_source_ref(section_id); + let mut deleted = tests_helpers::test_source_ref(section_id); + let mut ignored = tests_helpers::test_source_ref(section_id); + let current_keys = tests_helpers::current_source_keys_for(&[&active, &deleted, &ignored]); + + deleted.source_status = Some("deleted".to_string()); + ignored.source_status = Some("ignore".to_string()); + + assert!(knowledge::recallable_source_refs(slice::from_ref(&active), ¤t_keys)); + assert!(!knowledge::recallable_source_refs(&[deleted], ¤t_keys)); + assert!(!knowledge::recallable_source_refs(&[ignored], ¤t_keys)); + + active.source_status = None; + + assert!(!knowledge::recallable_source_refs(&[active], ¤t_keys)); +} + +#[test] +fn search_source_refs_suppress_non_captured_spans() { + let section_id = Uuid::from_u128(71); + let mut excluded = tests_helpers::test_source_ref(section_id); + let mut source_ref_span = tests_helpers::test_source_ref(section_id); + let mut policy_span = tests_helpers::test_source_ref(section_id); + let mut malformed_span = tests_helpers::test_source_ref(section_id); + let current_keys = tests_helpers::current_source_keys_for(&[ + &excluded, + &source_ref_span, + &policy_span, + &malformed_span, + ]); + + excluded.source_snapshot = serde_json::json!({ + "source_span": { + "schema": "doc_source_span/v1", + "status": "excluded", + "reason_code": "WRITE_POLICY_EXCLUSION" + } + }); + source_ref_span.source_snapshot = serde_json::json!({ + "source_ref": { + "source_spans": [ + { + "schema": "doc_source_span/v1", + "status": "redacted", + "reason_code": "WRITE_POLICY_REDACTION" + } + ] + } + }); + policy_span.source_snapshot = serde_json::json!({ + "source_ref": { + "policy_spans": [ + { + "schema": "doc_source_span/v1", + "status": "excluded", + "reason_code": "WRITE_POLICY_EXCLUSION" + } + ] + } + }); + malformed_span.source_snapshot = serde_json::json!({ + "source_span": { + "schema": "doc_source_span/v1", + "reason_code": "WRITE_POLICY_REDACTION" + } + }); + + assert!(!knowledge::recallable_source_refs(&[excluded], ¤t_keys)); + assert!(!knowledge::recallable_source_refs(&[source_ref_span], ¤t_keys)); + assert!(!knowledge::recallable_source_refs(&[policy_span], ¤t_keys)); + assert!(!knowledge::recallable_source_refs(&[malformed_span], ¤t_keys)); +} + +#[test] +fn search_source_refs_suppress_nested_proposal_non_captured_spans() { + let section_id = Uuid::from_u128(73); + let mut proposal = + tests_helpers::test_source_ref_for(section_id, Uuid::from_u128(74), "proposal-hash"); + + proposal.source_kind = KnowledgeSourceKind::Proposal.as_str().to_string(); + proposal.source_status = Some("applied".to_string()); + proposal.source_snapshot = serde_json::json!({ + "kind": "proposal", + "proposal_id": proposal.source_id, + "source_refs": [ + { + "kind": "doc_chunk", + "source_ref": { + "policy_spans": [ + { + "schema": "doc_source_span/v1", + "status": "excluded", + "reason_code": "WRITE_POLICY_EXCLUSION" + } + ] + } + } + ], + "source_snapshot": { + "sources": [ + { + "source_snapshot": { + "source_span": { + "schema": "doc_source_span/v1", + "status": "redacted", + "reason_code": "WRITE_POLICY_REDACTION" + } + } + } + ] + }, + "diff": { + "after": { + "source_ref": { + "source_spans": [ + { + "schema": "doc_source_span/v1", + "status": "excluded", + "reason_code": "WRITE_POLICY_EXCLUSION" + } + ] + } + } + } + }); + + let current_keys = tests_helpers::current_source_keys_for(&[&proposal]); + + assert!(!knowledge::recallable_source_refs(&[proposal], ¤t_keys)); +} + +#[test] +fn search_item_sanitizes_proposal_citations_and_source_refs() { + let section_id = Uuid::from_u128(75); + let mut source_ref = + tests_helpers::test_source_ref_for(section_id, Uuid::from_u128(76), "proposal-hash"); + + source_ref.source_kind = KnowledgeSourceKind::Proposal.as_str().to_string(); + source_ref.source_status = Some("applied".to_string()); + source_ref.source_snapshot = serde_json::json!({ + "kind": "proposal", + "proposal_id": source_ref.source_id, + "proposal_kind": "create_derived_note", + "source_refs": [{ "kind": "doc", "source_id": Uuid::from_u128(77) }], + "source_snapshot": { "sources": [{ "source_snapshot": { "text": "private raw source" } }] }, + "lineage": { "parents": ["private"] }, + "diff": { "summary": "private raw diff" }, + "unsupported_claim_flags": [{ "quote": "private raw flag" }], + "target_ref": { "text": "private raw target" } + }); + + let row = KnowledgePageSearchRow { + page_id: Uuid::from_u128(78), + page_kind: "project".to_string(), + page_key: "elf".to_string(), + title: "ELF Knowledge".to_string(), + status: "active".to_string(), + source_coverage: serde_json::json!({ + "source_count": 1, + "cited_source_count": 1, + "coverage_complete": true + }), + rebuild_metadata: serde_json::json!({ "deterministic": true }), + page_updated_at: OffsetDateTime::UNIX_EPOCH, + rebuilt_at: OffsetDateTime::UNIX_EPOCH, + section_id, + section_key: "reviewed-proposals".to_string(), + heading: "Reviewed Proposals".to_string(), + role: "proposals".to_string(), + content: "Applied proposal create_derived_note".to_string(), + ordinal: 0, + citations: serde_json::json!([{ + "source_kind": "proposal", + "source_id": source_ref.source_id, + "source_snapshot": source_ref.source_snapshot.clone() + }]), + unsupported_reason: None, + lint_error_count: 0, + lint_warning_count: 0, + lint_info_count: 0, + section_source_ref_count: 1, + }; + let item = knowledge::knowledge_page_search_item(row, vec![source_ref], "proposal"); + let citation_snapshot = &item.citations[0]["source_snapshot"]; + let source_ref_snapshot = &item.source_refs[0].source_snapshot; + + assert_eq!(citation_snapshot["sanitized"], true); + assert_eq!(source_ref_snapshot["sanitized"], true); + assert!(citation_snapshot.get("source_refs").is_none()); + assert!(citation_snapshot.get("source_snapshot").is_none()); + assert!(citation_snapshot.get("diff").is_none()); + assert!(source_ref_snapshot.get("source_refs").is_none()); + assert!(source_ref_snapshot.get("source_snapshot").is_none()); + assert!(source_ref_snapshot.get("diff").is_none()); +} + +#[test] +fn search_source_refs_suppress_missing_current_sources() { + let section_id = Uuid::from_u128(72); + let source_ref = tests_helpers::test_source_ref(section_id); + + assert!(!knowledge::recallable_source_refs(&[source_ref], &BTreeSet::new())); +} + +#[test] +fn source_row_read_allowed_requires_shared_grant_for_other_agent_sources() { + let allowed_scopes = vec!["agent_private".to_string(), "project_shared".to_string()]; + let shared_grants = HashSet::new(); + + assert!(knowledge::source_row_read_allowed( + "owner-agent", + "project_shared", + Some("owner-agent"), + &allowed_scopes, + &shared_grants + )); + assert!(!knowledge::source_row_read_allowed( + "owner-agent", + "project_shared", + Some("reader-agent"), + &allowed_scopes, + &shared_grants + )); + + let shared_grants = HashSet::from([SharedSpaceGrantKey { + scope: "project_shared".to_string(), + space_owner_agent_id: "owner-agent".to_string(), + }]); + + assert!(knowledge::source_row_read_allowed( + "owner-agent", + "project_shared", + Some("reader-agent"), + &allowed_scopes, + &shared_grants + )); +} diff --git a/packages/elf-service/src/recall_debug/tests.rs b/packages/elf-service/src/recall_debug/tests.rs index 0d13b2c5..045425ce 100644 --- a/packages/elf-service/src/recall_debug/tests.rs +++ b/packages/elf-service/src/recall_debug/tests.rs @@ -1,550 +1,4 @@ -use std::collections::{BTreeMap, HashSet}; - -use serde_json::Value; -use time::OffsetDateTime; - -use crate::{ - RecallDebugRow, - access::SharedSpaceGrantKey, - recall_debug::{self, BTreeSet, Error, NoteDebugSourceRow, Uuid}, - search::{SearchTrace, SearchTrajectoryStage, TraceReplayCandidate}, -}; -use elf_storage::models::MemoryNote; - -#[test] -fn summary_preserves_not_requested_and_replay_counts() { - let layers = vec![ - recall_debug::not_requested_layer("graph_facts", "missing graph subject"), - recall_debug::layer_from_rows( - "memory_notes", - "pass", - Some("trace".to_string()), - "trace rows", - vec![ - RecallDebugRow { - layer: "memory_notes".to_string(), - item_ref: serde_json::json!({"note_id": "n1"}), - selection_state: "selected".to_string(), - authority_layer: "memory_note".to_string(), - freshness_state: "active".to_string(), - source_refs: serde_json::json!([]), - score: Some(1.0), - rank: Some(1), - rationale: None, - stage_reason: None, - replay_command: Some("elf_admin_trace_bundle_get".to_string()), - evidence_class: "pass".to_string(), - debug_artifacts: serde_json::json!({}), - }, - RecallDebugRow { - layer: "memory_notes".to_string(), - item_ref: serde_json::json!({"note_id": "n2"}), - selection_state: "dropped".to_string(), - authority_layer: "memory_note".to_string(), - freshness_state: "active".to_string(), - source_refs: serde_json::json!([]), - score: Some(0.5), - rank: Some(2), - rationale: None, - stage_reason: Some("not_in_final_top_k".to_string()), - replay_command: Some("elf_admin_trace_bundle_get".to_string()), - evidence_class: "pass".to_string(), - debug_artifacts: serde_json::json!({}), - }, - ], - ), - ]; - let summary = recall_debug::summarize_layers(&layers); - - assert_eq!(summary.layer_count, 2); - assert_eq!(summary.row_count, 2); - assert_eq!(summary.selected_count, 1); - assert_eq!(summary.dropped_count, 1); - assert_eq!(summary.not_requested_layer_count, 1); - assert_eq!(summary.replay_command_count, 2); - assert_eq!(summary.evidence_class_counts.get("pass"), Some(&1)); - assert_eq!(summary.evidence_class_counts.get("not_requested"), Some(&1)); -} - -#[test] -fn not_requested_layers_never_require_raw_sql() { - let layer = recall_debug::not_requested_layer("source_documents", "missing query"); - - assert_eq!(layer.evidence_class, "not_requested"); - assert_eq!(layer.row_count, 0); - assert!(!layer.raw_sql_needed); - assert!(!layer.replayable); -} - -#[test] -fn blocked_layers_are_counted_as_incomplete_evidence() { - let layer = recall_debug::blocked_layer( - "source_documents", - Some("alpha".to_string()), - "docs search failed", - &Error::Storage { message: "database unavailable".to_string() }, - ); - let summary = recall_debug::summarize_layers(&[layer]); - - assert_eq!(summary.layer_count, 1); - assert_eq!(summary.incomplete_layer_count, 1); - assert_eq!(summary.evidence_class_counts.get("blocked"), Some(&1)); -} - -#[test] -fn blocked_layer_does_not_expose_raw_backend_errors() { - let layer = recall_debug::blocked_layer( - "graph_facts", - None, - "graph report failed", - &Error::Storage { message: "password=secret host=db.internal".to_string() }, - ); - - assert!(layer.summary.contains("error_class=storage_unavailable")); - assert!(!layer.summary.contains("password=secret")); - assert!(!layer.summary.contains("db.internal")); -} - -#[test] -fn selected_candidate_filter_is_chunk_level() { - let note_id = Uuid::new_v4(); - let selected_chunk_id = Uuid::new_v4(); - let dropped_chunk_id = Uuid::new_v4(); - let selected = BTreeSet::from([recall_debug::candidate_identity(note_id, selected_chunk_id)]); - - assert!(selected.contains(&recall_debug::candidate_identity(note_id, selected_chunk_id))); - assert!(!selected.contains(&recall_debug::candidate_identity(note_id, dropped_chunk_id))); -} - -fn compact_replay_trace(trace_id: Uuid, now: OffsetDateTime) -> SearchTrace { - SearchTrace { - trace_id, - tenant_id: "tenant".to_string(), - project_id: "project".to_string(), - agent_id: "agent".to_string(), - read_profile: "private_plus_project".to_string(), - query: "release handoff".to_string(), - expansion_mode: "dynamic".to_string(), - expanded_queries: vec!["release handoff".to_string(), "owner transfer".to_string()], - allowed_scopes: vec!["agent_private".to_string(), "project_shared".to_string()], - candidate_count: 2, - top_k: 1, - config_snapshot: serde_json::json!({ - "search": { - "expansion": { - "mode": "dynamic", - "max_queries": 3, - "include_original": true - } - }, - "ranking": { - "policy_id": "ranking_v2:test", - "blend": { - "enabled": true - }, - "diversity": { - "enabled": true - }, - "retrieval_sources": { - "fusion_weight": 1.0 - }, - "override": { - "blend": { - "enabled": false - } - } - } - }), - created_at: now, - trace_version: 3, - } -} - -fn compact_replay_stages() -> Vec { - vec![ - SearchTrajectoryStage { - stage_order: 2, - stage_name: "recall.candidates".to_string(), - stage_payload: serde_json::json!({ - "stats": { - "candidate_count_before_filter": 2, - "candidate_count_after_filter": 2 - } - }), - items: Vec::new(), - }, - SearchTrajectoryStage { - stage_order: 4, - stage_name: "rerank.score".to_string(), - stage_payload: serde_json::json!({ - "stats": { - "reranked_count": 2 - }, - "decisions": { - "blend_enabled": true, - "diversity_enabled": true - } - }), - items: Vec::new(), - }, - ] -} - -fn compact_replay_selected_candidate( - note_id: Uuid, - chunk_id: Uuid, - now: OffsetDateTime, -) -> TraceReplayCandidate { - TraceReplayCandidate { - note_id, - chunk_id, - chunk_index: 0, - snippet: "selected".to_string(), - retrieval_rank: 2, - retrieval_score: Some(0.4), - rerank_score: 0.9, - note_scope: "project_shared".to_string(), - note_importance: 0.7, - note_updated_at: now, - note_hit_count: 0, - note_last_hit_at: None, - diversity_selected: Some(true), - diversity_selected_rank: Some(1), - diversity_selected_reason: Some("mmr".to_string()), - diversity_skipped_reason: None, - diversity_nearest_selected_note_id: None, - diversity_similarity: None, - diversity_mmr_score: Some(0.8), - diversity_missing_embedding: Some(false), - } -} - -fn compact_replay_dropped_candidate( - note_id: Uuid, - chunk_id: Uuid, - selected_note_id: Uuid, - now: OffsetDateTime, -) -> TraceReplayCandidate { - TraceReplayCandidate { - note_id, - chunk_id, - chunk_index: 0, - snippet: "dropped".to_string(), - retrieval_rank: 1, - retrieval_score: Some(0.8), - rerank_score: 0.1, - note_scope: "project_shared".to_string(), - note_importance: 0.3, - note_updated_at: now, - note_hit_count: 0, - note_last_hit_at: None, - diversity_selected: Some(false), - diversity_selected_rank: None, - diversity_selected_reason: None, - diversity_skipped_reason: Some("not_in_final_top_k".to_string()), - diversity_nearest_selected_note_id: Some(selected_note_id), - diversity_similarity: Some(0.92), - diversity_mmr_score: Some(0.1), - diversity_missing_embedding: Some(false), - } -} - -fn compact_replay_source_refs( - selected_note_id: Uuid, - dropped_note_id: Uuid, - now: OffsetDateTime, -) -> BTreeMap { - BTreeMap::from([ - ( - selected_note_id, - NoteDebugSourceRow { - status: "active".to_string(), - source_ref: serde_json::json!({"schema": "source_ref/v1", "ref": {"id": "selected"}}), - updated_at: now, - }, - ), - ( - dropped_note_id, - NoteDebugSourceRow { - status: "active".to_string(), - source_ref: serde_json::json!({"schema": "source_ref/v1", "ref": {"id": "dropped"}}), - updated_at: now, - }, - ), - ]) -} - -fn assert_compact_replay_artifact(artifact: &Value) { - assert_eq!( - artifact.pointer("/schema").and_then(serde_json::Value::as_str), - Some("elf.recall_debug.compact_replay/v1") - ); - assert_eq!(artifact.pointer("/controls/top_k").and_then(serde_json::Value::as_u64), Some(1)); - assert_eq!( - artifact.pointer("/controls/expanded_query_count").and_then(serde_json::Value::as_u64), - Some(2) - ); - assert_eq!( - artifact.pointer("/controls/ranking/policy_id").and_then(serde_json::Value::as_str), - Some("ranking_v2:test") - ); - assert_eq!( - artifact.pointer("/stage_movement/1/stage_name").and_then(serde_json::Value::as_str), - Some("rerank.score") - ); - assert_eq!( - artifact.pointer("/candidate_replay/selected_count").and_then(serde_json::Value::as_u64), - Some(1) - ); - assert_eq!( - artifact - .pointer("/candidate_replay/rows/0/selection_state") - .and_then(serde_json::Value::as_str), - Some("selected") - ); - assert_eq!( - artifact - .pointer("/candidate_replay/rows/0/source_ref_available") - .and_then(serde_json::Value::as_bool), - Some(true) - ); - assert_eq!( - artifact - .pointer("/candidate_replay/rows/0/rerank_delta") - .and_then(serde_json::Value::as_i64), - Some(1) - ); - assert_eq!( - artifact - .pointer("/candidate_replay/rows/0/policy_reason") - .and_then(serde_json::Value::as_str), - Some("mmr") - ); - assert_eq!( - artifact - .pointer("/candidate_replay/rows/1/selection_state") - .and_then(serde_json::Value::as_str), - Some("dropped") - ); - assert_eq!( - artifact.pointer("/authority/raw_sql_needed").and_then(serde_json::Value::as_bool), - Some(false) - ); -} - -#[test] -fn compact_replay_artifact_exposes_controls_stage_movement_and_rerank_effects() { - let trace_id = Uuid::new_v4(); - let selected_note_id = Uuid::new_v4(); - let selected_chunk_id = Uuid::new_v4(); - let dropped_note_id = Uuid::new_v4(); - let dropped_chunk_id = Uuid::new_v4(); - let now = OffsetDateTime::from_unix_timestamp(0).expect("Valid timestamp."); - let candidates = vec![ - compact_replay_selected_candidate(selected_note_id, selected_chunk_id, now), - compact_replay_dropped_candidate(dropped_note_id, dropped_chunk_id, selected_note_id, now), - ]; - let selected = - BTreeSet::from([recall_debug::candidate_identity(selected_note_id, selected_chunk_id)]); - let source_refs = compact_replay_source_refs(selected_note_id, dropped_note_id, now); - let artifact = recall_debug::memory_compact_replay_artifact( - &compact_replay_trace(trace_id, now), - compact_replay_stages().as_slice(), - candidates.as_slice(), - &[], - &selected, - &source_refs, - "elf_admin_trace_bundle_get trace_id= mode=bounded", - ); - - assert_compact_replay_artifact(&artifact); -} - -#[test] -fn debug_note_readability_requires_current_note_and_scope_access() { - let allowed_scopes = vec!["agent_private".to_string(), "project_shared".to_string()]; - let shared_grants = HashSet::new(); - let now = OffsetDateTime::now_utc(); - let mut note = note_for_debug_visibility("owner-agent", "agent_private", "active"); - - assert!(recall_debug::note_debug_read_allowed( - ¬e, - "owner-agent", - &allowed_scopes, - &shared_grants, - now - )); - assert!(!recall_debug::note_debug_read_allowed( - ¬e, - "other-agent", - &allowed_scopes, - &shared_grants, - now - )); - - note.status = "deleted".to_string(); - - assert!(!recall_debug::note_debug_read_allowed( - ¬e, - "owner-agent", - &allowed_scopes, - &shared_grants, - now - )); - - note.status = "deprecated".to_string(); - - assert!(!recall_debug::note_debug_read_allowed( - ¬e, - "owner-agent", - &allowed_scopes, - &shared_grants, - now - )); - - note.status = "active".to_string(); - note.expires_at = Some(now); - - assert!(!recall_debug::note_debug_read_allowed( - ¬e, - "owner-agent", - &allowed_scopes, - &shared_grants, - now - )); - - note.expires_at = None; - note.scope = "project_shared".to_string(); - - assert!(!recall_debug::note_debug_read_allowed( - ¬e, - "other-agent", - &allowed_scopes, - &shared_grants, - now - )); - - let shared_grants = HashSet::from([SharedSpaceGrantKey { - scope: "project_shared".to_string(), - space_owner_agent_id: "owner-agent".to_string(), - }]); - - assert!(recall_debug::note_debug_read_allowed( - ¬e, - "other-agent", - &allowed_scopes, - &shared_grants, - now - )); -} - -#[test] -fn recall_trace_flattens_stale_and_dropped_context() { - let layers = vec![ - recall_debug::layer_from_rows( - "memory_notes", - "pass", - Some("trace".to_string()), - "trace rows", - vec![ - RecallDebugRow { - layer: "memory_notes".to_string(), - item_ref: serde_json::json!({"note_id": "selected-stale"}), - selection_state: "selected".to_string(), - authority_layer: "memory_note".to_string(), - freshness_state: "deprecated".to_string(), - source_refs: serde_json::json!([{"schema": "source_ref/v1"}]), - score: Some(0.9), - rank: Some(1), - rationale: Some("selected but stale".to_string()), - stage_reason: Some("status=deprecated".to_string()), - replay_command: Some("elf_trace".to_string()), - evidence_class: "pass".to_string(), - debug_artifacts: serde_json::json!({}), - }, - RecallDebugRow { - layer: "memory_notes".to_string(), - item_ref: serde_json::json!({"note_id": "dropped"}), - selection_state: "dropped".to_string(), - authority_layer: "memory_note".to_string(), - freshness_state: "active".to_string(), - source_refs: serde_json::json!([]), - score: Some(0.4), - rank: Some(4), - rationale: Some("candidate not narrated".to_string()), - stage_reason: Some("not_in_final_top_k".to_string()), - replay_command: Some("elf_trace".to_string()), - evidence_class: "pass".to_string(), - debug_artifacts: serde_json::json!({}), - }, - ], - ), - recall_debug::not_requested_layer("graph_facts", "missing graph subject"), - ]; - let trace = recall_debug::build_recall_trace(&layers); - - assert_eq!(trace.schema, "elf.recall_trace/v1"); - assert_eq!(trace.summary.entry_count, 3); - assert_eq!(trace.summary.selected_count, 1); - assert_eq!(trace.summary.dropped_count, 1); - assert_eq!(trace.summary.stale_count, 1); - assert_eq!(trace.summary.not_requested_count, 1); - assert_eq!(trace.summary.replay_command_count, 2); - assert_eq!(trace.entries[0].context_state, "stale"); - assert_eq!(trace.entries[0].policy_reason.as_deref(), Some("status=deprecated")); - assert_eq!(trace.entries[1].context_state, "dropped"); - assert_eq!(trace.entries[1].policy_reason.as_deref(), Some("not_in_final_top_k")); - assert_eq!(trace.entries[2].context_state, "not_requested"); -} - -#[test] -fn recall_trace_counts_blocked_layers_without_backend_details() { - let layer = recall_debug::blocked_layer( - "source_documents", - Some("alpha".to_string()), - "docs search failed", - &Error::Storage { message: "password=secret host=db.internal".to_string() }, - ); - let trace = recall_debug::build_recall_trace(&[layer]); - - assert_eq!(trace.summary.blocked_count, 1); - assert_eq!(trace.entries[0].context_state, "blocked"); - assert_eq!(trace.entries[0].selection_state, "blocked"); - assert!( - trace.entries[0] - .policy_reason - .as_deref() - .is_some_and(|reason| reason.contains("error_class=storage_unavailable")) - ); - assert!( - trace.entries[0] - .policy_reason - .as_deref() - .is_some_and(|reason| !reason.contains("password=secret")) - ); -} - -fn note_for_debug_visibility(agent_id: &str, scope: &str, status: &str) -> MemoryNote { - let now = OffsetDateTime::now_utc(); - - MemoryNote { - note_id: Uuid::new_v4(), - tenant_id: "tenant-a".to_string(), - project_id: "project-a".to_string(), - agent_id: agent_id.to_string(), - scope: scope.to_string(), - r#type: "fact".to_string(), - key: None, - text: "Fact: debug visibility test note.".to_string(), - importance: 0.7, - confidence: 0.9, - status: status.to_string(), - created_at: now, - updated_at: now, - expires_at: None, - embedding_version: "test:v1".to_string(), - source_ref: serde_json::json!({"schema": "source_ref/v1"}), - hit_count: 0, - last_hit_at: None, - } -} +mod tests_helpers; +mod tests_layers; +mod tests_replay; +mod tests_visibility; diff --git a/packages/elf-service/src/recall_debug/tests_helpers.rs b/packages/elf-service/src/recall_debug/tests_helpers.rs new file mode 100644 index 00000000..61163c92 --- /dev/null +++ b/packages/elf-service/src/recall_debug/tests_helpers.rs @@ -0,0 +1,251 @@ +use std::collections::BTreeMap; + +use serde_json::Value; +use time::OffsetDateTime; + +use crate::{ + recall_debug::{NoteDebugSourceRow, Uuid}, + search::{SearchTrace, SearchTrajectoryStage, TraceReplayCandidate}, +}; +use elf_storage::models::MemoryNote; + +pub(super) fn compact_replay_trace(trace_id: Uuid, now: OffsetDateTime) -> SearchTrace { + SearchTrace { + trace_id, + tenant_id: "tenant".to_string(), + project_id: "project".to_string(), + agent_id: "agent".to_string(), + read_profile: "private_plus_project".to_string(), + query: "release handoff".to_string(), + expansion_mode: "dynamic".to_string(), + expanded_queries: vec!["release handoff".to_string(), "owner transfer".to_string()], + allowed_scopes: vec!["agent_private".to_string(), "project_shared".to_string()], + candidate_count: 2, + top_k: 1, + config_snapshot: serde_json::json!({ + "search": { + "expansion": { + "mode": "dynamic", + "max_queries": 3, + "include_original": true + } + }, + "ranking": { + "policy_id": "ranking_v2:test", + "blend": { + "enabled": true + }, + "diversity": { + "enabled": true + }, + "retrieval_sources": { + "fusion_weight": 1.0 + }, + "override": { + "blend": { + "enabled": false + } + } + } + }), + created_at: now, + trace_version: 3, + } +} + +pub(super) fn compact_replay_stages() -> Vec { + vec![ + SearchTrajectoryStage { + stage_order: 2, + stage_name: "recall.candidates".to_string(), + stage_payload: serde_json::json!({ + "stats": { + "candidate_count_before_filter": 2, + "candidate_count_after_filter": 2 + } + }), + items: Vec::new(), + }, + SearchTrajectoryStage { + stage_order: 4, + stage_name: "rerank.score".to_string(), + stage_payload: serde_json::json!({ + "stats": { + "reranked_count": 2 + }, + "decisions": { + "blend_enabled": true, + "diversity_enabled": true + } + }), + items: Vec::new(), + }, + ] +} + +pub(super) fn compact_replay_selected_candidate( + note_id: Uuid, + chunk_id: Uuid, + now: OffsetDateTime, +) -> TraceReplayCandidate { + TraceReplayCandidate { + note_id, + chunk_id, + chunk_index: 0, + snippet: "selected".to_string(), + retrieval_rank: 2, + retrieval_score: Some(0.4), + rerank_score: 0.9, + note_scope: "project_shared".to_string(), + note_importance: 0.7, + note_updated_at: now, + note_hit_count: 0, + note_last_hit_at: None, + diversity_selected: Some(true), + diversity_selected_rank: Some(1), + diversity_selected_reason: Some("mmr".to_string()), + diversity_skipped_reason: None, + diversity_nearest_selected_note_id: None, + diversity_similarity: None, + diversity_mmr_score: Some(0.8), + diversity_missing_embedding: Some(false), + } +} + +pub(super) fn compact_replay_dropped_candidate( + note_id: Uuid, + chunk_id: Uuid, + selected_note_id: Uuid, + now: OffsetDateTime, +) -> TraceReplayCandidate { + TraceReplayCandidate { + note_id, + chunk_id, + chunk_index: 0, + snippet: "dropped".to_string(), + retrieval_rank: 1, + retrieval_score: Some(0.8), + rerank_score: 0.1, + note_scope: "project_shared".to_string(), + note_importance: 0.3, + note_updated_at: now, + note_hit_count: 0, + note_last_hit_at: None, + diversity_selected: Some(false), + diversity_selected_rank: None, + diversity_selected_reason: None, + diversity_skipped_reason: Some("not_in_final_top_k".to_string()), + diversity_nearest_selected_note_id: Some(selected_note_id), + diversity_similarity: Some(0.92), + diversity_mmr_score: Some(0.1), + diversity_missing_embedding: Some(false), + } +} + +pub(super) fn compact_replay_source_refs( + selected_note_id: Uuid, + dropped_note_id: Uuid, + now: OffsetDateTime, +) -> BTreeMap { + BTreeMap::from([ + ( + selected_note_id, + NoteDebugSourceRow { + status: "active".to_string(), + source_ref: serde_json::json!({"schema": "source_ref/v1", "ref": {"id": "selected"}}), + updated_at: now, + }, + ), + ( + dropped_note_id, + NoteDebugSourceRow { + status: "active".to_string(), + source_ref: serde_json::json!({"schema": "source_ref/v1", "ref": {"id": "dropped"}}), + updated_at: now, + }, + ), + ]) +} + +pub(super) fn assert_compact_replay_artifact(artifact: &Value) { + assert_eq!( + artifact.pointer("/schema").and_then(serde_json::Value::as_str), + Some("elf.recall_debug.compact_replay/v1") + ); + assert_eq!(artifact.pointer("/controls/top_k").and_then(serde_json::Value::as_u64), Some(1)); + assert_eq!( + artifact.pointer("/controls/expanded_query_count").and_then(serde_json::Value::as_u64), + Some(2) + ); + assert_eq!( + artifact.pointer("/controls/ranking/policy_id").and_then(serde_json::Value::as_str), + Some("ranking_v2:test") + ); + assert_eq!( + artifact.pointer("/stage_movement/1/stage_name").and_then(serde_json::Value::as_str), + Some("rerank.score") + ); + assert_eq!( + artifact.pointer("/candidate_replay/selected_count").and_then(serde_json::Value::as_u64), + Some(1) + ); + assert_eq!( + artifact + .pointer("/candidate_replay/rows/0/selection_state") + .and_then(serde_json::Value::as_str), + Some("selected") + ); + assert_eq!( + artifact + .pointer("/candidate_replay/rows/0/source_ref_available") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + artifact + .pointer("/candidate_replay/rows/0/rerank_delta") + .and_then(serde_json::Value::as_i64), + Some(1) + ); + assert_eq!( + artifact + .pointer("/candidate_replay/rows/0/policy_reason") + .and_then(serde_json::Value::as_str), + Some("mmr") + ); + assert_eq!( + artifact + .pointer("/candidate_replay/rows/1/selection_state") + .and_then(serde_json::Value::as_str), + Some("dropped") + ); + assert_eq!( + artifact.pointer("/authority/raw_sql_needed").and_then(serde_json::Value::as_bool), + Some(false) + ); +} + +pub(super) fn note_for_debug_visibility(agent_id: &str, scope: &str, status: &str) -> MemoryNote { + let now = OffsetDateTime::now_utc(); + + MemoryNote { + note_id: Uuid::new_v4(), + tenant_id: "tenant-a".to_string(), + project_id: "project-a".to_string(), + agent_id: agent_id.to_string(), + scope: scope.to_string(), + r#type: "fact".to_string(), + key: None, + text: "Fact: debug visibility test note.".to_string(), + importance: 0.7, + confidence: 0.9, + status: status.to_string(), + created_at: now, + updated_at: now, + expires_at: None, + embedding_version: "test:v1".to_string(), + source_ref: serde_json::json!({"schema": "source_ref/v1"}), + hit_count: 0, + last_hit_at: None, + } +} diff --git a/packages/elf-service/src/recall_debug/tests_layers.rs b/packages/elf-service/src/recall_debug/tests_layers.rs new file mode 100644 index 00000000..fcbbb261 --- /dev/null +++ b/packages/elf-service/src/recall_debug/tests_layers.rs @@ -0,0 +1,109 @@ +use crate::{ + RecallDebugRow, + recall_debug::{self, BTreeSet, Error, Uuid}, +}; + +#[test] +fn summary_preserves_not_requested_and_replay_counts() { + let layers = vec![ + recall_debug::not_requested_layer("graph_facts", "missing graph subject"), + recall_debug::layer_from_rows( + "memory_notes", + "pass", + Some("trace".to_string()), + "trace rows", + vec![ + RecallDebugRow { + layer: "memory_notes".to_string(), + item_ref: serde_json::json!({"note_id": "n1"}), + selection_state: "selected".to_string(), + authority_layer: "memory_note".to_string(), + freshness_state: "active".to_string(), + source_refs: serde_json::json!([]), + score: Some(1.0), + rank: Some(1), + rationale: None, + stage_reason: None, + replay_command: Some("elf_admin_trace_bundle_get".to_string()), + evidence_class: "pass".to_string(), + debug_artifacts: serde_json::json!({}), + }, + RecallDebugRow { + layer: "memory_notes".to_string(), + item_ref: serde_json::json!({"note_id": "n2"}), + selection_state: "dropped".to_string(), + authority_layer: "memory_note".to_string(), + freshness_state: "active".to_string(), + source_refs: serde_json::json!([]), + score: Some(0.5), + rank: Some(2), + rationale: None, + stage_reason: Some("not_in_final_top_k".to_string()), + replay_command: Some("elf_admin_trace_bundle_get".to_string()), + evidence_class: "pass".to_string(), + debug_artifacts: serde_json::json!({}), + }, + ], + ), + ]; + let summary = recall_debug::summarize_layers(&layers); + + assert_eq!(summary.layer_count, 2); + assert_eq!(summary.row_count, 2); + assert_eq!(summary.selected_count, 1); + assert_eq!(summary.dropped_count, 1); + assert_eq!(summary.not_requested_layer_count, 1); + assert_eq!(summary.replay_command_count, 2); + assert_eq!(summary.evidence_class_counts.get("pass"), Some(&1)); + assert_eq!(summary.evidence_class_counts.get("not_requested"), Some(&1)); +} + +#[test] +fn not_requested_layers_never_require_raw_sql() { + let layer = recall_debug::not_requested_layer("source_documents", "missing query"); + + assert_eq!(layer.evidence_class, "not_requested"); + assert_eq!(layer.row_count, 0); + assert!(!layer.raw_sql_needed); + assert!(!layer.replayable); +} + +#[test] +fn blocked_layers_are_counted_as_incomplete_evidence() { + let layer = recall_debug::blocked_layer( + "source_documents", + Some("alpha".to_string()), + "docs search failed", + &Error::Storage { message: "database unavailable".to_string() }, + ); + let summary = recall_debug::summarize_layers(&[layer]); + + assert_eq!(summary.layer_count, 1); + assert_eq!(summary.incomplete_layer_count, 1); + assert_eq!(summary.evidence_class_counts.get("blocked"), Some(&1)); +} + +#[test] +fn blocked_layer_does_not_expose_raw_backend_errors() { + let layer = recall_debug::blocked_layer( + "graph_facts", + None, + "graph report failed", + &Error::Storage { message: "password=secret host=db.internal".to_string() }, + ); + + assert!(layer.summary.contains("error_class=storage_unavailable")); + assert!(!layer.summary.contains("password=secret")); + assert!(!layer.summary.contains("db.internal")); +} + +#[test] +fn selected_candidate_filter_is_chunk_level() { + let note_id = Uuid::new_v4(); + let selected_chunk_id = Uuid::new_v4(); + let dropped_chunk_id = Uuid::new_v4(); + let selected = BTreeSet::from([recall_debug::candidate_identity(note_id, selected_chunk_id)]); + + assert!(selected.contains(&recall_debug::candidate_identity(note_id, selected_chunk_id))); + assert!(!selected.contains(&recall_debug::candidate_identity(note_id, dropped_chunk_id))); +} diff --git a/packages/elf-service/src/recall_debug/tests_replay.rs b/packages/elf-service/src/recall_debug/tests_replay.rs new file mode 100644 index 00000000..88303aff --- /dev/null +++ b/packages/elf-service/src/recall_debug/tests_replay.rs @@ -0,0 +1,37 @@ +use time::OffsetDateTime; + +use crate::recall_debug::{self, BTreeSet, Uuid, tests::tests_helpers}; + +#[test] +fn compact_replay_artifact_exposes_controls_stage_movement_and_rerank_effects() { + let trace_id = Uuid::new_v4(); + let selected_note_id = Uuid::new_v4(); + let selected_chunk_id = Uuid::new_v4(); + let dropped_note_id = Uuid::new_v4(); + let dropped_chunk_id = Uuid::new_v4(); + let now = OffsetDateTime::from_unix_timestamp(0).expect("Valid timestamp."); + let candidates = vec![ + tests_helpers::compact_replay_selected_candidate(selected_note_id, selected_chunk_id, now), + tests_helpers::compact_replay_dropped_candidate( + dropped_note_id, + dropped_chunk_id, + selected_note_id, + now, + ), + ]; + let selected = + BTreeSet::from([recall_debug::candidate_identity(selected_note_id, selected_chunk_id)]); + let source_refs = + tests_helpers::compact_replay_source_refs(selected_note_id, dropped_note_id, now); + let artifact = recall_debug::memory_compact_replay_artifact( + &tests_helpers::compact_replay_trace(trace_id, now), + tests_helpers::compact_replay_stages().as_slice(), + candidates.as_slice(), + &[], + &selected, + &source_refs, + "elf_admin_trace_bundle_get trace_id= mode=bounded", + ); + + tests_helpers::assert_compact_replay_artifact(&artifact); +} diff --git a/packages/elf-service/src/recall_debug/tests_visibility.rs b/packages/elf-service/src/recall_debug/tests_visibility.rs new file mode 100644 index 00000000..4d971c1a --- /dev/null +++ b/packages/elf-service/src/recall_debug/tests_visibility.rs @@ -0,0 +1,174 @@ +use std::collections::HashSet; + +use time::OffsetDateTime; + +use crate::{ + RecallDebugRow, + access::SharedSpaceGrantKey, + recall_debug::{self, Error, tests::tests_helpers}, +}; + +#[test] +fn debug_note_readability_requires_current_note_and_scope_access() { + let allowed_scopes = vec!["agent_private".to_string(), "project_shared".to_string()]; + let shared_grants = HashSet::new(); + let now = OffsetDateTime::now_utc(); + let mut note = + tests_helpers::note_for_debug_visibility("owner-agent", "agent_private", "active"); + + assert!(recall_debug::note_debug_read_allowed( + ¬e, + "owner-agent", + &allowed_scopes, + &shared_grants, + now + )); + assert!(!recall_debug::note_debug_read_allowed( + ¬e, + "other-agent", + &allowed_scopes, + &shared_grants, + now + )); + + note.status = "deleted".to_string(); + + assert!(!recall_debug::note_debug_read_allowed( + ¬e, + "owner-agent", + &allowed_scopes, + &shared_grants, + now + )); + + note.status = "deprecated".to_string(); + + assert!(!recall_debug::note_debug_read_allowed( + ¬e, + "owner-agent", + &allowed_scopes, + &shared_grants, + now + )); + + note.status = "active".to_string(); + note.expires_at = Some(now); + + assert!(!recall_debug::note_debug_read_allowed( + ¬e, + "owner-agent", + &allowed_scopes, + &shared_grants, + now + )); + + note.expires_at = None; + note.scope = "project_shared".to_string(); + + assert!(!recall_debug::note_debug_read_allowed( + ¬e, + "other-agent", + &allowed_scopes, + &shared_grants, + now + )); + + let shared_grants = HashSet::from([SharedSpaceGrantKey { + scope: "project_shared".to_string(), + space_owner_agent_id: "owner-agent".to_string(), + }]); + + assert!(recall_debug::note_debug_read_allowed( + ¬e, + "other-agent", + &allowed_scopes, + &shared_grants, + now + )); +} + +#[test] +fn recall_trace_flattens_stale_and_dropped_context() { + let layers = vec![ + recall_debug::layer_from_rows( + "memory_notes", + "pass", + Some("trace".to_string()), + "trace rows", + vec![ + RecallDebugRow { + layer: "memory_notes".to_string(), + item_ref: serde_json::json!({"note_id": "selected-stale"}), + selection_state: "selected".to_string(), + authority_layer: "memory_note".to_string(), + freshness_state: "deprecated".to_string(), + source_refs: serde_json::json!([{"schema": "source_ref/v1"}]), + score: Some(0.9), + rank: Some(1), + rationale: Some("selected but stale".to_string()), + stage_reason: Some("status=deprecated".to_string()), + replay_command: Some("elf_trace".to_string()), + evidence_class: "pass".to_string(), + debug_artifacts: serde_json::json!({}), + }, + RecallDebugRow { + layer: "memory_notes".to_string(), + item_ref: serde_json::json!({"note_id": "dropped"}), + selection_state: "dropped".to_string(), + authority_layer: "memory_note".to_string(), + freshness_state: "active".to_string(), + source_refs: serde_json::json!([]), + score: Some(0.4), + rank: Some(4), + rationale: Some("candidate not narrated".to_string()), + stage_reason: Some("not_in_final_top_k".to_string()), + replay_command: Some("elf_trace".to_string()), + evidence_class: "pass".to_string(), + debug_artifacts: serde_json::json!({}), + }, + ], + ), + recall_debug::not_requested_layer("graph_facts", "missing graph subject"), + ]; + let trace = recall_debug::build_recall_trace(&layers); + + assert_eq!(trace.schema, "elf.recall_trace/v1"); + assert_eq!(trace.summary.entry_count, 3); + assert_eq!(trace.summary.selected_count, 1); + assert_eq!(trace.summary.dropped_count, 1); + assert_eq!(trace.summary.stale_count, 1); + assert_eq!(trace.summary.not_requested_count, 1); + assert_eq!(trace.summary.replay_command_count, 2); + assert_eq!(trace.entries[0].context_state, "stale"); + assert_eq!(trace.entries[0].policy_reason.as_deref(), Some("status=deprecated")); + assert_eq!(trace.entries[1].context_state, "dropped"); + assert_eq!(trace.entries[1].policy_reason.as_deref(), Some("not_in_final_top_k")); + assert_eq!(trace.entries[2].context_state, "not_requested"); +} + +#[test] +fn recall_trace_counts_blocked_layers_without_backend_details() { + let layer = recall_debug::blocked_layer( + "source_documents", + Some("alpha".to_string()), + "docs search failed", + &Error::Storage { message: "password=secret host=db.internal".to_string() }, + ); + let trace = recall_debug::build_recall_trace(&[layer]); + + assert_eq!(trace.summary.blocked_count, 1); + assert_eq!(trace.entries[0].context_state, "blocked"); + assert_eq!(trace.entries[0].selection_state, "blocked"); + assert!( + trace.entries[0] + .policy_reason + .as_deref() + .is_some_and(|reason| reason.contains("error_class=storage_unavailable")) + ); + assert!( + trace.entries[0] + .policy_reason + .as_deref() + .is_some_and(|reason| !reason.contains("password=secret")) + ); +} diff --git a/packages/elf-service/tests/acceptance/graph_ingestion.rs b/packages/elf-service/tests/acceptance/graph_ingestion.rs index ce48ba65..b90cb255 100644 --- a/packages/elf-service/tests/acceptance/graph_ingestion.rs +++ b/packages/elf-service/tests/acceptance/graph_ingestion.rs @@ -1,701 +1,6 @@ -mod single_predicate; - -use std::{ - collections::hash_map::DefaultHasher, - hash::{Hash, Hasher}, - sync::{Arc, atomic::AtomicUsize}, -}; - -use sqlx::PgPool; -use uuid::Uuid; - -use crate::acceptance::{self, SpyExtractor, StubEmbedding, StubRerank}; -use elf_config::EmbeddingProviderConfig; -use elf_domain::memory_policy::MemoryPolicyDecision; -use elf_service::{ - AddEventRequest, AddNoteInput, AddNoteRequest, BoxFuture, DeleteRequest, ElfService, - EmbeddingProvider, EventMessage, GraphQueryEntityRef, GraphQueryPredicateRef, - GraphQueryRequest, NoteOp, Providers, Result, StructuredFields, -}; -use elf_testkit::TestDatabase; - -const TEST_TENANT: &str = "t"; -const TEST_PROJECT: &str = "p"; -const TEST_SCOPE: &str = "agent_private"; -const GRAPH_REL_SUBJECT: &str = "alice"; -const GRAPH_REL_PREDICATE: &str = "mentors"; -const GRAPH_REL_OBJECT: &str = "Bob"; - -struct HashEmbedding { - vector_dim: u32, -} -impl EmbeddingProvider for HashEmbedding { - fn embed<'a>( - &'a self, - _: &'a EmbeddingProviderConfig, - texts: &'a [String], - ) -> BoxFuture<'a, Result>>> { - let vector_dim = self.vector_dim as usize; - let vectors = texts - .iter() - .map(|text| { - let mut values = Vec::with_capacity(vector_dim); - - for idx in 0..vector_dim { - let mut hasher = DefaultHasher::new(); - - text.hash(&mut hasher); - idx.hash(&mut hasher); - - let raw = hasher.finish(); - let normalized = ((raw % 2_000_000) as f32 / 1_000_000.0) - 1.0; - - values.push(normalized); - } - - values - }) - .collect(); - - Box::pin(async move { Ok(vectors) }) - } -} - -fn fact_note(key: &str, text: &str, predicate: &str, object_value: &str) -> AddNoteInput { - let structured = serde_json::from_value::(serde_json::json!({ - "relations": [{ - "subject": { "canonical": "Alice" }, - "predicate": predicate, - "object": { "value": object_value } - }] - })) - .expect("Failed to build structured fields."); - - AddNoteInput { - r#type: "fact".to_string(), - key: Some(key.to_string()), - text: text.to_string(), - structured: Some(structured), - importance: 0.8, - confidence: 0.9, - ttl_days: None, - source_ref: serde_json::json!({}), - write_policy: None, - } -} - -fn assert_graph_policy_from_op(op: NoteOp, policy_decision: MemoryPolicyDecision) { - match op { - NoteOp::Add => assert_eq!(policy_decision, MemoryPolicyDecision::Remember), - NoteOp::Update => assert_eq!(policy_decision, MemoryPolicyDecision::Update), - _ => {}, - } -} - -fn duplicate_fact_attaches_multiple_evidence_request() -> AddNoteRequest { - AddNoteRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "agent_private".to_string(), - notes: vec![ - AddNoteInput { - r#type: "fact".to_string(), - key: Some("mentorship-a".to_string()), - text: "Alice mentors Bob in 2026.".to_string(), - structured: Some( - serde_json::from_value::( - serde_json::json!({ - "relations": [{ - "subject": { "canonical": "Alice" }, - "predicate": "mentors", - "object": { "value": "Bob" } - }] - }), - ) - .expect("Failed to build structured fields."), - ), - importance: 0.8, - confidence: 0.9, - ttl_days: None, - source_ref: serde_json::json!({}), - write_policy: None, - }, - AddNoteInput { - r#type: "fact".to_string(), - key: Some("mentorship-b".to_string()), - text: "Alice also mentors Bob often.".to_string(), - structured: Some( - serde_json::from_value::( - serde_json::json!({ - "relations": [{ - "subject": { "canonical": "Alice" }, - "predicate": "mentors", - "object": { "value": "Bob" } - }] - }), - ) - .expect("Failed to build structured fields."), - ), - importance: 0.7, - confidence: 0.8, - ttl_days: None, - source_ref: serde_json::json!({}), - write_policy: None, - }, - ], - } -} - -async fn graph_fact_id(pool: &PgPool) -> Uuid { - sqlx::query_scalar( - "\ -SELECT gf.fact_id -FROM graph_facts gf -JOIN graph_entities ge ON ge.entity_id = gf.subject_entity_id -WHERE ge.canonical_norm = $1 - AND gf.predicate = $2 - AND gf.object_value = $3 - AND gf.tenant_id = $4 - AND gf.project_id = $5 - AND gf.scope = $6", - ) - .bind(GRAPH_REL_SUBJECT) - .bind(GRAPH_REL_PREDICATE) - .bind(GRAPH_REL_OBJECT) - .bind(TEST_TENANT) - .bind(TEST_PROJECT) - .bind(TEST_SCOPE) - .fetch_one(pool) - .await - .expect("Failed to load fact.") -} - -async fn graph_fact_count(pool: &PgPool) -> i64 { - sqlx::query_scalar( - "\ -SELECT COUNT(*) -FROM graph_facts gf -JOIN graph_entities ge ON ge.entity_id = gf.subject_entity_id -WHERE ge.canonical_norm = $1 - AND gf.predicate = $2 - AND gf.object_value = $3 - AND gf.tenant_id = $4 - AND gf.project_id = $5 - AND gf.scope = $6", - ) - .bind(GRAPH_REL_SUBJECT) - .bind(GRAPH_REL_PREDICATE) - .bind(GRAPH_REL_OBJECT) - .bind(TEST_TENANT) - .bind(TEST_PROJECT) - .bind(TEST_SCOPE) - .fetch_one(pool) - .await - .expect("Failed to count fact rows.") -} - -async fn graph_fact_evidence_count(pool: &PgPool, fact_id: Uuid) -> i64 { - sqlx::query_scalar("SELECT COUNT(*) FROM graph_fact_evidence WHERE fact_id = $1") - .bind(fact_id) - .fetch_one(pool) - .await - .expect("Failed to load fact evidence.") -} - -async fn graph_fact_evidence_count_for_note(pool: &PgPool, fact_id: Uuid, note_id: Uuid) -> i64 { - sqlx::query_scalar( - "SELECT COUNT(*) FROM graph_fact_evidence WHERE fact_id = $1 AND note_id = $2", - ) - .bind(fact_id) - .bind(note_id) - .fetch_one(pool) - .await - .expect("Failed to load note evidence.") -} - -async fn add_fact_note( - service: &ElfService, - key: &str, - text: &str, - predicate: &str, - object_value: &str, -) -> Uuid { - let response = service - .add_note(AddNoteRequest { - tenant_id: TEST_TENANT.to_string(), - project_id: TEST_PROJECT.to_string(), - agent_id: "a".to_string(), - scope: TEST_SCOPE.to_string(), - notes: vec![fact_note(key, text, predicate, object_value)], - }) - .await - .expect("add_note failed."); - - assert_eq!(response.results.len(), 1); - assert_eq!(response.results[0].op, NoteOp::Add); - - assert_graph_policy_from_op(response.results[0].op, response.results[0].policy_decision); - - response.results[0].note_id.expect("Expected note_id.") -} - -async fn build_test_db(test_name: &str) -> Option { - let Some(test_db) = acceptance::test_db().await else { - eprintln!("Skipping {test_name}; set ELF_PG_DSN to run."); - - return None; - }; - let Some(_) = acceptance::test_qdrant_url() else { - eprintln!("Skipping {test_name}; set ELF_QDRANT_URL to run."); - - return None; - }; - - Some(test_db) -} - -async fn build_stub_service(test_db: &TestDatabase) -> ElfService { - let qdrant_url = acceptance::test_qdrant_url().expect("Expected Qdrant test URL."); - let providers = Providers::new( - Arc::new(StubEmbedding { vector_dim: 4_096 }), - Arc::new(StubRerank), - Arc::new(SpyExtractor { - calls: Arc::new(AtomicUsize::new(0)), - payload: serde_json::json!({ "notes": [] }), - }), - ); - let collection = test_db.collection_name("elf_acceptance"); - let docs_collection = test_db.collection_name("elf_acceptance_docs"); - let cfg = acceptance::test_config( - test_db.dsn().to_string(), - qdrant_url, - 4_096, - collection, - docs_collection, - ); - - acceptance::build_service(cfg, providers).await.expect("Failed to build service.") -} - -async fn reset_service_db(service: &ElfService) { - acceptance::reset_db(&service.db.pool).await.expect("Failed to reset test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_URL to run."] -async fn add_note_duplicate_fact_attaches_multiple_evidence() { - let Some(test_db) = acceptance::test_db().await else { - eprintln!( - "Skipping add_note_duplicate_fact_attaches_multiple_evidence; set ELF_PG_DSN to run.", - ); - - return; - }; - let Some(qdrant_url) = acceptance::test_qdrant_url() else { - eprintln!( - "Skipping add_note_duplicate_fact_attaches_multiple_evidence; set ELF_QDRANT_URL to run.", - ); - - return; - }; - let providers = Providers::new( - Arc::new(HashEmbedding { vector_dim: 4_096 }), - Arc::new(StubRerank), - Arc::new(SpyExtractor { - calls: Arc::new(AtomicUsize::new(0)), - payload: serde_json::json!({ "notes": [] }), - }), - ); - let collection = test_db.collection_name("elf_acceptance"); - let docs_collection = test_db.collection_name("elf_acceptance_docs"); - let cfg = acceptance::test_config( - test_db.dsn().to_string(), - qdrant_url, - 4_096, - collection, - docs_collection, - ); - let service = - acceptance::build_service(cfg, providers).await.expect("Failed to build service."); - - acceptance::reset_db(&service.db.pool).await.expect("Failed to reset test database."); - - let response = service - .add_note(duplicate_fact_attaches_multiple_evidence_request()) - .await - .expect("add_note failed."); - - assert_eq!(response.results.len(), 2); - assert_eq!(response.results[0].op, NoteOp::Add); - assert_eq!(response.results[1].op, NoteOp::Add); - - assert_graph_policy_from_op(response.results[0].op, response.results[0].policy_decision); - assert_graph_policy_from_op(response.results[1].op, response.results[1].policy_decision); - - let first_note_id = response.results[0].note_id.expect("Expected note_id."); - let second_note_id = response.results[1].note_id.expect("Expected note_id."); - - assert_ne!(first_note_id, second_note_id); - - let fact_id = graph_fact_id(&service.db.pool).await; - let fact_count = graph_fact_count(&service.db.pool).await; - let evidence_count = graph_fact_evidence_count(&service.db.pool, fact_id).await; +pub(crate) mod tests_helpers; - assert_eq!(fact_count, 1); - assert_eq!(evidence_count, 2); - - let first_evidence_count = - graph_fact_evidence_count_for_note(&service.db.pool, fact_id, first_note_id).await; - let second_evidence_count = - graph_fact_evidence_count_for_note(&service.db.pool, fact_id, second_note_id).await; - - assert_eq!(first_evidence_count, 1); - assert_eq!(second_evidence_count, 1); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_URL to run."] -async fn add_note_invalid_relation_rejected_has_field_path() { - let Some(test_db) = acceptance::test_db().await else { - eprintln!( - "Skipping add_note_invalid_relation_rejected_has_field_path; set ELF_PG_DSN to run." - ); - - return; - }; - let Some(qdrant_url) = acceptance::test_qdrant_url() else { - eprintln!( - "Skipping add_note_invalid_relation_rejected_has_field_path; set ELF_QDRANT_URL to run.", - ); - - return; - }; - let providers = Providers::new( - Arc::new(StubEmbedding { vector_dim: 4_096 }), - Arc::new(StubRerank), - Arc::new(SpyExtractor { - calls: Arc::new(AtomicUsize::new(0)), - payload: serde_json::json!({ "notes": [] }), - }), - ); - let collection = test_db.collection_name("elf_acceptance"); - let docs_collection = test_db.collection_name("elf_acceptance_docs"); - let cfg = acceptance::test_config( - test_db.dsn().to_string(), - qdrant_url, - 4_096, - collection, - docs_collection, - ); - let service = - acceptance::build_service(cfg, providers).await.expect("Failed to build service."); - let response = service - .add_note(AddNoteRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "agent_private".to_string(), - notes: vec![AddNoteInput { - r#type: "fact".to_string(), - key: Some("mentorship".to_string()), - text: "Alice mentors Bob.".to_string(), - structured: Some( - serde_json::from_value::( - serde_json::json!({ - "relations": [{ - "subject": { "canonical": "Alice" }, - "object": { "value": "Bob" } - }] - }), - ) - .expect("Failed to build structured fields."), - ), - importance: 0.8, - confidence: 0.9, - ttl_days: None, - source_ref: serde_json::json!({}), - write_policy: None, - }], - }) - .await - .expect("add_note failed."); - - assert_eq!(response.results.len(), 1); - assert_eq!(response.results[0].op, NoteOp::Rejected); - assert_eq!(response.results[0].reason_code.as_deref(), Some("REJECT_STRUCTURED_INVALID")); - assert_eq!( - response.results[0].field_path, - Some("structured.relations[0].predicate".to_string()), - ); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_URL to run."] -async fn add_note_persists_graph_relations() { - let Some(test_db) = acceptance::test_db().await else { - eprintln!("Skipping add_note_persists_graph_relations; set ELF_PG_DSN to run."); - - return; - }; - let Some(qdrant_url) = acceptance::test_qdrant_url() else { - eprintln!("Skipping add_note_persists_graph_relations; set ELF_QDRANT_URL to run."); - - return; - }; - let providers = Providers::new( - Arc::new(StubEmbedding { vector_dim: 4_096 }), - Arc::new(StubRerank), - Arc::new(SpyExtractor { - calls: Arc::new(AtomicUsize::new(0)), - payload: serde_json::json!({ "notes": [] }), - }), - ); - let collection = test_db.collection_name("elf_acceptance"); - let docs_collection = test_db.collection_name("elf_acceptance_docs"); - let cfg = acceptance::test_config( - test_db.dsn().to_string(), - qdrant_url, - 4_096, - collection, - docs_collection, - ); - let service = - acceptance::build_service(cfg, providers).await.expect("Failed to build service."); - - acceptance::reset_db(&service.db.pool).await.expect("Failed to reset test database."); - - let response = service - .add_note(AddNoteRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: "agent_private".to_string(), - notes: vec![AddNoteInput { - r#type: "fact".to_string(), - key: Some("mentorship".to_string()), - text: "Alice mentors Bob.".to_string(), - structured: Some( - serde_json::from_value::( - serde_json::json!({ - "relations": [{ - "subject": { "canonical": "Alice" }, - "predicate": "mentors", - "object": { "value": "Bob" } - }] - }), - ) - .expect("Failed to build structured fields."), - ), - importance: 0.8, - confidence: 0.9, - ttl_days: None, - source_ref: serde_json::json!({}), - write_policy: None, - }], - }) - .await - .expect("add_note failed."); - - assert_eq!(response.results.len(), 1); - assert_eq!(response.results[0].op, NoteOp::Add); - - assert_graph_policy_from_op(response.results[0].op, response.results[0].policy_decision); - - let note_id = response.results[0].note_id.expect("Expected note_id."); - let fact_id = graph_fact_id(&service.db.pool).await; - let fact_count = graph_fact_count(&service.db.pool).await; - let evidence_count = - graph_fact_evidence_count_for_note(&service.db.pool, fact_id, note_id).await; - - assert_eq!(fact_count, 1); - assert_eq!(evidence_count, 1); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_URL to run."] -async fn graph_query_suppresses_deleted_evidence_notes() { - let Some(test_db) = acceptance::test_db().await else { - eprintln!("Skipping graph_query_suppresses_deleted_evidence_notes; set ELF_PG_DSN to run."); - - return; - }; - let Some(qdrant_url) = acceptance::test_qdrant_url() else { - eprintln!( - "Skipping graph_query_suppresses_deleted_evidence_notes; set ELF_QDRANT_URL to run.", - ); - - return; - }; - let providers = Providers::new( - Arc::new(StubEmbedding { vector_dim: 4_096 }), - Arc::new(StubRerank), - Arc::new(SpyExtractor { - calls: Arc::new(AtomicUsize::new(0)), - payload: serde_json::json!({ "notes": [] }), - }), - ); - let collection = test_db.collection_name("elf_acceptance"); - let docs_collection = test_db.collection_name("elf_acceptance_docs"); - let cfg = acceptance::test_config( - test_db.dsn().to_string(), - qdrant_url, - 4_096, - collection, - docs_collection, - ); - let service = - acceptance::build_service(cfg, providers).await.expect("Failed to build service."); - - acceptance::reset_db(&service.db.pool).await.expect("Failed to reset test database."); - - let note_id = - add_fact_note(&service, "mentorship", "Alice mentors Bob.", "mentors", "Bob").await; - let before_delete = service - .graph_query(GraphQueryRequest { - tenant_id: TEST_TENANT.to_string(), - project_id: TEST_PROJECT.to_string(), - agent_id: "a".to_string(), - read_profile: "private_only".to_string(), - subject: GraphQueryEntityRef::Surface { surface: "Alice".to_string() }, - predicate: Some(GraphQueryPredicateRef::Surface { surface: "mentors".to_string() }), - scopes: Some(vec![TEST_SCOPE.to_string()]), - as_of: None, - limit: Some(10), - explain: Some(true), - }) - .await - .expect("graph query before delete should succeed"); - - assert_eq!(before_delete.facts.len(), 1); - assert_eq!(before_delete.facts[0].evidence_note_ids, vec![note_id]); - - let delete = service - .delete(DeleteRequest { - tenant_id: TEST_TENANT.to_string(), - project_id: TEST_PROJECT.to_string(), - agent_id: "a".to_string(), - note_id, - }) - .await - .expect("note delete should succeed"); - - assert_eq!(delete.op, NoteOp::Delete); - - let after_delete = service - .graph_query(GraphQueryRequest { - tenant_id: TEST_TENANT.to_string(), - project_id: TEST_PROJECT.to_string(), - agent_id: "a".to_string(), - read_profile: "private_only".to_string(), - subject: GraphQueryEntityRef::Surface { surface: "Alice".to_string() }, - predicate: Some(GraphQueryPredicateRef::Surface { surface: "mentors".to_string() }), - scopes: Some(vec![TEST_SCOPE.to_string()]), - as_of: None, - limit: Some(10), - explain: Some(true), - }) - .await - .expect("graph query after delete should succeed"); - - assert!( - after_delete.facts.is_empty(), - "graph facts without active readable evidence notes must be suppressed" - ); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} - -#[tokio::test] -#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_URL to run."] -async fn add_event_persists_graph_relations() { - let Some(test_db) = acceptance::test_db().await else { - eprintln!("Skipping add_event_persists_graph_relations; set ELF_PG_DSN to run."); - - return; - }; - let Some(qdrant_url) = acceptance::test_qdrant_url() else { - eprintln!("Skipping add_event_persists_graph_relations; set ELF_QDRANT_URL to run."); - - return; - }; - let extractor_payload = serde_json::json!({ - "notes": [{ - "type": "fact", - "key": "mentorship", - "text": "Alice mentors Bob.", - "structured": { - "relations": [{ - "subject": { "canonical": "Alice" }, - "predicate": "mentors", - "object": { "value": "Bob" } - }] - }, - "importance": 0.8, - "confidence": 0.9, - "ttl_days": null, - "scope_suggestion": "agent_private", - "evidence": [{ "message_index": 0, "quote": "Alice mentors Bob." }], - "reason": "test" - }] - }); - let providers = Providers::new( - Arc::new(StubEmbedding { vector_dim: 4_096 }), - Arc::new(StubRerank), - Arc::new(SpyExtractor { calls: Arc::new(AtomicUsize::new(0)), payload: extractor_payload }), - ); - let collection = test_db.collection_name("elf_acceptance"); - let docs_collection = test_db.collection_name("elf_acceptance_docs"); - let cfg = acceptance::test_config( - test_db.dsn().to_string(), - qdrant_url, - 4_096, - collection, - docs_collection, - ); - let service = - acceptance::build_service(cfg, providers).await.expect("Failed to build service."); - - acceptance::reset_db(&service.db.pool).await.expect("Failed to reset test database."); - - let response = service - .add_event(AddEventRequest { - tenant_id: "t".to_string(), - project_id: "p".to_string(), - agent_id: "a".to_string(), - scope: Some("agent_private".to_string()), - dry_run: Some(false), - ingestion_profile: None, - messages: vec![EventMessage { - role: "user".to_string(), - content: "Alice mentors Bob.".to_string(), - ts: None, - msg_id: None, - write_policy: None, - }], - }) - .await - .expect("add_event failed."); - - assert_eq!(response.results.len(), 1); - assert_eq!(response.results[0].op, NoteOp::Add); - - assert_graph_policy_from_op(response.results[0].op, response.results[0].policy_decision); - - let note_id = response.results[0].note_id.expect("Expected note_id."); - let fact_id = graph_fact_id(&service.db.pool).await; - let fact_count = graph_fact_count(&service.db.pool).await; - let evidence_count = - graph_fact_evidence_count_for_note(&service.db.pool, fact_id, note_id).await; - - assert_eq!(fact_count, 1); - assert_eq!(evidence_count, 1); - - test_db.cleanup().await.expect("Failed to cleanup test database."); -} +mod single_predicate; +mod tests_event; +mod tests_note; +mod tests_query; diff --git a/packages/elf-service/tests/acceptance/graph_ingestion/single_predicate.rs b/packages/elf-service/tests/acceptance/graph_ingestion/single_predicate.rs index f798e7f9..d00214e5 100644 --- a/packages/elf-service/tests/acceptance/graph_ingestion/single_predicate.rs +++ b/packages/elf-service/tests/acceptance/graph_ingestion/single_predicate.rs @@ -2,7 +2,7 @@ use sqlx::{FromRow, PgPool}; use time::OffsetDateTime; use uuid::Uuid; -use crate::acceptance::graph_ingestion::{ +use crate::acceptance::graph_ingestion::tests_helpers::{ self, GRAPH_REL_SUBJECT, TEST_PROJECT, TEST_SCOPE, TEST_TENANT, }; use elf_service::{ @@ -156,16 +156,15 @@ WHERE from_fact_id = $1 #[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_URL to run."] async fn add_note_single_predicate_supersedes_conflicting_fact() { let Some(test_db) = - graph_ingestion::build_test_db("add_note_single_predicate_supersedes_conflicting_fact") - .await + tests_helpers::build_test_db("add_note_single_predicate_supersedes_conflicting_fact").await else { return; }; - let service = graph_ingestion::build_stub_service(&test_db).await; + let service = tests_helpers::build_stub_service(&test_db).await; - graph_ingestion::reset_service_db(&service).await; + tests_helpers::reset_service_db(&service).await; - let old_note_id = graph_ingestion::add_fact_note( + let old_note_id = tests_helpers::add_fact_note( &service, "employment-a", "Alice works at Initech.", @@ -180,7 +179,7 @@ async fn add_note_single_predicate_supersedes_conflicting_fact() { tokio::time::sleep(std::time::Duration::from_millis(1)).await; - let note_id = graph_ingestion::add_fact_note( + let note_id = tests_helpers::add_fact_note( &service, "employment-b", "Alice works at Globex.", diff --git a/packages/elf-service/tests/acceptance/graph_ingestion/tests_event.rs b/packages/elf-service/tests/acceptance/graph_ingestion/tests_event.rs new file mode 100644 index 00000000..6889ea7e --- /dev/null +++ b/packages/elf-service/tests/acceptance/graph_ingestion/tests_event.rs @@ -0,0 +1,73 @@ +use crate::acceptance::graph_ingestion::tests_helpers; +use elf_service::{AddEventRequest, EventMessage, NoteOp}; + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_URL to run."] +async fn add_event_persists_graph_relations() { + let Some(test_db) = tests_helpers::build_test_db("add_event_persists_graph_relations").await + else { + return; + }; + let extractor_payload = serde_json::json!({ + "notes": [{ + "type": "fact", + "key": "mentorship", + "text": "Alice mentors Bob.", + "structured": { + "relations": [{ + "subject": { "canonical": "Alice" }, + "predicate": "mentors", + "object": { "value": "Bob" } + }] + }, + "importance": 0.8, + "confidence": 0.9, + "ttl_days": null, + "scope_suggestion": "agent_private", + "evidence": [{ "message_index": 0, "quote": "Alice mentors Bob." }], + "reason": "test" + }] + }); + let service = + tests_helpers::build_service_with_extractor_payload(&test_db, extractor_payload).await; + + tests_helpers::reset_service_db(&service).await; + + let response = service + .add_event(AddEventRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: Some("agent_private".to_string()), + dry_run: Some(false), + ingestion_profile: None, + messages: vec![EventMessage { + role: "user".to_string(), + content: "Alice mentors Bob.".to_string(), + ts: None, + msg_id: None, + write_policy: None, + }], + }) + .await + .expect("add_event failed."); + + assert_eq!(response.results.len(), 1); + assert_eq!(response.results[0].op, NoteOp::Add); + + tests_helpers::assert_graph_policy_from_op( + response.results[0].op, + response.results[0].policy_decision, + ); + + let note_id = response.results[0].note_id.expect("Expected note_id."); + let fact_id = tests_helpers::graph_fact_id(&service.db.pool).await; + let fact_count = tests_helpers::graph_fact_count(&service.db.pool).await; + let evidence_count = + tests_helpers::graph_fact_evidence_count_for_note(&service.db.pool, fact_id, note_id).await; + + assert_eq!(fact_count, 1); + assert_eq!(evidence_count, 1); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/packages/elf-service/tests/acceptance/graph_ingestion/tests_helpers.rs b/packages/elf-service/tests/acceptance/graph_ingestion/tests_helpers.rs new file mode 100644 index 00000000..15129abf --- /dev/null +++ b/packages/elf-service/tests/acceptance/graph_ingestion/tests_helpers.rs @@ -0,0 +1,317 @@ +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + sync::{Arc, atomic::AtomicUsize}, +}; + +use serde_json::Value; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::acceptance::{self, SpyExtractor, StubEmbedding, StubRerank}; +use elf_config::EmbeddingProviderConfig; +use elf_domain::memory_policy::MemoryPolicyDecision; +use elf_service::{ + AddNoteInput, AddNoteRequest, BoxFuture, ElfService, EmbeddingProvider, NoteOp, Providers, + Result, StructuredFields, +}; +use elf_testkit::TestDatabase; + +pub(super) const TEST_TENANT: &str = "t"; +pub(super) const TEST_PROJECT: &str = "p"; +pub(super) const TEST_SCOPE: &str = "agent_private"; +pub(super) const GRAPH_REL_SUBJECT: &str = "alice"; +pub(super) const GRAPH_REL_PREDICATE: &str = "mentors"; +pub(super) const GRAPH_REL_OBJECT: &str = "Bob"; + +pub(super) struct HashEmbedding { + vector_dim: u32, +} +impl EmbeddingProvider for HashEmbedding { + fn embed<'a>( + &'a self, + _: &'a EmbeddingProviderConfig, + texts: &'a [String], + ) -> BoxFuture<'a, Result>>> { + let vector_dim = self.vector_dim as usize; + let vectors = texts + .iter() + .map(|text| { + let mut values = Vec::with_capacity(vector_dim); + + for idx in 0..vector_dim { + let mut hasher = DefaultHasher::new(); + + text.hash(&mut hasher); + idx.hash(&mut hasher); + + let raw = hasher.finish(); + let normalized = ((raw % 2_000_000) as f32 / 1_000_000.0) - 1.0; + + values.push(normalized); + } + + values + }) + .collect(); + + Box::pin(async move { Ok(vectors) }) + } +} + +pub(super) fn fact_note( + key: &str, + text: &str, + predicate: &str, + object_value: &str, +) -> AddNoteInput { + let structured = serde_json::from_value::(serde_json::json!({ + "relations": [{ + "subject": { "canonical": "Alice" }, + "predicate": predicate, + "object": { "value": object_value } + }] + })) + .expect("Failed to build structured fields."); + + AddNoteInput { + r#type: "fact".to_string(), + key: Some(key.to_string()), + text: text.to_string(), + structured: Some(structured), + importance: 0.8, + confidence: 0.9, + ttl_days: None, + source_ref: serde_json::json!({}), + write_policy: None, + } +} + +pub(super) fn assert_graph_policy_from_op(op: NoteOp, policy_decision: MemoryPolicyDecision) { + match op { + NoteOp::Add => assert_eq!(policy_decision, MemoryPolicyDecision::Remember), + NoteOp::Update => assert_eq!(policy_decision, MemoryPolicyDecision::Update), + _ => {}, + } +} + +pub(super) fn duplicate_fact_attaches_multiple_evidence_request() -> AddNoteRequest { + AddNoteRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "agent_private".to_string(), + notes: vec![ + AddNoteInput { + r#type: "fact".to_string(), + key: Some("mentorship-a".to_string()), + text: "Alice mentors Bob in 2026.".to_string(), + structured: Some( + serde_json::from_value::( + serde_json::json!({ + "relations": [{ + "subject": { "canonical": "Alice" }, + "predicate": "mentors", + "object": { "value": "Bob" } + }] + }), + ) + .expect("Failed to build structured fields."), + ), + importance: 0.8, + confidence: 0.9, + ttl_days: None, + source_ref: serde_json::json!({}), + write_policy: None, + }, + AddNoteInput { + r#type: "fact".to_string(), + key: Some("mentorship-b".to_string()), + text: "Alice also mentors Bob often.".to_string(), + structured: Some( + serde_json::from_value::( + serde_json::json!({ + "relations": [{ + "subject": { "canonical": "Alice" }, + "predicate": "mentors", + "object": { "value": "Bob" } + }] + }), + ) + .expect("Failed to build structured fields."), + ), + importance: 0.7, + confidence: 0.8, + ttl_days: None, + source_ref: serde_json::json!({}), + write_policy: None, + }, + ], + } +} + +pub(super) async fn graph_fact_id(pool: &PgPool) -> Uuid { + sqlx::query_scalar( + "\ +SELECT gf.fact_id +FROM graph_facts gf +JOIN graph_entities ge ON ge.entity_id = gf.subject_entity_id +WHERE ge.canonical_norm = $1 + AND gf.predicate = $2 + AND gf.object_value = $3 + AND gf.tenant_id = $4 + AND gf.project_id = $5 + AND gf.scope = $6", + ) + .bind(GRAPH_REL_SUBJECT) + .bind(GRAPH_REL_PREDICATE) + .bind(GRAPH_REL_OBJECT) + .bind(TEST_TENANT) + .bind(TEST_PROJECT) + .bind(TEST_SCOPE) + .fetch_one(pool) + .await + .expect("Failed to load fact.") +} + +pub(super) async fn graph_fact_count(pool: &PgPool) -> i64 { + sqlx::query_scalar( + "\ +SELECT COUNT(*) +FROM graph_facts gf +JOIN graph_entities ge ON ge.entity_id = gf.subject_entity_id +WHERE ge.canonical_norm = $1 + AND gf.predicate = $2 + AND gf.object_value = $3 + AND gf.tenant_id = $4 + AND gf.project_id = $5 + AND gf.scope = $6", + ) + .bind(GRAPH_REL_SUBJECT) + .bind(GRAPH_REL_PREDICATE) + .bind(GRAPH_REL_OBJECT) + .bind(TEST_TENANT) + .bind(TEST_PROJECT) + .bind(TEST_SCOPE) + .fetch_one(pool) + .await + .expect("Failed to count fact rows.") +} + +pub(super) async fn graph_fact_evidence_count(pool: &PgPool, fact_id: Uuid) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM graph_fact_evidence WHERE fact_id = $1") + .bind(fact_id) + .fetch_one(pool) + .await + .expect("Failed to load fact evidence.") +} + +pub(super) async fn graph_fact_evidence_count_for_note( + pool: &PgPool, + fact_id: Uuid, + note_id: Uuid, +) -> i64 { + sqlx::query_scalar( + "SELECT COUNT(*) FROM graph_fact_evidence WHERE fact_id = $1 AND note_id = $2", + ) + .bind(fact_id) + .bind(note_id) + .fetch_one(pool) + .await + .expect("Failed to load note evidence.") +} + +pub(super) async fn add_fact_note( + service: &ElfService, + key: &str, + text: &str, + predicate: &str, + object_value: &str, +) -> Uuid { + let response = service + .add_note(AddNoteRequest { + tenant_id: TEST_TENANT.to_string(), + project_id: TEST_PROJECT.to_string(), + agent_id: "a".to_string(), + scope: TEST_SCOPE.to_string(), + notes: vec![fact_note(key, text, predicate, object_value)], + }) + .await + .expect("add_note failed."); + + assert_eq!(response.results.len(), 1); + assert_eq!(response.results[0].op, NoteOp::Add); + + assert_graph_policy_from_op(response.results[0].op, response.results[0].policy_decision); + + response.results[0].note_id.expect("Expected note_id.") +} + +pub(super) async fn build_test_db(test_name: &str) -> Option { + let Some(test_db) = acceptance::test_db().await else { + eprintln!("Skipping {test_name}; set ELF_PG_DSN to run."); + + return None; + }; + let Some(_) = acceptance::test_qdrant_url() else { + eprintln!("Skipping {test_name}; set ELF_QDRANT_URL to run."); + + return None; + }; + + Some(test_db) +} + +pub(super) async fn build_hash_service(test_db: &TestDatabase) -> ElfService { + let qdrant_url = acceptance::test_qdrant_url().expect("Expected Qdrant test URL."); + let providers = Providers::new( + Arc::new(HashEmbedding { vector_dim: 4_096 }), + Arc::new(StubRerank), + Arc::new(SpyExtractor { + calls: Arc::new(AtomicUsize::new(0)), + payload: serde_json::json!({ "notes": [] }), + }), + ); + let collection = test_db.collection_name("elf_acceptance"); + let docs_collection = test_db.collection_name("elf_acceptance_docs"); + let cfg = acceptance::test_config( + test_db.dsn().to_string(), + qdrant_url, + 4_096, + collection, + docs_collection, + ); + + acceptance::build_service(cfg, providers).await.expect("Failed to build service.") +} + +pub(super) async fn build_stub_service(test_db: &TestDatabase) -> ElfService { + build_service_with_extractor_payload(test_db, serde_json::json!({ "notes": [] })).await +} + +pub(super) async fn build_service_with_extractor_payload( + test_db: &TestDatabase, + extractor_payload: Value, +) -> ElfService { + let qdrant_url = acceptance::test_qdrant_url().expect("Expected Qdrant test URL."); + let providers = Providers::new( + Arc::new(StubEmbedding { vector_dim: 4_096 }), + Arc::new(StubRerank), + Arc::new(SpyExtractor { calls: Arc::new(AtomicUsize::new(0)), payload: extractor_payload }), + ); + let collection = test_db.collection_name("elf_acceptance"); + let docs_collection = test_db.collection_name("elf_acceptance_docs"); + let cfg = acceptance::test_config( + test_db.dsn().to_string(), + qdrant_url, + 4_096, + collection, + docs_collection, + ); + + acceptance::build_service(cfg, providers).await.expect("Failed to build service.") +} + +pub(super) async fn reset_service_db(service: &ElfService) { + acceptance::reset_db(&service.db.pool).await.expect("Failed to reset test database."); +} diff --git a/packages/elf-service/tests/acceptance/graph_ingestion/tests_note.rs b/packages/elf-service/tests/acceptance/graph_ingestion/tests_note.rs new file mode 100644 index 00000000..8882520e --- /dev/null +++ b/packages/elf-service/tests/acceptance/graph_ingestion/tests_note.rs @@ -0,0 +1,174 @@ +use crate::acceptance::graph_ingestion::tests_helpers; +use elf_service::{AddNoteInput, AddNoteRequest, NoteOp}; + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_URL to run."] +async fn add_note_duplicate_fact_attaches_multiple_evidence() { + let Some(test_db) = + tests_helpers::build_test_db("add_note_duplicate_fact_attaches_multiple_evidence").await + else { + return; + }; + let service = tests_helpers::build_hash_service(&test_db).await; + + tests_helpers::reset_service_db(&service).await; + + let response = service + .add_note(tests_helpers::duplicate_fact_attaches_multiple_evidence_request()) + .await + .expect("add_note failed."); + + assert_eq!(response.results.len(), 2); + assert_eq!(response.results[0].op, NoteOp::Add); + assert_eq!(response.results[1].op, NoteOp::Add); + + tests_helpers::assert_graph_policy_from_op( + response.results[0].op, + response.results[0].policy_decision, + ); + tests_helpers::assert_graph_policy_from_op( + response.results[1].op, + response.results[1].policy_decision, + ); + + let first_note_id = response.results[0].note_id.expect("Expected note_id."); + let second_note_id = response.results[1].note_id.expect("Expected note_id."); + + assert_ne!(first_note_id, second_note_id); + + let fact_id = tests_helpers::graph_fact_id(&service.db.pool).await; + let fact_count = tests_helpers::graph_fact_count(&service.db.pool).await; + let evidence_count = tests_helpers::graph_fact_evidence_count(&service.db.pool, fact_id).await; + + assert_eq!(fact_count, 1); + assert_eq!(evidence_count, 2); + + let first_evidence_count = + tests_helpers::graph_fact_evidence_count_for_note(&service.db.pool, fact_id, first_note_id) + .await; + let second_evidence_count = tests_helpers::graph_fact_evidence_count_for_note( + &service.db.pool, + fact_id, + second_note_id, + ) + .await; + + assert_eq!(first_evidence_count, 1); + assert_eq!(second_evidence_count, 1); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_URL to run."] +async fn add_note_invalid_relation_rejected_has_field_path() { + let Some(test_db) = + tests_helpers::build_test_db("add_note_invalid_relation_rejected_has_field_path").await + else { + return; + }; + let service = tests_helpers::build_stub_service(&test_db).await; + let response = service + .add_note(AddNoteRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "agent_private".to_string(), + notes: vec![AddNoteInput { + r#type: "fact".to_string(), + key: Some("mentorship".to_string()), + text: "Alice mentors Bob.".to_string(), + structured: Some( + serde_json::from_value::( + serde_json::json!({ + "relations": [{ + "subject": { "canonical": "Alice" }, + "object": { "value": "Bob" } + }] + }), + ) + .expect("Failed to build structured fields."), + ), + importance: 0.8, + confidence: 0.9, + ttl_days: None, + source_ref: serde_json::json!({}), + write_policy: None, + }], + }) + .await + .expect("add_note failed."); + + assert_eq!(response.results.len(), 1); + assert_eq!(response.results[0].op, NoteOp::Rejected); + assert_eq!(response.results[0].reason_code.as_deref(), Some("REJECT_STRUCTURED_INVALID")); + assert_eq!( + response.results[0].field_path, + Some("structured.relations[0].predicate".to_string()), + ); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_URL to run."] +async fn add_note_persists_graph_relations() { + let Some(test_db) = tests_helpers::build_test_db("add_note_persists_graph_relations").await + else { + return; + }; + let service = tests_helpers::build_stub_service(&test_db).await; + + tests_helpers::reset_service_db(&service).await; + + let response = service + .add_note(AddNoteRequest { + tenant_id: "t".to_string(), + project_id: "p".to_string(), + agent_id: "a".to_string(), + scope: "agent_private".to_string(), + notes: vec![AddNoteInput { + r#type: "fact".to_string(), + key: Some("mentorship".to_string()), + text: "Alice mentors Bob.".to_string(), + structured: Some( + serde_json::from_value::( + serde_json::json!({ + "relations": [{ + "subject": { "canonical": "Alice" }, + "predicate": "mentors", + "object": { "value": "Bob" } + }] + }), + ) + .expect("Failed to build structured fields."), + ), + importance: 0.8, + confidence: 0.9, + ttl_days: None, + source_ref: serde_json::json!({}), + write_policy: None, + }], + }) + .await + .expect("add_note failed."); + + assert_eq!(response.results.len(), 1); + assert_eq!(response.results[0].op, NoteOp::Add); + + tests_helpers::assert_graph_policy_from_op( + response.results[0].op, + response.results[0].policy_decision, + ); + + let note_id = response.results[0].note_id.expect("Expected note_id."); + let fact_id = tests_helpers::graph_fact_id(&service.db.pool).await; + let fact_count = tests_helpers::graph_fact_count(&service.db.pool).await; + let evidence_count = + tests_helpers::graph_fact_evidence_count_for_note(&service.db.pool, fact_id, note_id).await; + + assert_eq!(fact_count, 1); + assert_eq!(evidence_count, 1); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +} diff --git a/packages/elf-service/tests/acceptance/graph_ingestion/tests_query.rs b/packages/elf-service/tests/acceptance/graph_ingestion/tests_query.rs new file mode 100644 index 00000000..1d5a0369 --- /dev/null +++ b/packages/elf-service/tests/acceptance/graph_ingestion/tests_query.rs @@ -0,0 +1,81 @@ +use crate::acceptance::graph_ingestion::tests_helpers::{ + self, TEST_PROJECT, TEST_SCOPE, TEST_TENANT, +}; +use elf_service::{ + DeleteRequest, GraphQueryEntityRef, GraphQueryPredicateRef, GraphQueryRequest, NoteOp, +}; + +#[tokio::test] +#[ignore = "Requires external Postgres and Qdrant. Set ELF_PG_DSN and ELF_QDRANT_URL to run."] +async fn graph_query_suppresses_deleted_evidence_notes() { + let Some(test_db) = + tests_helpers::build_test_db("graph_query_suppresses_deleted_evidence_notes").await + else { + return; + }; + let service = tests_helpers::build_stub_service(&test_db).await; + + tests_helpers::reset_service_db(&service).await; + + let note_id = tests_helpers::add_fact_note( + &service, + "mentorship", + "Alice mentors Bob.", + "mentors", + "Bob", + ) + .await; + let before_delete = service + .graph_query(GraphQueryRequest { + tenant_id: TEST_TENANT.to_string(), + project_id: TEST_PROJECT.to_string(), + agent_id: "a".to_string(), + read_profile: "private_only".to_string(), + subject: GraphQueryEntityRef::Surface { surface: "Alice".to_string() }, + predicate: Some(GraphQueryPredicateRef::Surface { surface: "mentors".to_string() }), + scopes: Some(vec![TEST_SCOPE.to_string()]), + as_of: None, + limit: Some(10), + explain: Some(true), + }) + .await + .expect("graph query before delete should succeed"); + + assert_eq!(before_delete.facts.len(), 1); + assert_eq!(before_delete.facts[0].evidence_note_ids, vec![note_id]); + + let delete = service + .delete(DeleteRequest { + tenant_id: TEST_TENANT.to_string(), + project_id: TEST_PROJECT.to_string(), + agent_id: "a".to_string(), + note_id, + }) + .await + .expect("note delete should succeed"); + + assert_eq!(delete.op, NoteOp::Delete); + + let after_delete = service + .graph_query(GraphQueryRequest { + tenant_id: TEST_TENANT.to_string(), + project_id: TEST_PROJECT.to_string(), + agent_id: "a".to_string(), + read_profile: "private_only".to_string(), + subject: GraphQueryEntityRef::Surface { surface: "Alice".to_string() }, + predicate: Some(GraphQueryPredicateRef::Surface { surface: "mentors".to_string() }), + scopes: Some(vec![TEST_SCOPE.to_string()]), + as_of: None, + limit: Some(10), + explain: Some(true), + }) + .await + .expect("graph query after delete should succeed"); + + assert!( + after_delete.facts.is_empty(), + "graph facts without active readable evidence notes must be suppressed" + ); + + test_db.cleanup().await.expect("Failed to cleanup test database."); +}