Skip to main content

pharos_native/runtime/event_discovery/
social_evidence.rs

1//! Social candidate screening keeps announcement age separate from the performance window.
2
3/// Bounds announcement history without excluding months-ahead event announcements.
4const ANNOUNCEMENT_HISTORY_SECONDS: u64 = 366 * 86400;
5use super::{Value, runtime_timestamp_seconds};
6
7/// Reads an explicit absolute date without guessing a year or promoting a publication date to an event date.
8pub(super) fn date(value: &str) -> Option<u64> {
9    if let Some(value) = runtime_timestamp_seconds(value) {
10        return Some(value);
11    }
12    let tokens = value
13        .split(|c: char| !c.is_ascii_alphanumeric() && c != '-')
14        .filter(|v| !v.is_empty())
15        .take(512)
16        .collect::<Vec<_>>();
17    for (index, token) in tokens.iter().enumerate() {
18        if token.len() == 4
19            && let Ok(year) = token.parse::<u32>()
20            && (2000..=2100).contains(&year)
21            && let Some(parts) = tokens.get(index + 1..index + 3)
22            && let (Ok(month), Ok(day)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>())
23            && (1..=12).contains(&month)
24            && (1..=31).contains(&day)
25            && let Some(value) =
26                runtime_timestamp_seconds(&format!("{year:04}-{month:02}-{day:02}T00:00:00Z"))
27        {
28            return Some(value);
29        }
30        if token.len() == 10
31            && token.as_bytes().get(4) == Some(&b'-')
32            && let Some(value) = runtime_timestamp_seconds(&format!("{token}T00:00:00Z"))
33        {
34            return Some(value);
35        }
36        let lower = token.to_ascii_lowercase();
37        let month = [
38            ("jan", "january"),
39            ("feb", "february"),
40            ("mar", "march"),
41            ("apr", "april"),
42            ("may", "may"),
43            ("jun", "june"),
44            ("jul", "july"),
45            ("aug", "august"),
46            ("sep", "september"),
47            ("oct", "october"),
48            ("nov", "november"),
49            ("dec", "december"),
50        ]
51        .iter()
52        .position(|(short, long)| {
53            lower == *short || lower == *long || (*short == "sep" && lower == "sept")
54        });
55        let Some(month) = month else {
56            continue;
57        };
58        let parts = tokens
59            .get(index + 1..index + 3)
60            .and_then(|parts| Some((parts[0].parse::<u32>().ok()?, parts[1].parse::<u32>().ok()?)))
61            .or_else(|| {
62                Some((
63                    tokens.get(index.checked_sub(1)?)?.parse::<u32>().ok()?,
64                    tokens.get(index + 1)?.parse::<u32>().ok()?,
65                ))
66            });
67        if let Some((day, year)) =
68            parts.filter(|(day, year)| (1..=31).contains(day) && (2000..=2100).contains(year))
69            && let Some(value) =
70                runtime_timestamp_seconds(&format!("{year:04}-{:02}-{day:02}T00:00:00Z", month + 1))
71        {
72            return Some(value);
73        }
74    }
75    None
76}
77
78/// Reads only source publication metadata, never a date mentioned in post prose or artwork.
79pub(super) fn publication(value: &Value) -> Option<u64> {
80    value
81        .get("datePublished")
82        .or_else(|| value.get("published_at"))
83        .and_then(Value::as_str)
84        .and_then(date)
85        .or_else(|| {
86            value
87                .get("publication_labels")
88                .and_then(Value::as_array)
89                .and_then(|values| values.iter().filter_map(Value::as_str).find_map(date))
90        })
91}
92
93/// Identifies an absolute source posting date independently of body and image dates.
94pub(crate) fn posting_date_known(value: &Value) -> bool {
95    publication(value).is_some()
96}
97
98/// Admits bounded, dated announcements for OCR independently of the event display window.
99pub(crate) fn dated_announcement(value: &Value, now: u64) -> bool {
100    publication(value).is_some_and(|posted| {
101        posted >= now.saturating_sub(ANNOUNCEMENT_HISTORY_SECONDS)
102            && posted <= now.saturating_add(86400)
103    })
104}
105
106/// Rejects stale postings, retrospective prose and undated social evidence before it can enter owner review.
107pub(crate) fn eligible(value: &Value, now: u64) -> bool {
108    let text = value
109        .get("description")
110        .and_then(Value::as_str)
111        .unwrap_or("");
112    if text.len() > 7680 {
113        return false;
114    }
115    let lower = format!(
116        "{} {}",
117        super::social_announcements::prospective_text(text).unwrap_or(text),
118        value
119            .get("image_text")
120            .and_then(Value::as_str)
121            .unwrap_or("")
122    )
123    .to_lowercase();
124    let retrospective = [
125        "thank you",
126        "thanks to",
127        "thanks everyone",
128        "thanks for",
129        "after-movie",
130        "aftermovie",
131        "after movie",
132        "recap",
133        "memories",
134        "was amazing",
135        "last night",
136        "obrigado",
137        "obrigada",
138        "na memória",
139        "ありがとうございました",
140    ];
141    if retrospective.iter().any(|term| lower.contains(term)) {
142        return false;
143    }
144    let cutoff = now.saturating_sub(30 * 86400);
145    let latest = now.saturating_add(730 * 86400);
146    let publication = publication(value);
147    if publication.is_some_and(|published| {
148        published < now.saturating_sub(ANNOUNCEMENT_HISTORY_SECONDS)
149            || published > now.saturating_add(86400)
150    }) {
151        return false;
152    }
153    let event_date = value
154        .get("startDate")
155        .and_then(Value::as_str)
156        .and_then(date);
157    let rendered = value
158        .get("evidence_kind")
159        .and_then(Value::as_str)
160        .is_some_and(|kind| matches!(kind, "rendered_post" | "rendered_link"));
161    if !rendered {
162        return event_date.is_some_and(|start| start >= cutoff && start <= latest);
163    }
164    // An older announcement may describe a current event; its posting date still establishes bounded provenance.
165    let attributed = value
166        .get("author_url")
167        .and_then(Value::as_str)
168        .zip(value.get("source_profile").and_then(Value::as_str))
169        .is_some_and(|(author, source)| {
170            let (Ok(author), Ok(source)) = (url::Url::parse(author), url::Url::parse(source))
171            else {
172                return false;
173            };
174            author.scheme() == "https"
175                && source.scheme() == "https"
176                && author
177                    .host_str()
178                    .map(|host| host.trim_start_matches("www.").trim_start_matches("m."))
179                    == source
180                        .host_str()
181                        .map(|host| host.trim_start_matches("www.").trim_start_matches("m."))
182                && author.path().trim_end_matches('/') == source.path().trim_end_matches('/')
183                && (source.path() != "/profile.php"
184                    || source.query_pairs().find(|(key, _)| key == "id")
185                        == author.query_pairs().find(|(key, _)| key == "id"))
186        });
187    if !attributed {
188        return false;
189    }
190    if publication.is_none() {
191        return false;
192    }
193    let body_date = event_date.or_else(|| super::social_announcements::event_day(value));
194    body_date.is_some_and(|start| start >= cutoff && start <= latest)
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use serde_json::json;
201    /// Source posting timestamps override recent-looking body dates; posters supply event dates without inventing them.
202    #[test]
203    fn screening_separates_announcement_age_from_event_window() {
204        let now = runtime_timestamp_seconds("2026-10-23T12:00:00Z").unwrap();
205        let mut post = json!({"evidence_kind":"rendered_post","author_url":"https://facebook.com/artist","source_profile":"https://www.facebook.com/artist/","datePublished":"2026-10-22T10:00:00Z","description":"Playing a live set October 30 2026"});
206        assert!(eligible(&post, now));
207        post["datePublished"] = json!("2026-08-17T10:00:00Z");
208        post["description"] = json!("Playing a live set October 26 2026");
209        assert!(eligible(&post, now));
210        assert!(dated_announcement(&post, now));
211        post["description"] = json!("Playing October 26 2026 — event details on poster");
212        post["author_url"] = json!("https://facebook.com/owner");
213        assert!(!eligible(&post, now));
214        post["author_url"] = json!("https://facebook.com/artist");
215        post["datePublished"] = json!("Dec 30 2024");
216        assert!(!dated_announcement(&post, now));
217        assert!(!eligible(&post, now));
218        post["datePublished"] = json!("2026-10-22T10:00:00Z");
219        post["description"] = json!("Thank you organizers for the festival October 22 2026");
220        assert!(!eligible(&post, now));
221        post["description"] = json!("Playing this festival — details on poster");
222        post["image_text"] = json!("Festival 30 October 2026");
223        assert!(eligible(&post, now));
224        post["image_text"] = json!("Festival 2026.10.30");
225        assert!(eligible(&post, now));
226        post["image_text"] = json!("Festival 2026年10月30日");
227        assert!(eligible(&post, now));
228        post["image_text"] = json!("Festival December 30 2024");
229        assert!(!eligible(&post, now));
230        post["image_text"] = json!("");
231        assert!(!eligible(&post, now));
232        post["datePublished"] = json!("");
233        post["image_text"] = json!("October 30 2026");
234        assert!(!eligible(&post, now));
235        assert!(!eligible(
236            &json!({"@type":"Event","startDate":"2024-12-30T20:00:00Z"}),
237            now
238        ));
239        assert!(!eligible(
240            &json!({"@type":"Event","description":"private account stream"}),
241            now
242        ));
243    }
244}