Skip to main content

paper_gap/
report.rs

1use crate::{Result, analyse, arxiv, provider, pwc, repos};
2use chrono::{DateTime, NaiveDate, Utc};
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Default, Serialize, Deserialize)]
6pub struct ReportProvenance {
7    pub generated_at: Option<DateTime<Utc>>,
8    pub operation: Option<OperationKind>,
9    #[serde(default)]
10    pub selectors: ReportSelectors,
11    pub limit: Option<usize>,
12    pub paper_source: Option<String>,
13    pub repo_source: Option<String>,
14}
15
16#[derive(Debug, Default, Serialize, Deserialize)]
17pub struct ReportSelectors {
18    pub query: Option<String>,
19    pub category: Option<String>,
20    pub since: Option<String>,
21    pub from: Option<NaiveDate>,
22    pub to: Option<NaiveDate>,
23    pub arxiv: Option<String>,
24}
25
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum OperationKind {
29    Scan,
30    Inspect,
31}
32
33impl OperationKind {
34    fn as_str(self) -> &'static str {
35        match self {
36            Self::Scan => "scan",
37            Self::Inspect => "inspect",
38        }
39    }
40}
41
42#[derive(Debug, Serialize, Deserialize)]
43pub struct ScanReport {
44    #[serde(default)]
45    pub schema_version: u32,
46    #[serde(default)]
47    pub provenance: ReportProvenance,
48    #[serde(default)]
49    pub paper_provider_outcomes: Vec<provider::ProviderOutcome>,
50    pub paper_results: Vec<PaperResult>,
51}
52
53#[derive(Debug, Serialize, Deserialize)]
54pub struct PaperResult {
55    pub paper: arxiv::Paper,
56    pub code_links: Vec<pwc::CodeLink>,
57    pub repositories: Vec<repos::Repository>,
58    #[serde(default)]
59    pub repository_matches: Vec<repos::RepositoryMatch>,
60    pub repository_analyses: Vec<analyse::RepositoryAnalysis>,
61    #[serde(default = "provider::default_success")]
62    pub pwc_outcome: provider::ProviderOutcome,
63    #[serde(default = "provider::default_success")]
64    pub repository_search_outcome: provider::ProviderOutcome,
65    #[serde(default)]
66    pub repository_inspection_outcomes: Vec<provider::ProviderOutcome>,
67    pub gap_assessment: analyse::GapAssessment,
68}
69
70#[derive(Clone, Copy)]
71pub enum OutputFormat {
72    Markdown,
73    Json,
74}
75
76pub fn render(report: &ScanReport, format: OutputFormat) -> Result<String> {
77    Ok(match format {
78        OutputFormat::Json => serde_json::to_string_pretty(report)?,
79        OutputFormat::Markdown => markdown(report),
80    })
81}
82
83fn markdown(report: &ScanReport) -> String {
84    let mut results: Vec<_> = report.paper_results.iter().collect();
85    results.sort_by_key(|result| std::cmp::Reverse(result.gap_assessment.implementation_gap_score));
86
87    let mut out = String::from("# paper-gap report\n\n");
88    push_provenance(&mut out, report);
89    out.push_str(&format!(
90        "{} papers analysed. Ranked by implementation gap score.\n\n",
91        results.len()
92    ));
93    for outcome in report
94        .paper_provider_outcomes
95        .iter()
96        .filter(|outcome| !outcome.succeeded())
97    {
98        out.push_str(&format!(
99            "- Paper provider warning: {} {}{}\n",
100            outcome.provider,
101            outcome.status,
102            outcome
103                .message
104                .as_deref()
105                .map(|message| format!(" — {message}"))
106                .unwrap_or_default()
107        ));
108    }
109    if report
110        .paper_provider_outcomes
111        .iter()
112        .any(|outcome| !outcome.succeeded())
113    {
114        out.push('\n');
115    }
116
117    for (index, result) in results.iter().enumerate() {
118        let p = &result.paper;
119        out.push_str(&format!(
120            "## {}. {}\n\n- Implementation gap score: {}\n- arXiv: {}\n- Published: {}\n- Authors: {}\n- Categories: {}\n",
121            index + 1,
122            p.title,
123            result.gap_assessment.implementation_gap_score,
124            p.arxiv_id.as_deref().unwrap_or(""),
125            p.published_date.as_deref().unwrap_or("unknown"),
126            p.authors.join(", "),
127            p.categories.join(", ")
128        ));
129
130        push_code_links(&mut out, &result.code_links);
131        push_repositories(
132            &mut out,
133            &result.repositories,
134            &result.repository_matches,
135            &result.repository_analyses,
136        );
137        push_provider_warnings(&mut out, result);
138        push_score(&mut out, &result.gap_assessment);
139
140        out.push_str(&format!("\n{}\n\n", p.abstract_text));
141    }
142
143    out
144}
145
146fn push_provenance(out: &mut String, report: &ScanReport) {
147    if report.schema_version > 0 {
148        out.push_str(&format!("- Schema version: {}\n", report.schema_version));
149    }
150    let provenance = &report.provenance;
151    if let Some(generated_at) = provenance.generated_at {
152        out.push_str(&format!("- Generated: {}\n", generated_at.to_rfc3339()));
153    }
154    if let Some(operation) = provenance.operation {
155        out.push_str(&format!("- Operation: {}\n", operation.as_str()));
156    }
157    let selectors = &provenance.selectors;
158    let values = [
159        ("query", selectors.query.as_deref().map(str::to_string)),
160        (
161            "category",
162            selectors.category.as_deref().map(str::to_string),
163        ),
164        ("since", selectors.since.as_deref().map(str::to_string)),
165        ("from", selectors.from.map(|value| value.to_string())),
166        ("to", selectors.to.map(|value| value.to_string())),
167        ("arxiv", selectors.arxiv.as_deref().map(str::to_string)),
168    ]
169    .into_iter()
170    .filter_map(|(name, value)| value.map(|value| format!("{name}={value}")))
171    .collect::<Vec<_>>();
172    if !values.is_empty() {
173        out.push_str(&format!("- Selectors: {}\n", values.join(", ")));
174    }
175    if let Some(limit) = provenance.limit {
176        out.push_str(&format!("- Limit: {limit}\n"));
177    }
178    if let Some(source) = &provenance.paper_source {
179        out.push_str(&format!("- Paper source: {source}\n"));
180    }
181    if let Some(source) = &provenance.repo_source {
182        out.push_str(&format!("- Repository source: {source}\n"));
183    }
184    if report.schema_version > 0 || provenance.operation.is_some() {
185        out.push('\n');
186    }
187}
188
189fn push_code_links(out: &mut String, links: &[pwc::CodeLink]) {
190    if links.is_empty() {
191        out.push_str("- Papers with Code links: none known\n");
192        return;
193    }
194    out.push_str("- Papers with Code links:\n");
195    for link in links {
196        out.push_str(&format!("  - {}\n", link.repository_url));
197    }
198}
199
200fn push_repositories(
201    out: &mut String,
202    repositories: &[repos::Repository],
203    matches: &[repos::RepositoryMatch],
204    analyses: &[analyse::RepositoryAnalysis],
205) {
206    if repositories.is_empty() {
207        out.push_str(
208            "- Repository search results: none found
209",
210        );
211        return;
212    }
213    out.push_str(
214        "- Repository search results:
215",
216    );
217    for (index, repo) in repositories.iter().enumerate() {
218        let signals = analyses
219            .get(index)
220            .map(quality_signals)
221            .unwrap_or_else(|| vec!["signals: none".to_string()]);
222        let match_summary = matches
223            .iter()
224            .find(|matched| matched.repository.url == repo.url)
225            .map(|matched| {
226                let evidence = matched
227                    .evidence
228                    .iter()
229                    .map(|item| format!("{}={} (+{})", item.kind, item.value, item.points))
230                    .collect::<Vec<_>>()
231                    .join("; ");
232                format!("match {} [{}]", matched.confidence_score, evidence)
233            })
234            .unwrap_or_else(|| "match unknown".into());
235        out.push_str(&format!(
236            "  - {} ({}) — {} — {}
237",
238            repo.url,
239            repo.forge,
240            match_summary,
241            signals.join(", ")
242        ));
243    }
244}
245
246fn quality_signals(analysis: &analyse::RepositoryAnalysis) -> Vec<String> {
247    let mut signals = Vec::new();
248    if analysis.has_readme {
249        signals.push("README");
250    }
251    if analysis.has_license {
252        signals.push("license");
253    }
254    if analysis.has_tests {
255        signals.push("tests");
256    }
257    if analysis.has_ci {
258        signals.push("CI");
259    }
260    if analysis.has_docs {
261        signals.push("docs");
262    }
263    if analysis.has_examples {
264        signals.push("examples");
265    }
266    if analysis.has_package_manifest {
267        signals.push("package");
268    }
269    if analysis.has_reproducibility_scripts {
270        signals.push("repro");
271    }
272    if analysis.has_benchmarks {
273        signals.push("benchmarks");
274    }
275    if analysis.is_notebook_only {
276        signals.push("notebook-only");
277    }
278    if analysis.is_stale {
279        signals.push("stale");
280    }
281    if analysis.is_archived {
282        signals.push("archived");
283    }
284    if signals.is_empty() {
285        vec!["signals: none".to_string()]
286    } else {
287        signals.into_iter().map(str::to_string).collect()
288    }
289}
290
291fn push_provider_warnings(out: &mut String, result: &PaperResult) {
292    let outcomes = std::iter::once(&result.pwc_outcome)
293        .chain(std::iter::once(&result.repository_search_outcome))
294        .chain(result.repository_inspection_outcomes.iter());
295    for outcome in outcomes.filter(|outcome| !outcome.succeeded()) {
296        out.push_str(&format!(
297            "- Provider warning: {} {}{}\n",
298            outcome.provider,
299            outcome.status,
300            outcome
301                .message
302                .as_deref()
303                .map(|message| format!(" — {message}"))
304                .unwrap_or_default()
305        ));
306    }
307}
308
309fn push_score(out: &mut String, assessment: &analyse::GapAssessment) {
310    let c = &assessment.components;
311    out.push_str("- Score components:\n");
312    out.push_str(&format!("  - no_code_score: {}\n", c.no_code_score));
313    out.push_str(&format!(
314        "  - prototype_only_score: {}\n",
315        c.prototype_only_score
316    ));
317    out.push_str(&format!("  - stale_repo_score: {}\n", c.stale_repo_score));
318    out.push_str(&format!(
319        "  - packaging_gap_score: {}\n",
320        c.packaging_gap_score
321    ));
322    out.push_str(&format!("  - docs_gap_score: {}\n", c.docs_gap_score));
323    out.push_str(&format!("  - tests_gap_score: {}\n", c.tests_gap_score));
324    out.push_str(&format!("  - license_gap_score: {}\n", c.license_gap_score));
325    out.push_str(&format!(
326        "  - reproducibility_gap_score: {}\n",
327        c.reproducibility_gap_score
328    ));
329    out.push_str(&format!(
330        "  - ecosystem_gap_score: {}\n",
331        c.ecosystem_gap_score
332    ));
333
334    if assessment.gaps.is_empty() {
335        out.push_str("- Gap reasons: none\n");
336        return;
337    }
338    out.push_str("- Gap reasons:\n");
339    for gap in &assessment.gaps {
340        out.push_str(&format!(
341            "  - {:?}: +{} — {}\n",
342            gap.gap_type, gap.points, gap.explanation
343        ));
344    }
345}