-module(starfuzz@rank). -compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]). -define(FILEPATH, "src/starfuzz/rank.gleam"). -export([combine/1, builder/0, add_signal/4, with_minimum_score/2, with_limit/2, calculate/2]). -export_type([rank_error/0, component_score/0, ranking/1, signal/1, rank_builder/1]). -if(?OTP_RELEASE >= 27). -define(MODULEDOC(Str), -moduledoc(Str)). -define(DOC(Str), -doc(Str)). -else. -define(MODULEDOC(Str), -compile([])). -define(DOC(Str), -compile([])). -endif. ?MODULEDOC( " A module providing ranking and composite scoring capabilities.\n" " Multiple scoring signals can be combined with weights into a single score,\n" " with automatic weight normalization and stable tie-breaking descending sorting.\n" ). -type rank_error() :: zero_weight_sum | {negative_weight, binary()}. -type component_score() :: {component_score, binary(), float(), float()}. -type ranking(DRT) :: {ranking, DRT, float(), list(component_score())}. -type signal(DRU) :: {signal, binary(), float(), fun((DRU) -> float())}. -type rank_builder(DRV) :: {rank_builder, list(signal(DRV)), float(), gleam@option:option(integer())}. -file("src/starfuzz/rank.gleam", 37). ?DOC( " Combines a list of component scores using a weighted average.\n" " Auto-normalizes weights: `sum(score * weight) / sum(weight)`.\n" ). -spec combine(list(component_score())) -> {ok, float()} | {error, rank_error()}. combine(Components) -> Check = gleam@list:fold( Components, {ok, {+0.0, +0.0}}, fun(Acc, Comp) -> case Acc of {error, E} -> {error, E}; {ok, {Sum_weighted, Sum_weight}} -> case erlang:element(4, Comp) < +0.0 of true -> {error, {negative_weight, erlang:element(2, Comp)}}; false -> {ok, {Sum_weighted + (erlang:element(3, Comp) * erlang:element( 4, Comp )), Sum_weight + erlang:element(4, Comp)}} end end end ), case Check of {error, E@1} -> {error, E@1}; {ok, {Sum_weighted@1, Sum_weight@1}} -> case Sum_weight@1 =:= +0.0 of true -> {error, zero_weight_sum}; false -> {ok, case Sum_weight@1 of +0.0 -> +0.0; -0.0 -> -0.0; Gleam@denominator -> Sum_weighted@1 / Gleam@denominator end} end end. -file("src/starfuzz/rank.gleam", 83). ?DOC(" Creates a new default RankBuilder with no signals, min score of 0.0, and no limit.\n"). -spec builder() -> rank_builder(any()). builder() -> {rank_builder, [], +0.0, none}. -file("src/starfuzz/rank.gleam", 92). ?DOC(" Adds a named scoring signal with its weight and scorer callback.\n"). -spec add_signal(rank_builder(DSC), binary(), float(), fun((DSC) -> float())) -> rank_builder(DSC). add_signal(Builder, Name, Weight, Scorer) -> {rank_builder, [{signal, Name, Weight, Scorer} | erlang:element(2, Builder)], erlang:element(3, Builder), erlang:element(4, Builder)}. -file("src/starfuzz/rank.gleam", 97). ?DOC(" Configures the minimum combined score threshold. Rankings below this are discarded.\n"). -spec with_minimum_score(rank_builder(DSG), float()) -> rank_builder(DSG). with_minimum_score(Builder, Min_score) -> {rank_builder, erlang:element(2, Builder), Min_score, erlang:element(4, Builder)}. -file("src/starfuzz/rank.gleam", 102). ?DOC(" Configures a limit on the number of returned rankings.\n"). -spec with_limit(rank_builder(DSJ), integer()) -> rank_builder(DSJ). with_limit(Builder, Limit) -> {rank_builder, erlang:element(2, Builder), erlang:element(3, Builder), {some, Limit}}. -file("src/starfuzz/rank.gleam", 108). ?DOC( " Calculates combined rankings for a list of candidates.\n" " Returns ranked items sorted descending by combined score, with index-based stable sorting.\n" ). -spec calculate(rank_builder(DSM), list(DSM)) -> {ok, list(ranking(DSM))} | {error, rank_error()}. calculate(Builder, Candidates) -> Result = gleam@list:fold( Candidates, {ok, []}, fun(Acc_state, Candidate) -> case Acc_state of {error, E} -> {error, E}; {ok, Acc} -> Components_res = gleam@list:fold( erlang:element(2, Builder), {ok, []}, fun(Comp_acc_state, Signal) -> case Comp_acc_state of {error, E@1} -> {error, E@1}; {ok, Comp_acc} -> case erlang:element(3, Signal) < +0.0 of true -> {error, {negative_weight, erlang:element(2, Signal)}}; false -> Score = (erlang:element(4, Signal))( Candidate ), {ok, [{component_score, erlang:element( 2, Signal ), Score, erlang:element( 3, Signal )} | Comp_acc]} end end end ), case Components_res of {error, E@2} -> {error, E@2}; {ok, Components} -> case combine(Components) of {error, E@3} -> {error, E@3}; {ok, Score@1} -> case Score@1 >= erlang:element(3, Builder) of true -> {ok, [{ranking, Candidate, Score@1, Components} | Acc]}; false -> {ok, Acc} end end end end end ), case Result of {error, E@4} -> {error, E@4}; {ok, Rankings} -> Indexed = gleam@list:index_map( lists:reverse(Rankings), fun(R, I) -> {R, I} end ), Sorted = gleam@list:sort( Indexed, fun(A, B) -> case erlang:element(3, erlang:element(1, A)) > erlang:element( 3, erlang:element(1, B) ) of true -> lt; false -> case erlang:element(3, erlang:element(1, A)) < erlang:element( 3, erlang:element(1, B) ) of true -> gt; false -> case erlang:element(2, A) < erlang:element( 2, B ) of true -> lt; false -> gt end end end end ), Ranked = gleam@list:map(Sorted, fun(X) -> erlang:element(1, X) end), case erlang:element(4, Builder) of {some, L} -> {ok, gleam@list:take(Ranked, L)}; none -> {ok, Ranked} end end.