Current section

Files

Jump to
crucible_datasets lib dataset_manager result_store.ex
Raw

lib/dataset_manager/result_store.ex

defmodule CrucibleDatasets.ResultStore do
@moduledoc """
Persistent storage for evaluation results.
Stores results in `~/.elixir_ai_research/results/` with indexing for fast
retrieval and querying. Results are organized by date and include a searchable
index for filtering by model, dataset, accuracy, and other criteria.
## Storage Structure
~/.elixir_ai_research/results/
├── index.json # Searchable index of all results
├── 2025-11-25/
│ ├── gpt4_mmlu_stem_abc123.json
│ ├── claude_gsm8k_def456.json
│ └── ...
├── 2025-11-24/
│ └── ...
## Examples
# Save a result
{:ok, result_id} = CrucibleDatasets.ResultStore.save(evaluation_result)
# Load a result
{:ok, result} = CrucibleDatasets.ResultStore.load(result_id)
# Query results
{:ok, results} = CrucibleDatasets.ResultStore.query(
model: "gpt-4",
dataset: :mmlu_stem,
min_accuracy: 0.8
)
# List all result summaries
{:ok, summaries} = CrucibleDatasets.ResultStore.list_all()
# Delete a result
:ok = CrucibleDatasets.ResultStore.delete(result_id)
"""
alias CrucibleDatasets.EvaluationResult
@storage_env_var "CRUCIBLE_DATASETS_RESULTS_DIR"
@type result_id :: String.t()
@type query_filter ::
{:model, String.t()}
| {:dataset, atom() | String.t()}
| {:min_accuracy, float()}
| {:max_accuracy, float()}
| {:date_from, Date.t()}
| {:date_to, Date.t()}
@doc """
Save an evaluation result to persistent storage.
Generates a unique ID based on model name, dataset, and timestamp.
## Parameters
* `result` - EvaluationResult struct
* `opts` - Optional keyword list
* `:id` - Custom result ID (auto-generated if not provided)
## Returns
* `{:ok, result_id}` - Successfully saved
* `{:error, reason}` - Save failed
## Examples
{:ok, result_id} = CrucibleDatasets.ResultStore.save(result)
# => {:ok, "2025-11-25/gpt4_mmlu_stem_20251125_143022_abc123"}
"""
@spec save(EvaluationResult.t(), keyword()) :: {:ok, result_id()} | {:error, term()}
def save(%EvaluationResult{} = result, opts \\ []) do
result_id = Keyword.get(opts, :id) || generate_result_id(result)
with :ok <- ensure_storage_dir(),
{:ok, result_path} <- build_result_path(result_id),
:ok <- write_result(result_path, result),
:ok <- update_index(result_id, result) do
{:ok, result_id}
end
end
@doc """
Load an evaluation result by ID.
## Parameters
* `result_id` - Result identifier
## Returns
* `{:ok, result}` - Result loaded successfully
* `{:error, :not_found}` - Result doesn't exist
* `{:error, reason}` - Other error
## Examples
{:ok, result} = CrucibleDatasets.ResultStore.load("2025-11-25/gpt4_mmlu_stem_abc123")
"""
@spec load(result_id()) :: {:ok, EvaluationResult.t()} | {:error, term()}
def load(result_id) do
with {:ok, result_path} <- build_result_path(result_id),
true <- File.exists?(result_path),
{:ok, content} <- File.read(result_path),
{:ok, json} <- Jason.decode(content) do
result = deserialize_result(json)
{:ok, result}
else
false -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
@doc """
Query results with filters.
## Parameters
* `filters` - Keyword list of query filters (see module documentation)
## Examples
# Find all GPT-4 results
{:ok, results} = CrucibleDatasets.ResultStore.query(model: "gpt-4")
# Find high-accuracy results from last week
{:ok, results} = CrucibleDatasets.ResultStore.query(
min_accuracy: 0.9,
date_from: Date.add(Date.utc_today(), -7)
)
# Find results for specific dataset
{:ok, results} = CrucibleDatasets.ResultStore.query(dataset: :mmlu_stem)
"""
@spec query(keyword()) :: {:ok, [EvaluationResult.t()]} | {:error, term()}
def query(filters \\ []) do
with {:ok, index} <- load_index() do
filtered_entries =
index
|> Enum.filter(&matches_filters?(&1, filters))
results =
filtered_entries
|> Enum.map(fn entry -> load(entry["id"]) end)
|> Enum.filter(&match?({:ok, _}, &1))
|> Enum.map(fn {:ok, result} -> result end)
{:ok, results}
end
end
@doc """
List all stored results with summary information.
Returns lightweight summaries without loading full results.
## Examples
{:ok, summaries} = CrucibleDatasets.ResultStore.list_all()
# => {:ok, [
# %{
# id: "...",
# model: "gpt-4",
# dataset: "mmlu_stem",
# accuracy: 0.89,
# timestamp: ~U[2025-11-25 10:30:00Z]
# },
# ...
# ]}
"""
@spec list_all() :: {:ok, [map()]} | {:error, term()}
def list_all do
case load_index() do
{:ok, index} -> {:ok, index}
{:error, reason} -> {:error, reason}
end
end
@doc """
Delete a stored result.
Removes the result file and updates the index.
## Parameters
* `result_id` - Result identifier
## Examples
:ok = CrucibleDatasets.ResultStore.delete("2025-11-25/gpt4_mmlu_stem_abc123")
"""
@spec delete(result_id()) :: :ok | {:error, term()}
def delete(result_id) do
with {:ok, result_path} <- build_result_path(result_id),
:ok <- File.rm(result_path),
:ok <- remove_from_index(result_id) do
:ok
else
# Already deleted
{:error, :enoent} -> :ok
{:error, reason} -> {:error, reason}
end
end
@doc """
Clear all stored results.
**Warning:** This deletes all evaluation results permanently.
## Examples
:ok = CrucibleDatasets.ResultStore.clear_all()
"""
@spec clear_all() :: :ok
def clear_all do
storage_dir()
|> File.rm_rf()
:ok
end
## Private functions
defp ensure_storage_dir do
storage_dir()
|> File.mkdir_p()
end
defp generate_result_id(result) do
date = DateTime.to_date(result.timestamp)
date_str = Date.to_iso8601(date)
model_slug = slugify(result.model)
dataset_slug = slugify(result.dataset_name)
timestamp_str =
result.timestamp
|> DateTime.to_naive()
|> NaiveDateTime.to_iso8601()
|> String.replace(["-", ":", "."], "")
|> String.slice(0..13)
random_suffix =
:crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower) |> String.slice(0..5)
"#{date_str}/#{model_slug}_#{dataset_slug}_#{timestamp_str}_#{random_suffix}"
end
defp slugify(str) do
str
|> to_string()
|> String.downcase()
|> String.replace(~r/[^\w]+/, "_")
|> String.trim("_")
end
defp build_result_path(result_id) do
# Extract date directory from result_id if present
path =
if String.contains?(result_id, "/") do
Path.join(storage_dir(), result_id <> ".json")
else
# Legacy format without date prefix
Path.join(storage_dir(), result_id <> ".json")
end
# Ensure parent directory exists
path
|> Path.dirname()
|> File.mkdir_p()
{:ok, path}
end
defp write_result(path, result) do
json = EvaluationResult.to_json(result)
content = Jason.encode!(json, pretty: true)
File.write(path, content)
end
defp load_index do
if File.exists?(index_file()) do
with {:ok, content} <- File.read(index_file()),
{:ok, data} <- Jason.decode(content) do
{:ok, Map.get(data, "results", [])}
end
else
{:ok, []}
end
end
defp update_index(result_id, result) do
{:ok, index} = load_index()
# Create index entry
entry = %{
"id" => result_id,
"model" => result.model,
"dataset" => result.dataset_name,
"dataset_version" => result.dataset_version,
"accuracy" => result.accuracy,
"total_items" => result.total_items,
"timestamp" => DateTime.to_iso8601(result.timestamp),
"metrics" => result.metrics,
"duration_ms" => result.duration_ms
}
# Remove existing entry with same ID (if updating)
new_index =
index
|> Enum.reject(&(&1["id"] == result_id))
|> then(&[entry | &1])
# Write updated index
index_data = %{
"results" => new_index,
"updated_at" => DateTime.to_iso8601(DateTime.utc_now())
}
content = Jason.encode!(index_data, pretty: true)
File.mkdir_p(storage_dir())
File.write(index_file(), content)
end
defp remove_from_index(result_id) do
{:ok, index} = load_index()
new_index = Enum.reject(index, &(&1["id"] == result_id))
index_data = %{
"results" => new_index,
"updated_at" => DateTime.to_iso8601(DateTime.utc_now())
}
content = Jason.encode!(index_data, pretty: true)
File.write(index_file(), content)
end
defp matches_filters?(entry, filters) do
Enum.all?(filters, fn filter -> matches_filter?(entry, filter) end)
end
defp matches_filter?(entry, {:model, model}) do
entry["model"] == model
end
defp matches_filter?(entry, {:dataset, dataset}) do
entry["dataset"] == to_string(dataset)
end
defp matches_filter?(entry, {:min_accuracy, min_acc}) do
entry["accuracy"] >= min_acc
end
defp matches_filter?(entry, {:max_accuracy, max_acc}) do
entry["accuracy"] <= max_acc
end
defp matches_filter?(entry, {:date_from, date_from}) do
case DateTime.from_iso8601(entry["timestamp"]) do
{:ok, dt, _} -> Date.compare(DateTime.to_date(dt), date_from) != :lt
_ -> false
end
end
defp matches_filter?(entry, {:date_to, date_to}) do
case DateTime.from_iso8601(entry["timestamp"]) do
{:ok, dt, _} -> Date.compare(DateTime.to_date(dt), date_to) != :gt
_ -> false
end
end
defp matches_filter?(_entry, _unknown_filter), do: true
defp deserialize_result(json) do
timestamp =
case DateTime.from_iso8601(json["timestamp"]) do
{:ok, dt, _} -> dt
_ -> DateTime.utc_now()
end
%EvaluationResult{
dataset_name: json["dataset_name"],
dataset_version: json["dataset_version"],
model: json["model"],
total_items: json["total_items"],
correct_items: json["correct_items"],
accuracy: json["accuracy"],
metrics: atomize_keys(json["metrics"]),
item_results: json["item_results"],
timestamp: timestamp,
duration_ms: json["duration_ms"]
}
end
defp atomize_keys(map) when is_map(map) do
Map.new(map, fn {k, v} ->
key = if is_binary(k), do: String.to_atom(k), else: k
{key, v}
end)
end
defp storage_dir do
System.get_env(@storage_env_var)
|> case do
nil -> Path.expand("~/.elixir_ai_research/results")
dir -> Path.expand(dir)
end
end
defp index_file do
Path.join(storage_dir(), "index.json")
end
end