defmodule HiiveRisk.Contract do @moduledoc """ Validates review snapshots and model responses at the OTP boundary. `priv/contract.json` is generated from `dataset.ReviewItem.model_json_schema()` and `dataset.Verdict.model_json_schema()` using the repository's Python venv. Schemas are embedded at compilation; Python and repository files outside this OTP app are not needed at runtime. Snapshot validation checks the schema and cross-field structural invariants. It does not recompute summaries or verify their mathematical correctness. Callers own the existing Python `compute_summaries` computation. Inputs are JSON-compatible maps (string or atom keys), not encoded JSON text. """ @contract_path Path.expand("../../priv/contract.json", __DIR__) @external_resource @contract_path @contract @contract_path |> File.read!() |> Jason.decode!() # These Pydantic schemas use draft-7-compatible validation keywords. Explicitly # select it so numeric exclusiveMinimum is not interpreted as draft 4's boolean. @draft "http://json-schema.org/draft-07/schema#" @snapshot_schema ExJsonSchema.Schema.resolve( Map.put(@contract["ReviewItem"], "$schema", @draft) ) @verdict_schema ExJsonSchema.Schema.resolve(Map.put(@contract["Verdict"], "$schema", @draft)) @spec snapshot(term()) :: :ok | {:error, :invalid_snapshot} def snapshot(value) do with {:ok, item} <- normalize(value), true <- ExJsonSchema.Validator.valid?(@snapshot_schema, item), true <- consistent_snapshot?(item) do :ok else _ -> {:error, :invalid_snapshot} end end @spec decision(term()) :: {:ok, map()} | {:error, :invalid_decision_response} def decision(value) when is_map(value) do # Ignore unrelated fields even if they cannot be encoded as JSON. scores = %{ "p_review" => Map.get(value, "p_review", Map.get(value, :p_review)), "answer_mass" => Map.get(value, "answer_mass", Map.get(value, :answer_mass)) } if Enum.all?(Map.values(scores), &unit_score?/1) do {:ok, scores} else {:error, :invalid_decision_response} end end def decision(_), do: {:error, :invalid_decision_response} @spec verdict(term()) :: {:ok, map()} | {:error, :invalid_sft_response} def verdict(value) do with {:ok, verdict} <- normalize(value), true <- ExJsonSchema.Validator.valid?(@verdict_schema, verdict), reasons = verdict["reasons"], true <- length(Enum.uniq(reasons)) == length(reasons), true <- "NO_CONCERN" not in reasons or length(reasons) == 1 do {:ok, verdict} else _ -> {:error, :invalid_sft_response} end end defp normalize(value) when is_map(value) do with {:ok, json} <- Jason.encode(value) do Jason.decode(json) end end defp normalize(_), do: :error # BEAM floats are finite; JSON encoding also rejects non-JSON numeric values. defp unit_score?(value), do: is_number(value) and value >= 0 and value <= 1 defp consistent_snapshot?(item) do target = item["review_target"] account = item["account"] company = item["company"] details = Map.get(target, "details", %{}) times = Enum.map(item["recent_events"], & &1["minutes_ago"]) [low, high] = company["typical_employee_holding"] shares = details["shares"] price = details["price_per_share"] target["minutes_ago"] == 0 and times == Enum.sort(times, :desc) and Enum.all?(times, &(&1 > 0 and &1 <= account["age_days"] * 1440)) and account["account_type"] == "entity" == (account["entity_info"] != nil) and low > 0 and low <= high and details["company_id"] == company["company_id"] and is_integer(shares) and shares > 0 and is_number(price) and price > 0 end end