# SPDX-License-Identifier: MPL-2.0 defmodule Hypatia.OutcomeTracker do @moduledoc """ Closes the feedback loop by recording fix outcomes and updating recipe confidence. Writes to: - verisim-data/outcomes/YYYY-MM.jsonl (append-only outcome log) - verisim-data/recipes/{id}.json (updated confidence scores) - gitbot-fleet/shared-context/learning/fix-outcomes.jsonl (fleet learning feed) """ require Logger alias Hypatia.{ConfidenceAnnealing, Paths} # Bayesian confidence updating via Beta distribution # Prior strength controls how much weight initial confidence carries # relative to observed evidence. Higher = more conservative updates. @prior_strength 10 @confidence_cap 0.99 @confidence_floor 0.10 # Bump when the admissible-evidence definition changes. Legacy states # without this marker are NOT trusted even if the counts happen to match. @annealing_evidence_version 1 @doc """ Record an outcome from a fix attempt. Parameters: - recipe_id: which recipe was applied - repo: repository name - file: file that was fixed - outcome: :success | :failure | :false_positive - metadata: optional map of extra fields to merge into the record (e.g. %{"verification" => "verified"} from `record_and_verify`). Pre-existing keys (recipe_id, repo, file, outcome, timestamp, bot) are not overwritten by metadata. """ def record_outcome(recipe_id, repo, file, outcome, metadata \\ %{}) do persist_outcome(recipe_id, repo, file, outcome, metadata, true) end @doc """ Import a postcondition-verified fleet attempt without echoing it into the source fleet log. The provenance id makes repeat imports detectable. """ def record_fleet_outcome(recipe_id, repo, file, outcome, source_id, verification) do persist_outcome( recipe_id, repo, file, outcome, %{ "fleet_import_id" => source_id, "origin" => "gitbot-fleet", "verification" => verification }, false ) end @doc """ IDs already imported from fleet, read from the append-only destination so a scheduler restart cannot turn one attempt into repeated training examples. """ def imported_fleet_ids do outcomes_dir = Path.expand(Paths.outcomes()) case File.ls(outcomes_dir) do {:ok, files} -> files |> Enum.filter(&String.ends_with?(&1, ".jsonl")) |> Enum.sort() |> Enum.reduce_while({:ok, MapSet.new()}, fn filename, {:ok, seen} -> path = Path.join(outcomes_dir, filename) case File.read(path) do {:ok, data} -> case imported_ids_in_log(data, path, seen) do {:ok, ids} -> {:cont, {:ok, ids}} error -> {:halt, error} end {:error, reason} -> {:halt, {:error, {:outcome_log_read, path, reason}}} end end) {:error, :enoent} -> {:ok, MapSet.new()} {:error, reason} -> {:error, {:outcomes_directory, reason}} end end defp imported_ids_in_log(data, path, seen) do data |> String.split("\n", trim: true) |> Enum.reduce_while({:ok, seen}, fn line, {:ok, ids} -> if String.contains?(line, ~s("fleet_import_id")) do case Jason.decode(line) do {:ok, %{"fleet_import_id" => id}} when is_binary(id) and id != "" -> {:cont, {:ok, MapSet.put(ids, id)}} _ -> {:halt, {:error, {:invalid_fleet_import_record, path}}} end else {:cont, {:ok, ids}} end end) end defp persist_outcome(recipe_id, repo, file, outcome, metadata, publish_to_fleet?) do now = DateTime.utc_now() |> DateTime.to_iso8601() outcome_str = Atom.to_string(outcome) base = %{ "pattern_id" => nil, "recipe_id" => recipe_id, "repo" => repo, "file" => file, "outcome" => outcome_str, "timestamp" => now, "bot" => "hypatia" } record = Map.merge(metadata, base) case write_outcome_log(record) do :ok -> if publish_to_fleet?, do: write_fleet_outcome(record) if qualified_outcome?(record), do: update_annealing_state(recipe_id, outcome) # The event is committed even when the derived recipe summary is # temporarily unavailable. The LearningScheduler retries that recipe # from the durable log on the next cycle. case update_recipe_confidence(recipe_id) do {:error, reason} -> Logger.warning("Outcome logged; recipe update pending: #{inspect(reason)}") :recipe_not_found -> Logger.warning("Outcome logged; recipe not yet registered: #{recipe_id}") _ -> :ok end Hypatia.Telemetry.outcome_recorded( recipe_id: recipe_id, repo: repo, outcome: outcome_str, verification: Map.get(metadata, "verification", "unverified") ) Logger.info("Outcome recorded: #{recipe_id} in #{repo}/#{file} -> #{outcome_str}") {:ok, record} {:error, reason} -> Logger.error("Outcome not persisted: #{inspect(reason)}") {:error, {:outcome_log_write, reason}} end end @doc false def qualified_outcome?(record) when is_map(record) do counts = qualified_outcome_counts([record]) counts.successes + counts.failures + counts.false_positives == 1 end @doc """ Verify a fix by re-scanning the target repo and checking if the weak point is gone. Runs panic-attacker assail on the specific repo, then checks if the original pattern still appears in the results. Returns :verified (gone), :still_present, or :scan_failed. """ def verify_fix(repo_path, pattern_id, category) do result = do_verify_fix(repo_path, pattern_id, category) Hypatia.Telemetry.verification_result( recipe_id: pattern_id, repo: Path.basename(repo_path), verdict: result ) result end defp do_verify_fix(repo_path, pattern_id, category) do case System.cmd("panic-attack", ["assail", repo_path, "--output-format", "json", "--quiet"], stderr_to_stdout: true ) do {output, 0} -> case Jason.decode(output) do {:ok, scan} -> weak_points = Map.get(scan, "weak_points", []) still_found = Enum.any?(weak_points, fn wp -> Map.get(wp, "category", "") == category end) if still_found do Logger.warning( "Verification FAILED for #{pattern_id} in #{repo_path} -- pattern still present" ) :still_present else Logger.info( "Verification PASSED for #{pattern_id} in #{repo_path} -- pattern removed" ) :verified end {:error, _} -> Logger.error("Failed to parse re-scan output for #{repo_path}") :scan_failed end {_output, _code} -> Logger.error("Re-scan failed for #{repo_path}") :scan_failed end end @doc """ Canonical entry point for the dispatch-runner / external automaton *after* a fix has been applied to a repo. This is the default-verify variant: unlike `record_outcome/5` (which is intentionally unverified, used by replay and rollback paths), this always attempts a post-fix re-scan via panic-attack when `outcome` is `:success`. It auto-derives the `category` and `pattern_id` from the recipe registry, so callers don't have to thread them through. Failure to derive `category` (recipe not found / no `target_categories`) falls back to `record_outcome/5` with `"verification" = "scan_skipped"` so the outcome is still recorded and the verification gap is auditable. Returns `{:ok, record, verification}` where verification is one of `:verified | :false_positive | :scan_unavailable | :not_verified`. """ def record_outcome_for_fix(recipe_id, repo, file, outcome, opts \\ []) do derived = case derive_verify_opts(recipe_id, repo, opts) do {:ok, derived_opts} -> Keyword.merge(derived_opts, opts) {:error, _reason} -> Keyword.put(opts, :verify, false) end record_and_verify(recipe_id, repo, file, outcome, Keyword.put(derived, :verify, true)) end defp derive_verify_opts(recipe_id, repo, opts) do cond do Keyword.has_key?(opts, :category) and Keyword.has_key?(opts, :repo_path) -> {:ok, opts} true -> recipe_path = find_recipe_file(recipe_id) case recipe_path && File.read(recipe_path) do {:ok, content} -> case Jason.decode(content) do {:ok, recipe} -> cats = Map.get(recipe, "target_categories", []) category = Keyword.get(opts, :category, List.first(cats) || "") repos_dir = System.get_env("HYPATIA_REPOS_DIR", File.cwd!()) repo_path = Keyword.get(opts, :repo_path, Path.join(repos_dir, repo)) {:ok, [ category: category, pattern_id: Keyword.get(opts, :pattern_id, recipe_id), repo_path: repo_path ]} _ -> {:error, :recipe_unparseable} end _ -> {:error, :recipe_not_found} end end end @doc """ Record an outcome and optionally verify the fix by re-scanning. Low-level: prefer `record_outcome_for_fix/5` from external runners (it auto-derives `category` from the recipe registry). This entry point is for in-tree call sites that already have those fields in hand. If `verify: true` is passed, runs `panic-attacker` against the repo after recording the outcome. The verification verdict is persisted on every branch (verified / still_present / scan_failed / unverified) so `recipe_health/1` can compute meaningful aggregates. """ def record_and_verify(recipe_id, repo, file, outcome, opts \\ []) do if Keyword.get(opts, :verify, false) and outcome == :success do repos_dir = System.get_env("HYPATIA_REPOS_DIR", File.cwd!()) repo_path = Keyword.get(opts, :repo_path, Path.join(repos_dir, repo)) category = Keyword.get(opts, :category, "") pattern_id = Keyword.get(opts, :pattern_id, "") case verify_fix(repo_path, pattern_id, category) do :verified -> # Record success WITH the verification stamp so recipe_health # can distinguish verified-clean fixes from un-verified ones. {:ok, record} = record_outcome(recipe_id, repo, file, outcome, %{"verification" => "verified"}) {:ok, record, :verified} :still_present -> Logger.warning( "Fix claimed success but pattern still present -- recording false_positive" ) # Both records are tagged so the trail is explicit: the claimed # success was actually a false positive, surfaced by re-scan. {:ok, _} = record_outcome(recipe_id, repo, file, outcome, %{"verification" => "still_present"}) {:ok, record} = record_outcome(recipe_id, repo, file, :false_positive, %{ "verification" => "still_present", "caused_by" => "post_fix_rescan" }) {:ok, record, :false_positive} :scan_failed -> # The fix may or may not have worked; we just couldn't verify. # Recording the outcome with the scan_failed marker preserves # the distinction from "verified clean" without penalising the # recipe in confidence updates. {:ok, record} = record_outcome(recipe_id, repo, file, outcome, %{"verification" => "scan_failed"}) {:ok, record, :scan_unavailable} end else # Unverified outcome (or non-success): record as before, with the # explicit "unverified" marker so verification_rate aggregates can # tell the difference between "verification wasn't attempted" and # "verification was attempted and failed". {:ok, record} = record_outcome(recipe_id, repo, file, outcome, %{ "verification" => if(outcome == :failure, do: "failed", else: "unverified") }) {:ok, record, :not_verified} end end @doc """ Recalculate confidence for a recipe based on all recorded outcomes. Updates the recipe JSON file in verisim-data/recipes/. """ def update_recipe_confidence(recipe_id) do outcomes = load_outcomes_for_recipe(recipe_id) if Enum.empty?(outcomes) do :no_outcomes else counts = qualified_outcome_counts(outcomes) successes = counts.successes failures = counts.failures false_positives = counts.false_positives recipe_path = find_recipe_file(recipe_id) cond do recipe_path == nil -> Logger.warning("No recipe file found for #{recipe_id}") :recipe_not_found true -> case File.read(recipe_path) do {:ok, content} -> case Jason.decode(content) do {:ok, recipe} -> # Keep a fixed prior: re-evaluation must not treat the previous # posterior as a fresh prior and amplify the same evidence. base_confidence = Map.get(recipe, "confidence_prior", Map.get(recipe, "confidence", 0.5)) qualified_total = successes + failures + false_positives raw_confidence = if qualified_total == 0 do base_confidence else bayesian_update(base_confidence, successes, failures + false_positives) end # Apply temperature-based annealing to the raw confidence. # This flattens confidence toward 0.5 when the recipe has # few outcomes, and lets it sharpen as evidence accumulates. annealing_state = load_annealing_state(recipe_id) new_confidence = if qualified_total == 0 do base_confidence else ConfidenceAnnealing.anneal(raw_confidence, annealing_state) end annealing_summary = ConfidenceAnnealing.summary(annealing_state) updated_recipe = recipe |> Map.put("confidence_prior", base_confidence) |> Map.put("confidence", Float.round(new_confidence, 4)) |> Map.put("confidence_raw", Float.round(raw_confidence, 4)) |> Map.put("successful_fixes", successes) |> Map.put("failed_fixes", failures) |> Map.put("false_positives", false_positives) |> Map.put("total_attempts", length(outcomes)) |> Map.put("unqualified_attempts", counts.ignored) |> Map.put("annealing", annealing_summary) case Jason.encode(updated_recipe, pretty: true) do {:ok, json} -> case File.write(recipe_path, json <> "\n") do :ok -> Logger.info( "Updated #{recipe_id} confidence: #{base_confidence} -> #{new_confidence}" ) {:ok, new_confidence} {:error, reason} -> Logger.warning( "Failed to persist recipe confidence: #{inspect(reason)}" ) {:error, reason} end {:error, reason} -> Logger.error("Failed to encode recipe: #{inspect(reason)}") {:error, reason} end {:error, reason} -> Logger.error("Failed to decode recipe: #{inspect(reason)}") {:error, reason} end {:error, reason} -> Logger.error("Failed to read recipe file: #{inspect(reason)}") {:error, reason} end end end end @doc false def qualified_outcome_counts(outcomes) when is_list(outcomes) do Enum.reduce(outcomes, %{successes: 0, failures: 0, false_positives: 0, ignored: 0}, fn row, acc -> case row do %{"outcome" => "success", "verification" => "verified"} -> %{acc | successes: acc.successes + 1} %{"outcome" => "failure", "verification" => v} when v in ["failed", "incomplete", "verified"] -> %{acc | failures: acc.failures + 1} %{ "outcome" => "false_positive", "verification" => "rollback_requested", "caused_by" => "batch_rollback", "batch_id" => batch_id } when is_binary(batch_id) and batch_id != "" -> %{acc | false_positives: acc.false_positives + 1} %{"outcome" => "false_positive", "verification" => v} when v in ["still_present", "verified"] -> %{acc | false_positives: acc.false_positives + 1} _ -> %{acc | ignored: acc.ignored + 1} end end) end @doc """ Bayesian confidence update using Beta distribution conjugate prior. The recipe's initial confidence is treated as the prior mean of a Beta(alpha, beta) distribution. Each success increments alpha, each failure increments beta. The posterior mean (alpha / (alpha + beta)) is the updated confidence. Properties: - With no observations, returns the prior (initial confidence) - Many successes push confidence toward 1.0, but asymptotically - A single failure among many successes has proportional (not outsized) impact - Small sample sizes preserve uncertainty (stay close to prior) - @prior_strength controls how conservative updates are """ def bayesian_update(prior_confidence, successes, failures) do # Convert prior confidence to Beta distribution parameters # prior_confidence = alpha / (alpha + beta), with alpha + beta = @prior_strength alpha_prior = prior_confidence * @prior_strength beta_prior = (1.0 - prior_confidence) * @prior_strength # Posterior = prior + observations (conjugate update) alpha_posterior = alpha_prior + successes beta_posterior = beta_prior + failures # Posterior mean posterior = alpha_posterior / (alpha_posterior + beta_posterior) posterior |> max(@confidence_floor) |> min(@confidence_cap) end @doc """ Check if confidence for a recipe is trending up or down. Returns :improving, :declining, or :stable. """ def confidence_trend(recipe_id) do outcomes = load_outcomes_for_recipe(recipe_id) if length(outcomes) < 3 do :insufficient_data else # Look at last 10 outcomes recent = Enum.take(outcomes, -10) success_rate = Enum.count(recent, fn o -> Map.get(o, "outcome") == "success" end) / length(recent) cond do success_rate >= 0.8 -> :improving success_rate <= 0.4 -> :declining true -> :stable end end end # ─── Closed-loop verification metric ─────────────────────────────────── @doc """ Per-recipe verification rate. Returns `{:ok, %{verified, still_present, scan_failed, unverified, total, rate}}` where `rate` is the fraction of *verifiable* successes that were actually verified clean by post-fix re-scan. `scan_failed` and `unverified` records are excluded from the denominator so a recipe is not penalised for being run in environments without panic-attack. A recipe's verification rate is meaningful only after a handful of attempts -- returns `{:ok, :insufficient_data}` below the threshold. """ def verification_rate(recipe_id, min_attempts \\ 5) do outcomes = load_outcomes_for_recipe(recipe_id) successes = Enum.filter(outcomes, fn o -> Map.get(o, "outcome") == "success" end) counts = Enum.reduce( successes, %{verified: 0, still_present: 0, scan_failed: 0, unverified: 0}, fn o, acc -> case Map.get(o, "verification") do "verified" -> Map.update!(acc, :verified, &(&1 + 1)) "still_present" -> Map.update!(acc, :still_present, &(&1 + 1)) "scan_failed" -> Map.update!(acc, :scan_failed, &(&1 + 1)) _ -> Map.update!(acc, :unverified, &(&1 + 1)) end end ) verifiable = counts.verified + counts.still_present cond do length(successes) == 0 -> {:ok, :no_outcomes} verifiable < min_attempts -> {:ok, Map.merge(counts, %{ total: length(successes), rate: :insufficient_data, verifiable: verifiable })} true -> rate = counts.verified / verifiable {:ok, Map.merge(counts, %{ total: length(successes), rate: rate, verifiable: verifiable })} end end @doc """ Aggregate health stats across every recipe with recorded outcomes. Returns a list of maps sorted ascending by verification rate, so recipes that look most broken surface first. Recipes with insufficient verification data still appear -- they're flagged distinctly so they can be prioritised for verification-enabled runs. Schema: %{ recipe_id: String.t(), dispatches: non_neg_integer(), successes: non_neg_integer(), failures: non_neg_integer(), false_positives: non_neg_integer(), success_rate: float() | :no_data, verification: %{ verified: non_neg_integer(), still_present: non_neg_integer(), scan_failed: non_neg_integer(), unverified: non_neg_integer(), verifiable: non_neg_integer(), rate: float() | :insufficient_data | :no_data }, status: :healthy | :unverified | :insufficient_data | :degraded | :quarantine_candidate } """ def recipe_health(opts \\ []) do min_attempts = Keyword.get(opts, :min_attempts, 5) degraded_threshold = Keyword.get(opts, :degraded_threshold, 0.70) quarantine_threshold = Keyword.get(opts, :quarantine_threshold, 0.30) recipe_ids = all_recipe_ids_with_outcomes() recipe_ids |> Enum.map(fn recipe_id -> outcomes = load_outcomes_for_recipe(recipe_id) successes = Enum.count(outcomes, fn o -> Map.get(o, "outcome") == "success" end) failures = Enum.count(outcomes, fn o -> Map.get(o, "outcome") == "failure" end) false_positives = Enum.count(outcomes, fn o -> Map.get(o, "outcome") == "false_positive" end) dispatches = length(outcomes) attempts = successes + failures + false_positives success_rate = if attempts > 0, do: successes / attempts, else: :no_data {:ok, verification} = verification_rate(recipe_id, min_attempts) verification_map = case verification do :no_outcomes -> %{ verified: 0, still_present: 0, scan_failed: 0, unverified: 0, verifiable: 0, rate: :no_data } map when is_map(map) -> map end status = cond do verification_map.rate == :no_data -> :no_data verification_map.rate == :insufficient_data -> :insufficient_data is_float(verification_map.rate) and verification_map.rate < quarantine_threshold -> :quarantine_candidate is_float(verification_map.rate) and verification_map.rate < degraded_threshold -> :degraded is_float(verification_map.rate) -> :healthy true -> :unverified end %{ recipe_id: recipe_id, dispatches: dispatches, successes: successes, failures: failures, false_positives: false_positives, success_rate: success_rate, verification: verification_map, status: status } end) |> Enum.sort_by(fn r -> # Sort by rate ascending so quarantine_candidate / degraded float to # the top. :no_data and :insufficient_data sort after numerics so # they don't bury actionable rows. case r.verification.rate do :no_data -> {2, 0} :insufficient_data -> {1, 0} rate when is_float(rate) -> {0, rate} end end) end @doc """ Cheap predicate: is this recipe currently auto-quarantined on the basis of its verification rate? A recipe is quarantined when its verification rate (verified / (verified + still_present)) drops below `:threshold` AND the verifiable-outcomes denominator has crossed `:min_attempts`. Recipes with no verification data are NOT quarantined — the gate errs on the side of letting them dispatch so the runner can produce verification data in the first place. An operator override is available via `HYPATIA_RECIPE_QUARANTINE_DISABLE=true` for emergencies; the override is logged when consulted so audit history captures why an "unhealthy" recipe was still dispatched. Options: :threshold -- rate below which to quarantine (default 0.30) :min_attempts -- minimum verifiable count before the gate engages (default 5) """ def quarantined?(recipe_id, opts \\ []) do cond do System.get_env("HYPATIA_RECIPE_QUARANTINE_DISABLE") == "true" -> Logger.warning( "Recipe quarantine gate DISABLED via env override -- recipe " <> "#{recipe_id} dispatched without verification-rate check." ) false true -> threshold = Keyword.get(opts, :threshold, 0.30) min_attempts = Keyword.get(opts, :min_attempts, 5) case verification_rate(recipe_id, min_attempts) do {:ok, %{rate: rate}} when is_float(rate) -> quarantined = rate < threshold if quarantined do Hypatia.Telemetry.quarantine_triggered( kind: :recipe, id: recipe_id, reason: "verification_rate_below_threshold", level: :auto, rate: rate, threshold: threshold ) Logger.warning( "Recipe #{recipe_id} AUTO-QUARANTINED: " <> "verification rate #{:erlang.float_to_binary(rate, decimals: 2)} " <> "< threshold #{threshold}. Will be downgraded from " <> ":auto_execute to :review until human reviews recipe." ) end quarantined _ -> # :no_outcomes / :insufficient_data — let the dispatch through. # The whole point of letting it through is to accumulate # verification data; gating here would create a chicken-and- # egg problem where new recipes can never earn enough data # to leave quarantine. false end end end # --- Private --- defp all_recipe_ids_with_outcomes do outcomes_dir = Path.expand(Paths.outcomes()) case File.ls(outcomes_dir) do {:ok, files} -> files |> Enum.filter(&String.ends_with?(&1, ".jsonl")) |> Enum.flat_map(fn f -> path = Path.join(outcomes_dir, f) path |> File.stream!() |> Stream.map(fn line -> case Jason.decode(String.trim(line)) do {:ok, %{"recipe_id" => id}} when is_binary(id) -> id _ -> nil end end) |> Stream.reject(&is_nil/1) |> Enum.to_list() end) |> Enum.uniq() {:error, _} -> [] end end defp write_outcome_log(record) do now = Date.utc_today() month_str = String.pad_leading("#{now.month}", 2, "0") path = Path.join(Path.expand(Paths.outcomes()), "#{now.year}-#{month_str}.jsonl") with :ok <- File.mkdir_p(Path.dirname(path)), {:ok, json} <- Jason.encode(record) do assert_h6_monotone(path, Map.get(record, "timestamp")) File.write(path, json <> "\n", [:append, :utf8]) end end # H6 runtime check. ISO-8601 timestamps sort lexicographically, so # string comparison gives the right ordering without DateTime parsing # on the hot path. defp assert_h6_monotone(path, new_ts) when is_binary(new_ts) do case tail_timestamp(path) do {:ok, last_ts} when new_ts > last_ts -> :ok {:ok, last_ts} -> Logger.error( "H6 violation: outcome log non-monotone at #{path} " <> "(last=#{last_ts}, new=#{new_ts})" ) :none -> :ok end end defp assert_h6_monotone(_path, _), do: :ok defp tail_timestamp(path) do with true <- File.exists?(path), [line] <- path |> File.stream!() |> Enum.take(-1), {:ok, %{"timestamp" => ts}} when is_binary(ts) <- line |> String.trim_trailing() |> Jason.decode() do {:ok, ts} else _ -> :none end end defp write_fleet_outcome(record) do path = Path.join([ Path.expand(Paths.fleet()), "shared-context/learning/fix-outcomes.jsonl" ]) fleet_record = %{ "pattern" => Map.get(record, "recipe_id", "unknown"), "file" => Map.get(record, "file", "unknown"), "repo" => Map.get(record, "repo", "unknown"), "outcome" => Map.get(record, "outcome"), "fixed_at" => Map.get(record, "timestamp"), "bot" => "hypatia" } case Jason.encode(fleet_record) do {:ok, json} -> # Same silent-drop class as write_outcome_log/1: File.write with :append # fails :enoent on a missing dir, dropping the fleet learning signal. File.mkdir_p!(Path.dirname(path)) case File.write(path, json <> "\n", [:append, :utf8]) do :ok -> :ok {:error, reason} -> Logger.error("Failed to write fleet outcome: #{inspect(reason)}") end {:error, reason} -> Logger.error("Failed to encode fleet outcome: #{inspect(reason)}") end end defp load_outcomes_for_recipe(recipe_id) do outcomes_dir = Path.expand(Paths.outcomes()) case File.ls(outcomes_dir) do {:ok, files} -> files |> Enum.filter(&String.ends_with?(&1, ".jsonl")) |> Enum.flat_map(fn f -> path = Path.join(outcomes_dir, f) # Stream lines and pre-filter by string containment before # JSON decoding. This avoids parsing thousands of irrelevant # JSONL records and prevents timeout cascades on large datasets. path |> File.stream!() |> Stream.filter(&String.contains?(&1, recipe_id)) |> Stream.map(fn line -> case Jason.decode(String.trim(line)) do {:ok, record} -> record {:error, _} -> nil end end) |> Stream.reject(&is_nil/1) |> Stream.filter(fn r -> Map.get(r, "recipe_id") == recipe_id or Map.get(r, "pattern") == recipe_id end) |> Enum.to_list() end) {:error, _} -> [] end end defp find_recipe_file(recipe_id) do recipes_dir = Path.expand(Paths.recipes()) case File.ls(recipes_dir) do {:ok, files} -> case Enum.find(files, fn f -> String.starts_with?(f, "recipe-") and String.ends_with?(f, ".json") and match_recipe_id?(Path.join(recipes_dir, f), recipe_id) end) do nil -> nil file -> Path.join(recipes_dir, file) end {:error, _} -> nil end end defp match_recipe_id?(path, recipe_id) do case File.read(path) do {:ok, content} -> case Jason.decode(content) do {:ok, data} -> Map.get(data, "id") == recipe_id {:error, _} -> false end {:error, _} -> false end end # --- Annealing State Management --- @doc """ Get the annealing state for a recipe, computing the max dispatch tier. Returns {:ok, strategy} where strategy is the annealing-clamped dispatch tier for the given raw strategy. """ def annealed_strategy(recipe_id, raw_strategy) do state = load_annealing_state(recipe_id) ConfidenceAnnealing.clamp_strategy(raw_strategy, state) end @doc """ Get annealing-adjusted confidence for a recipe in a cross-repo context. When repo A's outcome data is used to inform dispatch in repo B, this applies a higher effective temperature (more conservative). """ def cross_repo_confidence(recipe_id, raw_confidence) do state = load_annealing_state(recipe_id) ConfidenceAnnealing.anneal_cross_repo(raw_confidence, state) end @doc "Get the full annealing state for a recipe (for diagnostics)." def get_annealing_state(recipe_id) do load_annealing_state(recipe_id) end defp update_annealing_state(recipe_id, outcome) do # At this point the outcome was already appended to the canonical log. # One additional qualified observation can be incorporated incrementally; # any legacy mismatch is rebuilt from qualified evidence instead. counts = qualified_outcome_counts(load_outcomes_for_recipe(recipe_id)) qualified_total = qualified_count(counts) state = case read_saved_annealing_state(recipe_id) do %{outcome_count: previous} = prior when previous + 1 == qualified_total -> ConfidenceAnnealing.record_outcome(prior, outcome) %{outcome_count: current} = prior when current == qualified_total -> prior _ -> ConfidenceAnnealing.from_existing( counts.successes, counts.failures, counts.false_positives ) end save_annealing_state(recipe_id, state) state end defp qualified_count(counts), do: counts.successes + counts.failures + counts.false_positives @doc false def reconcile_annealing_state(stored, counts) do expected = qualified_count(counts) case stored do %{outcome_count: ^expected} -> stored _ -> ConfidenceAnnealing.from_existing( counts.successes, counts.failures, counts.false_positives ) end end defp load_annealing_state(recipe_id) do # Read-time check is essential: already-persisted veteran stages may have # been computed from thousands of legacy, unverified "success" rows. counts = qualified_outcome_counts(load_outcomes_for_recipe(recipe_id)) saved = read_saved_annealing_state(recipe_id) current = reconcile_annealing_state(saved, counts) if current != saved and (saved != nil or qualified_count(counts) > 0), do: save_annealing_state(recipe_id, current) current end defp read_saved_annealing_state(recipe_id) do case File.read(annealing_state_path(recipe_id)) do {:ok, content} -> case Jason.decode(content) do {:ok, %{"qualification_version" => @annealing_evidence_version} = data} -> deserialize_annealing_state(data) _ -> nil end {:error, _} -> nil end end defp save_annealing_state(recipe_id, state) do path = annealing_state_path(recipe_id) dir = Path.dirname(path) File.mkdir_p!(dir) serialized = %{ "qualification_version" => @annealing_evidence_version, "temperature" => state.temperature, "outcome_count" => state.outcome_count, "stage" => Atom.to_string(state.stage), "recent_outcomes" => Enum.map(state.recent_outcomes, &Atom.to_string/1), "last_reheat" => if(state.last_reheat, do: DateTime.to_iso8601(state.last_reheat)), "created_at" => DateTime.to_iso8601(state.created_at), "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601() } case Jason.encode(serialized, pretty: true) do {:ok, json} -> File.write!(path, json <> "\n") {:error, reason} -> Logger.error("Failed to save annealing state: #{inspect(reason)}") end end defp deserialize_annealing_state(data) do %{ temperature: Map.get(data, "temperature", 1.0), outcome_count: Map.get(data, "outcome_count", 0), stage: String.to_existing_atom(Map.get(data, "stage", "nascent")), recent_outcomes: data |> Map.get("recent_outcomes", []) |> Enum.map(&String.to_existing_atom/1), last_reheat: case Map.get(data, "last_reheat") do nil -> nil ts -> case DateTime.from_iso8601(ts) do {:ok, dt, _} -> dt _ -> nil end end, created_at: case DateTime.from_iso8601(Map.get(data, "created_at", "")) do {:ok, dt, _} -> dt _ -> DateTime.utc_now() end } end defp annealing_state_path(recipe_id) do # Sanitise recipe_id for filesystem safety safe_id = String.replace(recipe_id, ~r/[^a-zA-Z0-9_\-]/, "_") directory = Application.get_env( :hypatia, :annealing_state_path, Path.join(Paths.verisimdb_data(), "annealing-states") ) Path.join(Path.expand(directory), "#{safe_id}.json") end end