Something went wrong. Try again.
Streamplace AtmosphereConf 2026 VOD JAM
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539//! Ionosphere comment threading and byte range → word index mapping.//!//! This module provides://! - [`build_comment_tree`]: organises a flat list of [`CommentData`] into a reply//! tree, separating anchored comments (those with a byte range on the transcript)//! from non-anchored ones.//! - [`map_byte_range_to_words`]: maps a byte range in the transcript full text to a//! `(first_word_index, last_word_index)` pair, with expansion to full word boundaries//! and clamping to the transcript length.
use crate::catalog::CommentData;use vodplace_subtitle::TranscriptData;
/// A comment node in the reply tree, with its nested replies.#[derive(Debug, Clone, PartialEq)]pub struct CommentNode { pub comment: CommentData, pub replies: Vec<CommentNode>,}
/// An anchored comment: a [`CommentNode`] together with the word-index range/// that the anchor byte range maps to.#[derive(Debug, Clone, PartialEq)]pub struct AnchoredComment { pub comment_node: CommentNode, /// First word index (inclusive) the anchor covers. pub word_start: usize, /// Last word index (inclusive) the anchor covers. pub word_end: usize,}
/// The result of [`build_comment_tree`].////// Anchored comments are sorted by `byte_start`. Non-anchored comments are/// sorted by `created_at`. Replies within each node are sorted by `created_at`.#[derive(Debug, Clone, PartialEq)]pub struct CommentTree { pub anchored: Vec<AnchoredComment>, pub non_anchored: Vec<CommentNode>,}
/// Build a reply tree from a flat list of comments, optionally mapping anchored/// comments to word indices using `transcript`.////// ## Threading////// - Comments whose `subject_uri` is NOT in the comment set (i.e. it points to/// a talk or transcript URI) become top-level nodes./// - Comments whose `subject_uri` IS one of the other comment URIs become a/// reply under that comment./// - Orphaned replies (parent URI present in a comment's `subject_uri` but not/// actually in `comments`) are promoted to top-level.////// ## Anchoring////// A top-level comment with a [`ByteRangeData`] anchor is placed in/// `anchored`. Word indices are computed if `transcript` is provided; without a/// transcript the comment is demoted to `non_anchored`.pub fn build_comment_tree( comments: &[CommentData], transcript: Option<&TranscriptData>,) -> CommentTree { use std::collections::HashMap;
// Index comment URIs so we can distinguish reply subjects from external subjects. let uri_set: std::collections::HashSet<&str> = comments.iter().map(|c| c.uri.as_str()).collect();
// Separate top-level comments from replies. let (top_level_refs, reply_refs): (Vec<&CommentData>, Vec<&CommentData>) = comments.iter().partition(|c| !uri_set.contains(c.subject_uri.as_str()));
// Build a map from parent URI → list of reply CommentData references. let mut reply_map: HashMap<&str, Vec<&CommentData>> = HashMap::new(); for reply in &reply_refs { reply_map .entry(reply.subject_uri.as_str()) .or_default() .push(reply); }
const MAX_REPLY_DEPTH: usize = 50;
/// Recursively construct a `CommentNode` with all nested replies. /// Stops at `MAX_REPLY_DEPTH` to prevent stack overflow from crafted chains. fn build_node<'a>( comment: &'a CommentData, reply_map: &HashMap<&str, Vec<&'a CommentData>>, depth: usize, ) -> CommentNode { let mut replies: Vec<CommentNode> = if depth < MAX_REPLY_DEPTH { reply_map .get(comment.uri.as_str()) .map(|rs| rs.iter().map(|r| build_node(r, reply_map, depth + 1)).collect()) .unwrap_or_default() } else { Vec::new() }; replies.sort_by(|a, b| { a.comment .created_at .as_ref() .cmp(b.comment.created_at.as_ref()) }); CommentNode { comment: comment.clone(), replies, } }
let mut anchored: Vec<AnchoredComment> = Vec::new(); let mut non_anchored: Vec<CommentNode> = Vec::new();
for comment in &top_level_refs { let node = build_node(comment, &reply_map, 0); match (comment.anchor.as_ref(), transcript) { (Some(range), Some(tx)) => { let (word_start, word_end) = map_byte_range_to_words(tx, range.byte_start, range.byte_end); anchored.push(AnchoredComment { comment_node: node, word_start, word_end, }); } // No anchor, or anchor but no transcript to map against → non-anchored. _ => { non_anchored.push(node); } } }
// Sort anchored by byte_start; non-anchored by created_at. anchored.sort_by_key(|a| { a.comment_node .comment .anchor .as_ref() .map_or(0, |r| r.byte_start) }); non_anchored.sort_by(|a, b| { a.comment .created_at .as_ref() .cmp(b.comment.created_at.as_ref()) });
CommentTree { anchored, non_anchored, }}
/// Map a byte range `[byte_start, byte_end)` to `(first_word_index, last_word_index)`.////// Any word whose byte span overlaps with the query range is included. This/// naturally implements the "expand to full word boundary" behaviour required by/// AC2.8: if the range starts or ends mid-word, that word is included.////// `byte_end` is clamped to `transcript.full_text.len()` before the lookup/// (AC2.7). If no words overlap — for example an empty transcript or a range/// entirely outside the text — `(0, 0)` is returned.pub fn map_byte_range_to_words( transcript: &TranscriptData, byte_start: usize, byte_end: usize,) -> (usize, usize) { if transcript.words.is_empty() { return (0, 0); }
// Clamp byte_end to the length of the full text (AC2.7). let text_len = transcript.full_text.len(); let clamped_end = byte_end.min(text_len);
// If the range is empty after clamping, return (0, 0). if byte_start >= clamped_end && byte_start >= text_len { return (0, 0); }
let mut first: Option<usize> = None; let mut last: Option<usize> = None;
for (idx, word) in transcript.words.iter().enumerate() { let word_start = word.byte_offset; let word_end = word.byte_offset + word.byte_len;
// Overlap check: ranges [byte_start, clamped_end) and [word_start, word_end) // overlap when byte_start < word_end && clamped_end > word_start. let query_end = if clamped_end == byte_start { // Zero-length range: treat as a single-byte probe at byte_start. byte_start + 1 } else { clamped_end };
if byte_start < word_end && query_end > word_start { if first.is_none() { first = Some(idx); } last = Some(idx); } }
match (first, last) { (Some(f), Some(l)) => (f, l), _ => (0, 0), }}
#[cfg(test)]mod tests { use super::*; use jacquard::deps::smol_str::SmolStr; use jacquard::types::{aturi::AtUri, string::Datetime}; use vodplace_subtitle::{TranscriptData, TranscriptWord};
use crate::catalog::{ByteRangeData, CommentData};
// ── helpers ───────────────────────────────────────────────────────────────
fn make_did(n: u8) -> jacquard::types::did::Did { format!("did:plc:{:0>24}", n).parse().unwrap() }
fn make_handle(n: u8) -> jacquard::types::handle::Handle { format!("user{n}.test").parse().unwrap() }
fn make_uri(s: &str) -> AtUri { s.parse().unwrap() }
fn make_dt(s: &str) -> Datetime { s.parse().unwrap() }
/// Build a comment where `subject_uri` is the talk URI (top-level). fn top_level( rkey: &str, subject: &str, anchor: Option<ByteRangeData>, created_at: &str, ) -> CommentData { let uri_str = format!("at://did:plc:000000000000000000000001/tv.ionosphere.comment/{rkey}"); CommentData { uri: make_uri(&uri_str), text: SmolStr::new("comment text"), author_did: make_did(1), author_handle: make_handle(1), created_at: make_dt(created_at), anchor, subject_uri: make_uri(subject), facets: None, } }
/// Build a comment that replies to `parent_uri`. fn reply(rkey: &str, parent_uri: &str, created_at: &str) -> CommentData { let uri_str = format!("at://did:plc:000000000000000000000002/tv.ionosphere.comment/{rkey}"); CommentData { uri: make_uri(&uri_str), text: SmolStr::new("reply text"), author_did: make_did(2), author_handle: make_handle(2), created_at: make_dt(created_at), anchor: None, subject_uri: make_uri(parent_uri), facets: None, } }
/// Build a minimal TranscriptData from (text, words) pairs of (word, start_ms, end_ms). fn make_transcript(words_data: &[(&str, u64, u64)]) -> TranscriptData { // Reconstruct full_text with single spaces between words. let mut full_text = String::new(); let mut transcript_words = Vec::new(); let mut byte_cursor = 0usize;
for (i, (word, start_ms, end_ms)) in words_data.iter().enumerate() { if i > 0 { full_text.push(' '); byte_cursor += 1; } let byte_offset = byte_cursor; let byte_len = word.len(); full_text.push_str(word); byte_cursor += byte_len;
transcript_words.push(TranscriptWord { text: SmolStr::new(*word), start_ms: *start_ms, end_ms: *end_ms, byte_offset, byte_len, }); }
TranscriptData { words: transcript_words, full_text: SmolStr::new(&full_text), } }
// ── map_byte_range_to_words tests ────────────────────────────────────────
#[test] fn empty_transcript_returns_zero() { let tx = TranscriptData { words: vec![], full_text: SmolStr::new(""), }; assert_eq!(map_byte_range_to_words(&tx, 0, 5), (0, 0)); }
#[test] fn exact_word_boundary() { // "hello world foo" // 01234 67890 1234 // 5 1 1111 let tx = make_transcript(&[("hello", 0, 100), ("world", 200, 300), ("foo", 400, 500)]); // "hello" occupies [0, 5) assert_eq!(map_byte_range_to_words(&tx, 0, 5), (0, 0)); // "world" occupies [6, 11) assert_eq!(map_byte_range_to_words(&tx, 6, 11), (1, 1)); // "foo" occupies [12, 15) assert_eq!(map_byte_range_to_words(&tx, 12, 15), (2, 2)); }
#[test] fn exact_span_two_words() { // "hello world foo" let tx = make_transcript(&[("hello", 0, 100), ("world", 200, 300), ("foo", 400, 500)]); // Exact span of "hello world": [0, 11) assert_eq!(map_byte_range_to_words(&tx, 0, 11), (0, 1)); }
#[test] fn mid_word_start_expands_to_include_word() { // "hello world foo" let tx = make_transcript(&[("hello", 0, 100), ("world", 200, 300), ("foo", 400, 500)]); // Range starts at byte 3 (middle of "hello"), ends at byte 8 (middle of "world"). // Should include both words: indices 0 and 1. assert_eq!(map_byte_range_to_words(&tx, 3, 8), (0, 1)); }
#[test] fn byte_end_past_transcript_length_clamps() { // "hello world" — length 11 let tx = make_transcript(&[("hello", 0, 100), ("world", 200, 300)]); let text_len = tx.full_text.len(); // 11 // byte_end = 9999, should clamp to text_len and still return last word. assert_eq!(map_byte_range_to_words(&tx, 6, 9999), (1, 1)); // Also works when byte_start is 0. assert_eq!(map_byte_range_to_words(&tx, 0, 9999), (0, 1)); let _ = text_len; // suppress unused warning }
#[test] fn overlapping_ranges_independent() { // "hello world foo" let tx = make_transcript(&[("hello", 0, 100), ("world", 200, 300), ("foo", 400, 500)]); let range_a = (0usize, 7usize); // overlaps "hello" and "world" let range_b = (4usize, 13usize); // overlaps "hello", "world", "foo"
let result_a = map_byte_range_to_words(&tx, range_a.0, range_a.1); let result_b = map_byte_range_to_words(&tx, range_b.0, range_b.1);
assert_eq!(result_a, (0, 1)); assert_eq!(result_b, (0, 2)); // They are computed independently — each returns its own result. assert_ne!(result_a, result_b); }
// ── build_comment_tree tests ─────────────────────────────────────────────
const TALK_URI: &str = "at://did:plc:talk000000000000000000001/tv.ionosphere.talk/self";
#[test] fn no_comments_produces_empty_tree() { let tree = build_comment_tree(&[], None); assert!(tree.anchored.is_empty()); assert!(tree.non_anchored.is_empty()); }
#[test] fn top_level_non_anchored_comment() { let comments = vec![top_level( "aaaa", TALK_URI, None, "2024-01-01T00:00:00.000Z", )]; let tree = build_comment_tree(&comments, None); assert!(tree.anchored.is_empty()); assert_eq!(tree.non_anchored.len(), 1); assert!(tree.non_anchored[0].replies.is_empty()); }
#[test] fn reply_nested_under_parent() { let parent_uri = "at://did:plc:000000000000000000000001/tv.ionosphere.comment/aaaa"; let comments = vec![ top_level("aaaa", TALK_URI, None, "2024-01-01T00:00:00.000Z"), reply("bbbb", parent_uri, "2024-01-01T00:01:00.000Z"), ]; let tree = build_comment_tree(&comments, None); assert_eq!(tree.non_anchored.len(), 1); assert_eq!(tree.non_anchored[0].replies.len(), 1); assert_eq!( tree.non_anchored[0].replies[0].comment.uri.as_str(), "at://did:plc:000000000000000000000002/tv.ionosphere.comment/bbbb" ); }
#[test] fn orphaned_reply_treated_as_top_level() { // A comment whose subject_uri points to a URI not in the comment list. let unknown_parent = "at://did:plc:xxxxxxxxxxxxxxxxxxxxxxxxx/tv.ionosphere.comment/xxxx"; let comments = vec![reply("bbbb", unknown_parent, "2024-01-01T00:01:00.000Z")]; let tree = build_comment_tree(&comments, None); // Orphaned reply becomes top-level (non-anchored, no anchor field). assert_eq!(tree.non_anchored.len(), 1); assert!(tree.anchored.is_empty()); }
#[test] fn anchored_comment_mapped_when_transcript_provided() { let tx = make_transcript(&[("hello", 0, 100), ("world", 200, 300), ("foo", 400, 500)]); let anchor = ByteRangeData { byte_start: 0, byte_end: 5, }; let comments = vec![top_level( "aaaa", TALK_URI, Some(anchor), "2024-01-01T00:00:00.000Z", )]; let tree = build_comment_tree(&comments, Some(&tx)); assert_eq!(tree.anchored.len(), 1); assert!(tree.non_anchored.is_empty()); let anchored = &tree.anchored[0]; assert_eq!(anchored.word_start, 0); assert_eq!(anchored.word_end, 0); }
#[test] fn anchored_comment_without_transcript_demoted_to_non_anchored() { let anchor = ByteRangeData { byte_start: 0, byte_end: 5, }; let comments = vec![top_level( "aaaa", TALK_URI, Some(anchor), "2024-01-01T00:00:00.000Z", )]; let tree = build_comment_tree(&comments, None); // No transcript → cannot map → demoted. assert!(tree.anchored.is_empty()); assert_eq!(tree.non_anchored.len(), 1); }
#[test] fn overlapping_anchored_ranges_both_present() { let tx = make_transcript(&[("hello", 0, 100), ("world", 200, 300), ("foo", 400, 500)]); let anchor_a = ByteRangeData { byte_start: 0, byte_end: 7, }; let anchor_b = ByteRangeData { byte_start: 4, byte_end: 13, }; let uri_b = format!( "at://did:plc:000000000000000000000003/tv.ionosphere.comment/cccc" ); let comments = vec![ top_level("aaaa", TALK_URI, Some(anchor_a), "2024-01-01T00:00:00.000Z"), CommentData { uri: uri_b.parse().unwrap(), text: SmolStr::new("another comment"), author_did: make_did(3), author_handle: make_handle(3), created_at: make_dt("2024-01-01T00:02:00.000Z"), anchor: Some(anchor_b), subject_uri: make_uri(TALK_URI), facets: None, }, ]; let tree = build_comment_tree(&comments, Some(&tx)); // Both overlapping anchored ranges produce separate AnchoredComment entries (AC2.9). assert_eq!(tree.anchored.len(), 2); assert!(tree.non_anchored.is_empty());
let a = &tree.anchored[0]; let b = &tree.anchored[1]; assert_eq!(a.word_start, 0); assert_eq!(a.word_end, 1); // "hello world" assert_eq!(b.word_start, 0); assert_eq!(b.word_end, 2); // "hello world foo" }
#[test] fn replies_sorted_by_created_at() { let parent_uri = "at://did:plc:000000000000000000000001/tv.ionosphere.comment/aaaa"; let comments = vec![ top_level("aaaa", TALK_URI, None, "2024-01-01T00:00:00.000Z"), reply("cccc", parent_uri, "2024-01-01T00:03:00.000Z"), reply("bbbb", parent_uri, "2024-01-01T00:01:00.000Z"), ]; let tree = build_comment_tree(&comments, None); let replies = &tree.non_anchored[0].replies; assert_eq!(replies.len(), 2); // Should be sorted: bbbb (00:01) before cccc (00:03). let rkey_0 = replies[0] .comment .uri .as_str() .rsplit('/') .next() .unwrap(); let rkey_1 = replies[1] .comment .uri .as_str() .rsplit('/') .next() .unwrap(); assert_eq!(rkey_0, "bbbb"); assert_eq!(rkey_1, "cccc"); }}