···11{
22 "db_name": "SQLite",
33- "query": "SELECT t.did AS \"did!\", t.rkey AS \"rkey!\", t.uri AS \"uri!\", t.cid AS \"cid!\",\n t.name AS \"name!\", t.summary, t.license AS \"license!\", t.tags_json, t.cover_json,\n t.derived_from_uri, t.created_at AS \"created_at!\", t.indexed_at AS \"indexed_at!\",\n t.record_json,\n COALESCE(cs.like_count, 0) AS \"like_count!: i64\",\n COALESCE(cs.save_count, 0) AS \"save_count!: i64\",\n (SELECT COUNT(*) FROM thing_models tm WHERE tm.thing_uri = t.uri) AS \"model_count!: i64\",\n (SELECT COUNT(*) FROM thing_models tm JOIN model_parts mp ON mp.model_uri = tm.model_uri WHERE tm.thing_uri = t.uri) AS \"part_count!: i64\"\n FROM things t LEFT JOIN content_stats cs ON cs.uri = t.uri\n ORDER BY t.created_at DESC LIMIT 500",
33+ "query": "SELECT t.did AS \"did!\", t.rkey AS \"rkey!\", t.uri AS \"uri!\", t.cid AS \"cid!\",\n t.name AS \"name!\", t.summary, t.license AS \"license!\", t.tags_json, t.cover_json,\n t.derived_from_uri, t.created_at AS \"created_at!\", t.indexed_at AS \"indexed_at!\",\n t.record_json,\n COALESCE(cs.like_count, 0) AS \"like_count!: i64\",\n COALESCE(cs.save_count, 0) AS \"save_count!: i64\",\n (SELECT COUNT(*) FROM thing_models tm WHERE tm.thing_uri = t.uri) AS \"model_count!: i64\",\n (SELECT COUNT(*) FROM thing_models tm JOIN model_parts mp ON mp.model_uri = tm.model_uri WHERE tm.thing_uri = t.uri) AS \"part_count!: i64\"\n FROM things t LEFT JOIN content_stats cs ON cs.uri = t.uri\n WHERE COALESCE(cs.like_count, 0) + COALESCE(cs.save_count, 0) = 0\n AND (? IS NULL OR t.rkey < ? OR (t.rkey = ? AND t.uri < ?))\n ORDER BY t.rkey DESC, t.uri DESC\n LIMIT ?",
44 "describe": {
55 "columns": [
66 {
···172172 }
173173 ],
174174 "parameters": {
175175- "Right": 0
175175+ "Right": 5
176176 },
177177 "nullable": [
178178 false,
···194194 false
195195 ]
196196 },
197197- "hash": "7141c9ae0cf701b4e9a5b7791b7583737f2d4f66b8f45c4c83ee9e946ecbd2ec"
197197+ "hash": "9b2ec255fedbb5d38f44bd5b18fb1ed9cd6eed8a7402347afd8a23a73452008b"
198198}
+1
AGENTS.md
···102102## Code style
103103104104- **Prefer branded types over bare `String`/`&str`.** Any use of a bare `String` or `&str` where a wrapping, possibly-validated branded type exists (`Did`, `Handle`, `AtUri`, `Nsid`, `Cid`, `AtIdentifier`, …) is a bug to fix immediately unless clearly rebutted — e.g. binding to SQLite (TEXT columns), human-facing error/log messages, or where the surrounding error structure already carries the needed context. Thread the brand from the request/parse site down to the system boundary (DB bind, serialization) and convert with `.as_ref()`/`.as_str()` only there. Don't reconstruct a brand you already have (e.g. don't `Did::new_owned(s)` from a string you got by stringifying a `Did`), and prefer matching on a branded enum's variants over sniffing its serialized form (`ident.starts_with("did:")`).
105105+- Production SQL must use SQLx compile-time checked macros (`query!`, `query_as!`) rather than runtime `query`/`query_as` escape hatches. When changing production SQL or migrations, regenerate and commit the `.sqlx` cache with `just sqlx-prepare`.
+172-5
src/appview/tests.rs
···1515use serde_json::json;
1616use sqlx::SqlitePool;
1717use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
1818+use std::collections::HashSet;
1819use std::str::FromStr;
1920use tower::ServiceExt;
2021···126127 .await
127128 .unwrap();
128129 uri
130130+}
131131+132132+async fn set_thing_created_at(pool: &SqlitePool, uri: &str, created_at: i64) {
133133+ sqlx::query("UPDATE things SET created_at = ? WHERE uri = ?")
134134+ .bind(created_at)
135135+ .bind(uri)
136136+ .execute(pool)
137137+ .await
138138+ .unwrap();
129139}
130140131141async fn seed_model(
···367377}
368378369379#[tokio::test]
380380+async fn feed_hot_cursor_pins_ranking_time_and_keyset_boundary() {
381381+ let state = state().await;
382382+ seed_identity(&state.pool, DID_A, "alice.com").await;
383383+ let now_a = 1_700_000_000_000_000_000;
384384+ let now_b = now_a + 90 * 86_400_000_000_000;
385385+ let rows = [
386386+ ("3aaaaaaaaaaaa", "fresh-low", 2, 1),
387387+ ("3bbbbbbbbbbbb", "medium-high", 7, 12),
388388+ ("3cccccccccccc", "older-high", 48, 100),
389389+ ("3dddddddddddd", "newer-medium", 3, 4),
390390+ ("3eeeeeeeeeeee", "old-low", 120, 2),
391391+ ];
392392+ for (rkey, name, age_hours, likes) in rows {
393393+ let uri = seed_thing(&state.pool, DID_A, rkey, name, &[], likes).await;
394394+ set_thing_created_at(&state.pool, &uri, now_a - age_hours * 3_600_000_000_000).await;
395395+ }
396396+397397+ let expected = views::feed_hot_at(&state, 10, None, None, now_a)
398398+ .await
399399+ .unwrap()
400400+ .items
401401+ .into_iter()
402402+ .map(|item| item.thing.uri)
403403+ .collect::<Vec<_>>();
404404+ assert_eq!(expected.len(), rows.len());
405405+406406+ let page1 = views::feed_hot_at(&state, 2, None, None, now_a)
407407+ .await
408408+ .unwrap();
409409+ let cursor = page1
410410+ .cursor
411411+ .as_ref()
412412+ .expect("first hot page should have a cursor");
413413+ assert!(
414414+ cursor.as_str().starts_with("hot:v1:"),
415415+ "hot cursor must be versioned and opaque"
416416+ );
417417+418418+ let page2 = views::feed_hot_at(&state, 10, Some(cursor.as_str()), None, now_b)
419419+ .await
420420+ .unwrap();
421421+ let combined = page1
422422+ .items
423423+ .into_iter()
424424+ .chain(page2.items)
425425+ .map(|item| item.thing.uri)
426426+ .collect::<Vec<_>>();
427427+ let unique = combined.iter().collect::<HashSet<_>>();
428428+ assert_eq!(
429429+ unique.len(),
430430+ combined.len(),
431431+ "hot pagination duplicated an item"
432432+ );
433433+ assert_eq!(
434434+ combined, expected,
435435+ "hot pagination must keep the cursor-pinned order"
436436+ );
437437+}
438438+439439+#[tokio::test]
440440+async fn feed_hot_cursor_boundary_handles_same_rkey_across_repos() {
441441+ let state = state().await;
442442+ seed_identity(&state.pool, DID_A, "alice.com").await;
443443+ seed_identity(&state.pool, DID_B, "bob.com").await;
444444+ let now = 1_700_000_000_000_000_000;
445445+ let shared_rkey = "3sameeeeeeeee";
446446+ let uri_a = seed_thing(&state.pool, DID_A, shared_rkey, "alice", &[], 5).await;
447447+ let uri_b = seed_thing(&state.pool, DID_B, shared_rkey, "bob", &[], 5).await;
448448+ set_thing_created_at(&state.pool, &uri_a, now - 86_400_000_000_000).await;
449449+ set_thing_created_at(&state.pool, &uri_b, now - 86_400_000_000_000).await;
450450+451451+ let page1 = views::feed_hot_at(&state, 1, None, None, now)
452452+ .await
453453+ .unwrap();
454454+ let cursor = page1.cursor.as_deref().expect("first row has another page");
455455+ let page2 = views::feed_hot_at(&state, 1, Some(cursor), None, now)
456456+ .await
457457+ .unwrap();
458458+459459+ let uris = page1
460460+ .items
461461+ .into_iter()
462462+ .chain(page2.items)
463463+ .map(|item| item.thing.uri)
464464+ .collect::<Vec<_>>();
465465+ let unique = uris.iter().collect::<HashSet<_>>();
466466+ assert_eq!(unique.len(), 2, "same-rkey tied rows must not duplicate");
467467+ assert!(uris.iter().any(|uri| uri.as_ref() == uri_a));
468468+ assert!(uris.iter().any(|uri| uri.as_ref() == uri_b));
469469+}
470470+471471+#[tokio::test]
472472+async fn feed_hot_zero_engagement_fallback_paginates_after_zero_boundary() {
473473+ let state = state().await;
474474+ seed_identity(&state.pool, DID_A, "alice.com").await;
475475+ let now = 1_700_000_000_000_000_000;
476476+ for rkey in [
477477+ "3dddddddddddd",
478478+ "3cccccccccccc",
479479+ "3bbbbbbbbbbbb",
480480+ "3aaaaaaaaaaaa",
481481+ ] {
482482+ let uri = seed_thing(&state.pool, DID_A, rkey, rkey, &[], 0).await;
483483+ set_thing_created_at(&state.pool, &uri, now - 86_400_000_000_000).await;
484484+ }
485485+486486+ let page1 = views::feed_hot_at(&state, 2, None, None, now)
487487+ .await
488488+ .unwrap();
489489+ assert_eq!(page1.items.len(), 2);
490490+ assert_eq!(page1.items[0].thing.name.as_str(), "3dddddddddddd");
491491+ assert_eq!(page1.items[1].thing.name.as_str(), "3cccccccccccc");
492492+ let cursor = page1
493493+ .cursor
494494+ .as_deref()
495495+ .expect("zero-score first page has cursor");
496496+497497+ let page2 = views::feed_hot_at(&state, 2, Some(cursor), None, now)
498498+ .await
499499+ .unwrap();
500500+ assert_eq!(page2.items.len(), 2);
501501+ assert_eq!(page2.items[0].thing.name.as_str(), "3bbbbbbbbbbbb");
502502+ assert_eq!(page2.items[1].thing.name.as_str(), "3aaaaaaaaaaaa");
503503+ assert!(page2.cursor.is_none());
504504+}
505505+506506+#[tokio::test]
507507+async fn feed_hot_ranks_older_high_engagement_beyond_previous_candidate_cap() {
508508+ let state = state().await;
509509+ seed_identity(&state.pool, DID_A, "alice.com").await;
510510+ let now = 1_700_000_000_000_000_000;
511511+ let older = seed_thing(
512512+ &state.pool,
513513+ DID_A,
514514+ "3aaaaaaaaaaaa",
515515+ "older-popular",
516516+ &[],
517517+ 1_000_000,
518518+ )
519519+ .await;
520520+ set_thing_created_at(&state.pool, &older, now - 30 * 86_400_000_000_000).await;
521521+522522+ for i in 0..501 {
523523+ let rkey = format!("3new{i:010}");
524524+ let uri = seed_thing(&state.pool, DID_A, &rkey, &format!("new-{i}"), &[], 0).await;
525525+ set_thing_created_at(&state.pool, &uri, now - 3_600_000_000_000 + i).await;
526526+ }
527527+528528+ let feed = views::feed_hot_at(&state, 5, None, None, now)
529529+ .await
530530+ .unwrap();
531531+ assert_eq!(feed.items[0].thing.uri.as_ref(), older);
532532+ assert_eq!(feed.items[0].thing.name.as_str(), "older-popular");
533533+}
534534+535535+#[tokio::test]
370536async fn search_things_uses_fts() {
371537 let state = state().await;
372538 seed_identity(&state.pool, DID_A, "alice.com").await;
···383549#[tokio::test]
384550async fn malformed_ranked_cursor_is_rejected() {
385551 let state = state().await;
386386- let err = views::search_things(&state, "x", 10, Some("not-a-number"), None)
552552+ let search_err = views::search_things(&state, "x", 10, Some("not-a-number"), None)
387553 .await
388554 .unwrap_err();
389389- assert!(matches!(err, AppError::InvalidRequest(_)));
555555+ assert!(matches!(search_err, AppError::InvalidRequest(_)));
556556+ let hot_err = views::feed_hot(&state, 10, Some("not-a-hot-cursor"), None)
557557+ .await
558558+ .unwrap_err();
559559+ assert!(matches!(hot_err, AppError::InvalidRequest(_)));
390560}
391561392562#[tokio::test]
···556726 let state = state().await;
557727 let errs = [
558728 views::search_things(&state, "x", 10, Some("-1"), None)
559559- .await
560560- .unwrap_err(),
561561- views::feed_hot(&state, 10, Some("-5"), None)
562729 .await
563730 .unwrap_err(),
564731 views::list_things(
+295-32
src/appview/views.rs
···1212//! on the record's TID `rkey` (descending); the returned cursor is the last
1313//! item's `rkey`, and the next page uses `rkey < cursor` (a NULL cursor — no
1414//! cursor — returns the first page).
1515-//! - Ranked views (`getFeed?algorithm=hot`, `searchThings`) use an offset cursor
1616-//! (stringified); a non-numeric cursor is rejected with 400. Ranking is
1717-//! deterministic: hot scores tie-break on `rkey` desc; search is BM25.
1515+//! - `getFeed?algorithm=hot` uses an opaque versioned keyset cursor over a hot
1616+//! ranking snapshot. The cursor pins the ranking timestamp and last returned
1717+//! `(score, rkey, uri)` boundary so later pages use the same total ordering.
1818+//! - `searchThings` uses a stringified offset cursor for BM25 results; a
1919+//! non-numeric cursor is rejected with 400.
18201921use std::cmp::Ordering;
2022···3335};
3436use sqlx::SqlitePool;
35373636-use super::error::{AppResult, db, internal, invalid_request, not_found};
3838+use super::error::{AppError, AppResult, db, internal, invalid_request, not_found};
3739use super::state::AppState;
3840use crate::oauth::SqliteAuthStore;
4141+4242+const HOT_POSITIVE_BATCH: i64 = 256;
39434044// ---------------------------------------------------------------------------
4145// value-construction helpers
···669673 cursor: Option<&str>,
670674 viewer_did: Option<Did<&str>>,
671675) -> AppResult<FeedView> {
672672- let offset = parse_offset(cursor)?;
673673- // Candidate window: the most recent things. Hot score is computed in Rust so
674674- // the formula (engagement / (age_hours + 2)^1.5) does not depend on SQLite
675675- // math functions, and ranking is deterministic for a fixed `now`.
676676+ let default_now_nanos = chrono::Utc::now()
677677+ .timestamp_nanos_opt()
678678+ .expect("current timestamp must fit in i64 nanoseconds");
679679+ feed_hot_at(state, limit, cursor, viewer_did, default_now_nanos).await
680680+}
681681+682682+pub(super) async fn feed_hot_at(
683683+ state: &AppState,
684684+ limit: i64,
685685+ cursor: Option<&str>,
686686+ viewer_did: Option<Did<&str>>,
687687+ default_now_nanos: i64,
688688+) -> AppResult<FeedView> {
689689+ let page_state = parse_hot_cursor(cursor, default_now_nanos)?;
690690+ let now = page_state.now_nanos;
691691+ let mut scored = hot_positive_candidates(state, limit, &page_state).await?;
692692+693693+ if scored.len() as i64 <= limit {
694694+ scored.extend(
695695+ hot_zero_engagement_candidates(state, limit + 1 - scored.len() as i64, &page_state)
696696+ .await?,
697697+ );
698698+ }
699699+700700+ // score desc, then rkey desc and uri desc. rkeys are only per-repo unique, so
701701+ // uri completes the total order used by the page boundary.
702702+ sort_hot_scores(&mut scored);
703703+ scored.truncate(limit as usize + 1);
704704+ let cursor = if scored.len() as i64 > limit {
705705+ scored
706706+ .get((limit - 1) as usize)
707707+ .map(|(score, row)| encode_hot_cursor(now, *score, &row.rkey, &row.uri))
708708+ } else {
709709+ None
710710+ };
711711+ scored.truncate(limit as usize);
712712+ let page = scored.into_iter().map(|(_, row)| row).collect();
713713+ feed_view(state, page, cursor, viewer_did).await
714714+}
715715+716716+async fn hot_positive_candidates(
717717+ state: &AppState,
718718+ limit: i64,
719719+ page_state: &HotPageState,
720720+) -> AppResult<Vec<(f64, ThingRow)>> {
721721+ if !page_state.may_include_positive_scores() {
722722+ return Ok(Vec::new());
723723+ }
724724+725725+ let mut scored = Vec::new();
726726+ let mut offset = 0;
727727+ loop {
728728+ // PM-51 keeps ranking exact without a fixed recent window, but this is
729729+ // still a per-request candidate scan. PM-56 tracks replacing it with a
730730+ // materialized/indexed hot-feed ranking table.
731731+ let rows = db(
732732+ sqlx::query_as!(
733733+ ThingRow,
734734+ r#"SELECT t.did AS "did!", t.rkey AS "rkey!", t.uri AS "uri!", t.cid AS "cid!",
735735+ t.name AS "name!", t.summary, t.license AS "license!", t.tags_json, t.cover_json,
736736+ t.derived_from_uri, t.created_at AS "created_at!", t.indexed_at AS "indexed_at!",
737737+ t.record_json,
738738+ COALESCE(cs.like_count, 0) AS "like_count!: i64",
739739+ COALESCE(cs.save_count, 0) AS "save_count!: i64",
740740+ (SELECT COUNT(*) FROM thing_models tm WHERE tm.thing_uri = t.uri) AS "model_count!: i64",
741741+ (SELECT COUNT(*) FROM thing_models tm JOIN model_parts mp ON mp.model_uri = tm.model_uri WHERE tm.thing_uri = t.uri) AS "part_count!: i64"
742742+ FROM things t LEFT JOIN content_stats cs ON cs.uri = t.uri
743743+ WHERE COALESCE(cs.like_count, 0) + COALESCE(cs.save_count, 0) > 0
744744+ ORDER BY COALESCE(cs.like_count, 0) + COALESCE(cs.save_count, 0) DESC,
745745+ t.rkey DESC, t.uri DESC
746746+ LIMIT ? OFFSET ?"#,
747747+ HOT_POSITIVE_BATCH,
748748+ offset,
749749+ )
750750+ .fetch_all(&state.pool)
751751+ .await,
752752+ )?;
753753+ if rows.is_empty() {
754754+ break;
755755+ }
756756+757757+ let batch_len = rows.len() as i64;
758758+ let last_engagement = rows
759759+ .last()
760760+ .map(|row| row.like_count + row.save_count)
761761+ .unwrap_or(0);
762762+ for row in rows {
763763+ let score = hot_score(&row, page_state.now_nanos);
764764+ if page_state.is_after_boundary(score, &row.rkey, &row.uri) {
765765+ scored.push((score, row));
766766+ }
767767+ }
768768+ sort_hot_scores(&mut scored);
769769+ scored.truncate(limit as usize + 1);
770770+771771+ if batch_len < HOT_POSITIVE_BATCH || can_stop_positive_scan(&scored, limit, last_engagement)
772772+ {
773773+ break;
774774+ }
775775+ offset += HOT_POSITIVE_BATCH;
776776+ }
777777+ Ok(scored)
778778+}
779779+780780+async fn hot_zero_engagement_candidates(
781781+ state: &AppState,
782782+ fetch: i64,
783783+ page_state: &HotPageState,
784784+) -> AppResult<Vec<(f64, ThingRow)>> {
785785+ if fetch <= 0 || !page_state.may_include_zero_scores() {
786786+ return Ok(Vec::new());
787787+ }
788788+ let (after_rkey, after_uri) = page_state.zero_score_boundary();
676789 let rows = db(
677790 sqlx::query_as!(
678791 ThingRow,
···685798 (SELECT COUNT(*) FROM thing_models tm WHERE tm.thing_uri = t.uri) AS "model_count!: i64",
686799 (SELECT COUNT(*) FROM thing_models tm JOIN model_parts mp ON mp.model_uri = tm.model_uri WHERE tm.thing_uri = t.uri) AS "part_count!: i64"
687800 FROM things t LEFT JOIN content_stats cs ON cs.uri = t.uri
688688- ORDER BY t.created_at DESC LIMIT 500"#,
801801+ WHERE COALESCE(cs.like_count, 0) + COALESCE(cs.save_count, 0) = 0
802802+ AND (? IS NULL OR t.rkey < ? OR (t.rkey = ? AND t.uri < ?))
803803+ ORDER BY t.rkey DESC, t.uri DESC
804804+ LIMIT ?"#,
805805+ after_rkey,
806806+ after_rkey,
807807+ after_rkey,
808808+ after_uri,
809809+ fetch,
689810 )
690811 .fetch_all(&state.pool)
691812 .await,
692813 )?;
693693- let now = chrono::Utc::now()
694694- .timestamp_nanos_opt()
695695- .expect("current timestamp must fit in i64 nanoseconds");
696696- let mut scored: Vec<(f64, ThingRow)> =
697697- rows.into_iter().map(|r| (hot_score(&r, now), r)).collect();
698698- // score desc, tie-break rkey desc (deterministic, reproducible page boundary).
699699- scored.sort_by(|a, b| {
700700- b.0.partial_cmp(&a.0)
701701- .unwrap_or(Ordering::Equal)
702702- .then_with(|| b.1.rkey.cmp(&a.1.rkey))
703703- });
704704- let page: Vec<ThingRow> = scored
705705- .into_iter()
706706- .skip(offset as usize)
707707- .take(limit as usize + 1)
708708- .map(|(_, r)| r)
709709- .collect();
710710- let cursor = if page.len() as i64 > limit {
711711- Some((offset + limit).to_string())
712712- } else {
713713- None
814814+ Ok(rows.into_iter().map(|row| (0.0, row)).collect())
815815+}
816816+817817+fn sort_hot_scores(scored: &mut [(f64, ThingRow)]) {
818818+ scored.sort_by(hot_score_order);
819819+}
820820+821821+fn hot_score_order(a: &(f64, ThingRow), b: &(f64, ThingRow)) -> Ordering {
822822+ b.0.partial_cmp(&a.0)
823823+ .unwrap_or(Ordering::Equal)
824824+ .then_with(|| b.1.rkey.cmp(&a.1.rkey))
825825+ .then_with(|| b.1.uri.cmp(&a.1.uri))
826826+}
827827+828828+fn can_stop_positive_scan(scored: &[(f64, ThingRow)], limit: i64, last_engagement: i64) -> bool {
829829+ let Some((worst_score, _)) = scored.get(limit as usize) else {
830830+ return false;
831831+ };
832832+ let max_unseen_score = (last_engagement as f64) / 2.0_f64.powf(1.5);
833833+ max_unseen_score < *worst_score
834834+}
835835+836836+struct HotPageState {
837837+ now_nanos: i64,
838838+ boundary: Option<HotCursorBoundary>,
839839+}
840840+841841+struct HotCursorBoundary {
842842+ score: f64,
843843+ rkey: String,
844844+ uri: String,
845845+}
846846+847847+impl HotPageState {
848848+ fn may_include_positive_scores(&self) -> bool {
849849+ self.boundary
850850+ .as_ref()
851851+ .is_none_or(|boundary| boundary.score > 0.0)
852852+ }
853853+854854+ fn may_include_zero_scores(&self) -> bool {
855855+ self.boundary
856856+ .as_ref()
857857+ .is_none_or(|boundary| boundary.score >= 0.0)
858858+ }
859859+860860+ fn zero_score_boundary(&self) -> (Option<&str>, Option<&str>) {
861861+ match &self.boundary {
862862+ Some(boundary) if boundary.score == 0.0 => {
863863+ (Some(boundary.rkey.as_str()), Some(boundary.uri.as_str()))
864864+ }
865865+ _ => (None, None),
866866+ }
867867+ }
868868+869869+ fn is_after_boundary(&self, score: f64, rkey: &str, uri: &str) -> bool {
870870+ let Some(boundary) = &self.boundary else {
871871+ return true;
872872+ };
873873+ score < boundary.score
874874+ || (score == boundary.score
875875+ && (rkey < boundary.rkey.as_str()
876876+ || (rkey == boundary.rkey.as_str() && uri < boundary.uri.as_str())))
877877+ }
878878+}
879879+880880+fn parse_hot_cursor(cursor: Option<&str>, default_now_nanos: i64) -> AppResult<HotPageState> {
881881+ let Some(cursor) = cursor else {
882882+ return Ok(HotPageState {
883883+ now_nanos: default_now_nanos,
884884+ boundary: None,
885885+ });
886886+ };
887887+888888+ // The hot cursor pins the ranking timestamp. Without that snapshot value,
889889+ // time-dependent hot scores can shift between requests and make a page
890890+ // boundary skip or duplicate rows.
891891+ let mut parts = cursor.splitn(6, ':');
892892+ let valid_prefix = parts.next() == Some("hot") && parts.next() == Some("v1");
893893+ let Some(now_nanos) = parts.next() else {
894894+ return Err(invalid_hot_cursor());
895895+ };
896896+ let Some(score_bits_hex) = parts.next() else {
897897+ return Err(invalid_hot_cursor());
898898+ };
899899+ let Some(rkey) = parts.next() else {
900900+ return Err(invalid_hot_cursor());
901901+ };
902902+ let Some(uri) = parts.next() else {
903903+ return Err(invalid_hot_cursor());
714904 };
715715- let page = page.into_iter().take(limit as usize).collect();
716716- feed_view(state, page, cursor, viewer_did).await
905905+ if !valid_prefix || rkey.is_empty() || uri.is_empty() {
906906+ return Err(invalid_hot_cursor());
907907+ }
908908+ let now_nanos = now_nanos.parse().map_err(|_| invalid_hot_cursor())?;
909909+ let score_bits = u64::from_str_radix(score_bits_hex, 16).map_err(|_| invalid_hot_cursor())?;
910910+ let score = f64::from_bits(score_bits);
911911+ if !(score.is_finite() && score >= 0.0) {
912912+ return Err(invalid_hot_cursor());
913913+ }
914914+ Ok(HotPageState {
915915+ now_nanos,
916916+ boundary: Some(HotCursorBoundary {
917917+ score,
918918+ rkey: rkey.to_owned(),
919919+ uri: uri.to_owned(),
920920+ }),
921921+ })
922922+}
923923+924924+fn encode_hot_cursor(now_nanos: i64, score: f64, rkey: &str, uri: &str) -> String {
925925+ format!("hot:v1:{now_nanos}:{:016x}:{rkey}:{uri}", score.to_bits())
926926+}
927927+928928+fn invalid_hot_cursor() -> AppError {
929929+ invalid_request("cursor must be an opaque hot feed cursor")
717930}
718931719932fn hot_score(row: &ThingRow, now_nanos: i64) -> f64 {
720933 let age_hours = ((now_nanos - row.created_at).max(0) as f64) / 3_600_000_000_000.0;
721934 let engagement = row.like_count as f64 + row.save_count as f64;
722935 engagement / (age_hours + 2.0).powf(1.5)
936936+}
937937+938938+#[cfg(test)]
939939+mod hot_tests {
940940+ use super::*;
941941+942942+ fn row(rkey: &str, uri: &str) -> ThingRow {
943943+ ThingRow {
944944+ did: "did:plc:test".to_owned(),
945945+ rkey: rkey.to_owned(),
946946+ uri: uri.to_owned(),
947947+ cid: "cid".to_owned(),
948948+ name: "test".to_owned(),
949949+ summary: None,
950950+ license: "CC-BY-4.0".to_owned(),
951951+ tags_json: None,
952952+ cover_json: None,
953953+ derived_from_uri: None,
954954+ created_at: 0,
955955+ indexed_at: 0,
956956+ record_json: None,
957957+ like_count: 0,
958958+ save_count: 0,
959959+ model_count: 0,
960960+ part_count: 0,
961961+ }
962962+ }
963963+964964+ #[test]
965965+ fn positive_scan_bound_stops_only_when_unseen_scores_cannot_enter_page() {
966966+ let page = vec![
967967+ (10.0, row("3c", "at://did:plc:c/thing/3c")),
968968+ (9.0, row("3b", "at://did:plc:b/thing/3b")),
969969+ ];
970970+ assert!(can_stop_positive_scan(&page, 1, 1));
971971+ assert!(!can_stop_positive_scan(&page, 1, 100));
972972+ assert!(!can_stop_positive_scan(&page[..1], 1, 1));
973973+ }
974974+975975+ #[test]
976976+ fn rejects_malformed_hot_cursor_score_bits() {
977977+ let bits = |s: f64| format!("{:016x}", s.to_bits());
978978+ let cursor = |score_bits: String| format!("hot:v1:100:{score_bits}:rkey:at://did:plc:x/x");
979979+ assert!(parse_hot_cursor(Some(&cursor(bits(f64::NAN))), 0).is_err());
980980+ assert!(parse_hot_cursor(Some(&cursor(bits(f64::INFINITY))), 0).is_err());
981981+ assert!(parse_hot_cursor(Some(&cursor(bits(f64::NEG_INFINITY))), 0).is_err());
982982+ assert!(parse_hot_cursor(Some(&cursor(bits(-1.0))), 0).is_err());
983983+ assert!(parse_hot_cursor(Some(&cursor(bits(0.0))), 0).is_ok());
984984+ assert!(parse_hot_cursor(Some(&cursor(bits(12.5))), 0).is_ok());
985985+ }
723986}
724987725988pub(super) async fn search_things(