Current section

Files

Jump to
macula_neuroevolution src silos lc_ets_utils.erl
Raw

src/silos/lc_ets_utils.erl

%% @doc Shared ETS utilities for Liquid Conglomerate Silos.
%%
%% Provides a consistent pattern for silos that need persistent collections:
%% Competitive Silo: opponents, matches, elo_ratings
%% Social Silo: reputations, coalitions, interactions
%% Cultural Silo: innovations, traditions, memes
%% Communication Silo: vocabulary, dialects, messages
%%
%% == Usage Pattern ==
%%
%% In init/1, create tables for your silo:
%%
%% EtsTables = lc_ets_utils:create_tables(competitive, Realm, TableSpecs)
%%
%% In normal operation, use insert/lookup/delete:
%%
%% lc_ets_utils:insert(Table, Key, Value)
%% {ok, Data} = lc_ets_utils:lookup(Table, Key)
%%
%% Cleanup when silo terminates:
%%
%% lc_ets_utils:delete_tables(EtsTables)
%%
%% == Time-Based Operations ==
%%
%% All entries are timestamped automatically for:
%% Age-based pruning (remove stale entries)
%% Recency queries (get most recent N entries)
%% Decay calculations (reduce values over time)
%%
%% @author Macula.io
%% @copyright 2025 Macula.io
-module(lc_ets_utils).
-export([
%% Table management
create_tables/3,
delete_tables/1,
table_name/3,
%% CRUD operations
insert/3,
insert_with_timestamp/4,
lookup/2,
lookup_with_timestamp/2,
delete/2,
update/3,
%% Bulk operations
all/1,
all_keys/1,
count/1,
%% Time-based operations
prune_by_age/2,
prune_by_count/2,
get_recent/2,
get_oldest/2,
%% Aggregation
sum_field/2,
avg_field/2,
max_field/2,
min_field/2,
%% Iteration
fold/3,
foreach/2,
filter/2
]).
-type table_name() :: atom().
-type table_ref() :: ets:tid() | atom().
-type table_spec() :: {table_name(), [ets:table_type() | {atom(), term()}]}.
-type timestamped_entry() :: {Key :: term(), Value :: term(), Timestamp :: integer()}.
%%% ============================================================================
%%% Table Management
%%% ============================================================================
%% @doc Create ETS tables for a silo with consistent naming.
%%
%% Tables are named: {SiloType}_{Realm}_{TableName}
%% Example: competitive_realm1_opponents
%%
%% Returns a map of table_name => ets:tid().
-spec create_tables(SiloType :: atom(), Realm :: binary(), TableSpecs :: [table_spec()]) ->
#{table_name() => table_ref()}.
create_tables(SiloType, Realm, TableSpecs) ->
lists:foldl(
fun({TableName, Options}, Acc) ->
FullName = table_name(SiloType, Realm, TableName),
%% Merge default options with provided options
DefaultOpts = [set, public, {keypos, 1}, {read_concurrency, true}],
MergedOpts = merge_options(DefaultOpts, Options),
Tid = ets:new(FullName, MergedOpts),
maps:put(TableName, Tid, Acc)
end,
#{},
TableSpecs
).
%% @doc Delete all ETS tables in the map.
-spec delete_tables(Tables :: #{table_name() => table_ref()}) -> ok.
delete_tables(Tables) ->
maps:foreach(
fun(_Name, Tid) ->
catch ets:delete(Tid)
end,
Tables
),
ok.
%% @doc Generate consistent table name atom.
-spec table_name(SiloType :: atom(), Realm :: binary(), TableName :: atom()) -> atom().
table_name(SiloType, Realm, TableName) ->
RealmStr = binary_to_list(Realm),
list_to_atom(
atom_to_list(SiloType) ++ "_" ++ RealmStr ++ "_" ++ atom_to_list(TableName)
).
%%% ============================================================================
%%% CRUD Operations
%%% ============================================================================
%% @doc Insert a key-value pair with automatic timestamp.
-spec insert(Table :: table_ref(), Key :: term(), Value :: term()) -> true.
insert(Table, Key, Value) ->
Timestamp = erlang:system_time(millisecond),
ets:insert(Table, {Key, Value, Timestamp}).
%% @doc Insert with explicit timestamp (for time-travel or replay).
-spec insert_with_timestamp(
Table :: table_ref(),
Key :: term(),
Value :: term(),
Timestamp :: integer()
) -> true.
insert_with_timestamp(Table, Key, Value, Timestamp) ->
ets:insert(Table, {Key, Value, Timestamp}).
%% @doc Lookup a value by key. Returns {ok, Value} or not_found.
-spec lookup(Table :: table_ref(), Key :: term()) -> {ok, term()} | not_found.
lookup(Table, Key) ->
lookup_result(ets:lookup(Table, Key)).
lookup_result([{_Key, Value, _Timestamp}]) -> {ok, Value};
lookup_result([{_Key, Value}]) -> {ok, Value}; %% Non-timestamped
lookup_result([]) -> not_found.
%% @doc Lookup a value with its timestamp.
-spec lookup_with_timestamp(Table :: table_ref(), Key :: term()) ->
{ok, term(), integer()} | not_found.
lookup_with_timestamp(Table, Key) ->
lookup_ts_result(ets:lookup(Table, Key)).
lookup_ts_result([{_Key, Value, Timestamp}]) -> {ok, Value, Timestamp};
lookup_ts_result([]) -> not_found.
%% @doc Delete an entry by key.
-spec delete(Table :: table_ref(), Key :: term()) -> true.
delete(Table, Key) ->
ets:delete(Table, Key).
%% @doc Update a value using a function.
%%
%% UpdateFn(OldValue) -> NewValue
%% If key doesn't exist, UpdateFn(undefined) is called.
-spec update(Table :: table_ref(), Key :: term(), UpdateFn :: fun((term()) -> term())) -> ok.
update(Table, Key, UpdateFn) ->
OldValue = get_value_or_undefined(lookup(Table, Key)),
NewValue = UpdateFn(OldValue),
insert(Table, Key, NewValue),
ok.
get_value_or_undefined({ok, V}) -> V;
get_value_or_undefined(not_found) -> undefined.
%%% ============================================================================
%%% Bulk Operations
%%% ============================================================================
%% @doc Get all entries as a list of {Key, Value, Timestamp} tuples.
-spec all(Table :: table_ref()) -> [timestamped_entry()].
all(Table) ->
ets:tab2list(Table).
%% @doc Get all keys in the table.
-spec all_keys(Table :: table_ref()) -> [term()].
all_keys(Table) ->
ets:foldl(
fun({Key, _Value, _Timestamp}, Acc) -> [Key | Acc];
({Key, _Value}, Acc) -> [Key | Acc] %% Non-timestamped
end,
[],
Table
).
%% @doc Count entries in the table.
-spec count(Table :: table_ref()) -> non_neg_integer().
count(Table) ->
ets:info(Table, size).
%%% ============================================================================
%%% Time-Based Operations
%%% ============================================================================
%% @doc Remove entries older than MaxAgeMs milliseconds.
%%
%% Returns the number of entries deleted.
-spec prune_by_age(Table :: table_ref(), MaxAgeMs :: pos_integer()) -> non_neg_integer().
prune_by_age(Table, MaxAgeMs) ->
Now = erlang:system_time(millisecond),
Cutoff = Now - MaxAgeMs,
%% Find old entries
OldKeys = ets:foldl(
fun({Key, _Value, Timestamp}, Acc) when Timestamp < Cutoff -> [Key | Acc];
(_, Acc) -> Acc
end,
[],
Table
),
%% Delete them
lists:foreach(fun(Key) -> ets:delete(Table, Key) end, OldKeys),
length(OldKeys).
%% @doc Remove oldest entries to keep table at MaxCount size.
%%
%% Returns the number of entries deleted.
-spec prune_by_count(Table :: table_ref(), MaxCount :: pos_integer()) -> non_neg_integer().
prune_by_count(Table, MaxCount) ->
CurrentCount = ets:info(Table, size),
prune_if_over_limit(Table, CurrentCount, MaxCount).
prune_if_over_limit(_Table, CurrentCount, MaxCount) when CurrentCount =< MaxCount ->
0;
prune_if_over_limit(Table, CurrentCount, MaxCount) ->
Entries = ets:tab2list(Table),
Sorted = lists:sort(fun sort_by_timestamp_asc/2, Entries),
ToDelete = lists:sublist(Sorted, CurrentCount - MaxCount),
delete_entries(Table, ToDelete),
length(ToDelete).
sort_by_timestamp_asc({_, _, T1}, {_, _, T2}) -> T1 < T2.
delete_entries(Table, Entries) ->
lists:foreach(fun({Key, _, _}) -> ets:delete(Table, Key) end, Entries).
%% @doc Get the N most recent entries.
-spec get_recent(Table :: table_ref(), N :: pos_integer()) -> [timestamped_entry()].
get_recent(Table, N) ->
Entries = ets:tab2list(Table),
Sorted = lists:sort(
fun({_, _, T1}, {_, _, T2}) -> T1 > T2 end, %% Most recent first
Entries
),
lists:sublist(Sorted, N).
%% @doc Get the N oldest entries.
-spec get_oldest(Table :: table_ref(), N :: pos_integer()) -> [timestamped_entry()].
get_oldest(Table, N) ->
Entries = ets:tab2list(Table),
Sorted = lists:sort(
fun({_, _, T1}, {_, _, T2}) -> T1 < T2 end, %% Oldest first
Entries
),
lists:sublist(Sorted, N).
%%% ============================================================================
%%% Aggregation Functions
%%% ============================================================================
%% @doc Sum a numeric field from all values.
%%
%% FieldFn extracts the numeric field from each value.
-spec sum_field(Table :: table_ref(), FieldFn :: fun((term()) -> number())) -> number().
sum_field(Table, FieldFn) ->
ets:foldl(
fun({_Key, Value, _Timestamp}, Acc) -> Acc + FieldFn(Value);
({_Key, Value}, Acc) -> Acc + FieldFn(Value)
end,
0,
Table
).
%% @doc Average a numeric field from all values.
-spec avg_field(Table :: table_ref(), FieldFn :: fun((term()) -> number())) ->
number() | undefined.
avg_field(Table, FieldFn) ->
{Sum, Count} = ets:foldl(
fun(Entry, {S, C}) -> {S + FieldFn(extract_value(Entry)), C + 1} end,
{0, 0},
Table
),
compute_avg(Sum, Count).
compute_avg(_Sum, 0) -> undefined;
compute_avg(Sum, Count) -> Sum / Count.
%% @doc Find maximum value of a numeric field.
-spec max_field(Table :: table_ref(), FieldFn :: fun((term()) -> number())) ->
number() | undefined.
max_field(Table, FieldFn) ->
fold_field(Table, FieldFn, fun erlang:max/2).
%% @doc Find minimum value of a numeric field.
-spec min_field(Table :: table_ref(), FieldFn :: fun((term()) -> number())) ->
number() | undefined.
min_field(Table, FieldFn) ->
fold_field(Table, FieldFn, fun erlang:min/2).
%% @private Fold over table applying FieldFn and combining with CombineFn.
fold_field(Table, FieldFn, CombineFn) ->
fold_field_if_nonempty(ets:info(Table, size), Table, FieldFn, CombineFn).
fold_field_if_nonempty(0, _Table, _FieldFn, _CombineFn) ->
undefined;
fold_field_if_nonempty(_Size, Table, FieldFn, CombineFn) ->
ets:foldl(
fun(Entry, undefined) -> FieldFn(extract_value(Entry));
(Entry, Acc) -> CombineFn(Acc, FieldFn(extract_value(Entry)))
end,
undefined,
Table
).
%% @private Extract value from entry (supports timestamped and non-timestamped).
extract_value({_Key, Value, _Timestamp}) -> Value;
extract_value({_Key, Value}) -> Value.
%%% ============================================================================
%%% Iteration Functions
%%% ============================================================================
%% @doc Fold over all entries.
-spec fold(
Fun :: fun((timestamped_entry(), Acc) -> Acc),
InitAcc :: Acc,
Table :: table_ref()
) -> Acc when Acc :: term().
fold(Fun, InitAcc, Table) ->
ets:foldl(Fun, InitAcc, Table).
%% @doc Execute a function for each entry (side effects).
-spec foreach(Fun :: fun((timestamped_entry()) -> any()), Table :: table_ref()) -> ok.
foreach(Fun, Table) ->
ets:foldl(
fun(Entry, ok) -> Fun(Entry), ok end,
ok,
Table
),
ok.
%% @doc Filter entries matching a predicate.
-spec filter(
Pred :: fun((timestamped_entry()) -> boolean()),
Table :: table_ref()
) -> [timestamped_entry()].
filter(Pred, Table) ->
ets:foldl(
fun(Entry, Acc) -> maybe_include(Pred(Entry), Entry, Acc) end,
[],
Table
).
maybe_include(true, Entry, Acc) -> [Entry | Acc];
maybe_include(false, _Entry, Acc) -> Acc.
%%% ============================================================================
%%% Internal Functions
%%% ============================================================================
%% @private Merge ETS table options, with later options taking precedence.
merge_options(DefaultOpts, OverrideOpts) ->
%% Convert to maps for easy merging, handling both atoms and {Key,Val} tuples
DefaultMap = opts_to_map(DefaultOpts),
OverrideMap = opts_to_map(OverrideOpts),
MergedMap = maps:merge(DefaultMap, OverrideMap),
%% Convert back to list format
maps:fold(fun opts_from_map/3, [], MergedMap).
%% @private Convert map entries back to ETS option format.
%% Bare atom options (set, public, etc.) are stored as atom => true.
opts_from_map(Key, true, Acc) ->
maybe_bare_atom_opt(Key, Acc);
opts_from_map(Key, Val, Acc) ->
[{Key, Val} | Acc].
%% @private Bare atom options become just the atom, others become tuples.
maybe_bare_atom_opt(set, Acc) -> [set | Acc];
maybe_bare_atom_opt(ordered_set, Acc) -> [ordered_set | Acc];
maybe_bare_atom_opt(bag, Acc) -> [bag | Acc];
maybe_bare_atom_opt(duplicate_bag, Acc) -> [duplicate_bag | Acc];
maybe_bare_atom_opt(public, Acc) -> [public | Acc];
maybe_bare_atom_opt(protected, Acc) -> [protected | Acc];
maybe_bare_atom_opt(private, Acc) -> [private | Acc];
maybe_bare_atom_opt(named_table, Acc) -> [named_table | Acc];
maybe_bare_atom_opt(Key, Acc) -> [{Key, true} | Acc].
opts_to_map(Opts) ->
lists:foldl(fun opt_to_map_entry/2, #{}, Opts).
opt_to_map_entry(set, Acc) -> maps:put(set, true, Acc);
opt_to_map_entry(ordered_set, Acc) -> maps:put(ordered_set, true, Acc);
opt_to_map_entry(bag, Acc) -> maps:put(bag, true, Acc);
opt_to_map_entry(duplicate_bag, Acc) -> maps:put(duplicate_bag, true, Acc);
opt_to_map_entry(public, Acc) -> maps:put(public, true, Acc);
opt_to_map_entry(protected, Acc) -> maps:put(protected, true, Acc);
opt_to_map_entry(private, Acc) -> maps:put(private, true, Acc);
opt_to_map_entry(named_table, Acc) -> maps:put(named_table, true, Acc);
opt_to_map_entry({Key, Val}, Acc) -> maps:put(Key, Val, Acc);
opt_to_map_entry(_, Acc) -> Acc.