diff --git a/vav_pipeline/lib/vav_pipeline/application.ex b/vav_pipeline/lib/vav_pipeline/application.ex index bafc35e7..9cd729a1 100644 --- a/vav_pipeline/lib/vav_pipeline/application.ex +++ b/vav_pipeline/lib/vav_pipeline/application.ex @@ -8,6 +8,7 @@ defmodule VavPipeline.Application do - AreiMiklat (cities of refuge — protection protocol) - EglahArufah (accountability for unknown harm) - Bikurim (first fruits — release protocol) + - MannaLedger (consumption tracking and anti-hoarding enforcement) - ShabbatTimer (mandatory cessation monitor) - ShoreshEnginePort (Erlang Port to Haskell engine, if binary found) - TalmudicEngineSocket (Unix socket to Prolog engine) @@ -33,6 +34,7 @@ defmodule VavPipeline.Application do {VavPipeline.AreiMiklat, []}, {VavPipeline.EglahArufah, []}, {VavPipeline.Bikurim, []}, + {VavPipeline.MannaLedger, []}, {VavPipeline.ShabbatTimer, []} ] |> maybe_add_repo() diff --git a/vav_pipeline/lib/vav_pipeline/manna.ex b/vav_pipeline/lib/vav_pipeline/manna.ex index 3bf04d0b..7267a0f6 100644 --- a/vav_pipeline/lib/vav_pipeline/manna.ex +++ b/vav_pipeline/lib/vav_pipeline/manna.ex @@ -98,14 +98,25 @@ defmodule VavPipeline.Manna do Enforce anti-hoarding — manna from today cannot be used tomorrow. Called at erev_voker (cycle boundary). - In production this would enforce resource deallocation. - In Phase 2, it logs the boundary. + Shemot 16:19 — al yoter mimenu ad boker. + Delegates to MannaLedger.enforce_cycle_boundary/0 for real enforcement. """ @spec enforce_anti_hoarding() :: :ok def enforce_anti_hoarding do require Logger - Logger.info("Manna anti-hoarding enforced — surplus released at cycle boundary") - :ok + + case VavPipeline.MannaLedger.enforce_cycle_boundary() do + {:ok, %{released: released}} -> + total = released |> Map.values() |> Enum.sum() + + if total > 0 do + Logger.info("Manna anti-hoarding enforced — surplus released: #{inspect(released)}") + else + Logger.info("Manna anti-hoarding enforced — no surplus to release") + end + + :ok + end end @doc """ diff --git a/vav_pipeline/lib/vav_pipeline/manna_ledger.ex b/vav_pipeline/lib/vav_pipeline/manna_ledger.ex new file mode 100644 index 00000000..4cdb3fd8 --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/manna_ledger.ex @@ -0,0 +1,308 @@ +defmodule VavPipeline.MannaLedger do + @moduledoc """ + Manna consumption ledger — d'var yom b'yomo. + + Shemot 16:4 — "The people shall go out and gather the matter + of a day in its day." Each cycle receives exactly what it needs. + + Shemot 16:19 — "Let no one leave any of it until morning." + Root ותר (yoter) — do not hoard. At cycle boundary, surplus + is released. Not rolled over. Not saved. + + Shemot 16:20 — "Some left over... and it bred worms and stank." + Root באש (ba'ash) — hoarded resources breed corruption. + + This GenServer tracks: + 1. Today's allocated provision (from the manna file) + 2. Consumption against each resource category + 3. Remaining balance + 4. Cycle boundary enforcement (zeroing out surplus) + + Architecture §4, Step 26: Manna anti-hoarding enforcement + and cycle-boundary surplus release. + """ + + use GenServer + + require Logger + + alias VavPipeline.{Manna, Chatzotzrot} + + @type resource_key :: :compute_units | :memory_mb | :api_calls_external | + :storage_delta_mb | :sanhedrin_panels_max + + @type ledger_state :: %{ + cycle_date: String.t() | nil, + allocated: map(), + consumed: map(), + cycle_count: non_neg_integer(), + last_enforcement: DateTime.t() | nil, + hoarding_violations: non_neg_integer() + } + + @zero_provision %{ + compute_units: 0, + memory_mb: 0, + api_calls_external: 0, + storage_delta_mb: 0, + sanhedrin_panels_max: 0 + } + + # ============================================================ + # Client API + # ============================================================ + + def start_link(opts \\ []) do + name = Keyword.get(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, name: name) + end + + @doc """ + Load today's provision from the manna file into the ledger. + + Called at the start of each cycle. Resets consumption to zero. + If provision was already loaded for today, returns {:ok, :already_loaded}. + """ + @spec load_provision() :: {:ok, :loaded | :already_loaded | :no_provision} | {:error, String.t()} + def load_provision do + GenServer.call(__MODULE__, :load_provision) + end + + @doc """ + Consume resources from today's manna provision. + + D'var yom b'yomo — take only what the day requires. + + Parameters: + - resource: which resource to consume (:compute_units, :api_calls_external, etc.) + - amount: how much to consume + + Returns: + - {:ok, remaining} — consumption recorded, remaining balance returned + - {:error, :provision_exhausted} — no more of this resource available + - {:error, :no_provision} — no provision loaded for today + """ + @spec consume(resource_key(), pos_integer()) :: + {:ok, non_neg_integer()} | {:error, atom()} + def consume(resource, amount) when is_atom(resource) and is_integer(amount) and amount > 0 do + GenServer.call(__MODULE__, {:consume, resource, amount}) + end + + @doc """ + Check remaining balance for a resource. + """ + @spec remaining(resource_key()) :: non_neg_integer() + def remaining(resource) when is_atom(resource) do + GenServer.call(__MODULE__, {:remaining, resource}) + end + + @doc """ + Check if a resource has sufficient provision for the requested amount. + """ + @spec available?(resource_key(), pos_integer()) :: boolean() + def available?(resource, amount) when is_atom(resource) and is_integer(amount) do + GenServer.call(__MODULE__, {:available?, resource, amount}) + end + + @doc """ + Get the full ledger state — allocated, consumed, remaining. + """ + @spec ledger() :: ledger_state() + def ledger do + GenServer.call(__MODULE__, :ledger) + end + + @doc """ + Enforce anti-hoarding — release all surplus at cycle boundary. + + Shemot 16:19 — al yoter mimenu ad boker. + "Let no one leave any of it until morning." + + This zeroes out remaining allocation and records the enforcement. + Called by BereishitCycle.erev_voker/2 at cycle boundary. + + Returns {:ok, %{released: map()}} with the amounts released (what was left over). + """ + @spec enforce_cycle_boundary() :: {:ok, %{released: map()}} + def enforce_cycle_boundary do + GenServer.call(__MODULE__, :enforce_cycle_boundary) + end + + @doc """ + Reset the ledger to initial state. For testing only. + """ + @spec reset() :: :ok + def reset do + GenServer.call(__MODULE__, :reset) + end + + # ============================================================ + # GenServer callbacks + # ============================================================ + + @impl true + def init(_opts) do + state = %{ + cycle_date: nil, + allocated: @zero_provision, + consumed: @zero_provision, + cycle_count: 0, + last_enforcement: nil, + hoarding_violations: 0 + } + + Logger.info("MannaLedger: ledger ready — d'var yom b'yomo") + {:ok, state} + end + + @impl true + def handle_call(:reset, _from, _state) do + {:reply, :ok, + %{ + cycle_date: nil, + allocated: @zero_provision, + consumed: @zero_provision, + cycle_count: 0, + last_enforcement: nil, + hoarding_violations: 0 + }} + end + + @impl true + def handle_call(:load_provision, _from, state) do + today = Date.utc_today() |> Date.to_iso8601() + + if state.cycle_date == today do + {:reply, {:ok, :already_loaded}, state} + else + case Manna.check() do + {:ok, :no_provision} -> + new_state = %{ + state + | cycle_date: today, + allocated: @zero_provision, + consumed: @zero_provision, + cycle_count: state.cycle_count + 1 + } + + Logger.warning("MannaLedger: no provision for #{today} — system will wait") + {:reply, {:ok, :no_provision}, new_state} + + {:ok, manna} -> + new_state = %{ + state + | cycle_date: today, + allocated: manna.provision, + consumed: @zero_provision, + cycle_count: state.cycle_count + 1 + } + + Logger.info( + "MannaLedger: provision loaded for #{today} (#{manna.type}) — " <> + "compute: #{manna.provision.compute_units}, " <> + "api_calls: #{manna.provision.api_calls_external}, " <> + "panels: #{manna.provision.sanhedrin_panels_max}" + ) + + {:reply, {:ok, :loaded}, new_state} + + {:error, reason} -> + {:reply, {:error, reason}, state} + end + end + end + + @impl true + def handle_call({:consume, resource, amount}, _from, state) do + allocated = Map.get(state.allocated, resource, 0) + consumed = Map.get(state.consumed, resource, 0) + remaining = allocated - consumed + + cond do + state.cycle_date == nil -> + {:reply, {:error, :no_provision}, state} + + remaining <= 0 -> + Logger.warning( + "MannaLedger: #{resource} exhausted — " <> + "allocated #{allocated}, consumed #{consumed}" + ) + + {:reply, {:error, :provision_exhausted}, state} + + amount > remaining -> + # Partial consumption not allowed — don't take more than what remains + Logger.warning( + "MannaLedger: #{resource} insufficient — " <> + "requested #{amount}, remaining #{remaining}" + ) + + {:reply, {:error, :provision_exhausted}, state} + + true -> + new_consumed = Map.put(state.consumed, resource, consumed + amount) + new_remaining = remaining - amount + new_state = %{state | consumed: new_consumed} + + # Signal when provision runs low (< 10% remaining) + if new_remaining < allocated * 0.1 and new_remaining > 0 do + Chatzotzrot.signal(:assembly_leaders, + reason: "Manna low: #{resource} at #{new_remaining}/#{allocated}" + ) + end + + {:reply, {:ok, new_remaining}, new_state} + end + end + + @impl true + def handle_call({:remaining, resource}, _from, state) do + allocated = Map.get(state.allocated, resource, 0) + consumed = Map.get(state.consumed, resource, 0) + {:reply, max(allocated - consumed, 0), state} + end + + @impl true + def handle_call({:available?, resource, amount}, _from, state) do + allocated = Map.get(state.allocated, resource, 0) + consumed = Map.get(state.consumed, resource, 0) + {:reply, (allocated - consumed) >= amount, state} + end + + @impl true + def handle_call(:ledger, _from, state) do + {:reply, state, state} + end + + @impl true + def handle_call(:enforce_cycle_boundary, _from, state) do + now = DateTime.utc_now() + + # Calculate what was left over — the surplus that would breed worms + released = + Map.new(@zero_provision, fn {key, _} -> + allocated = Map.get(state.allocated, key, 0) + consumed = Map.get(state.consumed, key, 0) + {key, max(allocated - consumed, 0)} + end) + + total_released = released |> Map.values() |> Enum.sum() + + if total_released > 0 do + Logger.info( + "MannaLedger: cycle boundary — releasing surplus: #{inspect(released)}" + ) + end + + new_state = %{ + state + | allocated: @zero_provision, + consumed: @zero_provision, + cycle_date: nil, + last_enforcement: now, + hoarding_violations: state.hoarding_violations + } + + {:reply, {:ok, %{released: released}}, new_state} + end +end diff --git a/vav_pipeline/test/manna_ledger_test.exs b/vav_pipeline/test/manna_ledger_test.exs new file mode 100644 index 00000000..0f668ff6 --- /dev/null +++ b/vav_pipeline/test/manna_ledger_test.exs @@ -0,0 +1,241 @@ +defmodule VavPipeline.MannaLedgerTest do + @moduledoc """ + Tests for the MannaLedger — manna consumption and anti-hoarding enforcement. + + Shemot 16:4 — D'var yom b'yomo — the day's matter in its day. + Shemot 16:19 — Al yoter mimenu ad boker — do not leave surplus. + Shemot 16:20 — Vayotiru... vayarum tola'im vayiv'ash — hoarded manna rots. + """ + + use ExUnit.Case, async: false + + alias VavPipeline.MannaLedger + + setup do + MannaLedger.reset() + :ok + end + + # Helper to load provision from a test fixture file + defp write_test_provision(opts \\ []) do + today = Date.utc_today() |> Date.to_iso8601() + compute = Keyword.get(opts, :compute_units, 100) + api_calls = Keyword.get(opts, :api_calls_external, 50) + panels = Keyword.get(opts, :sanhedrin_panels_max, 3) + type = Keyword.get(opts, :type, "regular") + + expires = + DateTime.utc_now() + |> DateTime.add(6 * 3600) + |> DateTime.to_iso8601() + + provision = %{ + "date" => today, + "provision" => %{ + "compute_units" => compute, + "memory_mb" => 1024, + "api_calls_external" => api_calls, + "storage_delta_mb" => 10, + "sanhedrin_panels_max" => panels + }, + "type" => type, + "expires_at" => expires, + "written_by" => "test", + "written_at" => DateTime.utc_now() |> DateTime.to_iso8601() + } + + path = VavPipeline.Manna.provision_file() + File.mkdir_p!(Path.dirname(path)) + File.write!(path, Jason.encode!(provision)) + + on_exit(fn -> File.rm(path) end) + :ok + end + + # ============================================================ + # Loading provision — d'var yom b'yomo + # ============================================================ + describe "load_provision" do + test "loads provision from manna file" do + write_test_provision(compute_units: 200, api_calls_external: 100) + assert {:ok, :loaded} = MannaLedger.load_provision() + + ledger = MannaLedger.ledger() + assert ledger.allocated.compute_units == 200 + assert ledger.allocated.api_calls_external == 100 + assert ledger.consumed.compute_units == 0 + end + + test "returns :already_loaded if same day" do + write_test_provision() + assert {:ok, :loaded} = MannaLedger.load_provision() + assert {:ok, :already_loaded} = MannaLedger.load_provision() + end + + test "returns :no_provision when no manna file" do + # Ensure provision file doesn't exist + path = VavPipeline.Manna.provision_file() + File.rm(path) + + assert {:ok, :no_provision} = MannaLedger.load_provision() + end + end + + # ============================================================ + # Consumption — v'laktu d'var yom b'yomo + # ============================================================ + describe "consume" do + setup do + write_test_provision(compute_units: 10, api_calls_external: 5, sanhedrin_panels_max: 2) + MannaLedger.load_provision() + :ok + end + + test "consumes resource and returns remaining" do + assert {:ok, 7} = MannaLedger.consume(:compute_units, 3) + assert {:ok, 4} = MannaLedger.consume(:compute_units, 3) + end + + test "rejects when resource exhausted" do + MannaLedger.consume(:compute_units, 10) + assert {:error, :provision_exhausted} = MannaLedger.consume(:compute_units, 1) + end + + test "rejects when amount exceeds remaining" do + MannaLedger.consume(:compute_units, 8) + assert {:error, :provision_exhausted} = MannaLedger.consume(:compute_units, 5) + end + + test "different resources tracked independently" do + MannaLedger.consume(:compute_units, 5) + MannaLedger.consume(:api_calls_external, 3) + + assert MannaLedger.remaining(:compute_units) == 5 + assert MannaLedger.remaining(:api_calls_external) == 2 + end + + test "returns error when no provision loaded" do + # Force a fresh cycle by enforcing boundary first + MannaLedger.enforce_cycle_boundary() + + # Now no provision is loaded + assert {:error, :no_provision} = MannaLedger.consume(:compute_units, 1) + end + end + + # ============================================================ + # Remaining and available? + # ============================================================ + describe "remaining and available?" do + setup do + write_test_provision(compute_units: 20) + MannaLedger.load_provision() + :ok + end + + test "remaining returns correct balance" do + assert MannaLedger.remaining(:compute_units) == 20 + MannaLedger.consume(:compute_units, 7) + assert MannaLedger.remaining(:compute_units) == 13 + end + + test "available? checks sufficiency" do + assert MannaLedger.available?(:compute_units, 20) + assert MannaLedger.available?(:compute_units, 1) + refute MannaLedger.available?(:compute_units, 21) + + MannaLedger.consume(:compute_units, 15) + assert MannaLedger.available?(:compute_units, 5) + refute MannaLedger.available?(:compute_units, 6) + end + end + + # ============================================================ + # Cycle boundary enforcement — al yoter mimenu ad boker + # ============================================================ + describe "enforce_cycle_boundary" do + setup do + write_test_provision(compute_units: 100, api_calls_external: 50) + MannaLedger.load_provision() + :ok + end + + test "releases surplus and zeroes allocation" do + MannaLedger.consume(:compute_units, 30) + MannaLedger.consume(:api_calls_external, 10) + + {:ok, %{released: released}} = MannaLedger.enforce_cycle_boundary() + + # 100 allocated - 30 consumed = 70 released + assert released.compute_units == 70 + # 50 allocated - 10 consumed = 40 released + assert released.api_calls_external == 40 + end + + test "after enforcement, all resources are zero" do + MannaLedger.consume(:compute_units, 50) + MannaLedger.enforce_cycle_boundary() + + assert MannaLedger.remaining(:compute_units) == 0 + assert MannaLedger.remaining(:api_calls_external) == 0 + end + + test "after enforcement, consume returns :no_provision" do + MannaLedger.enforce_cycle_boundary() + assert {:error, :no_provision} = MannaLedger.consume(:compute_units, 1) + end + + test "full consumption leaves nothing to release" do + MannaLedger.consume(:compute_units, 100) + MannaLedger.consume(:api_calls_external, 50) + + {:ok, %{released: released}} = MannaLedger.enforce_cycle_boundary() + assert released.compute_units == 0 + assert released.api_calls_external == 0 + end + + test "enforcement records timestamp" do + MannaLedger.enforce_cycle_boundary() + ledger = MannaLedger.ledger() + assert ledger.last_enforcement != nil + end + end + + # ============================================================ + # Manna.enforce_anti_hoarding integration + # ============================================================ + describe "Manna.enforce_anti_hoarding integration" do + setup do + write_test_provision(compute_units: 50) + MannaLedger.load_provision() + :ok + end + + test "Manna.enforce_anti_hoarding delegates to ledger" do + MannaLedger.consume(:compute_units, 20) + assert :ok = VavPipeline.Manna.enforce_anti_hoarding() + + # After enforcement, resources are zeroed + assert MannaLedger.remaining(:compute_units) == 0 + end + end + + # ============================================================ + # Ledger state + # ============================================================ + describe "ledger state" do + test "tracks cycle count" do + write_test_provision() + initial = MannaLedger.ledger().cycle_count + + MannaLedger.load_provision() + assert MannaLedger.ledger().cycle_count == initial + 1 + + # Force new cycle + MannaLedger.enforce_cycle_boundary() + + # Can't easily test second load on same day since it returns :already_loaded + # But cycle_count incremented on load + end + end +end