Current section

Files

Jump to
dspex examples flagship_multi_pool_rlm.exs
Raw

examples/flagship_multi_pool_rlm.exs

# Flagship Multi-Pool + RLM Demo - Using Generated Native Bindings
#
# Run with: mix run --no-start examples/flagship_multi_pool_rlm.exs
#
# Requires: GEMINI_API_KEY environment variable
# Requires: Deno for PythonInterpreter (install via asdf or https://deno.land/install)
alias Dspy.Predict.RLM
alias SnakeBridge.ConfigHelper
alias SnakeBridge.Runtime
alias Snakepit.Bridge.SessionStore
defmodule DSPex.FlagshipMultiPoolRlm do
@moduledoc false
require SnakeBridge
@model "gemini/gemini-flash-lite-latest"
@triage_signature "ticket -> category, urgency, action"
@rlm_signature "context, query -> output"
@rlm_max_iterations 4
@rlm_max_llm_calls 12
@rlm_max_output_chars 4_000
@rlm_verbose false
@rlm_tools []
@rlm_sub_lm nil
@rlm_interpreter nil
@pools [
%{name: :triage_pool, pool_size: 2, affinity: :strict_queue},
%{name: :rlm_pool, pool_size: 2, affinity: :strict_queue},
%{name: :analytics_pool, pool_size: 2, affinity: :hint}
]
def run do
configure_snakepit!()
SnakeBridge.script restart: true do
banner()
print_pools()
tickets = tickets()
holdout = holdout_ticket()
IO.puts("\n==> Step 1: Build DSPy modules across pools")
triage_sessions = build_triage_sessions()
rlm_session = build_rlm_session()
analytics_session = build_analytics_session()
print_session_workers("Triage sessions", triage_sessions)
print_session_workers("RLM session", [rlm_session])
print_session_workers("Analytics session", [analytics_session])
IO.puts("\n==> Step 2: Run triage predictions in parallel")
triage_results = run_triage_predictions(triage_sessions, tickets)
IO.puts("\n==> Step 3: RLM analysis over long context")
rlm_result = run_rlm_analysis(rlm_session, triage_results)
IO.puts("\n==> Step 4: Evaluate with numpy in the analytics pool")
analytics = run_numpy_eval(analytics_session, triage_results)
IO.puts("\n==> Step 5: Baseline vs holdout triage")
baseline = triage_ticket(List.first(triage_sessions), holdout)
IO.puts(" Holdout -> #{baseline.category}/#{baseline.urgency}")
IO.puts("\n==> LM History via Graceful Serialization (triage pool)")
Enum.each(triage_sessions, fn session ->
print_prompt_history("Triage #{session.label}", session)
end)
IO.puts("\n==> LM History via Graceful Serialization (RLM pool)")
print_prompt_history("RLM", rlm_session)
summary(triage_results, rlm_result, analytics)
end
end
defp configure_snakepit! do
ConfigHelper.snakepit_config(pools: @pools)
|> Enum.each(fn {key, value} ->
Application.put_env(:snakepit, key, value)
end)
end
defp banner do
IO.puts("DSPex Flagship Demo: Multi-Pool RLM + Analytics")
IO.puts(String.duplicate("=", 64))
IO.puts("This demo uses strict affinity for DSPy sessions,")
IO.puts("and a hint pool for stateless numpy analytics.")
end
defp print_pools do
IO.puts("\nPools:")
Enum.each(@pools, fn pool ->
IO.puts(" #{pool.name} (size=#{pool.pool_size}, affinity=#{pool.affinity})")
end)
end
defp tickets do
[
%{
id: "INC-101",
text: "Checkout fails with a 500 error for EU users after last deploy.",
category: "outage",
urgency: "high"
},
%{
id: "INC-102",
text: "Refund still not received after 10 business days.",
category: "billing",
urgency: "medium"
},
%{
id: "INC-103",
text: "Requesting a bulk export feature for analytics dashboards.",
category: "feature",
urgency: "low"
},
%{
id: "INC-104",
text: "Admin login locked after SSO update; multiple users blocked.",
category: "account",
urgency: "high"
},
%{
id: "INC-105",
text: "CSV export shows incorrect timezone offsets in reports.",
category: "bug",
urgency: "medium"
}
]
end
defp holdout_ticket do
%{
id: "HOLDOUT-02",
text: "Customer cannot update billing address in the portal.",
category: "billing",
urgency: "medium"
}
end
defp build_triage_sessions do
Enum.map(1..2, fn idx ->
setup_predictor(:triage_pool, "triage_#{idx}", @triage_signature, 0.2)
end)
end
defp build_rlm_session do
session_id = unique_session("rlm")
ensure_session(session_id)
{:ok, lm} = Dspy.LM.new(@model, [], with_runtime([temperature: 0.3], :rlm_pool, session_id))
{:ok, _} = Dspy.configure(with_runtime([lm: lm], :rlm_pool, session_id))
{:ok, rlm} =
RLM.new(
@rlm_signature,
@rlm_max_iterations,
@rlm_max_llm_calls,
@rlm_max_output_chars,
@rlm_verbose,
@rlm_tools,
@rlm_sub_lm,
@rlm_interpreter,
with_runtime([], :rlm_pool, session_id)
)
%{
label: "rlm",
pool: :rlm_pool,
session_id: session_id,
rlm: rlm,
lm: lm
}
end
defp build_analytics_session do
session_id = unique_session("analytics")
ensure_session(session_id)
%{
label: "analytics",
pool: :analytics_pool,
session_id: session_id
}
end
defp setup_predictor(pool, label, signature, temperature) do
session_id = unique_session(label)
ensure_session(session_id)
{:ok, lm} =
Dspy.LM.new(@model, [], with_runtime([temperature: temperature], pool, session_id))
{:ok, _} = Dspy.configure(with_runtime([lm: lm], pool, session_id))
{:ok, predictor} = Dspy.PredictClass.new(signature, [], with_runtime([], pool, session_id))
%{
label: label,
pool: pool,
session_id: session_id,
predictor: predictor,
lm: lm
}
end
defp run_triage_predictions(sessions, tickets) do
assignments =
tickets
|> Enum.with_index()
|> Enum.map(fn {ticket, idx} ->
session = Enum.at(sessions, rem(idx, length(sessions)))
{session, ticket}
end)
assignments
|> Task.async_stream(
fn {session, ticket} ->
triage_ticket(session, ticket)
end,
max_concurrency: length(sessions),
timeout: 300_000
)
|> Enum.map(fn {:ok, result} -> result end)
|> tap(fn results ->
Enum.each(results, fn item ->
IO.puts(" #{item.id} -> #{item.category}/#{item.urgency} (#{item.session})")
end)
end)
end
defp triage_ticket(session, ticket) do
opts = with_runtime([ticket: ticket.text], session.pool, session.session_id)
{:ok, result} = Dspy.PredictClass.forward(session.predictor, opts)
runtime_opts = runtime(session.pool, session.session_id)
%{
id: ticket.id,
text: ticket.text,
gold_category: ticket.category,
gold_urgency: ticket.urgency,
category: to_string(SnakeBridge.attr!(result, "category", __runtime__: runtime_opts)),
urgency: to_string(SnakeBridge.attr!(result, "urgency", __runtime__: runtime_opts)),
action: to_string(SnakeBridge.attr!(result, "action", __runtime__: runtime_opts)),
session: session.label
}
end
defp run_rlm_analysis(session, triage_results) do
if deno_available?() do
context = build_rlm_context(triage_results)
query = "Identify the top two recurring issues and recommend next actions."
case call_rlm_with_retry(session, context, query) do
{:ok, answer} ->
IO.puts(" RLM answer: #{answer}")
%{query: query, answer: answer}
{:error, reason} ->
raise "RLM error: #{inspect(reason)}"
end
else
print_deno_missing()
%{query: nil, answer: nil}
end
end
defp call_rlm_with_retry(session, context, query) do
case call_rlm(session, context, query) do
{:ok, answer} ->
{:ok, answer}
{:error, :session_worker_unavailable} ->
new_session = rehydrate_rlm_session(session)
call_rlm(new_session, context, query)
{:error, reason} ->
{:error, reason}
end
end
defp call_rlm(session, context, query) do
opts = with_runtime([context: context, query: query], session.pool, session.session_id)
case RLM.forward(session.rlm, opts) do
{:ok, result} ->
runtime_opts = runtime(session.pool, session.session_id)
{:ok, answer} = SnakeBridge.attr(result, "output", __runtime__: runtime_opts)
{:ok, to_string(answer)}
{:error, reason} ->
{:error, reason}
end
end
defp rehydrate_rlm_session(session) do
IO.puts(" WARNING: RLM session worker unavailable; rehydrating session state.")
new_session = build_rlm_session()
print_session_workers("Rehydrated RLM session", [new_session])
Map.put(new_session, :label, session.label)
end
defp build_rlm_context(triage_results) do
incidents =
Enum.map_join(triage_results, "\n\n", fn item ->
"""
Ticket #{item.id}:
- Report: #{item.text}
- Category: #{item.category}
- Urgency: #{item.urgency}
- Action: #{item.action}
"""
end)
timeline = """
Timeline notes:
09:05 - EU checkout errors spike.
09:30 - Support queue flooded with billing delays.
10:00 - SSO rollout triggers admin lockouts.
10:20 - Data exports show timezone offset drift.
"""
base =
"""
Incident digest:
#{incidents}
#{timeline}
"""
String.duplicate(base, 2)
end
defp run_numpy_eval(session, triage_results) do
scores =
Enum.map(triage_results, fn item ->
score_prediction(item)
end)
runtime_opts = runtime(session.pool, session.session_id)
{:ok, numpy_version} =
Runtime.get_module_attr("numpy", "__version__", __runtime__: runtime_opts)
{:ok, mean} = SnakeBridge.call("numpy", "mean", [scores], __runtime__: runtime_opts)
{:ok, std} = SnakeBridge.call("numpy", "std", [scores], __runtime__: runtime_opts)
{:ok, p80} = SnakeBridge.call("numpy", "percentile", [scores, 80], __runtime__: runtime_opts)
IO.puts(" numpy version: #{numpy_version}")
IO.puts(" mean score: #{Float.round(mean, 3)}")
IO.puts(" std dev: #{Float.round(std, 3)}")
IO.puts(" 80th percentile: #{Float.round(p80, 3)}")
%{mean: mean, std: std, p80: p80}
end
defp print_prompt_history(label, session) do
runtime_opts = runtime(session.pool, session.session_id)
IO.puts(" #{label}:")
case fetch_prompt_history(session, runtime_opts, 6) do
{:ok, []} ->
IO.puts(" (no history)")
{:ok, history} ->
Enum.with_index(history, 1)
|> Enum.each(fn {entry, idx} ->
print_history_entry(idx, entry)
end)
{:error, reason} ->
IO.puts(" (history fetch failed: #{reason})")
end
end
defp fetch_prompt_history(session, runtime_opts, limit) do
lm = Map.fetch!(session, :lm)
code = "list(lm.history[-#{limit}:])"
{:ok, history} =
SnakeBridge.call("builtins", "eval", [code, %{"lm" => lm}], __runtime__: runtime_opts)
if is_list(history) do
{:ok, history}
else
{:ok, []}
end
rescue
e -> {:error, Exception.message(e)}
end
defp print_history_entry(idx, entry) when is_map(entry) do
model = entry["model"] || "unknown"
cost = format_cost(entry["cost"])
usage = format_usage(entry["usage"])
IO.puts(" [#{idx}] model=#{model}, cost=#{cost}, #{usage}")
if prompt = entry["prompt"] do
prompt_str =
case prompt do
text when is_binary(text) -> text
_ -> inspect(prompt, limit: 2, printable_limit: 200)
end
preview = prompt_str |> String.slice(0..60) |> String.replace(~r/\s+/, " ")
IO.puts(" prompt: #{preview}...")
end
if response = entry["response"] do
if SnakeBridge.ref?(response) do
IO.puts(" response: <#{response.type_name}> (ref)")
else
IO.puts(" response: (serialized)")
end
end
end
defp print_history_entry(idx, _entry) do
IO.puts(" [#{idx}] (invalid entry)")
end
defp format_cost(nil), do: "$0.00"
defp format_cost(cost) when is_number(cost),
do: "$#{:erlang.float_to_binary(cost * 1.0, decimals: 4)}"
defp format_cost(_), do: "$?.??"
defp format_usage(nil), do: "tokens=?"
defp format_usage(%{"total_tokens" => total}), do: "tokens=#{total}"
defp format_usage(%{"prompt_tokens" => p, "completion_tokens" => c}), do: "tokens=#{p}+#{c}"
defp format_usage(_), do: "tokens=?"
defp print_session_workers(title, sessions) do
IO.puts("\n#{title} worker routing:")
Enum.each(sessions, fn session ->
case SessionStore.get_session(session.session_id) do
{:ok, %{last_worker_id: worker_id}} when is_binary(worker_id) ->
IO.puts(" #{session.label} -> #{worker_id}")
_ ->
IO.puts(" #{session.label} -> (not assigned)")
end
end)
end
defp summary(triage_results, rlm_result, analytics) do
IO.puts("\n==> Summary")
IO.puts(" Triage results: #{length(triage_results)}")
if rlm_result.answer do
IO.puts(" RLM summary: #{String.slice(rlm_result.answer, 0..80)}...")
else
IO.puts(" RLM summary: (skipped)")
end
IO.puts(" Mean score: #{Float.round(analytics.mean, 3)}")
IO.puts(" Std dev: #{Float.round(analytics.std, 3)}")
end
defp score_prediction(item) do
category_match = normalize(item.category) == normalize(item.gold_category)
urgency_match = normalize(item.urgency) == normalize(item.gold_urgency)
if(category_match, do: 0.5, else: 0.0) + if urgency_match, do: 0.5, else: 0.0
end
defp normalize(value) do
value
|> to_string()
|> String.trim()
|> String.downcase()
end
defp unique_session(label) do
"#{label}_#{System.unique_integer([:positive])}"
end
defp ensure_session(session_id) do
case SessionStore.create_session(session_id) do
{:ok, _session} -> :ok
{:error, :already_exists} -> :ok
{:error, _reason} -> :ok
end
end
defp runtime(pool, session_id, extra \\ []) do
Keyword.merge([pool_name: pool, session_id: session_id], extra)
end
defp with_runtime(opts, pool, session_id, extra_runtime \\ []) do
Keyword.put(opts, :__runtime__, runtime(pool, session_id, extra_runtime))
end
defp deno_available? do
System.find_executable("deno") != nil
end
defp print_deno_missing do
IO.puts(ansi(:yellow, " Deno not found; skipping RLM step."))
Enum.each(deno_install_instructions(), fn line ->
IO.puts(line)
end)
end
defp deno_install_instructions do
[
" Deno is an external runtime binary used by DSPy RLM's default interpreter.",
" Install (asdf):",
" asdf plugin add deno https://github.com/asdf-community/asdf-deno.git",
" asdf install",
" Or install directly:",
" curl -fsSL https://deno.land/install.sh | sh",
" export PATH=\"$HOME/.deno/bin:$PATH\"",
" Then rerun: mix run --no-start examples/flagship_multi_pool_rlm.exs"
]
end
defp ansi(color, text) do
if IO.ANSI.enabled?() do
IO.ANSI.format([color, :bright, text, :reset]) |> IO.iodata_to_binary()
else
text
end
end
end
DSPex.FlagshipMultiPoolRlm.run()