Skip to main content

uqa_graph/
adapters.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Explicit adapters between posting representations.
8//!
9//! These transformations map values only. They are intentionally not called
10//! functors: no operator/morphism map is defined, so category identity and
11//! composition laws are not part of their contract.
12
13use std::collections::BTreeMap;
14
15use uqa_core::{Payload, PostingEntry, PostingList};
16
17use crate::posting_list::{GraphPayload, GraphPostingList, GraphPostingListResult};
18
19/// Versioned codec between graph side-table storage and an ordinary posting
20/// payload. Encoding preserves graph metadata rather than stripping it.
21pub struct GraphPostingCodec;
22
23impl GraphPostingCodec {
24    #[allow(clippy::needless_pass_by_value)]
25    pub fn encode(graph: GraphPostingList) -> PostingList {
26        graph.to_posting_list()
27    }
28
29    pub fn decode(posting: &PostingList) -> GraphPostingList {
30        GraphPostingList::from_posting_list(posting)
31    }
32}
33
34/// Attach a shared vertex-context side table to every posting entry.
35///
36/// This adapter does not invent graph edges, so it has no edge-label option.
37#[derive(Debug, Clone, Copy, Default)]
38pub struct PostingToGraphAdapter;
39
40impl PostingToGraphAdapter {
41    #[allow(clippy::needless_pass_by_value)]
42    pub fn attach_shared_vertex_context(
43        &self,
44        posting: PostingList,
45    ) -> GraphPostingListResult<GraphPostingList> {
46        let all_vertices: Vec<u64> = posting.iter().map(|entry| entry.doc_id).collect();
47        let graph_payloads: BTreeMap<u64, GraphPayload> = posting
48            .iter()
49            .map(|entry| {
50                (
51                    entry.doc_id,
52                    GraphPayload {
53                        subgraph_vertices: all_vertices.clone(),
54                        subgraph_edges: Vec::new(),
55                        graph_name: String::new(),
56                        score_override: Some(entry.payload.score),
57                    },
58                )
59            })
60            .collect();
61        GraphPostingList::try_from_parts(posting, graph_payloads)
62    }
63}
64
65/// Normalize a TF-weighted text score into `[0, 1]` over one posting list.
66///
67/// This is a query-pool score transform; it does not construct vectors and
68/// therefore has no vector-dimension setting.
69#[derive(Debug, Clone, Copy, Default)]
70pub struct TextTfScoreNormalizer;
71
72impl TextTfScoreNormalizer {
73    #[allow(clippy::needless_pass_by_value)]
74    pub fn normalize(&self, posting: PostingList) -> PostingList {
75        if posting.is_empty() {
76            return PostingList::new();
77        }
78        let mut raw_scores: Vec<(PostingEntry, f64)> = Vec::with_capacity(posting.len());
79        let mut max_score = 0.0_f64;
80        for entry in &posting {
81            let term_frequency = if entry.payload.positions.is_empty() {
82                1
83            } else {
84                entry.payload.positions.len()
85            };
86            let raw = term_frequency as f64 * entry.payload.score.max(0.01);
87            max_score = max_score.max(raw);
88            raw_scores.push((entry.clone(), raw));
89        }
90        let entries = raw_scores
91            .into_iter()
92            .map(|(entry, raw)| PostingEntry {
93                doc_id: entry.doc_id,
94                payload: Payload {
95                    positions: entry.payload.positions,
96                    score: if max_score > 0.0 {
97                        raw / max_score
98                    } else {
99                        0.0
100                    },
101                    fields: entry.payload.fields,
102                },
103            })
104            .collect();
105        PostingList::from_sorted_unchecked(entries)
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    fn entry(doc_id: u64, score: f64, positions: Vec<u32>) -> PostingEntry {
114        PostingEntry {
115            doc_id,
116            payload: Payload {
117                positions,
118                score,
119                fields: BTreeMap::new(),
120            },
121        }
122    }
123
124    #[test]
125    fn posting_adapter_attaches_vertices_without_inventing_edges() {
126        let posting = PostingList::from_sorted_unchecked(vec![
127            entry(1, 0.5, Vec::new()),
128            entry(2, 0.7, Vec::new()),
129        ]);
130        let graph = PostingToGraphAdapter
131            .attach_shared_vertex_context(posting)
132            .unwrap();
133        let payload = graph.get_graph_payload(1).unwrap();
134        assert_eq!(payload.subgraph_vertices, vec![1, 2]);
135        assert!(payload.subgraph_edges.is_empty());
136        assert_eq!(payload.score_override, Some(0.5));
137    }
138
139    #[test]
140    fn text_score_normalizer_normalizes_scores_without_vector_metadata() {
141        let posting = PostingList::from_sorted_unchecked(vec![
142            entry(1, 0.5, vec![1, 2]),
143            entry(2, 0.5, vec![1]),
144        ]);
145        let mapped = TextTfScoreNormalizer.normalize(posting);
146        let scores: Vec<f64> = mapped.iter().map(|entry| entry.payload.score).collect();
147        assert!(scores.iter().any(|score| (score - 1.0).abs() < 1e-9));
148        assert!(scores.iter().all(|score| (0.0..=1.0).contains(score)));
149    }
150
151    #[test]
152    fn graph_codec_round_trips_complete_graph_payloads() {
153        let posting = PostingList::from_sorted_unchecked(vec![entry(1, 0.5, Vec::new())]);
154        let graph = PostingToGraphAdapter
155            .attach_shared_vertex_context(posting)
156            .unwrap();
157        let encoded = GraphPostingCodec::encode(graph.clone());
158        assert_eq!(GraphPostingCodec::decode(&encoded), graph);
159    }
160}