From 6abc4a071208508c52bcf71aa9414adcdec6cc4f Mon Sep 17 00:00:00 2001 From: hermes Date: Sat, 17 Oct 2026 05:07:41 +0000 Subject: [PATCH] fix(ownership): one release order everywhere; keep the old search API From the Codex re-gate of the 0.3.3 fixes: - `changes` broke ties between same-date releases by row id, but the ticker, entity, and graph queries and the snapshot builder did not, so two files imported in the same second could resolve differently per query. Order by as_of_date, imported_at, then id everywhere. - search::fts_search and search::rebuild_fts were public in a published crate; keep them as deprecated wrappers. - Invalid --source and --by are INVALIDINPUT, like other argument errors. - The prefix-ranking test could not tell prefix from exact-alias ranking; add one where only prefix vs substring differs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh --- crates/idx-core/src/investor.rs | 4 +-- crates/idx-core/src/ownership/db.rs | 37 +++++++++++++++++++++++-- crates/idx-core/src/ownership/graph.rs | 2 +- crates/idx-core/src/ownership/search.rs | 22 +++++++++++++++ scripts/build-ownership-snapshot.sh | 6 ++-- 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/crates/idx-core/src/investor.rs b/crates/idx-core/src/investor.rs index 15c03a8..001fdaa 100644 --- a/crates/idx-core/src/investor.rs +++ b/crates/idx-core/src/investor.rs @@ -555,7 +555,7 @@ impl FromStr for HolderSource { "all" => Ok(Self::All), "ksei" => Ok(Self::Ksei), "bing" => Ok(Self::Bing), - _ => Err(IdxError::ParseError( + _ => Err(IdxError::InvalidInput( "invalid --source, expected: all|ksei|bing".to_string(), )), } @@ -588,7 +588,7 @@ impl FromStr for ConcentrationRank { "top1" => Ok(Self::Top1), "top3" => Ok(Self::Top3), "hhi" => Ok(Self::Hhi), - _ => Err(IdxError::ParseError( + _ => Err(IdxError::InvalidInput( "invalid --by, expected: top1|top3|hhi".to_string(), )), } diff --git a/crates/idx-core/src/ownership/db.rs b/crates/idx-core/src/ownership/db.rs index bafa263..7668c6b 100644 --- a/crates/idx-core/src/ownership/db.rs +++ b/crates/idx-core/src/ownership/db.rs @@ -797,7 +797,7 @@ pub fn query_releases(conn: &Connection) -> Result, IdxErr .prepare( "SELECT id, source_url, sha256, as_of_date, row_count, imported_at FROM ownership_releases - ORDER BY as_of_date DESC, imported_at DESC", + ORDER BY as_of_date DESC, imported_at DESC, id DESC", ) .map_err(|e| IdxError::DatabaseError(e.to_string()))?; @@ -1298,7 +1298,7 @@ fn latest_ksei_release(conn: &Connection) -> Result, I conn.query_row( "SELECT sha256, as_of_date FROM ownership_releases - ORDER BY as_of_date DESC, imported_at DESC + ORDER BY as_of_date DESC, imported_at DESC, id DESC LIMIT 1", [], |row| { @@ -1544,6 +1544,39 @@ mod tests { ); } + #[test] + fn same_second_imports_resolve_to_the_later_file_everywhere() { + // Original and corrected file for one date imported in the same + // second: the ticker view and `changes` must read the same one. + let conn = setup(); + let bbca = upsert_ticker(&conn, "BBCA", None).unwrap(); + let alpha = entity(&conn, "ALPHA"); + write_release( + &conn, + "jan", + "2026-01-30", + 7, + bbca, + &[(Some(alpha), "ALPHA", 1000)], + ); + write_release( + &conn, + "jan-fix", + "2026-01-30", + 7, + bbca, + &[(Some(alpha), "ALPHA", 1200)], + ); + + let ticker = query_ticker_holdings(&conn, "BBCA").unwrap(); + + assert_eq!( + ticker.holders[0].percentage_bps, 1200, + "{:?}", + ticker.holders + ); + } + #[test] fn changes_reject_a_date_without_a_release() { let conn = setup(); diff --git a/crates/idx-core/src/ownership/graph.rs b/crates/idx-core/src/ownership/graph.rs index c295376..d51fd12 100644 --- a/crates/idx-core/src/ownership/graph.rs +++ b/crates/idx-core/src/ownership/graph.rs @@ -182,7 +182,7 @@ fn query_all_edges(conn: &Connection) -> Result, IdxError> { if let Ok(release_sha) = conn.query_row( "SELECT sha256 FROM ownership_releases - ORDER BY as_of_date DESC, imported_at DESC + ORDER BY as_of_date DESC, imported_at DESC, id DESC LIMIT 1", [], |row| row.get::<_, String>(0), diff --git a/crates/idx-core/src/ownership/search.rs b/crates/idx-core/src/ownership/search.rs index 40fd1f1..9b0af1e 100644 --- a/crates/idx-core/src/ownership/search.rs +++ b/crates/idx-core/src/ownership/search.rs @@ -60,6 +60,19 @@ pub fn search_entities( .collect() } +/// Former name of [`search_entities`], kept for library users. +#[deprecated(since = "0.3.3", note = "use search_entities")] +pub fn fts_search(conn: &Connection, query: &str, limit: usize) -> Result, IdxError> { + search_entities(conn, query, limit) +} + +/// Search no longer uses an FTS index, so there is nothing to rebuild; kept +/// for library users. +#[deprecated(since = "0.3.3", note = "search reads entities and aliases directly")] +pub fn rebuild_fts(_conn: &Connection) -> Result<(), IdxError> { + Ok(()) +} + #[cfg(test)] mod tests { use rusqlite::{Connection, params}; @@ -124,6 +137,15 @@ mod tests { ); } + #[test] + fn a_name_prefix_ranks_above_a_mid_name_match() { + // Neither is an exact name or alias: only prefix vs substring differs, + // and the substring match is shorter, so length alone would flip them. + let conn = conn_with(&[("BANK MANDIRI TASPEN", &[]), ("PT BANK", &[])]); + + assert_eq!(names(&conn, "bank"), ["BANK MANDIRI TASPEN", "PT BANK"]); + } + #[test] fn treats_like_wildcards_literally_and_empty_queries_as_no_match() { let conn = conn_with(&[("ALPHA_FUND", &[]), ("ALPHAXFUND", &[]), ("BETA 100%", &[])]); diff --git a/scripts/build-ownership-snapshot.sh b/scripts/build-ownership-snapshot.sh index 997b70b..b423c7f 100755 --- a/scripts/build-ownership-snapshot.sh +++ b/scripts/build-ownership-snapshot.sh @@ -79,9 +79,9 @@ fi mkdir -p "$OUTPUT_DIR" -LATEST_AS_OF="$(sqlite3 "$DB_PATH" "SELECT as_of_date FROM ownership_releases ORDER BY as_of_date DESC, imported_at DESC LIMIT 1;")" -LATEST_RELEASE_SHA="$(sqlite3 "$DB_PATH" "SELECT sha256 FROM ownership_releases ORDER BY as_of_date DESC, imported_at DESC LIMIT 1;")" -LATEST_ROW_COUNT="$(sqlite3 "$DB_PATH" "SELECT row_count FROM ownership_releases ORDER BY as_of_date DESC, imported_at DESC LIMIT 1;")" +LATEST_AS_OF="$(sqlite3 "$DB_PATH" "SELECT as_of_date FROM ownership_releases ORDER BY as_of_date DESC, imported_at DESC, id DESC LIMIT 1;")" +LATEST_RELEASE_SHA="$(sqlite3 "$DB_PATH" "SELECT sha256 FROM ownership_releases ORDER BY as_of_date DESC, imported_at DESC, id DESC LIMIT 1;")" +LATEST_ROW_COUNT="$(sqlite3 "$DB_PATH" "SELECT row_count FROM ownership_releases ORDER BY as_of_date DESC, imported_at DESC, id DESC LIMIT 1;")" RELEASE_COUNT="$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM ownership_releases;")" TICKER_COUNT="$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM tickers;")"