Current section
Files
Jump to
Current section
Files
src/persist/checkpoint_manager.erl
%%%-------------------------------------------------------------------
%%% @doc Network Checkpoint Manager.
%%%
%%% This module provides network checkpointing capabilities for
%%% saving and loading evolved networks at key milestones during training.
%%%
%%% == Checkpoint Triggers ==
%%%
%%% Networks are saved at configurable milestones:
%%% fitness_record - When a new best fitness is achieved
%%% generation_interval - Every N generations
%%% evaluation_interval - Every N evaluations
%%%
%%% == Storage Format ==
%%%
%%% Each checkpoint is stored as an Erlang term file containing:
%%% Network binary (from network_evaluator:to_binary/1)
%%% Metadata (fitness, generation, timestamp, etc.)
%%% Configuration used during training
%%%
%%% @author Macula.io
%%% @copyright 2025 Macula.io
%%% @end
%%%-------------------------------------------------------------------
-module(checkpoint_manager).
-include("neuroevolution.hrl").
%% API
-export([
%% Configuration
init/1,
get_checkpoint_dir/0,
set_checkpoint_dir/1,
%% Saving
save_checkpoint/2,
save_checkpoint/3,
%% Loading
load_checkpoint/1,
load_latest/0,
load_latest/1,
load_best_fitness/0,
load_best_fitness/1,
%% Management
list_checkpoints/0,
list_checkpoints/1,
delete_checkpoint/1,
prune_checkpoints/1,
%% Utilities
checkpoint_filename/2,
parse_checkpoint_filename/1
]).
%% Default checkpoint directory (relative to CWD)
-define(DEFAULT_CHECKPOINT_DIR, "_checkpoints").
%% File extension for checkpoints
-define(CHECKPOINT_EXT, ".checkpoint").
%% Maximum checkpoints to keep (per reason) before pruning old ones
-define(DEFAULT_MAX_CHECKPOINTS, 20).
%%==============================================================================
%% Types
%%==============================================================================
-type checkpoint_reason() ::
fitness_record | %% New best fitness achieved
generation_interval | %% Every N generations
evaluation_interval | %% Every N evaluations
manual | %% User-triggered save
pre_mutation | %% Before topology mutation
training_complete. %% End of training
-type checkpoint_metadata() :: #{
reason := checkpoint_reason(),
fitness := float(),
generation := non_neg_integer(),
total_evaluations := non_neg_integer(),
individual_id := term(),
timestamp := non_neg_integer(),
config => map()
}.
-type checkpoint() :: #{
individual := individual(),
metadata := checkpoint_metadata()
}.
-export_type([checkpoint_reason/0, checkpoint_metadata/0, checkpoint/0]).
%%==============================================================================
%% Configuration API
%%==============================================================================
%% @doc Initialize the checkpoint manager with a configuration.
%% Creates the checkpoint directory if it doesn't exist.
-spec init(map()) -> ok | {error, term()}.
init(Config) ->
Dir = maps:get(checkpoint_dir, Config, ?DEFAULT_CHECKPOINT_DIR),
case filelib:ensure_dir(filename:join(Dir, "dummy")) of
ok ->
persistent_term:put({?MODULE, checkpoint_dir}, Dir),
ok;
{error, Reason} ->
{error, {create_dir_failed, Reason}}
end.
%% @doc Get the current checkpoint directory.
-spec get_checkpoint_dir() -> file:filename().
get_checkpoint_dir() ->
try
persistent_term:get({?MODULE, checkpoint_dir})
catch
error:badarg ->
?DEFAULT_CHECKPOINT_DIR
end.
%% @doc Set the checkpoint directory.
-spec set_checkpoint_dir(file:filename()) -> ok | {error, term()}.
set_checkpoint_dir(Dir) ->
case filelib:ensure_dir(filename:join(Dir, "dummy")) of
ok ->
persistent_term:put({?MODULE, checkpoint_dir}, Dir),
ok;
{error, Reason} ->
{error, {create_dir_failed, Reason}}
end.
%%==============================================================================
%% Saving API
%%==============================================================================
%% @doc Save a checkpoint with the given individual and metadata.
-spec save_checkpoint(individual(), checkpoint_metadata()) -> ok | {error, term()}.
save_checkpoint(Individual, Metadata) ->
save_checkpoint(Individual, Metadata, #{}).
%% @doc Save a checkpoint with additional options.
%% Options:
%% checkpoint_dir - Override the default directory
%% compress - true (default) or false
-spec save_checkpoint(individual(), checkpoint_metadata(), map()) -> ok | {error, term()}.
save_checkpoint(Individual, Metadata, Options) ->
Dir = maps:get(checkpoint_dir, Options, get_checkpoint_dir()),
Compress = maps:get(compress, Options, true),
%% Ensure metadata has required fields
CompleteMetadata = complete_metadata(Metadata, Individual),
%% Generate filename from metadata
Filename = checkpoint_filename(CompleteMetadata, Dir),
%% Build checkpoint data
Checkpoint = #{
version => 1,
individual => Individual,
metadata => CompleteMetadata
},
%% Serialize with optional compression
Binary = case Compress of
true -> term_to_binary(Checkpoint, [compressed]);
false -> term_to_binary(Checkpoint)
end,
%% Write to file
case file:write_file(Filename, Binary) of
ok ->
error_logger:info_msg("[checkpoint_manager] Saved checkpoint: ~s (~.2f KB)~n",
[filename:basename(Filename), byte_size(Binary) / 1024]),
ok;
{error, Reason} ->
error_logger:error_msg("[checkpoint_manager] Failed to save checkpoint: ~p~n", [Reason]),
{error, {write_failed, Reason}}
end.
%% @private Complete metadata with defaults and derived values.
-spec complete_metadata(checkpoint_metadata(), individual()) -> checkpoint_metadata().
complete_metadata(Metadata, Individual) ->
Now = erlang:system_time(millisecond),
Defaults = #{
reason => manual,
fitness => Individual#individual.fitness,
generation => Individual#individual.generation_born,
total_evaluations => 0,
individual_id => Individual#individual.id,
timestamp => Now
},
maps:merge(Defaults, Metadata).
%%==============================================================================
%% Loading API
%%==============================================================================
%% @doc Load a checkpoint from a file.
-spec load_checkpoint(file:filename()) -> {ok, individual(), checkpoint_metadata()} | {error, term()}.
load_checkpoint(Filename) ->
case file:read_file(Filename) of
{ok, Binary} ->
try
#{version := 1,
individual := Individual,
metadata := Metadata} = binary_to_term(Binary),
{ok, Individual, Metadata}
catch
_:Reason ->
{error, {parse_failed, Reason}}
end;
{error, Reason} ->
{error, {read_failed, Reason}}
end.
%% @doc Load the most recent checkpoint.
-spec load_latest() -> {ok, individual(), checkpoint_metadata()} | {error, no_checkpoints | term()}.
load_latest() ->
load_latest(#{}).
%% @doc Load the most recent checkpoint from a specific directory.
-spec load_latest(map()) -> {ok, individual(), checkpoint_metadata()} | {error, no_checkpoints | term()}.
load_latest(Options) ->
case list_checkpoints(Options) of
[] ->
{error, no_checkpoints};
Checkpoints ->
%% Sort by timestamp (newest first) and take the first
Sorted = lists:sort(fun(A, B) ->
maps:get(timestamp, A, 0) > maps:get(timestamp, B, 0)
end, Checkpoints),
#{filename := Filename} = hd(Sorted),
load_checkpoint(Filename)
end.
%% @doc Load the checkpoint with the best fitness.
-spec load_best_fitness() -> {ok, individual(), checkpoint_metadata()} | {error, no_checkpoints | term()}.
load_best_fitness() ->
load_best_fitness(#{}).
%% @doc Load the checkpoint with the best fitness from a specific directory.
-spec load_best_fitness(map()) -> {ok, individual(), checkpoint_metadata()} | {error, no_checkpoints | term()}.
load_best_fitness(Options) ->
case list_checkpoints(Options) of
[] ->
{error, no_checkpoints};
Checkpoints ->
%% Sort by fitness (highest first) and take the first
Sorted = lists:sort(fun(A, B) ->
maps:get(fitness, A, 0) > maps:get(fitness, B, 0)
end, Checkpoints),
#{filename := Filename} = hd(Sorted),
load_checkpoint(Filename)
end.
%%==============================================================================
%% Management API
%%==============================================================================
%% @doc List all checkpoints in the default directory.
-spec list_checkpoints() -> [map()].
list_checkpoints() ->
list_checkpoints(#{}).
%% @doc List all checkpoints in the specified directory.
%% Returns a list of maps with metadata and filename for each checkpoint.
-spec list_checkpoints(map()) -> [map()].
list_checkpoints(Options) ->
Dir = maps:get(checkpoint_dir, Options, get_checkpoint_dir()),
Pattern = filename:join(Dir, "*" ++ ?CHECKPOINT_EXT),
Files = filelib:wildcard(Pattern),
lists:filtermap(fun(Filename) ->
case parse_checkpoint_filename(Filename) of
{ok, Info} ->
{true, Info#{filename => Filename}};
{error, _} ->
false
end
end, Files).
%% @doc Delete a specific checkpoint.
-spec delete_checkpoint(file:filename()) -> ok | {error, term()}.
delete_checkpoint(Filename) ->
file:delete(Filename).
%% @doc Prune old checkpoints, keeping only the most recent N per reason.
%% Options:
%% max_per_reason - Maximum checkpoints to keep per reason (default: 20)
%% keep_best - Always keep the best fitness checkpoint (default: true)
-spec prune_checkpoints(map()) -> {ok, non_neg_integer()} | {error, term()}.
prune_checkpoints(Options) ->
MaxPerReason = maps:get(max_per_reason, Options, ?DEFAULT_MAX_CHECKPOINTS),
KeepBest = maps:get(keep_best, Options, true),
Checkpoints = list_checkpoints(Options),
%% Group by reason
ByReason = lists:foldl(fun(CP, Acc) ->
Reason = maps:get(reason, CP, manual),
Existing = maps:get(Reason, Acc, []),
maps:put(Reason, [CP | Existing], Acc)
end, #{}, Checkpoints),
%% Find best fitness checkpoint to preserve
BestCheckpoint = case KeepBest andalso length(Checkpoints) > 0 of
true ->
SortedByFitness = lists:sort(fun(A, B) ->
maps:get(fitness, A, 0) > maps:get(fitness, B, 0)
end, Checkpoints),
hd(SortedByFitness);
false ->
undefined
end,
%% Prune each reason group
Deleted = maps:fold(fun(_Reason, CPs, AccDeleted) ->
%% Sort by timestamp (newest first)
SortedByTime = lists:sort(fun(A, B) ->
maps:get(timestamp, A, 0) > maps:get(timestamp, B, 0)
end, CPs),
%% Keep first MaxPerReason, delete the rest
{_Keep, ToDelete} = lists:split(min(MaxPerReason, length(SortedByTime)), SortedByTime),
lists:foldl(fun(CP, Acc) ->
%% Don't delete the best fitness checkpoint
case BestCheckpoint =/= undefined andalso
maps:get(filename, CP) =:= maps:get(filename, BestCheckpoint) of
true ->
Acc;
false ->
case delete_checkpoint(maps:get(filename, CP)) of
ok -> Acc + 1;
_ -> Acc
end
end
end, AccDeleted, ToDelete)
end, 0, ByReason),
{ok, Deleted}.
%%==============================================================================
%% Utility Functions
%%==============================================================================
%% @doc Generate a checkpoint filename from metadata.
%% Format: REASON-genN-fitF-TIMESTAMP.checkpoint
%% Uses dashes as separators since reason names may contain underscores.
-spec checkpoint_filename(checkpoint_metadata(), file:filename()) -> file:filename().
checkpoint_filename(Metadata, Dir) ->
Reason = maps:get(reason, Metadata, manual),
Gen = maps:get(generation, Metadata, 0),
Fitness = maps:get(fitness, Metadata, 0.0),
Timestamp = maps:get(timestamp, Metadata, erlang:system_time(millisecond)),
%% Format fitness as string with 4 decimal places
FitnessStr = io_lib:format("~.4f", [Fitness]),
%% Remove any decimal points or negative signs for filename safety
FitnessSafe = lists:flatten([C || C <- FitnessStr, C =/= $.]),
Basename = io_lib:format("~s-gen~B-fit~s-~B~s",
[Reason, Gen, FitnessSafe, Timestamp, ?CHECKPOINT_EXT]),
filename:join(Dir, lists:flatten(Basename)).
%% @doc Parse checkpoint information from a filename.
%% Extracts reason, generation, fitness, and timestamp from the filename.
%% Format: REASON-genN-fitF-TIMESTAMP.checkpoint
-spec parse_checkpoint_filename(file:filename()) -> {ok, map()} | {error, invalid_format}.
parse_checkpoint_filename(Filename) ->
Basename = filename:basename(Filename, ?CHECKPOINT_EXT),
case string:split(Basename, "-", all) of
[ReasonStr, GenStr, FitStr, TimestampStr | _] ->
try
Reason = list_to_existing_atom(ReasonStr),
Gen = parse_gen(GenStr),
Fitness = parse_fitness(FitStr),
Timestamp = list_to_integer(TimestampStr),
{ok, #{
reason => Reason,
generation => Gen,
fitness => Fitness,
timestamp => Timestamp
}}
catch
_:_ -> {error, invalid_format}
end;
_ ->
{error, invalid_format}
end.
%% @private Parse generation from "gen123" format.
parse_gen("gen" ++ Rest) ->
list_to_integer(Rest);
parse_gen(Str) ->
list_to_integer(Str).
%% @private Parse fitness from "fit12345" format (4 decimal places encoded).
parse_fitness("fit" ++ Rest) ->
parse_fitness_value(Rest);
parse_fitness(Str) ->
parse_fitness_value(Str).
parse_fitness_value(Str) ->
%% Handle negative numbers (encoded as 'n' prefix)
{Sign, NumStr} = case Str of
[$n | Rest] -> {-1, Rest};
_ -> {1, Str}
end,
%% Last 4 digits are decimal places
Len = length(NumStr),
case Len > 4 of
true ->
IntPart = list_to_integer(lists:sublist(NumStr, Len - 4)),
DecPart = list_to_integer(lists:sublist(NumStr, Len - 3, 4)),
Sign * (IntPart + DecPart / 10000);
false ->
%% All decimal
Sign * list_to_integer(NumStr) / 10000
end.