diff --git a/README.md b/README.md index 3ec59b3a..85eeefbb 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,11 @@ provider-backed ELF evidence was required. and surfaces it as `page_version_diff` in benchmark artifacts. The live command now reports `version_diff_coverage = 1.000` while preserving deterministic page content hashes and `source_mutation_allowed = false`. +- Graph topic-map reports after XY-1020: the June 20 follow-up adds + `elf.graph_report/v1` through service, HTTP, and MCP readback. Reports use + Postgres graph-lite facts to show current, historical, future, sourced, inferred, + ambiguous, stale, and superseded markers without introducing a separate graph + database or replacing source evidence. - Operator-approved public-proxy addendum after XY-930: the June 19 follow-up runs `cargo make baseline-production-private-addendum` with a simulated/public-proxy production corpus manifest approved for this stage. The run records 12 documents, @@ -348,6 +353,7 @@ Detailed evidence and interpretation: - [Service-Native Dreaming Readback Report - June 19, 2026](docs/evidence/benchmarking/2026-06-19-service-native-dreaming-readback-report.md) - [OpenMemory UI/Export Product Readback Report - June 19, 2026](docs/evidence/benchmarking/2026-06-19-openmemory-ui-export-product-readback-report.md) - [Operator-Approved Public-Proxy Production-Private Addendum - June 19, 2026](docs/evidence/benchmarking/2026-06-19-operator-approved-public-proxy-production-private-addendum.md) +- [Graph Topic-Map Report - June 20, 2026](docs/evidence/benchmarking/2026-06-20-graph-topic-map-report.md) - [Knowledge Workspace Version-Diff Report - June 20, 2026](docs/evidence/benchmarking/2026-06-20-knowledge-workspace-version-diff-report.md) - [Live Knowledge-Page Rebuild/Lint Report - June 20, 2026](docs/evidence/benchmarking/2026-06-20-live-knowledge-page-rebuild-lint-report.md) - [Live Baseline Benchmark Runbook](docs/runbook/benchmarking/live_baseline_benchmark.md) @@ -451,8 +457,9 @@ Detailed comparison, mechanism-level analysis, and source map: - [Dreaming Product Surface Follow-Up Research](docs/research/dreaming_product_surface_followup.md) Latest real-world benchmark report: June 20, 2026. Latest external research refresh: -June 11, 2026; June 20 adds the Knowledge Workspace Version-Diff Report - June 20, 2026 -and the Live Knowledge-Page Rebuild/Lint Report - June 20, 2026 after the June 19 +June 11, 2026; June 20 adds the Graph Topic-Map Report - June 20, 2026, +Knowledge Workspace Version-Diff Report - June 20, 2026, and the Live +Knowledge-Page Rebuild/Lint Report - June 20, 2026 after the June 19 XY-930 operator-approved public-proxy production addendum and service-native Dreaming readback, the qmd debug-ergonomics Dreaming retest, the June 17 competitor-strength closeout, and the June 16 temporal reconciliation, live consolidation self-check, diff --git a/apps/elf-api/src/routes.rs b/apps/elf-api/src/routes.rs index a0eec14c..55b0c728 100644 --- a/apps/elf-api/src/routes.rs +++ b/apps/elf-api/src/routes.rs @@ -54,17 +54,17 @@ use elf_service::{ DocsGetRequest, DocsGetResponse, DocsPutRequest, DocsPutResponse, DocsSearchL0Request, DocsSearchL0Response, EntityMemoryViewRequest, EntityMemoryViewResponse, Error, EventMessage, GranteeKind, GraphQueryEntityRef, GraphQueryPredicateRef, GraphQueryRequest, - GraphQueryResponse, IngestionProfileSelector, KnowledgePageGetRequest, - KnowledgePageLintRequest, KnowledgePageLintResponse, KnowledgePageRebuildRequest, - KnowledgePageRebuildResponse, KnowledgePageResponse, KnowledgePageSearchRequest, - KnowledgePageSearchResponse, KnowledgePagesListRequest, KnowledgePagesListResponse, - ListRequest, ListResponse, MemoryHistoryGetRequest, MemoryHistoryResponse, NoteFetchRequest, - NoteFetchResponse, NoteProvenanceBundleResponse, NoteProvenanceGetRequest, PayloadLevel, - PublishNoteRequest, QueryPlan, RankingRequestOverride, RebuildReport, SearchDetailsRequest, - SearchDetailsResult, SearchExplainRequest, SearchExplainResponse, SearchIndexItem, - SearchRequest, SearchResponse, SearchSessionGetRequest, SearchTimelineGroup, - SearchTimelineRequest, SearchTrajectoryResponse, SearchTrajectorySummary, ShareScope, - SpaceGrantRevokeRequest, SpaceGrantRevokeResponse, SpaceGrantUpsertRequest, + GraphQueryResponse, GraphReportRequest, GraphReportResponse, IngestionProfileSelector, + KnowledgePageGetRequest, KnowledgePageLintRequest, KnowledgePageLintResponse, + KnowledgePageRebuildRequest, KnowledgePageRebuildResponse, KnowledgePageResponse, + KnowledgePageSearchRequest, KnowledgePageSearchResponse, KnowledgePagesListRequest, + KnowledgePagesListResponse, ListRequest, ListResponse, MemoryHistoryGetRequest, + MemoryHistoryResponse, NoteFetchRequest, NoteFetchResponse, NoteProvenanceBundleResponse, + NoteProvenanceGetRequest, PayloadLevel, PublishNoteRequest, QueryPlan, RankingRequestOverride, + RebuildReport, SearchDetailsRequest, SearchDetailsResult, SearchExplainRequest, + SearchExplainResponse, SearchIndexItem, SearchRequest, SearchResponse, SearchSessionGetRequest, + SearchTimelineGroup, SearchTimelineRequest, SearchTrajectoryResponse, SearchTrajectorySummary, + ShareScope, SpaceGrantRevokeRequest, SpaceGrantRevokeResponse, SpaceGrantUpsertRequest, SpaceGrantsListRequest, TextPositionSelector, TextQuoteSelector, TraceBundleGetRequest, TraceBundleResponse, TraceGetRequest, TraceGetResponse, TraceRecentListRequest, TraceRecentListResponse, TraceTrajectoryGetRequest, UnpublishNoteRequest, UpdateRequest, @@ -285,6 +285,16 @@ struct GraphQueryBody { explain: Option, } +#[derive(Clone, Debug, Deserialize)] +struct GraphReportBody { + subject: GraphQueryEntityRef, + predicate: Option, + scopes: Option>, + as_of: Option, + limit: Option, + explain: Option, +} + #[derive(Clone, Debug, Deserialize)] struct SearchCreateRequest { mode: SearchMode, @@ -652,6 +662,7 @@ pub fn router(state: AppState) -> Router { .route("/v2/searches/{search_id}/timeline", routing::get(searches_timeline)) .route("/v2/searches/{search_id}/notes", routing::post(searches_notes)) .route("/v2/graph/query", routing::post(graph_query)) + .route("/v2/graph/report", routing::post(graph_report)) .route("/v2/notes", routing::get(notes_list)) .route( "/v2/notes/{note_id}", @@ -1846,6 +1857,52 @@ async fn graph_query( Ok(Json(response)) } +#[utoipa::path( + post, + path = "/v2/graph/report", + tag = "graph", + request_body = Value, + responses( + (status = 200, description = "Source-backed graph topic-map report.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +async fn graph_report( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = required_read_profile(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None) + })?; + let as_of = parse_optional_rfc3339(payload.as_of.as_ref(), "$.as_of")?; + let response = state + .service + .graph_report(GraphReportRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + subject: payload.subject, + predicate: payload.predicate, + scopes: payload.scopes, + as_of, + limit: payload.limit, + explain: payload.explain, + }) + .await?; + + Ok(Json(response)) +} + #[utoipa::path( post, path = "/v2/searches", diff --git a/apps/elf-eval/tests/real_world_job_benchmark.rs b/apps/elf-eval/tests/real_world_job_benchmark.rs index b86fb1f1..21faa114 100644 --- a/apps/elf-eval/tests/real_world_job_benchmark.rs +++ b/apps/elf-eval/tests/real_world_job_benchmark.rs @@ -304,6 +304,14 @@ fn graph_rag_citation_navigation_promotion_report_markdown_path() -> Result Result { + Ok(workspace_root()? + .join("docs") + .join("evidence") + .join("benchmarking") + .join("2026-06-20-graph-topic-map-report.md")) +} + fn operator_approved_public_proxy_private_addendum_report_markdown_path() -> Result { Ok(workspace_root()? .join("docs") @@ -3822,6 +3830,43 @@ fn graph_rag_citation_navigation_promotion_preserves_typed_non_passes() -> Resul Ok(()) } +#[test] +fn graph_topic_map_report_wires_source_backed_graph_lite_readback() -> Result<()> { + let markdown = fs::read_to_string(graph_topic_map_report_markdown_path()?)?; + let benchmarking_index = fs::read_to_string(benchmarking_index_path()?)?; + let readme = fs::read_to_string(readme_path()?)?; + let graph_report_service = + fs::read_to_string(workspace_root()?.join("packages/elf-service/src/graph_report.rs"))?; + let api_routes = fs::read_to_string(workspace_root()?.join("apps/elf-api/src/routes.rs"))?; + let mcp_server = fs::read_to_string(workspace_root()?.join("apps/elf-mcp/src/server.rs"))?; + let graph_spec = + fs::read_to_string(workspace_root()?.join("docs/spec/system_graph_memory_postgres_v1.md"))?; + + assert!(markdown.contains("Graph Topic-Map Report - June 20, 2026")); + assert!(markdown.contains("elf.graph_report/v1")); + assert!(markdown.contains("sourced")); + assert!(markdown.contains("inferred")); + assert!(markdown.contains("ambiguous")); + assert!(markdown.contains("stale")); + assert!(markdown.contains("superseded")); + assert!(markdown.contains("valid_from")); + assert!(markdown.contains("valid_to")); + assert!(markdown.contains("valid_at")); + assert!(markdown.contains("invalid_at")); + assert!(graph_report_service.contains("ELF_GRAPH_REPORT_SCHEMA_V1")); + assert!(graph_report_service.contains("GraphReportSummary")); + assert!(graph_report_service.contains("build_topic_map")); + assert!(api_routes.contains("/v2/graph/report")); + assert!(mcp_server.contains("elf_graph_report")); + assert!(graph_spec.contains("elf.graph_report/v1")); + assert!(graph_spec.contains("Graphiti/Zep `valid_at` and `invalid_at`")); + assert!(benchmarking_index.contains("2026-06-20-graph-topic-map-report.md")); + assert!(readme.contains("Graph Topic-Map Report - June 20, 2026")); + assert!(readme.contains("Graph topic-map reports after XY-1020")); + + Ok(()) +} + fn assert_openviking_trajectory_materialization_summary(report: &Value) -> Result<()> { assert_eq!( report.pointer("/schema").and_then(Value::as_str), diff --git a/apps/elf-mcp/src/server.rs b/apps/elf-mcp/src/server.rs index 015a8c3d..63440001 100644 --- a/apps/elf-mcp/src/server.rs +++ b/apps/elf-mcp/src/server.rs @@ -268,6 +268,15 @@ impl ElfMcp { self.forward(HttpMethod::Post, "/v2/graph/query", params, None).await } + #[rmcp::tool( + name = "elf_graph_report", + description = "Build a source-backed graph topic map with current, historical, future, inferred, ambiguous, stale, and superseded fact markers.", + input_schema = graph_report_schema() + )] + async fn elf_graph_report(&self, params: JsonObject) -> Result { + self.forward(HttpMethod::Post, "/v2/graph/report", params, None).await + } + #[rmcp::tool( name = "elf_events_ingest", description = "Ingest an event by extracting evidence-bound notes using the configured LLM extractor.", @@ -1024,6 +1033,10 @@ fn graph_query_schema() -> Arc { })) } +fn graph_report_schema() -> Arc { + graph_query_schema() +} + fn events_ingest_schema() -> Arc { Arc::new(rmcp::object!({ "type": "object", @@ -1603,7 +1616,7 @@ mod tests { type RequestRecorder = Arc>>>; - const ALL_TOOL_DEFINITIONS: [ToolDefinition; 31] = [ + const ALL_TOOL_DEFINITIONS: [ToolDefinition; 32] = [ ToolDefinition::new( "elf_notes_ingest", HttpMethod::Post, @@ -1616,6 +1629,12 @@ mod tests { "/v2/graph/query", "Query graph entities and relations by structured criteria.", ), + ToolDefinition::new( + "elf_graph_report", + HttpMethod::Post, + "/v2/graph/report", + "Build a source-backed graph topic map with current, historical, future, inferred, ambiguous, stale, and superseded fact markers.", + ), ToolDefinition::new( "elf_events_ingest", HttpMethod::Post, @@ -1828,6 +1847,7 @@ mod tests { let expected = [ "elf_notes_ingest", "elf_graph_query", + "elf_graph_report", "elf_events_ingest", "elf_core_blocks_get", "elf_entity_memory_get", diff --git a/docs/evidence/benchmarking/2026-06-20-graph-topic-map-report.md b/docs/evidence/benchmarking/2026-06-20-graph-topic-map-report.md new file mode 100644 index 00000000..47dbe59f --- /dev/null +++ b/docs/evidence/benchmarking/2026-06-20-graph-topic-map-report.md @@ -0,0 +1,74 @@ +--- +type: Evidence +title: "Graph Topic-Map Report - June 20, 2026" +description: "Checked-in benchmark evidence record: Graph Topic-Map Report - June 20, 2026." +resource: docs/evidence/benchmarking/2026-06-20-graph-topic-map-report.md +status: active +authority: current_state +owner: evidence +last_verified: 2026-06-20 +tags: + - docs + - evidence + - benchmarking +--- +# Graph Topic-Map Report - June 20, 2026 + +Goal: Close XY-1020's graph-lite product increment by proving ELF can report +Postgres-backed temporal graph facts as source-backed topic maps without introducing +a separate graph database or hidden source of truth. +Read this when: You need to know whether graph facts expose current, historical, +future, inferred, ambiguous, stale, and superseded status markers. +Inputs: `packages/elf-service/src/graph_report.rs`, `/v2/graph/report`, +`elf_graph_report`, and `docs/spec/system_graph_memory_postgres_v1.md`. +Outputs: Service, HTTP, MCP, and documentation evidence for `elf.graph_report/v1`. + +## Executive Judgment + +ELF now has a first-class graph report surface for one subject entity. The report +uses existing Postgres graph-lite facts, evidence links, predicate registry metadata, +validity windows, and supersession rows. It returns a topic map plus fact rows with +status markers for `sourced`, `inferred`, `ambiguous`, `stale`, and `superseded` +states. + +This is an ELF-native graph-memory readback improvement. It does not claim Graphiti, +Zep, GraphRAG, RAGFlow, LightRAG, llm-wiki, gbrain, or graphify parity. Graphiti/Zep +`valid_at` and `invalid_at` vocabulary remains adapter-boundary terminology only; +ELF internal schema and reports use `valid_from` and `valid_to`. + +## Command Evidence + +| Command | Result | +| --- | --- | +| `cargo test -p elf-service graph_report -- --nocapture` | Passed; proves temporal/source/supersession markers and topic-map edges are shaped by service code. | +| `cargo test -p elf-mcp registers_all_tools -- --nocapture` | Passed; guards that `elf_graph_report` remains registered. | +| `cargo test -p elf-eval --test real_world_job_benchmark graph_topic_map_report_wires_source_backed_graph_lite_readback -- --nocapture` | Passed; guards the service, HTTP, MCP, spec, README, and evidence-report wiring. | +| `cargo make check` | Passed; runs formatting, docs, clippy, vstyle, and workspace tests. | + +## Contract Readback + +| Surface | Contract | +| --- | --- | +| Service | `ElfService::graph_report(GraphReportRequest)` returns `elf.graph_report/v1`. | +| HTTP | `/v2/graph/report` builds a source-backed graph topic-map report under the authenticated read profile. | +| MCP | `elf_graph_report` forwards to `/v2/graph/report` for agent readback. | +| Storage | Existing Postgres graph-lite tables remain authoritative; no graph database is introduced. | +| Vocabulary | Internal schema uses `valid_from`/`valid_to`; Graphiti/Zep `valid_at`/`invalid_at` remains adapter-boundary vocabulary. | + +## Status Markers + +| Marker | Meaning | +| --- | --- | +| `sourced` | The fact has one or more `graph_fact_evidence.note_id` links. | +| `inferred` | The predicate is pending or unresolved rather than operator-activated. | +| `ambiguous` | Multiple current facts conflict under a single-cardinality predicate. | +| `stale` | The fact is historical at the report `as_of` timestamp. | +| `superseded` | A `graph_fact_supersessions` row links the fact to a replacement. | + +## Follow-Up Queue + +| Follow-up | Reason | +| --- | --- | +| XY-1021 | Dreaming/background proposal review can now cite graph report markers before recommending rebuilds or mutations. | +| XY-1022 | Plugin/admin surfaces can expose graph report readback without bypassing source evidence. | +| XY-1023 | Benchmark adapters can score graph report parity only after comparable external artifacts exist. | diff --git a/docs/evidence/benchmarking/index.md b/docs/evidence/benchmarking/index.md index e444fb6a..557d75d1 100644 --- a/docs/evidence/benchmarking/index.md +++ b/docs/evidence/benchmarking/index.md @@ -43,5 +43,6 @@ Routes to: Benchmarking evidence concepts under `docs/evidence/benchmarking/`. - `2026-06-19-operator-approved-public-proxy-production-private-addendum.md`: Operator-Approved Public-Proxy Production-Private Addendum - June 19, 2026; closes the current XY-930 proxy/simulated-corpus stage with 8/8 query pass, 0 wrong_result, and explicit boundaries that this is not real private-corpus or provider-backed proof. - `2026-06-19-qmd-debug-ergonomics-dreaming-retest-report.md`: qmd Debug-Ergonomics Dreaming Retest Report - June 19, 2026; confirms qmd's default top-k/replay edge is unchanged while ELF keeps the narrow operator-debug trace/stage visibility wins. - `2026-06-19-service-native-dreaming-readback-report.md`: Service-Native Dreaming Readback Report - June 19, 2026; materializes memory summary, proactive brief, and scheduled-memory derived outputs through `ElfService` readback with 9 pass, 0 wrong_result, and 2 typed XY-930 blockers. +- `2026-06-20-graph-topic-map-report.md`: Graph Topic-Map Report - June 20, 2026; adds the ELF-native `elf.graph_report/v1` readback for Postgres graph-lite facts with sourced, inferred, ambiguous, stale, and superseded topic-map markers. - `2026-06-20-knowledge-workspace-version-diff-report.md`: Knowledge Workspace Version-Diff Report - June 20, 2026; proves ELF knowledge pages now expose previous-version diff metadata without perturbing page content hashes while preserving citation, lint, and source-of-truth boundaries. - `2026-06-20-live-knowledge-page-rebuild-lint-report.md`: Live Knowledge-Page Rebuild/Lint Report - June 20, 2026; adds a Docker-contained ELF service-native knowledge-page materialization command while preserving llm-wiki, gbrain, GraphRAG, RAGFlow, LightRAG, and graphify as separate comparison targets until they emit comparable scored page artifacts. diff --git a/docs/log.md b/docs/log.md index f136fa97..e6f0f7ed 100644 --- a/docs/log.md +++ b/docs/log.md @@ -71,3 +71,7 @@ logs. rebuild metadata now exposes `elf.knowledge_page.version_diff/v1`, live benchmark artifacts expose `page_version_diff`, and the Docker-contained live knowledge report now publishes `version_diff_coverage`. +- Added the Graph Topic-Map report for XY-1020. ELF now exposes + `elf.graph_report/v1` through service, HTTP, and MCP readback, using existing + Postgres graph-lite facts with sourced, inferred, ambiguous, stale, and superseded + markers while keeping `valid_from`/`valid_to` as the internal temporal vocabulary. diff --git a/docs/spec/system_graph_memory_postgres_v1.md b/docs/spec/system_graph_memory_postgres_v1.md index 70610304..c68fa579 100644 --- a/docs/spec/system_graph_memory_postgres_v1.md +++ b/docs/spec/system_graph_memory_postgres_v1.md @@ -217,6 +217,13 @@ Supersession rule (write-time): - `historical` when `valid_to <= read_at`. - `future` when `valid_from > read_at`. - Search relation context may include historical facts when they are evidence-linked to a returned note, but it must label them as historical instead of silently treating them as current. +- Graph report APIs expose `elf.graph_report/v1` topic maps from the same Postgres + graph-lite tables. Report facts must retain `valid_from`, `valid_to`, + `evidence_note_ids`, and supersession links, and must mark sourced, inferred, + ambiguous, stale, and superseded states distinctly. +- Graphiti/Zep `valid_at` and `invalid_at` vocabulary is adapter-boundary + terminology only. ELF internal schema, reports, docs, and service payloads use + `valid_from` and `valid_to`. ============================================================ 7. CALL EXAMPLES diff --git a/packages/elf-service/src/graph_report.rs b/packages/elf-service/src/graph_report.rs new file mode 100644 index 00000000..7d18dc4c --- /dev/null +++ b/packages/elf-service/src/graph_report.rs @@ -0,0 +1,931 @@ +//! Source-backed graph topic-map reports. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgConnection}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{ + ElfService, Error, Result, + access::{self, ORG_PROJECT_ID}, + graph::RelationTemporalStatus, + graph_query::{ + self, GraphQueryEntityRef, GraphQueryObject, GraphQueryObjectEntity, GraphQueryPredicateRef, + }, + search, +}; +use elf_storage::{graph, models::GraphEntity}; + +/// Schema identifier for graph report responses. +pub const ELF_GRAPH_REPORT_SCHEMA_V1: &str = "elf.graph_report/v1"; + +const DEFAULT_GRAPH_REPORT_LIMIT: u32 = 100; +const MAX_GRAPH_REPORT_LIMIT: u32 = 500; +const GRAPH_REPORT_EVIDENCE_LIMIT: i64 = 24; +const GRAPH_REPORT_FACTS_SQL: &str = "\ +SELECT + gf.fact_id, + gf.scope, + gf.agent_id AS actor, + gf.predicate, + gf.predicate_id, + gp.status AS predicate_status, + gp.cardinality AS predicate_cardinality, + gf.object_entity_id, + object_entity.canonical AS object_canonical, + object_entity.kind AS object_kind, + gf.object_value, + gf.valid_from, + gf.valid_to, + COALESCE( + (SELECT ARRAY_AGG(e.note_id ORDER BY e.created_at ASC, e.note_id ASC) + FROM ( + SELECT note_id, created_at + FROM graph_fact_evidence + WHERE fact_id = gf.fact_id + ORDER BY created_at ASC, note_id ASC + LIMIT $9 + ) e), + '{}'::uuid[] + ) AS evidence_note_ids, + COALESCE( + (SELECT ARRAY_AGG(s.to_fact_id ORDER BY s.effective_at ASC, s.to_fact_id ASC) + FROM graph_fact_supersessions s + WHERE s.from_fact_id = gf.fact_id), + '{}'::uuid[] + ) AS superseded_by_fact_ids, + COALESCE( + (SELECT ARRAY_AGG(s.from_fact_id ORDER BY s.effective_at ASC, s.from_fact_id ASC) + FROM graph_fact_supersessions s + WHERE s.to_fact_id = gf.fact_id), + '{}'::uuid[] + ) AS supersedes_fact_ids +FROM graph_facts AS gf +LEFT JOIN graph_predicates AS gp + ON gp.predicate_id = gf.predicate_id +LEFT JOIN graph_entities AS object_entity + ON object_entity.entity_id = gf.object_entity_id + AND object_entity.tenant_id = gf.tenant_id + AND object_entity.project_id = gf.project_id +WHERE gf.tenant_id = $1 + AND (gf.project_id = $2 OR (gf.project_id = $10 AND gf.scope = 'org_shared')) + AND gf.subject_entity_id = $3 + AND gf.scope = ANY($4::text[]) + AND ($11::uuid IS NULL OR gf.predicate_id = $11) + AND ( + (gf.scope = 'agent_private' AND gf.agent_id = $6) + OR (gf.scope <> 'agent_private' AND ( + gf.agent_id = $6 OR (gf.scope || ':' || gf.agent_id) = ANY($7::text[]) + )) + ) +ORDER BY gf.valid_from DESC, gf.fact_id ASC +LIMIT $8"; + +/// Request payload for a graph topic-map report. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct GraphReportRequest { + /// Tenant to query within. + pub tenant_id: String, + /// Project to query within. + pub project_id: String, + /// Agent requesting the read. + pub agent_id: String, + /// Read profile that determines visible scopes. + pub read_profile: String, + /// Subject entity selector. + pub subject: GraphQueryEntityRef, + /// Optional predicate selector used to narrow the report. + pub predicate: Option, + /// Optional requested scopes. + pub scopes: Option>, + #[serde(with = "crate::time_serde::option")] + /// Point-in-time used for current, historical, and future classification. + pub as_of: Option, + /// Optional maximum number of returned facts. + pub limit: Option, + /// When true, includes explain metadata. + pub explain: Option, +} + +/// Response payload for a graph topic-map report. +#[derive(Clone, Debug, Serialize)] +pub struct GraphReportResponse { + /// Report schema identifier. + pub schema: String, + #[serde(with = "crate::time_serde")] + /// Effective point-in-time view used for temporal classification. + pub as_of: OffsetDateTime, + /// Resolved subject entity. + pub subject: GraphReportEntity, + #[serde(skip_serializing_if = "Option::is_none")] + /// Resolved predicate, when the request filtered by predicate. + pub predicate: Option, + /// Effective scopes used for the report. + pub scopes: Vec, + /// Aggregate report counters. + pub summary: GraphReportSummary, + /// Topic map projection of the graph facts. + pub topic_map: GraphTopicMap, + /// Returned fact rows. + pub facts: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional explain metadata. + pub explain: Option, +} + +/// Resolved graph entity reference. +#[derive(Clone, Debug, Serialize)] +pub struct GraphReportEntity { + /// Entity identifier. + pub entity_id: Uuid, + /// Canonical entity surface. + pub canonical: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional entity kind. + pub kind: Option, +} + +/// Resolved graph predicate reference. +#[derive(Clone, Debug, Serialize)] +pub struct GraphReportPredicate { + /// Predicate identifier. + pub predicate_id: Uuid, + /// Canonical predicate surface. + pub canonical: String, +} + +/// Aggregate counters for graph reports. +#[derive(Clone, Debug, Default, Serialize)] +pub struct GraphReportSummary { + /// Number of returned facts. + pub fact_count: usize, + /// Number of facts current at `as_of`. + pub current_count: usize, + /// Number of facts historical at `as_of`. + pub historical_count: usize, + /// Number of facts whose validity starts after `as_of`. + pub future_count: usize, + /// Number of facts with at least one evidence note link. + pub sourced_count: usize, + /// Number of facts still backed by pending or unresolved predicate vocabulary. + pub inferred_count: usize, + /// Number of facts that conflict under a single-cardinality predicate. + pub ambiguous_count: usize, + /// Number of stale facts, currently equivalent to historical facts. + pub stale_count: usize, + /// Number of facts linked to a superseding replacement. + pub superseded_count: usize, + /// Total evidence note links returned with the facts. + pub evidence_link_count: usize, +} + +/// One graph fact returned by a graph report. +#[derive(Clone, Debug, Serialize)] +pub struct GraphReportFact { + /// Fact identifier. + pub fact_id: Uuid, + /// Scope key for the fact. + pub scope: String, + /// Agent that emitted the fact. + pub actor: String, + /// Predicate surface recorded on the fact. + pub predicate: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Resolved predicate identifier, when available. + pub predicate_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Predicate registry status, when available. + pub predicate_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Predicate registry cardinality, when available. + pub predicate_cardinality: Option, + #[serde(with = "crate::time_serde")] + /// Start of the fact validity window. + pub valid_from: OffsetDateTime, + #[serde(with = "crate::time_serde::option")] + /// End of the fact validity window, if superseded or explicitly bounded. + pub valid_to: Option, + /// Temporal state for the fact relative to report `as_of`. + pub temporal_status: RelationTemporalStatus, + /// Object payload for the fact. + pub object: GraphQueryObject, + /// Evidence note identifiers supporting the fact. + pub evidence_note_ids: Vec, + /// Replacement fact ids that supersede this fact. + pub superseded_by_fact_ids: Vec, + /// Older fact ids superseded by this fact. + pub supersedes_fact_ids: Vec, + /// Source-backed report status markers. + pub status_markers: Vec, +} + +/// Topic-map projection for graph reports. +#[derive(Clone, Debug, Serialize)] +pub struct GraphTopicMap { + /// Topic-map nodes. + pub nodes: Vec, + /// Topic-map edges, one per returned fact. + pub edges: Vec, +} + +/// Topic-map node. +#[derive(Clone, Debug, Serialize)] +pub struct GraphTopicNode { + /// Stable node identifier. + pub node_id: String, + /// Human-readable node label. + pub label: String, + /// Node type such as subject, entity, or value. + pub node_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + /// Optional entity kind. + pub kind: Option, +} + +/// Topic-map edge. +#[derive(Clone, Debug, Serialize)] +pub struct GraphTopicEdge { + /// Backing fact identifier. + pub fact_id: Uuid, + /// Source topic node identifier. + pub source_node_id: String, + /// Target topic node identifier. + pub target_node_id: String, + /// Predicate label. + pub predicate: String, + /// Temporal state for the edge. + pub temporal_status: RelationTemporalStatus, + /// Source-backed report status markers. + pub status_markers: Vec, + /// Evidence note identifiers supporting the edge. + pub evidence_note_ids: Vec, +} + +/// Explain metadata for graph reports. +#[derive(Clone, Debug, Serialize)] +pub struct GraphReportExplain { + /// Explain schema identifier. + pub schema: String, + #[serde(with = "crate::time_serde")] + /// Effective point-in-time used for classification. + pub as_of: OffsetDateTime, + /// Requested result limit. + pub requested_limit: u32, + /// Scopes allowed by the read profile. + pub allowed_scopes: Vec, + /// Scopes effectively queried after request filtering. + pub effective_scopes: Vec, + /// Number of rows read from storage. + pub queried_rows: usize, + /// Number of rows returned to the caller. + pub returned_rows: usize, + /// Whether the result set was truncated by the limit. + pub truncated: bool, +} + +#[derive(Debug)] +struct PreparedGraphReport { + tenant_id: String, + project_id: String, + agent_id: String, + read_profile: String, + subject: GraphQueryEntityRef, + predicate: Option, + requested_scopes: Vec, + as_of: OffsetDateTime, + limit: usize, + explain: bool, +} + +#[derive(Debug)] +struct ResolvedGraphReportSubject { + entity_id: Uuid, + canonical: String, + kind: Option, +} + +#[derive(Debug)] +struct ResolvedGraphReportPredicate { + id: Uuid, + canonical: String, +} + +#[derive(Debug)] +struct GraphReportRowsFetchParams<'a> { + tenant_id: &'a str, + project_id: &'a str, + subject_entity_id: Uuid, + scopes: &'a [String], + actor: &'a str, + shared_scope_keys: &'a [String], + predicate_id: Option, + limit_plus_one: i64, +} + +#[derive(Debug, FromRow)] +struct GraphReportFactRow { + fact_id: Uuid, + scope: String, + actor: String, + predicate: String, + predicate_id: Option, + predicate_status: Option, + predicate_cardinality: Option, + object_entity_id: Option, + object_canonical: Option, + object_kind: Option, + object_value: Option, + valid_from: OffsetDateTime, + valid_to: Option, + evidence_note_ids: Vec, + superseded_by_fact_ids: Vec, + supersedes_fact_ids: Vec, +} + +impl ElfService { + /// Builds a source-backed graph report for one subject entity. + pub async fn graph_report(&self, req: GraphReportRequest) -> Result { + let prepared = validate_graph_report_request(req)?; + let allowed_scopes = + search::resolve_read_profile_scopes(&self.cfg, prepared.read_profile.as_str())?; + let effective_scopes = graph_query::resolve_effective_scopes( + &allowed_scopes, + prepared.requested_scopes.as_slice(), + )?; + let org_shared_allowed = allowed_scopes.iter().any(|scope| scope.trim() == "org_shared"); + let mut conn = self.db.pool.acquire().await?; + let subject = + resolve_subject(&mut conn, &prepared.tenant_id, &prepared.project_id, prepared.subject) + .await?; + let predicate = resolve_predicate( + &mut conn, + &prepared.tenant_id, + &prepared.project_id, + prepared.predicate, + ) + .await?; + let shared_grants = access::load_shared_read_grants_with_org_shared( + conn.as_mut(), + prepared.tenant_id.as_str(), + prepared.project_id.as_str(), + prepared.agent_id.as_str(), + org_shared_allowed, + ) + .await?; + let shared_scope_keys: Vec = shared_grants + .into_iter() + .map(|item| format!("{}:{}", item.scope, item.space_owner_agent_id)) + .collect(); + let predicate_id = predicate.as_ref().map(|predicate| predicate.id); + let rows = fetch_graph_report_rows( + &mut conn, + GraphReportRowsFetchParams { + tenant_id: prepared.tenant_id.as_str(), + project_id: prepared.project_id.as_str(), + subject_entity_id: subject.entity_id, + scopes: effective_scopes.as_slice(), + actor: prepared.agent_id.as_str(), + shared_scope_keys: shared_scope_keys.as_slice(), + predicate_id, + limit_plus_one: (prepared.limit as i64) + 1, + }, + ) + .await?; + let queried_rows = rows.len(); + let (rows, truncated) = truncate_report_rows(rows, prepared.limit); + let facts = build_report_facts(rows, prepared.as_of); + let summary = summarize_report_facts(&facts); + let topic_map = build_topic_map(&subject, &facts); + let explain = if prepared.explain { + Some(GraphReportExplain { + schema: ELF_GRAPH_REPORT_SCHEMA_V1.to_string(), + as_of: prepared.as_of, + requested_limit: prepared.limit as u32, + allowed_scopes, + effective_scopes: effective_scopes.clone(), + queried_rows, + returned_rows: facts.len(), + truncated, + }) + } else { + None + }; + + Ok(GraphReportResponse { + schema: ELF_GRAPH_REPORT_SCHEMA_V1.to_string(), + as_of: prepared.as_of, + subject: GraphReportEntity { + entity_id: subject.entity_id, + canonical: subject.canonical, + kind: subject.kind, + }, + predicate: predicate.map(|resolved| GraphReportPredicate { + predicate_id: resolved.id, + canonical: resolved.canonical, + }), + scopes: effective_scopes, + summary, + topic_map, + facts, + explain, + }) + } +} + +fn validate_graph_report_request(req: GraphReportRequest) -> Result { + let tenant_id = normalize_required_field(req.tenant_id.as_str(), "tenant_id")?; + let project_id = normalize_required_field(req.project_id.as_str(), "project_id")?; + let agent_id = normalize_required_field(req.agent_id.as_str(), "agent_id")?; + let read_profile = normalize_required_field(req.read_profile.as_str(), "read_profile")?; + let subject = match req.subject { + GraphQueryEntityRef::EntityId { entity_id } => GraphQueryEntityRef::EntityId { entity_id }, + GraphQueryEntityRef::Surface { surface } => { + let surface = normalize_required_field(surface.as_str(), "subject.surface")?; + + GraphQueryEntityRef::Surface { surface } + }, + }; + let predicate = match req.predicate { + Some(GraphQueryPredicateRef::PredicateId { predicate_id }) => + Some(GraphQueryPredicateRef::PredicateId { predicate_id }), + Some(GraphQueryPredicateRef::Surface { surface }) => { + let surface = normalize_required_field(surface.as_str(), "predicate.surface")?; + + Some(GraphQueryPredicateRef::Surface { surface }) + }, + None => None, + }; + let requested_scopes = normalize_scopes(req.scopes)?; + let limit = req.limit.unwrap_or(DEFAULT_GRAPH_REPORT_LIMIT); + + if !matches!(limit, 1..=MAX_GRAPH_REPORT_LIMIT) { + return Err(Error::InvalidRequest { + message: format!("limit must be between 1 and {MAX_GRAPH_REPORT_LIMIT}."), + }); + } + + Ok(PreparedGraphReport { + tenant_id, + project_id, + agent_id, + read_profile, + subject, + predicate, + requested_scopes, + as_of: req.as_of.unwrap_or_else(OffsetDateTime::now_utc), + limit: limit as usize, + explain: req.explain.unwrap_or(false), + }) +} + +fn normalize_required_field(value: &str, field: &str) -> Result { + let trimmed = value.trim(); + + if trimmed.is_empty() { + return Err(Error::InvalidRequest { message: format!("{field} is required.") }); + } + + Ok(trimmed.to_string()) +} + +fn normalize_scopes(scopes: Option>) -> Result> { + let scopes = scopes.unwrap_or_default(); + let mut seen = BTreeSet::new(); + let mut normalized = Vec::new(); + + for scope in scopes { + let scope = scope.trim().to_string(); + + if scope.is_empty() { + return Err(Error::InvalidRequest { + message: "scopes entries must be non-empty strings.".to_string(), + }); + } + if seen.insert(scope.clone()) { + normalized.push(scope); + } + } + + Ok(normalized) +} + +fn truncate_report_rows( + mut rows: Vec, + limit: usize, +) -> (Vec, bool) { + let truncated = rows.len() > limit; + + if truncated { + rows.truncate(limit); + } + + (rows, truncated) +} + +fn build_report_facts( + rows: Vec, + as_of: OffsetDateTime, +) -> Vec { + let current_single_counts = current_single_predicate_counts(&rows, as_of); + + rows.into_iter() + .map(|row| { + let temporal_status = + crate::graph::relation_temporal_status(row.valid_from, row.valid_to, as_of); + let object = graph_object(&row); + let predicate_key = predicate_group_key(&row); + let ambiguous = temporal_status == RelationTemporalStatus::Current + && row.predicate_cardinality.as_deref() == Some("single") + && current_single_counts.get(&predicate_key).copied().unwrap_or(0) > 1; + let status_markers = report_status_markers(&row, temporal_status, ambiguous); + + GraphReportFact { + fact_id: row.fact_id, + scope: row.scope, + actor: row.actor, + predicate: row.predicate, + predicate_id: row.predicate_id, + predicate_status: row.predicate_status, + predicate_cardinality: row.predicate_cardinality, + valid_from: row.valid_from, + valid_to: row.valid_to, + temporal_status, + object, + evidence_note_ids: row.evidence_note_ids, + superseded_by_fact_ids: row.superseded_by_fact_ids, + supersedes_fact_ids: row.supersedes_fact_ids, + status_markers, + } + }) + .collect() +} + +fn current_single_predicate_counts( + rows: &[GraphReportFactRow], + as_of: OffsetDateTime, +) -> BTreeMap { + let mut counts = BTreeMap::new(); + + for row in rows { + if row.predicate_cardinality.as_deref() != Some("single") { + continue; + } + if crate::graph::relation_temporal_status(row.valid_from, row.valid_to, as_of) + != RelationTemporalStatus::Current + { + continue; + } + + *counts.entry(predicate_group_key(row)).or_insert(0) += 1; + } + + counts +} + +fn predicate_group_key(row: &GraphReportFactRow) -> String { + row.predicate_id + .map(|id| id.to_string()) + .unwrap_or_else(|| format!("surface:{}", row.predicate)) +} + +fn graph_object(row: &GraphReportFactRow) -> GraphQueryObject { + if let Some(entity_id) = row.object_entity_id { + return GraphQueryObject { + entity: Some(GraphQueryObjectEntity { + entity_id, + canonical: row.object_canonical.clone().unwrap_or_default(), + kind: row.object_kind.clone(), + }), + value: None, + }; + } + + GraphQueryObject { entity: None, value: row.object_value.clone() } +} + +fn report_status_markers( + row: &GraphReportFactRow, + temporal_status: RelationTemporalStatus, + ambiguous: bool, +) -> Vec { + let mut markers = Vec::new(); + + if row.evidence_note_ids.is_empty() { + markers.push("unsupported".to_string()); + } else { + markers.push("sourced".to_string()); + } + if row.predicate_status.as_deref() != Some("active") { + markers.push("inferred".to_string()); + } + if temporal_status == RelationTemporalStatus::Historical { + markers.push("stale".to_string()); + } + if !row.superseded_by_fact_ids.is_empty() { + markers.push("superseded".to_string()); + } + if ambiguous { + markers.push("ambiguous".to_string()); + } + + markers +} + +fn summarize_report_facts(facts: &[GraphReportFact]) -> GraphReportSummary { + let mut summary = GraphReportSummary { fact_count: facts.len(), ..Default::default() }; + + for fact in facts { + match fact.temporal_status { + RelationTemporalStatus::Current => summary.current_count += 1, + RelationTemporalStatus::Historical => summary.historical_count += 1, + RelationTemporalStatus::Future => summary.future_count += 1, + } + + if !fact.evidence_note_ids.is_empty() { + summary.sourced_count += 1; + } + if fact.status_markers.iter().any(|marker| marker == "inferred") { + summary.inferred_count += 1; + } + if fact.status_markers.iter().any(|marker| marker == "ambiguous") { + summary.ambiguous_count += 1; + } + if fact.status_markers.iter().any(|marker| marker == "stale") { + summary.stale_count += 1; + } + if fact.status_markers.iter().any(|marker| marker == "superseded") { + summary.superseded_count += 1; + } + + summary.evidence_link_count += fact.evidence_note_ids.len(); + } + + summary +} + +fn build_topic_map( + subject: &ResolvedGraphReportSubject, + facts: &[GraphReportFact], +) -> GraphTopicMap { + let subject_node_id = format!("entity:{}", subject.entity_id); + let mut nodes = BTreeMap::new(); + + nodes.insert( + subject_node_id.clone(), + GraphTopicNode { + node_id: subject_node_id.clone(), + label: subject.canonical.clone(), + node_type: "subject".to_string(), + kind: subject.kind.clone(), + }, + ); + + let edges = facts + .iter() + .map(|fact| { + let (target_node_id, label, kind, node_type) = match &fact.object.entity { + Some(entity) => ( + format!("entity:{}", entity.entity_id), + entity.canonical.clone(), + entity.kind.clone(), + "entity".to_string(), + ), + None => ( + format!("value:{}", fact.object.value.as_deref().unwrap_or_default()), + fact.object.value.clone().unwrap_or_default(), + None, + "value".to_string(), + ), + }; + + nodes.entry(target_node_id.clone()).or_insert_with(|| GraphTopicNode { + node_id: target_node_id.clone(), + label, + node_type, + kind, + }); + GraphTopicEdge { + fact_id: fact.fact_id, + source_node_id: subject_node_id.clone(), + target_node_id, + predicate: fact.predicate.clone(), + temporal_status: fact.temporal_status, + status_markers: fact.status_markers.clone(), + evidence_note_ids: fact.evidence_note_ids.clone(), + } + }) + .collect(); + + GraphTopicMap { nodes: nodes.into_values().collect(), edges } +} + +async fn resolve_subject( + conn: &mut PgConnection, + tenant_id: &str, + project_id: &str, + subject: GraphQueryEntityRef, +) -> Result { + match subject { + GraphQueryEntityRef::EntityId { entity_id } => { + let row = sqlx::query_as::<_, GraphEntity>( + "\ +SELECT + entity_id, + tenant_id, + project_id, + canonical, + canonical_norm, + kind, + created_at, + updated_at +FROM graph_entities +WHERE tenant_id = $1 + AND project_id = $2 + AND entity_id = $3", + ) + .bind(tenant_id) + .bind(project_id) + .bind(entity_id) + .fetch_optional(conn) + .await?; + let Some(row) = row else { + return Err(Error::NotFound { + message: format!("graph entity not found for subject entity_id={entity_id}"), + }); + }; + + Ok(ResolvedGraphReportSubject { + entity_id: row.entity_id, + canonical: row.canonical, + kind: row.kind, + }) + }, + GraphQueryEntityRef::Surface { surface } => { + let Some(row) = + graph::resolve_entity_by_surface(conn, tenant_id, project_id, &surface).await? + else { + return Err(Error::NotFound { + message: format!("graph entity not found for subject surface={surface}"), + }); + }; + + Ok(ResolvedGraphReportSubject { + entity_id: row.entity_id, + canonical: row.canonical, + kind: row.kind, + }) + }, + } +} + +async fn resolve_predicate( + conn: &mut PgConnection, + tenant_id: &str, + project_id: &str, + predicate: Option, +) -> Result> { + match predicate { + Some(GraphQueryPredicateRef::PredicateId { predicate_id }) => { + let Some(row) = graph::get_predicate_by_id(conn, predicate_id).await? else { + return Err(Error::NotFound { + message: format!("graph predicate not found; predicate_id={predicate_id}"), + }); + }; + + Ok(Some(ResolvedGraphReportPredicate { + id: row.predicate_id, + canonical: row.canonical, + })) + }, + Some(GraphQueryPredicateRef::Surface { surface }) => { + let Some(row) = + graph::resolve_predicate_no_register(conn, tenant_id, project_id, &surface).await? + else { + return Err(Error::NotFound { + message: format!("graph predicate not found for surface={surface}"), + }); + }; + + Ok(Some(ResolvedGraphReportPredicate { + id: row.predicate_id, + canonical: row.canonical, + })) + }, + None => Ok(None), + } +} + +async fn fetch_graph_report_rows( + conn: &mut PgConnection, + params: GraphReportRowsFetchParams<'_>, +) -> Result> { + let rows = sqlx::query_as::<_, GraphReportFactRow>(GRAPH_REPORT_FACTS_SQL) + .bind(params.tenant_id) + .bind(params.project_id) + .bind(params.subject_entity_id) + .bind(params.scopes) + .bind(OffsetDateTime::now_utc()) + .bind(params.actor) + .bind(params.shared_scope_keys) + .bind(params.limit_plus_one) + .bind(GRAPH_REPORT_EVIDENCE_LIMIT) + .bind(ORG_PROJECT_ID) + .bind(params.predicate_id) + .fetch_all(conn) + .await?; + + Ok(rows) +} + +#[cfg(test)] +mod tests { + use time::OffsetDateTime; + use uuid::Uuid; + + use crate::{ + RelationTemporalStatus, + graph_report::{self, GraphReportFactRow}, + }; + + fn ts(value: i64) -> OffsetDateTime { + OffsetDateTime::from_unix_timestamp(value).expect("valid timestamp") + } + + fn row( + raw_id: u128, + object_value: &str, + valid_from: i64, + valid_to: Option, + predicate_status: &str, + cardinality: &str, + superseded_by: Vec, + ) -> GraphReportFactRow { + GraphReportFactRow { + fact_id: Uuid::from_u128(raw_id), + scope: "agent_private".to_string(), + actor: "agent".to_string(), + predicate: "works at".to_string(), + predicate_id: Some(Uuid::from_u128(999)), + predicate_status: Some(predicate_status.to_string()), + predicate_cardinality: Some(cardinality.to_string()), + object_entity_id: None, + object_canonical: None, + object_kind: None, + object_value: Some(object_value.to_string()), + valid_from: ts(valid_from), + valid_to: valid_to.map(ts), + evidence_note_ids: vec![Uuid::from_u128(raw_id + 10_000)], + superseded_by_fact_ids: superseded_by, + supersedes_fact_ids: vec![], + } + } + + #[test] + fn graph_report_classifies_temporal_source_and_supersession_markers() { + let replacement_id = Uuid::from_u128(2); + let facts = graph_report::build_report_facts( + vec![ + row(1, "Initech", 10, Some(20), "active", "single", vec![replacement_id]), + row(2, "Globex", 20, None, "active", "single", vec![]), + row(3, "Umbrella", 30, None, "pending", "single", vec![]), + ], + ts(25), + ); + let summary = graph_report::summarize_report_facts(&facts); + + assert_eq!(summary.fact_count, 3); + assert_eq!(summary.current_count, 1); + assert_eq!(summary.historical_count, 1); + assert_eq!(summary.future_count, 1); + assert_eq!(summary.sourced_count, 3); + assert_eq!(summary.inferred_count, 1); + assert_eq!(summary.stale_count, 1); + assert_eq!(summary.superseded_count, 1); + assert_eq!(summary.evidence_link_count, 3); + assert_eq!(facts[0].temporal_status, RelationTemporalStatus::Historical); + assert!(facts[0].status_markers.iter().any(|marker| marker == "superseded")); + assert!(facts[2].status_markers.iter().any(|marker| marker == "inferred")); + } + + #[test] + fn graph_topic_map_preserves_fact_edges_and_source_markers() { + let subject = super::ResolvedGraphReportSubject { + entity_id: Uuid::from_u128(42), + canonical: "Alice".to_string(), + kind: Some("person".to_string()), + }; + let facts = graph_report::build_report_facts( + vec![row(1, "Globex", 20, None, "active", "single", vec![])], + ts(25), + ); + let topic_map = graph_report::build_topic_map(&subject, &facts); + + assert_eq!(topic_map.nodes.len(), 2); + assert_eq!(topic_map.edges.len(), 1); + assert_eq!(topic_map.edges[0].predicate, "works at"); + assert_eq!(topic_map.edges[0].temporal_status, RelationTemporalStatus::Current); + assert!(topic_map.edges[0].status_markers.iter().any(|marker| marker == "sourced")); + } +} diff --git a/packages/elf-service/src/lib.rs b/packages/elf-service/src/lib.rs index bdfa32d9..d95b02c7 100644 --- a/packages/elf-service/src/lib.rs +++ b/packages/elf-service/src/lib.rs @@ -13,6 +13,7 @@ pub mod docs; pub mod entity_memory; pub mod graph; pub mod graph_query; +pub mod graph_report; pub mod knowledge; pub mod list; pub mod notes; @@ -72,6 +73,11 @@ pub use self::{ GraphQueryFact, GraphQueryObject, GraphQueryObjectEntity, GraphQueryPredicate, GraphQueryPredicateRef, GraphQueryRequest, GraphQueryResponse, }, + graph_report::{ + ELF_GRAPH_REPORT_SCHEMA_V1, GraphReportEntity, GraphReportExplain, GraphReportFact, + GraphReportPredicate, GraphReportRequest, GraphReportResponse, GraphReportSummary, + GraphTopicEdge, GraphTopicMap, GraphTopicNode, + }, ingestion_profiles::{ AdminIngestionProfileCreateRequest, AdminIngestionProfileDefaultGetRequest, AdminIngestionProfileDefaultResponse, AdminIngestionProfileDefaultSetRequest,