Skip to main content

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}