signalkit_core/model.rs
1//! Shared data model for snapshot collection and reporting.
2//!
3//! One subject/observation pair serves every source: a *subject* is a
4//! thing being tracked (repo, HN story, crate), an *observation* is one
5//! point-in-time reading of its headline counter. Velocity is always
6//! `Δscore / Δdays` over the window; what "score" means is documented
7//! per [`SourceKind`].
8
9use std::fmt;
10
11/// What kind of thing a subject is, and what its score counts.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub enum SourceKind {
14 /// GitHub repository; score = stars.
15 GithubRepo,
16 /// Hacker News story; score = points.
17 HnStory,
18 /// crates.io crate; score = total downloads.
19 Crate,
20 /// HuggingFace model; score = downloads.
21 HfModel,
22}
23
24impl SourceKind {
25 /// Stable string stored in the database.
26 #[must_use]
27 pub const fn as_str(self) -> &'static str {
28 match self {
29 SourceKind::GithubRepo => "github_repo",
30 SourceKind::HnStory => "hn_story",
31 SourceKind::Crate => "crate",
32 SourceKind::HfModel => "hf_model",
33 }
34 }
35
36 /// Short tag for report tables.
37 #[must_use]
38 pub const fn tag(self) -> &'static str {
39 match self {
40 SourceKind::GithubRepo => "GH",
41 SourceKind::HnStory => "HN",
42 SourceKind::Crate => "CR",
43 SourceKind::HfModel => "HF",
44 }
45 }
46
47 /// Parse the database string back.
48 #[must_use]
49 pub fn parse(s: &str) -> Option<SourceKind> {
50 match s {
51 "github_repo" => Some(SourceKind::GithubRepo),
52 "hn_story" => Some(SourceKind::HnStory),
53 "crate" => Some(SourceKind::Crate),
54 "hf_model" => Some(SourceKind::HfModel),
55 _ => None,
56 }
57 }
58}
59
60impl fmt::Display for SourceKind {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 f.write_str(self.as_str())
63 }
64}
65
66/// A thing being tracked, stored once per `(kind, key)` and enriched on
67/// later scans. `title` carries the human-readable line (repo
68/// description, story title, crate description).
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct SubjectRecord {
71 /// Subject type.
72 pub kind: SourceKind,
73 /// Source-stable identifier: `owner/repo`, HN object id, crate name.
74 pub key: String,
75 /// Human-readable line.
76 pub title: Option<String>,
77 /// Primary language (GitHub only today).
78 pub lang: Option<String>,
79 /// Canonical URL.
80 pub url: Option<String>,
81 /// Topics/keywords, lowercased by the source.
82 pub topics: Vec<String>,
83 /// Source creation timestamp (RFC 3339 or epoch), when available.
84 pub created_at: Option<String>,
85}
86
87/// One point-in-time reading of a subject's counters.
88///
89/// `extra1`/`extra2` are source-defined (GitHub: forks/watchers, HN:
90/// comments, crates.io: recent downloads) — velocity only ever uses
91/// `score`.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct Observation {
94 /// Unix seconds at reading time.
95 pub ts: i64,
96 /// Headline counter (stars / points / downloads).
97 pub score: i64,
98 /// Source-defined secondary counter.
99 pub extra1: i64,
100 /// Source-defined tertiary counter.
101 pub extra2: i64,
102}
103
104/// One row from a user's repository listing, used for the personal profile.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct RepoListed {
107 /// GitHub full name (`owner/repo`).
108 pub full_name: String,
109 /// Primary language, if any.
110 pub language: Option<String>,
111 /// Repository topics, lowercased by GitHub.
112 pub topics: Vec<String>,
113 /// Short description, if any.
114 pub description: Option<String>,
115 /// True for forks (excluded from profile weighting).
116 pub is_fork: bool,
117 /// Last push timestamp as returned by GitHub, if present.
118 pub pushed_at: Option<String>,
119 /// Star count at listing time.
120 pub stars: i64,
121}
122
123/// A velocity computation for one subject over a snapshot window.
124#[derive(Debug, Clone, PartialEq)]
125pub struct VelocityRow {
126 /// Subject type.
127 pub kind: SourceKind,
128 /// Source-stable identifier.
129 pub key: String,
130 /// Human-readable line, if any.
131 pub title: Option<String>,
132 /// Primary language, if any.
133 pub lang: Option<String>,
134 /// Canonical URL, if any.
135 pub url: Option<String>,
136 /// Topics attached to the subject.
137 pub topics: Vec<String>,
138 /// Most recent observed score.
139 pub score_now: i64,
140 /// Earliest observed score inside the window.
141 pub score_prev: i64,
142 /// Seconds between the two observations.
143 pub window_secs: i64,
144 /// Score gained per day over the window (see [`crate::score`]).
145 pub velocity_per_day: f64,
146}
147
148#[cfg(test)]
149mod tests {
150 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
151
152 use super::*;
153
154 #[test]
155 fn source_kind_roundtrips() {
156 for kind in [
157 SourceKind::GithubRepo,
158 SourceKind::HnStory,
159 SourceKind::Crate,
160 ] {
161 assert_eq!(SourceKind::parse(kind.as_str()), Some(kind));
162 }
163 assert_eq!(SourceKind::parse("nope"), None);
164 }
165
166 #[test]
167 fn tags_are_distinct() {
168 let tags = [
169 SourceKind::GithubRepo.tag(),
170 SourceKind::HnStory.tag(),
171 SourceKind::Crate.tag(),
172 ];
173 assert_eq!(tags.len(), 3);
174 assert_ne!(tags[0], tags[1]);
175 assert_ne!(tags[1], tags[2]);
176 }
177}