1use std::collections::BTreeMap;
14
15use uqa_core::{Payload, PostingEntry, PostingList};
16
17use crate::posting_list::{GraphPayload, GraphPostingList, GraphPostingListResult};
18
19pub 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#[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#[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}