fix(ownership): correct changes, alias search, graph, and name lookups

From a full-repo Codex audit, each reproduced on the synced ownership DB:
- `changes` paired an investor's accounts across releases instead of
  comparing totals: KREN's KRESNA PRIMA INVEST went 9.44% -> 13.45% (a
  second 4.01% account) but was reported as decreased 944 -> 401 bps.
  Sum a holder's accounts per ticker, and read one release per date (the
  latest imported, as other queries do), so a corrected file supersedes
  the original instead of mixing with it.
- `changes` / get_ownership_changes accepted any dates: `from: ""`
  reported all 7,154 holders as new, unknown dates returned no changes.
  Dates must be YYYY-MM-DD imported releases with `from` before `to`.
- get_investor_holdings / `entity` with an empty name matched every
  investor and returned an arbitrary one ("AMY"); `%` and `_` acted as
  LIKE wildcards. Reject empty names; match wildcards literally.
- Alias search never worked: the FTS table declared an `aliases` column
  its content table lacks (`no such column: T.aliases`), so searches
  fell back to canonical names and a raw KSEI name such as
  "PT ASTRA INTERNATIONAL TBK" found nothing. Search names and aliases
  directly, ranked exact name, exact alias, prefix, then shorter names.
- `graph` enumerated every path (exponential in depth) and its cycle
  check matched substrings, dropping entity:1 when entity:10 was on the
  path. Traverse breadth-first with an exact visited set.
- Name normalization removed leading PT tokens from the front of a Vec,
  quadratic in hostile names; narrow a slice instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
hermes 2026-10-17 04:09:51 +00:00
commit 1d3fe5ca88
6 changed files with 437 additions and 196 deletions

View file

@ -109,34 +109,25 @@ pub fn normalize_name(raw: &str) -> String {
return upper;
}
let mut tokens: Vec<&str> = upper.split_whitespace().collect();
loop {
let mut changed = false;
if let Some(first) = tokens.first().copied()
&& matches!(first, "PT" | "PT.")
{
tokens.remove(0);
changed = true;
}
if let Some(last) = tokens.last().copied()
&& matches!(
last,
"TBK" | "Tbk" | "(PERSERO)" | "LIMITED" | "PTE" | "LTD"
)
{
let _ = tokens.pop();
changed = true;
}
if !changed {
break;
}
let tokens: Vec<&str> = upper.split_whitespace().collect();
// Trim prefix and suffix tokens by narrowing a slice: linear, where
// removing from the front of the Vec was quadratic in hostile names.
let mut kept = tokens.as_slice();
while let [first, rest @ ..] = kept
&& matches!(*first, "PT" | "PT.")
{
kept = rest;
}
while let [rest @ .., last] = kept
&& matches!(
*last,
"TBK" | "Tbk" | "(PERSERO)" | "LIMITED" | "PTE" | "LTD"
)
{
kept = rest;
}
collapse_whitespace(&tokens.join(" "))
collapse_whitespace(&kept.join(" "))
}
/// Convert a KseiRawRow into a normalized KseiHoldingDraft.
@ -357,6 +348,14 @@ mod tests {
);
}
#[test]
fn normalize_name_strips_many_prefixes_in_linear_time() {
// Removing from the front of a Vec made this quadratic (~5e9 shifts).
let hostile = format!("{}ACME TBK", "PT ".repeat(100_000));
assert_eq!(normalize_name(&hostile), "ACME");
assert_eq!(normalize_name("PT TBK"), "");
}
#[test]
fn test_normalize_name() {
assert_eq!(